Skip to content

Commit 01550a3

Browse files
Build root command from factory (#440)
Instantiates the Cobra singleton `rootCmd` from a function that builds it out, instead of directly building the singleton inside of the module. --- Pulled this out of a too-big PR for [GROW-2118: PG Create](https://linear.app/render-com/issue/GROW-2118/pg-create). I want to be able to instantiate the application inside of a test, instead of executing commands against the rootCmd singleton. When we execute commands against the rootCmd singleton, it holds on to global state that the real production application would never hold on to, such as the flags set. This makes it easier to write tests _and_ better simulates production! The singleton works fine in production because a CLI process executes once, but in tests we run many command executions in the same Go process. Cobra command objects hold mutable state, including parsed flag values, changed flags, output writers, args, and context. That means tests can accidentally share state that the production CLI would never share. There are a couple levels of testing we can choose from. The smallest unit test is to call a command’s `RunE` directly: ```go cmd := newPgCreateCmd(deps) cmd.RunE(cmd, nil) ``` That can be useful for narrow command behavior, but it skips some of the CLI surface a user actually exercises: command routing, argument parsing, persistent flags, persistent hooks, and dependency setup. For higher-fidelity tests, I want to pass args into the CLI and let Cobra execute the command: Before: ```go rootCmd.SetArgs([]string{"ea", "pg", "create", "--output", "json"}) err := rootCmd.Execute() ``` That reuses the global command tree and has to be careful about resetting flags and other state. Today, that often means tests need cleanup like this between executions: ```go rootCmd.SetArgs(nil) rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { f.Changed = false f.Value.Set(f.DefValue) }) pgCreateCmd.Flags().VisitAll(func(f *pflag.Flag) { f.Changed = false f.Value.Set(f.DefValue) }) ``` An example of state persisting between executions: ```go // Test A rootCmd.SetArgs([]string{"ea", "pg", "create", "--output", "json"}) _ = rootCmd.Execute() // Test B rootCmd.SetArgs([]string{"ea", "pg", "create"}) _ = rootCmd.Execute() ``` If both tests reuse the same Cobra command objects, Test B can accidentally observe state mutated by Test A, such as a flag still being marked as changed. A real user running `render ea pg create` would get a fresh process and a fresh command tree. After: ```go // In the ideal state, we would just need to do: app = newApplication // Today, because we have a mix of ways that commands get attached to rootCmd, // Tests need to build out their own command tree // which does lower the fidelity a bit root := newRootCmd() setupPGCommands(root, deps) setupRootCmdPersistentRun(root, deps) root.SetArgs([]string{"ea", "pg", "create", "--output", "json"}) err := root.Execute() ``` Each test can build a fresh command tree, closer to how a real CLI invocation starts from a clean process. There is still a mix of command construction styles today. Some commands are already factories: ```go cmd := NewRunListCmd(deps) cmd := NewLogsCmd(deps) cmd := newLogoutCmd() ``` Many others are still package-level singletons: ```go var kvCreateCmd = &cobra.Command{...} var EarlyAccessCmd = &cobra.Command{...} var servicesCmd = &cobra.Command{...} ``` Moving the root command to a factory does not require converting every command all at once. It gives us a better place to opt individual command subtrees into the factory pattern when we want the testing benefits. If this direction feels good, I can keep migrating commands over time instead of doing one giant refactor. The end-state could be even cleaner: once the commands are factory-built, tests should not need to know about `setupPGCommands` or any other command-specific setup function. We could have something like: ```go root := newApplication(deps) root.SetArgs([]string{"ea", "pg", "create", "--output", "json"}) err := root.Execute() ``` `newApplication()` would build the root command, attach all commands, wire dependencies, and return the full CLI app ready to execute. That gets us the production-shaped test harness without forcing every test to hand-pick command subtrees. One nice side effect: this should also make `t.Parallel()` possible for command tests. If every test gets its own command tree, tests are no longer racing over shared Cobra state like args, flag values, output writers, and command context. We still need each test to isolate other process-level things like env vars, config paths, and fake API servers, but this removes one of the biggest shared-state blockers. GitOrigin-RevId: d1ee179e985c936a15e8ae737ad4b2593e9bcefe
1 parent 616ce1d commit 01550a3

1 file changed

Lines changed: 60 additions & 60 deletions

File tree

cmd/root.go

Lines changed: 60 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -29,53 +29,76 @@ The CLI's default interactive mode provides intuitive, menu-based navigation.
2929
To use in non-interactive mode (such as in a script), set each command's --output option to either json or yaml for structured responses. The CLI also detects non-TTY stdout and automatically switches to text output.
3030
`
3131

32-
// rootCmd represents the base command when called without any subcommands
33-
var rootCmd = &cobra.Command{
34-
Use: "render",
35-
36-
Short: "Interact with resources on Render",
37-
Long: longHelp,
38-
Example: ` # List services in the active workspace
32+
// rootCmd represents the base command when called without any subcommands.
33+
var rootCmd = newRootCmd()
34+
35+
func newRootCmd() *cobra.Command {
36+
root := &cobra.Command{
37+
Use: "render",
38+
Short: "Interact with resources on Render",
39+
Long: longHelp,
40+
Example: ` # List services in the active workspace
3941
render services
4042
4143
# Output services as JSON
4244
render services --output json`,
4345

44-
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
45-
ctx := cmd.Context()
46-
deps := dependencies.GetFromContext(ctx)
46+
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
47+
ctx := cmd.Context()
48+
deps := dependencies.GetFromContext(ctx)
4749

48-
output := command.GetFormatFromContext(ctx)
49-
if output.Interactive() {
50-
stack := tui.GetStackFromContext(ctx)
51-
if stack.IsEmpty() {
52-
return nil
53-
}
50+
output := command.GetFormatFromContext(ctx)
51+
if output.Interactive() {
52+
stack := tui.GetStackFromContext(ctx)
53+
if stack.IsEmpty() {
54+
return nil
55+
}
5456

55-
var m tea.Model = stack
57+
var m tea.Model = stack
5658

57-
local := isLocalCommand(cmd)
59+
local := isLocalCommand(cmd)
5860

59-
if !local && cmd.Name() != deps.Commands.WorkspaceSetCmd().Name() {
60-
if !config.IsWorkspaceSet() {
61-
m = tui.NewConfigWrapper(m, "Set Workspace", views.NewWorkspaceView(ctx, views.ListWorkspaceInput{}))
61+
if !local && cmd.Name() != deps.Commands.WorkspaceSetCmd().Name() {
62+
if !config.IsWorkspaceSet() {
63+
m = tui.NewConfigWrapper(m, "Set Workspace", views.NewWorkspaceView(ctx, views.ListWorkspaceInput{}))
64+
}
6265
}
63-
}
6466

65-
if !local && cmd.Name() != loginCmd.Name() {
66-
m = tui.NewConfigWrapper(m, "Login", views.NewLoginView(ctx))
67-
}
67+
if !local && cmd.Name() != loginCmd.Name() {
68+
m = tui.NewConfigWrapper(m, "Login", views.NewLoginView(ctx))
69+
}
6870

69-
p := tea.NewProgram(m, tea.WithAltScreen())
70-
_, err := p.Run()
71-
if err != nil {
72-
panic(fmt.Sprintf("Failed to initialize interface. Use -o to specify a non-interactive output mode: %v", err))
71+
p := tea.NewProgram(m, tea.WithAltScreen())
72+
_, err := p.Run()
73+
if err != nil {
74+
panic(fmt.Sprintf("Failed to initialize interface. Use -o to specify a non-interactive output mode: %v", err))
75+
}
76+
return nil
7377
}
78+
7479
return nil
75-
}
80+
},
81+
}
7682

77-
return nil
78-
},
83+
root.AddGroup(AllGroups...)
84+
root.SetHelpTemplate(CustomHelpTemplate)
85+
root.Version = cfg.Version
86+
root.CompletionOptions.DisableDefaultCmd = true
87+
root.PersistentFlags().StringP("output", "o", "interactive", "Set output format to interactive, json, yaml, or text. Auto-switches to text on non-TTY")
88+
setAnnotationBestEffort(root.PersistentFlags(), "output", command.FlagPlaceholderAnnotation, []string{command.OutputPlaceholder})
89+
root.PersistentFlags().Bool(command.ConfirmFlag, false, "Skip all confirmation prompts")
90+
91+
// Flags from the old CLI that we error with a helpful message.
92+
root.PersistentFlags().Bool("pretty-json", false, "")
93+
if err := root.PersistentFlags().MarkHidden("pretty-json"); err != nil {
94+
panic(err)
95+
}
96+
root.PersistentFlags().Bool("json-record-per-line", false, "")
97+
if err := root.PersistentFlags().MarkHidden("json-record-per-line"); err != nil {
98+
panic(err)
99+
}
100+
101+
return root
79102
}
80103

81104
func isLocalCommand(cmd *cobra.Command) bool {
@@ -149,13 +172,13 @@ func SetupCommands() error {
149172
setupWorkflowCommands(deps)
150173
setupLogCommands(deps)
151174
setupWorkspaceCommands(deps)
152-
setupRootCmdPersistentRun(deps)
175+
setupRootCmdPersistentRun(rootCmd, deps)
153176

154177
return nil
155178
}
156179

157-
func setupRootCmdPersistentRun(deps *dependencies.Dependencies) {
158-
rootCmd.PersistentPreRunE = rootPersistentPreRun(deps)
180+
func setupRootCmdPersistentRun(root *cobra.Command, deps *dependencies.Dependencies) {
181+
root.PersistentPreRunE = rootPersistentPreRun(deps)
159182
}
160183

161184
func rootPersistentPreRun(deps *dependencies.Dependencies) func(cmd *cobra.Command, args []string) error {
@@ -293,12 +316,8 @@ func Execute() {
293316
}
294317

295318
func init() {
296-
// Here you will define your flags and configuration settings.
297-
// Cobra supports persistent flags, which, if defined here,
298-
// will be global for your application.
299-
rootCmd.AddGroup(AllGroups...)
300-
301-
// Add custom template functions
319+
// Template functions are global to cobra. Help template registration is
320+
// per-command and lives in newRootCmd.
302321
cobra.AddTemplateFunc("combinedFlagUsages", CombinedFlagUsages)
303322
cobra.AddTemplateFunc("wrapText", wrapText)
304323
cobra.AddTemplateFunc("cliVersion", cliVersion)
@@ -308,25 +327,6 @@ func init() {
308327
cobra.AddTemplateFunc("hasVisibleGroupCommands", hasVisibleGroupCommands)
309328
cobra.AddTemplateFunc("trimPeriod", trimTrailingPeriod)
310329
cobra.AddTemplateFunc("groupHeader", groupHeaderText)
311-
312-
// Set custom help template
313-
rootCmd.SetHelpTemplate(CustomHelpTemplate)
314-
315-
rootCmd.Version = cfg.Version
316-
rootCmd.CompletionOptions.DisableDefaultCmd = true
317-
rootCmd.PersistentFlags().StringP("output", "o", "interactive", "Set output format to interactive, json, yaml, or text. Auto-switches to text on non-TTY")
318-
setAnnotationBestEffort(rootCmd.PersistentFlags(), "output", command.FlagPlaceholderAnnotation, []string{command.OutputPlaceholder})
319-
rootCmd.PersistentFlags().Bool(command.ConfirmFlag, false, "Skip all confirmation prompts")
320-
321-
// Flags from the old CLI that we error with a helpful message
322-
rootCmd.PersistentFlags().Bool("pretty-json", false, "")
323-
if err := rootCmd.PersistentFlags().MarkHidden("pretty-json"); err != nil {
324-
panic(err)
325-
}
326-
rootCmd.PersistentFlags().Bool("json-record-per-line", false, "")
327-
if err := rootCmd.PersistentFlags().MarkHidden("json-record-per-line"); err != nil {
328-
panic(err)
329-
}
330330
}
331331

332332
// checkForDeprecatedFlagUsage checks for usage of deprecated flags and returns an error with the new flag if found.

0 commit comments

Comments
 (0)