# Plan — v5.4 Phase 3: Vercel adapter rollback + `deploy status` CLI

**Spec:** `docs/specs/v5.4-vercel-adapter.md` (Phase 3, ~1h estimate)
**Branch:** `v5.4-phase3-rollback` (off `origin/master` @ `f330b57`)
**Master test count baseline:** 895
**Target test count:** 903 (+8)

## Context

Phases 1 (deploy + status) and 2 (log streaming) already merged. The
`DeployAdapter` interface in `src/adapters/deploy/types.ts` already
declares `rollback?` as optional — Phase 1 reserved the slot. Phase 3
ships the implementation plus two new CLI subcommands that exercise it.

## Tasks

### Task 1 — Adapter: rollback + listDeployments

**File:** `src/adapters/deploy/vercel.ts`

Append three additions to `VercelDeployAdapter` (no contract changes):

1. **Public type `VercelDeployListItem`** at the top of the file (next to
   `VercelDeployResponse`):
   ```ts
   export interface VercelDeployListItem {
     id: string;
     url?: string;
     state?: VercelState;
     createdAt?: number;
   }
   ```

2. **`async rollback(input: DeployRollbackInput): Promise<DeployResult>`** — implements the existing optional contract method.
   - Capture `start = this.now()`.
   - If `input.to` provided, set `targetId = input.to`.
   - If omitted, call `findPreviousProdDeployment()`. If null, throw
     `GuardrailError({ code: 'no_previous_deploy', provider: 'vercel' })`
     with message `"No previous production deployment found for project '<project>' to roll back to"`.
   - POST `/v13/deployments/<targetId>/promote` (urlWithTeam-wrapped) via
     `fetchWithRetry`. Body is empty JSON `{}`.
   - Run through `assertOkOrThrow(res, 'promote deployment')`. The
     existing helper already maps 401/403 → `auth`, 404 → `invalid_config`,
     5xx → `adapter_bug`.
   - Parse the response (Vercel returns the promoted deployment object,
     same shape as `VercelDeployResponse`). Return:
     ```ts
     {
       status: 'pass',
       deployId: targetId,
       rolledBackTo: targetId,
       deployUrl: data.url ? `https://${data.url}` : undefined,
       buildLogsUrl: this.buildLogsUrl(targetId),
       durationMs: this.now() - start,
       output: `Vercel deployment ${targetId} promoted to production`,
     }
     ```

3. **`async listDeployments(limit = 5): Promise<VercelDeployListItem[]>`** (public).
   - GET `/v6/deployments?projectId=<encodeURIComponent(this.project)>&limit=<limit>&target=production`
     (urlWithTeam-wrapped — note `urlWithTeam` already handles the `?` vs `&` separator).
   - Run through `assertOkOrThrow(res, 'list deployments')`.
   - Parse `{ deployments: VercelDeployListItem[] }` → return the array
     (default to `[]` if missing).

4. **`private async findPreviousProdDeployment(): Promise<VercelDeployListItem | null>`**
   - `const items = await this.listDeployments(5);`
   - Filter to `state === 'READY'`.
   - Sort by `createdAt` desc (treat missing `createdAt` as 0).
   - If `< 2` remain, return `null`.
   - Return `items[1]` (the one immediately under the current prod).

**Lines added:** ~120. Total file: ~440 → ~560.

### Task 2 — CLI runners

**File:** `src/cli/deploy.ts`

1. **Extract `loadDeployConfig(opts)` private helper** that does the
   config-load + adapter-merge dance currently inline at the top of
   `runDeploy`. Returns either `{ adapter, merged }` or `{ error: number }`.
   Reuse from all three runners.

2. **Export `runDeployRollback(opts: RunDeployRollbackOptions): Promise<number>`**
   - `RunDeployRollbackOptions` extends the shared options shape with
     `to?: string`.
   - Build adapter via factory. If `typeof adapter.rollback !== 'function'`,
     print `[deploy] adapter "<name>" does not support rollback` (red),
     return 1.
   - Wrap in try/catch (mirrors `runDeploy`'s error handling for
     `formatErr`).
   - Print on success:
     `[deploy] rollback status=pass adapter=vercel rolledBackTo=<id> duration=<s>s`
     (green); on failure (status !== 'pass'), red.
   - Return 0 on `pass`, else 1.

3. **Export `runDeployStatus(opts: RunDeployStatusOptions): Promise<number>`**
   - Build adapter. If `typeof adapter.listDeployments !== 'function'`,
     print error, return 1.
   - Call `adapter.listDeployments(5)`.
   - Sort returned items by `createdAt` desc (defensive — Vercel already
     returns newest-first, but don't rely on it).
   - Print:
     ```
     [deploy] status — adapter=vercel
       current: <id> state=<state> url=<url> age=<age>
       recent builds:
         <id> state=<state> age=<age> url=<url>
         ...
     ```
   - Return 0 on success, 1 on failure.

**File:** `src/cli/index.ts`

In `case 'deploy':`:
- Read `process.argv` slice after the `deploy` token. The first non-flag
  positional arg, if `rollback` or `status`, dispatches accordingly.
  Existing behavior (no positional) keeps calling `runDeploy`.
- Add `--to <deploy-id>` flag handling for the rollback subcommand.
- Update `printUsage()` `Options (deploy):` block to document
  subcommands and the `--to` flag.

**Lines added:** ~110 in `deploy.ts`, ~30 in `index.ts`.

### Task 3 — Tests: adapter

**File:** `tests/deploy-vercel.test.ts`

Add 6 tests using the same `fetchImpl`-mock pattern that the existing
file uses (look at the deploy + status tests for the canonical recipe —
construct `VercelDeployAdapter` with `fetchImpl: vi.fn().mockImplementation(...)`,
`sleepImpl: () => Promise.resolve()`, `nowImpl` controllable counter):

1. **`rollback({ to: 'dpl_x' })` success** — fetch mock returns 200 with
   `{ id: 'dpl_x', url: 'my-app-x.vercel.app' }`. Assert:
   - URL matches `/v13/deployments/dpl_x/promote`
   - Method is POST
   - Result `status: 'pass'`, `rolledBackTo: 'dpl_x'`,
     `deployUrl: 'https://my-app-x.vercel.app'`.

2. **`rollback({})` (no `to`) finds previous and promotes** — first
   fetch returns the list with three READY deploys (newest-first), second
   fetch returns 200 promote. Assert promoted ID is the second-newest.

3. **`rollback({})` no previous deploy** — list returns only one READY
   deploy → expect `GuardrailError` thrown with `code: 'no_previous_deploy'`,
   `provider: 'vercel'`.

4. **`rollback({ to: 'dpl_x' })` 401** — fetch returns 401 → expect
   `GuardrailError` with `code: 'auth'`.

5. **`rollback({ to: 'dpl_x' })` 404** — fetch returns 404 → expect
   `GuardrailError` with `code: 'invalid_config'`.

6. **`listDeployments(5)` query params** — fetch mock asserts URL
   contains `projectId=<project>`, `limit=5`, `target=production`. Returns
   `{ deployments: [...] }` → assert array shape.

### Task 4 — Tests: CLI

**File:** `tests/deploy-cli.test.ts`

Add 2 tests using the existing `adapterFactory` test seam:

7. **`runDeployRollback` success** — fake adapter with
   `rollback: async () => ({ status: 'pass', deployId: 'dpl_x', rolledBackTo: 'dpl_x', durationMs: 50 })`,
   `name: 'vercel'`. Call with a `guardrail.config.yaml` in tmpdir
   declaring `deploy.adapter: vercel, project: x`. Assert exit 0,
   stdout matches `/rolledBackTo=dpl_x/`.

8. **`runDeployStatus` success** — fake adapter with
   `listDeployments: async () => [...3 READY items...]`,
   `status` defined. Assert exit 0, stdout includes the current deploy
   ID and at least one "recent builds" line.

### Task 5 — Validate

```bash
cd /Users/alexledbetter/Downloads/claude-autopilot
npx tsc --noEmit
npm test
```

Expectations:
- `tsc --noEmit`: 0 errors (project ships with `tsc` clean today).
- `npm test`: 903 tests pass (895 baseline + 8 new).

If either fails, fix before opening PR. No retries on validation, just
fix and re-run.

### Task 6 — Push + PR

```bash
git push -u origin v5.4-phase3-rollback
gh pr create --title "feat(v5.4): Vercel deploy adapter — Phase 3 (rollback + status CLI)" \
  --body "<see PR body below>" \
  --base master
