Skip to content

Add --envrc-dir flag to allow specifying location of direnv config #2629

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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
75 changes: 70 additions & 5 deletions internal/boxcli/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package boxcli
import (
"cmp"
"fmt"
"path/filepath"
"regexp"

"github.com/pkg/errors"
Expand All @@ -24,6 +25,7 @@ type generateCmdFlags struct {
force bool
printEnvrcContent bool
rootUser bool
envrcDir string // only used by generate direnv command
}

type generateDockerfileCmdFlags struct {
Expand Down Expand Up @@ -147,10 +149,22 @@ func direnvCmd() *cobra.Command {
command.Flags().BoolVarP(
&flags.force, "force", "f", false, "force overwrite existing files")
command.Flags().BoolVarP(
&flags.printEnvrcContent, "print-envrc", "p", false, "output contents of devbox configuration to use in .envrc")
&flags.printEnvrcContent, "print-envrc", "p", false,
"output contents of devbox configuration to use in .envrc")
// this command marks a flag as hidden. Error handling for it is not necessary.
_ = command.Flags().MarkHidden("print-envrc")

// --envrc-dir allows users to specify a directory where the .envrc file should be generated
// separately from the devbox config directory. Without this flag, the .envrc file
// will be generated in the same directory as the devbox config file (i.e., either the current
// directory or the directory specified by --config). This is useful for users who want to keep
// their .envrc and devbox config files in different locations.
command.Flags().StringVar(
&flags.envrcDir, "envrc-dir", "",
"path to directory where the .envrc file should be generated. "+
"If not specified, the .envrc file will be generated in "+
"the current working directory.")

Comment on lines +164 to +167
Copy link
Contributor

Choose a reason for hiding this comment

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

Should be:

"If not specified, the .envrc file will be generated in "+
			"the same directory as the devbox.json")

Since --config will determine where the .envrc file is created, which may not match the working directory.

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe also clarify that if this flag is used, then we use relative path for --config

flags.config.register(command)
return command
}
Expand Down Expand Up @@ -266,13 +280,32 @@ func runGenerateCmd(cmd *cobra.Command, flags *generateCmdFlags) error {
}

func runGenerateDirenvCmd(cmd *cobra.Command, flags *generateCmdFlags) error {
// --print-envrc is used within the .envrc file and therefore doesn't make sense to also
// use it with --envrc-dir, which specifies a directory where the .envrc file should be generated.
if flags.printEnvrcContent && flags.envrcDir != "" {
return usererr.New(
"Cannot use --print-envrc with --envrc-dir. " +
"Use --envrc-dir to specify the directory where the .envrc file should be generated.")
}

// Determine the directories for .envrc and config
configDir, envrcDir, err := determineDirenvDirs(flags.config.path, flags.envrcDir)
Copy link
Contributor

Choose a reason for hiding this comment

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

I think determineDirenvDirs can be removed and just compute the relative path internally when generating the .envrc

if err != nil {
return errors.WithStack(err)
}

generateOpts := devopt.EnvrcOpts{
EnvrcDir: envrcDir,
ConfigDir: configDir,
Copy link
Contributor

Choose a reason for hiding this comment

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

no need to supply ConfigDir since it is already supplied by devopt.Opts

Copy link
Contributor

Choose a reason for hiding this comment

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

This is also a relative path (but named ConfigDir) which I'm afraid would get really confusing later on.

EnvFlags: devopt.EnvFlags(flags.envFlag),
}

if flags.printEnvrcContent {
return devbox.PrintEnvrcContent(
cmd.OutOrStdout(), devopt.EnvFlags(flags.envFlag))
return devbox.PrintEnvrcContent(cmd.OutOrStdout(), generateOpts)
}

box, err := devbox.Open(&devopt.Opts{
Dir: flags.config.path,
Dir: filepath.Join(envrcDir, configDir),
Copy link
Contributor

Choose a reason for hiding this comment

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

keep this as flags.config.path,

Environment: flags.config.environment,
Stderr: cmd.ErrOrStderr(),
})
Expand All @@ -281,5 +314,37 @@ func runGenerateDirenvCmd(cmd *cobra.Command, flags *generateCmdFlags) error {
}

return box.GenerateEnvrcFile(
cmd.Context(), flags.force, devopt.EnvFlags(flags.envFlag))
cmd.Context(), flags.force, generateOpts)
}

