@gjsify/cli 0.40.0 → 0.42.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 (72) hide show
  1. package/README.md +79 -33
  2. package/dist/cli.gjs.mjs +1043 -404
  3. package/lib/actions/build.d.ts +14 -0
  4. package/lib/actions/build.js +54 -7
  5. package/lib/bundler-pick.js +10 -2
  6. package/lib/cli-app.js +3 -1
  7. package/lib/commands/browse.js +1 -1
  8. package/lib/commands/create.d.ts +3 -0
  9. package/lib/commands/create.js +49 -6
  10. package/lib/commands/debug.js +1 -1
  11. package/lib/commands/dev.d.ts +13 -0
  12. package/lib/commands/dev.js +132 -0
  13. package/lib/commands/dlx.js +1 -1
  14. package/lib/commands/index.d.ts +2 -0
  15. package/lib/commands/index.js +2 -0
  16. package/lib/commands/install.d.ts +1 -0
  17. package/lib/commands/install.js +32 -1
  18. package/lib/commands/prune.d.ts +11 -0
  19. package/lib/commands/prune.js +84 -0
  20. package/lib/commands/run.js +38 -85
  21. package/lib/commands/self-update.d.ts +1 -0
  22. package/lib/commands/self-update.js +55 -0
  23. package/lib/commands/ship.js +6 -1
  24. package/lib/commands/showcase.js +1 -1
  25. package/lib/commands/storybook.js +17 -74
  26. package/lib/commands/test.js +12 -16
  27. package/lib/commands/tsc.js +96 -45
  28. package/lib/config.js +4 -0
  29. package/lib/templates/install.mjs.tmpl +8 -3
  30. package/lib/types/config-data.d.ts +27 -0
  31. package/lib/utils/app-metadata.js +27 -6
  32. package/lib/utils/check-system-deps.d.ts +2 -2
  33. package/lib/utils/check-system-deps.js +92 -13
  34. package/lib/utils/daemon-command.d.ts +7 -0
  35. package/lib/utils/daemon-command.js +30 -0
  36. package/lib/utils/dev-plan.d.ts +52 -0
  37. package/lib/utils/dev-plan.js +117 -0
  38. package/lib/utils/gi-runtime-paths.d.ts +27 -0
  39. package/lib/utils/gi-runtime-paths.js +84 -0
  40. package/lib/utils/gi-typelib.d.ts +49 -0
  41. package/lib/utils/gi-typelib.js +160 -0
  42. package/lib/utils/install-provenance.d.ts +44 -0
  43. package/lib/utils/install-provenance.js +106 -0
  44. package/lib/utils/node-script.d.ts +4 -1
  45. package/lib/utils/node-script.js +27 -3
  46. package/lib/utils/npm-oidc.js +8 -5
  47. package/lib/utils/prune-prefix.d.ts +83 -0
  48. package/lib/utils/prune-prefix.js +333 -0
  49. package/lib/utils/run-gjs.d.ts +12 -1
  50. package/lib/utils/run-gjs.js +24 -31
  51. package/lib/utils/run-node.d.ts +9 -4
  52. package/lib/utils/run-node.js +34 -41
  53. package/lib/utils/ship/depends.d.ts +5 -0
  54. package/lib/utils/ship/depends.js +38 -0
  55. package/lib/utils/ship/discover.js +106 -1
  56. package/lib/utils/ship/launcher.js +27 -4
  57. package/lib/utils/ship/payload.d.ts +35 -0
  58. package/lib/utils/ship/payload.js +84 -0
  59. package/lib/utils/ship/plan.js +20 -0
  60. package/lib/utils/ship/settings.d.ts +8 -1
  61. package/lib/utils/ship/settings.js +13 -1
  62. package/lib/utils/ship/types.d.ts +19 -0
  63. package/lib/utils/simple-command.d.ts +16 -0
  64. package/lib/utils/simple-command.js +77 -0
  65. package/lib/utils/spawn.d.ts +7 -1
  66. package/lib/utils/spawn.js +34 -7
  67. package/lib/utils/system-gi.d.ts +24 -0
  68. package/lib/utils/system-gi.js +14 -3
  69. package/lib/utils/watch-loop.d.ts +91 -0
  70. package/lib/utils/watch-loop.js +207 -0
  71. package/package.json +27 -18
  72. package/lib/templates/app/desktop.tmpl +0 -10
@@ -0,0 +1,84 @@
1
+ // `gjsify prune` — remove installed packages this target cannot use.
2
+ //
3
+ // The repair half of ADR 0025. `install`/`self-update` prune what they would not
4
+ // have placed, but only going forward: a prefix that accreted foreign-platform
5
+ // packages under an older CLI is only reachable by asking. That is this command,
6
+ // and it is why it exists at all rather than the automatic pass alone.
7
+ //
8
+ // `--os/--cpu/--libc` are accepted here and deliberately NOT honoured by the
9
+ // automatic pass: typed on `prune` they are a request ("show me what a darwin-arm64
10
+ // host could not use"), while inherited by an install they would delete the host's
11
+ // own packages. See `automaticPruneRefusal`.
12
+ import { existsSync } from 'node:fs';
13
+ import { acquireInstallLock } from '../utils/install-lock.js';
14
+ import { defaultGlobalLayout } from '../utils/install-global.js';
15
+ import { resolveHostPlatform } from '../utils/platform-check.js';
16
+ import { executePrune, formatPruneReport, planPrune } from '../utils/prune-prefix.js';
17
+ export const pruneCommand = {
18
+ command: 'prune',
19
+ description: 'Remove installed packages this host cannot use (foreign os/cpu/libc). ' +
20
+ 'Reads each package manifest — a package that declares no platform is never touched.',
21
+ builder: (yargs) => yargs
22
+ .option('global', {
23
+ description: 'Prune the user-global XDG prefix (the `install -g` target) instead of the project.',
24
+ type: 'boolean',
25
+ alias: 'g',
26
+ default: false,
27
+ })
28
+ .option('dry-run', {
29
+ description: 'Report what would be removed without touching the filesystem.',
30
+ type: 'boolean',
31
+ default: false,
32
+ })
33
+ .option('verbose', {
34
+ description: 'List every package rather than the first few.',
35
+ type: 'boolean',
36
+ default: false,
37
+ })
38
+ .option('os', {
39
+ description: 'Prune AS IF the host were this OS (linux, darwin, win32). Defaults to this host.',
40
+ type: 'string',
41
+ })
42
+ .option('cpu', {
43
+ description: 'Prune AS IF the host were this architecture (x64, arm64, …). Defaults to this host.',
44
+ type: 'string',
45
+ })
46
+ .option('libc', {
47
+ description: 'Prune AS IF the host used this libc (glibc, musl). Defaults to this host.',
48
+ type: 'string',
49
+ }),
50
+ handler: async (args) => {
51
+ const layout = args.global ? defaultGlobalLayout() : null;
52
+ const prefix = layout ? layout.prefix : process.cwd();
53
+ if (!existsSync(`${prefix}/node_modules`)) {
54
+ console.log(`gjsify prune: nothing installed at ${prefix}`);
55
+ return;
56
+ }
57
+ // The flags are read HERE rather than through the npm config keys, so the
58
+ // automatic pass — which refuses to act on an overridden target — cannot be
59
+ // reached by way of this command's own options.
60
+ const target = resolveHostPlatform({
61
+ env: {
62
+ ...(args.os ? { npm_config_os: args.os } : {}),
63
+ ...(args.cpu ? { npm_config_cpu: args.cpu } : {}),
64
+ ...(args.libc ? { npm_config_libc: args.libc } : {}),
65
+ },
66
+ });
67
+ // A prune racing an install's extract would delete a half-written tree. The
68
+ // lock is the same one every writer of this prefix takes (ADR 0001).
69
+ const lock = await acquireInstallLock(prefix, {});
70
+ let result;
71
+ try {
72
+ result = executePrune(planPrune({ prefix, target }), { dryRun: args.dryRun });
73
+ }
74
+ finally {
75
+ lock.release();
76
+ }
77
+ console.log(formatPruneReport(result, { dryRun: args.dryRun, verbose: args.verbose }));
78
+ // Removing nothing is a SUCCESS — idempotent housekeeping, unlike `uninstall`
79
+ // where naming a package that is not there is a mistake worth reporting. Only
80
+ // a removal the user asked for and did not get is a failure.
81
+ if (result.failed.length > 0)
82
+ return process.exit(1);
83
+ },
84
+ };
@@ -15,13 +15,15 @@
15
15
  // to disambiguate can pass `./<file>` explicitly.
16
16
  import { readFileSync, statSync } from 'node:fs';
17
17
  import { basename, delimiter, join, resolve } from 'node:path';
18
- import { spawn } from 'node:child_process';
18
+ import { describeExit, spawnToCompletion } from '../utils/spawn.js';
19
19
  import { runGjsBundle } from '../utils/run-gjs.js';
20
20
  import { runRuntimeBundle } from '../utils/run-node.js';
21
21
  import { readPackageJson } from '../utils/pkg-json-edit.js';
22
22
  import { runNodeScript } from '../utils/node-script.js';
23
23
  import { nodeShimDir } from '../utils/gjsify-shim.js';
24
24
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
25
+ import { gjsifyCommandArgv } from '../utils/simple-command.js';
26
+ import { isDaemonCommand } from '../utils/daemon-command.js';
25
27
  import { discoverWorkspaces } from '@gjsify/workspace';
26
28
  import { isGjs, hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
27
29
  import { EXAMPLE_RUNTIMES, isExampleRuntime, readDeclaredRuntimes, checkRuntimeSupported, } from '../utils/runtimes.js';
@@ -124,7 +126,7 @@ export const runCommand = {
124
126
  console.error(`gjsify run: --node-script cannot be combined with ${clash}.`);
125
127
  return process.exit(1);
126
128
  }
127
- await runNodeScript(target, extraArgs, { exitOnSuccess: true });
129
+ await runNodeScript(target, extraArgs, { completion: 'exit', exitOnSuccess: true });
128
130
  return;
129
131
  }
130
132
  // `--runtime <gjs|node|bun|deno>` (explicit) → run a BUNDLE FILE on the
@@ -174,10 +176,10 @@ export const runCommand = {
174
176
  // Terminal calls — exit on success, or the GJS main loop parks
175
177
  // this process forever (see RunGjsBundleOptions.exitOnSuccess).
176
178
  if (runtime === 'gjs') {
177
- await runGjsBundle(file, extraArgs, { exitOnSuccess: true });
179
+ await runGjsBundle(file, extraArgs, { completion: 'exit', exitOnSuccess: true });
178
180
  }
179
181
  else {
180
- await runRuntimeBundle(runtime, file, extraArgs, { exitOnSuccess: true });
182
+ await runRuntimeBundle(runtime, file, extraArgs, { completion: 'exit', exitOnSuccess: true });
181
183
  }
182
184
  return;
183
185
  }
@@ -209,10 +211,10 @@ async function runTargetOnRuntime(runtime, target, extraArgs) {
209
211
  const file = resolve(target);
210
212
  // Terminal call — exit on success (the GJS/GLib loop would otherwise park).
211
213
  if (runtime === 'gjs') {
212
- await runGjsBundle(file, extraArgs, { exitOnSuccess: true });
214
+ await runGjsBundle(file, extraArgs, { completion: 'exit', exitOnSuccess: true });
213
215
  }
214
216
  else {
215
- await runRuntimeBundle(runtime, file, extraArgs, { exitOnSuccess: true });
217
+ await runRuntimeBundle(runtime, file, extraArgs, { completion: 'exit', exitOnSuccess: true });
216
218
  }
217
219
  }
218
220
  /**
@@ -420,6 +422,16 @@ async function runScript(script, extraArgs, cwd = process.cwd()) {
420
422
  console.error(err.message);
421
423
  dispatchFailed = true;
422
424
  }
425
+ // A watch loop RESOLVES as soon as it is armed and then keeps running,
426
+ // so its resolution is not an ending to exit on: `gjsify run dev` used
427
+ // to take down the loop it had just started, the instant the first
428
+ // build+launch finished. Node never saw it — there the same script takes
429
+ // the shell-spawn path below — and it is the DOCUMENTED entry point:
430
+ // `gjsify create` prints `gjsify run dev`, and every template's `dev`
431
+ // script is a bare `gjsify dev`. Returning leaves the process on the
432
+ // main loop `runWatchLoop` holds, where Ctrl+C ends it.
433
+ if (!dispatchFailed && isDaemonCommand())
434
+ return;
423
435
  // A thrown command wins over `process.exitCode`; otherwise honour a
424
436
  // non-zero code the handler set instead of throwing (e.g. a failing
425
437
  // check) — a bare exit(0) here masked those as success too.
@@ -436,23 +448,23 @@ async function runScript(script, extraArgs, cwd = process.cwd()) {
436
448
  return process.exit(dispatchCode);
437
449
  }
438
450
  const fullCmd = extraArgs.length > 0 ? `${literal} ${extraArgs.map(shellEscape).join(' ')}` : literal;
439
- // ensureMainLoop() (called inside spawn) keeps GJS alive after the
440
- // child exits — without an explicit process.exit() the success path
441
- // would park the loop forever. The error path already exits.
442
- await new Promise((resolveOk, reject) => {
443
- const child = spawn(fullCmd, [], { cwd, env, stdio: 'inherit', shell: true });
444
- child.on('close', (code) => {
445
- if (code === 0)
446
- resolveOk();
447
- else
448
- reject(new Error(`script "${script}" exited with code ${code}`));
449
- });
450
- child.on('error', reject);
451
- }).catch((err) => {
451
+ // `completion: 'exit'` — ensureMainLoop() (called inside the async spawn) keeps
452
+ // GJS alive after the child exits, and both paths below end in `process.exit`,
453
+ // which is what tears it down. `shell: true` deliberately skips the win32
454
+ // rewrite: this is a command LINE, and Node routes it through %COMSPEC%.
455
+ let result;
456
+ try {
457
+ result = await spawnToCompletion(fullCmd, [], { completion: 'exit', cwd, env, shell: true });
458
+ }
459
+ catch (err) {
452
460
  console.error(err.message);
453
- process.exit(1);
454
- });
455
- process.exit(0);
461
+ return process.exit(1);
462
+ }
463
+ if (result.code !== 0) {
464
+ console.error(`script "${script}" exited with ${describeExit(result)}`);
465
+ return process.exit(1);
466
+ }
467
+ return process.exit(0);
456
468
  }
457
469
  function shellEscape(arg) {
458
470
  if (/^[a-zA-Z0-9_\-./=:@,]+$/.test(arg))
@@ -463,70 +475,11 @@ function shellEscape(arg) {
463
475
  * If `literal` is a SINGLE `gjsify <subcommand> …` command with no shell
464
476
  * operators / substitutions / unquoted globs, return the argv to feed `runCli`
465
477
  * (the subcommand + its args, with `extraArgs` appended). Otherwise null — the
466
- * caller falls back to the shell spawn path. Quotes are honoured so a quoted
467
- * glob (`'src/**'`) survives as a single literal token, exactly as the shell
468
- * would hand it to `gjsify` (which does its own glob expansion).
478
+ * caller falls back to the shell spawn path.
469
479
  */
470
480
  function gjsifyInProcessArgv(literal, extraArgs) {
471
- const tokens = tokenizeSimpleCommand(literal);
472
- if (!tokens || tokens.length < 2 || tokens[0] !== 'gjsify')
481
+ const argv = gjsifyCommandArgv(literal);
482
+ if (!argv)
473
483
  return null;
474
- return [...tokens.slice(1), ...extraArgs];
475
- }
476
- /**
477
- * Quote-aware tokenizer for a SIMPLE command line (no shell features beyond
478
- * single/double quoting). Returns the tokens, or null if the string contains
479
- * anything the shell would treat specially — operators (`&& | ; < > &`),
480
- * substitutions (`$(...)` / backticks / `$VAR`), unquoted globs/expansions
481
- * (`* ? { } [ ] ~`), comments (`#`), or an unterminated quote — in which case
482
- * the caller MUST use the real shell instead.
483
- */
484
- function tokenizeSimpleCommand(cmd) {
485
- const tokens = [];
486
- let cur = '';
487
- let has = false;
488
- let quote = null;
489
- for (let i = 0; i < cmd.length; i++) {
490
- const c = cmd[i];
491
- if (quote === "'") {
492
- if (c === "'")
493
- quote = null;
494
- else
495
- cur += c;
496
- continue;
497
- }
498
- if (quote === '"') {
499
- if (c === '"')
500
- quote = null;
501
- else if (c === '\\' && (cmd[i + 1] === '"' || cmd[i + 1] === '\\'))
502
- cur += cmd[++i];
503
- else if (c === '$' || c === '`')
504
- return null; // substitution inside "…"
505
- else
506
- cur += c;
507
- continue;
508
- }
509
- if (c === "'" || c === '"') {
510
- quote = c;
511
- has = true;
512
- continue;
513
- }
514
- if (c === ' ' || c === '\t') {
515
- if (has) {
516
- tokens.push(cur);
517
- cur = '';
518
- has = false;
519
- }
520
- continue;
521
- }
522
- if ('|&;<>`$()\\\n\r*?{}[]~#!'.includes(c))
523
- return null;
524
- cur += c;
525
- has = true;
526
- }
527
- if (quote)
528
- return null; // unterminated quote
529
- if (has)
530
- tokens.push(cur);
531
- return tokens;
484
+ return [...argv, ...extraArgs];
532
485
  }
@@ -4,6 +4,7 @@ interface SelfUpdateOptions {
4
4
  force?: boolean;
5
5
  tag: string;
6
6
  skipDeps?: boolean;
7
+ prune?: boolean;
7
8
  }
8
9
  export declare const selfUpdateCommand: Command<unknown, SelfUpdateOptions>;
9
10
  /** Verdict of the post-install "did this change what runs?" check. */
@@ -25,7 +25,10 @@ import { dirname, join, posix, resolve, win32 } from 'node:path';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import { fetchPackument } from '@gjsify/npm-registry';
27
27
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
28
+ import { classifyInstall } from '../utils/install-provenance.js';
28
29
  import { defaultGlobalLayout, globalLayoutIsDefault, hasBundlerEngineInstalled, installGjsEnginePackages, linkGlobalBins, resolveBinOnPath, } from '../utils/install-global.js';
30
+ import { resolveHostPlatform } from '../utils/platform-check.js';
31
+ import { pruneAfterInstall } from '../utils/prune-prefix.js';
29
32
  const PACKAGE_NAME = '@gjsify/cli';
30
33
  export const selfUpdateCommand = {
31
34
  command: 'self-update',
@@ -45,6 +48,11 @@ export const selfUpdateCommand = {
45
48
  description: 'npm dist-tag or pinned version to install (e.g. `latest`, `next`, `0.5.0`).',
46
49
  type: 'string',
47
50
  default: 'latest',
51
+ })
52
+ .option('prune', {
53
+ description: 'After updating, remove packages an earlier install left behind that this host cannot use (foreign os/cpu/libc). Use --no-prune to disable.',
54
+ type: 'boolean',
55
+ default: true,
48
56
  })
49
57
  .option('skip-deps', {
50
58
  description: 'Update only the @gjsify/cli bundle itself; do not pull its runtime dependencies ' +
@@ -60,6 +68,24 @@ export const selfUpdateCommand = {
60
68
  const currentVersion = readCurrentVersion();
61
69
  const installedAtPrefix = existsSync(installedPkgJson);
62
70
  console.log(`Current ${PACKAGE_NAME}: v${currentVersion ?? '(unknown)'}`);
71
+ // REFUSE, do not warn-and-continue, when something else owns this install.
72
+ //
73
+ // Everything below writes the user-global XDG prefix. Under a `.deb`,
74
+ // `.rpm` or Flatpak that produces a SECOND copy which shadows the one the
75
+ // package manager tracks — so the next `apt upgrade` reverts it, or the two
76
+ // diverge, and the user's report is "I updated and nothing changed". A
77
+ // clean refusal naming the right command is strictly more useful than a
78
+ // successful write to the wrong place; #1064 is the same lesson one layer
79
+ // down, where the write succeeded and PATH still resolved the old binary.
80
+ const provenance = classifyInstall({ selfDir: readSelfDir(), xdgPrefix: layout.prefix });
81
+ if (provenance.managedElsewhere) {
82
+ console.error(`\ngjsify self-update: this gjsify is managed by something else — ${provenance.evidence}.\n` +
83
+ `Updating it from here would write ${layout.prefix} and leave you running two installs.\n\n` +
84
+ `Update with: ${provenance.updateWith}\n`);
85
+ // `return process.exit()` — a bare `process.exit()` is DEFERRED under
86
+ // GJS and the handler would run on into the install it just refused.
87
+ return process.exit(1);
88
+ }
63
89
  if (!installedAtPrefix) {
64
90
  console.warn(`\nWarning: no @gjsify/cli install found under ${layout.prefix}.\n` +
65
91
  `self-update only manages installs created by install.mjs or \`gjsify install -g\`.\n` +
@@ -157,6 +183,13 @@ export const selfUpdateCommand = {
157
183
  if (pullDeps) {
158
184
  await installGjsEnginePackages(layout.prefix, target, { verbose: false });
159
185
  }
186
+ // Same ordering reason as `install -g`: before the LINK, whose launchers bake
187
+ // the resolved prebuild directories into their env.
188
+ if (args.prune !== false) {
189
+ pruneAfterInstall(layout.prefix, resolveHostPlatform({ env: {} }), {
190
+ hint: 'gjsify prune -g --dry-run',
191
+ });
192
+ }
160
193
  const linked = linkGlobalBins([PACKAGE_NAME], layout);
161
194
  if (linked.length === 0) {
162
195
  console.warn('self-update: install completed but no bins were linked — package.json may be missing a `bin` field.');
@@ -246,6 +279,28 @@ export function verifyPathResolution(opts) {
246
279
  * `import.meta.url` (the bundle file under GJS, or `lib/index.js` under
247
280
  * Node) until it finds a package.json with `name === '@gjsify/cli'`.
248
281
  */
282
+ /**
283
+ * The directory of the running CLI's own package — the input the provenance
284
+ * question is actually about.
285
+ *
286
+ * The same upward walk `readCurrentVersion()` does, and for the same reason: it
287
+ * works from `lib/index.js` under Node and from the published `dist/cli.gjs.mjs`
288
+ * bundle under GJS. Honours the same `GJSIFY_CLI_PACKAGE_JSON` escape hatch, so a
289
+ * test can place a synthetic install anywhere without a real one.
290
+ */
291
+ function readSelfDir() {
292
+ const override = process.env.GJSIFY_CLI_PACKAGE_JSON;
293
+ if (override)
294
+ return dirname(resolve(override));
295
+ const here = fileURLToPath(import.meta.url);
296
+ let dir = dirname(resolve(here));
297
+ for (let i = 0; i < 8 && dir !== dirname(dir); i++) {
298
+ if (existsSync(join(dir, 'package.json')))
299
+ return dir;
300
+ dir = dirname(dir);
301
+ }
302
+ return dirname(resolve(here));
303
+ }
249
304
  function readCurrentVersion() {
250
305
  try {
251
306
  // Escape hatch for tests: point GJSIFY_CLI_PACKAGE_JSON at a synthetic
@@ -20,7 +20,7 @@ import { deriveDepends, warnAboutGjsFloor } from '../utils/ship/depends.js';
20
20
  import { discoverPayload } from '../utils/ship/discover.js';
21
21
  import { resolveFormats, FORMAT_IDS } from '../utils/ship/formats.js';
22
22
  import { scanGiNamespaces } from '../utils/ship/gi-namespaces.js';
23
- import { buildTimestamp, isArchIndependent } from '../utils/ship/payload.js';
23
+ import { assertPayloadMatchesArch, buildTimestamp, isArchIndependent } from '../utils/ship/payload.js';
24
24
  import { planOverlay, planStage } from '../utils/ship/plan.js';
25
25
  import { renderLauncher } from '../utils/ship/launcher.js';
26
26
  import { buildRpm } from '../utils/ship/rpm.js';
@@ -151,10 +151,15 @@ async function packOne(input) {
151
151
  hasSchemas: settings.schemaFiles.length > 0,
152
152
  extra: settings.extraDepends[format.id],
153
153
  typelibPackages: settings.typelibPackages,
154
+ bundledTypelibs: settings.typelibFiles,
154
155
  minGjsVersion: settings.minGjsVersion,
155
156
  });
156
157
  for (const warning of warnAboutGjsFloor(format.id, settings.minGjsVersion))
157
158
  console.warn(`${LOG} ${warning}`);
159
+ // Before the label is chosen, not after: `archName` collapses an
160
+ // arch-independent payload to `all`/`noarch`, and a mismatch only matters
161
+ // for a payload that IS architecture-specific.
162
+ assertPayloadMatchesArch(payload, settings.arch);
158
163
  const archLabel = format.archName(settings.arch, isArchIndependent(payload));
159
164
  const common = { settings, payload, prefix: format.prefix, depends, archLabel, mtime };
160
165
  const bytes = format.id === 'deb' ? await buildDeb(common) : await buildRpm(common);
@@ -411,7 +411,7 @@ async function runShowcaseOnRuntime(showcase, runtime, cliVersion) {
411
411
  // thrown: an exception escaping the handler makes yargs dump the command's
412
412
  // full `--help` above the message, which buries it.
413
413
  try {
414
- await runRuntimeBundle(runtime, entry, [], { exitOnSuccess: true });
414
+ await runRuntimeBundle(runtime, entry, [], { completion: 'exit', exitOnSuccess: true });
415
415
  }
416
416
  catch (err) {
417
417
  console.error(`${err.message}\n\n` +
@@ -16,7 +16,6 @@
16
16
  // identically under Node and GJS. @gjsify/storybook is resolved from the
17
17
  // CONSUMER's node_modules by the generated entry, so it stays out of the CLI's
18
18
  // own dependencies.
19
- import { spawn } from 'node:child_process';
20
19
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
21
20
  import { join, relative, resolve } from 'node:path';
22
21
  import { hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
@@ -165,18 +164,18 @@ export const storybookCommand = {
165
164
  const launch = async () => {
166
165
  if (runtime === 'gjs') {
167
166
  const { runGjsBundle } = await import('../utils/run-gjs.js');
168
- await runGjsBundle(outPath, [], { exitOnSuccess: true });
167
+ await runGjsBundle(outPath, [], { completion: 'exit', exitOnSuccess: true });
169
168
  }
170
169
  else if (runtime === 'node') {
171
170
  const { runNodeBundle } = await import('../utils/run-node.js');
172
- await runNodeBundle(outPath, [], { exitOnSuccess: true });
171
+ await runNodeBundle(outPath, [], { completion: 'exit', exitOnSuccess: true });
173
172
  }
174
173
  else {
175
174
  // bun / deno — run the SAME `--app node` bundle via the shared
176
175
  // runner (Node-API is their common ABI). node-gi is required
177
176
  // because the storybook uses gi:// (checked in runRuntimeBundle).
178
177
  const { runRuntimeBundle } = await import('../utils/run-node.js');
179
- await runRuntimeBundle(runtime, outPath, [], { exitOnSuccess: true });
178
+ await runRuntimeBundle(runtime, outPath, [], { completion: 'exit', exitOnSuccess: true });
180
179
  }
181
180
  };
182
181
  if (!args.watch) {
@@ -186,77 +185,21 @@ export const storybookCommand = {
186
185
  await launch();
187
186
  return;
188
187
  }
189
- // Watch mode: rebuild + relaunch on any change under the stories dir.
190
- // The storybook bundle only needs system Gtk/Adw typelibs at runtime
191
- // (no @gjsify native bridges), so a plain `gjs -m` / `node` child is
192
- // enough and stays killable for relaunch.
193
- const { watch } = await import('node:fs');
194
- const spawnChild = async () => {
195
- if (runtime === 'gjs') {
196
- return spawn('gjs', ['-m', outPath], { stdio: 'inherit' });
197
- }
198
- // node / bun / deno — run the `--app node` bundle with the native
199
- // typelib env wired like `gjsify run`. All three need @gjsify/node-gi
200
- // (the storybook uses gi://), kept external by the bundler.
201
- const { computeNativeEnvForBundle } = await import('../utils/run-gjs.js');
202
- const { resolveNodeGiForBundle, nodeBinary } = await import('../utils/run-node.js');
203
- const { RUNTIMES } = await import('../utils/runtimes.js');
204
- // Same resolution base as `runRuntimeBundle`: the bundle's own dir
205
- // first (that is where the runtime resolves the external specifier
206
- // from), cwd as the fallback.
207
- if (!resolveNodeGiForBundle(outPath, cwd)) {
208
- throw new Error(`Cannot run the storybook on ${runtime}: \`@gjsify/node-gi\` is not installed. ` +
209
- `Add @gjsify/node-gi as a dependency to run the storybook on ${runtime}.`);
210
- }
211
- const { env: nativeEnv } = computeNativeEnvForBundle(outPath, cwd);
212
- const env = { ...process.env, ...nativeEnv };
213
- // `node` uses the resolved node binary (under a GJS-hosted CLI
214
- // `process.execPath` is gjs); bun/deno launch via the shared spec
215
- // (deno's `--node-modules-dir=manual`, etc.).
216
- if (runtime === 'node') {
217
- return spawn(nodeBinary(), [outPath], { stdio: 'inherit', env });
218
- }
219
- const [cmd, launchArgs] = RUNTIMES[runtime].launch(outPath);
220
- return spawn(cmd, launchArgs, { stdio: 'inherit', env });
221
- };
222
- let child = null;
223
- let rebuilding = false;
224
- let pending = false;
225
- const rebuild = async () => {
226
- if (rebuilding) {
227
- pending = true;
228
- return;
229
- }
230
- rebuilding = true;
231
- try {
188
+ // Watch mode: rebuild + relaunch on any change under the stories dir,
189
+ // through the loop shared with `gjsify dev` (`utils/watch-loop.ts`).
190
+ const { runWatchLoop, spawnBundleSupervised } = await import('../utils/watch-loop.js');
191
+ await runWatchLoop({
192
+ dir: storiesDir,
193
+ dirLabel: relative(cwd, storiesDir) || '.',
194
+ label: 'storybook',
195
+ // Stories are DISCOVERED, so the generated entry is re-derived on
196
+ // every pass — a new `*.story.ts` must show up without a restart.
197
+ prepare: () => {
232
198
  writeEntry();
233
- if (child) {
234
- child.kill();
235
- child = null;
236
- }
237
- await buildBundle();
238
- if (!args.buildOnly) {
239
- child = await spawnChild();
240
- }
241
- }
242
- catch (err) {
243
- console.error(`[storybook] rebuild failed: ${err.message}`);
244
- }
245
- finally {
246
- rebuilding = false;
247
- if (pending) {
248
- pending = false;
249
- void rebuild();
250
- }
251
- }
252
- };
253
- await rebuild();
254
- let debounce = null;
255
- watch(storiesDir, { recursive: true }, () => {
256
- if (debounce)
257
- clearTimeout(debounce);
258
- debounce = setTimeout(() => void rebuild(), 200);
199
+ },
200
+ build: buildBundle,
201
+ spawnChild: args.buildOnly ? null : () => spawnBundleSupervised(runtime, outPath, cwd, 'the storybook'),
202
+ output: outPath,
259
203
  });
260
- console.log(`[storybook] watching ${relative(cwd, storiesDir) || '.'} — press Ctrl+C to stop`);
261
204
  },
262
205
  };
@@ -7,9 +7,9 @@
7
7
  // exit codes.
8
8
  import { existsSync, statSync, readdirSync } from 'node:fs';
9
9
  import { join, dirname, resolve, relative } from 'node:path';
10
- import { spawn } from 'node:child_process';
11
10
  import { isRuntimeAvailable, RUNTIMES } from '../utils/runtimes.js';
12
11
  import { nodeBinary } from '../utils/run-node.js';
12
+ import { describeExit, spawnToCompletion } from '../utils/spawn.js';
13
13
  import { Config } from '../config.js';
14
14
  import { BuildAction } from '../actions/build.js';
15
15
  import { runGjsBundle } from '../utils/run-gjs.js';
@@ -190,23 +190,19 @@ async function buildTestBundle(entry, outfile, runtime, verbose) {
190
190
  /** Run a single test bundle and reject on non-zero exit. */
191
191
  async function runTestBundle(outfile, runtime) {
192
192
  if (runtime === 'gjs') {
193
- await runGjsBundle(outfile);
193
+ // `completion: 'exit'` (this handler ends in `process.exit`) WITHOUT
194
+ // `exitOnSuccess`: the two answer different questions, and exiting here
195
+ // would truncate the multi-runtime loop after the first bundle.
196
+ await runGjsBundle(outfile, [], { completion: 'exit' });
194
197
  return;
195
198
  }
196
- await new Promise((resolvePromise, reject) => {
197
- // `nodeBinary()`, never a bare `'node'`: under GJS `process.execPath` is
198
- // the `gjs` interpreter, which cannot run a `--app node` bundle, and on a
199
- // host without Node a bare literal dies `spawn node ENOENT` — which is
200
- // how a fully passing GJS suite was reported as a failed run.
201
- const child = spawn(nodeBinary(), [outfile], { stdio: 'inherit' });
202
- child.on('error', reject);
203
- child.on('exit', (code) => {
204
- if (code === 0)
205
- resolvePromise();
206
- else
207
- reject(new Error(`node exited with code ${code}`));
208
- });
209
- });
199
+ // `nodeBinary()`, never a bare `'node'`: under GJS `process.execPath` is
200
+ // the `gjs` interpreter, which cannot run a `--app node` bundle, and on a
201
+ // host without Node a bare literal dies `spawn node ENOENT` — which is
202
+ // how a fully passing GJS suite was reported as a failed run.
203
+ const result = await spawnToCompletion(nodeBinary(), [outfile], { completion: 'exit' });
204
+ if (result.code !== 0)
205
+ throw new Error(`node exited with ${describeExit(result)}`);
210
206
  }
211
207
  /** True when `outfile` exists and is newer than every `.ts`/`.mts` file under the entry's directory tree. */
212
208
  function isFresh(outfile, entry) {