docs(adr): propose native lifecycle CLI contract

This commit is contained in:
end committed 2026-10-28 14:04:02 -07:00
1 parent f0439000cb
commit 23dfb6d956
1 file changed
+98
@@ -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.