Merge pull request #12 from 0xnyn/release/windows-fix

fix: make airship build and run on Windows
This commit is contained in:
Nayan
2026-08-12 01:02:51 +05:30
committed by GitHub
49 changed files with 1097 additions and 248 deletions
+4
View File
@@ -0,0 +1,4 @@
# Check out everything with LF, even on Windows (core.autocrlf=true would
# otherwise write CRLF, which breaks the front-matter regexes in the
# editor-tokens/editor-icons/site-tokens gen scripts).
* text=auto eol=lf
+40 -3
View File
@@ -21,7 +21,16 @@ concurrency:
jobs:
check:
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false
runs-on: ubuntu-latest
# Windows is a first-class target — the CLI installs from npm onto it — but
# it went untested until a contributor reported that a fresh clone could not
# build there at all. Three separate path/line-ending bugs had shipped
# invisibly because every lane ran on Linux only. fail-fast is off so a
# Windows-only break still reports the Linux result, and vice versa.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
with:
@@ -33,7 +42,12 @@ jobs:
- name: Lint (Biome via Ultracite)
run: pnpm lint
# `shell: bash` on every multi-line block below: the default shell on
# windows-latest is pwsh, which shares none of this syntax — no `[ -n … ]`,
# no `if ! cmd`, and `${VAR}` is not env expansion. Git Bash ships on the
# Windows runner, so pinning the shell is enough; nothing needs rewriting.
- name: Typecheck + test (affected)
shell: bash
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
run: |
@@ -43,10 +57,25 @@ jobs:
pnpm turbo run typecheck test
fi
# Unconditional and unscoped, unlike the step above. `--affected` is what
# let the Windows build bugs through: they lived in build scripts
# (check-css.mjs, vendor-assets.mjs, the gen.mjs front-matter readers), and
# a PR that touched none of the affected packages never ran them. This is
# the step that actually proves a clean checkout builds on both platforms.
- name: Build (every package)
run: pnpm build
# 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.
#
# Linux only, here and below: both of these check that a COMMITTED
# generated file matches what the generator emits. That is a property of
# the repo, not of the platform, so running it twice doubles the runtime
# and the flake surface for no extra signal.
- name: README is not stale
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
if ! node scripts/sync-readme.mjs --check; then
echo "::error file=apps/cli/README.md::The CLI README is stale. Run 'make readme' and commit the result."
@@ -57,16 +86,24 @@ jobs:
# Regenerated by BUILDING, not by `tsr generate`: two things write this
# file and they disagree — the router CLI emits the tree alone, while the
# Start Vite plugin appends the `declare module` block that registers the
# router type for SSR. The build's output is the committed one.
# router type for SSR. The build's output is the committed one. The build
# step above already wrote it; this only reads the result.
- name: Route tree is not stale
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
pnpm turbo run build --filter=@airship/web
if ! git diff --quiet -- apps/web/src/routeTree.gen.ts; then
git diff --stat -- apps/web/src/routeTree.gen.ts
echo "::error file=apps/web/src/routeTree.gen.ts::The route tree is stale. Run 'make web:build' and commit the result."
exit 1
fi
# Last, because it deletes what everything above produced. Every workspace
# `clean` was `rm -rf` until this lane existed to catch it — a command that
# does not exist on Windows, so `pnpm clean` failed in all eleven packages.
- name: Clean (removes what the build produced)
run: pnpm clean
commitlint:
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
runs-on: ubuntu-latest
+37
View File
@@ -30,6 +30,43 @@ 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`.
### On Windows
Everything builds, tests and runs on Windows — `checks.yml` gates every PR on a
`windows-latest` leg alongside Linux, so a break there fails the PR.
`make` is the one thing that does not carry over: the Makefile declares
`SHELL := /bin/bash` and a handful of targets genuinely need it (`help` is an `awk`
program, `preflight` a shell conditional, `release` a bash script). It is a thin
wrapper either way — every recipe is one `pnpm` or `node` 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 check` | `pnpm lint && pnpm typecheck && pnpm test` |
| `make readme` | `node scripts/sync-readme.mjs` |
| `make storybook`| `pnpm turbo run storybook --filter=@airship/overlay` |
`pnpm dev:web` rather than a bare `vite dev`: the site cannot start until
`@airship/site-tokens` has emitted `dist/tokens.css`, and only turbo knows that.
Two things worth setting up once:
- **Git Bash**, which ships with Git for Windows, runs the Husky hooks and the release
scripts. Without a POSIX `sh` on PATH the pre-commit formatter silently does not run.
- **Developer Mode** (Settings → System → For developers), so pnpm can create the
symlinks its `node_modules` layout depends on without elevation.
Line endings are pinned to LF by [`.gitattributes`](.gitattributes) — do not override it
with `core.autocrlf`. Several generators parse their input with anchored regexes, and a
CRLF checkout makes them report a missing front-matter block rather than a wrong one.
If you cloned before that file existed, renormalize once with
`git rm --cached -r . && git reset --hard`.
## Repo layout
| Package | Role |
+2
View File
@@ -428,6 +428,8 @@ and provider it would use from your terminal.
Node 22.13 or later, and one of Claude Code, OpenAI Codex or OpenCode.
macOS, Linux and Windows. Every PR is built and tested on Linux and Windows.
## Links
- [airship.design](https://airship.design)
+2
View File
@@ -430,6 +430,8 @@ and provider it would use from your terminal.
Node 22.13 or later, and one of Claude Code, OpenAI Codex or OpenCode.
macOS, Linux and Windows. Every PR is built and tested on Linux and Windows.
## Links
- [airship.design](https://airship.design)
+1 -1
View File
@@ -31,7 +31,7 @@
],
"scripts": {
"build": "tsup && node scripts/vendor-assets.mjs",
"clean": "rm -rf dist .turbo",
"clean": "node ../../scripts/clean.mjs dist .turbo",
"dev": "tsup --watch",
"test": "vitest run",
"typecheck": "tsc --noEmit"
+6 -3
View File
@@ -17,7 +17,7 @@
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
@@ -50,7 +50,10 @@ function resolve(specifier, what) {
// serves `${bundle}.map` derived from whatever path it resolved, so the map has
// to land beside its bundle here too.
function copyWithMap(from, toDir) {
const name = from.split("/").pop();
// basename, not split("/"): require.resolve returns a native path, so on
// Windows there is no "/" to split on and `name` would come back as the whole
// absolute path — which join() then appends to the destination wholesale.
const name = basename(from);
copyFileSync(from, join(toDir, name));
if (existsSync(`${from}.map`)) {
copyFileSync(`${from}.map`, join(toDir, `${name}.map`));
@@ -87,5 +90,5 @@ for (const name of expected) {
}
console.log(
`vendor-assets: ${expected.length} bundles + ${FONTS.length} fonts -> ${dirname(vendorDir)}/vendor`
`vendor-assets: ${expected.length} bundles + ${FONTS.length} fonts -> ${vendorDir}`
);
+30 -1
View File
@@ -204,6 +204,28 @@ async function assertFree(port: number): Promise<void> {
}
}
/**
* Turn a failure to bind the overlay's port into something actionable.
*
* EACCES is the Windows-specific one: Hyper-V, WSL2 and Docker Desktop reserve
* whole ranges of dynamic ports, and a port inside one accepts no connection
* yet refuses to be bound — so it looks free right up until it isn't.
*/
function asBindError(err: unknown, port: number): unknown {
const code = (err as NodeJS.ErrnoException | null)?.code;
if (code === "EADDRINUSE") {
return new CliError(`Port ${port} is already in use`, {
hint: "Pass --port with a free one.",
});
}
if (code === "EACCES") {
return new CliError(`Not allowed to bind port ${port}`, {
hint: "On Windows this port may sit in a reserved range (see `netsh interface ipv4 show excludedportrange tcp`). Pass --port with one outside it.",
});
}
return err;
}
export const serve = defineCommand({
args: argsFor(SERVE_FLAGS),
meta: {
@@ -274,7 +296,7 @@ export const serve = defineCommand({
// We started the dev server; if the proxy cannot come up it is ours to
// clean up, or the user is left with a stray process holding the port.
await dev?.stop();
throw err;
throw asBindError(err, port);
}
if (opts.json) {
@@ -332,5 +354,12 @@ export const serve = defineCommand({
};
process.on("SIGINT", onSignal);
process.on("SIGTERM", onSignal);
// Windows never delivers SIGTERM — registering it is legal, it just never
// fires — so without SIGBREAK the only clean exit there is Ctrl-C. Ctrl-Break
// and a console close would otherwise skip shutdown entirely and strand the
// `opencode serve` child and any dev server we started.
if (process.platform === "win32") {
process.on("SIGBREAK", onSignal);
}
},
});
+20 -1
View File
@@ -172,6 +172,25 @@ export async function detectTarget(
return first ? { ...first, listening: false } : undefined;
}
/**
* Whether we can actually take this port, by taking it and letting go.
*
* `isListening` answers a different question — "is something accepting here" —
* and the two diverge on Windows, where Hyper-V, WSL2 and Docker Desktop
* reserve whole ranges of dynamic ports (`netsh interface ipv4 show
* excludedportrange tcp`). Nothing accepts on a reserved port, so a connect
* probe calls it free, and the bind then fails with EACCES.
*/
function canBind(port: number, host: string): Promise<boolean> {
return new Promise((resolvePromise) => {
const probe = net.createServer();
probe.once("error", () => resolvePromise(false));
probe.listen({ host, port }, () => {
probe.close(() => resolvePromise(true));
});
});
}
/** A port nothing is bound to, so the proxy does not collide on startup. */
export async function firstFreePort(
start: number,
@@ -182,7 +201,7 @@ export async function firstFreePort(
// Also ordered: "the first free port at or after `start`" is the answer, so
// the probes cannot be collapsed into one parallel batch.
// biome-ignore lint/performance/noAwaitInLoops: ordered first-match search
if (!(await isListening(port, host))) {
if (await canBind(port, host)) {
return port;
}
}
+38 -13
View File
@@ -10,11 +10,13 @@
* first, and our own shutdown would be racing a corpse.
*/
import { type ChildProcess, spawn } from "node:child_process";
import { type ChildProcess, spawn, spawnSync } from "node:child_process";
import { isListening } from "./detect";
import { CliError } from "./errors";
import { style } from "./terminal";
const WIN32 = process.platform === "win32";
const READY_TIMEOUT_MS = 90_000;
const POLL_INTERVAL_MS = 250;
const STOP_GRACE_MS = 3000;
@@ -33,9 +35,16 @@ function killTree(child: ChildProcess, signal: NodeJS.Signals): void {
return;
}
try {
if (process.platform === "win32") {
// No process groups to negate; Node maps this to TerminateProcess.
child.kill(signal);
if (WIN32) {
// `child` here is cmd.exe, not the dev server — we spawn with `shell`,
// so the real tree is cmd.exe -> pnpm -> node -> Vite. `child.kill()`
// maps to TerminateProcess on cmd.exe alone and leaves that tree running,
// still holding the port, outliving airship and blocking the next launch.
// taskkill /T is the only way to take the descendants with it.
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
return;
}
// Negative pid = the whole group, which is what actually stops a dev server
@@ -87,12 +96,25 @@ export async function startDevServer(opts: {
child.once("exit", (code, signal) => {
exited = { code, signal };
});
// A shell that will not start emits `error` and never `exit`. Without this
// the readiness loop below would wait the full 90s for a process that was
// never running — and an unheard `error` event throws.
child.once("error", () => {
exited ??= { code: null, signal: null };
});
const stop = async (): Promise<void> => {
if (exited) {
return;
}
killTree(child, "SIGTERM");
// Windows has no graceful signal — killTree already went straight to
// `taskkill /F`, so there is nothing to escalate to and nothing to wait
// for. Polling for three seconds before repeating the same forced kill
// would only delay shutdown.
if (WIN32) {
return;
}
for (let waited = 0; waited < STOP_GRACE_MS; waited += POLL_INTERVAL_MS) {
if (exited) {
return;
@@ -134,26 +156,29 @@ export async function startDevServer(opts: {
}
}
const BROWSER_OPENERS: Record<string, string> = {
darwin: "open",
win32: "start",
};
/** Open a URL in the default browser, best-effort. */
export function openInBrowser(url: string): void {
const command = BROWSER_OPENERS[process.platform] ?? "xdg-open";
// `cmd /c start "" <url>` on Windows rather than `shell: true` + `start`.
// With a shell Node does no argument escaping, so a URL carrying `&` would be
// split by cmd.exe into two commands; and `start` reads its first quoted
// argument as a window title, which is what the empty string absorbs. Same
// form as openUrl in @airship/server's open-editor.
const [command, args] =
process.platform === "win32"
? (["cmd", ["/c", "start", "", url]] as const)
: ([process.platform === "darwin" ? "open" : "xdg-open", [url]] as const);
try {
// Detached and fully ignored: the browser outliving airship is the point,
// and its stdio would otherwise pollute the terminal.
const child = spawn(command, [url], {
const child = spawn(command, [...args], {
detached: true,
shell: process.platform === "win32",
stdio: "ignore",
windowsHide: true,
});
child.unref();
child.on("error", () => {
// No browser, no display, no `open` — the URL is printed either way.
});
child.unref();
} catch {
// Same: --open is a convenience, never a reason to fail a launch.
}
+1 -1
View File
@@ -8,7 +8,7 @@
},
"scripts": {
"build": "vite build",
"clean": "rm -rf dist .output .nitro .tanstack node_modules/.vite .turbo",
"clean": "node ../../scripts/clean.mjs dist .output .nitro .tanstack node_modules/.vite .turbo",
"dev": "vite dev",
"og": "node scripts/og.mjs",
"routes": "tsr generate",
+1 -1
View File
@@ -13,7 +13,7 @@
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm --dts --sourcemap --clean",
"clean": "rm -rf dist .turbo",
"clean": "node ../../scripts/clean.mjs dist .turbo",
"dev": "tsup src/index.ts --format esm --dts --sourcemap --watch",
"test": "vitest run",
"typecheck": "tsc --noEmit"
+56
View File
@@ -125,3 +125,59 @@ describe("DiffCapture on the Codex path", () => {
expect(diff.before).toBe("one\ntwo\nthree\n");
});
});
/**
* The Windows shape, reproducible anywhere: `core.autocrlf=true` leaves a CRLF
* working tree, every agent's edit tool writes LF, and the HEAD blob the Codex
* path reads back is LF too. Comparing those byte-for-byte makes every line
* differ by its terminator, so a one-line edit reports as a whole-file rewrite.
*/
describe("DiffCapture with CRLF line endings", () => {
it("diffs only the line that changed, not every line", () => {
write("crlf.txt", "one\r\ntwo\r\nthree\r\n");
const dc = new DiffCapture(repo);
dc.recordBefore("crlf.txt");
// The agent rewrites the file with LF, changing exactly one line.
write("crlf.txt", "one\nTWO\nthree\n");
const [diff] = dc.finalize();
expect(diff.additions).toBe(1);
expect(diff.deletions).toBe(1);
});
it("keeps the bytes on disk in before/after so undo round-trips exactly", () => {
// The patch is normalized for display; `restoreFiles` writes `before` back
// verbatim, so it has to carry the CRLF the file actually had.
write("crlf.txt", "one\r\ntwo\r\n");
const dc = new DiffCapture(repo);
dc.recordBefore("crlf.txt");
write("crlf.txt", "one\nCHANGED\n");
const [diff] = dc.finalize();
expect(diff.before).toBe("one\r\ntwo\r\n");
expect(diff.after).toBe("one\nCHANGED\n");
});
it("does not report a first-line change when only the BOM was dropped", () => {
// Visual Studio writes a BOM; agent edit tools generally do not put it
// back, and readFileSync surfaces it as a real character.
write("bom.txt", "one\ntwo\n");
const dc = new DiffCapture(repo);
dc.recordBefore("bom.txt");
write("bom.txt", "one\nCHANGED\n");
const [diff] = dc.finalize();
expect(diff.additions).toBe(1);
expect(diff.deletions).toBe(1);
});
it("reports no change when only the line endings were rewritten", () => {
write("crlf.txt", "one\r\ntwo\r\n");
const dc = new DiffCapture(repo);
dc.recordBefore("crlf.txt");
write("crlf.txt", "one\ntwo\n");
expect(dc.finalize()).toEqual([]);
expect(dc.pairFor("crlf.txt")).toBeNull();
});
});
+51 -19
View File
@@ -13,10 +13,15 @@
* everything else from the file's HEAD blob. See the two methods for why that
* covers every case.
*/
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { basename, dirname, relative, resolve } from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { relative, resolve } from "node:path";
import { canonicalPath } from "@airship/git";
import type { FileDiff } from "@airship/protocol";
import { createPatch } from "diff";
import { toPosixPath } from "./paths";
const CRLF = /\r\n/g;
const BOM = /^/;
function readSafe(abs: string): string | null {
try {
@@ -26,28 +31,51 @@ function readSafe(abs: string): string | null {
}
}
/**
* Line endings, flattened for comparison and diffing only.
*
* On Windows `core.autocrlf=true` is the Git-for-Windows default, so the
* working tree is CRLF while every agent's edit tool writes LF — and the HEAD
* blob `fileAtHead` returns for the Codex path is LF too. Comparing those
* directly makes every line of every file differ by its terminator, so a
* one-line edit renders as a whole-file rewrite with garbage `+N −M` counts and
* review comments that anchor to the wrong lines.
*
* Only the comparison and the patch are normalized. `FileDiff.before`/`after`
* keep the bytes actually on disk, because `restoreFiles` writes `before` back
* verbatim and undo has to round-trip exactly.
*
* The leading BOM goes the same way and for the same reason. Visual Studio and
* several Windows editors write one; agent edit tools generally do not preserve
* it, and `readFileSync(…, "utf8")` hands it back as a real `` character
* rather than stripping it — so dropping it would otherwise show up as the
* first line having changed when its text is identical.
*
* One consequence worth knowing: an edit that changes *nothing but* line
* endings or the BOM now reads as no change at all, so it is neither shown nor
* committed. That is the intended trade — it is noise, not an edit.
*/
function forDiff(text: string | null): string {
return text === null ? "" : text.replace(BOM, "").replace(CRLF, "\n");
}
/**
* Canonicalize a path so the same file always produces the same map key.
*
* Without this the two `before` sources silently fail to meet: git reports
* paths through `rev-parse --show-toplevel`, which resolves symlinks, while
* `cwd` arrives however the user typed it. On macOS that is the common case,
* not an edge one — `/tmp` and `/var` are both symlinks. A mismatch here does
* not throw; it just means `prime` records a baseline nobody ever reads, so the
* user's own uncommitted edits get attributed to the agent and undone with it.
* not an edge one — `/tmp` and `/var` are both symlinks; on Windows it is any
* two spellings that differ in case, including the drive letter. A mismatch
* here does not throw; it just means `prime` records a baseline nobody ever
* reads, so the user's own uncommitted edits get attributed to the agent and
* undone with it.
*
* Resolves the directory rather than the file, because the file frequently does
* not exist yet — that is precisely the create case.
* `canonicalPath` falls back to canonicalizing the containing directory when
* the file itself does not exist, which is precisely the create case.
*/
function canonical(cwd: string, path: string): string {
const abs = resolve(cwd, path);
try {
return resolve(realpathSync(dirname(abs)), basename(abs));
} catch {
// A path whose parent does not exist yet cannot be canonicalized; the raw
// resolution is still a consistent key for it.
return abs;
}
return canonicalPath(resolve(cwd, path));
}
/** Reads a path's content as of git HEAD. Injected so core need not know how. */
@@ -127,7 +155,7 @@ export class DiffCapture {
}
const before = this.before.get(abs) ?? null;
const after = readSafe(abs);
return (before ?? "") === (after ?? "") ? null : { after, before };
return forDiff(before) === forDiff(after) ? null : { after, before };
}
/** Net before→after diff for every file the agent touched. */
@@ -136,11 +164,15 @@ export class DiffCapture {
for (const abs of this.touched) {
const before = this.before.get(abs) ?? null;
const after = readSafe(abs);
if ((before ?? "") === (after ?? "")) {
if (forDiff(before) === forDiff(after)) {
continue;
}
const rel = relative(this.root, abs);
const patch = createPatch(rel, before ?? "", after ?? "");
// Forward slashes from here on. This value is a git pathspec in
// `commitEdit` (where backslash is wildmatch's escape character), a key
// the browser splits on "/", and a string embedded in JSON headed for the
// model, where every backslash arrives doubled.
const rel = toPosixPath(relative(this.root, abs));
const patch = createPatch(rel, forDiff(before), forDiff(after));
const { additions, deletions } = countChanges(patch);
diffs.push({
additions,
+2
View File
@@ -6,6 +6,8 @@ export type {
} from "./agent";
export { getAdapter } from "./agent";
export { DiffCapture } from "./diff-capture";
/** Path identity, shared so containment, diff keys and history keys agree. */
export { isPathInside, pathKey, toPosixPath } from "./paths";
export type { EditPromptInput } from "./prompt";
export { buildEditPrompt, PIKA_SYSTEM_PROMPT, systemPrompt } from "./prompt";
export type { CodexConfigValue, CodexSettings } from "./providers/codex";
+154
View File
@@ -0,0 +1,154 @@
/**
* Path identity, which four separate features key on — `--safe` containment,
* `DiffCapture`'s before/after map, the history store's per-repo directory, and
* the editor handler's containment check.
*
* The containment cases moved here wholesale when `isPathInside` moved out of
* `sandbox.ts`; the guard's job has not changed. It must deny what is genuinely
* outside the project — and, just as importantly, allow everything inside it. A
* false deny is not a safe failure: the model burns turns probing why it was
* refused and then routes around the guard, which costs money and produces a
* worse edit.
*
* Some of what these helpers exist for is Windows-only and cannot be exercised
* from a POSIX runner: `realpathSync.native` case-folding and `toPosixPath`
* rewriting separators both no-op here. The `windows-latest` leg in checks.yml
* is what covers those.
*/
import {
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, parse, sep } from "node:path";
import { canonicalPath } from "@airship/git";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isPathInside, pathKey, toPosixPath } from "./paths";
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "airship-paths-test-"));
mkdirSync(join(root, "src"));
writeFileSync(join(root, "src", "app.ts"), "x");
});
afterEach(() => {
rmSync(root, { force: true, recursive: true });
});
describe("isPathInside", () => {
it("allows a relative path in the project", () => {
expect(isPathInside(root, "src/app.ts")).toBe(true);
});
it("allows an absolute path in the project", () => {
expect(isPathInside(root, join(root, "src", "app.ts"))).toBe(true);
});
it("allows the root itself", () => {
expect(isPathInside(root, root)).toBe(true);
});
it("allows a file that does not exist yet", () => {
expect(isPathInside(root, join(root, "src", "new.ts"))).toBe(true);
});
it("allows the symlink-resolved spelling of an in-project path", () => {
// On macOS `mkdtemp` hands back `/var/...` while everything that resolves
// the path reports `/private/var/...`. Both name the same file, and a guard
// that denies one of them fires on an ordinary project.
const viaRealpath = join(realpathSync(root), "src", "app.ts");
expect(isPathInside(root, viaRealpath)).toBe(true);
});
it("allows an in-project path reached through a symlinked root", () => {
const link = join(tmpdir(), `airship-paths-link-${process.pid}`);
rmSync(link, { force: true, recursive: true });
// "junction" on Windows: Node's default link type is "file", which does not
// work for a directory, and unlike a real directory symlink a junction
// needs neither elevation nor Developer Mode.
symlinkSync(root, link, process.platform === "win32" ? "junction" : "dir");
try {
expect(isPathInside(link, join(root, "src", "app.ts"))).toBe(true);
expect(isPathInside(root, join(link, "src", "app.ts"))).toBe(true);
} finally {
rmSync(link, { force: true, recursive: true });
}
});
it("denies a sibling directory that shares the project's name prefix", () => {
expect(isPathInside(root, `${root}-evil/secret.txt`)).toBe(false);
});
it("denies a traversal out of the project", () => {
expect(isPathInside(root, "../../etc/hosts")).toBe(false);
});
it("denies an unrelated absolute path", () => {
expect(isPathInside(root, "/etc/hosts")).toBe(false);
});
it("handles a root that already ends in a separator", () => {
// Documentation rather than regression: `resolve` strips a trailing
// separator, so this case was never the broken one.
expect(isPathInside(`${root}${sep}`, join(root, "src", "app.ts"))).toBe(
true
);
});
it("handles the filesystem root, which is nothing but a separator", () => {
// This is the regression. `resolve` cannot strip the separator here — it is
// the whole path — so the old `absRoot + sep` built the prefix `//`, which
// no real path starts with, and every file read as outside the root. The
// same shape is far more reachable on Windows, where a project checked out
// at `C:\` is ordinary.
//
// `parse(root).root` rather than a bare `sep`: on Windows that resolves
// against the *current* drive, and CI runs the checkout on D: while the
// temp directory lives on C:, so the two would be genuinely unrelated.
expect(isPathInside(parse(root).root, join(root, "src", "app.ts"))).toBe(
true
);
});
});
describe("pathKey", () => {
it("agrees with itself across spellings of one path", () => {
const viaJoin = pathKey(join(root, "src", "app.ts"));
const viaTraversal = pathKey(join(root, "src", "..", "src", "app.ts"));
expect(viaJoin).toBe(viaTraversal);
});
it("resolves a symlinked root to the same key as the real one", () => {
// This is what stops the history store splitting in two for one project.
expect(pathKey(root)).toBe(pathKey(realpathSync(root)));
});
});
describe("canonicalPath", () => {
it("falls back to the parent directory for a file that does not exist", () => {
// The create case: the file is absent but its directory is real, so the
// result still has to be the canonical location it will occupy.
expect(canonicalPath(join(root, "src", "absent.ts"))).toBe(
join(canonicalPath(join(root, "src")), "absent.ts")
);
});
it("returns an absolute path even when nothing on the way exists", () => {
const result = canonicalPath(join(root, "no", "such", "dir", "x.ts"));
expect(result.endsWith(join("no", "such", "dir", "x.ts"))).toBe(true);
});
});
describe("toPosixPath", () => {
it("leaves an already-POSIX path alone", () => {
expect(toPosixPath("src/components/Button.tsx")).toBe(
"src/components/Button.tsx"
);
});
});
+82
View File
@@ -0,0 +1,82 @@
/**
* Path canonicalization, shared by everything that uses a path as an identity.
*
* Four places key on paths — the `--safe` containment check, `DiffCapture`'s
* before/after map, the history store's per-repo directory, and the editor
* handler's containment check — and all four were comparing paths that Windows
* considers equal but JavaScript does not.
*
* Two distinct hazards, and they need different tools:
*
* 1. Symlinks. `/tmp` and `/var` are symlinks on macOS, so `cwd` arrives as
* `/var/folders/…` while the agent reports `/private/var/folders/…`.
* `realpathSync` handles this and always did.
*
* 2. Case. Windows filesystems are case-insensitive, so `C:\Proj` and
* `c:\proj` are the same directory — but `===` and `startsWith` say
* otherwise, and `realpathSync` does NOT fix it: the JS implementation
* resolves links while preserving whatever spelling the caller passed.
* `realpathSync.native` does canonicalize case on Windows, because it goes
* through `GetFinalPathNameByHandle`. Drive-letter case alone is enough to
* trigger this: `process.cwd()` upper-cases it, but `--cwd c:/proj` and an
* `airship.config.json` value both pass through untouched.
*
* The failures are quiet rather than loud. `--safe` refuses edits inside the
* project, `DiffCapture` attributes the user's own uncommitted work to the agent
* and undoes it with the turn, and the history store splits in two so undo and
* PR-from-session silently find nothing.
*/
import { resolve, sep } from "node:path";
import { canonicalPath } from "@airship/git";
const WIN32 = process.platform === "win32";
// canonicalPath comes from @airship/git and is deliberately NOT re-exported
// here: one implementation, one import path. That package needs the identical
// rule for `fileAtHead`, which derives a repo-relative path from keys this
// module produced, and when a second copy lived here the two drifted on
// Windows — the JS realpathSync leaves an 8.3 short name (`RUNNER~1`) alone
// while the native one expands it (`runneradmin`) — so `relative` returned a
// `..` path and every file read as absent from HEAD.
/**
* A stable comparison key for a path.
*
* Lower-cased on Windows, where the filesystem is case-insensitive. Use this to
* compare or to key a Map; keep the original string for anything that touches
* the filesystem, so error messages and editor targets stay in the user's own
* spelling.
*/
export function pathKey(path: string): string {
const canon = canonicalPath(path);
return WIN32 ? canon.toLowerCase() : canon;
}
/**
* True if `target` resolves to a path at or under `root`.
*
* The `+ sep` is what stops `/proj2` matching root `/proj`, so it cannot be
* dropped — but it has to be applied to a root that does not already end in a
* separator, or a drive root (`C:\`, `/`) becomes `C:\\` and matches nothing.
*/
export function isPathInside(root: string, target: string): boolean {
const absRoot = pathKey(resolve(root));
const abs = pathKey(resolve(resolve(root), target));
if (abs === absRoot) {
return true;
}
const prefix = absRoot.endsWith(sep) ? absRoot : absRoot + sep;
return abs.startsWith(prefix);
}
/**
* Rewrite a native path to forward slashes.
*
* For values that leave the filesystem: git pathspecs (backslash is git's
* wildmatch escape character), anything crossing the wire to the browser, and
* anything embedded in JSON headed for the model, where every `\` is doubled.
* A no-op off Windows.
*/
export function toPosixPath(path: string): string {
return WIN32 ? path.split(sep).join("/") : path;
}
+2 -1
View File
@@ -30,8 +30,9 @@ import {
type AgentRunOutcome,
failureText,
} from "../agent";
import { isPathInside } from "../paths";
import { systemPrompt } from "../prompt";
import { EDIT_TOOLS, isPathInside, makeSandboxHook } from "../sandbox";
import { EDIT_TOOLS, makeSandboxHook } from "../sandbox";
import type { TimelineRecorder } from "../timeline";
import { describeTool } from "../tool-summary";
import { buildAirshipMcpServer } from "../tools";
+36 -5
View File
@@ -96,7 +96,29 @@ export interface OpencodeHandle {
url: string;
}
const BINARY = process.platform === "win32" ? "opencode.exe" : "opencode";
/**
* The names `opencode` can go by on disk, most-specific first.
*
* Hardcoding `opencode.exe` on Windows found nothing for the install we
* ourselves recommend: `npm i -g opencode-ai` writes `opencode.cmd` and
* `opencode.ps1` shims, never a `.exe`. So a perfectly good opencode reported
* "No `opencode` binary found on PATH" and every edit through that backend
* failed. PATHEXT is the OS's own answer to which extensions are executable;
* the fallback matches its default.
*/
const BINARY_NAMES: string[] =
process.platform === "win32"
? [
...(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
.split(";")
.filter(Boolean)
.map((ext) => `opencode${ext.toLowerCase()}`),
// Extension-less last, so a real launcher always wins — but still
// present, because dropping it would be its own regression for anyone
// whose opencode is a bare binary on PATH.
"opencode",
]
: ["opencode"];
/** A cold first launch on a slow machine must not read as a broken install. */
const START_TIMEOUT_MS = 30_000;
@@ -115,9 +137,11 @@ export function resolveOpencodeBinary(override?: string): string | null {
if (!dir) {
continue;
}
const candidate = join(dir, BINARY);
if (existsSync(candidate)) {
return candidate;
for (const name of BINARY_NAMES) {
const candidate = join(dir, name);
if (existsSync(candidate)) {
return candidate;
}
}
}
return null;
@@ -255,9 +279,16 @@ function registerExitHooks(): void {
return;
}
hooksRegistered = true;
for (const signal of ["exit", "SIGINT", "SIGTERM"] as const) {
// SIGBREAK on Windows, where SIGTERM is accepted by `process.once` but never
// delivered — Ctrl-Break would otherwise leave this child holding its port.
// "exit" remains the backstop that catches everything else.
const signals = ["exit", "SIGINT", "SIGTERM"] as const;
for (const signal of signals) {
process.once(signal, () => shutdownServer());
}
if (process.platform === "win32") {
process.once("SIGBREAK", () => shutdownServer());
}
}
/**
+18 -1
View File
@@ -50,8 +50,9 @@ import {
type AgentRunOutcome,
failureText,
} from "../agent";
import { isPathInside } from "../paths";
import { systemPrompt } from "../prompt";
import { isPathInside, screenBash, screenEdit } from "../sandbox";
import { screenBash, screenEdit } from "../sandbox";
import { modelRefFor, sessionIdOf } from "./opencode-events";
import {
finishBlocks,
@@ -579,11 +580,27 @@ const PROVIDER_ENV = [
"AWS_ACCESS_KEY_ID",
];
/**
* Where opencode keeps its state, per platform.
*
* Windows gets `%LOCALAPPDATA%` / `%APPDATA%` ahead of the XDG fallbacks: the
* XDG variables are almost never set there, so the fallback resolved to a
* `~/.local/share` that opencode has no reason to use. Both callers are the
* credential heuristic below, so getting this wrong only costs a spurious "no
* provider credentials" warning at launch — the run still proceeds — but that
* warning is indistinguishable from the real thing.
*/
function dataHome(): string {
if (process.platform === "win32" && process.env.LOCALAPPDATA) {
return process.env.LOCALAPPDATA;
}
return process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
}
function configHome(): string {
if (process.platform === "win32" && process.env.APPDATA) {
return process.env.APPDATA;
}
return process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
}
+59 -66
View File
@@ -1,76 +1,69 @@
/**
* The path guard's job is to deny what is genuinely outside the project — and,
* just as importantly, to allow everything inside it. A false deny is not a
* safe failure: the model burns turns probing why it was refused and then
* routes around the guard, which costs money and produces a worse edit.
* The command screen, which `--safe` promises in the launch banner.
*
* Two directions matter equally. It has to block what would actually destroy
* the user's machine — and it has to leave ordinary commands alone, because a
* false deny is not a safe failure: the model cannot see the reason, so it
* burns turns working around a guard that should never have fired.
*
* The Windows half exists because the original list was entirely POSIX-shell
* shaped, so on Windows — where Codex and OpenCode run commands through cmd.exe
* or PowerShell — the screen was a no-op while the banner still said it was on.
* (Path containment lives in ./paths.test.ts.)
*/
import {
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isPathInside } from "./sandbox";
import { describe, expect, it } from "vitest";
import { screenBash } from "./sandbox";
let root: string;
const blocked = (command: string) => screenBash(command).allowed === false;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "airship-sandbox-test-"));
mkdirSync(join(root, "src"));
writeFileSync(join(root, "src", "app.ts"), "x");
describe("screenBash blocks destructive POSIX commands", () => {
it.each([
"rm -rf /",
"rm -f important.txt",
"git push origin main",
"git reset --hard HEAD~5",
"sudo rm something",
"dd if=/dev/zero of=/dev/sda",
"mkfs.ext4 /dev/sda1",
])("blocks %j", (command) => {
expect(blocked(command)).toBe(true);
});
});
afterEach(() => {
rmSync(root, { force: true, recursive: true });
describe("screenBash blocks destructive Windows commands", () => {
it.each([
"del /f /s /q C:\\Users\\me\\project",
"del /q important.txt",
"rd /s /q build",
"rmdir /s /q node_modules",
"Remove-Item -Recurse -Force .\\dist",
"Remove-Item .\\dist -Recurse",
"format c:",
"diskpart",
"Clear-Disk -Number 0",
"runas /user:Administrator cmd.exe",
"Start-Process powershell -Verb RunAs",
])("blocks %j", (command) => {
expect(blocked(command)).toBe(true);
});
});
describe("isPathInside", () => {
it("allows a relative path in the project", () => {
expect(isPathInside(root, "src/app.ts")).toBe(true);
});
it("allows an absolute path in the project", () => {
expect(isPathInside(root, join(root, "src", "app.ts"))).toBe(true);
});
it("allows a file that does not exist yet", () => {
expect(isPathInside(root, join(root, "src", "new.ts"))).toBe(true);
});
it("allows the symlink-resolved spelling of an in-project path", () => {
// On macOS `mkdtemp` hands back `/var/...` while everything that resolves
// the path reports `/private/var/...`. Both name the same file, and a guard
// that denies one of them fires on an ordinary project.
const viaRealpath = join(realpathSync(root), "src", "app.ts");
expect(isPathInside(root, viaRealpath)).toBe(true);
});
it("allows an in-project path reached through a symlinked root", () => {
const link = join(tmpdir(), `airship-sandbox-link-${process.pid}`);
rmSync(link, { force: true });
symlinkSync(root, link);
try {
expect(isPathInside(link, join(root, "src", "app.ts"))).toBe(true);
expect(isPathInside(root, join(link, "src", "app.ts"))).toBe(true);
} finally {
rmSync(link, { force: true });
}
});
it("denies a sibling directory that shares the project's name prefix", () => {
expect(isPathInside(root, `${root}-evil/secret.txt`)).toBe(false);
});
it("denies a traversal out of the project", () => {
expect(isPathInside(root, "../../etc/hosts")).toBe(false);
});
it("denies an unrelated absolute path", () => {
expect(isPathInside(root, "/etc/hosts")).toBe(false);
describe("screenBash allows ordinary commands", () => {
it.each([
// The reason the Windows patterns name their flags rather than the verb:
// all of these contain `del`, `rd` or `format` as a substring or a word.
"sed -i 's/del /x/g' notes.txt",
"grep -rn 'delete' src/",
"npm run build && npm test",
"git status",
"git log --format=oneline",
"prettier --write .",
"node scripts/format.mjs",
"cargo build --release",
"echo 'runas is a windows command'",
"ls -la",
"pnpm dlx ultracite fix",
])("allows %j", (command) => {
expect(blocked(command)).toBe(false);
});
});
+22 -37
View File
@@ -12,9 +12,9 @@
* functions. Keeping the policy separate from the hook is what lets one set of
* rules — and one set of tests — cover both.
*/
import { realpathSync } from "node:fs";
import { basename, dirname, resolve, sep } from "node:path";
import { resolve } from "node:path";
import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import { isPathInside } from "./paths";
export const EDIT_TOOLS = new Set([
"Write",
@@ -32,6 +32,26 @@ const DESTRUCTIVE = [
/>\s*\/dev\/(sd|disk)/,
/\bsudo\b/,
/:\(\)\s*\{/,
// The Windows half. Every pattern above except the two `git` ones is
// POSIX-shell shaped, and on Windows the backends run commands through
// cmd.exe or PowerShell — so without these the command screen was a no-op
// there while the launch banner still told the user it was on.
//
// Each names the flags the real command takes rather than matching the verb
// loosely. These run against every command on every platform, and `del` and
// `runas` are short enough to appear inside ordinary POSIX ones — a bare
// /\bdel\s+\// fires on `sed -i 's/del /x/'`. A false deny is not free: the
// model cannot see why it was refused, so it burns turns working around a
// guard that should not have fired.
/\bdel\s+\/[fsqap]\b/i,
/\brd\s+\/s\b/i,
/\brmdir\s+\/s\b/i,
/\bRemove-Item\b[^\n]*\s-(?:Recurse|Force)\b/i,
/\bformat\s+[a-z]:/i,
/\bdiskpart\b/i,
/\bClear-Disk\b/i,
/\brunas\s+\/user:/i,
/\bStart-Process\b[^\n]*\s-Verb\s+RunAs\b/i,
];
function deny(reason: string) {
@@ -44,41 +64,6 @@ function deny(reason: string) {
};
}
/**
* Resolve symlinks so two spellings of the same path compare equal.
*
* Falls back to the containing directory for a file that does not exist yet,
* which is the create case, and to the raw path when even that is missing.
*/
function canonical(path: string): string {
try {
return realpathSync(path);
} catch {
try {
return resolve(realpathSync(dirname(path)), basename(path));
} catch {
return path;
}
}
}
/**
* True if `target` resolves to a path at or under `root`.
*
* Both sides are canonicalized first. Comparing raw strings denies perfectly
* legitimate in-project edits whenever the project sits under a symlink — on
* macOS `/tmp` and `/var` both are, so `cwd` arrives as `/var/folders/…` while
* the agent reports the file as `/private/var/folders/…`. The failure is
* expensive rather than loud: the edit is refused, the model burns turns
* probing with `pwd -P` and `realpath` to work out why, and eventually routes
* around a guard that should never have fired.
*/
export function isPathInside(root: string, target: string): boolean {
const absRoot = canonical(resolve(root));
const abs = canonical(resolve(resolve(root), target));
return abs === absRoot || abs.startsWith(absRoot + sep);
}
/** The verdict shape both screens return. `reason` is user-facing. */
export interface ScreenResult {
allowed: boolean;
+1 -1
View File
@@ -16,7 +16,7 @@
],
"scripts": {
"build": "node scripts/gen.mjs && tsup src/index.ts --format esm --dts --sourcemap --clean",
"clean": "rm -rf dist .turbo",
"clean": "node ../../scripts/clean.mjs dist .turbo",
"dev": "node scripts/gen.mjs && tsup src/index.ts --format esm --dts --sourcemap --watch",
"gen": "node scripts/gen.mjs",
"typecheck": "tsc --noEmit"
+3 -1
View File
@@ -39,7 +39,9 @@ if (!existsSync(specPath)) {
}
const raw = readFileSync(specPath, "utf8");
const match = raw.match(/^---\n([\s\S]*?)\n---/);
// \r? because a Windows checkout with core.autocrlf=true hands us CRLF, and a
// front-matter block that opens `---\r\n` would otherwise read as absent.
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) {
throw new Error("gen: ICONS.md is missing its YAML front-matter block");
}
+1 -1
View File
@@ -19,7 +19,7 @@
],
"scripts": {
"build": "node scripts/gen.mjs && tsup src/index.ts --format esm --dts --sourcemap --clean && node scripts/postbuild.mjs",
"clean": "rm -rf dist .turbo",
"clean": "node ../../scripts/clean.mjs dist .turbo",
"dev": "node scripts/gen.mjs && tsup src/index.ts --format esm --dts --sourcemap --watch",
"gen": "node scripts/gen.mjs",
"typecheck": "tsc --noEmit"
+3 -1
View File
@@ -16,7 +16,9 @@ if (!existsSync(specPath)) {
}
const raw = readFileSync(specPath, "utf8");
const match = raw.match(/^---\n([\s\S]*?)\n---/);
// \r? because a Windows checkout with core.autocrlf=true hands us CRLF, and a
// front-matter block that opens `---\r\n` would otherwise read as absent.
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) {
throw new Error("gen: EDITOR.md is missing its YAML front-matter block");
}
+1 -1
View File
@@ -13,7 +13,7 @@
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm --dts --sourcemap --clean",
"clean": "rm -rf dist .turbo",
"clean": "node ../../scripts/clean.mjs dist .turbo",
"dev": "tsup src/index.ts --format esm --dts --sourcemap --watch",
"typecheck": "tsc --noEmit"
},
+27 -7
View File
@@ -128,8 +128,8 @@ export function fileAtHead(cwd: string, path: string): string | null {
// from mixed sources — some resolved through git, some as the user typed
// them — and on macOS `/tmp` and `/var` are symlinks, so an uncanonicalized
// comparison reads a perfectly normal path as escaping the project.
const root = realpathSafe(cwd);
const rel = relative(root, realpathSafe(resolve(cwd, path)));
const root = canonicalPath(cwd);
const rel = relative(root, canonicalPath(resolve(cwd, path)));
// `..` means the path really does escape the working directory; `HEAD:./..`
// is not valid git syntax and there is nothing sensible to return.
if (!rel || rel.startsWith("..")) {
@@ -138,15 +138,35 @@ export function fileAtHead(cwd: string, path: string): string | null {
return tryGitRaw(cwd, ["show", `HEAD:./${rel.split(sep).join("/")}`]);
}
/** Resolve symlinks where possible, falling back for paths that don't exist. */
function realpathSafe(path: string): string {
/**
* Resolve symlinks — and, on Windows, case and 8.3 short names — where
* possible, falling back for paths that don't exist.
*
* `.native` on Windows is not a detail. The JS implementation resolves links
* while preserving whatever spelling the caller passed, so `C:\Users\RUNNER~1`
* stays short while anything that went through `GetFinalPathNameByHandle`
* reads `C:\Users\runneradmin`. Two spellings of one directory make `relative`
* below return a `..` path, and `fileAtHead` then reports every file as absent
* from HEAD — which on the Codex path silently turns each edit into a
* whole-file diff with no baseline.
*
* Exported, and re-exported by @airship/core's ./paths rather than
* reimplemented there: core canonicalizes DiffCapture's map keys and hands the
* results straight to `fileAtHead`, so a second implementation is a second
* chance for the two to disagree. This package is the lowest one that touches
* the filesystem, which makes it the place that owns the rule.
*/
const realpath =
process.platform === "win32" ? realpathSync.native : realpathSync;
export function canonicalPath(path: string): string {
try {
return realpathSync(path);
return realpath(path);
} catch {
try {
return resolve(realpathSync(dirname(path)), basename(path));
return resolve(realpath(dirname(path)), basename(path));
} catch {
return path;
return resolve(path);
}
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
"build": "node scripts/check-css.mjs && tsup",
"build-storybook": "storybook build",
"check:css": "node scripts/check-css.mjs",
"clean": "rm -rf dist .turbo storybook-static",
"clean": "node ../../scripts/clean.mjs dist .turbo storybook-static",
"dev": "tsup --watch",
"storybook": "storybook dev -p 6006 --no-open",
"test": "vitest run",
+7 -1
View File
@@ -16,8 +16,14 @@
*/
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const DIR = new URL("../src/styles/", import.meta.url).pathname;
// fileURLToPath, not `.pathname`. `.pathname` is a URL component, not a path:
// on Windows it yields `/C:/…`, which readdirSync resolves against the current
// drive as `C:\C:\…`, and on every platform it stays percent-encoded, so a
// checkout under a directory with a space in it fails too. `join(DIR, file)`
// below needs a string, so this cannot stay a URL.
const DIR = fileURLToPath(new URL("../src/styles/", import.meta.url));
const START = /export const css\s*=\s*`/;
const problems = [];
+1 -6
View File
@@ -44,7 +44,7 @@ import {
FEEDBACK,
manager,
} from "./dnd/manager";
import { clear, cls, el, PREFIX } from "./dom";
import { basename, clear, cls, el, PREFIX } from "./dom";
import { emptyState } from "./empty";
import { History } from "./history";
import { createOpApplier } from "./history-ops";
@@ -3242,11 +3242,6 @@ function chipLabel(e: ElementContext): string {
return e.displayName || `<${e.tagName}>`;
}
/** Last path segment — a chip has no room for `src/components/ui/Button.tsx`. */
function basename(path: string): string {
return path.split("/").pop() || path;
}
function changeSummary(
styleCount: number,
moveCount: number,
+16
View File
@@ -50,6 +50,22 @@ export function clear(node: HTMLElement): void {
* (first two classes). Shared by the selection/hover badges and the tree/DOM
* views so they read identically.
*/
/**
* Last segment of a source path — a chip has no room for
* `src/components/ui/Button.tsx`.
*
* Handles both separators. Diff paths arrive forward-slashed, but source
* locations come from the framework's own metadata, which on Windows is
* backslashed — and a `split("/")` on one of those returns the whole path, so
* the chip renders the full `src\components\ui\Button.tsx` it was meant to
* shorten.
*/
export function basename(path: string): string {
return path.slice(
Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1
);
}
export function elementLabel(node: Element): string {
const tag = node.tagName.toLowerCase();
const classes = Array.from(node.classList)
+1 -9
View File
@@ -25,7 +25,7 @@ import {
hit,
manager,
} from "../dnd/manager";
import { clear, cls, el, elementLabel } from "../dom";
import { basename, clear, cls, el, elementLabel } from "../dom";
import { isEditorNode } from "../edit-guard";
import { emptyState } from "../empty";
import type { History } from "../history";
@@ -166,14 +166,6 @@ function isNonVisual(node: Element): boolean {
return NON_VISUAL.has(node.tagName.toLowerCase());
}
/** Last segment of a source path. Handles both separators — the path comes from
* the framework's own metadata, which on Windows is backslashed. */
function basename(path: string): string {
return path.slice(
Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1
);
}
/**
* The Source heading's summary — `App.tsx:31`, full path on hover.
*
+1 -1
View File
@@ -17,7 +17,7 @@
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts src/tokens.ts --format esm --dts --sourcemap --clean",
"clean": "rm -rf dist .turbo",
"clean": "node ../../scripts/clean.mjs dist .turbo",
"dev": "tsup src/index.ts src/tokens.ts --format esm --dts --sourcemap --watch",
"test": "vitest run",
"typecheck": "tsc --noEmit"
+1 -1
View File
@@ -13,7 +13,7 @@
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm --dts --sourcemap --clean",
"clean": "rm -rf dist .turbo",
"clean": "node ../../scripts/clean.mjs dist .turbo",
"dev": "tsup src/index.ts --format esm --dts --sourcemap --watch",
"test": "vitest run",
"typecheck": "tsc --noEmit"
+27 -2
View File
@@ -13,13 +13,38 @@ import {
} from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { pathKey } from "@airship/core";
import type { JobDiffBundle, JobHistorySummary } from "@airship/protocol";
function repoDir(cwd: string): string {
const hash = createHash("sha1").update(cwd).digest("hex").slice(0, 12);
function hashDir(key: string): string {
const hash = createHash("sha1").update(key).digest("hex").slice(0, 12);
return join(homedir(), ".airship", "history", hash);
}
/**
* One directory per project, keyed by a hash of its canonical path.
*
* `pathKey` rather than the raw `cwd`: the string the user typed is not a
* stable identity for a directory. A symlinked project (macOS `/tmp`, `/var`)
* or two spellings that differ only in case (Windows, drive letter included)
* hash to different directories, and the whole store — history, undo,
* PR-from-session — silently comes back empty for the same project.
*
* The legacy fallback exists because that hash used to be taken over the raw
* `cwd`, so canonicalizing it moves the directory for any project reached
* through a symlink. Without this their existing history would simply vanish on
* upgrade. Only read from: once anything is written under the canonical key,
* that is the one directory in play.
*/
function repoDir(cwd: string): string {
const canonical = hashDir(pathKey(cwd));
if (existsSync(canonical)) {
return canonical;
}
const legacy = hashDir(cwd);
return existsSync(legacy) ? legacy : canonical;
}
export function writeBundle(cwd: string, bundle: JobDiffBundle): void {
const dir = repoDir(cwd);
mkdirSync(dir, { recursive: true });
+11 -2
View File
@@ -524,8 +524,17 @@ export async function startServer(opts: ServerOptions): Promise<RunningServer> {
socket.on("close", () => tunnelled.delete(socket as Socket));
});
await new Promise<void>((resolve) => {
server.listen(opts.port, () => resolve());
// The `error` listener is what makes this rejectable. Without it an
// EADDRINUSE — or, on Windows, the EACCES you get from a port inside a
// Hyper-V/WSL2 reserved range — is emitted with nobody listening, becomes an
// uncaughtException, and escapes the caller's try/catch, so the dev server
// airship started with `--exec` is never stopped either.
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(opts.port, () => {
server.removeListener("error", reject);
resolve();
});
});
const addr = server.address() as AddressInfo | null;
+100 -22
View File
@@ -8,12 +8,15 @@
* a column and are awkward to detect. So: try the binary, fall back to the
* scheme.
*/
import { spawn, spawnSync } from "node:child_process";
import { type ChildProcess, spawn, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { platform } from "node:os";
import { resolve, sep } from "node:path";
import { resolve } from "node:path";
import { isPathInside, toPosixPath } from "@airship/core";
import type { Editor } from "@airship/protocol";
const WIN32 = platform() === "win32";
export interface OpenRequest {
column?: number;
editor?: Editor;
@@ -48,8 +51,10 @@ const LAUNCHERS: Record<Editor, Launcher> = {
const PREFERENCE: Editor[] = ["vscode", "cursor", "windsurf", "zed"];
/** Vite serves out-of-root files under `/@fs/<abs path>`. */
const VITE_FS_PREFIX = /^\/@fs(\/.*)$/;
const VITE_FS_PREFIX = /^\/@fs\/(.*)$/;
const LEADING_SLASHES = /^\/+/;
/** `C:` — a Windows path that is already rooted at a drive. */
const WIN32_DRIVE = /^[a-zA-Z]:/;
export function openInEditor(cwd: string, req: OpenRequest): OpenResult {
const root = resolve(cwd);
@@ -57,7 +62,12 @@ export function openInEditor(cwd: string, req: OpenRequest): OpenResult {
// This socket is unauthenticated and local, and this handler spawns
// processes — without the containment check any page the browser loads could
// ask the daemon to open arbitrary files on disk.
if (abs !== root && !abs.startsWith(root + sep)) {
//
// isPathInside rather than a bare startsWith: the two sides reach here by
// different routes and can disagree on symlinks (macOS `/tmp`, `/var`) or on
// case (Windows, including the drive letter), and a false "outside the
// project" on the user's own file is indistinguishable from a real refusal.
if (!isPathInside(root, abs)) {
return { error: "path is outside the project", ok: false };
}
if (!existsSync(abs)) {
@@ -73,22 +83,13 @@ export function openInEditor(cwd: string, req: OpenRequest): OpenResult {
if (!onPath(launcher.bin)) {
continue;
}
try {
// Detached and unref'd: the editor outlives the daemon, and inheriting
// stdio would wire its output into ours. Never `shell: true` — argv
// arrays mean a path with a space or a quote is just a path.
spawn(launcher.bin, launcher.args(target), {
detached: true,
stdio: "ignore",
}).unref();
if (launch(launcher.bin, launcher.args(target))) {
return { editor, ok: true };
} catch {
// Fall through to the next candidate, then to the URL scheme.
}
}
const wanted = req.editor ?? PREFERENCE[0];
if (openUrl(`${LAUNCHERS[wanted].scheme}://file/${abs}:${line}:${column}`)) {
if (openUrl(editorUrl(LAUNCHERS[wanted].scheme, abs, line, column))) {
return { editor: wanted, ok: true };
}
return {
@@ -111,16 +112,83 @@ export function openInEditor(cwd: string, req: OpenRequest): OpenResult {
function projectPath(file: string): string {
const fs = file.match(VITE_FS_PREFIX);
if (fs?.[1]) {
return fs[1];
// The remainder is already absolute. On POSIX it needs the slash the
// capture dropped; on Windows it opens with a drive letter and must NOT
// get one, because `resolve` reads a leading slash as rooted-but-
// deviceless and inherits the device from cwd — turning
// `/@fs/C:/Users/me/x.tsx` into `C:\C:\Users\me\x.tsx`.
return WIN32_DRIVE.test(fs[1]) ? fs[1] : `/${fs[1]}`;
}
// A real absolute path exists on disk; a URL path like `/src/App.tsx` does
// not, and is meant to be read relative to the project root.
if (file.startsWith("/") && !existsSync(file)) {
// not, and is meant to be read relative to the project root. On Windows the
// probe is skipped: a leading slash with no drive letter is never an absolute
// path there, so `existsSync` would only ever be answering about a different
// file — `<cwd's drive>:\src\App.tsx`, which routinely exists.
if (file.startsWith("/") && (WIN32 || !existsSync(file))) {
return file.replace(LEADING_SLASHES, "");
}
return file;
}
/**
* Start a detached child, or report that it could not start.
*
* `shell` on Windows because every one of these editors ships as a `.cmd` shim
* (`code.cmd`, `cursor.cmd`, …) and libuv's PATH search only ever tries `.com`
* and `.exe`. `where code` finds the shim, so `onPath` says yes, and the bare
* spawn then fails with ENOENT — which is how this managed to be 100% broken on
* Windows while looking fine. Node applies cmd-specific argument escaping when
* `shell` is set, so a path with a space in it survives.
*
* The `error` listener is not optional. spawn reports a failure to start
* asynchronously, so the `try` here never sees it; an EventEmitter that emits
* `error` with nobody listening throws, and this runs in the daemon, so one
* click on a file it cannot open took the whole server down.
*
* `pid` is undefined when the spawn failed outright, which is the only
* synchronous signal available — and it is what lets the caller fall through to
* the URL scheme instead of reporting a success that never happened.
*/
function launch(bin: string, args: string[]): boolean {
try {
const child = spawn(bin, args, {
detached: true,
shell: WIN32,
stdio: "ignore",
windowsHide: true,
});
detach(child);
return child.pid !== undefined;
} catch {
return false;
}
}
/** Let the child outlive us, and never let its `error` reach the top level. */
function detach(child: ChildProcess): void {
child.on("error", () => {
// No editor, no PATH entry, no display. The caller reports it; the daemon
// must not die of it.
});
child.unref();
}
/**
* `vscode://file/...` for the fallback path.
*
* Backslashes are not legal in a URI path, so a Windows path has to be
* rewritten or the scheme handler simply ignores it. POSIX paths already open
* with `/`, which is why there is only one after `file` in the template.
*/
function editorUrl(
scheme: string,
abs: string,
line: number,
column: number
): string {
return `${scheme}://file/${toPosixPath(abs)}:${line}:${column}`;
}
function candidates(explicit?: Editor): Editor[] {
if (explicit) {
return [explicit];
@@ -140,10 +208,12 @@ function onPath(bin: string): boolean {
if (hit !== undefined) {
return hit;
}
const probe = platform() === "win32" ? "where" : "which";
const probe = WIN32 ? "where" : "which";
let found = false;
try {
found = spawnSync(probe, [bin], { stdio: "ignore" }).status === 0;
found =
spawnSync(probe, [bin], { stdio: "ignore", windowsHide: true }).status ===
0;
} catch {
found = false;
}
@@ -158,12 +228,20 @@ function openUrl(url: string): boolean {
if (os === "darwin") {
bin = "open";
} else if (os === "win32") {
// `cmd /c start "" <url>` rather than `shell: true`: `start` takes a window
// title as its first quoted argument, so the empty string is what stops it
// swallowing the URL as one. cmd.exe is a real .exe, so no shell needed.
bin = "cmd";
args = ["/c", "start", "", url];
}
try {
spawn(bin, args, { detached: true, stdio: "ignore" }).unref();
return true;
const child = spawn(bin, args, {
detached: true,
stdio: "ignore",
windowsHide: true,
});
detach(child);
return child.pid !== undefined;
} catch {
return false;
}
+5 -1
View File
@@ -163,7 +163,11 @@ describe("preparePromptInput — source backfill", () => {
source: { file: "/src/App.tsx", line: 4 },
})
);
expect(input.source?.file).toBe(join("src", "App.tsx"));
// Forward slashes, not `join`: this value is separator-independent by
// design. It goes into the edit prompt, into the JSON an MCP tool returns
// (where a backslash arrives doubled), and into the overlay as a label, so
// the resolver normalizes it rather than emitting a native path.
expect(input.source?.file).toBe("src/App.tsx");
expect(input.source?.context).toContain("Get Started");
});
});
+1 -1
View File
@@ -20,7 +20,7 @@
],
"scripts": {
"build": "node scripts/gen.mjs && tsup src/index.ts --format esm --dts --sourcemap --clean && node scripts/postbuild.mjs",
"clean": "rm -rf dist .turbo",
"clean": "node ../../scripts/clean.mjs dist .turbo",
"gen": "node scripts/gen.mjs",
"typecheck": "tsc --noEmit"
},
+3 -1
View File
@@ -16,7 +16,9 @@ if (!existsSync(designPath)) {
}
const raw = readFileSync(designPath, "utf8");
const match = raw.match(/^---\n([\s\S]*?)\n---/);
// \r? because a Windows checkout with core.autocrlf=true hands us CRLF, and a
// front-matter block that opens `---\r\n` would otherwise read as absent.
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) {
throw new Error("gen: DESIGN.md is missing its YAML front-matter block");
}
+1 -1
View File
@@ -19,7 +19,7 @@
},
"scripts": {
"build": "tsup src/browser.ts src/server.ts src/tokens.ts --format esm --dts --sourcemap --clean",
"clean": "rm -rf dist .turbo",
"clean": "node ../../scripts/clean.mjs dist .turbo",
"dev": "tsup src/browser.ts src/server.ts src/tokens.ts --format esm --dts --sourcemap --watch",
"test": "vitest run",
"typecheck": "tsc --noEmit"
+77
View File
@@ -0,0 +1,77 @@
/**
* Turning what the browser reports into a path on disk.
*
* The input is a dev-server URL path, not a filesystem path, and the two look
* alike enough to be confused: `/src/App.tsx` is rooted as far as `resolve` is
* concerned. Getting it wrong is quiet — the agent is handed context from the
* wrong file, or none at all — so the cases are pinned here.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveServerSource } from "./server";
/** Native separators to URL ones, and the leading slash a URL path drops. */
const BACKSLASH = /\\/g;
const LEADING_SLASH = /^\//;
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), "airship-source-test-"));
mkdirSync(join(cwd, "src"), { recursive: true });
writeFileSync(join(cwd, "src", "App.tsx"), "const App = () => null;\n");
});
afterEach(() => {
rmSync(cwd, { force: true, recursive: true });
});
const resolveFile = (file: string, line = 1) =>
resolveServerSource(cwd, { source: { file, line } })?.file;
describe("resolveServerSource", () => {
it("resolves a dev-server URL path against the project root", () => {
expect(resolveFile("/src/App.tsx")).toBe("src/App.tsx");
});
it("resolves a project-relative path", () => {
expect(resolveFile("src/App.tsx")).toBe("src/App.tsx");
});
it("resolves a genuinely absolute path inside the project", () => {
expect(resolveFile(join(cwd, "src", "App.tsx"))).toBe("src/App.tsx");
});
it("resolves a /@fs/ path, which Vite uses for files outside its root", () => {
// Built the way Vite builds it — `/@fs/` followed by the absolute path in
// URL form: forward slashes, and no leading slash of its own. On POSIX that
// reads `/@fs/Users/…`, on Windows `/@fs/C:/Users/…`, so the remainder has
// a leading slash to restore in one case and a drive letter in the other.
const abs = join(cwd, "src", "App.tsx");
const url = `/@fs/${abs.replace(BACKSLASH, "/").replace(LEADING_SLASH, "")}`;
expect(resolveFile(url)).toBe("src/App.tsx");
});
it("reports the path unchanged when the file cannot be located", () => {
expect(resolveFile("/src/Missing.tsx")).toBe("/src/Missing.tsx");
});
it("attaches surrounding source as context once it locates the file", () => {
const resolved = resolveServerSource(cwd, {
source: { file: "/src/App.tsx", line: 1 },
});
expect(resolved?.context).toContain("const App");
});
it("emits forward slashes for a nested path", () => {
// This value goes into the edit prompt, into the JSON an MCP tool returns
// (where a backslash arrives doubled) and into the overlay as a label.
mkdirSync(join(cwd, "src", "components"), { recursive: true });
writeFileSync(join(cwd, "src", "components", "Button.tsx"), "x\n");
const file = resolveFile("/src/components/Button.tsx");
expect(file).toBe("src/components/Button.tsx");
expect(file).not.toContain("\\");
});
});
+46 -8
View File
@@ -7,7 +7,7 @@
import { existsSync, readFileSync } from "node:fs";
import { relative, resolve } from "node:path";
import type { ElementContext, SourceLocation } from "@airship/protocol";
import { readCapped, walkFiles } from "./walk";
import { readCapped, toPosix, walkFiles } from "./walk";
export interface ResolveInput {
element?: ElementContext;
@@ -33,6 +33,11 @@ const LEADING_SLASHES = /^\/+/;
/** Route-ish and component-ish directories, on either path separator. */
const ROUTE_DIR = /[\\/](pages|app|routes)[\\/]/;
const COMPONENT_DIR = /[\\/]components?[\\/]/;
/** Vite serves files outside its root under `/@fs/<abs path>`. */
const VITE_FS_PREFIX = /^\/@fs\/(.*)$/;
/** `C:` — a path already rooted at a Windows drive. */
const WIN32_DRIVE = /^[a-zA-Z]:/;
const WIN32 = process.platform === "win32";
export function resolveServerSource(
cwd: string,
@@ -42,7 +47,10 @@ export function resolveServerSource(
const abs = resolveExistingSource(cwd, input.source.file);
// Normalize to a project-relative path so the agent gets a path it can
// open; fall back to the reported path if we can't locate the file.
const file = abs ? relative(cwd, abs) : input.source.file;
// Forward slashes on the way out — this string is rendered into the prompt
// and into the JSON the MCP tool returns, where a Windows separator arrives
// doubled, and the overlay uses it as a display label.
const file = abs ? toPosix(relative(cwd, abs)) : input.source.file;
const context = abs ? readContext(abs, input.source.line) : undefined;
return { ...input.source, context: context ?? input.source.context, file };
}
@@ -57,19 +65,49 @@ export function resolveServerSource(
* would treat the leading slash as absolute and miss the file. Try the reported
* path first, then a cwd-relative form. Returns the absolute path that exists,
* or null.
*
* The ordering flips on Windows, and it matters. There `resolve(cwd, "/src/
* App.tsx")` does not fail — it resolves against cwd's *drive*, yielding
* `C:\src\App.tsx`. `C:\src` is an ordinary directory that may well exist, and
* if it does the wrong file is read, handed to the agent as context, and opened
* in the editor. On Windows a leading slash with no drive letter is never an
* absolute path, so the project-relative reading is the only correct one.
*/
function resolveExistingSource(cwd: string, file: string): string | null {
const direct = resolve(cwd, file);
if (existsSync(direct)) {
return direct;
const rooted = viteFsPath(file);
if (rooted) {
return existsSync(rooted) ? rooted : null;
}
if (file.startsWith("/")) {
const urlPath = file.startsWith("/");
if (urlPath) {
const stripped = resolve(cwd, file.replace(LEADING_SLASHES, ""));
if (existsSync(stripped)) {
return stripped;
}
if (WIN32) {
return null;
}
}
return null;
const direct = resolve(cwd, file);
return existsSync(direct) ? direct : null;
}
/**
* The absolute path behind a `/@fs/` URL, or null if this is not one.
*
* Vite serves anything outside its root this way. Without handling it here the
* whole `/@fs/…` string fell through as an unresolvable path and went to the
* agent verbatim — and on Windows `/@fs/C:/Users/…` would resolve to
* `C:\C:\Users\…`, which cannot exist.
*/
function viteFsPath(file: string): string | null {
const match = file.match(VITE_FS_PREFIX);
if (!match?.[1]) {
return null;
}
// POSIX needs back the slash the capture dropped; a Windows path already
// starts with its drive and must not be given one.
return WIN32_DRIVE.test(match[1]) ? match[1] : `/${match[1]}`;
}
function readContext(absPath: string, line: number): string | undefined {
@@ -161,7 +199,7 @@ function searchByElement(
}
return {
context: readContext(best.file, best.line),
file: relative(cwd, best.file),
file: toPosix(relative(cwd, best.file)),
line: best.line,
};
}
+2 -2
View File
@@ -29,7 +29,7 @@ import {
isTokenizableValue,
type TokenScanResult,
} from "@airship/protocol/tokens";
import { readCapped, walkFiles } from "./walk";
import { readCapped, toPosix, walkFiles } from "./walk";
const CSS_EXT: ReadonlySet<string> = new Set([
".css",
@@ -300,7 +300,7 @@ function scanUncached(cwd: string): TokenScanResult {
const lines = lineIndex(text);
// Relative to the scan root, which is what the agent's own cwd-relative
// paths are resolved against.
const rel = relative(root, file) || file;
const rel = toPosix(relative(root, file)) || file;
scanFile(text, lines, rel, { customProperties, usage, utilities });
}
+13 -1
View File
@@ -9,7 +9,7 @@
* daemon that appears to hang on startup.
*/
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { join, sep } from "node:path";
export const IGNORE_DIRS: ReadonlySet<string> = new Set([
"node_modules",
@@ -35,6 +35,18 @@ export function safeReaddir(dir: string) {
}
}
/**
* Rewrite a native path to forward slashes.
*
* Every path this package hands out crosses a boundary that has no notion of a
* Windows separator: the edit prompt, the JSON an MCP tool returns (where each
* `\` arrives doubled), and the overlay, which uses these as display labels and
* map keys. A no-op off Windows.
*/
export function toPosix(path: string): string {
return sep === "/" ? path : path.split(sep).join("/");
}
export function extOf(name: string): string {
const dot = name.lastIndexOf(".");
return dot === -1 ? "" : name.slice(dot);
+30 -21
View File
@@ -24,28 +24,37 @@ minimumReleaseAgeExclude:
# a name pattern carrying a version union and enumerating all 25 for every
# bump is not maintainable. They only ever publish in lockstep with the
# version above, so the unpinned glob follows whatever it is pinned to.
#
# EVERY VERSION BELOW MUST MATCH THE LOCKFILE. A pin left behind at an older
# version does not fail loudly — it simply stops excluding anything, and the
# package silently falls back under the release-age gate, which is the
# esbuild failure above all over again. All of these but esbuild had drifted
# at once (turbo 2.10.1 vs 2.10.9, the Claude SDK 0.3.196 vs 0.3.226, both
# codex packages a minor behind), and every one of them ships per-platform
# optional deps including win32 — so the package most likely to go missing is
# the one for whichever platform is least represented in the team's machines.
# `pnpm why <pkg>` after a bump, and update the pin in the same commit.
- 'esbuild@0.28.2'
- '@esbuild/*'
- '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.196'
- '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.196'
- '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.196'
- '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.196'
- '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.196'
- '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.196'
- '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.196'
- '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.196'
- '@anthropic-ai/claude-agent-sdk@0.3.196'
- '@openai/codex-sdk@0.146.0'
- '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.226'
- '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.226'
- '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.226'
- '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.226'
- '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.226'
- '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.226'
- '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.226'
- '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.226'
- '@anthropic-ai/claude-agent-sdk@0.3.226'
- '@openai/codex-sdk@0.147.0'
# Unlike the other two SDKs this bundles no binary — the `opencode` CLI is a
# separate install, detected on PATH at runtime. See providers/opencode.ts.
- '@opencode-ai/sdk@1.18.13'
- '@openai/codex@0.146.0-darwin-arm64 || 0.146.0-darwin-x64 || 0.146.0-linux-arm64 || 0.146.0-linux-x64 || 0.146.0-win32-arm64 || 0.146.0-win32-x64 || 0.146.0'
- '@turbo/darwin-64@2.10.1'
- '@turbo/darwin-arm64@2.10.1'
- '@turbo/linux-64@2.10.1'
- '@turbo/linux-arm64@2.10.1'
- '@turbo/windows-64@2.10.1'
- '@turbo/windows-arm64@2.10.1'
- turbo@2.10.1
- ultracite@7.10.0
- '@opencode-ai/sdk@1.18.13'
- '@opencode-ai/sdk@1.18.15'
- '@openai/codex@0.147.0-darwin-arm64 || 0.147.0-darwin-x64 || 0.147.0-linux-arm64 || 0.147.0-linux-x64 || 0.147.0-win32-arm64 || 0.147.0-win32-x64 || 0.147.0'
- '@turbo/darwin-64@2.10.9'
- '@turbo/darwin-arm64@2.10.9'
- '@turbo/linux-64@2.10.9'
- '@turbo/linux-arm64@2.10.9'
- '@turbo/windows-64@2.10.9'
- '@turbo/windows-arm64@2.10.9'
- turbo@2.10.9
- ultracite@7.10.2
+43
View File
@@ -0,0 +1,43 @@
// Removes build output for the package that invokes it.
//
// Why this exists: every workspace `clean` script used to be `rm -rf dist
// .turbo`, and `rm` does not exist on Windows. pnpm runs lifecycle scripts
// through the platform shell, so on Windows that is cmd.exe and all eleven of
// them died with "'rm' is not recognized". `turbo run clean` fans out to every
// package, so `pnpm clean` failed eleven times over.
//
// A shared script rather than a `rimraf` dependency: this needs no install to
// work, nothing to keep pinned against the release-age gate in
// pnpm-workspace.yaml, and no argument that has to survive two different
// shells' quoting rules. Node resolves forward slashes on Windows, so
// `node ../../scripts/clean.mjs dist .turbo` is literally the same string
// everywhere.
//
// Usage, from a package directory: node ../../scripts/clean.mjs <path>...
import { rmSync } from "node:fs";
import { isAbsolute, relative, resolve, sep } from "node:path";
const targets = process.argv.slice(2);
if (targets.length === 0) {
process.stderr.write("clean: nothing to remove (pass one or more paths)\n");
process.exit(1);
}
const cwd = process.cwd();
for (const target of targets) {
// This deletes recursively and by force, so it refuses anything that is not
// strictly inside the calling package. A typo in a package.json should not be
// able to reach the workspace root.
const abs = resolve(cwd, target);
const rel = relative(cwd, abs);
if (isAbsolute(target) || rel === "" || rel.startsWith(`..${sep}`)) {
process.stderr.write(
`clean: refusing to remove "${target}" — it escapes ${cwd}\n`
);
process.exit(1);
}
rmSync(abs, { force: true, recursive: true });
}
+10 -2
View File
@@ -33,6 +33,10 @@ const LINK = /(!?)\[([^\]]*)\]\(([^)\s]+)\)/g;
/** ``` or ~~~ opening or closing a fenced block, which is never rewritten. */
const FENCE = /^\s*(?:```|~~~)/;
/** A scheme, a protocol-relative host, or an anchor — already resolvable. */
// Matches CRLF as readily as LF: a Windows working tree would otherwise leave a
// trailing \r on every line, and the rejoin below emits LF, so the --check
// comparison could never match the file on disk.
const LINE_BREAK = /\r?\n/;
const ABSOLUTE = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
const LEADING_DOT_SLASH = /^\.\//;
const REPO_URL = /github\.com[/:]([^/]+)\/([^/.]+)/;
@@ -94,7 +98,7 @@ function render(source, slug) {
return `${bang}[${label}](${absolutize(target, bang === "!", slug)})`;
});
for (const line of source.split("\n")) {
for (const line of source.split(LINE_BREAK)) {
if (FENCE.test(line)) {
inFence = !inFence;
out.push(line);
@@ -115,7 +119,11 @@ if (process.argv.includes("--check")) {
} catch {
die("apps/cli/README.md is missing — run `node scripts/sync-readme.mjs`");
}
if (current !== text) {
// Compare on content, not line endings. .gitattributes pins a fresh checkout
// to LF, but a file that arrived some other way — an existing clone, a zip, a
// patch, an editor configured for CRLF — would otherwise report as eternally
// stale with no way for the contributor to make it pass.
if (current.replace(/\r\n/g, "\n") !== text) {
die(
"apps/cli/README.md is stale — run `node scripts/sync-readme.mjs` and commit the result"
);