@gjsify/cli 0.26.0 → 0.27.0

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 (57) hide show
  1. package/dist/cli.gjs.mjs +184 -182
  2. package/lib/cli-app.js +3 -1
  3. package/lib/commands/affected.js +146 -15
  4. package/lib/commands/build.js +21 -14
  5. package/lib/commands/check.js +40 -4
  6. package/lib/commands/clear.d.ts +8 -0
  7. package/lib/commands/clear.js +90 -0
  8. package/lib/commands/copy.d.ts +8 -0
  9. package/lib/commands/copy.js +87 -0
  10. package/lib/commands/dlx.js +8 -1
  11. package/lib/commands/index.d.ts +2 -0
  12. package/lib/commands/index.js +2 -0
  13. package/lib/commands/install.d.ts +4 -0
  14. package/lib/commands/install.js +164 -15
  15. package/lib/commands/onboard.js +17 -14
  16. package/lib/commands/publish.js +18 -4
  17. package/lib/commands/storybook.js +5 -1
  18. package/lib/commands/test.js +29 -2
  19. package/lib/commands/uninstall.js +44 -2
  20. package/lib/commands/upgrade.js +1 -4
  21. package/lib/templates/install.mjs.tmpl +110 -19
  22. package/lib/utils/bin-shim.d.ts +73 -0
  23. package/lib/utils/bin-shim.js +97 -2
  24. package/lib/utils/build-cache.d.ts +7 -27
  25. package/lib/utils/build-cache.js +34 -4
  26. package/lib/utils/check-system-deps.d.ts +15 -1
  27. package/lib/utils/check-system-deps.js +126 -2
  28. package/lib/utils/clear-targets.d.ts +30 -0
  29. package/lib/utils/clear-targets.js +114 -0
  30. package/lib/utils/copy-targets.d.ts +41 -0
  31. package/lib/utils/copy-targets.js +145 -0
  32. package/lib/utils/detect-native-packages.d.ts +160 -5
  33. package/lib/utils/detect-native-packages.js +326 -50
  34. package/lib/utils/dir-link.d.ts +86 -0
  35. package/lib/utils/dir-link.js +140 -0
  36. package/lib/utils/dlx-cache.d.ts +30 -3
  37. package/lib/utils/dlx-cache.js +61 -7
  38. package/lib/utils/install-backend-native.d.ts +109 -1
  39. package/lib/utils/install-backend-native.js +622 -59
  40. package/lib/utils/install-backend.js +17 -15
  41. package/lib/utils/install-global.js +6 -34
  42. package/lib/utils/install-lock.js +1 -1
  43. package/lib/utils/install-packument-cache.d.ts +51 -9
  44. package/lib/utils/install-packument-cache.js +88 -25
  45. package/lib/utils/oxc-resolve.d.ts +27 -1
  46. package/lib/utils/oxc-resolve.js +53 -26
  47. package/lib/utils/pkg-json.d.ts +24 -0
  48. package/lib/utils/pkg-json.js +27 -1
  49. package/lib/utils/platform-check.d.ts +215 -0
  50. package/lib/utils/platform-check.js +377 -0
  51. package/lib/utils/resolve-npm-package.js +9 -2
  52. package/lib/utils/run-gjs.d.ts +1 -1
  53. package/lib/utils/run-gjs.js +19 -3
  54. package/lib/utils/spawn.js +50 -2
  55. package/lib/utils/win32-command.d.ts +54 -0
  56. package/lib/utils/win32-command.js +140 -0
  57. package/package.json +19 -20
