@adhdev/ghostty-vt-node 1.0.77 → 1.0.82-rc.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CMakeLists.txt +284 -0
  2. package/README.md +151 -9
  3. package/binding-candidates.js +40 -0
  4. package/index.d.ts +107 -0
  5. package/index.js +92 -48
  6. package/package.json +6 -2
  7. package/prebuilt/darwin-arm64-node137/ghostty_vt_node.node +0 -0
  8. package/prebuilt/darwin-arm64-node137/libghostty-vt.0.1.0.dylib +0 -0
  9. package/prebuilt/darwin-arm64-node137/libghostty-vt.dylib +0 -0
  10. package/prebuilt/darwin-arm64-node137/manifest.json +34 -0
  11. package/prebuilt/linux-x64-node137/ghostty_vt_node.node +0 -0
  12. package/prebuilt/linux-x64-node137/libghostty-vt.so +0 -0
  13. package/prebuilt/{linux-x64-node115/libghostty-vt.so → linux-x64-node137/libghostty-vt.so.0} +0 -0
  14. package/prebuilt/linux-x64-node137/libghostty-vt.so.0.1.0 +0 -0
  15. package/prebuilt/linux-x64-node137/manifest.json +38 -0
  16. package/prebuilt/win32-x64-node137/ghostty-vt.dll +0 -0
  17. package/prebuilt/win32-x64-node137/ghostty_vt_node.node +0 -0
  18. package/prebuilt/win32-x64-node137/manifest.json +30 -0
  19. package/scripts/binding-digest.mjs +60 -0
  20. package/scripts/build-or-use-prebuilt.mjs +60 -31
  21. package/scripts/check-prebuilts.mjs +113 -0
  22. package/scripts/clean-dir.cjs +32 -0
  23. package/scripts/ensure-prebuilt-sonames.mjs +52 -0
  24. package/scripts/host-path-scan.mjs +115 -0
  25. package/scripts/native-format.mjs +219 -0
  26. package/scripts/prebuilt-manifest.mjs +231 -0
  27. package/scripts/probe-build-provenance.mjs +288 -0
  28. package/scripts/verify-prebuilt.mjs +115 -0
  29. package/src/addon.cc +403 -23
  30. package/src/ghostty_bridge.c +344 -0
  31. package/src/ghostty_bridge.h +96 -0
  32. package/toolchain.json +3 -0
  33. package/prebuilt/darwin-arm64-node115/ghostty_vt_node.node +0 -0
  34. package/prebuilt/darwin-arm64-node115/libghostty-vt.0.1.0.dylib +0 -0
  35. package/prebuilt/darwin-arm64-node115/libghostty-vt.dylib +0 -0
  36. package/prebuilt/darwin-arm64-node127/ghostty_vt_node.node +0 -0
  37. package/prebuilt/darwin-arm64-node127/libghostty-vt.0.1.0.dylib +0 -0
  38. package/prebuilt/darwin-arm64-node127/libghostty-vt.dylib +0 -0
  39. package/prebuilt/darwin-arm64-node141/ghostty_vt_node.node +0 -0
  40. package/prebuilt/darwin-arm64-node141/libghostty-vt.0.1.0.dylib +0 -0
  41. package/prebuilt/darwin-arm64-node141/libghostty-vt.dylib +0 -0
  42. package/prebuilt/linux-x64-node115/ghostty_vt_node.node +0 -0
  43. package/prebuilt/linux-x64-node115/libghostty-vt.so.0.1.0 +0 -0
  44. package/prebuilt/linux-x64-node127/ghostty_vt_node.node +0 -0
  45. package/prebuilt/linux-x64-node127/libghostty-vt.so +0 -0
  46. package/prebuilt/linux-x64-node127/libghostty-vt.so.0.1.0 +0 -0
  47. package/prebuilt/linux-x64-node141/ghostty_vt_node.node +0 -0
  48. package/prebuilt/linux-x64-node141/libghostty-vt.so +0 -0
  49. package/prebuilt/linux-x64-node141/libghostty-vt.so.0.1.0 +0 -0
  50. package/prebuilt/win32-x64-node127/ghostty-vt.dll +0 -0
  51. package/prebuilt/win32-x64-node127/ghostty_vt_node.node +0 -0
