Skip to content

Commit 6f434d3

Browse files
authored
nix: allow users to set shell startup hooks (#93)
Add a `shell.init_hook` field to devbox.json which can contain arbitrary commands to run at shell startup. { "packages": ["go"], "shell": { "init_hook": "echo hello from a hook!" } } This change also merges the `shell` package into `nix`, making it clearer that it handles Nix shells specifically. The `nix.Shell` function is replaced by the `nix.Shell` struct, which does everything that the `shell.Shell` struct did. The new API looks something like: sh, err := nix.DetectShell() sh.UserInitHook = cfg.Shell.InitHook sh.Run(nixPath) Finally, we now build the devbox shellrc with a template since it was getting a bit complex.
1 parent fc63faf commit 6f434d3

File tree

6 files changed

+259
-284
lines changed

6 files changed

+259
-284
lines changed

config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ type Config struct {
1616
// Packages is the slice of Nix packages that devbox makes available in
1717
// its environment.
1818
Packages []string `cue:"[...string]" json:"packages"`
19+
20+
// Shell configures the devbox shell environment.
21+
Shell struct {
22+
// InitHook contains commands that will run at shell startup.
23+
InitHook string `json:"init_hook,omitempty"`
24+
} `json:"shell,omitempty"`
1925
}
2026

2127
// ReadConfig reads a devbox config file.

devbox.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,13 @@ func (d *Devbox) Shell() error {
131131
return errors.WithStack(err)
132132
}
133133
nixDir := filepath.Join(d.srcDir, ".devbox/gen/shell.nix")
134-
return nix.Shell(nixDir)
134+
sh, err := nix.DetectShell()
135+
if err != nil {
136+
// Fall back to using a plain Nix shell.
137+
sh = &nix.Shell{}
138+
}
139+
sh.UserInitHook = d.cfg.Shell.InitHook
140+
return sh.Run(nixDir)
135141
}
136142

137143
// saveCfg writes the config file to the devbox directory.

nix/nix.go

Lines changed: 0 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -7,94 +7,9 @@ import (
77
"bytes"
88
"encoding/json"
99
"fmt"
10-
"os"
1110
"os/exec"
12-
"strings"
13-
14-
"github.com/pkg/errors"
15-
"go.jetpack.io/devbox/debug"
16-
"go.jetpack.io/devbox/shell"
1711
)
1812

