Skip to content

Commit

Permalink
feat(projects): Correctly cache node/npm version when using volta.
Browse files Browse the repository at this point in the history
  • Loading branch information
jwalton committed Feb 1, 2022
1 parent 0aea641 commit 2a676c4
Show file tree
Hide file tree
Showing 3 changed files with 176 additions and 22 deletions.
32 changes: 10 additions & 22 deletions internal/kitsch/projects/defaultProjectTypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,12 @@ var DefaultProjectTypes = []ProjectType{
IfFiles: []string{"yarn.lock"},
},
ToolSymbol: "node",
ToolVersion: getters.CustomGetter{
Type: getters.TypeCustom,
From: "node --version",
Regex: `v(.*)`,
Cache: getters.CacheSettings{Enabled: true},
},
PackageManagerSymbol: "yarn",
PackageManagerVersion: getters.CustomGetter{
Type: getters.TypeCustom,
From: "yarn --version",
Cache: getters.CacheSettings{Enabled: true},
ToolVersion: nodejsGetter{
executable: "node",
regex: `v(.*)`,
},
PackageManagerSymbol: "yarn",
PackageManagerVersion: nodejsGetter{executable: "yarn"},
PackageVersion: getters.CustomGetter{
Type: getters.TypeFile,
From: "package.json",
Expand All @@ -86,18 +80,12 @@ var DefaultProjectTypes = []ProjectType{
IfFiles: []string{"package.json"},
},
ToolSymbol: "node",
ToolVersion: getters.CustomGetter{
Type: getters.TypeCustom,
From: "node --version",
Regex: `v(.*)`,
Cache: getters.CacheSettings{Enabled: true},
},
PackageManagerSymbol: "npm",
PackageManagerVersion: getters.CustomGetter{
Type: getters.TypeCustom,
From: "npm --version",
Cache: getters.CacheSettings{Enabled: true},
ToolVersion: nodejsGetter{
executable: "node",
regex: `v(.*)`,
},
PackageManagerSymbol: "npm",
PackageManagerVersion: nodejsGetter{executable: "npm"},
PackageVersion: getters.CustomGetter{
Type: getters.TypeFile,
From: "package.json",
Expand Down
120 changes: 120 additions & 0 deletions internal/kitsch/projects/nodejsGetter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package projects

import (
"encoding/json"
"fmt"
"io/fs"
"os/exec"
"path/filepath"
"strings"

"github.com/jwalton/kitsch/internal/fileutils"
"github.com/jwalton/kitsch/internal/kitsch/getters"
)

// nodejsGetter is a volta-aware getter for the current node/npm version.
//
// When we run `node --version`, we usually let the CustomGetter try to find the
// node executable and cache the the path, size, and timestamp of the node executable.
// If the user is using volta to manage their node/npm version, though, this doesn't
// work, because the "node" symlink goes to ~/.volta/bin/volta-shim, which is
// the same executable no matter which version of node we're using. One solution
// here would be to disable caching for node.js, but `npm --version` is crazy slow
// to run - about half a second - so we really don't want to disable the cache here.
//
// Instead we have this custom "nodejsGetter" which tries to detect if we're using
// volta, and if so we run `volta which node` or `volta which npm` to work out what
// version we're running.
type nodejsGetter struct {
// executable should be "node", "npm", or "yarn".
executable string
regex string
}

func (getter nodejsGetter) GetValue(getterContext getters.GetterContext) (interface{}, error) {
// Resolve the executable to an absolute path.
executable, err := fileutils.LookPathSafe(getter.executable)
if err != nil {
return nil, fmt.Errorf("could not find executable: \"%s\": %w", getter.executable, err)
}

// If the executable is a symlink, resolve it.
executable, err = filepath.EvalSymlinks(executable)
if err != nil {
return nil, fmt.Errorf("could not resolve executable: \"%s\": %w", getter.executable, err)
}

// If the executable is "volta-shim", ask volta for the command we're actually going to run.
if strings.HasSuffix(executable, "volta-shim") {
return getter.getFromVolta(getterContext)
}

return getter.getFromExecutable(getterContext, executable)
}

func (getter nodejsGetter) getFromVolta(getterContext getters.GetterContext) (interface{}, error) {
result, err := getter.getFromVoltaPackageJSON(getterContext)
if err == nil {
return result, nil
}

// If the version isn't in package.json, need to run volta.
volta, err := fileutils.LookPathSafe("volta")
if err != nil {
return nil, fmt.Errorf("could not find volta: %w", err)
}

cmd := exec.Command(volta, "which", getter.executable)
cmd.Dir = getterContext.GetWorkingDirectory().Path()
executable, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("could not resolve \"%s\" target version: %w", getter.executable, err)
}

return getter.getFromExecutable(getterContext, strings.TrimSpace(string(executable)))
}

func (getter nodejsGetter) getFromVoltaPackageJSON(getterContext getters.GetterContext) (string, error) {
// First, try to parse the version from the volta section of package.json.
rawPackageJSON, err := fs.ReadFile(getterContext.GetWorkingDirectory().FileSystem(), "package.json")
if err != nil {
return "", fmt.Errorf("could not read package.json: %w", err)
}

packageJSON := map[string]interface{}{}
err = json.Unmarshal(rawPackageJSON, &packageJSON)
if err != nil {
return "", fmt.Errorf("could not parse package.json: %w", err)
}

voltaSection, ok := packageJSON["volta"]
if !ok {
return "", fmt.Errorf("package.json does not have a volta section")
}
voltaSectionMap, ok := voltaSection.(map[string]interface{})
if !ok {
return "", fmt.Errorf("volta section in package.json is not expected type")
}
version, ok := voltaSectionMap[getter.executable]
if !ok {
return "", fmt.Errorf("%s missing in volta section", getter.executable)
}
result, ok := version.(string)
if !ok {
return "", fmt.Errorf("%s is in volta section but is not a string", getter.executable)
}

return result, nil
}

func (getter nodejsGetter) getFromExecutable(getterContext getters.GetterContext, resolvedExecutable string) (interface{}, error) {
// Delegate this to a custom getter to handle caching.
customGetter := getters.CustomGetter{
Type: getters.TypeCustom,
From: resolvedExecutable + " --version",
Regex: getter.regex,
Cache: getters.CacheSettings{Enabled: true},
}

return customGetter.GetValue(getterContext)
}
46 changes: 46 additions & 0 deletions internal/kitsch/projects/nodejsGetter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package projects

import (
"io/fs"
"testing"
"testing/fstest"

"github.com/jwalton/kitsch/internal/cache"
"github.com/jwalton/kitsch/internal/fileutils"
"github.com/stretchr/testify/assert"
)

type testContext struct {
fsys fs.FS
cache cache.Cache
}

func (t testContext) GetWorkingDirectory() fileutils.Directory {
return fileutils.NewDirectoryTestFS("/home/jwalton/projects/test", t.fsys)
}

func (testContext) Getenv(key string) string {
return ""
}

// GetValueCache returns the value cache.
func (t testContext) GetValueCache() cache.Cache {
if t.cache == nil {
t.cache = cache.NewMemoryCache()
}
return t.cache
}

func TestGetNodeVersionFromVoltaPacakgeJSON(t *testing.T) {
fsys := fstest.MapFS{
"package.json": &fstest.MapFile{
Data: []byte(`{"volta": {"node": "16.0.0"}}`),
},
}
ctx := testContext{fsys: fsys}

getter := nodejsGetter{executable: "node"}
version, err := getter.GetValue(ctx)
assert.NoError(t, err)
assert.Equal(t, "16.0.0", version)
}

0 comments on commit 2a676c4

Please sign in to comment.