--- title: "Run native urfave commands inside selected lifecycle modules" date: 2026-10-28 status: proposed implementation: not-started summary: Use native urfave commands and flags with thin lifecycle action adapters and an explicit exit boundary. tags: - lifecycle - cli supersedes: [] superseded_by: [] related: - /docs/adr/20261025T214616275Z-version-light-core-packages-in-the-root-module-and-nest-heavy-dependency-modules.md - /docs/adr/20261027T184038082Z-supervise-module-lifecycle-and-managed-workers-within-one-shutdown-budget.md tracking: - https://gitea.auvem.com/corestack/go/issues/8 implemented_by: [] --- # Run native urfave commands inside selected lifecycle modules ## Context and Problem Statement [Issue 8](https://gitea.auvem.com/corestack/go/issues/8) needs command-specific resource setup, signal-aware work, cleanup on completion, automatic help and distinct usage failures. The owner selected `github.com/urfave/cli/v3` v3.14.0 for every command, subcommand, flag and help surface; callers declare native flags. One-offs skip module services; serve runs them. The root-module boundary and lifecycle ordering, cancellation and shared shutdown budget already stand. This clean-room proposal uses no go-toolkit/appcli source. All new decisions below remain proposed pending owner acceptance: the three-function API, adapter-owned signal subscription, compatibility limits and exit/diagnostic exceptions. The approved native dependency and one-off service suppression remain settled; this proposal authorizes no implementation. ## Considered Options * Native urfave command tree with action adapters: shares native flags/help and existing lifecycle coordination; adds only selection, signal ownership and exit classification. * A new command/flag schema wrapping urfave: could centralize declarations, but duplicates its API and increases conversion and maintenance obligations without a requested capability. * Standard-library flag parsing: smaller dependency graph, but would require our own subcommands/help and contradict the owner's dependency choice. * Per-binary urfave/lifecycle wiring: no library API to maintain, but repeats signal cleanup, one-off service suppression and exit policy in each consumer. ## Decision Outcome Propose the first option in root package `go.auvem.com/corestack/lifecycle/cli`, with no lifecycle core change. Import urfave as `urfave` to distinguish its native types from this package. The entire new public surface is: ```go // Run executes a caller-owned native tree and returns its process code and error. // It prints diagnostics but never exits the process; args includes argv[0]. func Run(ctx context.Context, root *urfave.Command, args []string) (int, error) // Serve returns an action running exactly these modules, including their services. func Serve(config lifecycle.Config, modules []lifecycle.Module) urfave.ActionFunc // Action returns a one-off action using exactly these modules, without services. func Action(config lifecycle.Config, modules []lifecycle.Module, fn urfave.ActionFunc) urfave.ActionFunc ``` The functions capture module declarations in their supplied order; callers list every needed module explicitly. There is no registry, name lookup, implicit dependency expansion or DAG. Callbacks close over caller-owned resource handles populated by Setup; the one-off receives the selected native command for parsed flag access. There is no public App, worker method, configuration framework or flag/environment facade. Empty module lists are valid. Nil one-off functions, nil Run context/root, invalid lifecycle config and invalid selected modules are runtime errors before resource acquisition; validate every selected declaration before starting setup. Bindings do not start work until invoked. A command tree and its mutable resources have one execution owner; construct fresh trees for independent runs and do not mutate captured declarations concurrently. `Serve` constructs a fresh lifecycle App, registers selected modules and calls its Run. `Action` copies selected modules with Run cleared, then registers an internal final module with no-op Setup/Teardown and the command function as Run. Choose its nonempty name by checking all selected names and varying a private suffix until unused; no public module name is reserved. All selected setups precede work; returning from the one-off leaves no selected services to keep the App alive. Lifecycle closes admission, cancels the callback context and performs reverse cleanup. No success sentinel, cancellation-error filtering, parent-context cancellation or second shutdown timer is added. Action failures are lifecycle failures and preserve their chains. Core setup failure/partial cleanup, parent cancellation, second signal, abandonment and joined-error behavior remain authoritative. Selected module services cannot run during one-offs. The invocation App is private: modules or callbacks that need its App.Go cannot obtain it through these adapters and are incompatible with this narrow API. Capturing another App does not confer this invocation's supervision. Supported modules manage resources and optional services without that access; raw goroutines remain caller-owned and outside managed-worker completion. Exposing worker access would require a separate owner decision. Each invoked adapter subscribes to SIGINT/SIGTERM using a private channel buffered for two signals when Config.Signals is nil, and defers signal.Stop. With a supplied channel, subscription, filtering and cleanup remain caller-owned as in lifecycle; a closed supplied channel disables signals. The adapter never closes either channel. No subscription or relay goroutine is needed during parsing/help. Lifecycle alone consumes/counts signals and cancels command work; do not also use signal.NotifyContext, which would turn graceful Ctrl-C into parent cancellation. After adapter return, subscription cleanup restores the prior process signal behavior; the caller owns process exit and abandoned resources. Run owns the tree's ExitErrHandler, OnUsageError and CommandNotFound hooks, rejecting conflicting caller hooks as runtime configuration errors rather than silently overriding them. It installs a non-exiting root handler and usage/not-found handling on the caller-declared tree before urfave runs; no urfave package globals are changed. CommandNotFound returns void, so its callback records a local usage cause for this invocation; Run combines that cause with the native result even when urfave returns nil, retaining any simultaneous runtime error. Native actions, Before/After, ArgValidator, flags, arguments and help templates remain caller declarations. Hooks outside the lifecycle action are outside its signal/shutdown/resource guarantee: use them for prompt validation, and put resource work inside selected modules or the adapted action. Leave group Actions unset for native group help/unknown-command handling; positional arguments on actionable leaves follow native urfave declarations and validators. The exact proposed mapping is: | Result | Code | | --- | --- | | No returned or locally recorded error, including valid help, empty group help, normal completion or graceful first signal with successful cleanup | 0 | | Lifecycle setup/action/service/teardown error, parent cancellation, budget/second-signal abandonment, or other unclassified native error | 1 | | Native OnUsageError path on caller-declared commands (invalid/missing flag value, required flag/argument, mutually exclusive flags, native argument parsing), or locally recorded unknown command/help topic | 2 | | Parse failure in a urfave-generated help command, including its aliases | 1 | | Explicit `urfave.Exit(err, 2)` from pre-lifecycle caller validation | 2 | A lifecycle failure always remains code 1, including action-returned ExitCoder values and joins containing cleanup failures; do not infer success by matching context.Canceled. Non-2 ExitCoder values outside lifecycle normalize to 1; an error never becomes success through ExitCode 0. Use private provenance wrappers for usage and lifecycle failures. Native After hooks may aggregate failures in urfave.MultiError, whose Errors() children are invisible to standard traversal. A small private bridge traverses both standard Unwrap forms and native MultiError.Errors(), recursively exposing children through Unwrap() []error while also retaining each original error/wrapper as a traversal child. It delegates Error() to the original rather than duplicating its text. Do not flatten or replace native wrappers: errors.Is must still match their identity and errors.As must still find their types, alongside nested causes and private markers. Classify every semantic branch, carrying its usage/lifecycle provenance through wrappers and aggregates; aggregate containers are not extra unclassified failures. Any lifecycle branch or unclassified runtime branch dominates usage branches, including an Exit(err, 2) action plus failing After, usage plus runtime After, and nested parent/leaf After failures. Looking up only the first ExitCoder or marker is insufficient. Run returns the traversable error after cleanup and emits at most one adapter-owned final diagnostic to root.ErrWriter. This is not a single total diagnostic guarantee: native help parse handling and caller hooks may also print. Usage hooks request relevant native help through urfave's Show*Help functions. Native help goes to root.Writer and diagnostics to root.ErrWriter, defaulting to stdout/stderr; callers may supply writers there. No logger/writer registry or logging policy is added. Rendering retains urfave's own writer-error behavior, without promising stronger delivery guarantees. Proposed owner exception: urfave creates generated help nodes lazily after the pre-run hook walk; their parse failures bypass OnUsageError. Accept code 1 with native diagnostics for malformed generated help invocations (root, nested and aliases); Run's final diagnostic may repeat the native error. Valid help remains code 0, and unknown help topics remain code 2 through the recorded CommandNotFound cause. Uniform code 2 and one total diagnostic would require separate feasibility evidence and owner approval. Do not initialize by reparsing, copy native parser/help code, or detect diagnostics through error strings. Urfave v3.14.0 does not expose all validation errors through OnUsageError: invalid environment values in Flag.PostParse bypass it, as do ArgValidator and flag-action errors. Unclassified errors from those paths are code 1. Callers needing code 2 from custom validators return `urfave.Exit(err, 2)` before lifecycle entry; native environment-source failures remain code 1 unless the caller validates explicitly. Do not inspect error strings, reparse arguments, wrap every flag type or promise universal parse/runtime discrimination. A schematic consumer uses ordinary Go wiring; the implementation Example must be executable and show both modes: ```go cfg := lifecycle.Config{Logger: logger} root := &urfave.Command{Name: "app", Commands: []*urfave.Command{ {Name: "serve", Action: cli.Serve(cfg, []lifecycle.Module{database, server})}, {Name: "inspect", Flags: []urfave.Flag{&urfave.BoolFlag{Name: "verbose"}}, Action: cli.Action(cfg, []lifecycle.Module{database}, func(ctx context.Context, cmd *urfave.Command) error { return inspect(ctx, db, cmd.Bool("verbose")) })}, }} code, err := cli.Run(context.Background(), root, os.Args) _ = err // already diagnosed; available for inspection by an embedding caller os.Exit(code) // caller-owned process boundary, after all adapter cleanup ``` The dependency belongs in root go.mod/go.sum under the accepted boundary ADR. At v3.14.0 `go list -deps` reports no nonstandard production package except urfave itself; its [module requirements](https://github.com/urfave/cli/blob/v3.14.0/go.mod) are for tests, with YAML already present here. This is a small CLI dependency, not a reason to introduce a nested module. The native exported types deliberately couple consumers to urfave v3; we own integration tests when upgrading it. ### Consequences * Scope is one PR, approximately 200–350 production lines plus focused tests and docs: new lifecycle/cli/cli.go (package/public comments and adapters), cli_test.go, example_test.go, root go.mod/go.sum and this ADR. No existing callers, fixtures, lifecycle core, migration package, release/config framework or GUI changes are anticipated. The reusable migration command belongs exclusively to issue 9. * There are no persistence writers, migrations, remote services, new auth boundaries or persistent workers. Active work consists of lifecycle-owned callbacks, its existing shutdown deadline, and a temporary OS signal subscription only while an adapted action executes. Callers own command output, cooperative callbacks, partial setup cleanup and eventual process exit; importing the package starts nothing. * Tests must prove selected-only setup and registration/reverse order, no one-off services, teardown on success/failure, serve services, fixed code mapping with preserved typed causes and mixed cleanup errors, native flags/subcommands/help without module setup, invalid environment classification, injected first/second signals, parent cancellation and the shared deadline. Test with injected channels and explicitly release blocked goroutines; do not send real signals. Exercise native callbacks returning ExitCoder without process exit. Add focused action/lifecycle-plus-After, usage-plus-runtime-After and nested After tests asserting code 1, original wrapper identity/types and all child causes. Cover root/nested generated help parse errors and aliases (code 1/native diagnostics), valid help (code 0), unknown commands/help topics returning native nil (code 2), and synthetic-name collisions without reserving names. * Native [aggregation and parse handling](https://github.com/urfave/cli/blob/v3.14.0/command_run.go), [MultiError API](https://github.com/urfave/cli/blob/v3.14.0/errors.go) and [lazy help setup](https://github.com/urfave/cli/blob/v3.14.0/command_setup.go) establish integration obligations; they do not verify the unimplemented adapters. Retain these cases in upgrade tests. The simpler alternative is narrower hook support; retaining native hooks with a small traversal bridge preserves the requested cause guarantees. Accepting the generated-help exception avoids a second parser/help subsystem. * Review and owner acceptance precede implementation. Later standard and fresh high independent implementation reviews, repository checks and reconciliation against the latest target branch precede publication; this draft records no acceptance, production delivery, PR or CI success.