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.
581 lines
33 KiB
Markdown
581 lines
33 KiB
Markdown
# 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.
|