@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.
- package/dist/cli.gjs.mjs +168 -168
- package/lib/actions/build.d.ts +7 -0
- package/lib/actions/build.js +18 -3
- package/lib/config.d.ts +14 -0
- package/lib/config.js +18 -1
- package/lib/templates/install.mjs.tmpl +126 -7
- 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/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/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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
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
|
}
|
package/lib/utils/node-script.js
CHANGED
|
@@ -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
|
}
|
package/lib/utils/system-gi.d.ts
CHANGED
|
@@ -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;
|
package/lib/utils/system-gi.js
CHANGED
|
@@ -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
|
+
}
|