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.
This commit is contained in:
Nayan
2026-08-16 13:05:37 +05:30
parent 3f39fdbe58
commit 4c2d1c822d
8 changed files with 607 additions and 26 deletions
+5
View File
@@ -2,3 +2,8 @@
# otherwise write CRLF, which breaks the front-matter regexes in the
# editor-tokens/editor-icons/site-tokens gen scripts).
* text=auto eol=lf
# ...except the one file cmd.exe reads. Batch parsing is only reliable with
# CRLF, and this is the Windows door onto the dev CLI. Its sibling `airship`
# must stay LF for the opposite reason: bash chokes on a CRLF shebang.
airship.cmd text eol=crlf
+19
View File
@@ -65,6 +65,25 @@ jobs:
- name: Build (every package)
run: pnpm build
# The ./airship wrapper is the documented way to run the dev CLI, and it
# is the only thing here with a per-platform implementation — a bash shim
# and a .cmd one. Nothing else exercises either, so a broken path or a
# CRLF shebang would reach contributors instead of CI.
#
# --skip-build keeps this a pure shim test — path resolution, argv
# passthrough, exit code — with no build time, since the step above
# already produced dist/.
- name: Dev CLI wrapper (posix)
if: matrix.os != 'windows-latest'
run: ./airship --skip-build --version
# shell: cmd because the runner's default on Windows is pwsh, and cmd.exe
# is what airship.cmd exists for.
- name: Dev CLI wrapper (windows)
if: matrix.os == 'windows-latest'
shell: cmd
run: airship.cmd --skip-build --version
# apps/cli/README.md is generated from the root README.md, and it is what
# npmjs.com renders for @airshiplabs/cli. Committed rather than built on
# demand so a clean checkout can publish without running the generator.
+97 -7
View File
@@ -30,6 +30,11 @@ Open <http://localhost:5174> and you are looking at Airship, with Airship's own
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
@@ -38,15 +43,16 @@ Everything builds, tests and runs on Windows — `checks.yml` gates every PR on
`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` or `node` call — so use those directly:
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` | `node apps/cli/dist/index.js --target 5173 --cwd apps/web` |
| `make run:solo` | `node apps/cli/dist/index.js --cwd apps/web --exec "pnpm dev:web"` |
| `make doctor` | `node apps/cli/dist/index.js doctor --cwd apps/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` |
@@ -56,6 +62,14 @@ wrapper either way — every recipe is one `pnpm` or `node` call — so use thos
`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
@@ -96,6 +110,7 @@ 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
@@ -105,15 +120,90 @@ drift check, which is exactly what CI gates a PR on — run it before opening on
Toolchain: **pnpm** workspaces + **Turborepo**, **Biome** via **Ultracite**, **Husky** +
**commitlint** for Conventional Commits, **tsup** builds.
### Two turbo edges worth knowing
### Running the dev CLI
Both are the same edge for the same reason: a package's `dist` has to exist before something
else can start against it.
`./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
+23 -19
View File
@@ -31,8 +31,6 @@ TARGET ?= 5173
# URLs the dev server reports (/src/app.tsx) against this.
APP := apps/web
CLI := apps/cli/dist/index.js
# Generated, and committed on purpose — the route tree is checked in so a clean
# checkout typechecks without a build first (see .gitignore). Committed
# generated files drift, so preflight regenerates and diffs them.
@@ -248,31 +246,37 @@ storybook\:build: ## Build the static story catalogue into storybook-static/
##@ CLI (drive airship against APP)
# These are presets, not the CLI. Every one of them is `./airship` with the
# flags this repo usually wants — so for anything the presets do not model
# (--effort, --max-budget, a second --allowed-hosts) skip make and run
# `./airship` directly. It is a pure passthrough, and it owns the build check:
# the workspace packages are INLINED into the CLI bundle, so it rebuilds when
# they change. That check used to live here as a `$(CLI):` file rule, which only
# fired when dist was missing and so ran a stale bundle after every packages/
# edit. See CONTRIBUTING.md § Running the dev CLI.
.PHONY: run run\:safe run\:codex run\:opencode run\:inline run\:solo doctor demo
run: $(CLI) ## Point airship at apps/web (its dev server must be up)
@node $(CLI) --target $(TARGET) --cwd $(APP)
run: ## Point airship at apps/web (its dev server must be up)
@./airship --target $(TARGET) --cwd $(APP)
run\:safe: $(CLI) ## Same as run, with the agent confined to the project
@node $(CLI) --target $(TARGET) --cwd $(APP) --safe
run\:safe: ## Same as run, with the agent confined to the project
@./airship --target $(TARGET) --cwd $(APP) --safe
run\:codex: $(CLI) ## Same as run, on Codex instead of Claude
@node $(CLI) --target $(TARGET) --cwd $(APP) --agent codex
run\:codex: ## Same as run, on Codex instead of Claude
@./airship --target $(TARGET) --cwd $(APP) --agent codex
run\:opencode: $(CLI) ## Same as run, on OpenCode (needs `opencode` on PATH)
@node $(CLI) --target $(TARGET) --cwd $(APP) --agent opencode
run\:opencode: ## Same as run, on OpenCode (needs `opencode` on PATH)
@./airship --target $(TARGET) --cwd $(APP) --agent opencode
run\:inline: $(CLI) ## Same as run, on the inline surface instead of the canvas
@node $(CLI) --target $(TARGET) --cwd $(APP) --mode inline
run\:inline: ## Same as run, on the inline surface instead of the canvas
@./airship --target $(TARGET) --cwd $(APP) --mode inline
run\:solo: $(CLI) ## Run apps/web's dev server and airship together, one terminal
@node $(CLI) --cwd $(APP) --exec "pnpm turbo run dev --filter=@airship/web"
run\:solo: ## Run apps/web's dev server and airship together, one terminal
@./airship --cwd $(APP) --exec "pnpm turbo run dev --filter=@airship/web"
doctor: $(CLI) ## Check the environment and report what is wrong
@node $(CLI) doctor --cwd $(APP)
$(CLI):
@pnpm build
doctor: ## Check the environment and report what is wrong
@./airship doctor --cwd $(APP)
demo: ## One-shot: install + build, then print the two-terminal recipe
@$(MAKE) install build
Executable
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# The workspace's own airship, in shorthand.
#
# A pure passthrough to the CLI built from this tree — `./airship <anything>`
# behaves exactly like the published `airship` binary, so everything in
# `airship --help` works verbatim and a bare `./airship` gives you the real
# wizard. The one thing it adds is freshness: the workspace packages are inlined
# into the CLI bundle, so it rebuilds when they change.
#
# ./airship --target 3000 --cwd ../my-app
# ./airship doctor
# ./airship --skip-build ... # trust dist as-is (also AIRSHIP_SKIP_BUILD=1)
# ./airship --force-build ... # rebuild regardless (also AIRSHIP_FORCE_BUILD=1)
#
# All the logic lives in scripts/airship-run.mjs — in Node rather than here so
# that airship.cmd gives Windows the same behaviour instead of a degraded
# passthrough. See CONTRIBUTING.md § Running the dev CLI.
set -euo pipefail
# Resolve through symlinks, so `ln -s /path/to/repo/airship ~/bin/airship` works.
src="${BASH_SOURCE[0]}"
while [ -L "$src" ]; do
dir="$(cd -P "$(dirname "$src")" && pwd)"
src="$(readlink "$src")"
[[ $src != /* ]] && src="$dir/$src"
done
here="$(cd -P "$(dirname "$src")" && pwd)"
# exec, so no shell sits between your terminal and the CLI: signal delivery and
# exit codes stay exact, which matters for a long-lived server you Ctrl-C.
exec node "$here/scripts/airship-run.mjs" "$@"
+19
View File
@@ -0,0 +1,19 @@
@echo off
:: The workspace's own airship, in shorthand — the Windows half of ./airship.
::
:: `make` is not available here (see CONTRIBUTING.md § On Windows), so this is
:: the supported way to run the CLI built from this tree. It is a pure
:: passthrough, and it rebuilds when the inlined workspace packages change,
:: exactly like the bash script does.
::
:: airship --target 3000 --cwd ..\my-app
:: airship doctor
::
:: %* rather than %1 %2 ...: it forwards the command line with its original
:: quoting intact, which `airship --exec "pnpm dev"` depends on. %~dp0 is this
:: file's own directory, with a trailing backslash, so nothing here depends on
:: the working directory — which must reach the CLI untouched.
node "%~dp0scripts\airship-run.mjs" %*
:: Explicit, so the CLI's exit codes (2 usage, 127 unknown command) survive
:: being called from another script.
exit /b %errorlevel%
+12
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
assertKnownFlags,
collectRepeated,
FLAGS,
requireAmount,
requireEnum,
requireHost,
@@ -247,3 +248,14 @@ describe("closest", () => {
expect(closest("opencode-ur", ["opencode-url"])).toBe("opencode-url");
});
});
// scripts/airship-run.mjs — the ./airship dev wrapper — consumes these two and
// strips them before argv reaches this CLI. If either ever became a real flag,
// the wrapper would silently eat it and the repo's binary would diverge from
// the published one for anyone using ./airship. Rename the wrapper's flag
// instead; do not delete this test.
describe("flags reserved by the dev wrapper", () => {
it.each(["skip-build", "force-build"])("does not define --%s", (name) => {
expect(FLAGS.map((flag) => flag.name)).not.toContain(name);
});
});
+401
View File
@@ -0,0 +1,401 @@
/**
* Runs the workspace's own build of the CLI, rebuilding it first if it is stale.
*
* This is what `./airship` and `airship.cmd` call. It is a PASSTHROUGH: every
* argument goes to the CLI untouched, so `./airship <anything>` behaves exactly
* like the published `airship` binary. `make run` and friends are presets on
* top of it, not a different path.
*
* ./airship --target 3000 --cwd ../my-app
* ./airship doctor
* ./airship # the real wizard, same as a user gets
*
* Why it exists at all: apps/cli/tsup.config.ts sets `noExternal: [/^@airship\//]`,
* so every workspace package is INLINED into apps/cli/dist/index.js — that is why
* they are all devDependencies, and why the published tarball declares no
* @airship/* dependency. The consequence is easy to miss: a change anywhere in
* packages/* does nothing until the CLI is rebuilt. The Makefile could not catch
* that (`$(CLI):` was a file rule with no source prerequisites, so it only fired
* when dist was missing), which meant editing the overlay and running `make run`
* silently ran the old bundle.
*
* So: mtime-scan the sources, and shell out to turbo only when something moved.
* The scan is NOT a reimplementation of turbo's cache — turbo remains the source
* of truth for what actually needs rebuilding. The scan is a ~8ms doorman in
* front of a ~150ms turbo run, which is worth having on a command you type all
* day. It is deliberately coarse: a false positive costs one cached turbo run,
* a false negative runs stale code.
*
* Wrapper-owned flags, consumed here and never forwarded:
*
* --skip-build skip the scan and the build (AIRSHIP_SKIP_BUILD=1)
* --force-build build regardless of the scan (AIRSHIP_FORCE_BUILD=1)
*
* They are NOT airship flags and will not appear in `airship --help`.
*
* They are deliberately not called `--no-build`, which is the obvious name and
* the wrong one: apps/cli/src/lib/args.ts generates `--no-<name>` for every
* boolean flag, and the README documents that as user-facing syntax. `--no-build`
* therefore reads as CLI syntax, and would become argv this script silently ate
* the day anyone adds a boolean `build` flag. Anything before a `--` is fair game
* for us; anything after it is the CLI's, matching `assertKnownFlags`.
*/
import { spawn, spawnSync } from "node:child_process";
import { readdirSync, statSync, utimesSync } from "node:fs";
import { createRequire } from "node:module";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = fileURLToPath(new URL("..", import.meta.url));
const CLI_PACKAGE = "@airshiplabs/cli";
const CLI_ENTRY = join(ROOT, "apps", "cli", "dist", "index.js");
// vendor-assets.mjs copies the overlay IIFEs and the editor fonts in here after
// tsup runs. `tsup --watch` (apps/cli's `dev` script) has `clean: true` and does
// NOT re-run that step, so a watch loop leaves this missing — which is a dist
// that no longer matches the published layout, and worth rebuilding for.
const VENDOR_PROBE = join(ROOT, "apps", "cli", "dist", "vendor");
// Directories that are build output or machinery, never input. Skipping `dist`
// is not an optimisation but a correctness requirement: the scan compares
// against dist/index.js, so walking dist/ would always find something at least
// as new and rebuild forever. Skipping node_modules likewise — pnpm symlinks
// every workspace package into apps/cli/node_modules/@airship/*, so the walk
// would otherwise wander into the store. Dotted names are skipped separately.
const SKIP = new Set(["dist", "node_modules", "storybook-static"]);
// Same spelling convention as the CLI's own AIRSHIP_* booleans, so there is one
// rule to learn. See apps/cli/src/lib/config.ts.
const TRUTHY = new Set(["1", "true", "yes", "on"]);
const FALSY = new Set(["0", "false", "no", "off", ""]);
const COLOR = Boolean(
process.env.FORCE_COLOR || (process.stderr.isTTY && !process.env.NO_COLOR)
);
const CYAN = COLOR ? "\u001b[0;36m" : "";
const RED = COLOR ? "\u001b[0;31m" : "";
const RESET = COLOR ? "\u001b[0m" : "";
/**
* Progress goes to stderr, never stdout: `airship --json` writes machine-readable
* output on stdout and a rebuild notice in the middle of it would corrupt the
* parse. Same reason turbo's own output is redirected in runBuild().
*/
function note(message) {
process.stderr.write(`${CYAN}»${RESET} ${message}\n`);
}
function fail(message, hint, code = 1) {
process.stderr.write(`${RED}✖${RESET} ${message}\n`);
if (hint) {
process.stderr.write(` ${hint}\n`);
}
process.exit(code);
}
function envBoolean(name) {
const raw = process.env[name];
if (raw === undefined) {
return false;
}
const value = raw.trim().toLowerCase();
if (TRUTHY.has(value)) {
return true;
}
if (FALSY.has(value)) {
return false;
}
return fail(
`${name} is not a boolean: '${raw}'`,
"Use 1/true/yes/on or 0/false/no/off."
);
}
/** 0 for anything unreadable, which callers read as "older than everything". */
function mtimeOf(path) {
try {
return statSync(path).mtimeMs;
} catch {
return 0;
}
}
function splitArgs(argv) {
const passthrough = [];
let skip = envBoolean("AIRSHIP_SKIP_BUILD");
let force = envBoolean("AIRSHIP_FORCE_BUILD");
let ours = true;
for (const arg of argv) {
if (ours && arg === "--") {
// Everything past here belongs to the CLI, including tokens that look
// like ours. Mirrors assertKnownFlags in apps/cli/src/lib/args.ts.
ours = false;
passthrough.push(arg);
continue;
}
if (ours && arg === "--skip-build") {
skip = true;
continue;
}
if (ours && arg === "--force-build") {
force = true;
continue;
}
passthrough.push(arg);
}
return { force, passthrough, skip };
}
/**
* apps/cli plus every packages/* — the package ROOTS, not their src/.
*
* Scanning src/ alone would be wrong, and quietly so. `turbo run build
* --filter=@airshiplabs/cli --dry=json` reports the real input set, and it
* reaches well outside src/: the CLI hashes README.md, package.json,
* tsconfig.json, tsup.config.ts, vitest.config.ts and scripts/vendor-assets.mjs,
* while @airship/editor-icons is generated from 507 SVGs under assets/ plus
* ICONS.md — edit an icon and a src/-only scan would see nothing at all.
*
* Globbing packages/* rather than walking the CLI's real dependency graph
* over-scans by exactly one package (site-tokens, which only apps/web uses).
* That never drifts as the graph changes, and the cost of the false positive is
* one cached turbo run. apps/web is not scanned: it cannot affect the bundle.
*/
function scanRoots() {
const roots = [join(ROOT, "apps", "cli")];
const packages = join(ROOT, "packages");
let entries;
try {
entries = readdirSync(packages, { withFileTypes: true });
} catch {
return roots;
}
for (const entry of entries) {
if (entry.isDirectory()) {
roots.push(join(packages, entry.name));
}
}
return roots;
}
/** First path under `dir` newer than `since`, or null. Stops at the first hit. */
function findNewer(dir, since) {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
// A root that is not there — a package removed, a partial checkout — is not
// this script's problem to report. turbo is the authority on what builds.
return null;
}
// The directory's own mtime counts. Deleting a source file moves no surviving
// file's timestamp, only its parent's, and a deletion changes the bundle.
if (mtimeOf(dir) > since) {
return dir;
}
for (const entry of entries) {
if (entry.name.startsWith(".") || SKIP.has(entry.name)) {
continue;
}
const path = join(dir, entry.name);
if (entry.isDirectory()) {
const found = findNewer(path, since);
if (found) {
return found;
}
continue;
}
if (mtimeOf(path) > since) {
return path;
}
}
return null;
}
function findStaleInput(builtAt) {
for (const root of scanRoots()) {
const found = findNewer(root, builtAt);
if (found) {
return found;
}
}
return null;
}
function runBuild() {
// Captured BEFORE the build, and used as the stamp afterwards. Stamping with
// the finish time instead would mask a file saved while a slow build was
// running: it would land between start and finish, be missed by the build,
// and then look older than the bundle forever after.
const startedAt = new Date();
const require = createRequire(import.meta.url);
let turbo;
try {
// turbo/bin/turbo is a Node script that dispatches to the platform binary,
// so running it under process.execPath works identically on Windows without
// a node_modules/.bin/*.cmd shim or `shell: true` (which Node 22 would
// demand for a .cmd, and which reintroduces quoting bugs).
turbo = require.resolve("turbo/bin/turbo");
} catch {
return fail(
"turbo is not installed.",
"Run `pnpm install` at the repo root first."
);
}
const result = spawnSync(
process.execPath,
[
turbo,
"run",
"build",
`--filter=${CLI_PACKAGE}`,
// Show only the tasks that actually ran. Without this, turbo replays the
// full cached log of all nine packages — hundreds of lines — every time
// one source file moved, which buries the one task that did work.
"--output-logs=new-only",
],
{
cwd: ROOT,
// Both streams to fd 2: turbo's progress is diagnostic, and letting it
// reach stdout would corrupt `airship --json`.
stdio: ["ignore", 2, 2],
}
);
if (result.error) {
return fail(`could not run turbo: ${result.error.message}`);
}
if (result.status !== 0) {
// Deliberately does not fall through to the bundle already on disk.
// Launching yesterday's binary after a failed build is the exact confusion
// this wrapper exists to end.
return fail(
"the build failed — not launching the stale bundle.",
"To run the existing bundle anyway: ./airship --skip-build …",
result.status ?? 1
);
}
// Stamp the entry, or the gate never closes. On a cache hit turbo replays the
// logs and leaves the existing dist/ untouched — the bundle is correct, but
// its mtime is still older than the source you just edited, so the next run
// would find it stale and rebuild again, and again, forever. Stamping makes
// the mtime mean "when the wrapper last confirmed this bundle matches the
// sources", which is the question actually being asked. Turbo hashes inputs,
// not output timestamps, so this cannot affect its caching.
//
// Only when turbo did NOT rewrite the entry, though. A real build already
// wrote it, later than everything it consumed, and stamping backwards to
// startedAt would reopen a window: tsup has `clean: true`, so it recreates
// apps/cli/dist and thereby bumps apps/cli's own directory mtime past
// startedAt, costing one redundant rebuild on the next run. A missing entry
// is left for the check below to report.
if (mtimeOf(CLI_ENTRY) < startedAt.getTime()) {
try {
utimesSync(CLI_ENTRY, startedAt, startedAt);
} catch {
// A stamp we cannot write costs one redundant cached build next run.
// Never worth failing a launch over.
}
}
}
// apps/cli builds for node22 and its engines field says >=22.13.0. Catch that
// here rather than letting it surface as a syntax error from inside the bundle.
const MIN_NODE_MAJOR = 22;
if (Number(process.versions.node.split(".")[0]) < MIN_NODE_MAJOR) {
fail(
`airship needs Node ${MIN_NODE_MAJOR}.13 or later — this is ${process.versions.node}.`,
"The version this repo expects is in .nvmrc."
);
}
const { force, passthrough, skip } = splitArgs(process.argv.slice(2));
if (!skip) {
// Ordered, and the dist checks come first unconditionally: `pnpm clean`
// removes apps/cli/dist, so anything that trusted a timestamp alone would
// happily call a deleted bundle fresh.
const builtAt = mtimeOf(CLI_ENTRY);
if (builtAt === 0) {
note(`${relative(ROOT, CLI_ENTRY)} is missing — building ${CLI_PACKAGE}`);
runBuild();
} else if (mtimeOf(VENDOR_PROBE) === 0) {
note(
`${relative(ROOT, VENDOR_PROBE)} is missing — building ${CLI_PACKAGE}`
);
runBuild();
} else if (force) {
note(`--force-build — rebuilding ${CLI_PACKAGE}`);
runBuild();
} else {
const stale = findStaleInput(builtAt);
if (stale) {
note(
`${relative(ROOT, stale)} is newer than the CLI bundle — rebuilding ${CLI_PACKAGE}`
);
runBuild();
}
}
}
if (mtimeOf(CLI_ENTRY) === 0) {
// Two ways to land here, and they want opposite advice: --skip-build over a
// tree that was never built, or a "successful" build that emitted no entry.
fail(
`${relative(ROOT, CLI_ENTRY)} does not exist.`,
skip
? "Drop --skip-build so the wrapper can build it, or run `make build`."
: "The build reported success but produced no entry — try `make build`."
);
}
// No `cwd` here, on purpose. `--cwd` is resolved against process.cwd()
// (apps/cli/src/lib/settings.ts), loadConfig walks upward from it, and a bare
// run passes it to the wizard — so moving the cwd would silently change what
// `./airship --cwd ../my-app` means.
const child = spawn(process.execPath, [CLI_ENTRY, ...passthrough], {
stdio: "inherit",
});
// Signals are split by where they come from, and the difference matters.
//
// SIGINT (and SIGBREAK) originate at the terminal, which delivers them to the
// whole foreground process group — the child has already got its own copy. All
// we must do is not die: serve.ts drains the proxy, stops any --exec dev server
// and exits 0, and if we took the default action we would hand the shell back a
// prompt while that was still running, orphaning a process on the port.
// Forwarding here would be actively harmful, because a second SIGINT is the
// CLI's own "stop waiting, exit now" escape hatch (serve.ts:398).
//
// SIGTERM has no terminal behind it — it arrives from `kill`, a supervisor or a
// CI runner, addressed to this pid alone. Swallowing it would hang, so it is the
// one we pass on, and let the CLI shut down the same way.
const swallow = () => undefined;
process.on("SIGINT", swallow);
if (process.platform === "win32") {
process.on("SIGBREAK", swallow);
}
process.on("SIGTERM", () => {
child.kill("SIGTERM");
});
child.on("error", (error) => {
fail(`could not start the CLI: ${error.message}`);
});
child.on("close", (code, signal) => {
if (signal) {
// Drop our handler first or the re-raise hits the no-op above and hangs.
process.removeAllListeners(signal);
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});