diff --git a/.gitattributes b/.gitattributes index f9c77dd..6fbe861 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index fadd8e4..1429b51 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6a435a..b05a4b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,11 @@ Open 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 diff --git a/Makefile b/Makefile index b94a2cf..8ca58d0 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/airship b/airship new file mode 100755 index 0000000..9534f04 --- /dev/null +++ b/airship @@ -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 ` +# 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" "$@" diff --git a/airship.cmd b/airship.cmd new file mode 100644 index 0000000..2e5396a --- /dev/null +++ b/airship.cmd @@ -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% diff --git a/apps/cli/src/lib/args.test.ts b/apps/cli/src/lib/args.test.ts index cf0d61c..e2b9bc2 100644 --- a/apps/cli/src/lib/args.test.ts +++ b/apps/cli/src/lib/args.test.ts @@ -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); + }); +}); diff --git a/scripts/airship-run.mjs b/scripts/airship-run.mjs new file mode 100644 index 0000000..07a2224 --- /dev/null +++ b/scripts/airship-run.mjs @@ -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 ` 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-` 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); +});