@gjsify/cli 0.37.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.
@@ -159,6 +159,13 @@ export declare class BuildAction {
159
159
  * wrapper does not apply here.
160
160
  */
161
161
  preserveDefaultExport?: boolean;
162
+ /**
163
+ * The globals policy for this bundle, when the caller has one
164
+ * (`Config.forNodeScript`). Unset keeps `--globals auto`, which is what
165
+ * the plugin and config loaders want.
166
+ */
167
+ globals?: string;
168
+ excludeGlobals?: string[];
162
169
  }): Promise<string>;
163
170
  /** Application mode */
164
171
  buildApp(app?: App, opts?: {
@@ -4,7 +4,7 @@ import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
4
4
  import { resolveUserPlugins } from '../utils/resolve-plugin-by-name.js';
5
5
  import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals, detectNodeGiGlobals, } from '@gjsify/rolldown-plugin-gjsify/globals';
6
6
  import { pnpPlugin } from '@gjsify/rolldown-plugin-pnp';
7
- import { dirname, extname, join, resolve } from 'node:path';
7
+ import { basename, dirname, extname, join, resolve } from 'node:path';
8
8
  import { pathToFileURL } from 'node:url';
9
9
  import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
10
10
  import { normalizeBundlerOptions, mergeBundlerOptions } from '../utils/normalize-bundler-options.js';
@@ -467,7 +467,20 @@ export class BuildAction {
467
467
  const cwd = process.cwd();
468
468
  const cacheDir = join(cwd, 'node_modules', '.cache', 'gjsify', opts.cacheSubdir);
469
469
  const safeName = opts.label.replace(/[^a-zA-Z0-9._-]/g, '_');
470
- const outfile = join(cacheDir, `${safeName}-${shortHash(inputPath)}.mjs`);
470
+ // The policy belongs in the cache KEY, not only in the build: one file under two
471
+ // policies is two artifacts, and the freshness check below (entry mtime + lockfile)
472
+ // cannot see the `package.json` edit that changed one. Callers without a policy keep
473
+ // their existing cache filenames.
474
+ const cacheKey = opts.globals === undefined && opts.excludeGlobals === undefined
475
+ ? inputPath
476
+ : `${inputPath} ${JSON.stringify([opts.globals ?? null, opts.excludeGlobals ?? null])}`;
477
+ // THE HASH GOES IN THE DIRECTORY, THE FILE KEEPS THE SOURCE'S NAME. As
478
+ // `<name>-<hash>.mjs` it broke every script guarding its body with the standard
479
+ // is-entry test: `audit-runtimes.mjs` asks whether `process.argv[1]` ENDS WITH its
480
+ // own name, so `--node-script` ran it, printed NOTHING and exited 0. Same cause the
481
+ // `import.meta.url` define exists for — the bundle does not live where the source
482
+ // does — and a name is cheaper to keep right than an argv to rewrite.
483
+ const outfile = join(cacheDir, `${safeName}-${shortHash(cacheKey)}`, `${basename(inputPath).replace(/\.[cm]?[jt]sx?$/i, '')}.mjs`);
471
484
  const cacheDisabled = opts.cache === false || (opts.noCacheEnv ? isTruthyEnv(process.env[opts.noCacheEnv]) : false);
472
485
  if (!cacheDisabled && (await isBundleFresh(outfile, inputPath, cwd))) {
473
486
  if (opts.verbose)
@@ -476,9 +489,11 @@ export class BuildAction {
476
489
  }
477
490
  if (opts.verbose)
478
491
  console.debug(`[gjsify] bundling ${inputPath} for GJS → ${outfile}`);
479
- await mkdir(cacheDir, { recursive: true });
492
+ await mkdir(dirname(outfile), { recursive: true });
480
493
  await new BuildAction({
481
494
  verbose: opts.verbose,
495
+ ...(opts.globals !== undefined ? { globals: opts.globals } : {}),
496
+ ...(opts.excludeGlobals !== undefined ? { excludeGlobals: opts.excludeGlobals } : {}),
482
497
  bundler: {
483
498
  input: inputPath,
484
499
  output: { file: outfile },
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 };
@@ -134,6 +134,62 @@ function checkGjsVersion() {
134
134
  }
135
135
  }
136
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
+ */
137
193
  async function fetchBytes(session, url) {
138
194
  if (url.startsWith('file://')) {
139
195
  const path = url.slice('file://'.length);
@@ -141,14 +197,22 @@ async function fetchBytes(session, url) {
141
197
  const [, bytes] = file.load_contents(null);
142
198
  return bytes;
143
199
  }
144
- const message = Soup.Message.new('GET', url);
145
- message.request_headers.append('User-Agent', USER_AGENT);
146
- const bytes = await session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null);
147
- const status = message.get_status();
148
- if (status !== Soup.Status.OK) {
149
- 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
+ }
150
214
  }
151
- return bytes.get_data();
215
+ throw lastErr;
152
216
  }
153
217
 
154
218
  function sha256Hex(bytes) {
@@ -200,6 +264,41 @@ function pruneCache(dir, keepBasename) {
200
264
  }
201
265
  }
202
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
+
203
302
  /**
204
303
  * Resolve a VERIFIED bootstrap bundle and return its path.
205
304
  *
@@ -222,6 +321,26 @@ async function resolveBootstrap(session, bootstrapUrl, sha256Url) {
222
321
  try {
223
322
  sumBytes = await fetchBytes(session, sha256Url);
224
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
+ }
225
344
  error(`Could not fetch ${sha256Url}: ${err.message}`);
226
345
  error('Refusing to install an UNVERIFIED bootstrap bundle.');
227
346
  error("To bootstrap without verification, set GJSIFY_INSTALL_BOOTSTRAP_SHA256_URL='' explicitly.");
@@ -105,6 +105,18 @@ export interface ConfigData {
105
105
  * excludes the HTTP polyfill stack.
106
106
  */
107
107
  excludeGlobals?: string[];
108
+ /**
109
+ * Overrides for the ad-hoc bundle `gjsify run --node-script <file>` builds under
110
+ * GJS. Unset keys fall through to the package-level `globals` / `excludeGlobals`
111
+ * above, so most packages need only those. `@gjsify/adwaita-web` forced the split:
112
+ * its browser bundle wants the DOM registers, while the script that COMPILES its
113
+ * stylesheet must not have them — injecting them made that bundle demand `gi://Gdk`
114
+ * and believe it was running on Node (#1053).
115
+ */
116
+ nodeScript?: {
117
+ globals?: string;
118
+ excludeGlobals?: string[];
119
+ };
108
120
  /**
109
121
  * Compile-time defines from `package.json` fields: JS identifier → dotted
110
122
  * package.json path. Values are JSON-stringified before merging into
@@ -130,31 +130,16 @@ export declare function buildLauncherShims(opts: {
130
130
  * script chain: the self-relative `@loader_path` rpath that
131
131
  * `check-prebuild-loader-path.mjs` enforces is what actually has to carry it.
132
132
  *
133
- * NOT EXTENDED WITH THE HOST'S GI LIBDIRS — a decision, not an oversight.
134
- * `buildNativeEnv()` now puts `systemGiLibraryDirs()` on the gjs CHILD's
135
- * `DYLD_FALLBACK_LIBRARY_PATH`, which repairs every command that goes through this
136
- * CLI. A launcher that `exec`s a GJS bundle DIRECTLY — `gjsify install -g` on a
137
- * package whose `gjsify.bin` is a GTK app — has no CLI in the loop and still
138
- * carries the gap.
139
- *
140
- * The MECHANISM would work, and that was measured on the macOS 15.7.8 VM rather
141
- * than assumed. It is a finer point than the caveat above: what SIP strips is an
142
- * INHERITED `DYLD_*` at the `/bin/sh` exec, so a value this preamble exports
143
- * ITSELF survives the following `exec gjs` (an unprotected Homebrew binary) and
144
- * `Gtk.init()` then succeeds. What is missing is a shape worth having:
145
- *
146
- * - BAKING `systemGiLibraryDirs()` in at write time reintroduces precisely the
147
- * snapshot this function exists to remove — a launcher is routinely written
148
- * BEFORE `brew install gtk4`, and would bake nothing while saying nothing.
149
- * - RE-DERIVING it in shell means a THIRD copy of a rule that already has two,
150
- * pinned in agreement by `system-gi.spec.ts` — and shell can express only one
151
- * of its three sources (the probed prefixes; `pkg-config` would be a spawn on
152
- * every launch), so that copy would be knowingly partial, in the one language
153
- * nothing here type-checks.
154
- *
155
- * So the gap is recorded in `status/open-todos.md` with the measurement attached
156
- * instead of being closed by a mechanism that drifts. Closing it properly means
157
- * teaching such a launcher to defer to the CLI, not to re-derive.
133
+ * IT DOES export dyld's own default fallback list on darwin — see
134
+ * {@link dyldFallbackPreamble}. That is a CONSTANT, so it carries none of the
135
+ * staleness that rules out the alternatives: BAKING `systemGiLibraryDirs()` in
136
+ * at write time reintroduces the snapshot this function exists to remove (a
137
+ * launcher is routinely written BEFORE `brew install gtk4`), and RE-DERIVING it
138
+ * in shell would be a knowingly partial THIRD copy of a two-copy rule, in the
139
+ * one language nothing here type-checks. The REMAINING gap — a host GI stack
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
142
+ * defer to the CLI rather than to re-derive.
158
143
  *
159
144
  * @param scanRoot Directory whose `node_modules` is globbed at launch time.
160
145
  * @param bakedDirs Prebuild dirs found at write time; entries outside `scanRoot`
@@ -166,6 +151,36 @@ export declare function buildNativeEnvPreamble(scanRoot: string, bakedDirs?: rea
166
151
  arch?: string;
167
152
  libc?: HostLibc | null;
168
153
  }): string;
154
+ /**
155
+ * The darwin half of {@link buildNativeEnvPreamble}: put dyld's OWN default
156
+ * fallback list back, from inside the launcher.
157
+ *
158
+ * MEASURED on the macOS 15.7.9 x86_64 VM against the published 0.37.0: every
159
+ * `gjsify` command died in `dlopen(libsoup-3.0.0.dylib)` — before downloading
160
+ * anything, the CLI's own npm fetcher going through Soup — with a dyld search
161
+ * list that ended at `/usr/lib` and never held `/usr/local/lib`, where Homebrew
162
+ * puts every GTK library on Intel macOS. Exporting these four directories here
163
+ * makes the same command resolve, download and render (`OpenGL 4.1`).
164
+ *
165
+ * WHY HERE, and not the caller's job: SIP strips every `DYLD_*` variable when a
166
+ * PROTECTED binary is exec'd, and `/bin/sh` — this script — is protected. A
167
+ * value the USER exports is already gone by the time the preamble runs
168
+ * (measured: exporting the identical value around `gjsify showcase` changes
169
+ * nothing), while one the preamble exports ITSELF survives the following
170
+ * `exec gjs`, Homebrew's gjs being unprotected. This is the last hop that can.
171
+ *
172
+ * A CONSTANT is safe here only because it is dyld's DOCUMENTED DEFAULT: it
173
+ * cannot go stale the way baking `systemGiLibraryDirs()` in would (a launcher is
174
+ * routinely written before `brew install gtk4`), and it re-derives nothing —
175
+ * four literals from `dyld(1)`, shared with {@link dyldDefaultFallbackDirs}.
176
+ * An inherited value stays AHEAD of it; the export only ADDS BACK what dyld
177
+ * would have searched on its own.
178
+ *
179
+ * NOT the host's GI libdirs: `/opt/homebrew/lib` was never in dyld's default
180
+ * either, so an ARM Mac has that gap with or without gjsify —
181
+ * `status/open-todos.md`.
182
+ */
183
+ export declare function dyldFallbackPreamble(platform: string): string;
169
184
  /** POSIX `sh` single-quoting for a launcher's target path. */
170
185
  export declare function shQuoteArg(s: string): string;
171
186
  /**
@@ -25,6 +25,7 @@
25
25
  // declaring `#!/usr/bin/env node` and one declaring `#!/usr/bin/env -S gjs -m`
26
26
  // both work without the caller knowing which it is.
27
27
  import { detectHostLibc, libraryPathVar, prebuildDirCandidates } from './detect-native-packages.js';
28
+ import { dyldDefaultFallbackDirs } from './system-gi.js';
28
29
  // Matches `#!<prog> <args>` and `#!/usr/bin/env [-S] [K=V ...] <prog> <args>`.
29
30
  // Verbatim from cmd-shim's `shebangExpr`.
30
31
  const SHEBANG_RE = /^#!\s*(?:\/usr\/bin\/env\s+(?:-S\s+)?((?:[^ \t=]+=[^ \t=]+\s+)*))?([^ \t]+)(.*)$/;
@@ -340,31 +341,16 @@ function shQuote(s) {
340
341
  * script chain: the self-relative `@loader_path` rpath that
341
342
  * `check-prebuild-loader-path.mjs` enforces is what actually has to carry it.
342
343
  *
343
- * NOT EXTENDED WITH THE HOST'S GI LIBDIRS — a decision, not an oversight.
344
- * `buildNativeEnv()` now puts `systemGiLibraryDirs()` on the gjs CHILD's
345
- * `DYLD_FALLBACK_LIBRARY_PATH`, which repairs every command that goes through this
346
- * CLI. A launcher that `exec`s a GJS bundle DIRECTLY — `gjsify install -g` on a
347
- * package whose `gjsify.bin` is a GTK app — has no CLI in the loop and still
348
- * carries the gap.
349
- *
350
- * The MECHANISM would work, and that was measured on the macOS 15.7.8 VM rather
351
- * than assumed. It is a finer point than the caveat above: what SIP strips is an
352
- * INHERITED `DYLD_*` at the `/bin/sh` exec, so a value this preamble exports
353
- * ITSELF survives the following `exec gjs` (an unprotected Homebrew binary) and
354
- * `Gtk.init()` then succeeds. What is missing is a shape worth having:
355
- *
356
- * - BAKING `systemGiLibraryDirs()` in at write time reintroduces precisely the
357
- * snapshot this function exists to remove — a launcher is routinely written
358
- * BEFORE `brew install gtk4`, and would bake nothing while saying nothing.
359
- * - RE-DERIVING it in shell means a THIRD copy of a rule that already has two,
360
- * pinned in agreement by `system-gi.spec.ts` — and shell can express only one
361
- * of its three sources (the probed prefixes; `pkg-config` would be a spawn on
362
- * every launch), so that copy would be knowingly partial, in the one language
363
- * nothing here type-checks.
364
- *
365
- * So the gap is recorded in `status/open-todos.md` with the measurement attached
366
- * instead of being closed by a mechanism that drifts. Closing it properly means
367
- * teaching such a launcher to defer to the CLI, not to re-derive.
344
+ * IT DOES export dyld's own default fallback list on darwin — see
345
+ * {@link dyldFallbackPreamble}. That is a CONSTANT, so it carries none of the
346
+ * staleness that rules out the alternatives: BAKING `systemGiLibraryDirs()` in
347
+ * at write time reintroduces the snapshot this function exists to remove (a
348
+ * launcher is routinely written BEFORE `brew install gtk4`), and RE-DERIVING it
349
+ * in shell would be a knowingly partial THIRD copy of a two-copy rule, in the
350
+ * one language nothing here type-checks. The REMAINING gap — a host GI stack
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
353
+ * defer to the CLI rather than to re-derive.
368
354
  *
369
355
  * @param scanRoot Directory whose `node_modules` is globbed at launch time.
370
356
  * @param bakedDirs Prebuild dirs found at write time; entries outside `scanRoot`
@@ -433,7 +419,46 @@ export function buildNativeEnvPreamble(scanRoot, bakedDirs = [], target = {}) {
433
419
  ` ${libVar}="$gjsify_np\${${libVar}:+${separator}$${libVar}}"\n` +
434
420
  ` export GI_TYPELIB_PATH ${libVar}\n` +
435
421
  `fi\n` +
436
- `unset gjsify_np gjsify_d gjsify_t\n`);
422
+ `unset gjsify_np gjsify_d gjsify_t\n` +
423
+ dyldFallbackPreamble(platform));
424
+ }
425
+ /**
426
+ * The darwin half of {@link buildNativeEnvPreamble}: put dyld's OWN default
427
+ * fallback list back, from inside the launcher.
428
+ *
429
+ * MEASURED on the macOS 15.7.9 x86_64 VM against the published 0.37.0: every
430
+ * `gjsify` command died in `dlopen(libsoup-3.0.0.dylib)` — before downloading
431
+ * anything, the CLI's own npm fetcher going through Soup — with a dyld search
432
+ * list that ended at `/usr/lib` and never held `/usr/local/lib`, where Homebrew
433
+ * puts every GTK library on Intel macOS. Exporting these four directories here
434
+ * makes the same command resolve, download and render (`OpenGL 4.1`).
435
+ *
436
+ * WHY HERE, and not the caller's job: SIP strips every `DYLD_*` variable when a
437
+ * PROTECTED binary is exec'd, and `/bin/sh` — this script — is protected. A
438
+ * value the USER exports is already gone by the time the preamble runs
439
+ * (measured: exporting the identical value around `gjsify showcase` changes
440
+ * nothing), while one the preamble exports ITSELF survives the following
441
+ * `exec gjs`, Homebrew's gjs being unprotected. This is the last hop that can.
442
+ *
443
+ * A CONSTANT is safe here only because it is dyld's DOCUMENTED DEFAULT: it
444
+ * cannot go stale the way baking `systemGiLibraryDirs()` in would (a launcher is
445
+ * routinely written before `brew install gtk4`), and it re-derives nothing —
446
+ * four literals from `dyld(1)`, shared with {@link dyldDefaultFallbackDirs}.
447
+ * An inherited value stays AHEAD of it; the export only ADDS BACK what dyld
448
+ * would have searched on its own.
449
+ *
450
+ * NOT the host's GI libdirs: `/opt/homebrew/lib` was never in dyld's default
451
+ * either, so an ARM Mac has that gap with or without gjsify —
452
+ * `status/open-todos.md`.
453
+ */
454
+ export function dyldFallbackPreamble(platform) {
455
+ if (platform !== 'darwin')
456
+ return '';
457
+ // `HOME: '$HOME'` on purpose — the shim must expand it at LAUNCH, not bake
458
+ // the writing machine's home directory into every launcher.
459
+ const dirs = dyldDefaultFallbackDirs({ HOME: '$HOME' }).join(':');
460
+ return (`DYLD_FALLBACK_LIBRARY_PATH="\${DYLD_FALLBACK_LIBRARY_PATH:+$DYLD_FALLBACK_LIBRARY_PATH:}${dirs}"\n` +
461
+ `export DYLD_FALLBACK_LIBRARY_PATH\n`);
437
462
  }
438
463
  /** POSIX `sh` single-quoting for a launcher's target path. */
439
464
  export function shQuoteArg(s) {
@@ -285,12 +285,12 @@ export declare function libraryPathVar(platform: string): {
285
285
  * exactly the "nothing else found it" case. Consequence, and it is the
286
286
  * intended precedence: gjsify's own prebuilds keep priority through the
287
287
  * override variable, the system stack fills in behind it.
288
- * 2. **Our dirs go FIRST, an inherited fallback is kept BEHIND them, and
289
- * `/usr/lib` is appended when there is none.** Setting the variable
290
- * REPLACES dyld's own default fallback list, so writing only our dirs would
291
- * silently remove `/usr/lib` from the search — a host that exported the
292
- * variable itself already carries whatever tail it wants. Same composition
293
- * as node-gi's re-exec.
288
+ * 2. **Our dirs go FIRST, an inherited fallback behind them, and dyld's own
289
+ * default list behind that** — `composeDyldFallback()`, the same
290
+ * composition node-gi's re-exec uses. Setting the variable REPLACES that
291
+ * default list rather than extending it, so any composition that does not
292
+ * carry it hands the child a SMALLER search path than it would have had
293
+ * with the variable unset.
294
294
  *
295
295
  * Emitted even when `packages` is empty: the gap is in the host's loader, not in
296
296
  * a gjsify prebuild, so a plain `gjsify run script.gjs.js` that touches GTK needs
@@ -24,7 +24,7 @@
24
24
  import { readdirSync, existsSync } from 'node:fs';
25
25
  import { join, resolve } from 'node:path';
26
26
  import { readPackageJson } from './pkg-json.js';
27
- import { splitSearchPath, systemGiLibraryDirs } from './system-gi.js';
27
+ import { composeDyldFallback, systemGiLibraryDirs } from './system-gi.js';
28
28
  /**
29
29
  * Node `process.arch` → the uname-style spelling the Vala/meson bridges used to stage
30
30
  * into (`prebuilds/linux-x86_64/`).
@@ -567,12 +567,12 @@ const DYLD_FALLBACK_VAR = 'DYLD_FALLBACK_LIBRARY_PATH';
567
567
  * exactly the "nothing else found it" case. Consequence, and it is the
568
568
  * intended precedence: gjsify's own prebuilds keep priority through the
569
569
  * override variable, the system stack fills in behind it.
570
- * 2. **Our dirs go FIRST, an inherited fallback is kept BEHIND them, and
571
- * `/usr/lib` is appended when there is none.** Setting the variable
572
- * REPLACES dyld's own default fallback list, so writing only our dirs would
573
- * silently remove `/usr/lib` from the search — a host that exported the
574
- * variable itself already carries whatever tail it wants. Same composition
575
- * as node-gi's re-exec.
570
+ * 2. **Our dirs go FIRST, an inherited fallback behind them, and dyld's own
571
+ * default list behind that** — `composeDyldFallback()`, the same
572
+ * composition node-gi's re-exec uses. Setting the variable REPLACES that
573
+ * default list rather than extending it, so any composition that does not
574
+ * carry it hands the child a SMALLER search path than it would have had
575
+ * with the variable unset.
576
576
  *
577
577
  * Emitted even when `packages` is empty: the gap is in the host's loader, not in
578
578
  * a gjsify prebuild, so a plain `gjsify run script.gjs.js` that touches GTK needs
@@ -603,19 +603,7 @@ export function buildNativeEnv(packages, opts = {}) {
603
603
  // it, so a Mac with no GI stack installed is left byte-unchanged.
604
604
  const systemDirs = (opts.systemGiDirs ?? systemGiLibraryDirs)({ platform, env });
605
605
  if (systemDirs.length > 0) {
606
- const inherited = splitSearchPath(env[DYLD_FALLBACK_VAR]);
607
- // Deduplicated, and not for tidiness: `systemGiLibraryDirs()` accepts a
608
- // libdir stated outright through `GI_TYPELIB_PATH` on directory
609
- // existence alone, so a host that names `/usr/lib/girepository-1.0`
610
- // there puts `/usr/lib` in `systemDirs` — and the `/usr/lib` tail below
611
- // then repeated it. A search path that lists the same directory twice
612
- // asks the loader to stat it twice for every miss, and it makes the
613
- // value the `$ …` echo prints read as though the CLI had composed
614
- // something it did not. First occurrence wins in every loader, so
615
- // dropping later repeats is behaviour-preserving.
616
- out[DYLD_FALLBACK_VAR] = [
617
- ...new Set([...systemDirs, ...(inherited.length > 0 ? inherited : ['/usr/lib'])]),
618
- ].join(':');
606
+ out[DYLD_FALLBACK_VAR] = composeDyldFallback(systemDirs, env);
619
607
  }
620
608
  return out;
621
609
  }
@@ -17,6 +17,10 @@
17
17
  // injects the globals the script reads, and resolves bare
18
18
  // specifiers), then run the bundle.
19
19
  //
20
+ // "the globals the script reads" is auto-detection: a syntactic answer to a runtime question,
21
+ // blind to a dead branch. The script's own package overrides it through `gjsify.nodeScript` /
22
+ // `gjsify.globals` (`Config.forNodeScript`) — the shim path has no command line for a flag.
23
+ //
20
24
  // `import.meta.url` is the whole trick: the bundle does not live where the source lives, and
21
25
  // a script's own location is how it finds what it operates on — all four scripts this was
22
26
  // built for derive their package root from `dirname(fileURLToPath(import.meta.url))`. Left
@@ -29,6 +33,7 @@ import { existsSync, statSync } from 'node:fs';
29
33
  import { basename, resolve } from 'node:path';
30
34
  import { pathToFileURL } from 'node:url';
31
35
  import { hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
36
+ import { Config } from '../config.js';
32
37
  import { runGjsBundle } from './run-gjs.js';
33
38
  import { runRuntimeBundle } from './run-node.js';
34
39
  /**
@@ -73,6 +78,7 @@ export async function runNodeScript(file, extraArgs = [], options = {}) {
73
78
  // Dynamic import keeps the bundler machinery out of the eager module graph
74
79
  // of every `gjsify run`, matching how config.ts pulls it.
75
80
  const { BuildAction } = await import('../actions/build.js');
81
+ const { globals, excludeGlobals } = await new Config().forNodeScript(scriptPath);
76
82
  let bundlePath;
77
83
  try {
78
84
  bundlePath = await BuildAction.bundleFileForGjsCached(scriptPath, {
@@ -82,6 +88,8 @@ export async function runNodeScript(file, extraArgs = [], options = {}) {
82
88
  // A script is executed, never imported — no default export to preserve.
83
89
  preserveDefaultExport: false,
84
90
  define: { 'import.meta.url': JSON.stringify(pathToFileURL(scriptPath).href) },
91
+ globals,
92
+ excludeGlobals,
85
93
  });
86
94
  }
87
95
  catch (err) {
@@ -90,6 +98,6 @@ export async function runNodeScript(file, extraArgs = [], options = {}) {
90
98
  `workspace packages, pure-JS npm deps). (${err.message})`);
91
99
  }
92
100
  // Quiet for the same reason as the node path above; the bundle's location is
93
- // deterministic (`node_modules/.cache/gjsify/node-scripts/<name>-<hash>.mjs`).
101
+ // deterministic (`node_modules/.cache/gjsify/node-scripts/<name>-<hash>/<name>.mjs`).
94
102
  await runGjsBundle(bundlePath, extraArgs, { exitOnSuccess: options.exitOnSuccess, quiet: true });
95
103
  }
@@ -60,3 +60,21 @@ export declare function systemGiLibraryDirs({ platform, env, existsDir, searchDi
60
60
  * pins both.
61
61
  */
62
62
  export declare function pathCovers(wanted: readonly string[], current: readonly string[]): boolean;
63
+ /**
64
+ * dyld's OWN default fallback list, verbatim from `dyld(1)`. Setting
65
+ * `DYLD_FALLBACK_LIBRARY_PATH` REPLACES it, so anything composed for that
66
+ * variable must carry it as a tail. The incident that bought this rule is in the
67
+ * ORIGINAL (`packages/node-gi/node-gi/system-gi.js`); the mirror does not
68
+ * restate it.
69
+ */
70
+ export declare function dyldDefaultFallbackDirs(env?: Record<string, string | undefined>): string[];
71
+ /**
72
+ * Compose a `DYLD_FALLBACK_LIBRARY_PATH` value: `wanted`, then the launching
73
+ * environment's own value, then {@link dyldDefaultFallbackDirs} —
74
+ * unconditionally, so a child launched through gjsify never searches LESS than
75
+ * one launched without it. Deduplicated: `systemGiLibraryDirs()` can legitimately
76
+ * yield a directory the tail also names, and a repeated entry costs the loader a
77
+ * second stat on every miss. First occurrence wins everywhere, so dropping later
78
+ * repeats is behaviour-preserving.
79
+ */
80
+ export declare function composeDyldFallback(wanted: readonly string[], env?: Record<string, string | undefined>): string;
@@ -174,3 +174,28 @@ export function systemGiLibraryDirs({ platform = process.platform, env = process
174
174
  export function pathCovers(wanted, current) {
175
175
  return wanted.every((dir) => current.includes(dir));
176
176
  }
177
+ /**
178
+ * dyld's OWN default fallback list, verbatim from `dyld(1)`. Setting
179
+ * `DYLD_FALLBACK_LIBRARY_PATH` REPLACES it, so anything composed for that
180
+ * variable must carry it as a tail. The incident that bought this rule is in the
181
+ * ORIGINAL (`packages/node-gi/node-gi/system-gi.js`); the mirror does not
182
+ * restate it.
183
+ */
184
+ export function dyldDefaultFallbackDirs(env = process.env) {
185
+ const home = env['HOME'];
186
+ return [...(home ? [`${home}/lib`] : []), '/usr/local/lib', '/lib', '/usr/lib'];
187
+ }
188
+ /**
189
+ * Compose a `DYLD_FALLBACK_LIBRARY_PATH` value: `wanted`, then the launching
190
+ * environment's own value, then {@link dyldDefaultFallbackDirs} —
191
+ * unconditionally, so a child launched through gjsify never searches LESS than
192
+ * one launched without it. Deduplicated: `systemGiLibraryDirs()` can legitimately
193
+ * yield a directory the tail also names, and a repeated entry costs the loader a
194
+ * second stat on every miss. First occurrence wins everywhere, so dropping later
195
+ * repeats is behaviour-preserving.
196
+ */
197
+ export function composeDyldFallback(wanted, env = process.env) {
198
+ return [
199
+ ...new Set([...wanted, ...splitSearchPath(env['DYLD_FALLBACK_LIBRARY_PATH']), ...dyldDefaultFallbackDirs(env)]),
200
+ ].join(':');
201
+ }