| 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. |