Files
airship/CONTRIBUTING.md
T
Nayan 4c2d1c822d feat(cli): add ./airship, a dev wrapper that rebuilds the stale bundle
tsup inlines every @airship/* package into apps/cli/dist/index.js
(noExternal), so an edit anywhere under packages/ is invisible until the
CLI is rebuilt. The Makefile could not catch that: $(CLI) was a file
prerequisite with no source prerequisites, so `@pnpm build` ran only when
dist was absent. Once it existed, `make run` launched the stale bundle
silently.

./airship is a pure passthrough — argv untouched, cwd never changed, so
--cwd, the upward config search and the bare-invocation wizard behave
exactly as the published binary does — that mtime-scans apps/cli and
packages/* and shells out to turbo only when something moved.

Three things the obvious implementation gets wrong:

- Turbo hashes contents, so a cache hit leaves dist/ untouched and a
  plain mtime comparison never converges. A successful build stamps the
  entry, but only when turbo did not rewrite it, since stamping backwards
  reopens the window tsup's `clean` creates on apps/cli's own mtime.
- Scanning */src misses real build inputs. `turbo --dry=json` reaches
  package.json, tsconfig.json, scripts/ and editor-icons' 507 SVGs under
  assets/, so the scan covers package roots minus a skip set.
- SIGINT arrives from the tty at the whole process group, so forwarding
  it would trip the CLI's own second-Ctrl-C escape hatch; SIGTERM arrives
  at one pid, so swallowing it would hang. Swallow the first, forward the
  second.

The logic lives in scripts/airship-run.mjs so airship.cmd gives Windows
the same behaviour rather than a degraded passthrough. The Makefile's
run:* and doctor targets now go through the wrapper, so they inherit the
build check; a test reserves the wrapper's two flag names, since the CLI
generates --no-<name> for every boolean and would collide silently.
2026-08-16 13:05:37 +05:30