19-
func Shell(path string) error {
20-
// nix-shell only runs bash, which isn't great if the user has a
21-
// different default shell. Here we try to detect what their current
22-
// shell is, and then `exec` it to replace the bash process inside
23-
// nix-shell.
24-
sh, err := shell.Detect()
25-
if err != nil {
26-
// Fall back to running the vanilla Nix bash shell.
27-
return runFallbackShell(path)
28-
}
29-
30-
// Naively running the user's shell has two problems:
31-
//
32-
// 1. The shell will source the user's rc file and potentially reorder
33-
// the PATH. This is especially a problem with some shims that prepend
34-
// their own directories to the front of the PATH, replacing the
35-
// Nix-installed packages.
36-
// 2. If their shell is bash, we end up double-sourcing their ~/.bashrc.
37-
// Once when nix-shell launches bash, and again when we exec it.
38-
//
39-
// To workaround this, first we store the current (outside of devbox)
40-
// PATH in ORIGINAL_PATH. Then we run a "pure" nix-shell to prevent it
41-
// from sourcing their ~/.bashrc. From inside the nix-shell (but before
42-
// launching the user's preferred shell) we store the PATH again in
43-
// PURE_NIX_PATH. When we're finally in the user's preferred shell, we
44-
// can use these env vars to set the PATH so that Nix packages are up
45-
// front, and all of the other programs come after.
46-
//
47-
// ORIGINAL_PATH is set by sh.StartCommand.
48-
// PURE_NIX_PATH is set by the shell hook in shell.nix.tmpl.
49-
sh.PreInitHook = `
50-
# Update the $PATH so that the user's init script has access to all of their
51-
# non-devbox programs.
52-
export PATH="$PURE_NIX_PATH:$ORIGINAL_PATH"
53-
`
54-
sh.PostInitHook = `
55-
# Update the $PATH again so that the Nix packages take priority over the
56-
# programs outside of devbox.
57-
export PATH="$PURE_NIX_PATH:$ORIGINAL_PATH"
58-
59-
# Prepend to the prompt to make it clear we're in a devbox shell.
60-
export PS1="(devbox) $PS1"
61-
`
62-
63-
if debug.IsEnabled() {
64-
sh.PostInitHook += `echo "POST-INIT PATH=$PATH"
65-
`
66-
}
67-
68-
cmd := exec.Command("nix-shell", path)
69-
cmd.Args = append(cmd.Args, "--pure", "--command", sh.ExecCommand())
70-
cmd.Stdin = os.Stdin
71-
cmd.Stdout = os.Stdout
72-
cmd.Stderr = os.Stderr
73-
74-
debug.Log("Executing nix-shell command: %v", cmd.Args)
75-
return errors.WithStack(cmd.Run())
76-
}
77-
78-
func runFallbackShell(path string) error {
79-
cmd := exec.Command("nix-shell", path)
80-
cmd.Stdin = os.Stdin
81-
cmd.Stdout = os.Stdout
82-
cmd.Stderr = os.Stderr
83-
84-
debug.Log("Unrecognized user shell, falling back to: %v", cmd.Args)
85-
return errors.WithStack(cmd.Run())
86-
}
87-
88-
func Exec(path string, command []string) error {
89-
runCmd := strings.Join(command, " ")
90-
cmd := exec.Command("nix-shell", "--run", runCmd)
91-
cmd.Stdin = os.Stdin
92-
cmd.Stdout = os.Stdout
93-
cmd.Stderr = os.Stderr
94-
cmd.Dir = path
95-
return errors.WithStack(cmd.Run())
96-
}
97-
9813
func PkgExists(pkg string) bool {
9914
_, found := PkgInfo(pkg)
10015
return found

nix/shell.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Copyright 2022 Jetpack Technologies Inc and contributors. All rights reserved.
2+
// Use of this source code is governed by the license in the LICENSE file.
3+
4+
package nix
5+
6+
import (
7+
"bytes"
8+
_ "embed"
9+
"fmt"
10+
"os"
11+
"os/exec"
12+
"path/filepath"
13+
"strings"
14+
"text/template"
15+
16+
"github.com/pkg/errors"
17+
"go.jetpack.io/devbox/debug"
18+
)
19+
20+
//go:embed shellrc.tmpl
21+
var shellrcText string
22+
var shellrcTmpl = template.Must(template.New("shellrc").Parse(shellrcText))
23+
24+
type name string
25+
26+
const (
27+
shUnknown name = ""
28+
shBash name = "bash"
29+
shZsh name = "zsh"
30+
shKsh name = "ksh"
31+
shPosix name = "posix"
32+
)
33+
34+
// Shell configures a user's shell to run in Devbox. Its zero value is a
35+
// fallback shell that launches a regular Nix shell.
36+
type Shell struct {
37+
name name
38+
binPath string
39+
userShellrcPath string
40+
41+
// UserInitHook contains commands that will run at shell startup.
42+
UserInitHook string
43+
}
44+
45+
// DetectShell attempts to determine the user's default shell.
46+
func DetectShell() (*Shell, error) {
47+
path := os.Getenv("SHELL")
48+
if path == "" {
49+
return nil, errors.New("unable to detect the current shell")
50+
}
51+
52+
sh := &Shell{binPath: filepath.Clean(path)}
53+
base := filepath.Base(path)
54+
// Login shell
55+
if base[0] == '-' {
56+
base = base[1:]
57+
}
58+
switch base {
59+
case "bash":
60+
sh.name = shBash
61+
sh.userShellrcPath = rcfilePath(".bashrc")
62+
case "zsh":
63+
sh.name = shZsh
64+
sh.userShellrcPath = rcfilePath(".zshrc")
65+
case "ksh":
66+
sh.name = shKsh
67+
sh.userShellrcPath = rcfilePath(".kshrc")
68+
case "dash", "ash", "sh":
69+
sh.name = shPosix
70+
sh.userShellrcPath = os.Getenv("ENV")
71+
72+
// Just make up a name if there isn't already an init file set
73+
// so we have somewhere to put a new one.
74+
if sh.userShellrcPath == "" {
75+
sh.userShellrcPath = ".shinit"
76+
}
77+
default:
78+
sh.name = shUnknown
79+
}
80+
debug.Log("Detected shell: %s", sh.binPath)
81+
debug.Log("Recognized shell as: %s", sh.binPath)
82+
debug.Log("Looking for user's shell init file at: %s", sh.userShellrcPath)
83+
return sh, nil
84+
}
85+
86+
// rcfilePath returns the absolute path for an rcfile, which is usually in the
87+
// user's home directory. It doesn't guarantee that the file exists.
88+
func rcfilePath(basename string) string {
89+
home, err := os.UserHomeDir()
90+
if err != nil {
91+
return ""
92+
}
93+
return filepath.Join(home, basename)
94+
}
95+
96+
func (s *Shell) Run(nixPath string) error {
97+
// Launch a fallback shell if we couldn't find the path to the user's
98+
// default shell.
99+
if s.binPath == "" {
100+
cmd := exec.Command("nix-shell", nixPath)
101+
cmd.Stdin = os.Stdin
102+
cmd.Stdout = os.Stdout
103+
cmd.Stderr = os.Stderr
104+
105+
debug.Log("Unrecognized user shell, falling back to: %v", cmd.Args)
106+
return errors.WithStack(cmd.Run())
107+
}
108+
109+
cmd := exec.Command("nix-shell", nixPath)
110+
cmd.Args = append(cmd.Args, "--pure", "--command", s.execCommand())
111+
cmd.Stdin = os.Stdin
112+
cmd.Stdout = os.Stdout
113+
cmd.Stderr = os.Stderr
114+
115+
debug.Log("Executing nix-shell command: %v", cmd.Args)
116+
return errors.WithStack(cmd.Run())
117+
}
118+
119+
// execCommand is a command that replaces the current shell with s.
120+
func (s *Shell) execCommand() string {
121+
shellrc, err := writeDevboxShellrc(s.userShellrcPath, s.UserInitHook)
122+
if err != nil {
123+
debug.Log("Failed to write devbox shellrc: %v", err)
124+
return "exec " + s.binPath
125+
}
126+
127+
switch s.name {
128+
case shBash:
129+
return fmt.Sprintf(`exec /usr/bin/env ORIGINAL_PATH="%s" %s --rcfile "%s"`,
130+
os.Getenv("PATH"), s.binPath, shellrc)
131+
case shZsh:
132+
return fmt.Sprintf(`exec /usr/bin/env ORIGINAL_PATH="%s" ZDOTDIR="%s" %s`,
133+
os.Getenv("PATH"), filepath.Dir(shellrc), s.binPath)
134+
case shKsh, shPosix:
135+
return fmt.Sprintf(`exec /usr/bin/env ORIGINAL_PATH="%s" ENV="%s" %s `,
136+
os.Getenv("PATH"), shellrc, s.binPath)
137+
default:
138+
return "exec " + s.binPath
139+
}
140+
}
141+
142+
func writeDevboxShellrc(userShellrcPath string, userHook string) (path string, err error) {
143+
// We need a temp dir (as opposed to a temp file) because zsh uses
144+
// ZDOTDIR to point to a new directory containing the .zshrc.
145+
tmp, err := os.MkdirTemp("", "devbox")
146+
if err != nil {
147+
return "", fmt.Errorf("create temp dir for shell init file: %v", err)
148+
}
149+
150+
// This is a best-effort to include the user's existing shellrc. If we
151+
// can't read it, then just omit it from the devbox shellrc.
152+
userShellrc, err := os.ReadFile(userShellrcPath)
153+
if err != nil {
154+
userShellrc = []byte{}
155+
}
156+
157+
path = filepath.Join(tmp, filepath.Base(userShellrcPath))
158+
shellrcf, err := os.Create(path)
159+
if err != nil {
160+
return "", fmt.Errorf("write to shell init file: %v", err)
161+
}
162+
defer func() {
163+
cerr := shellrcf.Close()
164+
if err == nil {
165+
err = cerr
166+
}
167+
}()
168+
169+
err = shellrcTmpl.Execute(shellrcf, struct {
170+
OriginalInit string
171+
OriginalInitPath string
172+
UserHook string
173+
}{
174+
OriginalInit: string(bytes.TrimSpace(userShellrc)),
175+
OriginalInitPath: filepath.Clean(userShellrcPath),
176+
UserHook: strings.TrimSpace(userHook),
177+
})
178+
if err != nil {
179+
return "", fmt.Errorf("execute shellrc template: %v", err)
180+
}
181+
182+
debug.Log("Wrote devbox shellrc to: %s", path)
183+
return path, nil
184+
}

nix/shellrc.tmpl

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
{{- /*
2+
3+
// This template defines the shellrc file that the devbox shell will run at
4+
// startup.
5+
//
6+
// It includes the user's original shellrc, which varies depending on their
7+
// shell. It will either be ~/.bashrc, ~/.zshrc, a path set in ENV, or something
8+
// else. It also appends any user-defined shell hooks from devbox.json.
9+
//
10+
// Devbox also needs to ensure that the shell's PATH, prompt, and a few other
11+
// things are set correctly at startup. To do this, it must run some commands
12+
// before and after the user's shellrc. These commands are in the
13+
// "Devbox Pre/Post-init Hook" sections.
14+
//
15+
// The devbox pre/post-init hooks assume two environment variables are already
16+
// set:
17+
//
18+
// - ORIGINAL_PATH - embedded into the command built by Shell.execCommand. It
19+
// preserves the PATH at the time `devbox shell` is invoked.
20+
// - PURE_NIX_PATH - set by the shell hook in shell.nix.tmpl. It preserves the
21+
// PATH set by Nix's "pure" shell mode.
22+
23+
*/ -}}
24+
25+
# Begin Devbox Pre-init Hook
26+
27+
# Update the $PATH so that the user's init script has access to all of their
28+
# non-devbox programs.
29+
export PATH="$PURE_NIX_PATH:$ORIGINAL_PATH"
30+
31+
# End Devbox Pre-init Hook
32+
33+
{{- if .OriginalInit }}
34+
35+
# Begin {{ .OriginalInitPath }}
36+
37+
{{ .OriginalInit }}
38+
39+
# End {{ .OriginalInitPath }}
40+
41+
{{- end }}
42+
43+
# Begin Devbox Post-init Hook
44+
45+
# Update the $PATH again so that the Nix packages take priority over the
46+
# programs outside of devbox.
47+
export PATH="$PURE_NIX_PATH:$ORIGINAL_PATH"
48+
49+
# Prepend to the prompt to make it clear we're in a devbox shell.
50+
export PS1="(devbox) $PS1"
51+
52+
# End Devbox Post-init Hook
53+
54+
{{- if .UserHook }}
55+
56+
# Begin Devbox User Hook
57+
58+
{{ .UserHook }}
59+
60+
# End Devbox User Hook
61+
62+
{{- end }}

0 commit comments

Comments
 (0)