|
| 1 | +package cmd |
| 2 | + |
| 3 | +// errors.go — typed CLI errors that carry an exit-code contract. |
| 4 | +// |
| 5 | +// The CLI advertises specific exit codes in its `up` help text (and intends |
| 6 | +// the same discipline platform-wide): |
| 7 | +// |
| 8 | +// ExitOK 0 success |
| 9 | +// ExitGeneric 1 generic / unknown failure (manifest parse, I/O, ...) |
| 10 | +// ExitResourceFailed 2 one or more resources failed to provision / reconcile |
| 11 | +// ExitAuthRequired 3 authentication required for the requested action |
| 12 | +// (also used for 401 from a previously-valid session |
| 13 | +// — see ExitSessionExpired below, which folds into 3) |
| 14 | +// ExitSessionExpired 3 same exit code as ExitAuthRequired: the server |
| 15 | +// rejected our credentials, so re-login is required. |
| 16 | +// |
| 17 | +// main.go inspects the returned error for an *ExitCodeError; if present the |
| 18 | +// embedded code is the process exit code. RunE handlers return one of the |
| 19 | +// helpers below (errResourceFailed, errAuthRequired, errSessionExpired) and |
| 20 | +// wrap any extra context with %w if needed. |
| 21 | +// |
| 22 | +// Pre-existing surface preserved: a plain `error` still exits 1 (errors.go's |
| 23 | +// extractExitCode default), matching today's behaviour for legacy code paths |
| 24 | +// that have not been converted to the typed-error contract. |
| 25 | + |
| 26 | +import ( |
| 27 | + "errors" |
| 28 | + "fmt" |
| 29 | +) |
| 30 | + |
| 31 | +// Exit codes — the public contract the CLI promises. |
| 32 | +const ( |
| 33 | + ExitOK = 0 |
| 34 | + ExitGeneric = 1 |
| 35 | + ExitResourceFailed = 2 |
| 36 | + ExitAuthRequired = 3 |
| 37 | + // ExitSessionExpired collapses to the same value as ExitAuthRequired: an |
| 38 | + // agent script only needs one branch ("any auth issue → re-login") and |
| 39 | + // the documented contract advertises 0/1/2/3. |
| 40 | + ExitSessionExpired = ExitAuthRequired |
| 41 | +) |
| 42 | + |
| 43 | +// ExitCodeError carries both a wrapped cause and the documented exit code |
| 44 | +// the CLI should terminate with. main.go uses errors.As to extract it. |
| 45 | +type ExitCodeError struct { |
| 46 | + Code int |
| 47 | + Err error |
| 48 | +} |
| 49 | + |
| 50 | +// Error implements error. |
| 51 | +func (e *ExitCodeError) Error() string { |
| 52 | + if e == nil || e.Err == nil { |
| 53 | + return fmt.Sprintf("exit %d", e.codeOrDefault()) |
| 54 | + } |
| 55 | + return e.Err.Error() |
| 56 | +} |
| 57 | + |
| 58 | +// Unwrap supports errors.Is / errors.As. |
| 59 | +func (e *ExitCodeError) Unwrap() error { |
| 60 | + if e == nil { |
| 61 | + return nil |
| 62 | + } |
| 63 | + return e.Err |
| 64 | +} |
| 65 | + |
| 66 | +func (e *ExitCodeError) codeOrDefault() int { |
| 67 | + if e == nil || e.Code == 0 { |
| 68 | + return ExitGeneric |
| 69 | + } |
| 70 | + return e.Code |
| 71 | +} |
| 72 | + |
| 73 | +// withExitCode wraps an error with the requested exit code. Nil err returns |
| 74 | +// nil so callers can chain it on the happy path. |
| 75 | +func withExitCode(code int, err error) error { |
| 76 | + if err == nil { |
| 77 | + return nil |
| 78 | + } |
| 79 | + return &ExitCodeError{Code: code, Err: err} |
| 80 | +} |
| 81 | + |
| 82 | +// errResourceFailed wraps an error with ExitResourceFailed (2). Used by `up` |
| 83 | +// when one or more resources failed to reconcile. |
| 84 | +func errResourceFailed(err error) error { |
| 85 | + return withExitCode(ExitResourceFailed, err) |
| 86 | +} |
| 87 | + |
| 88 | +// errAuthRequired returns a 'login required' error (exit code 3). The message |
| 89 | +// is a single uniform string so agents can branch on it deterministically. |
| 90 | +func errAuthRequired(detail string) error { |
| 91 | + if detail == "" { |
| 92 | + detail = "authentication required — run `instant login` or set INSTANT_TOKEN to a Personal Access Token" |
| 93 | + } |
| 94 | + return &ExitCodeError{Code: ExitAuthRequired, Err: errors.New(detail)} |
| 95 | +} |
| 96 | + |
| 97 | +// errSessionExpired is the uniform "your saved session is no longer valid" |
| 98 | +// error. Emitted whenever the server returns 401 for a request that did send |
| 99 | +// a bearer token. Tests assert on this exact wording so the contract is |
| 100 | +// stable for downstream agents. |
| 101 | +// |
| 102 | +// IMPORTANT: keep the literal phrase "session expired" in the message — the |
| 103 | +// hermetic suite (and the project's "shipped ≠ verified" rules) grep for it. |
| 104 | +func errSessionExpired() error { |
| 105 | + return &ExitCodeError{ |
| 106 | + Code: ExitSessionExpired, |
| 107 | + Err: errors.New("session expired — run `instant login` to re-authenticate"), |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 111 | +// ExitCodeFor returns the exit code for an error: 0 for nil, the embedded |
| 112 | +// code if it is an *ExitCodeError, else ExitGeneric (1). main.go uses this |
| 113 | +// to translate any error from the cobra tree into os.Exit(n). |
| 114 | +func ExitCodeFor(err error) int { |
| 115 | + if err == nil { |
| 116 | + return ExitOK |
| 117 | + } |
| 118 | + var ec *ExitCodeError |
| 119 | + if errors.As(err, &ec) { |
| 120 | + return ec.codeOrDefault() |
| 121 | + } |
| 122 | + return ExitGeneric |
| 123 | +} |
0 commit comments