diff --git a/.agents/skills/verify-libghostty-vt-node/SKILL.md b/.agents/skills/verify-libghostty-vt-node/SKILL.md new file mode 100644 index 0000000..c05a4cf --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/SKILL.md @@ -0,0 +1,101 @@ +--- +name: verify-libghostty-vt-node +description: "Drive @coder/libghostty-vt-node the way a user does: build and pack the package from this checkout, install the tarball into a scratch consumer project, run Node journeys that import the package by name, and keep stdout, stderr, and exit codes as evidence. Use to prove a change to the public API (createTerminal, feed, snapshot, resize, dispose, getNativeInfo), the native addon, the loader, or the package contents works for a package user." +--- + +# Verify libghostty-vt-node as a package user + +The user surface is a Node library. A user runs `npm install @coder/libghostty-vt-node` and gets a prebuilt addon for the platform. Then the user imports the package by name and calls `createTerminal`, `feed`, `snapshot`, `resize`, `getVisibleText`, `formatPlain`, `formatHtml`, `dispose`, and `getNativeInfo`. This skill reproduces that path from the current checkout, uncommitted changes included. It does not import `src/` or `dist/` from the checkout. + +Other surfaces are out of scope here. The release workflows (`.github/workflows/publish.yml`, `mise run publish-dry-run`) publish to npm. Do not drive them for verification. The worker-thread and dispose lifecycle has a formal check in the `verify-native-lifecycle` skill. + +All commands run from the repo root. Set this first, and again in each new shell (agent tool calls often start a new shell, so set `S`, `RUN_ROOT`, and `PATH` in each call): + +```sh +S=.agents/skills/verify-libghostty-vt-node/scripts +``` + +## Requirements + +- The toolchain that `mise.toml` pins: Node `26.12.1` and Zig `0.15.2` on `PATH`. `mise install` installs them. If `mise exec` fails on your host, put the two install directories on `PATH` directly, for example `~/.local/share/mise/installs/node/26.12.1/bin` and `~/.local/share/mise/installs/zig/0.15.2`. +- A C++17 compiler and Python for `node-gyp`. +- A checkout that mise trusts. In an untrusted copy, a mise shim that a build step calls fails with `Config files in .../mise.toml are not trusted`, and `launch.sh` exits `1`. +- The network on the first launch: `npm ci`, the Ghostty clone in `npm run build:libghostty`, and the dependency install in the consumer project. + +## Launch + +```sh +$S/launch.sh # makes a new RUN_ROOT with mktemp +$S/launch.sh "$RUN_ROOT" # or uses a directory that you name, outside the checkout +``` + +`launch.sh` runs in the checkout: `npm ci` (when `node_modules/` was not built from the current `package-lock.json`), `npm run build:libghostty` (when `vendor/libghostty-vt/` was not built from the current `scripts/build-libghostty-vt.sh` and `GHOSTTY_*` / `LIBGHOSTTY_VT_*` variables), `npm run build` from an empty `dist/`, `npm run build:prebuild`, and `npm pack`. A stamp file with the hash of those inputs records each reuse decision, and `RUN_ROOT/instance/launch.log` lists the steps that ran. It records the commit and tracked diff before the build, and it fails if they change before the end. Then it installs the tarball into `RUN_ROOT/instance/consumer/` with `npm install`. It is ready when the last line is `READY RUN_ROOT=`. Set the shell variable `RUN_ROOT` to that path. It can differ from the path you gave, because `launch.sh` resolves symlinks. A warm launch takes about 15 seconds. The first one also builds libghostty-vt. + +A `RUN_ROOT` that you name must be a real directory (not a symlink) that only you can write. Every directory above it must be owned by you or root, and group or others must not be able to write it, unless it has the sticky bit, like `/tmp`. `launch.sh` creates a missing one with mode `700`. It refuses anything else, because `npm install` runs package scripts from the tarball in `RUN_ROOT`. + +`launch.sh` writes `build/`, `dist/`, and `prebuilds/` in the checkout, so only one launch can run per checkout. A second launch exits `2` and names the lock directory `vendor/.verify-launch.lock`. After launch, each `RUN_ROOT` is independent: many runs can drive their own instances at the same time. + +There is no server to tear down. Each journey is a short Node process. Teardown is `cleanup.sh`. + +## Doctor + +```sh +$S/doctor.sh "$RUN_ROOT" +``` + +It is read-only. Exit `0` means that the instance is worth driving. Each line starts with `ok`, `WARN`, or `FAIL`. It checks that: + +1. `instance.json` exists, `launch.sh` made it, and it names this checkout. +2. The checkout has the same `HEAD` and the same tracked diff as at launch. Otherwise it prints `STALE`: run `cleanup.sh`, then `launch.sh` with a new `RUN_ROOT`. +3. `node` on `PATH` is the version that launch used. It warns when that is not the `mise.toml` pin. +4. The package is a real copy in the consumer's `node_modules`, not a symlink to the checkout. +5. `node-gyp-build` picks the packed prebuild (`prebuilds/-/@coder+libghostty-vt-node.node`). +6. The bare import resolves inside the consumer, and `getNativeInfo().packageVersion` matches `package.json`. + +Run it first, and again whenever a result looks wrong. + +## Drive + +A journey is an ES module that `drive.sh` reads from stdin and runs with `node` in the consumer project. It imports `@coder/libghostty-vt-node` by name and asserts its own result with `node:assert/strict`, so its exit code is the verdict. + +```sh +$S/drive.sh "$RUN_ROOT" my-journey <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 20, rows: 2 }); +t.feed("hi"); +console.log(JSON.stringify(t.getVisibleText())); +assert.equal(t.getVisibleText(), "hi\n"); +t.dispose(); +JS +``` + +Put node flags after the name, for example `$S/drive.sh "$RUN_ROOT" lifecycle-gc --expose-gc`. `drive.sh` prints the journey's stdout, then `--- exit N, evidence in ` on stderr, and it exits with the journey's code. When stdout and stderr share one stream, the exit line can show up out of order: read `exit-code.txt` for the verdict. A crash gives `128 + signal`: `134` for SIGABRT, `139` for SIGSEGV. + +The map in [`features/README.md`](features/README.md) has a literal journey for each sub-feature. Read the index, then use the matching feature file. + +## Evidence + +Each journey writes `RUN_ROOT/evidence//`: `journey.mjs`, `command.txt` (directory, node flags, time, node version), `stdout.txt`, `stderr.txt`, and `exit-code.txt`. `RUN_ROOT/evidence/instance.json` names the commit, the tracked-diff hash, and the sha256 of the tarball, of the loaded addon, and of the libghostty-vt static library with the hash of its build inputs. One `RUN_ROOT` holds the evidence of one launch: `launch.sh` refuses a `RUN_ROOT` whose `evidence/` is not empty. `drive.sh` refuses to overwrite a journey's directory. + +Proof standards: + +- Drive the installed package through its public exports. Do not import `src/`, `dist/` in the checkout, the `.node` file, or the native `createTerminal` directly. +- A proof shows the action and the resulting state. Print what the journey saw (text, snapshot fields, error type and message), and assert it. +- Check side effects that a user can see: the files in the installed package (`info-package-contents`), and the addon file that `doctor.sh` reports. +- No mocks. The package has no external system to stand in for. +- A failed journey is a result. Report it with its stderr. Do not change the assertions to make it pass. + +## Cleanup + +```sh +$S/cleanup.sh "$RUN_ROOT" +``` + +It removes `RUN_ROOT/instance/` (the tarball, the consumer project, and the `no-addon` copy that `info-load-error` makes). It refuses an `instance/` without the `.verify-libghostty-vt-node` marker that `launch.sh` writes first, so it also cleans up after a failed launch. It keeps `RUN_ROOT/evidence/` and lists it. It does not touch the checkout's `build/`, `dist/`, or `prebuilds/`: those are normal build outputs, and git ignores them. Run cleanup after every run, the failed ones too. Remove `RUN_ROOT` yourself when you no longer need the evidence. + +## Gotchas + +- `launch.sh` packs whatever is in `prebuilds/`, including directories for other platforms from an earlier `mise run assemble-prebuilds`. `doctor.sh` checks that this platform's prebuild is the one that loads. +- `doctor.sh` hashes only tracked changes. A new untracked source file does not make the instance `STALE`. Relaunch after you add one. +- `RUN_ROOT` must be outside the checkout, so the instance never shows up in `git status` and no import can resolve to the checkout's own files. `launch.sh` refuses a `RUN_ROOT` inside it. diff --git a/.agents/skills/verify-libghostty-vt-node/features/README.md b/.agents/skills/verify-libghostty-vt-node/features/README.md new file mode 100644 index 0000000..e5c6c12 --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/features/README.md @@ -0,0 +1,41 @@ +# libghostty-vt-node verification map + +This directory is the maintained source for verifying what a user of `@coder/libghostty-vt-node` sees. Read this index before you drive the package. Then use the matching feature file as the recipe. + +## Baseline preconditions + +- Run every command from the repo root. +- `RUN_ROOT` is a directory outside the checkout that `launch.sh` made for this run. Never drive a `RUN_ROOT` that another run made. +- `doctor.sh "$RUN_ROOT"` exits `0`. Run it again after any change to the checkout: it fails with `STALE` when the build no longer matches the code. +- `S=.agents/skills/verify-libghostty-vt-node/scripts`. + +## Driving conventions + +- Each journey is an ES module that `drive.sh` runs in the consumer project. It imports `@coder/libghostty-vt-node` by name, the way a user does. +- Each journey asserts its own result with `node:assert/strict`, so the exit code is the verdict. It also prints what it saw, so stdout shows the result. +- Copy each command literally. Give each run a new journey name: `drive.sh` refuses to overwrite evidence. Add a suffix such as `-2` for a second run. +- Each journey creates its own terminals and disposes them. No journey depends on another one. + +## Proof and skip reporting + +- A journey's proof is its directory in `$RUN_ROOT/evidence/`: `journey.mjs`, `command.txt`, `stdout.txt`, `stderr.txt`, and `exit-code.txt`. Also keep `$RUN_ROOT/evidence/instance.json`, which names the commit and tarball. +- Report the journey name and its exit code with every claim. +- Report a failed journey with its stderr. Do not change a journey's assertions to make it pass. +- When a precondition fails, report the command and the unmet precondition. Do not report a skipped sub-feature as verified through another one. + +## Feature entry contract + +Each feature file starts with an H1 title and one paragraph that describes what the user sees. It then has exactly four H2 sections in this order. + +1. `Sub-features` lists short IDs, one line for each behavior. Each ID is also the journey name. +2. `How to get to it (user POV)` lists each public API call or install step that reaches the feature. +3. `Driving it with drive.sh` starts with `Preconditions:` and pairs each action with a literal command and its observable result. +4. `Gotchas` lists traps that can waste or invalidate a run. + +## Features + +- [Feed bytes and read text](./terminal-text.md) covers `feed` input kinds, escape handling, split UTF-8, and the debug formatters. +- [Structured snapshot](./snapshot.md) covers size, cursor, styled cells, scrollback, and the alternate screen. +- [Resize](./resize.md) covers new sizes, reflow, and invalid sizes. +- [Lifecycle and errors](./lifecycle.md) covers `dispose`, garbage collection, invalid input, and worker threads. +- [Install, load, and native info](./native-info.md) covers the installed package, `getNativeInfo`, and the missing-addon error. diff --git a/.agents/skills/verify-libghostty-vt-node/features/lifecycle.md b/.agents/skills/verify-libghostty-vt-node/features/lifecycle.md new file mode 100644 index 0000000..afebaab --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/features/lifecycle.md @@ -0,0 +1,122 @@ +# Lifecycle and errors + +A user creates terminals, disposes them, and lets the garbage collector free the ones they drop. Bad options and use after `dispose()` throw clear errors. A user can also load the package in `worker_threads`. + +## Sub-features + +- `lifecycle-dispose` makes `dispose()` idempotent, and every method throws after it. +- `lifecycle-gc` lets V8 collect dropped terminals, disposed or not, without a crash. +- `lifecycle-invalid` rejects bad `createTerminal` options and bad `feed` data. +- `lifecycle-workers` creates terminals in the main thread after a Worker loaded the package. + +## How to get to it (user POV) + +- `term.dispose()`, then any method on `term`. +- Drop every reference to a terminal and let V8 collect it. +- `createTerminal(options)` with wrong options, and `term.feed(data)` with a wrong type. +- `new Worker(...)` that imports `@coder/libghostty-vt-node`. + +## Driving it with drive.sh + +Preconditions: + +- `launch.sh` printed `READY RUN_ROOT=...`, and `doctor.sh "$RUN_ROOT"` exits `0`. +- `S=.agents/skills/verify-libghostty-vt-node/scripts`, run from the repo root. + +- **Dispose.** Dispose twice, then call each method. Run `lifecycle-dispose`. Exit `0`. Stdout lists 6 methods, each with `GhosttyVtTerminal has been disposed`. + +```sh +$S/drive.sh "$RUN_ROOT" lifecycle-dispose <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 10, rows: 2 }); +t.dispose(); t.dispose(); +const calls = { feed: () => t.feed("x"), resize: () => t.resize(5, 5), snapshot: () => t.snapshot(), + getVisibleText: () => t.getVisibleText(), formatPlain: () => t.formatPlain(), formatHtml: () => t.formatHtml() }; +for (const [name, call] of Object.entries(calls)) { + assert.throws(call, /GhosttyVtTerminal has been disposed/); + console.log(name, "throws: GhosttyVtTerminal has been disposed"); +} +JS +``` + +- **Garbage collection.** Create 200 terminals in a function, dispose every other one, return, and force GC. Run `lifecycle-gc` with `--expose-gc`. Exit `0`, and stdout is `collected 100 disposed and 100 open terminals`. Each count proves that V8 collected the JS `Terminal` objects of that kind and that the process survived. JS cannot see the native `TerminalWrap` finalizer or the freed handle, so this journey does not prove that the handles were freed. The `verify-native-lifecycle` skill covers that. + +```sh +$S/drive.sh "$RUN_ROOT" lifecycle-gc --expose-gc <<'JS' +import { createTerminal } from "@coder/libghostty-vt-node"; +const freed = new FinalizationRegistry((kind) => { freed.counts[kind]++; }); +freed.counts = { disposed: 0, open: 0 }; +// Create in a function, so no local variable keeps a terminal alive. +function createAndDrop(n) { + for (let i = 0; i < n; i++) { + const t = createTerminal({ cols: 20, rows: 5 }); + t.feed(`t${i}`); + const kind = i % 2 === 0 ? "disposed" : "open"; + if (kind === "disposed") t.dispose(); + freed.register(t, kind); + } +} +createAndDrop(200); +for (let i = 0; i < 20 && freed.counts.disposed + freed.counts.open < 200; i++) { + globalThis.gc(); await new Promise((r) => setTimeout(r, 10)); +} +console.log(`collected ${freed.counts.disposed} disposed and ${freed.counts.open} open terminals`); +if (freed.counts.disposed !== 100 || freed.counts.open !== 100) process.exit(1); +JS +``` + +- **Invalid input.** Run `lifecycle-invalid`. Exit `0`. Stdout shows each call with its error: `null` options give `AssertionError: options must not be null`, `cols: 0` gives `TypeError: cols must be a positive integer`, `scrollbackLimit: -1` gives `TypeError: scrollbackLimit must be a non-negative integer`, `cols: 70000` gives `RangeError: cols must be a positive 16-bit integer`, and `feed(123)` and `feed(new Uint16Array(2))` give `TypeError: feed data must be a string, Buffer, or Uint8Array`. + +```sh +$S/drive.sh "$RUN_ROOT" lifecycle-invalid <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 10, rows: 2 }); +const cases = [ + ["createTerminal(null)", () => createTerminal(null), "AssertionError", /options must not be null/], + ["createTerminal({cols:0})", () => createTerminal({ cols: 0, rows: 2 }), "TypeError", /cols must be a positive integer/], + ["createTerminal({scrollbackLimit:-1})", () => createTerminal({ cols: 2, rows: 2, scrollbackLimit: -1 }), "TypeError", /scrollbackLimit must be a non-negative integer/], + ["createTerminal({cols:70000})", () => createTerminal({ cols: 70000, rows: 2 }), "RangeError", /positive 16-bit integer/], + ["feed(123)", () => t.feed(123), "TypeError", /string, Buffer, or Uint8Array/], + ["feed(Uint16Array)", () => t.feed(new Uint16Array(2)), "TypeError", /string, Buffer, or Uint8Array/], +]; +for (const [label, call, type, msg] of cases) { + try { call(); assert.fail(`${label} did not throw`); } + catch (e) { console.log(label, "->", e.constructor.name + ":", e.message); + assert.equal(e.constructor.name, type); assert.match(e.message, msg); } +} +t.feed("still usable"); assert.equal(t.getVisibleText(), "still usab\nle"); +t.dispose(); +JS +``` + +- **Worker threads.** The main thread creates a terminal, a Worker loads the package, and the main thread creates another terminal. Run `lifecycle-workers`. Exit `0`, and stdout ends with `main: created a terminal after the worker loaded`. A failure kills the process with a signal, so `drive.sh` exits `134` (SIGABRT, stderr has `Cannot create a handle without a HandleScope`) or `139` (SIGSEGV). A hang exits `1` after 20 seconds with `timeout:` on stderr. That is a regression of finding F1 in the `verify-native-lifecycle` skill: report it, and do not change the journey to make it pass. + +```sh +$S/drive.sh "$RUN_ROOT" lifecycle-workers <<'JS' +import { Worker } from "node:worker_threads"; +import { once } from "node:events"; +import { createTerminal } from "@coder/libghostty-vt-node"; +// A hang is a failure too: end the journey with evidence instead of waiting forever. +setTimeout(() => { console.error("timeout: the worker journey did not finish in 20 s"); process.exit(1); }, 20_000).unref(); +createTerminal({ cols: 10, rows: 2 }).dispose(); +console.log("main: created a terminal"); +const w = new Worker(`import("@coder/libghostty-vt-node").then((m) => { + m.createTerminal({ cols: 10, rows: 2 }).dispose(); + import("node:worker_threads").then(({ parentPort }) => parentPort.postMessage("loaded")); +});`, { eval: true }); +await once(w, "message"); +console.log("worker: loaded the package and created a terminal"); +createTerminal({ cols: 10, rows: 2 }).dispose(); +console.log("main: created a terminal after the worker loaded"); +await w.terminate(); +JS +``` + +## Gotchas + +- `lifecycle-workers` checks one order of events. The `verify-native-lifecycle` skill checks every order within its bounds, and `test/workers.test.ts` covers an exited worker and two workers. +- `lifecycle-gc` needs `--expose-gc` after the journey name. Without it, `globalThis.gc` is undefined and the journey throws. +- GC timing varies. The journey retries 20 times. A count below 100 for either kind after that is a failure to report, not a reason to raise the retry count. Create terminals inside a function as the journey does: a terminal held by a top-level variable or array is never collected. +- A Worker's `eval` code resolves `@coder/libghostty-vt-node` from the consumer project, because `drive.sh` runs node in that directory. diff --git a/.agents/skills/verify-libghostty-vt-node/features/native-info.md b/.agents/skills/verify-libghostty-vt-node/features/native-info.md new file mode 100644 index 0000000..d545d80 --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/features/native-info.md @@ -0,0 +1,79 @@ +# Install, load, and native info + +A user installs the npm package and imports it. The package loads the prebuilt addon for the user's platform without a compiler. `getNativeInfo()` reports the package version, Node-API version, platform, and Ghostty version. When no addon fits, the import works and the first native call throws a clear setup error. + +## Sub-features + +- `info-fields` reports the package version, Node-API version, platform, arch, and Ghostty version. +- `info-package-contents` ships `dist/` and the prebuild, and leaves sources and tests out. +- `info-load-error` throws the setup error when the addon is missing. + +## How to get to it (user POV) + +- `npm install @coder/libghostty-vt-node`, then `import { getNativeInfo, createTerminal } from "@coder/libghostty-vt-node"`. +- `getNativeInfo()`. + +## Driving it with drive.sh + +Preconditions: + +- `launch.sh` printed `READY RUN_ROOT=...`, and `doctor.sh "$RUN_ROOT"` exits `0`. +- `S=.agents/skills/verify-libghostty-vt-node/scripts`, run from the repo root. + +- **Native info.** Run `info-fields`. Exit `0`. Stdout is one JSON object with `packageVersion` equal to `package.json`'s version, `napiVersion` 8 or more, `platform` and `arch` of this machine, and a `ghosttyVersion` string. + +```sh +$S/drive.sh "$RUN_ROOT" info-fields <<'JS' +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { getNativeInfo } from "@coder/libghostty-vt-node"; +const info = getNativeInfo(); +const pkg = JSON.parse(readFileSync("node_modules/@coder/libghostty-vt-node/package.json", "utf8")); +console.log(JSON.stringify(info)); +assert.equal(info.packageVersion, pkg.version); +assert.ok(info.napiVersion >= 8); +assert.deepEqual([info.platform, info.arch], [process.platform, process.arch]); +assert.equal(typeof info.ghosttyVersion, "string"); +JS +``` + +- **Package contents.** List the installed package. Run `info-package-contents`. Exit `0`. Stdout lists `dist/index.js`, `dist/index.d.ts`, and `prebuilds/-/@coder+libghostty-vt-node.node`, and no `src/`, `test/`, `vendor/`, or `build/` entry. + +```sh +$S/drive.sh "$RUN_ROOT" info-package-contents <<'JS' +import assert from "node:assert/strict"; +import { readdirSync } from "node:fs"; +const dir = "node_modules/@coder/libghostty-vt-node"; +const files = readdirSync(dir, { recursive: true }).map(String).sort(); +console.log(files.join("\n")); +const addon = `prebuilds/${process.platform}-${process.arch}/@coder+libghostty-vt-node.node`; +for (const f of ["dist/index.js", "dist/index.d.ts", addon]) assert.ok(files.includes(f), `missing ${f}`); +for (const d of ["src", "test", "vendor", "build"]) assert.ok(!files.includes(d), `ships ${d}/`); +JS +``` + +- **Missing addon.** The journey copies the installed package to `instance/no-addon/` without `prebuilds/`, imports the copy, and calls `getNativeInfo()`. Run `info-load-error`. Exit `0`. Stdout shows that the import worked, then `Failed to load @coder/libghostty-vt-node native addon. Build it with \`npm run build:native\`...`. + +```sh +$S/drive.sh "$RUN_ROOT" info-load-error <<'JS' +import assert from "node:assert/strict"; +import { cpSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +const copy = resolve("../no-addon/node_modules/@coder/libghostty-vt-node"); +rmSync(resolve("../no-addon"), { recursive: true, force: true }); +cpSync("node_modules/@coder/libghostty-vt-node", copy, { recursive: true }); +cpSync("node_modules/node-gyp-build", resolve("../no-addon/node_modules/node-gyp-build"), { recursive: true }); +rmSync(resolve(copy, "prebuilds"), { recursive: true }); +const m = await import(pathToFileURL(resolve(copy, "dist/index.js")).href); +console.log("import worked"); +assert.throws(() => m.getNativeInfo(), (e) => { console.log(e.message); return /^Failed to load @coder\/libghostty-vt-node native addon\./.test(e.message); }); +JS +``` + +## Gotchas + +- `getNativeInfo()` has no `ghosttyCommit` with the current build (the Ghostty build reports no build string). Do not use it to check the pinned commit. +- `ghosttyVersion` is `0.1.0-dev` at the current pin. It is Ghostty's version, not this package's. +- The import itself never fails for a missing addon. The loader runs on the first native call (`createTerminal` or `getNativeInfo`). +- `info-load-error` writes `instance/no-addon/`. `cleanup.sh` removes it with the instance. diff --git a/.agents/skills/verify-libghostty-vt-node/features/resize.md b/.agents/skills/verify-libghostty-vt-node/features/resize.md new file mode 100644 index 0000000..a05174f --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/features/resize.md @@ -0,0 +1,77 @@ +# Resize + +A user resizes the terminal when the real terminal window changes size. The snapshot reports the new size, and wrapped text reflows to the new width. + +## Sub-features + +- `resize-dims` changes `cols` and `rows` in the snapshot. +- `resize-reflow` rewraps a long line when the width shrinks and joins it again when the width grows. +- `resize-invalid` rejects sizes that are not positive 16-bit integers. + +## How to get to it (user POV) + +- `term.resize(cols, rows)`. + +## Driving it with drive.sh + +Preconditions: + +- `launch.sh` printed `READY RUN_ROOT=...`, and `doctor.sh "$RUN_ROOT"` exits `0`. +- `S=.agents/skills/verify-libghostty-vt-node/scripts`, run from the repo root. + +- **New size.** Resize 80x24 to 100x30. Run `resize-dims`. Exit `0`, and stdout is `100 30`. + +```sh +$S/drive.sh "$RUN_ROOT" resize-dims <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 80, rows: 24 }); +t.resize(100, 30); +const s = t.snapshot(); +console.log(s.cols, s.rows); +assert.deepEqual([s.cols, s.rows, s.visibleLines.length], [100, 30, 30]); +t.dispose(); +JS +``` + +- **Reflow.** Write `0123456789abc` into a 10-column terminal, shrink it to 5 columns, then widen it to 20. Run `resize-reflow`. Exit `0`. Stdout shows `0123456789/abc`, then `01234/56789/abc`, then `0123456789abc`. + +```sh +$S/drive.sh "$RUN_ROOT" resize-reflow <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 10, rows: 3 }); +const rows = () => t.snapshot().visibleLines.map((l) => l.text).filter(Boolean).join("/"); +t.feed("0123456789abc"); +const before = rows(); t.resize(5, 6); +const narrow = rows(); t.resize(20, 3); +const wide = rows(); +console.log(before); console.log(narrow); console.log(wide); +assert.deepEqual([before, narrow, wide], ["0123456789/abc", "01234/56789/abc", "0123456789abc"]); +t.dispose(); +JS +``` + +- **Invalid sizes.** Call `resize(0, 1)`, `resize(1.5, 1)`, and `resize(70000, 1)`. Run `resize-invalid`. Exit `0`. The first two throw `TypeError: cols must be a positive integer`. The third throws `RangeError: cols must be a positive 16-bit integer`. The size stays 10x3. + +```sh +$S/drive.sh "$RUN_ROOT" resize-invalid <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 10, rows: 3 }); +for (const [cols, Err, msg] of [[0, TypeError, /positive integer/], [1.5, TypeError, /positive integer/], + [70000, RangeError, /positive 16-bit integer/]]) { + try { t.resize(cols, 1); assert.fail(`resize(${cols}) did not throw`); } + catch (e) { console.log(`resize(${cols}, 1):`, e.constructor.name, e.message); + assert.ok(e instanceof Err); assert.match(e.message, msg); } +} +const s = t.snapshot(); +assert.deepEqual([s.cols, s.rows], [10, 3]); +t.dispose(); +JS +``` + +## Gotchas + +- Only wrapped lines reflow. A line that ended with `\r\n` stays its own row. +- The JS layer rejects non-integers and values below 1 with `TypeError`. The native layer rejects values above 65535 with `RangeError`. Both layers are on the user path, so test both. diff --git a/.agents/skills/verify-libghostty-vt-node/features/snapshot.md b/.agents/skills/verify-libghostty-vt-node/features/snapshot.md new file mode 100644 index 0000000..5a2f8f6 --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/features/snapshot.md @@ -0,0 +1,99 @@ +# Structured snapshot + +A user calls `term.snapshot()` to read the screen as data: size, cursor position, alternate-screen flag, one entry for each visible row and, on request, the styled cells and the scrollback rows. + +## Sub-features + +- `snapshot-shape` reports size, cursor, and one `visibleLines` entry for each row. +- `snapshot-cells` reports each cell's text, width, and style when `includeCells` is set. +- `snapshot-scrollback` reports rows that scrolled off when `includeScrollback` is set. +- `snapshot-alt-screen` reports `isAltScreen` and the alternate screen's text. + +## How to get to it (user POV) + +- `term.snapshot()`, `term.snapshot({ includeCells: true })`, `term.snapshot({ includeScrollback: true })`. +- `createTerminal({ cols, rows, scrollbackLimit })` sets how many rows scrollback keeps. + +## Driving it with drive.sh + +Preconditions: + +- `launch.sh` printed `READY RUN_ROOT=...`, and `doctor.sh "$RUN_ROOT"` exits `0`. +- `S=.agents/skills/verify-libghostty-vt-node/scripts`, run from the repo root. + +- **Shape and cursor.** Write `hello` on the first row, move the cursor to row 3, column 5 (`ESC[3;5H`), and write `cursor`. Run `snapshot-shape`. Exit `0`. Stdout shows `cols` 80, `rows` 24, `cursorRow` 2, `cursorCol` 10 (both zero-based), the first 3 visible lines, and no `cells` or `scrollbackLines` key. The journey asserts that there are 24 visible lines. + +```sh +$S/drive.sh "$RUN_ROOT" snapshot-shape <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 80, rows: 24 }); +t.feed("hello\r\n\x1b[3;5Hcursor"); +const s = t.snapshot(); +console.log(JSON.stringify({ ...s, visibleLines: s.visibleLines.slice(0, 3) })); +assert.deepEqual([s.cols, s.rows, s.cursorRow, s.cursorCol, s.isAltScreen], [80, 24, 2, 10, false]); +assert.equal(s.visibleLines.length, 24); +assert.deepEqual(s.visibleLines[2], { row: 2, text: " cursor" }); +assert.ok(!("cells" in s) && !("scrollbackLines" in s)); +t.dispose(); +JS +``` + +- **Styled cells.** Write `S` bold, italic, underlined, red on green, then `R` in RGB `1;2;3`. Run `snapshot-cells`. Exit `0`. The `S` cell has `bold`, `italic`, `underline`, `foreground` `#cc6666`, and `background` `#b5bd68`. The `R` cell has `foreground` `#010203` and no other style keys. + +```sh +$S/drive.sh "$RUN_ROOT" snapshot-cells <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 10, rows: 1 }); +t.feed("\x1b[1;3;4;31;42mS\x1b[0m\x1b[38;2;1;2;3mR\x1b[0m"); +const cells = t.snapshot({ includeCells: true }).cells; +console.log(JSON.stringify(cells)); +assert.deepEqual(cells[0], { row: 0, col: 0, text: "S", width: 1, bold: true, italic: true, + underline: true, foreground: "#cc6666", background: "#b5bd68" }); +assert.deepEqual(cells[1], { row: 0, col: 1, text: "R", width: 1, foreground: "#010203" }); +t.dispose(); +JS +``` + +- **Scrollback.** Write 6 lines into a 3-row terminal with `scrollbackLimit: 100`. Run `snapshot-scrollback`. Exit `0`. `scrollbackLines` holds `l1`, `l2`, `l3` (rows 0 to 2). The visible rows are `l4`, `l5`, `l6`. + +```sh +$S/drive.sh "$RUN_ROOT" snapshot-scrollback <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 10, rows: 3, scrollbackLimit: 100 }); +t.feed("l1\r\nl2\r\nl3\r\nl4\r\nl5\r\nl6"); +const s = t.snapshot({ includeScrollback: true }); +console.log(JSON.stringify({ scrollbackLines: s.scrollbackLines, visibleLines: s.visibleLines })); +assert.deepEqual(s.scrollbackLines.map((l) => l.text), ["l1", "l2", "l3"]); +assert.deepEqual(s.visibleLines.map((l) => l.text), ["l4", "l5", "l6"]); +t.dispose(); +JS +``` + +- **Alternate screen.** Write `primary`, enter the alternate screen (`ESC[?1049h`), move the cursor home (`ESC[H`), write `alt`, then leave it (`ESC[?1049l`). Run `snapshot-alt-screen`. Exit `0`. Stdout shows `true` with the text `alt`, then `false` with the text `primary`. + +```sh +$S/drive.sh "$RUN_ROOT" snapshot-alt-screen <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 20, rows: 2 }); +t.feed("primary\x1b[?1049h\x1b[Halt"); +const inAlt = t.snapshot(); +t.feed("\x1b[?1049l"); +const back = t.snapshot(); +console.log(inAlt.isAltScreen, JSON.stringify(inAlt.visibleLines[0].text)); +console.log(back.isAltScreen, JSON.stringify(back.visibleLines[0].text)); +assert.deepEqual([inAlt.isAltScreen, inAlt.visibleLines[0].text], [true, "alt"]); +assert.deepEqual([back.isAltScreen, back.visibleLines[0].text], [false, "primary"]); +t.dispose(); +JS +``` + +## Gotchas + +- `cells` lists only cells that have text. Blank cells and the spacer cell after a wide character are not in it. +- Palette colors come from Ghostty's default palette (red is `#cc6666`). A change of the pinned Ghostty commit can change these values. Treat that as a change to check, not as noise. +- `scrollbackLines` is an empty array until a row scrolls off. With `scrollbackLimit: 0` it stays empty. +- Entering the alternate screen keeps the cursor column. Feed `ESC[H` first when the test expects text at column 0. diff --git a/.agents/skills/verify-libghostty-vt-node/features/terminal-text.md b/.agents/skills/verify-libghostty-vt-node/features/terminal-text.md new file mode 100644 index 0000000..ebccf5f --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/features/terminal-text.md @@ -0,0 +1,94 @@ +# Feed bytes and read text + +A user creates a terminal, feeds it the bytes a program wrote (a string, a `Buffer`, or a `Uint8Array`), and reads the screen back as plain text. Escape sequences change the screen but never show up in the text. `formatPlain()` and `formatHtml()` export the same screen for debugging. + +## Sub-features + +- `text-feed-kinds` accepts a string, a `Buffer`, and a `Uint8Array`. +- `text-sgr-hidden` keeps color escapes out of the visible text. +- `text-utf8-split` joins a UTF-8 character that arrives split across feeds. +- `text-formatters` exports the screen as plain text and as HTML. + +## How to get to it (user POV) + +- `createTerminal({ cols, rows })`, then `term.feed(data)` and `term.getVisibleText()`. +- `term.formatPlain()` and `term.formatHtml()` on the same terminal. + +## Driving it with drive.sh + +Preconditions: + +- `launch.sh` printed `READY RUN_ROOT=...`, and `doctor.sh "$RUN_ROOT"` exits `0`. +- `S=.agents/skills/verify-libghostty-vt-node/scripts`, run from the repo root. + +- **Feed each kind and read the text.** Run `text-feed-kinds`. Exit `0`, and stdout is `"hello\nbuf\nu8\n"`: one line for each feed, then the empty fourth row. + +```sh +$S/drive.sh "$RUN_ROOT" text-feed-kinds <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 20, rows: 4 }); +t.feed("hello\r\n"); +t.feed(Buffer.from("buf\r\n")); +t.feed(new Uint8Array([0x75, 0x38, 0x0d, 0x0a])); +const text = t.getVisibleText(); +console.log(JSON.stringify(text)); +assert.equal(text, "hello\nbuf\nu8\n"); +t.dispose(); +JS +``` + +- **Color escapes stay out of the text.** Run `text-sgr-hidden`. Exit `0`, and stdout is `"red text"`. + +```sh +$S/drive.sh "$RUN_ROOT" text-sgr-hidden <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 20, rows: 1 }); +t.feed("\x1b[31mred\x1b[0m \x1b[1;4mtext\x1b[0m"); +console.log(JSON.stringify(t.getVisibleText())); +assert.equal(t.getVisibleText(), "red text"); +t.dispose(); +JS +``` + +- **A split UTF-8 character.** Feed the three bytes of `界` in three calls, then `x`. Run `text-utf8-split`. Exit `0`. Stdout shows the text `"界x"` and two cells: `界` at col 0 with width 2, and `x` at col 2. + +```sh +$S/drive.sh "$RUN_ROOT" text-utf8-split <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 10, rows: 1 }); +const bytes = Buffer.from("界x"); +t.feed(bytes.subarray(0, 1)); t.feed(bytes.subarray(1, 2)); t.feed(bytes.subarray(2)); +const cells = t.snapshot({ includeCells: true }).cells; +console.log(JSON.stringify(t.getVisibleText()), JSON.stringify(cells)); +assert.equal(t.getVisibleText(), "界x"); +assert.deepEqual(cells.map((c) => [c.text, c.col, c.width]), [["界", 0, 2], ["x", 2, 1]]); +t.dispose(); +JS +``` + +- **Debug formatters.** Run `text-formatters`. Exit `0`. `formatPlain` prints `"hello\nred"`. The HTML starts with `
` and colors red with `var(--vt-palette-1)`. + +```sh +$S/drive.sh "$RUN_ROOT" text-formatters <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 20, rows: 3 }); +t.feed("hello\r\n\x1b[31mred\x1b[0m"); +const plain = t.formatPlain(), html = t.formatHtml(); +console.log(JSON.stringify(plain)); console.log(html); +assert.equal(plain, "hello\nred"); +assert.ok(html.startsWith('
')); +assert.match(html, /color: var\(--vt-palette-1\);[^>]*>red&2 + exit 2 + } + rm -rf "$INSTANCE" + echo "removed $INSTANCE" +fi +if [[ -d "$RUN_ROOT/evidence" ]]; then + echo "evidence kept in $RUN_ROOT/evidence:" + ls -1 "$RUN_ROOT/evidence" +fi diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh b/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh new file mode 100755 index 0000000..f5fe563 --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Read-only check: is the instance in RUN_ROOT worth driving? +# Usage: .agents/skills/verify-libghostty-vt-node/scripts/doctor.sh RUN_ROOT +# Exit: 0 = every check passed (WARN lines allowed), 1 = a check failed. +set -uo pipefail + +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT="$(cd "$SKILL_DIR/../../.." && pwd -P)" +RUN_ROOT="${1:?usage: doctor.sh RUN_ROOT}" +INSTANCE="$RUN_ROOT/instance" +fail=0 +ok() { echo "ok $*"; } +bad() { echo "FAIL $*"; fail=1; } +warn() { echo "WARN $*"; } + +if [[ ! -f "$INSTANCE/instance.json" ]]; then + bad "no $INSTANCE/instance.json: run launch.sh first" + exit 1 +fi +ok "instance.json: $INSTANCE/instance.json" +# Physical path: node reports real paths, and RUN_ROOT can sit behind a symlink. +CONSUMER="$(cd "$INSTANCE/consumer" && pwd -P)" + +field() { node -e 'const i=require(process.argv[1]); process.stdout.write(String(i[process.argv[2]]))' "$INSTANCE/instance.json" "$1"; } +[[ "$(field marker)" == "verify-libghostty-vt-node" ]] && ok "made by launch.sh" || bad "instance.json was not made by launch.sh" +[[ "$(field checkout)" == "$ROOT" ]] && ok "built from this checkout" || bad "built from $(field checkout), not $ROOT" + +head_now="$(git -C "$ROOT" rev-parse HEAD)" +diff_now="$(git -C "$ROOT" diff HEAD | { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi; } | cut -d' ' -f1)" +if [[ "$(field commit)" == "$head_now" && "$(field trackedDiffSha256)" == "$diff_now" ]]; then + ok "current: built from HEAD $head_now and the same uncommitted diff" +else + bad "STALE: the checkout changed after launch (HEAD or tracked diff). Run cleanup.sh, then launch.sh with a new RUN_ROOT" +fi + +[[ "$(field node)" == "$(node --version)" ]] && ok "node $(node --version) matches launch" \ + || bad "node on PATH is $(node --version), launch used $(field node)" +pinned="$(sed -n 's/^node = "\(.*\)"/v\1/p' "$ROOT/mise.toml")" +[[ "$(node --version)" == "$pinned" ]] || warn "node $(node --version) is not the mise.toml pin $pinned" + +pkg_dir="$CONSUMER/node_modules/@coder/libghostty-vt-node" +[[ -d "$pkg_dir" && ! -L "$pkg_dir" ]] && ok "package installed as a copy in the consumer" \ + || bad "missing or symlinked $pkg_dir" + +addon="$(cd "$CONSUMER" && node -e 'process.stdout.write(require("node-gyp-build").path(process.argv[1]))' "$pkg_dir" 2>&1)" +case "$addon" in + "$pkg_dir"/prebuilds/*.node) ok "addon loads from the packed prebuild: ${addon#"$CONSUMER"/}" ;; + *) bad "node-gyp-build does not pick a packed prebuild: $addon" ;; +esac + +info="$(cd "$CONSUMER" && node --input-type=module -e ' + const { fileURLToPath } = await import("node:url"); + const url = import.meta.resolve("@coder/libghostty-vt-node"); + const { getNativeInfo } = await import(url); + console.log(JSON.stringify({ path: fileURLToPath(url), ...getNativeInfo() })); +' 2>&1)" +if [[ "$info" == "{"* ]]; then + ok "getNativeInfo: $info" + resolved="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).path)' "$info")" + case "$resolved" in + "$CONSUMER/node_modules/"*) ok "the bare import resolves inside the consumer" ;; + *) bad "the bare import resolves outside the consumer: $resolved" ;; + esac + version="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).packageVersion)' "$info")" + want="$(node -p 'require(process.argv[1]).version' "$ROOT/package.json")" + [[ "$version" == "$want" ]] && ok "packageVersion $version matches package.json" \ + || bad "packageVersion $version, package.json has $want" +else + bad "the package does not load in the consumer: $info" +fi + +exit $fail diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh b/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh new file mode 100755 index 0000000..9360fb2 --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Run one journey: an ES module read from stdin, run with node inside the +# consumer project, so `import ... from "@coder/libghostty-vt-node"` loads the +# installed package. Saves the script, stdout, stderr, and exit code as evidence. +# +# Usage: .agents/skills/verify-libghostty-vt-node/scripts/drive.sh RUN_ROOT NAME [node flags...] <<'JS' +# import { createTerminal } from "@coder/libghostty-vt-node"; +# ... +# JS +# Writes: RUN_ROOT/evidence/NAME/{journey.mjs,command.txt,stdout.txt,stderr.txt,exit-code.txt} +# Exit: the journey's exit code. A signal gives 128 + the signal number. +set -uo pipefail + +RUN_ROOT="${1:?usage: drive.sh RUN_ROOT NAME [node flags...] < journey.mjs}" +NAME="${2:?usage: drive.sh RUN_ROOT NAME [node flags...] < journey.mjs}" +shift 2 +CONSUMER="$RUN_ROOT/instance/consumer" +OUT="$RUN_ROOT/evidence/$NAME" +[[ -f "$RUN_ROOT/instance/instance.json" ]] || { echo "error: no instance in $RUN_ROOT: run launch.sh" >&2; exit 2; } +[[ "$NAME" =~ ^[a-z0-9][a-z0-9._-]*$ ]] || { echo "error: NAME must match [a-z0-9][a-z0-9._-]*" >&2; exit 2; } +# One mkdir reserves the name, so two runs with one NAME cannot share evidence. +mkdir -p "$RUN_ROOT/evidence" "$CONSUMER/journeys" +mkdir "$OUT" 2>/dev/null || { echo "error: $OUT exists: pick a new NAME, evidence is never overwritten" >&2; exit 2; } +cat > "$CONSUMER/journeys/$NAME.mjs" +cp "$CONSUMER/journeys/$NAME.mjs" "$OUT/journey.mjs" +echo "cd $CONSUMER && node $* journeys/$NAME.mjs # $(date -u +%Y-%m-%dT%H:%M:%SZ) $(node --version)" > "$OUT/command.txt" + +(cd "$CONSUMER" && node "$@" "journeys/$NAME.mjs") > "$OUT/stdout.txt" 2> "$OUT/stderr.txt" +code=$? +echo "$code" > "$OUT/exit-code.txt" +cat "$OUT/stdout.txt" +[[ -s "$OUT/stderr.txt" ]] && { echo "--- stderr" >&2; head -20 "$OUT/stderr.txt" >&2; } +echo "--- exit $code, evidence in $OUT" >&2 +exit $code diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh new file mode 100755 index 0000000..90d3d2c --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# Build @coder/libghostty-vt-node from this checkout (with uncommitted changes), +# pack it with a prebuild for this platform, and install the tarball into a new +# consumer project, the way a user installs the package from npm. +# +# Usage: .agents/skills/verify-libghostty-vt-node/scripts/launch.sh [RUN_ROOT] +# RUN_ROOT default: a new mktemp dir. The last line is `READY RUN_ROOT=`. +# Makes: RUN_ROOT/instance/ (tarball, consumer project, instance.json, launch.log) +# RUN_ROOT/evidence/ (empty; drive.sh writes here, cleanup.sh keeps it) +set -euo pipefail + +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT="$(cd "$SKILL_DIR/../../.." && pwd -P)" +outside_checkout() { + case "$1/" in + "$ROOT"/*) echo "error: RUN_ROOT must be outside the checkout ($ROOT)." >&2; exit 2 ;; + esac +} +RUN_ROOT="${1:-}" +if [[ -z "$RUN_ROOT" ]]; then + RUN_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/verify-libghostty-vt-node.XXXXXX")" +elif [[ ! -e "$RUN_ROOT" && ! -L "$RUN_ROOT" ]]; then + parent="$(dirname -- "$RUN_ROOT")" + if [[ -d "$parent" ]]; then outside_checkout "$(cd "$parent" && pwd -P)/$(basename -- "$RUN_ROOT")"; fi + mkdir -p -- "$parent" + mkdir -m 700 -- "$RUN_ROOT" +fi +# The build installs the tarball from RUN_ROOT with npm, which runs package +# scripts. Refuse a RUN_ROOT that another account can change or move: a +# symlink, a directory owned by someone else or writable by group or others, +# or one below any directory that another account owns (root excepted) or +# that group or others can write without the sticky bit. Check the ancestors +# of the path as given and of its physical path, and use the physical path +# that this check returns: never resolve RUN_ROOT again after it. +RUN_ROOT="$(node -e ' + const fs = require("node:fs"), path = require("node:path"); + const dir = process.argv[1], uid = process.getuid(); + const fail = (why) => { console.error(`error: RUN_ROOT ${dir} ${why}`); process.exit(2); }; + const st = fs.lstatSync(dir); + if (st.isSymbolicLink()) fail("is a symlink: pass the real path"); + if (!st.isDirectory()) fail("is not a directory"); + if (st.uid !== uid) fail("is not owned by you"); + if (st.mode & 0o022) fail("is writable by group or others: chmod 700 it, or use a new path"); + const real = fs.realpathSync(dir); + for (const start of [path.resolve(dir), real]) { + for (let up = path.dirname(start); ; up = path.dirname(up)) { + const a = fs.statSync(up); + if (a.uid !== uid && a.uid !== 0) fail(`is below ${up}, which another account owns`); + if ((a.mode & 0o022) && !(a.mode & 0o1000)) fail(`is below ${up}, which group or others can write`); + if (up === path.dirname(up)) break; + } + } + process.stdout.write(real); +' "$RUN_ROOT")" +outside_checkout "$RUN_ROOT" +INSTANCE="$RUN_ROOT/instance" +EVIDENCE="$RUN_ROOT/evidence" + +if [[ -e "$INSTANCE" ]]; then + echo "error: $INSTANCE exists. Run cleanup.sh $RUN_ROOT first, then use a new RUN_ROOT." >&2 + exit 2 +fi +# Evidence belongs to the launch that made it: never mix two launches in one RUN_ROOT. +if [[ -d "$EVIDENCE" && -n "$(ls -A "$EVIDENCE")" ]]; then + echo "error: $EVIDENCE holds evidence from an earlier launch. Use a new RUN_ROOT." >&2 + exit 2 +fi + +# One launch at a time per checkout: launch writes build/, dist/, and prebuilds/. +mkdir -p "$ROOT/vendor" +lock="$ROOT/vendor/.verify-launch.lock" +if ! mkdir "$lock" 2>/dev/null; then + echo "error: another launch is building in this checkout ($lock)." >&2 + echo "Wait for it. Remove the directory only if no launch.sh process runs." >&2 + exit 2 +fi +trap 'rmdir "$lock"' EXIT + +mkdir -p "$INSTANCE/consumer" "$EVIDENCE" +# cleanup.sh removes only an instance with this marker, also after a failed launch. +echo "made by launch.sh $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$INSTANCE/.verify-libghostty-vt-node" +log="$INSTANCE/launch.log" +step() { echo "== $*" | tee -a "$log"; } +run() { "$@" >>"$log" 2>&1 || { echo "error: '$*' failed. Last lines of $log:" >&2; tail -30 "$log" >&2; exit 1; }; } +sha_stdin() { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi | cut -d' ' -f1; } +sha() { sha_stdin < "$1"; } +# The checkout state that this launch builds. It must not change until the end. +checkout_state() { echo "$(git -C "$ROOT" rev-parse HEAD) $(git -C "$ROOT" diff HEAD | sha_stdin)"; } +state_before="$(checkout_state)" + +cd "$ROOT" +# Reuse node_modules and libghostty-vt only when they were built from the +# current inputs. Each stamp holds the hash of those inputs. +deps_stamp="node_modules/.verify-libghostty-vt-node.sha256" +deps_inputs="$(sha package-lock.json)" +if [[ "$(cat "$deps_stamp" 2>/dev/null)" != "$deps_inputs" ]]; then + step "npm ci (node_modules is missing or older than package-lock.json)" + run npm ci + echo "$deps_inputs" > "$deps_stamp" +fi +lib_dir="${LIBGHOSTTY_VT_PREFIX:-$ROOT/vendor/libghostty-vt}" +lib_stamp="$lib_dir/.verify-libghostty-vt-node.sha256" +lib_inputs="$( { cat scripts/build-libghostty-vt.sh; env | grep -E '^(GHOSTTY_|LIBGHOSTTY_VT_)' | sort || true; } | sha_stdin)" +if [[ ! -f "$lib_dir/lib/libghostty-vt.a" || "$(cat "$lib_stamp" 2>/dev/null)" != "$lib_inputs" ]]; then + step "npm run build:libghostty (missing, or built from other inputs: needs zig 0.15.2 and the network)" + run npm run build:libghostty + echo "$lib_inputs" > "$lib_stamp" +fi +# tsc never deletes output whose source is gone, and npm pack ships all of dist/. +step "npm run build (from an empty dist/)"; rm -rf dist; run npm run build +step "npm run build:prebuild"; run npm run build:prebuild +step "npm pack" +tarball="$(npm pack --silent --pack-destination "$INSTANCE" 2>>"$log" | tail -n 1)" +[[ -f "$INSTANCE/$tarball" ]] || { echo "error: npm pack did not write $INSTANCE/$tarball" >&2; exit 1; } + +step "install $tarball into $INSTANCE/consumer" +cat > "$INSTANCE/consumer/package.json" <<'JSON' +{ "name": "verify-libghostty-vt-node-consumer", "private": true, "type": "module" } +JSON +(cd "$INSTANCE/consumer" && run npm install --no-audit --no-fund "$INSTANCE/$tarball") + +if [[ "$(checkout_state)" != "$state_before" ]]; then + echo "error: the checkout changed during the launch (HEAD or tracked diff). Run cleanup.sh, then launch again with a new RUN_ROOT." >&2 + exit 1 +fi +static_lib="${LIBGHOSTTY_VT_STATIC_LIB:-$lib_dir/lib/libghostty-vt.a}" +addon="$(cd "$INSTANCE/consumer" && node -e 'process.stdout.write(require("node-gyp-build").path(process.argv[1]))' "$INSTANCE/consumer/node_modules/@coder/libghostty-vt-node")" +INSTANCE="$INSTANCE" TARBALL="$tarball" TARBALL_SHA="$(sha "$INSTANCE/$tarball")" \ +LIB_INPUTS="$lib_inputs" STATIC_LIB="$static_lib" STATIC_LIB_SHA="$(sha "$static_lib")" \ +ADDON="${addon#"$INSTANCE/consumer/"}" ADDON_SHA="$(sha "$addon")" \ +COMMIT="${state_before% *}" DIFF_SHA="${state_before#* }" CHECKOUT="$ROOT" \ +node -e ' + const fs = require("node:fs"); + const e = process.env; + const info = { + marker: "verify-libghostty-vt-node", + checkout: e.CHECKOUT, commit: e.COMMIT, trackedDiffSha256: e.DIFF_SHA, + tarball: e.TARBALL, tarballSha256: e.TARBALL_SHA, + addon: e.ADDON, addonSha256: e.ADDON_SHA, + libghosttyVt: { staticLib: e.STATIC_LIB, staticLibSha256: e.STATIC_LIB_SHA, buildInputsSha256: e.LIB_INPUTS }, + node: process.version, platform: process.platform, arch: process.arch, + createdAt: new Date().toISOString(), + }; + fs.writeFileSync(e.INSTANCE + "/instance.json", JSON.stringify(info, null, 2) + "\n"); +' +cp "$INSTANCE/instance.json" "$EVIDENCE/instance.json" +echo "READY RUN_ROOT=$RUN_ROOT"