diff --git a/.schema/devbox.schema.json b/.schema/devbox.schema.json index f540406976e..3be7eb5aa37 100644 --- a/.schema/devbox.schema.json +++ b/.schema/devbox.schema.json @@ -95,8 +95,36 @@ } } }, + "init_hook": { + "description": "List of shell commands/scripts to run right after devbox shell starts.", + "type": [ + "array", + "string" + ], + "items": { + "type": "string" + } + }, + "scripts": { + "description": "List of command/script definitions to run with `devbox run `.", + "type": "object", + "patternProperties": { + ".*": { + "description": "Alias name for the script.", + "type": [ + "array", + "string" + ], + "items": { + "type": "string", + "description": "The script's shell commands." + } + } + } + }, "shell": { - "description": "Definitions of scripts and actions to take when in devbox shell.", + "deprecated": true, + "description": "Deprecated: use the top-level init_hook and scripts fields instead. Run `devbox config fmt` to migrate.", "type": "object", "properties": { "init_hook": { diff --git a/internal/boxcli/config_fmt.go b/internal/boxcli/config_fmt.go new file mode 100644 index 00000000000..d21bd1500bb --- /dev/null +++ b/internal/boxcli/config_fmt.go @@ -0,0 +1,70 @@ +// Copyright 2024 Jetify Inc. and contributors. All rights reserved. +// Use of this source code is governed by the license in the LICENSE file. + +package boxcli + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + "go.jetify.com/devbox/internal/devconfig" +) + +func configCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage your devbox.json config file", + } + cmd.AddCommand(configFmtCmd()) + return cmd +} + +type configFmtFlags struct { + pathFlag +} + +func configFmtCmd() *cobra.Command { + flags := &configFmtFlags{} + cmd := &cobra.Command{ + Use: "fmt", + Short: "Format and modernize devbox.json", + Long: "Format and modernize devbox.json. This rewrites the config using a " + + "canonical layout and migrates deprecated fields (such as the nested " + + `"shell" object) to their modern, top-level equivalents.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return configFmtFunc(cmd, flags) + }, + } + flags.pathFlag.register(cmd) + return cmd +} + +func configFmtFunc(cmd *cobra.Command, flags *configFmtFlags) error { + path := flags.path + if path == "" { + path = "." + } + + // Open the config directly (rather than through devbox.Open) so that + // formatting doesn't trigger unrelated environment setup or warnings. + cfg, err := devconfig.Open(path) + if err != nil { + return err + } + + // Modernize: migrate the deprecated nested "shell" object to top-level + // init_hook and scripts fields. + cfg.Root.MigrateShell() + + // Save writes the (re)formatted config back to disk. + dir := filepath.Dir(cfg.Root.AbsRootPath) + if err := cfg.Root.SaveTo(dir); err != nil { + return err + } + + fmt.Fprintf(cmd.ErrOrStderr(), "Formatted %s\n", cfg.Root.AbsRootPath) + return nil +} diff --git a/internal/boxcli/root.go b/internal/boxcli/root.go index 86f2011f160..bd14b5795b1 100644 --- a/internal/boxcli/root.go +++ b/internal/boxcli/root.go @@ -59,6 +59,7 @@ func RootCmd() *cobra.Command { command.AddCommand(authCmd()) } command.AddCommand(cacheCmd()) + command.AddCommand(configCmd()) command.AddCommand(createCmd()) command.AddCommand(secretsCmd()) command.AddCommand(generateCmd()) diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go index 43ce1f2935c..82bdb6612f5 100644 --- a/internal/devbox/devbox.go +++ b/internal/devbox/devbox.go @@ -126,6 +126,19 @@ func Open(opts *devopt.Opts) (*Devbox, error) { customProcessComposeFile: opts.CustomProcessComposeFile, } + if !opts.IgnoreWarnings && cfg.Root.UsesDeprecatedShellField() { + stderr := box.stderr + if stderr == nil { + stderr = os.Stderr + } + ux.Fwarning( + stderr, + `The "shell" field in devbox.json is deprecated and will be removed in `+ + "an upcoming version. Move init_hook and scripts to the top level, "+ + "or run `devbox config fmt` to migrate automatically.\n", + ) + } + lock, err := lock.GetFile(box) if err != nil { return nil, err diff --git a/internal/devconfig/config.go b/internal/devconfig/config.go index 445585dd04a..8f6bf474b35 100644 --- a/internal/devconfig/config.go +++ b/internal/devconfig/config.go @@ -50,15 +50,13 @@ func DefaultConfig() *Config { cfg, err := loadBytes([]byte(fmt.Sprintf(`{ "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/%s/.schema/devbox.schema.json", "packages": [], - "shell": { - "init_hook": [ - "%s" - ], - "scripts": { - "test": [ - "echo \"Error: no test specified\" && exit 1" - ] - } + "init_hook": [ + "%s" + ], + "scripts": { + "test": [ + "echo \"Error: no test specified\" && exit 1" + ] } } `, diff --git a/internal/devconfig/configfile/ast.go b/internal/devconfig/configfile/ast.go index bd92c6fac47..a3acfb035d8 100644 --- a/internal/devconfig/configfile/ast.go +++ b/internal/devconfig/configfile/ast.go @@ -465,3 +465,45 @@ func (c *configAST) setEnv(env map[string]string) { } c.root.Format() } + +// migrateShellToTopLevel moves the deprecated "shell.init_hook" and +// "shell.scripts" members up to the root object and removes the "shell" object. +// Comments and formatting are preserved. Members that already exist at the top +// level are not overwritten. +func (c *configAST) migrateShellToTopLevel() { + rootObj := c.root.Value.(*hujson.Object) + shellIdx := c.memberIndex(rootObj, "shell") + if shellIdx == -1 { + return + } + + shellObj, ok := rootObj.Members[shellIdx].Value.Value.(*hujson.Object) + if !ok { + // "shell" isn't an object (e.g. null); just remove it. + rootObj.Members = slices.Delete(rootObj.Members, shellIdx, shellIdx+1) + c.root.Format() + return + } + + for _, key := range []string{"init_hook", "scripts"} { + srcIdx := c.memberIndex(shellObj, key) + if srcIdx == -1 { + continue + } + // Don't clobber a field that already exists at the top level. + if c.memberIndex(rootObj, key) != -1 { + continue + } + member := shellObj.Members[srcIdx] + // Ensure the migrated field starts on its own line. + if !slices.Contains(member.Name.BeforeExtra, '\n') { + member.Name.BeforeExtra = append([]byte{'\n'}, member.Name.BeforeExtra...) + } + rootObj.Members = append(rootObj.Members, member) + } + + // Remove the now-migrated "shell" object. + shellIdx = c.memberIndex(rootObj, "shell") + rootObj.Members = slices.Delete(rootObj.Members, shellIdx, shellIdx+1) + c.root.Format() +} diff --git a/internal/devconfig/configfile/file.go b/internal/devconfig/configfile/file.go index de88ed952a5..2d92a6c2011 100644 --- a/internal/devconfig/configfile/file.go +++ b/internal/devconfig/configfile/file.go @@ -42,7 +42,19 @@ type ConfigFile struct { // Only allows "envsec" for now EnvFrom string `json:"env_from,omitempty"` + // InitHookField contains commands that will run at shell startup. It is the + // modern, top-level replacement for the deprecated shell.init_hook field. + InitHookField *shellcmd.Commands `json:"init_hook,omitempty"` + + // ScriptsField is a set of named scripts. It is the modern, top-level + // replacement for the deprecated shell.scripts field. + ScriptsField map[string]*shellcmd.Commands `json:"scripts,omitempty"` + // Shell configures the devbox shell environment. + // + // Deprecated: init_hook and scripts are now top-level fields. The nested + // "shell" object is still accepted for backward compatibility but will be + // removed in an upcoming version. Run `devbox config fmt` to migrate. Shell *shellConfig `json:"shell,omitempty"` // Nixpkgs specifies the repository to pull packages from // Deprecated: Versioned packages don't need this @@ -101,10 +113,48 @@ func (c *ConfigFile) NixPkgsCommitHash() string { } func (c *ConfigFile) InitHook() *shellcmd.Commands { - if c == nil || c.Shell == nil || c.Shell.InitHook == nil { + if c == nil { return &shellcmd.Commands{} } - return c.Shell.InitHook + if c.InitHookField != nil { + return c.InitHookField + } + // Fall back to the deprecated shell.init_hook for backward compatibility. + if c.Shell != nil && c.Shell.InitHook != nil { + return c.Shell.InitHook + } + return &shellcmd.Commands{} +} + +// UsesDeprecatedShellField reports whether the config nests init_hook and/or +// scripts inside the deprecated top-level "shell" object. +func (c *ConfigFile) UsesDeprecatedShellField() bool { + return c != nil && c.Shell != nil +} + +// MigrateShell moves the deprecated shell.init_hook and shell.scripts fields up +// to the top level and removes the "shell" object. Fields that already exist at +// the top level take precedence and are not overwritten. It updates both the +// parsed struct and the underlying AST so the change is preserved on save. +func (c *ConfigFile) MigrateShell() { + if c == nil || c.Shell == nil { + return + } + if c.Shell.InitHook != nil && c.InitHookField == nil { + c.InitHookField = c.Shell.InitHook + } + for name, cmds := range c.Shell.Scripts { + if c.ScriptsField == nil { + c.ScriptsField = map[string]*shellcmd.Commands{} + } + if _, ok := c.ScriptsField[name]; !ok { + c.ScriptsField[name] = cmds + } + } + c.Shell = nil + if c.ast != nil { + c.ast.migrateShellToTopLevel() + } } // SaveTo writes the config to a file. diff --git a/internal/devconfig/configfile/scripts.go b/internal/devconfig/configfile/scripts.go index 96c0e4b932b..7a4486e5853 100644 --- a/internal/devconfig/configfile/scripts.go +++ b/internal/devconfig/configfile/scripts.go @@ -14,14 +14,30 @@ type script struct { type Scripts map[string]*script func (c *ConfigFile) Scripts() Scripts { - if c == nil || c.Shell == nil { + if c == nil { return nil } result := make(Scripts) - for name, commands := range c.Shell.Scripts { + + // Read legacy shell.scripts first so that top-level scripts with the same + // name take precedence. + if c.Shell != nil { + for name, commands := range c.Shell.Scripts { + comments := "" + if c.ast != nil { + comments = string(c.ast.beforeComment("shell", "scripts", name)) + } + result[name] = &script{ + Commands: *commands, + Comments: comments, + } + } + } + + for name, commands := range c.ScriptsField { comments := "" if c.ast != nil { - comments = string(c.ast.beforeComment("shell", "scripts", name)) + comments = string(c.ast.beforeComment("scripts", name)) } result[name] = &script{ Commands: *commands, @@ -29,6 +45,9 @@ func (c *ConfigFile) Scripts() Scripts { } } + if len(result) == 0 { + return nil + } return result } diff --git a/internal/devconfig/configfile/shell_migration_test.go b/internal/devconfig/configfile/shell_migration_test.go new file mode 100644 index 00000000000..c0f6cbf86d8 --- /dev/null +++ b/internal/devconfig/configfile/shell_migration_test.go @@ -0,0 +1,119 @@ +package configfile + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDeprecatedShellBackwardCompat verifies that the legacy nested "shell" +// object is still readable via the InitHook and Scripts accessors. +func TestDeprecatedShellBackwardCompat(t *testing.T) { + const json = `{ + "shell": { + "init_hook": ["echo hi"], + "scripts": { + "build": "go build ./..." + } + } + }` + + cfg, err := LoadBytes([]byte(json)) + require.NoError(t, err) + + assert.True(t, cfg.UsesDeprecatedShellField()) + assert.Equal(t, []string{"echo hi"}, cfg.InitHook().Cmds) + + scripts := cfg.Scripts() + require.Contains(t, scripts, "build") + assert.Equal(t, []string{"go build ./..."}, scripts["build"].Cmds) +} + +// TestTopLevelShellFields verifies that the modern top-level init_hook and +// scripts fields are read. +func TestTopLevelShellFields(t *testing.T) { + const json = `{ + "init_hook": ["echo hi"], + "scripts": { + "build": "go build ./..." + } + }` + + cfg, err := LoadBytes([]byte(json)) + require.NoError(t, err) + + assert.False(t, cfg.UsesDeprecatedShellField()) + assert.Equal(t, []string{"echo hi"}, cfg.InitHook().Cmds) + + scripts := cfg.Scripts() + require.Contains(t, scripts, "build") + assert.Equal(t, []string{"go build ./..."}, scripts["build"].Cmds) +} + +// TestMigrateShell verifies that MigrateShell moves the nested shell fields to +// the top level, removes the "shell" object, and preserves the data. +func TestMigrateShell(t *testing.T) { + const json = `{ + "packages": ["go@latest"], + "shell": { + "init_hook": ["echo hi"], + "scripts": { + "build": "go build ./..." + } + } + }` + + cfg, err := LoadBytes([]byte(json)) + require.NoError(t, err) + + cfg.MigrateShell() + + // The struct no longer references the deprecated shell object. + assert.False(t, cfg.UsesDeprecatedShellField()) + assert.Equal(t, []string{"echo hi"}, cfg.InitHook().Cmds) + require.Contains(t, cfg.Scripts(), "build") + + // The serialized output should no longer contain a "shell" object, but + // should have top-level init_hook and scripts. + out := string(cfg.Bytes()) + assert.NotContains(t, out, `"shell"`) + assert.Contains(t, out, `"init_hook"`) + assert.Contains(t, out, `"scripts"`) + + // The migrated config should round-trip and still parse. + reparsed, err := LoadBytes(cfg.Bytes()) + require.NoError(t, err) + assert.False(t, reparsed.UsesDeprecatedShellField()) + assert.Equal(t, []string{"echo hi"}, reparsed.InitHook().Cmds) + require.Contains(t, reparsed.Scripts(), "build") +} + +// TestMigrateShellPreservesComments verifies comments survive migration. +func TestMigrateShellPreservesComments(t *testing.T) { + const json = `{ + "shell": { + // build the project + "scripts": { + "build": "go build ./..." + } + } + }` + + cfg, err := LoadBytes([]byte(json)) + require.NoError(t, err) + cfg.MigrateShell() + + out := string(cfg.Bytes()) + assert.True(t, strings.Contains(out, "build the project"), + "expected comment to be preserved, got:\n%s", out) +} + +// TestMigrateShellNoShell is a no-op when there's no shell object. +func TestMigrateShellNoShell(t *testing.T) { + cfg, err := LoadBytes([]byte(`{"packages": []}`)) + require.NoError(t, err) + require.NotPanics(t, func() { cfg.MigrateShell() }) + assert.False(t, cfg.UsesDeprecatedShellField()) +} diff --git a/testscripts/config/fmt.test.txt b/testscripts/config/fmt.test.txt new file mode 100644 index 00000000000..e56d1f79d53 --- /dev/null +++ b/testscripts/config/fmt.test.txt @@ -0,0 +1,36 @@ +# Test that `devbox config fmt` migrates the deprecated nested "shell" object +# to top-level init_hook and scripts fields. + +exec devbox config fmt +stderr 'Formatted' + +# The "shell" object should be gone, replaced by top-level fields. +! grep '"shell"' devbox.json +grep '"init_hook"' devbox.json +grep '"scripts"' devbox.json + +# Running fmt again should be a no-op (idempotent) and still valid. +exec devbox config fmt +! grep '"shell"' devbox.json + +json.superset devbox.json expected.json + +-- devbox.json -- +{ + "packages": ["go@latest"], + "shell": { + "init_hook": ["echo hello"], + "scripts": { + "build": "go build ./..." + } + } +} + +-- expected.json -- +{ + "packages": ["go@latest"], + "init_hook": ["echo hello"], + "scripts": { + "build": "go build ./..." + } +}