Root Command Factory
Overview
The RootFactory (cmd/root_factory.go) assembles the root cobra command from
injectable dependencies. Each call to Build() returns a fresh, fully-wired
*cli.CommandAdder whose flags, sub-commands, and hooks are independent of
every other tree's — the key property that prevents cross-test leakage and
lets tests run in isolation. Some wiring still goes through process-global
state; see What stays shared for the list and the
resulting rules.
Production code (via main) uses the singleton built in init(). Tests use
NewRootFactory(...) directly with stubbed dependencies, or
NewIsolatedRootFactory() for an all-no-op preset.
Diagrams
Factory structure
classDiagram
class RootFactory {
+deps Dependencies
+telemetryClient *Client
+Build() *CommandAdder
+TelemetryClient() *Client
}
class Dependencies {
+ConfigInitializer func
+TLSSetup func
+TelemetryProps func
+TelemetryClient func
+Animation func
+PluginRegistrar func
+ViperBinder func
}
class CommandAdder {
+Command *cobra.Command
+AddCommand(...)
+ExecuteContext(ctx)
}
class Options {
WithConfigInitializer()
WithTLSSetup()
WithTelemetryProps()
WithTelemetryClient()
WithAnimation()
WithPluginRegistrar()
}
RootFactory --> Dependencies : owns
RootFactory --> CommandAdder : Build() produces
Options ..> RootFactory : configure
Runtime sequence
How a command execution flows from main through the factory.
sequenceDiagram
participant M as main
participant E as ExecuteContext
participant F as productionFactory
participant P as persistentPreRun
participant C as cobra cmd
Note over F: built once at init()
M->>E: ExecuteContext(ctx)
E->>F: RootCmd.ExecuteContext(ctx)
F->>P: cobra fires PersistentPreRunE
P->>P: deps.ConfigInitializer()
P->>P: deps.TLSSetup()
P->>P: deps.TelemetryProps()
P->>P: stamp CommandKind
P->>P: deps.TelemetryClient(props)
P->>P: deps.Animation()
P->>C: cmd.RunE()
C-->>M: error or nil
Note over F: cobra.OnFinalize flushes telemetry
Adding a new command
1. Register it
// cmd/root_factory.go — addSubcommands()
func (f *RootFactory) addSubcommands(adder *cli.CommandAdder) {
adder.AddCommand(
// ...existing commands...
myfeature.Cmd(), // ← add it here
)
}
2. Write the command
Commands have no knowledge of the factory. Same cobra pattern as always:
// cmd/myfeature/cmd.go
package myfeature
func Cmd() *cobra.Command {
c := &cobra.Command{
Use: "myfeature",
Short: "Does the thing",
RunE: run,
}
telemetry.Track(c) // wired once, fires automatically via cobra.OnFinalize
return c
}
func run(cmd *cobra.Command, args []string) error {
// config, TLS, and telemetry client are already set up by the factory's
// PersistentPreRunE — nothing to initialize here
return nil
}
3. Write a test
This is where the factory pays off. Previously you'd fight shared viperx state; now you get a clean isolated tree:
func TestMyFeatureCmd(t *testing.T) {
root := cmd.NewIsolatedRootFactory().Build()
var buf bytes.Buffer
root.SetOut(&buf)
root.SetArgs([]string{"myfeature"})
require.NoError(t, root.Execute())
assert.Contains(t, buf.String(), "expected output")
}
NewIsolatedRootFactory pre-applies no-op overrides for every dependency, so
the test touches no disk, no environment, no global viper state, and no plugin
binaries. Pass your own options after the defaults to override specific
dependencies per test.
The short version: commands are written identically to before — they return a
*cobra.Command from a Cmd() constructor, get registered in one place, and
know nothing about the factory. The factory only changes how tests and main
assemble the tree.
Overriding a dependency in a test
The most common reason to override a dependency is preventing viperx state from leaking between tests. Viper resolves a key in this precedence order:
viperx.Set() > changed pflag > env var (live) > config file > defaults
Env vars are read live — os.LookupEnv on every Get — so t.Setenv cleanup
is visible immediately, and env vars are not the leak vector. The sticky
layers are the other three:
viperx.Set(...)overrides persist untilviperx.Reset()and, being highest-precedence, shadow live env reads for every later test in the process.- Config-file contents parsed by
ReadInConfigpersist in the global instance until the next read orviperx.Reset(). - Changed-flag state on the shared
RootCmdsingleton: once a test executes it with a flag (e.g.--debug), the pflag binding serves that value with priority over env and config for the rest of the process.
The fix is to give each test its own command tree built with a no-op
ConfigInitializer that never touches viper:
func TestSomethingThatSetsEnvVars(t *testing.T) {
t.Setenv("DATAROBOT_CLI_API_TOKEN", "test-token")
// Without the factory: Set overrides and config-file contents from
// earlier tests persist in the global viper instance and shadow this
// test's env vars.
//
// With the factory: the no-op ConfigInitializer never calls
// viperx.AutomaticEnv() or ReadConfigFile(), so this tree resolves
// only what the test seeds explicitly.
root := cmd.NewIsolatedRootFactory(
cmd.WithConfigInitializer(func(_ *cobra.Command) error {
// Seed the viper keys your command needs without calling
// AutomaticEnv() or ReadConfigFile(). Note: viperx.Set writes
// a highest-precedence override that persists until
// viperx.Reset() (which itself wipes flag bindings, so it is
// only safe when the next use is a fresh Build). Prefer
// seeding keys only in isolated-tree tests like this one.
viperx.Set("api-token", "test-token")
return nil
}),
).Build()
root.SetArgs([]string{"mycommand"})
require.NoError(t, root.Execute())
}
NewIsolatedRootFactory already no-ops every dependency, including the ones
with surprising side effects: the default TelemetryProps hits the network or
disk for device/user IDs, and the default PluginRegistrar executes every
dr-* binary found on PATH to fetch its manifest. Prefer the preset over
hand-assembling stubs:
Why not just use the global
RootCmd? The singleton is built once ininit()with the production dependencies, and everyExecute()on it runsReadInConfigagainst the live environment and marks its bound flags Changed. Both effects stick: config contents stay in the global viper instance until the next read, and a changed flag keeps outranking env vars for the rest of the process. (There is no env-var caching involved — viper readsos.LookupEnvlive on everyGet.) A fresh factory tree sidesteps both, andNewIsolatedRootFactory()skips the config read entirely.
What stays shared
Build() gives each test its own cobra tree, but some wiring still goes
through process-global state. Knowing exactly what remains shared tells you
which test topologies are safe.
| Shared global | Touched when | Effect |
|---|---|---|
| viper flag bindings | every Build() (default ViperBinder) |
bindings re-point at the newest tree; only the last-built tree's flags resolve via viperx.Get* |
| viper config contents | Execute() via ReadInConfig, read at Build() by plugin discovery |
parsed file contents persist in the global instance until the next read or viperx.Reset() |
cobra.EnableCaseInsensitive |
every Build() |
idempotent global write |
cobra.OnFinalize list |
every Execute | append-only and replayed on every Execute; the factory's generation guard neutralizes stale replays |
http.DefaultTransport |
Execute (TLS setup) | last-executed tree's TLS config wins |
log package logger |
Execute (log.Start / log.Stop) |
single global logger |
config.SetAPIConsumerTrace |
Execute | last-executed tree's trace string wins |
The practical rules:
- Serial build-then-execute is safe, especially via
NewIsolatedRootFactory(), which skips the viper binding and plugin discovery entirely. - Mixing
RootCmdwith factory trees is not: the newestBuild()hijacks the global viper bindings from any previously built tree, so only one tree's bound flags resolve at a time ("one live tree at a time"). t.Parallelacross trees is not safe with default dependencies — parallel trees race on every global listed above. True parallel isolation requires an injected viper instance, tracked as a backlog follow-up.
Adding a new dependency to the factory
Follow these four steps whenever a new cross-cutting concern needs to be injectable (for example, a new HTTP client, a feature-flag provider, or an audit logger).
Step 1 — Define the function type
Add a named function type to the top of root_factory.go. Named types make
option signatures self-documenting and prevent callers from accidentally passing
the wrong function:
// AuditLoggerFunc writes an audit event for the current command invocation.
// Replace in tests with a no-op or a recorder.
type AuditLoggerFunc func(cmd *cobra.Command, args []string) error
Step 2 — Add the field to Dependencies
type Dependencies struct {
ConfigInitializer ConfigInitializerFunc
TLSSetup TLSSetupFunc
// ... existing fields ...
// AuditLogger writes an audit event before each command runs.
AuditLogger AuditLoggerFunc
}
Step 3 — Set the production default and add a With* option
func DefaultDependencies() Dependencies {
return Dependencies{
// ... existing defaults ...
AuditLogger: defaultAuditLogger,
}
}
// WithAuditLogger overrides the audit-logging step.
// Pass a no-op in tests that don't need audit coverage.
func WithAuditLogger(fn AuditLoggerFunc) Option {
return func(f *RootFactory) {
f.deps.AuditLogger = fn
}
}
Step 4 — Call it in the right factory method
Most new dependencies belong in persistentPreRun (runs before every command)
or addSubcommands (runs once at build time). For something like an audit
logger that needs to fire before each command:
func (f *RootFactory) persistentPreRun(cmd *cobra.Command, args []string) error {
// ... existing setup ...
if err := f.deps.AuditLogger(cmd, args); err != nil {
return err
}
return nil
}
That's it. Tests override it with WithAuditLogger(func(...) error { return nil });
production gets defaultAuditLogger for free.
Planned next field: an
IOStreamsdependency (stdin/stdout/stderr abstraction, à la GitHub CLI'scmdutil.Factory). The injection rails are in place, but it only pays off once command constructors consume injected streams instead of writing toos.Stdoutdirectly — a cross-cutting migration tracked as a follow-up to this stack.
Feature-gating a command
For gating mechanics — features.SetGate, env-var naming, and gating nested
subcommands — see feature-gates.md.
The factory-specific fact: gates are evaluated by cli.CommandAdder.AddCommand
at Build time, so a built tree's gate state is frozen. The production
singleton's gate state is fixed at init(); a test that needs a different
gate state must set the env var first and then build a fresh tree:
func TestMyFeatureHiddenByDefault(t *testing.T) {
// Build a fresh tree with no feature gate enabled.
root := cmd.NewIsolatedRootFactory().Build()
for _, sub := range root.Commands() {
assert.NotEqual(t, "myfeature", sub.Name(),
"myfeature should not be present when gate is disabled")
}
}
func TestMyFeatureVisibleWhenGateEnabled(t *testing.T) {
t.Setenv("DATAROBOT_CLI_FEATURE_MYFEATURE", "true")
root := cmd.NewIsolatedRootFactory().Build()
var found bool
for _, sub := range root.Commands() {
if sub.Name() == "myfeature" {
found = true
}
}
assert.True(t, found, "myfeature should be present when gate is enabled")
}