From fb6ce91b5d02d52052db8f0797d53f0092e82de6 Mon Sep 17 00:00:00 2001 From: Takeshi Date: Thu, 25 Sep 2025 12:32:08 -0600 Subject: [PATCH 1/2] feat(package): Add scoped package transformation and validation for npm input --- internal/devpkg/package.go | 45 +++++++++++++++++++++++++++++++++ internal/devpkg/package_test.go | 27 ++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/internal/devpkg/package.go b/internal/devpkg/package.go index 45cdc0fef4e..5bde51aa59a 100644 --- a/internal/devpkg/package.go +++ b/internal/devpkg/package.go @@ -133,6 +133,26 @@ func PackageFromStringWithOptions(raw string, locker lock.Locker, opts devopt.Ad } func newPackage(raw string, isInstallable func() bool, locker lock.Locker) *Package { + // Trim and validate input + raw = strings.TrimSpace(raw) + if raw == "" { + // Handle empty input gracefully, perhaps treat as invalid but proceed + raw = "" + } + + // Parse name, version, and detect npm + name, version, isNpm := parsePackageInput(raw) + + // Transform for npm packages (only with explicit npm: prefix) + if isNpm { + transformed := fmt.Sprintf(`nodePackages."%s"`, name) + if version != "" { + raw = transformed + "@" + version + } else { + raw = transformed + } + } + pkg := &Package{ Raw: raw, lockfile: locker, @@ -164,6 +184,31 @@ func newPackage(raw string, isInstallable func() bool, locker lock.Locker) *Pack return pkg } +// parsePackageInput parses the raw package string into name, version, and detects if it's npm-related. +// It only supports explicit npm: prefix for npm packages. +func parsePackageInput(raw string) (name, version string, isNpm bool) { + if strings.HasPrefix(raw, "npm:") { + afterNpm := strings.TrimPrefix(raw, "npm:") + if lastAt := strings.LastIndex(afterNpm, "@"); lastAt > 0 { + name = afterNpm[:lastAt] + version = afterNpm[lastAt+1:] + } else { + name = afterNpm + } + isNpm = true + } else { + if lastAt := strings.LastIndex(raw, "@"); lastAt > 0 { + name = raw[:lastAt] + version = raw[lastAt+1:] + } else { + name = raw + } + // No implicit scoped detection; only explicit npm: prefix + isNpm = false + } + return +} + // resolve is the implementation of Package.resolve, where it is wrapped in a // sync.OnceValue function. It should not be called directly. func resolve(pkg *Package) error { diff --git a/internal/devpkg/package_test.go b/internal/devpkg/package_test.go index b35912a0f12..86d30d503cd 100644 --- a/internal/devpkg/package_test.go +++ b/internal/devpkg/package_test.go @@ -211,3 +211,30 @@ func TestCanonicalName(t *testing.T) { }) } } + +func TestScopedPackageTransformation(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"@angular/cli", "@angular/cli"}, // No longer transformed without npm: prefix + {"@angular/cli@1.0", "@angular/cli@1.0"}, + {"@github/copilot", "@github/copilot"}, + {"npm:@angular/cli", `nodePackages."@angular/cli"`}, + {"npm:@angular/cli@1.0", `nodePackages."@angular/cli"@1.0`}, + {"npm:lodash", `nodePackages."lodash"`}, + {"npm:lodash@4.17.21", `nodePackages."lodash"@4.17.21`}, + {"regular", "regular"}, + {"@notscoped", "@notscoped"}, // no slash, so not transformed + {"go@1.20", "go@1.20"}, // not npm + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + pkg := PackageFromStringWithDefaults(tt.input, &lockfile{}) + if pkg.Raw != tt.expected { + t.Errorf("Expected Raw %q, but got %q", tt.expected, pkg.Raw) + } + }) + } +} From 8a4f0cf562fcab554bb1999b9242b01c24a49fda Mon Sep 17 00:00:00 2001 From: Takeshi Date: Thu, 25 Sep 2025 18:49:32 -0600 Subject: [PATCH 2/2] fix(outputs): Improve error handling for npm packages not found in Nixpkgs --- internal/devpkg/outputs.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/devpkg/outputs.go b/internal/devpkg/outputs.go index e3f4062e0b6..ed9db7dac39 100644 --- a/internal/devpkg/outputs.go +++ b/internal/devpkg/outputs.go @@ -1,5 +1,11 @@ package devpkg +import ( + "strings" + + "go.jetify.com/devbox/internal/boxcli/usererr" +) + type Output struct { Name string CacheURI string @@ -32,6 +38,18 @@ func (out *outputs) GetNames(pkg *Package) ([]string, error) { func (out *outputs) initDefaultNames(pkg *Package) error { sysInfo, err := pkg.sysInfoIfExists() if err != nil { + // Provide better error for npm packages not found in Nixpkgs + errMsg := strings.TrimSpace(err.Error()) + if strings.Contains(errMsg, "package not found") && strings.HasPrefix(pkg.Raw, "nodePackages.") { + npmPkg := strings.TrimPrefix(pkg.Raw, `nodePackages."`) + npmPkg = strings.TrimSuffix(npmPkg, `"`) + return usererr.New( + "npm package %s was not found in Nixpkgs.\n"+ + "This may mean the package isn't packaged yet. Check https://search.nixos.org/packages for availability.\n"+ + "As a workaround, you can install it manually in your devbox shell using npm after running: npm config set prefix '~/.npm-global'", + npmPkg, + ) + } return err }