Skip to content

feat(cmd): introduce RootFactory pattern for isolated command assembly - #802

Open
ajalon1 wants to merge 13 commits into
mainfrom
aj/root-command-factory
Open

feat(cmd): introduce RootFactory pattern for isolated command assembly#802
ajalon1 wants to merge 13 commits into
mainfrom
aj/root-command-factory

Conversation

@ajalon1

@ajalon1 ajalon1 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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:

  1. Env-var leakage in tests — viperx state set by one test bleeds into the next because the production RootCmd singleton (built at init() time) reads live env vars on every Execute call.
  2. Inconsistent telemetry context — properties like command_kind and non_interactive have to be stamped ad-hoc inside PersistentPreRunE against 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:

9a082257f754 b26ebd55c100

CHANGES

New: cmd/root_factory.go

  • Defines RootFactory, a Dependencies struct, and seven With* functional option helpers (WithConfigInitializer, WithTLSSetup, WithTelemetryProps, WithTelemetryClient, WithAnimation, WithPluginRegistrar, WithViperBinder).
  • Build() returns a fresh, fully-wired *cli.CommandAdder per 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.
  • All wiring that was in init() (flag registration, viper binding, group/subcommand registration, help overrides, plugin discovery, unknown-arg guards) now runs per-build inside factory methods.
  • A process-global generation guard on cobra.OnFinalize ensures 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.go

Regression test proving two sequentially executed trees each track exactly one telemetry event (verified to fail without the OnFinalize guard).

Updated: internal/telemetry/telemetry.go

Adds NewTestClient, a test seam for capturing tracked events via an injected amplitude.Client. Production behavior is unchanged.

New: 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 import cycles.

Refactored: cmd/root.go

Reduced to ~90 lines. Its only jobs now are:

  1. Register import-cycle-breaking function values (allCommandsOutputFn, runVersionCommandFn).
  2. Build the production singleton via NewRootFactory().
  3. Expose the backward-compatible RootCmd package-level var so existing tests need no changes.

Updated: cmd/exit.go

Reads the telemetry client from productionFactory.TelemetryClient() at flush time rather than a bare package-level pointer.

TESTING

  • go build ./... — clean
  • go test -race ./cmd/... ./internal/cli/... ./internal/telemetry/... — all green
  • task lint — 0 issues across linux, darwin, and windows
  • All pre-commit hooks pass

RELATED

FOLLOW-UP

  • IOStreams (stdin/stdout/stderr injection, à la gh's factory) as the next Dependencies field — separate PR
  • Viper instance-mode (injected viper per tree) is tracked separately in the backlog; the guide documents the current shared-state limitations instead

Note

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 RootFactory so tests can build a fresh cobra tree with stubbed config, TLS, telemetry, plugins, and viper binding, instead of sharing the init()-built RootCmd singleton.

Production still uses that singleton (RootCmd / ExecuteContext unchanged for callers). Wiring that lived in root.go now runs per Build(): flags, groups, subcommands, help, plugin discovery, and unknown-arg guards. cmd.Exit flushes telemetry from productionFactory rather than a package-level client pointer.

NewIsolatedRootFactory no-ops those side effects for unit tests. A generation guard on cobra.OnFinalize prevents 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.

@github-actions github-actions Bot added the go Pull requests that update go code label Aug 20, 2026
@chasdr

chasdr commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Hi AJ - Just saw this and I'll review it shortly

@chasdr chasdr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👋 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

Comment thread cmd/root_factory.go Outdated
Comment thread cmd/root_factory.go
Comment thread cmd/root_factory.go
Comment thread docs/development/root-factory.md Outdated
Comment thread docs/development/root-factory.md Outdated
Comment thread docs/development/root-factory.md Outdated
Comment thread docs/development/root-factory.md
@ajalon1

ajalon1 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

my compliments to whichever of your agents drew the diagrams.

mermaid for the win.

  1. cobra.OnFinalize never forgets. elephant-grade memory

feels like a feature to me. :)

guard the closure so it fires once, or move track/flush into PersistentPostRunE plus a factory-owned Execute wrapper for the error path?

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".

@ajalon1
ajalon1 force-pushed the aj/root-command-factory branch from 862e5f5 to e28b511 Compare August 20, 2026 21:31
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
@ajalon1
ajalon1 force-pushed the aj/root-command-factory branch from 427b5ef to a0fa619 Compare August 21, 2026 03:57
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
@ajalon1

ajalon1 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Think I handled everything.

ship a NewIsolatedRootFactory()/TestDependencies()

was a good idea -- handled that

the most valuable field in gh's factory is IOStreams

also a great idea, but I'll leave that for a separate PR

@ajalon1
ajalon1 marked this pull request as ready for review August 21, 2026 04:01
@ajalon1
ajalon1 requested a review from a team as a code owner August 21, 2026 04:01

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread cmd/root_factory.go
cobra.OnFinalize(func() {
if gen != finalizerGen.Load() {
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
}
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
}
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a0fa619. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought I did this already.

ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
@ajalon1
ajalon1 force-pushed the aj/root-command-factory branch from a0fa619 to 4bb08a6 Compare August 21, 2026 16:17
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 added a commit that referenced this pull request Aug 21, 2026
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>
ajalon1 and others added 13 commits August 21, 2026 12:31
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>
@ajalon1
ajalon1 force-pushed the aj/root-command-factory branch from 4bb08a6 to 835c242 Compare August 21, 2026 19:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

go Pull requests that update go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants