@gjsify/cli 0.23.0 → 0.24.2

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.
@@ -48,6 +48,18 @@ export declare class BuildAction {
48
48
  * there is no in-bundle plugin, so this hook is the sole injection point.
49
49
  */
50
50
  private applyShebang;
51
+ /**
52
+ * Post-bundle gate for `--app gjs`: throw when the emitted bundle still
53
+ * STATICALLY imports Node builtins, which stock GJS cannot resolve (see
54
+ * `utils/gjs-bundle-guard.ts` for the full rationale).
55
+ *
56
+ * The oracle is the bundler's own module graph — each chunk's `imports`
57
+ * list — not the emitted text. Both engines report it (npm `rolldown`
58
+ * natively, `@gjsify/rolldown-native` via `synthRolldownOutput`), and it
59
+ * cannot be fooled by a `node:` specifier quoted inside a string, which a
60
+ * text scan cannot tell from a statement.
61
+ */
62
+ private assertGjsOutputLoadable;
51
63
  /**
52
64
  * Post-bundle step for a `--app browser` HTML entry: write the processed
53
65
  * `index.html` beside the JS bundle, with the entry `<script>`'s `src`
@@ -10,6 +10,7 @@ import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
10
10
  import { normalizeBundlerOptions, mergeBundlerOptions } from '../utils/normalize-bundler-options.js';
11
11
  import { inputSourceDirs, isOutdirInsideSource, libraryOutputLeakError } from '../utils/library-output.js';
12
12
  import { detectHtmlEntry, parseHtmlEntry, emitBrowserHtml, htmlOutPathFor } from '../utils/html-entry.js';
13
+ import { assertGjsBundleLoadable } from '../utils/gjs-bundle-guard.js';
13
14
  const DEFAULT_GJS_SHEBANG = '#!/usr/bin/env -S gjs -m';
14
15
  /**
15
16
  * `true` when `path` points at a location that's unsafe to use as a build
@@ -275,6 +276,21 @@ export class BuildAction {
275
276
  if (verbose)
276
277
  console.debug(`[gjsify] --shebang: wrote ${line} + chmod 0o755 to ${outfile}`);
277
278
  }
279
+ /**
280
+ * Post-bundle gate for `--app gjs`: throw when the emitted bundle still
281
+ * STATICALLY imports Node builtins, which stock GJS cannot resolve (see
282
+ * `utils/gjs-bundle-guard.ts` for the full rationale).
283
+ *
284
+ * The oracle is the bundler's own module graph — each chunk's `imports`
285
+ * list — not the emitted text. Both engines report it (npm `rolldown`
286
+ * natively, `@gjsify/rolldown-native` via `synthRolldownOutput`), and it
287
+ * cannot be fooled by a `node:` specifier quoted inside a string, which a
288
+ * text scan cannot tell from a statement.
289
+ */
290
+ assertGjsOutputLoadable(result, outfile, outdir) {
291
+ const chunks = (result.output ?? []).filter((item) => item.type === 'chunk');
292
+ assertGjsBundleLoadable(chunks, outfile ?? outdir ?? 'the GJS bundle');
293
+ }
278
294
  /**
279
295
  * Post-bundle step for a `--app browser` HTML entry: write the processed
280
296
  * `index.html` beside the JS bundle, with the entry `<script>`'s `src`
@@ -576,6 +592,10 @@ export class BuildAction {
576
592
  if ((app === 'gjs' || app === 'node') && this.configData.shebang) {
577
593
  await this.applyShebang(app, outfile, verbose);
578
594
  }
595
+ // Refuse to hand back a `--app gjs` bundle stock GJS cannot even load.
596
+ if (app === 'gjs') {
597
+ this.assertGjsOutputLoadable(writeResult, outfile, outdir);
598
+ }
579
599
  // Browser HTML entry: emit the processed `index.html` beside the bundle
580
600
  // (engine-portable post-step — the native rolldown engine has no
581
601
  // `emitFile`, so this can't live in a plugin). `outfile` is guaranteed
@@ -626,6 +646,12 @@ export class BuildAction {
626
646
  if ((app === 'gjs' || app === 'node') && this.configData.shebang) {
627
647
  await this.applyShebang(app, outfile, verbose);
628
648
  }
649
+ // No `--app gjs` loadability gate here: the watcher's
650
+ // BUNDLE_END carries the bundle, not the generated
651
+ // output, and re-running `generate()` just to read the
652
+ // chunk graph would double every rebuild. The one-shot
653
+ // build is the gate that CI and the pre-commit hook go
654
+ // through; `--watch` is a dev loop.
629
655
  }
630
656
  finally {
631
657
  await event.result.close();
@@ -50,16 +50,7 @@ async function loadNpmRolldown() {
50
50
  if (isGjs()) {
51
51
  throw new Error('gjsify build: no usable bundler engine under GJS — `@gjsify/rolldown-native` is not loadable, ' +
52
52
  'and the npm `rolldown` engine is a Rust napi crate that cannot run under GJS.\n' +
53
- 'Most likely cause: the CLI bundle was invoked DIRECTLY (`gjs -m …/cli.gjs.mjs` or by executing ' +
54
- 'dist/cli.gjs.mjs), which bypasses the `gjsify` launcher that exports GI_TYPELIB_PATH / ' +
55
- 'LD_LIBRARY_PATH for the prebuilds. The typelib lookup happens inside the GJS runtime, so those ' +
56
- 'must be set BEFORE the process starts — the CLI cannot repair it from the inside. ' +
57
- 'Run through the `gjsify` bin instead (`node_modules/.bin/gjsify …`, or the globally installed shim), ' +
58
- 'or export the env yourself:\n' +
59
- ' P=<repo>/packages/infra/rolldown-native/prebuilds/linux-x86_64\n' +
60
- ' GI_TYPELIB_PATH=$P LD_LIBRARY_PATH=$P gjs -m …/cli.gjs.mjs build …\n' +
61
- 'Other causes: the JS facade `lib/` is not built (`gjsify workspace @gjsify/rolldown-native build` ' +
62
- 'under Node), or no prebuild exists for this architecture. Running the build under Node also works.');
53
+ diagnoseNativeEngine());
63
54
  }
64
55
  // Indirect specifier so Rolldown's static-analysis doesn't try to
65
56
  // bundle the npm crate into a GJS target build.
@@ -68,6 +59,58 @@ async function loadNpmRolldown() {
68
59
  const mod = (await import(/* @vite-ignore */ target));