// Returns cononical paths for configDir and envrcDir. Both locations are relative to the current
Copy link
Preview

Copilot AI Jul 1, 2025

Choose a reason for hiding this comment

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

Fix the typo 'cononical' to 'canonical' in the comment for determineDirenvDirs.

Suggested change
// Returns cononical paths for configDir and envrcDir. Both locations are relative to the current
// Returns canonical paths for configDir and envrcDir. Both locations are relative to the current

Copilot uses AI. Check for mistakes.

// working directory when provided to this function. However, since the config file will ultimately
// be relative to the .envrc file, we need to determine the relative path from envrcDir to configDir.
func determineDirenvDirs(configDir, envrcDir string) (string, string, error) {
// If envrcDir is not specified, we will use the configDir as the location for .envrc. This is
// for backward compatibility (prior to the --envrc-dir flag being introduced).
if envrcDir == "" {
return "", configDir, nil
}

// If no configDir is specified, it will be assumed to be in the same directory as the .envrc file
// which means we can just return an empty configDir.
if configDir == "" {
return "", envrcDir, nil
}
Comment on lines +330 to +334
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure I agree with the semantics of this. --envrc-dir should dictate where the .envrc is created, not which config is used. The default for all devbox commands is the use the devbox.json in the current directory and if it doesn't exist, recursively check parent directories. I think we should preserve that.

In practice that means that using --envrc-dir and not using configDir will use the "current' devbox project, but create the .envrc where specified.


relativeConfigDir, err := filepath.Rel(envrcDir, configDir)
if err != nil {
return "", "", errors.Wrapf(err, "failed to determine relative path from %s to %s", envrcDir, configDir)
}

// If the relative path is ".", it means configDir is the same as envrcDir. Leaving it as "."
// will result in the .envrc containing "--config .", which is fine, but unnecessary and also
// a change from the previous behavior. So we will return an empty string for relativeConfigDir
// which will result in the .envrc file not containing the "--config" flag at all.
if relativeConfigDir == "." {
relativeConfigDir = ""
}
Comment on lines +336 to +347
Copy link
Contributor

Choose a reason for hiding this comment

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

You're computing the relative path, and then joining again at the call site. Instead of precomputing the relative path, I think it would be simpler to do that when the .envrc is generated.


return relativeConfigDir, envrcDir, nil
}
20 changes: 10 additions & 10 deletions internal/devbox/devbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -527,21 +527,21 @@ func (d *Devbox) GenerateDockerfile(ctx context.Context, generateOpts devopt.Gen
}))
}

