@gjsify/cli 0.11.0 → 0.13.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.
@@ -6,6 +6,8 @@ interface BrowseCliOptions {
6
6
  globals?: string;
7
7
  out?: string;
8
8
  devtools: boolean;
9
+ inspectorPort?: number;
10
+ screenshot?: string;
9
11
  buildOnly: boolean;
10
12
  }
11
13
  /** Derive a DBus app id from the package name (mirrored by `gjsify debug`). */
@@ -24,14 +24,16 @@ export function deriveBrowserAppId(name) {
24
24
  .replace(/^\.+|\.+$/g, '');
25
25
  return `org.gjsify.browser.${slug}`;
26
26
  }
27
- function generateEntry(applicationId, url, title) {
27
+ function generateEntry(applicationId, url, title, inspectorPort, screenshot) {
28
28
  const titleLine = title ? `\n title: ${JSON.stringify(title)},` : '';
29
29
  const urlLine = url ? `\n homeUrl: ${JSON.stringify(url)},` : '';
30
+ const inspectorLine = inspectorPort !== undefined ? `\n inspectorPort: ${inspectorPort},` : '';
31
+ const screenshotLine = screenshot ? `\n screenshot: ${JSON.stringify(screenshot)},` : '';
30
32
  return `// Auto-generated by \`gjsify browse\` — do not edit by hand.
31
33
  import { runBrowserDevtools } from '@gjsify/devtools-browser';
32
34
 
33
35
  await runBrowserDevtools({
34
- applicationId: ${JSON.stringify(applicationId)},${titleLine}${urlLine}
36
+ applicationId: ${JSON.stringify(applicationId)},${titleLine}${urlLine}${inspectorLine}${screenshotLine}
35
37
  });
36
38
  `;
37
39
  }
@@ -57,6 +59,14 @@ export const browseCommand = {
57
59
  description: 'Enable the MCP devtools control plane (sets GJSIFY_DEVTOOLS=1)',
58
60
  type: 'boolean',
59
61
  default: false,
62
+ })
63
+ .option('inspector-port', {
64
+ description: "Enable WebKit's remote inspector protocol on this port + the Cdp* methods (implies --devtools)",
65
+ type: 'number',
66
+ })
67
+ .option('screenshot', {
68
+ description: 'One-shot: load the URL, capture a WebKit screenshot to this path, and exit (CI / quick captures).',
69
+ type: 'string',
60
70
  })