69
60
  return mod.rolldown;
70
61
  }
62
+ /**
63
+ * Say WHY the native engine could not be loaded, read off disk rather than
64
+ * guessed.
65
+ *
66
+ * The three causes need three different fixes, and this message used to assert
67
+ * the rarest of them ("the CLI bundle was invoked DIRECTLY … bypasses the
68
+ * `gjsify` launcher") as the "most likely cause". On the by-far most common
69
+ * one — a freshly cloned + freshly installed tree, where the engine's JS facade
70
+ * has simply never been built — that reading is not merely unhelpful, it points
71
+ * at a launcher that was in fact used correctly, so the reader goes looking for
72
+ * an env-var problem that does not exist. `scripts/verify-committed-bundles.mjs`
73
+ * hit exactly that and it blocked releases.
74
+ *
75
+ * The probe is `existsSync`-only (no import, no resolution) so it cannot itself
76
+ * throw or recurse into the failure being explained.
77
+ */
78
+ function diagnoseNativeEngine() {
79
+ let pkgDir = null;
80
+ try {
81
+ const bundleDir = path.dirname(new URL(import.meta.url).pathname);
82
+ pkgDir = findRolldownNativeDir(process.cwd(), bundleDir);
83
+ }
84
+ catch {
85
+ pkgDir = null;
86
+ }
87
+ if (pkgDir === null) {
88
+ return ('`@gjsify/rolldown-native` is NOT INSTALLED — no node_modules/@gjsify/rolldown-native was found ' +
89
+ `walking up from ${process.cwd()} or from the CLI bundle's own directory.\n` +
90
+ 'Install it: `gjsify install @gjsify/rolldown-native` (in the gjsify repo: `gjsify install`). ' +
91
+ 'Running the build under Node also works — there the npm `rolldown` crate is used instead.');
92
+ }
93
+ const facade = path.join(pkgDir, 'lib', 'esm', 'index.js');
94
+ if (!existsSync(facade)) {
95
+ return (`The engine's JS facade is NOT BUILT: ${facade} does not exist.\n` +
96
+ 'It is a BUILD OUTPUT, not something `gjsify install` produces — so a freshly cloned or freshly ' +
97
+ 'installed tree never has it, and every `gjsify build` under GJS fails here until it is built once. ' +
98
+ 'Building it needs a bundler, which is why it cannot bootstrap itself under GJS; the Node CLI entry ' +
99
+ 'does it instead. From the gjsify repo root:\n' +
100
+ ' gjsify run build:infra # the full cold-tree chain (recommended)\n' +
101
+ ' node scripts/bootstrap-native-facades.mjs # just the facades, if the rest is already built\n' +
102
+ 'Both are idempotent and skip already-built facades in seconds.');
103
+ }
104
+ return ('The JS facade is built, so this is the typelib/prebuild lookup failing. Most likely the CLI bundle was ' +
105
+ 'invoked DIRECTLY (`gjs -m …/cli.gjs.mjs`, or by executing dist/cli.gjs.mjs), which bypasses the `gjsify` ' +
106
+ 'launcher that exports GI_TYPELIB_PATH / LD_LIBRARY_PATH for the prebuilds. The typelib lookup happens ' +
107
+ 'inside the GJS runtime, so those must be set BEFORE the process starts — the CLI cannot repair it from ' +
108
+ 'the inside. Run through the `gjsify` bin instead (`node_modules/.bin/gjsify …`, or the globally installed ' +
109
+ 'shim), or export the env yourself:\n' +
110
+ ` P=${path.join(pkgDir, 'prebuilds', `${process.platform ?? 'linux'}-${process.arch ?? 'x64'}`)}\n` +
111
+ ' GI_TYPELIB_PATH=$P LD_LIBRARY_PATH=$P gjs -m …/cli.gjs.mjs build …\n' +
112
+ 'Otherwise: no prebuild exists for this architecture. Running the build under Node also works.');
113
+ }
71
114
  /**
72
115
  * Convert a bare npm specifier into something dynamic `import(...)` can
73
116
  * load regardless of host runtime:
@@ -295,9 +338,7 @@ async function tryLoadNative() {
295
338
  return null;
296
339
  }
297
340
  })();
298
- const resolved = resolvedFromNpm ??
299
- resolvedFromFs ??
300
- createRequire(import.meta.url).resolve(specifier);
341
+ const resolved = resolvedFromNpm ?? resolvedFromFs ?? createRequire(import.meta.url).resolve(specifier);
301
342
  target = pathToFileURL(resolved).href;
302
343
  }
303
344
  const mod = (await import(/* @vite-ignore */ target));
