/
shell.go
401 lines (348 loc) · 11.7 KB
/
shell.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
// Copyright 2024 Jetify Inc. and contributors. All rights reserved.
// Use of this source code is governed by the license in the LICENSE file.
package devbox
import (
"bytes"
_ "embed"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"time"
"github.com/alessio/shellescape"
"github.com/pkg/errors"
"go.jetpack.io/devbox/internal/shellgen"
"go.jetpack.io/devbox/internal/telemetry"
"go.jetpack.io/devbox/internal/debug"
"go.jetpack.io/devbox/internal/envir"
"go.jetpack.io/devbox/internal/nix"
"go.jetpack.io/devbox/internal/xdg"
)
//go:embed shellrc.tmpl
var shellrcText string
var shellrcTmpl = template.Must(template.New("shellrc").Parse(shellrcText))
//go:embed shellrc_fish.tmpl
var fishrcText string
var fishrcTmpl = template.Must(template.New("shellrc_fish").Parse(fishrcText))
type name string
const (
shUnknown name = ""
shBash name = "bash"
shZsh name = "zsh"
shKsh name = "ksh"
shFish name = "fish"
shPosix name = "posix"
)
var ErrNoRecognizableShellFound = errors.New("SHELL in undefined, and couldn't find any common shells in PATH")
// TODO consider splitting this struct's functionality so that there is a simpler
// `nix.Shell` that can produce a raw nix shell once again.
// DevboxShell configures a user's shell to run in Devbox. Its zero value is a
// fallback shell that launches a regular Nix shell.
type DevboxShell struct {
devbox *Devbox
name name
binPath string
projectDir string // path to where devbox.json config resides
env map[string]string
userShellrcPath string
historyFile string
// shellStartTime is the unix timestamp for when the command was invoked
shellStartTime time.Time
}
type ShellOption func(*DevboxShell)
// NewDevboxShell initializes the DevboxShell struct so it can be used to start a shell environment
// for the devbox project.
func NewDevboxShell(devbox *Devbox, opts ...ShellOption) (*DevboxShell, error) {
shPath, err := shellPath(devbox)
if err != nil {
return nil, err
}
sh := initShellBinaryFields(shPath)
sh.devbox = devbox
for _, opt := range opts {
opt(sh)
}
debug.Log("Recognized shell as: %s", sh.binPath)
debug.Log("Looking for user's shell init file at: %s", sh.userShellrcPath)
return sh, nil
}
// shellPath returns the path to a shell binary, or error if none found.
func shellPath(devbox *Devbox) (path string, err error) {
defer func() {
if err != nil {
path = filepath.Clean(path)
}
}()
if !devbox.pure {
// First, check the SHELL environment variable.
path = os.Getenv(envir.Shell)
if path != "" {
debug.Log("Using SHELL env var for shell binary path: %s\n", path)
return path, nil
}
}
// Second, fallback to using the bash that nix uses by default.
var bashNixStorePath string // of the form /nix/store/{hash}-bash-{version}/
cmd := exec.Command(
"nix", "eval", "--raw",
fmt.Sprintf("%s#bashInteractive", nix.FlakeNixpkgs(devbox.cfg.NixPkgsCommitHash())),
)
cmd.Args = append(cmd.Args, nix.ExperimentalFlags()...)
out, err := cmd.Output()
if err != nil {
return "", errors.WithStack(err)
}
bashNixStorePath = string(out)
// install bashInteractive in nix/store without creating a symlink to local directory (--no-link)
cmd = exec.Command("nix", "build", bashNixStorePath, "--no-link")
cmd.Args = append(cmd.Args, nix.ExperimentalFlags()...)
err = cmd.Run()
if err != nil {
return "", errors.WithStack(err)
}
if bashNixStorePath != "" {
// the output is the raw path to the bash installation in the /nix/store
return fmt.Sprintf("%s/bin/bash", bashNixStorePath), nil
}
// Else, return an error
return "", ErrNoRecognizableShellFound
}
// initShellBinaryFields initializes the fields specific to the shell binary that will be used
// for the devbox shell.
func initShellBinaryFields(path string) *DevboxShell {
shell := &DevboxShell{binPath: path}
base := filepath.Base(path)
// Login shell
if base[0] == '-' {
base = base[1:]
}
switch base {
case "bash":
shell.name = shBash
shell.userShellrcPath = rcfilePath(".bashrc")
case "zsh":
shell.name = shZsh
if zdotdir := os.Getenv("ZDOTDIR"); zdotdir != "" {
shell.userShellrcPath = filepath.Join(os.ExpandEnv(zdotdir), ".zshrc")
} else {
shell.userShellrcPath = rcfilePath(".zshrc")
}
case "ksh":
shell.name = shKsh
shell.userShellrcPath = rcfilePath(".kshrc")
case "fish":
shell.name = shFish
shell.userShellrcPath = fishConfig()
case "dash", "ash", "shell":
shell.name = shPosix
shell.userShellrcPath = os.Getenv(envir.Env)
// Just make up a name if there isn't already an init file set
// so we have somewhere to put a new one.
if shell.userShellrcPath == "" {
shell.userShellrcPath = ".shinit"
}
default:
shell.name = shUnknown
}
return shell
}
func WithHistoryFile(historyFile string) ShellOption {
return func(s *DevboxShell) {
s.historyFile = historyFile
}
}
// TODO: Consider removing this once plugins add env vars directly to binaries via wrapper scripts.
func WithEnvVariables(envVariables map[string]string) ShellOption {
return func(s *DevboxShell) {
s.env = envVariables
}
}
func WithProjectDir(projectDir string) ShellOption {
return func(s *DevboxShell) {
s.projectDir = projectDir
}
}
func WithShellStartTime(t time.Time) ShellOption {
return func(s *DevboxShell) {
s.shellStartTime = t
}
}
// rcfilePath returns the absolute path for an rcfile, which is usually in the
// user's home directory. It doesn't guarantee that the file exists.
func rcfilePath(basename string) string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, basename)
}
func fishConfig() string {
return xdg.ConfigSubpath("fish/config.fish")
}
func (s *DevboxShell) Run() error {
var cmd *exec.Cmd
shellrc, err := s.writeDevboxShellrc()
if err != nil {
// We don't have a good fallback here, since all the variables we need for anything to work
// are in the shellrc file. For now let's fail. Later on, we should remove the vars from the
// shellrc file. That said, one of the variables we have to evaluate ($shellHook), so we need
// the shellrc file anyway (unless we remove the hook somehow).
debug.Log("Failed to write devbox shellrc: %s", err)
return errors.WithStack(err)
}
// Link other files that affect the shell settings and environments.
s.linkShellStartupFiles(filepath.Dir(shellrc))
extraEnv, extraArgs := s.shellRCOverrides(shellrc)
env := s.env
for k, v := range extraEnv {
env[k] = v
}
env["SHELL"] = s.binPath
cmd = exec.Command(s.binPath)
cmd.Env = envir.MapToPairs(env)
cmd.Args = append(cmd.Args, extraArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
debug.Log("Executing shell %s with args: %v", s.binPath, cmd.Args)
err = cmd.Run()
// If the error is an ExitError, this means the shell started up fine but there was
// an error from executing a shell command or script.
//
// This could be from one of the generated shellrc commands, but more likely is from
// a user's command or script. So, we want to return nil for this.
if exitErr := (&exec.ExitError{}); errors.As(err, &exitErr) {
return nil
}
// This means that there was an error from devbox's code or nix's code. Not a user
// error and so we do return it.
return errors.WithStack(err)
}
func (s *DevboxShell) shellRCOverrides(shellrc string) (extraEnv map[string]string, extraArgs []string) {
// Shells have different ways of overriding the shellrc, so we need to
// look at the name to know which env vars or args to set when launching the shell.
switch s.name {
case shBash:
extraArgs = []string{"--rcfile", shellescape.Quote(shellrc)}
case shZsh:
extraEnv = map[string]string{"ZDOTDIR": shellescape.Quote(filepath.Dir(shellrc))}
case shKsh, shPosix:
extraEnv = map[string]string{"ENV": shellescape.Quote(shellrc)}
case shFish:
extraArgs = []string{"-C", ". " + shellrc}
}
return extraEnv, extraArgs
}
func (s *DevboxShell) writeDevboxShellrc() (path string, err error) {
// We need a temp dir (as opposed to a temp file) because zsh uses
// ZDOTDIR to point to a new directory containing the .zshrc.
tmp, err := os.MkdirTemp("", "devbox")
if err != nil {
return "", fmt.Errorf("create temp dir for shell init file: %v", err)
}
// This is a best-effort to include the user's existing shellrc.
userShellrc := []byte{}
if s.userShellrcPath != "" {
// If we can't read it, then just omit it from the devbox shellrc.
userShellrc, _ = os.ReadFile(s.userShellrcPath)
}
// If the user already has a shellrc file, then give the devbox shellrc
// file the same name. Otherwise, use an arbitrary name of "shellrc".
shellrcName := "shellrc"
if s.userShellrcPath != "" {
shellrcName = filepath.Base(s.userShellrcPath)
}
path = filepath.Join(tmp, shellrcName)
shellrcf, err := os.Create(path)
if err != nil {
return "", fmt.Errorf("write to shell init file: %v", err)
}
defer func() {
cerr := shellrcf.Close()
if err == nil {
err = cerr
}
}()
tmpl := shellrcTmpl
if s.name == shFish {
tmpl = fishrcTmpl
}
err = tmpl.Execute(shellrcf, struct {
ProjectDir string
OriginalInit string
OriginalInitPath string
HooksFilePath string
ShellStartTime string
HistoryFile string
ExportEnv string
RefreshAliasName string
RefreshCmd string
RefreshAliasEnvVar string
}{
ProjectDir: s.projectDir,
OriginalInit: string(bytes.TrimSpace(userShellrc)),
OriginalInitPath: s.userShellrcPath,
HooksFilePath: shellgen.ScriptPath(s.projectDir, shellgen.HooksFilename),
ShellStartTime: telemetry.FormatShellStart(s.shellStartTime),
HistoryFile: strings.TrimSpace(s.historyFile),
ExportEnv: exportify(s.env),
RefreshAliasName: s.devbox.refreshAliasName(),
RefreshCmd: s.devbox.refreshCmd(),
RefreshAliasEnvVar: s.devbox.refreshAliasEnvVar(),
})
if err != nil {
return "", fmt.Errorf("execute shellrc template: %v", err)
}
debug.Log("Wrote devbox shellrc to: %s", path)
return path, nil
}
// linkShellStartupFiles will link files used by the shell for initialization.
// We choose to link instead of copy so that changes made outside can be reflected
// within the devbox shell.
//
// We do not link the .{shell}rc files, since devbox modifies them. See writeDevboxShellrc
func (s *DevboxShell) linkShellStartupFiles(shellSettingsDir string) {
// For now, we only need to do this for zsh shell
if s.name == shZsh {
// List of zsh startup files: https://zsh.sourceforge.io/Intro/intro_3.html
filenames := []string{".zshenv", ".zprofile", ".zlogin", ".zlogout"}
// zim framework
// https://zimfw.sh/docs/install/
filenames = append(filenames, ".zimrc")
for _, filename := range filenames {
// The userShellrcPath should be set to ZDOTDIR already.
fileOld := filepath.Join(filepath.Dir(s.userShellrcPath), filename)
_, err := os.Stat(fileOld)
if errors.Is(err, fs.ErrNotExist) {
// this file may not be relevant for the user's setup.
continue
}
if err != nil {
debug.Log("os.Stat error for %s is %v", fileOld, err)
}
fileNew := filepath.Join(shellSettingsDir, filename)
cmd := exec.Command("cp", fileOld, fileNew)
if err := cmd.Run(); err != nil {
// This is a best-effort operation. If there's an error then log it for visibility but continue.
debug.Log("Error copying zsh setting file from %s to %s: %v", fileOld, fileNew, err)
continue
}
}
}
}
func filterPathList(pathList string, keep func(string) bool) string {
filtered := []string{}
for _, path := range filepath.SplitList(pathList) {
if keep(path) {
filtered = append(filtered, path)
}
}
return strings.Join(filtered, string(filepath.ListSeparator))
}
func isFishShell() bool {
return filepath.Base(os.Getenv("SHELL")) == "fish" ||
os.Getenv("FISH_VERSION") != ""
}