581 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Contributing
This is the contributor's half of the documentation. [`README.md`](README.md) covers
installing and using Airship; everything here is about working on it.
## Getting set up
Node 22.13 or later, and [pnpm](https://pnpm.io) — the repo pins `pnpm@11.9.0` through
`packageManager`, so Corepack will pick the right one.
```bash
pnpm install
pnpm build
```
`make help` lists every target, grouped by the surface it acts on. Targets follow a
`<surface>:<action>` convention — `web:dev`, `run:codex`, `storybook:build` — and repo-wide
operations stay bare (`build`, `check`, `preflight`).
The fastest way to see the tool working on itself:
```bash
make demo # install + build, then prints the recipe
make web:dev # terminal 1 — apps/web's dev server on :5173
make run # terminal 2 — airship on :5174, editing apps/web
```
Open <http://localhost:5174> and you are looking at Airship, with Airship's own home page live
inside it. Pick the hero's button, ask for a change, and the diff lands in `apps/web/src/`.
`make run:solo` does both in one terminal via `--exec`.
`make run` is a preset. For anything it does not model — a different port, another project,
`--effort`, `--max-budget` — use `./airship`, which is this checkout's CLI with every flag
available. See [Running the dev CLI](#running-the-dev-cli); read it before your first change
under `packages/`, because the bundle it builds is staler than you would expect.
### On Windows
Everything builds, tests and runs on Windows — `checks.yml` gates every PR on a
`windows-latest` leg alongside Linux, so a break there fails the PR.
`make` is the one thing that does not carry over: the Makefile declares
`SHELL := /bin/bash` and a handful of targets genuinely need it (`help` is an `awk`
program, `preflight` a shell conditional, `release` a bash script). It is a thin
wrapper either way — every recipe is one `pnpm`, `node` or `airship` call — so use those
directly:
| Instead of | Run |
| --------------- | ---------------------------------------------------------- |
| `make demo` | `pnpm install && pnpm build` |
| `make web:dev` | `pnpm dev:web` |
| `make run` | `airship.cmd --target 5173 --cwd apps/web` |
| `make run:solo` | `airship.cmd --cwd apps/web --exec "pnpm dev:web"` |
| `make doctor` | `airship.cmd doctor --cwd apps/web` |
| `make check` | `pnpm lint && pnpm typecheck && pnpm test` |
| `make readme` | `node scripts/sync-readme.mjs` |
| `make controls` | `node --experimental-strip-types scripts/gen-controls.mjs`, then `make readme` |
| `make models:refresh` | `node scripts/gen-models.mjs` — refetches the seed model list |
| `make storybook`| `pnpm turbo run storybook --filter=@airship/overlay` |
`pnpm dev:web` rather than a bare `vite dev`: the site cannot start until
`@airship/site-tokens` has emitted `dist/tokens.css`, and only turbo knows that.
`airship.cmd` is the Windows half of `./airship`, with the same behaviour including the rebuild
check — the logic lives in `scripts/airship-run.mjs` precisely so both platforms share it. From
Git Bash, prefer `./airship` directly. Three Windows-only notes: PowerShell needs
`.\airship.cmd`, and its 5.x releases mangle quotes when passing arguments to native commands,
so `--exec "…"` is a Git Bash job; and Ctrl-C in `cmd.exe` prints `Terminate batch job (Y/N)?`
*after* the CLI has already shut down cleanly, which is a batch-file fact rather than an
airship one.
Two things worth setting up once:
- **Git Bash**, which ships with Git for Windows, runs the Husky hooks and the release
scripts. Without a POSIX `sh` on PATH the pre-commit formatter silently does not run.
- **Developer Mode** (Settings → System → For developers), so pnpm can create the
symlinks its `node_modules` layout depends on without elevation.
Line endings are pinned to LF by [`.gitattributes`](.gitattributes) — do not override it
with `core.autocrlf`. Several generators parse their input with anchored regexes, and a
CRLF checkout makes them report a missing front-matter block rather than a wrong one.
If you cloned before that file existed, renormalize once with
`git rm --cached -r . && git reset --hard`.
## Repo layout
| Package | Role |
| --- | --- |
| `@airship/protocol` | Shared zod schemas + types (the client↔server contract) |
| `@airship/source` | DOM-element → source-file resolution (`element-source` + server fallback) |
| `@airship/git` | Optional auto-commit (Conventional Commits) + content-restore undo |
| `@airship/core` | **The agent engine** — `runEdit()` dispatches to a backend adapter |
| `@airship/server` | Reverse proxy + WebSocket + job/history store |
| `@airship/overlay` | Canvas shell + frames, picker, inspector, prompt, diffs, history — and a Storybook of all of it |
| `@airship/editor-tokens` | The editor's own `--ap-*` design tokens, generated from `EDITOR.md` |
| `@airship/editor-icons` | Vendored UI icon set, normalised to one generated module |
| `@airship/site-tokens` | The home page's `--pk-*` design tokens, generated from `DESIGN.md` |
| `@airshiplabs/cli` | The `airship` binary — the one package published to npm |
| `@airship/web` | The home page — and the app Airship edits in `make run` |
## Everyday commands
```bash
pnpm build # turbo build (topological, including apps/web)
pnpm dev # watch every package AND serve the site
pnpm dev:pkgs # …packages only, when the site is not what you are changing
pnpm typecheck # tsc --noEmit across the workspace
pnpm test # vitest
pnpm lint # biome (ultracite preset)
pnpm commit # guided Conventional Commit (czg)
make storybook # the overlay's own chrome, browsable — see below
./airship # this checkout's CLI, rebuilt when it is behind — see below
```
`make check` runs lint + typecheck + test. `make preflight` runs that plus the route-tree
drift check, which is exactly what CI gates a PR on — run it before opening one.
`make preflight:fix` autofixes what is autofixable first, then verifies the rest.
Toolchain: **pnpm** workspaces + **Turborepo**, **Biome** via **Ultracite**, **Husky** +
**commitlint** for Conventional Commits, **tsup** builds.
### Running the dev CLI
`./airship` runs the CLI built from this checkout. It is a **pure passthrough** — every
argument reaches the CLI untouched and the working directory is never changed — so anything in
`airship --help` works verbatim, `--cwd` and the upward `airship.config.json` search resolve
against wherever you typed it, and a bare `./airship` gives you the same interactive wizard a
real user gets. It works from outside the repo too: `cd ~/my-app && /path/to/airship/airship
--target 3000` drives your own project with this checkout's build.
```bash
./airship # the wizard, against $PWD
./airship --target 3000 # a dev server on another port
./airship doctor # any subcommand
./airship --skip-build ... # trust dist as-is (AIRSHIP_SKIP_BUILD=1)
./airship --force-build ... # rebuild even when it looks fresh (AIRSHIP_FORCE_BUILD=1)
```
`--skip-build` and `--force-build` are consumed by the wrapper and never forwarded, so they are
not in `airship --help`. Everything after a `--` is the CLI's, including tokens that look like
those two. A test in `apps/cli/src/lib/args.test.ts` keeps the CLI from ever claiming those
names, because the wrapper would silently swallow them.
**Why this exists, and why it is not optional.** `apps/cli/tsup.config.ts` sets
`noExternal: [/^@airship\//]`, which **inlines every workspace package** into
`apps/cli/dist/index.js`. That is deliberate: none of the `@airship/*` packages is published,
so the tarball must declare no dependency on them, which is also why they sit in
`devDependencies` rather than `dependencies`.
The consequence catches everyone once. Edit `packages/core/src/runner.ts`, run the CLI, and you
are running the *old* code — no error, no warning, your change simply does not happen. The same
goes for `server`, `overlay`, `protocol`, `source`, `git`, and for the two generated packages
whose real inputs are not even TypeScript: `@airship/editor-icons` builds from 507 SVGs under
`assets/`, and `@airship/editor-tokens` from a markdown file.
The Makefile could not catch this. Its guard was `$(CLI): ; @pnpm build` — a *file*
prerequisite, so make ran it only when `dist/index.js` was **absent**. A stale bundle exists,
so `make run` launched it happily. `./airship` replaces that check, and the `run:*` targets now
go through the wrapper, so they get it too.
There is also no watch loop to fall back on. `apps/cli`'s `dev` script is a bare `tsup --watch`,
which never runs `scripts/vendor-assets.mjs` and — because the config has `clean: true` —
*deletes* `dist/vendor/` on every rebuild. And `pnpm dev:pkgs` rebuilding `packages/*/dist` does
nothing for a bundle that already inlined them. On-demand rebuild is the loop.
**How the check works.** Before launching, the wrapper compares `apps/cli/dist/index.js`
against `apps/cli/` and every `packages/*/` — the package roots, not just their `src/`, because
`turbo run build --filter=@airshiplabs/cli --dry=json` shows the real input set reaching
`package.json`, `tsconfig.json`, `tsup.config.ts`, `scripts/` and those `assets/` trees. Build
output and machinery (`dist`, `node_modules`, dotted directories) are skipped. If anything is
newer it runs `turbo run build --filter=@airshiplabs/cli` — the CLI's slice of the graph, so
`apps/web` is never touched — and otherwise launches straight through, for about 90 ms of
overhead.
Two details worth knowing when it surprises you:
- **It over-triggers rather than under-triggers, on purpose.** Turbo remains the authority on
what actually needs rebuilding; the mtime scan is only a cheap doorman deciding whether to
ask it. A false positive costs one cached turbo run. A false negative runs stale code, which
is the bug being fixed. So editing `packages/site-tokens` — which only `apps/web` uses — will
rebuild the CLI, and that is fine.
- **A successful build stamps `dist/index.js`.** Turbo hashes file *contents*, so on a cache
hit it replays logs and leaves `dist/` untouched — meaning a plain mtime comparison would
never converge and would rebuild on every single invocation forever. Anything that moves
timestamps without changing bytes (`git checkout` and back, `git stash pop`, an
`ultracite fix` pass) hits exactly that path.
Finally: run `./airship`, not `airship`. If you have `@airshiplabs/cli` installed globally, the
bare name runs the *published* binary from inside this repo, and `--version` will often not
tell them apart.
### Three turbo edges worth knowing
All three are the same edge for the same reason: a package's `dist` has to exist before
something else can start against it.
- **`@airship/web#dev` depends on `@airship/site-tokens#build`**, because Vite has no
`dist/tokens.css` to import until that package's postbuild has emitted it. Start the site
through turbo (`make web:dev`, `pnpm dev:web`) rather than with a bare `vite dev`.
- **Storybook must start through turbo** for the same reason — see below.
- **`@airshiplabs/cli#build` depends on `@airship/overlay#build` and
`@airship/editor-tokens#build`**, on top of the usual `^build`. Those two are not imported,
they are *served*: `packages/server/src/proxy.ts` resolves the overlay IIFEs and the editor
fonts at runtime, so no bundler can inline them and their `dist` has to be on disk. It is
also why `./airship` rebuilds through turbo rather than calling `tsup` itself.
### Hooks
`pre-commit` blocks direct commits to `main` and runs `ultracite fix` over staged files.
`commit-msg` runs commitlint. `pre-push` blocks pushes to `main`. The test suite deliberately
does *not* run in `pre-commit`: it runs in CI on every PR and locally via `make preflight`, and
a full `turbo run test` on every commit would tax exactly the fast checkpoint commits this repo
lives on while catching nothing the PR gate would not.
## Architecture
```
canvas shell ──ws──► proxy/server ──► @airship/core ──► claude │ codex │ opencode ──► edits your files
├ frame 1440×900 (serve + route) (adapter) (agent backend) (diff + undo)
└ frame 393×852
(live app, pick element)
```
`runEdit()` owns everything that does not depend on which agent runs — the rendered prompt, the
activity timeline, diff capture, and result assembly — and dispatches the rest through
`AgentAdapter` (`packages/core/src/providers/`).
### Claude
Leans on the Agent SDK rather than hand-rolling a subprocess:
- **`query()` streaming-input loop** — typed `SDKMessage`s; native image input; cancel via `AbortController`.
- **`includePartialMessages`** — token-level streaming of Claude's text to the overlay.
- **`PreToolUse` hooks** — robust before/after **diff capture**, plus (under `--safe`) a **sandbox** that denies edits/commands outside the project.
- **`createSdkMcpServer` + `tool()`** — a `get_element_context` tool exposing the selection to Claude.
- **`outputFormat` (JSON schema)** — typed `{ summary, filesChanged, followUps }` per edit.
- **`enableFileCheckpointing` + `rewindFiles`** — native undo (with content-restore fallback).
- **`resume` / `forkSession`** — multi-turn refinement on the same session.
- **`settingSources: ["project"]`** — respects the target repo's `CLAUDE.md`.
- **`maxTurns` / `maxBudgetUsd` / `effort`** — per-edit guardrails; usage/cost reported back.
### Codex
The Codex SDK spawns `codex exec --experimental-json` and streams JSONL. It offers less, and
the adapter is explicit about each gap rather than faking it:
| | Claude | Codex |
| --- | --- | --- |
| Text streaming | token-level | whole messages at completion |
| Diff `before` side | `PreToolUse` snapshot | reconstructed from the git HEAD blob |
| Tool screening | hooks + `canUseTool` | the CLI's own OS sandbox |
| Selection re-read | `get_element_context` MCP tool | inlined in the prompt |
| Structured output | validated by the SDK | model JSON, zod-parsed defensively |
| Cost | `total_cost_usd` | tokens only |
| Fork a session | `forkSession` | starts a fresh thread, and says so |
| `maxTurns` / `maxBudgetUsd` | enforced | unsupported; warned about at startup |
| Model selection | `options.model` | `ThreadOptions.model` |
| Listing its models | `query.supportedModels()`, account-scoped | **nothing** — no subcommand, no RPC |
Codex items are normalized into the same tool vocabulary Claude uses (`command_execution` →
`Bash`, `file_change` → `Edit`/`Write`/`Delete`), so one copy of the summarization rules serves
both and the transcript reads the same either way.
### OpenCode
Structurally unlike the other two: OpenCode is a **client/server pair**, not an in-process
iterator. The SDK shells out to `opencode serve` and speaks HTTP + SSE, so a turn is a blocking
`session.prompt` raced against a *global* event subscription carrying every session on that
server. Airship keeps one lazily started server per process and filters every event by session
id.
It is the most capable of the three in several places, and the adapter uses all of them:
| | Codex | OpenCode |
| --- | --- | --- |
| Text streaming | whole messages at completion | token-level (`message.part.delta`) |
| System prompt | rides on the first turn's text | a real `system` field |
| Fork a session | starts a fresh thread, and says so | native `session.fork`, history intact |
| Native undo | none | `session.revert`, snapshot-backed |
| Cost | tokens only | real cost per message |
| Reasoning effort | `modelReasoningEffort` | **none** — `--effort` is ignored |
| Model selection | `ThreadOptions.model` | `{providerID, modelID}` on the prompt |
| Listing its models | **nothing** | `client.config.providers()`, only what is authed |
Gaps handled explicitly rather than faked:
- **No bundled binary.** `@opencode-ai/sdk` ships only a spawn helper; the `opencode` CLI is a
separate install. `checkAuth()` scans PATH and says so.
- **No OS sandbox.** See the safety section in the README.
- **No in-process MCP** — MCP servers are config-declared subprocesses, so the selection is
inlined in the prompt as it is for Codex.
- **`--model` wants `provider/model`.** A bare id cannot be resolved to a provider, so it is
dropped with a warning rather than silently ignored.
- **Structured output is fragile twice over.** On the wire, `format` is a *forced tool call* —
opencode registers an internal StructuredOutput tool and sets `toolChoice: "required"`, which
providers reject outright on thinking/reasoning models (opencode#15226, closed upstream). The
adapter classifies that rejection, retries the turn once without `format`, and remembers the
model. In the text, the JSON arrives inside `<structuredoutput>` tags within ordinary prose —
a convention the system prompt now carries too, so it survives `format` being dropped — and
opencode's own extractor frequently fails to lift it back out. The adapter parses the tag
itself and strips it from the streamed transcript on the way past — including when the
opening tag arrives split across two deltas.
The wire types are declared in `providers/opencode-wire.ts` rather than imported from the SDK:
the SDK's generated `Event` union omits `message.part.delta` entirely (237 of 313 events in a
real turn), omits `server.heartbeat`, and declares a `permission.updated` the server does not
emit in place of the `permission.asked` it does. Trusting it would drop streaming and deadlock
every permission request while type-checking cleanly.
## The site
`apps/web` does two jobs. It is Airship's home page, and it is the app the `run` targets point
the CLI at — so the demo in its hero is a picture of the tool editing the very page you are
reading.
It is a TanStack Start (Vite 7) app inside this workspace, styled from `packages/site-tokens`
(`--pk-*`, generated from that package's `DESIGN.md`).
The hero recreates Airship's **inline mode** in miniature and animates the thing the tool
actually claims: pick an element, describe the change, the agent edits the file — composer,
streaming tool calls, diff, and the button changing on the page. It is a static recreation, not
the editor: the chrome is HTML and a ten-second CSS timeline, sharing only the `--ap-*` palette
with the real thing, which `site-tokens` re-emits scoped to that one subtree so it cannot drift.
Two things about it are editorial rather than faithful, and are commented as such where they
live. The real overlay runs **two** docks — chat left, inspector right — while the hero folds
them into one panel with the agent as the front tab (`design-dock.tsx`); and the hero's glyphs
are hand-drawn rather than the vendored set the editor uses. Everything inside those panels is
transcribed 1:1.
`?frame=62` freezes the loop at any percent for inspection, and `prefers-reduced-motion` pins it
at the frame that explains the most.
Page copy lives in `apps/web/src/content/*.json` so it can be edited without opening a
component, and `content/resolve.ts` is the only place that turns a `{{token}}` or a `link` key
into a real value. That indirection is why the install command appears once, in `site.json`, and
why `resolve.ts` throws at module load on an unknown token rather than shipping the literal
`{{installCommand}}` to a visitor.
### The controls reference
`CONTROLS.md`, and the short table between the `<!-- controls:start -->` markers in
`README.md`, are generated:
```bash
make controls # rewrites both, then syncs apps/cli/README.md, then commit them
```
The source is `packages/overlay/src/keys/catalog.ts` — the same table the runtime binds
from, the shortcuts panel and the ⌘K palette render from, and every tooltip chip resolves
against. Nothing else in the editor is allowed to spell a chord: `MenuItem.command` renders
one from the catalog, and `keys/catalog.test.ts` fails any `hint:` literal that looks like a
keystroke.
That is not tidiness. Chords used to be string literals at each `keys.bind` call site, so
twenty-seven of the thirty-three shortcuts appeared nowhere in the product or the docs, five
menu rows showed Mac glyphs to Windows users, one advertised `⌘Z` for a feature ⌘Z has never
run, and the only reference — six hand-written rows in `README.md` — had already drifted in
its own copy under `apps/cli/`.
`gen-controls.mjs` imports the `.ts` catalog directly under `--experimental-strip-types`,
which is why that module may contain **no value imports**; type imports are erased before
resolution and are free. `keys/catalog.test.ts` enforces it, and
`keys/controls-doc.test.ts` byte-compares the committed files against the same renderers the
script uses — so drift fails the suite even where the script itself cannot run.
Run it before `make readme`, never after: it writes into the root `README.md` that
`sync-readme.mjs` copies. `make controls` does both in that order.
### The seed model list
`packages/protocol/src/models.ts` is generated from [models.dev](https://models.dev):
```bash
make models:refresh # refetches, rewrites the module, then commit it
```
It exists because of an asymmetry between the backends. Claude answers
`query.supportedModels()` and OpenCode answers `client.config.providers()`, both live and both
scoped to what you are actually signed in to — so for those two this is only what the picker
paints before the answer arrives, and what it falls back to offline. **Codex can enumerate
nothing**, at any layer, so for that backend this file *is* the list.
Two things about it are deliberate.
**It is not in `make preflight`.** `gen-controls.mjs --check` belongs there because it derives
from a file committed beside it, so it can only drift when someone edits that file. This one
derives from a remote registry that changes whenever a vendor ships a model — gating on it
would need network to pass and would turn PRs that touched nothing red. `reference/NEXT-STEPS.md`
§7 describes what that costs. Treat a refresh like a lockfile bump: deliberate, and reviewed as
a diff.
**Judgement lives in `scripts/models.curation.json`, not in the output.** The mechanical filter
— `tool_call`, `reasoning`, a release-date floor — admits things like `gpt-realtime-2.1`, which
is not a coding model. The deny list, the cap, and the Claude CLI aliases models.dev cannot know
about are all in that file, so the generated module stays purely derived and the taste is what
gets reviewed.
The generator formats its own output through the repo's biome before writing. Without that,
`ultracite fix` would reformat the file the first time anyone linted and `--check` would report
stale against a file nobody touched.
### The social card
`public/og.png` is generated, not drawn:
```bash
make web:og # rewrites apps/web/public/og.png, then commit it
```
`apps/web/scripts/og.mjs` renders an HTML card in headless Chromium at a fixed 1200×630 and
screenshots it. The heading and the install command are read from `hero.json` and `site.json` —
the same files the page renders from — because those are the two strings that drift. The card
spent a while advertising a headline the page had stopped using and an install command that
installed somebody else's package, which is what a picture of copy gets you when nothing checks
it. The one line the script owns is the blurb, and there is a comment saying why.
It needs `@airship/site-tokens` built, since it embeds Inter and JetBrains Mono from that
package's `dist/fonts`; the make target builds it first.
## Storybook
```bash
make storybook # the catalogue on :6006
make test:browser # every story, as a test, in real Chromium
make browsers # one-time: download that Chromium
```
`packages/overlay` carries a Storybook of the editor's own chrome — the inspector's controls, its
sections, the whole Design panel, the chat timeline, diffs, toasts and the device presets.
Stories are colocated next to the code they cover (`src/**/*.stories.ts`), the same convention
the `.test.ts` files already follow.
It exists for two reasons that are really one.
**The panel is hard to look at.** The only other way is `make web:dev` plus `make run`, then
picking an element and hoping it lands in the state you wanted. States that matter and are
awkward to reach by hand — `Mixed` across a multi-selection, a token-bound field, a locked aspect
ratio, a six-stop gradient, an element matched by fourteen rules, a `Bash` that failed next to an
`Edit` still running — are effectively unreviewable. Each of those is now a URL.
**It is the real-browser half of the test suite.** `packages/overlay/vitest.config.ts` runs on
happy-dom, and its own docstring — plus `inspector/test-support.ts`'s — lists what that costs: no
layout, no native CSS nesting, `@layer` dropped outright, no `CSSStyleDeclaration` iterator.
Those are the four things the inspector reads. `make test:browser` runs the same stories under
`@vitest/browser` where all four are real, which is why the CSS pane has a story at all —
`style-model.ts` notes it "could never be rendered in a test".
Two things are worth knowing before you touch it.
- **Start it through turbo.** `.storybook/main.ts` maps `@airship/editor-tokens`' `dist/fonts`
onto `/__airship/fonts`, which is where the overlay's `@font-face` rules point; Storybook
refuses to start when a `staticDirs` source is missing, so a bare
`pnpm --filter @airship/overlay storybook` fails on a clean checkout.
- **The browser tier is deliberately outside `pnpm test`.** It has its own config
(`vitest.browser.config.ts`) and its own script, because a test tier that launches Chromium
fails on any machine that has not downloaded it. It is also outside CI for now —
`@storybook/addon-a11y` fails the run on axe violations and the overlay still has a backlog of
them, so the lane would start red. `make test:browser` runs it on demand; `make browsers`
fetches the Chromium it needs.
Section stories render one section inside a *real* `DesignPanel` rather than against a stand-in
`SectionContext`, by shadowing one private method. The reasoning, and the six things that go
quietly wrong if you do it the other way, are in `src/stories/story-panel.ts`.
## Testing
`pnpm test` runs vitest across the workspace — around sixty suites, concentrated in
`packages/overlay` (the inspector's CSS reasoning), `packages/core` (provider event mapping,
diff capture, the sandbox), and `apps/cli/src/lib` (argument parsing, config resolution, port
detection, help rendering).
The CLI's own tests are worth reading before changing `args.ts`: citty parses but does not
validate, and `args.test.ts` pins the behaviour that covers the difference.
The browser tier is separate — see Storybook above.
## CI
Every PR into `main` runs:
- **`checks.yml`** — lint, then `turbo typecheck test` scoped with `--affected` against the PR
base. `make preflight` is the local equivalent, minus the scoping: locally there is no base, so
it runs repo-wide, and it layers on a route-tree drift check.
- **`branch-policy.yml`** — only `release/*`, `hotfix/*`, `security/*` and `dependabot/*`
branches may target `main`. Releases are cut from `release/*`, so this is what keeps the
release lane legible, and what makes `dependabot/*` an explicit exception rather than an
accident.
**The site's deploy is not in this repo.** `apps/web` ships through Cloudflare Workers Builds,
configured in the Cloudflare dashboard and wired to GitHub by the *Cloudflare Workers & Pages*
GitHub App — so there is no `web-deploy.yml` to find, and no `CLOUDFLARE_*` secret on the repo.
A push to `main` touching `apps/web/`, `packages/site-tokens/` or `packages/editor-tokens/`
runs `pnpm -w run build:web` and then `wrangler deploy`, updating the `airship-web` worker. Any
other branch runs `wrangler versions upload` instead, which publishes a *version* rather than
promoting it: the app posts that version's preview URL onto the PR, and production is untouched
until the merge. Build logs live in the dashboard, not in the Actions tab.
This is why `apps/web/wrangler.jsonc` declares no `env` block — one worker, and the branch
decides the command. `make web:deploy` remains as a manual override that authenticates as you.
**What gets deployed is prerendered, not server-rendered.** `vite.config.ts` passes
`prerender: { enabled: true }` to `tanstackStart()`, so the build runs the server bundle once and
writes `dist/client/index.html` — which is the worker's assets directory, so Cloudflare serves
the page as a static file and the worker is never invoked for a normal view. It is still built
and still deployed: it answers whatever the assets do not match, which is what renders the 404.
That has a consequence worth knowing before you reach for one. **A server function or route
loader added to this app will run at build time, not per request** — its result gets baked into
the HTML. That is correct for this site, which has one route and imports all of its copy from
`src/content/*.json`, but the day the page genuinely needs request-dependent output, turning
prerendering off is the change to make, not working around it.
## Releases
`@airshiplabs/cli` is the only package published to npm. Everything else in the workspace is
private and gets **inlined into the CLI bundle** at build time, so the published tarball declares
no `@airship/*` dependency.
The overlay IIFEs and the editor fonts cannot be inlined — they are assets the server resolves at
runtime — so `apps/cli/scripts/vendor-assets.mjs` copies them into `dist/vendor/`, and
`packages/server/src/proxy.ts` falls back to that copy when `require.resolve` finds nothing.
**That fallback is the whole reason a published install works**; if the overlay ever 404s from
npm but not from source, start there.
Two ways to cut a release, and they never both publish:
```bash
make release # local: bump, validate packaging, commit, tag — then
# `git push --follow-tags` fires release.yml
make release:ci BUMP=minor # all-in-CI: publish.yml does the whole thing
make release DRY=1 # validate everything, write nothing
```
`publish.yml` pushes its tag with `GITHUB_TOKEN`, which by design does not trigger other
workflows — so `release.yml` stays dormant for CI-cut releases. Both need an `NPM_TOKEN` secret
on the repo.
## Design decisions
Conscious engineering choices, not oversights:
- **Diff rendering is a self-contained renderer**, not `@pierre/diffs` + shiki. Keeps the overlay
IIFE small and dependency-light (no shiki in the browser bundle). The server still computes
patches with `diff`.
- **Undo is content-restore first** (instant, from the before-state captured by the SDK
`PreToolUse` hooks on Claude, or reconstructed from git on Codex). SDK `rewindFiles` is
implemented (`core.rewindEdit`) as the SDK-native alternative on Claude; the two are not run
concurrently. Codex has no native checkpointing, and `rewindEdit` says so rather than silently
doing nothing.
- **Session persistence uses the SDK's own `persistSession`** plus Airship's `~/.airship/history`
bundles (which record each `sessionId`), so resume survives a daemon restart — without a custom
`SessionStore`. A `SessionStore` adapter (S3/Redis) is the path for multi-host hosted mode.
- **The minimap draws rectangles, not thumbnails.** Every frame is a live same-origin
`iframe` running a full instance of the user's app. There is no paint capture anywhere in
the overlay and no cheap way to add one — `FrameAgent` exposes DOM, not pixels — an
`iframe` cannot be duplicated, and cloning one would boot a ninth app against a cap of
eight. So `canvas/minimap.ts` positions plain `div`s, the way `chrome-layer.ts` draws
every other piece of canvas chrome.
- **Frame reorder publishes `z-index`; it never moves DOM nodes.** Moving an `iframe` in the
document tears down and rebuilds its browsing context, so the obvious implementation of
drag-to-restack would reload the app inside whichever frame you dragged, losing its route
and scroll position. `FrameManager.reorder` splices the array and `applyOrder` writes the
index as `z-index` instead; `frames.test.ts` asserts that no element moves.
- **A `feedback: "none"` draggable must be registered `POINTER_ONLY`.** dnd-kit's
`KeyboardSensor` needs `dragOperation.shape`, which only the Feedback plugin publishes —
so a "none" draggable starts a keyboard drag that no arrow key can move, and swallows the
Tab out of it. Nine of the overlay's draggables have no keyboard route for this reason;
where one is wanted it is a real command beside the drag, not a keyboard drag (see
`FramesPanel.moveBy`, and `num-field.ts`'s `stepBy` beside its scrub).
- **Deferred for now:** multi-element select, `startup()` pre-warm (a latency optimization that
requires threading a warm handle through each edit), and the Vite/Next plugin wrappers plus
hosted mode.
## `reference/`
`reference/` holds the original `spidey-sense`, `layrr`, `element-source`, and `diffity`
projects for context — Airship unifies the ideas behind the first two on a single core. It is
git-ignored and never built.