diff --git a/cmd/agent/container/setup.go b/cmd/agent/container/setup.go index d3f08ac01..3ddf95e3a 100644 --- a/cmd/agent/container/setup.go +++ b/cmd/agent/container/setup.go @@ -61,9 +61,9 @@ type SetupContainerCmd struct { } // NewSetupContainerCmd creates a new command. -func NewSetupContainerCmd(flags *flags.GlobalFlags) *cobra.Command { +func NewSetupContainerCmd(globalFlags *flags.GlobalFlags) *cobra.Command { cmd := &SetupContainerCmd{ - GlobalFlags: flags, + GlobalFlags: globalFlags, } setupContainerCmd := &cobra.Command{ Use: "setup", @@ -96,6 +96,10 @@ func NewSetupContainerCmd(flags *flags.GlobalFlags) *cobra.Command { setupContainerCmd.Flags(). StringVar(&cmd.DotfilesScript, "dotfiles-script", "", "Dotfiles install script path") _ = setupContainerCmd.MarkFlagRequired("setup-info") + + flags.BindEnv(setupContainerCmd.Flags(), "access-key") + flags.BindEnv(setupContainerCmd.Flags(), "platform-host") + return setupContainerCmd } diff --git a/cmd/env_flags_test.go b/cmd/env_flags_test.go new file mode 100644 index 000000000..c64a1179f --- /dev/null +++ b/cmd/env_flags_test.go @@ -0,0 +1,181 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + envDevsyHost = "DEVSY_HOST" + envDevsyProject = "DEVSY_PROJECT" +) + +// TestOptInEnvFlags_AppliesEnvValueToFlag verifies that for each opt-in flag, +// setting the corresponding DEVSY_* env var pushes the value into the bound +// pflag at registration time, satisfying cobra's required-flag check. +func TestOptInEnvFlags_AppliesEnvValueToFlag(t *testing.T) { + cases := []struct { + cmdPath string + flagName string + envName string + want string + persistent bool + }{ + {flagName: "home", envName: "DEVSY_HOME", want: "/tmp/h", persistent: true}, + {flagName: "context", envName: "DEVSY_CONTEXT", want: "ctx-a", persistent: true}, + {flagName: "provider", envName: "DEVSY_PROVIDER", want: "docker", persistent: true}, + {flagName: "debug", envName: "DEVSY_DEBUG", want: "true", persistent: true}, + { + cmdPath: "pro list-workspaces", + flagName: "host", + envName: envDevsyHost, + want: "pro.example.com", + }, + {cmdPath: "pro list-clusters", flagName: "project", envName: envDevsyProject, want: "demo"}, + { + cmdPath: "agent container setup", + flagName: "access-key", + envName: "DEVSY_ACCESS_KEY", + want: "secret", + }, + { + cmdPath: "agent container setup", + flagName: "platform-host", + envName: "DEVSY_PLATFORM_HOST", + want: "p.example.com", + }, + } + + for _, tc := range cases { + t.Run(tc.envName, func(t *testing.T) { + t.Setenv(tc.envName, tc.want) + rootCmd, _ := BuildRoot() + cmd := resolveCommand(t, rootCmd, tc.cmdPath) + fs := cmd.Flags() + if tc.persistent { + fs = cmd.PersistentFlags() + } + f := fs.Lookup(tc.flagName) + require.NotNil(t, f, "flag --%s not found on %q", tc.flagName, tc.cmdPath) + assert.Equal(t, tc.want, f.Value.String()) + assert.True(t, f.Changed, "Changed must be true so MarkFlagRequired passes") + assert.Contains(t, f.Usage, tc.envName, "usage should advertise env var") + }) + } +} + +func TestOptInEnvFlags_NoEnvLeavesDefault(t *testing.T) { + for _, name := range []string{"DEVSY_HOME", "DEVSY_CONTEXT", "DEVSY_PROVIDER", envDevsyHost, envDevsyProject} { + t.Setenv(name, "") + } + rootCmd, _ := BuildRoot() + f := rootCmd.PersistentFlags().Lookup("context") + require.NotNil(t, f) + assert.False(t, f.Changed) + assert.Equal(t, "", f.Value.String()) +} + +// TestOptInEnvFlags_EmptyEnvIsNoOp guards the "value == \"\"" branch in +// BindEnv: setting DEVSY_HOST="" must NOT mark the flag as Changed (otherwise +// MarkFlagRequired would be satisfied by an empty value). +func TestOptInEnvFlags_EmptyEnvIsNoOp(t *testing.T) { + t.Setenv(envDevsyHost, "") + rootCmd, _ := BuildRoot() + cmd := resolveCommand(t, rootCmd, "pro list-workspaces") + f := cmd.Flags().Lookup("host") + require.NotNil(t, f) + assert.False(t, f.Changed, "empty env value must not mark flag as Changed") +} + +// TestOptInEnvFlags_CLIOverridesEnv proves that an explicit CLI flag value +// wins over a value supplied via DEVSY_*. This is the standard precedence +// users expect: flag > env > default. +func TestOptInEnvFlags_CLIOverridesEnv(t *testing.T) { + t.Setenv(envDevsyHost, "env-value") + rootCmd, _ := BuildRoot() + // Replace the leaf command's RunE so Execute() doesn't try to actually + // talk to a pro instance; we only care that flag resolution put cli-value + // in cmd.Host. + leaf := resolveCommand(t, rootCmd, "pro list-workspaces") + var seen string + leaf.RunE = func(c *cobra.Command, _ []string) error { + seen, _ = c.Flags().GetString("host") + return nil + } + rootCmd.SetArgs([]string{"pro", "list-workspaces", "--host", "cli-value"}) + require.NoError(t, rootCmd.Execute()) + assert.Equal(t, "cli-value", seen, "CLI flag must override env value") +} + +// TestOptInEnvFlags_EnvSatisfiesRequired drives cobra's full parse + +// ValidateRequiredFlags pipeline to prove DEVSY_HOST satisfies +// MarkFlagRequired("host") at execution time, not just in static inspection. +func TestOptInEnvFlags_EnvSatisfiesRequired(t *testing.T) { + t.Setenv(envDevsyHost, "from-env.example.com") + rootCmd, _ := BuildRoot() + leaf := resolveCommand(t, rootCmd, "pro list-workspaces") + var seen string + leaf.RunE = func(c *cobra.Command, _ []string) error { + seen, _ = c.Flags().GetString("host") + return nil + } + rootCmd.SetArgs([]string{"pro", "list-workspaces"}) + require.NoError(t, rootCmd.Execute(), "required --host must be satisfied by env") + assert.Equal(t, "from-env.example.com", seen) +} + +// TestOptInEnvFlags_ProSubcommandSweep asserts that DEVSY_HOST applies to +// every pro subcommand that exposes --host (and similarly for --project). +// This catches the regression where a future addition to cmd/pro/ forgets +// the inline BindEnv call. +func TestOptInEnvFlags_ProSubcommandSweep(t *testing.T) { + for _, env := range []string{envDevsyHost, envDevsyProject} { + t.Setenv(env, "sweep-"+strings.ToLower(env)) + } + rootCmd, _ := BuildRoot() + proCmd := resolveCommand(t, rootCmd, "pro") + + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + for _, flagName := range []string{"host", "project"} { + if f := c.Flags().Lookup(flagName); f != nil { + assert.True(t, f.Changed, + "%s: --%s should be Changed from DEVSY_%s", + c.CommandPath(), flagName, strings.ToUpper(flagName), + ) + } + } + for _, child := range c.Commands() { + walk(child) + } + } + walk(proCmd) +} + +func resolveCommand(t *testing.T, rootCmd *cobra.Command, path string) *cobra.Command { + t.Helper() + if path == "" { + return rootCmd + } + cur := rootCmd + for seg := range strings.SplitSeq(path, " ") { + var next *cobra.Command + for _, c := range cur.Commands() { + use := c.Use + if i := strings.IndexByte(use, ' '); i >= 0 { + use = use[:i] + } + if use == seg { + next = c + break + } + } + require.NotNil(t, next, "segment %q not found under %q", seg, cur.Use) + cur = next + } + return cur +} diff --git a/cmd/flag_aliases_test.go b/cmd/flag_aliases_test.go index 3e59d9ead..d8954508c 100644 --- a/cmd/flag_aliases_test.go +++ b/cmd/flag_aliases_test.go @@ -43,7 +43,7 @@ func TestBuildCmd_ConfigAlias(t *testing.T) { } func TestGlobalFlags_LogFormatAlias(t *testing.T) { - rootCmd := BuildRoot() + rootCmd, globalFlags := BuildRoot() rootCmd.SetArgs([]string{flagLogFormat, formatJSON, "version"}) err := rootCmd.Execute() require.NoError(t, err) @@ -58,14 +58,14 @@ func TestConfigAlias_IsHidden(t *testing.T) { } func TestLogFormatAlias_IsHidden(t *testing.T) { - rootCmd := BuildRoot() + rootCmd, _ := BuildRoot() f := rootCmd.PersistentFlags().Lookup("log-format") require.NotNil(t, f) assert.True(t, f.Hidden, flagLogFormat+" alias should be hidden") } func TestConfigAlias_E2E(t *testing.T) { - rootCmd := BuildRoot() + rootCmd, _ := BuildRoot() rootCmd.SetArgs([]string{"up", flagConfig, "/tmp/test.json", "--help"}) err := rootCmd.Execute() require.NoError(t, err) diff --git a/cmd/flags/env.go b/cmd/flags/env.go new file mode 100644 index 000000000..9d8c7bf90 --- /dev/null +++ b/cmd/flags/env.go @@ -0,0 +1,53 @@ +package flags + +import ( + "os" + "strings" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/log" + pflag "github.com/spf13/pflag" +) + +// EnvName returns the canonical env var name for a flag under the uniform rule: +// +// config.EnvPrefix + uppercase(flagName) with "-" replaced by "_" +// +// Example: "agent-url" -> "DEVSY_AGENT_URL". +func EnvName(flagName string) string { + return config.EnvPrefix + strings.ToUpper(strings.ReplaceAll(flagName, "-", "_")) +} + +// BindEnv wires the canonical DEVSY_* env var into a cobra flag. Call it +// immediately after the flag's *Var registration. If the env var is set, the +// value is applied to the flag (Changed=true, satisfying MarkFlagRequired) and +// the usage string advertises the env var. +// +// Failure modes are loud by design: +// - panics if the flag is not registered (programmer bug at startup); +// - log.Fatalf if the env value is rejected by the flag's type (operator bug; +// e.g. DEVSY_DEBUG=garbage on a bool flag). This matches the contract of +// the prior inheritFlagsFromEnvironment loop so misconfigured env vars are +// never silently ignored. +func BindEnv(fs *pflag.FlagSet, flagName string) { + f := fs.Lookup(flagName) + if f == nil { + panic("flags.BindEnv: flag --" + flagName + " is not registered") + } + envName := EnvName(flagName) + // "[$DEVSY_FOO]" — $VAR convention, bracket avoids clashing with + // parentheses inside the existing description. + suffix := " [$" + envName + "]" + desc := strings.TrimRight(f.Usage, " .") + if !strings.HasSuffix(desc, suffix) { + f.Usage = desc + suffix + } + value, ok := os.LookupEnv(envName) + if !ok || value == "" { + return + } + if err := fs.Set(flagName, value); err != nil { + log.Fatalf("invalid value %q for %s (flag --%s): %v", value, envName, flagName, err) + } + f.DefValue = value +} diff --git a/cmd/flags/env_test.go b/cmd/flags/env_test.go new file mode 100644 index 000000000..9de76a2ee --- /dev/null +++ b/cmd/flags/env_test.go @@ -0,0 +1,24 @@ +package flags + +import ( + "testing" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/stretchr/testify/assert" +) + +// TestEnvName_MatchesConfigConstants pins the uniform rule against the +// canonical env-var constants in pkg/config. If a constant ever stops matching +// what EnvName(flagName) produces, this test fires so the two surfaces stay in +// sync. +func TestEnvName_MatchesConfigConstants(t *testing.T) { + cases := map[string]string{ + "home": config.EnvHome, + "debug": config.EnvDebug, + "agent-url": config.EnvAgentURL, + "log-level": config.EnvLogLevel, + } + for flagName, want := range cases { + assert.Equal(t, want, EnvName(flagName), "EnvName(%q)", flagName) + } +} diff --git a/cmd/flags/flags.go b/cmd/flags/flags.go index b57e4ada3..f4e90adbc 100644 --- a/cmd/flags/flags.go +++ b/cmd/flags/flags.go @@ -1,7 +1,6 @@ package flags import ( - "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/platform" flag "github.com/spf13/pflag" ) @@ -27,7 +26,7 @@ func SetGlobalFlags(flags *flag.FlagSet) *GlobalFlags { flags.StringVar( &globalFlags.DevsyHome, - config.BinaryName+"-home", + "home", "", "If defined will override the default devsy home", ) @@ -78,5 +77,11 @@ func SetGlobalFlags(flags *flag.FlagSet) *GlobalFlags { "The data folder where agent data is stored.", ) _ = flags.MarkHidden("agent-dir") + + BindEnv(flags, "home") + BindEnv(flags, "context") + BindEnv(flags, "provider") + BindEnv(flags, "debug") + return globalFlags } diff --git a/cmd/pro/add/cluster.go b/cmd/pro/add/cluster.go index 52b4c9346..293fafbdc 100644 --- a/cmd/pro/add/cluster.go +++ b/cmd/pro/add/cluster.go @@ -78,6 +78,7 @@ func NewClusterCmd(globalFlags *proflags.GlobalFlags) *cobra.Command { c.Flags(). StringVar(&cmd.KubeContext, "kube-context", "", "The kube context to use for installation") c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") + proflags.BindEnv(c.Flags(), "host") return c } diff --git a/cmd/pro/check_health.go b/cmd/pro/check_health.go index 6640ea413..7b4179c3c 100644 --- a/cmd/pro/check_health.go +++ b/cmd/pro/check_health.go @@ -58,6 +58,7 @@ func NewCheckHealthCmd(globalFlags *flags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + flags.BindEnv(c.Flags(), "host") return c } diff --git a/cmd/pro/check_update.go b/cmd/pro/check_update.go index 8391193cc..54aeaa8d4 100644 --- a/cmd/pro/check_update.go +++ b/cmd/pro/check_update.go @@ -58,6 +58,7 @@ func NewCheckUpdateCmd(globalFlags *flags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + flags.BindEnv(c.Flags(), "host") return c } diff --git a/cmd/pro/create_workspace.go b/cmd/pro/create_workspace.go index 585034f33..16ba172ca 100644 --- a/cmd/pro/create_workspace.go +++ b/cmd/pro/create_workspace.go @@ -48,6 +48,7 @@ func NewCreateWorkspaceCmd(globalFlags *flags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + flags.BindEnv(c.Flags(), "host") c.Flags().StringVar(&cmd.Instance, "instance", "", "The workspace instance to create") _ = c.MarkFlagRequired("instance") diff --git a/cmd/pro/daemon/netcheck.go b/cmd/pro/daemon/netcheck.go index 11d9fffd8..b2fa1f267 100644 --- a/cmd/pro/daemon/netcheck.go +++ b/cmd/pro/daemon/netcheck.go @@ -58,6 +58,7 @@ func NewNetcheckCmd(flags *proflags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + proflags.BindEnv(c.Flags(), "host") _ = c.RegisterFlagCompletionFunc( "host", func(rootCmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { diff --git a/cmd/pro/daemon/start.go b/cmd/pro/daemon/start.go index 45b5594f5..a840a8dc0 100644 --- a/cmd/pro/daemon/start.go +++ b/cmd/pro/daemon/start.go @@ -52,6 +52,7 @@ func NewStartCmd(flags *proflags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + proflags.BindEnv(c.Flags(), "host") _ = c.RegisterFlagCompletionFunc( "host", func(rootCmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { diff --git a/cmd/pro/daemon/status.go b/cmd/pro/daemon/status.go index 676d6c5ef..5d2bd6d09 100644 --- a/cmd/pro/daemon/status.go +++ b/cmd/pro/daemon/status.go @@ -57,6 +57,7 @@ func NewStatusCmd(flags *proflags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + proflags.BindEnv(c.Flags(), "host") _ = c.RegisterFlagCompletionFunc( "host", func(rootCmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { diff --git a/cmd/pro/flags/flags.go b/cmd/pro/flags/flags.go index 499feded3..3993bac8b 100644 --- a/cmd/pro/flags/flags.go +++ b/cmd/pro/flags/flags.go @@ -1,14 +1,20 @@ package flags import ( - "github.com/devsy-org/devsy/cmd/flags" + rootflags "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/platform/client" flag "github.com/spf13/pflag" ) +// BindEnv re-exports rootflags.BindEnv so pro subcommands can wire env vars +// through their existing local `flags` import alias. +func BindEnv(fs *flag.FlagSet, flagName string) { + rootflags.BindEnv(fs, flagName) +} + // GlobalFlags is the flags that contains the global flags. type GlobalFlags struct { - *flags.GlobalFlags + *rootflags.GlobalFlags Config string } diff --git a/cmd/pro/list_clusters.go b/cmd/pro/list_clusters.go index 0c4c2687f..2c2aea43f 100644 --- a/cmd/pro/list_clusters.go +++ b/cmd/pro/list_clusters.go @@ -47,8 +47,10 @@ func NewListClustersCmd(globalFlags *flags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + flags.BindEnv(c.Flags(), "host") c.Flags().StringVar(&cmd.Project, "project", "", "The project to use") _ = c.MarkFlagRequired("project") + flags.BindEnv(c.Flags(), "project") return c } diff --git a/cmd/pro/list_projects.go b/cmd/pro/list_projects.go index cbb4a6178..aeb4c723b 100644 --- a/cmd/pro/list_projects.go +++ b/cmd/pro/list_projects.go @@ -45,6 +45,7 @@ func NewListProjectsCmd(globalFlags *flags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + flags.BindEnv(c.Flags(), "host") return c } diff --git a/cmd/pro/list_templates.go b/cmd/pro/list_templates.go index 19aa2cba9..35b13c848 100644 --- a/cmd/pro/list_templates.go +++ b/cmd/pro/list_templates.go @@ -47,8 +47,10 @@ func NewListTemplatesCmd(globalFlags *flags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + flags.BindEnv(c.Flags(), "host") c.Flags().StringVar(&cmd.Project, "project", "", "The project to use") _ = c.MarkFlagRequired("project") + flags.BindEnv(c.Flags(), "project") return c } diff --git a/cmd/pro/list_workspaces.go b/cmd/pro/list_workspaces.go index 5e0688ff8..e19443b73 100644 --- a/cmd/pro/list_workspaces.go +++ b/cmd/pro/list_workspaces.go @@ -45,6 +45,7 @@ func NewListWorkspacesCmd(globalFlags *flags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + flags.BindEnv(c.Flags(), "host") return c } diff --git a/cmd/pro/provider/rebuild.go b/cmd/pro/provider/rebuild.go index 7814d83e3..9f8aa5193 100644 --- a/cmd/pro/provider/rebuild.go +++ b/cmd/pro/provider/rebuild.go @@ -38,6 +38,7 @@ func NewRebuildCmd(globalFlags *flags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Project, "project", "", "The project to use") _ = c.MarkFlagRequired("project") + flags.BindEnv(c.Flags(), "project") return c } diff --git a/cmd/pro/rebuild.go b/cmd/pro/rebuild.go index c6979ad8b..8ddb82b34 100644 --- a/cmd/pro/rebuild.go +++ b/cmd/pro/rebuild.go @@ -37,8 +37,10 @@ func NewRebuildCmd(globalFlags *flags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Project, "project", "", "The project to use") _ = c.MarkFlagRequired("project") + flags.BindEnv(c.Flags(), "project") c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + flags.BindEnv(c.Flags(), "host") return c } diff --git a/cmd/pro/self.go b/cmd/pro/self.go index be4344570..cfa3531b4 100644 --- a/cmd/pro/self.go +++ b/cmd/pro/self.go @@ -45,6 +45,7 @@ func NewSelfCmd(globalFlags *flags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + flags.BindEnv(c.Flags(), "host") return c } diff --git a/cmd/pro/sleep.go b/cmd/pro/sleep.go index 78c2cf259..84faa44f0 100644 --- a/cmd/pro/sleep.go +++ b/cmd/pro/sleep.go @@ -48,8 +48,10 @@ func NewSleepCmd(globalFlags *flags.GlobalFlags) *cobra.Command { "During this time the space can only be woken up by `devsy pro wakeup`, "+ "manually deleting the annotation on the namespace or through the UI") _ = c.MarkFlagRequired("project") + flags.BindEnv(c.Flags(), "project") c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + flags.BindEnv(c.Flags(), "host") return c } diff --git a/cmd/pro/start.go b/cmd/pro/start.go index b773a855d..7abf395a8 100644 --- a/cmd/pro/start.go +++ b/cmd/pro/start.go @@ -136,6 +136,8 @@ func NewStartCmd(flags *proflags.GlobalFlags) *cobra.Command { startCmd.Flags(). StringVar(&cmd.ChartRepo, "chart-repo", "https://charts.devsy.sh/", "The chart repo to deploy Devsy Pro") + proflags.BindEnv(startCmd.Flags(), "host") + return startCmd } diff --git a/cmd/pro/update_provider.go b/cmd/pro/update_provider.go index 531710fbd..ed2205c7b 100644 --- a/cmd/pro/update_provider.go +++ b/cmd/pro/update_provider.go @@ -36,6 +36,7 @@ func NewUpdateProviderCmd(globalFlags *flags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + flags.BindEnv(c.Flags(), "host") return c } diff --git a/cmd/pro/update_workspace.go b/cmd/pro/update_workspace.go index a2efc6ef0..cf219a13b 100644 --- a/cmd/pro/update_workspace.go +++ b/cmd/pro/update_workspace.go @@ -48,6 +48,7 @@ func NewUpdateWorkspaceCmd(globalFlags *flags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + flags.BindEnv(c.Flags(), "host") c.Flags().StringVar(&cmd.Instance, "instance", "", "The workspace instance to update") _ = c.MarkFlagRequired("instance") diff --git a/cmd/pro/version.go b/cmd/pro/version.go index 9cdc50903..e802be413 100644 --- a/cmd/pro/version.go +++ b/cmd/pro/version.go @@ -45,6 +45,7 @@ func NewVersionCmd(globalFlags *flags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + flags.BindEnv(c.Flags(), "host") return c } diff --git a/cmd/pro/wakeup.go b/cmd/pro/wakeup.go index 0f20259fe..eae8053ae 100644 --- a/cmd/pro/wakeup.go +++ b/cmd/pro/wakeup.go @@ -42,8 +42,10 @@ func NewWakeupCmd(globalFlags *flags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Project, "project", "", "The project to use") _ = c.MarkFlagRequired("project") + flags.BindEnv(c.Flags(), "project") c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + flags.BindEnv(c.Flags(), "host") return c } diff --git a/cmd/pro/watch_workspaces.go b/cmd/pro/watch_workspaces.go index e73d19fa5..a80f720f1 100644 --- a/cmd/pro/watch_workspaces.go +++ b/cmd/pro/watch_workspaces.go @@ -50,8 +50,10 @@ func NewWatchWorkspacesCmd(globalFlags *flags.GlobalFlags) *cobra.Command { c.Flags().StringVar(&cmd.Host, "host", "", "The pro instance to use") _ = c.MarkFlagRequired("host") + flags.BindEnv(c.Flags(), "host") c.Flags().StringVar(&cmd.Project, "project", "", "The project to use") _ = c.MarkFlagRequired("project") + flags.BindEnv(c.Flags(), "project") c.Flags(). BoolVar(&cmd.FilterByOwner, "filter-by-owner", true, "If true only shows workspaces of current owner") diff --git a/cmd/root.go b/cmd/root.go index e710d31c9..c68d3a0bd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,7 +3,6 @@ package cmd import ( "os" "os/exec" - "strings" "github.com/devsy-org/devsy/cmd/agent" "github.com/devsy-org/devsy/cmd/completion" @@ -23,57 +22,15 @@ import ( "github.com/devsy-org/devsy/pkg/telemetry" "github.com/go-logr/logr" "github.com/spf13/cobra" - flag "github.com/spf13/pflag" "golang.org/x/crypto/ssh" "k8s.io/klog/v2" ) -var globalFlags *flags.GlobalFlags - -// NewRootCmd returns a new root command. -func NewRootCmd() *cobra.Command { - return &cobra.Command{ - Use: config.BinaryName, - Short: "Devsy", - SilenceUsage: true, - SilenceErrors: true, - PersistentPreRunE: func(cobraCmd *cobra.Command, args []string) error { - log.Init(log.Config{ - Verbosity: globalFlags.Verbosity, - Quiet: globalFlags.Quiet, - Debug: globalFlags.Debug || os.Getenv(config.EnvDebug) == config.BoolTrue, - Format: globalFlags.LogOutput, - }) - klog.SetLogger(logr.New(log.LogrSink())) - - if globalFlags.DevsyHome != "" { - _ = os.Setenv(config.EnvHome, globalFlags.DevsyHome) - } - - devsyConfig, err := config.LoadConfig(globalFlags.Context, globalFlags.Provider) - if err == nil { - telemetry.StartCLI(devsyConfig, cobraCmd) - } - - return nil - }, - PersistentPostRunE: func(cmd *cobra.Command, args []string) error { - if globalFlags.DevsyHome != "" { - _ = os.Unsetenv(config.EnvHome) - } - - return nil - }, - } -} - // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { - // build the root command - rootCmd := BuildRoot() + rootCmd, globalFlags := BuildRoot() - // execute command err := rootCmd.Execute() telemetry.CollectorCLI.RecordCLI(err) telemetry.CollectorCLI.Flush() @@ -103,13 +60,52 @@ func Execute() { } } -// BuildRoot creates a new root command from the. -func BuildRoot() *cobra.Command { - rootCmd := NewRootCmd() +// BuildRoot constructs the root command and returns it alongside the parsed +// global flags struct so callers (Execute, tests) can inspect parsed state +// without reaching for package-level mutable state. +func BuildRoot() (*cobra.Command, *flags.GlobalFlags) { + rootCmd := &cobra.Command{ + Use: config.BinaryName, + Short: "Devsy", + SilenceUsage: true, + SilenceErrors: true, + } persistentFlags := rootCmd.PersistentFlags() - globalFlags = flags.SetGlobalFlags(persistentFlags) + globalFlags := flags.SetGlobalFlags(persistentFlags) _ = completion.RegisterFlagCompletionFuns(rootCmd, globalFlags) + rootCmd.PersistentPreRunE = func(cobraCmd *cobra.Command, _ []string) error { + log.Init(log.Config{ + Verbosity: globalFlags.Verbosity, + Quiet: globalFlags.Quiet, + Debug: globalFlags.Debug, + Format: globalFlags.LogOutput, + }) + klog.SetLogger(logr.New(log.LogrSink())) + + if globalFlags.DevsyHome != "" { + _ = os.Setenv(config.EnvHome, globalFlags.DevsyHome) + } + + devsyConfig, err := config.LoadConfig(globalFlags.Context, globalFlags.Provider) + if err == nil { + telemetry.StartCLI(devsyConfig, cobraCmd) + } + return nil + } + rootCmd.PersistentPostRunE = func(_ *cobra.Command, _ []string) error { + if globalFlags.DevsyHome != "" { + _ = os.Unsetenv(config.EnvHome) + } + return nil + } + + registerSubcommands(rootCmd, globalFlags) + + return rootCmd, globalFlags +} + +func registerSubcommands(rootCmd *cobra.Command, globalFlags *flags.GlobalFlags) { rootCmd.AddCommand(agent.NewAgentCmd(globalFlags)) rootCmd.AddCommand(provider.NewProviderCmd(globalFlags)) rootCmd.AddCommand(use.NewUseCmd(globalFlags)) @@ -145,57 +141,4 @@ func BuildRoot() *cobra.Command { rootCmd.AddCommand(features.NewFeaturesCmd(globalFlags)) rootCmd.AddCommand(templates.NewTemplatesCmd(globalFlags)) rootCmd.AddCommand(NewDaemonLocalCmd(globalFlags)) - - inheritCommandFlagsFromEnvironment(rootCmd) - - return rootCmd -} - -func inheritCommandFlagsFromEnvironment(cmd *cobra.Command) { - inheritFlagsFromEnvironment(cmd.Flags()) - inheritFlagsFromEnvironment(cmd.PersistentFlags()) - - for _, sub := range cmd.Commands() { - inheritCommandFlagsFromEnvironment(sub) - } -} - -// Inherits default values for all flags that have a corresponding environment variable set. -func inheritFlagsFromEnvironment(flags *flag.FlagSet) { - flags.VisitAll(func(flag *flag.Flag) { - // calculate environment variable name from flag name - suffix := strings.ToUpper(strings.ReplaceAll(flag.Name, "-", "_")) - - // do not prepend the env prefix if the flag name already starts with it - // (applies to one flag - "devsy-home"). - var environmentVariable string - if strings.HasPrefix(suffix, config.EnvPrefix) { - environmentVariable = suffix - } else { - environmentVariable = config.EnvPrefix + suffix - } - - if value, exists := os.LookupEnv(environmentVariable); exists { - // set the variable holding the flag's value to the default supplied by the environment - err := flag.Value.Set(value) - if err != nil { - log.Fatalf( - "failed to set flag %s from the environment variable %s with value %s: %+v", - flag.Name, - environmentVariable, - value, - err, - ) - } - // reflect this default in the usage output - flag.DefValue = value - } - - // add note about environment variable to usage, but only if it is not there yet - - // in case we visit the same flag more than once. - usageAddition := ". You can also use " + environmentVariable + " to set this" - if !strings.HasSuffix(flag.Usage, usageAddition) { - flag.Usage = flag.Usage + usageAddition - } - }) } diff --git a/cmd/runusercommands_test.go b/cmd/runusercommands_test.go index cce8c4016..142d6e5d0 100644 --- a/cmd/runusercommands_test.go +++ b/cmd/runusercommands_test.go @@ -27,7 +27,7 @@ func TestNewRunUserCommandsCmdAlias_IsHidden(t *testing.T) { } func TestNewRunUserCommandsCmdAlias_RegisteredInRoot(t *testing.T) { - rootCmd := BuildRoot() + rootCmd, _ := BuildRoot() found := false for _, sub := range rootCmd.Commands() { if sub.Use == "runUserCommands" { @@ -399,7 +399,7 @@ func TestBuildLifecycleEnvArgs_NilValueSkipped(t *testing.T) { } func TestRunUserCommandsCmd_RegisteredInRoot(t *testing.T) { - rootCmd := BuildRoot() + rootCmd, _ := BuildRoot() found := false for _, sub := range rootCmd.Commands() { if sub.Use == "run-user-commands" { diff --git a/docs/pages/developing-in-workspaces/create-a-workspace.mdx b/docs/pages/developing-in-workspaces/create-a-workspace.mdx index 336710745..c1c996241 100644 --- a/docs/pages/developing-in-workspaces/create-a-workspace.mdx +++ b/docs/pages/developing-in-workspaces/create-a-workspace.mdx @@ -34,12 +34,12 @@ Under the hood, the Desktop Application will call the CLI command `devsy up REPO ::: :::info Note -You can set the location of your devsy home by passing the `--devsy-home={home_path}` flag, +You can set the location of your devsy home by passing the `--home={home_path}` flag, or by setting the env var `DEVSY_HOME` to your desired home directory. This can be useful if you are having trouble with a workspace trying to mount to a windows location when it should be mounting to a path inside the WSL VM. -For example: setting `devsy-home=/mnt/c/Users/MyUser/` will result in a workspace path of something like `/mnt/c/Users/MyUser/.devsy/contexts/default/workspaces/...` +For example: setting `--home=/mnt/c/Users/MyUser/` will result in a workspace path of something like `/mnt/c/Users/MyUser/.devsy/contexts/default/workspaces/...` ::: ### Via Devsy CLI diff --git a/pkg/ssh/config.go b/pkg/ssh/config.go index ae79d89ae..41c27524c 100644 --- a/pkg/ssh/config.go +++ b/pkg/ssh/config.go @@ -122,7 +122,7 @@ func newProxyCommandBuilder(execPath, context, user, workspace string) *proxyCom func (b *proxyCommandBuilder) withDevsyHome(home string) *proxyCommandBuilder { if home != "" { - b.options = append(b.options, fmt.Sprintf("--devsy-home \"%s\"", home)) + b.options = append(b.options, fmt.Sprintf("--home \"%s\"", home)) } return b } diff --git a/pkg/ssh/config_test.go b/pkg/ssh/config_test.go index 16b9082ba..746fcfe97 100644 --- a/pkg/ssh/config_test.go +++ b/pkg/ssh/config_test.go @@ -91,7 +91,7 @@ Host testhost workdir: "", command: "", gpgagent: false, - devsyHome: "C:\\\\White Space\\devsy\\test", + devsyHome: "C:\\\\W S\\d", provider: "", expected: `# Devsy Start testhost Host testhost @@ -100,7 +100,7 @@ Host testhost StrictHostKeyChecking no UserKnownHostsFile /dev/null HostKeyAlgorithms rsa-sha2-256,rsa-sha2-512,ssh-rsa - ProxyCommand "/path/to/exec" ssh --stdio --context testcontext --user testuser testworkspace --devsy-home "C:\\White Space\devsy\test" + ProxyCommand "/path/to/exec" ssh --stdio --context testcontext --user testuser testworkspace --home "C:\\W S\d" User testuser # Devsy End testhost`, },