@gjsify/cli 0.29.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 +197 -189
- 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/bin-shim.d.ts +39 -0
- package/lib/utils/bin-shim.js +89 -13
- package/lib/utils/check-system-deps.d.ts +88 -1
- package/lib/utils/check-system-deps.js +177 -6
- 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-backend-native.d.ts +8 -0
- package/lib/utils/install-backend-native.js +8 -2
- package/lib/utils/install-global.d.ts +30 -2
- package/lib/utils/install-global.js +51 -5
- package/lib/utils/oxc-resolve.d.ts +1 -1
- package/lib/utils/oxc-resolve.js +10 -6
- package/lib/utils/run-lifecycle-script.js +41 -25
- package/lib/utils/spawn.d.ts +17 -1
- package/lib/utils/spawn.js +23 -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
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
|
+
}
|
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* True when nested `gjsify` calls in this process tree are being routed through
|
|
3
|
+
* a self-shim rather than through the workspace's `node_modules/.bin/gjsify`.
|
|
4
|
+
*
|
|
5
|
+
* `detectPackageManager()` reads this: a bootstrap CLI must run package scripts
|
|
6
|
+
* ITSELF, because npm re-prepends `node_modules/.bin` for every lifecycle
|
|
7
|
+
* script it starts — which puts the tree's dead shim back ahead of the shim dir
|
|
8
|
+
* this module placed on PATH, and undoes the whole repair one level down.
|
|
9
|
+
*/
|
|
10
|
+
export declare function usingSelfShim(): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Does this invocation need its own `gjsify` on PATH, rather than the
|
|
13
|
+
* workspace's npm bin?
|
|
14
|
+
*
|
|
15
|
+
* Pure, and takes the host facts as PARAMETERS rather than reading them
|
|
16
|
+
* ambiently — the same shape `platform-check.ts` documents, and what lets the
|
|
17
|
+
* Windows-relevant branch be tested from a Linux host.
|
|
18
|
+
*
|
|
19
|
+
* @param gjs running under GJS (the npm bin is the Node entry there)
|
|
20
|
+
* @param selfEntry absolute path of the CLI entry currently executing
|
|
21
|
+
* @param workspaceRoot absolute path of the workspace the command will act on
|
|
22
|
+
*/
|
|
23
|
+
export declare function needsSelfShim(opts: {
|
|
24
|
+
gjs: boolean;
|
|
25
|
+
selfEntry: string;
|
|
26
|
+
workspaceRoot: string;
|
|
27
|
+
}): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Ensure a runnable `gjsify` is on `process.env.PATH` for child processes.
|
|
3
30
|
* Idempotent per process; children inherit the shim via `GJSIFY_SHIM_DIR` and
|
|
4
|
-
* reuse it instead of creating their own.
|
|
31
|
+
* reuse it instead of creating their own.
|
|
5
32
|
*/
|
|
6
33
|
export declare function ensureGjsifyShimOnPath(): void;
|
package/lib/utils/gjsify-shim.js
CHANGED
|
@@ -1,52 +1,161 @@
|
|
|
1
|
-
//
|
|
1
|
+
// A runnable `gjsify` on PATH, for the two trees where the npm bin is not one.
|
|
2
2
|
//
|
|
3
|
-
// The CLI's npm
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// The CLI's npm bin (`node_modules/.bin/gjsify`) is a DISPATCHER written at
|
|
4
|
+
// install time: it prefers `packages/infra/cli/dist/cli.gjs.mjs` on a host that
|
|
5
|
+
// has `gjs`, and otherwise execs the Node entry `packages/infra/cli/lib/index.js`.
|
|
6
|
+
// Multi-package orchestration and compound package scripts resolve `gjsify`
|
|
7
|
+
// from PATH and go through it:
|
|
7
8
|
// - `gjsify workspace`/`foreach` spawn `<pm> run <script>` per package; under
|
|
8
9
|
// GJS the package manager is `gjsify` itself, and `spawn('gjsify', …)`
|
|
9
10
|
// searches PATH.
|
|
10
11
|
// - a compound script like `gjsify run a && gjsify run b` is executed via
|
|
11
|
-
// `/bin/sh -c
|
|
12
|
-
//
|
|
12
|
+
// `/bin/sh -c …` (or `%COMSPEC% /c …`), and the shell resolves each
|
|
13
|
+
// `gjsify` from PATH.
|
|
13
14
|
//
|
|
14
|
-
// Fix:
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// Fix: write a tiny wrapper that re-invokes the RUNNING CLI, and prepend its
|
|
16
|
+
// directory to `process.env.PATH` so every child process (and grandchild, via
|
|
17
|
+
// the inherited `GJSIFY_SHIM_DIR` marker) resolves `gjsify` to a CLI that
|
|
18
|
+
// actually exists.
|
|
19
|
+
//
|
|
20
|
+
// TWO HOSTS NEED IT, FOR TWO DIFFERENT REASONS
|
|
21
|
+
//
|
|
22
|
+
// GJS — always. The npm bin is the NODE entry and is useless in a node-free
|
|
23
|
+
// environment (the Flatpak sandbox has no Node): without the shim the failures
|
|
24
|
+
// are `spawn npm ENOENT` and `gjsify: not found`.
|
|
25
|
+
//
|
|
26
|
+
// Node — only when the running CLI came from OUTSIDE the workspace, which is
|
|
27
|
+
// exactly the cold-tree bootstrap (`npx @gjsify/cli@latest …`, or a global
|
|
28
|
+
// install). ADR 0002 untracked BOTH bundles, so on a tree that is installed but
|
|
29
|
+
// not yet built, `node_modules/.bin/gjsify` exists and everything it dispatches
|
|
30
|
+
// to is missing. The bin shim is then a live landmine: it resolves, and then
|
|
31
|
+
// dies with `Cannot find module …/packages/infra/cli/lib/index.js` on the FIRST
|
|
32
|
+
// nested `gjsify` — which for `gjsify run build:infra` is its first clause,
|
|
33
|
+
// `gjsify workspace @gjsify/vite-plugin-blueprint build`.
|
|
34
|
+
//
|
|
35
|
+
// THE INCIDENT, because a rule without it gets simplified back into the bug.
|
|
36
|
+
//
|
|
37
|
+
// `windows-suites.yml` (added #1021) and `macos-suites.yml` (#1022) both landed
|
|
38
|
+
// during the 2026-08-06 GitHub Actions degradation with NO checks reported on
|
|
39
|
+
// their PRs, and neither leg had ever been green. Both ordered the cold-tree
|
|
40
|
+
// bootstrap as `run build:infra` BEFORE `install`, which cannot work either:
|
|
41
|
+
// `@gjsify/vite-plugin-blueprint` declares `"types": ["node"]`, so with no
|
|
42
|
+
// `node_modules` its `gjsify tsc` fails `TS2688: Cannot find type definition
|
|
43
|
+
// file for 'node'`. Correcting the order to install-then-build then exposed
|
|
44
|
+
// THIS gap, one step later.
|
|
45
|
+
//
|
|
46
|
+
// Reproduced on Linux at de7f5525 in a cold worktree — nothing about it is
|
|
47
|
+
// Windows-specific:
|
|
48
|
+
//
|
|
49
|
+
// npx --yes @gjsify/cli@latest install --immutable # ok
|
|
50
|
+
// npx --yes @gjsify/cli@latest run build:infra # Cannot find module …
|
|
51
|
+
//
|
|
52
|
+
// Linux CI never saw it because its bootstrap runs `gjs -m <published bundle>
|
|
53
|
+
// run build:infra` (`.github/actions/gjsify-setup`), which takes the GJS branch
|
|
54
|
+
// above. Windows is simply the only leg with no `gjs` to fall back on, so it is
|
|
55
|
+
// where the gap became load-bearing.
|
|
56
|
+
//
|
|
57
|
+
// Scoped to an outside-the-workspace CLI ON PURPOSE: when the invocation
|
|
58
|
+
// already came through the workspace's own `node_modules/.bin/gjsify`, that
|
|
59
|
+
// shim IS the CLI the tree wants, and today's dispatch (GJS bundle first on a
|
|
60
|
+
// host with `gjs`) stays byte-for-byte unchanged. Widening this to every Node
|
|
61
|
+
// invocation would silently move in-repo nested builds off the GJS bundle.
|
|
18
62
|
import { mkdtempSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
|
|
19
63
|
import { tmpdir } from 'node:os';
|
|
20
|
-
import { join, delimiter } from 'node:path';
|
|
64
|
+
import { join, delimiter, relative, isAbsolute } from 'node:path';
|
|
21
65
|
import { fileURLToPath } from 'node:url';
|
|
22
66
|
import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
|
|
67
|
+
import { buildLauncherShims } from './bin-shim.js';
|
|
68
|
+
import { findWorkspaceRoot } from './workspace-root.js';
|
|
23
69
|
let _ensured = false;
|
|
70
|
+
let _selfShimActive = false;
|
|
24
71
|
/**
|
|
25
|
-
*
|
|
72
|
+
* True when nested `gjsify` calls in this process tree are being routed through
|
|
73
|
+
* a self-shim rather than through the workspace's `node_modules/.bin/gjsify`.
|
|
74
|
+
*
|
|
75
|
+
* `detectPackageManager()` reads this: a bootstrap CLI must run package scripts
|
|
76
|
+
* ITSELF, because npm re-prepends `node_modules/.bin` for every lifecycle
|
|
77
|
+
* script it starts — which puts the tree's dead shim back ahead of the shim dir
|
|
78
|
+
* this module placed on PATH, and undoes the whole repair one level down.
|
|
79
|
+
*/
|
|
80
|
+
export function usingSelfShim() {
|
|
81
|
+
return _selfShimActive;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Does this invocation need its own `gjsify` on PATH, rather than the
|
|
85
|
+
* workspace's npm bin?
|
|
86
|
+
*
|
|
87
|
+
* Pure, and takes the host facts as PARAMETERS rather than reading them
|
|
88
|
+
* ambiently — the same shape `platform-check.ts` documents, and what lets the
|
|
89
|
+
* Windows-relevant branch be tested from a Linux host.
|
|
90
|
+
*
|
|
91
|
+
* @param gjs running under GJS (the npm bin is the Node entry there)
|
|
92
|
+
* @param selfEntry absolute path of the CLI entry currently executing
|
|
93
|
+
* @param workspaceRoot absolute path of the workspace the command will act on
|
|
94
|
+
*/
|
|
95
|
+
export function needsSelfShim(opts) {
|
|
96
|
+
if (opts.gjs)
|
|
97
|
+
return true;
|
|
98
|
+
// Outside the workspace ⇒ a bootstrap CLI (npx / global install), so the
|
|
99
|
+
// tree's own `.bin/gjsify` may well point at build outputs that do not
|
|
100
|
+
// exist yet. Inside ⇒ the tree's CLI is the one running; leave PATH alone.
|
|
101
|
+
const rel = relative(opts.workspaceRoot, opts.selfEntry);
|
|
102
|
+
return rel === '' || rel.startsWith('..') || isAbsolute(rel);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Ensure a runnable `gjsify` is on `process.env.PATH` for child processes.
|
|
26
106
|
* Idempotent per process; children inherit the shim via `GJSIFY_SHIM_DIR` and
|
|
27
|
-
* reuse it instead of creating their own.
|
|
107
|
+
* reuse it instead of creating their own.
|
|
28
108
|
*/
|
|
29
109
|
export function ensureGjsifyShimOnPath() {
|
|
30
|
-
if (_ensured
|
|
110
|
+
if (_ensured)
|
|
31
111
|
return;
|
|
32
112
|
_ensured = true;
|
|
33
|
-
// A parent gjsify
|
|
34
|
-
//
|
|
113
|
+
// A parent gjsify already created the shim — reuse it so nested
|
|
114
|
+
// orchestration doesn't pile up temp dirs / PATH entries. The
|
|
115
|
+
// extension-less member is written on every host, so it stays the probe.
|
|
35
116
|
const inherited = process.env.GJSIFY_SHIM_DIR;
|
|
36
117
|
if (inherited && existsSync(join(inherited, 'gjsify'))) {
|
|
118
|
+
_selfShimActive = true;
|
|
37
119
|
if (!(process.env.PATH ?? '').split(delimiter).includes(inherited)) {
|
|
38
120
|
process.env.PATH = inherited + delimiter + (process.env.PATH ?? '');
|
|
39
121
|
}
|
|
40
122
|
return;
|
|
41
123
|
}
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
124
|
+
const gjs = isGjs();
|
|
125
|
+
// Under GJS `import.meta.url` IS the entry — the whole CLI collapses into
|
|
126
|
+
// the single `cli.gjs.mjs` bundle, so this module's URL is it. Under Node
|
|
127
|
+
// this module is `lib/utils/gjsify-shim.js` and the npm bin is
|
|
128
|
+
// `lib/index.js` (package.json `bin`), one hop up.
|
|
129
|
+
const selfEntry = gjs ? fileURLToPath(import.meta.url) : fileURLToPath(new URL('../index.js', import.meta.url));
|
|
130
|
+
const workspaceRoot = findWorkspaceRoot(process.cwd()) ?? process.cwd();
|
|
131
|
+
if (!needsSelfShim({ gjs, selfEntry, workspaceRoot }))
|
|
132
|
+
return;
|
|
46
133
|
const dir = mkdtempSync(join(tmpdir(), 'gjsify-shim-'));
|
|
47
134
|
const shim = join(dir, 'gjsify');
|
|
48
|
-
|
|
135
|
+
// The interpreter is named, not pathed, on every member — matching
|
|
136
|
+
// `buildShLauncher`'s `exec gjs` / `exec node` and `buildLauncherShims`'
|
|
137
|
+
// documented contract, so a missing interpreter fails the same way on every
|
|
138
|
+
// OS. `process.execPath` is deliberately NOT used: the batch member cannot
|
|
139
|
+
// quote an interpreter argument (`<interpreter> "<target>" %*`), so a path
|
|
140
|
+
// like `C:\Program Files\nodejs\node.exe` would split, and a trio whose
|
|
141
|
+
// members disagree about which interpreter they re-enter is worse than one
|
|
142
|
+
// name resolved consistently.
|
|
143
|
+
const interpreter = gjs ? process.env.GJS_CONSOLE || 'gjs' : 'node';
|
|
144
|
+
const interpreterArgs = gjs ? ['-m'] : [];
|
|
145
|
+
const argv = interpreterArgs.length > 0 ? `${interpreterArgs.join(' ')} ` : '';
|
|
146
|
+
writeFileSync(shim, `#!/bin/sh\nexec "${interpreter}" ${argv}"${selfEntry}" "$@"\n`, { mode: 0o755 });
|
|
49
147
|
chmodSync(shim, 0o755);
|
|
148
|
+
// cmd.exe and pwsh cannot run the extension-less member: it is not on
|
|
149
|
+
// PATHEXT and Windows has no shebang handling. npm's three-sibling answer
|
|
150
|
+
// is the de-facto standard, and `buildLauncherShims` is this repo's
|
|
151
|
+
// reviewed port of it — including the IF/ELSE ERRORLEVEL handling that
|
|
152
|
+
// `&&`/`||` chaining gets wrong.
|
|
153
|
+
if (!gjs && process.platform === 'win32') {
|
|
154
|
+
const { cmd, ps1 } = buildLauncherShims({ interpreter, interpreterArgs, target: selfEntry });
|
|
155
|
+
writeFileSync(`${shim}.cmd`, cmd);
|
|
156
|
+
writeFileSync(`${shim}.ps1`, ps1);
|
|
157
|
+
}
|
|
158
|
+
_selfShimActive = true;
|
|
50
159
|
process.env.GJSIFY_SHIM_DIR = dir;
|
|
51
160
|
process.env.PATH = dir + delimiter + (process.env.PATH ?? '');
|
|
52
161
|
}
|
|
@@ -250,6 +250,14 @@ export declare function writeBinEntry(opts: {
|
|
|
250
250
|
envPreamble: string;
|
|
251
251
|
prebuildDirs: readonly string[];
|
|
252
252
|
};
|
|
253
|
+
/**
|
|
254
|
+
* The package's npm `bin` target, when it declares one ALONGSIDE
|
|
255
|
+
* `gjsify.bin`. The launcher then picks a runtime per invocation instead of
|
|
256
|
+
* hard-coding `gjs` — see {@link pickBinMap}. Absent for a package that
|
|
257
|
+
* declares only one of the two, which is every package but `@gjsify/cli`
|
|
258
|
+
* today.
|
|
259
|
+
*/
|
|
260
|
+
nodeFallbackAbs?: string;
|
|
253
261
|
}): void;
|
|
254
262
|
type Logger = (fmt: string, ...args: unknown[]) => void;
|
|
255
263
|
export {};
|