@@ -448,10 +448,7 @@ function printTable(candidates) {
448
448
  'kind' +
449
449
  ANSI.reset;
450
450
  console.log(head);
451
- console.log(' '.repeat(idxW + 2) +
452
- ANSI.dim +
453
- '─'.repeat(nameW + fanW + curW + newW + 16) +
454
- ANSI.reset);
451
+ console.log(' '.repeat(idxW + 2) + ANSI.dim + '─'.repeat(nameW + fanW + curW + newW + 16) + ANSI.reset);
455
452
  for (let i = 0; i < candidates.length; i++) {
456
453
  const c = candidates[i];
457
454
  const idx = `${i + 1}.`.padEnd(idxW);
@@ -26,8 +26,11 @@
26
26
  * Test hooks (set by tests/e2e/install-script/run.mjs):
27
27
  * GJSIFY_INSTALL_BOOTSTRAP_URL override the cli.gjs.mjs download origin
28
28
  * (accepts file:// for offline tests)
29
- * GJSIFY_INSTALL_BOOTSTRAP_SHA256_URL override the .sha256 companion URL
30
- * (set to empty string to skip SHA-256)
29
+ * GJSIFY_INSTALL_BOOTSTRAP_SHA256_URL override the .sha256 companion URL.
30
+ * An EMPTY string is the only way to install
31
+ * without verification, and it warns loudly; a
32
+ * digest URL that is set but unreachable is a
33
+ * hard failure, never a silent downgrade.
31
34
  * GJSIFY_GLOBAL_PREFIX override install prefix (forwarded to cli)
32
35
  * GJSIFY_GLOBAL_BIN_DIR override bin dir (forwarded to cli)
33
36
  * GJSIFY_INSTALL_REGISTRY override npm registry (forwarded as npm_config_registry)
@@ -50,6 +53,11 @@ const DEFAULT_BOOTSTRAP_SHA256_URL = `${DEFAULT_BOOTSTRAP_URL}.sha256`;
50
53
 
51
54
  const USER_AGENT = 'gjsify-installer/1.0';
52
55
 
56
+ // The bootstrap cache is CONTENT-ADDRESSED: `cli-<sha256>.gjs.mjs`. See
57
+ // `resolveBootstrap()` for why the digest, not a fixed name, keys it.
58
+ const CACHE_PREFIX = 'cli-';
59
+ const CACHE_SUFFIX = '.gjs.mjs';
60
+
53
61
  function info(msg) {
54
62
  print(`[gjsify] ${msg}`);
55
63
  }
@@ -168,35 +176,118 @@ function writeBytes(path, bytes) {
168
176
  Gio.File.new_for_path(path).replace_contents(bytes, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null);
169
177
  }
170
178
 
171
- async function downloadBootstrap(session, bootstrapUrl, sha256Url) {
172
- info(`Downloading bootstrap from ${bootstrapUrl} ...`);
173
- const bundleBytes = await fetchBytes(session, bootstrapUrl);
174
- if (sha256Url && sha256Url !== '') {
175
- info('Verifying SHA-256 ...');
176
- let sumExpected;
179
+ function readBytesOrNull(path) {
180
+ try {
181
+ const [, bytes] = Gio.File.new_for_path(path).load_contents(null);
182
+ return bytes;
183
+ } catch {
184
+ // An absent — or unreadable, e.g. truncated by a killed earlier run —
185
+ // cache entry is a MISS, not an error. `load_contents` is a real
186
+ // `throws="1"` GIR call, so this catch has a genuine throw path.
187
+ return null;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Drop every other cached bootstrap so the content-addressed cache stays
193
+ * bounded. Each entry is a ~6.6 MB bundle and the digest changes with every
194
+ * release — without this, keying the cache by content would trade "the cache
195
+ * can never be warm" for "the cache grows forever".
196
+ */
197
+ function pruneCache(dir, keepBasename) {
198
+ const children = Gio.File.new_for_path(dir).enumerate_children('standard::name', Gio.FileQueryInfoFlags.NONE, null);
199
+ for (;;) {
200
+ const info_ = children.next_file(null);
201
+ if (info_ === null) break;
202
+ const name = info_.get_name();
203
+ if (name === keepBasename || !name.startsWith(CACHE_PREFIX) || !name.endsWith(CACHE_SUFFIX)) continue;
204
+ Gio.File.new_for_path(GLib.build_filenamev([dir, name])).delete(null);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Resolve a VERIFIED bootstrap bundle and return its path.
210
+ *
211
+ * The digest is fetched BEFORE the bundle, and the cache is keyed by it. That
212
+ * ordering is the mechanism, not an optimisation — it fixes two independent
213
+ * defects that both came from doing it the other way round:
214
+ *
215
+ * 1. Verification was skippable by anyone who could break ONE request. The
216
+ * bundle was downloaded first and verified after, and a failed `.sha256`
217
+ * fetch merely logged "skipping verification" and carried on — so a proxy,
218
+ * a 404, or a captive portal silently downgraded the install to no
219
+ * verification at all. Fetching the digest first makes that impossible:
220
+ * no digest, no install.
221
+ * 2. The cache could never be warm. It wrote a fixed `cli.gjs.mjs` and never
222
+ * read it back, so every run re-downloaded ~6.6 MB. A content-addressed
223
+ * name lets a matching entry short-circuit the download entirely.
224
+ *
225
+ * Skipping verification is still possible, but only as an EXPLICIT act
226
+ * (`GJSIFY_INSTALL_BOOTSTRAP_SHA256_URL=''`), and it says so on stderr.
227
+ */
228
+ async function resolveBootstrap(session, bootstrapUrl, sha256Url) {
229
+ let expected = null;
230
+ if (sha256Url) {
231
+ info(`Fetching published SHA-256 from ${sha256Url} ...`);
232
+ let sumBytes;
177
233
  try {
178
- const sumBytes = await fetchBytes(session, sha256Url);
179
- sumExpected = new TextDecoder().decode(sumBytes).trim().split(/\s+/)[0];
234
+ sumBytes = await fetchBytes(session, sha256Url);
180
235
  } catch (err) {
181
- error(`Could not fetch ${sha256Url} — skipping verification: ${err.message}`);
236
+ error(`Could not fetch ${sha256Url}: ${err.message}`);
237
+ error('Refusing to install an UNVERIFIED bootstrap bundle.');
238
+ error("To bootstrap without verification, set GJSIFY_INSTALL_BOOTSTRAP_SHA256_URL='' explicitly.");
239
+ exit(1);
182
240
  }
183
- if (sumExpected) {
184
- const sumActual = sha256Hex(bundleBytes);
185
- if (sumExpected.toLowerCase() !== sumActual.toLowerCase()) {
186
- error(`SHA-256 mismatch: expected ${sumExpected}, got ${sumActual}`);
187
- exit(1);
188
- }
241
+ expected = (new TextDecoder().decode(sumBytes).trim().split(/\s+/)[0] ?? '').toLowerCase();
242
+ if (!/^[0-9a-f]{64}$/.test(expected)) {
243
+ error(`${sha256Url} did not contain a SHA-256 digest (read ${JSON.stringify(expected.slice(0, 80))}).`);
244
+ exit(1);
189
245
  }
246
+ } else {
247
+ error("SHA-256 verification is DISABLED (GJSIFY_INSTALL_BOOTSTRAP_SHA256_URL='') — the bundle is unverified.");
190
248
  }
249
+
191
250
  const dir = cacheDir();
251
+ const basename = expected
252
+ ? `${CACHE_PREFIX}${expected}${CACHE_SUFFIX}`
253
+ : `${CACHE_PREFIX}unverified${CACHE_SUFFIX}`;
254
+ const bundlePath = GLib.build_filenamev([dir, basename]);
255
+
256
+ if (expected) {
257
+ // Re-hash rather than trusting the filename: the name is only a CLAIM
258
+ // about the contents, and a truncated entry keeps its name.
259
+ const cached = readBytesOrNull(bundlePath);
260
+ if (cached && sha256Hex(cached).toLowerCase() === expected) {
261
+ info(`Reusing verified bootstrap ${bundlePath} (${cached.length} bytes)`);
262
+ return bundlePath;
263
+ }
264
+ }
265
+
266
+ info(`Downloading bootstrap from ${bootstrapUrl} ...`);
267
+ const bundleBytes = await fetchBytes(session, bootstrapUrl);
268
+ if (expected) {
269
+ const actual = sha256Hex(bundleBytes).toLowerCase();
270
+ if (actual !== expected) {
271
+ error(`SHA-256 mismatch: expected ${expected}, got ${actual}`);
272
+ exit(1);
273
+ }
274
+ info('SHA-256 verified.');
275
+ }
276
+
192
277
  try {
193
278
  ensureDir(dir);
194
279
  } catch {
195
280
  /* exists */
196
281
  }
197
- const bundlePath = GLib.build_filenamev([dir, 'cli.gjs.mjs']);
198
282
  writeBytes(bundlePath, bundleBytes);
199
283
  info(`Bootstrap cached at ${bundlePath} (${bundleBytes.length} bytes)`);
284
+ try {
285
+ pruneCache(dir, basename);
286
+ } catch (err) {
287
+ // Housekeeping only: a read-only or concurrently-modified cache dir
288
+ // must never fail an install.
289
+ info(`Could not prune the bootstrap cache: ${err.message}`);
290
+ }
200
291
  return bundlePath;
201
292
  }
202
293
 
@@ -232,7 +323,7 @@ async function main() {
232
323
  const session = new Soup.Session();
233
324
  let bundlePath;
234
325
  try {
235
- bundlePath = await downloadBootstrap(session, opts.bootstrapUrl, opts.bootstrapSha256Url);
326
+ bundlePath = await resolveBootstrap(session, opts.bootstrapUrl, opts.bootstrapSha256Url);
236
327
  } catch (err) {
237
328
  error(`Bootstrap download failed: ${err.message}`);
238
329
  exit(1);
@@ -1,3 +1,4 @@
1
+ import { type HostLibc } from './detect-native-packages.js';
1
2
  /** The three sibling files a Windows bin entry consists of. */
2
3
  export interface WindowsShimFiles {
3
4
  /** POSIX `sh` script — the extension-less `<name>` file. */
@@ -104,6 +105,20 @@ export declare function buildLauncherShims(opts: {
104
105
  * {@link buildLauncherShims} from the same baked list, and `PATH` doubles as the
105
106
  * DLL search path with `;` separators. It keeps the snapshot behaviour.
106
107
  *
108
+ * MACOS CAVEAT — the `${DYLD_LIBRARY_PATH:+…}` half of the export can never
109
+ * fire, and the export itself does not survive a script boundary. System
110
+ * Integrity Protection strips every `DYLD_*` variable from the environment
111
+ * whenever a PROTECTED binary is exec'd, and `/bin/sh` is protected — so by the
112
+ * time this preamble runs, an inherited `DYLD_LIBRARY_PATH` is already gone
113
+ * (measured: `DYLD_LIBRARY_PATH=x /bin/sh -c 'echo $DYLD_LIBRARY_PATH'` prints
114
+ * nothing, while the same through Homebrew's unprotected `node` prints `x`).
115
+ * The value this preamble exports is likewise dropped again at the next
116
+ * `/bin/sh -c` hop, which is how every package script runs. Do NOT "simplify"
117
+ * the preservation away as dead code — it is load-bearing on Linux. And do not
118
+ * rely on `DYLD_LIBRARY_PATH` alone to make a darwin prebuild loadable across a
119
+ * script chain: the self-relative `@loader_path` rpath that
120
+ * `check-prebuild-loader-path.mjs` enforces is what actually has to carry it.
121
+ *
107
122
  * @param scanRoot Directory whose `node_modules` is globbed at launch time.
108
123
  * @param bakedDirs Prebuild dirs found at write time; entries outside `scanRoot`
109
124
  * are embedded (entries inside it are dropped — the scan finds them).
@@ -112,4 +127,62 @@ export declare function buildLauncherShims(opts: {
112
127
  export declare function buildNativeEnvPreamble(scanRoot: string, bakedDirs?: readonly string[], target?: {
113
128
  platform?: string;
114
129
  arch?: string;
130
+ libc?: HostLibc | null;
131
+ }): string;
132
+ /** POSIX `sh` single-quoting for a launcher's target path. */
133
+ export declare function shQuoteArg(s: string): string;
134
+ /**
135
+ * Expand a `bin` / `gjsify.bin` declaration into `binName → relative target`.
136
+ *
137
+ * The string form is npm's shorthand for `{ <package-name-without-scope>: <path> }`.
138
+ */
139
+ export declare function normalizeBinMap(pkgName: string, bin: string | Record<string, string>): Map<string, string>;
140
+ /**
141
+ * Which bin map a package wants installed, and whether it is the GJS-runnable one.
142
+ *
143
+ * `gjsify.bin` WINS over npm `bin` when declared. The npm field can only ever
144
+ * name the Node entry — npm has no way to express "and this other file is the
145
+ * one to run when the host has `gjs` but no `node`" — so a package that ships
146
+ * both declares the GJS bundle here. Honouring it is what makes a Node-LESS GJS
147
+ * host work at all: on such a host the npm bin's `#!/usr/bin/env node` shebang
148
+ * fails with `env: can't execute 'node'`, and because the launcher is also the
149
+ * only thing that can export `GI_TYPELIB_PATH` before the GJS runtime starts,
150
+ * that failure takes `gjsify build` down with it (the typelib lookup happens
151
+ * before the bundle's first line — the CLI cannot repair it from the inside).
152
+ *
153
+ * Measured on a postmarketOS/aarch64 phone with gjs 1.88 and no node: install
154
+ * and prebuild loading both worked, and only the `#!/usr/bin/env node` shim in
155
+ * `node_modules/.bin/` stood between that host and a working `gjsify build`.
156
+ *
157
+ * `isGjsBin` lets the caller keep plain-Node packages on the plain symlink path
158
+ * — this must not change how `lodash`'s bin is linked.
159
+ */
160
+ export declare function pickBinMap(pkgName: string, pkgJson: {
161
+ bin?: string | Record<string, string>;
162
+ gjsify?: {
163
+ bin?: string | Record<string, string>;
164
+ };
165
+ }): {
166
+ map: Map<string, string>;
167
+ isGjsBin: boolean;
168
+ } | null;
169
+ /**
170
+ * The POSIX `sh` launcher gjsify writes for a bin it owns.
171
+ *
172
+ * Why a launcher and not a symlink: when a GJS bundle is reached through a
173
+ * symlink, the kernel resolves it to find the executable but hands the ORIGINAL
174
+ * path to it, so `gjs -m <symlink>` makes `import.meta.url` point at the link's
175
+ * directory and every asset read relative to it looks in the wrong place. An
176
+ * explicit `exec` with the real path avoids that.
177
+ *
178
+ * `.gjs.mjs`/`.mjs` targets are wrapped in `gjs -m` rather than exec'd directly
179
+ * because not every published bundle carries a `#!/usr/bin/env -S gjs -m` line,
180
+ * and direct-exec'ing a shebang-less `.mjs` falls through to `/bin/sh`, which
181
+ * then parses JavaScript as shell.
182
+ */
183
+ export declare function buildShLauncher(targetAbs: string, opts: {
184
+ envPreamble?: string;
185
+ isGjsBundle: boolean;
115
186
  }): string;
187
+ /** True when a bin target is a GJS-runnable bundle rather than a Node script. */
188
+ export declare function isGjsBundlePath(target: string): boolean;
@@ -24,7 +24,7 @@
24
24
  // All three re-derive the interpreter from the target's shebang, so a bin
25
25
  // declaring `#!/usr/bin/env node` and one declaring `#!/usr/bin/env -S gjs -m`
26
26
  // both work without the caller knowing which it is.
27
- import { libraryPathVar, prebuildDirCandidates } from './detect-native-packages.js';
27
+ import { detectHostLibc, libraryPathVar, prebuildDirCandidates } from './detect-native-packages.js';
28
28
  // Matches `#!<prog> <args>` and `#!/usr/bin/env [-S] [K=V ...] <prog> <args>`.
29
29
  // Verbatim from cmd-shim's `shebangExpr`.
30
30
  const SHEBANG_RE = /^#!\s*(?:\/usr\/bin\/env\s+(?:-S\s+)?((?:[^ \t=]+=[^ \t=]+\s+)*))?([^ \t]+)(.*)$/;
@@ -289,6 +289,20 @@ function shQuote(s) {
289
289
  * {@link buildLauncherShims} from the same baked list, and `PATH` doubles as the
290
290
  * DLL search path with `;` separators. It keeps the snapshot behaviour.
291
291
  *
292
+ * MACOS CAVEAT — the `${DYLD_LIBRARY_PATH:+…}` half of the export can never
293
+ * fire, and the export itself does not survive a script boundary. System
294
+ * Integrity Protection strips every `DYLD_*` variable from the environment
295
+ * whenever a PROTECTED binary is exec'd, and `/bin/sh` is protected — so by the
296
+ * time this preamble runs, an inherited `DYLD_LIBRARY_PATH` is already gone
297
+ * (measured: `DYLD_LIBRARY_PATH=x /bin/sh -c 'echo $DYLD_LIBRARY_PATH'` prints
298
+ * nothing, while the same through Homebrew's unprotected `node` prints `x`).
299
+ * The value this preamble exports is likewise dropped again at the next
300
+ * `/bin/sh -c` hop, which is how every package script runs. Do NOT "simplify"
301
+ * the preservation away as dead code — it is load-bearing on Linux. And do not
302
+ * rely on `DYLD_LIBRARY_PATH` alone to make a darwin prebuild loadable across a
303
+ * script chain: the self-relative `@loader_path` rpath that
304
+ * `check-prebuild-loader-path.mjs` enforces is what actually has to carry it.
305
+ *
292
306
  * @param scanRoot Directory whose `node_modules` is globbed at launch time.
293
307
  * @param bakedDirs Prebuild dirs found at write time; entries outside `scanRoot`
294
308
  * are embedded (entries inside it are dropped — the scan finds them).
@@ -297,6 +311,15 @@ function shQuote(s) {
297
311
  export function buildNativeEnvPreamble(scanRoot, bakedDirs = [], target = {}) {
298
312
  const platform = target.platform ?? process.platform;
299
313
  const arch = target.arch ?? process.arch;
314
+ // The libc is a HOST fact, so it belongs in the glob just as much as the
315
+ // arch does. Omitting it made the launcher probe only `linux-<arch>` on a
316
+ // musl host — survivable while every prebuild sat in the depending
317
+ // package's own tree, but ADR 0017 moved each binary into a per-target
318
+ // package (`<name>-linux-arm64-musl`, shipping `prebuilds/linux-arm64-musl/`),
319
+ // so the directory the launcher must find is now the one it did not name.
320
+ // `detectNativePackages` has always been libc-aware; this is the same
321
+ // question asked in the shell, and the two must not answer it differently.
322
+ const libc = target.libc !== undefined ? target.libc : detectHostLibc(platform);
300
323
  const { name: libVar, separator } = libraryPathVar(platform);
301
324
  if (platform === 'win32') {
302
325
  if (bakedDirs.length === 0)
@@ -317,7 +340,7 @@ export function buildNativeEnvPreamble(scanRoot, bakedDirs = [], target = {}) {
317
340
  // platforms is correct here — the shell cannot read a package.json, and a
318
341
  // package declaring ONLY a non-canonical spelling of this host has not
319
342
  // existed since the audit made that state impossible.
320
- const candidates = prebuildDirCandidates(platform, arch);
343
+ const candidates = prebuildDirCandidates(platform, arch, undefined, libc);
321
344
  const patterns = [];
322
345
  for (const candidate of candidates) {
323
346
  // Scoped (`@gjsify/x`) and unscoped packages, matching `scanNodeModules`.
@@ -349,3 +372,75 @@ export function buildNativeEnvPreamble(scanRoot, bakedDirs = [], target = {}) {
349
372
  `fi\n` +
350
373
  `unset gjsify_np gjsify_d gjsify_t\n`);
351
374
  }
375
+ /** POSIX `sh` single-quoting for a launcher's target path. */
376
+ export function shQuoteArg(s) {
377
+ return shQuote(s);
378
+ }
379
+ /**
380
+ * Expand a `bin` / `gjsify.bin` declaration into `binName → relative target`.
381
+ *
382
+ * The string form is npm's shorthand for `{ <package-name-without-scope>: <path> }`.
383
+ */
384
+ export function normalizeBinMap(pkgName, bin) {
385
+ const out = new Map();
386
+ if (typeof bin === 'string') {
387
+ const baseName = pkgName.startsWith('@') ? pkgName.slice(pkgName.indexOf('/') + 1) : pkgName;
388
+ out.set(baseName, bin);
389
+ return out;
390
+ }
391
+ for (const [k, v] of Object.entries(bin))
392
+ out.set(k, v);
393
+ return out;
394
+ }
395
+ /**
396
+ * Which bin map a package wants installed, and whether it is the GJS-runnable one.
397
+ *
398
+ * `gjsify.bin` WINS over npm `bin` when declared. The npm field can only ever
399
+ * name the Node entry — npm has no way to express "and this other file is the
400
+ * one to run when the host has `gjs` but no `node`" — so a package that ships
401
+ * both declares the GJS bundle here. Honouring it is what makes a Node-LESS GJS
402
+ * host work at all: on such a host the npm bin's `#!/usr/bin/env node` shebang
403
+ * fails with `env: can't execute 'node'`, and because the launcher is also the
404
+ * only thing that can export `GI_TYPELIB_PATH` before the GJS runtime starts,
405
+ * that failure takes `gjsify build` down with it (the typelib lookup happens
406
+ * before the bundle's first line — the CLI cannot repair it from the inside).
407
+ *
408
+ * Measured on a postmarketOS/aarch64 phone with gjs 1.88 and no node: install
409
+ * and prebuild loading both worked, and only the `#!/usr/bin/env node` shim in
410
+ * `node_modules/.bin/` stood between that host and a working `gjsify build`.
411
+ *
412
+ * `isGjsBin` lets the caller keep plain-Node packages on the plain symlink path
413
+ * — this must not change how `lodash`'s bin is linked.
414
+ */
415
+ export function pickBinMap(pkgName, pkgJson) {
416
+ const gjsifyBin = pkgJson.gjsify?.bin;
417
+ if (gjsifyBin !== undefined)
418
+ return { map: normalizeBinMap(pkgName, gjsifyBin), isGjsBin: true };
419
+ const npmBin = pkgJson.bin;
420
+ if (npmBin !== undefined)
421
+ return { map: normalizeBinMap(pkgName, npmBin), isGjsBin: false };
422
+ return null;
423
+ }
424
+ /**
425
+ * The POSIX `sh` launcher gjsify writes for a bin it owns.
426
+ *
427
+ * Why a launcher and not a symlink: when a GJS bundle is reached through a
428
+ * symlink, the kernel resolves it to find the executable but hands the ORIGINAL
429
+ * path to it, so `gjs -m <symlink>` makes `import.meta.url` point at the link's
430
+ * directory and every asset read relative to it looks in the wrong place. An
431
+ * explicit `exec` with the real path avoids that.
432
+ *
433
+ * `.gjs.mjs`/`.mjs` targets are wrapped in `gjs -m` rather than exec'd directly
434
+ * because not every published bundle carries a `#!/usr/bin/env -S gjs -m` line,
435
+ * and direct-exec'ing a shebang-less `.mjs` falls through to `/bin/sh`, which
436
+ * then parses JavaScript as shell.
437
+ */
438
+ export function buildShLauncher(targetAbs, opts) {
439
+ const preamble = opts.isGjsBundle ? (opts.envPreamble ?? '') : '';
440
+ const exec = opts.isGjsBundle ? `exec gjs -m ${shQuote(targetAbs)} "$@"` : `exec ${shQuote(targetAbs)} "$@"`;
441
+ return `#!/bin/sh\n${preamble}${exec}\n`;
442
+ }
443
+ /** True when a bin target is a GJS-runnable bundle rather than a Node script. */
444
+ export function isGjsBundlePath(target) {
445
+ return target.endsWith('.gjs.mjs') || target.endsWith('.mjs');
446
+ }
@@ -5,8 +5,14 @@ import { type Workspace } from '@gjsify/workspace';
5
5
  * v2: output ownership moved from the whole candidate dir to per-child
6
6
  * "output units" (`lib/esm` vs `lib/types`) — a v1 manifest recording `lib`
7
7
  * would restore over the sibling subtree a different script owns.
8
+ *
9
+ * v3: a `.tsbuildinfo` inside a candidate dir no longer counts as a loose file
10
+ * (see {@link outputUnits}). The six packages that emit one into `lib/` were
11
+ * still on v2's whole-dir ownership and could restore over a sibling unit; a v2
12
+ * manifest for them records `lib`, so it must not be replayed against the
13
+ * per-child units this version stores.
8
14
  */
9
- export declare const BUILD_CACHE_LAYOUT_VERSION = "v2";
15
+ export declare const BUILD_CACHE_LAYOUT_VERSION = "v3";
10
16
  /** Conventional per-package build output directories, checked in this order. */
11
17
  export declare const OUTPUT_DIR_CANDIDATES: readonly ["lib", "dist", "dist-templates"];
12
18
  /** Keep at most this many keys per package; evict the least recently stored/used. */
@@ -51,32 +57,6 @@ export declare function hashPackageInputs(location: string): string;
51
57
  * as an integrity stamp of what was stored.
52
58
  */
53
59
  export declare function hashOutputDirs(location: string, dirs: readonly string[]): string;
54
- /**
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
60
  export declare function outputUnits(location: string): string[];
81
61
  /**
82
62
  * Cheap per-unit change signature (existence + file list + sizes + mtimes).
@@ -49,18 +49,25 @@
49
49
  // or a permission error degrade to "run the script uncached" — a cache
50
50
  // hiccup must never break a build.
51
51
  import { createHash } from 'node:crypto';
52
- import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, renameSync, rmSync, symlinkSync, copyFileSync, writeFileSync, } from 'node:fs';
52
+ import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, renameSync, rmSync, copyFileSync, writeFileSync, } from 'node:fs';
53
53
  import { dirname, join } from 'node:path';
54
54
  import { fileURLToPath } from 'node:url';
55
55
  import { buildDependencyGraph } from '@gjsify/workspace';
56
+ import { replicateLinkSync } from './dir-link.js';
56
57
  /**
57
58
  * Bump to invalidate every existing cache entry (schema/semantic changes).
58
59
  *
59
60
  * v2: output ownership moved from the whole candidate dir to per-child
60
61
  * "output units" (`lib/esm` vs `lib/types`) — a v1 manifest recording `lib`
61
62
  * would restore over the sibling subtree a different script owns.
63
+ *
64
+ * v3: a `.tsbuildinfo` inside a candidate dir no longer counts as a loose file
65
+ * (see {@link outputUnits}). The six packages that emit one into `lib/` were
66
+ * still on v2's whole-dir ownership and could restore over a sibling unit; a v2
67
+ * manifest for them records `lib`, so it must not be replayed against the
68
+ * per-child units this version stores.
62
69
  */
63
- export const BUILD_CACHE_LAYOUT_VERSION = 'v2';
70
+ export const BUILD_CACHE_LAYOUT_VERSION = 'v3';
64
71
  /** Conventional per-package build output directories, checked in this order. */
65
72
  export const OUTPUT_DIR_CANDIDATES = ['lib', 'dist', 'dist-templates'];
66
73
  /** Keep at most this many keys per package; evict the least recently stored/used. */
@@ -259,7 +266,26 @@ export function hashOutputDirs(location, dirs) {
259
266
  * modifies is never recorded, so it can never be restored over (`@gjsify/tsc`'s
260
267
  * committed, no-build `lib/lib*.d.ts` are loose files → one `lib` unit → never
261
268
  * touched by any build → never captured).
269
+ *
270
+ * A `.tsbuildinfo` does NOT count as a loose file, and that exemption is
271
+ * load-bearing rather than cosmetic. It is incremental-compiler STATE, not a
272
+ * build output — nothing ships it — yet six packages point `tsBuildInfoFile`
273
+ * inside their emit dir, and its mere presence flipped `lib/` back to whole-dir
274
+ * ownership, re-creating for those packages exactly the clobber the per-child
275
+ * split above was introduced to fix. Measured on `@gjsify/semver`: `lib/`
276
+ * held `esm/`, `types/` and `tsconfig.build.tsbuildinfo`, so a `build:types`
277
+ * cache hit restored the whole `lib` and DELETED the `lib/esm` a previous step
278
+ * had just produced — leaving the Node CLI entry unloadable
279
+ * (`ERR_MODULE_NOT_FOUND: @gjsify/semver/lib/esm/index.js`) on a cold macOS
280
+ * tree, where that entry is the only usable one.
281
+ *
282
+ * Excluding it also keeps it OUT of every stored unit, which is the right
283
+ * outcome independently: a restored `.tsbuildinfo` describes an emit that did
284
+ * not happen in this tree, and tsc then treats the project as up to date,
285
+ * writes nothing and exits 0 — the silent no-op `verify-package-outputs.mjs`
286
+ * exists to catch.
262
287
  */
288
+ const isBuildInfo = (name) => name.endsWith('.tsbuildinfo');
263
289
  export function outputUnits(location) {
264
290
  const units = [];
265
291
  for (const dir of OUTPUT_DIR_CANDIDATES) {
@@ -268,7 +294,7 @@ export function outputUnits(location) {
268
294
  continue;
269
295
  let entries;
270
296
  try {
271
- entries = readdirSync(abs).filter((name) => name !== 'node_modules');
297
+ entries = readdirSync(abs).filter((name) => name !== 'node_modules' && !isBuildInfo(name));
272
298
  }
273
299
  catch {
274
300
  continue;
@@ -417,7 +443,11 @@ function copyDirRecursive(from, to) {
417
443
  const dst = join(to, entry);
418
444
  const st = lstatSync(src);
419
445
  if (st.isSymbolicLink()) {
420
- symlinkSync(readlinkSync(src), dst);
446
+ // Not `symlinkSync(readlinkSync(src), dst)`: replicating a link to a
447
+ // DIRECTORY verbatim needs a junction on Windows, and the target has
448
+ // to be resolved for it. See `dir-link.ts` — the one definition of
449
+ // that rule, added after the same mistake shipped in `dlx-cache.ts`.
450
+ replicateLinkSync(src, dst);
421
451
  }
422
452
  else if (st.isDirectory()) {
423
453
  copyDirRecursive(src, dst);
@@ -12,7 +12,21 @@ export interface DepCheck {
12
12
  /** For optional deps: which @gjsify/* packages need this lib */
13
13
  requiredBy?: string[];
14
14
  }
15
- export type PackageManager = 'apt' | 'dnf' | 'pacman' | 'zypper' | 'apk' | 'unknown';
15
+ export type PackageManager = 'apt' | 'dnf' | 'pacman' | 'zypper' | 'apk' | 'brew' | 'unknown';
16
+ /**
17
+ * The package manager whose install command we may suggest.
18
+ *
19
+ * Probe order is PLATFORM-FIRST, not a single global list. On macOS the Linux
20
+ * managers are absent, so a flat list ending in `brew` would work by accident —
21
+ * but Homebrew also runs on Linux, where it is the LAST resort rather than the
22
+ * first (a Fedora host with linuxbrew installed wants `sudo dnf install`, whose
23
+ * packages carry the distro's own GI typelibs). Keying the order on
24
+ * `process.platform` states that intent instead of leaving it to list position.
25
+ *
26
+ * Returning `unknown` is a supported answer, not a failure: `buildInstallCommand`
27
+ * then prints no command rather than a wrong one. That is the honest result on a
28
+ * MacPorts or Nix host, and it is why this must never fall back to "probably brew".
29
+ */
16
30
  export declare function detectPackageManager(): PackageManager;
17
31
  /**
18
32
  * Run all dependency checks. Used by `gjsify check` to show full system status.