CLAUDE.md · git:20261023.869b6fe · 2026-10-23 · sha256 80b7d46072f66afa

CLAUDE.md git:20261023.869b6feA

Immutable. This exact content never changes and is served at /api/v1/blob/80b7d46072f66afa.

# CLAUDE.md

Claude Code instructions for the **Ship SDK & CLI** package.

**@shipstatic/ship** — universal SDK and CLI for ShipStatic. Clean `resource.action()` API, identical in Node.js and Browser. **Maturity:** Stable; semver applies — breaking changes require a major version bump. **The pre-launch exception:** until the platform has external consumers, a change to a published type rides a minor under the same exception `@shipstatic/types` records beside its additive-evolution law, and the release notes name it (3.4.0: `account.get()` answers `Account`).

**Branches:** `main` (production) + `development` (integration). The publish workflow runs on both — the guarded publish step publishes only when `package.json` holds a version not yet on the registry, with the dist-tag derived from the version (`-` suffix → `beta`, else `latest`). See root `CLAUDE.md` "Branch & CI Model".

## Architecture

```
src/
├── shared/              # Cross-platform code (70% of codebase)
│   ├── api/http.ts      # The transport: headers, retries, timeout, events
│   ├── base-ship.ts     # Base Ship class (auth state, lazy /limits fetch, resources)
│   ├── resources.ts     # Resource factories — every endpoint, stated once
│   ├── types.ts         # Internal SDK types
│   ├── core/            # constants, credential schema, the deploy bodies, the request planner, the file pipeline
│   └── lib/             # Utilities (validation, junk filtering, MD5)
├── browser/             # Browser Ship class + the `webkitRelativePath` read
└── node/
    ├── core/config.ts       # readEnvConfig — SHIP_* env-var resolution (no filesystem)
    ├── core/node-files.ts   # the filesystem walk (the pipeline itself is shared)
    └── cli/
        ├── bin.ts           # THE EXECUTABLE (dist/cli.cjs) — the only file with side effects
        ├── index.ts         # Commander.js command tree + withErrorHandling + performDeploy
        ├── create-client.ts # Credential precedence (flag → env → file) → Ship instance
        ├── shiprc.ts        # ~/.shiprc + --config reader (strict JSON) — CLI ONLY
        ├── config.ts        # Interactive `ship config` wizard
        ├── error-handling.ts # toShipError + getUserMessage (TEXT channel)
        ├── formatters.ts    # announce() + the render router
        ├── utils.ts         # Output primitives (success/error/info, table, details)
        ├── types.ts         # CLI option + result types
        ├── completions.ts   # Shell completion scripts, RENDERED from the tree
        └── completion.ts    # Shell completion install/uninstall
```

**`bin.ts` is the executable; `index.ts` is a library.** Importing the command
tree has no side effects, which is what lets the suite and the completion
renderer read it. The bin block sat at the bottom of `index.ts` behind
`if (process.env.NODE_ENV !== 'test')` until 2026-07-29 — production behaviour
keyed on a test variable, and a real constraint besides: anything wanting to
read the tree had to be a test or pretend to be one. A module boundary says the
same thing to every caller, without the conditional.

The SDK proper has no filesystem dependency — the only ambient credential source is `SHIP_*` env vars. File-based config (`~/.shiprc`, or a `--config` path) lives entirely in `cli/shiprc.ts`. This is what makes `new Ship({})` safe to use in embedded contexts (MCP, GitHub Action) without leaking the host's `~/.shiprc` credentials. (The n8n node is NOT an SDK consumer — zero runtime dependencies is n8n Cloud's verification requirement, so it speaks HTTP directly and its credential comes from n8n's own store.)

## Quick Reference

```bash
pnpm test --run              # unit + integration (e2e is NOT in the default run)
pnpm test:unit --run         # Pure functions only
pnpm test:integration --run  # SDK/CLI against the mock server
pnpm test:e2e --run          # REAL API — refuses to load without SHIP_E2E_API_KEY
pnpm coverage                # the suite + the ratchet — what CI runs
pnpm typecheck               # tsc over src AND tests (tsconfig.check.json)
pnpm check:package           # publint + attw over the built artifact
pnpm build                   # Build all bundles
```

### Key Files

| File | Purpose |
|------|---------|
| `src/shared/resources.ts` | Every request the SDK can make — path, verb, body, response type |
| `src/shared/api/http.ts` | The transport. Carries requests; knows nothing about what they mean |
| `src/shared/base-ship.ts` | Base Ship class (auth, init, top-level methods) |
| `src/node/core/config.ts` | `readEnvConfig` — SHIP_* env-var resolution (the SDK's only ambient source) |
| `src/node/cli/bin.ts` | The executable — `dist/cli.cjs`. The one file that runs on import |
| `src/node/cli/index.ts` | CLI command tree, `withErrorHandling`, `performDeploy` |
| `src/node/cli/create-client.ts` | `createClient` + `mergeCliConfig` — credential precedence (flag > env > file) |
| `src/node/cli/shiprc.ts` | `loadShipFile` / `parseShipFile` — strict-JSON reader for `~/.shiprc` and `--config` (CLI only). No repository file is ever read |
| `src/node/cli/utils.ts` | Output primitives (`success`, `error`, `warn`, `info`, `formatTable`, `formatDetails`) |
| `src/node/cli/formatters.ts` | Resource-specific output formatters, `formatOutput` router |
| `src/node/cli/types.ts` | CLI option and result types (`GlobalOptions`, `CLIResult`, `EnrichedDomain`) |
| `src/node/cli/error-handling.ts` | CLI error UX — `toShipError` (normalize), `getUserMessage` (translate). Text channel only; `--json` transmits `ShipError.toResponse()` via `utils.ts` `error()` |
| `src/node/cli/config.ts` | Interactive `ship config` wizard — the only WRITER of the file `shiprc.ts` is the only reader of (writes `~/.shiprc`, or the file `--config` names) |
| `src/node/cli/completions.ts` | `renderCompletion(program, shell)` — bash/zsh/fish scripts derived from the tree |
| `src/node/cli/completion.ts` | Shell completion install/uninstall |
| `tests/fixtures/builders.ts` | Typed fixture builders — the only fixture source |
| `tests/mocks/handler.ts` | The mock API: one Web-standard handler, wire-cited per route |
| `tests/architecture/` | Suite-time fences (integrity, naming, docs contract) |

## Comments state the law; this file keeps the story

**A comment in code states the rule and, where the rule was bought with a bug,
one short pointer — "see CLAUDE.md §…". The dated war story lives only here.**

Narratives drift exactly like code does, and a story told in three places
drifts three ways — at one point `config.ts` opened with a 37-line account of
a `JSON.parse` divergence whose parser no longer existed. What stays in code:
the rule, the mechanism, and the non-obvious constraint — `this`-binding,
registration order, why a cast is safe. What moves here: dates, bug
archaeology, and "it used to be X" where X is gone. The rest of the platform
already works this way.

## Core Patterns

### Ship Class Public Surface (base-ship.ts)

```typescript
// Resources — every collection the SDK reaches. `/activities` is
// deliberately not among them (see "Routes the API exposes that the SDK
// does not reach").
ship.deployments / ship.domains / ship.account / ship.tokens

// Convenience shortcuts
ship.deploy(input, options?)   // → deployments.upload()
ship.whoami()                  // → account.get()

// Top-level
ship.ping()                    // returns PingResponse ({ timestamp })
ship.getLimits()               // returns PlatformLimits (asks the server every time)
ship.setToken(token)           // any platform token, or a TokenProvider
ship.setHeaders(headers)       // global headers on every request (lowest priority)
ship.clearHeaders()
ship.on(event, handler)
ship.off(event, handler)
```

Deploy options are `signal` (the one cancellation mechanism), `pathDetect`,
plus the wire options from `DeploymentUploadOptions` (`labels`, `via`,
`password`, `ttl`, `spaDetect`, and the `@internal` flags). There are deliberately no
per-deploy `timeout`/`onProgress`/`onCancel`/`maxConcurrency` options —
request timeout is a client concern (`ShipClientOptions.timeout`), fetch has
no upload-progress events to honestly report, `signal` already covers
cancellation, and a deploy's requests go one after another (a declaration,
then a fill per batch), each a whole request with nothing to parallelize.
The vestigial four were removed for 2.0 (2026-07-27) after an audit found
them declared but never consumed.

### One layer, not two: the resources ARE the endpoints

