Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions .agents/skills/verify-libghostty-vt-node/features/native-info.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# 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.
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, the libghostty-vt version, and the Ghostty commit the addon was built from. 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-fields` reports the package version, Node-API version, platform, arch, libghostty-vt version, and Ghostty commit (`ghosttyCommit`).
- `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.

Expand All @@ -20,7 +20,7 @@ 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.
- **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, `ghosttyCommit` equal to the default `GHOSTTY_COMMIT` in the installed `scripts/build-libghostty-vt.sh`, and `ghosttyVersion` ending with `+` and that commit.

```sh
$S/drive.sh "$RUN_ROOT" info-fields <<'JS'
Expand All @@ -29,11 +29,15 @@ 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"));
const buildScript = readFileSync("node_modules/@coder/libghostty-vt-node/scripts/build-libghostty-vt.sh", "utf8");
const pinned = buildScript.match(/^GHOSTTY_COMMIT="\$\{GHOSTTY_COMMIT:-([0-9a-f]{40})\}"$/m)?.[1];
assert.ok(pinned, "no default GHOSTTY_COMMIT in scripts/build-libghostty-vt.sh");
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");
assert.equal(info.ghosttyCommit, pinned);
assert.ok(info.ghosttyVersion.endsWith(`+${pinned}`), info.ghosttyVersion);
JS
```

Expand Down Expand Up @@ -73,7 +77,7 @@ 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.
- `ghosttyCommit` is the full Ghostty commit only when `scripts/build-libghostty-vt.sh` built libghostty-vt. A prebuild from a build with `GHOSTTY_COMMIT` overridden fails `info-fields`.
- `ghosttyVersion` is libghostty-vt's version with the commit as build metadata, `0.1.0-dev+<commit>` at the current pin. It is not the Ghostty release (1.4.0) and not this package's version.
- 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.
7 changes: 4 additions & 3 deletions native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ JavaScript exports:
- Module init `InitAll(env, exports)` runs `TerminalWrap::Init` and sets `exports.getNativeInfo`. `NODE_API_MODULE(libghostty_vt_node, InitAll)` registers it [native/addon.cc:InitAll]
- `TerminalWrap::Init` defines the class `GhosttyVtTerminal` with the instance methods `feed`, `resize`, `encodeMouse`, `snapshot`, `getVisibleText`, `formatPlain`, `formatHtml`, and `dispose`, and sets `exports.createTerminal`. It does not put the class constructor on `exports` [native/terminal.cc:TerminalWrap::Init]
- `createTerminal(options: { cols, rows, scrollbackLimit? })`: returns a new `GhosttyVtTerminal` [native/terminal.cc:CreateTerminal]
- `getNativeInfo()`: returns `{ napiVersion, ghosttyVersion?, ghosttyCommit? }`. `napiVersion` comes from `napi_get_version` at run time, not from the compiled `NAPI_VERSION`. `ghosttyVersion` comes from `GHOSTTY_BUILD_INFO_VERSION_STRING` and `ghosttyCommit` from `GHOSTTY_BUILD_INFO_VERSION_BUILD` [native/terminal.cc:GetNativeInfo]
- `getNativeInfo()`: returns `{ napiVersion, ghosttyVersion?, ghosttyCommit? }`. `napiVersion` comes from `napi_get_version` at run time, not from the compiled `NAPI_VERSION`. `ghosttyVersion` comes from `GHOSTTY_BUILD_INFO_VERSION_STRING` and `ghosttyCommit` from `GHOSTTY_BUILD_INFO_VERSION_BUILD` [native/terminal.cc:GetNativeInfo]. `ghosttyVersion` is libghostty-vt's own version, not the Ghostty release. When `scripts/build-libghostty-vt.sh` built libghostty-vt, `ghosttyVersion` is `<lib_version>+<commit>` (`0.1.0-dev+<commit>` at the default pin) and `ghosttyCommit` is the full Ghostty commit [scripts/build-libghostty-vt.sh:zig_args]
- `feed(data: string | Buffer | Uint8Array)`: writes the bytes (a string as UTF-8) [native/terminal.cc:TerminalWrap::Feed]
- `resize(cols, rows)`: calls `ghostty_terminal_resize(terminal, cols, rows, 0, 0)`. When the call makes the grid narrower and taller at once, it first resizes to the new rows at the old width, because Ghostty v1.4.0 pulls scrollback back into view only for that order [native/terminal.cc:TerminalWrap::Resize]
- `encodeMouse(event: { action, button?, x, y, modifiers? }, options: { geometry, anyButtonPressed?, trackLastCell? })`: returns a `Buffer` with the mouse input bytes for the tracking mode and format that the terminal output selected. The buffer is empty when tracking is off or the mode drops the event. `geometry` has `screenWidth`, `screenHeight`, `cellWidth`, `cellHeight`, `paddingTop`, `paddingBottom`, `paddingRight`, and `paddingLeft`, all required here [native/terminal.cc:TerminalWrap::EncodeMouse]
Expand Down Expand Up @@ -59,7 +59,8 @@ C++ declarations:
- Snapshot cells come from visible rows only, because `BuildLines` passes no cell list for scrollback. They include only cells with text. `width` is 2 for a wide cell and 1 otherwise [native/terminal.cc:TerminalWrap::BuildSnapshot] [native/terminal.cc:TerminalWrap::BuildCellObject].
- `scrollbackLines` rows are numbered from 0 with the history point tag. The count is clamped to the `uint32_t` maximum [native/terminal.cc:TerminalWrap::BuildSnapshot].
- An unknown color tag fails `assert(false)` [native/terminal.cc:TerminalWrap::ResolveStyleColor].
- `getNativeInfo` leaves out `ghosttyVersion` and `ghosttyCommit` when `BuildInfoString` returns an empty string, which it does when `ghostty_build_info` fails [native/terminal.cc:GetNativeInfo] [native/libghostty_vt_shim.cc:BuildInfoString].
- `getNativeInfo` leaves out `ghosttyVersion` and `ghosttyCommit` when `BuildInfoString` returns an empty string, which it does when `ghostty_build_info` fails [native/terminal.cc:GetNativeInfo] [native/libghostty_vt_shim.cc:BuildInfoString]. It also leaves out `ghosttyCommit` for a libghostty-vt built without build metadata in its version.
- When `scripts/build-libghostty-vt.sh` built libghostty-vt, `ghosttyCommit` equals `commit` in `$PREFIX/ghostty-source.json` [scripts/build-libghostty-vt.sh:ghostty_commit].
- `Utf8FromCodepoint` asserts `codepoint <= 0x10FFFF` [native/libghostty_vt_shim.cc:Utf8FromCodepoint]. `HexColor` asserts that it wrote 7 characters [native/libghostty_vt_shim.cc:HexColor].