package/index.js CHANGED
@@ -2,6 +2,7 @@ const fs = require('node:fs');
2
2
  const path = require('node:path');
3
3
 
4
4
  const ADDON_FILE = 'ghostty_vt_node.node';
5
+ const { orderPrebuiltTriplets } = require('./binding-candidates.js');
5
6
  const PREBUILT_ROOT = path.join(__dirname, 'prebuilt');
6
7
 
7
8
  function getTriplet() {
@@ -19,9 +20,10 @@ function getTriplet() {
19
20
  // Consequently the loader resolves in three tiers:
20
21
  // 1. an explicit override dir (ADHDEV_GHOSTTY_VT_PREBUILT_DIR),
21
22
  // 2. a locally compiled build/ output,
22
- // 3. an exact `platform-arch-nodeABI` prebuilt, and finally
23
- // 4. ANY prebuilt for the same `platform-arch` (N-API portability fallback).
24
- // Tier 4 is what makes a brand-new Node ABI (e.g. a future node26) work without
23
+ // 3. same-`platform-arch` prebuilts ranked by manifest apiLevel (highest
24
+ // first), exact ABI as tie-breaker (see binding-candidates.js).
25
+ // Not preferring the exact ABI is deliberate: stale API-level-1 binaries for
26
+ // ABI 115/127 once shadowed the current API-level-2 build. This is what makes a brand-new Node ABI (e.g. a future node26) work without
25
27
  // shipping a new directory — the previous failure mode where node137 runtimes
26
28
  // hit "binding unavailable" purely because no `node137` directory existed.
27
29
 
@@ -38,66 +40,69 @@ function listAvailablePrebuiltTriplets() {
38
40
  }
39
41
  }
40
42
 
41
- function samePlatformArchTriplets(triplet) {
42
- // triplet === `${platform}-${arch}-node${abi}` → prefix is `${platform}-${arch}-`
43
- const prefix = `${process.platform}-${process.arch}-`;
44
- return listAvailablePrebuiltTriplets().filter(
45
- (name) => name.startsWith(prefix) && name !== triplet,
46
- );
47
- }
48
-
49
- // Last-resort: every other installed prebuilt, regardless of platform/arch.
50
- // Requiring a foreign-OS `.node` throws a load error (ELF/PE mismatch,
51
- // "invalid ELF header", "not a valid Win32 application"), which loadBinding()
52
- // simply skips — so this tier is inert on a correctly-addressed runtime. Its
53
- // sole purpose is test isolation: suites that mock `process.platform` (e.g.
54
- // win32 PTY-write / submit driver coverage) corrupt getTriplet() into an
55
- // address for which no loadable binary exists on the CI runner; walking the
56
- // remaining triplets lets the real host binary (the one that actually loads)
57
- // still resolve, instead of the whole ProviderCliAdapter construction throwing.
58
- function otherInstalledTriplets(triplet, alreadyTried) {
59
- return listAvailablePrebuiltTriplets().filter(
60
- (name) => name !== triplet && !alreadyTried.has(name),
61
- );
43
+ function readManifestApiLevel(name) {
44
+ try {
45
+ const manifest = JSON.parse(fs.readFileSync(path.join(PREBUILT_ROOT, name, 'manifest.json'), 'utf8'));
46
+ return Number.isFinite(manifest.apiLevel) ? manifest.apiLevel : 0;
47
+ } catch {
48
+ return 0;
49
+ }
62
50
  }
63
51
 
52
+ // Selection rule (see binding-candidates.js): among same platform-arch
53
+ // prebuilts the highest manifest apiLevel wins, then the exact running ABI.
54
+ // Foreign-platform dirs are a last-resort test-isolation fallback (requiring a
55
+ // foreign-OS `.node` throws and is skipped; it matters only for suites that
56
+ // mock process.platform).
57
+ //
58
+ // A locally compiled build/ output is tried before the prebuilts, but only
59
+ // accepted if it reports apiLevel >= the best prebuilt manifest level — a stale
60
+ // build/ directory must not shadow a newer prebuilt. A rejected build is kept
61
+ // as the very last resort.
64
62
  function loadBinding() {
65
- const triplet = getTriplet();
63
+ const abi = process.versions.modules;
66
64
  const explicitPrebuiltDir = process.env.ADHDEV_GHOSTTY_VT_PREBUILT_DIR
67
65
  ? path.resolve(process.env.ADHDEV_GHOSTTY_VT_PREBUILT_DIR)
68
66
  : null;
67
+ const triplet = getTriplet();
68
+ const ordered = orderPrebuiltTriplets({
69
+ names: listAvailablePrebuiltTriplets(),
70
+ platform: process.platform,
71
+ arch: process.arch,
72
+ abi,
73
+ readApiLevel: readManifestApiLevel,
74
+ });
75
+ const prebuiltFile = (name) => path.join(PREBUILT_ROOT, name, ADDON_FILE);
69
76
 
77
+ // { file, minApiLevel } — minApiLevel > 0 means "verify after load".
70
78
  const candidates = [
71
- // 1. explicit override
72
- explicitPrebuiltDir ? path.join(explicitPrebuiltDir, triplet, ADDON_FILE) : null,
73
- explicitPrebuiltDir ? path.join(explicitPrebuiltDir, ADDON_FILE) : null,
74
- // 2. locally compiled output
75
- path.join(__dirname, 'build', 'Release', ADDON_FILE),
76
- path.join(__dirname, 'build', 'Debug', ADDON_FILE),
77
- // 3. exact-ABI prebuilt
78
- path.join(PREBUILT_ROOT, triplet, ADDON_FILE),
79
- // 4. N-API portability fallback: any same platform-arch prebuilt
80
- ...samePlatformArchTriplets(triplet).map((name) =>
81
- path.join(PREBUILT_ROOT, name, ADDON_FILE),
82
- ),
83
- // 5. test-isolation fallback: any remaining installed triplet (see
84
- // otherInstalledTriplets). Foreign-OS binaries throw on require and
85
- // are skipped; the real host binary still resolves under a mocked
86
- // process.platform.
87
- ...otherInstalledTriplets(
88
- triplet,
89
- new Set([triplet, ...samePlatformArchTriplets(triplet)]),
90
- ).map((name) => path.join(PREBUILT_ROOT, name, ADDON_FILE)),
79
+ explicitPrebuiltDir ? { file: path.join(explicitPrebuiltDir, triplet, ADDON_FILE) } : null,
80
+ explicitPrebuiltDir ? { file: path.join(explicitPrebuiltDir, ADDON_FILE) } : null,
81
+ { file: path.join(__dirname, 'build', 'Release', ADDON_FILE), minApiLevel: ordered.bestApiLevel },
82
+ { file: path.join(__dirname, 'build', 'Debug', ADDON_FILE), minApiLevel: ordered.bestApiLevel },
83
+ ...ordered.samePlatform.map((name) => ({ file: prebuiltFile(name) })),
84
+ ...ordered.otherPlatform.map((name) => ({ file: prebuiltFile(name) })),
91
85
  ].filter(Boolean);
92
86
 
93
87
  const errors = [];
88
+ const rejected = [];
94
89
  for (const candidate of candidates) {
95
90
  try {
96
- return require(candidate);
91
+ const loaded = require(candidate.file);
92
+ if (candidate.minApiLevel) {
93
+ const level = describeBinding(loaded).apiLevel;
94
+ if (level < candidate.minApiLevel) {
95
+ errors.push(`${candidate.file}: apiLevel ${level} < best prebuilt ${candidate.minApiLevel}`);
96
+ rejected.push(loaded);
97
+ continue;
98
+ }
99
+ }
100
+ return loaded;
97
101
  } catch (error) {
98
- errors.push(`${candidate}: ${error && error.message ? error.message : String(error)}`);
102
+ errors.push(`${candidate.file}: ${error && error.message ? error.message : String(error)}`);
99
103
  }
100
104
  }
105
+ if (rejected.length) return rejected[0];
101
106
 
102
107
  const available = listAvailablePrebuiltTriplets();
103
108
  throw new Error(
@@ -108,4 +113,43 @@ function loadBinding() {
108
113
  );
109
114
  }
110
115
 
111
- module.exports = loadBinding();
116
+ // Surface added at API level 2. A binary built before it (an older prebuilt or
117
+ // a stale build/ output that shadows the prebuilt) still loads and still
118
+ // serves the legacy methods; it simply lacks these. Consumers feature-detect
119
+ // via `capabilities.viewport` (or `typeof handle.getViewport === 'function'`)
120
+ // and must treat the exact-read path as unavailable rather than falling back
121
+ // to formatter output.
122
+ const VIEWPORT_METHODS = ['getViewport', 'readRow', 'scrollViewport'];
123
+
124
+ function describeBinding(binding) {
125
+ if (typeof binding.getBindingInfo === 'function') {
126
+ const info = binding.getBindingInfo();
127
+ return {
128
+ apiLevel: info.apiLevel,
129
+ sourceDigest: info.sourceDigest || null,
130
+ ghosttyRevision: info.ghosttyRevision || null,
131
+ napiVersion: info.napiVersion ?? null,
132
+ };
133
+ }
134
+ return { apiLevel: 1, sourceDigest: null, ghosttyRevision: null, napiVersion: null };
135
+ }
136
+
137
+ function probeViewportSupport(binding, info) {
138
+ if (info.apiLevel < 2 || typeof binding.createTerminal !== 'function') return false;
139
+ let terminal;
140
+ try {
141
+ terminal = binding.createTerminal({ cols: 1, rows: 1, scrollback: 0 });
142
+ return VIEWPORT_METHODS.every((name) => typeof terminal[name] === 'function');
143
+ } catch {
144
+ return false;
145
+ } finally {
146
+ try { terminal?.dispose(); } catch { /* best effort */ }
147
+ }
148
+ }
149
+
150
+ const binding = loadBinding();
151
+ const bindingInfo = describeBinding(binding);
152
+ binding.bindingInfo = bindingInfo;
153
+ binding.capabilities = Object.freeze({ viewport: probeViewportSupport(binding, bindingInfo) });
154
+
155
+ module.exports = binding;
package/package.json CHANGED
@@ -1,22 +1,26 @@
1
1
  {
2
2
  "name": "@adhdev/ghostty-vt-node",
3
- "version": "1.0.77",
3
+ "version": "1.0.82-rc.37",
4
4
  "description": "Native Node binding for libghostty-vt",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
7
7
  "files": [
8
8
  "index.js",
9
+ "binding-candidates.js",
9
10
  "index.d.ts",
10
11
  "prebuilt",
11
12
  "scripts",
12
13
  "CMakeLists.txt",
14
+ "toolchain.json",
13
15
  "src",
14
16
  "README.md"
15
17
  ],
16
18
  "scripts": {
17
19
  "build": "node ./scripts/build-or-use-prebuilt.mjs",
18
20
  "rebuild": "cmake-js rebuild",
19
- "clean": "cmake-js clean"
21
+ "clean": "cmake-js clean",
22
+ "test": "node --import ../../scripts/test-tmp-guard.mjs --test test/*.test.mjs",
23
+ "check:prebuilts": "node scripts/check-prebuilts.mjs"
20
24
  },
21
25
  "author": "vilmire",
22
26
  "dependencies": {
@@ -0,0 +1,34 @@
1
+ {
2
+ "schema": 2,
3
+ "platform": "darwin",
4
+ "arch": "arm64",
5
+ "apiLevel": 2,
6
+ "sourceDigest": "e4ca3372235a1289373e172996a5e7804bc4d5f3eac630d91d9160dd932e0627",
7
+ "ghosttyRevision": "d4a38c8661453026bd1b1bb022ba2c37b347adce",
8
+ "napiVersion": 8,
9
+ "builtWithNode": "v22.17.0",
10
+ "release": true,
11
+ "provenance": {
12
+ "problems": [],
13
+ "ghosttySourceRevision": "d4a38c8661453026bd1b1bb022ba2c37b347adce",
14
+ "ghosttySourceClean": true,
15
+ "zigVersion": "0.15.2",
16
+ "cCompiler": "AppleClang 17.0.0.17000316",
17
+ "cxxCompiler": "AppleClang 17.0.0.17000316",
18
+ "buildType": "Release"
19
+ },
20
+ "files": {
21
+ "ghostty_vt_node.node": {
22
+ "sha256": "fbdbf5e197b99d43b609b875d3621ce3e873ac8e0e04608cd057fba987a8555a",
23
+ "size": 100536
24
+ },
25
+ "libghostty-vt.0.1.0.dylib": {
26
+ "sha256": "d2f92f4bdbaa734b014fe8d38615ccaa5a97f9f07847e5476e87a4c7a64f3c00",
27
+ "size": 1528392
28
+ },
29
+ "libghostty-vt.dylib": {
30
+ "sha256": "d2f92f4bdbaa734b014fe8d38615ccaa5a97f9f07847e5476e87a4c7a64f3c00",
31
+ "size": 1528392
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "schema": 2,
3
+ "platform": "linux",
4
+ "arch": "x64",
5
+ "apiLevel": 2,
6
+ "sourceDigest": "e4ca3372235a1289373e172996a5e7804bc4d5f3eac630d91d9160dd932e0627",
7
+ "ghosttyRevision": "d4a38c8661453026bd1b1bb022ba2c37b347adce",
8
+ "napiVersion": 8,
9
+ "builtWithNode": "v22.22.0",
10
+ "release": true,
11
+ "provenance": {
12
+ "problems": [],
13
+ "ghosttySourceRevision": "d4a38c8661453026bd1b1bb022ba2c37b347adce",
14
+ "ghosttySourceClean": true,
15
+ "zigVersion": "0.15.2",
16
+ "cCompiler": "GNU 13.3.0",
17
+ "cxxCompiler": "GNU 13.3.0",
18
+ "buildType": "Release"
19
+ },
20
+ "files": {
21
+ "ghostty_vt_node.node": {
22
+ "sha256": "aefc9e332147bc2980f3810558d4f80ad3d407dfb3b19fcd9dd0d416e62c38bc",
23
+ "size": 130096
24
+ },
25
+ "libghostty-vt.so": {
26
+ "sha256": "2dc10d68318c378a35584ee36580f836c91d64428fcd5bf4466b08f3716c3c23",
27
+ "size": 7496328
28
+ },
29
+ "libghostty-vt.so.0": {
30
+ "sha256": "2dc10d68318c378a35584ee36580f836c91d64428fcd5bf4466b08f3716c3c23",
31
+ "size": 7496328
32
+ },
33
+ "libghostty-vt.so.0.1.0": {
34
+ "sha256": "2dc10d68318c378a35584ee36580f836c91d64428fcd5bf4466b08f3716c3c23",
35
+ "size": 7496328
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "schema": 2,
3
+ "platform": "win32",
4
+ "arch": "x64",
5
+ "apiLevel": 2,
6
+ "sourceDigest": "e4ca3372235a1289373e172996a5e7804bc4d5f3eac630d91d9160dd932e0627",
7
+ "ghosttyRevision": "d4a38c8661453026bd1b1bb022ba2c37b347adce",
8
+ "napiVersion": 8,
9
+ "builtWithNode": "v22.14.0",
10
+ "release": true,
11
+ "provenance": {
12
+ "problems": [],
13
+ "ghosttySourceRevision": "d4a38c8661453026bd1b1bb022ba2c37b347adce",
14
+ "ghosttySourceClean": true,
15
+ "zigVersion": "0.15.2",
16
+ "cCompiler": "MSVC 19.44.35229.0",
17
+ "cxxCompiler": "MSVC 19.44.35229.0",
18
+ "buildType": "Release"
19
+ },
20
+ "files": {
21
+ "ghostty-vt.dll": {
22
+ "sha256": "7d2ce8845d559a7279bde0080c99ccf795dd9f13d4236add3bf0c4e312fdad7a",
23
+ "size": 1305600
24
+ },
25
+ "ghostty_vt_node.node": {
26
+ "sha256": "6d29fd06b15d1f05930ce5c969ee316f0135c42d334763c4a07ff584e3fb9a6d",
27
+ "size": 70656
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ // Binding source digest: the identity of the sources a native binary was
3
+ // compiled from. CMakeLists.txt computes the same value at configure time and
4
+ // embeds it in the addon (getBindingInfo().sourceDigest), so a prebuilt can be
5
+ // checked against the sources in this checkout.
6
+ //
7
+ // Algorithm (keep in sync with CMakeLists.txt): for each input in order,
8
+ // "<path>:<sha256 of content with CRLF -> LF>\n"; digest = sha256 of the
9
+ // concatenation. LF normalization keeps a win32 autocrlf checkout from
10
+ // reporting a different digest for identical sources.
11
+ //
12
+ // node scripts/binding-digest.mjs # prints the digest
13
+ import { createHash } from 'node:crypto';
14
+ import { readFileSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ export const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
19
+
20
+ export const BINDING_DIGEST_INPUTS = [
21
+ 'CMakeLists.txt',
22
+ 'toolchain.json',
23
+ 'src/addon.cc',
24
+ 'src/ghostty_bridge.c',
25
+ 'src/ghostty_bridge.h',
26
+ 'scripts/clean-dir.cjs',
27
+ ];
28
+
29
+ const sha256 = (data) => createHash('sha256').update(data).digest('hex');
30
+
31
+ export function computeBindingDigest(packageDir = PACKAGE_DIR) {
32
+ let manifest = '';
33
+ for (const input of BINDING_DIGEST_INPUTS) {
34
+ const content = readFileSync(join(packageDir, input), 'utf8').replace(/\r\n/g, '\n');
35
+ manifest += `${input}:${sha256(Buffer.from(content, 'utf8'))}\n`;
36
+ }
37
+ return sha256(Buffer.from(manifest, 'utf8'));
38
+ }
39
+
40
+ // The zig version toolchain.json pins: the single source the native workflow
41
+ // installs, CMakeLists.txt enforces and the release gate compares against.
42
+ export function pinnedZigVersion(packageDir = PACKAGE_DIR) {
43
+ const { zig } = JSON.parse(readFileSync(join(packageDir, 'toolchain.json'), 'utf8'));
44
+ if (typeof zig !== 'string' || !/^\d+\.\d+\.\d+$/.test(zig)) throw new Error('toolchain.json does not pin "zig" to x.y.z');
45
+ return zig;
46
+ }
47
+
48
+ // The ghostty revision CMakeLists.txt pins by default.
49
+ export function pinnedGhosttyRevision(packageDir = PACKAGE_DIR) {
50
+ const cmake = readFileSync(join(packageDir, 'CMakeLists.txt'), 'utf8');
51
+ const match = cmake.match(/set\(GHOSTTY_GIT_TAG "([0-9a-f]{40})"/);
52
+ const pin = cmake.match(/set\(ADHDEV_GHOSTTY_PINNED_REVISION "([0-9a-f]{40})"\)/);
53
+ if (!match || !pin) throw new Error('CMakeLists.txt does not pin GHOSTTY_GIT_TAG / ADHDEV_GHOSTTY_PINNED_REVISION to a 40-hex revision');
54
+ if (match[1] !== pin[1]) throw new Error(`CMakeLists.txt pins disagree: GHOSTTY_GIT_TAG ${match[1]} != ADHDEV_GHOSTTY_PINNED_REVISION ${pin[1]}`);
55
+ return pin[1];
56
+ }
57
+
58
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
59
+ console.log(computeBindingDigest());
60
+ }
@@ -3,6 +3,8 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
6
+ import { emitPrebuilt } from './prebuilt-manifest.mjs';
7
+
6
8
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
7
9
  const packageDir = path.resolve(scriptDir, '..');
8
10
  const outputDir = path.join(packageDir, 'build', 'Release');
@@ -10,9 +12,11 @@ const outputFile = path.join(outputDir, 'ghostty_vt_node.node');
10
12
  const triplet = `${process.platform}-${process.arch}-node${process.versions.modules}`;
11
13
  const sourceInputs = [
12
14
  path.join(packageDir, 'CMakeLists.txt'),
15
+ path.join(packageDir, 'toolchain.json'),
13
16
  path.join(packageDir, 'src', 'addon.cc'),
14
17
  path.join(packageDir, 'src', 'ghostty_bridge.c'),
15
18
  path.join(packageDir, 'src', 'ghostty_bridge.h'),
19
+ path.join(packageDir, 'scripts', 'clean-dir.cjs'),
16
20
  ];
17
21
 
18
22
  function safeMtime(filePath) {
@@ -117,45 +121,76 @@ function installPrebuilt(candidateFile) {
117
121
  if (!fs.existsSync(outputFile)) fs.copyFileSync(candidateFile, outputFile);
118
122
  }
119
123
 
120
- function installPrebuiltIfPresent(sourceMtime) {
124
+ // ADHDEV_GHOSTTY_VT_FORCE_COMPILE=1 always compiles from source: no existing
125
+ // build output, no prebuilt. The native CI build uses it — in a fresh checkout
126
+ // the sources are never "locally modified", so without it the tracked
127
+ // prebuilt is copied and a binary that predates the current sources ships.
128
+ // `--plan` prints the decision and exits without touching anything.
129
+ const forceCompile = process.env.ADHDEV_GHOSTTY_VT_FORCE_COMPILE === '1';
130
+ const planOnly = process.argv.includes('--plan');
131
+
132
+ function decide() {
133
+ if (forceCompile) return { action: 'compile', mode: 'rebuild', reason: 'ADHDEV_GHOSTTY_VT_FORCE_COMPILE=1' };
134
+ const sourceMtime = latestSourceMtime();
135
+ const outputMtime = safeMtime(outputFile);
136
+ // A local build output is something this machine compiled, so its mtime
137
+ // against the sources is meaningful: keep it while it is not older.
138
+ if (outputMtime >= sourceMtime && outputMtime > 0) return { action: 'keep-local', file: outputFile };
121
139
  for (const candidate of candidatePrebuiltPaths()) {
122
140
  if (!fs.existsSync(candidate.file)) continue;
123
141
  if (!isUsablePrebuilt(candidate, sourceMtime)) {
124
142
  console.log(`[ghostty-vt-node] skipping prebuilt ${candidate.file}: sources were modified locally after it was built`);
125
143
  continue;
126
144
  }
127
- installPrebuilt(candidate.file);
128
- console.log(`[ghostty-vt-node] using prebuilt native binding from ${candidate.file}`);
129
- return true;
145
+ return { action: 'use-prebuilt', file: candidate.file };
130
146
  }
131
- return false;
147
+ if (process.env.ADHDEV_SKIP_GHOSTTY_VT_BUILD === '1') return { action: 'skip', reason: 'ADHDEV_SKIP_GHOSTTY_VT_BUILD=1' };
148
+ return { action: 'compile', reason: 'no usable local build or prebuilt' };
132
149
  }
133
150
 
134
- const sourceMtime = latestSourceMtime();
135
- const outputMtime = safeMtime(outputFile);
151
+ const decision = decide();
152
+ if (planOnly) {
153
+ console.log(`[ghostty-vt-node] plan: ${decision.action}${decision.mode ? ` (cmake-js ${decision.mode})` : ''}${decision.file ? ` ${decision.file}` : ''}${decision.reason ? ` (${decision.reason})` : ''}`);
154
+ process.exit(0);
155
+ }
136
156
 
137
- // A local build output is something this machine compiled, so its mtime
138
- // against the sources is meaningful: keep it while it is not older.
139
- if (outputMtime >= sourceMtime && outputMtime > 0) {
157
+ if (decision.action === 'keep-local') {
140
158
  console.log(`[ghostty-vt-node] keeping existing local build at ${outputFile}`);
141
159
  process.exit(0);
142
160
  }
143
161
 
144
- if (installPrebuiltIfPresent(sourceMtime)) {
162
+ if (decision.action === 'use-prebuilt') {
163
+ installPrebuilt(decision.file);
164
+ console.log(`[ghostty-vt-node] using prebuilt native binding from ${decision.file}`);
145
165
  process.exit(0);
146
166
  }
147
167
 
148
- if (process.env.ADHDEV_SKIP_GHOSTTY_VT_BUILD === '1') {
168
+ if (decision.action === 'skip') {
149
169
  console.log(`[ghostty-vt-node] skipping native build for ${triplet} (ADHDEV_SKIP_GHOSTTY_VT_BUILD=1)`);
150
170
  process.exit(0);
151
171
  }
152
172
 
173
+ // A forced compile is a CLEAN rebuild (fresh configure, fresh FetchContent
174
+ // clone, zig-out regenerated): an incremental `compile` would reuse whatever
175
+ // the previous build directory holds. Other compiles stay incremental.
176
+ const cmakeJsCommand = forceCompile ? 'rebuild' : 'compile';
153
177
  const isWindows = process.platform === 'win32';
154
178
  const command = isWindows ? (process.env.ComSpec || 'cmd.exe') : 'npm';
155
179
  const args = isWindows
156
- ? ['/d', '/s', '/c', 'npm exec -- cmake-js compile']
157
- : ['exec', '--', 'cmake-js', 'compile'];
158
- console.log(`[ghostty-vt-node] compiling ${triplet} via ${command} ${args.join(' ')}`);
180
+ ? ['/d', '/s', '/c', `npm exec -- cmake-js ${cmakeJsCommand}`]
181
+ : ['exec', '--', 'cmake-js', cmakeJsCommand];
182
+ // cmake-js' own clean (`cmake -E remove_directory`) has the same Windows
183
+ // trailing-dot problem as file(REMOVE_RECURSE), so a forced rebuild removes
184
+ // build/ itself first and refuses to continue on a partial removal.
185
+ if (forceCompile) {
186
+ const buildDir = path.join(packageDir, 'build');
187
+ const clean = spawnSync(process.execPath, [path.join(scriptDir, 'clean-dir.cjs'), buildDir], { stdio: 'inherit', windowsHide: true });
188
+ if (clean.status !== 0 || fs.existsSync(buildDir)) {
189
+ console.error(`[ghostty-vt-node] could not remove ${buildDir}; refusing to rebuild on top of it`);
190
+ process.exit(1);
191
+ }
192
+ }
193
+ console.log(`[ghostty-vt-node] compiling ${triplet} (${decision.reason}) via ${command} ${args.join(' ')}`);
159
194
  const result = spawnSync(command, args, {
160
195
  cwd: packageDir,
161
196
  env: process.env,
@@ -166,26 +201,20 @@ if (result.error) {
166
201
  console.error(`[ghostty-vt-node] failed to launch native build for ${triplet}:`, result.error);
167
202
  }
168
203
 
169
- // Mirror a successful local build into prebuilt/<triplet>/ so CI (or a developer)
170
- // can produce a shippable prebuilt reproducibly: build once on the target
171
- // platform, then commit prebuilt/<triplet>/. Set ADHDEV_GHOSTTY_VT_EMIT_PREBUILT=1
172
- // to enable (off by default so ordinary `npm run build` does not churn the dir).
173
- // The binding is Node-API (ABI-stable), so a single emitted triplet is enough for
174
- // the running platform-arch; the runtime loader (index.js) falls back across ABIs.
204
+ // Mirror a successful local build into the committed prebuilt for this
205
+ // platform-arch (prebuilt/<platform>-<arch>-node137, whatever Node runs the
206
+ // build — the binding is Node-API, so the ABI in the name is only an address)
207
+ // and stamp its manifest.json from the compiled binary. Set
208
+ // ADHDEV_GHOSTTY_VT_EMIT_PREBUILT=1 to enable (off by default so ordinary
209
+ // `npm run build` does not churn the dir). Combine with
210
+ // ADHDEV_GHOSTTY_VT_FORCE_COMPILE=1 to rebuild a prebuilt from source.
175
211
  if ((result.status ?? 1) === 0 && process.env.ADHDEV_GHOSTTY_VT_EMIT_PREBUILT === '1') {
176
212
  try {
177
- const prebuiltDir = path.join(packageDir, 'prebuilt', triplet);
178
- fs.mkdirSync(prebuiltDir, { recursive: true });
179
- for (const entry of fs.readdirSync(outputDir)) {
180
- // Ship the addon plus its co-located runtime libs (.dylib/.so/.dll),
181
- // skip intermediate build artifacts.
182
- if (/\.(node|dylib|so|so\.\d.*|dll)$/.test(entry)) {
183
- fs.copyFileSync(path.join(outputDir, entry), path.join(prebuiltDir, entry));
184
- }
185
- }
186
- console.log(`[ghostty-vt-node] emitted prebuilt for ${triplet} → ${prebuiltDir}`);
213
+ const { target, manifest } = emitPrebuilt(outputDir);
214
+ console.log(`[ghostty-vt-node] emitted prebuilt → ${target} (source digest ${manifest.sourceDigest})`);
187
215
  } catch (emitError) {
188
216
  console.error(`[ghostty-vt-node] failed to emit prebuilt for ${triplet}:`, emitError);
217
+ process.exit(1);
189
218
  }
190
219
  }
191
220