**`resources.ts` states WHICH request; `api/http.ts` carries it.** A resource
method is its endpoint — the path, the verb, the body, the response type — and
it hands that to a narrow `Transport` (`request`, `requestWithStatus`, plus the
deploy's carriage). Nothing above that line knows the base URL, the credential,
the retry policy or the event vocabulary; nothing below knows what a domain is.

**It was two layers until 2026-08-12, and the second was a restatement.**
`ApiHttp` carried eighteen endpoint methods — `getDomain`, `listTokens`, … —
and every resource method wrapped one of them 1:1 (`get: async (name) =>
getApi().getDomain(name)`). That is not an adapter, because the isomorphism was
DESIGNED: this SDK mirrors the wire one method per endpoint (see "Recorded
absences"), so the two could not diverge without one of them being wrong.

The fold went **DOWN**, and the direction is the whole decision. Moving the
resource vocabulary UP into `ApiHttp` would mix what the doctrine separates —
the public grouping is the `*Resource` interfaces' business, and those live in
`@shipstatic/types`. Going the other way, each layer keeps exactly what it
already named, and **"`ApiHttp` is pure transport" stopped being an aspiration
in this file and became literally true**: the class has no endpoint table, no
API vocabulary, and no idea what it is carrying.

Four things moved with it, each to the thing that was already its only caller:

| What | Went to | Because |
|---|---|---|
| The 18 endpoint methods | `resources.ts` | Each was wrapped 1:1 by a resource of the same shape |
| `deploy()` | `deployments.upload` | The deploy pipeline read across two files — collection and SPA detection here, validators and body there — for no reason a reader could see from either end. It is one function now |
| `checkSPA()` | nowhere | The single-page question rides the declaration as a field ("The single-page question"), so the SDK holds no pre-flight at all |
| `ping` / `getLimits` | `base-ship.ts` | Top-level `Ship` methods, not resources; the wire call belongs with the method that is its only reader |

Two consequences worth knowing:

- **`request` takes a PATH, not a URL.** The base is the transport's. Twenty-two
  call sites wrote `${this.apiUrl}${API_PATHS.X}` by hand, which is twenty-two
  chances to assemble it differently.
- **The deploy's carriage stayed with transport**, as `Transport['deploy']`:
  the endpoint (a client option may redirect it to `/upload`) and the two
  ceilings. The NUMBERS are transport's — a budget for how long to wait on a
  wire is nothing else — while the CHOICE between them is the resource's,
  because only it knows that `build`/`prerender` wait on work the server does
  after the upload lands, and `spa` does not.

**What did not change: the public surface.** The `*Resource` interfaces come
from `@shipstatic/types` and did not move. `ApiHttp`'s eighteen endpoint methods
were exported by accident of `export *` and nothing in the estate reached them;
their removal is internals only.

### Resource Factory Pattern

Resources are factory functions that receive a `ResourceContext` (`getApi`, a
thunk resolving the `Transport`) instead of the full Ship instance. This enables
functional composition: factories only depend on the callbacks they actually
need. Deployment resource additionally receives `processInput`.

### HTTP Client Architecture

`ApiHttp` in `src/shared/api/http.ts` — every request flows through
`executeRequest`, which handles header merging, timeout, retries, event emission
and error mapping. Two public variants: `request<T>()` returns data directly;
`requestWithStatus<T>()` returns `{ data, status }` for the one operation where
HTTP status IS the answer (201 vs 200 on domain upsert).

**Key patterns:**
- All path parameters use `encodeURIComponent()` — the SDK does not validate
  identifiers, so this is the whole client-side defence against a hostile name
  forging a sub-route. Fenced by a row **quantified over every resource method
  that takes one** (`resources-paths.test.ts`): one method forgetting it is the
  entire defect class, and a hand-picked example only ever proves the method
  someone remembered.
- Optional arrays: use `labels !== undefined` (not `labels?.length`) — distinguishes "not provided" from "empty array"
- `requestWithStatus()` used when HTTP status drives behavior (domain creation: 201 = `isCreate: true`)
- Internal request options are `ShipRequestInit`, whose `headers` is narrowed to
  a plain record — see "Retries" for why that narrowing is load-bearing

**Transport injection (`fetch`):** `ShipClientOptions.fetch` overrides the function used for every outbound API call. Defaults to `globalThis.fetch` (captured at construction). Any `fetch`-compatible function works — typical uses include a Cloudflare service-binding `Fetcher` for Worker-to-Worker calls, tracing/retry wrappers, and test mocks. All downstream machinery (events, timeouts, `AbortSignal`, multipart bodies, `ShipError` normalisation) operates on standard `Request`/`Response`, so the injected fetcher inherits everything for free.

**Events — every failure is visible, and the event NAME says whether it ended
the call:**
```typescript
ship.on('request', (url, init) => ...);          // once per ATTEMPT
ship.on('retry', (error, url, attempt) => ...);  // that attempt failed; another is coming
ship.on('response', (response, url) => ...);     // the call succeeded
ship.on('error', (error, url) => ...);           // the call failed, terminally
```

One call emits `retry* (error | response)`, so the stream is unambiguous at
every PREFIX — a consumer never has to wait to learn what it is watching. The
failure events are emitted by `executeRequest`, not `attemptOnce`, and that
placement IS the mechanism: terminality is a property of the loop, so an
attempt cannot name its own failure. `attempt` counts from 1, which makes the
value read both ways at once — attempt N failing IS retry N.

See "Retries" for why this replaced a per-attempt `error`.

### Authentication Flow

The constructor is fully synchronous: the credential and the HTTP client are formed at construction time from constructor args, with `SHIP_TOKEN` / `SHIP_API_URL` filling any gaps in Node. Nothing is deferred and nothing is held: platform limits are read when they are used (below).

**One credential slot.** `token` carries any platform token — `ship-` API key, `deploy-` deploy token, `oauth-` OAuth access token — sent verbatim as `Authorization: Bearer <value>`. The value's shape says what it is: the server classifies with the same `classifyToken` (`@shipstatic/types`) the SDK uses for boundary validation, so client and server can never disagree on dispatch. **The OAuth population gained its prefix on 2026-08-14** and the SDK needed no source change for it — one slot, one dispatcher, the rules owned by the constitution — which is the clearest evidence that slot is the right shape. A bearer carrying no known prefix still rides the slot and is still forwarded verbatim; the server refuses it, because the platform mints nothing unprefixed any more. A `TokenProvider` function in the same slot supplies the token per request — minting and refresh live with the caller, the SDK just asks. `session: true` is the cookie-session identity for first-party browser apps. `token` + `session` together is a config error. **There is no credential precedence anywhere in the SDK** — one slot means multiplicity is inexpressible.

**Token resolution (Node, in priority order):**

1. Constructor argument (`new Ship({ token })`)
2. Environment variable: `SHIP_TOKEN` (plus `SHIP_API_URL` for the endpoint)

That's the entire SDK contract. `~/.shiprc` and the `--config` path are **CLI-only** — see `src/node/cli/shiprc.ts`. This separation is what makes `new Ship({})` safe in embedded contexts: the SDK can't reach into the host developer's personal dotfile and silently leak credentials into anonymous public deployments. The single env var follows the industry's one-token idiom (`GITHUB_TOKEN`, `NPM_TOKEN`, `VERCEL_TOKEN`).

**The lifetime-dominance doctrine:** storage must not outlive the credential. A dotfile is indefinite — `.shiprc` holds durable tokens. A process environment lives as long as the process — `SHIP_TOKEN` holds whatever its provisioner keeps fresh (a CI job injecting a short-lived token per run is correct). A constructor argument or provider lives per request — it holds anything, which is where hourly OAuth bearers belong.

**The fail-closed anonymity invariant:** anonymity requires proven absence of credentials. An anonymous deploy simply carries no `Authorization` header — the API grants the public-account agent identity per request (`AuthMethod.AGENT`; claim URL + expiry on the response), and the SDK has no agent-token machinery at all. A credential that is present but expired, malformed, or rejected fails the request with a typed error — it never demotes a deploy to an anonymous PUBLIC_ACCOUNT deploy. Empty-string normalization is the invariant's boundary condition: `''` (shell expansion of an unset CI variable) is absence of intent and falls through to the next source; a configured provider yielding nothing is broken intent and throws.

Browser `Ship` has no ambient source at all — the token comes from constructor options (or, for first-party browser apps, the cookie session via `session: true`).

#### Strict-isolation contract for embedded hosts

The env-var fallback is **the** SDK contract. There is no programmatic opt-out — no `envFallback: false` flag, no `token: null` sentinel. Embedded SDK consumers (MCP, GitHub Action, library wrappers, multi-tenant integrations) are expected to manage `SHIP_TOKEN` at the process boundary:

- **Hosts that pass credentials explicitly** (e.g. MCP receives a token via its server config and forwards it to `new Ship({ token })`) get exactly what they expect — explicit args win, no surprises.
- **Hosts that need strict isolation** (e.g. a multi-tenant runner where the deployer's identity must never leak into a customer's deployment) must scrub `SHIP_TOKEN` from the worker process or run the SDK in a sub-process with a clean env. The SDK trusts whatever env it sees.

Why no opt-out option? Because every flag we add to disable env reading would itself become a footgun in the same way `configFile` resolution did — embedded consumers would forget to set it and silently leak. Making env reading non-overrideable forces the host to think about credential isolation as a process-level concern, where it actually belongs.

#### The device-flow future

The CLI's recorded future for delegated auth is the OAuth device flow. Its doctrine reading: the *refresh token* is the durable credential — ambient storage (OS keychain, `.shiprc`) is permitted; the access tokens it mints are short-lived and ride a `TokenProvider`, never a dotfile or an env var. The provider is already the public contract, so the device flow adds no SDK surface.

### Cross-Platform File Input

The shared `DeployInput` type is `File[] | string | string[]`. Each platform's `deploy()` shortcut narrows to its accepted shape; non-matching inputs throw at runtime.

```typescript
// Node — string or string[] (file/directory paths; directories walked recursively)
ship.deploy('./dist');
ship.deploy(['./dist/index.html', './dist/app']);

// Browser — File[] (typically from <input type="file"> or drag-and-drop)
ship.deploy(Array.from(fileInput.files));
ship.deploy([fileFromDragDrop]);
```

`FileList` is not accepted directly — the runtime check is `Array.isArray(input) && input.every(item => item instanceof File)`. Convert with `Array.from(fileInput.files)`. Synthetic `StaticFile` objects (`{path, content, md5, size}`) are an internal pipeline shape produced by `processFilesForNode` / `processFilesForBrowser`; they are not a public input format for `deploy()`.

### One deploy pipeline, two collectors

**Only the FINDING is platform-shaped.** Node walks a filesystem; a browser is
handed `File` objects. Everything after — optimize the paths, drop the junk,
refuse what the platform's rules refuse, checksum what survives — is one
sequence, and since 2026-08-12 it is stated once in
`shared/core/deploy-files.ts`. `node-files.ts` and `browser-files.ts` are the
collectors, and the browser one is now nine lines of body.

The seam is `DeploySource`: a path, a size, and a `read()`.

**`size` sits beside `read()` rather than behind it, and that is the design.**
Node fills it from the `statSync` its directory walk already performs to tell a
file from a directory, so the empty-file skip costs nothing and every deploy
rule is judged before the first `read()`: a refused deploy opens no file,
however many it holds, because no rule reads more than a path and a size. It
removed work
rather than preserving it: the old loop stat'd every file a SECOND time to learn
a number the walk had already read and thrown away.

Two asymmetries are BY DESIGN — recorded, not unified:

- **Only Node pre-checks `UNBUILT_PROJECT_MARKERS` on input directories.** A
  Node user types a path and can plausibly type a repository root, where the
  walk would then enumerate `node_modules`; a browser user picks files. The junk
  filter refuses the same mistake one step later for paths it can see, so this
  is about the cheapest moment, which for a large monorepo is the difference
  between an immediate answer and a long one.
- **Only browser callers set `build`/`prerender`** — see "Server-Processed
  Uploads". The MODE is read from the shared options where the loop is; who sets
  it is the asymmetry.

Fenced by 26 rows whose sources are SYNTHETIC — none of them needs a disk or a
browser, which is itself the evidence the seam landed in the right place.

### A deploy larger than one request

**A deploy is declared, then filled, and a deploy that fits one request is
the case where the declaration carries everything.** `deployments.upload`
always builds the manifest (`deployManifest`: every file by path, size and
MD5) and the declaration's fields (`deployFields`, in one fixed order), and
asks the planner (`shared/core/deploy-plan.ts`) how to send them:

- **Everything fits:** one request, the declaration carrying every file, 201.
- **It does not:** a bare declaration (the manifest, the options and the root
  `ship.json`, which the API compiles at the declaration) answering 202
  pending, then one `POST /deployments/:deployment/files` per batch. The
  first 201 or 200 is the deployment; a 202 after the last batch is refused,
  since the client cannot resolve it.

**The planner counts; it never measures.** Each part is its bytes, plus its
name as the body carries it, plus `PART_OVERHEAD`; one more `PART_OVERHEAD`
is the closing delimiter. Text values are counted with line breaks as CRLF
and filenames with their quote, CR and LF escaped, the HTML standard's
multipart rules, so the count is a ceiling on what any runtime's `FormData`
emits and a 413 on a planned request means the arithmetic is wrong.
`bodyBytes` is the one count, read by the planner and by its fixture: the
rows in `deploy-plan.test.ts` hold it against a real `FormData` on node and
on Chromium, and two of them hold the value and the name counts EXACTLY, by
the runtime's own byte difference between two bodies, because the framing's
slack would hide a counter off by a few bytes from every other row.

**Splitting is internal, which is why this is a minor version.** The
signature, the options and the answer are the same; the one change a caller
sees is that a large deploy no longer meets the edge's limit.

**Abort does nothing, and that is the design.** Ctrl+C or a caller's signal
stops the sending; the pending deployment is the platform's sweep's to
reclaim, after its window, at the cost of one cap slot until then. No
`DELETE` is sent: the CLI exits before cleanup could run, a deploy token
cannot delete, and a `DELETE` after a lost final response would delete a site
that had just gone live. A fill answering 404 ("This upload was cancelled or
timed out. Deploy again.") ends the deploy, and the SDK never redeclares on
its own.

### Server-Processed Uploads (Build/Prerender)

When `build` or `prerender` is set on `DeploymentUploadOptions`, the SDK delegates the build to the server:

- **`processDeployFiles`** (`shared/core/deploy-files.ts`) carries a
  project's source as it was given. Which files a project holds is decided
  once, before the SDK sees them, by `@shipstatic/drop`'s `projectSource`
  (what the repository holds), so this loop takes nothing away: no junk
  filter (a build reads `.npmrc` and `.vitepress/`), no empty-file skip (an
  empty module a build imports is source, and the files go into a zip for the
  build service, never to storage), and none of the deploy rules, which
  describe the build's OUTPUT. It still checksums. `filterJunk` judges a
  site's files and only a site's, so it takes no option.
- **A `build` or `prerender` never splits**: the planner refuses a project over one request before a byte is sent, in `BUILD_TOO_LARGE`. The planner reads the flags from the declaration's own fields, so what it plans is what is sent
- **`deployFields`** appends `build=true` / `prerender=true` to the declaration

**Only browser callers set them, and that asymmetry lives in the CALLERS.**
The platform's own web apps are browser apps and reach `/upload`; Node has no
such caller, and its own unbuilt-marker precheck would refuse a project folder before
the walk in any case. The browser processor carried a whole second copy of the
per-file loop for this mode until 2026-08-12 — a third statement of "skip empty,
checksum, push" — which is what the shared pipeline deleted. What did not move
is which platform's users can set the flags.

These are `@internal` flags, used only by the platform's own web apps through `/upload`. External clients (SDK, CLI, integrations) never set them. They deploy through `/deployments`, which builds nothing: builds happen locally before upload.

`captcha` rides the same internal tier: the platform's public uploader passes its proof as a deploy option and the body creators append it as a form field. First-party only; every other anonymous deploy is the credential-less `/deployments` path, which needs no proof.

## CLI Patterns

### Output Conventions

| Type | Text format | JSON format | Quiet (`-q`) format |
|------|------------|-------------|---------------------|
| Success message | green text | `{ "success": "..." }` | — |
| Data (single) | key-value pairs | raw JSON object | key identifier only |
| Data (list) | table | raw JSON object | one identifier per line |
| Void/ping | success/error text | `{ "success": "..." }` | no output (exit code) |
| Error | `[error]` prefix, red | `ErrorResponse` (see below) | stderr (unchanged) |

**Text translates, JSON transmits.** The three message envelopes above
(`success` / `warning` / `info`) have no wire counterpart, so they are
CLI-shaped `{ kind: message }`. Errors do have one, and the `--json` channel
emits it verbatim — `ShipError.toResponse()`, so `error` names the
`ErrorType`, `message` carries the wire's own sentence, `status` the HTTP
status. Text mode is the only channel that gets `getUserMessage`'s actionable
rewording ("pass --token, set SHIP_TOKEN, or run ship config").

The two must not be merged. Until 2026-07-29 `--json` emitted
`{ error: <message> }` — prose under the key the API, the SDK, and
`@shipstatic/types` all reserve for the type, which left an agent nothing to
branch on but the sentence, against this platform's own rule that clients
branch on `error` type / `status` and never on message strings. Five emitters
carried the inverted shape, not one.

Two things hold it now, and neither is prose: `error()` in `utils.ts` is
**overloaded** so that a bare string is accepted only for the text channel
(an untyped failure has no wire shape, so writing one into `--json` is a
compile error), and `tests/node/cli/json-errors.test.ts` asserts the envelope
across every producer — the global boundary, Commander's parser,
`handleUnknownSubcommand`, the `preAction` validator, and transport failure.

### Deletions answer with an acknowledgement

The same law, one channel over. A deletion is not void: the wire answers with
the resource noun carrying its canonical key, plus the resource's own state
field where the state changed (`@shipstatic/types`,
`DeploymentDeleteResponse`). It carries no `message` on purpose — *"an
acknowledgement is data, and each surface composes its own copy"* — so this is
the one shape where the API deliberately authors no prose and the CLI is
expected to write the sentence itself.

Three channels, one acknowledgement, each in its own idiom: **quiet** prints
the key, **`--json`** transmits the shape through the single JSON exit in
`formatOutput`, and **text** is the only one that composes copy.

**And the TENSE in that sentence is the response's too.** An acknowledgement
carries the resource's own state field only where the resource survived
mid-transition, so that field is how a surface knows whether to speak in the
past. `DELETE /deployments/:deployment` answers **202**, marks the row
`deleting`, and queues the cleanup; the files stay served until the
platform's cleanup finishes. The CLI
read the acknowledgement's key, discarded its `status`, and said "deleted"
anyway — a completed past tense over a live site, which is precisely backwards
for someone deleting a deployment BECAUSE it exposed something. `--json` was
truthful throughout; only the sentence lied. Text now says
`<host> deployment deleting — served until cleanup completes`, while a hard
delete (`{domain}`, `{token}`) still says `deleted`, because there the row is
genuinely gone.

The gate is the transitional-state MAP in `formatters.ts`
(`DELETION_IN_FLIGHT`), never the mere presence of a `status` — `Deployment`
and `Domain` both carry a status of their own (`pending`, `success`), so a
formatter keying on presence would answer "www.example.com domain pending" the
day a handler resolved an entity here. An unrecognised state reads as done.

**The identifier in that sentence is the response's, never the request's.**
The two diverge routinely — a deployment is addressable by bare slug and
answers with its hostname; a domain is accepted in any case and answers
normalized — so a sentence built from the argument names whichever form the
caller happened to type. The resource noun is the CLI's own word, and because
an acknowledgement is the resource noun carrying its key, that one `resource`
both selects the field and writes the sentence.

This regressed once (`11fc633`): the commit that gave the delete methods real
return types also widened the formatter's branch to a test on the operation
name, intercepting the acknowledgement it had just plumbed through — `--json`
emitted `{ success: "<slug> deployment removed" }`: prose in the data
channel, a bare slug where the platform names an FQDN, no `status`. Two
things hold it now, and neither is a convention: `OutputContext` no longer
carries the caller's argument at all (the field existed only to be echoed, so
a sentence composed from input is now inexpressible), and
`tests/node/cli/json-acknowledgements.test.ts` asserts the envelope for every
deletion — including that deleting by slug and by hostname produces
byte-identical output.

### What a status means

`ErrorResponse.status` is documented **"HTTP status code (API contexts)"** — it
is a fact about an exchange, not decoration on a 4xx-ish type. So the question
is never "did we make a request?", it is **"what would the wire say?"**:

- **A check that mirrors a server rule keeps the status the server would send.**
  Blocked extensions, label rules, password length, token format — the platform
  validates these on both sides from the same rules (root `CLAUDE.md`,
  "Validation Architecture"), and the whole point is that the error reads the
  same wherever it was caught. `ShipError.validation(...)` with its 400 is
  correct here. **"The same rules" means imported OR delivered**: the extension
  blocklist arrives at runtime in `PlatformLimits.blockedExtensions` rather than
  as a constant, and mirrors the server no less for it — more so, since a
  delivered rule cannot be stale.
- **A fault with no server rule to mirror carries no status**, and therefore one
  of the client-only types — `Network`, `Cancelled`, `File`, `Config`, which are
  exactly the statusless factories in `@shipstatic/types`. Your shell, your
  dotfile, this binary's command grammar: no API has an opinion on any of them,
  so a 400 would be an HTTP fact about an exchange that never happened. That is
  a *plausible* lie, which is worse than an obvious one — it survives review.

`completion.ts` is the worked example: an fs call that threw is `file`,
everything else there is a statement about the user's shell setup, so `config`.
CLI grammar errors go through `usageError()` in `index.ts` — `Validation` for
the type, no status.

**The SDK was audited against this rule (2026-07-29) and mostly already
complied** — an earlier note here claimed "~25 sites violate it", which was
wrong and is corrected. Almost every local throw in `src/shared/`,
`src/browser/`, and `src/node/core/` *mirrors a server rule* and rightly keeps
its 400: unsafe filenames, path traversal, label rules, and the size/count caps
all come from the same constants the API enforces (`hasUnbuiltMarker` is checked
in `api/src/lib/validation.ts` too), and blocked extensions come from the list
the API *delivers*. One site was genuinely wrong — a failed local file read in
`md5.ts`, now `ShipError.file` with the path in `details`.

What remains is a third category the rule deliberately does not govern:
**assertions** like "processFilesForNode can only be called in Node.js
environment" or "Invalid input for MD5 calculation". They guard states a correct
caller cannot reach, so their type and status are not a contract anyone reads,
and reclassifying them would be churn for no observable difference. Leave them.

**Local commands throw; they never report.** `completion` and `config` make no
request, so neither can use `withErrorHandling` (it builds a `Ship`, which
resolves credentials). Both take the same shape instead — the action wraps the
call and hands anything thrown to `handleError` — which is what gives a local
failure the same one writer and the same exit code as every other. Reporting
inline is not a smaller version of this; it is a different thing that looks the
same: `completion` printed `[error] …` and then exited **0** until 2026-07-29,
so `ship completion install && …` ran on after a failure. `handleError` in turn
resolves the credential only for the auth branch, so a local failure never
reads `.shiprc` to render its own message.

- Text messages open lowercase (leading sentence word decapitalized; identifiers, paths, and acronyms survive verbatim); trailing periods stripped — `plainMessage` in `utils.ts` is that typography, named once
- Deletions produce a success message **in text only** — see "Deletions answer with an acknowledgement" below

### One grammar: `<key> <noun> <verb>` — and one function

`announce(result, context)` in `formatters.ts` composes **every** sentence the
CLI makes about a mutation, from the two things it already has: what the command
IS (`OutputContext`) and what the wire ANSWERED. Nothing else is needed, and no
formatter writes a template any more:

```
<canonical key> <resource noun> <past tense, or the wire's own state>
```

The key is read from the NOUN (`result[noun]`), so `.url` is not reachable; the
word order belongs to the function, not a call site; and the state override
applies everywhere at once, so an operation that becomes asynchronous tomorrow
updates its own sentence. Five formatters each wrote their own template until
2026-07-29 and produced six grammars between them — every one of those bugs was
a formatter making a choice it did not need to make. Three of them stopped
needing `context` at all once the sentences were hoisted, which is the cleanest
evidence the split was real: a renderer does not need to know what the command
was.

**Mutations announce, reports render.** A report has no key and no verb, so it
prints its answer instead — `ping`, `validate`, `records`, `dns`, `share`.
`ACKNOWLEDGING` (`delete`, `verify`) names the mutations whose response is an
acknowledgement and therefore has no entity to render beneath the sentence.

`announce` has **two occasions, one writer**: `formatOutput` calls it for the
answer, and `announceStep` calls it mid-command for the first beat of a
composed one (`ship <path> --domain <name>`). `announceStep` is the only export
— `announce` itself stays private, because a second exported sentence-maker is
how six grammars happened the first time. See "Composability".

The resulting sentences:

```
mock-deploy-001.shipstatic.com deployment uploaded
www.example.com domain created            (…updated)
tok0001 token created
www.example.com domain verification queued
www.example.com domain deleted
tok0001 token deleted
brave-otter-a1b2c3d.shipstatic.com deployment deleting — served until cleanup completes
www.example.com domain is valid
```

**The key, never the URL.** A URL is a *field* of the resource and is already
printed on its own row in the details block below, so opening with it made the
text channel disagree with `-q` and `--json`, which have always named the key.
`ship ./dist` and `domains set` opened with the URL while `verify` and `delete`
opened with the key — one resource, two identifiers, decided by which command
you happened to run.

Audited against the real binary on 2026-07-29 and found to be **six** grammars
across eleven messages: `token tok0001 created` inverted the order its own
sibling `tok0002 token deleted` used; `domain is valid` named no subject at all
though it held the normalized name; `domains share` emitted a bare URL
identical to its own `-q` output. Fenced by the `it.each` table in
`tests/node/cli/formatters.unit.test.ts` ("one grammar"), which asserts every
composed sentence *starts with* the key its fixture carries — so a new command
cannot invent a seventh form.

**Reports render, mutations announce.** A report answers a question, so it
prints its answer (`records` a table, `dns` and `share` a details block,
`validate` its verdict, `ping` the word `api reachable`); only a mutation gets
a success sentence. `share` printed a green URL under `success()` until this
audit, which is why its text and `-q` output were byte-identical.

**A verdict is not a failure.** `domains validate` on an invalid name writes
the reason to **stdout** and sets exit 1. The exit code is the machine answer —
`ship domains validate x && …` is the documented idiom — but the call
*succeeded*, and stderr under `[error]` claimed otherwise, contradicting both
the SDK (which resolves that shape without throwing) and `--json` (which has
always put the same verdict on stdout). It names no subject because
`normalized` is null when invalid: the response carries no identifier, and the
CLI does not fall back to the caller's argument.

**Shell completions are RENDERED from the tree, not shipped.**
`renderCompletion(program, shell)` emits bash/zsh/fish, and
`ship completion install` writes what it renders at that moment — an
installed completion always matches the binary that installed it, which a
copied file could never promise. Three hand-written scripts were the third,
fourth and fifth statement of the command tree until 2026-07-29 (`ship tokens
get` shipped the previous day and completed in **zero** shells;
`--limit`/`--cursor` were in none of them). `tests/node/cli/completions.test.ts`
quantifies over the whole tree; the smoke tier installs through the real
binary and runs `bash -n` / `zsh -n` / `fish --no-execute`, because a real
shell is the only thing that can say the output parses.

**And so is scoped usage.** `handleUnknownSubcommand` reads `this.name()` and
`subcommandsOf(this)` — no arguments, no closure, module scope. Until
2026-07-30 it took `(parentName, validSubcommands[])` by hand — the last
hand-written restatement of the tree, stale the same way (`ship tokens bogus`
offered three subcommands while the derived completion one module over
offered four). Fenced in `tests/node/cli/unknown-commands.test.ts`,
quantified over `buildProgram()` (the hand-written table it replaced carried
the very defect it existed to catch). Consequence for word order: the printed
list is REGISTRATION order — the same order `--help` and the completions
show. One order, three surfaces; the front page keeps its own, by design.

**The front page may curate, but not forget.** `helpText()` is hand-written and
byte-pinned, so leaving a command off is legitimate — provided the omission is
recorded in `HELP_OMISSIONS` (`tests/architecture/docs-contract.test.ts`) with
its reason. It was missing `ping`, `account get` and `tokens get` when the fence
was written; two were added to the page, and `account get` is recorded, because
`ship whoami` is the same read and is the spelling the page shows.

**`-q` prints the key for every shape that has one.** `tokens get` and
`tokens delete` printed NOTHING until 2026-07-29 — the quiet router matched the
`tokens` collection and had no branch for a single token, so the one resource
whose identifier you most want to pipe was the only one emitting none, and
`ship tokens list -q | xargs -I{} ship tokens delete {} -q` (this repo's own
README idiom, one noun over) was silent. The one deliberate exception is
`tokens create -q`, which emits the **secret** rather than the id: it is shown
once and never again, so `ship tokens create -q >> .env` is why that channel
exists there. It is checked before the `token` branch, since a creation
response carries both.
- Internal fields (`isCreate`, `_dnsRecords`, `_shareUrl`) are stripped from JSON output
- `[error]`/`[warning]`/`[info]` prefixes use inverse color backgrounds in TTY

### No repository file is ever read

`.shiprc` resolution is **two locations**: `~/.shiprc`, or the path `--config`
names. A project-level search — `./.shiprc`, `package.json` `"ship"`, walking
up from the cwd — sat in front of both until 2.0.0 and is gone, along with
cosmiconfig.

It was deleted rather than fixed, because its only capability WAS the
anti-pattern: a repository-controlled file supplying credentials. Two exploits
were verified against the real binary on 2026-07-30:

1. A cloned repo's `package.json` carrying `{"ship": {"apiUrl": "http://…"}}`
   received `Authorization: Bearer <the user's SHIP_TOKEN>` on a plain
   `ship deployments list` — exit 0, no warning.
2. **Worse, because it survived the first patch:** a repo's `token` outranked
   `~/.shiprc` entirely, so `ship ./dist` in a cloned repo deployed to the
   *repo owner's* account, silently.

The first was patched with a `refuseProjectApiUrl` rule (~50 lines with
symlink canonicalisation, since `/var` is a symlink on macOS and a textual
compare misread the user's own home file as a project file). That patch was
the wrong SHAPE of fix: it refused one field of a surface whose every field
was equally untrustworthy, which is why the second exploit was still there
after it. Both are properties of the surface, so the surface went.

The asymmetry that gives the game away: the wizard `chmod`s `~/.shiprc` to
0600 "like `~/.netrc`" while the reader accepted the same credential from a
tracked, world-readable `package.json`.

`--config <file>` is unaffected and is now strictly better — there is no
extension dispatch to work around, so any path is simply read.

**Format: strict JSON.** YAML acceptance was an accident of cosmiconfig's
`noExt` loader default, and it cost the `dev.shiprc` "No loader specified"
bug, a two-pass writer/reader repair, and a 15-line comment explaining
`extname` dispatch. Nothing in the product ever produced YAML — the wizard
writes JSON and the docs show JSON. An **empty file is an absent config**, not
a broken one; `touch ~/.shiprc` and any interrupted write produce one.

### One file, two commands, one idea of what it is

`ship config` is the only WRITER of the file `shiprc.ts` is the only READER
of. Until 2026-07-30 they held different ideas of it, in both directions: the
writer's bare `JSON.parse` inside `catch → {}` read a file the reader
rejected BY NAME as "no existing config" and wrote `{}` over it — token and
`apiUrl` gone, under a `saved to …` message, making **the recovery path the
destructive one**; and "preserve every other field" kept the very `apiKey`
the reader's rename hint promised `ship config` would fix, so **the advice
was a loop**.

Both are one rule now: **the schema is the file.** `CREDENTIAL_FIELDS` is
`.strict()` — `token` and `apiUrl` are the only fields a `.shiprc` may
legally hold. The writer rebuilds the file from
`Object.keys(CREDENTIAL_FIELDS)` rather than mutating what it read (dropping
a rejected key IS the repair the hint promises, announced rather than done
quietly), and it **refuses what it cannot parse** instead of replacing it
(`ErrorType.Config`, *the file was left unchanged*) — a file we cannot read
is a file whose contents we cannot claim to preserve.

**The FORMAT is shared outright** — `readExistingConfig` calls the reader's
own `parseShipFile`. **One parse, two policies**: the reader parses then
rejects; the writer parses then repairs what the reader rejects (it must
accept `{apiKey}` in order to fix it). The fence therefore asserts format
agreement only, per file (`tests/node/cli/config.test.ts`): reader and writer
run over the same content and their verdicts are compared — when `.shiprc`
became strict JSON in 2.0.0 that fence needed no structural change, only its
`expected` column moved on both sides at once, which is the fence working.

### Composability

`-q` outputs only the key identifier — the value you'd pipe forward. `domains set` reads deployment from stdin when piped.

```bash
ship ./dist -q | ship domains set www.example.com
ship ./dist --domain www.example.com          # the same two calls, one command
```

**Both spellings are product; neither replaces the other, and this paragraph
exists so the question stays answered.** The pipe composes *interactively* —
any two commands, wherever `-q` hands the next one the value it wants — and it
is the documented shell idiom. `--domain` exists because a pipeline is the
wrong shape for CI: a workflow `run:` block is `bash -e` **without**
`pipefail`, so a pipeline reports the LAST command's status and a failed deploy
is masked by `domains set`'s own confusion — and a CI consumer needs the
deploy's full `--json`, which `-q` deliberately discards. One process, one exit
code, one JSON.

**With `--domain`, the answer is the DOMAIN**, because the domain is what the
user asked about: "deploy this *to www.example.com*" is a question about the
destination, and `domains.set()` has always carried the answer — the `Domain`
it returns holds the freshly linked deployment and the URL. So the composed
command renders through the `domain.set` row that already exists: **no new
type, no new response shape, no new formatter, no new output row.** The only
type-level edit the whole feature needed was `DeployCommandOptions.domain`.

Four consequences, each falling out rather than being arranged:

- `-q` prints the domain name, because the quiet channel prints the row's key
  and the row is `domain.set`'s.
- `--json` transmits the `DomainSetResult`, internals stripped, byte-identical
  to `ship domains set --json`.
- A new external domain gets the same first-link DNS enrichment, because
  `performDomainSet` is literally the same function `domains set` calls.
- **`ship ./dist -q` without the flag still prints the deployment id.** The
  pipe seed is untouched, forever, with a test pinning it.

**The flag chooses which command runs, not what one command does.** `runDeploy`
reads `--domain` ONCE and picks between two `withErrorHandling` pairs —
`deployOnly` under `{upload, deployment}` and `deployAndLink(domain)` under
`{set, domain}`. Identity and behaviour are therefore chosen together, from one
reading. The alternative shapes were both considered and are worse: a result
carrying an internal `_context` override would be **the response deciding the
output**, which is the exact thing `OUTPUTS` exists to forbid (see
"`formatOutput` Router"); a static action plus a separately-resolved context
branches on one condition in two places and lets the two answers disagree.

Two beats in the text channel, one sentence-writer. `announceStep` (exported
from `formatters.ts`) writes the deployment's own sentence the moment the
upload lands — streamed like the spinner, and load-bearing rather than
decorative: if the link then fails, the id the user has already paid for is on
screen. A beat is the **sentence alone**, in the **text channel alone**: the
details block belongs to the answer, and `--json` still has exactly one exit
while `-q` still prints exactly one key. `announce` stays private — this is a
second OCCASION for it, not a second writer.

**`--domain` refuses without a credential BEFORE it reads a file.** An
anonymous account cannot own a domain, so the invocation is unsatisfiable, and
discovering that after the upload would have minted a public, expiring,
claimable deployment as the side effect of a failed *authenticated* intent —
which the fail-closed anonymity invariant forbids. It asks `resolveCliToken`,
i.e. the credential the CLI RESOLVED, so a fourth source slots in there and
never here. The error is statusless `Config`, per "What a status means": no
exchange happened, so there is no HTTP status to report, and `Authentication`
would both carry a 401 for a request never made and hand the sentence to
`getUserMessage`'s generic auth arm — losing the one thing worth saying, which
is that it is `--domain` that needs the token. The remedy itself
(`CREDENTIAL_HINT` in `error-handling.ts`) is one fact with one owner, shared
with the auth message, because the device flow will change both at once.

A link failure is **not** rolled back: a deployed-but-unlinked site is a valid
platform state, and an idempotent re-run replays the deploy and simply links
again. **"Idempotent" is a condition, not a description of the default** — it
holds when `SHIP_IDEMPOTENCY_KEY` is set, which is the CI shape the flag exists
for (the GitHub Action derives one per workflow run). A bare shell retry has no
key, so it mints a SECOND deployment: valid, unlinked, and visible in the
account. Say so when advising a retry.

**The fold rule, so the next composed command inherits it rather than
re-deriving it: on failure, `--json` stays the verbatim error envelope and
completed steps are text-only.** The machine channel never learns the
mid-command deployment id. That is not an oversight to fix later — it is the
one-JSON-exit law meeting the envelope-verbatim law, and either alternative
breaks one of them: a second document breaks the first, and a `partial` field
beside `error` breaks the second. The convergence story is idempotent replay,
not a richer error. Text is the channel that carries the progress, which is why
`announceStep` is text-only by construction.

**Recorded absences** (decisions, not gaps):

- **No SDK `domain` deploy option.** The SDK mirrors the wire 1:1
  (`resource.action()` per endpoint); composition is CLI grammar, exactly as
  DNS enrichment is CLI-only. An SDK consumer composes two typed calls.
- **No `SHIP_DOMAIN` env var.** The CLI-only env tier exists for values a
  subprocess wrapper cannot put on argv (secrets, ambient keys). A domain rides
  argv fine.
- **No domain prevalidation before the upload.** Parity with the verb it
  names: `domains set` does not prevalidate either. The API refuses at link
  time with its own message, and the idempotent replay makes the retry cheap.
  The credential preflight above is different in kind — it prevents a *wrong
  deploy*, not a wrong link.

**Deferred mechanisms, each with the condition that ends the deferral.** The
estate's stopping rule governs all four — machinery earns existence at the
SECOND holder, not the first — so these are recorded here rather than built,
and the next composition lands born-here instead of re-arguing the design.

| Deferred mechanism | Builds when | What it replaces |
|---|---|---|
| **Identity as a dispatcher contract** — `withErrorHandling` accepts `OutputContext \| (input) => OutputContext`, resolves once, hands the result to the handler | The **second** command whose identity depends on its invocation | `runDeploy`'s hand-rolled dispatch and the comment defending it |
| **Dispatcher-owned step emission** — the handler gets an `emit` capability; channels become folds (text renders steps, `--json`/`-q` ignore them), tested once at the fold | The **second** command that announces a mid-command beat | The exported `announceStep` and its per-command channel discipline |
| **A composition/plan shape** | The **second** composed command | Nothing yet — `deployAndLink` as a plain async function is the right altitude for one |
| **A command table above Commander** | A consumer that needs identity or requirements to be *enumerable* — none exists; the tree already serves completions, help, both fences, and scoped usage | Nothing — the tree IS the table today |
| **An ordered TABLE for `getUserMessage`** — (predicate, renderer) rows with a completeness tie, the `SHAPES` / deploy-rules move | The **third** ordering TIE in that chain. It has two (auth-before-client, timeout-before-network), both fenced by tests that turn red on a reorder | The if/else chain and the two tie notes above it. Deliberately not built now: those tables exist to delete a SECOND statement of one fact — `SHAPES` had two chains that drifted, the deploy rules one rule read three ways — and this chain has ONE reader, so a table would move code without removing a restatement |
| **Hand-rolled ambient-config validation**, retiring `zod` from `dependencies` | Someone is paying for the dependency — a consumer install size complaint, or the third field on `CREDENTIAL_FIELDS` | zod's entire runtime footprint here: three imports over TWO fields (`apiUrl`, `token`), where `z.string().url()` is a weaker restatement of the constitution's `validateApiUrl`. Not free, though: `.strict()` is what makes `.shiprc` refuse unknown keys, and `Object.keys(CREDENTIAL_FIELDS)` is what the config wizard rebuilds the file from, so the replacement owes a key list plus a parse that reports which field failed. A change to the published dependency CONTRACT belongs in its own wave, never as a rider. (The live gap that audit surfaced was separable and is fixed — see "One rule, every source" below.) |

**Considered and declined outright**, so it is not rediscovered: a cross-repo
existence fence for `web/docs`' CLI page. The rename-drift class it would catch
is already owned by the prose fence's retired-spellings list, and a true
existence check would need ship's command tree executed from another repo's CI
— disproportionate machinery for a covered risk.

### Table Output

- **3 spaces** between columns (matches ps, kubectl, docker)
- Trailing spaces are trimmed from table rows, because columnify pads every cell
  to its column width. **NUL bytes are not** — both renderers scrubbed them until
  2026-08-12 for an artifact that does not exist. Measured, not assumed: 18
  shapes through columnify 1.6.0's real API — plain rows, ragged widths and
  ragged KEYS, empty strings, CJK and emoji, multiline cells, ANSI-coloured
  cells, tabs and CR, twenty columns, a 400-char cell, and the details
  renderer's own two-column form — emit zero NUL. Two copies of a scrub for an
  artifact that never appears is the cost with none of the benefit.
- Headers are dimmed; property names can be remapped via `headerMap`
- Property order matches API response exactly
- `INTERNAL_FIELDS` list (`['isCreate', 'claim']`) is filtered from table/details output — `claim` renders through the claim CTA instead, and deliberately stays in `--json` output so scripts can read it

### `processOptions` Helper

Always call `processOptions(this)` inside action handlers — not `program.opts()`. It converts Commander's `--no-color` (which sets `color: false`) to the `noColor: true` convention used throughout.

### `performDeploy` Helper

Shared deploy logic used by both `ship <path>` shortcut and `ship deployments upload`. Handles: path existence/type validation, the deploy options (labels, password, `--no-path-detect`, `--no-spa-detect`), AbortController for Ctrl+C, and a spinner (TTY only, suppressed in `--json` and `--no-color` modes).

It takes `(client, deployPath, options)` and nothing else: every deploy flag
reaches it through Commander's own merge, so there is no per-flag plumbing and
no source to arbitrate between (see "Two flag tiers").

**The two spellings register the SAME action function.** `runDeploy` is what
`.action()` receives on both, and both take their flags from one
`withDeployOptions(cmd)`, so the flag set and the behaviour cannot drift
between them — there is nothing to keep in step. `runDeploy` then reads
`--domain` and dispatches to `deployOnly` or `deployAndLink(domain)`; see
"Composability" for why the flag picks a command rather than modifying one.

`performDomainSet` is its counterpart on the domain side: everything
`ship domains set` does once its own grammar has produced arguments. The stdin
fallback and the label merge stay in that command — they are how it reads a
request, not how it links — and what is left is shared with the `--domain` arm,
so both spellings link identically by construction.

### Command Handler Pattern

```typescript
// Handler: (client: Ship, options: GlobalOptions, ...positional args) => Promise<CLIResult>
deploymentsCmd
  .command('get <deployment>')
  .action(withErrorHandling(
    (client: Ship, _options: GlobalOptions, deployment: string) =>
      client.deployments.get(deployment),
    { operation: 'get', resource: 'deployment' }
  ));
```

`OutputContext` (`operation`, `resource`) names what the command IS. Both are
**union types**, not strings: `operation` selects behaviour (`PAST_TENSE`,
`ACKNOWLEDGING`) and `resource` names the response field carrying the
identifier, so a typo in either used to mean a command that silently announced
nothing with every test still green. `resource` is lowercase because that IS
the field name — `announce` reads `result[resource]` directly; it was
`resourceType: 'Deployment'` with a `.toLowerCase()` at the single point of
use, a capitalisation carried around only to undo.

It reaches `formatOutput` and **nothing else**. The error path took it too
until 2026-07-29 and ignored it — `getUserMessage`'s parameter was literally
`_context` — and could not have used it either: the wire message already names
the resource, which is why the CLI relays it. `withErrorHandling` additionally
re-declared the context's shape inline and rebuilt it field by field into a
copy; both are gone.

It deliberately carries **no positional argument**: the arguments are the
request, and every sentence the CLI writes about a result is composed from the
response. A `getResourceId` plumbed the caller's argument to the formatter
until 2026-07-29, where its only consumer echoed it back as the deletion
identifier — see "Deletions answer with an acknowledgement".

### `formatOutput` Router — one table, both channels

`SHAPES` in `formatters.ts` lists every response shape the CLI can receive, in
resolution order, each stated ONCE with both of the things a channel needs from
it: the key `-q` pipes forward, and the formatter text renders.

| Discriminant | `-q` emits | Text |
|---|---|---|
| `deployments` | one id per row | `formatDeploymentsList` |
| `domains` | one name per row | `formatDomainsList` |
| `tokens` | one id per row | `formatTokensList` |
| `records` | `<type> <name> <value>` per row | `formatDomainRecords` |
| `hash` | the setup URL | `formatDomainShare` |
| `dns` | the provider name, if resolved | `formatDomainDns` |
| `domain` | the name | `formatDomain` (plain `Domain` or `EnrichedDomain`); the standing's next step beneath its details, per `DOMAIN_NEXT_STEP` |
| `domain` (verify) | the name | `formatDomainVerify` (`EnrichedDomainVerify`): where to read the verdict, and the link step when the read after queueing found nothing linked. NOT the standing's step — see "One step per standing" |
| `deployment` | the id | `formatDeployment` |
| `secret` | the SECRET — shown once, never again | `formatToken` |
| `token` | the id | `formatToken` |
| `email` | the address | `formatAccount` |
| `valid` | the normalized name, or nothing | `formatDomainValidate` |

Plus `(operation: 'ping')`, answered before the table — text says
`api reachable`, `--json` transmits `{ timestamp }`, `-q` says nothing.

**Order is load-bearing** in two places, and both are ties rather than
preferences: `Domain` carries a `deployment` field, so `domain` must precede
`deployment`; a token creation carries both `token` and `secret`, so `secret`
must precede `token`.

**The two channels were two independent `if/else` chains until 2026-07-30** —
same discriminants, same order, nothing tying them — and it shipped a bug:
`tokens get`/`tokens delete` printed NOTHING under `-q` (the quiet chain had
a branch for the collection, none for a single token). A row cannot
half-exist now; resolution order is a property of the list. `SHAPES` is
**exported for the suite**, and that tie is load-bearing:
`tests/node/cli/formatters.unit.test.ts` pins a hand-written case per row,
then asserts its own list deep-equals `SHAPES.map(s => s.on)` — without that
line the completeness check counted the test's own array and was a tautology
(caught in review, 2026-07-30). Asserting the mapped list also makes the two
order ties above enforced rather than trusted to this paragraph. A fence must
quantify over PRODUCTION and pin expectations by hand; both sides
hand-written is a mirror.

A shape with no row falls through to `formatDetails`, deliberately — a future
`GET /labels` shows its content on the first run rather than needing a
formatter first.

A deletion short-circuits ahead of the table **in text mode only**, composing
its sentence from the acknowledgement; in `--json` it falls through to the one
JSON exit, and in `-q` the quiet branch above the table has already printed the
key.

### One step per standing

Since types 3.0.0, `Domain.status` is the domain's STANDING — the one derived
word saying what it needs from its owner (`live`, `unlinked`, `unverified`,
`paused`) — and the DNS fact lives beside it as `verification`. The CLI reads
that word and derives nothing.

`DOMAIN_NEXT_STEP` in `formatters.ts` is the whole surface: a
`Record<DomainStatusType, (domain) => string | null>`, so a fifth standing
fails to compile at the one place that prints a step. `live` prints nothing.
It replaced a lone `deployment === null` hint, which was the console's
`unlinked` derivation done a second time in another repo — and wrong in the
same way the console's was: a linked-but-unverified domain got no advice at
all, while an unverified unlinked one was told to link rather than to fix DNS.

**`ship domains verify` is the one domain surface that does NOT print it**,
and the exception is the command rather than an oversight. Its read of the
domain lands moments after the check is QUEUED and before it has run, so the
standing it would report is `unverified` by construction. Printing that
sentence would tell somebody who has just configured DNS and asked us to check
it to go and configure DNS. `get` and `set` report a settled state, where the
standing is the whole answer; `verify` reports a state that is deliberately
stale, where the only honest thing to say is the one fact a pending verdict
cannot change: whether anything will be served when it passes. Hence
`_deployment` survives on `EnrichedDomainVerify`, and its sentence is written
in the future tense ("once it verifies, point it at a deployment").

**`ship domains list` gained a `status` column** in the same wave. It had none,
and a wave whose thesis is
one derived word a reader needs cannot ship the list that hides it. The
deployments list keeps its own omission, because a listed deployment is nearly
always `success` while `unverified` and `unlinked` are ordinary states of the
domain flow.

**And one instant-shaped trap the wave surfaced.** `formatValue` in `utils.ts`
renders a unix-seconds field as a date from an ALLOWLIST of key names
(`INSTANT_FIELDS`), because the alternative is guessing from the value and
`links`, `files` and `size` are all plain numbers on the same objects. It is a
restatement of the constitution's instant-named fields with one holder, so it
is not promoted into `@shipstatic/types` (one holder is no owner) — but the
drift is silent in the worst way: a new instant renders as `1785000000` and
nothing fails. When the constitution gains an instant, add it there in the same
wave. `verified` and `paused` joined on 2026-09-20.

### DNS Enrichment on Domain Create

When `ship domains set <name> [deployment]` creates a new external domain (`isCreate: true`, name contains `.`), the CLI fetches `domains.records()` and `domains.share()` in parallel, attaching results as `_dnsRecords` and `_shareUrl` on the result for the formatter to display. The share URL arrives FINISHED from the API (`DomainShareResponse.url`, composed server-side from the environment's own domain, like the claim URL); the CLI never assembles one. This is CLI-only behavior; SDK resources return plain data.

### Retries

The CLI's own 5xx message says "try again". The client now takes its own
advice before handing that sentence to a person. `executeRequest` is the loop
and `attemptOnce` is one request — the same single wrap point that already
owned headers, the timeout signal, the events and error normalization, so an
attempt is a whole request and nothing has to be undone between two.

**Retried:** anything where nothing was exchanged — `Network` (a refused
connection, a DNS failure) and `Timeout` (a deadline of ours expired) — and
500/502/503/504. Two retries by default, so three attempts, with full
jitter — `random() * min(2s, 300ms * 2^n)`. Jitter matters more than the
curve: it is what stops a platform hiccup from returning every client at once.

**The first two are read through `isNetworkError()`, the CATEGORY, and not
named individually.** "Nothing was exchanged" IS the retryability criterion,
so a future member should inherit this answer rather than wait for someone to
remember the line — and naming `Timeout` beside a guard that already contains
it would be a second owner of that membership, free to disagree with the
first. `@shipstatic/types` owns it (`ERROR_CATEGORIES.network`); what holds
the decision here is the http-timeout suite's "and is RETRIED", which turns
red the moment a deadline leaves the category.

**Not retried, each a decision rather than an omission:**

- **A maintenance 503.** A state, not a fault: its message says when to come
  back, and retrying it three times only delays that sentence reaching the
  person who needs it. Same STATUS as the retryable 503, which is exactly why
  the check reads the TYPE — and why that test is the load-bearing one.
- **429.** The platform's rate limiter has just answered; a client that
  auto-retries is arguing with it. Revisit only alongside honoring
  `Retry-After`, as its own decision.
- **`PUT` / `DELETE`.** Semantically idempotent here and excluded anyway: a
  DELETE whose response was lost answers 404 on the retry, turning a success
  into a reported failure.
- **Any other request its resource did not mark `idempotent`.** The deployment
  resource marks every fill, which the API answers from the deployment when it
  is repeated, and a declaration carrying an `Idempotency-Key`, which the API
  answers with the live deployment the key names. Everything else is sent once.
- **Anything the CALLER's signal stopped.** The subtle one, and the reason the
  check reads `options.signal.aborted` rather than only the error type: a
  caller's `AbortSignal.timeout()` classifies as `Timeout`, exactly like our
  own ceiling, which this loop retries on purpose — so on the error alone the
  caller's deadline would look retryable and be silently outlived. **That
  check carries the invariant alone**; nothing else in `isRetryable` can tell
  whose clock ran out, which is what makes "does not retry past a caller's own
  deadline" the load-bearing test in `http-retry`.

**Which required teaching the composed signal to say which deadline fired.**
`createTimeoutSignal` aborted bare, so the SDK's own timeout, a caller's abort
and a caller's `AbortSignal.timeout()` all arrived as `AbortError` and all
classified as `Cancelled` — "you cancelled this" for a deadline nobody set by
hand. An abort REASON survives fetch's rejection verbatim (captured across
Node, Bun and the three engines), so each keeps its identity: ours is a
`TimeoutError` naming the ceiling, the caller's is forwarded untouched. The
same change removes the abort listener on cleanup, which was harmless when
there was one attempt and a leak that grows once there are three.

**Events stay honest, and `error` still means what it always meant.** The
first cut of this tier made `request` and `error` fire per ATTEMPT. Honest
about what happened — and it silently redefined `error`: before retries, an
error-event count equalled failed CALLS; after, a consumer saw
`error, error, response` and could not tell "failed, retrying" from "failed,
terminally" at any prefix. That is a semantic change to an event every
existing consumer already had a handler for.

So the vocabulary gained a word instead. `request` stays per attempt (a
request truly went out). A failed attempt that will be tried again emits
**`retry`**, carrying the same normalized `ShipError`, the URL, and the
attempt number. `error` is terminal and fires exactly once per failed call —
including a mid-backoff abort, which ends the call. `response` fires once, on
the attempt that worked. Nothing is hidden and nothing is ambiguous:
`retry* (error | response)`.

Corrected on the beta channel in `2.2.0-beta.7`, two prereleases after the
shape it replaces (`beta.5`, `beta.6`). The tests asserting three `error`
events were pinning that shape, and were rewritten rather than relaxed.

**The resource says what may be repeated; the transport never infers it.**
`ShipRequestInit.idempotent` is set by the resource that composed the request,
because only it knows: a fill is safe to repeat whatever it carries, and a
declaration is safe only with a key. The transport's rule is one line, "GET,
HEAD, or idempotent", and it reads no header and matches no path, so a request
cannot become retryable by carrying a header nobody meant as a mark. The field
is this client's own word and is stripped before `fetch` sees the init.
Fenced in `http-retry.test.ts` ("the idempotent request option").

**Surface:** `maxRetries` on `ShipClientOptions`, `0` disables. One knob, the
name Stripe and OpenAI use, which by this file's own doc-placement rule is
what earns it public README surface. No env var and **no CLI flag** (recorded
absence — the CLI rides the default; add a flag the day someone asks).

No `tests/contract.ts` rows: retry is client POLICY, not a wire fact. The API
is not promising to fail twice.

### Ephemeral deployments (`--ttl`)

**The platform was already asking how long a deployment lives; the request may
now answer it.** The API's entitlement answered from IDENTITY (anonymous → 3
days, authenticated → forever) and the orchestrator stamped that verbatim; a
requested `ttl` overrides only the authenticated `null`. This half of the
feature is deliberately thin — when the wire owns the fact, the SDK mirrors it
1:1 and the CLI declares one flag into machinery that already knows what to do
with it.

- **SDK:** `deploy(path, { ttl })` in SECONDS, validated at the request
  boundary by `validateTtl` from `@shipstatic/types` beside `validatePassword`
  and `validateLabels`, appended as `DEPLOY_FIELDS.TTL`. A duration, never an
  instant: the server owns time.
- **CLI:** `--ttl <duration>` on `withDeployOptions`, so the flag law supplies
  both spellings, the misapplication refusal, and the completions with no
  further code.
- **One parser, two commands.** `parseTtl` accepts bare seconds or `<n><unit>`
  (`s`/`m`/`h`/`d`), and `tokens create --ttl` moved onto it — one word, one
  grammar, everywhere. The parser owns the SPELLING and nothing else;
  `validateTtl` owns the RANGE, which is what makes the refusal identical from
  the CLI, the SDK and the API. The wire only ever carries a number.
- **`ttlOf` reads the EFFECTIVE options, and that is load-bearing.**
  `tokens create` declared `--ttl` alone and correctly read `cmdOptions.ttl`
  until 2026-08-12. The moment the deploy shortcut — which IS the program —
  declared the same flag, Commander's root began consuming it and that read
  went silently undefined: every `ship tokens create --ttl 1h` would have
  minted a permanent token. One helper, one source, for both commands. See
  "Two flag tiers".

**Two preflights, both before a byte is read**, and they are the two deploy
flags that promise something past the upload:

- **No credential → statusless `Config`** carrying `CREDENTIAL_HINT`, the
  `--domain` arm's shape verbatim. An anonymous deployment already expires on
  the platform's schedule, and discovering the refusal after the upload would
  have minted a public, expiring, claimable deployment as the side effect of a
  failed authenticated intent.
- **With `--domain` → statusless `Validation`.** A domain is a commitment and
  a deadline is its opposite; the API refuses to link any deployment with a
  non-null `expires`, so the combination cannot succeed and the only question
  is whether the user pays for an upload first.

**Rendering needed nothing.** `formatValue` already humanizes `expires`
through `formatTimestamp`, so the details block shows the expiry with zero new
code. The claim CTA's *relative* phrase ("expires in 3 days") is gated on
`claim`, which a credentialed ttl deploy never carries — reusing it would have
ADDED a second duration phrase rather than sharing one.

**Recorded absences** (decisions, not gaps):

- **No `SHIP_TTL` env var.** The CLI-only env tier exists for values a
  subprocess wrapper cannot put on argv (secrets, ambient keys). A duration
  rides argv fine — the `SHIP_DOMAIN` precedent.
- **No ttl mutation.** A deployment is an immutable artifact with labels as
  its only mutable annotation. To keep something longer, redeploy; deploys are
  cheap and idempotent replay makes them cheaper. The claim flow remains the
  only writer of `expires` after create.
- **No ttl on `domains set`** — a link is not a lease.
- **A replay answers the deployment the key names, as it stands**, with the
  `expires` its commit stamped; a replayed request's ttl is not consulted.
**A CLIENT is a fixture too.** The anonymous refusal has a `tests/contract.ts`
row with a LIVE half, and getting there corrected a reading: "a runner supplies
one client" sounded structural and was not — a credential-less `Ship` is
exactly the kind of thing the context already guarantees, so it sits beside the
ids. The live half is the point, since only it can catch the API dropping the
guard, and one `if` in the orchestrator is all that holds a coherence rule the
platform designed deliberately (the claim window is pinned to the anonymous
lifetime). It costs one slot of the anonymous issuance budget per run, recorded
in the row so several e2e runs in an hour read as a budget, not a contract.

That fixture also hardened the suite: `tests/setup-e2e.ts` now scrubs
`SHIP_TOKEN`, because the SDK reads it and a developer with one exported would
have run the whole live suite as that account — and made the anonymous fixture
quietly authenticated, turning a refusal row into a pass.

**And a fence limitation worth knowing.** The docs contract asks whether a
flag NAME is taught, not whether each command teaching is present — `--ttl`
was already documented for `tokens create`, so the fence stayed green when the
deploy gained it. It is not the first catch this flag was expected to be, and
a per-command check would need the docs to name commands beside flags, which
they do not. The deploy docs were written deliberately instead.

### The bundle boundary

**What this package bundles and what it asks a consumer to install is one
line, and it is fenced in three directions** (`tests/package/bundle-boundary.test.ts`,
an artifact tier — it reads the BUILT bytes, because what a bundle requires is
a property of the bytes and not of the config that produced them):

1. `dist/cli.cjs` requires **nothing beyond node builtins**;
2. every bare specifier the SDK entries require is a declared dependency;
3. every declared dependency is reached by some entry — the direction that
   catches rot;
   plus the config half: every tsup external names a declared dependency.

`dependencies` is now the SDK's three — `zod`, `spark-md5`, `junk`. It was
five until 2026-08-12; see "One deploy body" below for why the multipart pair
left. The CLI's four (`commander`,
`columnify`, `yocto-spinner`, `yoctocolors`) are devDependencies bundled into
`dist/cli.cjs`, so an embedded SDK consumer — the MCP, and through it the
vscode `.vsix` — no longer installs four packages it never executes, and an
`npx @shipstatic/ship` cold-run no longer downloads them.

The list had rotted in BOTH directions before the fence existed: `tsup.config.ts`
named `cosmiconfig` and `cli-table3` as externals for two majors after both
were deleted with 2.0, and aliased a build shim for a package that no longer
existed (`build-shims/empty.cjs` STAYS — it shims node builtins for the browser
bundle). Nothing was wrong at runtime, which is exactly why it survived: a dead
external is invisible until someone reads the file.

**And the fence carries its own counterexample, which it runs every time.**
The hand drill meant to prove this one planted NOTHING, twice (2026-08-12): a
grep matched the dead external's name in a *comment* rather than the array,
and on the second try biome collapsed the array back. Both times the suite
stayed green and the fence was one step from being reported as proven. **A
hand drill is evidence that evaporates** — nobody reading a green suite later
can see it happened — so the three checks are now functions of their inputs
(specifier list, declared dependencies, externals), and a `the checks can see
a defect` block feeds each one a synthetic defect of its own class and asserts
it is NAMED: an escaped specifier, a dead external spelled `cosmiconfig` and
`cli-table3`, an unreached dependency. Each row also passes a clean input, so
a check that "caught" everything would fail too, and a fourth row proves the
extractor sees real bytes at all — `dist/cli.cjs` being empty is an assertion
here, so an extractor that always returned `[]` would satisfy the fence's
headline check and every direction under it.

Synthetic rather than planted, deliberately: a plant has to survive a
formatter, a grep and a reviewer, and this one survived none of them. This is
the transport wave's planted-impossible-input applied to a check instead of a
runtime — and it generalizes. `docs-contract`'s taught-ness matcher got the
one-line version of the same treatment (`isTaught('--flag-that-cannot-exist')`
must answer `false`), because a matcher answering `true` for everything makes
"every flag is taught" unfailable while reading exactly as it does now. The
deploy-rules tables need neither: their completeness ties are structural (the
test's own lists are asserted equal to the production tables, in order, in
`@shipstatic/types`), so a second plant there would be ceremony.

**Bundling carries licence obligations, so the notices ship.**
`scripts/third-party-licenses.cjs` writes `THIRD-PARTY-LICENSES.md` at build
time, and the list is **derived from esbuild's metafile** rather than written
down — transitive dependencies included, which a hand list misses on day one
(five of the fifteen are transitive). A bundled package whose notice cannot be
found fails the build. It does not read `require.resolve('pkg/package.json')`:
a modern `exports` map does not expose it, which fails for exactly the packages
most likely to be bundled.

**And a derivation is only as honest as its inputs, which this one paid for
(2026-08-12, the stable gate).** tsup names its metafile
`metafile-{format}.json` inside `outDir`; the three build configs share one
`outDir` and tsup runs them CONCURRENTLY — so `index` and `browser` both wrote
`metafile-esm.json`, and `index` and `cli` both wrote `metafile-cjs.json`. Two
writers per path. Measured over 24 builds: **about one in nine under CPU load**
ended with a half-overwritten file that `JSON.parse` rejected, failing the
build — and `prepack` is `build`, so a publish inherited the coin flip.

**The crash was the lucky outcome.** The quiet one is a metafile that survives
intact while describing the wrong bundle: the licence list is then derived from
a SUBSET of what the artifact bundles, the notice under-reports, and the build
exits 0. That never happened only because the CLI bundle is a superset of the
index bundle — an accident of this package's shape. The index bundle's own
metafile was discarded on **every** build for the whole life of the collision,
and nothing noticed, because nothing was looking.

Two changes, and the second is the one that generalizes:

- `tsup.config.ts` writes one metafile per `(entry, format)` via a small
  `esbuildPlugins` hook, into `build-meta/` — outside `dist`, since a metafile
  is build telemetry rather than product and `files: ["dist", …]` had been
  publishing 78 KB of them to every consumer.
- `tests/package/licence-coverage.test.ts` declines to trust that. It asks
  whether every bundle the package **ships** is described by some metafile,
  reading the shipped set from `package.json`'s `exports` + `bin` — the
  published artifact's own account of itself, **not** a restatement of the
  build config that produced it. A fourth entry is covered the day it ships,
  and the same check inside the script fails the build directly. Both drills
  were watched failing: a missing metafile turns the fence red and names
  `dist/index.cjs`, a corrupt one exits 1 with a message instead of a stack.

The spinner keeps its dynamic `import()`. Bundled, that no longer defers a
package install, but it still defers evaluating the module on the runs that
never show a spinner (`--json`, `-q`, non-TTY), which is most CI runs.

**One deploy body, and the multipart pair retired (2026-08-12).** There were
two `deploy-body.ts` files, and everything that mattered was identical: the
same fields appended in the same order. The Node one carried twenty extra
lines because `formdata-node`'s objects are not the ones undici's `fetch`
knows how to encode, so it serialized through `form-data-encoder` into an
ArrayBuffer with a hand-computed `Content-Type` and `Content-Length`.

That was a real constraint on a Node without a global `FormData`, and
`engines.node >= 20` has had global `FormData`, `File` and a
multipart-encoding `fetch` all along — the constraint had lapsed before the
code was written. One shared builder in `shared/core/deploy-body.ts`, both
platform files deleted, both packages out of `dependencies` and out of the
tsup externals.

**And the seam it left open closed on 2026-08-12.** `getDeployBodyCreator()`
was an abstract method on the base class feeding a `createDeployBody` slot on
`ApiHttpOptions` — and once one builder served both platforms, the two subclass
overrides were the same four words. A seam with the same answer on both sides is
not a seam. The abstract method, the option, the private field and the
`DeployBodyCreator` type are gone; `deploy` imports the builder.

The tests improved by losing it. Four files injected a `FormData`-returning
stub, and `http.test.ts` made its stub a `vi.fn` so the deploy CONTEXT was
assertable — one seam short of the claim, since the API reads FIELDS. With
nothing left to inject, `via` and the captcha proof are read back off the real
`FormData` the transport handed to `fetch`.

**This section previously recorded the swap as out of scope** — "an SDK
behaviour change with its own compatibility questions, a separate decision,
not a bundling detail". Separate was right and it got its own wave; what the
note did not say, and what a reader took from it, is that something GATED it.
Nothing did.

Two things earned it rather than assumed it. The behaviour was captured on
both runtimes this SDK targets — node 22 and bun 1.3, the latter because
`plan-ship-native-binary.md` compiles a Bun binary — and both encode a native
`FormData` with `filename="assets/nested/index.html"` intact, which is the
whole contract: the deploy PATH rides `File.name`. And the built artifact was
posted at a real socket, where the paths, the index-aligned checksums and the
metadata fields all arrive as before.

The creator returns a bare `FormData` now, not `{ body, headers }`: `fetch`
sets the boundary and the `Content-Type`, so a caller that composed headers
was doing the runtime's job. The 400-line Node test went with it — it mocked
both packages and asserted on `append` CALLS, where a real `FormData` can be
READ BACK, so the one replacement asserts the artifact the API receives.

### Two flag tiers, and one place each is read

**Global flags** — `--token`, `--config`, `--api-url`, `--json`, `-q/--quiet`,
`--no-color` (plus Commander's `-h`/`-V`). Identity and channel: they mean the
same thing on every command and parse anywhere. **Command-owned flags** —
everything else. `GLOBAL_FLAGS` in `index.ts` is the whole statement; the
command-owned set is its complement, so a flag joins the law by being declared
and there is no second list to keep in step.

**A flag parses only where it means something.** The deploy shortcut IS the
program, so its flags (`--domain`, `--label`, `--password`, the two `--no-…`
detections) must be declared at program level — and Commander then recognises
them in front of every other command, where nothing reads them.
`ship --domain www.x.com domains list` parsed cleanly and dropped the domain;
the user who typed it believed they had linked one. Silent-swallow is the worst
of the three possible answers. `assertFlagsApply` (the `preAction` hook, beside
the token/URL validation) now refuses before any action runs: a command-owned
flag that was actually TYPED must be declared by the command about to run. It
names the flag and — read from the tree, never restated — the commands that own
it. Statusless `Validation`, per "What a status means".

Two mechanical points, both load-bearing:

- **Presence comes from the SOURCE, not the value.** `getOptionValueSource(key)
  === 'cli'`. `--label` defaults to `[]` and `--no-path-detect` to `true`, so a
  truthiness test would refuse every subcommand always.
- **POSITION is deliberately not part of the law.** `ship --label x deployments
  upload ./dist` is honoured. Commander stores it identically to the canonical
  spelling, so telling the two apart means scanning raw argv beside the parser
  — a second parser, refused on the same grounds as a second copy of the
  command tree. The defect worth money was misapplication, and that is closed.

**One rule, every source — the API URL.** `validateApiUrl` ran only in the
`preAction` hook, which sees the FLAG and nothing else. So one value got two
verdicts by how it arrived: `ship --api-url https://api.example.com/v1` was
refused with the constitution's authored sentence, while the same value saved
into `.shiprc` or exported as `SHIP_API_URL` was accepted and produced
transport failures on every command afterwards — with nothing connecting the
symptom to the cause. Measured against the real binary, all three sources,
before the fix.

**The sources reach one verdict where they become one value**, so the check
sits in `createClient` after `mergeCliConfig`. The `preAction` check stays: it
is the flag's fast-fail and a better moment to fail. Two call sites of one
rule at two tiers is the dual-validation idiom, not a second statement — the
rule has one owner, `@shipstatic/types`.

**CLI TIER ONLY, deliberately.** The `Ship` constructor stays loose because an
embedded consumer may legitimately pass an unroutable `apiUrl` — a Cloudflare
service binding dispatches by binding identity, so `apiUrl: 'https://api'` is
correct there and is documented in the README. This is the CLI deciding what a
person may write in a config file, which is a different question.

Fenced in `create-client.unit.test.ts` rather than through the CLI harness,
for a mechanical reason worth knowing: **the harness injects
`--api-url <mock server>` into every invocation that does not carry one**, and
a flag beats both other sources by design — so no harness-driven test can ever
exercise the file or env path. That is also why the gap survived a suite with
a dedicated file for parse-time URL checks.

**And one place each flag is read.** Commander's root consumes any option the
ROOT declares, wherever it sits in argv — so a deploy flag typed *after*
`deployments upload` still lands on the program, and that subcommand's own
declaration (which exists so its `--help` and its completions are accurate)
never receives a value. Hence:

- A flag declared ONLY on its command (`--limit`, `--cursor`, `--ttl`) arrives
  in that action's own `cmdOptions`.
- A flag the program declares too arrives in the EFFECTIVE options —
  `processOptions(command)` → `optsWithGlobals()`, parent winning — which is
  what every handler already receives as `options` (`EffectiveOptions`).

Read one place per tier; never arbitrate between them. Three helpers
(`mergeLabelOption` / `mergePasswordOption` / `mergeDomainOption`) and four
call sites did that arbitration by hand until 2026-08-12, and they were not
belt-and-braces — they were the ONLY thing making `ship deployments upload
./dist --label x` work, which is why the merge cannot simply be deleted in
favour of `cmdOptions`. What survives is `labelsOf` (the `--label ''` clearing
rule) and `passwordOf` (the `SHIP_PASSWORD` tier), each reading one source.

`EffectiveOptions` is **wider than any one command's truth** — `domains set`
receives a type carrying `domain` and `password` — and that is recorded rather
than fixed. It is unreachable, not merely unset: the guard refuses such a flag
before the handler exists. Narrowing per command (`GlobalOptions &
LabelOptions` at each site) was rejected because it would be a second statement
of that command's flag set with nothing checking it against the `.option()`
calls — the restatement class this file removes everywhere else.

**The owner list in the refusal includes the SHORTCUT.** `ownersOf` walks from
the command it is given, so the program is surveyed too, and `pathOf` renders
the root from its own `registeredArguments` (`ship <path>`). Skipping it was
the first version's defect, and a self-contradicting one: `assertFlagsApply`
exempts the program *because* it owns these flags, while the survey ten lines
down could not say so — sending the reader to `ship deployments upload` when
`ship <path> --domain …` is the spelling every doc leads with.

Required Commander boilerplate is unchanged: `.enablePositionalOptions()` on
parent groups, `.passThroughOptions()` on subcommands taking a positional
followed by flags.

**The bug this law was written over.** `--no-path-detect` and `--no-spa-detect`
were declared as `noPathDetect` / `noSpaDetect` and read under those names —
but Commander stores the POSITIVE key for a `--no-x` flag, defaulted `true`. So
both flags parsed cleanly and did **nothing**, in both deploy spellings, for as
long as they have existed. The test that let it live asserted `exitCode === 0`,
which a dead flag also produces. They are fenced now through `config` — the
API sets it from a `ship.json` at the deploy ROOT and the mock derives it the
same way, so `--no-spa-detect` (the SDK sends the opt-out, so the platform writes no config) and
`--no-path-detect` (a nested `dist/ship.json` never reaches the root) are both
visible through a wire field rather than through a probe. Fixtures:
`tests/fixtures/spa-site`, `tests/fixtures/nested-site`.

## SDK-Local Types

`DomainSetResult` is the published return shape of `domains.set()` — `Domain` plus an `isCreate` flag derived from HTTP 201 vs 200. It lives in `@shipstatic/types` (alongside `Domain`) so the resource interface return type matches the SDK's actual return value.

`EnrichedDomain extends DomainSetResult` — adds optional `_dnsRecords` and `_shareUrl` for CLI display. `EnrichedDomainVerify extends DomainVerifyResponse` adds `_deployment`, the domain's linked deployment as read once after the verify was queued (null when nothing is linked, absent when that read failed), which is what lets `ship domains verify` name the one step left. It carries the LINK fact rather than the domain's `status` for a reason recorded at both the type and the formatter — see "One step per standing". `CLIResult` is the discriminated union of all possible command outputs. All in `src/node/cli/types.ts`.

## Testing

```bash
pnpm test --run          # unit + integration (e2e and browser are NOT in the default run)
pnpm coverage            # the same suite, plus the ratchet — what CI runs
pnpm typecheck           # tsc over src AND tests, 0 errors
pnpm check:package       # publint + attw over the built artifact
pnpm test:browser        # capability tier — real Chromium via playwright (CI runs it)
pnpm test:e2e --run      # REAL API; refuses to load without SHIP_E2E_API_KEY
```

| Pattern | Project | Mock server |
|---------|---------|-------------|
| `*.unit.test.ts` | `unit` | No |
| `*.test.ts` | `integration` | Yes — per file, ephemeral port |
| `tests-browser/*.test.ts` | `browser` | No — real Chromium, pure browser primitives |
| `*.e2e.test.ts` | `e2e` | No (a real API) |

**EVERY test tier is typechecked.** `pnpm typecheck` runs `tsconfig.check.json`
over `src`, `tests` and `tests-browser` together. This is the load-bearing one:
vitest transpiles through esbuild WITHOUT checking types, so until 2026-07-27
nothing checked the test tree — which is what let an `apiKey` constructor option, a
`flattenDirs` deploy option, a `basePath` that never existed, a
`findCommonParent(paths, separator)` overload that never existed, and
`processFilesForNode` called with four arguments all sit there passing. The
fixtures' `satisfies` clauses were decorative until this gate existed.

**`tests-browser/**` was left out of that repair and spent the next fortnight in
exactly the condition it describes.** Two of the four projects — `e2e` and
`browser` — are absent from `pnpm test`, which makes typecheck the ONLY local
gate with any reach into them. On 2026-08-12 the deploy-body consolidation
deleted `src/browser/core/deploy-body.ts` and left a test importing it: every
local gate green, `pnpm test:browser` red in CI, four tests not-run rather than
passing for a fortnight. A tier outside the typecheck gate is a tier where
deleting a module is silent. The include list is "all of them" now, which is the
only shape that cannot rot the same way, and both halves of that defect were
drilled against it — the deleted module (TS2307) and the retired `{ body }`
shape (TS2339).

### Hermeticity

`tests/setup.ts` loads for BOTH in-process projects and enforces two
invariants that used to be convention:

- **No ambient credentials** — every `SHIP_*` var is scrubbed at load, so a
  developer's exported `SHIP_TOKEN` cannot authenticate the suite's
  "anonymous" paths.
- **No outbound network** — `fetch` is wrapped and THROWS, naming the URL, for
  any host that is not loopback. `DEFAULT_API` is production, so a missing
  mock route or a forgotten `apiUrl` would otherwise reach it.

The child-process CLI tier builds its environment from an **allowlist**
(`tests/node/cli/helpers.ts`) — `PATH`, `TMPDIR`, a throwaway `HOME`,
`NO_COLOR`, `CI`, and nothing else. A blocklist only removes what someone
thought of: `FORCE_COLOR=3` (iTerm's default) used to reach the child and turn
sixteen exact-output tests red on a developer's machine while CI stayed green.
Notably `SHELL` is absent too, so shell detection is a test input rather than a
property of the machine. The same file refuses to run against a **stale
`dist/`** — those tests execute `dist/cli.cjs`, which `pnpm test` does not
build.

### The four fences

`tests/architecture/` holds suite-time invariants. Each catches a class the
others cannot:

| Fence | Catches |
|---|---|
| `test-integrity.test.ts` | A test file that reaches NO production code — the tautology class. A tautology neither raises nor lowers coverage, so no ratchet can see it. Reach is resolved TRANSITIVELY through local test-support modules (`./harness`, `../mocks/…`) but importing only fixture builders does not count — builders pull in `@shipstatic/types`, never `src/`. Its only exceptions are the two artifact tiers (`smoke.test.ts`, `package/dist-entries.test.ts`), each recorded with a reason. |
| `test-naming.test.ts` | Layout drift: a filename that describes the test instead of its subject, a mirror file with no `src/` counterpart, an aspect split not recorded in this file. |
| `docs-contract.test.ts` | Drift between the PUBLISHED docs and this code — a command or flag the docs teach that does not exist, a command that exists and no doc teaches, an SDK member or response key the docs name that `@shipstatic/types` dropped. Also the curated front page: a command missing from `helpText()` must be recorded in `HELP_OMISSIONS` with a reason. |
| `coverage.thresholds` | Coverage decay. A ratchet — it only goes up. Global bar plus per-glob floors for the three files whose residual gaps are named in `vitest.config.ts` (bin block/spinner/SIGINT are smoke-proven; browser env arms are browser-tier-proven). |

**A published doc is API surface, not prose.** `package.json` publishes
`README.md`, and for an agent a published doc IS the API surface: the file read
before a command is typed. The package ships no skill of its own: ShipStatic's
one skill for agents, `publish-online`, lives in the coding-agent plugin
(`integrations/plugin`) and is served at www.shipstatic.com/SKILL.md, and it
teaches this CLI's no-account publish. Two skills under one name, one of them
a full CLI reference, would leave an agent unable to tell which is
ShipStatic's.

The fence reads the docs named in `files`, so **shipping a new markdown file is
what puts it under contract** — there is no list to update. Its one deliberate
omission is `helpText()`: a hand-curated front page (see "Two help scopes")
byte-pinned by the smoke tier, where leaving a command out is a design choice.
Reference documentation makes no such claim.

### The mock server

`tests/mocks/` is a hand-maintained twin of `cloudflare/api`, in three parts:
`handler.ts` (one Web-standard `handleApiRequest(request, state)`),
`state.ts` (per-instance state from a factory), and `server.ts` (a thin
`node:http` adapter plus lifecycle).

Three mechanisms keep it honest, and they cover different halves:

| | Holds | Runs |
|---|---|---|
| `satisfies` on every body | the response SHAPES | `pnpm typecheck` |
| `tests/contract.ts` | the BEHAVIOUR — status, typed error, guard order | CI (mock half) + opt-in (live half) |
| `// wire:` citations | where to look when one of the above fails | a reader |

**The contract table is the one that closed the real gap.** Shapes were tied to
the published types on 2026-07-29, but behaviour was tied to nothing: a route
flipping 202 to 200 leaves every citation reading exactly as before, and ~1000
tests green. The e2e suite was nominally the detector — its header even claimed
it "asserts the same contract points the mock encodes" — but nothing checked
that claim, so they were two hand-maintained lists with no tie. Now the points
live once in `tests/contract.ts` and two runners consume them:
`tests/contract.test.ts` (mock, in CI) and the `wire contract` block of
`tests/e2e/smoke.e2e.test.ts` (real API, opt-in). Both observe through
PUBLISHED surface only — a success's status off the `response` event, a
failure's off the `ShipError` — which is what lets one table drive both.

**What each half can say**, stated because the difference matters: the mock
half proves the mock encodes the table; only the live half can prove the table
matches `cloudflare/api`. So CI catches a mock that drifts from the table, and
a live run catches a table that drifts from the API. **Run `pnpm test:e2e`
before a release, and after any API change.**

Rows the e2e tier must not run carry their reason as a string instead of
`live: true` (`NO_DOMAINS`, `NO_TOKENS`), so the coverage gap is stated in the
table rather than inferred. That gap always existed; it was simply invisible.

**CI catches API drift on a schedule.** `.github/workflows/e2e.yml` runs
`pnpm test:e2e` daily, plus `workflow_dispatch` for a hand run. It is LIVE only
once the operator sets two repository secrets — `SHIP_E2E_API_KEY` (a
dev-account API key) and `SHIP_E2E_API_URL` (the dev API URL). Both are
required: without the URL the harness defaults to PRODUCTION, and a scheduled
run must never surprise-target prod. An unconfigured run emits a `::notice` and
passes, so its verdict reads "unconfigured" rather than "broken".

**It does NOT gate `publish`, and that half of the original decision stands
unchanged**: the dev API is deployed by hand and may legitimately lag this
repo, so a red contract run means "these two disagree", not "this release is
wrong". It is a separate scheduled workflow that reports, never a `needs:` of
anything — and it lives in its own file, because `ci.yml`'s filename is half
the npm trusted-publisher registration.

**What changed was the other half.** This section read *"deliberately not wired
today — inert config rots"* until 2026-08-12, and two things that day moved the
balance. The wave-closing e2e run failed TWICE on the suite's own staleness — a
27-char label against a 25-char rule, and a stale typed-404 belief about async
deletion — which is what *an unrun suite reports nothing, including about
itself* looks like in practice. And the ttl wave added contract rows whose live
halves exist precisely for drift only a live run can see. A `schedule:` answers
"inert config rots" by construction: a cron runs whether or not anyone
remembers it.

Two mechanical notes. The failure notice sits in its own job under
`permissions: {}`, because the e2e job carries a real API credential and the
alerts webhook must never sit beside it (root `CLAUDE.md`, the
one-job-one-credential law and "CI notifications"). And a burst of hand dispatches can flip the
anonymous-ttl row's `Forbidden` into a `RateLimit`: each run spends a slot of
the dev API's anonymous budget, since the limiter
runs before the orchestrator refuses. The daily cadence is far inside it; that
row's own comment in `tests/contract.ts` carries the signature.

### Testing canon

The cohesion contract. A change that breaks one of these needs a recorded
exception, not a workaround:

1. **Pure lib tests mock nothing.** `md5`, `path`, `deploy-paths`,
   `validation`, `junk` run the real thing against real inputs.
2. **SDK-level tests run a real `Ship` against the wire-truth handler.**
   Internal module mocks only with a recorded reason — today exactly one:
   `node:readline/promises` in `config.test.ts`, because stdin is the one
   collaborator a test cannot supply for real.
3. **A mock may only mock exports that exist** — the typecheck enforces it.
4. **Raw `fetch` stubbing only where transport IS the subject** — the
   `http*.test.ts` family. Everywhere else, inject the `fetch` option (a
   published contract) or talk to the mock server.
5. **Builders are the only fixture source** (`tests/fixtures/builders.ts`),
   and they take explicit timestamps — no `Date.now()` in an asserted value.
6. **Every mock route cites its `cloudflare/api` wire truth**, `satisfies` its
   published response type, and has its status / typed error / guard order
   stated in `tests/contract.ts` — a citation says where to look, the type
   holds the shape, the table holds the behaviour.

The e2e harness var is deliberately named `SHIP_E2E_API_KEY` — it names a
literal API key, the CI secret name is unchanged, and it is not part of the
SDK's env contract (`SHIP_TOKEN`). Don't rename it during credential sweeps.

**Tests run in parallel.** Each file gets its OWN mock server on an ephemeral
port and its OWN state (`tests/mocks/server.ts`), so no file can observe
another's writes and no two can contend for a port. This replaced a
"never parallelize" rule whose reason — one shared server on a fixed 13579 —
no longer exists; the law changed with its reason, not despite it.

**Recorded aspect splits** — one subject, more than one mirror file. Legal
under the layout law (`<module>-<aspect>.test.ts`), listed here because the law
requires the aspect to be recorded — and since 2026-07-27 that recording is
mechanical: `tests/architecture/test-naming.test.ts` fails the suite if a split
is not named here by full basename.

| Module | Files | Why |
|---|---|---|
| `src/shared/api/http.ts` | `http`, `http-anonymous`, `http-events`, `http-rate-limit`, `http-retry`, `http-timeout` | The transport's cross-cutting concerns, one file each. `http.test.ts` is the anchor (stubbed `fetch`); the rest drive a real `Ship` against the wire-truth handler. It was three files longer until 2026-08-12 — `http-domains`, `http-tokens` and `http-browser` named halves of an endpoint table that has since folded down into `resources.ts`, and they moved with their subject. |
| `src/shared/base-ship.ts` | `base-ship`, `base-ship-credentials`, `base-ship-lifecycle`, `base-ship-limits` | Three separable doctrines on one class: the credential slot, the init/auth lifecycle, and `getLimits`, which asks the server every time. |
| `src/shared/resources.ts` | `resources-account`, `resources-deployments`, `resources-domains`, `resources-paths`, `resources-tokens` | One file per resource factory — a single file would be a grab bag with no reason to read any part of it — plus `resources-paths`, which is not a resource but the one thing none of them can assert: the URL each builds. The four resource files drive a real `Ship` against the wire-truth handler, and a mock server that ROUTED a request has already forgiven whatever the client did to the URL on the way in, so path encoding and pagination serialization need a stubbed `fetch` and a string comparison. |
| `src/shared/types.ts` | `types-reexport` | Not a test of the module's own types but of the **freshness** of what it re-exports — a bundled-dependency fence. |

**Recorded feature-axis files** — no single subject module, so the mirror rule
cannot apply. The list lives in the naming fence; adding to it is a decision.
Today: `unknown-commands` (the CLI's error surface), `validation` (parse-time
credential/URL checks), `smoke` (the true-binary tier), and `e2e/smoke.e2e`.

### Commander

`commander@^14` — deliberately NOT 15: Commander 15 (2026-05) is ESM-only
and requires Node ≥22.12, incompatible with the CJS `dist/cli.cjs` bin and
the `engines >=20.19.0` consumer contract. Revisit when dropping Node 20 /
going ESM-only is decided (a consumer-contract call, like the engines pin
itself). The floor moved from `>=20.0.0` on 2026-08-12 and the conclusion here
is unchanged — 22.12 is above both — but note the shape of what moved it: an
ESM-only package reached by `require()` is exactly the hazard this pin avoids,
and the SDK's own `dist/index.cjs` still has one in `junk`.

**Two help scopes, one machinery.** The ROOT renders the hand-written front
page (`helpText()` — the kept design, byte-pinned in the smoke tier): that is
what `ship`, `ship --help`/`-h`, and `ship help` show. Subcommands render
Commander's NATIVE scoped help (`ship domains --help`,
`ship help deployments`, and `--help` beside a missing argument), which knows
each command's exact usage and options. The split is one conditional in
`configureHelp.formatHelp` — root → `helpText()`, else →
`Help.prototype.formatHelp` (the escape past our own override). Riding the
built-in `-h, --help` option and explicit `help [command]` command means no
help route ever resolves credentials or reads a config file. Short forms
`-h`/`-V` are supported (user decision 2026-07-27).
`allowExcessArguments()` is a recorded opt-out of v13+'s excess-arguments
error: unknown subcommands must reach `handleUnknownSubcommand` as excess
args to get scoped usage. Option arguments that parse (`--ttl`) throw
`InvalidArgumentError` — the docs' canonical pattern; a bare `parseInt` once
turned `--ttl abc` into `NaN` on the wire.

### The CLI's two tiers

`src/node/cli/index.ts` exports `buildProgram()` — a factory returning a
fresh Commander tree (instances are not reusable across parses). The tree
**never calls `process.exit` on the command path**: outcomes land in
`process.exitCode` or ride a thrown `CommanderError`, and the bin path ends
naturally when the event loop drains, so buffered stdout can never be
truncated on a pipe. The one recorded exception is `performDeploy`'s SIGINT
handler (`process.exit(130)`) — Ctrl+C must terminate *now* with the shell's
128+SIGINT convention, and an interrupt is precisely the case where waiting
for the loop to drain is wrong. That design is what makes the tree drivable
in-process:

- **In-process tier** (`index.test.ts`, `unknown-commands`, `validation` via
  `tests/node/cli/harness.ts`): drives `buildProgram()` directly, so V8
  coverage sees the command tree — a subprocess is invisible to it, which is
  how 917 lines once read 0% while being "tested". The harness pins a
  deterministic colour environment (`NO_COLOR` on, `FORCE_COLOR` cleared) —
  the in-process tier reads the same `process.env` the child tier once leaked.
- **Child-process smoke tier** (`smoke.test.ts`, the ONE file that spawns
  `dist/cli.cjs`): proves what only a real binary can — byte-exact help, exit
  codes, stdin piping, colour responding to the launch environment, the
  stdout surviving a pipe intact, and the completion round trip — install
  through the real binary, then `bash -n` / `zsh -n` / `fish --no-execute` over
  what it wrote, because a real shell is the only thing that can say the
  rendered script parses.

### The browser capability tier

`tests-browser/` (the analog of the backend's `tests-workerd`): a small suite
run on **real Chromium** via `pnpm test:browser` (vitest browser mode,
playwright provider). It certifies what jsdom can only approximate — native
`getENV()` detection with no test override, `webkitRelativePath` as Chromium
defines it, real `File`/`FormData` bytes through `createDeployBody`, and
spark-md5 digests (same published vectors as the Node tier, deliberately: the
manifest's digests are what R2 checks every put against, so both runtimes must
agree), and the planner's serialized count against Chromium's own `FormData`
encoder (`deploy-plan.test.ts`). No coverage
coupling; separate CI step with `playwright install chromium`.

```
tests/
├── contract.ts       # The wire facts ship depends on — stated ONCE
├── contract.test.ts  # …run against the MOCK (CI). Its twin is in e2e/
├── architecture/     # Fences: integrity, naming, docs contract
├── browser/ node/ shared/   # Mirror axis — tests/<path>/<module>.test.ts
├── node/cli/completions.test.ts  # The rendered shell scripts (mirror of completions.ts)
├── node/cli/harness.ts      # In-process CLI runner (buildProgram + capture)
├── node/cli/smoke.test.ts   # The ONE child-process file (dist/cli.cjs)
├── package/          # The BUILT artifact (dist entries)
├── e2e/              # Real API, opt-in — runs the SAME contract table
├── fixtures/builders.ts     # Typed builders — the only fixture source
├── mocks/            # handler.ts + state.ts + server.ts
├── setup.ts          # Hermeticity (both in-process projects)
└── setup-server.ts   # Mock-server lifecycle (integration only)
tests-browser/        # Capability tier — real Chromium (pnpm test:browser)
```

**When the API changes:** update `@shipstatic/types` → follow the `// wire:`
citations in `tests/mocks/handler.ts` → `pnpm typecheck` guides the rest.

## Adding New Features

**New SDK method:** `@shipstatic/types` (interface) → `resources.ts` (the
endpoint: path, verb, body, response type) → fixture → tests.

Three steps, not four. It was `types → api/http.ts → resources.ts → …` until
2026-08-12, and the middle pair was one fact written twice — see "One layer,
not two".

**New CLI command:** `cli/index.ts` (command + `withErrorHandling`) → `cli/formatters.ts` (formatter if needed) → `cli/types.ts` (`CLIResult` union if needed) → tests.

**New shared utility:** `src/shared/lib/` → export from `lib/index.ts` if public → unit tests.

## The single-page question

The platform asks it of a deploy that sends `spa=true` (`DEPLOY_FIELDS.SPA`) and writes the routing config itself, when the root `index.html` is an empty application shell and the deploy ships no `ship.json` (`cloudflare/api/src/lib/platform-files.ts`). Absence asks nothing. That is production's own field and meaning: the console sends `spa=true`, and 3.8 and older send nothing and run their own check through `/spa-check`. This SDK asks by default: the `spaDetect` option is `true` unless set `false` (`--no-spa-detect` on the CLI), and `false` sends nothing. The option and the field are two names for two layers. A `ship.json` the deploy carries always wins.

It costs no request of its own. A deploy that splits carries the files that configure it in its bare declaration, the root `ship.json` and the root `index.html`, whether or not the question is asked (`deploy-plan.ts`). The API refuses a declaration that leaves the `ship.json` behind, and one that asks the question and leaves the `index.html` behind.

`/spa-check` answers the same question without deploying, for the clients that predate the field. This SDK does not call it.

**3.10 reaches `latest` only after the API that knows the field is on production.** An API without it gives no app its routing config, since this SDK no longer writes one. The API's smoke asks on a declaration that leaves `index.html` behind, which writes nothing and passes only on an API that knows the field (`cloudflare/api/CLAUDE.md`, "The release gate"); a green `pnpm smoke:prod:api` is the precondition for merging this repo's version to `main`. Three `tests/contract.ts` rows hold the wire: an app deployed with no option sends `spa=true` and comes back configured, the opt-out sends nothing and leaves the app unconfigured, and a split declaration carries `index.html`. `pnpm test:e2e` proves them against a real API.

## ship.json: the client checks syntax, the server owns the schema

`deploy()` calls `validateDeployConfig` (`shared/lib/validation.ts`) before it
builds the multipart body — the third of three request-boundary validators in
the same fast-fail block, beside `validatePassword` and `validateLabels`, all
imported from that one module. It finds the root `ship.json`, reads it on
either platform (`Buffer` in Node, `Blob` in the browser), and applies the
types-tier rule `assertShipJsonSyntax`.

**The split is deliberate and bounded.** ship.json's schema and its compiler
live on the server and evolve there, so a client that judged them would
reject configs a newer platform accepts — which is why validation was
server-only to begin with. The client therefore checks only what is true of
*every* past and future schema: the text parses as JSON (frozen by RFC 8259),
and its top level is an object. Both are monotonic; neither can produce a
false rejection. A UTF-8 BOM is stripped rather than refused, matching the
server.

What it buys is the hand-edit case — a trailing comma, a `//` comment,
single quotes, smart quotes pasted from documentation. Measured: an 11 MB
deploy with a one-character config typo fails in **964 ms having uploaded
nothing**, against 5.5 s for the same deploy's real upload. Failures carry
`ErrorType.Config`, the same type the server's own rejection uses, so the
error contract does not depend on where the mistake was caught.

Scope matches the API's `findDeploymentConfigFile`: the exact name at the
deploy root, which arrives canonical, since the path pipeline strips any
leading slash. A `config/ship.json` is an ordinary asset and is left alone.

## Error Handling

All errors use `ShipError` from `@shipstatic/types`. The class provides the full factory + type-guard API and the two HTTP-context constructors (`fromHttpResponse`, `fromFetchError`). See `@shipstatic/types/CLAUDE.md` "Error Flow" for the end-to-end lifecycle.

**`ApiHttp` is pure transport**, and since 2026-08-12 that is literally true
rather than aspirational — see "One layer, not two". It owns no error-mapping
logic either: `executeRequest` calls the two helpers directly —
`ShipError.fromHttpResponse(response, operationName)` for non-OK responses and
`ShipError.fromFetchError(error, operationName)` for thrown causes (which passes
existing `ShipError`s through unchanged).

**CLI error UX** (`src/node/cli/error-handling.ts`) — pure functions, fully unit-testable:
- `toShipError(err)` — normalizes any thrown value to a `ShipError` (used by the CLI's global error handler for non-fetch errors like Commander parse failures).
- `getUserMessage(err, context, options)` — maps a `ShipError` to an actionable user-facing CLI string (auth → credential hints, timeout → the deadline sentence verbatim, network → connectivity, maintenance → the operator's sentence + where to watch, client/4xx → trust the API message, 5xx → generic "try again"). **Text channel only.**

  The timeout arm sits AHEAD of the network one and branches on the TYPE,
  because the two share a category — `isNetworkError()` is true for both, and
  rightly so: nothing was exchanged either way. Until 2026-08-12 there was no
  type to branch on and the network arm claimed it, so a deploy that hit its
  five-minute ceiling — the slowest, most expensive failure this CLI produces
  — told the user to check their Wi-Fi. `--json` was truthful throughout; only
  the human channel lied. The arm relays `fromFetchError`'s own sentence and
  appends nothing: "try again" belongs to the 5xx arm, which has nothing
  better to say, and it is half-false here because the client has already
  tried three times.

  The maintenance arm sits between network and client for a reason: a closed
  platform is neither the caller's fault nor a transport failure nor a server
  fault, and each neighbouring arm would mis-serve it. It relays the sentence
  and appends the status URL but **never "try again"** — that advice belongs to
  the 5xx arm, which has nothing better to say, whereas a maintenance message
  states when. See root `plan-maintenance-mode.md`.

There is deliberately no JSON formatter in this module. `--json` serialization
is `ShipError.toResponse()`, emitted by `error()` in `utils.ts` — a second
serializer is exactly how the `--json` envelope drifted from the wire's in the
first place. See "Output Conventions" above.

## Known Gotchas

**Deploy token vs API key** — both ride the one `token` slot; the prefix says which population a value belongs to. An API key (`ship-`) is durable and grants full account access; a deploy token (`deploy-`) is scoped to deploys, supports an optional TTL, and is revocable.

**Browser file handling** — SDK extracts path from `webkitRelativePath` or falls back to `name`.

**Credential resolution (effective end-to-end):**

| Layer | Where | Reads |
|---|---|---|
| **CLI** | `src/node/cli/shiprc.ts` | `~/.shiprc`, or the `--config` path (strict JSON) |
| **CLI** | `src/node/cli/create-client.ts` `createClient()` | merges flag → env → file via `mergeCliConfig`, hands result to `new Ship({...})` |
| **CLI** | `src/node/cli/create-client.ts` `resolveCliToken()` | the same chain read for its token alone — the error handler's auth branch, and the `--domain` / `--ttl` preflights |
| **SDK (Node)** | `src/node/index.ts` constructor | `SHIP_TOKEN`, `SHIP_API_URL` (under any constructor arg) |
| **SDK (Browser)** | `src/browser/index.ts` constructor | nothing — fully explicit |

For a CLI user who has all three: `--token` flag → env → file, per value. For an embedded SDK consumer: constructor arg → env. There's no path from the SDK to the filesystem.

**Two readers of that chain, one file.** `resolveCliToken` sat in `index.ts`
until 2026-08-12 while `create-client.ts` opened by declaring itself the owner of
the precedence contract. It stays a SECOND function rather than a shared helper
with a posture flag, because the two differ in exactly what such a flag would
have parameterized: a broken config file is SWALLOWED there (it runs inside the
error handler, where a config that cannot be read must not mask the failure
being reported), and the merged API URL is NOT judged there (that would raise a
second error while rendering the first).

**Neither hazard is reachable today, and that was measured rather than
assumed.** Every caller runs after `createClient` has already read the same
file — the preflights are inside `withErrorHandling`, and the auth branch needs
a 401, which needs a client. Deleting the `catch {}` leaves the whole suite
green. That is a statement about the call graph, not about the guard, and it is
written at the function so the next reader loses a minute instead of an hour.
The guard stays because the property belongs to the CALL SITE: move one caller
ahead of `createClient` — a fourth credential source, an earlier preflight — and
the hazard is live again with nothing to announce it.

**CLI-only env vars** (read by the CLI but *not* by the SDK constructor):

| Var | Purpose |
|---|---|
| `SHIP_PASSWORD` | Default for `--password <password>` on `ship deploy` / `ship deployments upload`. Empty string is normalized to absence (so unset CI variables don't accidentally protect a deploy). |
| `SHIP_IDEMPOTENCY_KEY` | The SDK's `idempotencyKey` for `ship deploy` / `ship deployments upload`: a re-run is answered with the live deployment the key already made instead of deploying twice (see the Backend Integration table). Empty string is normalized to absence, for the same reason `SHIP_PASSWORD` is: an unset CI variable must not collapse every deploy in a job into one replay. There is **no flag**: this is the whole CLI surface, and it exists for the integrations that wrap the CLI as a subprocess. The GitHub Action derives one key per workflow run, so a re-run is answered with the deployment the first run made rather than creating a second one. A shell user's `--idempotency-key` remains the open product call below. |
| `SHIP_VIA` | Overrides the deploy `via` field (the CLI sends `'cli'` when unset). Used by integrations that **wrap a first-party executable as a subprocess**: the GitHub Action sets `SHIP_VIA=git` on the CLI, and the Gemini extension sets `SHIP_VIA=gmn` on the stdio MCP bin, which reads the same slot with `mcp` as its own default. Since the name gained that second reader, its owner is `SHIP_VIA_ENV` in `@shipstatic/types` (deliberately beside, not inside, `SHIP_ENV`: the SDK never reads it) and both executables read through the constant. In-process SDK consumers set the same field as a programmatic `via` option on the SDK call (`ship.deployments.upload(..., { via: 'mcp' })`) — same destination, different mechanism. Distinct from the programmatic `caller` option, which is for rate-limit bucketing in multi-tenant orchestrators (see `ShipClientOptions.caller` JSDoc). |

**Every deploy names its surface; `via` is never empty by accident.** The
registry is `DeploymentVia` in `@shipstatic/types` — closed on purpose. Each
client owns its member (`cli`, `git` for the Action, `mcp`, `vsc`, `web` for
the web apps, `n8n`, `gpt`), and a direct SDK call falls back to `sdk`,
applied at the single wire boundary in `api/http.ts` so it cannot differ per
platform. A closed enum is what makes a surface's attribution the COMPILER's
business: the vscode extension's `via: 'vsc'` stopped being a lockstep anyone
had to remember the day the types pin landed there, and a typo'd value cannot
silently store as `NULL` — `normalizeVia` drops what the vocabulary does not
name, and the request option refuses it at compile time. Adding a
distribution surface is a types PR, which is exactly the ceremony a new
surface deserves. What the `sdk` default buys is that a deploy through this
SDK is never unattributed — the self-identification Vercel's `source` and
every comparable client (AWS, Stripe, OpenAI) practise. What a request that
names nothing is recorded as is the API's own call, not this package's.

> **Doc placement note:** `SHIP_VIA` and `caller` are intentionally *not* in the public README's CLI Reference / SDK Deploy Options. They're ShipStatic-specific operational levers (analytics origin, rate-limit bucketing) that serve first-party integration code paths and stay in internal-tier surfaces (this file + JSDoc + the integrations submodules). Keep mechanisms of the same shape — platform-tier behavior shaping — in the same tier.
>
> The `fetch` option is the exception: it's in the public README ("Custom fetch") because transport injection is a convention every comparable SDK ships (Stripe, OpenAI, Anthropic). Rule of thumb: ShipStatic-specific levers stay internal; industry-standard SDK conventions are public.

**`getLimits()` asks the server every time, and the request is EARNED.**
Limits are the account's policy and the server may change them at any time (a
plan move, an operator's grant), so the client never holds them. `getLimits()`
issues `GET /limits` on every call, and a deploy reads them once, at its start,
and hands them to `processInput` (file-size, file-count and blocklist checks).
Nothing else requests them: `domains list`, `tokens list`, `whoami` and `ping`
issue their own request and no other.

Holding them is refused, measured on the console: a client lives as long as a
browser tab, so a memo validated today's upload against the morning's limits,
and a caller's own cache (the console's TanStack query, `@shipstatic/drop`)
could refresh nothing beneath it. A CLI command and the hosted MCP make one
client per operation, so for them the two contracts cost the same one request.
Fenced by request COUNTS and by a server that changes its answer between calls
in `base-ship-limits.test.ts`, since the resolved values alone cannot tell a
fresh read from a stale one.

**`/limits` is also how the platform's extension blocklist reaches the client.**
`PlatformLimits.blockedExtensions` is the API's list (`cloudflare/api/src/lib/blocklist.ts`);
`validateFiles` and both file pipelines read it and refuse nothing the platform
did not name. The field is **optional and the absence is a contract**: an API
predating it sends none, which means "no client-side check", never "an empty
policy" — the deploy proceeds and the API refuses the file at the boundary,
which is where refusal belongs. Never restore a compiled-in copy: `@shipstatic/types`
is a devDependency **bundled into dist**, so a constant here is baked into every
published tarball and a user on an old `ship` would enforce a policy the platform
has moved on from, in both directions. Fenced by the `getLimits` row in
`tests/contract.ts` — the live half is the only thing in this repo that can see
the API drop the field.

**The rules are the platform's, and this package renders them.** Every rule a
deployment's files are held to is one table in `@shipstatic/types` ("Deploy
rules"): `FILE_RULES` for each file (name, extension, project, path, file size,
running total) and `SET_RULES` for the deployment as a whole (it has files,
each path once, no more than the plan allows, and a home page, `index.html` at
its root). The API
judges a declaration by the same table, so a refusal reads the same wherever it
is made, and a rule cannot be added to one judge and not the other. This package
renders it twice and authors no sentence of its own:

- **The throwing renderer** is `deployRefusal` itself, which the shared
  pipeline (`core/deploy-files.ts`) calls over every file's path and size
  before it reads one, so a refused deploy costs no I/O. A refusal is
  `ShipError.validation`, the API's own type and status for it.
- **The collecting renderer**, `validateFiles`, which the UI tier (drop) reads:
  each file's first broken rule, then the set rules, filed under `(N files)`
  because they name no one file. Its own check is the one about a `File`
  rather than a deployment (a processing error), and an empty file is
  excluded with a warning, as the pipelines skip it.

**Every sentence is complete on its own**, names its file once (the set rules
name none), and ends a limit's sentence with the way forward the API delivered
(`PlatformLimits.suggestions`), so drop, the console, the CLI and the MCPs show
it as it is and this package never learns what a plan is. The CLI's
`plainMessage` lowercases only the opening word and strips only the final
period.

**What the junk filter does and does not do.** It filters a site's junk and
judges nothing: a project's files (`node_modules`, `package.json`) are kept,
even under pnpm's `node_modules/.pnpm/`, so the `project` rule refuses them by
name. A `..` segment is dot-prefixed, so it is filtered rather than refused.

**The home page.** A deploy with no `index.html` at the top is refused before
the first request, and the sentence ends with the step that fits what the files
show (the table's, `npm/types/CLAUDE.md`, "Deploy rules"): the folder to
publish when the site sits one folder down, the page to rename when there are
pages but none at the top, or a small `index.html` to add when there is no page
at all. An empty `index.html` is refused as EMPTY ("The
index.html at the top of your site is empty."), because both renderers hand the
set rules the files as sent, empty ones included, and the table decides what is
published (`isPublishedFile`): an empty file is never published, and never
refused for itself. `tests/contract.ts` holds the refusal as a
validation error made with no request (`deployments.upload (no index.html)`).

## Backend Integration

| SDK Method | API Endpoint | Notes |
|------------|--------------|-------|
| `deployments.upload()` | `POST /deployments` | The declaration: the manifest, the options (`spa=true` among them unless `spaDetect` is `false`), the root `ship.json` and `index.html`, and any other files. 201 live when it carries every file, 202 pending otherwise |
| ↳ each further batch | `POST /deployments/:deployment/files` | After a 202, one fill per batch: 202 still pending, 201 live, 200 already live. Files only |
| ↳ `idempotencyKey` | header `Idempotency-Key` (the declaration only) | Answers the earliest live deployment the key made in your account within 24h, as it stands, instead of deploying twice. An anonymous key is ignored. Key the ATTEMPT (run id, commit sha), never the try. |
| ↳ CLI `--domain` | *(no endpoint)* | Composed CLIENT-side: this upload, then `domains.set()` with the fresh id. Two wire calls, no new route, no SDK option — see "Composability". |
| `deployments.list()` | `GET /deployments` | Paginated — `{limit, cursor}` options (`--limit`/`--cursor` on the CLI; text mode prints a rerun hint while `--json` carries `cursor`) |
| `deployments.get()` | `GET /deployments/:deployment` | |
| `deployments.set()` | `PATCH /deployments/:deployment` | Labels only |
| `deployments.delete()` | `DELETE /deployments/:deployment` | 202 (async) `{deployment, status:'deleting'}` — resolved, so a caller learns the transitional state without a re-read; a repeat answers the same object, since it states where the row is rather than what the call did |
| `domains.set()` | `PUT /domains/:name` | Upsert — create, repoint, or label |
| `domains.list()` | `GET /domains` | Paginated — same `{limit, cursor}` contract |
| `domains.get()` | `GET /domains/:name` | |
| `domains.validate()` | `POST /domains/validate` | Pre-flight check — name rides the JSON body, not the path |
| `domains.verify()` | `POST /domains/:domain/verify` | Wire: 202 `{domain}` — the SDK returns it; the CLI composes its own copy and reads the domain once more to say whether anything is linked |
| `domains.dns()` | `GET /domains/:name/dns` | DNS provider information |
| `domains.records()` | `GET /domains/:name/records` | Required DNS records |
| `domains.share()` | `GET /domains/:name/share` | Shareable setup hash |
| `domains.delete()` | `DELETE /domains/:domain` | 200 `{domain}` — resolved, not discarded |
| `tokens.create()` | `POST /tokens` | Returns 201 |
| `tokens.list()` | `GET /tokens` | Paginated — same `{limit, cursor}` contract |
| `tokens.get()` | `GET /tokens/:token` | The same row the listing carries, addressable — `ship tokens get` |
| `tokens.delete()` | `DELETE /tokens/:token` | 200 `{token}` — resolved, not discarded |
| `account.get()` | `GET /account` | Resolves `Account`, which names the account it describes (`account`). The SDK itself sends no `X-Account`: a key, a deploy token or an OAuth grant is its account, and a browser host in session mode names the account through its own `fetch` (the console does) |
| `ping()` | `GET /ping` | Resolves `PingResponse` (`{timestamp}`) — reachability is the absence of a throw, so there is no boolean to read |
| `getLimits()` | `GET /limits` | Asks the server every time. Carries the plan caps **and** `blockedExtensions` — the platform's hosting blocklist, optional (an older API sends none) |
| (internal) | `POST /upload` | Only via the `@internal` `deployEndpoint` option, for the platform's own web apps |

**Deploy requests get their own timeout ceilings: three budgets, one per kind of work.** 30s is right for a metadata read, where anything slower is a fault rather than a payload. A deploy request is bounded by the request ceiling instead: one carries at most `DEPLOY_REQUEST_CONSTRAINTS.MAX_BYTES` (64 MiB), and a larger deployment is sent in several requests, each with a ceiling of its own. `DEFAULT_DEPLOY_TIMEOUT` is 5 minutes: at `UPLOAD_FLOOR_BITS_PER_SECOND` (2.5 Mbit/s, stated beside it) the largest request transfers in about 215 s, which leaves about 85 s for the server's own work on it. `http-timeout.test.ts` holds that sum, so a bigger request cannot silently outrun the ceiling.

A **build or prerender** deploy is the third: it waits for work the SERVER does after the upload lands, so `DEFAULT_DEPLOY_BUILD_TIMEOUT` is **derived, not chosen** — written in code as `DEFAULT_DEPLOY_TIMEOUT + BUILD_SERVICE_BUDGET`, so tuning either half carries. Raising that server constant must raise this one; they sit in different repos so nothing fences the pair, and the constraint is stated at both ends. `spa` does NOT qualify: the single-page question is a pure function of one page on the server; only build/prerender reach the build service.

**Only the DEFAULTS split by operation** — an explicit `timeout` option governs every request including deploys: a caller who names a ceiling asked for a ceiling, not for one with an exception.

**And since retries landed, that ceiling is per ATTEMPT.** The sentence above
was written when one call meant one request, and it has to be reconciled
rather than quietly contradicted: each attempt is an honest request and
deserves the ceiling the caller named, while `maxRetries` is the lever on how
many there may be. A caller who wants a hard WALL-CLOCK deadline passes their
own `signal` (`AbortSignal.timeout(ms)`) — and that works precisely because
the loop never retries past a signal the caller supplied. See "Retries" below. The correct long-term instrument is an **idle timeout** — reset on progress, the only formulation independent of payload size — blocked on upload-progress observation, which browser `fetch` cannot provide. Build it when someone wants a progress bar; the timeout falls out of that work for free.

**Routes the API exposes that the SDK does not reach.** Two classes — do not
conflate them:

*Settled:* first-party and operator routes are not the SDK's to reach; the SDK
is the client of the public API only.

*Settled by decision:* **`GET /activities` stays out of the SDK** (decided
2026-07-28). It paginates like every other collection on the API side, but
the dashboard is its only client — a CLI user reads their audit trail in
`my`, not through a resource. This is a deliberate absence, not a gap: do
not add `ship.activities` without a new call.

The argument that reopens it, and why it does not: *"`ActivityListResponse`
is in `@shipstatic/types` with no `*Resource` to fetch it — a public
contract with a missing half."* It has both halves; the SDK is simply not
one of them. The API produces that type and `web/my` consumes it, which is
two consumers and exactly what earns a place in the shared package. A
`ActivityResource` interface with no implementation would be the dead
surface — added, then reverted, on 2026-07-28 for precisely this reason.
The `*Resource` interfaces describe **the SDK's** contract, not the API's;
the API's contract is its routes.

*Open product questions (awaiting a call):* `GET /labels` (a real endpoint with no SDK method —
a CLI user would plausibly want it), and an `--idempotency-key` CLI flag.
The SDK half of that second one **shipped**: `deploy()` takes
`idempotencyKey`, validates it through `validateIdempotencyKey`, and sends
the header (see the Backend Integration table). This paragraph
claimed "the SDK/CLI never send the header" until 2026-07-30, which was
true when written and stale thereafter. Neither item is drift, and neither
should be added without the call.

**The FLAG is still open; the env var is not.** `SHIP_IDEMPOTENCY_KEY`
landed 2026-08-07 (see "CLI-only env vars") because the two audiences are
different and only one of them was blocked. A subprocess-wrapping
integration has no other channel at all — it composes an argv it does not
own and cannot reach a per-call SDK option — and the GitHub Action is that
consumer, deriving a key per workflow run so a re-run replays. A shell user
already has one (`SHIP_IDEMPOTENCY_KEY=… ship ./dist`), which is why the
flag stays a product call rather than a gap: it would be a second way to
say what the tier already says, and the reason to add it is ergonomics for
humans, not reach. (List pagination, formerly in this list, shipped
2026-07-27 as F1 and reached the last collection — tokens — on 2026-07-28;
see the Backend Integration table.)

### Domain Write Semantics

`PUT /domains/:name` is a merge-upsert: omitted fields are preserved on update, defaulted on create. Supports: reserve (omit deployment), link, atomic deployment switch, label update.

**No unlinking (by design).** `{ deployment: null }` returns 400. Reservation (forward-looking: "claiming domain, will link soon") is valid; unlinking (backward-looking: "what does this serve now?") is not. To take a site offline, deploy a maintenance page. To clean up, delete the domain.

**Why PUT not PATCH?** Domains are mutable routing records identified by natural key — PUT upsert is one endpoint for create, repoint, and label. Deployments use PATCH because they're immutable artifacts with labels as the only mutable annotation.

### Domain Normalization

The SDK is a transparent pipe — zero domain validation or normalization. It URL-encodes names in API paths (`encodeURIComponent`) and passes everything else as-is. The API owns all domain semantics: it accepts liberal input (any case, Unicode), normalizes to canonical form, validates, and returns the normalized name.

---

*This file provides Claude Code guidance. User-facing documentation lives in README.md.*