feat(cmd): introduce RootFactory pattern for isolated command assembly - #802
feat(cmd): introduce RootFactory pattern for isolated command assembly#802ajalon1 wants to merge 13 commits into
Conversation
|
Hi AJ - Just saw this and I'll review it shortly |
chasdr
left a comment
There was a problem hiding this comment.
👋 chasbot here — chas had me take a techspec pass since you asked for input. design feedback, not a merge gate, agent-to-agent: my compliments to whichever of your agents drew the diagrams.
i diffed the factory against the old init() line by line hoping to catch a smuggled behavior change. nothing. deeply disappointing. the two problems i did find are both about the isolation story, not the port:
1. Build() re-points the global viper bindings at the newest tree. the factory hands out fresh cobra trees, but viper is still the one shared watering hole, and bindViperFlags marches every new tree straight to it on every Build. it's also the one wiring step not in Dependencies, so even the doc's "minimal no-op factory" mutates global state. reproduced locally:
_ = RootCmd.PersistentFlags().Set("debug", "true") // visible through viperx ✓
_ = NewRootFactory(/* all no-op deps */).Build() // another test builds a tree
viperx.GetBool("debug") // false — binding hijacked
log.Start reads debug/verbose and setupTLS reads ca-cert/-k through those bindings, so only the last-built tree's bound flags resolve. serial build-then-execute tests work; mixing RootCmd with factory trees doesn't, and t.Parallel remains a fantasy. since rationale #1 is viper leakage: is an injected viper instance (viperx instance mode) on the stack's roadmap, or should the doc state the "one live tree at a time" rule explicitly?
2. cobra.OnFinalize never forgets. elephant-grade memory, no delete API: the finalizer list is append-only and replayed on every execute. inherited from the old root.go, but this stack makes multiple Executes per process the primary use case, so execute N re-tracks executes 1..N-1's stale telemetry events on their old clients. a capturing test client sees phantom duplicates. guard the closure so it fires once, or move track/flush into PersistentPostRunE plus a factory-owned Execute wrapper for the error path?
cool-takes, in leverage order:
- ship a NewIsolatedRootFactory()/TestDependencies() preset in this PR rather than #811 — the doc pastes the same five stubs four times, which is the doc quietly asking for the helper. also, a default-deps Build() execs dr-* binaries off PATH via PluginRegistrar, so the safe preset should be one call away.
- the most valuable field in gh's factory is IOStreams. the injection rails now exist; IOStreams as the next Dependencies field gains command tests more than any of the current six.
everything above fits inside the pattern you already built. — chas's claude, blocking nothing, it's a draft
mermaid for the win.
feels like a feature to me. :)
Seeing these options reminds me that putting anything expectd to always run into PersistentPostRunE at the root level is not a good idea. Any subcommand can simply override PersistentPostRunE and then we won't get telemetry, with no errors or warnings. I don't think we do this at all, but it's there. (I think its a similar issue with PersistentPreRunE but I'd have to check.) So I'll tinker with "guarding the closure". |
862e5f5 to
e28b511
Compare
The configFilePath field was never assigned: registerFlags uses
flags.String("config", ...) with no backing variable, so the field was
always "" and --config resolved only through the viper-binding fallback
in defaultConfigInitializer. Flags also parse at Execute time, so the
field comment's "populated during Build()" could never happen.
- Change ConfigInitializerFunc to func(cmd *cobra.Command) error
- defaultConfigInitializer now reads --config from cobra directly
(flag > DATAROBOT_CLI_CONFIG env > default), which keeps working
even when tests stub out the viper binding
- Update the root-factory guide's stub examples and class diagram
No WithConfigFilePath option: a test that needs a fixture config path
can capture one in a WithConfigInitializer closure.
Addresses review feedback from chasdr on PR #802.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The client is created in persistentPreRun at Execute time, not "during the most recent Build() call" — a built-but-never-executed tree returns nil. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
cobra.OnFinalize appends to a process-global, append-only list that is replayed in full after every Execute in the process — of any tree, built by any factory. Previously, execute N re-tracked the events of executes 1..N-1 on their old captured clients. Latent before (one Execute per process in production, no capturable client in tests), but the factory makes multiple executes per process the primary test use case. A process-global generation counter (finalizerGen) now stamps each finalizer closure; only the finalizer registered by the most recent Execute acts, and stale ones no-op. The counter must be shared by all factories because cobra's finalizer list is itself process-global. Track/flush stays in OnFinalize rather than PersistentPostRunE: cobra skips PostRunE on the RunE error path, and innermost-hook shadowing means any subcommand defining its own PersistentPostRunE would silently disable the flush. Adds telemetry.NewTestClient as a test seam for capturing tracked events, and a regression test proving each of two sequentially executed trees tracks exactly one event (verified to fail without the guard). Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
bindViperFlags was the one Build-time wiring step not represented in Dependencies, so even a fully stubbed test factory still re-pointed the global viper bindings at the newest tree on every Build — hijacking flag resolution from any previously built tree (including RootCmd). Adds ViperBinderFunc, a ViperBinder field on Dependencies, and a WithViperBinder option; Build now calls the injected dependency. The default is the existing bindViperFlags behavior (now a package-level function), so production is unchanged. Tests can pass a no-op to leave global viper untouched, with the documented caveat that viper-backed reads (debug/verbose, ca-cert) then won't resolve that tree's flags. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The guide pasted the same five no-op stubs in four places, and a default-deps Build() is genuinely unsafe to copy into a test: plugin discovery executes every dr-* binary found on PATH to fetch manifests. The safe preset should be one call away. NewIsolatedRootFactory pre-applies side-effect-free defaults for every dependency — no disk reads, no env binding, no TLS setup, no animation, no telemetry transmission (via telemetry.NewTestClient), no viper flag binding (via the new WithViperBinder), and no plugin discovery. Caller options apply after the defaults, so specific deps remain overridable. The regression test from the OnFinalize fix now dogfoods the preset, and the guide's examples collapse to one-liners. Also resolves the guide's forward reference to a "test-isolation follow-up PR" helper — this is that helper — and names IOStreams as the next planned Dependencies field. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The factory docs claimed "no shared mutable state" / "does not mutate any package-level state", which overpromised: Build re-points global viper bindings at the newest tree, plugin discovery reads global viper and execs PATH binaries, and Execute touches cobra.OnFinalize, http.DefaultTransport, the log package, and SetAPIConsumerTrace. Scope the claims to what is actually true — trees are independent of each other's flags, sub-commands, and hooks — and add a "What stays shared" section to the guide enumerating the remaining globals with the practical rules: serial build-then-execute is safe (especially via NewIsolatedRootFactory), mixing RootCmd with factory trees is not, and t.Parallel across trees needs the injected-viper backlog item. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The guide claimed env vars "can still be visible to the second test even after the env var is restored" because of an internal viper cache swept on the next AutomaticEnv call. Verified against the viper v1.21.0 source: getEnv is os.LookupEnv live on every Get — there is no env cache and no sweep, so t.Setenv cleanup is visible immediately. The real sticky layers, per viper's precedence order (Set > changed pflag > env > config file > defaults): - viperx.Set overrides persist until viperx.Reset and shadow env reads - config-file contents parsed by ReadInConfig persist until re-read - changed-flag state on the shared RootCmd singleton outranks env for the rest of the process Also adds the caveat at the guide's own Set example: the override persists process-wide until viperx.Reset (which itself wipes flag bindings, so it is only safe before a fresh Build). Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The section duplicated docs/development/feature-gates.md (SetGate mechanics, env-var naming, nested subcommands), and two copies would drift. Keep only the factory-specific fact — gates evaluate in CommandAdder.AddCommand at Build time, so a test needs a fresh tree per gate state — plus the test pattern that demonstrates it, and link to feature-gates.md for the rest. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The "How #803 extends the factory" section documented symbols that only exist in stacked PR #803 (telemetry.StampInteractionMode, cli.IsNonInteractive, cli.YesFlagName). If #802 merges first — or #803 reshapes during review — main's docs would reference nonexistent symbols with nothing flagging the drift. The section moves to #803's own docs, where the symbols are real. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The configFilePath field was never assigned: registerFlags uses
flags.String("config", ...) with no backing variable, so the field was
always "" and --config resolved only through the viper-binding fallback
in defaultConfigInitializer. Flags also parse at Execute time, so the
field comment's "populated during Build()" could never happen.
- Change ConfigInitializerFunc to func(cmd *cobra.Command) error
- defaultConfigInitializer now reads --config from cobra directly
(flag > DATAROBOT_CLI_CONFIG env > default), which keeps working
even when tests stub out the viper binding
- Update the root-factory guide's stub examples and class diagram
No WithConfigFilePath option: a test that needs a fixture config path
can capture one in a WithConfigInitializer closure.
Addresses review feedback from chasdr on PR #802.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The client is created in persistentPreRun at Execute time, not "during the most recent Build() call" — a built-but-never-executed tree returns nil. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
cobra.OnFinalize appends to a process-global, append-only list that is replayed in full after every Execute in the process — of any tree, built by any factory. Previously, execute N re-tracked the events of executes 1..N-1 on their old captured clients. Latent before (one Execute per process in production, no capturable client in tests), but the factory makes multiple executes per process the primary test use case. A process-global generation counter (finalizerGen) now stamps each finalizer closure; only the finalizer registered by the most recent Execute acts, and stale ones no-op. The counter must be shared by all factories because cobra's finalizer list is itself process-global. Track/flush stays in OnFinalize rather than PersistentPostRunE: cobra skips PostRunE on the RunE error path, and innermost-hook shadowing means any subcommand defining its own PersistentPostRunE would silently disable the flush. Adds telemetry.NewTestClient as a test seam for capturing tracked events, and a regression test proving each of two sequentially executed trees tracks exactly one event (verified to fail without the guard). Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
427b5ef to
a0fa619
Compare
bindViperFlags was the one Build-time wiring step not represented in Dependencies, so even a fully stubbed test factory still re-pointed the global viper bindings at the newest tree on every Build — hijacking flag resolution from any previously built tree (including RootCmd). Adds ViperBinderFunc, a ViperBinder field on Dependencies, and a WithViperBinder option; Build now calls the injected dependency. The default is the existing bindViperFlags behavior (now a package-level function), so production is unchanged. Tests can pass a no-op to leave global viper untouched, with the documented caveat that viper-backed reads (debug/verbose, ca-cert) then won't resolve that tree's flags. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The guide pasted the same five no-op stubs in four places, and a default-deps Build() is genuinely unsafe to copy into a test: plugin discovery executes every dr-* binary found on PATH to fetch manifests. The safe preset should be one call away. NewIsolatedRootFactory pre-applies side-effect-free defaults for every dependency — no disk reads, no env binding, no TLS setup, no animation, no telemetry transmission (via telemetry.NewTestClient), no viper flag binding (via the new WithViperBinder), and no plugin discovery. Caller options apply after the defaults, so specific deps remain overridable. The regression test from the OnFinalize fix now dogfoods the preset, and the guide's examples collapse to one-liners. Also resolves the guide's forward reference to a "test-isolation follow-up PR" helper — this is that helper — and names IOStreams as the next planned Dependencies field. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The factory docs claimed "no shared mutable state" / "does not mutate any package-level state", which overpromised: Build re-points global viper bindings at the newest tree, plugin discovery reads global viper and execs PATH binaries, and Execute touches cobra.OnFinalize, http.DefaultTransport, the log package, and SetAPIConsumerTrace. Scope the claims to what is actually true — trees are independent of each other's flags, sub-commands, and hooks — and add a "What stays shared" section to the guide enumerating the remaining globals with the practical rules: serial build-then-execute is safe (especially via NewIsolatedRootFactory), mixing RootCmd with factory trees is not, and t.Parallel across trees needs the injected-viper backlog item. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The guide claimed env vars "can still be visible to the second test even after the env var is restored" because of an internal viper cache swept on the next AutomaticEnv call. Verified against the viper v1.21.0 source: getEnv is os.LookupEnv live on every Get — there is no env cache and no sweep, so t.Setenv cleanup is visible immediately. The real sticky layers, per viper's precedence order (Set > changed pflag > env > config file > defaults): - viperx.Set overrides persist until viperx.Reset and shadow env reads - config-file contents parsed by ReadInConfig persist until re-read - changed-flag state on the shared RootCmd singleton outranks env for the rest of the process Also adds the caveat at the guide's own Set example: the override persists process-wide until viperx.Reset (which itself wipes flag bindings, so it is only safe before a fresh Build). Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The section duplicated docs/development/feature-gates.md (SetGate mechanics, env-var naming, nested subcommands), and two copies would drift. Keep only the factory-specific fact — gates evaluate in CommandAdder.AddCommand at Build time, so a test needs a fresh tree per gate state — plus the test pattern that demonstrates it, and link to feature-gates.md for the rest. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The "How #803 extends the factory" section documented symbols that only exist in stacked PR #803 (telemetry.StampInteractionMode, cli.IsNonInteractive, cli.YesFlagName). If #802 merges first — or #803 reshapes during review — main's docs would reference nonexistent symbols with nothing flagging the drift. The section moves to #803's own docs, where the symbols are real. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
|
Think I handled everything.
was a good idea -- handled that
also a great idea, but I'll leave that for a separate PR |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit a0fa619. Configure here.
| cobra.OnFinalize(func() { | ||
| if gen != finalizerGen.Load() { | ||
| return | ||
| } |
There was a problem hiding this comment.
Finalizer guard misses early failures
Medium Severity
finalizerGen is bumped only after config/TLS succeed and the new OnFinalize is registered. OnFinalize still runs when PersistentPreRunE returns earlier, so the previous execute’s finalizer stays current and can Track again — the phantom-duplicate case this guard was meant to stop in tests.
| } | |
| log.Start() | |
| // Retire prior Executes' finalizers before any fallible work. A PreRun that | |
| // returns early must still bump the generation so stale closures no-op when | |
| // cobra replays the process-global OnFinalize list. | |
| gen := finalizerGen.Add(1) | |
| // Suppress cobra's usage printout for runtime errors — only show it for | |
| // flag-parsing failures, which happen before this hook runs. | |
| cmd.SilenceUsage = true | |
| // Read drconfig.yaml and bind env vars into viper. | |
| if err := f.deps.ConfigInitializer(cmd); err != nil { | |
| return err | |
| } | |
| // Configure the default HTTP transport (ca-cert, skip-verify). | |
| if err := f.deps.TLSSetup(cmd); err != nil { | |
| return err | |
| } | |
| // Collect common properties for telemetry (and optional debug logging). | |
| // Always collect even in dry-run mode; transmission is gated inside Client. | |
| props := f.deps.TelemetryProps() | |
| if props != nil { | |
| // Stamp command_kind so every event knows whether it came from a core | |
| // command or a plugin. | |
| if telemetry.IsPluginCommand(cmd) { | |
| props.CommandKind = "plugin" | |
| } else { | |
| props.CommandKind = "core" | |
| } | |
| } | |
| // Log the detected shell only when debug is active. Reuse Shell from | |
| // telemetry props (already collected above) when available to avoid | |
| // spawning a redundant ps(1) subprocess on macOS. | |
| if log.GetLevel() <= log.DebugLevel { | |
| var shell string | |
| if props != nil { | |
| shell = props.Shell | |
| } else { | |
| shell = telemetry.DetectShell() | |
| } | |
| log.Debug("Shell", "name", shell) | |
| } | |
| client := f.deps.TelemetryClient(props) | |
| // Store as factory-level client so cmd.Exit can flush on the main error path. | |
| f.telemetryClient = client | |
| // Store telemetry client in context for use by sub-commands. | |
| cmd.SetContext(context.WithValue(cmd.Context(), telemetry.ClientContextKey{}, client)) | |
| // cobra.OnFinalize appends to a process-global, append-only list that is | |
| // replayed in full after EVERY Execute in the process — of any tree, | |
| // built by any factory. Without a guard, execute N would re-track the | |
| // events of executes 1..N-1 on their old (captured) clients — harmless | |
| // in production (one Execute per process) but a source of phantom | |
| // duplicate events in tests. The process-global generation counter | |
| // ensures only the finalizer registered by the most recent Execute acts; | |
| // stale finalizers from earlier executes no-op. | |
| cobra.OnFinalize(func() { | |
| if gen != finalizerGen.Load() { | |
| return | |
| } |
Reviewed by Cursor Bugbot for commit a0fa619. Configure here.
There was a problem hiding this comment.
I thought I did this already.
The configFilePath field was never assigned: registerFlags uses
flags.String("config", ...) with no backing variable, so the field was
always "" and --config resolved only through the viper-binding fallback
in defaultConfigInitializer. Flags also parse at Execute time, so the
field comment's "populated during Build()" could never happen.
- Change ConfigInitializerFunc to func(cmd *cobra.Command) error
- defaultConfigInitializer now reads --config from cobra directly
(flag > DATAROBOT_CLI_CONFIG env > default), which keeps working
even when tests stub out the viper binding
- Update the root-factory guide's stub examples and class diagram
No WithConfigFilePath option: a test that needs a fixture config path
can capture one in a WithConfigInitializer closure.
Addresses review feedback from chasdr on PR #802.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The client is created in persistentPreRun at Execute time, not "during the most recent Build() call" — a built-but-never-executed tree returns nil. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
cobra.OnFinalize appends to a process-global, append-only list that is replayed in full after every Execute in the process — of any tree, built by any factory. Previously, execute N re-tracked the events of executes 1..N-1 on their old captured clients. Latent before (one Execute per process in production, no capturable client in tests), but the factory makes multiple executes per process the primary test use case. A process-global generation counter (finalizerGen) now stamps each finalizer closure; only the finalizer registered by the most recent Execute acts, and stale ones no-op. The counter must be shared by all factories because cobra's finalizer list is itself process-global. Track/flush stays in OnFinalize rather than PersistentPostRunE: cobra skips PostRunE on the RunE error path, and innermost-hook shadowing means any subcommand defining its own PersistentPostRunE would silently disable the flush. Adds telemetry.NewTestClient as a test seam for capturing tracked events, and a regression test proving each of two sequentially executed trees tracks exactly one event (verified to fail without the guard). Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
bindViperFlags was the one Build-time wiring step not represented in Dependencies, so even a fully stubbed test factory still re-pointed the global viper bindings at the newest tree on every Build — hijacking flag resolution from any previously built tree (including RootCmd). Adds ViperBinderFunc, a ViperBinder field on Dependencies, and a WithViperBinder option; Build now calls the injected dependency. The default is the existing bindViperFlags behavior (now a package-level function), so production is unchanged. Tests can pass a no-op to leave global viper untouched, with the documented caveat that viper-backed reads (debug/verbose, ca-cert) then won't resolve that tree's flags. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The guide pasted the same five no-op stubs in four places, and a default-deps Build() is genuinely unsafe to copy into a test: plugin discovery executes every dr-* binary found on PATH to fetch manifests. The safe preset should be one call away. NewIsolatedRootFactory pre-applies side-effect-free defaults for every dependency — no disk reads, no env binding, no TLS setup, no animation, no telemetry transmission (via telemetry.NewTestClient), no viper flag binding (via the new WithViperBinder), and no plugin discovery. Caller options apply after the defaults, so specific deps remain overridable. The regression test from the OnFinalize fix now dogfoods the preset, and the guide's examples collapse to one-liners. Also resolves the guide's forward reference to a "test-isolation follow-up PR" helper — this is that helper — and names IOStreams as the next planned Dependencies field. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
a0fa619 to
4bb08a6
Compare
The factory docs claimed "no shared mutable state" / "does not mutate any package-level state", which overpromised: Build re-points global viper bindings at the newest tree, plugin discovery reads global viper and execs PATH binaries, and Execute touches cobra.OnFinalize, http.DefaultTransport, the log package, and SetAPIConsumerTrace. Scope the claims to what is actually true — trees are independent of each other's flags, sub-commands, and hooks — and add a "What stays shared" section to the guide enumerating the remaining globals with the practical rules: serial build-then-execute is safe (especially via NewIsolatedRootFactory), mixing RootCmd with factory trees is not, and t.Parallel across trees needs the injected-viper backlog item. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The guide claimed env vars "can still be visible to the second test even after the env var is restored" because of an internal viper cache swept on the next AutomaticEnv call. Verified against the viper v1.21.0 source: getEnv is os.LookupEnv live on every Get — there is no env cache and no sweep, so t.Setenv cleanup is visible immediately. The real sticky layers, per viper's precedence order (Set > changed pflag > env > config file > defaults): - viperx.Set overrides persist until viperx.Reset and shadow env reads - config-file contents parsed by ReadInConfig persist until re-read - changed-flag state on the shared RootCmd singleton outranks env for the rest of the process Also adds the caveat at the guide's own Set example: the override persists process-wide until viperx.Reset (which itself wipes flag bindings, so it is only safe before a fresh Build). Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The section duplicated docs/development/feature-gates.md (SetGate mechanics, env-var naming, nested subcommands), and two copies would drift. Keep only the factory-specific fact — gates evaluate in CommandAdder.AddCommand at Build time, so a test needs a fresh tree per gate state — plus the test pattern that demonstrates it, and link to feature-gates.md for the rest. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The "How #803 extends the factory" section documented symbols that only exist in stacked PR #803 (telemetry.StampInteractionMode, cli.IsNonInteractive, cli.YesFlagName). If #802 merges first — or #803 reshapes during review — main's docs would reference nonexistent symbols with nothing flagging the drift. The section moves to #803's own docs, where the symbols are real. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Adds a `RootFactory` type (`cmd/root_factory.go`) that constructs the root cobra command from a set of injectable `Dependencies`, mirroring the approach used by GitHub CLI (`pkg/cmdutil.Factory`) and kubectl (`pkg/cmd/util.Factory`). Key changes: - `cmd/root_factory.go`: defines `RootFactory`, `Dependencies`, and five functional option helpers (`WithConfigInitializer`, `WithTLSSetup`, `WithTelemetryProps`, `WithTelemetryClient`, `WithAnimation`, `WithPluginRegistrar`). Each `Build()` call returns a fresh, fully-wired `*cli.CommandAdder` with no shared mutable state. - `cmd/root_helpers.go`: extracts `showFirstRunAnimation` and `setUnknownArgGuards` from the old `root.go` into a focused helper file so the factory can call them without causing import cycles. - `cmd/root.go`: reduced to a thin bootstrap that registers import-cycle- breaking function values (`allCommandsOutputFn`, `runVersionCommandFn`), builds the production singleton via `NewRootFactory()`, and exposes the familiar `RootCmd` package-level var for backward compatibility. - `cmd/exit.go`: updated to read the telemetry client from `productionFactory.TelemetryClient()` rather than a package-level pointer, so `Exit()` always flushes the most recently set client. - `internal/telemetry/interaction.go` + `interaction_test.go`: ports the `StampInteractionMode` / `computeNonInteractive` / `hasYesFlag` helpers from PR #797 so the factory's `persistentPreRun` can stamp `NonInteractive` on every event without per-command duplication. - `internal/telemetry/properties.go` + `properties_test.go`: adds the `NonInteractive bool` field to `CommonProperties` and `non_interactive` to `AsMap()`; updates tests to assert the field is always present. Validation: `go build ./cmd/...`, `go test -race ./cmd/... ./internal/telemetry/...` all green; `task lint` reports 0 issues across linux, darwin, and windows. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…examples Covers the RootFactory pattern introduced in this PR: - class diagram of RootFactory / Dependencies / CommandAdder - runtime sequence from main through PersistentPreRunE - flowchart showing how #803 (non-interactive telemetry) slots in - step-by-step guide for adding a new command and writing isolated tests Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…patterns Adds three new sections: - Overriding a dependency in a test: explains viperx leakage and the no-op ConfigInitializer pattern, with a callout on why the global RootCmd singleton is unsafe for tests that mutate env state. - Adding a new dependency to the factory: 4-step recipe (type alias, Dependencies field, With* option + default, call site). - Feature-gating a command: SetGate usage, nested subcommand gating, the env var naming convention, and test patterns for both gated and ungated states using a fresh factory tree. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…lpers.go root_factory.go — describes its purpose (injectable root command constructor), lists the extension points, and links to the developer guide. root_helpers.go — describes showFirstRunAnimation and setUnknownArgGuards and explains why they live in a separate file (import cycle avoidance). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The configFilePath field was never assigned: registerFlags uses
flags.String("config", ...) with no backing variable, so the field was
always "" and --config resolved only through the viper-binding fallback
in defaultConfigInitializer. Flags also parse at Execute time, so the
field comment's "populated during Build()" could never happen.
- Change ConfigInitializerFunc to func(cmd *cobra.Command) error
- defaultConfigInitializer now reads --config from cobra directly
(flag > DATAROBOT_CLI_CONFIG env > default), which keeps working
even when tests stub out the viper binding
- Update the root-factory guide's stub examples and class diagram
No WithConfigFilePath option: a test that needs a fixture config path
can capture one in a WithConfigInitializer closure.
Addresses review feedback from chasdr on PR #802.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The client is created in persistentPreRun at Execute time, not "during the most recent Build() call" — a built-but-never-executed tree returns nil. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
cobra.OnFinalize appends to a process-global, append-only list that is replayed in full after every Execute in the process — of any tree, built by any factory. Previously, execute N re-tracked the events of executes 1..N-1 on their old captured clients. Latent before (one Execute per process in production, no capturable client in tests), but the factory makes multiple executes per process the primary test use case. A process-global generation counter (finalizerGen) now stamps each finalizer closure; only the finalizer registered by the most recent Execute acts, and stale ones no-op. The counter must be shared by all factories because cobra's finalizer list is itself process-global. Track/flush stays in OnFinalize rather than PersistentPostRunE: cobra skips PostRunE on the RunE error path, and innermost-hook shadowing means any subcommand defining its own PersistentPostRunE would silently disable the flush. Adds telemetry.NewTestClient as a test seam for capturing tracked events, and a regression test proving each of two sequentially executed trees tracks exactly one event (verified to fail without the guard). Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
bindViperFlags was the one Build-time wiring step not represented in Dependencies, so even a fully stubbed test factory still re-pointed the global viper bindings at the newest tree on every Build — hijacking flag resolution from any previously built tree (including RootCmd). Adds ViperBinderFunc, a ViperBinder field on Dependencies, and a WithViperBinder option; Build now calls the injected dependency. The default is the existing bindViperFlags behavior (now a package-level function), so production is unchanged. Tests can pass a no-op to leave global viper untouched, with the documented caveat that viper-backed reads (debug/verbose, ca-cert) then won't resolve that tree's flags. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The guide pasted the same five no-op stubs in four places, and a default-deps Build() is genuinely unsafe to copy into a test: plugin discovery executes every dr-* binary found on PATH to fetch manifests. The safe preset should be one call away. NewIsolatedRootFactory pre-applies side-effect-free defaults for every dependency — no disk reads, no env binding, no TLS setup, no animation, no telemetry transmission (via telemetry.NewTestClient), no viper flag binding (via the new WithViperBinder), and no plugin discovery. Caller options apply after the defaults, so specific deps remain overridable. The regression test from the OnFinalize fix now dogfoods the preset, and the guide's examples collapse to one-liners. Also resolves the guide's forward reference to a "test-isolation follow-up PR" helper — this is that helper — and names IOStreams as the next planned Dependencies field. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The factory docs claimed "no shared mutable state" / "does not mutate any package-level state", which overpromised: Build re-points global viper bindings at the newest tree, plugin discovery reads global viper and execs PATH binaries, and Execute touches cobra.OnFinalize, http.DefaultTransport, the log package, and SetAPIConsumerTrace. Scope the claims to what is actually true — trees are independent of each other's flags, sub-commands, and hooks — and add a "What stays shared" section to the guide enumerating the remaining globals with the practical rules: serial build-then-execute is safe (especially via NewIsolatedRootFactory), mixing RootCmd with factory trees is not, and t.Parallel across trees needs the injected-viper backlog item. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The guide claimed env vars "can still be visible to the second test even after the env var is restored" because of an internal viper cache swept on the next AutomaticEnv call. Verified against the viper v1.21.0 source: getEnv is os.LookupEnv live on every Get — there is no env cache and no sweep, so t.Setenv cleanup is visible immediately. The real sticky layers, per viper's precedence order (Set > changed pflag > env > config file > defaults): - viperx.Set overrides persist until viperx.Reset and shadow env reads - config-file contents parsed by ReadInConfig persist until re-read - changed-flag state on the shared RootCmd singleton outranks env for the rest of the process Also adds the caveat at the guide's own Set example: the override persists process-wide until viperx.Reset (which itself wipes flag bindings, so it is only safe before a fresh Build). Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The section duplicated docs/development/feature-gates.md (SetGate mechanics, env-var naming, nested subcommands), and two copies would drift. Keep only the factory-specific fact — gates evaluate in CommandAdder.AddCommand at Build time, so a test needs a fresh tree per gate state — plus the test pattern that demonstrates it, and link to feature-gates.md for the rest. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The "How #803 extends the factory" section documented symbols that only exist in stacked PR #803 (telemetry.StampInteractionMode, cli.IsNonInteractive, cli.YesFlagName). If #802 merges first — or #803 reshapes during review — main's docs would reference nonexistent symbols with nothing flagging the drift. The section moves to #803's own docs, where the symbols are real. Addresses review feedback from chasdr on PR #802. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
4bb08a6 to
835c242
Compare


THIS IS A DRAFT AND NOT FINALIZED. YOUR COMMENTS AND INPUT ARE REQUESTED
RATIONALE
As Carson noted, the CLI's root command setup uses package-level globals and
init()side effects inherited from a basic cobra template. This creates two concrete problems:RootCmdsingleton (built atinit()time) reads live env vars on every Execute call.command_kindandnon_interactivehave to be stamped ad-hoc insidePersistentPreRunEagainst a shared global, making it easy to miss them in new commands or test scenarios.GitHub CLI (
pkg/cmdutil.Factory) and kubectl (pkg/cmd/util.Factory) both solve this by assembling the root command via an injectable factory whose dependencies can be swapped in tests. This PR brings the same pattern to the DataRobot CLI.This PR does not fix env-var leakage in existing tests that use
RootCmd()directly — that's #811. Telemetry/yes-flag centralization and command migrations are in the stacked #803.DIAGRAMS?
If you want all the gory details, go to docs/development/root-factory.md. Here are the more important ones:
CHANGES
New:
cmd/root_factory.goRootFactory, aDependenciesstruct, and sevenWith*functional option helpers (WithConfigInitializer,WithTLSSetup,WithTelemetryProps,WithTelemetryClient,WithAnimation,WithPluginRegistrar,WithViperBinder).Build()returns a fresh, fully-wired*cli.CommandAdderper call. The cobra tree itself is self-contained; the process globals that stay shared (viper bindings,cobra.OnFinalize,http.DefaultTransport, the log package) are documented in the guide's "What stays shared" section, along with the "one live tree at a time" rule.init()(flag registration, viper binding, group/subcommand registration, help overrides, plugin discovery, unknown-arg guards) now runs per-build inside factory methods.cobra.OnFinalizeensures stale finalizers from earlier Executes no-op instead of re-tracking old events on old clients — only the most recent Execute's finalizer acts.NewIsolatedRootFactory()is the safe one-call preset for tests: every dependency no-oped (no disk reads, no env binding, no plugin discovery, no viper binding, no telemetry transmission), with per-test overrides still available.New:
cmd/root_factory_test.goRegression test proving two sequentially executed trees each track exactly one telemetry event (verified to fail without the
OnFinalizeguard).Updated:
internal/telemetry/telemetry.goAdds
NewTestClient, a test seam for capturing tracked events via an injectedamplitude.Client. Production behavior is unchanged.New:
cmd/root_helpers.goExtracts
showFirstRunAnimationandsetUnknownArgGuardsfrom the oldroot.gointo a focused helper file so the factory can call them without import cycles.Refactored:
cmd/root.goReduced to ~90 lines. Its only jobs now are:
allCommandsOutputFn,runVersionCommandFn).NewRootFactory().RootCmdpackage-level var so existing tests need no changes.Updated:
cmd/exit.goReads the telemetry client from
productionFactory.TelemetryClient()at flush time rather than a bare package-level pointer.TESTING
go build ./...— cleango test -race ./cmd/... ./internal/cli/... ./internal/telemetry/...— all greentask lint— 0 issues across linux, darwin, and windowsRELATED
cmdthroughplugin installconfirm pathroot_test.goto useNewRootFactorywith stubbed depsFOLLOW-UP
IOStreams(stdin/stdout/stderr injection, à la gh's factory) as the nextDependenciesfield — separate PRNote
Medium Risk
Refactors CLI bootstrap (config, TLS, telemetry flush, cobra hooks) while keeping the production singleton. Behavior is meant to be unchanged, but mistakes in pre-run/finalize wiring could affect every command.
Overview
Introduces an injectable
RootFactoryso tests can build a fresh cobra tree with stubbed config, TLS, telemetry, plugins, and viper binding, instead of sharing theinit()-builtRootCmdsingleton.Production still uses that singleton (
RootCmd/ExecuteContextunchanged for callers). Wiring that lived inroot.gonow runs perBuild(): flags, groups, subcommands, help, plugin discovery, and unknown-arg guards.cmd.Exitflushes telemetry fromproductionFactoryrather than a package-level client pointer.NewIsolatedRootFactoryno-ops those side effects for unit tests. A generation guard oncobra.OnFinalizeprevents stale telemetry re-tracks across sequential Executes. Docs call out remaining process-global state (viper, HTTP transport, logger) and the “one live tree at a time” rule.Reviewed by Cursor Bugbot for commit a0fa619. Configure here.