@@ -45,6 +45,38 @@
45
45
  // --format=globs one `@gjsify/<name>` per line
46
46
  // --format=github-actions $GITHUB_OUTPUT key=value lines
47
47
  //
48
+ // `include-args` CONTRACT (github-actions format) — read this before changing
49
+ // its shape. The value is a SPACE-SEPARATED list of ALREADY-SHELL-SAFE tokens:
50
+ //
51
+ // include-args=--include @gjsify/fs --include @gjsify/stream
52
+ //
53
+ // and its only supported consumption is an UNQUOTED `$INCLUDE_ARGS` expansion
54
+ // in a POSIX shell, where word splitting is exactly the intent. It carries NO
55
+ // quoting of its own, deliberately:
56
+ //
57
+ // * `main.yml` splices it into a `su testuser -c "… sh -c '…'"` nesting, and
58
+ // the result of a parameter expansion is NEVER re-scanned for quotes. When
59
+ // this emitted `--include '@gjsify/fs'`, the apostrophes became part of the
60
+ // glob, every pattern matched zero workspaces, and `foreach` exited 0 —
61
+ // CI "built" nothing on every selective PR run in 0.65 s (repo task #75).
62
+ // Pre-quoting a value for an unknown number of nesting levels cannot work;
63
+ // emitting tokens that need no quoting at any level can.
64
+ // * There are FIVE consumers of this one output — the build / browser-bundle
65
+ // / check / test steps splice it into a command line, and thirteen
66
+ // `contains(needs.changes.outputs.include-args, '@gjsify/…')` GitHub
67
+ // expressions substring-match it. A format that requires per-consumer
68
+ // unquoting would have to be repeated five times in the very nesting that
69
+ // produced the bug; `contains()` is unaffected either way (it matched the
70
+ // quoted spelling too, which is why the gates kept working while the build
71
+ // silently did nothing).
72
+ //
73
+ // Shell-safety is ENFORCED, not assumed: `assertShellSafeWorkspaceName` rejects
74
+ // any workspace name outside `[A-Za-z0-9@._/-]` before it is emitted. npm names
75
+ // cannot contain those characters, so this never fires in practice — and if it
76
+ // somehow does, the command exits non-zero, the classify step's
77
+ // `continue-on-error` leaves the outputs unset, and CI falls back to a FULL
78
+ // run. Failing towards "run everything" is the safe direction.
79
+ //
48
80
  // `--changed-from-stdin` skips `git diff` entirely and reads a newline-
