@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
@@ -80,11 +80,14 @@ export async function fetchGithubOidcToken(audience, log) {
80
80
  throw new OidcUnavailableError('GitHub Actions OIDC env vars (ACTIONS_ID_TOKEN_REQUEST_{URL,TOKEN}) not set. ' +
81
81
  'The calling workflow needs `permissions: id-token: write`.', 'no-env');
82
82
  }
83
- // Append the audience by STRING CONCATENATION, as @actions/core's getIDToken does. Do NOT
84
- // use `URL.searchParams.set` + `.href`: under GJS that serialization dropped the `audience`
85
- // param, so GitHub issued an id-token with the DEFAULT audience (the repo API URL) and npm
86
- // rejected an otherwise-valid token with 401 — repository/workflow_ref/sub all matched the
87
- // Trusted Publisher, only `aud` was wrong.
83
+ // Append the audience by STRING CONCATENATION, as @actions/core's getIDToken does.
84
+ //
85
+ // fixed upstream in gjsify: `URL.searchParams.set` + `.href` used to DROP the parameter under
86
+ // GJS — the mutation never reached the serialisation — so GitHub issued an id-token with the
87
+ // DEFAULT audience (the repo API URL) and npm rejected an otherwise-valid token with 401:
88
+ // repository/workflow_ref/sub all matched the Trusted Publisher, only `aud` was wrong. The
89
+ // URL live view now works, so the ban is lifted; the concatenation stays because it is what
90
+ // the reference implementation does, not because the alternative is broken.
88
91
  const sep = url.includes('?') ? '&' : '?';
89
92
  const requestUrl = `${url}${sep}audience=${encodeURIComponent(audience)}`;
90
93
  log?.(`gjsify oidc: GET ${requestUrl.replace(bearer, '<bearer>')}`);
