@gjsify/cli 0.37.0 → 0.38.1

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 },
@@ -7,18 +7,19 @@ import { ensurePkgDir } from './dlx.js';
7
7
  import { fetchPackument } from '@gjsify/npm-registry';
8
8
  import { loadNpmrc } from '../utils/load-npmrc.js';
9
9
  import { parseSpec } from '../utils/parse-spec.js';
10
+ import { cliVersion } from '../utils/publish-headers.js';
10
11
  import { resolveNodeEntry } from '../utils/resolve-gjs-entry.js';
11
12
  import { runRuntimeBundle } from '../utils/run-node.js';
12
13
  import { EXAMPLE_RUNTIMES, isExampleRuntime, readDeclaredRuntimes, checkRuntimeSupported, defaultExampleRuntime, requiresGjsSystemDeps, } from '../utils/runtimes.js';
14
+ /**
15
+ * This CLI's own version, or `undefined` when it genuinely cannot be told.
16
+ *
17
+ * The shared resolver, never a local fixed-depth manifest read — that misses from
18
+ * `dist/`, unpinning `dlxSpec`. Incident: `docs/code-anti-patterns.md`.
19
+ */
13
20
  function readCliVersion() {
14
- try {
15
- const pkgUrl = new URL('../../package.json', import.meta.url);
16
- const pkg = JSON.parse(readFileSync(pkgUrl, 'utf8'));
17
- return typeof pkg.version === 'string' ? pkg.version : undefined;
18
- }
19
- catch {
20
- return undefined;
21
- }
21
+ const version = cliVersion();
22
+ return version === 'unknown' ? undefined : version;
22
23
  }
23
24
  /**
24
25
  * `[gjsify <version>] ` for the banner — the CLI naming ITSELF.
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 };
@@ -20,9 +20,70 @@
20
20
 
21
21
  import GLib from 'gi://GLib';
22
22
  import Gio from 'gi://Gio';
23
- import Soup from 'gi://Soup?version=3.0';
24
23
  import system, { exit } from 'system';
25
24
 
25
+ /**
26
+ * Soup, loaded late and — on a macOS that cannot find it — through one re-exec.
27
+ *
28
+ * WHY THIS IS NOT A PLAIN IMPORT. GI resolves a typelib's shared library with a
29
+ * bare-leaf `dlopen("libsoup-3.0.0.dylib")`. Homebrew installs that dylib in
30
+ * `/usr/local/lib` (Intel) or `/opt/homebrew/lib` (arm64), and `gjs` carries an
31
+ * rpath only for glib's own directory, so on a stock Homebrew macOS dyld never
32
+ * looks where the library actually is. Measured 2026-08-13 on macOS 15.7.9:
33
+ *
34
+ * $ curl -fsSL .../install.mjs -o /tmp/g.mjs && gjs -m /tmp/g.mjs
35
+ * Failed to load shared library 'libsoup-3.0.0.dylib' referenced by the typelib
36
+ * JS ERROR: Error: Unsupported type void, deriving from fundamental void
37
+ *
38
+ * That is the DOCUMENTED install path for a platform this project advertises,
39
+ * and it fails before the first byte is downloaded — the bootstrap's own fetcher
40
+ * is Soup. `DYLD_FALLBACK_LIBRARY_PATH=/usr/local/lib` fixes it, but nothing can
41
+ * export that on the user's behalf from inside this process: the variable is
42
+ * read by dyld at image load, long before any JS runs. Hence a re-exec.
43
+ *
44
+ * Repaired ONLY when the load actually fails, so a healthy host (arm64 Homebrew,
45
+ * a distro-packaged gjs, anything with a working rpath) re-execs never.
46
+ *
47
+ * Note the probe touches `Session` rather than just importing the namespace:
48
+ * `imports.gi.Soup` SUCCEEDS on a broken host — the library is resolved lazily,
49
+ * when a type is first needed. A namespace-only check reports a healthy
50
+ * environment and is the reason this looked like a network bug for so long.
51
+ */
52
+ const DYLD_REEXEC_MARKER = 'GJSIFY_INSTALL_DYLD_REEXEC';
53
+
54
+ /** dyld's documented default fallback list, plus both Homebrew prefixes. */
55
+ function dyldFallbackDirs() {
56
+ const home = GLib.get_home_dir();
57
+ return [`${home}/lib`, '/usr/local/lib', '/opt/homebrew/lib', '/lib', '/usr/lib'].join(':');
58
+ }
59
+
60
+ async function loadSoup() {
61
+ const mod = await import('gi://Soup?version=3.0');
62
+ void mod.default.Session; // forces the dlopen; see above
63
+ return mod.default;
64
+ }
65
+
66
+ let Soup;
67
+ try {
68
+ Soup = await loadSoup();
69
+ } catch (error) {
70
+ const onMacos = GLib.file_test('/usr/lib/dyld', GLib.FileTest.EXISTS);
71
+ if (!onMacos || GLib.getenv(DYLD_REEXEC_MARKER)) throw error;
72
+
73
+ const self = GLib.filename_from_uri(import.meta.url)[0];
74
+ const gjs = GLib.find_program_in_path('gjs');
75
+ if (!gjs) throw error;
76
+
77
+ // SIP strips an INHERITED DYLD_* when a protected binary is exec'd, but a
78
+ // value this process sets itself survives into Homebrew's unprotected `gjs`.
79
+ const launcher = new Gio.SubprocessLauncher({ flags: Gio.SubprocessFlags.NONE });
80
+ launcher.setenv('DYLD_FALLBACK_LIBRARY_PATH', dyldFallbackDirs(), true);
81
+ launcher.setenv(DYLD_REEXEC_MARKER, '1', true);
82
+ const child = launcher.spawnv([gjs, '-m', self, ...(system?.programArgs ?? [])]);
83
+ child.wait(null);
84
+ exit(child.get_exit_status() === 0 ? 0 : 1);
85
+ }
86
+
26
87
  Gio._promisify(Soup.Session.prototype, 'send_and_read_async');
