@gjsify/cli 0.52.0 → 0.53.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 (81) hide show
  1. package/dist/cli.gjs.mjs +329 -298
  2. package/lib/actions/build.d.ts +22 -0
  3. package/lib/actions/build.js +37 -9
  4. package/lib/bundler-pick.d.ts +37 -1
  5. package/lib/bundler-pick.js +108 -17
  6. package/lib/cli-app.js +5 -2
  7. package/lib/commands/env.d.ts +31 -0
  8. package/lib/commands/env.js +114 -0
  9. package/lib/commands/exec.d.ts +10 -0
  10. package/lib/commands/exec.js +208 -0
  11. package/lib/commands/foreach.js +5 -97
  12. package/lib/commands/format.js +26 -6
  13. package/lib/commands/index.d.ts +3 -0
  14. package/lib/commands/index.js +3 -0
  15. package/lib/commands/install.d.ts +2 -2
  16. package/lib/commands/install.js +5 -5
  17. package/lib/commands/self-update.js +2 -1
  18. package/lib/commands/test.d.ts +18 -9
  19. package/lib/commands/test.js +56 -11
  20. package/lib/commands/tsc.js +4 -4
  21. package/lib/commands/upgrade.d.ts +15 -0
  22. package/lib/commands/upgrade.js +76 -2
  23. package/lib/commands/webext/index.d.ts +2 -0
  24. package/lib/commands/webext/index.js +15 -0
  25. package/lib/commands/webext/subcommands.d.ts +16 -0
  26. package/lib/commands/webext/subcommands.js +204 -0
  27. package/lib/types/config-data.d.ts +1 -1
  28. package/lib/utils/bin-shim.d.ts +8 -2
  29. package/lib/utils/bin-shim.js +6 -5
  30. package/lib/utils/bundle-inputs.d.ts +84 -0
  31. package/lib/utils/bundle-inputs.js +268 -0
  32. package/lib/utils/check-system-deps.d.ts +4 -2
  33. package/lib/utils/check-system-deps.js +21 -26
  34. package/lib/utils/dev-link.d.ts +9 -6
  35. package/lib/utils/dev-link.js +71 -19
  36. package/lib/utils/exec-bin.d.ts +137 -0
  37. package/lib/utils/exec-bin.js +321 -0
  38. package/lib/utils/gi-runtime-paths.js +3 -2
  39. package/lib/utils/gitignore.d.ts +15 -0
  40. package/lib/utils/gitignore.js +178 -0
  41. package/lib/utils/gjsify-shim.d.ts +47 -0
  42. package/lib/utils/gjsify-shim.js +97 -8
  43. package/lib/utils/install-backend-native.d.ts +19 -1
  44. package/lib/utils/install-backend-native.js +93 -7
  45. package/lib/utils/install-lock.js +4 -25
  46. package/lib/utils/native-skip-scan.d.ts +20 -0
  47. package/lib/utils/native-skip-scan.js +343 -0
  48. package/lib/utils/node-script.js +1 -1
  49. package/lib/utils/oxc-resolve.js +42 -3
  50. package/lib/utils/process-table.d.ts +22 -0
  51. package/lib/utils/process-table.js +188 -0
  52. package/lib/utils/prune-prefix.js +1 -1
  53. package/lib/utils/run-gjs.d.ts +15 -1
  54. package/lib/utils/run-gjs.js +16 -7
  55. package/lib/utils/run-node.d.ts +2 -0
  56. package/lib/utils/run-node.js +2 -1
  57. package/lib/utils/runtimes.js +14 -13
  58. package/lib/utils/ship/gzip.js +1 -1
  59. package/lib/utils/ship/icons.d.ts +6 -0
  60. package/lib/utils/ship/icons.js +6 -5
  61. package/lib/utils/spawn.d.ts +13 -0
  62. package/lib/utils/spawn.js +10 -5
  63. package/lib/utils/watch-loop.d.ts +15 -1
  64. package/lib/utils/watch-loop.js +9 -5
  65. package/lib/utils/webext/build.d.ts +42 -0
  66. package/lib/utils/webext/build.js +120 -0
  67. package/lib/utils/webext/config.d.ts +72 -0
  68. package/lib/utils/webext/config.js +234 -0
  69. package/lib/utils/webext/icons.d.ts +24 -0
  70. package/lib/utils/webext/icons.js +83 -0
  71. package/lib/utils/webext/launch.d.ts +32 -0
  72. package/lib/utils/webext/launch.js +77 -0
  73. package/lib/utils/webext/manifest.d.ts +53 -0
  74. package/lib/utils/webext/manifest.js +219 -0
  75. package/lib/utils/webext/pages.d.ts +26 -0
  76. package/lib/utils/webext/pages.js +61 -0
  77. package/lib/utils/webext/targets.d.ts +13 -0
  78. package/lib/utils/webext/targets.js +36 -0
  79. package/lib/utils/win32-command.d.ts +17 -0
  80. package/lib/utils/win32-command.js +21 -6
  81. package/package.json +19 -19
