Skip to content

[RFC][direnv-inspired] introduce export and hook commands, use in devbox shell, global and direnv #1172

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions devbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions internal/boxcli/export.go
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 5 additions & 0 deletions internal/boxcli/featureflag/prompt_hook.go
Original file line number Diff line number Diff line change
@@ -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")
6 changes: 5 additions & 1 deletion internal/boxcli/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func globalCmd() *cobra.Command {
}

addCommandAndHideConfigFlag(globalCmd, addCmd())
addCommandAndHideConfigFlag(globalCmd, hookCmd())
addCommandAndHideConfigFlag(globalCmd, installCmd())
addCommandAndHideConfigFlag(globalCmd, pathCmd())
addCommandAndHideConfigFlag(globalCmd, pullCmd())
Expand Down Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions internal/boxcli/hook.go
Original file line number Diff line number Diff line change
@@ -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]",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another possible name for this command is activate (it's what https://github.com/jdxcode/rtx uses)

Leave as hook, and let's get the PR in, but I wanna get John's thoughts on which one is the better name, before we remove the feature flag and start transitioning people.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, agree on activate. Please see my RFC proposal in the PR summary for reasons why I like it...

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])
}
2 changes: 2 additions & 0 deletions internal/boxcli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
12 changes: 8 additions & 4 deletions internal/boxcli/shellenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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) {
Expand Down
28 changes: 15 additions & 13 deletions internal/impl/devbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion internal/impl/generate/devcontainer_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"runtime/trace"
"strings"

"go.jetpack.io/devbox/internal/boxcli/featureflag"
"go.jetpack.io/devbox/internal/debug"
)

Expand Down Expand Up @@ -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(),
})
}
4 changes: 4 additions & 0 deletions internal/impl/generate/tmpl/envrcContent.tmpl
Original file line number Diff line number Diff line change
@@ -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
54 changes: 40 additions & 14 deletions internal/impl/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
6 changes: 6 additions & 0 deletions internal/impl/shellrc.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
5 changes: 5 additions & 0 deletions internal/impl/shellrc_fish.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
2 changes: 1 addition & 1 deletion internal/shenv/shell_bash.go
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
2 changes: 1 addition & 1 deletion internal/shenv/shell_fish.go
Original file line number Diff line number Diff line change
Expand Up @@ -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;
`

Expand Down
2 changes: 1 addition & 1 deletion internal/shenv/shell_ksh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/shenv/shell_posix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion internal/shenv/shell_zsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down