27
88
  Gio._promisify(Gio.Subprocess.prototype, 'wait_check_async');
28
89
 
@@ -134,6 +195,62 @@ function checkGjsVersion() {
134
195
  }
135
196
  }
136
197
 
198
+ /** Total attempts per URL, and the wait before each retry. Override for tests / slow links. */
199
+ const FETCH_ATTEMPTS = Number(GLib.getenv('GJSIFY_INSTALL_FETCH_ATTEMPTS') || '5');
200
+ const RETRY_BACKOFF_MS = [1000, 2000, 4000, 8000];
201
+
202
+ function sleepMs(ms) {
203
+ return new Promise((resolve) => {
204
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, ms, () => {
205
+ resolve();
206
+ return GLib.SOURCE_REMOVE;
207
+ });
208
+ });
209
+ }
210
+
211
+ async function fetchOnce(session, url, { forceHttp1 = false } = {}) {
212
+ const message = Soup.Message.new('GET', url);
213
+ message.request_headers.append('User-Agent', USER_AGENT);
214
+ // The failure this retries against announces its own protocol — libsoup reports it
215
+ // verbatim as `HTTP/2 Error: NO_ERROR`, a stream closed mid-response. Retrying over
216
+ // HTTP/1.1 sidesteps the multiplexing that produces it, so the first attempt keeps the
217
+ // faster protocol and every retry drops to the one that cannot fail that way. Guarded:
218
+ // `set_force_http1` is libsoup 3.4+, and this script must still run on older hosts.
219
+ if (forceHttp1 && typeof message.set_force_http1 === 'function') message.set_force_http1(true);
220
+ const bytes = await session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null);
221
+ const status = message.get_status();
222
+ if (status !== Soup.Status.OK) {
223
+ const err = new Error(`HTTP ${status} from ${url}`);
224
+ err.httpStatus = status;
225
+ throw err;
226
+ }
227
+ return bytes.get_data();
228
+ }
229
+
230
+ /**
231
+ * A dropped connection is a HICCUP; an HTTP answer is an ANSWER.
232
+ *
233
+ * THE INCIDENT. `https://github.com/gjsify/gjsify/releases/latest/download/…` dropped
234
+ * HTTP/2 connections for a stretch on 2026-08-12 and took four CI jobs across two PRs
235
+ * with it, each dying in `gjsify-setup` before a line of the change under test ran.
236
+ * Measured from a workstation during the same window: one in three `curl` attempts
237
+ * returned `Connection died, tried 5 times before giving up`. A single-shot fetch turns
238
+ * that into "Refusing to install an UNVERIFIED bootstrap bundle", which reads like the
239
+ * digest is missing or hostile — the one message that must never be a false alarm, since
240
+ * the correct response to it is to disable verification.
241
+ *
242
+ * So retries are scoped to what CANNOT be an answer: a transport failure (no status at
243
+ * all), plus 429 and 5xx. A 404 is the registry saying the asset does not exist and a 403
244
+ * is it saying no; retrying either would only delay the same result while making a real
245
+ * outage look like a hang.
246
+ *
247
+ * FIVE attempts, not three, and the numbers come from a run rather than a preference:
248
+ * with three the CI log showed `retry 2/3` and `retry 3/3` and still failed, because each
249
+ * attempt takes ~20 s to give up — so the 500ms/1500ms backoff was noise beside it and the
250
+ * whole sequence covered barely 40 s of a longer wobble. 1/2/4/8 s over five attempts
251
+ * covers about two minutes. Beyond that it is an outage, not a hiccup, and waiting longer
252
+ * only turns a clear failure into a hang.
253
+ */
137
254
  async function fetchBytes(session, url) {
138
255
  if (url.startsWith('file://')) {
139
256
  const path = url.slice('file://'.length);
@@ -141,14 +258,22 @@ async function fetchBytes(session, url) {
141
258
  const [, bytes] = file.load_contents(null);
142
259
  return bytes;
143
260
  }
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}`);
261
+ const attempts = Number.isFinite(FETCH_ATTEMPTS) && FETCH_ATTEMPTS >= 1 ? FETCH_ATTEMPTS : 1;
262
+ let lastErr = null;
263
+ for (let attempt = 1; attempt <= attempts; attempt++) {
264
+ try {
265
+ return await fetchOnce(session, url, { forceHttp1: attempt > 1 });
266
+ } catch (err) {
267
+ lastErr = err;
268
+ const status = err.httpStatus;
269
+ const retriable = status === undefined || status === 429 || status >= 500;
270
+ if (!retriable || attempt === attempts) break;
271
+ const wait = RETRY_BACKOFF_MS[Math.min(attempt - 1, RETRY_BACKOFF_MS.length - 1)];
272
+ info(`Fetch of ${url} failed (${err.message}) — retry ${attempt + 1}/${attempts} in ${wait}ms`);
273
+ await sleepMs(wait);
274
+ }
150
275
  }
151
- return bytes.get_data();
276
+ throw lastErr;
152
277
  }
153
278
 
154
279
  function sha256Hex(bytes) {
@@ -200,6 +325,41 @@ function pruneCache(dir, keepBasename) {
200
325
  }
201
326
  }
202
327
 
328
+ /**
329
+ * The newest cached bootstrap whose CONTENTS hash to the digest in its own name, or
330
+ * null. Re-hashing is the whole point: the filename is a claim, and a truncated entry
331
+ * keeps its name — the same reason the warm-cache path below re-hashes rather than
332
+ * trusting it.
333
+ */
334
+ function newestCachedBootstrap(dir) {
335
+ let best = null;
336
+ let children;
337
+ try {
338
+ children = Gio.File.new_for_path(dir).enumerate_children(
339
+ 'standard::name,time::modified',
340
+ Gio.FileQueryInfoFlags.NONE,
341
+ null,
342
+ );
343
+ } catch {
344
+ // No cache directory yet — `enumerate_children` is `throws="1"`.
345
+ return null;
346
+ }
347
+ for (;;) {
348
+ const entry = children.next_file(null);
349
+ if (entry === null) break;
350
+ const name = entry.get_name();
351
+ if (!name.startsWith(CACHE_PREFIX) || !name.endsWith(CACHE_SUFFIX)) continue;
352
+ const claimed = name.slice(CACHE_PREFIX.length, name.length - CACHE_SUFFIX.length).toLowerCase();
353
+ if (!/^[0-9a-f]{64}$/.test(claimed)) continue;
354
+ const path = GLib.build_filenamev([dir, name]);
355
+ const bytes = readBytesOrNull(path);
356
+ if (!bytes || sha256Hex(bytes).toLowerCase() !== claimed) continue;
357
+ const mtime = entry.get_modification_date_time()?.to_unix() ?? 0;
358
+ if (!best || mtime > best.mtime) best = { path, basename: name, mtime };
359
+ }
360
+ return best;
361
+ }
362
+
203
363
  /**
204
364
  * Resolve a VERIFIED bootstrap bundle and return its path.
205
365
  *
@@ -222,6 +382,26 @@ async function resolveBootstrap(session, bootstrapUrl, sha256Url) {
222
382
  try {
223
383
  sumBytes = await fetchBytes(session, sha256Url);
224
384
  } catch (err) {
385
+ // Before refusing: a bundle already in the cache carries its own digest in
386
+ // its NAME, and re-hashing the bytes proves the name. Those bytes were
387
+ // verified against a digest that WAS fetched, on an earlier run — so reusing
388
+ // them installs nothing unverified. That is a different thing from the defect
389
+ // this function's header records, where a failed digest fetch waved a FRESH
390
+ // download through with no digest at all.
391
+ //
392
+ // What it does concede is FRESHNESS: someone who can block the network can
393
+ // hold you on the release you already have. So it is loud, it names the
394
+ // digest, and it only ever runs after the retries above are exhausted — which
395
+ // on 2026-08-12 was a GitHub release CDN dropping every connection from the
396
+ // CI runners for over an hour, with a warm cache sitting right there, unusable
397
+ // because the digest fetch comes first.
398
+ const cached = newestCachedBootstrap(cacheDir());
399
+ if (cached) {
400
+ error(`Could not fetch ${sha256Url}: ${err.message}`);
401
+ info(`Falling back to the cached bootstrap verified earlier: ${cached.basename}`);
402
+ info('It may be OLDER than `latest` — re-run once the network recovers.');
403
+ return cached.path;
404
+ }
225
405
  error(`Could not fetch ${sha256Url}: ${err.message}`);
226
406
  error('Refusing to install an UNVERIFIED bootstrap bundle.');
227
407
  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
  }