|
| 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 | +} |
0 commit comments