49
81
  // separated list of paths from stdin. Useful for local debugging and
50
82
  // for the spec suite. Stdin is slurped via `utils/stdin.ts`, NOT
@@ -52,6 +84,15 @@
52
84
  // idiom opens the relative file `./0` and the committed GJS classifier
53
85
  // bundle died with `ENOENT … read '0'` on every `--changed-from-stdin` run.
54
86
  import { spawnSync } from 'node:child_process';
87
+ // STATIC import, never a lazy `require('node:fs')`. This package is ESM
88
+ // (`"type": "module"`), so a bare `require` is a ReferenceError the moment the
89
+ // line executes — and the only line that executes it sits behind
90
+ // `if (process.env.GITHUB_OUTPUT)`, i.e. exclusively inside a GitHub Actions
91
+ // step. That is why it survived from the command's first commit: every local
92
+ // run and every spec took the stdout branch. There is no reason for laziness
93
+ // here — the command already statically imports `node:child_process`, and the
94
+ // classifier bundle resolves `node:fs` through `@gjsify/fs` like any other.
95
+ import { appendFileSync } from 'node:fs';
55
96
  import { readStdinLines } from '../utils/stdin.js';
56
97
  import { discoverWorkspaces, buildDependencyGraph, buildReverseDependencyGraph, affectedClosure, workspacesForChangedFiles, } from '@gjsify/workspace';
57
98
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
@@ -318,9 +359,11 @@ function emit(format, r) {
318
359
  return;
319
360
  }
