@gjsify/cli 0.22.0 → 0.24.1

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 (47) hide show
  1. package/README.md +28 -1
  2. package/dist/cli.gjs.mjs +265 -184
  3. package/lib/actions/build.d.ts +12 -0
  4. package/lib/actions/build.js +26 -0
  5. package/lib/bundler-pick.js +56 -7
  6. package/lib/commands/affected.js +84 -14
  7. package/lib/commands/check.js +38 -29
  8. package/lib/commands/flatpak/build.js +19 -18
  9. package/lib/commands/foreach.js +119 -42
  10. package/lib/commands/info.js +11 -5
  11. package/lib/commands/install.d.ts +16 -0
  12. package/lib/commands/install.js +123 -36
  13. package/lib/commands/onboard.js +6 -1
  14. package/lib/commands/pack.js +69 -4
  15. package/lib/commands/run.js +26 -14
  16. package/lib/commands/showcase.js +17 -13
  17. package/lib/commands/storybook.js +5 -2
  18. package/lib/commands/trust.js +4 -62
  19. package/lib/commands/workspace.js +49 -30
  20. package/lib/utils/bin-shim.d.ts +66 -0
  21. package/lib/utils/bin-shim.js +246 -0
  22. package/lib/utils/build-cache.d.ts +42 -6
  23. package/lib/utils/build-cache.js +114 -42
  24. package/lib/utils/detect-native-packages.d.ts +118 -7
  25. package/lib/utils/detect-native-packages.js +213 -29
  26. package/lib/utils/gjs-bundle-guard.d.ts +32 -0
  27. package/lib/utils/gjs-bundle-guard.js +103 -0
  28. package/lib/utils/install-backend-native.d.ts +24 -0
  29. package/lib/utils/install-backend-native.js +89 -16
  30. package/lib/utils/install-global.d.ts +16 -0
  31. package/lib/utils/install-global.js +58 -8
  32. package/lib/utils/oxc-resolve.js +25 -57
  33. package/lib/utils/publish-headers.d.ts +2 -3
  34. package/lib/utils/publish-headers.js +64 -3
  35. package/lib/utils/publishable-packages.d.ts +22 -0
  36. package/lib/utils/publishable-packages.js +93 -0
  37. package/lib/utils/run-gjs.d.ts +6 -7
  38. package/lib/utils/run-gjs.js +4 -3
  39. package/lib/utils/run-node.d.ts +16 -0
  40. package/lib/utils/run-node.js +28 -7
  41. package/lib/utils/runtimes.d.ts +21 -0
  42. package/lib/utils/runtimes.js +23 -0
  43. package/lib/utils/spawn.d.ts +82 -0
  44. package/lib/utils/spawn.js +139 -0
  45. package/lib/utils/stdin.d.ts +25 -0
  46. package/lib/utils/stdin.js +132 -0
  47. package/package.json +23 -18
