diff --git a/.schema/devbox.schema.json b/.schema/devbox.schema.json index f540406976e..eafd6a9f76e 100644 --- a/.schema/devbox.schema.json +++ b/.schema/devbox.schema.json @@ -129,6 +129,16 @@ }, "additionalProperties": false }, + "aliases": { + "description": "Shell aliases set up when entering the devbox shell, after the init_hook runs. Works in bash, zsh, and fish.", + "type": "object", + "patternProperties": { + ".*": { + "description": "The command the alias expands to.", + "type": "string" + } + } + }, "include": { "description": "List of additional plugins to activate within your devbox shell", "type": "array", diff --git a/examples/plugins/local/devbox.json b/examples/plugins/local/devbox.json index b537e30e930..b31437339c1 100644 --- a/examples/plugins/local/devbox.json +++ b/examples/plugins/local/devbox.json @@ -10,6 +10,9 @@ ] } }, + "aliases": { + "greet": "echo greeting-from-parent" + }, "include": [ "path:my-plugin/plugin.json" ] diff --git a/examples/plugins/local/my-plugin/plugin.json b/examples/plugins/local/my-plugin/plugin.json index 1a12917fc38..f1cb0f9e3ee 100644 --- a/examples/plugins/local/my-plugin/plugin.json +++ b/examples/plugins/local/my-plugin/plugin.json @@ -20,5 +20,11 @@ this is a comment inside the create files "echo \"ran local plugin init hook\"", "export MY_INIT_HOOK_VAR=BAR" ] + }, + // Aliases defined by the plugin. "greet" is overridden by the parent + // devbox.json, while "plugin_only" is provided solely by this plugin. + "aliases": { + "greet": "echo greeting-from-plugin", + "plugin_only": "echo from-plugin" } } diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go index 43ce1f2935c..8ac001233d3 100644 --- a/internal/devbox/devbox.go +++ b/internal/devbox/devbox.go @@ -316,6 +316,15 @@ func (d *Devbox) RunScript(ctx context.Context, envOpts devopt.EnvOptions, cmdNa // which we don't want. So, one solution is to write the entire command and its arguments into the // file itself, but that may not be great if the variables contain sensitive information. Instead, // we save the entire command (with args) into the DEVBOX_RUN_CMD var, and then the script evals it. + // + // If cmdName is an alias defined in devbox.json, expand it to its command + // first, mirroring how a shell expands an alias that appears in command + // position. This lets `devbox run ` work without an interactive + // shell (i.e. even when the init hook hasn't defined the alias). + runCmd := cmdName + if alias, ok := d.cfg.Aliases()[cmdName]; ok { + runCmd = alias + } scriptBody, err := shellgen.ScriptBody(d, "eval $DEVBOX_RUN_CMD\n") if err != nil { return err @@ -326,7 +335,7 @@ func (d *Devbox) RunScript(ctx context.Context, envOpts devopt.EnvOptions, cmdNa } script := shellgen.ScriptPath(d.ProjectDir(), arbitraryCmdFilename) cmdWithArgs = []string{strconv.Quote(script)} - env["DEVBOX_RUN_CMD"] = strings.Join(append([]string{cmdName}, cmdArgs...), " ") + env["DEVBOX_RUN_CMD"] = strings.Join(append([]string{runCmd}, cmdArgs...), " ") } return nix.RunScript(d.projectDir, strings.Join(cmdWithArgs, " "), env) diff --git a/internal/devbox/shell.go b/internal/devbox/shell.go index 6de9327b8ea..e17e5df7dbf 100644 --- a/internal/devbox/shell.go +++ b/internal/devbox/shell.go @@ -12,6 +12,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "text/template" "time" @@ -325,6 +326,8 @@ func (s *DevboxShell) writeDevboxShellrc() (path string, err error) { ExportEnv string ShellName string + ShellAliases []string + RefreshAliasName string RefreshCmd string RefreshAliasEnvVar string @@ -337,6 +340,7 @@ func (s *DevboxShell) writeDevboxShellrc() (path string, err error) { HistoryFile: strings.TrimSpace(s.historyFile), ExportEnv: exportify(s.env), ShellName: string(s.name), + ShellAliases: s.aliasLines(), RefreshAliasName: s.devbox.refreshAliasName(), RefreshCmd: s.devbox.refreshCmd(), RefreshAliasEnvVar: s.devbox.refreshAliasEnvVar(), @@ -349,6 +353,48 @@ func (s *DevboxShell) writeDevboxShellrc() (path string, err error) { return path, nil } +// aliasLines returns the shell `alias` commands for the aliases defined in +// devbox.json, sorted by alias name for deterministic output. The returned +// lines use the current shell's builtin `alias` command (which is compatible +// across bash, zsh, and fish) and are injected into the shellrc after the init +// hook is sourced. It returns nil when there are no aliases or no config. +func (s *DevboxShell) aliasLines() []string { + if s.devbox == nil || s.devbox.Config() == nil { + return nil + } + aliases := s.devbox.Config().Aliases() + if len(aliases) == 0 { + return nil + } + + names := make([]string, 0, len(aliases)) + for name := range aliases { + names = append(names, name) + } + sort.Strings(names) + + lines := make([]string, 0, len(names)) + for _, name := range names { + lines = append(lines, fmt.Sprintf("alias %s=%s", name, s.quoteAliasValue(aliases[name]))) + } + return lines +} + +// quoteAliasValue single-quotes an alias value so it is passed verbatim to the +// shell's `alias` builtin, escaping any embedded quotes for the current shell. +func (s *DevboxShell) quoteAliasValue(value string) string { + if s.name == shFish { + // Inside fish single quotes, only \\ and \' are escape sequences. + value = strings.ReplaceAll(value, `\`, `\\`) + value = strings.ReplaceAll(value, `'`, `\'`) + return "'" + value + "'" + } + // POSIX shells (bash/zsh/ksh): close the quote, emit an escaped quote, + // then reopen, i.e. ' -> '\''. + value = strings.ReplaceAll(value, `'`, `'\''`) + return "'" + value + "'" +} + // setupShellStartupFiles creates initialization files for the shell by sourcing the user's originals. // We do this instead of linking or copying, so that we can set correct ZDOTDIR when sourcing // user's config files which may use the ZDOTDIR env var inside them. diff --git a/internal/devbox/shell_test.go b/internal/devbox/shell_test.go index ed466a67c4f..952286bfacb 100644 --- a/internal/devbox/shell_test.go +++ b/internal/devbox/shell_test.go @@ -15,6 +15,7 @@ import ( "github.com/google/go-cmp/cmp" "go.jetify.com/devbox/internal/devbox/devopt" + "go.jetify.com/devbox/internal/devconfig" "go.jetify.com/devbox/internal/envir" "go.jetify.com/devbox/internal/shellgen" "go.jetify.com/devbox/internal/xdg" @@ -113,6 +114,86 @@ If the new shellrc is correct, you can update the golden file with: } } +func TestWriteDevboxShellrcAliases(t *testing.T) { + cfgJSON := `{ + "shell": { + "init_hook": "echo hi" + }, + "aliases": { + "ll": "ls -la", + "gs": "git status", + "say": "echo it's here" + } +}` + dir := t.TempDir() + if err := os.WriteFile( + filepath.Join(dir, "devbox.json"), []byte(cfgJSON), 0o644, + ); err != nil { + t.Fatal(err) + } + cfg, err := devconfig.Open(dir) + if err != nil { + t.Fatalf("Open config error: %v", err) + } + + tests := []struct { + shell name + want []string + }{ + { + shell: shBash, + want: []string{ + `alias gs='git status'`, + `alias ll='ls -la'`, + `alias say='echo it'\''s here'`, + }, + }, + { + shell: shFish, + want: []string{ + `alias gs='git status'`, + `alias ll='ls -la'`, + `alias say='echo it\'s here'`, + }, + }, + } + + for _, test := range tests { + t.Run(string(test.shell), func(t *testing.T) { + s := &DevboxShell{ + devbox: &Devbox{projectDir: dir, cfg: cfg}, + projectDir: dir, + name: test.shell, + } + path, err := s.writeDevboxShellrc() + if err != nil { + t.Fatalf("writeDevboxShellrc error: %v", err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + got := string(b) + + // Aliases must appear after the init hook is sourced. + hookIdx := strings.Index(got, ".hooks") + if hookIdx < 0 { + t.Fatalf("hooks not sourced in shellrc:\n%s", got) + } + for _, line := range test.want { + idx := strings.Index(got, line) + if idx < 0 { + t.Errorf("expected alias line %q in shellrc:\n%s", line, got) + continue + } + if idx < hookIdx { + t.Errorf("alias %q injected before init hook was sourced", line) + } + } + }) + } +} + func TestShellPath(t *testing.T) { tests := []struct { name string diff --git a/internal/devbox/shellrc.tmpl b/internal/devbox/shellrc.tmpl index 6cf050bd12c..037012a22c1 100644 --- a/internal/devbox/shellrc.tmpl +++ b/internal/devbox/shellrc.tmpl @@ -69,6 +69,14 @@ cd "{{ .ProjectDir }}" || exit cd "$working_dir" || exit +{{- if .ShellAliases }} + +# Set aliases defined in devbox.json. These run after the init hook so they can +# rely on anything the hook sets up. +{{ range .ShellAliases }}{{ . }} +{{ end -}} +{{- end }} + {{- if .ShellStartTime }} # log that the shell is interactive now! devbox log shell-interactive {{ .ShellStartTime }} diff --git a/internal/devbox/shellrc_fish.tmpl b/internal/devbox/shellrc_fish.tmpl index 26ecfb7eaf2..ea8d60dbcdf 100644 --- a/internal/devbox/shellrc_fish.tmpl +++ b/internal/devbox/shellrc_fish.tmpl @@ -63,6 +63,14 @@ source "{{ .HooksFilePath }}" cd "$workingDir" || exit +{{- if .ShellAliases }} + +# Set aliases defined in devbox.json. These run after the init hook so they can +# rely on anything the hook sets up. +{{ range .ShellAliases }}{{ . }} +{{ end -}} +{{- end }} + {{- if .ShellStartTime }} # log that the shell is interactive now! devbox log shell-interactive {{ .ShellStartTime }} diff --git a/internal/devconfig/config.go b/internal/devconfig/config.go index 445585dd04a..17fffc46cf6 100644 --- a/internal/devconfig/config.go +++ b/internal/devconfig/config.go @@ -368,6 +368,18 @@ func (c *Config) InitHook() *shellcmd.Commands { return &commands } +// Aliases returns the merged shell aliases from this config and any included +// configs (plugins). Aliases defined in the root config take precedence over +// those from included configs. +func (c *Config) Aliases() map[string]string { + aliases := map[string]string{} + for _, i := range c.included { + maps.Copy(aliases, i.Aliases()) + } + maps.Copy(aliases, c.Root.Aliases) + return aliases +} + func (c *Config) Scripts() configfile.Scripts { scripts := configfile.Scripts{} for _, i := range c.included { diff --git a/internal/devconfig/config_test.go b/internal/devconfig/config_test.go index 94d82cf7add..9f747796156 100644 --- a/internal/devconfig/config_test.go +++ b/internal/devconfig/config_test.go @@ -297,6 +297,106 @@ func mkNestedDirs(t *testing.T) (root, child, nested string) { return root, child, nested } +func TestAliases(t *testing.T) { + dir := t.TempDir() + cfgJSON := `{ + "shell": { + "init_hook": "echo hi" + }, + "aliases": { + "ll": "ls -la", + "gs": "git status" + } +}` + if err := os.WriteFile(filepath.Join(dir, configfile.DefaultName), []byte(cfgJSON), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := Open(dir) + if err != nil { + t.Fatalf("Open error: %v", err) + } + got := cfg.Aliases() + want := map[string]string{"ll": "ls -la", "gs": "git status"} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("Aliases() mismatch (-want +got):\n%s", diff) + } +} + +func TestAliasesEmpty(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile( + filepath.Join(dir, configfile.DefaultName), + []byte(`{"shell": {"init_hook": "echo hi"}}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + cfg, err := Open(dir) + if err != nil { + t.Fatalf("Open error: %v", err) + } + if got := cfg.Aliases(); len(got) != 0 { + t.Errorf("Aliases() = %v, want empty", got) + } +} + +func TestAliasesInvalid(t *testing.T) { + tests := map[string]string{ + "empty name": `{"aliases": {"": "ls -la"}}`, + "whitespace name": `{"aliases": {"bad name": "ls -la"}}`, + "empty command": `{"aliases": {"ll": ""}}`, + "whitespace command": `{"aliases": {"ll": " "}}`, + } + for name, cfgJSON := range tests { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile( + filepath.Join(dir, configfile.DefaultName), []byte(cfgJSON), 0o644, + ); err != nil { + t.Fatal(err) + } + if _, err := Open(dir); err == nil { + t.Errorf("Open(%q) succeeded, want validation error", cfgJSON) + } + }) + } +} + +// TestExampleLocalPluginAliases loads the examples/plugins/local project and +// verifies that aliases defined in the plugin are merged into the parent +// config, and that an alias defined in the parent overrides the plugin's. +func TestExampleLocalPluginAliases(t *testing.T) { + exampleDir, err := filepath.Abs( + filepath.Join("..", "..", "examples", "plugins", "local"), + ) + if err != nil { + t.Fatal(err) + } + + cfg, err := Open(exampleDir) + if err != nil { + t.Fatalf("Open(%q) error: %v", exampleDir, err) + } + + lockfile, err := lock.GetFile(&testLockProject{dir: exampleDir}) + if err != nil { + t.Fatalf("lock.GetFile error: %v", err) + } + if err := cfg.LoadRecursive(lockfile); err != nil { + t.Fatalf("LoadRecursive error: %v", err) + } + + want := map[string]string{ + // Parent devbox.json overrides the plugin's "greet" alias. + "greet": "echo greeting-from-parent", + // "plugin_only" is contributed solely by the plugin. + "plugin_only": "echo from-plugin", + } + if diff := cmp.Diff(want, cfg.Aliases()); diff != "" { + t.Errorf("Aliases() mismatch (-want +got):\n%s", diff) + } +} + func TestDefault(t *testing.T) { path := filepath.Join(t.TempDir()) cfg := DefaultConfig() diff --git a/internal/devconfig/configfile/file.go b/internal/devconfig/configfile/file.go index de88ed952a5..490eee0ccc4 100644 --- a/internal/devconfig/configfile/file.go +++ b/internal/devconfig/configfile/file.go @@ -44,6 +44,13 @@ type ConfigFile struct { // Shell configures the devbox shell environment. Shell *shellConfig `json:"shell,omitempty"` + + // Aliases contains shell aliases that are set up when entering the devbox + // shell. They are injected after the init hook is sourced, so they can rely + // on anything the init hook sets up. The map key is the alias name and the + // value is the command it expands to. Aliases use the current shell's + // builtin `alias` command and work in bash, zsh, and fish. + Aliases map[string]string `json:"aliases,omitempty"` // Nixpkgs specifies the repository to pull packages from // Deprecated: Versioned packages don't need this Nixpkgs *NixpkgsConfig `json:"nixpkgs,omitempty"` @@ -157,6 +164,7 @@ func validateConfig(cfg *ConfigFile) error { fns := []func(cfg *ConfigFile) error{ ValidateNixpkg, validateScripts, + validateAliases, } for _, fn := range fns { @@ -187,6 +195,23 @@ func validateScripts(cfg *ConfigFile) error { return nil } +func validateAliases(cfg *ConfigFile) error { + for name, command := range cfg.Aliases { + if strings.TrimSpace(name) == "" { + return errors.New("cannot have alias with empty name in devbox.json") + } + if whitespace.MatchString(name) { + return errors.Errorf( + "cannot have alias name with whitespace in devbox.json: %s", name) + } + if strings.TrimSpace(command) == "" { + return errors.Errorf( + "cannot have an empty alias command in devbox.json: %s", name) + } + } + return nil +} + func ValidateNixpkg(cfg *ConfigFile) error { hash := cfg.NixPkgsCommitHash() if hash == "" { diff --git a/testscripts/run/script.test.txt b/testscripts/run/script.test.txt index 74e26846f18..f1042a83a57 100644 --- a/testscripts/run/script.test.txt +++ b/testscripts/run/script.test.txt @@ -19,6 +19,14 @@ stdout 'with script' exec devbox run -- hello -g directly stdout 'directly' +# An alias defined in devbox.json should be runnable via `devbox run` +exec devbox run greet +stdout 'hello from alias' + +# Alias arguments should be passed through to the expanded command +exec devbox run echo_args one two +stdout 'args: one two' + # TBD: Bad init hook should result in non-zero exit code #exec devbox --config bad_init run test #! stdout 'test' @@ -41,6 +49,10 @@ stdout 'directly' "hook_runs": "echo $HOOK", "hello_with_script": "hello -g \"with script\"" } + }, + "aliases": { + "greet": "echo \"hello from alias\"", + "echo_args": "echo args:" } }