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/examples/stacks/lapp-stack/devbox.json b/examples/stacks/lapp-stack/devbox.json index edca4b8d747..201aabfd0c1 100644 --- a/examples/stacks/lapp-stack/devbox.json +++ b/examples/stacks/lapp-stack/devbox.json @@ -22,7 +22,7 @@ "mkdir -p /tmp/devbox/lapp", "initdb", "devbox services up -b", - "echo 'sleep 5 second for the postgres server to initialize.' && sleep 5", + "echo 'Waiting for the postgres server to accept connections...' && for i in $(seq 1 30); do pg_isready -q && break; sleep 1; done && pg_isready", "cat .devbox/compose.log", "dropdb --if-exists devbox_lapp", "createdb devbox_lapp", diff --git a/examples/stacks/lepp-stack/devbox.json b/examples/stacks/lepp-stack/devbox.json index f98cb0a5e70..bd0d74d1b05 100644 --- a/examples/stacks/lepp-stack/devbox.json +++ b/examples/stacks/lepp-stack/devbox.json @@ -25,7 +25,7 @@ "rm -rf .devbox/virtenv/postgresql/data", "initdb", "devbox services up -b", - "echo 'sleep 2 seconds for the postgres server to initialize.' && sleep 2", + "echo 'Waiting for the postgres server to accept connections...' && for i in $(seq 1 30); do pg_isready -q && break; sleep 1; done && pg_isready", "dropdb --if-exists devbox_lepp", "createdb devbox_lepp", "psql devbox_lepp < setup_postgres_db.sql", 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/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 50d49424123..e5bc7472293 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,16 @@ 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 !opts.SkipVersionCheck { + 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/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/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..3a559655e1f --- /dev/null +++ b/internal/vercheck/project.go @@ -0,0 +1,100 @@ +// 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, constraint) + 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, constraint *semver.Constraints) 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 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 new file mode 100644 index 00000000000..0965c462ae2 --- /dev/null +++ b/internal/vercheck/project_test.go @@ -0,0 +1,167 @@ +// 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(envir.DevboxLatestVersion, "") + 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") + t.Setenv(envir.DevboxLatestVersion, "0.18.4") + 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 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") + + 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..2df1123e0b3 --- /dev/null +++ b/testscripts/version/devbox_version.test.txt @@ -0,0 +1,75 @@ +# 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 + +# 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 +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 +! 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": [] +}