@gjsify/cli 0.36.0 → 0.38.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 (122) hide show
  1. package/dist/cli.gjs.mjs +169 -169
  2. package/lib/actions/build.d.ts +7 -0
  3. package/lib/actions/build.js +18 -3
  4. package/lib/bundler-pick.js +61 -87
  5. package/lib/cli-fail.d.ts +4 -7
  6. package/lib/cli-fail.js +29 -55
  7. package/lib/commands/affected.js +161 -286
  8. package/lib/commands/install.d.ts +63 -66
  9. package/lib/commands/install.js +372 -536
  10. package/lib/config.d.ts +14 -0
  11. package/lib/config.js +18 -1
  12. package/lib/templates/install.mjs.tmpl +164 -75
  13. package/lib/types/cli-build-options.d.ts +33 -67
  14. package/lib/types/command.d.ts +9 -12
  15. package/lib/types/config-data.d.ts +137 -221
  16. package/lib/utils/auth-npmrc.d.ts +5 -10
  17. package/lib/utils/auth-npmrc.js +11 -21
  18. package/lib/utils/bin-shim.d.ts +40 -25
  19. package/lib/utils/bin-shim.js +51 -26
  20. package/lib/utils/clear-targets.d.ts +8 -11
  21. package/lib/utils/clear-targets.js +22 -29
  22. package/lib/utils/copy-targets.d.ts +13 -19
  23. package/lib/utils/copy-targets.js +31 -44
  24. package/lib/utils/dep-aggregation.d.ts +7 -15
  25. package/lib/utils/dep-aggregation.js +9 -22
  26. package/lib/utils/detect-native-packages.d.ts +89 -122
  27. package/lib/utils/detect-native-packages.js +119 -169
  28. package/lib/utils/dir-link.d.ts +29 -48
  29. package/lib/utils/dir-link.js +33 -56
  30. package/lib/utils/discover-showcases.d.ts +6 -7
  31. package/lib/utils/discover-showcases.js +11 -15
  32. package/lib/utils/dlx-cache.d.ts +29 -38
  33. package/lib/utils/dlx-cache.js +53 -63
  34. package/lib/utils/force-exit.d.ts +0 -3
  35. package/lib/utils/force-exit.js +13 -24
  36. package/lib/utils/gi-search-path.d.ts +16 -24
  37. package/lib/utils/gi-search-path.js +54 -82
  38. package/lib/utils/gjs-bundle-guard.d.ts +5 -13
  39. package/lib/utils/gjs-bundle-guard.js +35 -57
  40. package/lib/utils/gjs-source-escape.d.ts +2 -2
  41. package/lib/utils/gjs-source-escape.js +21 -39
  42. package/lib/utils/gjsify-shim.d.ts +17 -20
  43. package/lib/utils/gjsify-shim.js +80 -120
  44. package/lib/utils/html-entry.d.ts +8 -12
  45. package/lib/utils/html-entry.js +19 -28
  46. package/lib/utils/install-backend-native.d.ts +123 -161
  47. package/lib/utils/install-backend-native.js +536 -737
  48. package/lib/utils/install-backend.d.ts +42 -57
  49. package/lib/utils/install-backend.js +16 -21
  50. package/lib/utils/install-cache-fs.d.ts +10 -15
  51. package/lib/utils/install-cache-fs.js +18 -26
  52. package/lib/utils/install-lock.d.ts +5 -6
  53. package/lib/utils/install-lock.js +46 -65
  54. package/lib/utils/install-packument-cache.d.ts +27 -33
  55. package/lib/utils/install-packument-cache.js +44 -57
  56. package/lib/utils/install-progress.d.ts +2 -9
  57. package/lib/utils/install-progress.js +14 -39
  58. package/lib/utils/install-tarball-cache.d.ts +14 -20
  59. package/lib/utils/install-tarball-cache.js +41 -59
  60. package/lib/utils/library-output.d.ts +9 -22
  61. package/lib/utils/library-output.js +19 -37
  62. package/lib/utils/load-npmrc.d.ts +5 -6
  63. package/lib/utils/load-npmrc.js +18 -27
  64. package/lib/utils/node-bundle-guard.d.ts +4 -8
  65. package/lib/utils/node-bundle-guard.js +21 -35
  66. package/lib/utils/node-script.js +40 -53
  67. package/lib/utils/node-version.d.ts +5 -14
  68. package/lib/utils/node-version.js +15 -24
  69. package/lib/utils/normalize-bundler-options.d.ts +7 -11
  70. package/lib/utils/normalize-bundler-options.js +24 -42
  71. package/lib/utils/npm-oidc.d.ts +23 -37
  72. package/lib/utils/npm-oidc.js +28 -53
  73. package/lib/utils/npm-otp-cache.d.ts +4 -4
  74. package/lib/utils/npm-otp-cache.js +20 -25
  75. package/lib/utils/npm-otp.d.ts +20 -30
  76. package/lib/utils/npm-otp.js +32 -51
  77. package/lib/utils/onboard-probe.d.ts +7 -13
  78. package/lib/utils/onboard-probe.js +16 -27
  79. package/lib/utils/parse-spec.d.ts +3 -3
  80. package/lib/utils/parse-spec.js +4 -5
  81. package/lib/utils/pkg-json-edit.d.ts +7 -12
  82. package/lib/utils/pkg-json-edit.js +13 -26
  83. package/lib/utils/pkg-json.d.ts +11 -18
  84. package/lib/utils/pkg-json.js +19 -28
  85. package/lib/utils/prefixed-output.d.ts +3 -4
  86. package/lib/utils/prefixed-output.js +13 -19
  87. package/lib/utils/prompt.d.ts +3 -4
  88. package/lib/utils/prompt.js +14 -18
  89. package/lib/utils/publish-diagnose.d.ts +11 -16
  90. package/lib/utils/publish-diagnose.js +15 -28
  91. package/lib/utils/publish-headers.d.ts +7 -11
  92. package/lib/utils/publish-headers.js +36 -65
  93. package/lib/utils/publishable-packages.d.ts +4 -6
  94. package/lib/utils/publishable-packages.js +14 -18
  95. package/lib/utils/resolve-gjs-entry.d.ts +5 -8
  96. package/lib/utils/resolve-gjs-entry.js +10 -27
  97. package/lib/utils/resolve-npm-package.d.ts +6 -11
  98. package/lib/utils/resolve-npm-package.js +29 -63
  99. package/lib/utils/resolve-plugin-by-name.d.ts +11 -19
  100. package/lib/utils/resolve-plugin-by-name.js +9 -14
  101. package/lib/utils/run-gjs.d.ts +16 -21
  102. package/lib/utils/run-gjs.js +31 -56
  103. package/lib/utils/run-lifecycle-script.d.ts +5 -9
  104. package/lib/utils/run-lifecycle-script.js +23 -42
  105. package/lib/utils/run-node.d.ts +21 -33
  106. package/lib/utils/run-node.js +43 -64
  107. package/lib/utils/runtimes.d.ts +25 -35
  108. package/lib/utils/runtimes.js +34 -46
  109. package/lib/utils/spawn.d.ts +32 -48
  110. package/lib/utils/spawn.js +51 -84
  111. package/lib/utils/stdin.d.ts +2 -2
  112. package/lib/utils/stdin.js +10 -25
  113. package/lib/utils/suggest.d.ts +6 -7
  114. package/lib/utils/suggest.js +8 -10
  115. package/lib/utils/system-gi.d.ts +36 -27
  116. package/lib/utils/system-gi.js +85 -114
  117. package/lib/utils/trust-registry.d.ts +4 -8
  118. package/lib/utils/trust-registry.js +7 -15
  119. package/lib/utils/win32-command.d.ts +8 -14
  120. package/lib/utils/win32-command.js +29 -43
  121. package/lib/utils/workspace-root.js +7 -12
  122. package/package.json +18 -18
