@gjsify/cli 0.51.1 → 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 (126) hide show
  1. package/dist/cli.gjs.mjs +539 -493
  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 +7 -2
  7. package/lib/commands/affected-classify.d.ts +7 -1
  8. package/lib/commands/affected-classify.js +66 -9
  9. package/lib/commands/affected-script-refs.d.ts +20 -0
  10. package/lib/commands/affected-script-refs.js +218 -0
  11. package/lib/commands/affected.js +24 -2
  12. package/lib/commands/env.d.ts +31 -0
  13. package/lib/commands/env.js +114 -0
  14. package/lib/commands/exec.d.ts +10 -0
  15. package/lib/commands/exec.js +208 -0
  16. package/lib/commands/flatpak/diff.js +8 -2
  17. package/lib/commands/flatpak/init.js +4 -1
  18. package/lib/commands/flatpak/sources.js +4 -3
  19. package/lib/commands/flatpak/sync-flathub.d.ts +2 -0
  20. package/lib/commands/flatpak/sync-flathub.js +149 -7
  21. package/lib/commands/flatpak/utils.d.ts +10 -0
  22. package/lib/commands/flatpak/utils.js +10 -0
  23. package/lib/commands/foreach.js +5 -97
  24. package/lib/commands/format.js +26 -6
  25. package/lib/commands/index.d.ts +4 -0
  26. package/lib/commands/index.js +4 -0
  27. package/lib/commands/install.d.ts +2 -2
  28. package/lib/commands/install.js +81 -5
  29. package/lib/commands/link.d.ts +13 -0
  30. package/lib/commands/link.js +243 -0
  31. package/lib/commands/onboard.js +18 -2
  32. package/lib/commands/publish.d.ts +34 -0
  33. package/lib/commands/publish.js +153 -8
  34. package/lib/commands/self-update.js +2 -1
  35. package/lib/commands/ship.js +19 -1
  36. package/lib/commands/test.d.ts +18 -9
  37. package/lib/commands/test.js +56 -11
  38. package/lib/commands/tsc.js +4 -4
  39. package/lib/commands/upgrade.d.ts +15 -0
  40. package/lib/commands/upgrade.js +76 -2
  41. package/lib/commands/webext/index.d.ts +2 -0
  42. package/lib/commands/webext/index.js +15 -0
  43. package/lib/commands/webext/subcommands.d.ts +16 -0
  44. package/lib/commands/webext/subcommands.js +204 -0
  45. package/lib/types/config-data.d.ts +2 -2
  46. package/lib/utils/bin-shim.d.ts +8 -2
  47. package/lib/utils/bin-shim.js +6 -5
  48. package/lib/utils/bundle-inputs.d.ts +84 -0
  49. package/lib/utils/bundle-inputs.js +268 -0
  50. package/lib/utils/check-system-deps.d.ts +5 -19
  51. package/lib/utils/check-system-deps.js +31 -94
  52. package/lib/utils/dev-link.d.ts +225 -0
  53. package/lib/utils/dev-link.js +684 -0
  54. package/lib/utils/exec-bin.d.ts +137 -0
  55. package/lib/utils/exec-bin.js +321 -0
  56. package/lib/utils/gi-runtime-paths.js +3 -2
  57. package/lib/utils/gitignore.d.ts +15 -0
  58. package/lib/utils/gitignore.js +178 -0
  59. package/lib/utils/gjsify-shim.d.ts +47 -0
  60. package/lib/utils/gjsify-shim.js +97 -8
  61. package/lib/utils/hang-watchdog.d.ts +95 -0
  62. package/lib/utils/hang-watchdog.js +194 -0
  63. package/lib/utils/install-backend-native.d.ts +33 -1
  64. package/lib/utils/install-backend-native.js +184 -20
  65. package/lib/utils/install-extraneous.d.ts +90 -0
  66. package/lib/utils/install-extraneous.js +120 -0
  67. package/lib/utils/install-lock.js +4 -25
  68. package/lib/utils/install-tarball-cache.d.ts +37 -9
  69. package/lib/utils/install-tarball-cache.js +59 -14
  70. package/lib/utils/native-skip-scan.d.ts +20 -0
  71. package/lib/utils/native-skip-scan.js +343 -0
  72. package/lib/utils/node-script.js +1 -1
  73. package/lib/utils/normalize-bundler-options.js +11 -0
  74. package/lib/utils/oxc-resolve.js +42 -3
  75. package/lib/utils/process-table.d.ts +22 -0
  76. package/lib/utils/process-table.js +188 -0
  77. package/lib/utils/prune-prefix.js +1 -1
  78. package/lib/utils/publish-pins.d.ts +38 -0
  79. package/lib/utils/publish-pins.js +47 -0
  80. package/lib/utils/publish-readback.d.ts +103 -17
  81. package/lib/utils/publish-readback.js +326 -47
  82. package/lib/utils/run-gjs.d.ts +15 -1
  83. package/lib/utils/run-gjs.js +98 -13
  84. package/lib/utils/run-node.d.ts +2 -0
  85. package/lib/utils/run-node.js +2 -1
  86. package/lib/utils/runtimes.js +14 -13
  87. package/lib/utils/ship/gzip.js +1 -1
  88. package/lib/utils/ship/icns.d.ts +14 -0
  89. package/lib/utils/ship/icns.js +83 -0
  90. package/lib/utils/ship/ico.d.ts +25 -0
  91. package/lib/utils/ship/ico.js +120 -0
  92. package/lib/utils/ship/icons.d.ts +197 -0
  93. package/lib/utils/ship/icons.js +399 -0
  94. package/lib/utils/ship/launcher.js +16 -1
  95. package/lib/utils/ship/layout.d.ts +32 -0
  96. package/lib/utils/ship/layout.js +112 -38
  97. package/lib/utils/ship/msi.d.ts +1 -1
  98. package/lib/utils/ship/msi.js +30 -5
  99. package/lib/utils/ship/pe-launcher.d.ts +18 -0
  100. package/lib/utils/ship/pe-launcher.js +125 -4
  101. package/lib/utils/ship/plan.d.ts +1 -34
  102. package/lib/utils/ship/plan.js +7 -56
  103. package/lib/utils/ship/plist.d.ts +20 -10
  104. package/lib/utils/ship/plist.js +29 -12
  105. package/lib/utils/ship/settings.js +3 -2
  106. package/lib/utils/spawn.d.ts +13 -0
  107. package/lib/utils/spawn.js +10 -5
  108. package/lib/utils/watch-loop.d.ts +15 -1
  109. package/lib/utils/watch-loop.js +9 -5
  110. package/lib/utils/webext/build.d.ts +42 -0
  111. package/lib/utils/webext/build.js +120 -0
  112. package/lib/utils/webext/config.d.ts +72 -0
  113. package/lib/utils/webext/config.js +234 -0
  114. package/lib/utils/webext/icons.d.ts +24 -0
  115. package/lib/utils/webext/icons.js +83 -0
  116. package/lib/utils/webext/launch.d.ts +32 -0
  117. package/lib/utils/webext/launch.js +77 -0
  118. package/lib/utils/webext/manifest.d.ts +53 -0
  119. package/lib/utils/webext/manifest.js +219 -0
  120. package/lib/utils/webext/pages.d.ts +26 -0
  121. package/lib/utils/webext/pages.js +61 -0
  122. package/lib/utils/webext/targets.d.ts +13 -0
  123. package/lib/utils/webext/targets.js +36 -0
  124. package/lib/utils/win32-command.d.ts +17 -0
  125. package/lib/utils/win32-command.js +21 -6
  126. package/package.json +20 -20