@@ -0,0 +1,246 @@
1
+ // SPDX-License-Identifier: ISC
2
+ // Adapted from npm's `cmd-shim`
3
+ // (refs/npm-cli/node_modules/bin-links/node_modules/cmd-shim/lib/index.js and
4
+ // .../to-batch-syntax.js). Copyright (c) npm, Inc. and Contributors. ISC.
5
+ // Modifications: rewritten as pure TypeScript string builders (no fs, no
6
+ // `process.platform` read) so the Windows branch is unit-testable from a Linux
7
+ // host, and split into two entry points — one for linking a package bin that
8
+ // carries its own shebang (`buildCmdShim`, the faithful npm port) and one for
9
+ // the *generated* launchers gjsify writes itself (`buildLauncherShims`).
10
+ //
11
+ // WHY THIS EXISTS
12
+ //
13
+ // A bin is made runnable on POSIX with a symlink into `node_modules/.bin/`;
14
+ // the kernel reads the target's `#!` line and runs the interpreter. Windows has
15
+ // neither of those mechanisms: `.bin/<name>` with no extension is not on
16
+ // PATHEXT, and there is no shebang handling. npm's answer — the de-facto
17
+ // standard every Windows Node toolchain follows — is to write three sibling
18
+ // files:
19
+ //
20
+ // `<name>` a POSIX `sh` script (used by git-bash / MSYS / WSL / Cygwin)
21
+ // `<name>.cmd` a batch file (used by cmd.exe and by `npx`/`npm run`)
22
+ // `<name>.ps1` a PowerShell script (used by pwsh)
23
+ //
24
+ // All three re-derive the interpreter from the target's shebang, so a bin
25
+ // declaring `#!/usr/bin/env node` and one declaring `#!/usr/bin/env -S gjs -m`
26
+ // both work without the caller knowing which it is.
27
+ // Matches `#!<prog> <args>` and `#!/usr/bin/env [-S] [K=V ...] <prog> <args>`.
28
+ // Verbatim from cmd-shim's `shebangExpr`.
29
+ const SHEBANG_RE = /^#!\s*(?:\/usr\/bin\/env\s+(?:-S\s+)?((?:[^ \t=]+=[^ \t=]+\s+)*))?([^ \t]+)(.*)$/;
30
+ /**
31
+ * Parse the first line of a bin file. Returns `null` when it is not a shebang
32
+ * — cmd-shim then treats the target as directly executable.
33
+ */
34
+ export function parseShebang(firstLine) {
35
+ const m = SHEBANG_RE.exec(firstLine.trim());
36
+ if (!m)
37
+ return null;
38
+ return { variables: m[1] ?? '', prog: m[2] ?? '', args: m[3] ?? '' };
39
+ }
40
+ /** `$VAR` / `${VAR}` → `%VAR%`. Port of cmd-shim's `replaceDollarWithPercentPair`. */
41
+ function replaceDollarWithPercentPair(value) {
42
+ return value.replace(/\$\{?([^$@#?\- \t{}:]+)\}?/g, (_all, name) => `%${name}%`);
43
+ }
44
+ /** `K=V K2=V2 ` → `@SET K=V\r\n@SET K2=V2\r\n`. Port of `convertToSetCommands`. */
45
+ function convertToSetCommands(variableString) {
46
+ let out = '';
47
+ for (const declaration of variableString.split(' ')) {
48
+ const eq = declaration.indexOf('=');
49
+ if (eq <= 0)
50
+ continue;
51
+ const key = declaration.slice(0, eq).trim();
52
+ const value = declaration.slice(eq + 1).trim();
53
+ if (key && value)
54
+ out += `@SET ${key}=${replaceDollarWithPercentPair(value)}\r\n`;
55
+ }
56
+ return out;
57
+ }
58
+ // The `:find_dp0` subroutine trick — see npm/cmd-shim#10 and npm/cli#969.
59
+ const CMD_HEAD = '@ECHO off\r\n' +
60
+ 'GOTO start\r\n' +
61
+ ':find_dp0\r\n' +
62
+ 'SET dp0=%~dp0\r\n' +
63
+ 'EXIT /b\r\n' +
64
+ ':start\r\n' +
65
+ 'SETLOCAL\r\n' +
66
+ 'CALL :find_dp0\r\n';
67
+ // Resolves `$basedir` and, under git-bash/MSYS/WSL, the Windows-shaped
68
+ // `$basedir_win` the interpreter needs for its script argument.
69
+ const SH_BASEDIR = `basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')")\n` +
70
+ 'basedir_win="$basedir"\n' +
71
+ '\n' +
72
+ 'case `uname -a` in\n' +
73
+ ' *CYGWIN*|*MINGW*|*MSYS*)\n' +
74
+ ' if command -v cygpath > /dev/null 2>&1; then\n' +
75
+ ' basedir_win=`cygpath -w "$basedir"`\n' +
76
+ ' fi\n' +
77
+ ' ;;\n' +
78
+ ' *WSL2*)\n' +
79
+ ' if command -v wslpath > /dev/null 2>&1; then\n' +
80
+ ' basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"\n' +
81
+ ' if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then\n' +
82
+ ' echo "Error: wslpath failed to convert path. WSL environment may be misconfigured." >&2\n' +
83
+ ' exit 1\n' +
84
+ ' fi\n' +
85
+ ' fi\n' +
86
+ ' ;;\n' +
87
+ 'esac\n' +
88
+ '\n';
89
+ const PS1_HEAD = '#!/usr/bin/env pwsh\n' +
90
+ '$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent\n' +
91
+ '\n' +
92
+ '$exe=""\n' +
93
+ 'if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {\n' +
94
+ ' # Fix case when both the Windows and Linux builds of Node\n' +
95
+ ' # are installed in the same directory\n' +
96
+ ' $exe=".exe"\n' +
97
+ '}\n';
98
+ /**
99
+ * Build the three Windows shim files for a package bin.
100
+ *
101
+ * @param relTargetPosix POSIX-style path from the `.bin` directory to the real
102
+ * bin file (i.e. `path.relative(binDir, target)` with `\` normalised to `/`).
103
+ * @param shebang the target's parsed `#!` line, or `null` when it has none —
104
+ * in which case the shims invoke the target directly, matching cmd-shim.
105
+ */
106
+ export function buildCmdShim(relTargetPosix, shebang) {
107
+ let shTarget = relTargetPosix.split('\\').join('/');
108
+ let target = shTarget.split('/').join('\\');
109
+ let pwshTarget = shTarget;
110
+ let prog = shebang?.prog ?? '';
111
+ let args = shebang?.args ?? '';
112
+ const variables = shebang?.variables ?? '';
113
+ let shProg = prog ? prog.split('\\').join('/') : '';
114
+ let pwshProg = shProg ? `"${shProg}$exe"` : '';
115
+ let longProg = '';
116
+ let shLongProg = '';
117
+ let pwshLongProg = '';
118
+ if (!prog) {
119
+ prog = `"%dp0%\\${target}"`;
120
+ shProg = `"$basedir/${shTarget}"`;
121
+ pwshProg = shProg;
122
+ args = '';
123
+ target = '';
124
+ shTarget = '';
125
+ pwshTarget = '';
126
+ }
127
+ else {
128
+ longProg = `"%dp0%\\${prog}.exe"`;
129
+ shLongProg = `"$basedir/${prog}"`;
130
+ pwshLongProg = `"$basedir/${prog}$exe"`;
131
+ target = `"%dp0%\\${target}"`;
132
+ shTarget = `"$basedir_win/${shTarget}"`;
133
+ pwshTarget = `"$basedir/${pwshTarget}"`;
134
+ }
135
+ let cmd;
136
+ if (longProg) {
137
+ const trimmedArgs = args.trim();
138
+ cmd =
139
+ CMD_HEAD +
140
+ convertToSetCommands(variables) +
141
+ '\r\n' +
142
+ `IF EXIST ${longProg} (\r\n` +
143
+ ` SET "_prog=${longProg.replace(/(^")|("$)/g, '')}"\r\n` +
144
+ ') ELSE (\r\n' +
145
+ ` SET "_prog=${prog.replace(/(^")|("$)/g, '')}"\r\n` +
146
+ ')\r\n' +
147
+ '\r\n' +
148
+ // Suppresses the "Terminate Batch Job? (Y/n)" prompt — npm/cli#969.
149
+ 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & ' +
150
+ `set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" ${trimmedArgs} ${target} %*\r\n`;
151
+ }
152
+ else {
153
+ cmd = `${CMD_HEAD}${prog} ${args} ${target} %*\r\n`;
154
+ }
155
+ let sh = `#!/bin/sh\n${SH_BASEDIR}`;
156
+ if (shLongProg) {
157
+ sh +=
158
+ `PROG_EXE=${shLongProg.replace(/"$/, '.exe"')}\n` +
159
+ 'if ! [ -x "$PROG_EXE" ]; then\n' +
160
+ ` PROG_EXE=${shLongProg}\n` +
161
+ ' if ! [ -x "$PROG_EXE" ]; then\n' +
162
+ ` PROG_EXE=${shProg}\n` +
163
+ ' if ! [ -x "$PROG_EXE" ]; then\n' +
164
+ ` PROG_EXE=${shProg}.exe\n` +
165
+ ' fi\n' +
166
+ ' fi\n' +
167
+ 'fi\n' +
168
+ '\n' +
169
+ `exec ${variables}"$PROG_EXE" ${args} ${shTarget} "$@"\n`;
170
+ }
171
+ else {
172
+ sh += `exec ${shProg} ${args} ${shTarget} "$@"\n`;
173
+ }
174
+ let ps1 = PS1_HEAD;
175
+ if (pwshLongProg) {
176
+ ps1 +=
177
+ '$ret=0\n' +
178
+ `if (Test-Path ${pwshLongProg}) {\n` +
179
+ ' # Support pipeline input\n' +
180
+ ' if ($MyInvocation.ExpectingInput) {\n' +
181
+ ` $input | & ${pwshLongProg} ${args} ${pwshTarget} $args\n` +
182
+ ' } else {\n' +
183
+ ` & ${pwshLongProg} ${args} ${pwshTarget} $args\n` +
184
+ ' }\n' +
185
+ ' $ret=$LASTEXITCODE\n' +
186
+ '} else {\n' +
187
+ ' # Support pipeline input\n' +
188
+ ' if ($MyInvocation.ExpectingInput) {\n' +
189
+ ` $input | & ${pwshProg} ${args} ${pwshTarget} $args\n` +
190
+ ' } else {\n' +
191
+ ` & ${pwshProg} ${args} ${pwshTarget} $args\n` +
192
+ ' }\n' +
193
+ ' $ret=$LASTEXITCODE\n' +
194
+ '}\n' +
195
+ 'exit $ret\n';
196
+ }
197
+ else {
198
+ ps1 +=
199
+ '# Support pipeline input\n' +
200
+ 'if ($MyInvocation.ExpectingInput) {\n' +
201
+ ` $input | & ${pwshProg} ${pwshTarget} $args\n` +
202
+ '} else {\n' +
203
+ ` & ${pwshProg} ${pwshTarget} $args\n` +
204
+ '}\n' +
205
+ 'exit $LASTEXITCODE\n';
206
+ }
207
+ return { sh, cmd, ps1 };
208
+ }
209
+ /**
210
+ * `.cmd` / `.ps1` companions for a launcher gjsify *generates* itself
211
+ * (`linkGlobalBins`, `writeWorkspaceBinShims`) rather than links.
212
+ *
213
+ * Those launchers are not a symlink to a bin with a shebang — they are a
214
+ * hand-written `sh` script that picks an interpreter and passes an ABSOLUTE
215
+ * script path. `buildCmdShim` cannot express them (it derives everything from
216
+ * a relative target plus a shebang), so this builds the equivalent batch /
217
+ * PowerShell form: optional `SET` / `$env:` assignments, then
218
+ * `<interpreter> [args] "<absolute target>" <argv>`.
219
+ *
220
+ * Note the interpreter is invoked by NAME (resolved through `PATH`), matching
221
+ * the `sh` launcher's `exec gjs`/`exec node`. A missing interpreter therefore
222
+ * fails the same way on every OS.
223
+ */
224
+ export function buildLauncherShims(opts) {
225
+ const { interpreter, interpreterArgs = [], target, prependEnv = {} } = opts;
226
+ const winTarget = target.split('/').join('\\');
227
+ const argv = interpreterArgs.length > 0 ? `${interpreterArgs.join(' ')} ` : '';
228
+ const entries = Object.entries(prependEnv);
229
+ let cmd = CMD_HEAD;
230
+ for (const [key, value] of entries)
231
+ cmd += `@SET "${key}=${value};%${key}%"\r\n`;
232
+ cmd += `${interpreter} ${argv}"${winTarget}" %*\r\n`;
233
+ let ps1 = '#!/usr/bin/env pwsh\n';
234
+ for (const [key, value] of entries)
235
+ ps1 += `$env:${key} = "${value};" + $env:${key}\n`;
236
+ const ps1Args = interpreterArgs.map((a) => `"${a}" `).join('');
237
+ ps1 +=
238
+ '# Support pipeline input\n' +
239
+ 'if ($MyInvocation.ExpectingInput) {\n' +
240
+ ` $input | & "${interpreter}" ${ps1Args}"${winTarget}" $args\n` +
241
+ '} else {\n' +
242
+ ` & "${interpreter}" ${ps1Args}"${winTarget}" $args\n` +
243
+ '}\n' +
244
+ 'exit $LASTEXITCODE\n';
245
+ return { cmd, ps1 };
246
+ }
@@ -1,6 +1,12 @@
1
1
  import { type Workspace } from '@gjsify/workspace';
2
- /** Bump to invalidate every existing cache entry (schema/semantic changes). */
3
- export declare const BUILD_CACHE_LAYOUT_VERSION = "v1";
2
+ /**
3
+ * Bump to invalidate every existing cache entry (schema/semantic changes).
4
+ *
5
+ * v2: output ownership moved from the whole candidate dir to per-child
6
+ * "output units" (`lib/esm` vs `lib/types`) — a v1 manifest recording `lib`
7
+ * would restore over the sibling subtree a different script owns.
8
+ */
9
+ export declare const BUILD_CACHE_LAYOUT_VERSION = "v2";
4
10
  /** Conventional per-package build output directories, checked in this order. */
5
11
  export declare const OUTPUT_DIR_CANDIDATES: readonly ["lib", "dist", "dist-templates"];
6
12
  /** Keep at most this many keys per package; evict the least recently stored/used. */
@@ -46,14 +52,44 @@ export declare function hashPackageInputs(location: string): string;
46
52
  */
47
53
  export declare function hashOutputDirs(location: string, dirs: readonly string[]): string;
48
54
  /**
49
- * Cheap per-dir change signature (existence + file list + sizes + mtimes).
55
+ * The cacheable output units of a package — the granularity at which a cache
56
+ * entry claims ownership.
57
+ *
58
+ * A candidate dir (`lib/`) is NOT automatically one unit, because the package
59
+ * convention splits it between two DIFFERENT scripts: `build:gjsify` writes
60
+ * `lib/esm/**` (bundler output) and `build:types` writes `lib/types/**` (the
61
+ * `gjsify tsc` declaration emit). At whole-`lib/` granularity both scripts
62
+ * claim the same unit, so a cache HIT on either one does its delete-then-copy
63
+ * over the WHOLE tree and silently destroys the other's output — measured:
64
+ * `build:gjsify` hitting the cache after `build:types` ran left the package
65
+ * with no `lib/types/` at all, and since the script never ran, nothing
66
+ * regenerated them. That ships a tarball whose `types` entry point does not
67
+ * exist (the failure `assertTypeDeclarationsShipped` exists to catch).
68
+ *
69
+ * So: when a candidate dir contains ONLY sub-directories, each child is its
70
+ * own unit (`lib/esm`, `lib/types`) and the two scripts stop overlapping. A
71
+ * candidate holding loose files degrades to the whole dir — a build writing
72
+ * directly into `lib/` genuinely owns it, and the coarse unit is still
73
+ * correct, just less precise.
74
+ *
75
+ * The safety rule is unchanged and in fact tightened: a unit the build never
76
+ * modifies is never recorded, so it can never be restored over (`@gjsify/tsc`'s
77
+ * committed, no-build `lib/lib*.d.ts` are loose files → one `lib` unit → never
78
+ * touched by any build → never captured).
79
+ */
80
+ export declare function outputUnits(location: string): string[];
81
+ /**
82
+ * Cheap per-unit change signature (existence + file list + sizes + mtimes).
50
83
  * Taken BEFORE running the script and compared AFTER, to detect which output
51
- * dirs the build actually modified. Content hashing is deliberately avoided
84
+ * units the build actually modified. Content hashing is deliberately avoided
52
85
  * here — a build that rewrites identical bytes still bumps mtimes, and that
53
- * is exactly the "this dir belongs to the build" signal we want.
86
+ * is exactly the "this unit belongs to the build" signal we want.
87
+ *
88
+ * Units absent before the run are recorded as `null` so a freshly created
89
+ * `lib/esm` still reads as "modified".
54
90
  */
55
91
  export declare function snapshotOutputDirs(location: string): Record<string, string | null>;
56
- /** Output dirs whose signature changed (or that newly appeared) since `before`. */
92
+ /** Output units whose signature changed (or that newly appeared) since `before`. */
57
93
  export declare function modifiedOutputDirs(location: string, before: Record<string, string | null>): string[];
58
94
  export interface ComposeCacheKeyInput {
59
95
  script: string;
@@ -27,14 +27,20 @@
27
27
  // outputs/<dir>/… — copies of the output dirs the build MODIFIED
28
28
  //
29
29
  // Output contract: after a successful (miss) run, the candidate output dirs
30
- // (`lib/`, `dist/`, `dist-templates/`) are compared against a pre-run
31
- // snapshot and ONLY the dirs the script actually created/modified are
32
- // stored. On a later hit, EXACTLY those dirs are replaced (delete-then-copy
33
- // within the package). This is the safety rule from the v0.x CI incident
34
- // where a raw YAML cache restored over a package whose committed `lib/` is
35
- // tracked source: a dir the build never touches is never recorded, so it can
36
- // never be restored over. Packages that don't define the script at all are
37
- // filtered out by the calling command and never reach the cache.
30
+ // (`lib/`, `dist/`, `dist-templates/`) are expanded into OUTPUT UNITS — the
31
+ // candidate's immediate sub-directories when it holds only directories
32
+ // (`lib/esm`, `lib/types`), else the candidate itself — compared against a
33
+ // pre-run snapshot, and ONLY the units the script actually created/modified
34
+ // are stored. On a later hit, EXACTLY those units are replaced
35
+ // (delete-then-copy within the package). This is the safety rule from the
36
+ // v0.x CI incident where a raw YAML cache restored over a package whose
37
+ // committed `lib/` is tracked source: a unit the build never touches is never
38
+ // recorded, so it can never be restored over. The sub-directory granularity
39
+ // additionally keeps the two halves of the dual emit apart — `build:gjsify`
40
+ // (→ `lib/esm`) and `build:types` (→ `lib/types`) both write under `lib/`, and
41
+ // at whole-dir granularity a cache hit on one WIPED the other's output. See
42
+ // `outputUnits`. Packages that don't define the script at all are filtered out
43
+ // by the calling command and never reach the cache.
38
44
  //
39
45
  // Growth is bounded: at most MAX_KEYS_PER_PACKAGE entries per package,
40
46
  // oldest (by last store/use) evicted.
@@ -47,8 +53,14 @@ import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readd
47
53
  import { dirname, join } from 'node:path';
48
54
  import { fileURLToPath } from 'node:url';
49
55
  import { buildDependencyGraph } from '@gjsify/workspace';
50
- /** Bump to invalidate every existing cache entry (schema/semantic changes). */
51
- export const BUILD_CACHE_LAYOUT_VERSION = 'v1';
56
+ /**
57
+ * Bump to invalidate every existing cache entry (schema/semantic changes).
58
+ *
59
+ * v2: output ownership moved from the whole candidate dir to per-child
60
+ * "output units" (`lib/esm` vs `lib/types`) — a v1 manifest recording `lib`
61
+ * would restore over the sibling subtree a different script owns.
62
+ */
63
+ export const BUILD_CACHE_LAYOUT_VERSION = 'v2';
52
64
  /** Conventional per-package build output directories, checked in this order. */
53
65
  export const OUTPUT_DIR_CANDIDATES = ['lib', 'dist', 'dist-templates'];
54
66
  /** Keep at most this many keys per package; evict the least recently stored/used. */
@@ -223,51 +235,111 @@ export function hashOutputDirs(location, dirs) {
223
235
  return hash.digest('hex');
224
236
  }
225
237
  /**
226
- * Cheap per-dir change signature (existence + file list + sizes + mtimes).
227
- * Taken BEFORE running the script and compared AFTER, to detect which output
228
- * dirs the build actually modified. Content hashing is deliberately avoided
229
- * here — a build that rewrites identical bytes still bumps mtimes, and that
230
- * is exactly the "this dir belongs to the build" signal we want.
238
+ * The cacheable output units of a package — the granularity at which a cache
239
+ * entry claims ownership.
240
+ *
241
+ * A candidate dir (`lib/`) is NOT automatically one unit, because the package
242
+ * convention splits it between two DIFFERENT scripts: `build:gjsify` writes
243
+ * `lib/esm/**` (bundler output) and `build:types` writes `lib/types/**` (the
244
+ * `gjsify tsc` declaration emit). At whole-`lib/` granularity both scripts
245
+ * claim the same unit, so a cache HIT on either one does its delete-then-copy
246
+ * over the WHOLE tree and silently destroys the other's output — measured:
247
+ * `build:gjsify` hitting the cache after `build:types` ran left the package
248
+ * with no `lib/types/` at all, and since the script never ran, nothing
249
+ * regenerated them. That ships a tarball whose `types` entry point does not
250
+ * exist (the failure `assertTypeDeclarationsShipped` exists to catch).
251
+ *
252
+ * So: when a candidate dir contains ONLY sub-directories, each child is its
253
+ * own unit (`lib/esm`, `lib/types`) and the two scripts stop overlapping. A
254
+ * candidate holding loose files degrades to the whole dir — a build writing
255
+ * directly into `lib/` genuinely owns it, and the coarse unit is still
256
+ * correct, just less precise.
257
+ *
258
+ * The safety rule is unchanged and in fact tightened: a unit the build never
259
+ * modifies is never recorded, so it can never be restored over (`@gjsify/tsc`'s
260
+ * committed, no-build `lib/lib*.d.ts` are loose files → one `lib` unit → never
261
+ * touched by any build → never captured).
231
262
  */
232
- export function snapshotOutputDirs(location) {
233
- const snapshot = {};
263
+ export function outputUnits(location) {
264
+ const units = [];
234
265
  for (const dir of OUTPUT_DIR_CANDIDATES) {
235
266
  const abs = join(location, dir);
236
- if (!existsSync(abs)) {
237
- snapshot[dir] = null;
267
+ if (!existsSync(abs))
238
268
  continue;
269
+ let entries;
270
+ try {
271
+ entries = readdirSync(abs).filter((name) => name !== 'node_modules');
239
272
  }
240
- const files = [];
241
- walkFiles(abs, dir, files);
242
- const hash = createHash('sha256');
243
- for (const f of files) {
244
- let stamp = 'link';
245
- if (f.link === undefined) {
246
- try {
247
- const st = lstatSync(f.abs);
248
- stamp = `${st.size}:${st.mtimeMs}`;
249
- }
250
- catch {
251
- stamp = 'gone';
252
- }
273
+ catch {
274
+ continue;
275
+ }
276
+ // `lstatSync` rather than `withFileTypes` — the CLI also runs from the
277
+ // GJS bundle, where `@gjsify/fs` is the readdir implementation.
278
+ const allDirs = entries.every((name) => {
279
+ try {
280
+ return lstatSync(join(abs, name)).isDirectory();
281
+ }
282
+ catch {
283
+ return false;
284
+ }
285
+ });
286
+ if (entries.length === 0 || !allDirs) {
287
+ units.push(dir);
288
+ continue;
289
+ }
290
+ for (const child of [...entries].sort())
291
+ units.push(`${dir}/${child}`);
292
+ }
293
+ return units;
294
+ }
295
+ /** Cheap change signature for ONE unit (file list + sizes + mtimes). */
296
+ function signatureOf(location, unit) {
297
+ const files = [];
298
+ walkFiles(join(location, unit), unit, files);
299
+ const hash = createHash('sha256');
300
+ for (const f of files) {
301
+ let stamp = 'link';
302
+ if (f.link === undefined) {
303
+ try {
304
+ const st = lstatSync(f.abs);
305
+ stamp = `${st.size}:${st.mtimeMs}`;
306
+ }
307
+ catch {
308
+ stamp = 'gone';
253
309
  }
254
- hash.update(`${f.rel} ${stamp}\n`);
255
310
  }
256
- snapshot[dir] = hash.digest('hex');
311
+ hash.update(`${f.rel} ${stamp}\n`);
257
312
  }
313
+ return hash.digest('hex');
314
+ }
315
+ /**
316
+ * Cheap per-unit change signature (existence + file list + sizes + mtimes).
317
+ * Taken BEFORE running the script and compared AFTER, to detect which output
318
+ * units the build actually modified. Content hashing is deliberately avoided
319
+ * here — a build that rewrites identical bytes still bumps mtimes, and that
320
+ * is exactly the "this unit belongs to the build" signal we want.
321
+ *
322
+ * Units absent before the run are recorded as `null` so a freshly created
323
+ * `lib/esm` still reads as "modified".
324
+ */
325
+ export function snapshotOutputDirs(location) {
326
+ const snapshot = {};
327
+ for (const dir of OUTPUT_DIR_CANDIDATES)
328
+ snapshot[dir] = null;
329
+ for (const unit of outputUnits(location))
330
+ snapshot[unit] = signatureOf(location, unit);
258
331
  return snapshot;
259
332
  }
260
- /** Output dirs whose signature changed (or that newly appeared) since `before`. */
333
+ /** Output units whose signature changed (or that newly appeared) since `before`. */
261
334
  export function modifiedOutputDirs(location, before) {
262
- const after = snapshotOutputDirs(location);
263
335
  const modified = [];
264
- for (const dir of OUTPUT_DIR_CANDIDATES) {
265
- // Newly created, deleted, or changed — all count as "the build owns
266
- // this dir". A dir that is now ABSENT cannot be stored, though.
267
- if (after[dir] !== before[dir] && after[dir] !== null)
268
- modified.push(dir);
336
+ for (const unit of outputUnits(location)) {
337
+ // Newly created or changed — both mean "the build owns this unit".
338
+ // A unit that is now ABSENT cannot be stored and is simply not listed.
339
+ if (signatureOf(location, unit) !== (before[unit] ?? null))
340
+ modified.push(unit);
269
341
  }
270
- return modified;
342
+ return modified.sort();
271
343
  }
272
344
  /** Deterministic cache key (32 hex chars) from all invalidation ingredients. */
273
345
  export function composeCacheKey(input) {
@@ -1,9 +1,91 @@
1
1
  export interface NativePackage {
2
2
  /** npm package name, e.g. "@gjsify/webgl" */
3
3
  name: string;
4
- /** Absolute path to the arch-specific prebuilds dir, e.g. "/…/@gjsify/webgl/prebuilds/linux-x86_64" */
4
+ /** Absolute path to the arch-specific prebuilds dir, e.g. "/…/@gjsify/webgl/prebuilds/linux-x64" */
5
5
  prebuildsDir: string;
6
6
  }
7
+ /** The host target a resolution is performed for. Defaults to the running process. */
8
+ export interface HostTarget {
9
+ /** `process.platform` value — `linux` | `darwin` | `win32` | … */
10
+ platform?: string;
11
+ /** `process.arch` value — `x64` | `arm64` | `ppc64` | `s390x` | `riscv64` | … */
12
+ arch?: string;
13
+ }
14
+ /**
15
+ * Environment a GJS process needs so `gi://Gjsify…` imports resolve against
16
+ * the detected prebuilds. `GI_TYPELIB_PATH` is universal (girepository reads
17
+ * it on every OS); the *library* search path is whatever the host's dynamic
18
+ * loader actually consults, which is a different variable per OS — see
19
+ * {@link buildNativeEnv}.
20
+ *
21
+ * The index signature exists for the Windows case, where the key is the host's
22
+ * own `PATH` spelling (`Path` on a stock Windows env block) rather than a
23
+ * fixed literal.
24
+ */
25
+ export interface NativeEnv {
26
+ GI_TYPELIB_PATH: string;
27
+ /** ELF loader (Linux and other ELF platforms). */
28
+ LD_LIBRARY_PATH?: string;
29
+ /** Mach-O loader (macOS). `dyld` ignores `LD_LIBRARY_PATH` entirely. */
30
+ DYLD_LIBRARY_PATH?: string;
31
+ [key: string]: string | undefined;
32
+ }
33
+ /**
34
+ * Canonical `<os>-<arch>` form — the node spelling — so `linux-x86_64` and
35
+ * `linux-x64` compare equal. Only the *arch* half is normalised; the OS half
36
+ * is always a `process.platform` token (`linux`/`darwin`/`win32`), which has
37
+ * no competing spelling.
38
+ */
39
+ export declare function canonicalPlatformToken(token: string): string;
40
+ /**
41
+ * The prebuild directory names to probe for a host, most-specific first.
42
+ *
43
+ * The canonical name is `${process.platform}-${process.arch}` — `linux-x64`,
44
+ * `linux-arm64`, `darwin-arm64`, `win32-x64`. Every package in the release
45
+ * train declares it (`gjsify.platforms`), `scripts/stage-prebuild.mjs` is the
46
+ * only thing that creates it, and `scripts/audit-runtimes.mjs --check` rejects
47
+ * any other spelling, so the three can no longer drift.
48
+ *
49
+ * Two probes remain BEHIND that canonical name, and both are backward
50
+ * compatibility with tarballs already on npm — not a second convention:
51
+ *
52
+ * 1. **The package's own declared spelling.** A tarball published before the
53
+ * rename declares (and ships) `linux-x86_64`; probing its declaration
54
+ * first loads it without the CLI having to guess. This is also what makes
55
+ * the rename work in the OTHER direction — an older CLI resolving a newer
56
+ * package — because `canonicalPlatformToken` folds both spellings onto
57
+ * one form at both ends.
58
+ * 2. **The legacy uname spelling.** Only reachable for a package that ships
59
+ * a prebuild dir and declares NO `gjsify.platforms` at all — i.e. a
60
+ * tarball predating the OS-axis audit, or a third-party package using
61
+ * `gjsify.prebuilds` without the declaration. Inside this repo the audit
62
+ * makes that state impossible.
63
+ *
64
+ * Both are pure array entries matched against an already-read directory
65
+ * listing — no extra I/O — so tolerance on the READ side costs nothing, while
66
+ * the single spelling is enforced everywhere a name is WRITTEN.
67
+ *
68
+ * @param platform `process.platform` value.
69
+ * @param arch `process.arch` value.
70
+ * @param declaredPlatforms `package.json#gjsify.platforms`, when present.
71
+ */
72
+ export declare function prebuildDirCandidates(platform: string, arch: string, declaredPlatforms?: readonly string[] | undefined): string[];
73
+ /**
74
+ * Pick the prebuild directory a host should load, given the directories that
75
+ * actually exist under `<pkg>/<prebuilds>/`.
76
+ *
77
+ * Pure: no `process.*` reads, no filesystem access. This is the function the
78
+ * darwin and win32 branches are unit-tested through on a Linux host.
79
+ *
80
+ * @returns the chosen directory NAME (not a path), or `null` when the package
81
+ * ships nothing loadable on this host.
82
+ */
83
+ export declare function resolvePrebuildDirName(input: {
84
+ platform: string;
85
+ arch: string;
86
+ declaredPlatforms?: readonly string[] | undefined;
87
+ existingDirs: readonly string[];
88
+ }): string | null;
7
89
  /**
8
90
  * Walk up the directory tree from `startDir` and merge native packages found
9
91
  * in every `node_modules` encountered.
@@ -16,13 +98,42 @@ export interface NativePackage {
16
98
  *
17
99
  * Deduplication: the first match for a given package name wins (closer
18
100
  * node_modules shadows outer ones), matching Node.js resolution semantics.
101
+ *
102
+ * @param target overrides the host platform/arch. Only tests pass this; the
103
+ * production call sites take the defaults.
19
104
  */
20
- export declare function detectNativePackages(startDir: string): NativePackage[];
105
+ export declare function detectNativePackages(startDir: string, target?: HostTarget): NativePackage[];
21
106
  /**
22
- * Build the LD_LIBRARY_PATH and GI_TYPELIB_PATH env var values for the detected native packages.
23
- * Prepends the new paths to any existing values from the environment.
107
+ * The environment-variable name the host's dynamic loader consults for an
108
+ * additional shared-library search path, plus the separator its search paths
109
+ * use.
110
+ *
111
+ * * **Linux / other ELF** — `LD_LIBRARY_PATH`, `:`-separated.
112
+ * * **macOS** — `DYLD_LIBRARY_PATH`. `dyld` does not read `LD_LIBRARY_PATH`
113
+ * at all, which is why `.github/workflows/napi.yml` sets `DYLD_LIBRARY_PATH`
114
+ * by hand on every macOS gate step.
115
+ * * **Windows** — there is no dedicated variable; `LoadLibrary` searches the
116
+ * process `PATH`, `;`-separated. This is the same mechanism
117
+ * `@gjsify/node-gi`'s `maybePrependGtkRuntimeDllPath()` uses to make its
118
+ * bundled GTK DLLs resolvable.
24
119
  */
25
- export declare function buildNativeEnv(packages: NativePackage[]): {
26
- LD_LIBRARY_PATH: string;
27
- GI_TYPELIB_PATH: string;
120
+ export declare function libraryPathVar(platform: string): {
121
+ name: string;
122
+ separator: string;
28
123
  };
124
+ /**
125
+ * Build the typelib + shared-library search-path environment for the detected
126
+ * native packages. Prepends the new paths to any existing values.
127
+ *
128
+ * Pure w.r.t. the host: `platform` and `env` are parameters, so the darwin and
129
+ * win32 branches are exercised by unit tests running on Linux.
130
+ *
131
+ * On Windows the `PATH` key is written back under the *host's own* spelling
132
+ * (a stock Windows env block uses `Path`) — Windows env names are
133
+ * case-insensitive, but a plain JS object is not, and `{...process.env,
134
+ * PATH: …}` would hand a child process two competing entries.
135
+ */
136
+ export declare function buildNativeEnv(packages: NativePackage[], opts?: {
137
+ platform?: string;
138
+ env?: Record<string, string | undefined>;
139
+ }): NativeEnv;