## Side Effects
Expand All @@ -76,5 +77,5 @@ C++ declarations:
- Run (after `npm ci`): `npm run build:libghostty && npm run build:native && npm test`
- Every native test skips when the addon does not load (`test/native-support.ts:nativeSupport`), so check the test output for skips.
- `mise run ci-ubsan` builds the addon with UBSan (Linux, GCC), then runs the tests and smoke. Undefined behavior aborts the run. The task also fails when the addon's object files contain no UBSan checks, which means the flags did not reach g++ (`mise.toml:tasks.ci-ubsan`).
- `test/workers.test.ts` checks the per-environment state in child processes with `worker_threads`. `test/terminal.test.ts` covers `feed`, `snapshot`, `resize`, `dispose`, the `RangeError` cases of the `createTerminal` checks in the addon, the `encodeMouse` bytes of each mouse format, `cursorVisible`, the cell style fields, DECSTR, and whether `scrollbackLimit` keeps history. `test/snapshot-safety.test.ts` checks in child processes that `Object.prototype` accessors cannot dispose the terminal mid-`snapshot`. `test/mouse-safety.test.ts` runs the `encodeMouse` limits and the getter re-entry cases against both the package API and the raw addon object. It skips when the addon is missing, like the other native suites. Its child-process cases load `src/` through `tsx`, so they need no `dist/` build. No vitest test covers `formatPlain`, `formatHtml`, or the content of `scrollbackLines`.
- `test/workers.test.ts` checks the per-environment state in child processes with `worker_threads`. `test/terminal.test.ts` covers `feed`, `snapshot`, `resize`, `dispose`, the `RangeError` cases of the `createTerminal` checks in the addon, the `encodeMouse` bytes of each mouse format, `cursorVisible`, the cell style fields, DECSTR, and whether `scrollbackLimit` keeps history. `test/snapshot-safety.test.ts` checks in child processes that `Object.prototype` accessors cannot dispose the terminal mid-`snapshot`. `test/mouse-safety.test.ts` runs the `encodeMouse` limits and the getter re-entry cases against both the package API and the raw addon object. It skips when the addon is missing, like the other native suites. Its child-process cases load `src/` through `tsx`, so they need no `dist/` build. `test/load.test.ts` checks that `getNativeInfo().ghosttyCommit` matches `commit` in `ghostty-source.json`, and skips when that file is missing. No vitest test covers `formatPlain`, `formatHtml`, or the content of `scrollbackLines`.
- For changes to `TerminalWrap` or `InitAll`, also run the TLA+ lifecycle model: [`.agents/skills/verify-native-lifecycle/SKILL.md`](https://github.com/coder/libghostty-vt-node/blob/main/.agents/skills/verify-native-lifecycle/SKILL.md).
5 changes: 3 additions & 2 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ In the `.mjs` scripts, a failed `assert` or a thrown `Error` ends the process wi
- The default Ghostty commit is `83fb95624b0439ee660c2679b43b38c8b63769cd`. `GHOSTTY_COMMIT` overrides it [scripts/build-libghostty-vt.sh:GHOSTTY_COMMIT].
- `build-libghostty-vt.sh` exits 1 with `"error: missing required command '<cmd>'"` and `"Install it first, then rerun npm run build:libghostty."` when `git` or `zig` is not on `PATH`. For `zig` it also prints `"This Ghostty commit requires Zig 0.17.0. With mise: mise install"` [scripts/build-libghostty-vt.sh:need].
- `build-libghostty-vt.sh` exits 1 when `zig version` differs from `minimum_zig_version` in Ghostty's `build.zig.zon`. It skips this check when that file names no version [scripts/build-libghostty-vt.sh:required_zig].
- `build-libghostty-vt.sh` runs `zig build -Demit-lib-vt=true -Dsimd=$SIMD -Doptimize=$OPTIMIZE --prefix $PREFIX install`, with `--sysroot <xcrun --show-sdk-path>` on Darwin when `xcrun` returns a path. A failed build exits 1 [scripts/build-libghostty-vt.sh:zig_args].
- `build-libghostty-vt.sh` exits 1 with `"error: expected exactly one 'const lib_version = \"<semver>\";' line in <dir>/build.zig, with no build metadata"` when Ghostty's `build.zig` has no such line, has more than one, or the value is not `MAJOR.MINOR.PATCH[-PRE]`. It checks this before it runs `zig` [scripts/build-libghostty-vt.sh:lib_version].
- `build-libghostty-vt.sh` runs `zig build -Demit-lib-vt=true -Dsimd=$SIMD -Doptimize=$OPTIMIZE -Dlib-version-string=<lib_version>+<commit> --prefix $PREFIX install`, with `--sysroot <xcrun --show-sdk-path>` on Darwin when `xcrun` returns a path. `<commit>` is the full commit that `git rev-parse --verify HEAD` reports after the checkout. libghostty-vt reports `<lib_version>+<commit>` as its version string and `<commit>` as its build metadata. A failed build exits 1 [scripts/build-libghostty-vt.sh:zig_args] [scripts/build-libghostty-vt.sh:ghostty_commit].
- After the build, `$PREFIX/include/ghostty/vt.h` and `$PREFIX/lib/libghostty-vt.a` must exist. Otherwise the script exits 1 [scripts/build-libghostty-vt.sh:header].
- `resolve-libghostty-vt.mjs` exits 2 with `"usage: resolve-libghostty-vt.mjs <include|library>"` for any other argument [scripts/resolve-libghostty-vt.mjs:command]. It exits 1 when `<includeDir>/ghostty/vt.h` or the static library is missing, and prints the setup steps and the paths it checked [scripts/resolve-libghostty-vt.mjs:fail].
- `resolve-libghostty-vt.mjs` uses `??` for its defaults, so an environment variable set to an empty string does not fall back. `resolve("")` gives the working directory. Relative values resolve against the working directory, not the repository root [scripts/resolve-libghostty-vt.mjs:prefix].
Expand All @@ -38,7 +39,7 @@ In the `.mjs` scripts, a failed `assert` or a thrown `Error` ends the process wi

- `build-libghostty-vt.sh` creates the parent of `GHOSTTY_SOURCE_DIR` and the `PREFIX` directory with `mkdir -p` [scripts/build-libghostty-vt.sh:PREFIX].
- Network: when `$GHOSTTY_SOURCE_DIR/.git` is missing, `build-libghostty-vt.sh` runs `git clone $GHOSTTY_REPO` (default `https://github.com/ghostty-org/ghostty.git`). Every run then runs `git fetch --depth 1 origin <commit>` and `git checkout --detach <commit>` in that directory, so every run needs the network [scripts/build-libghostty-vt.sh:GHOSTTY_REPO].
- `build-libghostty-vt.sh` runs `zig build ... install` into `PREFIX` [scripts/build-libghostty-vt.sh:zig_args] and writes `$PREFIX/ghostty-source.json` with the repository, commit, Zig version, SIMD, and optimize values [scripts/build-libghostty-vt.sh:ghostty-source.json].
- `build-libghostty-vt.sh` runs `zig build ... install` into `PREFIX` [scripts/build-libghostty-vt.sh:zig_args] and writes `$PREFIX/ghostty-source.json` with the repository, commit, Zig version, SIMD, and optimize values. `commit` is the same resolved full commit that the build passed to `zig`, not the raw `GHOSTTY_COMMIT` value [scripts/build-libghostty-vt.sh:ghostty-source.json].
- `assemble-prebuilds.mjs` copies each `<source>/prebuilds-*/<platform>` directory into a staging directory next to the target (`<target>.staging-<pid>`, default `prebuilds.staging-<pid>`) with `errorOnExist: true`, and runs every check there. Only then does it move the old target to `<target>.previous-<pid>`, rename the staging directory to the target, and delete the old one. A failed run deletes the staging directory and leaves the target as it was [scripts/assemble-prebuilds.mjs:stagingRoot].
- `verify-prebuilds.mjs` imports `../dist/index.js` and calls `getNativeInfo()`, which loads the native addon [scripts/verify-prebuilds.mjs:info].
- Network and remote state: `create-release-tag.mjs` runs `git ls-remote --tags origin`. When the tag is absent, it runs `git tag v<version> <sha>` and `git push origin refs/tags/v<version>` [scripts/create-release-tag.mjs:git].
Expand Down
11 changes: 10 additions & 1 deletion scripts/build-libghostty-vt.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ fi

git -C "$GHOSTTY_SOURCE_DIR" fetch --depth 1 origin "$GHOSTTY_COMMIT"
git -C "$GHOSTTY_SOURCE_DIR" checkout --detach "$GHOSTTY_COMMIT"
ghostty_commit="$(git -C "$GHOSTTY_SOURCE_DIR" rev-parse --verify HEAD)"

lib_version="$(sed -n 's/^const lib_version = "\(.*\)";$/\1/p' "$GHOSTTY_SOURCE_DIR/build.zig")"
if [[ ! "$lib_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "error: expected exactly one 'const lib_version = \"<semver>\";' line in $GHOSTTY_SOURCE_DIR/build.zig, with no build metadata" >&2
echo "Found: '${lib_version}'" >&2
exit 1
fi

zig_version="$(zig version)"
required_zig="$(sed -n 's/.*minimum_zig_version = "\(.*\)".*/\1/p' "$GHOSTTY_SOURCE_DIR/build.zig.zon" | head -n 1)"
Expand All @@ -46,6 +54,7 @@ zig_args=(
-Demit-lib-vt=true
"-Dsimd=$SIMD"
"-Doptimize=$OPTIMIZE"
"-Dlib-version-string=${lib_version}+${ghostty_commit}"
--prefix "$PREFIX"
install
)
Expand Down Expand Up @@ -82,7 +91,7 @@ fi
cat > "$PREFIX/ghostty-source.json" <<JSON
{
"repository": "$GHOSTTY_REPO",
"commit": "$GHOSTTY_COMMIT",
"commit": "$ghostty_commit",
"zigVersion": "$zig_version",
"simd": "$SIMD",
"optimize": "$OPTIMIZE"
Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,17 @@ export interface GhosttyVtTerminal {
export interface NativeInfo {
packageVersion: string;
napiVersion: number;
/**
* libghostty-vt's own version, not the Ghostty release. Builds made by
* `npm run build:libghostty`, the published prebuilds included, end it with
* `+<ghosttyCommit>`.
*/
ghosttyVersion?: string;
/**
* The build metadata of libghostty-vt's version. Builds made by
* `npm run build:libghostty`, the published prebuilds included, set it to
* the full Ghostty commit. Omitted when the version has no build metadata.
*/
ghosttyCommit?: string;
platform: string;
arch: string;
Expand Down
24 changes: 24 additions & 0 deletions test/load.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { getNativeInfo } from "../src/index.js";
import { nativeSupport } from "./native-support.js";

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const prefix = resolve(
process.env.LIBGHOSTTY_VT_PREFIX ?? join(repoRoot, "vendor", "libghostty-vt"),
);
const ghosttySourcePath = join(prefix, "ghostty-source.json");

describe("native loader", () => {
it("reports useful native metadata or a clear setup error", () => {
const support = nativeSupport();
Expand All @@ -18,4 +27,19 @@ describe("native loader", () => {
expect(info.platform).toBe(process.platform);
expect(info.arch).toBe(process.arch);
});

it.skipIf(!nativeSupport().available || !existsSync(ghosttySourcePath))(
"reports the Ghostty commit that libghostty-vt was built from",
() => {
const { commit } = JSON.parse(readFileSync(ghosttySourcePath, "utf8")) as {
commit: string;
};
const info = getNativeInfo();
expect(
info.ghosttyCommit,
`ghosttyCommit differs from ${ghosttySourcePath}. Rerun npm run build:libghostty && npm run build:native`,
).toBe(commit);
expect(info.ghosttyVersion).toMatch(new RegExp(`\\+${commit}$`));
},
);
});
Loading