61
71
  .option('build-only', {
62
72
  description: 'Build the bundle but do not launch it',
@@ -71,12 +81,16 @@ export const browseCommand = {
71
81
  const title = args.title ?? config.title;
72
82
  const globals = args.globals ?? config.globals ?? 'auto,dom';
73
83
  const url = args.url;
84
+ const inspectorPort = args.inspectorPort ?? config.inspectorPort;
85
+ // CDP-over-MCP needs the control plane, so --inspector-port implies --devtools.
86
+ const devtools = args.devtools || inspectorPort !== undefined;
74
87
  const cacheDir = join(cwd, 'node_modules', '.cache', 'gjsify-browse');
75
88
  mkdirSync(cacheDir, { recursive: true });
76
89
  const entryPath = join(cacheDir, 'entry.ts');
77
90
  const outPath = args.out ? resolve(cwd, args.out) : join(cacheDir, 'browse.gjs.mjs');
78
- writeFileSync(entryPath, generateEntry(applicationId, url, title));
79
- console.log(`[gjsify browse] app=${applicationId}${url ? ` url=${url}` : ''}${args.devtools ? ' devtools=1' : ''}`);
91
+ const screenshotPath = args.screenshot ? resolve(cwd, args.screenshot) : undefined;
92
+ writeFileSync(entryPath, generateEntry(applicationId, url, title, inspectorPort, screenshotPath));
93
+ console.log(`[gjsify browse] app=${applicationId}${url ? ` url=${url}` : ''}${devtools ? ' devtools=1' : ''}${inspectorPort !== undefined ? ` inspector-port=${inspectorPort}` : ''}`);
80
94
  const { runCli } = await import('../cli-app.js');
81
95
  await runCli(['build', entryPath, '--app', 'gjs', '--globals', globals, '--outfile', outPath]);
82
96
  if (args.buildOnly) {
@@ -85,8 +99,18 @@ export const browseCommand = {
85
99
  }
86
100
  // runGjsBundle spreads process.env into the child, so set the devtools
87
101
  // gate here (RunGjsBundleOptions has no env field).
88
- if (args.devtools)
102
+ if (devtools)
89
103
  process.env.GJSIFY_DEVTOOLS = '1';
104
+ // Set the inspector env in the LAUNCHER so the spawned gjs has it from
105
+ // process start. WebKit reads WEBKIT_INSPECTOR_HTTP_SERVER once, at
106
+ // WebKitInitialize (triggered as the first WebView type initializes —
107
+ // before the app's own constructor runs), so setting it only inside the
108
+ // app is too late to bind the server. This is the authoritative place;
109
+ // the BrowserApplication constructor keeps a best-effort fallback for
110
+ // direct runBrowserDevtools() callers.
111
+ if (inspectorPort !== undefined) {
112
+ process.env.WEBKIT_INSPECTOR_HTTP_SERVER = `127.0.0.1:${inspectorPort}`;
113
+ }
90
114
  const { runGjsBundle } = await import('../utils/run-gjs.js');
91
115
  await runGjsBundle(outPath, [], { exitOnSuccess: true });
92
116
  },
@@ -56,6 +56,13 @@ await runDevtoolsMcp(storybookProfile(${JSON.stringify(busNameBase)}));
56
56
  import { runDevtoolsMcp, browserProfile } from '@gjsify/devtools-mcp';
57
57
 
58
58
  await runDevtoolsMcp(browserProfile(${JSON.stringify(busNameBase)}));
59
+ `;
60
+ }
61
+ if (profile === 'cdp') {
62
+ return `// Auto-generated by \`gjsify debug\` — do not edit by hand.
63
+ import { runDevtoolsMcp, cdpProfile } from '@gjsify/devtools-mcp';
64
+
65
+ await runDevtoolsMcp(cdpProfile(${JSON.stringify(busNameBase)}));
59
66
  `;
60
67
  }
61
68
  return `// Auto-generated by \`gjsify debug\` — do not edit by hand.
@@ -77,7 +84,7 @@ export const debugCommand = {
77
84
  type: 'string',
78
85
  })
79
86
  .option('profile', {
80
- description: 'Tool profile: generic | storybook | browser (default: auto — storybook if @gjsify/storybook is a dep, browser if @gjsify/devtools-browser is, else generic)',
87
+ description: 'Tool profile: generic | storybook | browser | cdp (default: auto — storybook if @gjsify/storybook is a dep, browser if @gjsify/devtools-browser is, cdp if @gjsify/devtools-cdp is, else generic)',
81
88
  type: 'string',
82
89
  })
83
90
  .option('globals', {
@@ -103,12 +110,14 @@ export const debugCommand = {
103
110
  ? 'storybook'
104
111
  : hasDep(pkg, '@gjsify/devtools-browser')
105
112
  ? 'browser'
106
- : 'generic');
113
+ : hasDep(pkg, '@gjsify/devtools-cdp')
114
+ ? 'cdp'
115
+ : 'generic');
107
116
  const busNameBase = args.busName ??
108
117
  config.busNameBase ??
109
118
  (profile === 'storybook'
110
119
  ? (pkg.gjsify?.storybook?.applicationId ?? deriveStorybookAppId(pkg.name))
111
- : profile === 'browser'
120
+ : profile === 'browser' || profile === 'cdp'
112
121
  ? (pkg.gjsify?.browse?.applicationId ?? deriveBrowserAppId(pkg.name))
113
122
  : undefined);
114
123
  if (!busNameBase) {
@@ -8,6 +8,7 @@ interface ForeachOptions {
8
8
  'topological-dev'?: boolean;
9
9
  include?: string[];
10
10
  exclude?: string[];
11
+ shard?: string;
11
12
  private?: boolean;
12
13
  verbose?: boolean;
13
14
  jobs?: number;
@@ -238,6 +238,10 @@ export const foreachCommand = {
238
238
  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
239
  type: 'boolean',
240
240
  default: false,
241
+ })
242
+ .option('shard', {
243
+ 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.',
244
+ type: 'string',
241
245
  })
242
246
  .parserConfiguration({
243
247
  // Preserve `--` as args._['--'] so callers can write
@@ -307,6 +311,33 @@ export const foreachCommand = {
307
311
  return typeof scripts[scriptName] === 'string';
308
312
  });
309
313
  }
314
+ // Optional sharding: run only a deterministic slice of the matched set,
315
+ // for fanning a long foreach (e.g. tests) across N parallel CI jobs.
316
+ // "<index>/<total>", 1-based. Partition by sorted name, round-robin, so
317
+ // the shards are disjoint and their union is exactly the full matched
318
+ // set with even-ish balance. Applied AFTER include/exclude + the
319
+ // script-presence filter so each shard is a slice of the actually-
320
+ // runnable set. Order-independent → safe for tests, NOT for ordered
321
+ // builds (it runs before the topological sort below, which then just
322
+ // orders whatever this shard kept).
323
+ if (typeof args.shard === 'string' && args.shard.length > 0) {
324
+ const m = /^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(args.shard);
325
+ if (!m) {
326
+ console.error(`gjsify foreach: invalid --shard "${args.shard}" — expected "<index>/<total>" (1-based), e.g. 2/4.`);
327
+ process.exit(1);
328
+ }
329
+ const index = Number(m[1]);
330
+ const total = Number(m[2]);
331
+ if (total < 1 || index < 1 || index > total) {
332
+ console.error(`gjsify foreach: invalid --shard "${args.shard}" — need 1 <= index <= total and total >= 1.`);
333
+ process.exit(1);
334
+ }
335
+ if (total > 1) {
336
+ const byName = [...selected].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
337
+ selected = byName.filter((_, i) => i % total === index - 1);
338
+ console.log(`gjsify foreach: shard ${index}/${total} → ${selected.length} of ${byName.length} workspace(s)`);
339
+ }
340
+ }
310
341
  if (selected.length === 0) {
311
342
  console.log(`gjsify foreach: no workspaces match (${exec ? 'exec' : 'script'}="${cmd}", include=${JSON.stringify(args.include ?? [])}, exclude=${JSON.stringify(args.exclude ?? [])})`);
312
343
  return;
@@ -25,7 +25,7 @@ import { discoverWorkspaces } from '@gjsify/workspace';
25
25
  import { buildInstallCommand, detectPackageManager, runMinimalChecks } from '../utils/check-system-deps.js';
26
26
  import { detectNativePackages } from '../utils/detect-native-packages.js';
27
27
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
28
- import { binDirOnPath, defaultGlobalLayout, linkGlobalBins, specToPackageName } from '../utils/install-global.js';
28
+ import { binDirOnPath, defaultGlobalLayout, installGjsEnginePackages, linkGlobalBins, specToPackageName, } from '../utils/install-global.js';
29
29
  import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFromPackageJson, readPackageJson, writePackageJson, } from '../utils/pkg-json-edit.js';
30
30
  // Default 30min wall-clock budget for the full install. Big workspaces
31
31
  // (212+ sub-packages × 600+ external deps in the gjsify monorepo itself)
@@ -920,12 +920,27 @@ async function installGlobalAndLink(specs, opts) {
920
920
  mkdirSync(layout.prefix, { recursive: true });
921
921
  console.log(`gjsify install --global → ${layout.prefix}`);
922
922
  console.log(` bins → ${layout.binDir}`);
923
- await installPackages({
923
+ const result = await installPackages({
924
924
  prefix: layout.prefix,
925
925
  specs,
926
926
  verbose: opts.verbose,
927
927
  });
928
928
  const packageNames = specs.map(specToPackageName);
929
+ // A global GJS install of `@gjsify/cli` must also lay down the GJS-native
930
+ // bundler engine (`@gjsify/rolldown-native`) + the sibling format/CSS
931
+ // bridges. They are declared OPTIONAL peers of `@gjsify/cli` (so plain
932
+ // `npm install @gjsify/cli` on Node doesn't force a linux prebuild), and
933
+ // the native backend doesn't resolve peerDependencies at all — so without
934
+ // this they'd never be installed, and `gjsify build` would hard-fail under
935
+ // GJS ("no usable bundler engine"). Installed best-effort at the cli's
936
+ // resolved version so they move in lockstep with the bundle; a platform
937
+ // with no published prebuild degrades to a warning. Must run BEFORE
938
+ // linkGlobalBins so the launcher's detectNativePackages() bakes the
939
+ // engine's prebuild dirs into the wrapper's GI_TYPELIB_PATH/LD_LIBRARY_PATH.
940
+ if (packageNames.includes('@gjsify/cli')) {
941
+ const cliVersion = result.installed.find((r) => r.name === '@gjsify/cli')?.version ?? 'latest';
942
+ await installGjsEnginePackages(layout.prefix, cliVersion, { verbose: opts.verbose });
943
+ }
929
944
  const created = linkGlobalBins(packageNames, layout);
930
945
  if (created.length === 0) {
931
946
  console.warn('\nNo bins declared (neither `gjsify.bin` nor `bin` in package.json) — nothing was symlinked.');
@@ -16,7 +16,7 @@ import { dirname, join, resolve } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { fetchPackument } from '@gjsify/npm-registry';
18
18
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
19
- import { defaultGlobalLayout, linkGlobalBins } from '../utils/install-global.js';
19
+ import { defaultGlobalLayout, hasBundlerEngineInstalled, installGjsEnginePackages, linkGlobalBins, } from '../utils/install-global.js';
20
20
  const PACKAGE_NAME = '@gjsify/cli';
21
21
  export const selfUpdateCommand = {
22
22
  command: 'self-update',
@@ -76,12 +76,31 @@ export const selfUpdateCommand = {
76
76
  return;
77
77
  }
78
78
  console.log(`Latest matching --tag ${args.tag}: v${target}`);
79
- if (currentVersion === target && !args.force) {
79
+ // A version match is NOT sufficient to declare "up to date": a global
80
+ // GJS install can be on the right `@gjsify/cli` version yet be MISSING
81
+ // the GJS bundler engine (`@gjsify/rolldown-native`) — it's an optional
82
+ // peer the native backend never resolves, so an install.mjs bootstrap
83
+ // before this fix laid down the cli but not the engine, and `gjsify
84
+ // build` hard-fails ("no usable bundler engine"). When the version
85
+ // matches but the engine is absent, REPAIR it (install the engine +
86
+ // re-link) instead of printing "Already up to date". `--skip-deps`
87
+ // still honours its contract (don't touch on-disk deps) and `--check`
88
+ // only reports, so neither triggers the repair.
89
+ const engineMissing = installedAtPrefix && !hasBundlerEngineInstalled(layout.prefix);
90
+ const needsEngineRepair = engineMissing && !args.skipDeps && !args.check;
91
+ if (currentVersion === target && !args.force && !needsEngineRepair) {
80
92
  console.log(`Already up to date (v${target}).`);
93
+ if (engineMissing && args.skipDeps) {
94
+ console.log(`Note: the GJS bundler engine @gjsify/rolldown-native is not installed — ` +
95
+ `re-run without --skip-deps to repair it (\`gjsify build\` needs it under GJS).`);
96
+ }
81
97
  if (!args.check)
82
98
  console.log(`Run with --force to reinstall anyway.`);
83
99
  return;
84
100
  }
101
+ if (needsEngineRepair && currentVersion === target && !args.force) {
102
+ console.log(`@gjsify/cli is at v${target} but the GJS bundler engine @gjsify/rolldown-native is missing — repairing.`);
103
+ }
85
104
  if (args.check) {
86
105
  console.log(currentVersion ? `Update available: v${currentVersion} → v${target}` : `Install required: → v${target}`);
87
106
  process.exit(1);
@@ -125,6 +144,14 @@ export const selfUpdateCommand = {
125
144
  process.exit(1);
126
145
  return;
127
146
  }
147
+ // Lay down (or repair) the GJS-native engine bridges in lockstep with
148
+ // the bundle — same set + best-effort handling as the global installer.
149
+ // Gated on `pullDeps` so `--skip-deps` keeps its bundle-only contract.
150
+ // Must run BEFORE linkGlobalBins so the launcher's detectNativePackages()
151
+ // bakes the engine's prebuild dirs into the wrapper env preamble.
152
+ if (pullDeps) {
153
+ await installGjsEnginePackages(layout.prefix, target, { verbose: false });
154
+ }
128
155
  const linked = linkGlobalBins([PACKAGE_NAME], layout);
129
156
  if (linked.length === 0) {
130
157
  console.warn('self-update: install completed but no bins were linked — package.json may be missing a `bin` field.');
@@ -129,12 +129,23 @@ export const tscCommand = {
129
129
  // `gjs` is not on PATH (ENOENT), transparently fall back to upstream
130
130
  // `typescript` under Node when available.
131
131
  if (bundlePath) {
132
+ // A failed spawn (notably ENOENT when `gjs` is absent) emits BOTH
133
+ // 'error' AND 'close'. The 'error' handler owns the outcome (Node
134
+ // fallback or a clear exit), so the 'close' handler must NOT also
135
+ // exit — otherwise it races and exits with the spawn errno (e.g.
136
+ // ENOENT → -2), which surfaced as a misleading 254 and pre-empted the
137
+ // fallback (gjsify tsc died instead of degrading to upstream tsc on a
138
+ // gjs-less host such as a CI runner).
139
+ let gjsSpawnFailed = false;
132
140
  const child = spawn('gjs', ['-m', bundlePath, ...tscArgs], { env, stdio: 'inherit' });
133
141
  await new Promise((resolvePromise) => {
134
142
  child.on('close', (code) => {
143
+ if (gjsSpawnFailed)
144
+ return;
135
145
  process.exit(code ?? 1);
136
146
  });
137
147
  child.on('error', (err) => {
148
+ gjsSpawnFailed = true;
138
149
  if (err.code === 'ENOENT' && nodeTscPath) {
139
150
  // No gjs on PATH — run upstream typescript under Node.
140
151
  runNodeTsc();
package/lib/index.js CHANGED
@@ -6,4 +6,23 @@
6
6
  // This file is just the executable wrapper around `runCli`.
7
7
  import { hideBin } from 'yargs/helpers';
8
8
  import { runCli } from './cli-app.js';
9
- await runCli(hideBin(process.argv));
9
+ try {
10
+ await runCli(hideBin(process.argv));
11
+ }
12
+ catch (err) {
13
+ // A command handler (or yargs) rejected. Print the message cleanly and exit
14
+ // 1 on BOTH runtimes. Without this, the rejection escapes the entry's
15
+ // top-level await as an UNHANDLED rejection: Node prints it, but GJS surfaces
16
+ // a scary `Gjs-CRITICAL **: Module … threw an exception` that buries the
17
+ // real, already-actionable message (e.g. "gjsify build: no usable bundler
18
+ // engine under GJS — build the facade first or run under Node"). See #597.
19
+ console.error(err instanceof Error ? err.message : String(err));
20
+ if (process.env.GJSIFY_DEBUG && err instanceof Error && err.stack)
21
+ console.error(err.stack);
22
+ // `process.exitCode` is the gentle Node idiom (streams flush). GJS has no
23
+ // atexit hook and would exit 0 at natural shutdown, so force a non-zero exit
24
+ // via `imports.system.exit` there (no-op on Node, where `imports` is unset).
25
+ process.exitCode = 1;
26
+ const gjs = globalThis.imports;
27
+ gjs?.system?.exit?.(1);
28
+ }
@@ -631,8 +631,14 @@ async function downloadAndExtractAll(nodes, prefix, npmrc, log, signal, progress
631
631
  const concurrency = Math.max(1, Math.min(DEFAULT_CONCURRENCY, queue.length));
632
632
  progress?.beginPhase('download', queue.length);
633
633
  let completed = 0;
634
- const tickProgress = (node) => {
634
+ // Count how many nodes were already correctly materialised on disk and
635
+ // skipped (the npm "unchanged" set). Surfaced in the summary log so a warm
636
+ // install makes it obvious why the phase finished in seconds.
637
+ let skipped = 0;
638
+ const tickProgress = (node, wasSkipped) => {
635
639
  completed++;
640
+ if (wasSkipped)
641
+ skipped++;
636
642
  progress?.update({
637
643
  phase: 'download',
638
644
  current: completed,
@@ -648,30 +654,91 @@ async function downloadAndExtractAll(nodes, prefix, npmrc, log, signal, progress
648
654
  const splitAt = depth1End < 0 ? queue.length : depth1End;
649
655
  // Serial root pass.
650
656
  while (cursor < splitAt) {
657
+ if (signal?.aborted)
658
+ throw abortError(signal);
651
659
  const node = queue[cursor++];
652
660
  if (!node)
653
661
  break;
654
- await extractOne(node, prefix, npmrc, log, signal);
655
- tickProgress(node);
662
+ const wasSkipped = await extractOne(node, prefix, npmrc, log, signal);
663
+ tickProgress(node, wasSkipped);
656
664
  }
657
665
  // Concurrent nested pass.
658
666
  for (let i = 0; i < concurrency; i++) {
659
667
  workers.push((async () => {
660
668
  while (true) {
669
+ // Honour an aborted overall-install budget inside the pool.
670
+ // Without this, a fired --timeout (or Ctrl-C) only aborts
671
+ // the NETWORK fetches; a tree that is mostly cache-hits /
672
+ // already-extracted keeps churning the extract loop to
673
+ // completion, so the install never actually stops when
674
+ // asked to (a contributor to the observed "it never
675
+ // completed; I killed it" hang).
676
+ if (signal?.aborted)
677
+ throw abortError(signal);
661
678
  const idx = cursor++;
662
679
  if (idx >= queue.length)
663
680
  return;
664
681
  const node = queue[idx];
665
682
  if (!node)
666
683
  return;
667
- await extractOne(node, prefix, npmrc, log, signal);
668
- tickProgress(node);
684
+ const wasSkipped = await extractOne(node, prefix, npmrc, log, signal);
685
+ tickProgress(node, wasSkipped);
669
686
  }
670
687
  })());
671
688
  }
672
689
  await Promise.all(workers);
673
690
  progress?.endPhase('download');
691
+ if (skipped > 0) {
692
+ log('install: %d/%d package(s) already up to date — extracted %d', skipped, queue.length, queue.length - skipped);
693
+ }
674
694
  }
695
+ /**
696
+ * Is `name@version` already correctly materialised at `dest`? Reads
697
+ * `<dest>/package.json` and returns true iff its `name` AND `version` match the
698
+ * resolved node exactly. This is the npm/yarn/pnpm "unchanged node" check — an
699
+ * already-present, correct copy is skipped instead of being `rm`-ed and
700
+ * re-extracted.
701
+ *
702
+ * Conservative by design: a missing / unreadable / unparseable package.json, or
703
+ * ANY name/version mismatch, returns false so the caller falls through to the
704
+ * full rm + extract. The only thing this fast-paths is the exact-match case,
705
+ * which is the overwhelming majority of nodes on a warm re-install (the whole
706
+ * resolved tree minus the genuinely-new subtree). Skipping the re-extract is
707
+ * what turns a warm `gjsify install` on a 2000+-package workspace from a
708
+ * many-minute re-extract of every tarball (each gunzip routed through GJS's
709
+ * Gio.ZlibDecompressor on the single GLib main loop) into a near-instant no-op.
710
+ */
711
+ function isAlreadyExtracted(dest, node) {
712
+ const manifestPath = path.join(dest, 'package.json');
713
+ let raw;
714
+ try {
715
+ raw = fs.readFileSync(manifestPath, 'utf-8');
716
+ }
717
+ catch {
718
+ return false;
719
+ }
720
+ try {
721
+ const parsed = JSON.parse(raw);
722
+ // Match version exactly. Match name too when present — a stale dir from
723
+ // a previous resolve could hold a different package at the same path
724
+ // (e.g. a nested placement that moved), in which case the version alone
725
+ // could coincidentally collide. A missing `name` (rare, malformed) is
726
+ // tolerated as long as the version matches.
727
+ if (parsed.version !== node.version)
728
+ return false;
729
+ if (typeof parsed.name === 'string' && parsed.name !== node.name)
730
+ return false;
731
+ return true;
732
+ }
733
+ catch {
734
+ return false;
735
+ }
736
+ }
737
+ /**
738
+ * Download + extract one node, or skip it when an identical copy is already on
739
+ * disk. Returns `true` when the node was skipped (already up to date), `false`
740
+ * when it was (re-)extracted.
741
+ */
675
742
  async function extractOne(node, prefix, npmrc, log, signal) {
676
743
  const dest = path.join(prefix, node.installPath);
677
744
  // Defense-in-depth against the workspace-source-wipe data-loss bug:
@@ -683,6 +750,15 @@ async function extractOne(node, prefix, npmrc, log, signal) {
683
750
  // source dir — the realpath check additionally rejects a `dest` that
684
751
  // resolves THROUGH a symlink into a directory outside node_modules.
685
752
  assertNodeModulesDest(dest, node);
753
+ // Idempotent fast-path: the package is already extracted at the resolved
754
+ // version. Skip the cache read + rm + re-extract entirely — this is the
755
+ // npm/yarn/pnpm default (only added/changed nodes are written), and it is
756
+ // the dominant cost on a warm re-install. Force a full re-extract with
757
+ // GJSIFY_INSTALL_FORCE_EXTRACT=1 (debug / corrupted-tree recovery).
758
+ if (process.env.GJSIFY_INSTALL_FORCE_EXTRACT !== '1' && isAlreadyExtracted(dest, node)) {
759
+ log('up-to-date: %s@%s (already extracted at %s)', node.name, node.version, node.installPath);
760
+ return true;
761
+ }
686
762
  // Hit the content-addressable cache before touching the network.
687
763
  // Tarballs are immutable per SRI integrity, so a hash hit means the
688
764
  // cached bytes are byte-identical to whatever the registry would
@@ -717,6 +793,21 @@ async function extractOne(node, prefix, npmrc, log, signal) {
717
793
  fs.rmSync(dest, { recursive: true, force: true });
718
794
  fs.mkdirSync(dest, { recursive: true });
719
795
  await extractTarball(bytes, dest);
796
+ return false;
797
+ }
798
+ /**
799
+ * Canonical AbortError carrying the signal's `reason` when it is an Error
800
+ * (e.g. the overall-install timeout sentinel), so `isAbortedFromOverallTimeout`
801
+ * in the install command recognises it and prints the actionable timeout
802
+ * message instead of a raw stack.
803
+ */
804
+ function abortError(signal) {
805
+ const reason = signal && 'reason' in signal ? signal.reason : undefined;
806
+ if (reason instanceof Error)
807
+ return reason;
808
+ const err = new Error('Aborted');
809
+ err.name = 'AbortError';
810
+ return err;
720
811
  }
721
812
  /**
722
813
  * Guard: a tarball may only be extracted into a `node_modules/` directory.
@@ -10,6 +10,47 @@ export interface GlobalLayout {
10
10
  * `GJSIFY_GLOBAL_PREFIX` and `GJSIFY_GLOBAL_BIN_DIR` escape hatches for tests.
11
11
  */
12
12
  export declare function defaultGlobalLayout(): GlobalLayout;
13
+ /**
14
+ * The GJS-native tooling bridges the global CLI needs to run a build under GJS.
15
+ *
16
+ * These are declared OPTIONAL peer dependencies of `@gjsify/cli` (so a plain
17
+ * `npm install @gjsify/cli` on Node — where the npm `rolldown` / `lightningcss`
18
+ * / `oxfmt` crates work — does not force a linux prebuild fetch). But a *global
19
+ * GJS install* exists precisely to run the GJS CLI, where there is NO npm
20
+ * fallback: `@gjsify/rolldown-native` is the only bundler engine (`gjsify build`
21
+ * hard-fails without it), `@gjsify/lightningcss-native` backs CSS lowering, and
22
+ * `@gjsify/oxfmt-native` backs `gjsify format`/`fix`. So the global installer
23
+ * lays them down explicitly, on top of the cli's resolved runtime deps.
24
+ *
25
+ * `@gjsify/rolldown-native` is the load-bearing one (build is dead without it);
26
+ * the other two degrade gracefully at runtime (CSS / format fall back), so all
27
+ * three are installed best-effort: a platform with no published prebuild for
28
+ * the engine just gets a warning + continue, mirroring how the optional-peer
29
+ * story already degrades on Node.
30
+ */
31
+ export declare const GJS_ENGINE_PACKAGES: readonly ["@gjsify/rolldown-native", "@gjsify/lightningcss-native", "@gjsify/oxfmt-native"];
32
+ /** True when `<prefix>/node_modules/@gjsify/rolldown-native` is laid down. */
33
+ export declare function hasBundlerEngineInstalled(prefix: string): boolean;
34
+ /**
35
+ * Best-effort install of the GJS-native engine packages into a global prefix.
36
+ *
37
+ * Each engine is installed in its own `installPackages` call so one engine that
38
+ * has no published prebuild for the current platform (the resolver throws "No
39
+ * version of … satisfies …", or the registry 404s) does NOT abort the others —
40
+ * it warns and continues. The bundler engine (`@gjsify/rolldown-native`) is the
41
+ * one a build truly needs; the failure surfaces a clear, actionable message.
42
+ *
43
+ * `version` is the dist-tag / version the top-level `@gjsify/cli` resolved to,
44
+ * so the engines move in lockstep with the bundle. It may be a concrete version
45
+ * (`0.11.0`) or a dist-tag (`latest`, `next`); both are valid spec suffixes.
46
+ *
47
+ * @param installFn Injected to keep this unit-testable without a real registry —
48
+ * defaults to the production `installPackages` backend.
49
+ */
50
+ export declare function installGjsEnginePackages(prefix: string, version: string, opts?: {
51
+ verbose?: boolean;
52
+ installFn?: (spec: string) => Promise<void>;
53
+ }): Promise<void>;
13
54
  export interface LinkedBin {
14
55
  /** The bin-name (key from `bin` / `gjsify.bin`). */
15
56
  name: string;
@@ -39,6 +39,80 @@ export function defaultGlobalLayout() {
39
39
  binDir: binOverride ?? path.join(home, '.local', 'bin'),
40
40
  };
41
41
  }
42
+ /**
43
+ * The GJS-native tooling bridges the global CLI needs to run a build under GJS.
44
+ *
45
+ * These are declared OPTIONAL peer dependencies of `@gjsify/cli` (so a plain
46
+ * `npm install @gjsify/cli` on Node — where the npm `rolldown` / `lightningcss`
47
+ * / `oxfmt` crates work — does not force a linux prebuild fetch). But a *global
48
+ * GJS install* exists precisely to run the GJS CLI, where there is NO npm
49
+ * fallback: `@gjsify/rolldown-native` is the only bundler engine (`gjsify build`
50
+ * hard-fails without it), `@gjsify/lightningcss-native` backs CSS lowering, and
51
+ * `@gjsify/oxfmt-native` backs `gjsify format`/`fix`. So the global installer
52
+ * lays them down explicitly, on top of the cli's resolved runtime deps.
53
+ *
54
+ * `@gjsify/rolldown-native` is the load-bearing one (build is dead without it);
55
+ * the other two degrade gracefully at runtime (CSS / format fall back), so all
56
+ * three are installed best-effort: a platform with no published prebuild for
57
+ * the engine just gets a warning + continue, mirroring how the optional-peer
58
+ * story already degrades on Node.
59
+ */
60
+ export const GJS_ENGINE_PACKAGES = [
61
+ '@gjsify/rolldown-native',
62
+ '@gjsify/lightningcss-native',
63
+ '@gjsify/oxfmt-native',
64
+ ];
65
+ /** True when `<prefix>/node_modules/@gjsify/rolldown-native` is laid down. */
66
+ export function hasBundlerEngineInstalled(prefix) {
67
+ return fs.existsSync(path.join(prefix, 'node_modules', '@gjsify/rolldown-native', 'package.json'));
68
+ }
69
+ /**
70
+ * Best-effort install of the GJS-native engine packages into a global prefix.
71
+ *
72
+ * Each engine is installed in its own `installPackages` call so one engine that
73
+ * has no published prebuild for the current platform (the resolver throws "No
74
+ * version of … satisfies …", or the registry 404s) does NOT abort the others —
75
+ * it warns and continues. The bundler engine (`@gjsify/rolldown-native`) is the
76
+ * one a build truly needs; the failure surfaces a clear, actionable message.
77
+ *
78
+ * `version` is the dist-tag / version the top-level `@gjsify/cli` resolved to,
79
+ * so the engines move in lockstep with the bundle. It may be a concrete version
80
+ * (`0.11.0`) or a dist-tag (`latest`, `next`); both are valid spec suffixes.
81
+ *
82
+ * @param installFn Injected to keep this unit-testable without a real registry —
83
+ * defaults to the production `installPackages` backend.
84
+ */
85
+ export async function installGjsEnginePackages(prefix, version, opts = {}) {
86
+ const verbose = opts.verbose ?? false;
87
+ const installOne = opts.installFn ??
88
+ (async (spec) => {
89
+ // Lazy import to avoid a static cycle (install-backend → … → here)
90
+ // and to keep this module importable from the lighter unit tests.
91
+ const { installPackages } = await import('./install-backend.js');
92
+ await installPackages({ prefix, specs: [spec], verbose });
93
+ });
94
+ for (const name of GJS_ENGINE_PACKAGES) {
95
+ const spec = version ? `${name}@${version}` : name;
96
+ try {
97
+ await installOne(spec);
98
+ }
99
+ catch (err) {
100
+ const msg = err instanceof Error ? err.message : String(err);
101
+ // `@gjsify/rolldown-native` is the bundler engine — its absence
102
+ // means `gjsify build` (and storybook / anything that builds) will
103
+ // hard-fail under GJS. Make that one loud; the other two degrade
104
+ // gracefully so a plain note suffices.
105
+ if (name === '@gjsify/rolldown-native') {
106
+ console.warn(`\nWarning: could not install the GJS bundler engine ${name}@${version} — ${msg}\n` +
107
+ ` \`gjsify build\` (and storybook / flatpak) will fail under GJS until it is present.\n` +
108
+ ` This usually means no prebuild is published for this platform (${process.platform}/${process.arch}).`);
109
+ }
110
+ else if (verbose) {
111
+ console.warn(`gjsify install: optional GJS engine ${name}@${version} not installed — ${msg}`);
112
+ }
113
+ }
114
+ }
115
+ }
42
116
  /**
43
117
  * Install each top-level package's bin entries into `binDir` as small POSIX
44
118
  * `sh` launchers that exec the real bin. Reads each package's installed
@@ -73,14 +73,38 @@ function makeTtyReporter(stream) {
73
73
  },
74
74
  };
75
75
  }
76
- // ──── Plain reporter — one line per phase begin + end (non-TTY) ────────────
76
+ // ──── Plain reporter — phase begin/end + periodic heartbeat (non-TTY) ──────
77
+ //
78
+ // On a non-TTY (piped to a log file, CI, or captured output) the bar can't
79
+ // `\r`-overwrite, so per-package lines are suppressed to avoid flooding the
80
+ // log. But emitting NOTHING between `beginPhase` and `endPhase` makes a long
81
+ // download/extract phase look frozen — the exact "no further output for 25+
82
+ // minutes" symptom of a slow install. So we emit a low-frequency heartbeat:
83
+ // at most one line per HEARTBEAT_MS, AND only after at least a few packages of
84
+ // real progress, so a fast warm install (most nodes skipped) stays quiet while
85
+ // a genuinely long phase reports `… N/M (X%)` every few seconds. This mirrors
86
+ // what npm (`--loglevel http`) and pnpm log on non-TTY: progress you can watch
87
+ // without it being one line per file.
88
+ const HEARTBEAT_MS = 5_000;
77
89
  function makePlainReporter(stream) {
90
+ let lastHeartbeat = 0;
91
+ let lastReported = 0;
78
92
  return {
79
93
  beginPhase(phase, total) {
94
+ lastHeartbeat = Date.now();
95
+ lastReported = 0;
80
96
  stream.write(`gjsify install: ${PHASE_LABEL[phase]} ${total} package(s)\n`);
81
97
  },
82
- update() {
83
- // Per-package output would flood logs; skip on non-TTY.
98
+ update(ev) {
99
+ const now = Date.now();
100
+ if (now - lastHeartbeat < HEARTBEAT_MS)
101
+ return;
102
+ if (ev.current <= lastReported)
103
+ return;
104
+ lastHeartbeat = now;
105
+ lastReported = ev.current;
106
+ const pct = ev.total > 0 ? Math.round((ev.current / ev.total) * 100) : 0;
107
+ stream.write(`gjsify install: ${PHASE_LABEL[ev.phase]} ${ev.current}/${ev.total} (${pct}%)\n`);
84
108
  },
85
109
  endPhase(phase) {
86
110
  stream.write(`gjsify install: ${PHASE_LABEL[phase]} done\n`);