Skip to content

Commit 24d356d

Browse files
mikeland73claude
andcommitted
Add JS package manager support (pnpm, yarn, npm) for devbox
Allow installing JS packages via `devbox add pnpm:vercel`, `devbox add npm:eslint`, etc. Packages are managed by the specified JS package manager rather than Nix, with entries in devbox.lock using a minimal `managed_by` field. Binaries are symlinked from node_modules/.bin/ to .devbox/virtenv/<manager>/bin/ and added to PATH. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f82a364 commit 24d356d

14 files changed

Lines changed: 569 additions & 29 deletions

File tree

internal/devbox/devbox.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,12 @@ func (d *Devbox) computeEnv(
800800
}
801801
devboxEnvPath = envpath.JoinPathLists(devboxEnvPath, runXPaths)
802802

803+
jspmPaths, err := d.JSPMPaths(ctx)
804+
if err != nil {
805+
return nil, err
806+
}
807+
devboxEnvPath = envpath.JoinPathLists(devboxEnvPath, jspmPaths)
808+
803809
pathStack := envpath.Stack(env, originalEnv)
804810
pathStack.Push(env, d.ProjectDirHash(), devboxEnvPath, envOpts.PreservePathStack)
805811
env["PATH"] = pathStack.Path(env)

internal/devbox/jspm.go

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
package devbox
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strings"
11+
12+
"github.com/samber/lo"
13+
"go.jetify.com/devbox/internal/devpkg"
14+
"go.jetify.com/devbox/internal/devpkg/pkgtype"
15+
"go.jetify.com/devbox/internal/nix"
16+
"go.jetify.com/devbox/internal/ux"
17+
)
18+
19+
// InstallJSPMPackages installs JS packages via their package managers.
20+
// Called from installPackages(), parallel to InstallRunXPackages().
21+
func (d *Devbox) InstallJSPMPackages(ctx context.Context) error {
22+
jspmPkgs := lo.Filter(d.InstallablePackages(), devpkg.IsJSPM)
23+
if len(jspmPkgs) == 0 {
24+
return nil
25+
}
26+
27+
for _, pkg := range jspmPkgs {
28+
mgr := pkg.JSPMType()
29+
name, version := pkg.JSPMPackageName()
30+
if version == "" {
31+
version = "latest"
32+
}
33+
34+
// Check version sync with package.json
35+
d.syncJSPMVersion(mgr, name, version)
36+
37+
// Install the package
38+
pkgSpec := name
39+
if version != "" {
40+
pkgSpec = name + "@" + version
41+
}
42+
ux.Finfof(d.stderr, "Installing %s via %s\n", pkgSpec, mgr)
43+
44+
if err := d.jspmRunCommand(ctx, string(mgr), "add", pkgSpec); err != nil {
45+
return fmt.Errorf("error installing %s package %s: %w", mgr, name, err)
46+
}
47+
}
48+
return nil
49+
}
50+
51+
// RemoveJSPMPackages removes JS packages via their package managers.
52+
func (d *Devbox) RemoveJSPMPackages(ctx context.Context, pkgs []string) error {
53+
for _, raw := range pkgs {
54+
if !pkgtype.IsJSPM(raw) {
55+
continue
56+
}
57+
mgr := pkgtype.JSPMType(raw)
58+
name, _ := pkgtype.JSPMPackageName(raw)
59+
60+
ux.Finfof(d.stderr, "Removing %s via %s\n", name, mgr)
61+
62+
if err := d.jspmRunCommand(ctx, string(mgr), "remove", name); err != nil {
63+
ux.Fwarningf(d.stderr, "warning: failed to remove %s via %s: %s\n", name, mgr, err)
64+
}
65+
}
66+
return nil
67+
}
68+
69+
// UpdateJSPMPackage updates a JS package via its package manager.
70+
func (d *Devbox) UpdateJSPMPackage(ctx context.Context, pkg *devpkg.Package) error {
71+
mgr := pkg.JSPMType()
72+
name, version := pkg.JSPMPackageName()
73+
74+
if version != "" && version != "latest" {
75+
// Specific version: use add to pin
76+
pkgSpec := name + "@" + version
77+
ux.Finfof(d.stderr, "Updating %s to %s via %s\n", name, version, mgr)
78+
return d.jspmRunCommand(ctx, string(mgr), "add", pkgSpec)
79+
}
80+
81+
// For latest or unversioned, use update
82+
var updateCmd string
83+
switch mgr {
84+
case pkgtype.Yarn:
85+
updateCmd = "upgrade"
86+
default:
87+
updateCmd = "update"
88+
}
89+
90+
ux.Finfof(d.stderr, "Updating %s via %s\n", name, mgr)
91+
return d.jspmRunCommand(ctx, string(mgr), updateCmd, name)
92+
}
93+
94+
// JSPMPaths creates symlinks for JSPM package binaries and returns the bin paths.
95+
// Called from computeEnv(), parallel to RunXPaths().
96+
func (d *Devbox) JSPMPaths(ctx context.Context) (string, error) {
97+
jspmPkgs := lo.Filter(d.InstallablePackages(), devpkg.IsJSPM)
98+
if len(jspmPkgs) == 0 {
99+
return "", nil
100+
}
101+
102+
// Collect unique managers in use
103+
managers := map[pkgtype.JSPackageManager]bool{}
104+
for _, pkg := range jspmPkgs {
105+
managers[pkg.JSPMType()] = true
106+
}
107+
108+
var binPaths []string
109+
for mgr := range managers {
110+
binPath := jspmBinPath(d.projectDir, mgr)
111+
if err := os.RemoveAll(binPath); err != nil {
112+
return "", err
113+
}
114+
if err := os.MkdirAll(binPath, 0o755); err != nil {
115+
return "", err
116+
}
117+
118+
// Symlink binaries from node_modules/.bin/ to our virtenv bin dir
119+
nodeModulesBin := filepath.Join(d.projectDir, "node_modules", ".bin")
120+
if entries, err := os.ReadDir(nodeModulesBin); err == nil {
121+
for _, entry := range entries {
122+
src := filepath.Join(nodeModulesBin, entry.Name())
123+
dst := filepath.Join(binPath, entry.Name())
124+
if err := os.Symlink(src, dst); err != nil && !os.IsExist(err) {
125+
return "", err
126+
}
127+
}
128+
}
129+
130+
binPaths = append(binPaths, binPath)
131+
}
132+
133+
return strings.Join(binPaths, string(filepath.ListSeparator)), nil
134+
}
135+
136+
// jspmRunCommand runs a JS package manager command. It looks for the binary
137+
// in the devbox nix profile first (which is already installed by the time
138+
// JSPM packages are installed), then falls back to the system PATH.
139+
func (d *Devbox) jspmRunCommand(ctx context.Context, manager string, args ...string) error {
140+
// Build a PATH that includes the nix profile bin directory.
141+
// By the time JSPM packages are installed, nix packages (including nodejs/pnpm)
142+
// are already in the nix profile.
143+
profileBin := nix.ProfileBinPath(d.projectDir)
144+
path := profileBin + string(filepath.ListSeparator) + os.Getenv("PATH")
145+
146+
// Look up the manager binary in our augmented PATH
147+
managerPath, err := lookPathIn(manager, path)
148+
if err != nil {
149+
return fmt.Errorf(
150+
"%s not found. Add nodejs or %s to your devbox.json packages",
151+
manager, manager,
152+
)
153+
}
154+
155+
cmd := exec.CommandContext(ctx, managerPath, args...)
156+
cmd.Dir = d.projectDir
157+
cmd.Env = append(os.Environ(), "PATH="+path)
158+
cmd.Stdout = d.stderr // use stderr for install output
159+
cmd.Stderr = d.stderr
160+
return cmd.Run()
161+
}
162+
163+
// lookPathIn searches for an executable in the given PATH string.
164+
func lookPathIn(file, pathEnv string) (string, error) {
165+
for _, dir := range filepath.SplitList(pathEnv) {
166+
path := filepath.Join(dir, file)
167+
if fi, err := os.Stat(path); err == nil && !fi.IsDir() && fi.Mode()&0o111 != 0 {
168+
return path, nil
169+
}
170+
}
171+
return "", fmt.Errorf("%s not found in PATH", file)
172+
}
173+
174+
// syncJSPMVersion checks if the package.json version matches devbox version and warns if not.
175+
func (d *Devbox) syncJSPMVersion(mgr pkgtype.JSPackageManager, name, devboxVersion string) {
176+
pkgJSONPath := filepath.Join(d.projectDir, "package.json")
177+
data, err := os.ReadFile(pkgJSONPath)
178+
if err != nil {
179+
// No package.json yet; the package manager will create one.
180+
return
181+
}
182+
183+
var pkgJSON map[string]any
184+
if err := json.Unmarshal(data, &pkgJSON); err != nil {
185+
return
186+
}
187+
188+
// Check both dependencies and devDependencies
189+
for _, depKey := range []string{"dependencies", "devDependencies"} {
190+
deps, ok := pkgJSON[depKey].(map[string]any)
191+
if !ok {
192+
continue
193+
}
194+
existingVersion, ok := deps[name].(string)
195+
if !ok {
196+
continue
197+
}
198+
199+
// Compare versions. Strip leading ^ ~ >= etc for comparison.
200+
cleanExisting := strings.TrimLeft(existingVersion, "^~>=<")
201+
if devboxVersion != "latest" && cleanExisting != devboxVersion {
202+
ux.Fwarningf(
203+
d.stderr,
204+
"devbox and package.json version of %s don't match (devbox: %s, package.json: %s). "+
205+
"Run \"devbox add %s:%s@%s\" to fix.\n",
206+
name, devboxVersion, existingVersion,
207+
mgr, name, cleanExisting,
208+
)
209+
}
210+
return
211+
}
212+
}
213+
214+
func jspmBinPath(projectDir string, mgr pkgtype.JSPackageManager) string {
215+
return filepath.Join(projectDir, ".devbox", "virtenv", string(mgr), "bin")
216+
}

