diff --git a/docs/adr/20261028T210148136Z-run-native-urfave-commands-inside-selected-lifecycle-modules.md b/docs/adr/20261028T210148136Z-run-native-urfave-commands-inside-selected-lifecycle-modules.md new file mode 100644 index 0000000..b71f3fe --- /dev/null +++ b/docs/adr/20261028T210148136Z-run-native-urfave-commands-inside-selected-lifecycle-modules.md @@ -0,0 +1,98 @@ +--- +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. + +The public API, adapter-owned signal subscription and exact classification boundary below are proposed for independent review and owner acceptance, not approved implementation requirements. No additional owner decision blocks drafting; acceptance must include the documented native validation limitation. + +## 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. 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. New background work in these CLI callbacks is not offered a managed-worker API; raw goroutines remain caller-owned and cannot claim lifecycle's managed-worker completion guarantee. + +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 per-command usage/not-found handling before urfave runs; no urfave package globals are changed. 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 | +| --- | --- | +| Nil error, including 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 (invalid/missing flag value, required flag/argument, mutually exclusive flags, native argument parsing), or unknown command/help topic | 2 | +| 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 wrapping classification that retains causes, and let runtime failures dominate mixed usage/runtime failures. Run returns the original/wrapped error for errors.Is/As inspection after cleanup, and emits one final diagnostic to root.ErrWriter. Usage hooks also request the 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. + +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. +* Raw feasibility on main f043900: an external Go experiment with v3.14.0 produced `setup action teardown`, nil error for normal one-off and injected SIGINT, without core edits. Help invoked no action; unknown command returned native code-3 error; bad/required flags invoked OnUsageError; invalid environment IntFlag returned an error with zero usage-hook calls. The pinned [command handler](https://github.com/urfave/cli/blob/v3.14.0/command.go) bypasses default process exit with root ExitErrHandler; [Exit(error,2)](https://github.com/urfave/cli/blob/v3.14.0/errors.go) unwraps its cause. The [execution source](https://github.com/urfave/cli/blob/v3.14.0/command_run.go) places PostParse outside usage handling. These are feasibility observations, not implementation tests or independent review. +* 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.