diff --git a/go.mod b/go.mod index 192d54f0e49..247bef22adb 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ require ( cuelang.org/go v0.4.3 github.com/bmatcuk/doublestar/v4 v4.2.0 github.com/denisbrodbeck/machineid v1.0.1 + github.com/fatih/color v1.13.0 + github.com/google/go-cmp v0.4.0 github.com/imdario/mergo v0.3.13 github.com/pelletier/go-toml/v2 v2.0.5 github.com/pkg/errors v0.9.1 @@ -24,7 +26,6 @@ require ( github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect github.com/cockroachdb/apd/v2 v2.0.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/fatih/color v1.13.0 // indirect github.com/google/uuid v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/kr/text v0.2.0 // indirect diff --git a/go.sum b/go.sum index 7cb5c719104..ab7d7531fe1 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,7 @@ github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= @@ -81,6 +82,7 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/nix/shell.go b/nix/shell.go index ba51287fe62..f9235be6430 100644 --- a/nix/shell.go +++ b/nix/shell.go @@ -118,7 +118,7 @@ func (s *Shell) Run(nixPath string) error { // execCommand is a command that replaces the current shell with s. func (s *Shell) execCommand() string { - shellrc, err := writeDevboxShellrc(s.userShellrcPath, s.UserInitHook) + shellrc, err := writeDevboxShellrc(s.userShellrcPath, s.UserInitHook, os.Environ()) if err != nil { debug.Log("Failed to write devbox shellrc: %v", err) return "exec " + s.binPath @@ -126,20 +126,25 @@ func (s *Shell) execCommand() string { switch s.name { case shBash: - return fmt.Sprintf(`exec /usr/bin/env ORIGINAL_PATH="%s" %s --rcfile "%s"`, - os.Getenv("PATH"), s.binPath, shellrc) + return fmt.Sprintf(`exec %s --rcfile "%s"`, s.binPath, shellrc) case shZsh: - return fmt.Sprintf(`exec /usr/bin/env ORIGINAL_PATH="%s" ZDOTDIR="%s" %s`, - os.Getenv("PATH"), filepath.Dir(shellrc), s.binPath) + return fmt.Sprintf(`exec /usr/bin/env ZDOTDIR="%s" %s`, filepath.Dir(shellrc), s.binPath) case shKsh, shPosix: - return fmt.Sprintf(`exec /usr/bin/env ORIGINAL_PATH="%s" ENV="%s" %s `, - os.Getenv("PATH"), shellrc, s.binPath) + return fmt.Sprintf(`exec /usr/bin/env ENV="%s" %s`, shellrc, s.binPath) default: return "exec " + s.binPath } } -func writeDevboxShellrc(userShellrcPath string, userHook string) (path string, err error) { +func writeDevboxShellrc(userShellrcPath string, userHook string, env []string) (path string, err error) { + if userShellrcPath == "" { + // If this happens, then there's a bug with how we detect shells + // and their shellrc paths. If the shell is unknown or we can't + // determine the shellrc path, then we should launch a fallback + // shell instead. + panic("writeDevboxShellrc called with an empty user shellrc path; use the fallback shell instead") + } + // 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") @@ -154,7 +159,22 @@ func writeDevboxShellrc(userShellrcPath string, userHook string) (path string, e userShellrc = []byte{} } - path = filepath.Join(tmp, filepath.Base(userShellrcPath)) + var envPath []string + for _, kv := range env { + key, val, _ := strings.Cut(kv, "=") + if key == "PATH" { + envPath = filepath.SplitList(val) + break + } + } + + // 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 userShellrcPath != "" { + shellrcName = filepath.Base(userShellrcPath) + } + path = filepath.Join(tmp, shellrcName) shellrcf, err := os.Create(path) if err != nil { return "", fmt.Errorf("write to shell init file: %v", err) @@ -167,10 +187,12 @@ func writeDevboxShellrc(userShellrcPath string, userHook string) (path string, e }() err = shellrcTmpl.Execute(shellrcf, struct { + Paths []string OriginalInit string OriginalInitPath string UserHook string }{ + Paths: envPath, OriginalInit: string(bytes.TrimSpace(userShellrc)), OriginalInitPath: filepath.Clean(userShellrcPath), UserHook: strings.TrimSpace(userHook), diff --git a/nix/shell_test.go b/nix/shell_test.go new file mode 100644 index 00000000000..db7af0991bc --- /dev/null +++ b/nix/shell_test.go @@ -0,0 +1,89 @@ +package nix + +import ( + "errors" + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +// update overwrites golden files with the new test results. +var update = flag.Bool("update", false, "update the golden files with the test results") + +func TestWriteDevboxShellrc(t *testing.T) { + testdirs, err := filepath.Glob("testdata/shellrc/*") + if err != nil { + t.Fatal("Error globbing testdata:", err) + } + + // Load up all the necessary data from each testdata/shellrc directory + // into a slice of tests cases. + tests := make([]struct { + name string + env []string + hook string + shellrcPath string + goldShellrcPath string + goldShellrc []byte + }, len(testdirs)) + for i, path := range testdirs { + test := &tests[i] + test.name = filepath.Base(path) + if b, err := os.ReadFile(filepath.Join(path, "env")); err == nil { + test.env = strings.Split(string(b), "\n") + } + if b, err := os.ReadFile(filepath.Join(path, "hook")); err == nil { + test.hook = string(b) + } + test.shellrcPath = filepath.Join(path, "shellrc") + if _, err := os.Stat(test.shellrcPath); errors.Is(err, os.ErrNotExist) { + test.shellrcPath = "noshellrc" + } + test.goldShellrcPath = filepath.Join(path, "shellrc.golden") + test.goldShellrc, err = os.ReadFile(test.goldShellrcPath) + if err != nil { + t.Fatal("Got error reading golden file:", err) + } + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + gotPath, err := writeDevboxShellrc(test.shellrcPath, test.hook, test.env) + if err != nil { + t.Fatal("Got writeDevboxShellrc error:", err) + } + gotShellrc, err := os.ReadFile(gotPath) + if err != nil { + t.Fatalf("Got error reading generated shellrc at %s: %v", gotPath, err) + } + + // Overwrite the golden file if the -update flag was + // set, and then proceed normally. The test should + // always pass in this case. + if *update { + err = os.WriteFile(test.goldShellrcPath, gotShellrc, 0666) + if err != nil { + t.Error("Error updating golden files:", err) + } + } + goldShellrc, err := os.ReadFile(test.goldShellrcPath) + if err != nil { + t.Fatal("Got error reading golden file:", err) + } + diff := cmp.Diff(goldShellrc, gotShellrc) + if diff != "" { + t.Errorf(strings.TrimSpace(` +Generated shellrc != shellrc.golden (-shellrc.golden +shellrc): + + %s +If the new shellrc is correct, you can update the golden file with: + + go test -run "^%s$" -update`), diff, t.Name()) + } + }) + } +} diff --git a/nix/shellrc.tmpl b/nix/shellrc.tmpl index 827f8590d38..9a4b249dcfd 100644 --- a/nix/shellrc.tmpl +++ b/nix/shellrc.tmpl @@ -12,21 +12,34 @@ // before and after the user's shellrc. These commands are in the // "Devbox Pre/Post-init Hook" sections. // -// The devbox pre/post-init hooks assume two environment variables are already -// set: +// The devbox pre/post-init hooks assume a PURE_NIX_PATH environment variable is +// already set by the shell hook in shell.nix.tmpl. It preserves the PATH set by +// Nix's "pure" shell mode. // -// - ORIGINAL_PATH - embedded into the command built by Shell.execCommand. It -// preserves the PATH at the time `devbox shell` is invoked. -// - PURE_NIX_PATH - set by the shell hook in shell.nix.tmpl. It preserves the -// PATH set by Nix's "pure" shell mode. +// This file is useful for debugging shell errors, so try to keep the generated +// content readable. */ -}} # Begin Devbox Pre-init Hook -# Update the $PATH so that the user's init script has access to all of their -# non-devbox programs. -export PATH="$PURE_NIX_PATH:$ORIGINAL_PATH" +# Don't allow the user's shellrc to re-source Nix since we're already in Nix. +export __ETC_PROFILE_NIX_SOURCED=1 + +# Put the Nix packages at the beginning of the PATH to give them priority over +# programs outside of devbox. +export PATH="$PURE_NIX_PATH" + +{{- if .Paths }} + +# Append any paths that were in the environment at the time the user launched +# devbox. This gives the shell access to non-devbox programs, while still +# preferring the ones that Nix installed. +{{- range .Paths }} +PATH="$PATH:{{ . }}" +{{- end }} + +{{- end }} # End Devbox Pre-init Hook @@ -44,7 +57,7 @@ export PATH="$PURE_NIX_PATH:$ORIGINAL_PATH" # Update the $PATH again so that the Nix packages take priority over the # programs outside of devbox. -export PATH="$PURE_NIX_PATH:$ORIGINAL_PATH" +export PATH="$PURE_NIX_PATH:$PATH" # Prepend to the prompt to make it clear we're in a devbox shell. export PS1="(devbox) $PS1" diff --git a/nix/testdata/shellrc/basic/env b/nix/testdata/shellrc/basic/env new file mode 100644 index 00000000000..5b9b5a3f6ee --- /dev/null +++ b/nix/testdata/shellrc/basic/env @@ -0,0 +1 @@ +PATH=/a/test/path diff --git a/nix/testdata/shellrc/basic/hook b/nix/testdata/shellrc/basic/hook new file mode 100644 index 00000000000..770d44938c5 --- /dev/null +++ b/nix/testdata/shellrc/basic/hook @@ -0,0 +1 @@ +echo "Hello from a devbox shell hook!" diff --git a/nix/testdata/shellrc/basic/shellrc b/nix/testdata/shellrc/basic/shellrc new file mode 100644 index 00000000000..85834d546fd --- /dev/null +++ b/nix/testdata/shellrc/basic/shellrc @@ -0,0 +1,37 @@ +# Set up the prompt + +autoload -Uz promptinit +promptinit +#prompt adam1 + +setopt histignorealldups sharehistory + +# Use emacs keybindings even if our EDITOR is set to vi +bindkey -e + +# Keep 1000 lines of history within the shell and save it to ~/.zsh_history: +HISTSIZE=1000 +SAVEHIST=1000 +HISTFILE=~/.zsh_history + +# Use modern completion system +autoload -Uz compinit +compinit + +zstyle ':completion:*' auto-description 'specify: %d' +zstyle ':completion:*' completer _expand _complete _correct _approximate +zstyle ':completion:*' format 'Completing %d' +zstyle ':completion:*' group-name '' +zstyle ':completion:*' menu select=2 +eval "$(dircolors -b)" +zstyle ':completion:*:default' list-colors ${(s.:.)LS_COLORS} +zstyle ':completion:*' list-colors '' +zstyle ':completion:*' list-prompt %SAt %p: Hit TAB for more, or the character to insert%s +zstyle ':completion:*' matcher-list '' 'm:{a-z}={A-Z}' 'm:{a-zA-Z}={A-Za-z}' 'r:|[._-]=* r:|=* l:|=*' +zstyle ':completion:*' menu select=long +zstyle ':completion:*' select-prompt %SScrolling active: current selection at %p%s +zstyle ':completion:*' use-compctl false +zstyle ':completion:*' verbose true + +zstyle ':completion:*:*:kill:*:processes' list-colors '=(#b) #([0-9]#)*=0=01;31' +zstyle ':completion:*:kill:*' command 'ps -u $USER -o pid,%cpu,tty,cputime,cmd' diff --git a/nix/testdata/shellrc/basic/shellrc.golden b/nix/testdata/shellrc/basic/shellrc.golden new file mode 100644 index 00000000000..4214b441816 --- /dev/null +++ b/nix/testdata/shellrc/basic/shellrc.golden @@ -0,0 +1,74 @@ +# Begin Devbox Pre-init Hook + +# Don't allow the user's shellrc to re-source Nix since we're already in Nix. +export __ETC_PROFILE_NIX_SOURCED=1 + +# Put the Nix packages at the beginning of the PATH to give them priority over +# programs outside of devbox. +export PATH="$PURE_NIX_PATH" + +# Append any paths that were in the environment at the time the user launched +# devbox. This gives the shell access to non-devbox programs, while still +# preferring the ones that Nix installed. +PATH="$PATH:/a/test/path" + +# End Devbox Pre-init Hook + +# Begin testdata/shellrc/basic/shellrc + +# Set up the prompt + +autoload -Uz promptinit +promptinit +#prompt adam1 + +setopt histignorealldups sharehistory + +# Use emacs keybindings even if our EDITOR is set to vi +bindkey -e + +# Keep 1000 lines of history within the shell and save it to ~/.zsh_history: +HISTSIZE=1000 +SAVEHIST=1000 +HISTFILE=~/.zsh_history + +# Use modern completion system +autoload -Uz compinit +compinit + +zstyle ':completion:*' auto-description 'specify: %d' +zstyle ':completion:*' completer _expand _complete _correct _approximate +zstyle ':completion:*' format 'Completing %d' +zstyle ':completion:*' group-name '' +zstyle ':completion:*' menu select=2 +eval "$(dircolors -b)" +zstyle ':completion:*:default' list-colors ${(s.:.)LS_COLORS} +zstyle ':completion:*' list-colors '' +zstyle ':completion:*' list-prompt %SAt %p: Hit TAB for more, or the character to insert%s +zstyle ':completion:*' matcher-list '' 'm:{a-z}={A-Z}' 'm:{a-zA-Z}={A-Za-z}' 'r:|[._-]=* r:|=* l:|=*' +zstyle ':completion:*' menu select=long +zstyle ':completion:*' select-prompt %SScrolling active: current selection at %p%s +zstyle ':completion:*' use-compctl false +zstyle ':completion:*' verbose true + +zstyle ':completion:*:*:kill:*:processes' list-colors '=(#b) #([0-9]#)*=0=01;31' +zstyle ':completion:*:kill:*' command 'ps -u $USER -o pid,%cpu,tty,cputime,cmd' + +# End testdata/shellrc/basic/shellrc + +# Begin Devbox Post-init Hook + +# Update the $PATH again so that the Nix packages take priority over the +# programs outside of devbox. +export PATH="$PURE_NIX_PATH:$PATH" + +# Prepend to the prompt to make it clear we're in a devbox shell. +export PS1="(devbox) $PS1" + +# End Devbox Post-init Hook + +# Begin Devbox User Hook + +echo "Hello from a devbox shell hook!" + +# End Devbox User Hook diff --git a/nix/testdata/shellrc/nohook/env b/nix/testdata/shellrc/nohook/env new file mode 100644 index 00000000000..5b9b5a3f6ee --- /dev/null +++ b/nix/testdata/shellrc/nohook/env @@ -0,0 +1 @@ +PATH=/a/test/path diff --git a/nix/testdata/shellrc/nohook/shellrc b/nix/testdata/shellrc/nohook/shellrc new file mode 100644 index 00000000000..85834d546fd --- /dev/null +++ b/nix/testdata/shellrc/nohook/shellrc @@ -0,0 +1,37 @@ +# Set up the prompt + +autoload -Uz promptinit +promptinit +#prompt adam1 + +setopt histignorealldups sharehistory + +# Use emacs keybindings even if our EDITOR is set to vi +bindkey -e + +# Keep 1000 lines of history within the shell and save it to ~/.zsh_history: +HISTSIZE=1000 +SAVEHIST=1000 +HISTFILE=~/.zsh_history + +# Use modern completion system +autoload -Uz compinit +compinit + +zstyle ':completion:*' auto-description 'specify: %d' +zstyle ':completion:*' completer _expand _complete _correct _approximate +zstyle ':completion:*' format 'Completing %d' +zstyle ':completion:*' group-name '' +zstyle ':completion:*' menu select=2 +eval "$(dircolors -b)" +zstyle ':completion:*:default' list-colors ${(s.:.)LS_COLORS} +zstyle ':completion:*' list-colors '' +zstyle ':completion:*' list-prompt %SAt %p: Hit TAB for more, or the character to insert%s +zstyle ':completion:*' matcher-list '' 'm:{a-z}={A-Z}' 'm:{a-zA-Z}={A-Za-z}' 'r:|[._-]=* r:|=* l:|=*' +zstyle ':completion:*' menu select=long +zstyle ':completion:*' select-prompt %SScrolling active: current selection at %p%s +zstyle ':completion:*' use-compctl false +zstyle ':completion:*' verbose true + +zstyle ':completion:*:*:kill:*:processes' list-colors '=(#b) #([0-9]#)*=0=01;31' +zstyle ':completion:*:kill:*' command 'ps -u $USER -o pid,%cpu,tty,cputime,cmd' diff --git a/nix/testdata/shellrc/nohook/shellrc.golden b/nix/testdata/shellrc/nohook/shellrc.golden new file mode 100644 index 00000000000..08575df05a3 --- /dev/null +++ b/nix/testdata/shellrc/nohook/shellrc.golden @@ -0,0 +1,68 @@ +# Begin Devbox Pre-init Hook + +# Don't allow the user's shellrc to re-source Nix since we're already in Nix. +export __ETC_PROFILE_NIX_SOURCED=1 + +# Put the Nix packages at the beginning of the PATH to give them priority over +# programs outside of devbox. +export PATH="$PURE_NIX_PATH" + +# Append any paths that were in the environment at the time the user launched +# devbox. This gives the shell access to non-devbox programs, while still +# preferring the ones that Nix installed. +PATH="$PATH:/a/test/path" + +# End Devbox Pre-init Hook + +# Begin testdata/shellrc/nohook/shellrc + +# Set up the prompt + +autoload -Uz promptinit +promptinit +#prompt adam1 + +setopt histignorealldups sharehistory + +# Use emacs keybindings even if our EDITOR is set to vi +bindkey -e + +# Keep 1000 lines of history within the shell and save it to ~/.zsh_history: +HISTSIZE=1000 +SAVEHIST=1000 +HISTFILE=~/.zsh_history + +# Use modern completion system +autoload -Uz compinit +compinit + +zstyle ':completion:*' auto-description 'specify: %d' +zstyle ':completion:*' completer _expand _complete _correct _approximate +zstyle ':completion:*' format 'Completing %d' +zstyle ':completion:*' group-name '' +zstyle ':completion:*' menu select=2 +eval "$(dircolors -b)" +zstyle ':completion:*:default' list-colors ${(s.:.)LS_COLORS} +zstyle ':completion:*' list-colors '' +zstyle ':completion:*' list-prompt %SAt %p: Hit TAB for more, or the character to insert%s +zstyle ':completion:*' matcher-list '' 'm:{a-z}={A-Z}' 'm:{a-zA-Z}={A-Za-z}' 'r:|[._-]=* r:|=* l:|=*' +zstyle ':completion:*' menu select=long +zstyle ':completion:*' select-prompt %SScrolling active: current selection at %p%s +zstyle ':completion:*' use-compctl false +zstyle ':completion:*' verbose true + +zstyle ':completion:*:*:kill:*:processes' list-colors '=(#b) #([0-9]#)*=0=01;31' +zstyle ':completion:*:kill:*' command 'ps -u $USER -o pid,%cpu,tty,cputime,cmd' + +# End testdata/shellrc/nohook/shellrc + +# Begin Devbox Post-init Hook + +# Update the $PATH again so that the Nix packages take priority over the +# programs outside of devbox. +export PATH="$PURE_NIX_PATH:$PATH" + +# Prepend to the prompt to make it clear we're in a devbox shell. +export PS1="(devbox) $PS1" + +# End Devbox Post-init Hook diff --git a/nix/testdata/shellrc/noshellrc/env b/nix/testdata/shellrc/noshellrc/env new file mode 100644 index 00000000000..5b9b5a3f6ee --- /dev/null +++ b/nix/testdata/shellrc/noshellrc/env @@ -0,0 +1 @@ +PATH=/a/test/path diff --git a/nix/testdata/shellrc/noshellrc/hook b/nix/testdata/shellrc/noshellrc/hook new file mode 100644 index 00000000000..770d44938c5 --- /dev/null +++ b/nix/testdata/shellrc/noshellrc/hook @@ -0,0 +1 @@ +echo "Hello from a devbox shell hook!" diff --git a/nix/testdata/shellrc/noshellrc/shellrc.golden b/nix/testdata/shellrc/noshellrc/shellrc.golden new file mode 100644 index 00000000000..ace152646f9 --- /dev/null +++ b/nix/testdata/shellrc/noshellrc/shellrc.golden @@ -0,0 +1,32 @@ +# Begin Devbox Pre-init Hook + +# Don't allow the user's shellrc to re-source Nix since we're already in Nix. +export __ETC_PROFILE_NIX_SOURCED=1 + +# Put the Nix packages at the beginning of the PATH to give them priority over +# programs outside of devbox. +export PATH="$PURE_NIX_PATH" + +# Append any paths that were in the environment at the time the user launched +# devbox. This gives the shell access to non-devbox programs, while still +# preferring the ones that Nix installed. +PATH="$PATH:/a/test/path" + +# End Devbox Pre-init Hook + +# Begin Devbox Post-init Hook + +# Update the $PATH again so that the Nix packages take priority over the +# programs outside of devbox. +export PATH="$PURE_NIX_PATH:$PATH" + +# Prepend to the prompt to make it clear we're in a devbox shell. +export PS1="(devbox) $PS1" + +# End Devbox Post-init Hook + +# Begin Devbox User Hook + +echo "Hello from a devbox shell hook!" + +# End Devbox User Hook