carlos / platform Public

plan: CLI through the console API — six tasks, signatures pinned

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d0495cba2e62145788b33ad520289c0d2292853e parent 4288805
2 files changed, +319 −2
  • docs/superpowers/plans/2026-08-03-cli-console-api.md +315 −0
  • docs/superpowers/specs/2026-08-03-cli-console-api-design.md +4 −2
diff --git a/docs/superpowers/plans/2026-08-03-cli-console-api.md b/docs/superpowers/plans/2026-08-03-cli-console-api.md
new file mode 100644
index 000000000..7dee0d94c
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-03-cli-console-api.md
@@ -0,0 +1,315 @@
+# CLI Through the Console API Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** `carlos ship/promote/rollback/channels` work with only a `carlos auth login` credential — the console does bucket writes and pointer signing server-side.
+
+**Architecture:** New bearer-gated JSON/multipart endpoints in `internal/console` (new file `apicli.go`) that share the browser doors' refusal order and call the same `release.*` functions. The CLI (root package) gains credential-first mode resolution: explicit store env → today's direct path, otherwise a stored credential → API path. Stable promotion is refused at the API with the console URL.
+
+**Tech Stack:** Go stdlib only (net/http, mime/multipart, crypto/sha256). No new dependencies.
+
+**Spec:** `docs/superpowers/specs/2026-08-03-cli-console-api-design.md` — read it first.
+
+## Global Constraints
+
+- Worktree: `.claude/worktrees/cli-console-api` (branch `worktree-cli-console-api`). All work happens there.
+- Build/test env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; tests may need the sandbox disabled (unix sockets / "read-only file system").
+- `go build ./... && go vet ./... && go test ./... -count=1` clean before every commit; `gofmt -l` clean for touched files.
+- Console security-gate ordering is load-bearing (CLAUDE.md): auth → membership/app (byte-identical 404) → cheap refusals → rung gate LAST.
+- Comment style: state constraints the code can't show; match the file's existing density and voice.
+- API paths live under `/api/cli/`; auth endpoints stay under `/auth/cli/` (do not move them).
+- The `{account}` path segment accepts sqid OR name; the server resolves it against the caller's own memberships (resolution and membership proof are the same query — see Task 1).
+
+---
+
+### Task 1: `apicli.go` — auth/resolution helper + the two read endpoints
+
+**Files:**
+- Create: `internal/console/apicli.go`
+- Create: `internal/console/apicli_test.go`
+- Modify: `internal/console/authcli.go` (registerCLIRoutes — add 2 routes)
+
+**Interfaces:**
+- Consumes: `(*Server).cliUser(w, r) (string, bool)` (authcli.go:254), `(*Store).AccountsFor(address) ([]Account, error)`, `(*Store).AppsIn(accountID) ([]string, error)`, `(*Store).HasAppIn(accountID, app) (bool, error)`, `release.Channel(ctx, store, pub, acct, app, name) (*ChannelPointer, error)`, `release.ChannelsPrefix(acct, app) string`, `writeJSON/jsonError` (authcli.go:206,215).
+- Produces (later tasks rely on these exact names):
+ - `func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (addr string, acct *Account, app string, ok bool)` — bearer auth, resolves `r.PathValue("account")` (sqid or name) against the caller's memberships, checks `HasAppIn`. Failure = `http.NotFound(w, r)` after `cliUser` succeeded, `ok=false`; `cliUser` failure already wrote its 401.
+ - Route pattern style: `s.mux.HandleFunc("GET /api/cli/apps", s.handleAPIApps)` etc.
+
+- [ ] **Step 1: Write failing tests** in `apicli_test.go` (package `console`, follow `authcli_test.go` fixtures for building a Server with a temp Store + `release.NewDirStore(t.TempDir())`):
+
+```go
+// TestAPIAppsListsMemberships: seed accounts bab(ops)+bac, member alice in
+// bab only, apps: titogo in bab. Bearer alice-token GET /api/cli/apps →
+// 200 {"accounts":[{"sqid":"bab","name":"ops","role":"member",
+// "apps":["titogo"]}]}. No bac in the body.
+// TestAPIAppsNoToken: no Authorization → 401 with the cliUser body.
+// TestAPIChannelsHappy: promote titogo abc1234 to canary/x in the dir
+// store (release.Ship a minimal manifest, then release.Promote with a test
+// key; ReleasePub pinned to its public half). GET
+// /api/cli/apps/bab/titogo/channels → 200, one entry
+// {"channel":"canary/x","version":"abc1234","rung":"canary",
+// "promoted_at":...}. Account segment "ops" (the NAME) must work
+// identically.
+// TestAPIChannelsRefusalMatrix: (a) member of nothing → 404; (b) member
+// of bab but app unclaimed → 404; (c) both 404 bodies byte-identical to
+// http.NotFound output.
+```
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/console/ -run TestAPI -count=1` → FAIL (handlers undefined).
+
+- [ ] **Step 3: Implement** `apicli.go`:
+
+```go
+// apiAccountApp resolves and authorizes one CLI API request. The account
+// segment accepts a sqid or a name, resolved against the caller's OWN
+// memberships — resolution and membership proof are deliberately the same
+// lookup, so an unknown account and someone else's account are the same 404.
+func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) {
+ addr, ok := s.cliUser(w, r)
+ if !ok {
+ return "", nil, "", false
+ }
+ seg := r.PathValue("account")
+ app := r.PathValue("app")
+ accts, err := s.store.AccountsFor(addr)
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "could not load accounts")
+ return "", nil, "", false
+ }
+ var acct *Account
+ for i := range accts {
+ if accts[i].Sqid == seg || accts[i].Name == seg {
+ acct = &accts[i]
+ break
+ }
+ }
+ if acct == nil {
+ http.NotFound(w, r)
+ return "", nil, "", false
+ }
+ if has, err := s.store.HasAppIn(acct.ID, app); err != nil || !has {
+ http.NotFound(w, r)
+ return "", nil, "", false
+ }
+ return addr, acct, app, true
+}
+```
+
+`handleAPIApps`: `cliUser`, `AccountsFor`, per account `AppsIn`, `writeJSON(w, 200, ...)` with the shape in Step 1. `handleAPIChannels`: `apiAccountApp`, then list `s.cfg.Releases.List(ctx, release.ChannelsPrefix(acct.Sqid, app))`, per key `release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, name)`; entries carry `channel`, `version`, `promoted_at` (RFC3339), `rung` (`release.Rung(name)`, empty when not a rung); a pointer that fails verification becomes `{"channel":name,"error":"..."}` — mirror `runChannels`' per-row ERROR behavior (cli.go:856), never a whole-response failure. 15s context timeout like `handlePromote`.
+
+- [ ] **Step 4: Run to verify pass** — same command → PASS.
+- [ ] **Step 5: Commit** — `git add internal/console/apicli.go internal/console/apicli_test.go internal/console/authcli.go && git commit -m "console: CLI API — accounts/apps listing and channels reads"`
+
+---
+
+### Task 2: promote + rollback endpoints
+
+**Files:**
+- Modify: `internal/console/apicli.go`, `internal/console/apicli_test.go`, `internal/console/authcli.go` (2 routes)
+
+**Interfaces:**
+- Consumes: `apiAccountApp` (Task 1), `release.ValidChannel`, `release.ValidVersion`, `release.GetManifest(ctx, store, sqid, app, version)`, `release.PromoteOnLadder(ctx, store, key, sqid, app, channel, version, release.PromoteOptions{Actor: addr}) (PromoteResult, error)`, `release.Rollback(ctx, store, key, sqid, app, channel) error`, `release.Rung(channel) (string, bool)`, `release.RungStable`, `s.audit(acct, app, addr, verb, detail)`.
+- Produces: `POST /api/cli/apps/{account}/{app}/promote` body `{"version":"...","channel":"..."}` → 200 `{"result":"promoted","from":"...","to":"...","noop":false}`; `POST .../rollback` body `{"channel":"..."}` → 200 `{"result":"rolled back"}`. Stable → 403 `{"error":"stable needs a passkey — finish in the console","console_url":"<Origin>/a/<sqid>/apps/<app>"}`.
+
+- [ ] **Step 1: Failing tests**:
+
+```go
+// TestAPIPromoteHappy: shipped abc1234 in dir store; POST promote
+// {version:"abc1234",channel:"canary/x"} → 200; release.Channel now
+// reports abc1234; store audit row exists with detail containing "(cli)".
+// TestAPIPromoteStableRefused: POST channel "stable" → 403 with
+// console_url "<origin>/a/bab/apps/titogo"; response bytes IDENTICAL for
+// a member token and an owner token (spec §3 — role must not leak).
+// TestAPIPromoteNoKey: Server built with Key nil → 503.
+// TestAPIPromoteBadVersion / BadChannel / UnshippedVersion → 400/400/404
+// with jsonError bodies naming the problem.
+// TestAPIPromoteNoHotfix: body {"hotfix":true} decodes into a struct
+// WITHOUT a hotfix field — unknown fields are ignored by encoding/json,
+// and the handler never sets PromoteOptions.Hotfix; ladder-refused
+// promotion stays refused (ship abc1234, promote to beta/x directly →
+// PromoteOnLadder error surfaces as 409 {"error": ladder text}).
+// TestAPIRollbackHappy: two promotions then rollback → pointer back.
+```
+
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Implement.** Door order copied from `handlePromote` (server.go:1170) minus CSRF; the rung gate is the API variant:
+
+```go
+if rung, _ := release.Rung(req.Channel); rung == release.RungStable {
+ writeJSON(w, http.StatusForbidden, map[string]string{
+ "error": "stable needs a passkey — finish in the console",
+ "console_url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app),
+ })
+ return
+}
+```
+
+Refusal mapping: invalid channel/version → 400; manifest missing → 404 (`jsonError`, text from the browser door: `"Version X is not a shipped release of Y."`); `PromoteOnLadder` error → 409 with `err.Error()` (the ladder text names the rungs); `res.NoOp` → 200 `{"result":"noop"}`. Audit: `s.audit(acct, app, addr, "promote", "promoted "+version+" to "+channel+" (cli)")`, rollback likewise.
+
+- [ ] **Step 4: Verify pass.** Also `go test ./internal/console/ -count=1` (whole package — the browser-door tests must still pass).
+- [ ] **Step 5: Commit** — `"console: CLI API — promote and rollback through the browser doors, stable refused"`
+
+---
+
+### Task 3: ship endpoint (multipart upload)
+
+**Files:**
+- Modify: `internal/console/apicli.go`, `internal/console/apicli_test.go`, `internal/console/authcli.go` (1 route)
+
+**Interfaces:**
+- Consumes: `apiAccountApp`, `release.Ship(ctx, store, sqid, m Manifest, files map[string][]byte) error`, `release.Manifest{Schema:1, App, Version, Kind, BuiltBy, BuiltAt, Artifacts map[string]release.Artifact}`, `release.Artifact{File, SHA256, Size}`, `release.ValidVersion`.
+- Produces: `POST /api/cli/apps/{account}/{app}/releases`, multipart/form-data; first part `meta` (JSON `{"version":"...","kind":"binary"|"static"}`), then file parts whose part NAME is the artifact key (`linux-arm64`, `linux-amd64`, or `site.tar.gz` for static). → 201 `{"result":"shipped","version":"...","artifacts":{"linux-arm64":{"sha256":"...","size":N}}}`.
+
+- [ ] **Step 1: Failing tests**:
+
+```go
+// TestAPIShipHappy: multipart with meta {version:"abc1234",kind:"binary"}
+// + part "linux-arm64" of 1KiB random bytes → 201; dir store now has
+// releases/abc1234/linux-arm64 with those bytes and manifest.json whose
+// sha256/size match what the SERVER computed; BuiltBy has prefix
+// "console-api ".
+// TestAPIShipStatic: kind static, part "site.tar.gz" → 201.
+// TestAPIShipRefusals: bad version chars → 400; kind "weird" → 400;
+// binary part named "not-a-target" → 400; static with a part that is not
+// site.tar.gz → 400; no file parts → 400; meta not first part → 400.
+// TestAPIShipTooBig: server built with apiShipMaxBytes lowered to 4KiB
+// (make it a var, default 256<<20) and an 8KiB part → 413.
+// TestAPIShipMemberGate: non-member token → the same 404 as Task 1's
+// matrix.
+```
+
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Implement.** Key points the implementer must not improvise:
+
+```go
+var apiShipMaxBytes int64 = 256 << 20 // one ship, all artifacts
+
+// Part names become bucket key segments via the manifest, so the charset
+// checks below are path-traversal defense, not tidiness.
+var targetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`)
+```
+
+`r.Body = http.MaxBytesReader(w, r.Body, apiShipMaxBytes)` before parsing; `r.MultipartReader()` (streaming — never `ParseMultipartForm`, which buffers to disk with its own limits); require the first part be `meta`, decode with a 8KiB `io.LimitReader`; each following part: validate name (`targetRe` for binary, exactly `"site.tar.gz"` for static), `io.ReadAll` into memory (release.Ship takes `map[string][]byte` — same buffering the direct path's `os.ReadFile` does; MaxBytesReader is the cap), sha256 the bytes, build `release.Artifact{File: name, SHA256: hex, Size: int64(len(b))}`. Manifest: `Schema:1, App:app, Version:meta.Version, Kind:meta.Kind, BuiltBy:"console-api "+version.Build, BuiltAt:time.Now().UTC().Format(time.RFC3339)`. `release.Ship` error → 502 `jsonError` (the store failed, not the caller). Audit `"shipped "+version+" (cli)"`. A `MaxBytesError` (check with `errors.As(err, new(*http.MaxBytesError))`) → 413.
+
+- [ ] **Step 4: Verify pass** (whole package).
+- [ ] **Step 5: Commit** — `"console: CLI API — multipart ship, hashes computed server-side"`
+
+---
+
+### Task 4: CLI mode resolution + API client methods
+
+**Files:**
+- Create: `apimode.go` (root package)
+- Create: `apimode_test.go`
+- Modify: `auth.go` (one addition: exported-in-package helper on consoleClient for multipart)
+
+**Interfaces:**
+- Consumes: `loggedInCredential(console string) (*credential, error)` (auth.go:659 — `pick` semantics: one credential or `-console` disambiguates), `newConsoleClient(base, token)` (auth.go:344), `(*consoleClient).do(ctx, method, path string, in, out any) (int, error)`.
+- Produces (Task 5 relies on these exact signatures):
+
+```go
+// apiSession is the resolved API mode: which console, as whom.
+type apiSession struct {
+ c *consoleClient
+ console string // origin, for messages
+ address string
+}
+
+// resolveMode returns (nil, nil) when the store env is set — direct mode,
+// the caller proceeds exactly as today. Otherwise a credential is required
+// and its session returned; consoleFlag is the -console value ("" = only).
+func resolveMode(consoleFlag string) (*apiSession, error)
+
+func (s *apiSession) promote(ctx context.Context, account, app, version, channel string) error
+func (s *apiSession) rollback(ctx context.Context, account, app, channel string) error
+func (s *apiSession) channels(ctx context.Context, account, app string) ([]apiChannel, error) // {Channel, Version, PromotedAt, Rung, Err string}
+func (s *apiSession) ship(ctx context.Context, account, app, version, kind string, files map[string]string) error // files: partName -> local path, streamed
+```
+
+- [ ] **Step 1: Failing tests** in `apimode_test.go` (package main; `t.Setenv` for env, temp `HOME`/`CARLOS_CONFIG_DIR` per what `credentialsPath()` (auth.go:125) reads — check it and use the same override the auth tests use):
+
+```go
+// TestResolveModeDirectWins: CARLOS_DEPLOYMENT_BUCKET=x set + credential
+// file present → (nil, nil).
+// TestResolveModeDirWins: CARLOS_DEPLOYMENT_DIR set → (nil, nil).
+// TestResolveModeAPI: no env, one credential → session with its origin.
+// TestResolveModeNothing: no env, no credentials → error mentioning both
+// "CARLOS_DEPLOYMENT_BUCKET" and "carlos auth login".
+// TestAPISessionShipMultipart: httptest server asserting: bearer header,
+// multipart with meta first (exact JSON), then parts in map-key-sorted
+// order; respond 201. ship() with two temp files succeeds.
+// TestAPISessionStableRefusal: server responds 403 {"error":"stable
+// needs a passkey — finish in the console","console_url":"http://x/a/b"};
+// promote() error text contains the console_url.
+```
+
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Implement.** `resolveMode`: env check first (both vars), then `loggedInCredential(consoleFlag)` — its error already names the fixes; wrap the no-credential case with the two-option message from the test. Multipart ship streams via `io.Pipe` + `multipart.NewWriter` in a goroutine (30 MiB artifacts should not double-buffer); part order: `meta` first, then files sorted by name (deterministic tests). The 403 promote path decodes `{error, console_url}` and returns `fmt.Errorf("%s\n %s", body.Error, body.ConsoleURL)`.
+- [ ] **Step 4: Verify pass.**
+- [ ] **Step 5: Commit** — `"cli: credential-first mode resolution and console API client"`
+
+---
+
+### Task 5: wire the four verbs
+
+**Files:**
+- Modify: `cli.go` (`runShip` :753, `runPromote` :817, `runChannels` :856, `runRollback` :886)
+- Modify: `apimode_test.go` (verb-level tests)
+
+**Interfaces:**
+- Consumes: Task 4's `resolveMode` + `apiSession` methods. Note `accountFlags` (cli.go:716) resolves via the LOCAL registry — in API mode there is no registry, so the verbs must NOT call `acctOf()` when a session is returned; they pass the RAW `-account` flag value (sqid or name — the server resolves, Task 1). Add a `-console` string flag to each verb, "" default.
+- Produces: user-visible behavior only.
+
+- [ ] **Step 1: Failing tests** — run each verb function against an httptest console (as in Task 4) by setting the credential file + no store env; assert stdout via the existing capture pattern in `auth_test.go` (find `captureOutput` or equivalent; if none exists, route verb output through a package-level `var stdout io.Writer = os.Stdout` — smallest change, keep `log.Fatal` paths untouched):
+
+```go
+// TestShipVerbAPIMode: runShip([...]) with one artifact file → server got
+// the upload; stdout has "shipped titogo abc1234" and the first-use line
+// "using http://127.0.0.1:PORT as vicky@keymail.dev".
+// TestPromoteVerbAPIMode / TestChannelsVerbAPIMode /
+// TestRollbackVerbAPIMode: analogous.
+// TestPromoteVerbDirectUnchanged: CARLOS_DEPLOYMENT_DIR set → the verb
+// never dials the httptest server (server counts requests; expect 0).
+```
+
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Implement.** Each verb: parse flags (add `-console`), call `resolveMode(*consoleFlag)`; `nil, nil` → existing body unchanged; session → API call + spec §5 output (`using <origin> as <address>` once per invocation, then the verb line). `runPromote` in API mode with `-hotfix` set → `log.Fatal("hotfix is an operator act — use the direct path (CARLOS_DEPLOYMENT_BUCKET) or the console")`. `runChannels` API mode prints the same tabwriter table, ERROR rows included.
+- [ ] **Step 4: Verify pass** — `go test ./... -count=1` (all 14 packages).
+- [ ] **Step 5: Commit** — `"cli: ship/promote/rollback/channels speak the console API when logged in"`
+
+---
+
+### Task 6: e2e — device flow to shipped release, README, format sweep
+
+**Files:**
+- Modify: `auth_e2e_test.go` (new test, reuse its device-flow helpers)
+- Modify: `README.md` (developer-flow section)
+
+**Interfaces:**
+- Consumes: everything above; `auth_e2e_test.go`'s existing console-server + device-approval fixtures.
+
+- [ ] **Step 1: Write the e2e test** (this one is the acceptance test — write it, watch it pass; every unit is already TDD'd):
+
+```go
+// TestE2ECLIThroughConsole: real console Server (dir store, test signing
+// key), device flow start→approve→poll to a real token in a temp
+// credential file; then, with NO store env:
+// runShip: 1KiB "linux-arm64" → 201, manifest hashes match
+// runPromote to "canary/e2e" → pointer moves, audit row "(cli)"
+// runChannels output contains "canary/e2e" and the version
+// runPromote to "stable" → refusal naming the console URL (assert the
+// verb surfaces it without promoting; pointer unchanged)
+// runRollback "canary/e2e" after a second ship+promote → pointer back
+```
+
+- [ ] **Step 2: Run the full suite** — `go build ./... && go vet ./... && go test ./... -count=1` all green; `gofmt -l` on touched files → empty.
+- [ ] **Step 3: README** — add to the developer section: login → ship → promote in API mode, one paragraph + example; state plainly that stable finishes in the console and that CI/operators keep the env-var path.
+- [ ] **Step 4: Commit** — `"cli+console: e2e — a credential is enough to ship and promote"`
+
+---
+
+## Self-Review (done at write time)
+
+- Spec coverage: §1→Task 4/5, §2→Tasks 1-3, §3→Tasks 1-2 (matrix tests), §4→Task 3, §5→Task 5, §6→Tasks 2-3 (audit asserts), §9→Tasks 1-6; §9's live tito PutObject probe is a deploy-time step, recorded for the PR body, not a code task. §4's "streams to a temp file" is implemented as capped in-memory buffering because `release.Ship` takes `map[string][]byte` — same buffering as the direct path; spec updated to match.
+- Placeholders: none; every handler's refusal mapping and every test's assertion target is named.
+- Type consistency: `apiAccountApp` (Tasks 1-3), `apiSession`/`resolveMode` (Tasks 4-5), `apiShipMaxBytes` (Task 3 test hook) — names match across tasks.
diff --git a/docs/superpowers/specs/2026-08-03-cli-console-api-design.md b/docs/superpowers/specs/2026-08-03-cli-console-api-design.md
index 1cc476154..86f90115b 100644
--- a/docs/superpowers/specs/2026-08-03-cli-console-api-design.md
+++ b/docs/superpowers/specs/2026-08-03-cli-console-api-design.md
@@ -102,8 +102,10 @@ principal here.
- `http.MaxBytesReader` at 256 MiB per request (largest current artifact is
~30 MiB; 256 leaves room for multi-target ships without inviting abuse).
-- Artifacts stream to a temp file, hashed while streaming; nothing is
- buffered whole in memory.
+- Artifacts are read part-by-part from the streaming multipart reader and
+ hashed as read; each artifact is held in memory once (the size cap is the
+ guard) because `release.Ship` takes `map[string][]byte` — the same
+ buffering the direct path's `os.ReadFile` does.
- `version` and `target` validated against the same charsets ship enforces
(`release.ValidVersion`; targets `GOOS-GOARCH` shaped) — they become
bucket key segments, so the validation is path-traversal defense, stated