Skip to content
Merged
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
25 changes: 25 additions & 0 deletions .schema/devbox.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion examples/stacks/lapp-stack/devbox.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion examples/stacks/lepp-stack/devbox.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions internal/boxcli/midcobra/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}, ""
Expand Down
65 changes: 39 additions & 26 deletions internal/boxcli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ package boxcli
import (
"fmt"
"log/slog"
"os"
"slices"
"sort"
"strings"
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions internal/devbox/devbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions internal/devbox/devopt/devboxopts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
136 changes: 136 additions & 0 deletions internal/devconfig/configfile/devbox_version.go
Original file line number Diff line number Diff line change
@@ -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,
)
}
Loading
Loading