func PrintEnvrcContent(w io.Writer, envFlags devopt.EnvFlags) error {
return generate.EnvrcContent(w, envFlags)
func PrintEnvrcContent(w io.Writer, opts devopt.EnvrcOpts) error {
return generate.EnvrcContent(w, opts)
}

// GenerateEnvrcFile generates a .envrc file that makes direnv integration convenient
func (d *Devbox) GenerateEnvrcFile(ctx context.Context, force bool, envFlags devopt.EnvFlags) error {
func (d *Devbox) GenerateEnvrcFile(ctx context.Context, force bool, opts devopt.EnvrcOpts) error {
ctx, task := trace.NewTask(ctx, "devboxGenerateEnvrc")
defer task.End()

envrcfilePath := filepath.Join(d.projectDir, ".envrc")
envrcfilePath := filepath.Join(opts.EnvrcDir, ".envrc")
filesExist := fileutil.Exists(envrcfilePath)
if !force && filesExist {
return usererr.New(
"A .envrc is already present in the current directory. " +
"Remove it or use --force to overwrite it.",
"A .envrc is already present in %q. Remove it or use --force to overwrite it.",
opts.EnvrcDir,
)
}

Expand All @@ -551,18 +551,18 @@ func (d *Devbox) GenerateEnvrcFile(ctx context.Context, force bool, envFlags dev
}

// .envrc file creation
err := generate.CreateEnvrc(ctx, d.projectDir, envFlags)
err := generate.CreateEnvrc(ctx, opts)
if err != nil {
return errors.WithStack(err)
}
ux.Fsuccessf(d.stderr, "generated .envrc file\n")
ux.Fsuccessf(d.stderr, "generated .envrc file in %q.\n", opts.EnvrcDir)
if cmdutil.Exists("direnv") {
cmd := exec.Command("direnv", "allow")
cmd := exec.Command("direnv", "allow", opts.EnvrcDir)
err := cmd.Run()
if err != nil {
return errors.WithStack(err)
}
ux.Fsuccessf(d.stderr, "ran `direnv allow`\n")
ux.Fsuccessf(d.stderr, "ran `direnv allow %s`\n", opts.EnvrcDir)
}
return nil
}
Expand Down
6 changes: 6 additions & 0 deletions internal/devbox/devopt/devboxopts.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ type EnvFlags struct {
EnvFile string
}

type EnvrcOpts struct {
EnvFlags
EnvrcDir string
ConfigDir string
}

type PullboxOpts struct {
Overwrite bool
URL string
Expand Down
16 changes: 9 additions & 7 deletions internal/devbox/generate/devcontainer_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,32 +140,33 @@ func (g *Options) CreateDevcontainer(ctx context.Context) error {
return err
}

func CreateEnvrc(ctx context.Context, path string, envFlags devopt.EnvFlags) error {
func CreateEnvrc(ctx context.Context, opts devopt.EnvrcOpts) error {
defer trace.StartRegion(ctx, "createEnvrc").End()

// create .envrc file
file, err := os.Create(filepath.Join(path, ".envrc"))
file, err := os.Create(filepath.Join(opts.EnvrcDir, ".envrc"))
if err != nil {
return err
}
defer file.Close()

flags := []string{}

if len(envFlags.EnvMap) > 0 {
for k, v := range envFlags.EnvMap {
if len(opts.EnvMap) > 0 {
for k, v := range opts.EnvMap {
flags = append(flags, fmt.Sprintf("--env %s=%s", k, v))
}
}
if envFlags.EnvFile != "" {
flags = append(flags, fmt.Sprintf("--env-file %s", envFlags.EnvFile))
if opts.EnvFile != "" {
flags = append(flags, fmt.Sprintf("--env-file %s", opts.EnvFile))
}

t := template.Must(template.ParseFS(tmplFS, "tmpl/envrc.tmpl"))

// write content into file
return t.Execute(file, map[string]string{
"Flags": strings.Join(flags, " "),
"Dir": opts.ConfigDir,
Copy link
Contributor

Choose a reason for hiding this comment

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

Name this ConfigDir to reduce ambiguity.

Compute relative path here instead of generate.go

Copy link
Contributor

Choose a reason for hiding this comment

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

Make empty if envrcDir is not specified.

})
}

Expand Down Expand Up @@ -219,7 +220,7 @@ func (g *Options) getDevcontainerContent() *devcontainerObject {
return devcontainerContent
}

func EnvrcContent(w io.Writer, envFlags devopt.EnvFlags) error {
func EnvrcContent(w io.Writer, envFlags devopt.EnvrcOpts) error {
tmplName := "envrcContent.tmpl"
t := template.Must(template.ParseFS(tmplFS, "tmpl/"+tmplName))
envFlag := ""
Expand All @@ -231,5 +232,6 @@ func EnvrcContent(w io.Writer, envFlags devopt.EnvFlags) error {
return t.Execute(w, map[string]string{
"EnvFlag": envFlag,
"EnvFile": envFlags.EnvFile,
"Dir": envFlags.EnvrcDir,
})
}
2 changes: 1 addition & 1 deletion internal/devbox/generate/tmpl/envrc.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Automatically sets up your devbox environment whenever you cd into this
# directory via our direnv integration:

eval "$(devbox generate direnv --print-envrc{{ if .Flags}} {{ .Flags }}{{ end }})"
eval "$(devbox generate direnv --print-envrc{{ if .Flags}} {{ .Flags }}{{ end }}{{ if .Dir }} --config {{ .Dir -}}{{ end }})"

# check out https://www.jetpack.io/devbox/docs/ide_configuration/direnv/
# for more details
5 changes: 3 additions & 2 deletions internal/devbox/generate/tmpl/envrcContent.tmpl
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{{define "DirPrefix"}}{{ if .Dir }}{{ .Dir }}/{{ end }}{{end}}
use_devbox() {
watch_file devbox.json devbox.lock
eval "$(devbox shellenv --init-hook --install --no-refresh-alias{{ if .EnvFlag }} {{ .EnvFlag }}{{ end }})"
watch_file {{template "DirPrefix" .}}devbox.json {{template "DirPrefix" .}}devbox.lock
eval "$(devbox shellenv --init-hook --install --no-refresh-alias {{ if .EnvFlag }}{{ .EnvFlag }}{{ end }} {{- if .Dir }}--config {{ .Dir -}}{{ end }})"
Comment on lines +1 to +4
Copy link
Contributor

Choose a reason for hiding this comment

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

Templates are pretty hard to read. Ideally this is computed in Go code and just passed in. e.g.

watch_file {{ .RelativeDevboxJSONPath }} {{ .RelativeDevboxLockPath }}

You could do similar with --config flag.

}
use devbox
{{ if .EnvFile }}
Expand Down
10 changes: 10 additions & 0 deletions testscripts/generate/direnv-envrcdir-config-sibling.test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Testscript to validate generating a direnv .envrc in a specified location (./dir) that also
# references a devbox config in another dir (./cfg) that is a sibling to the first.

mkdir cfg
exec devbox init cfg
exists cfg/devbox.json

mkdir dir
exec devbox generate direnv --envrc-dir dir --config cfg
grep 'eval "\$\(devbox generate direnv --print-envrc --config ../cfg\)"' dir/.envrc
9 changes: 9 additions & 0 deletions testscripts/generate/direnv-envrcdir-config-subdir.test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Testscript to validate generating a direnv .envrc in a specified location (./dir) that also
# references a devbox config in another dir (./cfg) that is a subdir of the first.

mkdir dir/cfg
exec devbox init dir/cfg
exists dir/cfg/devbox.json

exec devbox generate direnv --envrc-dir dir --config dir/cfg
grep 'eval "\$\(devbox generate direnv --print-envrc --config cfg\)"' dir/.envrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Testscript to validate generating a direnv .envrc in the current location that
# references a devbox config in a subdir (./cfg).

mkdir cfg
exec devbox init cfg
exists cfg/devbox.json

exec devbox generate direnv --envrc-dir . --config cfg
grep 'eval "\$\(devbox generate direnv --print-envrc --config cfg\)"' ./.envrc
9 changes: 9 additions & 0 deletions testscripts/generate/direnv-envrcdir.test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Testscript to validate generating a direnv .envrc in a specified location (./cfg) that also
# references a devbox config in the same location (no --config needed)

mkdir cfg
exec devbox init cfg
exists cfg/devbox.json

exec devbox generate direnv --envrc-dir cfg
grep 'eval "\$\(devbox generate direnv --print-envrc\)"' cfg/.envrc
11 changes: 11 additions & 0 deletions testscripts/generate/direnv-printenvrc-config.test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Testscript to validate generating the contents of the .envrc file. In this case the
# config location is ignored because there is no --envrc-dir param.

exec devbox init
exec devbox generate direnv --print-envrc --config config-dir
cp stdout results.txt
grep 'watch_file config-dir/devbox.json config-dir/devbox.lock' results.txt
grep 'eval "\$\(devbox shellenv --init-hook --install --no-refresh-alias --config config-dir\)"' results.txt
! exists .envrc
! exists config-dir
! exists config-dir/.envrc
6 changes: 6 additions & 0 deletions testscripts/generate/direnv-printenvrc-envrcdir.test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Testscript to validate that the --print-envrc and --envrc-dir params are not allowed
# to be used at the same time.

exec devbox init
! exec devbox generate direnv --print-envrc --envrc-dir dir
stderr 'Cannot use --print-envrc with --envrc-dir'
8 changes: 8 additions & 0 deletions testscripts/generate/direnv-printenvrc.test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Testscript to validate the output of --print-envrc

exec devbox init
exec devbox generate direnv --print-envrc
cp stdout results.txt
grep 'watch_file devbox.json devbox.lock' results.txt
grep 'eval "\$\(devbox shellenv --init-hook --install --no-refresh-alias \)"' results.txt
! exists .envrc
5 changes: 4 additions & 1 deletion testscripts/generate/direnv.test.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# Testscript to validate generating the contents of the .envrc file.

exec devbox init
exec devbox generate direnv
exists .envrc

exists devbox.json
grep 'eval "\$\(devbox generate direnv --print-envrc\)"' .envrc
Loading