@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.
- package/dist/cli.gjs.mjs +168 -168
- package/lib/actions/build.d.ts +7 -0
- package/lib/actions/build.js +18 -3
- package/lib/commands/showcase.js +9 -8
- package/lib/config.d.ts +14 -0
- package/lib/config.js +18 -1
- package/lib/templates/install.mjs.tmpl +188 -8
- package/lib/types/config-data.d.ts +12 -0
- package/lib/utils/bin-shim.d.ts +40 -25
- package/lib/utils/bin-shim.js +51 -26
- package/lib/utils/detect-native-packages.d.ts +6 -6
- package/lib/utils/detect-native-packages.js +8 -20
- package/lib/utils/node-script.js +9 -1
- package/lib/utils/publish-headers.d.ts +1 -0
- package/lib/utils/publish-headers.js +6 -1
- package/lib/utils/system-gi.d.ts +18 -0
- package/lib/utils/system-gi.js +25 -0
- package/package.json +18 -18
package/lib/actions/build.d.ts
CHANGED
|
@@ -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?: {
|
package/lib/actions/build.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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/commands/showcase.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
package/lib/utils/bin-shim.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
/**
|
package/lib/utils/bin-shim.js
CHANGED
|
@@ -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
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
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
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
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 {
|
|
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
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
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
|
-
|
|
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
|
}
|