@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
@@ -8,7 +8,7 @@
8
8
  // Output is line-prefixed `[<workspace-name>]` when --parallel is set,
9
9
  // matching yarn's interactive flow. Exit code is non-zero if any child
10
10
  // process failed; first failure's stderr is forwarded.
11
- import { spawn } from 'node:child_process';
11
+ import { spawnToCompletion } from '../utils/spawn.js';
12
12
  import { readFileSync, readdirSync } from 'node:fs';
13
13
  import { cpus } from 'node:os';
14
14
  import { buildDependencyGraph, discoverWorkspaces, filterWorkspaces, topologicalSort, } from '@gjsify/workspace';
@@ -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);
@@ -602,47 +672,54 @@ function detectPackageManager() {
602
672
  return 'gjsify';
603
673
  return 'npm';
604
674
  }
605
- function spawnPrefixed(cmd, args, cwd, prefix) {
606
- // Default FORCE_COLOR=1 unless the user explicitly opted out, so tools
607
- // that key on `process.stdout.isTTY` (chalk, picocolors, …) still emit
608
- // ANSI colors when run under gjsify foreach. Mirrors yarn / npm.
609
- const colorEnv = process.env.FORCE_COLOR !== undefined || process.env.NO_COLOR !== undefined ? {} : { FORCE_COLOR: '1' };
610
- return new Promise((resolve, reject) => {
611
- const child = spawn(cmd, args, {
675
+ async function spawnPrefixed(cmd, args, cwd, prefix) {
676
+ // `completion: 'exit'` — every path out of this command's handler ends in
677
+ // `process.exit(…)` (see the handler's tail), which is what quits the GLib
678
+ // main loop `spawn()` arms under GJS. That keeps foreach on the STREAMING
679
+ // async path: live prefixed output and real parallelism, both of which the
680
+ // blocking path would destroy. See utils/spawn.ts for the full contract.
681
+ let tracked = null;
682
+ const flushers = [];
683
+ try {
684
+ const result = await spawnToCompletion(cmd, args, {
685
+ completion: 'exit',
612
686
  cwd,
613
- stdio: prefix ? ['ignore', 'pipe', 'pipe'] : 'inherit',
614
- env: { ...process.env, ...colorEnv },
687
+ stdio: prefix ? 'pipe' : 'inherit',
688
+ // Default FORCE_COLOR=1 unless the user explicitly opted out, so
689
+ // tools that key on `process.stdout.isTTY` (chalk, picocolors, …)
690
+ // still emit ANSI colors under gjsify foreach. Mirrors yarn / npm.
691
+ color: true,
692
+ onSpawn: (child) => {
693
+ tracked = child;
694
+ activeChildren.add(child);
695
+ // Under GJS, `process.stdout.write` is a BLOCKING
696
+ // `Gio.write_all`. Writing each prefixed line LIVE during a
697
+ // PARALLEL foreach to a backpressuring pipe (a CI log
698
+ // collector that drains slowly) stalls the single GLib main
699
+ // loop on a full pipe → every parallel child's pipe backs up →
700
+ // their reads stall → the whole run HANGS. (A tty or a file
701
+ // sink never backpressures, which is why it only bites in CI.)
702
+ // On a NON-tty sink we therefore BUFFER each child's prefixed
703
+ // output and flush it as ONE write when that child closes: the
704
+ // child is already done by then, so its own read can't stall,
705
+ // and concurrent flushes serialize into brief loop stalls
706
+ // instead of a deadlock. On a tty (interactive) we keep live
707
+ // line-prefixing for responsive output.
708
+ const buffered = !process.stdout.isTTY;
709
+ if (prefix && child.stdout && child.stderr) {
710
+ flushers.push(prefixLines(child.stdout, process.stdout, prefix, buffered));
711
+ flushers.push(prefixLines(child.stderr, process.stderr, prefix, buffered));
712
+ }
713
+ },
615
714
  });
616
- activeChildren.add(child);
617
- // Under GJS, `process.stdout.write` is a BLOCKING `Gio.write_all`.
618
- // Writing each prefixed line LIVE during a PARALLEL foreach to a
619
- // backpressuring pipe (a CI log collector that drains slowly) stalls
620
- // the single GLib main loop on a full pipe → every parallel child's
621
- // pipe backs up → their reads stall → the whole run HANGS. (A tty or a
622
- // file sink never backpressures, which is why it only bites in CI.)
623
- // On a NON-tty sink we therefore BUFFER each child's prefixed output
624
- // and flush it as ONE write when that child closes: the child is
625
- // already done by then, so its own read can't stall, and concurrent
626
- // flushes serialize into brief loop stalls instead of a deadlock. On a
627
- // tty (interactive) we keep live line-prefixing for responsive output.
628
- const buffered = !process.stdout.isTTY;
629
- const flushers = [];
630
- if (prefix && child.stdout && child.stderr) {
631
- flushers.push(prefixLines(child.stdout, process.stdout, prefix, buffered));
632
- flushers.push(prefixLines(child.stderr, process.stderr, prefix, buffered));
715
+ for (const flush of flushers)
716
+ flush();
717
+ if (result.code !== 0) {
718
+ throw new Error(`${cmd} ${args.join(' ')} exited with code ${result.code}`);
633
719
  }
634
- child.on('close', (code) => {
635
- activeChildren.delete(child);
636
- for (const flush of flushers)
637
- flush();
638
- if (code === 0)
639
- resolve();
640
- else
641
- reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
642
- });
643
- child.on('error', (err) => {
644
- activeChildren.delete(child);
645
- reject(err);
646
- });
647
- });
720
+ }
721
+ finally {
722
+ if (tracked)
723
+ activeChildren.delete(tracked);
724
+ }
648
725
  }
@@ -20,11 +20,17 @@ export const infoCommand = {
20
20
  const cwd = process.cwd();
21
21
  const file = args.file ? resolve(args.file) : null;
22
22
  const nativePackages = detectNativePackages(cwd);
23
- const { LD_LIBRARY_PATH, GI_TYPELIB_PATH } = buildNativeEnv(nativePackages);
23
+ // Which variables this yields is host-dependent: GI_TYPELIB_PATH
24
+ // everywhere, plus the loader path variable the host actually consults
25
+ // (LD_LIBRARY_PATH on Linux, DYLD_LIBRARY_PATH on macOS, PATH on
26
+ // Windows). Iterate rather than destructuring two fixed names, or the
27
+ // printed environment is wrong off Linux.
28
+ const nativeEnv = buildNativeEnv(nativePackages);
29
+ const envEntries = Object.entries(nativeEnv).filter(([, value]) => value !== undefined);
24
30
  if (args.export) {
25
31
  // Machine-readable output for eval
26
- console.log(`export LD_LIBRARY_PATH="${LD_LIBRARY_PATH}"`);
27
- console.log(`export GI_TYPELIB_PATH="${GI_TYPELIB_PATH}"`);
32
+ for (const [key, value] of envEntries)
33
+ console.log(`export ${key}="${value}"`);
28
34
  return;
29
35
  }
30
36
  // Human-readable output
@@ -39,8 +45,8 @@ export const infoCommand = {
39
45
  }
40
46
  console.log('');
41
47
  console.log('To run your app directly with gjs, set:');
42
- console.log(` export LD_LIBRARY_PATH="${LD_LIBRARY_PATH}"`);
43
- console.log(` export GI_TYPELIB_PATH="${GI_TYPELIB_PATH}"`);
48
+ for (const [key, value] of envEntries)
49
+ console.log(` export ${key}="${value}"`);
44
50
  if (file) {
45
51
  console.log(` gjs -m ${file}`);
46
52
  }
@@ -14,4 +14,20 @@ 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;
18
+ /**
19
+ * `.cmd` / `.ps1` companions for {@link buildBinShim}.
20
+ *
21
+ * The `sh` shim's gjs-first / node-fallback probe has no Windows counterpart to
22
+ * express: there is no GJS host on Windows at all (no prebuilt `libgjs` — see
23
+ * `website/src/content/docs/platform-support.md`), so a workspace that ships
24
+ * both targets resolves to Node here, and a GJS-only workspace gets an honest
25
+ * `gjs`-invoking shim that fails exactly the way the `sh` one does.
26
+ *
27
+ * Exported + platform-free so it is unit-tested from Linux.
28
+ */
29
+ export declare function buildWorkspaceBinShimsForWindows(wsLocation: string, nodeTarget?: string, gjsTarget?: string, nativePrebuildDirs?: string[]): {
30
+ cmd: string;
31
+ ps1: string;
32
+ };
17
33
  export {};
@@ -30,18 +30,40 @@
30
30
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
31
31
  import { dirname, join, relative, resolve } from 'node:path';
32
32
  import { fileURLToPath } from 'node:url';
33
- import { spawn, spawnSync } from 'node:child_process';
33
+ 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 } from '../utils/detect-native-packages.js';
37
+ import { detectNativePackages, libraryPathVar } from '../utils/detect-native-packages.js';
38
+ import { buildLauncherShims } from '../utils/bin-shim.js';
38
39
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
39
40
  import { atomicWriteStrict } from '../utils/install-cache-fs.js';
40
41
  import { acquireInstallLock } from '../utils/install-lock.js';
41
42
  import { nodeBinary } from '../utils/run-node.js';
43
+ import { spawnToCompletion } from '../utils/spawn.js';
42
44
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
43
45
  import { binDirOnPath, defaultGlobalLayout, installGjsEnginePackages, linkGlobalBins, specToPackageName, } from '../utils/install-global.js';
44
46
  import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFromPackageJson, readPackageJson, writePackageJson, } from '../utils/pkg-json-edit.js';
47
+ /**
48
+ * Link type for the workspace↔workspace directory links.
49
+ *
50
+ * Windows cannot create a *directory symlink* without elevation or Developer
51
+ * Mode (`EPERM`), but any user can create an NTFS **junction** — which is why
52
+ * npm and yarn both use junctions for workspace links. These two call sites had
53
+ * no try/catch, so a non-elevated Windows workspace install hard-failed here.
54
+ */
55
+ const WORKSPACE_LINK_TYPE = process.platform === 'win32' ? 'junction' : undefined;
56
+ /**
57
+ * The `target` argument for a workspace link.
58
+ *
59
+ * Node normalises a `'junction'` target with `path.resolve()` — i.e. against
60
+ * the process CWD, NOT the link's own directory — so a junction MUST be given
61
+ * an absolute path. POSIX symlinks stay relative so the tree survives being
62
+ * moved.
63
+ */
64
+ function workspaceLinkTarget(linkPath, absTarget) {
65
+ return WORKSPACE_LINK_TYPE === 'junction' ? resolve(absTarget) : relative(dirname(linkPath), absTarget);
66
+ }
45
67
  // Default 30min wall-clock budget for the full install. Big workspaces
46
68
  // (212+ sub-packages × 600+ external deps in the gjsify monorepo itself)
47
69
  // can legitimately take 10-20 min on a fresh CI install when the npm CDN
@@ -183,10 +205,26 @@ export const installCommand = {
183
205
  const hardExitTimerId = overallTimeoutMs > 0
184
206
  ? setTimeout(() => {
185
207
  const secs = Math.round(overallTimeoutMs / 100) / 10;
186
- console.error(`gjsify install: still stuck ${Math.round(HARD_EXIT_GRACE_MS / 1000)}s after the ${secs}s ` +
187
- `budget elapsed and the in-flight aborts — forcing exit. A dependency's extract or ` +
188
- `link step wedged (typically a dropped Gio stream event under GJS). Re-run; if it ` +
189
- `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.`);
190
228
  forceExit(1);
191
229
  }, overallTimeoutMs + HARD_EXIT_GRACE_MS)
192
230
  : null;
@@ -621,7 +659,7 @@ async function workspaceInstallLocked(cwd, args, signal) {
621
659
  continue;
622
660
  const linkPath = join(target.location, 'node_modules', link.depName);
623
661
  parentDirs.add(dirname(linkPath));
624
- const relTarget = relative(dirname(linkPath), link.targetLocation);
662
+ const relTarget = workspaceLinkTarget(linkPath, link.targetLocation);
625
663
  plans.push({ linkPath, relTarget });
626
664
  }
627
665
  // Phase 1: one mkdir per unique parent (max ~213 instead of ~793).
@@ -638,13 +676,13 @@ async function workspaceInstallLocked(cwd, args, signal) {
638
676
  // `{ recursive: true, force: true }` handles every shape (rm
639
677
  // no-ops on missing paths under force; recursive covers dirs).
640
678
  try {
641
- await fsp.rm(linkPath, { recursive: true, force: true });
679
+ await fsp.rm(linkPath, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
642
680
  }
643
681
  catch {
644
682
  /* unexpected; symlink call below will surface the real
645
683
  cause if it persists. */
646
684
  }
647
- await fsp.symlink(relTarget, linkPath);
685
+ await fsp.symlink(relTarget, linkPath, WORKSPACE_LINK_TYPE);
648
686
  };
649
687
  for (let i = 0; i < Math.min(SYMLINK_CONCURRENCY, plans.length); i++) {
650
688
  workers.push((async () => {
@@ -704,8 +742,7 @@ async function workspaceInstallLocked(cwd, args, signal) {
704
742
  if (existsHere)
705
743
  continue;
706
744
  mkdirSync(dirname(linkPath), { recursive: true });
707
- const relTarget = relative(dirname(linkPath), ws.location);
708
- symlinkSync(relTarget, linkPath);
745
+ symlinkSync(workspaceLinkTarget(linkPath, ws.location), linkPath, WORKSPACE_LINK_TYPE);
709
746
  rootHoisted++;
710
747
  }
711
748
  if (rootHoisted > 0) {
@@ -912,16 +949,27 @@ function writeWorkspaceBinShims(cwd, workspaces) {
912
949
  mkdirSync(wsBinDir, { recursive: true });
913
950
  for (const [binName, { nodeTarget, gjsTarget }] of merged) {
914
951
  const linkPath = join(wsBinDir, binName);
915
- try {
916
- rmSync(linkPath, { force: true });
917
- }
918
- catch {
919
- /* fine */
952
+ for (const file of [linkPath, `${linkPath}.cmd`, `${linkPath}.ps1`]) {
953
+ try {
954
+ rmSync(file, { force: true, maxRetries: 10, retryDelay: 100 });
955
+ }
956
+ catch {
957
+ /* fine */
958
+ }
920
959
  }
921
960
  writeFileSync(linkPath, buildBinShim(ws.location, nodeTarget, gjsTarget, nativePrebuildDirs), {
922
961
  mode: 0o755,
923
962
  });
924
963
  chmodSync(linkPath, 0o755);
964
+ // Windows executes neither an extension-less file nor a `#!` line —
965
+ // the sh shim above is only reachable from git-bash/MSYS/WSL. Write
966
+ // the `.cmd`/`.ps1` companions cmd.exe and pwsh need, mirroring what
967
+ // npm's cmd-shim lays down next to every linked bin.
968
+ if (process.platform === 'win32') {
969
+ for (const [suffix, contents] of Object.entries(buildWorkspaceBinShimsForWindows(ws.location, nodeTarget, gjsTarget, nativePrebuildDirs))) {
970
+ writeFileSync(`${linkPath}.${suffix}`, contents, { mode: 0o755 });
971
+ }
972
+ }
925
973
  wsBinsCreated++;
926
974
  }
927
975
  }
@@ -997,19 +1045,25 @@ function scopedOverrideFor(ws, depName, extracted) {
997
1045
  }
998
1046
  return undefined;
999
1047
  }
1000
- function buildBinShim(wsLocation, nodeTarget, gjsTarget, nativePrebuildDirs = []) {
1048
+ export function buildBinShim(wsLocation, nodeTarget, gjsTarget, nativePrebuildDirs = [], platform = process.platform) {
1001
1049
  const nodeAbs = nodeTarget ? join(wsLocation, nodeTarget) : null;
1002
1050
  const gjsAbs = gjsTarget ? join(wsLocation, gjsTarget) : null;
1003
1051
  // GJS-only env preamble — Node ignores GI_TYPELIB_PATH so we scope the
1004
1052
  // export to the gjs branch, keeping the shim minimal when no native pkgs
1005
1053
  // exist or only the Node bin is in play.
1054
+ //
1055
+ // The library-path variable is host-dependent: `dyld` on macOS never reads
1056
+ // LD_LIBRARY_PATH, so exporting it there silently produced a launcher that
1057
+ // could not load a single `darwin-arm64` prebuild. `libraryPathVar()` is the
1058
+ // one place that mapping lives.
1059
+ const { name: libVar } = libraryPathVar(platform);
1006
1060
  const gjsPreamble = nativePrebuildDirs.length === 0
1007
1061
  ? ''
1008
1062
  : (() => {
1009
1063
  const joined = `'${nativePrebuildDirs.join(':').replace(/'/g, `'\\''`)}'`;
1010
1064
  return (`GI_TYPELIB_PATH=${joined}\${GI_TYPELIB_PATH:+":$GI_TYPELIB_PATH"}\n` +
1011
- `LD_LIBRARY_PATH=${joined}\${LD_LIBRARY_PATH:+":$LD_LIBRARY_PATH"}\n` +
1012
- `export GI_TYPELIB_PATH LD_LIBRARY_PATH\n`);
1065
+ `${libVar}=${joined}\${${libVar}:+":$${libVar}"}\n` +
1066
+ `export GI_TYPELIB_PATH ${libVar}\n`);
1013
1067
  })();
1014
1068
  if (nodeAbs && gjsAbs) {
1015
1069
  // GJS-FIRST: prefer the committed Node-free GJS bundle when `gjs` is on
@@ -1039,6 +1093,37 @@ function buildBinShim(wsLocation, nodeTarget, gjsTarget, nativePrebuildDirs = []
1039
1093
  return `#!/bin/sh\n${gjsPreamble}exec gjs -m "${gjsAbs}" "$@"\n`;
1040
1094
  throw new Error('buildBinShim: either nodeTarget or gjsTarget must be provided');
1041
1095
  }
1096
+ /**
1097
+ * `.cmd` / `.ps1` companions for {@link buildBinShim}.
1098
+ *
1099
+ * The `sh` shim's gjs-first / node-fallback probe has no Windows counterpart to
1100
+ * express: there is no GJS host on Windows at all (no prebuilt `libgjs` — see
1101
+ * `website/src/content/docs/platform-support.md`), so a workspace that ships
1102
+ * both targets resolves to Node here, and a GJS-only workspace gets an honest
1103
+ * `gjs`-invoking shim that fails exactly the way the `sh` one does.
1104
+ *
1105
+ * Exported + platform-free so it is unit-tested from Linux.
1106
+ */
1107
+ export function buildWorkspaceBinShimsForWindows(wsLocation, nodeTarget, gjsTarget, nativePrebuildDirs = []) {
1108
+ const nodeAbs = nodeTarget ? join(wsLocation, nodeTarget) : null;
1109
+ const gjsAbs = gjsTarget ? join(wsLocation, gjsTarget) : null;
1110
+ if (nodeAbs)
1111
+ return buildLauncherShims({ interpreter: 'node', target: nodeAbs });
1112
+ if (gjsAbs) {
1113
+ // Windows has no dedicated library-path variable — LoadLibrary searches
1114
+ // PATH — so the prebuild dirs go there, `;`-separated (which is also
1115
+ // GLib's G_SEARCHPATH_SEPARATOR on Windows, hence the same join for
1116
+ // GI_TYPELIB_PATH).
1117
+ const joined = nativePrebuildDirs.join(';');
1118
+ return buildLauncherShims({
1119
+ interpreter: 'gjs',
1120
+ interpreterArgs: ['-m'],
1121
+ target: gjsAbs,
1122
+ prependEnv: nativePrebuildDirs.length === 0 ? {} : { GI_TYPELIB_PATH: joined, PATH: joined },
1123
+ });
1124
+ }
1125
+ throw new Error('buildWorkspaceBinShimsForWindows: either nodeTarget or gjsTarget must be provided');
1126
+ }
1042
1127
  /**
1043
1128
  * Walk a workspace's `bin` (Node) + `gjsify.bin` (GJS) declarations
1044
1129
  * into a unified `<binName> → {nodeTarget?, gjsTarget?}` map. The
@@ -1092,23 +1177,25 @@ async function projectInstallViaNpm(args) {
1092
1177
  await spawnNpm(npmArgs);
1093
1178
  }
1094
1179
  async function spawnNpm(npmArgs) {
1095
- return new Promise((resolve, reject) => {
1096
- const child = spawn('npm', npmArgs, { stdio: 'inherit' });
1097
- child.on('close', (code) => {
1098
- if (code === 0)
1099
- resolve();
1100
- else
1101
- reject(new Error(`npm install exited with code ${code}`));
1102
- });
1103
- child.on('error', (err) => {
1104
- const code = err.code;
1105
- const msg = code === 'ENOENT'
1106
- ? 'npm not found on PATH — install Node.js first.'
1107
- : `npm install failed: ${err.message}`;
1108
- reject(new Error(msg));
1109
- });
1110
- }).catch((err) => {
1111
- console.error(err.message);
1180
+ // `completion: 'return'` — the `--backend npm` branch of the handler
1181
+ // RETURNS after `runPostInstallChecks()` instead of exiting, so under GJS
1182
+ // this must not leave the main loop `spawn()` arms parked (see
1183
+ // utils/spawn.ts). The npm backend needs a Node host anyway, so the
1184
+ // blocking path costs nothing in practice.
1185
+ return spawnToCompletion('npm', npmArgs, {
1186
+ completion: 'return',
1187
+ notFound: () => new Error('npm not found on PATH — install Node.js first.'),
1188
+ })
1189
+ .then(({ code }) => {
1190
+ if (code !== 0)
1191
+ throw new Error(`npm install exited with code ${code}`);
1192
+ })
1193
+ .catch((err) => {
1194
+ // A raw spawn failure still carries an errno `code`; the mapped
1195
+ // not-found hint and the non-zero-exit error do not, and already
1196
+ // read as finished sentences.
1197
+ const errno = err.code;
1198
+ console.error(errno === undefined ? err.message : `npm install failed: ${err.message}`);
1112
1199
  process.exit(1);
1113
1200
  });
1114
1201
  }
@@ -27,6 +27,7 @@
27
27
  import { spawn, spawnSync } from 'node:child_process';
28
28
  import { DEFAULT_REGISTRY, whoami } from '@gjsify/npm-registry';
29
29
  import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
30
+ import { mergePublishables } from '../utils/publishable-packages.js';
30
31
  import { hasAnyCredential, loadNpmrc } from '../utils/load-npmrc.js';
31
32
  import { OtpProvider } from '../utils/npm-otp.js';
32
33
  import { publishWorkspace } from './publish.js';
@@ -142,7 +143,11 @@ export const onboardCommand = {
142
143
  const environment = args.environment;
143
144
  // 2. Enumerate publishable workspaces (non-private, exclude @girs/*).
144
145
  const root = findWorkspaceRoot(cwd) ?? cwd;
145
- const all = discoverWorkspaces(root, { includeRoot: true });
146
+ // Non-workspace publishables (packages/{node-gi,napi}/*) publish through
147
+ // release.yml but `discoverWorkspaces` cannot see them — without this
148
+ // merge the sweep reports "all done" while a package that was never
149
+ // published is simply absent from the list.
150
+ const all = mergePublishables(discoverWorkspaces(root, { includeRoot: true }), cwd);
146
151
  const selected = filterWorkspaces(all, { noPrivate: true, exclude: ['@girs/*'] }).filter((ws) => ws.name);
147
152
  if (selected.length === 0) {
148
153
  const msg = 'gjsify onboard: no publishable workspace packages found.';
@@ -29,6 +29,7 @@ import { join, resolve } from 'node:path';
29
29
  import { createTarball, gzip } from '@gjsify/tar';
30
30
  import { discoverWorkspaces } from '@gjsify/workspace';
31
31
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
32
+ import { readPackageJson } from '../utils/pkg-json.js';
32
33
  import { runLifecycleScript } from '../utils/run-lifecycle-script.js';
33
34
  export const packCommand = {
34
35
  command: 'pack [path]',
@@ -488,23 +489,87 @@ function globToRegex(glob) {
488
489
  pat = pat.replace(/\?/g, '[^/]');
489
490
  return new RegExp(`^${pat}($|/)`);
490
491
  }
492
+ const DEP_BLOCKS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
493
+ /** Every `<block>.<name>` in `pkg` whose range uses the `workspace:` protocol. */
494
+ function collectWorkspaceProtocolDeps(pkg) {
495
+ const found = [];
496
+ for (const block of DEP_BLOCKS) {
497
+ const deps = pkg[block];
498
+ if (!deps)
499
+ continue;
500
+ for (const [name, range] of Object.entries(deps)) {
501
+ if (typeof range === 'string' && range.startsWith('workspace:'))
502
+ found.push(`${block}.${name}`);
503
+ }
504
+ }
505
+ return found;
506
+ }
507
+ /**
508
+ * Monorepo root to resolve `workspace:` ranges against.
509
+ *
510
+ * `findWorkspaceRoot` is deliberately strict: it only accepts a candidate root
511
+ * whose OWN `workspaces` globs list `start` as a member (so an unrelated
512
+ * grand-parent monorepo can't be picked up). Packing needs a WIDER answer,
513
+ * because some packages live inside the monorepo on purpose WITHOUT being
514
+ * members — `packages/napi/**` and `packages/node-gi/**` are excluded from
515
+ * `package.json#workspaces` (own CI, own release cadence, see AGENTS.md). For
516
+ * those the strict lookup returns `null`, and the pre-fix code took that as
517
+ * "nothing to do" and shipped the literal `workspace:^` string.
518
+ *
519
+ * So: strict answer first, else the nearest ancestor that declares
520
+ * `workspaces` at all — the monorepo the package physically lives in, whose
521
+ * members are exactly the siblings a `workspace:` range can refer to.
522
+ */
523
+ function findPackWorkspaceRoot(start) {
524
+ const strict = findWorkspaceRoot(start);
525
+ if (strict)
526
+ return strict;
527
+ let dir = start;
528
+ for (let i = 0; i < 12; i++) {
529
+ const pkgPath = join(dir, 'package.json');
530
+ if (existsSync(pkgPath) && readPackageJson(pkgPath)?.workspaces !== undefined)
531
+ return dir;
532
+ const parent = resolve(dir, '..');
533
+ if (parent === dir)
534
+ break;
535
+ dir = parent;
536
+ }
537
+ return null;
538
+ }
491
539
  /**
492
540
  * Walk pkg.dependencies / devDependencies / peerDependencies /
493
541
  * optionalDependencies and replace `workspace:^` / `workspace:~` /
494
542
  * `workspace:*` ranges with the resolved npm range based on each sibling
495
543
  * workspace's version field.
544
+ *
545
+ * A `workspace:` range that survives into a published tarball is unresolvable
546
+ * by EVERY registry client (`npm install` fails with `Unsupported URL Type
547
+ * "workspace:"`), so there is no such thing as a harmless skip here: this
548
+ * either substitutes or throws. Same failure mode, same reasoning as
549
+ * `expandFilesPatterns` above — a packer that silently produces a broken
550
+ * tarball costs a whole release train (v0.4.20, v0.4.37).
496
551
  */
497
552
  function rewriteWorkspaceDeps(pkg, wsDir) {
498
- const root = findWorkspaceRoot(wsDir);
499
- if (!root)
553
+ const root = findPackWorkspaceRoot(wsDir);
554
+ if (!root) {
555
+ // No enclosing monorepo. Fine for a standalone package — but then a
556
+ // `workspace:` range cannot possibly be resolved, so refuse rather
557
+ // than ship it.
558
+ const unresolvable = collectWorkspaceProtocolDeps(pkg);
559
+ if (unresolvable.length > 0) {
560
+ throw new Error(`gjsify pack: ${String(pkg.name)} declares workspace: ranges (${unresolvable.join(', ')}) ` +
561
+ `but ${wsDir} is not inside a monorepo (no ancestor package.json declares "workspaces"). ` +
562
+ `A workspace: range cannot be published — replace it with a real npm range, or pack from within the monorepo.`);
563
+ }
500
564
  return pkg;
565
+ }
501
566
  const siblings = new Map();
502
567
  for (const ws of discoverWorkspaces(root)) {
503
568
  if (ws.name && ws.version)
504
569
  siblings.set(ws.name, ws.version);
505
570
  }
506
571
  const cloned = JSON.parse(JSON.stringify(pkg));
507
- for (const block of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
572
+ for (const block of DEP_BLOCKS) {
508
573
  const deps = cloned[block];
509
574
  if (!deps)
510
575
  continue;
@@ -513,7 +578,7 @@ function rewriteWorkspaceDeps(pkg, wsDir) {
513
578
  continue;
514
579
  const wsVer = siblings.get(name);
515
580
  if (!wsVer) {
516
- throw new Error(`gjsify pack: ${cloned.name} declares workspace:^ on ${name} but no sibling workspace with that name exists in the monorepo at ${root}`);
581
+ throw new Error(`gjsify pack: ${cloned.name} declares ${range} on ${name} (${block}) but no sibling workspace with that name exists in the monorepo at ${root}`);
517
582
  }
518
583
  const operator = range.slice('workspace:'.length);
519
584
  if (operator === '*' || operator === '') {