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
10 changes: 10 additions & 0 deletions .schema/devbox.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Comment thread
mikeland73 marked this conversation as resolved.
"include": {
"description": "List of additional plugins to activate within your devbox shell",
"type": "array",
Expand Down
3 changes: 3 additions & 0 deletions examples/plugins/local/devbox.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
]
}
},
"aliases": {
"greet": "echo greeting-from-parent"
},
"include": [
"path:my-plugin/plugin.json"
]
Expand Down
6 changes: 6 additions & 0 deletions examples/plugins/local/my-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
11 changes: 10 additions & 1 deletion internal/devbox/devbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alias>` 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
Expand All @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions internal/devbox/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"text/template"
"time"
Expand Down Expand Up @@ -325,6 +326,8 @@ func (s *DevboxShell) writeDevboxShellrc() (path string, err error) {
ExportEnv string
ShellName string

ShellAliases []string

RefreshAliasName string
RefreshCmd string
RefreshAliasEnvVar string
Expand All @@ -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(),
Expand All @@ -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])))
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Added a validateAliases check (mirroring validateScripts) that runs at config load time and rejects aliases with empty/whitespace names or empty commands, so they can no longer render invalid alias syntax at shell startup. Fixed in 39c156a, with tests covering each invalid case.


Generated by Claude Code

}
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.
Expand Down
81 changes: 81 additions & 0 deletions internal/devbox/shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions internal/devbox/shellrc.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
8 changes: 8 additions & 0 deletions internal/devbox/shellrc_fish.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
12 changes: 12 additions & 0 deletions internal/devconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
100 changes: 100 additions & 0 deletions internal/devconfig/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading