spec: proactive CLI — moved consoles, login offers, self-update (#160 #161 #162)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+127
−0
docs/superpowers/specs/2026-08-12-proactive-cli-design.md+127 −0
diff --git a/docs/superpowers/specs/2026-08-12-proactive-cli-design.md b/docs/superpowers/specs/2026-08-12-proactive-cli-design.md| new file mode 100644 |
| index 000000000..9d0b40645 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-12-proactive-cli-design.md |
| @@ -0,0 +1,127 @@ |
| +# Proactive CLI: moved consoles, stale binaries, missing logins |
| + |
| +**Date:** 2026-08-12 · **Issues:** #160, #161, #162 · **Status:** approved |
| +(Paul, in-session: "let's get all that done"; the issues carry the designs) |
| + |
| +The direction (Paul, 2026-08-12): the CLI should never surface a bare |
| +status code or a dead end a person has to ask an oracle about. When it can |
| +see both the problem and the remedy, it explains the problem and — on a |
| +terminal — offers to perform the remedy. Scripts keep today's exact errors: |
| +they are load-bearing (`noCredentialsMsg` is string-matched) and a script |
| +cannot answer a prompt. All prompts ride the #159 machinery |
| +(`interactiveTerminal`, `chooserIn`, y/N wording). |
| + |
| +## 1. Console redirects (#160) |
| + |
| +**Today** the client follows Go's default redirect policy: GETs follow |
| +cross-host (Go strips Authorization → a mystery 401 from the new host), |
| +POSTs with unreplayable bodies surface the raw status ("unexpected |
| +response (status 308)" — Tom's case). Both are wrong. |
| + |
| +- **Never follow**: `CheckRedirect: http.ErrUseLastResponse` on the one |
| + `consoleClient` http.Client. Every method now deterministically sees the |
| + 3xx + `Location`. |
| +- **Typed**: 301/302/307/308 with a Location whose origin |
| + `normalizeConsole` accepts becomes `consoleMovedError{From, To}` at the |
| + response sites (`consoleClient.do`, `doMultipart`, `apiSession.getJSON`, |
| + `postJSON`). Its `Error()` text carries the whole story for scripts: |
| + `this console has moved: <from> -> <to> — run: carlos auth login -console <to>`. |
| + A 3xx without a usable Location falls back to today's "unexpected |
| + response" wording. |
| +- **Interactive**: where the typed error is born, a TTY gets: |
| + |
| + this console has moved: https://dashboard.x -> https://console.x |
| + log in to https://console.x now? [y/N] |
| + |
| + y runs the standard device flow (`authLogin -console <to>` — browser |
| + approval unchanged, credential minted per-origin, nothing reused). If a |
| + `.carlos/config` on the walk names the old console, one more offer |
| + rewrites it (`writeConfigKeys`, preserving everything else). The verb |
| + then exits nonzero with `logged in to <to> — re-run your command` — the |
| + original request died mid-flight against the old host; continuation is |
| + #162's territory, not this one's. |
| +- Location is trusted for **display and prompting only**, the user |
| + confirms before any credential exists, and `normalizeConsole`'s rules |
| + (https, or http strictly on-box) gate the target. |
| + |
| +## 2. Offer to log in (#162) |
| + |
| +One seam: every API verb reaches credentials through `resolveMode` → |
| +`loggedInCredential` → `pick`. The two pick refusals that already name a |
| +console — "not logged in to X" and "the credential for X expired" — become |
| +a typed `notLoggedInError{Console, Expired}` with byte-identical message |
| +strings. On a TTY, `resolveMode`: |
| + |
| + not logged in to https://console.carloku.com |
| + log in now? [y/N] |
| + |
| +y runs `authLogin` inline against that console, reloads credentials, |
| +retries once, and **returns a working session — the original command |
| +continues**; the user typed `carlos ship` and a ship is what happens. |
| +The bare "no deployment target configured" case has no console to offer: |
| +a TTY is prompted `console URL: ` (validated by `normalizeConsole`, empty |
| +answer = today's error), then the same login-and-continue. n/EOF, non-TTY, |
| +and direct mode keep today's errors verbatim. The login flow itself is |
| +untouched — no shortcuts around the browser approval or step-up. |
| + |
| +## 3. Self-update (#161) |
| + |
| +**Detection** — passive, our infra, never blocking: |
| +- `latest.txt` (one line, `v0.7.0`) joins the apt static app; the check is |
| + one GET to `https://apt.carlosframework.com/latest.txt` with a ~1.5s |
| + timeout, silent on any failure. |
| +- Ran after a successful member verb, at most once per 24h (cache |
| + `~/.carlos/update-check.json` `{checked_at, latest}`), and only when ALL |
| + of: stdout is a TTY; `version.Build` is a release tag (a `dev` build |
| + never checks); no `CARLOS_DEPLOYMENT_BUCKET`/`DIR`; not root; not opted |
| + out (`CARLOS_NO_UPDATE_CHECK`, or `update_check: "off"` in config); |
| + and the verb is not a box/daemon verb (edge, agent, adopt, bootstrap, |
| + add, remove, route, ops, status, canary, vet, release-keygen). |
| +- The hint is one muted line: |
| + `carlos v0.8.0 is available (you have v0.7.0) — run: carlos update` |
| + |
| +**`carlos update`** — detects how this binary is installed: |
| +- Homebrew path (`/Cellar/`, `/linuxbrew/`) → prints |
| + `brew upgrade carlosframework/tap/carlos` and stops (brew owns brew). |
| +- dpkg owns the path (`dpkg -S`, when dpkg exists) → prints the apt |
| + command and stops (apt owns apt). |
| +- Otherwise self-replace, y/N-gated on a TTY: download |
| + `carlos-<GOOS>-<GOARCH>`, `SHA256SUMS.txt`, and `SHA256SUMS.txt.sig` |
| + from the latest GitHub release; verify the sig, then the binary's |
| + SHA256; write a temp file beside the current executable and rename over |
| + it (the config files' crash-safety rule); a permission error names the |
| + sudo-shaped fix instead of failing mid-way. Non-TTY never self-replaces. |
| +- Refusals: root, bucket env set (boxes roll via update-platform-binary.sh |
| + and adoption — a self-updating box binary would fight the machinery), |
| + `dev` builds (nothing meaningful to update to). |
| + |
| +**Integrity**: SHA256SUMS.txt gains a detached signature. A dedicated |
| +ed25519 CLI-update keypair (NOT the release key: different blast radius, |
| +different rotation story): private half at SSM `/carlos/cli-update-key`, |
| +public half a constant in the binary. `SHA256SUMS.txt.sig` = |
| +base64(ed25519.Sign(priv, file bytes)) — stdlib verify, no OpenPGP. The |
| +release recipe gains the signing step; v0.7.0's sums are signed |
| +retroactively so the first self-update has something to verify. |
| + |
| +## Testing |
| + |
| +- Redirects: httptest consoles answering 3xx with/without Location, GET |
| + and POST; typed error text; interactive path (forced TTY + injected |
| + input) logs in against a fake new console and offers the config rewrite; |
| + whoami's silent degrade still silent. |
| +- Login offer: resolveMode against empty/expired credentials + fake |
| + console: y → session returned and original call proceeds; n/EOF/non-TTY |
| + → today's exact errors (string-pinned); URL prompt path. |
| +- Update: check gating table (dev build, root skipped where testable, |
| + bucket env, opt-outs, cache freshness, box verbs); hint text; `update` |
| + verb against a fake release server — sig verify failure REFUSES, sha |
| + mismatch REFUSES, happy path replaces a scratch binary; brew/dpkg paths |
| + answer with the right command and touch nothing. |
| +- Everything through `carlosTestEnv` rules where the binary is shelled. |
| + |
| +## Out of scope |
| + |
| +- Following redirects automatically (deliberately never). |
| +- Auto-update without a prompt; package-manager installs self-replacing. |
| +- Signing the .debs themselves (the apt repo's InRelease already covers |
| + apt-path integrity). |