diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go index f5d144a140c..1774bb38754 100644 --- a/internal/devbox/devbox.go +++ b/internal/devbox/devbox.go @@ -800,6 +800,12 @@ func (d *Devbox) computeEnv( } devboxEnvPath = envpath.JoinPathLists(devboxEnvPath, runXPaths) + jspmPaths, err := d.JSPMPaths(ctx) + if err != nil { + return nil, err + } + devboxEnvPath = envpath.JoinPathLists(devboxEnvPath, jspmPaths) + pathStack := envpath.Stack(env, originalEnv) pathStack.Push(env, d.ProjectDirHash(), devboxEnvPath, envOpts.PreservePathStack) env["PATH"] = pathStack.Path(env) diff --git a/internal/devbox/jspm.go b/internal/devbox/jspm.go new file mode 100644 index 00000000000..f8a74ebe16f --- /dev/null +++ b/internal/devbox/jspm.go @@ -0,0 +1,278 @@ +package devbox + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/samber/lo" + "go.jetify.com/devbox/internal/devpkg" + "go.jetify.com/devbox/internal/devpkg/pkgtype" + "go.jetify.com/devbox/internal/nix" + "go.jetify.com/devbox/internal/ux" +) + +// InstallJSPMPackages installs JS packages via their package managers. +// Called from installPackages(), parallel to InstallRunXPackages(). +func (d *Devbox) InstallJSPMPackages(ctx context.Context) error { + jspmPkgs := lo.Filter(d.InstallablePackages(), devpkg.IsJSPM) + if len(jspmPkgs) == 0 { + return nil + } + + for _, pkg := range jspmPkgs { + mgr := pkg.JSPMType() + name, version := pkg.JSPMPackageName() + if version == "" { + version = "latest" + } + + // Check version sync with package.json + d.syncJSPMVersion(mgr, name, version) + + // Install the package + pkgSpec := name + if version != "" { + pkgSpec = name + "@" + version + } + ux.Finfof(d.stderr, "Installing %s via %s\n", pkgSpec, mgr) + + if err := d.jspmRunCommand(ctx, string(mgr), "add", pkgSpec); err != nil { + return fmt.Errorf("error installing %s package %s: %w", mgr, name, err) + } + } + return nil +} + +// RemoveJSPMPackages removes JS packages via their package managers. +func (d *Devbox) RemoveJSPMPackages(ctx context.Context, pkgs []string) error { + for _, raw := range pkgs { + if !pkgtype.IsJSPM(raw) { + continue + } + mgr := pkgtype.JSPMType(raw) + name, _ := pkgtype.JSPMPackageName(raw) + + ux.Finfof(d.stderr, "Removing %s via %s\n", name, mgr) + + if err := d.jspmRunCommand(ctx, string(mgr), "remove", name); err != nil { + ux.Fwarningf(d.stderr, "warning: failed to remove %s via %s: %s\n", name, mgr, err) + } + } + return nil +} + +// UpdateJSPMPackage updates a JS package via its package manager. +func (d *Devbox) UpdateJSPMPackage(ctx context.Context, pkg *devpkg.Package) error { + mgr := pkg.JSPMType() + name, version := pkg.JSPMPackageName() + + if version != "" && version != "latest" { + // Specific version: use add to pin + pkgSpec := name + "@" + version + ux.Finfof(d.stderr, "Updating %s to %s via %s\n", name, version, mgr) + return d.jspmRunCommand(ctx, string(mgr), "add", pkgSpec) + } + + // For latest or unversioned, use update + var updateCmd string + switch mgr { + case pkgtype.Yarn: + updateCmd = "upgrade" + default: + updateCmd = "update" + } + + ux.Finfof(d.stderr, "Updating %s via %s\n", name, mgr) + return d.jspmRunCommand(ctx, string(mgr), updateCmd, name) +} + +// JSPMPaths creates symlinks for JSPM package binaries and returns the bin paths. +// Called from computeEnv(), parallel to RunXPaths(). +func (d *Devbox) JSPMPaths(ctx context.Context) (string, error) { + jspmPkgs := lo.Filter(d.InstallablePackages(), devpkg.IsJSPM) + if len(jspmPkgs) == 0 { + return "", nil + } + + // Collect unique managers in use + managers := map[pkgtype.JSPackageManager]bool{} + for _, pkg := range jspmPkgs { + managers[pkg.JSPMType()] = true + } + + var binPaths []string + for mgr := range managers { + binPath := jspmBinPath(d.projectDir, mgr) + if err := os.RemoveAll(binPath); err != nil { + return "", err + } + if err := os.MkdirAll(binPath, 0o755); err != nil { + return "", err + } + + // Symlink binaries from node_modules/.bin/ to our virtenv bin dir + nodeModulesBin := filepath.Join(d.projectDir, "node_modules", ".bin") + if entries, err := os.ReadDir(nodeModulesBin); err == nil { + for _, entry := range entries { + src := filepath.Join(nodeModulesBin, entry.Name()) + dst := filepath.Join(binPath, entry.Name()) + if err := os.Symlink(src, dst); err != nil && !os.IsExist(err) { + return "", err + } + } + } + + binPaths = append(binPaths, binPath) + } + + return strings.Join(binPaths, string(filepath.ListSeparator)), nil +} + +// jspmRunCommand runs a JS package manager command. It builds a PATH that +// includes the project's nix profile, the utility project's nodejs/corepack +// binaries, and the system PATH. +func (d *Devbox) jspmRunCommand(ctx context.Context, manager string, args ...string) error { + path, err := d.jspmPath(ctx) + if err != nil { + return err + } + + // Look up the manager binary in our augmented PATH + managerPath, err := lookPathIn(manager, path) + if err != nil { + return fmt.Errorf( + "%s not found. Add nodejs or %s to your devbox.json packages", + manager, manager, + ) + } + + cmd := exec.CommandContext(ctx, managerPath, args...) + cmd.Dir = d.projectDir + cmd.Env = append(os.Environ(), "PATH="+path) + cmd.Stdout = d.stderr // use stderr for install output + cmd.Stderr = d.stderr + return cmd.Run() +} + +// jspmPath builds a PATH string that includes all places where JS package +// manager binaries might be found: the project's nix profile, the utility +// project's corepack bin, and the system PATH. +func (d *Devbox) jspmPath(ctx context.Context) (string, error) { + var paths []string + + // 1. Project's nix profile (user may have nodejs/pnpm there) + profileBin := nix.ProfileBinPath(d.projectDir) + paths = append(paths, profileBin) + + // 2. Utility project's corepack bin (for corepack-enabled managers) + corepackBin, err := d.ensureUtilityNodejs(ctx) + if err == nil && corepackBin != "" { + paths = append(paths, corepackBin) + } + + // 3. Utility project's nix bin (for nodejs itself from utility project) + utilBin, err := utilityBinPath() + if err == nil { + paths = append(paths, utilBin) + } + + // 4. System PATH + paths = append(paths, os.Getenv("PATH")) + + return strings.Join(paths, string(filepath.ListSeparator)), nil +} + +// ensureUtilityNodejs installs nodejs in the utility project and enables +// corepack so that pnpm/yarn are available. Returns the corepack bin path. +func (d *Devbox) ensureUtilityNodejs(ctx context.Context) (string, error) { + if err := addToUtilityProject(ctx, d.stderr, "nodejs@latest"); err != nil { + return "", err + } + + // Enable corepack in the utility project + utilBin, err := utilityBinPath() + if err != nil { + return "", err + } + + corepackBin, err := utilityCorepackBinPath() + if err != nil { + return "", err + } + + if err := os.MkdirAll(corepackBin, 0o755); err != nil { + return "", err + } + + // Run corepack enable to install package manager shims + corepackPath := filepath.Join(utilBin, "corepack") + cmd := exec.CommandContext(ctx, corepackPath, "enable", "--install-directory", corepackBin) + cmd.Env = append(os.Environ(), "PATH="+utilBin+string(filepath.ListSeparator)+os.Getenv("PATH")) + cmd.Stdout = d.stderr + cmd.Stderr = d.stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to enable corepack: %w", err) + } + + return corepackBin, nil +} + +// lookPathIn searches for an executable in the given PATH string. +func lookPathIn(file, pathEnv string) (string, error) { + for _, dir := range filepath.SplitList(pathEnv) { + path := filepath.Join(dir, file) + if fi, err := os.Stat(path); err == nil && !fi.IsDir() && fi.Mode()&0o111 != 0 { + return path, nil + } + } + return "", fmt.Errorf("%s not found in PATH", file) +} + +// syncJSPMVersion checks if the package.json version matches devbox version and warns if not. +func (d *Devbox) syncJSPMVersion(mgr pkgtype.JSPackageManager, name, devboxVersion string) { + pkgJSONPath := filepath.Join(d.projectDir, "package.json") + data, err := os.ReadFile(pkgJSONPath) + if err != nil { + // No package.json yet; the package manager will create one. + return + } + + var pkgJSON map[string]any + if err := json.Unmarshal(data, &pkgJSON); err != nil { + return + } + + // Check both dependencies and devDependencies + for _, depKey := range []string{"dependencies", "devDependencies"} { + deps, ok := pkgJSON[depKey].(map[string]any) + if !ok { + continue + } + existingVersion, ok := deps[name].(string) + if !ok { + continue + } + + // Compare versions. Strip leading ^ ~ >= etc for comparison. + cleanExisting := strings.TrimLeft(existingVersion, "^~>=<") + if devboxVersion != "latest" && cleanExisting != devboxVersion { + ux.Fwarningf( + d.stderr, + "devbox and package.json version of %s don't match (devbox: %s, package.json: %s). "+ + "Run \"devbox add %s:%s@%s\" to fix.\n", + name, devboxVersion, existingVersion, + mgr, name, cleanExisting, + ) + } + return + } +} + +func jspmBinPath(projectDir string, mgr pkgtype.JSPackageManager) string { + return filepath.Join(projectDir, ".devbox", "virtenv", string(mgr), "bin") +} diff --git a/internal/devbox/packages.go b/internal/devbox/packages.go index 33b992108db..345b421746f 100644 --- a/internal/devbox/packages.go +++ b/internal/devbox/packages.go @@ -124,31 +124,36 @@ func (d *Devbox) Add(ctx context.Context, pkgsNames []string, opts devopt.AddOpt } } - // validate that the versioned package exists in the search endpoint. - // if not, fallback to legacy vanilla nix. - versionedPkg := devpkg.PackageFromStringWithOptions(pkg.Versioned(), d.lockfile, opts) - packageNameForConfig := pkg.Raw - ok, err := versionedPkg.ValidateExists(ctx) - if (err == nil && ok) || errors.Is(err, devpkg.ErrCannotBuildPackageOnSystem) { - // Only use versioned if it exists in search. We can disregard the error - // about not building on the current system, since user's can continue - // via --exclude-platform flag. + if pkg.IsJSPM() { + // JSPM packages skip nix validation; validated at install time. packageNameForConfig = pkg.Versioned() - } else if !versionedPkg.IsDevboxPackage { - // This means it didn't validate and we don't want to fallback to legacy - // Just propagate the error. - return err } else { - installable := flake.Installable{ - Ref: d.lockfile.Stdenv(), - AttrPath: pkg.Raw, - } - _, err := nix.Search(installable.String()) - if err != nil { - // This means it looked like a devbox package or attribute path, but we - // could not find it in search or in the legacy nixpkgs path. - return usererr.New("Package %s not found", pkg.Raw) + // validate that the versioned package exists in the search endpoint. + // if not, fallback to legacy vanilla nix. + versionedPkg := devpkg.PackageFromStringWithOptions(pkg.Versioned(), d.lockfile, opts) + + ok, err := versionedPkg.ValidateExists(ctx) + if (err == nil && ok) || errors.Is(err, devpkg.ErrCannotBuildPackageOnSystem) { + // Only use versioned if it exists in search. We can disregard the error + // about not building on the current system, since user's can continue + // via --exclude-platform flag. + packageNameForConfig = pkg.Versioned() + } else if !versionedPkg.IsDevboxPackage { + // This means it didn't validate and we don't want to fallback to legacy + // Just propagate the error. + return err + } else { + installable := flake.Installable{ + Ref: d.lockfile.Stdenv(), + AttrPath: pkg.Raw, + } + _, err := nix.Search(installable.String()) + if err != nil { + // This means it looked like a devbox package or attribute path, but we + // could not find it in search or in the legacy nixpkgs path. + return usererr.New("Package %s not found", pkg.Raw) + } } } @@ -262,6 +267,11 @@ func (d *Devbox) Remove(ctx context.Context, pkgs ...string) error { ) } + // Remove JSPM packages via their package managers + if err := d.RemoveJSPMPackages(ctx, packagesToUninstall); err != nil { + return err + } + if err := plugin.Remove(d.projectDir, packagesToUninstall); err != nil { return err } @@ -478,7 +488,11 @@ func (d *Devbox) installPackages(ctx context.Context, mode installMode) error { return err } - return d.InstallRunXPackages(ctx) + if err := d.InstallRunXPackages(ctx); err != nil { + return err + } + + return d.InstallJSPMPackages(ctx) } func (d *Devbox) handleInstallFailure(ctx context.Context, mode installMode) error { diff --git a/internal/devbox/services.go b/internal/devbox/services.go index 3af32a13383..b7829b41bc0 100644 --- a/internal/devbox/services.go +++ b/internal/devbox/services.go @@ -177,7 +177,7 @@ func (d *Devbox) AttachToProcessManager(ctx context.Context) error { return usererr.New("Process manager is not running. Run `devbox services up` to start it.") } - err := initDevboxUtilityProject(ctx, d.stderr) + err := addToUtilityProject(ctx, d.stderr, "process-compose@"+processComposeVersion) if err != nil { return err } @@ -241,7 +241,7 @@ func (d *Devbox) StartProcessManager( } } - err = initDevboxUtilityProject(ctx, d.stderr) + err = addToUtilityProject(ctx, d.stderr, "process-compose@"+processComposeVersion) if err != nil { return err } diff --git a/internal/devbox/update.go b/internal/devbox/update.go index 91eba0125b9..e950653576d 100644 --- a/internal/devbox/update.go +++ b/internal/devbox/update.go @@ -68,16 +68,8 @@ func (d *Devbox) Update(ctx context.Context, opts devopt.UpdateOpts) error { } } - for _, pkg := range pendingPackagesToUpdate { - if _, _, isVersioned := searcher.ParseVersionedPackage(pkg.Raw); !isVersioned { - if err = d.attemptToUpgradeFlake(pkg); err != nil { - return err - } - } else { - if err = d.updateDevboxPackage(pkg); err != nil { - return err - } - } + if err := d.updatePendingPackages(ctx, pendingPackagesToUpdate); err != nil { + return err } mode := update @@ -122,6 +114,25 @@ func (d *Devbox) inputsToUpdate( return pkgsToUpdate, nil } +func (d *Devbox) updatePendingPackages(ctx context.Context, packages []*devpkg.Package) error { + for _, pkg := range packages { + if pkg.IsJSPM() { + if err := d.UpdateJSPMPackage(ctx, pkg); err != nil { + return err + } + } else if _, _, isVersioned := searcher.ParseVersionedPackage(pkg.Raw); !isVersioned { + if err := d.attemptToUpgradeFlake(pkg); err != nil { + return err + } + } else { + if err := d.updateDevboxPackage(pkg); err != nil { + return err + } + } + } + return nil +} + func (d *Devbox) updateDevboxPackage(pkg *devpkg.Package) error { resolved, err := d.lockfile.FetchResolvedPackage(pkg.Raw) if err != nil { diff --git a/internal/devbox/util.go b/internal/devbox/util.go index 9ae5ebf8e4b..7de0e4bf6a8 100644 --- a/internal/devbox/util.go +++ b/internal/devbox/util.go @@ -20,7 +20,10 @@ const processComposeVersion = "1.87.0" var utilProjectConfigPath string -func initDevboxUtilityProject(ctx context.Context, stderr io.Writer) error { +// addToUtilityProject ensures the given packages are installed in the shared +// devbox utility project. Call this on-demand rather than eagerly installing +// all utilities at startup. +func addToUtilityProject(ctx context.Context, stderr io.Writer, packages ...string) error { devboxUtilityProjectPath, err := ensureDevboxUtilityConfig() if err != nil { return err @@ -34,11 +37,7 @@ func initDevboxUtilityProject(ctx context.Context, stderr io.Writer) error { return errors.WithStack(err) } - // Add all utilities here. - utilities := []string{ - "process-compose@" + processComposeVersion, - } - if err = box.Add(ctx, utilities, devopt.AddOpts{}); err != nil { + if err = box.Add(ctx, packages, devopt.AddOpts{}); err != nil { return err } @@ -100,3 +99,13 @@ func utilityBinPath() (string, error) { return filepath.Join(nixProfilePath, "default/bin"), nil } + +// utilityCorepackBinPath returns the path where corepack installs package +// manager binaries in the utility project. +func utilityCorepackBinPath() (string, error) { + path, err := utilityDataPath() + if err != nil { + return "", err + } + return filepath.Join(path, ".devbox/virtenv/nodejs/corepack-bin"), nil +} diff --git a/internal/devpkg/package.go b/internal/devpkg/package.go index 45cdc0fef4e..ee0293e25f3 100644 --- a/internal/devpkg/package.go +++ b/internal/devpkg/package.go @@ -139,6 +139,12 @@ func newPackage(raw string, isInstallable func() bool, locker lock.Locker) *Pack isInstallable: sync.OnceValue(isInstallable), } + // JSPM packages are managed by external JS package managers, not Nix. + if pkgtype.IsJSPM(raw) { + pkg.resolve = sync.OnceValue(func() error { return nil }) + return pkg + } + // The raw string is either a Devbox package ("name" or "name@version") // or it's a flake installable. In some cases they're ambiguous // ("nixpkgs" is a devbox package and a flake). When that happens, we @@ -496,8 +502,15 @@ func (p *Package) Equals(other *Package) bool { } // CanonicalName returns the name of the package without the version -// it only applies to devbox packages +// it only applies to devbox packages and JSPM packages func (p *Package) CanonicalName() string { + if p.IsJSPM() { + // For JSPM packages, canonical name includes the prefix but not version. + // e.g. "pnpm:vercel@latest" -> "pnpm:vercel" + prefix := string(p.JSPMType()) + ":" + name, _ := p.JSPMPackageName() + return prefix + name + } if !p.IsDevboxPackage { return "" } @@ -506,6 +519,13 @@ func (p *Package) CanonicalName() string { } func (p *Package) Versioned() string { + if p.IsJSPM() { + _, version := p.JSPMPackageName() + if version == "" { + return p.Raw + "@latest" + } + return p.Raw + } if p.IsDevboxPackage && !p.isVersioned() { return p.Raw + "@latest" } @@ -657,6 +677,19 @@ func (p *Package) IsRunX() bool { return pkgtype.IsRunX(p.Raw) } +func (p *Package) IsJSPM() bool { + return pkgtype.IsJSPM(p.Raw) +} + +func (p *Package) JSPMType() pkgtype.JSPackageManager { + return pkgtype.JSPMType(p.Raw) +} + +// JSPMPackageName returns the JS package name and version without the manager prefix. +func (p *Package) JSPMPackageName() (name, version string) { + return pkgtype.JSPMPackageName(p.Raw) +} + func (p *Package) IsNix() bool { return IsNix(p, 0) } @@ -680,13 +713,17 @@ func (p *Package) LockfileKey() string { } func IsNix(p *Package, _ int) bool { - return !p.IsRunX() + return !p.IsRunX() && !p.IsJSPM() } func IsRunX(p *Package, _ int) bool { return p.IsRunX() } +func IsJSPM(p *Package, _ int) bool { + return p.IsJSPM() +} + func (p *Package) DocsURL() string { if p.IsRunX() { path, _, _ := strings.Cut(p.RunXPath(), "@") @@ -701,7 +738,7 @@ func (p *Package) DocsURL() string { // GetOutputNames returns the names of the nix package outputs. Outputs can be // specified in devbox.json package fields or as part of the flake reference. func (p *Package) GetOutputNames() ([]string, error) { - if p.IsRunX() { + if p.IsRunX() || p.IsJSPM() { return []string{}, nil } diff --git a/internal/devpkg/package_test.go b/internal/devpkg/package_test.go index b35912a0f12..1054ec7a8de 100644 --- a/internal/devpkg/package_test.go +++ b/internal/devpkg/package_test.go @@ -197,6 +197,11 @@ func TestCanonicalName(t *testing.T) { {"runx:golangci/golangci-lint@latest", "runx:golangci/golangci-lint"}, {"runx:golangci/golangci-lint@v0.0.2", "runx:golangci/golangci-lint"}, {"runx:golangci/golangci-lint", "runx:golangci/golangci-lint"}, + {"pnpm:vercel", "pnpm:vercel"}, + {"pnpm:vercel@latest", "pnpm:vercel"}, + {"npm:eslint@8.0.0", "npm:eslint"}, + {"yarn:turbo@1.0.0", "yarn:turbo"}, + {"npm:@scope/pkg@1.0.0", "npm:@scope/pkg"}, {"github:NixOS/nixpkgs/12345", ""}, {"path:/to/my/file", ""}, } @@ -211,3 +216,53 @@ func TestCanonicalName(t *testing.T) { }) } } + +func TestVersioned(t *testing.T) { + tests := []struct { + pkgName string + expected string + }{ + {"go", "go@latest"}, + {"go@1.21", "go@1.21"}, + {"pnpm:vercel", "pnpm:vercel@latest"}, + {"pnpm:vercel@latest", "pnpm:vercel@latest"}, + {"pnpm:vercel@1.2.3", "pnpm:vercel@1.2.3"}, + {"npm:eslint", "npm:eslint@latest"}, + {"npm:@scope/pkg@1.0.0", "npm:@scope/pkg@1.0.0"}, + } + + for _, tt := range tests { + t.Run(tt.pkgName, func(t *testing.T) { + pkg := PackageFromStringWithDefaults(tt.pkgName, &lockfile{}) + got := pkg.Versioned() + if got != tt.expected { + t.Errorf("Versioned() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestIsJSPM(t *testing.T) { + tests := []struct { + pkgName string + expected bool + }{ + {"pnpm:vercel", true}, + {"pnpm:vercel@latest", true}, + {"npm:eslint", true}, + {"yarn:turbo", true}, + {"go@1.21", false}, + {"runx:foo/bar", false}, + {"hello", false}, + } + + for _, tt := range tests { + t.Run(tt.pkgName, func(t *testing.T) { + pkg := PackageFromStringWithDefaults(tt.pkgName, &lockfile{}) + got := pkg.IsJSPM() + if got != tt.expected { + t.Errorf("IsJSPM() = %v, want %v", got, tt.expected) + } + }) + } +} diff --git a/internal/devpkg/pkgtype/flake.go b/internal/devpkg/pkgtype/flake.go index b6ec9562a7d..24dc7d52800 100644 --- a/internal/devpkg/pkgtype/flake.go +++ b/internal/devpkg/pkgtype/flake.go @@ -7,7 +7,7 @@ import ( ) func IsFlake(s string) bool { - if IsRunX(s) { + if IsRunX(s) || IsJSPM(s) { return false } parsed, err := flake.ParseInstallable(s) diff --git a/internal/devpkg/pkgtype/jspm.go b/internal/devpkg/pkgtype/jspm.go new file mode 100644 index 00000000000..7dc1813827b --- /dev/null +++ b/internal/devpkg/pkgtype/jspm.go @@ -0,0 +1,63 @@ +package pkgtype + +import "strings" + +// JSPackageManager represents which JS package manager manages a package. +type JSPackageManager string + +const ( + Pnpm JSPackageManager = "pnpm" + Yarn JSPackageManager = "yarn" + Npm JSPackageManager = "npm" + + PnpmScheme = "pnpm" + PnpmPrefix = PnpmScheme + ":" + YarnScheme = "yarn" + YarnPrefix = YarnScheme + ":" + NpmScheme = "npm" + NpmPrefix = NpmScheme + ":" +) + +// IsJSPM returns true if the string has a pnpm:, yarn:, or npm: prefix. +func IsJSPM(s string) bool { + return strings.HasPrefix(s, PnpmPrefix) || + strings.HasPrefix(s, YarnPrefix) || + strings.HasPrefix(s, NpmPrefix) +} + +// JSPMType returns which JS package manager is indicated by the prefix. +// Panics if the string is not a JSPM package. +func JSPMType(s string) JSPackageManager { + switch { + case strings.HasPrefix(s, PnpmPrefix): + return Pnpm + case strings.HasPrefix(s, YarnPrefix): + return Yarn + case strings.HasPrefix(s, NpmPrefix): + return Npm + default: + panic("not a JSPM package: " + s) + } +} + +// JSPMPackageName strips the prefix and splits on @ to return (name, version). +// For "pnpm:vercel@latest" returns ("vercel", "latest"). +// For "pnpm:vercel" returns ("vercel", ""). +func JSPMPackageName(raw string) (name, version string) { + // Strip the prefix + pkg := raw + switch { + case strings.HasPrefix(pkg, PnpmPrefix): + pkg = strings.TrimPrefix(pkg, PnpmPrefix) + case strings.HasPrefix(pkg, YarnPrefix): + pkg = strings.TrimPrefix(pkg, YarnPrefix) + case strings.HasPrefix(pkg, NpmPrefix): + pkg = strings.TrimPrefix(pkg, NpmPrefix) + } + + // Split on last @ (to handle scoped packages like @scope/pkg@version) + if i := strings.LastIndex(pkg, "@"); i > 0 { + return pkg[:i], pkg[i+1:] + } + return pkg, "" +} diff --git a/internal/devpkg/pkgtype/jspm_test.go b/internal/devpkg/pkgtype/jspm_test.go new file mode 100644 index 00000000000..420dbb956d6 --- /dev/null +++ b/internal/devpkg/pkgtype/jspm_test.go @@ -0,0 +1,80 @@ +package pkgtype + +import "testing" + +func TestIsJSPM(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"pnpm:vercel", true}, + {"pnpm:vercel@latest", true}, + {"yarn:turbo", true}, + {"yarn:turbo@1.0.0", true}, + {"npm:eslint", true}, + {"npm:@scope/pkg@1.0.0", true}, + {"go@1.21", false}, + {"runx:foo/bar", false}, + {"hello", false}, + {"github:NixOS/nixpkgs#hello", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := IsJSPM(tt.input) + if got != tt.expected { + t.Errorf("IsJSPM(%q) = %v, want %v", tt.input, got, tt.expected) + } + }) + } +} + +func TestJSPMType(t *testing.T) { + tests := []struct { + input string + expected JSPackageManager + }{ + {"pnpm:vercel", Pnpm}, + {"pnpm:vercel@latest", Pnpm}, + {"yarn:turbo", Yarn}, + {"npm:eslint", Npm}, + {"npm:@scope/pkg@1.0.0", Npm}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := JSPMType(tt.input) + if got != tt.expected { + t.Errorf("JSPMType(%q) = %v, want %v", tt.input, got, tt.expected) + } + }) + } +} + +func TestJSPMPackageName(t *testing.T) { + tests := []struct { + input string + expectedName string + expectedVer string + }{ + {"pnpm:vercel@latest", "vercel", "latest"}, + {"pnpm:vercel", "vercel", ""}, + {"pnpm:vercel@1.2.3", "vercel", "1.2.3"}, + {"npm:eslint@8.0.0", "eslint", "8.0.0"}, + {"npm:eslint", "eslint", ""}, + {"yarn:turbo@latest", "turbo", "latest"}, + {"npm:@scope/pkg@1.0.0", "@scope/pkg", "1.0.0"}, + {"pnpm:@scope/pkg", "@scope/pkg", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + name, ver := JSPMPackageName(tt.input) + if name != tt.expectedName || ver != tt.expectedVer { + t.Errorf("JSPMPackageName(%q) = (%q, %q), want (%q, %q)", + tt.input, name, ver, tt.expectedName, tt.expectedVer) + } + }) + } +} diff --git a/internal/devpkg/validation.go b/internal/devpkg/validation.go index 3ef00902e27..064c221665b 100644 --- a/internal/devpkg/validation.go +++ b/internal/devpkg/validation.go @@ -9,6 +9,10 @@ import ( ) func (p *Package) ValidateExists(ctx context.Context) (bool, error) { + if p.IsJSPM() { + // JSPM packages are validated at install time by the JS package manager. + return true, nil + } if p.IsRunX() { _, err := p.lockfile.Resolve(p.Raw) return err == nil, err diff --git a/internal/lock/lockfile.go b/internal/lock/lockfile.go index a3fd3070bbe..ad31ea02dc0 100644 --- a/internal/lock/lockfile.go +++ b/internal/lock/lockfile.go @@ -75,10 +75,16 @@ func (f *File) Remove(pkgs ...string) error { // This avoids writing values that may need to be removed in case of error. func (f *File) Resolve(pkg string) (*Package, error) { entry, hasEntry := f.Packages[pkg] - if hasEntry && entry.Resolved != "" { + if hasEntry && (entry.Resolved != "" || entry.ManagedBy != "") { return f.Packages[pkg], nil } + if pkgtype.IsJSPM(pkg) { + locked := &Package{ManagedBy: string(pkgtype.JSPMType(pkg))} + f.Packages[pkg] = locked + return locked, nil + } + locked := &Package{} _, _, versioned := searcher.ParseVersionedPackage(pkg) if pkgtype.IsRunX(pkg) || versioned || pkgtype.IsFlake(pkg) { diff --git a/internal/lock/package.go b/internal/lock/package.go index 7131aa5d417..b1bb685392d 100644 --- a/internal/lock/package.go +++ b/internal/lock/package.go @@ -22,6 +22,9 @@ type Package struct { Version string `json:"version,omitempty"` // Systems is keyed by the system name Systems map[string]*SystemInfo `json:"systems,omitempty"` + // ManagedBy indicates the package is managed by an external JS package + // manager (pnpm, yarn, npm) rather than Nix. + ManagedBy string `json:"managed_by,omitempty"` // NOTE: if you add more fields, please update SyncLockfiles } diff --git a/testscripts/add/add_jspm.test.txt b/testscripts/add/add_jspm.test.txt new file mode 100644 index 00000000000..1ebb120e0df --- /dev/null +++ b/testscripts/add/add_jspm.test.txt @@ -0,0 +1,40 @@ +# Test adding and removing JS package manager packages + +# Add nodejs first so the JS package manager is available +exec devbox add nodejs_22 + +# Add a pnpm-managed JS package +exec devbox add pnpm:vercel +json.superset devbox.json expected_devbox_after_add.json +devboxlock.packages.contains devbox.lock pnpm:vercel@latest + +# Verify the binary is accessible via devbox run +exec devbox run -- vercel --version + +# Remove the JS package +exec devbox rm pnpm:vercel +json.superset devbox.json expected_devbox_after_rm.json +! devboxlock.packages.contains devbox.lock pnpm:vercel@latest + +-- devbox.json -- +{ + "packages": [], + "env": { + "DEVBOX_COREPACK_ENABLED": "true" + } +} + +-- expected_devbox_after_add.json -- +{ + "packages": [ + "nodejs_22@latest", + "pnpm:vercel@latest" + ] +} + +-- expected_devbox_after_rm.json -- +{ + "packages": [ + "nodejs_22@latest" + ] +} diff --git a/testscripts/jspm/devbox.json b/testscripts/jspm/devbox.json new file mode 100644 index 00000000000..782c1667f0c --- /dev/null +++ b/testscripts/jspm/devbox.json @@ -0,0 +1,14 @@ +{ + "packages": [ + "nodejs_22@latest", + "pnpm:vercel@latest" + ], + "env": { + "DEVBOX_COREPACK_ENABLED": "true" + }, + "shell": { + "scripts": { + "run_test": "vercel --version && echo 'JSPM test passed'" + } + } +}