320
361
  if (format === 'github-actions') {
362
+ // Unquoted, space-separated tokens — see the `include-args` CONTRACT
363
+ // block at the top of this file. Do NOT re-add quoting here.
321
364
  const includeArgs = r.global
322
365
  ? ''
323
- : r.workspaces.map((n) => `--include '${escSingleQuote(n)}'`).join(' ');
366
+ : r.workspaces.map((n) => `--include ${assertShellSafeWorkspaceName(n)}`).join(' ');
324
367
  const out = process.env.GITHUB_OUTPUT;
325
368
  const lines = [
326
369
  `skip-all=${r.skipAll}`,
@@ -332,7 +375,6 @@ function emit(format, r) {
332
375
  ];
333
376
  if (out) {
334
377
  // GitHub Actions: append to $GITHUB_OUTPUT.
335
- const { appendFileSync } = require('node:fs');
336
378
  for (const l of lines)
337
379
  appendFileSync(out, `${l}\n`);
338
380
  }
@@ -353,6 +395,22 @@ function emit(format, r) {
353
395
  for (const name of r.workspaces)
354
396
  process.stdout.write(` - ${name}\n`);
355
397
  }
356
- function escSingleQuote(s) {
357
- return s.replace(/'/g, `'\\''`);
398
+ // A workspace name that survives an UNQUOTED shell expansion unchanged: no
399
+ // whitespace, no quote/escape characters, no glob metacharacters, no `$`, and
400
+ // nothing the shell would treat as an operator. npm package names are a strict
401
+ // subset of this, so the guard is a machine-checked restatement of the
402
+ // `include-args` contract rather than a real runtime branch. Returns the name
403
+ // so it can be used inline at the emit site.
404
+ const SHELL_SAFE_WORKSPACE_NAME = /^[A-Za-z0-9@][A-Za-z0-9@._/-]*$/;
405
+ function assertShellSafeWorkspaceName(name) {
406
+ if (SHELL_SAFE_WORKSPACE_NAME.test(name))
407
+ return name;
408
+ process.stderr.write(`gjsify affected: workspace name ${JSON.stringify(name)} is not shell-safe, so it cannot be emitted as an\n` +
409
+ ` \`include-args\` token (the value is consumed by an UNQUOTED $INCLUDE_ARGS expansion — see the\n` +
410
+ ` contract at the top of commands/affected.ts). Rename the workspace, or teach this command a\n` +
411
+ ` transport the consumers can unquote. Refusing to emit a filter that would silently mis-match.\n`);
412
+ process.exit(2);
413
+ // Unreachable — `process.exit` terminates. Present so this function has no
414
+ // fall-through path that could ever return an unsafe name.
415
+ throw new Error(`gjsify affected: unsafe workspace name ${name}`);
358
416
  }
@@ -16,6 +16,21 @@ export interface EnsureOpts {
16
16
  registry?: string;
17
17
  cacheMaxAge: number;
18
18
  frozen: boolean;
19
+ /**
20
+ * Extra specs to install into the SAME cache tree as the requested package.
21
+ *
22
+ * A dlx tree is the package plus its own `dependencies`, which is right for
23
+ * the GJS path — the showcase's `--app gjs` bundle needs nothing else. The
24
+ * `--app node` bundle does: its `gi://` imports resolve through
25
+ * `@gjsify/node-gi`, which is a build-time concern for the showcase (a
26
+ * devDependency) but a RUNTIME one for whoever launches it off GJS. Nobody
27
+ * can act on the resulting "add @gjsify/node-gi as a dependency" error —
28
+ * the tree belongs to the cache, not to the user — so the launcher that
29
+ * knows the runtime supplies it.
30
+ *
31
+ * Part of the cache key, so the gjs and node trees never share an entry.
32
+ */
33
+ extraSpecs?: readonly string[];
19
34
  }
20
35
  /**
21
36
  * Resolve (installing + caching when remote) the on-disk directory of a package
@@ -106,7 +106,8 @@ export async function ensurePkgDir(parsed, opts) {
106
106
  if (parsed.kind === 'local') {
107
107
  return { pkgDir: parsed.path, cachedPkgName: null };
108
108
  }
109
- const cacheKey = createCacheKey({ packages: [parsed.spec] });
109
+ const specs = [parsed.spec, ...(opts.extraSpecs ?? [])];
110
+ const cacheKey = createCacheKey({ packages: specs });
110
111
  const cacheDir = cacheDirFor(cacheKey);
111
112
  const cached = opts.cacheMaxAge > 0 ? getValidCachedPkg(cacheDir, opts.cacheMaxAge) : undefined;
112
113
  if (cached) {
@@ -124,7 +125,7 @@ export async function ensurePkgDir(parsed, opts) {
124
125
  const progress = makeProgressReporter({ enabled: !opts.verbose });
125
126
  await installPackages({
126
127
  prefix: prepareDir,
127
- specs: [parsed.spec],
128
+ specs,
128
129
  verbose: opts.verbose,
129
130
  registry: opts.registry,
130
131
  // Cache-prepare dirs are scoped per cache key, so writing a lockfile
@@ -208,7 +208,7 @@ export const foreachCommand = {
208
208
  default: false,
209
209
  })
210
210
  .option('include', {
211
- description: 'Glob pattern to include workspaces by name (repeatable).',
211
+ description: 'Glob pattern to include workspaces by name (repeatable). A pattern that matches NO discovered workspace is a hard error — a filter selecting nothing is a typo or a caller-side quoting bug, never an intent.',
212
212
  type: 'string',
213
213
  array: true,
214
214
  })
@@ -295,6 +295,7 @@ export const foreachCommand = {
295
295
  process.exit(1);
296
296
  }
297
297
  }
298
+ assertEveryIncludeMatches(allWorkspaces, args.include);
298
299
  let selected = filterWorkspaces(allWorkspaces, {
299
300
  include: args.include,
300
301
  exclude: args.exclude,
@@ -344,6 +345,12 @@ export const foreachCommand = {
344
345
  }
345
346
  }
346
347
  if (selected.length === 0) {
348
+ // Reaching here means every --include pattern DID match something
349
+ // (assertEveryIncludeMatches above is fatal otherwise) and the set
350
+ // was then narrowed to nothing by --exclude, --no-private, the
351
+ // script-presence filter, or --shard. All four are legitimate, so
352
+ // this stays a clean exit-0 no-op — see the assert's doc comment
353
+ // for why the two cases are not the same condition.
347
354
  console.log(`gjsify foreach: no workspaces match (${exec ? 'exec' : 'script'}="${cmd}", include=${JSON.stringify(args.include ?? [])}, exclude=${JSON.stringify(args.exclude ?? [])})`);
348
355
  return;
349
356
  }
@@ -410,6 +417,69 @@ export const foreachCommand = {
410
417
  process.exit(0);
411
418
  },
412
419
  };
420
+ /**
421
+ * A `--include` pattern that matches ZERO of the discovered workspaces is a
422
+ * hard error. This is the load-bearing half of the fix for repo task #75: the
423
+ * CI classifier emitted its filter pre-quoted (`--include '@gjsify/fs'`), the
424
+ * workflow expanded it unquoted inside a `su … -c "… sh -c '…'"` nesting, the
425
+ * apostrophes became part of every glob, and this command matched nothing,
426
+ * printed one line, and exited 0. A build that did nothing was indistinguishable
427
+ * from a build that succeeded, so the bug lived through every selective PR run
428
+ * — the Build step took 0.65 s and every later step inspected whatever the
429
+ * cache had restored. The quoting was the defect; the SILENCE is why nobody saw
430
+ * it.
431
+ *
432
+ * Scope is deliberately "an include PATTERN matched no workspace", NOT "the
433
+ * final selection is empty". The final selection is legitimately empty in
434
+ * several supported situations and must stay exit-0:
435
+ * * `--shard 3/4` over a 2-workspace closure — most shards run nothing;
436
+ * * `--exclude '@gjsify/integration-*'` removing everything the include kept;
437
+ * * script mode dropping workspaces that don't declare `<script>` (yarn does
438
+ * this too, and it is why `foreach check` over a closure of script-less
439
+ * packages is a no-op rather than a failure).
440
+ * All three describe a real, existing set being narrowed to nothing. A pattern
441
+ * that names something the workspace tree does not contain is different in
442
+ * kind: it is a typo, a stale package name, or a caller-side quoting bug, and
443
+ * it can never be what the caller meant. There is no opt-out flag — an escape
444
+ * hatch here would be re-arming exactly the silence this exists to remove.
445
+ */
446
+ function assertEveryIncludeMatches(allWorkspaces, include) {
447
+ if (!include || include.length === 0)
448
+ return;
449
+ const unmatched = include.filter((pattern) => filterWorkspaces(allWorkspaces, { include: [pattern] }).length === 0);
450
+ if (unmatched.length === 0)
451
+ return;
452
+ const lines = [
453
+ `gjsify foreach: --include matched no workspace (${unmatched.length} of ${include.length} pattern(s)):`,
454
+ ];
455
+ for (const pattern of unmatched) {
456
+ lines.push(` ${JSON.stringify(pattern)}`);
457
+ // The task-#75 signature: the pattern only fails because the caller's
458
+ // shell quotes ended up INSIDE the value. Name it outright — that
459
+ // failure reads as "package not found" and sends people hunting in the
460
+ // wrong place.
461
+ const unquoted = stripSurroundingQuotes(pattern);
462
+ if (unquoted !== pattern && filterWorkspaces(allWorkspaces, { include: [unquoted] }).length > 0) {
463
+ lines.push(` → matches as ${JSON.stringify(unquoted)}: the QUOTES are part of the value. A shell expansion`, ` is not re-scanned for quoting, so a pre-quoted filter spliced through \`sh -c\` arrives with`, ` the quotes attached. Pass the bare name and let word splitting do the work.`);
464
+ continue;
465
+ }
466
+ const near = allWorkspaces
467
+ .map((w) => w.name)
468
+ .filter((name) => name.toLowerCase().includes(unquoted.replace(/\*/g, '').toLowerCase()))
469
+ .slice(0, 3);
470
+ if (near.length > 0)
471
+ lines.push(` → did you mean: ${near.join(', ')}?`);
472
+ }
473
+ lines.push(` ${allWorkspaces.length} workspace(s) discovered. Refusing to run: a filter that selects nothing would`, ` otherwise exit 0 and look exactly like a successful run over the whole set.`);
474
+ console.error(lines.join('\n'));
475
+ process.exit(1);
476
+ }
477
+ /** Drop ONE matching pair of surrounding `'` or `"` — the quoting-bug probe. */
478
+ function stripSurroundingQuotes(s) {
479
+ if (s.length >= 2 && (s[0] === "'" || s[0] === '"') && s[s.length - 1] === s[0])
480
+ return s.slice(1, -1);
481
+ return s;
482
+ }
413
483
  async function runSequential(workspaces, script, args, verbose, exec, cache) {
414
484
  for (const ws of workspaces) {
415
485
  await runOne(ws, script, args, /* prefixOutput */ false, verbose, exec, cache);
@@ -14,7 +14,7 @@ interface InstallOptions {
14
14
  timeout: number;
15
15
  }
16
16
  export declare const installCommand: Command<unknown, InstallOptions>;
17
- export declare function buildBinShim(wsLocation: string, nodeTarget?: string, gjsTarget?: string, nativePrebuildDirs?: string[], platform?: string): string;
17
+ export declare function buildBinShim(wsLocation: string, nodeTarget?: string, gjsTarget?: string, nativePrebuildDirs?: string[], platform?: string, scanRoot?: string): string;
18
18
  /**
19
19
  * `.cmd` / `.ps1` companions for {@link buildBinShim}.
20
20
  *
@@ -34,8 +34,8 @@ import { spawnSync } from 'node:child_process';
34
34
  import { gjsExit } from '@gjsify/rolldown-plugin-gjsify/runtime';
35
35
  import { discoverWorkspaces } from '@gjsify/workspace';
36
36
  import { buildInstallCommand, detectPackageManager, runMinimalChecks } from '../utils/check-system-deps.js';
37
- import { detectNativePackages, libraryPathVar } from '../utils/detect-native-packages.js';
38
- import { buildLauncherShims } from '../utils/bin-shim.js';
37
+ import { detectNativePackages } from '../utils/detect-native-packages.js';
38
+ import { buildLauncherShims, buildNativeEnvPreamble } from '../utils/bin-shim.js';
39
39
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
40
40
  import { atomicWriteStrict } from '../utils/install-cache-fs.js';
41
41
  import { acquireInstallLock } from '../utils/install-lock.js';
@@ -205,10 +205,26 @@ export const installCommand = {
205
205
  const hardExitTimerId = overallTimeoutMs > 0
206
206
  ? setTimeout(() => {
207
207
  const secs = Math.round(overallTimeoutMs / 100) / 10;
208
- console.error(`gjsify install: still stuck ${Math.round(HARD_EXIT_GRACE_MS / 1000)}s after the ${secs}s ` +
209
- `budget elapsed and the in-flight aborts — forcing exit. A dependency's extract or ` +
210
- `link step wedged (typically a dropped Gio stream event under GJS). Re-run; if it ` +
211
- `persists, raise --timeout or file an issue.`);
208
+ // Do NOT assert a cause here — this timer cannot tell the
209
+ // two shapes apart, and claiming the rarer one sends the
210
+ // next reader after a bug that isn't there. Measured on a
211
+ // cold tree of this workspace: 1597 packages / ~4.8 GB
212
+ // extracted, of which ~3.4 GB are foreign-platform
213
+ // optional deps (`os`/`cpu` excludes the host) that npm
214
+ // would never place. That install was still making steady
215
+ // progress when the 30-min budget elapsed, and a re-run
216
+ // finished the remainder in ~2 min. Extraction also has
217
+ // multi-second SYNCHRONOUS stretches (tar parse + file
218
+ // writes) and 16 run concurrently, so the clean abort path
219
+ // can easily miss the grace window with nothing wedged.
220
+ console.error(`gjsify install: the ${secs}s budget elapsed and the abort did not unwind within ` +
221
+ `${Math.round(HARD_EXIT_GRACE_MS / 1000)}s — forcing exit. That means one of two ` +
222
+ `things, and this timer cannot distinguish them: the install was still WORKING ` +
223
+ `(a cold tree here extracts several GB, and extraction blocks the loop in ` +
224
+ `multi-second synchronous stretches), or a step genuinely wedged. Re-running tells ` +
225
+ `you which, and is safe: extraction is idempotent, every package already on disk ` +
226
+ `is skipped, so a second run finishes the remainder in a fraction of the time. A ` +
227
+ `re-run that makes NO further progress is a real wedge — file an issue.`);
212
228
  forceExit(1);
213
229
  }, overallTimeoutMs + HARD_EXIT_GRACE_MS)
214
230
  : null;
@@ -941,9 +957,7 @@ function writeWorkspaceBinShims(cwd, workspaces) {
941
957
  /* fine */
942
958
  }
943
959
  }
944
- writeFileSync(linkPath, buildBinShim(ws.location, nodeTarget, gjsTarget, nativePrebuildDirs), {
945
- mode: 0o755,
946
- });
960
+ writeFileSync(linkPath, buildBinShim(ws.location, nodeTarget, gjsTarget, nativePrebuildDirs, process.platform, cwd), { mode: 0o755 });
947
961
  chmodSync(linkPath, 0o755);
948
962
  // Windows executes neither an extension-less file nor a `#!` line —
949
963
  // the sh shim above is only reachable from git-bash/MSYS/WSL. Write
@@ -1029,26 +1043,19 @@ function scopedOverrideFor(ws, depName, extracted) {
1029
1043
  }
1030
1044
  return undefined;
1031
1045
  }
1032
- export function buildBinShim(wsLocation, nodeTarget, gjsTarget, nativePrebuildDirs = [], platform = process.platform) {
1046
+ export function buildBinShim(wsLocation, nodeTarget, gjsTarget, nativePrebuildDirs = [], platform = process.platform, scanRoot = wsLocation) {
1033
1047
  const nodeAbs = nodeTarget ? join(wsLocation, nodeTarget) : null;
1034
1048
  const gjsAbs = gjsTarget ? join(wsLocation, gjsTarget) : null;
1035
1049
  // GJS-only env preamble — Node ignores GI_TYPELIB_PATH so we scope the
1036
1050
  // export to the gjs branch, keeping the shim minimal when no native pkgs
1037
1051
  // exist or only the Node bin is in play.
1038
1052
  //
1039
- // The library-path variable is host-dependent: `dyld` on macOS never reads
1040
- // LD_LIBRARY_PATH, so exporting it there silently produced a launcher that
1041
- // could not load a single `darwin-arm64` prebuild. `libraryPathVar()` is the
1042
- // one place that mapping lives.
1043
- const { name: libVar } = libraryPathVar(platform);
1044
- const gjsPreamble = nativePrebuildDirs.length === 0
1045
- ? ''
1046
- : (() => {
1047
- const joined = `'${nativePrebuildDirs.join(':').replace(/'/g, `'\\''`)}'`;
1048
- return (`GI_TYPELIB_PATH=${joined}\${GI_TYPELIB_PATH:+":$GI_TYPELIB_PATH"}\n` +
1049
- `${libVar}=${joined}\${${libVar}:+":$${libVar}"}\n` +
1050
- `export GI_TYPELIB_PATH ${libVar}\n`);
1051
- })();
1053
+ // `scanRoot` is the INSTALL prefix (the tree holding `node_modules/.bin`),
1054
+ // not the workspace package dir: the preamble globs its `node_modules` at
1055
+ // launch time instead of embedding what was installed the day the shim was
1056
+ // written, so `gjsify install <some>-native` afterwards is picked up without
1057
+ // a re-link. See `buildNativeEnvPreamble`.
1058
+ const gjsPreamble = buildNativeEnvPreamble(scanRoot, nativePrebuildDirs, { platform });
1052
1059
  if (nodeAbs && gjsAbs) {
1053
1060
  // GJS-FIRST: prefer the committed Node-free GJS bundle when `gjs` is on
1054
1061
  // PATH AND the bundle exists; fall back to the Node entry otherwise (a