package/lib/config.d.ts CHANGED
@@ -10,5 +10,19 @@ export declare class Config {
10
10
  private readPackageJSON;
11
11
  /** Loads tsconfig.json of the current project */
12
12
  private readTSConfig;
13
+ /**
14
+ * The globals policy for a `gjsify run --node-script` bundle, resolved from the
15
+ * package that OWNS THE SCRIPT — not the cwd, which during a monorepo build is the
16
+ * repo root. `gjsify.nodeScript` overrides either key (see `ConfigData.nodeScript`).
17
+ *
18
+ * CONFIG AND NOT A FLAG because on a host with no `node` the caller is a
19
+ * `package.json` spelling `node scripts/x.mjs`, re-entering the CLI through the shim
20
+ * `writeNodeShim()` writes: no call site to hang a flag on, on exactly the hosts this
21
+ * exists for.
22
+ */
23
+ forNodeScript(scriptPath: string): Promise<{
24
+ globals?: string;
25
+ excludeGlobals?: string[];
26
+ }>;
13
27
  forBuild(cliArgs: ArgumentsCamelCase<CliBuildOptions>): Promise<ConfigData>;
14
28
  }
package/lib/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { APP_NAME } from './constants.js';
2
2
  import { cosmiconfig } from 'cosmiconfig';
