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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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%
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user