@@ -29,6 +29,21 @@ export declare const upgradeCommand: Command<unknown, UpgradeOptions>;
29
29
  * Returns the number of offending declarations so the caller can exit once for both arms.
30
30
  */
31
31
  export declare function reportInexactRanges(groups: readonly DependencyGroup[]): number;
32
+ /**
33
+ * Check that every lockfile resolution satisfies the ranges declared for it.
34
+ *
35
+ * `gjsify upgrade --check` compares declared ranges against each other across
36
+ * workspaces. It does NOT compare the resolution against any of them, so a
37
+ * lockfile entry that violates every declaration in the repo passes silently.
38
+ *
39
+ * This check reads the lockfile, resolves each entry's version, and verifies
40
+ * it satisfies at least one declared range for that dependency.
41
+ *
42
+ * @param groups dependency groups with declared ranges
43
+ * @param cwd working directory
44
+ * @returns number of violations
45
+ */
46
+ export declare function checkLockfileRangeViolations(groups: readonly DependencyGroup[], cwd: string): number;
32
47
  /**
33
48
  * The range a dependency will be WRITTEN at.
34
49
  *
@@ -36,7 +36,7 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
36
36
  import { join, resolve } from 'node:path';
37
37
  import { homedir } from 'node:os';
38
38
  import { createInterface } from 'node:readline/promises';
39
- import { parse } from '@gjsify/semver';
39
+ import { parse, satisfies } from '@gjsify/semver';
40
40
  import { DEFAULT_REGISTRY, fetchPackument, parseNpmrc } from '@gjsify/npm-registry';
41
41
  import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
42
42
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
@@ -325,7 +325,81 @@ export function reportInexactRanges(groups) {
325
325
  }
326
326
  return total;
327
327
  }
328
- function runCheckMode(groups, exact = false) {
328
+ /**
329
+ * Check that every lockfile resolution satisfies the ranges declared for it.
330
+ *
331
+ * `gjsify upgrade --check` compares declared ranges against each other across
332
+ * workspaces. It does NOT compare the resolution against any of them, so a
333
+ * lockfile entry that violates every declaration in the repo passes silently.
334
+ *
335
+ * This check reads the lockfile, resolves each entry's version, and verifies
336
+ * it satisfies at least one declared range for that dependency.
337
+ *
338
+ * @param groups dependency groups with declared ranges
339
+ * @param cwd working directory
340
+ * @returns number of violations
341
+ */
342
+ export function checkLockfileRangeViolations(groups, cwd) {
343
+ const lockPath = join(cwd, 'gjsify-lock.json');
344
+ if (!existsSync(lockPath))
345
+ return 0;
346
+ let lock;
347
+ try {
348
+ lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
349
+ }
350
+ catch {
351
+ return 0;
352
+ }
353
+ if (!lock.packages)
354
+ return 0;
355
+ // Build a map: dep name -> set of declared ranges
356
+ const declaredRanges = new Map();
357
+ for (const g of groups) {
358
+ const ranges = declaredRanges.get(g.name) ?? new Set();
359
+ for (const occ of g.occurrences) {
360
+ ranges.add(occ.currentRange);
361
+ }
362
+ declaredRanges.set(g.name, ranges);
363
+ }
364
+ let violations = 0;
365
+ for (const [key, entry] of Object.entries(lock.packages)) {
366
+ if (!entry.version)
367
+ continue;
368
+ // Only check direct dependencies — the single-segment "node_modules/<name>" form.
369
+ // A deep transitive resolution (e.g. "node_modules/a/node_modules/b") is pinned by
370
+ // its parent, not by the root's declared range, and must not be checked against it.
371
+ const parts = key.split('node_modules/');
372
+ if (parts.length !== 2)
373
+ continue;
374
+ const depName = parts[1];
375
+ const ranges = declaredRanges.get(depName);
376
+ if (!ranges || ranges.size === 0)
377
+ continue;
378
+ // Check if the resolved version satisfies at least one declared range
379
+ let satisfied = false;
380
+ for (const range of ranges) {
381
+ try {
382
+ if (entry.version && satisfies(entry.version, range)) {
383
+ satisfied = true;
384
+ break;
385
+ }
386
+ }
387
+ catch {
388
+ // Invalid range, skip
389
+ }
390
+ }
391
+ if (!satisfied) {
392
+ console.error(` ${depName}: lockfile has ${entry.version}, but declared ranges are ${[...ranges].join(', ')}`);
393
+ violations++;
394
+ }
395
+ }
396
+ if (violations > 0) {
397
+ console.error(`gjsify upgrade --check: FAIL. ${violations} lockfile entr${violations === 1 ? 'y' : 'ies'} violate declared ranges:\n`);
398
+ }
399
+ return violations;
400
+ }
401
+ function runCheckMode(groups, exact = false, cwd = process.cwd()) {
402
+ const lockfileViolations = checkLockfileRangeViolations(groups, cwd);
329
403
  const inexact = exact ? reportInexactRanges(groups) : 0;
330
404
  const inconsistencies = findInconsistencies(groups);
331
405
  if (inconsistencies.length === 0 && inexact > 0) {
@@ -0,0 +1,2 @@
1
+ import type { Command } from '../../types/index.js';
2
+ export declare const webextCommand: Command;
@@ -0,0 +1,15 @@
1
+ // `gjsify webext` — yargs subcommand-group dispatcher (ADR 0077).
2
+ //
3
+ // Wires {build, zip, dev}; each is a self-contained `Command<>` in `subcommands.ts`,
4
+ // the same composition `flatpak/index.ts` uses.
5
+ import { webextBuildCommand, webextDevCommand, webextZipCommand } from './subcommands.js';
6
+ export const webextCommand = {
7
+ command: 'webext <subcommand>',
8
+ description: 'Browser extensions: build, zip and develop one WebExtension for Chrome, Edge, Firefox and Safari.',
9
+ builder: (yargs) => yargs
10
+ .command(webextBuildCommand.command, webextBuildCommand.description, webextBuildCommand.builder, webextBuildCommand.handler)
11
+ .command(webextZipCommand.command, webextZipCommand.description, webextZipCommand.builder, webextZipCommand.handler)
12
+ .command(webextDevCommand.command, webextDevCommand.description, webextDevCommand.builder, webextDevCommand.handler)
13
+ .demandCommand(1)
14
+ .strict(),
15
+ };
@@ -0,0 +1,16 @@
1
+ import type { Command } from '../../types/index.js';
2
+ interface WebextArgs {
3
+ target?: string[];
4
+ outDir?: string;
5
+ define: string[];
6
+ mode?: string;
7
+ launch: boolean;
8
+ browserBinary?: string;
9
+ profile?: string;
10
+ headless: boolean;
11
+ debounce: number;
12
+ }
13
+ export declare const webextBuildCommand: Command<unknown, WebextArgs>;
14
+ export declare const webextZipCommand: Command<unknown, WebextArgs>;
15
+ export declare const webextDevCommand: Command<unknown, WebextArgs>;
16
+ export {};
@@ -0,0 +1,204 @@
1
+ // `gjsify webext build|zip|dev` — browser extensions as a project shape
2
+ // (ADR 0077). The command owns the folder around the bundles: one per target,
3
+ // with a manifest composed for that target, pages, icons, public files and
4
+ // locales. The bundles themselves are ordinary `gjsify build --app browser`
5
+ // runs, re-entered in-process like `gjsify dev` does.
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
7
+ import { dirname, join, resolve, sep } from 'node:path';
8
+ import { buildWebext } from '../../utils/webext/build.js';
9
+ import { resolveWebextConfig, selectTargets } from '../../utils/webext/config.js';
10
+ import { defaultDevProfile, resolveWebExt, WEB_EXT_MISSING, webExtRunArgs, } from '../../utils/webext/launch.js';
11
+ function loadConfig(cwd) {
12
+ const manifest = join(cwd, 'package.json');
13
+ if (!existsSync(manifest))
14
+ throw new Error(`gjsify webext: no package.json in ${cwd}.`);
15
+ return resolveWebextConfig(JSON.parse(readFileSync(manifest, 'utf8')), cwd);
16
+ }
17
+ /** `--define K=V` entries on top of the config's map, the way `gjsify build` spells them. */
18
+ function effectiveDefines(config, flags) {
19
+ const out = { ...config.define };
20
+ for (const flag of flags) {
21
+ const at = flag.indexOf('=');
22
+ if (at <= 0)
23
+ throw new Error(`gjsify webext: --define "${flag}" is not KEY=VALUE.`);
24
+ out[flag.slice(0, at)] = flag.slice(at + 1);
25
+ }
26
+ return out;
27
+ }
28
+ /** One bundle, through the ordinary build command in this process. */
29
+ async function bundleInProcess(request) {
30
+ const { runCli } = await import('../../cli-app.js');
31
+ const argv = ['build', request.entry, '--app', 'browser', '--format', request.format, '--outfile', request.outfile];
32
+ for (const [key, value] of Object.entries(request.define))
33
+ argv.push('--define', `${key}=${value}`);
34
+ if (!request.minify)
35
+ argv.push('--no-minify');
36
+ await runCli(argv);
37
+ }
38
+ const targetOption = {
39
+ description: 'Build only these targets (default: every target in gjsify.webext.targets).',
40
+ type: 'string',
41
+ array: true,
42
+ };
43
+ const outDirOption = {
44
+ description: 'Output directory (default: gjsify.webext.outDir, else .output).',
45
+ type: 'string',
46
+ };
47
+ const defineOption = {
48
+ description: 'Compile-time constant for every bundle, KEY=VALUE (repeatable); wins over gjsify.webext.define.',
49
+ type: 'string',
50
+ array: true,
51
+ default: [],
52
+ };
53
+ async function runBuild(args, zip) {
54
+ const cwd = process.cwd();
55
+ const config = loadConfig(cwd);
56
+ const mode = args.mode === 'development' ? 'development' : 'production';
57
+ await buildWebext({
58
+ config,
59
+ targets: selectTargets(config, args.target),
60
+ outDir: args.outDir ? resolve(cwd, args.outDir) : config.outDir,
61
+ mode,
62
+ define: effectiveDefines(config, args.define),
63
+ inPlace: false,
64
+ zip,
65
+ bundle: bundleInProcess,
66
+ });
67
+ }
68
+ export const webextBuildCommand = {
69
+ command: 'build',
70
+ description: 'Build one extension folder per target: bundles, pages, icons, locales and the manifest.',
71
+ builder: (yargs) => yargs
72
+ .option('target', targetOption)
73
+ .option('out-dir', outDirOption)
74
+ .option('define', defineOption)
75
+ .option('mode', {
76
+ description: 'Passed to the manifest as ctx.mode; development also skips minification.',
77
+ type: 'string',
78
+ choices: ['production', 'development'],
79
+ default: 'production',
80
+ }),
81
+ handler: (args) => runBuild(args, false),
82
+ };
83
+ export const webextZipCommand = {
84
+ command: 'zip',
85
+ description: 'Production build, then one store-ready <name>-<version>-<target>.zip per target.',
86
+ builder: (yargs) => yargs.option('target', targetOption).option('out-dir', outDirOption).option('define', defineOption),
87
+ handler: (args) => runBuild(args, true),
88
+ };
89
+ /** The dev target when none is asked for: the first Firefox target, since web-ext's Firefox driver needs no extra binary. */
90
+ function devTarget(config, requested) {
91
+ if (requested && requested.length > 1)
92
+ throw new Error('gjsify webext dev: pass one --target.');
93
+ const [chosen] = selectTargets(config, requested);
94
+ if (requested && requested.length === 1)
95
+ return chosen;
96
+ return config.targets.find((t) => t.browser === 'firefox') ?? chosen;
97
+ }
98
+ /**
99
+ * What the loop watches: every top-level directory except `node_modules`,
100
+ * dot-directories and the output, recursively; the project root itself only
101
+ * for its own entries (`manifest.ts`, `package.json`); and `watch` extras.
102
+ */
103
+ function devWatchTargets(config, outDir) {
104
+ const skip = (name) => name === 'node_modules' || name.startsWith('.');
105
+ const out = [{ path: config.root, recursive: false }];
106
+ for (const entry of readdirSync(config.root, { withFileTypes: true })) {
107
+ const full = join(config.root, entry.name);
108
+ if (!entry.isDirectory() || skip(entry.name) || full === outDir || outDir.startsWith(full + sep))
109
+ continue;
110
+ out.push({ path: full, recursive: true });
111
+ }
112
+ for (const extra of config.watch)
113
+ out.push({ path: extra, recursive: true });
114
+ return out;
115
+ }
116
+ export const webextDevCommand = {
117
+ command: 'dev',
118
+ description: 'Build one target in development mode, launch it in a browser via web-ext, and rebuild in place on change.',
119
+ builder: (yargs) => yargs
120
+ .option('target', {
121
+ ...targetOption,
122
+ description: 'The target to run (default: the first Firefox target).',
123
+ })
124
+ .option('out-dir', { description: 'Output directory (default: <outDir>-dev).', type: 'string' })
125
+ .option('define', defineOption)
126
+ .option('launch', {
127
+ description: 'Start a browser with the extension (--no-launch only rebuilds).',
128
+ type: 'boolean',
129
+ default: true,
130
+ })
131
+ .option('browser-binary', { description: 'Browser executable web-ext should start.', type: 'string' })
132
+ .option('profile', {
133
+ description: 'Browser profile directory (default: $XDG_CACHE_HOME/gjsify/webext/<name>/<target>).',
134
+ type: 'string',
135
+ })
136
+ .option('headless', { description: 'Start the browser without a window.', type: 'boolean', default: false })
137
+ .option('debounce', { description: 'Quiet window in ms before a rebuild.', type: 'number', default: 200 }),
138
+ handler: async (args) => {
139
+ const cwd = process.cwd();
140
+ const config = loadConfig(cwd);
141
+ const target = devTarget(config, args.target);
142
+ const outDir = args.outDir ? resolve(cwd, args.outDir) : `${config.outDir}-dev`;
143
+ const define = effectiveDefines(config, args.define);
144
+ const sourceDir = join(outDir, target.id);
145
+ let webExt = null;
146
+ if (args.launch) {
147
+ webExt = resolveWebExt(cwd);
148
+ if (webExt === null)
149
+ throw new Error(WEB_EXT_MISSING);
150
+ }
151
+ let browser = null;
152
+ const stopBrowser = () => {
153
+ browser?.kill();
154
+ };
155
+ // Registered BEFORE the loop's own handlers, which end in process.exit.
156
+ process.on('SIGINT', stopBrowser);
157
+ process.on('SIGTERM', stopBrowser);
158
+ const launch = async () => {
159
+ if (webExt === null || browser !== null)
160
+ return;
161
+ const profile = args.profile ? resolve(cwd, args.profile) : defaultDevProfile(config.name, target);
162
+ // web-ext creates the profile directory but not its parent (ENOENT on a fresh machine).
163
+ mkdirSync(dirname(profile), { recursive: true });
164
+ const argv = webExtRunArgs({
165
+ sourceDir,
166
+ target,
167
+ profile,
168
+ browserBinary: args.browserBinary,
169
+ headless: args.headless,
170
+ });
171
+ console.log(`[webext] launching ${target.id} with profile ${profile}`);
172
+ const { spawnSupervised } = await import('../../utils/watch-loop.js');
173
+ browser = await spawnSupervised(webExt.cmd, argv, webExt.env);
174
+ browser.on('exit', (code) => {
175
+ console.log(`[webext] browser closed (${code ?? 0}) — stopping`);
176
+ process.exit(0);
177
+ });
178
+ };
179
+ const { runWatchLoop } = await import('../../utils/watch-loop.js');
180
+ await runWatchLoop({
181
+ dir: cwd,
182
+ dirLabel: '.',
183
+ label: 'webext',
184
+ watch: devWatchTargets(config, outDir),
185
+ ignore: (changed) => changed === outDir || changed.startsWith(outDir + sep),
186
+ build: async () => {
187
+ await buildWebext({
188
+ config,
189
+ targets: [target],
190
+ outDir,
191
+ mode: 'development',
192
+ define,
193
+ inPlace: true,
194
+ zip: false,
195
+ bundle: bundleInProcess,
196
+ });
197
+ // After the first SUCCESSFUL build: web-ext on a folder with no manifest.json exits at once.
198
+ await launch();
199
+ },
200
+ spawnChild: null,
201
+ debounceMs: args.debounce,
202
+ });
203
+ },
204
+ };
@@ -743,7 +743,7 @@ export interface ShipFlatpakOptions {
743
743
  * here (ADR 0024 § 8; the window is `utils/ship/flatpak-config.ts`). They are
744
744
  * NOT marked `@deprecated`: for the subcommands in this group they are still
745
745
  * the only spelling, and those commands have not moved under `ship` yet
746
- * (`status/open-todos.md`). Nothing else in this block is deprecated at all —
746
+ * (`status/open-todos/README.md`). Nothing else in this block is deprecated at all —
747
747
  * the {@link AppMetadata} half is a designed alias, not a legacy one.
748
748
  */
749
749
  export interface ConfigDataFlatpak extends AppMetadata {
@@ -138,7 +138,7 @@ export declare function buildLauncherShims(opts: {
138
138
  * in shell would be a knowingly partial THIRD copy of a two-copy rule, in the
139
139
  * one language nothing here type-checks. The REMAINING gap — a host GI stack
140
140
  * under a prefix dyld never defaulted to, `/opt/homebrew/lib` on Apple silicon —
141
- * stays in `status/open-todos.md`, to be closed by teaching such a launcher to
141
+ * stays in `status/open-todos/README.md`, to be closed by teaching such a launcher to
142
142
  * defer to the CLI rather than to re-derive.
143
143
  *
144
144
  * @param scanRoot Directory whose `node_modules` is globbed at launch time.
@@ -178,7 +178,7 @@ export declare function buildNativeEnvPreamble(scanRoot: string, bakedDirs?: rea
178
178
  *
179
179
  * NOT the host's GI libdirs: `/opt/homebrew/lib` was never in dyld's default
180
180
  * either, so an ARM Mac has that gap with or without gjsify —
181
- * `status/open-todos.md`.
181
+ * `status/open-todos/README.md`.
182
182
  */
183
183
  export declare function dyldFallbackPreamble(platform: string): string;
184
184
  /** POSIX `sh` single-quoting for a launcher's target path. */
@@ -263,6 +263,12 @@ export declare function buildShLauncher(targetAbs: string, opts: {
263
263
  envPreamble?: string;
264
264
  isGjsBundle: boolean;
265
265
  nodeFallbackAbs?: string;
266
+ /**
267
+ * The gjs to exec — `GJS_CONSOLE` for the CLI's self-shim. NAMED rather
268
+ * than pathed by default, so a missing interpreter fails the same way on
269
+ * every OS.
270
+ */
271
+ gjs?: string;
266
272
  }): string;
267
273
  /** True when a bin target is a GJS-runnable bundle rather than a Node script. */
268
274
  export declare function isGjsBundlePath(target: string): boolean;
@@ -349,7 +349,7 @@ function shQuote(s) {
349
349
  * in shell would be a knowingly partial THIRD copy of a two-copy rule, in the
350
350
  * one language nothing here type-checks. The REMAINING gap — a host GI stack
351
351
  * under a prefix dyld never defaulted to, `/opt/homebrew/lib` on Apple silicon —
352
- * stays in `status/open-todos.md`, to be closed by teaching such a launcher to
352
+ * stays in `status/open-todos/README.md`, to be closed by teaching such a launcher to
353
353
  * defer to the CLI rather than to re-derive.
354
354
  *
355
355
  * @param scanRoot Directory whose `node_modules` is globbed at launch time.
@@ -449,7 +449,7 @@ export function buildNativeEnvPreamble(scanRoot, bakedDirs = [], target = {}) {
449
449
  *
450
450
  * NOT the host's GI libdirs: `/opt/homebrew/lib` was never in dyld's default
451
451
  * either, so an ARM Mac has that gap with or without gjsify —
452
- * `status/open-todos.md`.
452
+ * `status/open-todos/README.md`.
453
453
  */
454
454
  export function dyldFallbackPreamble(platform) {
455
455
  if (platform !== 'darwin')
@@ -556,15 +556,16 @@ export function pickBinMap(pkgName, pkgJson) {
556
556
  */
557
557
  export function buildShLauncher(targetAbs, opts) {
558
558
  const preamble = opts.isGjsBundle ? (opts.envPreamble ?? '') : '';
559
+ const gjs = opts.gjs === undefined || opts.gjs === 'gjs' ? 'gjs' : shQuote(opts.gjs);
559
560
  if (opts.isGjsBundle && opts.nodeFallbackAbs !== undefined) {
560
561
  // The preamble only ever configures the GI search path, so it stays
561
562
  // inside the gjs branch — exporting it around a Node exec would be
562
563
  // inert at best and misleading to anyone reading the shim.
563
- return (`#!/bin/sh\nif command -v gjs >/dev/null 2>&1; then\n${preamble}` +
564
- `exec gjs -m ${shQuote(targetAbs)} "$@"\nfi\n` +
564
+ return (`#!/bin/sh\nif command -v ${gjs} >/dev/null 2>&1; then\n${preamble}` +
565
+ `exec ${gjs} -m ${shQuote(targetAbs)} "$@"\nfi\n` +
565
566
  `exec node ${shQuote(opts.nodeFallbackAbs)} "$@"\n`);
566
567
  }
567
- const exec = opts.isGjsBundle ? `exec gjs -m ${shQuote(targetAbs)} "$@"` : `exec ${shQuote(targetAbs)} "$@"`;
568
+ const exec = opts.isGjsBundle ? `exec ${gjs} -m ${shQuote(targetAbs)} "$@"` : `exec ${shQuote(targetAbs)} "$@"`;
568
569
  return `#!/bin/sh\n${preamble}${exec}\n`;
569
570
  }
570
571
  /** True when a bin target is a GJS-runnable bundle rather than a Node script. */
@@ -0,0 +1,84 @@
1
+ import type { RolldownPluginOption } from 'rolldown';
2
+ /**
3
+ * Bumped when the shape changes; a manifest of any other version is ignored.
4
+ *
5
+ * v2 adds `watchList`. A v1 manifest says nothing about whether the engine that
6
+ * wrote it could report a watch list at all, and the safe reading of "unknown"
7
+ * is "do not trust it": see {@link readBundleInputs}.
8
+ */
9
+ export declare const BUNDLE_INPUTS_VERSION = 2;
10
+ /** What a manifest says, once parsed. */
11
+ export interface BundleInputs {
12
+ inputs: string[];
13
+ /** `false` = this bundle's build could not see plugin-read inputs at all. */
14
+ watchList: boolean;
15
+ }
16
+ /** The manifest that belongs to `outfile`. */
17
+ export declare function bundleInputsPath(outfile: string): string;
18
+ export interface BundleInputsRecorder {
19
+ /** Pass to the bundler as a plugin; it observes, it never transforms. */
20
+ plugin: RolldownPluginOption;
21
+ /**
22
+ * The build's own watch list — what the engine reports, which is its module
23
+ * set PLUS everything a plugin declared with `this.addWatchFile`. That is
24
+ * the only account of a file the bundler never turned into a module: the
25
+ * CSS plugin follows a stylesheet's `@import` chain with its own `readFile`,
26
+ * and no graph mentions the imported file.
27
+ */
28
+ addWatchFiles(watch: {
29
+ files: readonly string[];
30
+ reportedByEngine: boolean;
31
+ }): void;
32
+ /** The recorded inputs so far: sorted, deduplicated, existing files only. */
33
+ inputs(): string[];
34
+ /**
35
+ * Persist the manifest beside `outfile`, so the NEXT freshness check can
36
+ * judge against the same set. Never throws: a manifest that cannot be
37
+ * written must not fail a test run — it degrades the check to the package
38
+ * walk, and says so.
39
+ */
40
+ write(): void;
41
+ }
42
+ /**
43
+ * A plugin that records every module the build transforms, plus the writer for
44
+ * the manifest.
45
+ *
46
+ * `transform` rather than a graph API (`getModuleIds`/`getModuleInfo`) because
47
+ * it is the one hook both engines run and the native bridge translates
48
+ * (`bundler-pick.ts` picks it verbatim). Every module that reaches the
49
+ * transform stage was LOADED from disk, so the observed set is what the build
50
+ * read — the modules it then tree-shook away are still inputs of the next
51
+ * build, which is the direction that errs safely.
52
+ *
53
+ * The observation is KEPT alongside {@link BundleInputsRecorder.addWatchFiles}
54
+ * rather than replaced by it, and neither engine is the reason: the native
55
+ * engine has no watch list to hand over (`BundleResult` carries warnings and
56
+ * output only, and its JS context had no `addWatchFile` at all until
57
+ * `rolldown-native` grew one), so on GJS the observed set is the only account
58
+ * of the modules; on npm the watch list is a superset of it. Union: the cost of
59
+ * a redundant path is a `stat`, the cost of a missing one is a stale green.
60
+ */
61
+ export declare function createBundleInputsRecorder(outfile: string): BundleInputsRecorder;
62
+ /**
63
+ * The inputs recorded for `outfile` by the build that wrote it, or `null` when
64
+ * there is none to trust — never written, unreadable, or written by another
65
+ * version of this shape. `null` is the fallback the caller must handle, not an
66
+ * error: a bundle built by an older CLI, or copied in from elsewhere, has no
67
+ * manifest and is judged by the package walk alone.
68
+ */
69
+ export declare function readBundleInputs(outfile: string): BundleInputs | null;
70
+ /**
71
+ * Newest mtime (ms) among recorded inputs, or 0 for an empty set.
72
+ *
73
+ * A recorded input that has since VANISHED counts as CHANGED, exactly as the
74
+ * package walk counts one that vanished mid-walk: an input we cannot read is
75
+ * not evidence that the artifact is current. A workspace-package source deleted
76
+ * while the bundle still imports it is precisely that case, and skipping it
77
+ * would rerun a bundle built from a file that no longer exists.
78
+ *
79
+ * Safe only because the toolchain's own scratch is never RECORDED
80
+ * ({@link isToolchainScratch}): the globals-inject stub and the cached plugin
81
+ * bundles are written and deleted between builds, and treating their absence as
82
+ * a change would rebuild on every run, forever.
83
+ */
84
+ export declare function newestBundleInputMtimeMs(inputs: readonly string[]): number;