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
18 changes: 18 additions & 0 deletions internal/devpkg/outputs.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package devpkg

import (
"strings"

"go.jetify.com/devbox/internal/boxcli/usererr"
)

type Output struct {
Name string
CacheURI string
Expand Down Expand Up @@ -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
}

Expand Down
45 changes: 45 additions & 0 deletions internal/devpkg/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions internal/devpkg/package_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]", "@angular/[email protected]"},
{"@github/copilot", "@github/copilot"},
{"npm:@angular/cli", `nodePackages."@angular/cli"`},
{"npm:@angular/[email protected]", `nodePackages."@angular/cli"@1.0`},
{"npm:lodash", `nodePackages."lodash"`},
{"npm:[email protected]", `nodePackages."lodash"@4.17.21`},
{"regular", "regular"},
{"@notscoped", "@notscoped"}, // no slash, so not transformed
{"[email protected]", "[email protected]"}, // 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)
}
})
}
}