@gjsify/cli 0.30.0 → 0.31.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 +186 -185
- package/lib/actions/build.d.ts +14 -0
- package/lib/actions/build.js +78 -3
- package/lib/actions/index.d.ts +2 -0
- package/lib/actions/index.js +2 -0
- package/lib/affected-entry.d.ts +1 -0
- package/lib/affected-entry.js +49 -0
- package/lib/bundler-pick.js +84 -19
- package/lib/cli-app.js +20 -0
- package/lib/cli-fail.d.ts +22 -0
- package/lib/cli-fail.js +67 -0
- package/lib/commands/affected.d.ts +2 -2
- package/lib/commands/affected.js +16 -0
- package/lib/commands/install.d.ts +55 -0
- package/lib/commands/install.js +38 -21
- package/lib/commands/run.js +25 -6
- package/lib/commands/test.js +4 -4
- package/lib/commands/tsc.js +59 -11
- package/lib/commands/workspace.js +13 -0
- package/lib/config.js +6 -2
- package/lib/index.js +18 -1
- package/lib/templates/install.mjs.tmpl +27 -2
- package/lib/types/command.d.ts +18 -0
- package/lib/utils/check-system-deps.d.ts +66 -1
- package/lib/utils/check-system-deps.js +139 -2
- package/lib/utils/detect-native-packages.js +12 -1
- package/lib/utils/discover-showcases.d.ts +0 -2
- package/lib/utils/discover-showcases.js +0 -1
- package/lib/utils/dlx-cache.d.ts +22 -2
- package/lib/utils/dlx-cache.js +62 -6
- package/lib/utils/force-exit.d.ts +10 -0
- package/lib/utils/force-exit.js +43 -0
- package/lib/utils/gi-search-path.d.ts +36 -0
- package/lib/utils/gi-search-path.js +146 -0
- package/lib/utils/gjs-source-escape.d.ts +10 -0
- package/lib/utils/gjs-source-escape.js +49 -0
- package/lib/utils/gjsify-shim.d.ts +29 -2
- package/lib/utils/gjsify-shim.js +131 -22
- package/lib/utils/install-global.d.ts +30 -2
- package/lib/utils/install-global.js +38 -4
- package/lib/utils/oxc-resolve.d.ts +1 -1
- package/lib/utils/oxc-resolve.js +10 -6
- package/lib/utils/stdin.d.ts +3 -13
- package/lib/utils/stdin.js +21 -100
- package/lib/utils/system-gi.js +10 -1
- package/package.json +22 -17
- package/showcases.json +8 -16
|
@@ -24,6 +24,42 @@ function tryExecFile(binary, args) {
|
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* True when `cmd` is on PATH. Walks `process.env.PATH` with `existsSync`
|
|
29
|
+
* instead of shelling out to `which`(1).
|
|
30
|
+
*
|
|
31
|
+
* `which` is NOT a probe you can rely on: the Fedora 43/44 minimal containers
|
|
32
|
+
* CI runs in ship without it (only the `which-2.x` rpm provides it), so a
|
|
33
|
+
* `which`-based check silently answers "missing" for EVERY command. That is
|
|
34
|
+
* exactly where this file's answer matters most — `detectPackageManager()`
|
|
35
|
+
* returning `unknown` makes `buildInstallCommand()` return null, so the
|
|
36
|
+
* distro-specific install hint for a missing system library prints nothing on
|
|
37
|
+
* the hosts that needed it. Measured twice on 2026-08-05: ts-for-gir#437 had to
|
|
38
|
+
* discover `json-glib` by hand, bauplaner#40 `libsoup3` — both in such a
|
|
39
|
+
* container, both while this file already declared the dependency.
|
|
40
|
+
*
|
|
41
|
+
* `tests/e2e/helpers.mjs`'s `hasCommand()` carries the same walk for the same
|
|
42
|
+
* reason. It stays a second copy on purpose: it is `.mjs` under `tests/` and
|
|
43
|
+
* cannot be imported from the shipped CLI.
|
|
44
|
+
*/
|
|
45
|
+
export function isOnPath(cmd) {
|
|
46
|
+
const pathVar = process.env.PATH;
|
|
47
|
+
if (!pathVar)
|
|
48
|
+
return false;
|
|
49
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
50
|
+
for (const dir of pathVar.split(sep)) {
|
|
51
|
+
if (!dir)
|
|
52
|
+
continue;
|
|
53
|
+
try {
|
|
54
|
+
if (existsSync(join(dir, cmd)))
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// An inaccessible PATH entry is not an answer about `cmd`.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
27
63
|
/** Check if a binary exists and optionally capture its version output. */
|
|
28
64
|
function checkBinary(id, name, binary, versionArgs, severity, parseVersion, requiredBy) {
|
|
29
65
|
const out = tryExecFile(binary, versionArgs);
|
|
@@ -79,14 +115,56 @@ function checkNpmPackage(id, name, packageName, cwd, severity, requiredBy) {
|
|
|
79
115
|
* MacPorts or Nix host, and it is why this must never fall back to "probably brew".
|
|
80
116
|
*/
|
|
81
117
|
export function detectPackageManager() {
|
|
118
|
+
// win32 has no `which`, so the loop below would return `unknown` by
|
|
119
|
+
// ACCIDENT rather than by construction — which was the pre-existing
|
|
120
|
+
// behaviour and produced the right answer for the wrong reason. `winget`
|
|
121
|
+
// ships with Windows 11 and is what a user actually has, and it is only
|
|
122
|
+
// ever consulted for the one dependency this platform has an entry for.
|
|
123
|
+
if (process.platform === 'win32') {
|
|
124
|
+
return tryExecFile('where', ['winget']) !== null ? 'winget' : 'unknown';
|
|
125
|
+
}
|
|
82
126
|
const managers = process.platform === 'darwin' ? ['brew'] : ['apt', 'dnf', 'pacman', 'zypper', 'apk', 'brew'];
|
|
83
127
|
for (const pm of managers) {
|
|
84
|
-
|
|
85
|
-
if (result !== null)
|
|
128
|
+
if (isOnPath(pm))
|
|
86
129
|
return pm;
|
|
87
130
|
}
|
|
88
131
|
return 'unknown';
|
|
89
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* The Visual C++ runtime — an undeclared, undiagnosed win32 prerequisite (#997).
|
|
135
|
+
*
|
|
136
|
+
* `@gjsify/gtk-runtime-win32-x64` is built with gvsbuild, i.e. against the MSVC
|
|
137
|
+
* ABI, and its DLLs load into stock (also MSVC-ABI) Node. On a Windows host
|
|
138
|
+
* WITHOUT the Visual C++ redistributable that load fails — and nothing in the
|
|
139
|
+
* toolchain said so. The precedent for why that matters is PR #994: a missing
|
|
140
|
+
* load-time library made a typelib unloadable and the user-visible message was
|
|
141
|
+
* `Unsupported type void, deriving from fundamental void`, which names nothing
|
|
142
|
+
* a user could act on.
|
|
143
|
+
*
|
|
144
|
+
* Probed by FILE PRESENCE in the system directory rather than by asking a
|
|
145
|
+
* package database: the redistributable can arrive with Visual Studio, with
|
|
146
|
+
* another application's installer, or as part of a Windows image, and only one
|
|
147
|
+
* of those routes is visible to a package manager. What the loader actually
|
|
148
|
+
* needs is the DLL, so that is what is checked.
|
|
149
|
+
*
|
|
150
|
+
* `vcruntime140.dll` and `msvcp140.dll` are the C and C++ halves;
|
|
151
|
+
* `vcruntime140_1.dll` carries the x64 exception-handling machinery and is
|
|
152
|
+
* absent from pre-2019 redistributables, which is exactly the "installed, but
|
|
153
|
+
* too old" case that otherwise fails at first use rather than at load.
|
|
154
|
+
*/
|
|
155
|
+
export function checkMsvcRuntime() {
|
|
156
|
+
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? 'C:\\Windows';
|
|
157
|
+
const required = ['vcruntime140.dll', 'vcruntime140_1.dll', 'msvcp140.dll'];
|
|
158
|
+
const missing = required.filter((dll) => !existsSync(`${systemRoot}\\System32\\${dll}`));
|
|
159
|
+
return {
|
|
160
|
+
id: 'msvc-runtime',
|
|
161
|
+
name: missing.length === 0
|
|
162
|
+
? 'Microsoft Visual C++ runtime'
|
|
163
|
+
: `Microsoft Visual C++ runtime (missing ${missing.join(', ')} — the GTK bundle's DLLs are MSVC-ABI and will not load)`,
|
|
164
|
+
found: missing.length === 0,
|
|
165
|
+
severity: 'required',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
90
168
|
/** Optional system deps keyed by their DepCheck id. Exported for the spec's declaration invariant. */
|
|
91
169
|
export const OPTIONAL_DEPS = {
|
|
92
170
|
manette: { id: 'manette', name: 'libmanette', pkgName: 'manette-0.2' },
|
|
@@ -121,6 +199,47 @@ export const OPTIONAL_DEPS = {
|
|
|
121
199
|
epoxy: { id: 'epoxy', name: 'libepoxy', pkgName: 'epoxy' },
|
|
122
200
|
'gst-webrtc': { id: 'gst-webrtc', name: 'GStreamer WebRTC', pkgName: 'gstreamer-webrtc-1.0' },
|
|
123
201
|
};
|
|
202
|
+
/**
|
|
203
|
+
* The declared system libraries a single `@gjsify/*` package needs that are NOT
|
|
204
|
+
* present on this host.
|
|
205
|
+
*
|
|
206
|
+
* Exists so a FAILURE can answer the question this file already knows the answer
|
|
207
|
+
* to. The tables below have declared `@gjsify/rolldown-native → json-glib` since
|
|
208
|
+
* #994, complete with per-distro package names — but the only reader was
|
|
209
|
+
* `runAllChecks()`, i.e. `gjsify system-check`, a command the user has to think
|
|
210
|
+
* to run. So when the engine was installed and simply would not LOAD, the build
|
|
211
|
+
* said "@gjsify/rolldown-native is NOT INSTALLED … install it" (it was) or
|
|
212
|
+
* guessed at a bypassed launcher. Two repositories spent a CI round rediscovering
|
|
213
|
+
* a library this file could have named: ts-for-gir#437, bauplaner#40.
|
|
214
|
+
*
|
|
215
|
+
* Cannot throw, by construction rather than by a catch: `checkPkgConfig` goes
|
|
216
|
+
* through `tryExecFile`, which returns null on any failure. That matters because
|
|
217
|
+
* the caller is `diagnoseNativeEngine()`, which runs *while explaining another
|
|
218
|
+
* failure* — a probe that dies there would replace the diagnosis with its own
|
|
219
|
+
* stack.
|
|
220
|
+
*
|
|
221
|
+
* Returns `[]` for an unknown package or one with no declared deps, so a caller
|
|
222
|
+
* needs no special case for "nothing to say".
|
|
223
|
+
*/
|
|
224
|
+
export function missingSystemDepsFor(packageName) {
|
|
225
|
+
const ids = PACKAGE_DEPS[packageName];
|
|
226
|
+
if (!ids)
|
|
227
|
+
return [];
|
|
228
|
+
const missing = [];
|
|
229
|
+
for (const id of ids) {
|
|
230
|
+
const dep = OPTIONAL_DEPS[id];
|
|
231
|
+
// An id with no OPTIONAL_DEPS entry is not "present"; it is unanswerable
|
|
232
|
+
// here. The spec's declaration invariant is what keeps that set empty —
|
|
233
|
+
// silently reporting it as found would be the failure mode this whole
|
|
234
|
+
// function exists to end.
|
|
235
|
+
if (!dep?.pkgName)
|
|
236
|
+
continue;
|
|
237
|
+
const result = checkPkgConfig(dep.id, dep.name, dep.pkgName, 'optional', [packageName]);
|
|
238
|
+
if (!result.found)
|
|
239
|
+
missing.push(result);
|
|
240
|
+
}
|
|
241
|
+
return missing;
|
|
242
|
+
}
|
|
124
243
|
/**
|
|
125
244
|
* Map of @gjsify/* package name → ids of OPTIONAL_DEPS this package needs.
|
|
126
245
|
* Generated by walking each package's `gi://` imports (excluding the always-
|
|
@@ -265,6 +384,12 @@ export function runMinimalChecks() {
|
|
|
265
384
|
results.push({ id: 'nodejs', name: 'Node.js', found: true, version: process.version, severity: 'required' });
|
|
266
385
|
// GJS
|
|
267
386
|
results.push(checkBinary('gjs', 'GJS', 'gjs', ['--version'], 'required', (out) => out.replace(/^GJS\s+/i, '').split('\n')[0] ?? out));
|
|
387
|
+
// The one win32 system dependency that is real and was undiagnosed (#997).
|
|
388
|
+
// Checked at INSTALL time, not just in `system-check`, because the failure
|
|
389
|
+
// it prevents is a bare `ERR_DLOPEN_FAILED` on a `.node` that is present —
|
|
390
|
+
// by then the user has no thread to pull.
|
|
391
|
+
if (process.platform === 'win32')
|
|
392
|
+
results.push(checkMsvcRuntime());
|
|
268
393
|
return results;
|
|
269
394
|
}
|
|
270
395
|
/** Check gwebgl npm package (project first, CLI fallback). Optional — only needed by @gjsify/webgl users. */
|
|
@@ -537,6 +662,15 @@ const PM_PACKAGES = {
|
|
|
537
662
|
'json-glib': 'json-glib',
|
|
538
663
|
epoxy: 'libepoxy',
|
|
539
664
|
},
|
|
665
|
+
// Deliberately ONE entry. Windows is not a GJS host, so `gjs`, the
|
|
666
|
+
// pkg-config toolchain and every `-devel` library above have no winget
|
|
667
|
+
// package that would help — offering one would be a wrong answer rather
|
|
668
|
+
// than a missing one, and `buildInstallCommand` already skips any id with
|
|
669
|
+
// no mapping. The Visual C++ runtime is the one win32 system dependency
|
|
670
|
+
// that IS real, installable, and currently undiagnosed (#997).
|
|
671
|
+
winget: {
|
|
672
|
+
'msvc-runtime': 'Microsoft.VCRedist.2015+.x64',
|
|
673
|
+
},
|
|
540
674
|
unknown: {},
|
|
541
675
|
};
|
|
542
676
|
const PM_INSTALL_PREFIX = {
|
|
@@ -547,6 +681,9 @@ const PM_INSTALL_PREFIX = {
|
|
|
547
681
|
apk: 'sudo apk add',
|
|
548
682
|
// No `sudo`: Homebrew refuses to run as root and tells you so.
|
|
549
683
|
brew: 'brew install',
|
|
684
|
+
// `--id` because winget's fuzzy name match is ambiguous for this package,
|
|
685
|
+
// and it is the spelling Microsoft's own docs use.
|
|
686
|
+
winget: 'winget install --id',
|
|
550
687
|
unknown: '',
|
|
551
688
|
};
|
|
552
689
|
/**
|
|
@@ -642,7 +642,18 @@ export function buildNativeEnv(packages, opts = {}) {
|
|
|
642
642
|
const systemDirs = (opts.systemGiDirs ?? systemGiLibraryDirs)({ platform, env });
|
|
643
643
|
if (systemDirs.length > 0) {
|
|
644
644
|
const inherited = splitSearchPath(env[DYLD_FALLBACK_VAR]);
|
|
645
|
-
|
|
645
|
+
// Deduplicated, and not for tidiness: `systemGiLibraryDirs()` accepts a
|
|
646
|
+
// libdir stated outright through `GI_TYPELIB_PATH` on directory
|
|
647
|
+
// existence alone, so a host that names `/usr/lib/girepository-1.0`
|
|
648
|
+
// there puts `/usr/lib` in `systemDirs` — and the `/usr/lib` tail below
|
|
649
|
+
// then repeated it. A search path that lists the same directory twice
|
|
650
|
+
// asks the loader to stat it twice for every miss, and it makes the
|
|
651
|
+
// value the `$ …` echo prints read as though the CLI had composed
|
|
652
|
+
// something it did not. First occurrence wins in every loader, so
|
|
653
|
+
// dropping later repeats is behaviour-preserving.
|
|
654
|
+
out[DYLD_FALLBACK_VAR] = [
|
|
655
|
+
...new Set([...systemDirs, ...(inherited.length > 0 ? inherited : ['/usr/lib'])]),
|
|
656
|
+
].join(':');
|
|
646
657
|
}
|
|
647
658
|
return out;
|
|
648
659
|
}
|
|
@@ -7,8 +7,6 @@ export interface ShowcaseInfo {
|
|
|
7
7
|
category: string;
|
|
8
8
|
/** Description for the list view */
|
|
9
9
|
description: string;
|
|
10
|
-
/** Whether the showcase needs the gwebgl native prebuild. */
|
|
11
|
-
needsWebgl: boolean;
|
|
12
10
|
}
|
|
13
11
|
/**
|
|
14
12
|
* Read the curated showcase list from `showcases.json`. Returns showcases
|
|
@@ -43,7 +43,6 @@ export function discoverShowcases() {
|
|
|
43
43
|
packageName: e.package,
|
|
44
44
|
category: e.category,
|
|
45
45
|
description: e.description ?? '',
|
|
46
|
-
needsWebgl: Boolean(e.needsWebgl),
|
|
47
46
|
}));
|
|
48
47
|
showcases.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
|
|
49
48
|
return showcases;
|
package/lib/utils/dlx-cache.d.ts
CHANGED
|
@@ -54,8 +54,28 @@ export declare function getValidCachedPkg(cacheDir: string, maxAgeMinutes?: numb
|
|
|
54
54
|
export declare function symlinkSwap(cacheDir: string, prepareDir: string, opts?: {
|
|
55
55
|
junctions?: boolean;
|
|
56
56
|
}): string;
|
|
57
|
-
/**
|
|
58
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Remove `<cacheDir>/<oldPrepareDir>` siblings older than `maxAgeMinutes`.
|
|
59
|
+
*
|
|
60
|
+
* Every `gjsify dlx` miss creates a fresh prepare dir and swaps the `pkg` link
|
|
61
|
+
* onto it; the previous one is then unreferenced and stayed on disk forever,
|
|
62
|
+
* because this function accepted a TTL and did nothing with it. A full npm tree
|
|
63
|
+
* per miss makes that the largest thing gjsify writes to a user's cache.
|
|
64
|
+
*
|
|
65
|
+
* Three rules keep it from deleting something it should not:
|
|
66
|
+
*
|
|
67
|
+
* 1. **Only names `makePrepareDir` could have produced** (`<hex>-<hex>`). The
|
|
68
|
+
* cache dir also holds `pkg` and, briefly, `pkg.tmp-<ts>-<pid>` links from
|
|
69
|
+
* a concurrent swap — and a name-shape guard is what makes "delete the
|
|
70
|
+
* rest" safe against anything a future version puts here.
|
|
71
|
+
* 2. **Never the LIVE target**, whatever its age. A long-lived cache entry
|
|
72
|
+
* that is still current is exactly the one worth keeping, and it ages past
|
|
73
|
+
* any TTL while being used on every run.
|
|
74
|
+
* 3. **Never throws.** This is housekeeping on a best-effort path: a losing
|
|
75
|
+
* race (a parallel dlx removing the same dir) or a read-only cache must not
|
|
76
|
+
* fail the command the user actually ran.
|
|
77
|
+
*/
|
|
78
|
+
export declare function cleanupStalePrepareDirs(cacheDir: string, maxAgeMinutes?: number): void;
|
|
59
79
|
/** Resolve absolute path to the installed package's directory inside cache. */
|
|
60
80
|
export declare function resolveInstalledPkgDir(cachedRoot: string, pkgName: string): string;
|
|
61
81
|
export {};
|
package/lib/utils/dlx-cache.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// - prepare into a fresh temp dir, then atomically swap a `pkg` symlink
|
|
7
7
|
// - TTL via lstat mtime + maxAgeMinutes (default 7 days)
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
|
-
import { lstatSync, mkdirSync, realpathSync, renameSync, rmSync } from 'node:fs';
|
|
9
|
+
import { lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync } from 'node:fs';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
11
|
import { join, resolve } from 'node:path';
|
|
12
12
|
import { dirLinksAreJunctions, linkDirSync } from './dir-link.js';
|
|
@@ -36,6 +36,12 @@ export function cacheDirFor(cacheKey) {
|
|
|
36
36
|
mkdirSync(dir, { recursive: true });
|
|
37
37
|
return dir;
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* The exact shape {@link makePrepareDir} produces — two lowercase-hex fields.
|
|
41
|
+
* {@link cleanupStalePrepareDirs} deletes ONLY names matching this, so the
|
|
42
|
+
* two must move together.
|
|
43
|
+
*/
|
|
44
|
+
const PREPARE_DIR_NAME = /^[0-9a-f]+-[0-9a-f]+$/;
|
|
39
45
|
/** A fresh prepare directory under the per-key cache, named timestamp-pid. */
|
|
40
46
|
export function makePrepareDir(cacheDir) {
|
|
41
47
|
const name = `${Date.now().toString(16)}-${process.pid.toString(16)}`;
|
|
@@ -156,11 +162,61 @@ export function symlinkSwap(cacheDir, prepareDir, opts = {}) {
|
|
|
156
162
|
}
|
|
157
163
|
return realpathSync(linkPath);
|
|
158
164
|
}
|
|
159
|
-
/**
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Remove `<cacheDir>/<oldPrepareDir>` siblings older than `maxAgeMinutes`.
|
|
167
|
+
*
|
|
168
|
+
* Every `gjsify dlx` miss creates a fresh prepare dir and swaps the `pkg` link
|
|
169
|
+
* onto it; the previous one is then unreferenced and stayed on disk forever,
|
|
170
|
+
* because this function accepted a TTL and did nothing with it. A full npm tree
|
|
171
|
+
* per miss makes that the largest thing gjsify writes to a user's cache.
|
|
172
|
+
*
|
|
173
|
+
* Three rules keep it from deleting something it should not:
|
|
174
|
+
*
|
|
175
|
+
* 1. **Only names `makePrepareDir` could have produced** (`<hex>-<hex>`). The
|
|
176
|
+
* cache dir also holds `pkg` and, briefly, `pkg.tmp-<ts>-<pid>` links from
|
|
177
|
+
* a concurrent swap — and a name-shape guard is what makes "delete the
|
|
178
|
+
* rest" safe against anything a future version puts here.
|
|
179
|
+
* 2. **Never the LIVE target**, whatever its age. A long-lived cache entry
|
|
180
|
+
* that is still current is exactly the one worth keeping, and it ages past
|
|
181
|
+
* any TTL while being used on every run.
|
|
182
|
+
* 3. **Never throws.** This is housekeeping on a best-effort path: a losing
|
|
183
|
+
* race (a parallel dlx removing the same dir) or a read-only cache must not
|
|
184
|
+
* fail the command the user actually ran.
|
|
185
|
+
*/
|
|
186
|
+
export function cleanupStalePrepareDirs(cacheDir, maxAgeMinutes = DEFAULT_TTL_MIN) {
|
|
187
|
+
const cutoff = Date.now() - maxAgeMinutes * ONE_MINUTE_MS;
|
|
188
|
+
// The live target, resolved once. An unreadable or absent `pkg` link simply
|
|
189
|
+
// means nothing is live yet — every prepare dir is then a candidate.
|
|
190
|
+
let live;
|
|
191
|
+
try {
|
|
192
|
+
live = realpathSync(join(cacheDir, 'pkg'));
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
live = undefined;
|
|
196
|
+
}
|
|
197
|
+
let entries;
|
|
198
|
+
try {
|
|
199
|
+
entries = readdirSync(cacheDir);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
for (const name of entries) {
|
|
205
|
+
if (!PREPARE_DIR_NAME.test(name))
|
|
206
|
+
continue;
|
|
207
|
+
const dir = join(cacheDir, name);
|
|
208
|
+
try {
|
|
209
|
+
if (live !== undefined && realpathSync(dir) === live)
|
|
210
|
+
continue;
|
|
211
|
+
if (lstatSync(dir).mtimeMs >= cutoff)
|
|
212
|
+
continue;
|
|
213
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// Lost a race with another dlx, or the entry vanished between the
|
|
217
|
+
// readdir and the stat. Both mean "someone else handled it".
|
|
218
|
+
}
|
|
219
|
+
}
|
|
164
220
|
}
|
|
165
221
|
/** Resolve absolute path to the installed package's directory inside cache. */
|
|
166
222
|
export function resolveInstalledPkgDir(cachedRoot, pkgName) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit the process with `code`, honoured on Node and GJS alike.
|
|
3
|
+
*
|
|
4
|
+
* Safe to call from any context, including an async callback where a bare
|
|
5
|
+
* `process.exit()` would be dropped under GJS. Does not return.
|
|
6
|
+
*
|
|
7
|
+
* `process.exitCode` is set FIRST so that a host which somehow reaches natural
|
|
8
|
+
* shutdown anyway still reports the right code.
|
|
9
|
+
*/
|
|
10
|
+
export declare function forceExit(code: number): never;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The one way to end the CLI with a chosen exit code on BOTH runtimes.
|
|
2
|
+
//
|
|
3
|
+
// Node exits at natural shutdown once `process.exitCode` is set. GJS does not:
|
|
4
|
+
// it has no atexit hook, and `process.exit()` there is not immediate — its
|
|
5
|
+
// `exitProcess` IDLE-SCHEDULES `quitMainLoop()` + `imports.system.exit()` (see
|
|
6
|
+
// `utils/spawn.ts` for the main-loop machinery this rides on). From inside an
|
|
7
|
+
// asynchronous continuation — a `child.on('close'|'error')` handler, a
|
|
8
|
+
// `setTimeout`, a promise callback — that idle can lose the race with the
|
|
9
|
+
// module's own shutdown, and gjs then exits **0** no matter what code was
|
|
10
|
+
// requested. `imports.system.exit` has no such gap: SpiderMonkey raises its
|
|
11
|
+
// uncatchable exit exception from any context.
|
|
12
|
+
//
|
|
13
|
+
// This is not theoretical. `gjsify tsc` spawned the compiler and ended both its
|
|
14
|
+
// `close` and `error` handlers with a bare `process.exit(…)`. Under the GJS
|
|
15
|
+
// bundle every one of those exits was dropped, so the command reported SUCCESS
|
|
16
|
+
// whatever the compiler did — a type error exited 0, and so did "no compiler
|
|
17
|
+
// could be spawned at all". A release-cut on a cold tree then ran
|
|
18
|
+
// `node scripts/process-template.mjs && gjsify tsc && node scripts/set-bin-mode.mjs`
|
|
19
|
+
// to completion and died in `set-bin-mode.mjs` on a `lib/index.js` that `tsc`
|
|
20
|
+
// had never written — pointing the blame two steps past the actual failure.
|
|
21
|
+
//
|
|
22
|
+
// Two copies of this logic already existed (`commands/install.ts`'s local
|
|
23
|
+
// `forceExit`, `utils/oxc-resolve.ts`'s `setOxcExitCode`) and a third call site
|
|
24
|
+
// simply did without. One exported helper is the point: a new async exit path
|
|
25
|
+
// gets it right by importing it rather than by remembering the rule.
|
|
26
|
+
import { gjsExit } from '@gjsify/rolldown-plugin-gjsify/runtime';
|
|
27
|
+
/**
|
|
28
|
+
* Exit the process with `code`, honoured on Node and GJS alike.
|
|
29
|
+
*
|
|
30
|
+
* Safe to call from any context, including an async callback where a bare
|
|
31
|
+
* `process.exit()` would be dropped under GJS. Does not return.
|
|
32
|
+
*
|
|
33
|
+
* `process.exitCode` is set FIRST so that a host which somehow reaches natural
|
|
34
|
+
* shutdown anyway still reports the right code.
|
|
35
|
+
*/
|
|
36
|
+
export function forceExit(code) {
|
|
37
|
+
process.exitCode = code;
|
|
38
|
+
// Under GJS this does not return — `imports.system.exit` unwinds via
|
|
39
|
+
// SpiderMonkey's uncatchable exit exception.
|
|
40
|
+
if (gjsExit(code))
|
|
41
|
+
return undefined;
|
|
42
|
+
return process.exit(code);
|
|
43
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type NativePackage } from './detect-native-packages.js';
|
|
2
|
+
/**
|
|
3
|
+
* Put every detected prebuild directory on girepository's typelib AND library
|
|
4
|
+
* search paths. Idempotent — the first call decides, later ones return it.
|
|
5
|
+
*
|
|
6
|
+
* This is THE LAUNCHER'S ENVIRONMENT, APPLIED TO THE PROCESS THAT IS ALREADY
|
|
7
|
+
* RUNNING: it feeds on the same `detectNativePackages()` whose output the
|
|
8
|
+
* `gjsify` bin turns into `GI_TYPELIB_PATH` + the host library-path variable
|
|
9
|
+
* (`buildNativeEnv`), so the two mechanisms cannot resolve different sets. That
|
|
10
|
+
* symmetry is also why this is not per-bridge: there is no list of covered
|
|
11
|
+
* engines to maintain, and a bridge added later is covered without touching
|
|
12
|
+
* this file. Today it covers all three the CLI loads in-process —
|
|
13
|
+
* `rolldown-native`, `lightningcss-native` (via the `css-as-string` plugin) and
|
|
14
|
+
* `oxfmt-native`.
|
|
15
|
+
*
|
|
16
|
+
* TWO ANCHORS, merged first-wins exactly as `computeNativeEnvForBundle()` does
|
|
17
|
+
* it: the cwd, and the directory this module itself lives in. The second is not
|
|
18
|
+
* redundant — with a GLOBAL CLI the engine sits next to the bundle while the cwd
|
|
19
|
+
* is an unrelated project, which is the same case `tryLoadNative()` documents
|
|
20
|
+
* needing its `bundleUrl` anchor for. After bundling, every CLI module collapses
|
|
21
|
+
* into `dist/cli.gjs.mjs`, so `import.meta.url` is the bundle's own URL, which
|
|
22
|
+
* is precisely the "where does the CLI live" question being asked.
|
|
23
|
+
*
|
|
24
|
+
* Called from the native-load paths, which are themselves memoized, so the
|
|
25
|
+
* node_modules sweep (~65 ms per anchor on this 960-workspace tree, far less in
|
|
26
|
+
* a consumer) is paid at most once per process and only by a command that
|
|
27
|
+
* actually wants an engine. Deliberately NOT skipped when the launcher already
|
|
28
|
+
* exported the same directories: that check can be wrong, prepending a
|
|
29
|
+
* directory that already resolves changes nothing, and a guard whose job is
|
|
30
|
+
* watching another mechanism is the shape `docs/governance.md` § simplicity
|
|
31
|
+
* warns about.
|
|
32
|
+
*
|
|
33
|
+
* @returns the packages activated — empty when there is no repository to write
|
|
34
|
+
* to (Node, or GLib < 2.86), which every caller reads as "carry on".
|
|
35
|
+
*/
|
|
36
|
+
export declare function activateNativePrebuilds(): readonly NativePackage[];
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Make the native prebuilds this process can see resolvable to the RUNNING GJS
|
|
2
|
+
// process, with nothing exported into the environment beforehand. ADR 0021.
|
|
3
|
+
//
|
|
4
|
+
// THE MEASUREMENT (gjs 1.88.1 / GLib 2.88.3, importing `gi://GjsifyRolldown`
|
|
5
|
+
// from a bare `gjs -m`) — there are TWO lookups, not one, and they fail
|
|
6
|
+
// differently:
|
|
7
|
+
//
|
|
8
|
+
// neither var "Typelib file for namespace 'GjsifyRolldown' (any
|
|
9
|
+
// version) not found"
|
|
10
|
+
// GI_TYPELIB_PATH "Failed to load shared library 'libgjsifyrolldown.so'
|
|
11
|
+
// only referenced by the typelib" — reaching JS as the nameless
|
|
12
|
+
// "Unsupported type void, deriving from fundamental void"
|
|
13
|
+
// both loads
|
|
14
|
+
//
|
|
15
|
+
// The received answer was "the typelib lookup happens inside the GJS runtime,
|
|
16
|
+
// so those must be set BEFORE the process starts — the CLI cannot repair it
|
|
17
|
+
// from the inside". Half of that is permanently true: `LD_LIBRARY_PATH` cannot
|
|
18
|
+
// be changed in-process because ld.so freezes its search path at startup, which
|
|
19
|
+
// is an OS property and is why the `gjsify` launcher still exists for user
|
|
20
|
+
// bundles.
|
|
21
|
+
//
|
|
22
|
+
// It is the wrong frame, because girepository delegates NEITHER lookup to the
|
|
23
|
+
// loader's environment. It keeps a typelib search path AND a library search
|
|
24
|
+
// path of its own, consults both before falling back to the system loader, and
|
|
25
|
+
// both are writable at runtime. `prepend_library_path()` is the half nobody
|
|
26
|
+
// went looking for: `LD_LIBRARY_PATH` was assumed to BE the mechanism when it
|
|
27
|
+
// is only girepository's fallback — which is exactly what the middle row above
|
|
28
|
+
// proves, since girepository's own library path starts empty and no environment
|
|
29
|
+
// variable seeds it.
|
|
30
|
+
//
|
|
31
|
+
// NOT an rpath problem, and the rpath is not the fix. `libgjsifyrolldown.so`
|
|
32
|
+
// already carries `RUNPATH=$ORIGIN` (every bridge's `meson.build` sets it so
|
|
33
|
+
// the Vala library finds the Rust cdylib shipped beside it — on macOS nothing
|
|
34
|
+
// sets `DYLD_LIBRARY_PATH`, so it is the only thing that does). It structurally
|
|
35
|
+
// cannot help gjs FIND `libgjsifyrolldown.so` itself: the typelib names it by
|
|
36
|
+
// bare SONAME, and `dlopen()` of a bare name searches the RPATH/RUNPATH of the
|
|
37
|
+
// CALLING object — libgirepository, whose `$ORIGIN` is /usr/lib64. An rpath on
|
|
38
|
+
// the callee is never consulted to find the callee, so no artifact changes.
|
|
39
|
+
import { dirname } from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { detectNativePackages } from './detect-native-packages.js';
|
|
42
|
+
/**
|
|
43
|
+
* girepository's process-global default repository — the one GJS's own `gi://`
|
|
44
|
+
* importer resolves against — or `null` when there is none to write to.
|
|
45
|
+
*
|
|
46
|
+
* `globalThis.imports?.gi` is both the GJS probe (`.oxlintrc.json` sanctions
|
|
47
|
+
* exactly this spelling for a runtime probe) and the correct CONDITION, which
|
|
48
|
+
* is why there is no `isGjs()` beside it: the question is "is there a
|
|
49
|
+
* GIRepository to prepend to", and a Node host answers no all by itself. That
|
|
50
|
+
* is the required no-op — on Node the CLI uses the npm `rolldown` crate and
|
|
51
|
+
* never wants a prebuild.
|
|
52
|
+
*
|
|
53
|
+
* `dup_default()` is GLib >= 2.86. A capability probe rather than a try/catch
|
|
54
|
+
* because this is a KNOWN host property with a defined answer, not an
|
|
55
|
+
* exceptional one: an older GLib simply keeps today's behaviour (the launcher's
|
|
56
|
+
* environment, or an unchanged diagnosis), so the change cannot make any host
|
|
57
|
+
* worse than it is.
|
|
58
|
+
*/
|
|
59
|
+
function defaultRepository() {
|
|
60
|
+
const gi = globalThis.imports?.gi;
|
|
61
|
+
const repository = gi?.GIRepository?.Repository;
|
|
62
|
+
if (typeof repository?.dup_default !== 'function')
|
|
63
|
+
return null;
|
|
64
|
+
return repository.dup_default();
|
|
65
|
+
}
|
|
66
|
+
let activated = null;
|
|
67
|
+
/**
|
|
68
|
+
* Put every detected prebuild directory on girepository's typelib AND library
|
|
69
|
+
* search paths. Idempotent — the first call decides, later ones return it.
|
|
70
|
+
*
|
|
71
|
+
* This is THE LAUNCHER'S ENVIRONMENT, APPLIED TO THE PROCESS THAT IS ALREADY
|
|
72
|
+
* RUNNING: it feeds on the same `detectNativePackages()` whose output the
|
|
73
|
+
* `gjsify` bin turns into `GI_TYPELIB_PATH` + the host library-path variable
|
|
74
|
+
* (`buildNativeEnv`), so the two mechanisms cannot resolve different sets. That
|
|
75
|
+
* symmetry is also why this is not per-bridge: there is no list of covered
|
|
76
|
+
* engines to maintain, and a bridge added later is covered without touching
|
|
77
|
+
* this file. Today it covers all three the CLI loads in-process —
|
|
78
|
+
* `rolldown-native`, `lightningcss-native` (via the `css-as-string` plugin) and
|
|
79
|
+
* `oxfmt-native`.
|
|
80
|
+
*
|
|
81
|
+
* TWO ANCHORS, merged first-wins exactly as `computeNativeEnvForBundle()` does
|
|
82
|
+
* it: the cwd, and the directory this module itself lives in. The second is not
|
|
83
|
+
* redundant — with a GLOBAL CLI the engine sits next to the bundle while the cwd
|
|
84
|
+
* is an unrelated project, which is the same case `tryLoadNative()` documents
|
|
85
|
+
* needing its `bundleUrl` anchor for. After bundling, every CLI module collapses
|
|
86
|
+
* into `dist/cli.gjs.mjs`, so `import.meta.url` is the bundle's own URL, which
|
|
87
|
+
* is precisely the "where does the CLI live" question being asked.
|
|
88
|
+
*
|
|
89
|
+
* Called from the native-load paths, which are themselves memoized, so the
|
|
90
|
+
* node_modules sweep (~65 ms per anchor on this 960-workspace tree, far less in
|
|
91
|
+
* a consumer) is paid at most once per process and only by a command that
|
|
92
|
+
* actually wants an engine. Deliberately NOT skipped when the launcher already
|
|
93
|
+
* exported the same directories: that check can be wrong, prepending a
|
|
94
|
+
* directory that already resolves changes nothing, and a guard whose job is
|
|
95
|
+
* watching another mechanism is the shape `docs/governance.md` § simplicity
|
|
96
|
+
* warns about.
|
|
97
|
+
*
|
|
98
|
+
* @returns the packages activated — empty when there is no repository to write
|
|
99
|
+
* to (Node, or GLib < 2.86), which every caller reads as "carry on".
|
|
100
|
+
*/
|
|
101
|
+
export function activateNativePrebuilds() {
|
|
102
|
+
if (activated)
|
|
103
|
+
return activated;
|
|
104
|
+
// Memoized BEFORE the work, so every outcome — including a throw — is
|
|
105
|
+
// decided once and every later caller gets an answer instead of a repeat
|
|
106
|
+
// attempt. `diagnoseNativeEngine()` depends on exactly that: it documents
|
|
107
|
+
// that nothing it calls may throw while explaining a failure.
|
|
108
|
+
activated = [];
|
|
109
|
+
try {
|
|
110
|
+
const repository = defaultRepository();
|
|
111
|
+
if (!repository)
|
|
112
|
+
return activated;
|
|
113
|
+
const cwdPackages = detectNativePackages(process.cwd());
|
|
114
|
+
const seen = new Set(cwdPackages.map((p) => p.name));
|
|
115
|
+
const selfPackages = detectNativePackages(dirname(fileURLToPath(import.meta.url)));
|
|
116
|
+
const packages = [...cwdPackages, ...selfPackages.filter((p) => !seen.has(p.name))];
|
|
117
|
+
for (const pkg of packages) {
|
|
118
|
+
repository.prepend_search_path(pkg.prebuildsDir);
|
|
119
|
+
repository.prepend_library_path(pkg.prebuildsDir);
|
|
120
|
+
}
|
|
121
|
+
activated = packages;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// TWO REAL THROW PATHS, named because a catch without one is the
|
|
125
|
+
// anti-pattern this repo measures:
|
|
126
|
+
//
|
|
127
|
+
// 1. `imports.gi.GIRepository` LOADS the namespace. GJS raises
|
|
128
|
+
// "Requiring GIRepository, version none: Typelib file for namespace
|
|
129
|
+
// 'GIRepository' (any version) not found" when that typelib is
|
|
130
|
+
// absent — verified by probe. It is a SEPARATE FILE from the
|
|
131
|
+
// girepository library GJS links against, and distributions split
|
|
132
|
+
// them (Debian ships `gir1.2-girepository-3.0` apart from
|
|
133
|
+
// `libgirepository-2.0-0`), so a lean host can have the second
|
|
134
|
+
// without the first.
|
|
135
|
+
// 2. The two `detectNativePackages()` walks are filesystem I/O and can
|
|
136
|
+
// raise (EACCES on an unreadable node_modules, a dir removed
|
|
137
|
+
// mid-walk).
|
|
138
|
+
//
|
|
139
|
+
// Both mean the same thing — no activation — and swallowing is the
|
|
140
|
+
// CORRECT handling rather than a hidden failure: the caller ends up
|
|
141
|
+
// exactly where it was before this function existed (the launcher's
|
|
142
|
+
// environment, and the unchanged diagnosis if that is absent too), so
|
|
143
|
+
// no host can be made worse by it.
|
|
144
|
+
}
|
|
145
|
+
return activated;
|
|
146
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replace every raw U+0000 in `code` with the `\x00` escape.
|
|
3
|
+
*
|
|
4
|
+
* Returns the rewritten code and how many were replaced, so the caller can skip
|
|
5
|
+
* writing the file (and reporting) in the overwhelmingly common zero case.
|
|
6
|
+
*/
|
|
7
|
+
export declare function escapeRawNulForGjs(code: string): {
|
|
8
|
+
code: string;
|
|
9
|
+
replaced: number;
|
|
10
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Build-time rewrite: a `--app gjs` bundle must not contain a raw U+0000 byte.
|
|
2
|
+
//
|
|
3
|
+
// GJS hands module source to SpiderMonkey as a NUL-TERMINATED C string, so a raw
|
|
4
|
+
// U+0000 in the emitted file truncates it at that point. Everything after is
|
|
5
|
+
// silently discarded and the error names whatever construct happened to be open
|
|
6
|
+
// when the text ran out:
|
|
7
|
+
//
|
|
8
|
+
// SyntaxError: `` literal not terminated before end of script
|
|
9
|
+
//
|
|
10
|
+
// which points at the wrong place entirely and never mentions a NUL.
|
|
11
|
+
//
|
|
12
|
+
// This is easy to hit and hard to diagnose. `'\u0000'` in TypeScript source is an
|
|
13
|
+
// ESCAPE — six harmless ASCII characters — but the minifier is free to inline the
|
|
14
|
+
// constant and emit the CHARACTER:
|
|
15
|
+
//
|
|
16
|
+
// const P = '\u0000'; … return `${P}${i}${P}` // source: escaped, loads fine
|
|
17
|
+
// … return`<NUL>${e}<NUL>` // minified: raw byte, will not load
|
|
18
|
+
//
|
|
19
|
+
// so the same code builds and runs unminified and breaks under `--minify`, which is
|
|
20
|
+
// the default. A real consumer hit exactly this with an IMAP literal marker — NUL
|
|
21
|
+
// being the one byte that cannot occur in IMAP response text is precisely why it
|
|
22
|
+
// was chosen as the marker.
|
|
23
|
+
//
|
|
24
|
+
// Node and browsers load a raw NUL in source without complaint (U+0000 is a legal
|
|
25
|
+
// source character per the spec), so no other target notices and nothing upstream
|
|
26
|
+
// is going to change. Escaping it on the way out is gjsify's job.
|
|
27
|
+
//
|
|
28
|
+
// The rewrite is safe as a plain text substitution: in valid JS a raw NUL can only
|
|
29
|
+
// appear inside a string, a template literal, a regex or a comment, and `\x00`
|
|
30
|
+
// denotes exactly U+0000 in all four — so the meaning is preserved wherever it sits.
|
|
31
|
+
//
|
|
32
|
+
// `\x00` and NOT `\0`: `\0` followed by a digit is a legacy octal escape, which is a
|
|
33
|
+
// SyntaxError in a template literal and under strict mode. A digit right after the
|
|
34
|
+
// marker is exactly the shape that triggered this (`\0${index}\0`), so `\0` would
|
|
35
|
+
// trade one unloadable bundle for another.
|
|
36
|
+
/**
|
|
37
|
+
* Replace every raw U+0000 in `code` with the `\x00` escape.
|
|
38
|
+
*
|
|
39
|
+
* Returns the rewritten code and how many were replaced, so the caller can skip
|
|
40
|
+
* writing the file (and reporting) in the overwhelmingly common zero case.
|
|
41
|
+
*/
|
|
42
|
+
export function escapeRawNulForGjs(code) {
|
|
43
|
+
// split/join rather than a regex: a NUL inside a regex is exactly what oxlint's
|
|
44
|
+
// `no-control-regex` exists to flag, and the rule is right in general even though this use
|
|
45
|
+
// is deliberate. Building the needle with fromCharCode also keeps THIS file free of the raw
|
|
46
|
+
// byte it exists to remove.
|
|
47
|
+
const parts = code.split(String.fromCharCode(0));
|
|
48
|
+
return { code: parts.join('\\x00'), replaced: parts.length - 1 };
|
|
49
|
+
}
|