Skip to content
Open
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
30 changes: 29 additions & 1 deletion .schema/devbox.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script_name>`.",
"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": {
Expand Down
70 changes: 70 additions & 0 deletions internal/boxcli/config_fmt.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions internal/boxcli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
13 changes: 13 additions & 0 deletions internal/devbox/devbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 7 additions & 9 deletions internal/devconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
`,
Expand Down
42 changes: 42 additions & 0 deletions internal/devconfig/configfile/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +493 to +495
}
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)
Comment on lines +497 to +502
}

// Remove the now-migrated "shell" object.
shellIdx = c.memberIndex(rootObj, "shell")
rootObj.Members = slices.Delete(rootObj.Members, shellIdx, shellIdx+1)
c.root.Format()
}
54 changes: 52 additions & 2 deletions internal/devconfig/configfile/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
25 changes: 22 additions & 3 deletions internal/devconfig/configfile/scripts.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,40 @@ 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,
Comments: comments,
}
}

if len(result) == 0 {
return nil
}
return result
}

Expand Down
Loading
Loading