diff --git a/boxcli/add.go b/boxcli/add.go index 96235ba951c..0b923268adf 100644 --- a/boxcli/add.go +++ b/boxcli/add.go @@ -11,25 +11,32 @@ import ( "go.jetpack.io/devbox" ) +type addCmdFlags struct { + config configFlags +} + func AddCmd() *cobra.Command { + flags := addCmdFlags{} + command := &cobra.Command{ Use: "add ...", Short: "Add a new package to your devbox", Args: cobra.MinimumNArgs(1), PersistentPreRunE: nixShellPersistentPreRunE, - RunE: addCmdFunc(), + RunE: func(cmd *cobra.Command, args []string) error { + return addCmdFunc(cmd, args, flags) + }, } + flags.config.register(command) return command } -func addCmdFunc() runFunc { - return func(cmd *cobra.Command, args []string) error { - box, err := devbox.Open(".", os.Stdout) - if err != nil { - return errors.WithStack(err) - } - - return box.Add(args...) +func addCmdFunc(_ *cobra.Command, args []string, flags addCmdFlags) error { + box, err := devbox.Open(flags.config.path, os.Stdout) + if err != nil { + return errors.WithStack(err) } + + return box.Add(args...) } diff --git a/boxcli/args.go b/boxcli/args.go index 4de212d2aeb..34b0de7afe9 100644 --- a/boxcli/args.go +++ b/boxcli/args.go @@ -4,15 +4,40 @@ package boxcli import ( + "fmt" "path/filepath" + "github.com/fatih/color" "github.com/pkg/errors" + "go.jetpack.io/devbox/boxcli/usererr" ) // Functions that help parse arguments // If args empty, defaults to the current directory // Otherwise grabs the path from the first argument +func configPathFromUser(args []string, flags *configFlags) (string, error) { + + if flags.path != "" && len(args) > 0 { + return "", usererr.New( + "Cannot specify devbox.json's path via both --config and the command arguments. " + + "Please use --config only.", + ) + } + + if flags.path != "" { + return flags.path, nil + } + + if len(args) > 0 { + fmt.Printf( + "%s devbox is deprecated, use devbox --config instead\n", + color.HiYellowString("Warning:"), + ) + } + return pathArg(args), nil +} + func pathArg(args []string) string { if len(args) > 0 { p, err := filepath.Abs(args[0]) @@ -21,5 +46,5 @@ func pathArg(args []string) string { } return p } - return "." + return "" } diff --git a/boxcli/build.go b/boxcli/build.go index cd4542da452..9dced1d76eb 100644 --- a/boxcli/build.go +++ b/boxcli/build.go @@ -12,39 +12,48 @@ import ( "go.jetpack.io/devbox/docker" ) +type buildCmdFlags struct { + config configFlags + docker docker.BuildFlags +} + func BuildCmd() *cobra.Command { - flags := &docker.BuildFlags{} + flags := buildCmdFlags{} command := &cobra.Command{ - Use: "build []", + Use: "build", Short: "Build an OCI image that can run as a container", Long: "Builds your current source directory and devbox configuration as a Docker container. Devbox will create a plan for your container based on your source code, and then apply the packages and stage overrides in your devbox.json. \n To learn more about how to configure your builds, see the [configuration reference](/docs/configuration_reference)", Args: cobra.MaximumNArgs(1), - RunE: buildCmdFunc(flags), + RunE: func(cmd *cobra.Command, args []string) error { + return buildCmdFunc(cmd, args, flags) + }, } + flags.config.register(command) command.Flags().StringVar( - &flags.Name, "name", "devbox", "name for the container") + &flags.docker.Name, "name", "devbox", "name for the container") command.Flags().BoolVar( - &flags.NoCache, "no-cache", false, "Do not use a cache") + &flags.docker.NoCache, "no-cache", false, "Do not use a cache") command.Flags().StringVar( - &flags.Engine, "engine", "docker", "Engine used to build the container: 'docker', 'podman'") + &flags.docker.Engine, "engine", "docker", "Engine used to build the container: 'docker', 'podman'") command.Flags().StringSliceVar( - &flags.Tags, "tags", []string{}, "tags for the container") + &flags.docker.Tags, "tags", []string{}, "tags for the container") return command } -func buildCmdFunc(flags *docker.BuildFlags) runFunc { - return func(cmd *cobra.Command, args []string) error { - path := pathArg(args) - - // Check the directory exists. - box, err := devbox.Open(path, os.Stdout) - if err != nil { - return errors.WithStack(err) - } +func buildCmdFunc(_ *cobra.Command, args []string, flags buildCmdFlags) error { + path, err := configPathFromUser(args, &flags.config) + if err != nil { + return err + } - return box.Build(flags) + // Check the directory exists. + box, err := devbox.Open(path, os.Stdout) + if err != nil { + return errors.WithStack(err) } + + return box.Build(&flags.docker) } diff --git a/boxcli/config.go b/boxcli/config.go new file mode 100644 index 00000000000..3a2cc19bdd9 --- /dev/null +++ b/boxcli/config.go @@ -0,0 +1,16 @@ +package boxcli + +import ( + "github.com/spf13/cobra" +) + +// to be composed into xyzCmdFlags structs +type configFlags struct { + path string +} + +func (flags *configFlags) register(cmd *cobra.Command) { + cmd.Flags().StringVarP( + &flags.path, "config", "c", "", "path to directory containing a devbox.json config file", + ) +} diff --git a/boxcli/generate.go b/boxcli/generate.go index a2e7b024d8b..e780cae9482 100644 --- a/boxcli/generate.go +++ b/boxcli/generate.go @@ -11,18 +11,32 @@ import ( "go.jetpack.io/devbox" ) +type generateCmdFlags struct { + config configFlags +} + func GenerateCmd() *cobra.Command { + flags := &generateCmdFlags{} + command := &cobra.Command{ - Use: "generate []", + Use: "generate", Args: cobra.MaximumNArgs(1), Hidden: true, // For debugging only - RunE: runGenerateCmd, + RunE: func(cmd *cobra.Command, args []string) error { + return runGenerateCmd(cmd, args, flags) + }, } + + flags.config.register(command) + return command } -func runGenerateCmd(cmd *cobra.Command, args []string) error { - path := pathArg(args) +func runGenerateCmd(_ *cobra.Command, args []string, flags *generateCmdFlags) error { + path, err := configPathFromUser(args, &flags.config) + if err != nil { + return err + } // Check the directory exists. box, err := devbox.Open(path, os.Stdout) diff --git a/boxcli/init.go b/boxcli/init.go index baff098f173..87fa9fb530d 100644 --- a/boxcli/init.go +++ b/boxcli/init.go @@ -15,12 +15,15 @@ func InitCmd() *cobra.Command { Short: "Initialize a directory as a devbox project", Long: "Initialize a directory as a devbox project. This will create an empty devbox.json in the current directory. You can then add packages using `devbox add`", Args: cobra.MaximumNArgs(1), - RunE: runInitCmd, + RunE: func(cmd *cobra.Command, args []string) error { + return runInitCmd(cmd, args) + }, } + return command } -func runInitCmd(cmd *cobra.Command, args []string) error { +func runInitCmd(_ *cobra.Command, args []string) error { path := pathArg(args) _, err := devbox.InitConfig(path) diff --git a/boxcli/plan.go b/boxcli/plan.go index db921e3fc98..f7d10abb939 100644 --- a/boxcli/plan.go +++ b/boxcli/plan.go @@ -12,18 +12,31 @@ import ( "go.jetpack.io/devbox" ) +type planCmdFlags struct { + config configFlags +} + func PlanCmd() *cobra.Command { + flags := planCmdFlags{} + command := &cobra.Command{ - Use: "plan []", + Use: "plan", Short: "Preview the plan used to build your environment", Args: cobra.MaximumNArgs(1), - RunE: runPlanCmd, + RunE: func(cmd *cobra.Command, args []string) error { + return runPlanCmd(cmd, args, flags) + }, } + + flags.config.register(command) return command } -func runPlanCmd(cmd *cobra.Command, args []string) error { - path := pathArg(args) +func runPlanCmd(_ *cobra.Command, args []string, flags planCmdFlags) error { + path, err := configPathFromUser(args, &flags.config) + if err != nil { + return err + } // Check the directory exists. box, err := devbox.Open(path, os.Stdout) diff --git a/boxcli/rm.go b/boxcli/rm.go index 8c7b57a345c..a668b1b603d 100644 --- a/boxcli/rm.go +++ b/boxcli/rm.go @@ -11,18 +11,27 @@ import ( "go.jetpack.io/devbox" ) +type removeCmdFlags struct { + config configFlags +} + func RemoveCmd() *cobra.Command { + flags := removeCmdFlags{} command := &cobra.Command{ Use: "rm ...", Short: "Remove a package from your devbox", Args: cobra.MinimumNArgs(1), - RunE: runRemoveCmd, + RunE: func(cmd *cobra.Command, args []string) error { + return runRemoveCmd(cmd, args, flags) + }, } + + flags.config.register(command) return command } -func runRemoveCmd(cmd *cobra.Command, args []string) error { - box, err := devbox.Open(".", os.Stdout) +func runRemoveCmd(_ *cobra.Command, args []string, flags removeCmdFlags) error { + box, err := devbox.Open(flags.config.path, os.Stdout) if err != nil { return errors.WithStack(err) } diff --git a/boxcli/root.go b/boxcli/root.go index 6b295e2bcc7..4bd627f3f38 100644 --- a/boxcli/root.go +++ b/boxcli/root.go @@ -56,5 +56,3 @@ func Main() { code := Execute(context.Background(), os.Args[1:]) os.Exit(code) } - -type runFunc func(cmd *cobra.Command, args []string) error diff --git a/boxcli/shell.go b/boxcli/shell.go index 57979065ccc..b712c2b0a3a 100644 --- a/boxcli/shell.go +++ b/boxcli/shell.go @@ -13,56 +13,66 @@ import ( "go.jetpack.io/devbox" ) -type shellFlags struct { +type shellCmdFlags struct { + config configFlags PrintEnv bool } func ShellCmd() *cobra.Command { - flags := &shellFlags{} + flags := shellCmdFlags{} command := &cobra.Command{ - Use: "shell [] -- []", - Short: "Start a new shell or run a command with access to your packages", - Long: "Start a new shell or run a command with access to your packages. \nIf invoked without `cmd`, this will start an interactive shell based on the devbox.json in your current directory, or the directory provided with `dir`. \nIf invoked with a `cmd`, this will start a shell based on the devbox.json provided in `dir`, run the command, and then exit.", + Use: "shell -- []", + Short: "Start a new shell or run a command with access to your packages", + Long: "Start a new shell or run a command with access to your packages. \n" + + "If invoked without `cmd`, devbox will start an interactive shell.\n" + + "If invoked with a `cmd`, devbox will run the command in a shell and then exit.\n" + + "In both cases, the shell will be started using the devbox.json found in the --config flag directory. " + + "If --config isn't set, then devbox recursively searches the current directory and its parents.", Args: validateShellArgs, PersistentPreRunE: nixShellPersistentPreRunE, - RunE: runShellCmd(flags), + RunE: func(cmd *cobra.Command, args []string) error { + return runShellCmd(cmd, args, flags) + }, } + command.Flags().BoolVar( &flags.PrintEnv, "print-env", false, "Print script to setup shell environment") + flags.config.register(command) return command } -func runShellCmd(flags *shellFlags) runFunc { - return func(cmd *cobra.Command, args []string) error { - path, cmds := parseShellArgs(cmd, args) +func runShellCmd(cmd *cobra.Command, args []string, flags shellCmdFlags) error { + path, cmds, err := parseShellArgs(cmd, args, flags) + if err != nil { + return err + } - // Check the directory exists. - box, err := devbox.Open(path, os.Stdout) - if err != nil { - return errors.WithStack(err) - } + // Check the directory exists. + box, err := devbox.Open(path, os.Stdout) + if err != nil { + return errors.WithStack(err) + } - if flags.PrintEnv { - // return here to prevent opening a devbox shell - return box.PrintShellEnv() - } + if flags.PrintEnv { + // return here to prevent opening a devbox shell + return box.PrintShellEnv() + } - if devbox.IsDevboxShellEnabled() { - return errors.New("You are already in an active devbox shell.\nRun 'exit' before calling devbox shell again. Shell inception is not supported.") - } + if devbox.IsDevboxShellEnabled() { + return errors.New("You are already in an active devbox shell.\nRun 'exit' before calling devbox shell again. Shell inception is not supported.") + } - if len(cmds) > 0 { - err = box.Exec(cmds...) - } else { - err = box.Shell() - } + if len(cmds) > 0 { + err = box.Exec(cmds...) + } else { + err = box.Shell() + } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return nil - } - return err + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return nil } + return err } func nixShellPersistentPreRunE(cmd *cobra.Command, args []string) error { @@ -81,14 +91,21 @@ func validateShellArgs(cmd *cobra.Command, args []string) error { return nil } -func parseShellArgs(cmd *cobra.Command, args []string) (string, []string) { +func parseShellArgs(cmd *cobra.Command, args []string, flags shellCmdFlags) (string, []string, error) { index := cmd.ArgsLenAtDash() if index < 0 { - return pathArg(args), []string{} + configPath, err := configPathFromUser(args, &flags.config) + if err != nil { + return "", nil, err + } + return configPath, []string{}, nil } - path := pathArg(args[:index]) + path, err := configPathFromUser(args[:index], &flags.config) + if err != nil { + return "", nil, err + } cmds := args[index:] - return path, cmds + return path, cmds, nil } diff --git a/boxcli/version.go b/boxcli/version.go index c1337193a61..30d9575f2a2 100644 --- a/boxcli/version.go +++ b/boxcli/version.go @@ -11,13 +11,19 @@ import ( "go.jetpack.io/devbox/build" ) +type versionFlags struct { + verbose bool +} + func VersionCmd() *cobra.Command { - flags := &versionFlags{} + flags := versionFlags{} command := &cobra.Command{ Use: "version", Short: "Print version information", Args: cobra.NoArgs, - RunE: versionCmdFunc(flags), + RunE: func(cmd *cobra.Command, args []string) error { + return versionCmdFunc(cmd, args, flags) + }, } command.Flags().BoolVarP(&flags.verbose, "verbose", "v", false, // value @@ -26,24 +32,18 @@ func VersionCmd() *cobra.Command { return command } -type versionFlags struct { - verbose bool -} - -func versionCmdFunc(flags *versionFlags) runFunc { - return func(cmd *cobra.Command, args []string) error { - v := getVersionInfo() - if flags.verbose { - fmt.Printf("Version: %v\n", v.Version) - fmt.Printf("Platform: %v\n", v.Platform) - fmt.Printf("Commit: %v\n", v.Commit) - fmt.Printf("Commit Time: %v\n", v.CommitDate) - fmt.Printf("Go Version: %v\n", v.GoVersion) - } else { - fmt.Printf("%v\n", v.Version) - } - return nil +func versionCmdFunc(_ *cobra.Command, _ []string, flags versionFlags) error { + v := getVersionInfo() + if flags.verbose { + fmt.Printf("Version: %v\n", v.Version) + fmt.Printf("Platform: %v\n", v.Platform) + fmt.Printf("Commit: %v\n", v.Commit) + fmt.Printf("Commit Time: %v\n", v.CommitDate) + fmt.Printf("Go Version: %v\n", v.GoVersion) + } else { + fmt.Printf("%v\n", v.Version) } + return nil } type versionInfo struct { diff --git a/config.go b/config.go index 8f10fa8237d..8f186451abb 100644 --- a/config.go +++ b/config.go @@ -6,11 +6,16 @@ package devbox import ( "encoding/json" "fmt" + "os" + "path/filepath" "strings" "unicode" "github.com/pkg/errors" + "go.jetpack.io/devbox/boxcli/usererr" "go.jetpack.io/devbox/cuecfg" + "go.jetpack.io/devbox/debug" + "go.jetpack.io/devbox/planner/plansdk" ) // Config defines a devbox environment as JSON. @@ -162,3 +167,89 @@ func (s *ConfigShellCmds) UnmarshalJSON(data []byte) error { func (s *ConfigShellCmds) String() string { return strings.Join(s.Cmds, "\n") } + +// findConfigDir is a utility for using the path +func findConfigDir(path string) (string, error) { + debug.Log("findConfigDir: path is %s\n", path) + + // Sanitize the directory and use the absolute path as canonical form + absPath, err := filepath.Abs(path) + if err != nil { + return "", errors.WithStack(err) + } + + // If the path is specified, then we check directly for a config. + // Otherwise, we search the parent directories. + if path != "" { + return findConfigDirAtPath(absPath) + } + return findConfigDirFromParentDirSearch("/" /*root*/, absPath) +} + +func findConfigDirAtPath(absPath string) (string, error) { + fi, err := os.Stat(absPath) + if err != nil { + return "", err + } + + switch mode := fi.Mode(); { + case mode.IsDir(): + if !plansdk.FileExists(filepath.Join(absPath, configFilename)) { + return "", missingConfigError(absPath, false /*didCheckParents*/) + } + return absPath, nil + default: // assumes 'file' i.e. mode.IsRegular() + if !plansdk.FileExists(filepath.Clean(absPath)) { + return "", missingConfigError(absPath, false /*didCheckParents*/) + } + // we return a directory from this function + return filepath.Dir(absPath), nil + } +} + +func findConfigDirFromParentDirSearch(root string, absPath string) (string, error) { + + cur := absPath + // Search parent directories for a devbox.json + for cur != root { + debug.Log("finding %s in dir: %s\n", configFilename, cur) + if plansdk.FileExists(filepath.Join(cur, configFilename)) { + return cur, nil + } + cur = filepath.Dir(cur) + } + if plansdk.FileExists(filepath.Join(cur, configFilename)) { + return cur, nil + } + return "", missingConfigError(absPath, true /*didCheckParents*/) +} + +func missingConfigError(path string, didCheckParents bool) error { + + var workingDir string + wd, err := os.Getwd() + if err == nil { + workingDir = wd + } + // We try to prettify the `path` before printing + if path == "." || path == "" || workingDir == path { + path = "this directory" + } else { + // Instead of a long absolute directory, print the relative directory + + // if an error occurs, then just use `path` + if workingDir != "" { + relDir, err := filepath.Rel(workingDir, path) + if err == nil { + path = relDir + } + } + } + + parentDirCheckAddendum := "" + if didCheckParents { + parentDirCheckAddendum = ", or any parent directories" + } + + return usererr.New("No devbox.json found in %s%s. Did you run `devbox init` yet?", path, parentDirCheckAddendum) +} diff --git a/config_test.go b/config_test.go index 3c2af888ee8..be3d15c606e 100644 --- a/config_test.go +++ b/config_test.go @@ -3,9 +3,12 @@ package devbox import ( "encoding/json" "fmt" + "os" + "path/filepath" "testing" "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" ) func TestConfigShellCmdsUnmarshalString(t *testing.T) { @@ -249,3 +252,132 @@ func TestAppendScript(t *testing.T) { }) } } + +func TestFindConfigDirFromParentDirSearch(t *testing.T) { + testCases := []struct { + name string + allDirs string + configDir string + searchPath string + expectError bool + }{ + { + name: "search_dir_same_as_config_dir", + allDirs: "a/b/c", + configDir: "a/b", + searchPath: "a/b", + expectError: false, + }, + { + name: "search_dir_in_nested_folder", + allDirs: "a/b/c", + configDir: "a/b", + searchPath: "a/b/c", + expectError: false, + }, + { + name: "search_dir_in_parent_folder", + allDirs: "a/b/c", + configDir: "a/b", + searchPath: "a", + expectError: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + assert := assert.New(t) + + root, err := filepath.Abs(t.TempDir()) + assert.NoError(err) + + err = os.MkdirAll(filepath.Join(root, testCase.allDirs), 0777) + assert.NoError(err) + + absConfigPath, err := filepath.Abs(filepath.Join(root, testCase.configDir, configFilename)) + assert.NoError(err) + err = os.WriteFile(absConfigPath, []byte("{}"), 0666) + assert.NoError(err) + + absSearchPath := filepath.Join(root, testCase.searchPath) + result, err := findConfigDirFromParentDirSearch(root, absSearchPath) + + if testCase.expectError { + assert.Error(err) + } else { + assert.NoError(err) + assert.Equal(filepath.Dir(filepath.Join(absConfigPath)), result) + } + }) + } +} + +func TestFindConfigDirAtPath(t *testing.T) { + + testCases := []struct { + name string + allDirs string + configDir string + flagPath string + expectError bool + }{ + { + name: "flag_path_is_dir_has_config", + allDirs: "a/b/c", + configDir: "a/b", + flagPath: "a/b", + expectError: false, + }, + { + name: "flag_path_is_dir_missing_config", + allDirs: "a/b/c", + configDir: "", // missing config + flagPath: "a/b", + expectError: true, + }, + { + name: "flag_path_is_file_has_config", + allDirs: "a/b/c", + configDir: "a/b", + flagPath: "a/b/" + configFilename, + expectError: false, + }, + { + name: "flag_path_is_file_missing_config", + allDirs: "a/b/c", + configDir: "", // missing config + flagPath: "a/b/" + configFilename, + expectError: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + assert := assert.New(t) + + root, err := filepath.Abs(t.TempDir()) + assert.NoError(err) + + err = os.MkdirAll(filepath.Join(root, testCase.allDirs), 0777) + assert.NoError(err) + + var absConfigPath string + if testCase.configDir != "" { + absConfigPath, err = filepath.Abs(filepath.Join(root, testCase.configDir, configFilename)) + assert.NoError(err) + err = os.WriteFile(absConfigPath, []byte("{}"), 0666) + assert.NoError(err) + } + + absFlagPath := filepath.Join(root, testCase.flagPath) + result, err := findConfigDirAtPath(absFlagPath) + + if testCase.expectError { + assert.Error(err) + } else { + assert.NoError(err) + assert.Equal(filepath.Dir(filepath.Join(absConfigPath)), result) + } + }) + } +} diff --git a/devbox.go b/devbox.go index 2ff65485551..f895131a3a7 100644 --- a/devbox.go +++ b/devbox.go @@ -16,7 +16,6 @@ import ( "github.com/fatih/color" "github.com/pkg/errors" "github.com/samber/lo" - "go.jetpack.io/devbox/boxcli/usererr" "go.jetpack.io/devbox/cuecfg" "go.jetpack.io/devbox/debug" "go.jetpack.io/devbox/docker" @@ -56,6 +55,7 @@ type Devbox struct { } // Open opens a devbox by reading the config file in dir. +// TODO savil. dir is technically path since it could be a dir or file func Open(dir string, writer io.Writer) (*Devbox, error) { cfgDir, err := findConfigDir(dir) @@ -311,47 +311,6 @@ func (d *Devbox) profileBinDir() (string, error) { return filepath.Join(profileDir, "bin"), nil } -func missingDevboxJSONError(dir string) error { - - // We try to prettify the `dir` before printing - if dir == "." || dir == "" { - dir = "this directory" - } else { - // Instead of a long absolute directory, print the relative directory - - wd, err := os.Getwd() - // if an error occurs, then just use `dir` - if err == nil { - relDir, err := filepath.Rel(wd, dir) - if err == nil { - dir = relDir - } - } - } - return usererr.New("No devbox.json found in %s, or any parent directories. Did you run `devbox init` yet?", dir) -} - -func findConfigDir(dir string) (string, error) { - - // Sanitize the directory and use the absolute path as canonical form - cur, err := filepath.Abs(dir) - if err != nil { - return "", errors.WithStack(err) - } - - for cur != "/" { - debug.Log("finding %s in dir: %s\n", configFilename, cur) - if plansdk.FileExists(filepath.Join(cur, configFilename)) { - return cur, nil - } - cur = filepath.Dir(cur) - } - if plansdk.FileExists(filepath.Join(cur, configFilename)) { - return cur, nil - } - return "", missingDevboxJSONError(dir) -} - // installMode is an enum for helping with ensurePackagesAreInstalled implementation type installMode string diff --git a/testdata/nodejs/nodejs-18/devbox.json b/testdata/nodejs/nodejs-18/devbox.json index cfd882f5f71..3e4b036d3a5 100644 --- a/testdata/nodejs/nodejs-18/devbox.json +++ b/testdata/nodejs/nodejs-18/devbox.json @@ -1,3 +1,5 @@ { - "packages": [] -} \ No newline at end of file + "packages": [ + "nodejs-18_x" + ] +}