@@ -0,0 +1,83 @@
1
+ import { type PlatformDeclaration, type PlatformTarget, type PlatformVerdict } from './platform-check.js';
2
+ /** One package directory found under a prefix's `node_modules` tree. */
3
+ export interface InstalledPackage {
4
+ /** `name` from its manifest, or the directory path when the manifest is unreadable. */
5
+ name: string;
6
+ version: string;
7
+ /** Absolute path to the package directory. */
8
+ dir: string;
9
+ /**
10
+ * A symlink, i.e. a workspace source tree linked into `node_modules` rather than
11
+ * a copy the installer extracted. Never pruned and never descended: the installer
12
+ * refuses to remove one for the same reason, and following it would walk the
13
+ * user's own sources.
14
+ */
15
+ linked: boolean;
16
+ /** The `os`/`cpu`/`libc` slice, absent when the manifest declares none. */
17
+ platform?: PlatformDeclaration;
18
+ }
19
+ export interface PruneEntry {
20
+ pkg: InstalledPackage;
21
+ /** Apparent size of the package directory, summed from `statSync().size`. */
22
+ bytes: number;
23
+ verdict: PlatformVerdict;
24
+ }
25
+ export interface PrunePlan {
26
+ prefix: string;
27
+ target: PlatformTarget;
28
+ /** How many package directories were examined, so an empty plan is distinguishable from an empty scan. */
29
+ scanned: number;
30
+ entries: PruneEntry[];
31
+ bytes: number;
32
+ }
33
+ export interface PruneResult {
34
+ plan: PrunePlan;
35
+ removed: PruneEntry[];
36
+ failed: Array<{
37
+ entry: PruneEntry;
38
+ error: string;
39
+ }>;
40
+ bytes: number;
41
+ /** `.bin` entries removed because they were symlinks with no target left. */
42
+ binLinks: string[];
43
+ }
44
+ /** Every package directory under `<prefix>/node_modules`, at any depth. */
45
+ export declare function scanPrefix(prefix: string): InstalledPackage[];
46
+ /**
47
+ * The packages `target` cannot use. Pure — no filesystem, no `process.*` — so every
48
+ * OS/arch/libc branch is decidable from any host.
49
+ */
50
+ export declare function planPlatformPrune(pkgs: readonly InstalledPackage[], target: PlatformTarget): PruneEntry[];
51
+ /** Apparent size of a directory tree — what the files claim, not what the block device spent. */
52
+ export declare function dirBytes(dir: string): number;
53
+ export declare function planPrune(opts: {
54
+ prefix: string;
55
+ target: PlatformTarget;
56
+ }): PrunePlan;
57
+ /**
58
+ * Remove a plan's entries.
59
+ *
60
+ * `remove` is injected so the failure path is testable without a filesystem that
61
+ * can fail on demand. Nothing here throws: this runs as housekeeping at the end of
62
+ * an install, and an install that already succeeded must not be reported as failed
63
+ * because a directory could not be unlinked.
64
+ */
65
+ export declare function executePrune(plan: PrunePlan, opts?: {
66
+ dryRun?: boolean;
67
+ remove?: (dir: string) => void;
68
+ }): PruneResult;
69
+ export declare function formatPruneReport(result: PruneResult, opts: {
70
+ dryRun: boolean;
71
+ verbose: boolean;
72
+ }): string;
73
+ /** Why an automatic pass declined to run, or null when it may proceed. */
74
+ export declare function automaticPruneRefusal(env: Record<string, string | undefined>, immutable: boolean): string | null;
75
+ /**
76
+ * The automatic pass: prune what this install would not have placed, report one
77
+ * line, and never fail the install that called it.
78
+ */
79
+ export declare function pruneAfterInstall(prefix: string, target: PlatformTarget, opts?: {
80
+ env?: Record<string, string | undefined>;
81
+ immutable?: boolean;
82
+ hint?: string;
83
+ }): void;
@@ -0,0 +1,333 @@
1
+ // Remove what an install prefix holds that THIS host cannot use.
2
+ //
3
+ // An install prefix is a write-only union today: `applyPlatformFilter` keeps a
4
+ // foreign-platform package from being installed, and nothing ever removes one that
5
+ // an EARLIER install placed. Measured on a workstation whose user-global prefix was
6
+ // created in May and whose CLI was current: 638 MB, of which `@rolldown` alone was
7
+ // 258 MB across sixteen platform bindings on a host that can load exactly one.
8
+ //
9
+ // The residue is ACCRETION, not a live filter defect, and the on-disk versions are
10
+ // what prove it: the foreign `@rolldown/binding-*` dirs were a minor version behind
11
+ // the `rolldown` that pins all of them, and every foreign `@gjsify/*-<os>-<arch>`
12
+ // was at the release that first SHIPPED the filter. They were written by the
13
+ // previous CLI, during the self-update that installed the fix. A filter cannot
14
+ // remove what predates it, which is why this exists as its own pass.
15
+ //
16
+ // ## What this is allowed to decide
17
+ //
18
+ // One rule, and it is a pure manifest read: npm's own `os`/`cpu`/`libc` fields,
19
+ // through the SAME `checkPlatform` the installer filters with — so a pruned prefix
20
+ // converges on what a fresh install would have placed, rather than on this module's
21
+ // opinion. A package that declares nothing is never touched: `@rolldown/binding-
22
+ // wasm32-wasi` is unusable here and says so nowhere, and guessing from its name is
23
+ // how a prune starts deleting things it cannot justify.
24
+ //
25
+ // Reachability ("nothing installed points at this any more") would catch that one
26
+ // and more, but it needs a record of what the prefix was ASSEMBLED FROM, which no
27
+ // prefix carries yet. Uncertain means keep — see ADR 0025 and
28
+ // `status/open-todos.md`.
29
+ import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import { checkPlatform, declaresPlatform, describePlatformTarget, readPlatformOverrides, } from './platform-check.js';
32
+ /** Directory entries under `node_modules` that are never packages. */
33
+ function isReservedEntry(name) {
34
+ // `.bin` holds launchers, `.gjsify-install-lock` is the LIVE cross-process lock
35
+ // directory — removing it mid-install would hand the prefix to a second writer.
36
+ return name.startsWith('.');
37
+ }
38
+ function readManifest(dir) {
39
+ try {
40
+ const raw = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
41
+ const platform = {
42
+ os: raw.os,
43
+ cpu: raw.cpu,
44
+ libc: raw.libc,
45
+ };
46
+ return {
47
+ name: typeof raw.name === 'string' ? raw.name : undefined,
48
+ version: typeof raw.version === 'string' ? raw.version : undefined,
49
+ platform: declaresPlatform(platform) ? platform : undefined,
50
+ };
51
+ }
52
+ catch {
53
+ // An unreadable or half-written manifest declares nothing, which makes the
54
+ // package un-prunable by the only rule this module has. That is the safe
55
+ // direction: the one thing worse than 638 MB of residue is deleting a
56
+ // package because its manifest could not be parsed.
57
+ return null;
58
+ }
59
+ }
60
+ function collect(nodeModules, out) {
61
+ let entries;
62
+ try {
63
+ entries = readdirSync(nodeModules, { withFileTypes: true });
64
+ }
65
+ catch {
66
+ return;
67
+ }
68
+ for (const entry of entries) {
69
+ if (isReservedEntry(entry.name))
70
+ continue;
71
+ const full = join(nodeModules, entry.name);
72
+ if (entry.name.startsWith('@')) {
73
+ let scoped;
74
+ try {
75
+ scoped = readdirSync(full, { withFileTypes: true });
76
+ }
77
+ catch {
78
+ continue;
79
+ }
80
+ for (const inner of scoped) {
81
+ if (isReservedEntry(inner.name))
82
+ continue;
83
+ addPackage(join(full, inner.name), out);
84
+ }
85
+ continue;
86
+ }
87
+ addPackage(full, out);
88
+ }
89
+ }
90
+ function addPackage(dir, out) {
91
+ let linked = false;
92
+ try {
93
+ linked = lstatSync(dir).isSymbolicLink();
94
+ }
95
+ catch {
96
+ return;
97
+ }
98
+ const manifest = readManifest(dir);
99
+ out.push({
100
+ name: manifest?.name ?? dir,
101
+ version: manifest?.version ?? '0.0.0',
102
+ dir,
103
+ linked,
104
+ platform: manifest?.platform,
105
+ });
106
+ // A linked package is the user's own source tree — its `node_modules` is not
107
+ // this prefix's to prune.
108
+ if (!linked)
109
+ collect(join(dir, 'node_modules'), out);
110
+ }
111
+ /** Every package directory under `<prefix>/node_modules`, at any depth. */
112
+ export function scanPrefix(prefix) {
113
+ const out = [];
114
+ collect(join(prefix, 'node_modules'), out);
115
+ return out;
116
+ }
117
+ /**
118
+ * The packages `target` cannot use. Pure — no filesystem, no `process.*` — so every
119
+ * OS/arch/libc branch is decidable from any host.
120
+ */
121
+ export function planPlatformPrune(pkgs, target) {
122
+ const entries = [];
123
+ for (const pkg of pkgs) {
124
+ if (pkg.linked || !pkg.platform)
125
+ continue;
126
+ const verdict = checkPlatform(pkg.platform, target);
127
+ if (!verdict.ok)
128
+ entries.push({ pkg, bytes: 0, verdict });
129
+ }
130
+ return entries;
131
+ }
132
+ /** Apparent size of a directory tree — what the files claim, not what the block device spent. */
133
+ export function dirBytes(dir) {
134
+ let total = 0;
135
+ const stack = [dir];
136
+ while (stack.length > 0) {
137
+ const current = stack.pop();
138
+ let entries;
139
+ try {
140
+ entries = readdirSync(current, { withFileTypes: true });
141
+ }
142
+ catch {
143
+ continue;
144
+ }
145
+ for (const entry of entries) {
146
+ const full = join(current, entry.name);
147
+ if (entry.isDirectory()) {
148
+ stack.push(full);
149
+ }
150
+ else if (entry.isFile()) {
151
+ try {
152
+ total += statSync(full).size;
153
+ }
154
+ catch {
155
+ // A file that vanished mid-walk contributes nothing; a size is a
156
+ // report, and refusing to produce one over a race would be worse.
157
+ }
158
+ }
159
+ }
160
+ }
161
+ return total;
162
+ }
163
+ export function planPrune(opts) {
164
+ const pkgs = scanPrefix(opts.prefix);
165
+ const entries = planPlatformPrune(pkgs, opts.target);
166
+ for (const entry of entries)
167
+ entry.bytes = dirBytes(entry.pkg.dir);
168
+ return {
169
+ prefix: opts.prefix,
170
+ target: opts.target,
171
+ scanned: pkgs.length,
172
+ entries,
173
+ bytes: entries.reduce((sum, e) => sum + e.bytes, 0),
174
+ };
175
+ }
176
+ /**
177
+ * Remove a plan's entries.
178
+ *
179
+ * `remove` is injected so the failure path is testable without a filesystem that
180
+ * can fail on demand. Nothing here throws: this runs as housekeeping at the end of
181
+ * an install, and an install that already succeeded must not be reported as failed
182
+ * because a directory could not be unlinked.
183
+ */
184
+ export function executePrune(plan, opts = {}) {
185
+ const removed = [];
186
+ const failed = [];
187
+ const binLinks = [];
188
+ // `maxRetries` mirrors the installer's own removal: on Windows a handle held by
189
+ // a virus scanner or an indexer makes the first unlink fail and the third work.
190
+ const remove = opts.remove ?? ((dir) => rmSync(dir, { recursive: true, force: true, maxRetries: 10 }));
191
+ if (!opts.dryRun) {
192
+ for (const entry of plan.entries) {
193
+ try {
194
+ remove(entry.pkg.dir);
195
+ removed.push(entry);
196
+ }
197
+ catch (err) {
198
+ failed.push({ entry, error: err.message });
199
+ }
200
+ }
201
+ binLinks.push(...pruneDeadBinLinks(plan.prefix));
202
+ }
203
+ return {
204
+ plan,
205
+ removed,
206
+ failed,
207
+ bytes: removed.reduce((sum, e) => sum + e.bytes, 0),
208
+ binLinks,
209
+ };
210
+ }
211
+ /**
212
+ * Drop `.bin` entries that are SYMLINKS whose target is gone.
213
+ *
214
+ * Only symlinks, and only dangling ones: that is the case where deadness is
215
+ * provable from the filesystem alone. A `.bin` entry that is a real file may be a
216
+ * launcher script this prefix still needs, and deciding that would mean reading it —
217
+ * a second, weaker rule for a few hundred bytes.
218
+ */
219
+ function pruneDeadBinLinks(prefix) {
220
+ const binDir = join(prefix, 'node_modules', '.bin');
221
+ const dropped = [];
222
+ let entries;
223
+ try {
224
+ entries = readdirSync(binDir, { withFileTypes: true });
225
+ }
226
+ catch {
227
+ return dropped;
228
+ }
229
+ for (const entry of entries) {
230
+ const full = join(binDir, entry.name);
231
+ try {
232
+ if (!lstatSync(full).isSymbolicLink())
233
+ continue;
234
+ if (existsSync(full))
235
+ continue;
236
+ unlinkSync(full);
237
+ dropped.push(entry.name);
238
+ }
239
+ catch {
240
+ // Same reasoning as `executePrune`: housekeeping never fails the caller.
241
+ }
242
+ }
243
+ return dropped;
244
+ }
245
+ function humanBytes(bytes) {
246
+ if (bytes < 1024)
247
+ return `${bytes} B`;
248
+ const units = ['KB', 'MB', 'GB'];
249
+ let value = bytes / 1024;
250
+ let unit = 0;
251
+ while (value >= 1024 && unit < units.length - 1) {
252
+ value /= 1024;
253
+ unit += 1;
254
+ }
255
+ return `${value.toFixed(1)} ${units[unit]}`;
256
+ }
257
+ /** How many entries a non-verbose report lists before summarising the rest. */
258
+ const REPORT_HEAD = 10;
259
+ export function formatPruneReport(result, opts) {
260
+ const { plan } = result;
261
+ const lines = [
262
+ `${plan.prefix} · target ${describePlatformTarget(plan.target)} · scanned ${plan.scanned} package(s)`,
263
+ ];
264
+ if (plan.entries.length === 0) {
265
+ // Not "every package declares a platform this host can use": most declare
266
+ // none at all, and saying otherwise reports a check that did not happen.
267
+ lines.push('nothing to prune — no installed package declares a platform this target cannot use.');
268
+ return lines.join('\n');
269
+ }
270
+ const shown = opts.verbose ? plan.entries : plan.entries.slice(0, REPORT_HEAD);
271
+ for (const entry of shown) {
272
+ const required = JSON.stringify(entry.verdict.required);
273
+ lines.push(` ${entry.pkg.name}@${entry.pkg.version} ${humanBytes(entry.bytes)} requires ${required}`);
274
+ }
275
+ if (shown.length < plan.entries.length) {
276
+ lines.push(` … and ${plan.entries.length - shown.length} more (--verbose to list them)`);
277
+ }
278
+ // The byte figure is APPARENT size, summed from the files themselves. `du`
279
+ // reports allocated blocks and will disagree; saying so here is cheaper than
280
+ // the bug report that difference otherwise produces.
281
+ lines.push(opts.dryRun
282
+ ? `would free ${humanBytes(plan.bytes)} across ${plan.entries.length} package(s) (apparent size)`
283
+ : `freed ${humanBytes(result.bytes)} across ${result.removed.length} package(s) (apparent size)`);
284
+ if (result.binLinks.length > 0) {
285
+ lines.push(`removed ${result.binLinks.length} dangling .bin link(s): ${result.binLinks.join(', ')}`);
286
+ }
287
+ for (const f of result.failed) {
288
+ lines.push(`FAILED ${f.entry.pkg.name}: ${f.error}`);
289
+ }
290
+ return lines.join('\n');
291
+ }
292
+ /** Why an automatic pass declined to run, or null when it may proceed. */
293
+ export function automaticPruneRefusal(env, immutable) {
294
+ // THE data-loss guard. `--os/--cpu/--libc` are legal on `gjsify install -g`, and
295
+ // they reach here as npm config keys. Pruning against a target the USER TYPED
296
+ // would make `gjsify install -g foo --os=darwin` delete every linux package in
297
+ // the real shared prefix — the engine set, the bundler bindings, the CLI's own.
298
+ // An explicit `gjsify prune --os=darwin` stays legal; it is a request, not a
299
+ // side effect.
300
+ if (Object.keys(readPlatformOverrides(env)).length > 0) {
301
+ return 'the platform target was overridden on the command line';
302
+ }
303
+ // `--immutable` promises the tree is not modified. Resolution-only would be a
304
+ // defensible reading, but it is not the one the flag's users have, and
305
+ // `ensureProjectGjsEngine` already declines under it for the same reason.
306
+ if (immutable)
307
+ return '--immutable';
308
+ return null;
309
+ }
310
+ /**
311
+ * The automatic pass: prune what this install would not have placed, report one
312
+ * line, and never fail the install that called it.
313
+ */
314
+ export function pruneAfterInstall(prefix, target, opts = {}) {
315
+ try {
316
+ const refusal = automaticPruneRefusal(opts.env ?? process.env, opts.immutable ?? false);
317
+ if (refusal)
318
+ return;
319
+ const plan = planPrune({ prefix, target });
320
+ if (plan.entries.length === 0)
321
+ return;
322
+ const result = executePrune(plan);
323
+ if (result.removed.length === 0)
324
+ return;
325
+ const hint = opts.hint ? ` (${opts.hint} to review)` : '';
326
+ console.log(`gjsify: pruned ${result.removed.length} package(s) this host cannot use, ` +
327
+ `freeing ${humanBytes(result.bytes)}${hint}`);
328
+ }
329
+ catch (err) {
330
+ if (process.env.GJSIFY_DEBUG)
331
+ console.error(`gjsify: prune skipped — ${err.message}`);
332
+ }
333
+ }
@@ -1,4 +1,5 @@
1
1
  import { type NativeEnv } from './detect-native-packages.js';
2
+ import { type SpawnCompletionContract } from './spawn.js';
2
3
  /**
3
4
  * Pure env computation: the typelib + shared-library search paths {@link runGjsBundle} would
4
5
  * inject into the spawned `gjs`, plus the formatted prefix for the `$ …` echo. Which library
@@ -15,6 +16,16 @@ export declare function computeNativeEnvForBundle(bundlePath: string, cwd?: stri
15
16
  * bundle's own node_modules tree.
16
17
  */
17
18
  export interface RunGjsBundleOptions {
19
+ /**
20
+ * The teardown contract of the command that will end this process — the same field,
21
+ * and the same reason, `spawnToCompletion` demands. Required rather than defaulted:
22
+ * absent, a caller silently inherited the side that ARMS the GJS main loop.
23
+ *
24
+ * Distinct from {@link exitOnSuccess}, which asks WHO performs the exit. `gjsify test`
25
+ * is `'exit'` — its handler exits at the end of `commands/test.ts` — while leaving
26
+ * `exitOnSuccess` off, because an exit HERE would truncate its multi-runtime loop.
27
+ */
28
+ completion: SpawnCompletionContract;
18
29
  /**
19
30
  * Exit this process with code 0 once the child succeeds. Under GJS, `ensureMainLoop()`
20
31
  * (armed by spawn) keeps the parent's GLib loop alive after the child exits, so a TERMINAL
@@ -31,4 +42,4 @@ export interface RunGjsBundleOptions {
31
42
  */
32
43
  quiet?: boolean;
33
44
  }
34
- export declare function runGjsBundle(bundlePath: string, extraArgs?: string[], options?: RunGjsBundleOptions): Promise<void>;
45
+ export declare function runGjsBundle(bundlePath: string, extraArgs: string[] | undefined, options: RunGjsBundleOptions): Promise<void>;
@@ -9,9 +9,9 @@
9
9
  //
10
10
  // Env composition is split out as the pure `computeNativeEnvForBundle()` so the e2e tests can
11
11
  // assert the env without spawning gjs.
12
- import { spawn } from 'node:child_process';
13
12
  import { dirname, resolve } from 'node:path';
14
13
  import { detectNativePackages, buildNativeEnv } from './detect-native-packages.js';
14
+ import { describeExit, spawnToCompletion } from './spawn.js';
15
15
  /**
16
16
  * Pure env computation: the typelib + shared-library search paths {@link runGjsBundle} would
17
17
  * inject into the spawned `gjs`, plus the formatted prefix for the `$ …` echo. Which library
@@ -40,7 +40,7 @@ export function computeNativeEnvForBundle(bundlePath, cwd = process.cwd(), inher
40
40
  .join(' ');
41
41
  return { env, envPrefix };
42
42
  }
43
- export async function runGjsBundle(bundlePath, extraArgs = [], options = {}) {
43
+ export async function runGjsBundle(bundlePath, extraArgs = [], options) {
44
44
  const { env: nativeEnv, envPrefix } = computeNativeEnvForBundle(bundlePath);
45
45
  const env = {
46
46
  ...process.env,
@@ -52,36 +52,29 @@ export async function runGjsBundle(bundlePath, extraArgs = [], options = {}) {
52
52
  const gjsCommand = ['gjs', ...gjsArgs.map((a) => (a.includes(' ') ? `"${a}"` : a))].join(' ');
53
53
  if (!options.quiet)
54
54
  console.error(`$ ${envPrefix ? `${envPrefix} ` : ''}${gjsCommand}`);
55
- const child = spawn('gjs', gjsArgs, { env, stdio: 'inherit' });
56
- let failed = false;
57
- // The CHILD's own exit code, re-raised verbatim; a spawn error (no `gjs` on PATH) has none
58
- // of its own and falls back to 1. Collapsing every failure to 1 lost information callers
59
- // act on — a chain reading `$?` to tell "tool failed" from "tool says no" saw 1 for both.
60
- let childCode = 1;
61
- await new Promise((resolvePromise, reject) => {
62
- child.on('close', (code) => {
63
- if (code !== 0) {
64
- childCode = code ?? 1;
65
- reject(new Error(`gjs exited with code ${code}`));
66
- }
67
- else {
68
- resolvePromise();
69
- }
70
- });
71
- child.on('error', reject);
72
- }).catch((err) => {
55
+ // The CHILD's own exit code is re-raised verbatim; a spawn error (no `gjs` on PATH) has
56
+ // none of its own and falls back to 1. Collapsing every failure to 1 lost information
57
+ // callers act on — a chain reading `$?` to tell "tool failed" from "tool says no" saw 1
58
+ // for both.
59
+ const failed = (code) => {
60
+ // `process.exitCode` is set BEFORE exiting because `gjsify run <script>` dispatches a
61
+ // nested `gjsify run <bundle>` IN PROCESS: the outer command reads `process.exitCode`
62
+ // to propagate. Without it a failing `test:gjs` returned 0 through the `test` chain,
63
+ // and through `gjsify foreach test` in CI. The `return` guards a fall-through exit.
64
+ process.exitCode = code;
65
+ return process.exit(code);
66
+ };
67
+ let result;
68
+ try {
69
+ result = await spawnToCompletion('gjs', gjsArgs, { completion: options.completion, env });
70
+ }
71
+ catch (err) {
73
72
  console.error(err.message);
74
- failed = true;
75
- });
76
- if (failed) {
77
- // Set `process.exitCode` synchronously BEFORE exiting: under GJS `process.exit()` is
78
- // deferred (the GLib main loop is armed), so under run.ts's in-process script dispatch
79
- // execution returns to the caller, which propagates by reading `process.exitCode`.
80
- // Without it, `gjsify run <script>` wrapping `gjsify run <bundle>` swallowed a non-zero
81
- // gjs exit and returned 0 — masking a failing `test:gjs` in the `test` chain, and via
82
- // `gjsify foreach test` in CI. The `return` guards a fall-through second `exit`.
83
- process.exitCode = childCode;
84
- return process.exit(childCode);
73
+ return failed(1);
74
+ }
75
+ if (result.code !== 0) {
76
+ console.error(`gjs exited with ${describeExit(result)}`);
77
+ return failed(result.code ?? 1);
85
78
  }
86
79
  // See RunGjsBundleOptions.exitOnSuccess — terminal callers opt in,
87
80
  // mid-flow callers (test.ts's runtime loop) keep the process alive.
@@ -1,4 +1,5 @@
1
1
  import { type ExampleRuntime } from './runtimes.js';
2
+ import { type SpawnCompletionContract } from './spawn.js';
2
3
  /**
3
4
  * Walk up from `startDir` for `<node_modules>/@gjsify/node-gi`, returning its package directory
4
5
  * or null. A plain fs walk because Node's own resolver is not available under the GJS-bundled CLI.
@@ -19,6 +20,8 @@ export declare function resolveNodeGiForBundle(bundlePath: string, cwd: string):
19
20
  /** Pick the node executable: the current one when on Node, else PATH `node`. */
20
21
  export declare function nodeBinary(): string;
21
22
  export interface RunNodeBundleOptions {
23
+ /** The caller's teardown contract — see `RunGjsBundleOptions.completion`. */
24
+ completion: SpawnCompletionContract;
22
25
  /** Exit this process with code 0 once the child succeeds (terminal callers). */
23
26
  exitOnSuccess?: boolean;
24
27
  /** Suppress the `$ <env> node …` echo — see `RunGjsBundleOptions.quiet`. */
@@ -28,12 +31,14 @@ export interface RunNodeBundleOptions {
28
31
  * Run a `--app node` bundle on Node.js with the native typelib env set (same detection as
29
32
  * {@link runGjsBundle}). Throws when `@gjsify/node-gi` is not resolvable.
30
33
  */
31
- export declare function runNodeBundle(bundlePath: string, extraArgs?: string[], options?: RunNodeBundleOptions): Promise<void>;
34
+ export declare function runNodeBundle(bundlePath: string, extraArgs: string[] | undefined, options: RunNodeBundleOptions): Promise<void>;
32
35
  /**
33
36
  * Whether a `--app node` bundle actually consumes `@gjsify/node-gi` — kept EXTERNAL, so a bundle
34
37
  * using gi:// imports the shim by name. A plain Node app does NOT, and must run on node/bun/deno
35
- * WITHOUT node-gi installed, which is why the check is conditional here and not unconditional
36
- * like the storybook path.
38
+ * WITHOUT node-gi installed, which is why the check is conditional rather than a flat "this is a
39
+ * node bundle, demand node-gi". The supervised launcher (`utils/watch-loop.ts`) asks the same
40
+ * question for the same reason: `gjsify dev` on a web-server template builds a node bundle that
41
+ * never reaches a typelib.
37
42
  */
38
43
  export declare function bundleRequiresNodeGi(bundlePath: string): boolean;
39
44
  /**
@@ -44,4 +49,4 @@ export declare function bundleRequiresNodeGi(bundlePath: string): boolean;
44
49
  *
45
50
  * `gjs` is intentionally NOT handled here — the caller dispatches it to {@link runGjsBundle}.
46
51
  */
47
- export declare function runRuntimeBundle(runtime: Exclude<ExampleRuntime, 'gjs'>, bundlePath: string, extraArgs?: string[], options?: RunNodeBundleOptions): Promise<void>;
52
+ export declare function runRuntimeBundle(runtime: Exclude<ExampleRuntime, 'gjs'>, bundlePath: string, extraArgs: string[] | undefined, options: RunNodeBundleOptions): Promise<void>;