3
- import { basename } from 'node:path';
3
+ import { basename, dirname } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { isGjs, hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
6
6
  import { buildAppForRuntime } from './utils/runtimes.js';
@@ -216,6 +216,23 @@ export class Config {
216
216
  const tsconfig = getTsconfig(dirPath)?.config || {};
217
217
  return tsconfig;
218
218
  }
219
+ /**
220
+ * The globals policy for a `gjsify run --node-script` bundle, resolved from the
221
+ * package that OWNS THE SCRIPT — not the cwd, which during a monorepo build is the
222
+ * repo root. `gjsify.nodeScript` overrides either key (see `ConfigData.nodeScript`).
223
+ *
224
+ * CONFIG AND NOT A FLAG because on a host with no `node` the caller is a
225
+ * `package.json` spelling `node scripts/x.mjs`, re-entering the CLI through the shim
226
+ * `writeNodeShim()` writes: no call site to hang a flag on, on exactly the hosts this
227
+ * exists for.
228
+ */
229
+ async forNodeScript(scriptPath) {
230
+ const { config } = await this.load(dirname(scriptPath));
231
+ return {
232
+ globals: config.nodeScript?.globals ?? config.globals,
233
+ excludeGlobals: config.nodeScript?.excludeGlobals ?? config.excludeGlobals,
234
+ };
235
+ }
219
236
  async forBuild(cliArgs) {
220
237
  const configFile = await this.load(process.cwd());
221
238
  const configData = { ...configFile.config };
@@ -1,40 +1,21 @@
1
1
  #!/usr/bin/env -S gjs -m
2
2
  /**
3
- * gjsify universal installer — bootstraps `@gjsify/cli` (or any GJS app
4
- * published to npm) on a system that has only `gjs` (and `curl`/`wget`)
5
- * available, without requiring Node.js or `npm`.
3
+ * gjsify universal installer — bootstraps `@gjsify/cli` (or any GJS app on npm)
4
+ * on a system that has only `gjs` and libsoup: no Node, no npm. Decision and
5
+ * history: docs/adr/0002-bootstrap-bundle-minimization.md.
6
6
  *
7
- * Usage:
8
- * gjs -m install.mjs # install / update @gjsify/cli
9
- * gjs -m install.mjs --target @scope/x # install any other GJS-runnable npm package
10
- * gjs -m install.mjs --tag next # pick an npm dist-tag or pinned version
11
- * gjs -m install.mjs --force # reinstall even if already present
12
- * gjs -m install.mjs --help
7
+ * Two stages: download a self-contained GJS bundle of `@gjsify/cli`
8
+ * (`cli.gjs.mjs`) from this repo's GitHub releases and verify its SHA-256, then
9
+ * spawn it as `gjs -m <bundle> install -g <target>@<tag>`. Dependency
10
+ * resolution, native prebuilds, lockfiles and the `~/.local/bin` launchers all
11
+ * live in that bundle — this bootstrapper re-implements none of it on purpose.
13
12
  *
14
- * How it works (two-stage bootstrap):
15
- * 1. Download a small self-contained GJS bundle of the @gjsify/cli
16
- * (`cli.gjs.mjs`) from this repo's GitHub releases. Verify SHA-256.
17
- * 2. Spawn that bundle: `gjs -m <bundle> install -g <target>@<tag>`. The
18
- * bundle handles transitive dependency resolution, native prebuilds,
19
- * lockfiles, and the `~/.local/bin` launchers — all the things this
20
- * thin bootstrapper deliberately does NOT re-implement.
13
+ * `gjsify generate-installer` re-emits this file for end-user GJS apps with the
14
+ * marked constants substituted.
21
15
  *
22
- * Generated by `gjsify generate-installer` for end-user GJS apps: in that
23
- * mode the constants below (BOOTSTRAP_URL, DEFAULT_TARGET, DEFAULT_BIN_NAME)
24
- * are pre-substituted to the consumer's package + custom bootstrap URL.
25
- *
26
- * Test hooks (set by tests/e2e/install-script/run.mjs):
27
- * GJSIFY_INSTALL_BOOTSTRAP_URL override the cli.gjs.mjs download origin
28
- * (accepts file:// for offline tests)
29
- * GJSIFY_INSTALL_BOOTSTRAP_SHA256_URL override the .sha256 companion URL.
30
- * An EMPTY string is the only way to install
31
- * without verification, and it warns loudly; a
32
- * digest URL that is set but unreachable is a
33
- * hard failure, never a silent downgrade.
34
- * GJSIFY_GLOBAL_PREFIX override install prefix (forwarded to cli)
35
- * GJSIFY_GLOBAL_BIN_DIR override bin dir (forwarded to cli)
36
- * GJSIFY_INSTALL_REGISTRY override npm registry (forwarded as npm_config_registry)
37
- * GJSIFY_INSTALL_BOOTSTRAP_CACHE override the bootstrap cache dir
16
+ * `--help` documents the user-facing flags and env vars. Two more exist only for
17
+ * tests/e2e/install-script/run.mjs: GJSIFY_INSTALL_BOOTSTRAP_SHA256_URL and
18
+ * GJSIFY_INSTALL_BOOTSTRAP_CACHE.
38
19
  */
39
20
 
40
21
  import GLib from 'gi://GLib';
@@ -53,17 +34,16 @@ const DEFAULT_BOOTSTRAP_SHA256_URL = `${DEFAULT_BOOTSTRAP_URL}.sha256`;
53
34
 
54
35
  const USER_AGENT = 'gjsify-installer/1.0';
55
36
 
56
- // The bootstrap cache is CONTENT-ADDRESSED: `cli-<sha256>.gjs.mjs`. See
57
- // `resolveBootstrap()` for why the digest, not a fixed name, keys it.
37
+ // Content-addressed cache: `cli-<sha256>.gjs.mjs`. Why the digest and not a
38
+ // fixed name keys it: `resolveBootstrap()`.
58
39
  const CACHE_PREFIX = 'cli-';
59
40
  const CACHE_SUFFIX = '.gjs.mjs';
60
41
 
61
42
  /**
62
- * Under `--fetch-only` the ONLY thing on stdout must be the bundle path, so
63
- * progress goes to stderr instead. A caller does
64
- * `BOOTSTRAP=$(gjs -m install.mjs --fetch-only)`, and one stray `info()` line
65
- * would make that a path with a log message glued to the front — a failure that
66
- * surfaces as a confusing "file not found" rather than as bad output.
43
+ * Under `--fetch-only` stdout carries nothing but the bundle path, so progress
44
+ * goes to stderr. A caller does `BOOTSTRAP=$(gjs -m install.mjs --fetch-only)`,
45
+ * where one stray `info()` line glues a log message to the front of the path and
46
+ * surfaces later as a confusing "file not found".
67
47
  */
68
48
  let logToStderr = false;
69
49
 
@@ -139,7 +119,7 @@ Examples:
139
119
  }
140
120
 
141
121
  function checkGjsVersion() {
142
- // imports.system.version is packed: major*10000 + minor*100 + micro
122
+ // `system.version` is packed: major*10000 + minor*100 + micro
143
123
  const v = system?.version;
144
124
  if (typeof v !== 'number') return;
145
125
  const major = Math.floor(v / 10000);
@@ -154,6 +134,62 @@ function checkGjsVersion() {
154
134
  }
155
135
  }
156
136
 
137
+ /** Total attempts per URL, and the wait before each retry. Override for tests / slow links. */
138
+ const FETCH_ATTEMPTS = Number(GLib.getenv('GJSIFY_INSTALL_FETCH_ATTEMPTS') || '5');
139
+ const RETRY_BACKOFF_MS = [1000, 2000, 4000, 8000];
140
+
141
+ function sleepMs(ms) {
142
+ return new Promise((resolve) => {
143
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, ms, () => {
144
+ resolve();
145
+ return GLib.SOURCE_REMOVE;
146
+ });
147
+ });
148
+ }
149
+
150
+ async function fetchOnce(session, url, { forceHttp1 = false } = {}) {
151
+ const message = Soup.Message.new('GET', url);
152
+ message.request_headers.append('User-Agent', USER_AGENT);
153
+ // The failure this retries against announces its own protocol — libsoup reports it
154
+ // verbatim as `HTTP/2 Error: NO_ERROR`, a stream closed mid-response. Retrying over
155
+ // HTTP/1.1 sidesteps the multiplexing that produces it, so the first attempt keeps the
156
+ // faster protocol and every retry drops to the one that cannot fail that way. Guarded:
157
+ // `set_force_http1` is libsoup 3.4+, and this script must still run on older hosts.
158
+ if (forceHttp1 && typeof message.set_force_http1 === 'function') message.set_force_http1(true);
159
+ const bytes = await session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null);
160
+ const status = message.get_status();
161
+ if (status !== Soup.Status.OK) {
162
+ const err = new Error(`HTTP ${status} from ${url}`);
163
+ err.httpStatus = status;
164
+ throw err;
165
+ }
166
+ return bytes.get_data();
167
+ }
168
+
169
+ /**
170
+ * A dropped connection is a HICCUP; an HTTP answer is an ANSWER.
171
+ *
172
+ * THE INCIDENT. `https://github.com/gjsify/gjsify/releases/latest/download/…` dropped
173
+ * HTTP/2 connections for a stretch on 2026-08-12 and took four CI jobs across two PRs
174
+ * with it, each dying in `gjsify-setup` before a line of the change under test ran.
175
+ * Measured from a workstation during the same window: one in three `curl` attempts
176
+ * returned `Connection died, tried 5 times before giving up`. A single-shot fetch turns
177
+ * that into "Refusing to install an UNVERIFIED bootstrap bundle", which reads like the
178
+ * digest is missing or hostile — the one message that must never be a false alarm, since
179
+ * the correct response to it is to disable verification.
180
+ *
181
+ * So retries are scoped to what CANNOT be an answer: a transport failure (no status at
182
+ * all), plus 429 and 5xx. A 404 is the registry saying the asset does not exist and a 403
183
+ * is it saying no; retrying either would only delay the same result while making a real
184
+ * outage look like a hang.
185
+ *
186
+ * FIVE attempts, not three, and the numbers come from a run rather than a preference:
187
+ * with three the CI log showed `retry 2/3` and `retry 3/3` and still failed, because each
188
+ * attempt takes ~20 s to give up — so the 500ms/1500ms backoff was noise beside it and the
189
+ * whole sequence covered barely 40 s of a longer wobble. 1/2/4/8 s over five attempts
190
+ * covers about two minutes. Beyond that it is an outage, not a hiccup, and waiting longer
191
+ * only turns a clear failure into a hang.
192
+ */
157
193
  async function fetchBytes(session, url) {
158
194
  if (url.startsWith('file://')) {
159
195
  const path = url.slice('file://'.length);
@@ -161,14 +197,22 @@ async function fetchBytes(session, url) {
161
197
  const [, bytes] = file.load_contents(null);
162
198
  return bytes;
163
199
  }
164
- const message = Soup.Message.new('GET', url);
165
- message.request_headers.append('User-Agent', USER_AGENT);
166
- const bytes = await session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null);
167
- const status = message.get_status();
168
- if (status !== Soup.Status.OK) {
169
- throw new Error(`HTTP ${status} from ${url}`);
200
+ const attempts = Number.isFinite(FETCH_ATTEMPTS) && FETCH_ATTEMPTS >= 1 ? FETCH_ATTEMPTS : 1;
201
+ let lastErr = null;
202
+ for (let attempt = 1; attempt <= attempts; attempt++) {
203
+ try {
204
+ return await fetchOnce(session, url, { forceHttp1: attempt > 1 });
205
+ } catch (err) {
206
+ lastErr = err;
207
+ const status = err.httpStatus;
208
+ const retriable = status === undefined || status === 429 || status >= 500;
209
+ if (!retriable || attempt === attempts) break;
210
+ const wait = RETRY_BACKOFF_MS[Math.min(attempt - 1, RETRY_BACKOFF_MS.length - 1)];
211
+ info(`Fetch of ${url} failed (${err.message}) — retry ${attempt + 1}/${attempts} in ${wait}ms`);
212
+ await sleepMs(wait);
213
+ }
170
214
  }
171
- return bytes.get_data();
215
+ throw lastErr;
172
216
  }
173
217
 
174
218
  function sha256Hex(bytes) {
@@ -197,18 +241,17 @@ function readBytesOrNull(path) {
197
241
  const [, bytes] = Gio.File.new_for_path(path).load_contents(null);
198
242
  return bytes;
199
243
  } catch {
200
- // An absent — or unreadable, e.g. truncated by a killed earlier run —
201
- // cache entry is a MISS, not an error. `load_contents` is a real
202
- // `throws="1"` GIR call, so this catch has a genuine throw path.
244
+ // An absent or unreadable entry (truncated by a killed earlier run) is a
245
+ // MISS, not an error. `load_contents` is `throws="1"`, so this catch has
246
+ // a genuine throw path.
203
247
  return null;
204
248
  }
205
249
  }
206
250
 
207
251
  /**
208
- * Drop every other cached bootstrap so the content-addressed cache stays
209
- * bounded. Each entry is a ~6.6 MB bundle and the digest changes with every
210
- * release — without this, keying the cache by content would trade "the cache
211
- * can never be warm" for "the cache grows forever".
252
+ * Drop every other cached bootstrap. Each entry is a ~6.6 MB bundle and the
253
+ * digest changes every release, so without pruning, content-addressing would
254
+ * trade "the cache can never be warm" for "the cache grows forever".
212
255
  */
213
256
  function pruneCache(dir, keepBasename) {
214
257
  const children = Gio.File.new_for_path(dir).enumerate_children('standard::name', Gio.FileQueryInfoFlags.NONE, null);
@@ -221,24 +264,53 @@ function pruneCache(dir, keepBasename) {
221
264
  }
222
265
  }
223
266
 
267
+ /**
268
+ * The newest cached bootstrap whose CONTENTS hash to the digest in its own name, or
269
+ * null. Re-hashing is the whole point: the filename is a claim, and a truncated entry
270
+ * keeps its name — the same reason the warm-cache path below re-hashes rather than
271
+ * trusting it.
272
+ */
273
+ function newestCachedBootstrap(dir) {
274
+ let best = null;
275
+ let children;
276
+ try {
277
+ children = Gio.File.new_for_path(dir).enumerate_children(
278
+ 'standard::name,time::modified',
279
+ Gio.FileQueryInfoFlags.NONE,
280
+ null,
281
+ );
282
+ } catch {
283
+ // No cache directory yet — `enumerate_children` is `throws="1"`.
284
+ return null;
285
+ }
286
+ for (;;) {
287
+ const entry = children.next_file(null);
288
+ if (entry === null) break;
289
+ const name = entry.get_name();
290
+ if (!name.startsWith(CACHE_PREFIX) || !name.endsWith(CACHE_SUFFIX)) continue;
291
+ const claimed = name.slice(CACHE_PREFIX.length, name.length - CACHE_SUFFIX.length).toLowerCase();
292
+ if (!/^[0-9a-f]{64}$/.test(claimed)) continue;
293
+ const path = GLib.build_filenamev([dir, name]);
294
+ const bytes = readBytesOrNull(path);
295
+ if (!bytes || sha256Hex(bytes).toLowerCase() !== claimed) continue;
296
+ const mtime = entry.get_modification_date_time()?.to_unix() ?? 0;
297
+ if (!best || mtime > best.mtime) best = { path, basename: name, mtime };
298
+ }
299
+ return best;
300
+ }
301
+
224
302
  /**
225
303
  * Resolve a VERIFIED bootstrap bundle and return its path.
226
304
  *
227
- * The digest is fetched BEFORE the bundle, and the cache is keyed by it. That
228
- * ordering is the mechanism, not an optimisation — it fixes two independent
229
- * defects that both came from doing it the other way round:
230
- *
231
- * 1. Verification was skippable by anyone who could break ONE request. The
232
- * bundle was downloaded first and verified after, and a failed `.sha256`
233
- * fetch merely logged "skipping verification" and carried on — so a proxy,
234
- * a 404, or a captive portal silently downgraded the install to no
235
- * verification at all. Fetching the digest first makes that impossible:
236
- * no digest, no install.
237
- * 2. The cache could never be warm. It wrote a fixed `cli.gjs.mjs` and never
238
- * read it back, so every run re-downloaded ~6.6 MB. A content-addressed
239
- * name lets a matching entry short-circuit the download entirely.
305
+ * Fetching the digest BEFORE the bundle, and keying the cache by it, is the
306
+ * mechanism rather than an optimisation; the reverse order cost two defects.
307
+ * Verifying after downloading made verification skippable by anyone who could
308
+ * break ONE request — a failed `.sha256` fetch logged "skipping verification"
309
+ * and carried on, so a proxy, a 404 or a captive portal silently downgraded the
310
+ * install. And a fixed `cli.gjs.mjs` name that was never read back meant every
311
+ * run re-downloaded ~6.6 MB; a content-addressed name short-circuits that.
240
312
  *
241
- * Skipping verification is still possible, but only as an EXPLICIT act
313
+ * Skipping verification remains possible only as an EXPLICIT act
242
314
  * (`GJSIFY_INSTALL_BOOTSTRAP_SHA256_URL=''`), and it says so on stderr.
243
315
  */
244
316
  async function resolveBootstrap(session, bootstrapUrl, sha256Url) {
@@ -249,6 +321,26 @@ async function resolveBootstrap(session, bootstrapUrl, sha256Url) {
249
321
  try {
250
322
  sumBytes = await fetchBytes(session, sha256Url);
251
323
  } catch (err) {
324
+ // Before refusing: a bundle already in the cache carries its own digest in
325
+ // its NAME, and re-hashing the bytes proves the name. Those bytes were
326
+ // verified against a digest that WAS fetched, on an earlier run — so reusing
327
+ // them installs nothing unverified. That is a different thing from the defect
328
+ // this function's header records, where a failed digest fetch waved a FRESH
329
+ // download through with no digest at all.
330
+ //
331
+ // What it does concede is FRESHNESS: someone who can block the network can
332
+ // hold you on the release you already have. So it is loud, it names the
333
+ // digest, and it only ever runs after the retries above are exhausted — which
334
+ // on 2026-08-12 was a GitHub release CDN dropping every connection from the
335
+ // CI runners for over an hour, with a warm cache sitting right there, unusable
336
+ // because the digest fetch comes first.
337
+ const cached = newestCachedBootstrap(cacheDir());
338
+ if (cached) {
339
+ error(`Could not fetch ${sha256Url}: ${err.message}`);
340
+ info(`Falling back to the cached bootstrap verified earlier: ${cached.basename}`);
341
+ info('It may be OLDER than `latest` — re-run once the network recovers.');
342
+ return cached.path;
343
+ }
252
344
  error(`Could not fetch ${sha256Url}: ${err.message}`);
253
345
  error('Refusing to install an UNVERIFIED bootstrap bundle.');
254
346
  error("To bootstrap without verification, set GJSIFY_INSTALL_BOOTSTRAP_SHA256_URL='' explicitly.");
@@ -313,14 +405,12 @@ function buildSpec(target, tag) {
313
405
  }
314
406
 
315
407
  async function runInstall(bundlePath, spec) {
316
- // `gjsify install -g <spec>` is always idempotent — it rewrites the tree
317
- // unconditionally. There is no separate "force" flag to forward; the
318
- // installer's own `--force` is satisfied by the fact that we always
319
- // re-download the bootstrap and re-invoke the CLI.
408
+ // `gjsify install -g <spec>` rewrites the tree unconditionally, so there is
409
+ // no force flag to forward and `--force` needs nothing here.
320
410
  info(`Running: gjs -m <bootstrap> install -g ${spec}`);
321
411
  const argv = ['gjs', '-m', bundlePath, 'install', '-g', spec];
322
- // Forward env vars verbatim so override paths set by tests / power-users
323
- // reach the spawned CLI.
412
+ // No `envp`: the launcher inherits ours, so override env vars set by tests
413
+ // and power-users reach the spawned CLI.
324
414
  const launcher = new Gio.SubprocessLauncher({
325
415
  flags: Gio.SubprocessFlags.NONE,
326
416
  });
@@ -349,7 +439,6 @@ async function main() {
349
439
  }
350
440
 
351
441
  if (opts.fetchOnly) {
352
- // The one thing on stdout. Everything above went to stderr.
353
442
  print(bundlePath);
354
443
  exit(0);
355
444
  }
@@ -1,106 +1,72 @@
1
1
  import type { App } from '@gjsify/rolldown-plugin-gjsify';
2
2
  export interface CliBuildOptions {
3
- /**
4
- * This is an array of files that each serve as an input to the bundling algorithm.
5
- * @see https://esbuild.github.io/api/#entry-points
6
- */
3
+ /** Input files to the bundling algorithm. */
7
4
  entryPoints?: string[];
8
- /** Switch on the verbose mode */
9
5
  verbose?: boolean;
10
- /**
11
- * When enabled, the generated code will be minified instead of pretty-printed.
12
- * @see https://esbuild.github.io/api/#minify
13
- */
6
+ /** Minify the generated code instead of pretty-printing it. */
14
7
  minify?: boolean;
15
- /**
16
- * Override the default output format.
17
- * @see https://esbuild.github.io/api/#format
18
- */
8
+ /** Override the default output format. */
19
9
  format?: 'iife' | 'esm' | 'cjs';
20
- /** Use this if you want to build an application or test, the platform node is usually only used internally to build the tests for Gjsify */
10
+ /** Build an application or test. `node` is mostly internal, for gjsify's tests. */
21
11
  app?: App;
22
- /** Use this if you want to build a library for Gjsify */
12
+ /** Build a library rather than an application. */
23
13
  library?: boolean;
24
- /**
25
- * This option sets the output file name for the build operation.
26
- * This is only applicable if there is a single entry point.
27
- * If there are multiple entry points, you must use the outdir option instead to specify an output directory.
28
- * @see https://esbuild.github.io/api/#outfile
29
- */
14
+ /** Output file name. Single entry point only — otherwise use `outdir`. */
30
15
  outfile?: string;
31
- /**
32
- * This option sets the output directory for the build operation.
33
- * @see https://esbuild.github.io/api/#outdir
34
- */
35
16
  outdir?: string;
36
- /** Enables TypeScript types on runtime using Deepkit's type compiler */
17
+ /** Enables TypeScript types at runtime using Deepkit's type compiler. */
37
18
  reflection?: boolean;
38
- /**
39
- * The log level can be changed to prevent esbuild from printing warning and/or error messages to the terminal
40
- * @see https://esbuild.github.io/api/#log-level
41
- */
19
+ /** How much the bundler prints to the terminal. */
42
20
  logLevel: 'silent' | 'error' | 'warning' | 'info' | 'debug' | 'verbose';
43
- /** An array of glob patterns to exclude matches and aliases */
21
+ /** Glob patterns excluding matches and aliases. */
44
22
  exclude?: string[];
45
23
  /**
46
- * Inject a console shim into GJS builds for clean output (no GLib prefix, ANSI colors work).
47
- * Use --no-console-shim to disable. Only applies to GJS app builds. Default: true.
24
+ * Inject a console shim into GJS app builds for clean output (no GLib prefix,
25
+ * working ANSI colors). Disable with `--no-console-shim`. Default: true.
48
26
  */
49
27
  consoleShim?: boolean;
50
28
  /**
51
- * Comma-separated list of global identifiers your code needs (e.g.
52
- * `"fetch,Buffer,process,URL,crypto"`). Each identifier is mapped to the
53
- * corresponding `@gjsify/<pkg>/register` module and injected into the
54
- * bundle. Only applies to GJS app builds.
29
+ * Comma-separated global identifiers the code needs, e.g.
30
+ * `"fetch,Buffer,process,URL,crypto"`. Each maps to the corresponding
31
+ * `@gjsify/<pkg>/register` module, injected into the bundle. GJS app builds only.
55
32
  */
56
33
  globals?: string;
57
34
  /**
58
- * Prepend a `#!/usr/bin/env -S gjs -m` shebang to the output file and mark
59
- * it executable (chmod 755). Only applies to GJS app builds with a single
60
- * `--outfile`. Default: false.
35
+ * Prepend a `#!/usr/bin/env -S gjs -m` shebang and chmod 755. GJS app builds with
36
+ * a single `--outfile` only. Default: false.
61
37
  */
62
38
  shebang?: boolean;
63
39
  /**
64
- * Module names that should NOT be bundled. Each name remains as a literal
65
- * `import`/`require` in the output and is resolved by the runtime against
66
- * its own `node_modules` (or equivalent) at execution time.
67
- *
68
- * Repeat the flag or pass a comma-separated value:
69
- * `--external typedoc,prettier --external typescript`. Glob-style wildcards
70
- * (`@inquirer/*`, `lodash-*`) are forwarded as-is to esbuild.
71
- *
72
- * @see https://esbuild.github.io/api/#external
40
+ * Module names that must NOT be bundled: each stays a literal `import`/`require`
41
+ * in the output, resolved by the runtime at execution time. Repeat the flag or
42
+ * pass a comma-separated value (`--external typedoc,prettier --external
43
+ * typescript`); glob-style wildcards (`@inquirer/*`, `lodash-*`) pass through to
44
+ * the bundler unchanged.
73
45
  */
74
46
  external?: string[];
75
47
  /**
76
- * Substitute compile-time constants in the bundle. Each entry is a
77
- * `KEY=VALUE` pair where `VALUE` is an arbitrary JS expression — string
78
- * literals must be quoted (`--define VERSION='"1.2.3"'`). Useful for
79
- * upstream packages that read a build-time constant via
48
+ * Compile-time constants as `KEY=VALUE`, where `VALUE` is an arbitrary JS
49
+ * expression — string literals must be quoted (`--define VERSION='"1.2.3"'`).
50
+ * For upstream packages reading a build-time constant via
80
51
  * `typeof __FOO__ !== 'undefined'`.
81
- *
82
- * @see https://esbuild.github.io/api/#define
83
52
  */
84
53
  define?: string[];
85
54
  /**
86
- * Map module specifiers to alternative targets at bundle time. Each entry
87
- * is `FROM=TO` where `FROM` is the imported package name and `TO` is the
88
- * substitute (typically `@gjsify/empty` to drop a heavy dep that the test
89
- * scenario never executes). Layered on top of the built-in alias map.
55
+ * Remap module specifiers at bundle time as `FROM=TO`, typically to
56
+ * `@gjsify/empty` to drop a heavy dep the scenario never executes. Layered on top
57
+ * of the built-in alias map.
90
58
  */
91
59
  alias?: string[];
92
60
  /**
93
- * Comma-separated global identifiers to remove from the auto-detected set.
94
- * Useful for false positives from dead browser-compat code in npm deps
95
- * whose polyfills require unavailable native libraries.
96
- * Example: `--exclude-globals fetch,XMLHttpRequest`
61
+ * Comma-separated global identifiers to drop from the auto-detected set — for
62
+ * false positives from dead browser-compat code in npm deps whose polyfills need
63
+ * unavailable native libraries. Example: `--exclude-globals fetch,XMLHttpRequest`.
97
64
  */
98
65
  excludeGlobals?: string[];
99
66
  /**
100
- * Watch source files and rebuild on change. Logs each rebuild with
101
- * duration; SIGINT/SIGTERM cleanly closes the watcher. Only valid with
102
- * `--app gjs|node|browser` (rejected with `--library`). Requires the
103
- * npm `rolldown` engine — run under Node.
67
+ * Watch and rebuild on change; SIGINT/SIGTERM closes the watcher cleanly. Valid
68
+ * only with `--app gjs|node|browser`, rejected with `--library`, and requires the
69
+ * npm `rolldown` engine — so run it under Node.
104
70
  */
105
71
  watch?: boolean;
106
72
  }
@@ -8,19 +8,16 @@ export interface Command<T = unknown, U = T> {
8
8
  deprecated?: boolean | string;
9
9
  }
10
10
  /**
11
- * A command that definitely runs something — the shape of every command except
12
- * the builder-only parents.
11
+ * A command that definitely runs something — every command except the
12
+ * builder-only parents. Annotate any command a standalone entry invokes directly.
13
13
  *
14
- * WHY IT EXISTS: `Command.handler` has to stay optional for parent commands,
15
- * but that optionality then lands on every CALL site, including the ones
16
- * outside `cli-app.ts`'s yargs registration where a missing handler would be a
17
- * silent no-op rather than a type error. `src/affected-entry.ts` — the entry
18
- * the committed `dist/affected.gjs.mjs` is built from — invoked
19
- * `affectedCommand.handler(...)` through a cast that suppressed exactly that,
20
- * and nothing noticed for as long as the package's tsconfig scoped the compile
21
- * to the import closure of `src/index.ts`. Annotate a command that a standalone
22
- * entry invokes directly with this type, and the guarantee is in the type
23
- * instead of in a cast.
14
+ * `Command.handler` must stay optional for parent commands, but that optionality
15
+ * then lands on every CALL site, including those outside `cli-app.ts`'s yargs
16
+ * registration, where a missing handler is a silent no-op rather than a type
17
+ * error. `src/affected-entry.ts` — the entry `dist/affected.gjs.mjs` is built
18
+ * from — invoked `affectedCommand.handler(...)` through a cast that suppressed
19
+ * exactly that, unnoticed for as long as the tsconfig scoped the compile to the
20
+ * import closure of `src/index.ts`. This puts the guarantee in the type instead.
24
21
  */
25
22
  export type LeafCommand<T = unknown, U = T> = Command<T, U> & {
26
23
  handler: NonNullable<Command<T, U>['handler']>;