@@ -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 {
@@ -771,7 +771,7 @@ export interface ConfigDataFlatpak extends AppMetadata {
771
771
  command?: string;
772
772
  /** Finish-args (capabilities). Default depends on `runtime` + `--cli-only`. */
773
773
  finishArgs?: string[];
774
- /** Extra Flatpak modules prepended before the app's own meson/simple module (e.g. `blueprint-compiler` build). */
774
+ /** Extra Flatpak modules prepended before the app's own meson/simple module (e.g. a GI library the runtime lacks). */
775
775
  extraModules?: unknown[];
776
776
  /**
777
777
  * Full replacement for the manifest's `modules` array: used verbatim, with
@@ -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;
@@ -0,0 +1,268 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // The SECOND half of "what are this bundle's build inputs" — what the BUNDLER
3
+ // read, recorded at build time beside the bundle it produced.
4
+ //
5
+ // `package-inputs.ts` answers the other half by walking the package, and that
6
+ // walk cannot see outside the package. A consumer's test bundle routinely reads
7
+ // outside it: a workspace package reached through `node_modules/<name>` — a
8
+ // SYMLINK into a sibling directory — is a file the build read and the walk
9
+ // never enumerates. An edit there left `gjsify test` judging a fresh bundle and
10
+ // rerunning it: a stale GREEN, the worst kind of wrong, and the only remedy a
11
+ // consumer had was `rm -rf dist`.
12
+ //
13
+ // The set is recorded from the bundler's own module graph rather than guessed
14
+ // from a directory list: a bare specifier's resolution is known to the bundler
15
+ // and to nobody else, and a list of "likely dirs" is wrong for every layout
16
+ // nobody anticipated — the same failure `package-inputs.ts` documents twice.
17
+ //
18
+ // TWO halves, because the bundler is not the only reader: a `transform`
19
+ // observer sees every MODULE, and the engine's watch list carries what a plugin
20
+ // read and declared itself (a stylesheet's `@import` chain is read by
21
+ // `gjsify-css-as-string`, not by the bundler, so no graph names it). Neither
22
+ // engine reports both, so the recorder takes the union. Deciding freshness
23
+ // stays cheap: the manifest is written once per build and read as `stat` calls.
24
+ //
25
+ // `package-inputs.ts` is not replaced by this. Its walk answers "what MAY this
26
+ // package's build read", which covers a file the current build does not import
27
+ // yet; this answers "what DID this build read", which reaches outside the
28
+ // package. The union is the honest input set, and both halves must exist.
29
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
30
+ import { dirname, join, sep } from 'node:path';
31
+ /**
32
+ * Bumped when the shape changes; a manifest of any other version is ignored.
33
+ *
34
+ * v2 adds `watchList`. A v1 manifest says nothing about whether the engine that
35
+ * wrote it could report a watch list at all, and the safe reading of "unknown"
36
+ * is "do not trust it": see {@link readBundleInputs}.
37
+ */
38
+ export const BUNDLE_INPUTS_VERSION = 2;
39
+ /** The manifest that belongs to `outfile`. */
40
+ export function bundleInputsPath(outfile) {
41
+ return `${outfile}.inputs.json`;
42
+ }
43
+ /**
44
+ * A module id as a path, or `null` when it names no file we can stat.
45
+ *
46
+ * A query or a hash is PART OF THE ID, not of the path: `@scope/pkg/x.css?raw`
47
+ * and `main-window.blp?shared-tree` (the blueprint plugin's shared-tree exit)
48
+ * both stat as ENOENT, and dropping the id silently would drop the file with
49
+ * it. `\0` is the bundler's virtual-module marker — no file, ever.
50
+ */
51
+ function idToPath(id) {
52
+ if (id === '' || id.startsWith('\0'))
53
+ return null;
54
+ const cut = id.search(/[?#]/);
55
+ if (cut === -1)
56
+ return id;
57
+ const stripped = id.slice(0, cut);
58
+ // The fallback is not a nicety: `#` is LEGAL in a Linux filename, and
59
+ // `a#b.blp` is that file's whole name. Stripping turned it into `a`, which
60
+ // does not exist, and the input was dropped. So the unstripped id gets its
61
+ // turn — and when neither is a file, `inputs()` drops it as before.
62
+ return isFile(stripped) ? stripped : id;
63
+ }
64
+ function isFile(abs) {
65
+ try {
66
+ return statSync(abs).isFile();
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ /**
73
+ * Files the TOOLCHAIN writes and deletes between builds, which therefore must
74
+ * never enter the input set.
75
+ *
76
+ * Not a filter on staleness but on membership: a vanished recorded input forces
77
+ * a rebuild (see {@link newestBundleInputMtimeMs}), so recording the
78
+ * globals-inject stub — `node_modules/.cache/gjsify/auto-globals-<hash>.mjs`,
79
+ * rewritten per build and DELETED when its config changes — would rebuild every
80
+ * run, forever. The same directory holds the cached GJS plugin bundles, which
81
+ * carry their own freshness rule (`bundleFileForGjsCached`).
82
+ *
83
+ * **Not a `tmpdir()` test**, which is the obvious wrong answer and the one this
84
+ * started as: the stub is written into the project's own cache directory
85
+ * (`utils/scan-globals.ts`), never to a temp dir, while a PROJECT under
86
+ * `/tmp` is a project — a CI job, a container, this suite's own fixtures. A
87
+ * freshness check that goes blind in those trees is the defect being fixed,
88
+ * reached from the other side.
89
+ */
90
+ function isToolchainScratch(abs) {
91
+ return abs.includes(`${sep}node_modules${sep}.cache${sep}`);
92
+ }
93
+ /**
94
+ * The resolution files of the package a recorded input belongs to: the nearest
95
+ * `package.json` above it, and the `tsconfig.json` beside it when there is one.
96
+ *
97
+ * Neither is ever a module. A workspace package reached through a symlink
98
+ * contributes its files, but a `package.json#exports` edit — which moves what
99
+ * every bare specifier in the app resolves to — or a `tsconfig.json` that turns
100
+ * on a `paths` mapping changes the output with no file of its own in the graph.
101
+ * Per package, deduplicated, so the walk is a handful of stats however many
102
+ * modules that package contributed.
103
+ */
104
+ function resolutionFilesFor(abs) {
105
+ const found = [];
106
+ for (let dir = dirname(abs);; dir = dirname(dir)) {
107
+ const manifest = join(dir, 'package.json');
108
+ if (existsSync(manifest)) {
109
+ found.push(manifest);
110
+ const tsconfig = join(dir, 'tsconfig.json');
111
+ if (existsSync(tsconfig))
112
+ found.push(tsconfig);
113
+ return found;
114
+ }
115
+ const parent = dirname(dir);
116
+ if (parent === dir)
117
+ return found;
118
+ }
119
+ }
120
+ /**
121
+ * A plugin that records every module the build transforms, plus the writer for
122
+ * the manifest.
123
+ *
124
+ * `transform` rather than a graph API (`getModuleIds`/`getModuleInfo`) because
125
+ * it is the one hook both engines run and the native bridge translates
126
+ * (`bundler-pick.ts` picks it verbatim). Every module that reaches the
127
+ * transform stage was LOADED from disk, so the observed set is what the build
128
+ * read — the modules it then tree-shook away are still inputs of the next
129
+ * build, which is the direction that errs safely.
130
+ *
131
+ * The observation is KEPT alongside {@link BundleInputsRecorder.addWatchFiles}
132
+ * rather than replaced by it, and neither engine is the reason: the native
133
+ * engine has no watch list to hand over (`BundleResult` carries warnings and
134
+ * output only, and its JS context had no `addWatchFile` at all until
135
+ * `rolldown-native` grew one), so on GJS the observed set is the only account
136
+ * of the modules; on npm the watch list is a superset of it. Union: the cost of
137
+ * a redundant path is a `stat`, the cost of a missing one is a stale green.
138
+ */
139
+ export function createBundleInputsRecorder(outfile) {
140
+ const seen = new Set();
141
+ // Sticky, and pessimistic: ONE pass without a watch list is enough to know
142
+ // the build cannot see a plugin-read input, and the artifact must not be
143
+ // judged fresh on the strength of the passes that could.
144
+ let watchList = true;
145
+ const record = (raw) => {
146
+ const abs = idToPath(raw);
147
+ if (abs !== null)
148
+ seen.add(abs);
149
+ };
150
+ const plugin = {
151
+ name: 'gjsify:bundle-inputs',
152
+ transform(_code, id) {
153
+ record(id);
154
+ return undefined;
155
+ },
156
+ };
157
+ const inputs = () => {
158
+ const out = [];
159
+ const packages = new Set();
160
+ for (const abs of seen) {
161
+ let isFile;
162
+ try {
163
+ isFile = statSync(abs).isFile();
164
+ }
165
+ catch {
166
+ continue;
167
+ }
168
+ if (!isFile || isToolchainScratch(abs))
169
+ continue;
170
+ out.push(abs);
171
+ // `resolutionFilesFor` walks to the package root; one call per
172
+ // DIRECTORY keeps a 1000-module bundle from walking 1000 times.
173
+ const dir = dirname(abs);
174
+ if (packages.has(dir))
175
+ continue;
176
+ packages.add(dir);
177
+ for (const meta of resolutionFilesFor(abs))
178
+ out.push(meta);
179
+ }
180
+ return [...new Set(out)].sort();
181
+ };
182
+ return {
183
+ plugin,
184
+ addWatchFiles(watch) {
185
+ if (!watch.reportedByEngine)
186
+ watchList = false;
187
+ for (const id of watch.files)
188
+ record(id);
189
+ },
190
+ inputs,
191
+ write() {
192
+ const path = bundleInputsPath(outfile);
193
+ const manifest = { version: BUNDLE_INPUTS_VERSION, inputs: inputs(), watchList };
194
+ const tmp = `${path}.tmp`;
195
+ try {
196
+ mkdirSync(dirname(path), { recursive: true });
197
+ // Rename, not write: a manifest is read by the NEXT process, and
198
+ // a half-written one is a parse error there — which reads as "no
199
+ // manifest" and silently degrades the check to the package walk.
200
+ writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8');
201
+ renameSync(tmp, path);
202
+ }
203
+ catch (err) {
204
+ try {
205
+ rmSync(tmp, { force: true });
206
+ }
207
+ catch {
208
+ // Nothing left to clean up — the write never got that far.
209
+ }
210
+ console.warn(`[gjsify test] could not record the build inputs beside ${path} (${err.message}) — ` +
211
+ 'the next run judges freshness by the package walk alone, which cannot see outside this package.');
212
+ }
213
+ },
214
+ };
215
+ }
216
+ /**
217
+ * The inputs recorded for `outfile` by the build that wrote it, or `null` when
218
+ * there is none to trust — never written, unreadable, or written by another
219
+ * version of this shape. `null` is the fallback the caller must handle, not an
220
+ * error: a bundle built by an older CLI, or copied in from elsewhere, has no
221
+ * manifest and is judged by the package walk alone.
222
+ */
223
+ export function readBundleInputs(outfile) {
224
+ let parsed;
225
+ try {
226
+ parsed = JSON.parse(readFileSync(bundleInputsPath(outfile), 'utf-8'));
227
+ }
228
+ catch {
229
+ return null;
230
+ }
231
+ if (parsed?.version !== BUNDLE_INPUTS_VERSION || !Array.isArray(parsed.inputs))
232
+ return null;
233
+ return {
234
+ inputs: parsed.inputs.filter((abs) => typeof abs === 'string'),
235
+ // v2 only, so a missing flag cannot be v2 — but the pessimistic reading
236
+ // is kept anyway: a manifest that does not vouch for its engine's watch
237
+ // list is not one to judge freshness on.
238
+ watchList: parsed.watchList === true,
239
+ };
240
+ }
241
+ /**
242
+ * Newest mtime (ms) among recorded inputs, or 0 for an empty set.
243
+ *
244
+ * A recorded input that has since VANISHED counts as CHANGED, exactly as the
245
+ * package walk counts one that vanished mid-walk: an input we cannot read is
246
+ * not evidence that the artifact is current. A workspace-package source deleted
247
+ * while the bundle still imports it is precisely that case, and skipping it
248
+ * would rerun a bundle built from a file that no longer exists.
249
+ *
250
+ * Safe only because the toolchain's own scratch is never RECORDED
251
+ * ({@link isToolchainScratch}): the globals-inject stub and the cached plugin
252
+ * bundles are written and deleted between builds, and treating their absence as
253
+ * a change would rebuild on every run, forever.
254
+ */
255
+ export function newestBundleInputMtimeMs(inputs) {
256
+ let newest = 0;
257
+ for (const abs of inputs) {
258
+ try {
259
+ const { mtimeMs } = statSync(abs);
260
+ if (mtimeMs > newest)
261
+ newest = mtimeMs;
262
+ }
263
+ catch {
264
+ return Number.POSITIVE_INFINITY;
265
+ }
266
+ }
267
+ return newest;
268
+ }