From 12f145028bf1b7589394a461a44909184b283810 Mon Sep 17 00:00:00 2001 From: Mike Landau Date: Mon, 28 Sep 2026 10:05:46 -0700 Subject: [PATCH 1/4] feat(config): add devbox_version to require a devbox version Add a top-level `devbox_version` field to devbox.json that declares which devbox versions a project supports (#1371). It accepts a semver constraint string, or an object with an explicit mismatch policy: "devbox_version": "^0.18.0" "devbox_version": {"version": "0.18.4", "on_mismatch": "error"} When the running devbox doesn't satisfy the constraint, "warn" (the default) prints a warning once per project per process tree and "error" fails the command. DEVBOX_VERSION_POLICY=off|warn|error overrides the policy. The check runs in devbox.Open right after the config loads, before the lockfile is read, so it covers every command that opens a project (including `devbox global`). Development builds skip it. Constraints use npm-style syntax via Masterminds/semver, which moves from an indirect to a direct dependency. Co-Authored-By: Claude Opus 5.5 (1M context) --- .schema/devbox.schema.json | 25 ++++ go.mod | 2 +- internal/devbox/devbox.go | 9 ++ .../devconfig/configfile/devbox_version.go | 136 +++++++++++++++++ .../configfile/devbox_version_test.go | 115 +++++++++++++++ internal/devconfig/configfile/file.go | 5 + internal/envir/env.go | 7 +- internal/vercheck/project.go | 89 ++++++++++++ internal/vercheck/project_test.go | 137 ++++++++++++++++++ testscripts/version/devbox_version.test.txt | 64 ++++++++ 10 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 internal/devconfig/configfile/devbox_version.go create mode 100644 internal/devconfig/configfile/devbox_version_test.go create mode 100644 internal/vercheck/project.go create mode 100644 internal/vercheck/project_test.go create mode 100644 testscripts/version/devbox_version.test.txt diff --git a/.schema/devbox.schema.json b/.schema/devbox.schema.json index eafd6a9f76e..8f931832694 100644 --- a/.schema/devbox.schema.json +++ b/.schema/devbox.schema.json @@ -17,6 +17,31 @@ "description": "A description of the Devbox development environment.", "type": "string" }, + "devbox_version": { + "description": "The devbox version (or semver constraint) this project requires, such as \"0.18.4\" or \"^0.18.0\". By default, running a different version prints a warning.", + "oneOf": [ + { + "type": "string", + "description": "A devbox version or semver constraint. A mismatch prints a warning." + }, + { + "type": "object", + "properties": { + "version": { + "type": "string", + "description": "A devbox version or semver constraint, such as \"0.18.4\" or \"^0.18.0\"." + }, + "on_mismatch": { + "type": "string", + "description": "What to do when the running devbox version doesn't satisfy the constraint. \"warn\" prints a warning (default) and \"error\" fails the command.", + "enum": ["warn", "error"] + } + }, + "required": ["version"], + "additionalProperties": false + } + ] + }, "packages": { "description": "Collection of packages to install", "oneOf": [ diff --git a/go.mod b/go.mod index 7eada5aab4f..77dd5242af2 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.1 require ( al.essio.dev/pkg/shellescape v1.6.0 github.com/AlecAivazis/survey/v2 v2.3.7 + github.com/Masterminds/semver/v3 v3.5.0 github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/briandowns/spinner v1.23.2 github.com/denisbrodbeck/machineid v1.0.1 @@ -57,7 +58,6 @@ require ( github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect github.com/Djarvur/go-err113 v0.1.1 // indirect github.com/InVisionApp/go-logger v1.0.1 // indirect - github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/MirrexOne/unqueryvet v1.5.4 // indirect github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect github.com/STARRY-S/zip v0.2.3 // indirect diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go index 50d49424123..b3db43f1e4b 100644 --- a/internal/devbox/devbox.go +++ b/internal/devbox/devbox.go @@ -45,6 +45,7 @@ import ( "go.jetify.com/devbox/internal/shellgen" "go.jetify.com/devbox/internal/telemetry" "go.jetify.com/devbox/internal/ux" + "go.jetify.com/devbox/internal/vercheck" "go.jetify.com/devbox/nix/flake" ) @@ -109,6 +110,14 @@ func Open(opts *devopt.Opts) (*Devbox, error) { return nil, usererr.WithUserMessage(err, "Error loading devbox.json.") } + var stderr io.Writer = os.Stderr + if opts.Stderr != nil { + stderr = opts.Stderr + } + if err := vercheck.CheckProjectVersion(stderr, cfg.Root.AbsRootPath, cfg.Root.DevboxVersion); err != nil { + return nil, err + } + environment, err := validateEnvironment(opts.Environment) if err != nil { return nil, err diff --git a/internal/devconfig/configfile/devbox_version.go b/internal/devconfig/configfile/devbox_version.go new file mode 100644 index 00000000000..9550789142d --- /dev/null +++ b/internal/devconfig/configfile/devbox_version.go @@ -0,0 +1,136 @@ +// Copyright 2024 Jetify Inc. and contributors. All rights reserved. +// Use of this source code is governed by the license in the LICENSE file. + +package configfile + +import ( + "encoding/json" + "slices" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/pkg/errors" + "go.jetify.com/devbox/internal/boxcli/usererr" +) + +// VersionPolicy controls what devbox does when the running devbox version +// doesn't satisfy a project's devbox_version constraint. +type VersionPolicy string + +const ( + // VersionPolicyOff disables the check. It can only be set with the + // DEVBOX_VERSION_POLICY environment variable, not in devbox.json. + VersionPolicyOff VersionPolicy = "off" + // VersionPolicyWarn prints a warning and continues. + VersionPolicyWarn VersionPolicy = "warn" + // VersionPolicyError fails the command. + VersionPolicyError VersionPolicy = "error" +) + +// ConfigVersionPolicies are the values allowed for devbox_version.on_mismatch. +var ConfigVersionPolicies = []VersionPolicy{ + VersionPolicyWarn, + VersionPolicyError, +} + +// DevboxVersion is the devbox_version field of devbox.json. It is either a +// version constraint string, which uses the default "warn" policy: +// +// "devbox_version": "^0.18.0" +// +// or an object with an explicit policy: +// +// "devbox_version": {"version": "0.18.4", "on_mismatch": "error"} +type DevboxVersion struct { + // Version is a semver constraint (e.g. "0.18.4", "^0.18.0", + // ">=0.17.0 <0.19.0") that the running devbox version must satisfy. + Version string `json:"version"` + + // OnMismatch is the policy to apply when the running devbox version + // doesn't satisfy Version. Defaults to "warn". + OnMismatch VersionPolicy `json:"on_mismatch,omitempty"` + + // isShorthand records whether the field was written as a plain string so + // that marshaling preserves the original form. + isShorthand bool +} + +// Policy returns the configured on_mismatch policy, or the default if unset. +func (d *DevboxVersion) Policy() VersionPolicy { + if d.OnMismatch == "" { + return VersionPolicyWarn + } + return d.OnMismatch +} + +// Constraint parses Version as a semver constraint. +func (d *DevboxVersion) Constraint() (*semver.Constraints, error) { + c, err := semver.NewConstraint(d.Version) + if err != nil { + return nil, usererr.New( + "Invalid devbox_version %q in devbox.json: %v. Use a version like \"0.18.4\" or a constraint like \"^0.18.0\".", + d.Version, err, + ) + } + return c, nil +} + +// ExactVersion returns Version as an exact version (without a leading "v") +// and true if Version is a single exact version rather than a range. +func (d *DevboxVersion) ExactVersion() (string, bool) { + v := strings.TrimPrefix(strings.TrimSpace(d.Version), "v") + if _, err := semver.StrictNewVersion(v); err != nil { + return "", false + } + return v, true +} + +func (d *DevboxVersion) UnmarshalJSON(data []byte) error { + if len(data) > 0 && data[0] == '"' { + d.isShorthand = true + return json.Unmarshal(data, &d.Version) + } + type devboxVersion DevboxVersion + return json.Unmarshal(data, (*devboxVersion)(d)) +} + +func (d DevboxVersion) MarshalJSON() ([]byte, error) { + if d.isShorthand { + return json.Marshal(d.Version) + } + type devboxVersion DevboxVersion + return json.Marshal(devboxVersion(d)) +} + +func validateDevboxVersion(cfg *ConfigFile) error { + required := cfg.DevboxVersion + if required == nil { + return nil + } + if strings.TrimSpace(required.Version) == "" { + return usererr.New("devbox_version in devbox.json must specify a version") + } + if _, err := required.Constraint(); err != nil { + return err + } + if required.OnMismatch != "" && !slices.Contains(ConfigVersionPolicies, required.OnMismatch) { + return usererr.New( + "Invalid devbox_version.on_mismatch %q in devbox.json. Valid values are %q and %q.", + required.OnMismatch, VersionPolicyWarn, VersionPolicyError, + ) + } + return nil +} + +// ParseVersionPolicy parses a policy from the DEVBOX_VERSION_POLICY +// environment variable. Unlike devbox.json, it also accepts "off". +func ParseVersionPolicy(s string) (VersionPolicy, error) { + p := VersionPolicy(strings.ToLower(strings.TrimSpace(s))) + if p == VersionPolicyOff || slices.Contains(ConfigVersionPolicies, p) { + return p, nil + } + return "", errors.Errorf( + "invalid policy %q: valid values are %q, %q, and %q", + s, VersionPolicyOff, VersionPolicyWarn, VersionPolicyError, + ) +} diff --git a/internal/devconfig/configfile/devbox_version_test.go b/internal/devconfig/configfile/devbox_version_test.go new file mode 100644 index 00000000000..eae187c8c5c --- /dev/null +++ b/internal/devconfig/configfile/devbox_version_test.go @@ -0,0 +1,115 @@ +package configfile + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDevboxVersionShorthand(t *testing.T) { + cfg, err := LoadBytes([]byte(`{"devbox_version": "^0.18.0"}`)) + require.NoError(t, err) + require.NotNil(t, cfg.DevboxVersion) + assert.Equal(t, "^0.18.0", cfg.DevboxVersion.Version) + assert.Equal(t, VersionPolicyWarn, cfg.DevboxVersion.Policy()) + + b, err := json.Marshal(cfg.DevboxVersion) + require.NoError(t, err) + assert.JSONEq(t, `"^0.18.0"`, string(b)) +} + +func TestDevboxVersionObject(t *testing.T) { + cfg, err := LoadBytes([]byte(`{ + "devbox_version": {"version": "0.18.4", "on_mismatch": "error"} + }`)) + require.NoError(t, err) + require.NotNil(t, cfg.DevboxVersion) + assert.Equal(t, "0.18.4", cfg.DevboxVersion.Version) + assert.Equal(t, VersionPolicyError, cfg.DevboxVersion.Policy()) + + b, err := json.Marshal(cfg.DevboxVersion) + require.NoError(t, err) + assert.JSONEq(t, `{"version": "0.18.4", "on_mismatch": "error"}`, string(b)) +} + +func TestDevboxVersionObjectDefaultsToWarn(t *testing.T) { + cfg, err := LoadBytes([]byte(`{"devbox_version": {"version": "0.18.4"}}`)) + require.NoError(t, err) + assert.Equal(t, VersionPolicyWarn, cfg.DevboxVersion.Policy()) +} + +func TestDevboxVersionUnset(t *testing.T) { + cfg, err := LoadBytes([]byte(`{"packages": []}`)) + require.NoError(t, err) + assert.Nil(t, cfg.DevboxVersion) +} + +func TestDevboxVersionValidation(t *testing.T) { + valid := []string{ + `"0.18.4"`, + `"v0.18.4"`, + `"^0.18.0"`, + `"~0.18.1"`, + `">=0.17.0 <0.19.0"`, + `">=0.17.0, <0.19.0"`, + `"0.18.x"`, + `"0.17.2 || ^0.18.0"`, + `{"version": "0.18.4", "on_mismatch": "warn"}`, + } + for _, v := range valid { + t.Run(v, func(t *testing.T) { + _, err := LoadBytes([]byte(`{"devbox_version": ` + v + `}`)) + assert.NoError(t, err) + }) + } + + invalid := []string{ + `""`, + `"latest"`, + `">=> 1"`, + `{}`, + `{"on_mismatch": "error"}`, + `{"version": "0.18.4", "on_mismatch": "explode"}`, + `{"version": "0.18.4", "on_mismatch": "off"}`, + } + for _, v := range invalid { + t.Run(v, func(t *testing.T) { + _, err := LoadBytes([]byte(`{"devbox_version": ` + v + `}`)) + assert.Error(t, err) + }) + } +} + +func TestDevboxVersionExactVersion(t *testing.T) { + tests := map[string]struct { + want string + ok bool + }{ + "0.18.4": {"0.18.4", true}, + "v0.18.4": {"0.18.4", true}, + " 0.18.4 ": {"0.18.4", true}, + "0.18.4-rc1": {"0.18.4-rc1", true}, + "0.18": {"", false}, + "^0.18.0": {"", false}, + "=0.18.4": {"", false}, + ">=0.17.0 <0.19.0": {"", false}, + } + for version, tt := range tests { + t.Run(version, func(t *testing.T) { + got, ok := (&DevboxVersion{Version: version}).ExactVersion() + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseVersionPolicy(t *testing.T) { + for _, s := range []string{"off", "warn", "error", " WARN "} { + _, err := ParseVersionPolicy(s) + assert.NoError(t, err, s) + } + _, err := ParseVersionPolicy("explode") + assert.Error(t, err) +} diff --git a/internal/devconfig/configfile/file.go b/internal/devconfig/configfile/file.go index a5e81927feb..98a5c62afa6 100644 --- a/internal/devconfig/configfile/file.go +++ b/internal/devconfig/configfile/file.go @@ -43,6 +43,10 @@ type ConfigFile struct { Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` + // DevboxVersion constrains which devbox versions can be used with this + // project and what happens when the running version doesn't match. + DevboxVersion *DevboxVersion `json:"devbox_version,omitempty"` + // PackagesMutator is the slice of Nix packages that devbox makes available in // its environment. Deliberately do not omitempty. PackagesMutator PackagesMutator `json:"packages"` @@ -188,6 +192,7 @@ func validateConfig(cfg *ConfigFile) error { ValidateNixpkg, validateScripts, validateAliases, + validateDevboxVersion, } for _, fn := range fns { diff --git a/internal/envir/env.go b/internal/envir/env.go index 403daa3b12f..b8c0b5e221b 100644 --- a/internal/envir/env.go +++ b/internal/envir/env.go @@ -18,7 +18,12 @@ const ( DevboxSearchHost = "DEVBOX_SEARCH_HOST" DevboxShellEnabled = "DEVBOX_SHELL_ENABLED" DevboxShellStartTime = "DEVBOX_SHELL_START_TIME" - DevboxVM = "DEVBOX_VM" + // DevboxUseVersion tells the launcher which devbox version to run. + DevboxUseVersion = "DEVBOX_USE_VERSION" + // DevboxVersionPolicy overrides the devbox_version.on_mismatch policy in + // devbox.json. Valid values are "off", "warn", and "error". + DevboxVersionPolicy = "DEVBOX_VERSION_POLICY" + DevboxVM = "DEVBOX_VM" LauncherVersion = "LAUNCHER_VERSION" LauncherPath = "LAUNCHER_PATH" diff --git a/internal/vercheck/project.go b/internal/vercheck/project.go new file mode 100644 index 00000000000..52c78958c33 --- /dev/null +++ b/internal/vercheck/project.go @@ -0,0 +1,89 @@ +// Copyright 2024 Jetify Inc. and contributors. All rights reserved. +// Use of this source code is governed by the license in the LICENSE file. + +package vercheck + +import ( + "fmt" + "io" + "log/slog" + "os" + "strings" + + "github.com/Masterminds/semver/v3" + + "go.jetify.com/devbox/internal/boxcli/usererr" + "go.jetify.com/devbox/internal/devconfig/configfile" + "go.jetify.com/devbox/internal/envir" + "go.jetify.com/devbox/internal/ux" +) + +// warnedEnvName records the config path of the last project whose +// devbox_version mismatch was reported, so that nested devbox commands don't +// print the same warning again. +const warnedEnvName = "__DEVBOX_VERSION_MISMATCH_WARNED" + +// CheckProjectVersion enforces the devbox_version field of the devbox.json at +// configPath against the running devbox version. Depending on the policy, a +// mismatch prints a warning or returns an error. The DEVBOX_VERSION_POLICY +// environment variable overrides the policy in devbox.json. +func CheckProjectVersion(w io.Writer, configPath string, required *configfile.DevboxVersion) error { + if required == nil || isDevBuild { + return nil + } + + policy := required.Policy() + if env := os.Getenv(envir.DevboxVersionPolicy); env != "" { + p, err := configfile.ParseVersionPolicy(env) + if err != nil { + return usererr.New("Invalid %s: %v", envir.DevboxVersionPolicy, err) + } + policy = p + } + if policy == configfile.VersionPolicyOff { + return nil + } + + constraint, err := required.Constraint() + if err != nil { + return err + } + current, err := semver.NewVersion(currentDevboxVersion) + if err != nil { + slog.Debug("skipping devbox_version check: can't parse running version", "version", currentDevboxVersion, "err", err) + return nil + } + if constraint.Check(current) { + return nil + } + + msg := mismatchMessage(configPath, required) + if policy == configfile.VersionPolicyError { + return usererr.New("%s", msg) + } + if os.Getenv(warnedEnvName) == configPath { + return nil + } + ux.Fwarningf(w, "%s\n", msg) + return os.Setenv(warnedEnvName, configPath) +} + +func mismatchMessage(configPath string, required *configfile.DevboxVersion) string { + var msg strings.Builder + fmt.Fprintf( + &msg, + "This project requires devbox %s (devbox_version in %s), but you are running %s.\n", + required.Version, configPath, currentDevboxVersion, + ) + if exact, ok := required.ExactVersion(); ok { + fmt.Fprintf(&msg, "Set %s=%s to run the required version.", envir.DevboxUseVersion, exact) + } else { + fmt.Fprintf( + &msg, + "Run `devbox version update`, or set %s to a version that satisfies %q.", + envir.DevboxUseVersion, required.Version, + ) + } + fmt.Fprintf(&msg, " To skip this check, set %s=off.", envir.DevboxVersionPolicy) + return msg.String() +} diff --git a/internal/vercheck/project_test.go b/internal/vercheck/project_test.go new file mode 100644 index 00000000000..20d27361669 --- /dev/null +++ b/internal/vercheck/project_test.go @@ -0,0 +1,137 @@ +// Copyright 2024 Jetify Inc. and contributors. All rights reserved. +// Use of this source code is governed by the license in the LICENSE file. + +package vercheck + +import ( + "bytes" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.jetify.com/devbox/internal/boxcli/usererr" + "go.jetify.com/devbox/internal/devconfig/configfile" + "go.jetify.com/devbox/internal/envir" +) + +const testConfigPath = "/project/devbox.json" + +// setupProjectVersionTest mocks the running devbox version and clears any +// environment that affects CheckProjectVersion. +func setupProjectVersionTest(t *testing.T, version string) { + t.Helper() + oldVersion, oldIsDev := currentDevboxVersion, isDevBuild + t.Cleanup(func() { currentDevboxVersion, isDevBuild = oldVersion, oldIsDev }) + currentDevboxVersion = version + isDevBuild = false + + t.Setenv(envir.DevboxVersionPolicy, "") + t.Setenv(warnedEnvName, "") + t.Cleanup(func() { os.Unsetenv(warnedEnvName) }) +} + +func TestCheckProjectVersionSatisfied(t *testing.T) { + setupProjectVersionTest(t, "0.18.4") + + for _, version := range []string{"0.18.4", "v0.18.4", "^0.18.0", "~0.18.1", ">=0.17.0 <0.19.0", "0.18.x"} { + t.Run(version, func(t *testing.T) { + buf := new(bytes.Buffer) + err := CheckProjectVersion(buf, testConfigPath, &configfile.DevboxVersion{ + Version: version, + OnMismatch: configfile.VersionPolicyError, + }) + require.NoError(t, err) + assert.Empty(t, buf.String()) + }) + } +} + +func TestCheckProjectVersionNoConstraint(t *testing.T) { + setupProjectVersionTest(t, "0.18.4") + + buf := new(bytes.Buffer) + require.NoError(t, CheckProjectVersion(buf, testConfigPath, nil)) + assert.Empty(t, buf.String()) +} + +func TestCheckProjectVersionSkipsDevBuild(t *testing.T) { + setupProjectVersionTest(t, "0.0.0-dev") + isDevBuild = true + + buf := new(bytes.Buffer) + err := CheckProjectVersion(buf, testConfigPath, &configfile.DevboxVersion{ + Version: "0.18.4", + OnMismatch: configfile.VersionPolicyError, + }) + require.NoError(t, err) + assert.Empty(t, buf.String()) +} + +func TestCheckProjectVersionWarn(t *testing.T) { + setupProjectVersionTest(t, "0.17.2") + required := &configfile.DevboxVersion{Version: "^0.18.0"} + + buf := new(bytes.Buffer) + require.NoError(t, CheckProjectVersion(buf, testConfigPath, required)) + assert.Contains(t, buf.String(), "requires devbox ^0.18.0") + assert.Contains(t, buf.String(), "you are running 0.17.2") + assert.Contains(t, buf.String(), "devbox version update") + + // A nested devbox command for the same project doesn't warn again. + buf.Reset() + require.NoError(t, CheckProjectVersion(buf, testConfigPath, required)) + assert.Empty(t, buf.String()) + + // A different project still warns. + require.NoError(t, CheckProjectVersion(buf, "/other/devbox.json", required)) + assert.Contains(t, buf.String(), "requires devbox ^0.18.0") +} + +func TestCheckProjectVersionError(t *testing.T) { + setupProjectVersionTest(t, "0.17.2") + + buf := new(bytes.Buffer) + err := CheckProjectVersion(buf, testConfigPath, &configfile.DevboxVersion{ + Version: "0.18.4", + OnMismatch: configfile.VersionPolicyError, + }) + require.Error(t, err) + userErr, ok := usererr.Extract(err) + require.True(t, ok, "expected a user error") + assert.Contains(t, userErr.Error(), "requires devbox 0.18.4") + assert.Contains(t, userErr.Error(), "DEVBOX_USE_VERSION=0.18.4") + assert.Empty(t, buf.String()) +} + +func TestCheckProjectVersionEnvOverride(t *testing.T) { + setupProjectVersionTest(t, "0.17.2") + required := &configfile.DevboxVersion{Version: "0.18.4", OnMismatch: configfile.VersionPolicyError} + + t.Run("off", func(t *testing.T) { + t.Setenv(envir.DevboxVersionPolicy, "off") + buf := new(bytes.Buffer) + require.NoError(t, CheckProjectVersion(buf, testConfigPath, required)) + assert.Empty(t, buf.String()) + }) + + t.Run("warn", func(t *testing.T) { + t.Setenv(envir.DevboxVersionPolicy, "warn") + buf := new(bytes.Buffer) + require.NoError(t, CheckProjectVersion(buf, testConfigPath, required)) + assert.Contains(t, buf.String(), "requires devbox 0.18.4") + }) + + t.Run("error", func(t *testing.T) { + t.Setenv(envir.DevboxVersionPolicy, "error") + err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, &configfile.DevboxVersion{Version: "0.18.4"}) + assert.Error(t, err) + }) + + t.Run("invalid", func(t *testing.T) { + t.Setenv(envir.DevboxVersionPolicy, "explode") + err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, required) + assert.ErrorContains(t, err, envir.DevboxVersionPolicy) + }) +} diff --git a/testscripts/version/devbox_version.test.txt b/testscripts/version/devbox_version.test.txt new file mode 100644 index 00000000000..c294540ed78 --- /dev/null +++ b/testscripts/version/devbox_version.test.txt @@ -0,0 +1,64 @@ +# Verify that devbox enforces the devbox_version field in devbox.json. +# +# Development builds skip the check, so DEVBOX_PROD forces a production build. +# Its version is still 0.0.0-dev, which doesn't satisfy >=0.1.0. +# `devbox generate readme` is used because it loads the project config without +# needing Nix. + +env DEVBOX_PROD=1 + +# The default policy warns and continues. +cp warn.json devbox.json +exec devbox generate readme +stderr 'Warning: This project requires devbox >=0.1.0' +stderr 'you are running 0.0.0-dev' +exists README.md +rm README.md + +# The error policy fails the command. +cp error.json devbox.json +! exec devbox generate readme +stderr 'This project requires devbox 0.1.0' +stderr 'DEVBOX_USE_VERSION=0.1.0' +! exists README.md + +# DEVBOX_VERSION_POLICY overrides the policy in devbox.json. +env DEVBOX_VERSION_POLICY=off +exec devbox generate readme +! stderr 'requires devbox' +exists README.md +env DEVBOX_VERSION_POLICY= + +# A satisfied constraint is silent. +cp satisfied.json devbox.json +exec devbox generate readme +! stderr 'requires devbox' + +# An invalid constraint is a config error. +cp invalid.json devbox.json +! exec devbox generate readme +stderr 'Invalid devbox_version "latest"' + +-- warn.json -- +{ + "devbox_version": ">=0.1.0", + "packages": [] +} + +-- error.json -- +{ + "devbox_version": {"version": "0.1.0", "on_mismatch": "error"}, + "packages": [] +} + +-- satisfied.json -- +{ + "devbox_version": {"version": "0.0.0-dev", "on_mismatch": "error"}, + "packages": [] +} + +-- invalid.json -- +{ + "devbox_version": "latest", + "packages": [] +} From 83e8fc2f109375871126fb4112191900c760abc9 Mon Sep 17 00:00:00 2001 From: Mike Landau Date: Mon, 28 Sep 2026 10:25:57 -0700 Subject: [PATCH 2/4] fix(vercheck): only suggest `devbox version update` when it would help For range constraints, the mismatch message always suggested `devbox version update`, but that only moves to the latest release. If the running version is already past the constraint's upper bound, or the latest release doesn't satisfy it, updating can't fix the mismatch. Now we only suggest it when the latest version (DEVBOX_LATEST_VERSION) satisfies the constraint, and otherwise point at DEVBOX_USE_VERSION alone. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/vercheck/project.go | 17 ++++++++++++++--- internal/vercheck/project_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/internal/vercheck/project.go b/internal/vercheck/project.go index 52c78958c33..3a559655e1f 100644 --- a/internal/vercheck/project.go +++ b/internal/vercheck/project.go @@ -57,7 +57,7 @@ func CheckProjectVersion(w io.Writer, configPath string, required *configfile.De return nil } - msg := mismatchMessage(configPath, required) + msg := mismatchMessage(configPath, required, constraint) if policy == configfile.VersionPolicyError { return usererr.New("%s", msg) } @@ -68,7 +68,7 @@ func CheckProjectVersion(w io.Writer, configPath string, required *configfile.De return os.Setenv(warnedEnvName, configPath) } -func mismatchMessage(configPath string, required *configfile.DevboxVersion) string { +func mismatchMessage(configPath string, required *configfile.DevboxVersion, constraint *semver.Constraints) string { var msg strings.Builder fmt.Fprintf( &msg, @@ -77,13 +77,24 @@ func mismatchMessage(configPath string, required *configfile.DevboxVersion) stri ) if exact, ok := required.ExactVersion(); ok { fmt.Fprintf(&msg, "Set %s=%s to run the required version.", envir.DevboxUseVersion, exact) - } else { + } else if latestSatisfies(constraint) { fmt.Fprintf( &msg, "Run `devbox version update`, or set %s to a version that satisfies %q.", envir.DevboxUseVersion, required.Version, ) + } else { + fmt.Fprintf(&msg, "Set %s to a version that satisfies %q.", envir.DevboxUseVersion, required.Version) } fmt.Fprintf(&msg, " To skip this check, set %s=off.", envir.DevboxVersionPolicy) return msg.String() } + +// latestSatisfies reports whether the latest devbox release satisfies +// constraint, meaning `devbox version update` would fix a mismatch. It's false +// when the latest version is unknown or when the constraint excludes it (for +// example, an upper bound that the running version is already past). +func latestSatisfies(constraint *semver.Constraints) bool { + latest, err := semver.NewVersion(latestVersion()) + return err == nil && constraint.Check(latest) +} diff --git a/internal/vercheck/project_test.go b/internal/vercheck/project_test.go index 20d27361669..0965c462ae2 100644 --- a/internal/vercheck/project_test.go +++ b/internal/vercheck/project_test.go @@ -28,6 +28,7 @@ func setupProjectVersionTest(t *testing.T, version string) { isDevBuild = false t.Setenv(envir.DevboxVersionPolicy, "") + t.Setenv(envir.DevboxLatestVersion, "") t.Setenv(warnedEnvName, "") t.Cleanup(func() { os.Unsetenv(warnedEnvName) }) } @@ -71,6 +72,7 @@ func TestCheckProjectVersionSkipsDevBuild(t *testing.T) { func TestCheckProjectVersionWarn(t *testing.T) { setupProjectVersionTest(t, "0.17.2") + t.Setenv(envir.DevboxLatestVersion, "0.18.4") required := &configfile.DevboxVersion{Version: "^0.18.0"} buf := new(bytes.Buffer) @@ -89,6 +91,34 @@ func TestCheckProjectVersionWarn(t *testing.T) { assert.Contains(t, buf.String(), "requires devbox ^0.18.0") } +func TestCheckProjectVersionUpdateSuggestion(t *testing.T) { + required := &configfile.DevboxVersion{Version: ">=0.17.0 <0.19.0", OnMismatch: configfile.VersionPolicyError} + + t.Run("latest_satisfies", func(t *testing.T) { + setupProjectVersionTest(t, "0.16.0") + t.Setenv(envir.DevboxLatestVersion, "0.18.4") + err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, required) + assert.ErrorContains(t, err, "devbox version update") + }) + + // Updating can't go backwards, so don't suggest it when the running + // version is already past the constraint's upper bound. + t.Run("too_new", func(t *testing.T) { + setupProjectVersionTest(t, "0.20.0") + t.Setenv(envir.DevboxLatestVersion, "0.20.0") + err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, required) + require.ErrorContains(t, err, "DEVBOX_USE_VERSION") + assert.NotContains(t, err.Error(), "devbox version update") + }) + + t.Run("latest_unknown", func(t *testing.T) { + setupProjectVersionTest(t, "0.16.0") + err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, required) + require.ErrorContains(t, err, "DEVBOX_USE_VERSION") + assert.NotContains(t, err.Error(), "devbox version update") + }) +} + func TestCheckProjectVersionError(t *testing.T) { setupProjectVersionTest(t, "0.17.2") From 4331a49674239eb08284442695567610053ac08d Mon Sep 17 00:00:00 2001 From: Mike Landau Date: Mon, 28 Sep 2026 11:23:38 -0700 Subject: [PATCH 3/4] fix(boxcli): don't check devbox_version when opening a project incidentally Two places opened the current directory's project on every devbox invocation, so the devbox_version check fired for commands that don't use the project (e.g. `devbox version` printed the mismatch warning), and a swallowed error let `devbox run --list` ignore the "error" policy: - `devbox run` computed ValidArgs eagerly while building the command tree. It's now a lazy ValidArgsFunction that only runs during shell completion, which also lets cobra parse --config for us. listScripts now returns its error, so `run --list` and bare `run` report it. - The telemetry middleware opens the project after every command to record package names. Add devopt.Opts.SkipVersionCheck for these incidental opens (telemetry and completion), and cover `devbox version` and `run --list` in the testscript. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/boxcli/midcobra/telemetry.go | 7 ++- internal/boxcli/run.go | 65 ++++++++++++--------- internal/devbox/devbox.go | 6 +- internal/devbox/devopt/devboxopts.go | 5 ++ testscripts/version/devbox_version.test.txt | 11 ++++ 5 files changed, 63 insertions(+), 31 deletions(-) diff --git a/internal/boxcli/midcobra/telemetry.go b/internal/boxcli/midcobra/telemetry.go index 44c7f8aefd9..5069ad10d8b 100644 --- a/internal/boxcli/midcobra/telemetry.go +++ b/internal/boxcli/midcobra/telemetry.go @@ -94,9 +94,10 @@ func getPackagesAndCommitHash(c *cobra.Command) ([]string, string) { } box, err := devbox.Open(&devopt.Opts{ - Dir: path, - Stderr: os.Stderr, - IgnoreWarnings: true, + Dir: path, + Stderr: os.Stderr, + IgnoreWarnings: true, + SkipVersionCheck: true, }) if err != nil { return []string{}, "" diff --git a/internal/boxcli/run.go b/internal/boxcli/run.go index b4045cac557..c521fed6f85 100644 --- a/internal/boxcli/run.go +++ b/internal/boxcli/run.go @@ -6,7 +6,6 @@ package boxcli import ( "fmt" "log/slog" - "os" "slices" "sort" "strings" @@ -83,58 +82,72 @@ func runCmd(defaults runFlagDefaults) *cobra.Command { "run command in all projects in the working directory, recursively. If command is not found in any project, it will be skipped.", ) - command.ValidArgs = listScripts(command, flags) + // Compute completions lazily so that running a command doesn't open the + // project just to build the command tree. + command.ValidArgsFunction = func( + cmd *cobra.Command, args []string, toComplete string, + ) ([]string, cobra.ShellCompDirective) { + return completeScripts(cmd, args, toComplete, flags) + } return command } -func listScripts(cmd *cobra.Command, flags runCmdFlags) []string { - path := flags.config.path - - // Special code path for shell completion. - // Landau: I'm not entirely sure why: - // * Flags need to be parsed again - // * cmd.Flag("config") contains the correct value, but flags.config.path is empty - // Give my low confidence, I'm making this a very narrow code path. - if path == "" && slices.Contains(os.Args, "__complete") { - _ = cmd.ParseFlags(os.Args) - if flag := cmd.Flag("config"); flag != nil && flag.Value != nil { - path = flag.Value.String() - } +// completeScripts completes the first argument of `devbox run` with the +// project's script names. +func completeScripts( + cmd *cobra.Command, args []string, toComplete string, flags runCmdFlags, +) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveDefault + } + scripts, err := listScripts(cmd, flags, true /*skipVersionCheck*/) + if err != nil { + slog.Error("failed to open devbox", "err", err) + } + if len(scripts) == 0 { + return nil, cobra.ShellCompDirectiveDefault } + return lo.Filter(scripts, func(s string, _ int) bool { + return strings.HasPrefix(s, toComplete) + }), cobra.ShellCompDirectiveNoFileComp +} +func listScripts(cmd *cobra.Command, flags runCmdFlags, skipVersionCheck bool) ([]string, error) { devboxOpts := &devopt.Opts{ - Dir: path, - Environment: flags.config.environment, - Stderr: cmd.ErrOrStderr(), - IgnoreWarnings: true, + Dir: flags.config.path, + Environment: flags.config.environment, + Stderr: cmd.ErrOrStderr(), + IgnoreWarnings: true, + SkipVersionCheck: skipVersionCheck, } if flags.allProjects { boxes, err := multi.Open(devboxOpts) if err != nil { - slog.Error("failed to open devbox", "err", err) - return nil + return nil, err } scripts := []string{} for _, box := range boxes { scripts = append(scripts, box.ListScripts()...) } sort.Strings(scripts) - return lo.Uniq(scripts) + return lo.Uniq(scripts), nil } box, err := devbox.Open(devboxOpts) if err != nil { - slog.Error("failed to open devbox", "err", err) - return nil + return nil, err } - return box.ListScripts() + return box.ListScripts(), nil } func runScriptCmd(cmd *cobra.Command, args []string, flags runCmdFlags) error { ctx := cmd.Context() if len(args) == 0 || flags.listScripts { - scripts := listScripts(cmd, flags) + scripts, err := listScripts(cmd, flags, false /*skipVersionCheck*/) + if err != nil { + return err + } if len(scripts) == 0 { fmt.Fprintln(cmd.OutOrStdout(), "no scripts defined in devbox.json") return nil diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go index b3db43f1e4b..e5bc7472293 100644 --- a/internal/devbox/devbox.go +++ b/internal/devbox/devbox.go @@ -114,8 +114,10 @@ func Open(opts *devopt.Opts) (*Devbox, error) { if opts.Stderr != nil { stderr = opts.Stderr } - if err := vercheck.CheckProjectVersion(stderr, cfg.Root.AbsRootPath, cfg.Root.DevboxVersion); err != nil { - return nil, err + if !opts.SkipVersionCheck { + if err := vercheck.CheckProjectVersion(stderr, cfg.Root.AbsRootPath, cfg.Root.DevboxVersion); err != nil { + return nil, err + } } environment, err := validateEnvironment(opts.Environment) diff --git a/internal/devbox/devopt/devboxopts.go b/internal/devbox/devopt/devboxopts.go index 6b66692d221..788b0b27d52 100644 --- a/internal/devbox/devopt/devboxopts.go +++ b/internal/devbox/devopt/devboxopts.go @@ -15,6 +15,11 @@ type Opts struct { IgnoreWarnings bool CustomProcessComposeFile string Stderr io.Writer + + // SkipVersionCheck skips enforcing devbox_version. Set it when opening a + // project incidentally (e.g. for telemetry or shell completion) rather + // than to run the user's command. + SkipVersionCheck bool } type ProcessComposeOpts struct { diff --git a/testscripts/version/devbox_version.test.txt b/testscripts/version/devbox_version.test.txt index c294540ed78..2df1123e0b3 100644 --- a/testscripts/version/devbox_version.test.txt +++ b/testscripts/version/devbox_version.test.txt @@ -15,6 +15,10 @@ stderr 'you are running 0.0.0-dev' exists README.md rm README.md +# Commands that don't use the project don't check its devbox_version. +exec devbox version +! stderr 'requires devbox' + # The error policy fails the command. cp error.json devbox.json ! exec devbox generate readme @@ -22,6 +26,13 @@ stderr 'This project requires devbox 0.1.0' stderr 'DEVBOX_USE_VERSION=0.1.0' ! exists README.md +# Listing scripts reports the error instead of ignoring it. +! exec devbox run --list +stderr 'This project requires devbox 0.1.0' + +exec devbox version +! stderr 'requires devbox' + # DEVBOX_VERSION_POLICY overrides the policy in devbox.json. env DEVBOX_VERSION_POLICY=off exec devbox generate readme From 901a8354691e2c7c4a8c2c9a1bcc8fb84cb8e631 Mon Sep 17 00:00:00 2001 From: Mike Landau Date: Mon, 28 Sep 2026 10:08:30 -0700 Subject: [PATCH 4/4] feat(config): add "auto" devbox_version policy to switch versions With `"devbox_version": {"version": "0.18.4", "on_mismatch": "auto"}`, a devbox that doesn't match re-runs the same command through the devbox launcher with DEVBOX_USE_VERSION set to the required version. The launcher already downloads and runs any release named by DEVBOX_USE_VERSION, so no launcher change is needed. - "auto" requires an exact version; a range with "auto" is a config error. - A DEVBOX_USE_VERSION set by the user wins; devbox warns instead of switching. __DEVBOX_AUTO_VERSION tells the two apart and stops re-exec loops if the launcher doesn't switch. - Without a launcher (e.g. Nix or Homebrew installs), "auto" fails with instructions, like "error". - DEVBOX_VERSION_POLICY also accepts "auto". Co-Authored-By: Claude Opus 5.5 (1M context) --- .schema/devbox.schema.json | 4 +- .../devconfig/configfile/devbox_version.go | 22 ++- .../configfile/devbox_version_test.go | 7 +- internal/vercheck/project.go | 92 ++++++++++++- internal/vercheck/project_test.go | 128 ++++++++++++++++++ .../version/devbox_version_auto.test.txt | 52 +++++++ 6 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 testscripts/version/devbox_version_auto.test.txt diff --git a/.schema/devbox.schema.json b/.schema/devbox.schema.json index 8f931832694..4ca51b7ff26 100644 --- a/.schema/devbox.schema.json +++ b/.schema/devbox.schema.json @@ -33,8 +33,8 @@ }, "on_mismatch": { "type": "string", - "description": "What to do when the running devbox version doesn't satisfy the constraint. \"warn\" prints a warning (default) and \"error\" fails the command.", - "enum": ["warn", "error"] + "description": "What to do when the running devbox version doesn't satisfy the constraint. \"warn\" prints a warning (default), \"error\" fails the command, and \"auto\" re-runs the command with the required version (requires an exact version).", + "enum": ["warn", "error", "auto"] } }, "required": ["version"], diff --git a/internal/devconfig/configfile/devbox_version.go b/internal/devconfig/configfile/devbox_version.go index 9550789142d..dff99d18a5f 100644 --- a/internal/devconfig/configfile/devbox_version.go +++ b/internal/devconfig/configfile/devbox_version.go @@ -25,12 +25,16 @@ const ( VersionPolicyWarn VersionPolicy = "warn" // VersionPolicyError fails the command. VersionPolicyError VersionPolicy = "error" + // VersionPolicyAuto re-runs the command with the required devbox version + // using the devbox launcher. It requires an exact version. + VersionPolicyAuto VersionPolicy = "auto" ) // ConfigVersionPolicies are the values allowed for devbox_version.on_mismatch. var ConfigVersionPolicies = []VersionPolicy{ VersionPolicyWarn, VersionPolicyError, + VersionPolicyAuto, } // DevboxVersion is the devbox_version field of devbox.json. It is either a @@ -40,7 +44,7 @@ var ConfigVersionPolicies = []VersionPolicy{ // // or an object with an explicit policy: // -// "devbox_version": {"version": "0.18.4", "on_mismatch": "error"} +// "devbox_version": {"version": "0.18.4", "on_mismatch": "auto"} type DevboxVersion struct { // Version is a semver constraint (e.g. "0.18.4", "^0.18.0", // ">=0.17.0 <0.19.0") that the running devbox version must satisfy. @@ -115,10 +119,18 @@ func validateDevboxVersion(cfg *ConfigFile) error { } if required.OnMismatch != "" && !slices.Contains(ConfigVersionPolicies, required.OnMismatch) { return usererr.New( - "Invalid devbox_version.on_mismatch %q in devbox.json. Valid values are %q and %q.", - required.OnMismatch, VersionPolicyWarn, VersionPolicyError, + "Invalid devbox_version.on_mismatch %q in devbox.json. Valid values are %q, %q, and %q.", + required.OnMismatch, VersionPolicyWarn, VersionPolicyError, VersionPolicyAuto, ) } + if required.OnMismatch == VersionPolicyAuto { + if _, ok := required.ExactVersion(); !ok { + return usererr.New( + "devbox_version.on_mismatch %q requires an exact version like \"0.18.4\", but devbox.json has %q.", + VersionPolicyAuto, required.Version, + ) + } + } return nil } @@ -130,7 +142,7 @@ func ParseVersionPolicy(s string) (VersionPolicy, error) { return p, nil } return "", errors.Errorf( - "invalid policy %q: valid values are %q, %q, and %q", - s, VersionPolicyOff, VersionPolicyWarn, VersionPolicyError, + "invalid policy %q: valid values are %q, %q, %q, and %q", + s, VersionPolicyOff, VersionPolicyWarn, VersionPolicyError, VersionPolicyAuto, ) } diff --git a/internal/devconfig/configfile/devbox_version_test.go b/internal/devconfig/configfile/devbox_version_test.go index eae187c8c5c..078307db087 100644 --- a/internal/devconfig/configfile/devbox_version_test.go +++ b/internal/devconfig/configfile/devbox_version_test.go @@ -57,6 +57,8 @@ func TestDevboxVersionValidation(t *testing.T) { `"0.18.x"`, `"0.17.2 || ^0.18.0"`, `{"version": "0.18.4", "on_mismatch": "warn"}`, + `{"version": "0.18.4", "on_mismatch": "auto"}`, + `{"version": "v0.18.4", "on_mismatch": "auto"}`, } for _, v := range valid { t.Run(v, func(t *testing.T) { @@ -73,6 +75,9 @@ func TestDevboxVersionValidation(t *testing.T) { `{"on_mismatch": "error"}`, `{"version": "0.18.4", "on_mismatch": "explode"}`, `{"version": "0.18.4", "on_mismatch": "off"}`, + `{"version": "^0.18.0", "on_mismatch": "auto"}`, + `{"version": "0.18.x", "on_mismatch": "auto"}`, + `{"version": "0.18", "on_mismatch": "auto"}`, } for _, v := range invalid { t.Run(v, func(t *testing.T) { @@ -106,7 +111,7 @@ func TestDevboxVersionExactVersion(t *testing.T) { } func TestParseVersionPolicy(t *testing.T) { - for _, s := range []string{"off", "warn", "error", " WARN "} { + for _, s := range []string{"off", "warn", "error", "auto", " WARN "} { _, err := ParseVersionPolicy(s) assert.NoError(t, err, s) } diff --git a/internal/vercheck/project.go b/internal/vercheck/project.go index 3a559655e1f..ac3f2e2806b 100644 --- a/internal/vercheck/project.go +++ b/internal/vercheck/project.go @@ -9,6 +9,7 @@ import ( "log/slog" "os" "strings" + "syscall" "github.com/Masterminds/semver/v3" @@ -23,9 +24,19 @@ import ( // print the same warning again. const warnedEnvName = "__DEVBOX_VERSION_MISMATCH_WARNED" +// autoVersionEnvName records the version that the "auto" policy switched to. +// It distinguishes a DEVBOX_USE_VERSION set by the auto policy from one the +// user set, and stops re-exec loops if the launcher doesn't switch versions. +const autoVersionEnvName = "__DEVBOX_AUTO_VERSION" + +// execFunc replaces the current process. We use this variable so that we can +// mock it in tests. +var execFunc = syscall.Exec + // CheckProjectVersion enforces the devbox_version field of the devbox.json at // configPath against the running devbox version. Depending on the policy, a -// mismatch prints a warning or returns an error. The DEVBOX_VERSION_POLICY +// mismatch prints a warning, returns an error, or re-runs the current command +// with the required version (which doesn't return). The DEVBOX_VERSION_POLICY // environment variable overrides the policy in devbox.json. func CheckProjectVersion(w io.Writer, configPath string, required *configfile.DevboxVersion) error { if required == nil || isDevBuild { @@ -58,9 +69,70 @@ func CheckProjectVersion(w io.Writer, configPath string, required *configfile.De } msg := mismatchMessage(configPath, required, constraint) - if policy == configfile.VersionPolicyError { + switch policy { + case configfile.VersionPolicyError: return usererr.New("%s", msg) + case configfile.VersionPolicyAuto: + return switchVersion(w, configPath, required, msg) + default: + return warnOnce(w, configPath, msg) + } +} + +// switchVersion re-runs the current command with the exact version required +// by devbox_version. It uses the devbox launcher, which downloads the version +// if needed and runs the version named by DEVBOX_USE_VERSION. +func switchVersion(w io.Writer, configPath string, required *configfile.DevboxVersion, msg string) error { + target, ok := required.ExactVersion() + if !ok { + // Only possible with DEVBOX_VERSION_POLICY=auto, since devbox.json + // validation rejects on_mismatch "auto" with a range. + return usererr.New( + "%s\nDevbox can't switch versions automatically because devbox_version is %q, not an exact version like \"0.18.4\".", + msg, required.Version, + ) } + + // Respect a version the user chose explicitly. + autoVersion := os.Getenv(autoVersionEnvName) + if useVersion := os.Getenv(envir.DevboxUseVersion); useVersion != "" && useVersion != autoVersion { + return warnOnce(w, configPath, fmt.Sprintf( + "%s\nNot switching versions automatically because %s=%s is set.", + msg, envir.DevboxUseVersion, useVersion, + )) + } + + if autoVersion == target { + return usererr.New( + "%s\nDevbox tried to switch to version %s automatically, but the launcher ran version %s instead.", + msg, target, currentDevboxVersion, + ) + } + + launcher := os.Getenv(envir.LauncherPath) + if launcher == "" { + return usererr.New( + "%s\nDevbox can't switch versions automatically because it wasn't started by the devbox launcher. "+ + "Install devbox with `curl -fsSL https://get.jetify.com/devbox | bash` to enable automatic switching.", + msg, + ) + } + + slog.Debug("switching devbox version for devbox_version", "from", currentDevboxVersion, "to", target, "launcher", launcher) + env := withEnv(os.Environ(), map[string]string{ + envir.DevboxUseVersion: target, + autoVersionEnvName: target, + }) + args := append([]string{launcher}, os.Args[1:]...) + if err := execFunc(launcher, args, env); err != nil { + return usererr.WithUserMessage(err, "Failed to switch to devbox version %s using the launcher at %s.", target, launcher) + } + return nil +} + +// warnOnce prints msg as a warning unless it was already printed for the +// project at configPath by this process or a parent devbox process. +func warnOnce(w io.Writer, configPath, msg string) error { if os.Getenv(warnedEnvName) == configPath { return nil } @@ -68,6 +140,22 @@ func CheckProjectVersion(w io.Writer, configPath string, required *configfile.De return os.Setenv(warnedEnvName, configPath) } +// withEnv returns environ with the variables in vars set, replacing any +// existing values. +func withEnv(environ []string, vars map[string]string) []string { + result := make([]string, 0, len(environ)+len(vars)) + for _, kv := range environ { + name, _, _ := strings.Cut(kv, "=") + if _, ok := vars[name]; !ok { + result = append(result, kv) + } + } + for name, value := range vars { + result = append(result, name+"="+value) + } + return result +} + func mismatchMessage(configPath string, required *configfile.DevboxVersion, constraint *semver.Constraints) string { var msg strings.Builder fmt.Fprintf( diff --git a/internal/vercheck/project_test.go b/internal/vercheck/project_test.go index 0965c462ae2..df7a44ec7a6 100644 --- a/internal/vercheck/project_test.go +++ b/internal/vercheck/project_test.go @@ -6,6 +6,7 @@ package vercheck import ( "bytes" "os" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -28,6 +29,8 @@ func setupProjectVersionTest(t *testing.T, version string) { isDevBuild = false t.Setenv(envir.DevboxVersionPolicy, "") + t.Setenv(envir.DevboxUseVersion, "") + t.Setenv(autoVersionEnvName, "") t.Setenv(envir.DevboxLatestVersion, "") t.Setenv(warnedEnvName, "") t.Cleanup(func() { os.Unsetenv(warnedEnvName) }) @@ -165,3 +168,128 @@ func TestCheckProjectVersionEnvOverride(t *testing.T) { assert.ErrorContains(t, err, envir.DevboxVersionPolicy) }) } + +// mockExec replaces execFunc and records the call instead of replacing the +// process. +type mockExec struct { + called bool + argv0 string + argv []string + envv []string +} + +func (m *mockExec) install(t *testing.T) { + t.Helper() + old := execFunc + t.Cleanup(func() { execFunc = old }) + execFunc = func(argv0 string, argv, envv []string) error { + m.called, m.argv0, m.argv, m.envv = true, argv0, argv, envv + return nil + } +} + +func (m *mockExec) env(name string) []string { + var values []string + for _, kv := range m.envv { + if k, v, _ := strings.Cut(kv, "="); k == name { + values = append(values, v) + } + } + return values +} + +func TestCheckProjectVersionAuto(t *testing.T) { + required := &configfile.DevboxVersion{Version: "v0.18.4", OnMismatch: configfile.VersionPolicyAuto} + + t.Run("switches_with_launcher", func(t *testing.T) { + setupProjectVersionTest(t, "0.17.2") + t.Setenv(envir.LauncherPath, "/usr/local/bin/devbox") + t.Setenv(envir.DevboxUseVersion, "") + t.Setenv(autoVersionEnvName, "") + exec := &mockExec{} + exec.install(t) + + buf := new(bytes.Buffer) + require.NoError(t, CheckProjectVersion(buf, testConfigPath, required)) + require.True(t, exec.called) + assert.Equal(t, "/usr/local/bin/devbox", exec.argv0) + assert.Equal(t, append([]string{"/usr/local/bin/devbox"}, os.Args[1:]...), exec.argv) + // The existing empty values are replaced, not duplicated. + assert.Equal(t, []string{"0.18.4"}, exec.env(envir.DevboxUseVersion)) + assert.Equal(t, []string{"0.18.4"}, exec.env(autoVersionEnvName)) + assert.Empty(t, buf.String()) + }) + + t.Run("switches_again_for_a_different_project", func(t *testing.T) { + // Inside a shell that auto switched to 0.17.2 for another project. + setupProjectVersionTest(t, "0.17.2") + t.Setenv(envir.LauncherPath, "/usr/local/bin/devbox") + t.Setenv(envir.DevboxUseVersion, "0.17.2") + t.Setenv(autoVersionEnvName, "0.17.2") + exec := &mockExec{} + exec.install(t) + + require.NoError(t, CheckProjectVersion(new(bytes.Buffer), testConfigPath, required)) + require.True(t, exec.called) + assert.Equal(t, []string{"0.18.4"}, exec.env(envir.DevboxUseVersion)) + }) + + t.Run("no_launcher", func(t *testing.T) { + setupProjectVersionTest(t, "0.17.2") + t.Setenv(envir.LauncherPath, "") + exec := &mockExec{} + exec.install(t) + + err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, required) + assert.ErrorContains(t, err, "wasn't started by the devbox launcher") + assert.False(t, exec.called) + }) + + t.Run("launcher_did_not_switch", func(t *testing.T) { + setupProjectVersionTest(t, "0.17.2") + t.Setenv(envir.LauncherPath, "/usr/local/bin/devbox") + t.Setenv(envir.DevboxUseVersion, "0.18.4") + t.Setenv(autoVersionEnvName, "0.18.4") + exec := &mockExec{} + exec.install(t) + + err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, required) + assert.ErrorContains(t, err, "launcher ran version 0.17.2 instead") + assert.False(t, exec.called) + }) + + t.Run("user_set_version_wins", func(t *testing.T) { + setupProjectVersionTest(t, "0.17.2") + t.Setenv(envir.LauncherPath, "/usr/local/bin/devbox") + t.Setenv(envir.DevboxUseVersion, "0.17.2") + t.Setenv(autoVersionEnvName, "") + exec := &mockExec{} + exec.install(t) + + buf := new(bytes.Buffer) + require.NoError(t, CheckProjectVersion(buf, testConfigPath, required)) + assert.False(t, exec.called) + assert.Contains(t, buf.String(), "DEVBOX_USE_VERSION=0.17.2 is set") + }) + + t.Run("env_override_with_range", func(t *testing.T) { + setupProjectVersionTest(t, "0.17.2") + t.Setenv(envir.DevboxVersionPolicy, "auto") + t.Setenv(envir.LauncherPath, "/usr/local/bin/devbox") + exec := &mockExec{} + exec.install(t) + + err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, &configfile.DevboxVersion{Version: "^0.18.0"}) + assert.ErrorContains(t, err, "not an exact version") + assert.False(t, exec.called) + }) + + t.Run("satisfied", func(t *testing.T) { + setupProjectVersionTest(t, "0.18.4") + exec := &mockExec{} + exec.install(t) + + require.NoError(t, CheckProjectVersion(new(bytes.Buffer), testConfigPath, required)) + assert.False(t, exec.called) + }) +} diff --git a/testscripts/version/devbox_version_auto.test.txt b/testscripts/version/devbox_version_auto.test.txt new file mode 100644 index 00000000000..c2f3bb91bbe --- /dev/null +++ b/testscripts/version/devbox_version_auto.test.txt @@ -0,0 +1,52 @@ +# Verify that the "auto" devbox_version policy re-runs the command through the +# devbox launcher with DEVBOX_USE_VERSION set to the required version. +# +# Development builds skip the check, so DEVBOX_PROD forces a production build. +# Its version is still 0.0.0-dev, which doesn't match 0.1.0. A fake launcher +# script stands in for the real one and prints what it was asked to run. + +env DEVBOX_PROD=1 +chmod 755 launcher.sh +cp auto.json devbox.json + +# With a launcher, devbox re-runs the same command with the required version. +env LAUNCHER_PATH=$WORK/launcher.sh +exec devbox generate readme +stdout 'launcher DEVBOX_USE_VERSION=0.1.0 args=generate readme' +! exists README.md + +# Without a launcher, devbox can't switch and fails. +env LAUNCHER_PATH= +! exec devbox generate readme +stderr 'This project requires devbox 0.1.0' +stderr 'wasn''t started by the devbox launcher' + +# A DEVBOX_USE_VERSION set by the user wins over the auto policy. +env LAUNCHER_PATH=$WORK/launcher.sh +env DEVBOX_USE_VERSION=0.0.0-dev +exec devbox generate readme +stderr 'Not switching versions automatically because DEVBOX_USE_VERSION=0.0.0-dev is set' +! stdout 'launcher' +exists README.md +env DEVBOX_USE_VERSION= + +# on_mismatch "auto" requires an exact version. +cp range.json devbox.json +! exec devbox generate readme +stderr 'requires an exact version' + +-- launcher.sh -- +#!/bin/sh +echo "launcher DEVBOX_USE_VERSION=$DEVBOX_USE_VERSION args=$*" + +-- auto.json -- +{ + "devbox_version": {"version": "0.1.0", "on_mismatch": "auto"}, + "packages": [] +} + +-- range.json -- +{ + "devbox_version": {"version": "^0.1.0", "on_mismatch": "auto"}, + "packages": [] +}