Skip to content
Closed
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
6 changes: 6 additions & 0 deletions internal/devbox/devbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
278 changes: 278 additions & 0 deletions internal/devbox/jspm.go
Original file line number Diff line number Diff line change
@@ -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")
}
60 changes: 37 additions & 23 deletions internal/devbox/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions internal/devbox/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading