@gjsify/cli 0.13.2 → 0.15.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.
@@ -13,6 +13,7 @@ interface ForeachOptions {
13
13
  verbose?: boolean;
14
14
  jobs?: number;
15
15
  exec?: boolean;
16
+ cached?: boolean;
16
17
  }
17
18
  export declare const foreachCommand: Command<unknown, ForeachOptions>;
18
19
  export {};
@@ -14,6 +14,7 @@ import { cpus } from 'node:os';
14
14
  import { buildDependencyGraph, discoverWorkspaces, filterWorkspaces, topologicalSort, } from '@gjsify/workspace';
15
15
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
16
16
  import { prefixLines } from '../utils/prefixed-output.js';
17
+ import { BuildCacheRunner, buildCacheEnabledByEnv } from '../utils/build-cache.js';
17
18
  import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
18
19
  // Every child spawned by spawnPrefixed registers here so fail-fast can
19
20
  // terminate the whole in-flight set instead of waiting on it. On CI a
@@ -238,6 +239,10 @@ export const foreachCommand = {
238
239
  description: 'Treat <script> [args..] as an arbitrary command (yarn `workspaces foreach exec`-equivalent) instead of a package.json script lookup. Workspace filtering by script presence is skipped. Use `-- <cmd> <args...>` to pass flags to the command without yargs intercepting them.',
239
240
  type: 'boolean',
240
241
  default: false,
242
+ })
243
+ .option('cached', {
244
+ description: 'Content-hash build cache (ADR 0006): skip a workspace whose inputs (src/**, package.json, tsconfig*.json, transitive workspace deps, toolchain versions) are unchanged and restore its stored outputs instead; on miss run the script and store the output dirs it modified. Script mode only. Default from GJSIFY_BUILD_CACHE=1.',
245
+ type: 'boolean',
241
246
  })
242
247
  .option('shard', {
243
248
  description: 'Run only a deterministic slice "<index>/<total>" (1-based) of the matched workspaces, e.g. --shard 2/4. For fanning a long run across parallel CI jobs. Partitions by sorted name (round-robin) so the shards are disjoint and their union is the full set; order-independent, so safe for tests but NOT for ordered builds.',
@@ -352,20 +357,43 @@ export const foreachCommand = {
352
357
  // `cmd` is guaranteed string at this point — both branches above
353
358
  // exit on undefined, but TS doesn't narrow through them.
354
359
  const finalCmd = cmd;
360
+ // Content-hash build cache (ADR 0006 phase 1). Explicit --cached /
361
+ // --no-cached wins; GJSIFY_BUILD_CACHE=1 is the env default so a
362
+ // whole root-script chain (`gjsify run build`) can opt in without
363
+ // editing every nested invocation. Keys are computed against ALL
364
+ // discovered workspaces (a dep outside the --include filter still
365
+ // participates in its dependents' keys).
366
+ const cached = args.cached ?? buildCacheEnabledByEnv();
367
+ if (args.cached === true && exec) {
368
+ // Only the EXPLICIT flag errors — the GJSIFY_BUILD_CACHE env
369
+ // default covers whole script chains (CI sets it once for the
370
+ // build job) where an --exec invocation is simply not cacheable.
371
+ console.error('gjsify foreach: --cached only applies to script mode, not --exec.');
372
+ process.exit(1);
373
+ }
374
+ const cache = cached && !exec
375
+ ? new BuildCacheRunner({
376
+ root: cwd,
377
+ workspaces: allWorkspaces,
378
+ script: finalCmd,
379
+ args: cmdArgs,
380
+ targets: selected.map((w) => w.name),
381
+ })
382
+ : undefined;
355
383
  try {
356
384
  if (args.parallel && !args.topological && !args['topological-dev']) {
357
385
  const jobs = args.jobs && args.jobs > 0 ? args.jobs : cpus().length;
358
- await runParallel(selected, finalCmd, cmdArgs, jobs, verbose, exec);
386
+ await runParallel(selected, finalCmd, cmdArgs, jobs, verbose, exec, cache);
359
387
  }
360
388
  else if (args.parallel) {
361
389
  // Topological + parallel: each workspace starts as soon as its
362
390
  // deps (in the selected set) have finished. Yarn calls this
363
391
  // "topological order with concurrency"; we cap at --jobs.
364
392
  const jobs = args.jobs && args.jobs > 0 ? args.jobs : cpus().length;
365
- await runTopologicalParallel(selected, finalCmd, cmdArgs, jobs, verbose, args['topological-dev'] === true, exec);
393
+ await runTopologicalParallel(selected, finalCmd, cmdArgs, jobs, verbose, args['topological-dev'] === true, exec, cache);
366
394
  }
367
395
  else {
368
- await runSequential(selected, finalCmd, cmdArgs, verbose, exec);
396
+ await runSequential(selected, finalCmd, cmdArgs, verbose, exec, cache);
369
397
  }
370
398
  }
371
399
  catch (err) {
@@ -382,25 +410,25 @@ export const foreachCommand = {
382
410
  process.exit(0);
383
411
  },
384
412
  };
385
- async function runSequential(workspaces, script, args, verbose, exec) {
413
+ async function runSequential(workspaces, script, args, verbose, exec, cache) {
386
414
  for (const ws of workspaces) {
387
- await runOne(ws, script, args, /* prefixOutput */ false, verbose, exec);
415
+ await runOne(ws, script, args, /* prefixOutput */ false, verbose, exec, cache);
388
416
  }
389
417
  }
390
- async function runParallel(workspaces, script, args, concurrency, verbose, exec) {
418
+ async function runParallel(workspaces, script, args, concurrency, verbose, exec, cache) {
391
419
  let cursor = 0;
392
420
  const workers = [];
393
421
  for (let w = 0; w < concurrency; w++) {
394
422
  workers.push((async () => {
395
423
  while (cursor < workspaces.length) {
396
424
  const i = cursor++;
397
- await runOne(workspaces[i], script, args, /* prefixOutput */ true, verbose, exec);
425
+ await runOne(workspaces[i], script, args, /* prefixOutput */ true, verbose, exec, cache);
398
426
  }
399
427
  })());
400
428
  }
401
429
  await Promise.all(workers);
402
430
  }
403
- async function runTopologicalParallel(workspaces, script, args, concurrency, verbose, includeDev, exec) {
431
+ async function runTopologicalParallel(workspaces, script, args, concurrency, verbose, includeDev, exec, cache) {
404
432
  const selectedNames = new Set(workspaces.map((w) => w.name));
405
433
  const remaining = new Map();
406
434
  for (const ws of workspaces) {
@@ -439,9 +467,7 @@ async function runTopologicalParallel(workspaces, script, args, concurrency, ver
439
467
  return new Promise((resolve, reject) => {
440
468
  let error = null;
441
469
  let lastActivity = Date.now();
442
- const describeInflight = () => [...inflightStarts.entries()]
443
- .map(([n, t]) => `${n} (${Math.round((Date.now() - t) / 1000)}s)`)
444
- .join(', ');
470
+ const describeInflight = () => [...inflightStarts.entries()].map(([n, t]) => `${n} (${Math.round((Date.now() - t) / 1000)}s)`).join(', ');
445
471
  const failFast = (e) => {
446
472
  // First error wins. KILL the in-flight siblings instead of
447
473
  // waiting for them (yarn waits — but a sibling's nested build
@@ -488,7 +514,7 @@ async function runTopologicalParallel(workspaces, script, args, concurrency, ver
488
514
  if (liveProgress) {
489
515
  console.error(`[gjsify foreach] start ${next} (${done.size}/${total} done, ${inflight} in flight)`);
490
516
  }
491
- runOne(byName.get(next), script, args, /* prefixOutput */ true, verbose, exec)
517
+ runOne(byName.get(next), script, args, /* prefixOutput */ true, verbose, exec, cache)
492
518
  .then(() => {
493
519
  done.add(next);
494
520
  })
@@ -528,7 +554,7 @@ async function runTopologicalParallel(workspaces, script, args, concurrency, ver
528
554
  pump();
529
555
  });
530
556
  }
531
- async function runOne(ws, script, args, prefixOutput, verbose, exec) {
557
+ async function runOne(ws, script, args, prefixOutput, verbose, exec, cache) {
532
558
  if (exec) {
533
559
  // Arbitrary-command mode: spawn `<script> <args...>` directly
534
560
  // (yarn `workspaces foreach exec`-equivalent). Used by callers
@@ -540,6 +566,13 @@ async function runOne(ws, script, args, prefixOutput, verbose, exec) {
540
566
  await spawnPrefixed(script, args, ws.location, prefixOutput ? `[${ws.name}] ` : null);
541
567
  return;
542
568
  }
569
+ // Content-hash build cache: on a key hit the stored outputs are
570
+ // restored and the script is skipped entirely; on a miss the output
571
+ // dirs are snapshotted so only the dirs the script MODIFIED are stored
572
+ // after it succeeds (never a committed, no-build lib/).
573
+ if (cache && cache.tryRestore(ws))
574
+ return;
575
+ const before = cache?.snapshotOutputs(ws);
543
576
  // Use the same package manager that invoked us — yarn under yarn,
544
577
  // npm under npm, gjsify under gjsify. Default to `npm` for portability
545
578
  // when nothing is detectable; the script-runner (D.5) will replace
@@ -550,6 +583,8 @@ async function runOne(ws, script, args, prefixOutput, verbose, exec) {
550
583
  console.error(`[${ws.name}] $ ${runner} ${argv.join(' ')}`);
551
584
  }
552
585
  await spawnPrefixed(runner, argv, ws.location, prefixOutput ? `[${ws.name}] ` : null);
586
+ if (cache && before)
587
+ cache.storeAfterSuccess(ws, before);
553
588
  }
554
589
  function detectPackageManager() {
555
590
  // Under GJS there is no Node — neither `npm` nor `yarn` can run scripts, so
@@ -15,9 +15,18 @@
15
15
  // Workspace install (`gjsify install` in a monorepo root with a
16
16
  // `"workspaces"` field) hoists every workspace's externals into the root
17
17
  // `node_modules/` and symlinks `workspace:*` / `workspace:^` / `workspace:~`
18
- // refs to their target source. Open follow-ups (see STATUS.md "Open TODOs"):
19
- // per-workspace dedup of conflicting transitive version ranges (first-match
20
- // wins today).
18
+ // refs to their target source. Conflicting version ranges of the same
19
+ // external dep from different workspaces still share ONE hoisted root copy
20
+ // (a real per-workspace dedup pass is Phase D.8, see STATUS.md), but since
21
+ // ADR 0001 step 3 the resolver surfaces every such conflict loudly —
22
+ // `[gjsify] warning: version conflict for <pkg> …` names both ranges, the
23
+ // workspaces that requested them, and the version that actually won.
24
+ //
25
+ // Concurrency (ADR 0001 step 2): every mutation of an install prefix —
26
+ // project node_modules/, gjsify-lock.json, the user-global prefix — runs
27
+ // under a per-prefix cross-process lock (utils/install-lock.ts). Installs
28
+ // into different prefixes stay parallel; the shared XDG tarball/packument
29
+ // caches need no lock because their writes are atomic tmp+rename.
21
30
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
22
31
  import { dirname, join, relative } from 'node:path';
23
32
  import { spawn, spawnSync } from 'node:child_process';
@@ -25,6 +34,8 @@ import { discoverWorkspaces } from '@gjsify/workspace';
25
34
  import { buildInstallCommand, detectPackageManager, runMinimalChecks } from '../utils/check-system-deps.js';
26
35
  import { detectNativePackages } from '../utils/detect-native-packages.js';
27
36
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
37
+ import { atomicWriteStrict } from '../utils/install-cache-fs.js';
38
+ import { acquireInstallLock } from '../utils/install-lock.js';
28
39
  import { binDirOnPath, defaultGlobalLayout, installGjsEnginePackages, linkGlobalBins, specToPackageName, } from '../utils/install-global.js';
29
40
  import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFromPackageJson, readPackageJson, writePackageJson, } from '../utils/pkg-json-edit.js';
30
41
  // Default 30min wall-clock budget for the full install. Big workspaces
@@ -268,40 +279,50 @@ async function projectInstallNative(args, signal) {
268
279
  const progress = makeProgressReporter({
269
280
  enabled: !args.verbose && !args.quiet && args.progress !== false,
270
281
  });
271
- const result = await installPackages({
272
- prefix: cwd,
273
- specs,
274
- verbose: args.verbose,
275
- // --immutable consumes the lockfile verbatim and must NOT rewrite
276
- // it (the whole point is byte-stability under CI).
277
- lockfile: !args.immutable,
278
- frozen: args.immutable,
279
- refreshLockfile: args['refresh-lockfile'],
280
- signal,
281
- progress,
282
- });
283
- // Update package.json only when the user passed explicit packages
284
- // (the `gjsify install <pkg>...` add-a-dep flow). The no-args refresh
285
- // flow doesn't mutate manifest entries.
286
- if (args.packages && args.packages.length > 0 && pkg) {
287
- const kind = depKindFromArgs(args);
288
- for (const spec of args.packages) {
289
- const { name, range } = parseSpec(spec);
290
- const installed = result.installed.find((r) => r.name === name);
291
- const finalRange = range ?? (installed ? defaultRangeFromVersion(installed.version) : 'latest');
292
- addDependencyEntry(pkg, name, finalRange, kind);
293
- }
294
- writePackageJson(pkgPath, pkg);
295
- // Re-sync the lockfile's `requested` field with what
296
- // `projectSpecsFromPackageJson()` will return on the next
297
- // invocation. Without this, a `gjsify install foo` (bare name,
298
- // lockfile records `"foo"`) followed by `gjsify install
299
- // --immutable` (reads package.json → spec `"foo@^1.2.3"`) would
300
- // surface a spurious drift error.
301
- if (!args.immutable) {
302
- syncLockfileRequested(cwd, projectSpecsFromPackageJson(pkg));
282
+ // Hold the per-prefix lock across the install AND the manifest/lockfile
283
+ // sync that follows, so a concurrent `gjsify install <pkg>` in the same
284
+ // project can't interleave its package.json / gjsify-lock.json writes
285
+ // with ours. `installPackages` re-acquires the same lock re-entrantly.
286
+ const lock = await acquireInstallLock(cwd, { signal });
287
+ try {
288
+ const result = await installPackages({
289
+ prefix: cwd,
290
+ specs,
291
+ verbose: args.verbose,
292
+ // --immutable consumes the lockfile verbatim and must NOT rewrite
293
+ // it (the whole point is byte-stability under CI).
294
+ lockfile: !args.immutable,
295
+ frozen: args.immutable,
296
+ refreshLockfile: args['refresh-lockfile'],
297
+ signal,
298
+ progress,
299
+ });
300
+ // Update package.json only when the user passed explicit packages
301
+ // (the `gjsify install <pkg>...` add-a-dep flow). The no-args refresh
302
+ // flow doesn't mutate manifest entries.
303
+ if (args.packages && args.packages.length > 0 && pkg) {
304
+ const kind = depKindFromArgs(args);
305
+ for (const spec of args.packages) {
306
+ const { name, range } = parseSpec(spec);
307
+ const installed = result.installed.find((r) => r.name === name);
308
+ const finalRange = range ?? (installed ? defaultRangeFromVersion(installed.version) : 'latest');
309
+ addDependencyEntry(pkg, name, finalRange, kind);
310
+ }
311
+ writePackageJson(pkgPath, pkg);
312
+ // Re-sync the lockfile's `requested` field with what
313
+ // `projectSpecsFromPackageJson()` will return on the next
314
+ // invocation. Without this, a `gjsify install foo` (bare name,
315
+ // lockfile records `"foo"`) followed by `gjsify install
316
+ // --immutable` (reads package.json → spec `"foo@^1.2.3"`) would
317
+ // surface a spurious drift error.
318
+ if (!args.immutable) {
319
+ syncLockfileRequested(cwd, projectSpecsFromPackageJson(pkg));
320
+ }
303
321
  }
304
322
  }
323
+ finally {
324
+ lock.release();
325
+ }
305
326
  }
306
327
  function syncLockfileRequested(cwd, specs) {
307
328
  const lockPath = join(cwd, 'gjsify-lock.json');
@@ -315,7 +336,9 @@ function syncLockfileRequested(cwd, specs) {
315
336
  return; // Already in sync; preserve byte-stability.
316
337
  }
317
338
  lock.requested = specs;
318
- writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
339
+ // Atomic tmp+rename — same rationale as the backend's lockfile
340
+ // writer: a torn gjsify-lock.json breaks the next `--immutable`.
341
+ atomicWriteStrict(lockPath, JSON.stringify(lock, null, 2) + '\n');
319
342
  }
320
343
  catch {
321
344
  // Best-effort sync; if the lockfile is malformed, the next
@@ -328,23 +351,45 @@ function syncLockfileRequested(cwd, specs) {
328
351
  * 2. Aggregate the union of their external (non-`workspace:`) deps.
329
352
  * 3. Run the native install backend ONCE at the root prefix so all
330
353
  * externals land in a single `node_modules/` (poor-man's hoisting —
331
- * we don't deduplicate version-range conflicts yet, the BFS resolver
332
- * picks first-match).
354
+ * workspaces with conflicting ranges of the same dep share one hoisted
355
+ * copy; the resolver emits a loud `[gjsify] warning: version conflict`
356
+ * naming both sides and the version that won).
333
357
  * 4. For every `workspace:` reference, symlink the target workspace's
334
358
  * directory into the requesting workspace's `node_modules/<dep>`
335
359
  * so `import '@gjsify/utils'` resolves to the local source.
336
360
  *
337
361
  * Hoisting strategy is intentionally minimal — per-workspace dedup +
338
- * nested `node_modules/` for version conflicts are tracked as a follow-up
339
- * in STATUS.md "Open TODOs".
362
+ * nested `node_modules/` for cross-workspace version conflicts are the
363
+ * Phase D.8 follow-up tracked in STATUS.md.
340
364
  */
341
365
  async function workspaceInstall(cwd, args, signal) {
366
+ // Hold the root-prefix lock for the WHOLE workspace flow: bin shims,
367
+ // workspace symlinks and the external install all mutate the root
368
+ // node_modules/, and a second concurrent workspace install interleaving
369
+ // its rm+symlink/extract steps with ours corrupts both. The inner
370
+ // `installPackages` calls re-acquire the same lock re-entrantly (root
371
+ // prefix) or take the child-workspace lock (scoped overrides) — always
372
+ // in root→child order, so there is no lock-ordering cycle.
373
+ const lock = await acquireInstallLock(cwd, { signal });
374
+ try {
375
+ await workspaceInstallLocked(cwd, args, signal);
376
+ }
377
+ finally {
378
+ lock.release();
379
+ }
380
+ }
381
+ /** Body of {@link workspaceInstall}, run while holding the root-prefix lock. */
382
+ async function workspaceInstallLocked(cwd, args, signal) {
342
383
  const workspaces = discoverWorkspaces(cwd, { includeRoot: true });
343
384
  if (workspaces.length === 0) {
344
385
  throw new Error(`gjsify install: ${cwd} has a "workspaces" field but no workspaces were discovered`);
345
386
  }
346
387
  const byName = new Map(workspaces.map((w) => [w.name, w]));
347
388
  const externalSpecs = new Set();
389
+ // Which workspace(s) declared each external spec — `"<name>@<range>"` →
390
+ // workspace names. Feeds the resolver's version-conflict warning so it
391
+ // can attribute both sides of a cross-workspace range conflict.
392
+ const specOrigins = new Map();
348
393
  const symlinks = [];
349
394
  for (const ws of workspaces) {
350
395
  const m = ws.manifest;
@@ -391,7 +436,14 @@ async function workspaceInstall(cwd, args, signal) {
391
436
  }
392
437
  if (/^(link|file|portal|git\+|https?):/.test(spec))
393
438
  continue;
394
- externalSpecs.add(`${depName}@${spec}`);
439
+ const specKey = `${depName}@${spec}`;
440
+ externalSpecs.add(specKey);
441
+ let origins = specOrigins.get(specKey);
442
+ if (!origins) {
443
+ origins = new Set();
444
+ specOrigins.set(specKey, origins);
445
+ }
446
+ origins.add(ws.name);
395
447
  }
396
448
  }
397
449
  }
@@ -624,6 +676,7 @@ async function workspaceInstall(cwd, args, signal) {
624
676
  // symlinks (e.g. a `types-dev/<lib>` workspace that also exists on
625
677
  // npm and is pinned transitively in the lockfile).
626
678
  workspaceNames: new Set(byName.keys()),
679
+ specOrigins: new Map([...specOrigins].map(([k, v]) => [k, [...v]])),
627
680
  });
628
681
  }
629
682
  else if (args.verbose) {
@@ -926,28 +979,42 @@ async function installGlobalAndLink(specs, opts) {
926
979
  mkdirSync(layout.prefix, { recursive: true });
927
980
  console.log(`gjsify install --global → ${layout.prefix}`);
928
981
  console.log(` bins → ${layout.binDir}`);
929
- const result = await installPackages({
930
- prefix: layout.prefix,
931
- specs,
932
- verbose: opts.verbose,
933
- });
934
- const packageNames = specs.map(specToPackageName);
935
- // A global GJS install of `@gjsify/cli` must also lay down the GJS-native
936
- // bundler engine (`@gjsify/rolldown-native`) + the sibling format/CSS
937
- // bridges. They are declared OPTIONAL peers of `@gjsify/cli` (so plain
938
- // `npm install @gjsify/cli` on Node doesn't force a linux prebuild), and
939
- // the native backend doesn't resolve peerDependencies at all — so without
940
- // this they'd never be installed, and `gjsify build` would hard-fail under
941
- // GJS ("no usable bundler engine"). Installed best-effort at the cli's
942
- // resolved version so they move in lockstep with the bundle; a platform
943
- // with no published prebuild degrades to a warning. Must run BEFORE
944
- // linkGlobalBins so the launcher's detectNativePackages() bakes the
945
- // engine's prebuild dirs into the wrapper's GI_TYPELIB_PATH/LD_LIBRARY_PATH.
946
- if (packageNames.includes('@gjsify/cli')) {
947
- const cliVersion = result.installed.find((r) => r.name === '@gjsify/cli')?.version ?? 'latest';
948
- await installGjsEnginePackages(layout.prefix, cliVersion, { verbose: opts.verbose });
982
+ // The global prefix is SHARED user-wide state — two concurrent
983
+ // `gjsify install -g` runs (e.g. parallel agent sessions bootstrapping
984
+ // tooling) used to interleave rm+extract on the same package dirs and
985
+ // race the launcher writes in ~/.local/bin. Hold the prefix lock across
986
+ // the install, the engine-package top-up AND the bin linking (ADR 0001).
987
+ const lock = await acquireInstallLock(layout.prefix, {});
988
+ try {
989
+ const result = await installPackages({
990
+ prefix: layout.prefix,
991
+ specs,
992
+ verbose: opts.verbose,
993
+ });
994
+ const packageNames = specs.map(specToPackageName);
995
+ // A global GJS install of `@gjsify/cli` must also lay down the GJS-native
996
+ // bundler engine (`@gjsify/rolldown-native`) + the sibling format/CSS
997
+ // bridges. They are declared OPTIONAL peers of `@gjsify/cli` (so plain
998
+ // `npm install @gjsify/cli` on Node doesn't force a linux prebuild), and
999
+ // the native backend doesn't resolve peerDependencies at all — so without
1000
+ // this they'd never be installed, and `gjsify build` would hard-fail under
1001
+ // GJS ("no usable bundler engine"). Installed best-effort at the cli's
1002
+ // resolved version so they move in lockstep with the bundle; a platform
1003
+ // with no published prebuild degrades to a warning. Must run BEFORE
1004
+ // linkGlobalBins so the launcher's detectNativePackages() bakes the
1005
+ // engine's prebuild dirs into the wrapper's GI_TYPELIB_PATH/LD_LIBRARY_PATH.
1006
+ if (packageNames.includes('@gjsify/cli')) {
1007
+ const cliVersion = result.installed.find((r) => r.name === '@gjsify/cli')?.version ?? 'latest';
1008
+ await installGjsEnginePackages(layout.prefix, cliVersion, { verbose: opts.verbose });
1009
+ }
1010
+ const created = linkGlobalBins(packageNames, layout);
1011
+ reportLinkedBins(created, layout.binDir);
1012
+ }
1013
+ finally {
1014
+ lock.release();
949
1015
  }
950
- const created = linkGlobalBins(packageNames, layout);
1016
+ }
1017
+ function reportLinkedBins(created, binDir) {
951
1018
  if (created.length === 0) {
952
1019
  console.warn('\nNo bins declared (neither `gjsify.bin` nor `bin` in package.json) — nothing was symlinked.');
953
1020
  }
@@ -957,9 +1024,9 @@ async function installGlobalAndLink(specs, opts) {
957
1024
  console.log(` • ${e.link} → ${e.target}`);
958
1025
  }
959
1026
  }
960
- if (created.length > 0 && !binDirOnPath(layout.binDir)) {
961
- console.warn(`\nNote: ${layout.binDir} is not on your PATH.\n` +
962
- `Add it to your shell rc file:\n export PATH="${layout.binDir}:$PATH"`);
1027
+ if (created.length > 0 && !binDirOnPath(binDir)) {
1028
+ console.warn(`\nNote: ${binDir} is not on your PATH.\n` +
1029
+ `Add it to your shell rc file:\n export PATH="${binDir}:$PATH"`);
963
1030
  }
964
1031
  }
965
1032
  async function runPostInstallChecks() {
@@ -5,6 +5,7 @@ interface StorybookCliOptions {
5
5
  title?: string;
6
6
  globals?: string;
7
7
  out?: string;
8
+ runtime?: string;
8
9
  watch: boolean;
9
10
  buildOnly: boolean;
10
11
  }
@@ -1,7 +1,14 @@
1
1
  // `gjsify storybook` — discover every *.story.ts in a project, generate a tiny
2
- // entry that registers them, then build (`--app gjs`) and launch the generic
2
+ // entry that registers them, then build and launch the generic
3
3
  // @gjsify/storybook component browser. No per-project storybook app needed.
4
4
  //
5
+ // `--runtime gjs` (default) builds `--app gjs` and runs it with `gjs -m`.
6
+ // `--runtime node` builds `--app node` (gi:// → @gjsify/node-gi reverse bridge)
7
+ // and runs it with `node` — the SAME storybook source rendering on Node.js. The
8
+ // node path wires GI_TYPELIB_PATH/LD_LIBRARY_PATH like `gjsify run` (so any
9
+ // native @gjsify bridge a story uses resolves) and requires `@gjsify/node-gi`
10
+ // installed in the project (kept external by the bundler, a native addon).
11
+ //
5
12
  // Dispatch is IN-PROCESS (runCli + runGjsBundle), not a re-spawn of the CLI:
6
13
  // the showcase-style `spawn(process.execPath, [cliBin, …])` pattern assumes
7
14
  // Node, but under the committed GJS bundle `process.execPath` is `gjs` and the
@@ -82,6 +89,13 @@ export const storybookCommand = {
82
89
  .option('out', {
83
90
  description: 'Output bundle path (default: node_modules/.cache/gjsify-storybook)',
84
91
  type: 'string',
92
+ })
93
+ .option('runtime', {
94
+ description: 'Runtime to build for and launch on: gjs (default) or node (via @gjsify/node-gi). ' +
95
+ 'node requires @gjsify/node-gi installed in the project.',
96
+ type: 'string',
97
+ choices: ['gjs', 'node'],
98
+ default: 'gjs',
85
99
  })
86
100
  .option('watch', {
87
101
  description: 'Rebuild and relaunch when a story file changes',
@@ -97,10 +111,16 @@ export const storybookCommand = {
97
111
  const applicationId = args.appId ?? config.applicationId ?? deriveAppId(pkg.name);
98
112
  const title = args.title ?? config.title;
99
113
  const globals = args.globals ?? config.globals ?? 'auto';
114
+ const runtime = (args.runtime ?? config.runtime ?? 'gjs');
115
+ // The runtime drives BOTH the build target (`gjsify build --app <app>`)
116
+ // and the launcher: `gjs` builds an `--app gjs` bundle run by `gjs -m`,
117
+ // `node` builds an `--app node` bundle (gi:// → @gjsify/node-gi) run by
118
+ // `node`. Same generated entry + `--globals auto` for both.
119
+ const app = runtime === 'node' ? 'node' : 'gjs';
100
120
  const cacheDir = join(cwd, 'node_modules', '.cache', 'gjsify-storybook');
101
121
  mkdirSync(cacheDir, { recursive: true });
102
122
  const entryPath = join(cacheDir, 'entry.ts');
103
- const outPath = args.out ? resolve(cwd, args.out) : join(cacheDir, 'storybook.gjs.mjs');
123
+ const outPath = args.out ? resolve(cwd, args.out) : join(cacheDir, `storybook.${app}.mjs`);
104
124
  const writeEntry = () => {
105
125
  const files = findStoryFiles(storiesDir).sort();
106
126
  writeFileSync(entryPath, generateEntry(files, applicationId, title));
@@ -111,24 +131,46 @@ export const storybookCommand = {
111
131
  console.error(`No *.story.ts files found under ${relative(cwd, storiesDir) || storiesDir}`);
112
132
  process.exit(1);
113
133
  }
114
- console.log(`Found ${initialCount} story file(s) under ${relative(cwd, storiesDir) || '.'}`);
134
+ console.log(`Found ${initialCount} story file(s) under ${relative(cwd, storiesDir) || '.'} (runtime: ${runtime})`);
115
135
  // In-process build — runtime-agnostic (Node uses npm rolldown, GJS uses
116
136
  // the @gjsify/rolldown-native facade), no re-spawn of the CLI binary.
117
137
  const { runCli } = await import('../cli-app.js');
118
- const buildBundle = () => runCli(['build', entryPath, '--app', 'gjs', '--globals', globals, '--outfile', outPath]);
138
+ const buildBundle = () => runCli(['build', entryPath, '--app', app, '--globals', globals, '--outfile', outPath]);
139
+ const launch = async () => {
140
+ if (runtime === 'node') {
141
+ const { runNodeBundle } = await import('../utils/run-node.js');
142
+ await runNodeBundle(outPath, [], { exitOnSuccess: true });
143
+ }
144
+ else {
145
+ const { runGjsBundle } = await import('../utils/run-gjs.js');
146
+ await runGjsBundle(outPath, [], { exitOnSuccess: true });
147
+ }
148
+ };
119
149
  if (!args.watch) {
120
150
  await buildBundle();
121
151
  if (args.buildOnly)
122
152
  return;
123
- const { runGjsBundle } = await import('../utils/run-gjs.js');
124
- await runGjsBundle(outPath, [], { exitOnSuccess: true });
153
+ await launch();
125
154
  return;
126
155
  }
127
156
  // Watch mode: rebuild + relaunch on any change under the stories dir.
128
157
  // The storybook bundle only needs system Gtk/Adw typelibs at runtime
129
- // (no @gjsify native bridges), so a plain `gjs -m` child is enough and
130
- // stays killable for relaunch.
158
+ // (no @gjsify native bridges), so a plain `gjs -m` / `node` child is
159
+ // enough and stays killable for relaunch.
131
160
  const { watch } = await import('node:fs');
161
+ const spawnChild = async () => {
162
+ if (runtime === 'node') {
163
+ const { computeNativeEnvForBundle } = await import('../utils/run-gjs.js');
164
+ const { resolveNodeGi, nodeBinary } = await import('../utils/run-node.js');
165
+ if (!resolveNodeGi(cwd)) {
166
+ throw new Error('Cannot run the storybook on Node: `@gjsify/node-gi` is not installed. ' +
167
+ 'Add @gjsify/node-gi as a dependency to run the storybook on Node.');
168
+ }
169
+ const { env: nativeEnv } = computeNativeEnvForBundle(outPath, cwd);
170
+ return spawn(nodeBinary(), [outPath], { stdio: 'inherit', env: { ...process.env, ...nativeEnv } });
171
+ }
172
+ return spawn('gjs', ['-m', outPath], { stdio: 'inherit' });
173
+ };
132
174
  let child = null;
133
175
  let rebuilding = false;
134
176
  let pending = false;
@@ -146,7 +188,7 @@ export const storybookCommand = {
146
188
  }
147
189
  await buildBundle();
148
190
  if (!args.buildOnly) {
149
- child = spawn('gjs', ['-m', outPath], { stdio: 'inherit' });
191
+ child = await spawnChild();
150
192
  }
151
193
  }
152
194
  catch (err) {
@@ -7,6 +7,7 @@ interface WorkspaceCmdOptions {
7
7
  'continue-on-error'?: boolean;
8
8
  'include-dev'?: boolean;
9
9
  verbose?: boolean;
10
+ cached?: boolean;
10
11
  }
11
12
  export declare const workspaceCommand: Command<unknown, WorkspaceCmdOptions>;
12
13
  export {};