internal/devbox/packages.go

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -124,31 +124,36 @@ func (d *Devbox) Add(ctx context.Context, pkgsNames []string, opts devopt.AddOpt
124124
}
125125
}
126126

127-
// validate that the versioned package exists in the search endpoint.
128-
// if not, fallback to legacy vanilla nix.
129-
versionedPkg := devpkg.PackageFromStringWithOptions(pkg.Versioned(), d.lockfile, opts)
130-
131127
packageNameForConfig := pkg.Raw
132-
ok, err := versionedPkg.ValidateExists(ctx)
133-
if (err == nil && ok) || errors.Is(err, devpkg.ErrCannotBuildPackageOnSystem) {
134-
// Only use versioned if it exists in search. We can disregard the error
135-
// about not building on the current system, since user's can continue
136-
// via --exclude-platform flag.
128+
if pkg.IsJSPM() {
129+
// JSPM packages skip nix validation; validated at install time.
137130
packageNameForConfig = pkg.Versioned()
138-
} else if !versionedPkg.IsDevboxPackage {
139-
// This means it didn't validate and we don't want to fallback to legacy
140-
// Just propagate the error.
141-
return err
142131
} else {
143-
installable := flake.Installable{
144-
Ref: d.lockfile.Stdenv(),
145-
AttrPath: pkg.Raw,
146-
}
147-
_, err := nix.Search(installable.String())
148-
if err != nil {
149-
// This means it looked like a devbox package or attribute path, but we
150-
// could not find it in search or in the legacy nixpkgs path.
151-
return usererr.New("Package %s not found", pkg.Raw)
132+
// validate that the versioned package exists in the search endpoint.
133+
// if not, fallback to legacy vanilla nix.
134+
versionedPkg := devpkg.PackageFromStringWithOptions(pkg.Versioned(), d.lockfile, opts)
135+
136+
ok, err := versionedPkg.ValidateExists(ctx)
137+
if (err == nil && ok) || errors.Is(err, devpkg.ErrCannotBuildPackageOnSystem) {
138+
// Only use versioned if it exists in search. We can disregard the error
139+
// about not building on the current system, since user's can continue
140+
// via --exclude-platform flag.
141+
packageNameForConfig = pkg.Versioned()
142+
} else if !versionedPkg.IsDevboxPackage {
143+
// This means it didn't validate and we don't want to fallback to legacy
144+
// Just propagate the error.
145+
return err
146+
} else {
147+
installable := flake.Installable{
148+
Ref: d.lockfile.Stdenv(),
149+
AttrPath: pkg.Raw,
150+
}
151+
_, err := nix.Search(installable.String())
152+
if err != nil {
153+
// This means it looked like a devbox package or attribute path, but we
154+
// could not find it in search or in the legacy nixpkgs path.
155+
return usererr.New("Package %s not found", pkg.Raw)
156+
}
152157
}
153158
}
154159

@@ -262,6 +267,11 @@ func (d *Devbox) Remove(ctx context.Context, pkgs ...string) error {
262267
)
263268
}
264269

270+
// Remove JSPM packages via their package managers
271+
if err := d.RemoveJSPMPackages(ctx, packagesToUninstall); err != nil {
272+
return err
273+
}
274+
265275
if err := plugin.Remove(d.projectDir, packagesToUninstall); err != nil {
266276
return err
267277
}
@@ -478,7 +488,11 @@ func (d *Devbox) installPackages(ctx context.Context, mode installMode) error {
478488
return err
479489
}
480490

481-
return d.InstallRunXPackages(ctx)
491+
if err := d.InstallRunXPackages(ctx); err != nil {
492+
return err
493+
}
494+
495+
return d.InstallJSPMPackages(ctx)
482496
}
483497

484498
func (d *Devbox) handleInstallFailure(ctx context.Context, mode installMode) error {

internal/devbox/update.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,11 @@ func (d *Devbox) Update(ctx context.Context, opts devopt.UpdateOpts) error {
6969
}
7070

7171
for _, pkg := range pendingPackagesToUpdate {
72-
if _, _, isVersioned := searcher.ParseVersionedPackage(pkg.Raw); !isVersioned {
72+
if pkg.IsJSPM() {
73+
if err = d.UpdateJSPMPackage(ctx, pkg); err != nil {
74+
return err
75+
}
76+
} else if _, _, isVersioned := searcher.ParseVersionedPackage(pkg.Raw); !isVersioned {
7377
if err = d.attemptToUpgradeFlake(pkg); err != nil {
7478
return err
7579
}

0 commit comments

Comments
 (0)