carlos / platform Public

spec: proactive CLI — moved consoles, login offers, self-update (#160 #161 #162)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell afe753457136a285e04f7fcb45fc0ea8015f2774 parent 9d1084e
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).