```

PR body:

> ## Summary
>
> Implements Phase 3 of the v5.4 Vercel deploy adapter spec — rollback
> via `POST /v13/deployments/<id>/promote` plus the `claude-autopilot deploy status`
> and `claude-autopilot deploy rollback` CLI subcommands.
>
> Spec: [docs/specs/v5.4-vercel-adapter.md](../docs/specs/v5.4-vercel-adapter.md)
>
> ## What ships
>
> - `VercelDeployAdapter.rollback({ to? })` — promotes a specific deploy
>   or auto-detects the previous prod deploy when `to` is omitted.
> - `VercelDeployAdapter.listDeployments(limit)` — fetches the most
>   recent prod deployments.
> - CLI: `claude-autopilot deploy rollback [--to <id>]` and
>   `claude-autopilot deploy status`.
> - 8 new tests (6 adapter, 2 CLI) covering success, auth failure,
>   not-found, no-previous-deploy edge case, and status listing.
>
> ## Out of scope
>
> - **Phase 4** (auto-rollback wired to `rollbackOn: [healthCheckFailure]`)
>   is deferred to its own PR.
> - **Persistent deploy ID cache** (open question 3 in the spec): defer.
>   The Vercel `/v6/deployments?target=production` list endpoint is one
>   cheap round-trip and the source of truth; a local cache would add a
>   staleness failure mode if a deploy is promoted from outside this CLI.
>
> ## Test plan
>
> - [x] `npx tsc --noEmit` clean
> - [x] `npm test` passes (895 → 903)
> - [ ] Live `claude-autopilot deploy rollback --to <real-id>` against a
>       sandbox Vercel project (skipped in CI without `VERCEL_TOKEN_TEST`).
>
> 🤖 Generated with [claude-autopilot](https://github.com/axledbetter/claude-autopilot)
