From cc2aff7e0e8f568fb735586534e2ff6ff4d68adb Mon Sep 17 00:00:00 2001 From: Savil Srivastava <676452+savil@users.noreply.github.com> Date: Thu, 15 Jun 2023 21:23:30 -0700 Subject: [PATCH 1/2] [direnv-inspired] add export and hook commands, and hook up shell, global and direnv --- devbox.go | 1 + internal/boxcli/export.go | 30 +++++++++++ internal/boxcli/featureflag/prompt_hook.go | 5 ++ internal/boxcli/global.go | 6 ++- internal/boxcli/hook.go | 41 ++++++++++++++ internal/boxcli/root.go | 2 + internal/boxcli/shellenv.go | 12 +++-- internal/impl/devbox.go | 28 +++++----- internal/impl/generate/devcontainer_util.go | 7 ++- internal/impl/generate/tmpl/envrcContent.tmpl | 4 ++ internal/impl/shell.go | 54 ++++++++++++++----- internal/impl/shellrc.tmpl | 6 +++ internal/impl/shellrc_fish.tmpl | 5 ++ .../testdata/shellrc/basic/shellrc.golden | 2 + internal/shenv/shell_bash.go | 2 +- internal/shenv/shell_fish.go | 2 +- internal/shenv/shell_ksh.go | 2 +- internal/shenv/shell_posix.go | 2 +- internal/shenv/shell_zsh.go | 2 +- 19 files changed, 175 insertions(+), 38 deletions(-) create mode 100644 internal/boxcli/export.go create mode 100644 internal/boxcli/featureflag/prompt_hook.go create mode 100644 internal/boxcli/hook.go diff --git a/devbox.go b/devbox.go index 41cf42c31e5..1b61b720bd9 100644 --- a/devbox.go +++ b/devbox.go @@ -21,6 +21,7 @@ type Devbox interface { // Adding duplicate packages is a no-op. Add(ctx context.Context, pkgs ...string) error Config() *devconfig.Config + ExportHook(shellName string) (string, error) ProjectDir() string // Generate creates the directory of Nix files and the Dockerfile that define // the devbox environment. diff --git a/internal/boxcli/export.go b/internal/boxcli/export.go new file mode 100644 index 00000000000..69059bd2f35 --- /dev/null +++ b/internal/boxcli/export.go @@ -0,0 +1,30 @@ +package boxcli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// exportCmd is an alias of shellenv, but is also hidden and hence we cannot define it +// simply using `Aliases: []string{"export"}` in the shellEnvCmd definition. +func exportCmd() *cobra.Command { + flags := shellEnvCmdFlags{} + cmd := &cobra.Command{ + Use: "export [shell]", + Hidden: true, + Short: "Print shell command to setup the shell export to ensure an up-to-date environment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + s, err := shellEnvFunc(cmd, flags) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), s) + return nil + }, + } + + registerShellEnvFlags(cmd, &flags) + return cmd +} diff --git a/internal/boxcli/featureflag/prompt_hook.go b/internal/boxcli/featureflag/prompt_hook.go new file mode 100644 index 00000000000..6084ff7c469 --- /dev/null +++ b/internal/boxcli/featureflag/prompt_hook.go @@ -0,0 +1,5 @@ +package featureflag + +// PromptHook controls the insertion of a shell prompt hook that invokes +// devbox shellenv, in lieu of using binary wrappers. +var PromptHook = disabled("PROMPT_HOOK") diff --git a/internal/boxcli/global.go b/internal/boxcli/global.go index ae68b99f372..8a69cdaeddc 100644 --- a/internal/boxcli/global.go +++ b/internal/boxcli/global.go @@ -26,6 +26,7 @@ func globalCmd() *cobra.Command { } addCommandAndHideConfigFlag(globalCmd, addCmd()) + addCommandAndHideConfigFlag(globalCmd, hookCmd()) addCommandAndHideConfigFlag(globalCmd, installCmd()) addCommandAndHideConfigFlag(globalCmd, pathCmd()) addCommandAndHideConfigFlag(globalCmd, pullCmd()) @@ -112,7 +113,10 @@ func setGlobalConfigForDelegatedCommands( } func ensureGlobalEnvEnabled(cmd *cobra.Command, args []string) error { - if cmd.Name() == "shellenv" { + // Skip checking this for shellenv and hook sub-commands of devbox global + // since these commands are what will enable the global environment when + // invoked from the user's shellrc + if cmd.Name() == "shellenv" || cmd.Name() == "hook" { return nil } path, err := ensureGlobalConfig(cmd) diff --git a/internal/boxcli/hook.go b/internal/boxcli/hook.go new file mode 100644 index 00000000000..de3a9eda4c7 --- /dev/null +++ b/internal/boxcli/hook.go @@ -0,0 +1,41 @@ +package boxcli + +import ( + "fmt" + + "github.com/spf13/cobra" + "go.jetpack.io/devbox" + "go.jetpack.io/devbox/internal/impl/devopt" +) + +type hookFlags struct { + config configFlags +} + +func hookCmd() *cobra.Command { + flags := hookFlags{} + cmd := &cobra.Command{ + Use: "hook [shell]", + Short: "Print shell command to setup the shell hook to ensure an up-to-date environment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output, err := hookFunc(cmd, args, flags) + if err != nil { + return err + } + fmt.Fprint(cmd.OutOrStdout(), output) + return nil + }, + } + + flags.config.register(cmd) + return cmd +} + +func hookFunc(cmd *cobra.Command, args []string, flags hookFlags) (string, error) { + box, err := devbox.Open(&devopt.Opts{Dir: flags.config.path, Writer: cmd.ErrOrStderr()}) + if err != nil { + return "", err + } + return box.ExportHook(args[0]) +} diff --git a/internal/boxcli/root.go b/internal/boxcli/root.go index 45a1696e53d..e95df6adeab 100644 --- a/internal/boxcli/root.go +++ b/internal/boxcli/root.go @@ -52,8 +52,10 @@ func RootCmd() *cobra.Command { command.AddCommand(authCmd()) } command.AddCommand(createCmd()) + command.AddCommand(exportCmd()) command.AddCommand(generateCmd()) command.AddCommand(globalCmd()) + command.AddCommand(hookCmd()) command.AddCommand(infoCmd()) command.AddCommand(initCmd()) command.AddCommand(installCmd()) diff --git a/internal/boxcli/shellenv.go b/internal/boxcli/shellenv.go index 752bc27bd6b..691c0228a58 100644 --- a/internal/boxcli/shellenv.go +++ b/internal/boxcli/shellenv.go @@ -36,6 +36,14 @@ func shellEnvCmd() *cobra.Command { }, } + registerShellEnvFlags(command, &flags) + command.AddCommand(shellEnvOnlyPathWithoutWrappersCmd()) + + return command +} + +func registerShellEnvFlags(command *cobra.Command, flags *shellEnvCmdFlags) { + command.Flags().BoolVar( &flags.runInitHook, "init-hook", false, "runs init hook after exporting shell environment") command.Flags().BoolVar( @@ -45,10 +53,6 @@ func shellEnvCmd() *cobra.Command { &flags.pure, "pure", false, "If this flag is specified, devbox creates an isolated environment inheriting almost no variables from the current environment. A few variables, in particular HOME, USER and DISPLAY, are retained.") flags.config.register(command) - - command.AddCommand(shellEnvOnlyPathWithoutWrappersCmd()) - - return command } func shellEnvFunc(cmd *cobra.Command, flags shellEnvCmdFlags) (string, error) { diff --git a/internal/impl/devbox.go b/internal/impl/devbox.go index b1766454c6c..4d03b6a9aef 100644 --- a/internal/impl/devbox.go +++ b/internal/impl/devbox.go @@ -822,21 +822,23 @@ func (d *Devbox) computeNixEnv(ctx context.Context, usePrintDevEnvCache bool) (m addEnvIfNotPreviouslySetByDevbox(env, pluginEnv) + envPaths := []string{} + if !featureflag.PromptHook.Enabled() { + envPaths = append(envPaths, filepath.Join(d.projectDir, plugin.WrapperBinPath)) + } + // Adding profile bin path is a temporary hack. Some packages .e.g. curl + // don't export the correct bin in the package, instead they export + // as a propagated build input. This can be fixed in 2 ways: + // * have NixBins() recursively look for bins in propagated build inputs + // * Turn existing planners into flakes (i.e. php, haskell) and use the bins + // in the profile. + // Landau: I prefer option 2 because it doesn't require us to re-implement + // nix recursive bin lookup. + envPaths = append(envPaths, nix.ProfileBinPath(d.projectDir), env["PATH"]) + // Prepend virtenv bin path first so user can override it if needed. Virtenv // is where the bin wrappers live - env["PATH"] = JoinPathLists( - filepath.Join(d.projectDir, plugin.WrapperBinPath), - // Adding profile bin path is a temporary hack. Some packages .e.g. curl - // don't export the correct bin in the package, instead they export - // as a propagated build input. This can be fixed in 2 ways: - // * have NixBins() recursively look for bins in propagated build inputs - // * Turn existing planners into flakes (i.e. php, haskell) and use the bins - // in the profile. - // Landau: I prefer option 2 because it doesn't require us to re-implement - // nix recursive bin lookup. - nix.ProfileBinPath(d.projectDir), - env["PATH"], - ) + env["PATH"] = JoinPathLists(envPaths...) // Include env variables in devbox.json configEnv := d.configEnvs(env) diff --git a/internal/impl/generate/devcontainer_util.go b/internal/impl/generate/devcontainer_util.go index e549f452c38..f14d52db49d 100644 --- a/internal/impl/generate/devcontainer_util.go +++ b/internal/impl/generate/devcontainer_util.go @@ -17,6 +17,7 @@ import ( "runtime/trace" "strings" + "go.jetpack.io/devbox/internal/boxcli/featureflag" "go.jetpack.io/devbox/internal/debug" ) @@ -159,5 +160,9 @@ func getDevcontainerContent(pkgs []string) *devcontainerObject { func EnvrcContent(w io.Writer) error { tmplName := "envrcContent.tmpl" t := template.Must(template.ParseFS(tmplFS, "tmpl/"+tmplName)) - return t.Execute(w, nil) + return t.Execute(w, struct { + PromptHookEnabled bool + }{ + PromptHookEnabled: featureflag.PromptHook.Enabled(), + }) } diff --git a/internal/impl/generate/tmpl/envrcContent.tmpl b/internal/impl/generate/tmpl/envrcContent.tmpl index d12a28cf18d..7db2eed1d7a 100644 --- a/internal/impl/generate/tmpl/envrcContent.tmpl +++ b/internal/impl/generate/tmpl/envrcContent.tmpl @@ -1,5 +1,9 @@ use_devbox() { watch_file devbox.json + {{ .PromptHookEnabled }} + eval "$(devbox export --init-hook --install)" + {{ else }} eval "$(devbox shellenv --init-hook --install)" + {{ end }} } use devbox diff --git a/internal/impl/shell.go b/internal/impl/shell.go index 4d2c68034a4..4a9d836a16b 100644 --- a/internal/impl/shell.go +++ b/internal/impl/shell.go @@ -16,6 +16,8 @@ import ( "github.com/alessio/shellescape" "github.com/pkg/errors" + "go.jetpack.io/devbox/internal/boxcli/featureflag" + "go.jetpack.io/devbox/internal/shenv" "go.jetpack.io/devbox/internal/debug" "go.jetpack.io/devbox/internal/envir" @@ -314,21 +316,25 @@ func (s *DevboxShell) writeDevboxShellrc() (path string, err error) { } err = tmpl.Execute(shellrcf, struct { - ProjectDir string - OriginalInit string - OriginalInitPath string - HooksFilePath string - ShellStartTime string - HistoryFile string - ExportEnv string + ProjectDir string + OriginalInit string + OriginalInitPath string + HooksFilePath string + ShellName string + ShellStartTime string + HistoryFile string + ExportEnv string + PromptHookEnabled bool }{ - ProjectDir: s.projectDir, - OriginalInit: string(bytes.TrimSpace(userShellrc)), - OriginalInitPath: s.userShellrcPath, - HooksFilePath: s.hooksFilePath, - ShellStartTime: s.shellStartTime, - HistoryFile: strings.TrimSpace(s.historyFile), - ExportEnv: exportify(s.env), + ProjectDir: s.projectDir, + OriginalInit: string(bytes.TrimSpace(userShellrc)), + OriginalInitPath: s.userShellrcPath, + HooksFilePath: s.hooksFilePath, + ShellName: string(s.name), + ShellStartTime: s.shellStartTime, + HistoryFile: strings.TrimSpace(s.historyFile), + ExportEnv: exportify(s.env), + PromptHookEnabled: featureflag.PromptHook.Enabled(), }) if err != nil { return "", fmt.Errorf("execute shellrc template: %v", err) @@ -411,3 +417,23 @@ func filterPathList(pathList string, keep func(string) bool) string { } return strings.Join(filtered, string(filepath.ListSeparator)) } + +func (d *Devbox) ExportHook(shellName string) (string, error) { + if !featureflag.PromptHook.Enabled() { + return "", nil + } + + // TODO: use a single common "enum" for both shenv and DevboxShell + hookTemplate, err := shenv.DetectShell(shellName).Hook() + if err != nil { + return "", err + } + + var buf bytes.Buffer + err = template.Must(template.New("hookTemplate").Parse(hookTemplate)). + Execute(&buf, struct{ ProjectDir string }{ProjectDir: d.projectDir}) + if err != nil { + return "", errors.WithStack(err) + } + return buf.String(), nil +} diff --git a/internal/impl/shellrc.tmpl b/internal/impl/shellrc.tmpl index 3c044d5ac84..704f927a54b 100644 --- a/internal/impl/shellrc.tmpl +++ b/internal/impl/shellrc.tmpl @@ -67,3 +67,9 @@ if ! type refresh >/dev/null 2>&1; then alias refresh='eval $(devbox shellenv)' export DEVBOX_REFRESH_ALIAS="refresh" fi + +{{ if .PromptHookEnabled }} +# Ensure devbox shellenv is evaluated +# TODO savil: how do I wrap ProjectDir in quotes? +eval "$(devbox hook {{ .ShellName }} -c {{ .ProjectDir }})" +{{ end }} diff --git a/internal/impl/shellrc_fish.tmpl b/internal/impl/shellrc_fish.tmpl index 9cb373559c6..c28349c1d6c 100644 --- a/internal/impl/shellrc_fish.tmpl +++ b/internal/impl/shellrc_fish.tmpl @@ -70,3 +70,8 @@ if not type refresh >/dev/null 2>&1 alias refresh='eval (devbox shellenv | string collect)' export DEVBOX_REFRESH_ALIAS="refresh" end + +{{ if .PromptHookEnabled }} +# Ensure devbox shellenv is evaluated +devbox hook fish -c "{{ .ProjectDir }}" | source +{{ end }} diff --git a/internal/impl/testdata/shellrc/basic/shellrc.golden b/internal/impl/testdata/shellrc/basic/shellrc.golden index 948995d32a3..65c45c817ac 100644 --- a/internal/impl/testdata/shellrc/basic/shellrc.golden +++ b/internal/impl/testdata/shellrc/basic/shellrc.golden @@ -27,3 +27,5 @@ if ! type refresh >/dev/null 2>&1; then alias refresh='eval $(devbox shellenv)' export DEVBOX_REFRESH_ALIAS="refresh" fi + + diff --git a/internal/shenv/shell_bash.go b/internal/shenv/shell_bash.go index 295f08052b4..695bd5724d0 100644 --- a/internal/shenv/shell_bash.go +++ b/internal/shenv/shell_bash.go @@ -11,7 +11,7 @@ const bashHook = ` _devbox_hook() { local previous_exit_status=$?; trap -- '' SIGINT; - eval "$(devbox shellenv --config {{ .ProjectDir }})"; + eval "$(devbox export --config {{ .ProjectDir }})"; trap - SIGINT; return $previous_exit_status; }; diff --git a/internal/shenv/shell_fish.go b/internal/shenv/shell_fish.go index c39261ce248..92971a727f8 100644 --- a/internal/shenv/shell_fish.go +++ b/internal/shenv/shell_fish.go @@ -12,7 +12,7 @@ var Fish Shell = fish{} const fishHook = ` function __devbox_shellenv_eval --on-event fish_prompt; - devbox shellenv --config {{ .ProjectDir }} | source; + devbox export --config {{ .ProjectDir }} | source; end; ` diff --git a/internal/shenv/shell_ksh.go b/internal/shenv/shell_ksh.go index 2e246084828..f128e056149 100644 --- a/internal/shenv/shell_ksh.go +++ b/internal/shenv/shell_ksh.go @@ -8,7 +8,7 @@ var Ksh Shell = ksh{} // um, this is ChatGPT writing it. I need to verify and test const kshHook = ` _devbox_hook() { - eval "$(devbox shellenv --config {{ .ProjectDir }})"; + eval "$(devbox export --config {{ .ProjectDir }})"; } if [[ "$(typeset -f precmd)" != *"_devbox_hook"* ]]; then function precmd { diff --git a/internal/shenv/shell_posix.go b/internal/shenv/shell_posix.go index 194eae68f35..9f4148ed598 100644 --- a/internal/shenv/shell_posix.go +++ b/internal/shenv/shell_posix.go @@ -12,7 +12,7 @@ const posixHook = ` _devbox_hook() { local previous_exit_status=$? trap : INT - eval "$(devbox shellenv --config {{ .ProjectDir }})" + eval "$(devbox export --config {{ .ProjectDir }})" trap - INT return $previous_exit_status } diff --git a/internal/shenv/shell_zsh.go b/internal/shenv/shell_zsh.go index 936b2846a3c..76522f2aa03 100644 --- a/internal/shenv/shell_zsh.go +++ b/internal/shenv/shell_zsh.go @@ -9,7 +9,7 @@ var Zsh Shell = zsh{} const zshHook = ` _devbox_hook() { trap -- '' SIGINT; - eval "$(devbox shellenv --config {{ .ProjectDir }})"; + eval "$(devbox export --config {{ .ProjectDir }})"; trap - SIGINT; } typeset -ag precmd_functions; From 0a6aa092277804d7ce2e69421ab89f6a17f941e6 Mon Sep 17 00:00:00 2001 From: Savil Srivastava <676452+savil@users.noreply.github.com> Date: Mon, 26 Jun 2023 11:38:48 -0700 Subject: [PATCH 2/2] test fix --- internal/impl/shellrc.tmpl | 2 +- internal/impl/testdata/shellrc/basic/shellrc.golden | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/impl/shellrc.tmpl b/internal/impl/shellrc.tmpl index 704f927a54b..cb4fe9fba61 100644 --- a/internal/impl/shellrc.tmpl +++ b/internal/impl/shellrc.tmpl @@ -68,7 +68,7 @@ if ! type refresh >/dev/null 2>&1; then export DEVBOX_REFRESH_ALIAS="refresh" fi -{{ if .PromptHookEnabled }} +{{- if .PromptHookEnabled }} # Ensure devbox shellenv is evaluated # TODO savil: how do I wrap ProjectDir in quotes? eval "$(devbox hook {{ .ShellName }} -c {{ .ProjectDir }})" diff --git a/internal/impl/testdata/shellrc/basic/shellrc.golden b/internal/impl/testdata/shellrc/basic/shellrc.golden index 65c45c817ac..948995d32a3 100644 --- a/internal/impl/testdata/shellrc/basic/shellrc.golden +++ b/internal/impl/testdata/shellrc/basic/shellrc.golden @@ -27,5 +27,3 @@ if ! type refresh >/dev/null 2>&1; then alias refresh='eval $(devbox shellenv)' export DEVBOX_REFRESH_ALIAS="refresh" fi - -