Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cli/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ var (
Usage: `Config or version file paths to install tools from. Can be specified multiple times. If not provided, detects files in the working directory. Supported file names and formats:
- .tool-versions (asdf/mise style): multiple tools, one "<tool> <version>" per line
- .<tool>-version (e.g. .node-version, .ruby-version): single tool, version string only
- .nvmrc (NVM): Node.js version
- .fvmrc (FVM 3.x): Flutter version from JSON {"flutter": "<version>"}
- .fvm/fvm_config.json (legacy FVM): Flutter version from {"flutterSdkVersion": "<version>"}
- bitrise.yml: tools defined in the "tools" section`,
Expand Down Expand Up @@ -173,10 +174,11 @@ var toolsSetupSubcommand = cli.Command{
Name: toolsSetupSubcommandName,
Usage: "Install tools from version files or bitrise.yml",
UsageText: "bitrise tools setup [--provider PROVIDER] [--fast-install true|false] [--config FILE] [--format FORMAT] [--workflow WORKFLOW]",
Description: `Install tools from version files (e.g. .tool-versions, .node-version, .fvmrc, .fvm/fvm_config.json) or bitrise.yml.
Description: `Install tools from version files (e.g. .tool-versions, .node-version, .nvmrc, .fvmrc, .fvm/fvm_config.json) or bitrise.yml.

EXAMPLES:
bitrise tools setup --config .tool-versions
bitrise tools setup --config .nvmrc
bitrise tools setup --config .fvmrc
bitrise tools setup --config .fvm/fvm_config.json
bitrise tools setup --config bitrise.yml
Expand Down
41 changes: 41 additions & 0 deletions integrationtests/toolprovider/setup_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,47 @@ workflows:
assert.NoError(t, err, "Should be able to eval bash output without error: %s", string(out))
},
},
// .nvmrc tests
{
name: "setup from .nvmrc with version",
fileContent: "20.0.0",
fileName: ".nvmrc",
outputFormat: "plaintext",
validateOutput: func(t *testing.T, output string) {
assert.Contains(t, output, "node")
assert.Contains(t, output, "20.0.0")
},
},
{
name: "setup from .nvmrc with v-prefixed version",
fileContent: "v20.0.0",
fileName: ".nvmrc",
outputFormat: "plaintext",
validateOutput: func(t *testing.T, output string) {
assert.Contains(t, output, "node")
assert.Contains(t, output, "20.0.0")
},
},
{
name: "setup from .nvmrc with comments",
fileContent: `# Node.js version
v18.16.0
# Another comment`,
fileName: ".nvmrc",
outputFormat: "plaintext",
validateOutput: func(t *testing.T, output string) {
assert.Contains(t, output, "node")
assert.Contains(t, output, "18.16.0")
},
},
{
name: "setup from .nvmrc with empty file fails",
fileContent: "",
fileName: ".nvmrc",
outputFormat: "plaintext",
wantErr: true,
errContains: "empty version file",
},
// .fvmrc tests
{
name: "setup from .fvmrc with exact version",
Expand Down
34 changes: 34 additions & 0 deletions toolprovider/versionfile/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package versionfile

import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"os"
Expand All @@ -25,6 +26,8 @@ func Parse(path string) ([]ToolVersion, error) {
return parseToolVersionsFile(path)
case ".fvmrc":
return parseFVMRC(path)
case ".nvmrc":
return parseNVMRC(path)
case "fvm_config.json":
return parseFVMConfigJSON(path)
default:
Expand All @@ -45,6 +48,7 @@ func FindVersionFiles(dir string) ([]string, error) {
".tool-versions",
".ruby-version",
".node-version",
".nvmrc",
".python-version",
".java-version",
".go-version",
Expand Down Expand Up @@ -157,6 +161,36 @@ func extractStringValue(config map[string]any, path string, key string) (string,
return str, nil
}

// parseNVMRC parses an NVM .nvmrc file to extract the Node.js version.
func parseNVMRC(path string) ([]ToolVersion, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}

content = bytes.TrimSpace(content)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: why not use strings.TrimSpace()?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is faster than strings (minor difference). I know, a few milliseconds won't really count, but it was no effort to implement like this.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it! It's a nitpick, I don't mind the current impl either!


if len(content) == 0 {
return nil, fmt.Errorf("%s: empty version file", path)
}

for line := range strings.SplitSeq(string(content), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.Contains(line, "=") {
continue
}
version := strings.TrimPrefix(line, "v")
if version == "" {
return nil, fmt.Errorf("%s: invalid version (empty after removing 'v' prefix)", path)
}
return []ToolVersion{
{ToolName: "node", Version: version},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Bug

The ToolName is hardcoded as "node", but the alias map in toolprovider/alias/alias.go shows that "node" is an alias for "nodejs". Other parsers like parseSingleToolVersion use inferToolID which calls GetCanonicalToolID to convert aliases to canonical tool IDs. This creates an inconsistency: .node-version files return "nodejs" (canonical), while .nvmrc files return "node" (alias). While the alias conversion happens later in run.go, the parser should be consistent with other parsers and return the canonical tool ID "nodejs".

🔄 Suggestion:

Suggested change
{ToolName: "node", Version: version},
{ToolName: "nodejs", Version: version},

}, nil
}

return nil, fmt.Errorf("%s: no valid version found", path)
}

// parseFVMRC parses an FVM 3.x .fvmrc JSON file to extract the Flutter version(s).
// Supports the main "flutter" key and optional "flavors" map.
func parseFVMRC(path string) ([]ToolVersion, error) {
Expand Down
104 changes: 104 additions & 0 deletions toolprovider/versionfile/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,12 @@ func TestParseVersionFile(t *testing.T) {
content: `{"flutter": "3.22.0"}`,
want: []ToolVersion{{ToolName: "flutter", Version: "3.22.0"}},
},
{
name: ".nvmrc format",
filePath: ".nvmrc",
content: "v18.0.0",
want: []ToolVersion{{ToolName: "node", Version: "18.0.0"}},
},
{
name: "fvm_config.json format",
filePath: "fvm_config.json",
Expand Down Expand Up @@ -347,6 +353,104 @@ func TestParseFVMRC(t *testing.T) {
}
}

func TestParseNVMRC(t *testing.T) {
tests := []struct {
name string
content string
want []ToolVersion
wantErr bool
}{
{
name: "version without v prefix",
content: "18.0.0",
want: []ToolVersion{{ToolName: "node", Version: "18.0.0"}},
},
{
name: "version with v prefix",
content: "v18.0.0",
want: []ToolVersion{{ToolName: "node", Version: "18.0.0"}},
},
{
name: "version with newline",
content: "18.0.0\n",
want: []ToolVersion{{ToolName: "node", Version: "18.0.0"}},
},
{
name: "version with v prefix and newline",
content: "v20.10.0\n",
want: []ToolVersion{{ToolName: "node", Version: "20.10.0"}},
},
{
name: "version with whitespace",
content: " v18.0.0 \n",
want: []ToolVersion{{ToolName: "node", Version: "18.0.0"}},
},
{
name: "with comment lines",
content: "# Node version\nv18.0.0\n",
want: []ToolVersion{{ToolName: "node", Version: "18.0.0"}},
},
{
name: "skips environment variable assignments",
content: "NODE_VERSION=18.0.0\nv20.0.0",
want: []ToolVersion{{ToolName: "node", Version: "20.0.0"}},
},
{
name: "first non-comment line wins",
content: "# Comment\n18.0.0\n20.0.0",
want: []ToolVersion{{ToolName: "node", Version: "18.0.0"}},
},
{
name: "empty file",
content: "",
wantErr: true,
},
{
name: "only whitespace",
content: " \n \n",
wantErr: true,
},
{
name: "only comments",
content: "# Comment only\n# Another comment",
wantErr: true,
},
{
name: "major version only",
content: "18",
want: []ToolVersion{{ToolName: "node", Version: "18"}},
},
{
name: "lts alias",
content: "lts/*",
want: []ToolVersion{{ToolName: "node", Version: "lts/*"}},
},
{
name: "only v prefix without version",
content: "v",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, ".nvmrc")
err := os.WriteFile(path, []byte(tt.content), 0644)
require.NoError(t, err)

got, err := parseNVMRC(path)
if tt.wantErr {
assert.Error(t, err)
return
}

require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

func TestParseFVMConfigJSON(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading