@gjsify/cli 0.30.0 → 0.32.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.
Files changed (51) hide show
  1. package/dist/cli.gjs.mjs +194 -193
  2. package/lib/actions/build.d.ts +14 -0
  3. package/lib/actions/build.js +78 -3
  4. package/lib/actions/index.d.ts +2 -0
  5. package/lib/actions/index.js +2 -0
  6. package/lib/affected-entry.d.ts +1 -0
  7. package/lib/affected-entry.js +49 -0
  8. package/lib/bundler-pick.js +84 -19
  9. package/lib/cli-app.js +20 -0
  10. package/lib/cli-fail.d.ts +22 -0
  11. package/lib/cli-fail.js +67 -0
  12. package/lib/commands/affected.d.ts +2 -2
  13. package/lib/commands/affected.js +16 -0
  14. package/lib/commands/install.d.ts +55 -0
  15. package/lib/commands/install.js +38 -21
  16. package/lib/commands/run.js +25 -6
  17. package/lib/commands/self-update.d.ts +27 -0
  18. package/lib/commands/self-update.js +77 -2
  19. package/lib/commands/showcase.d.ts +14 -0
  20. package/lib/commands/showcase.js +130 -10
  21. package/lib/commands/test.js +4 -4
  22. package/lib/commands/tsc.js +59 -11
  23. package/lib/commands/workspace.js +13 -0
  24. package/lib/config.js +6 -2
  25. package/lib/index.js +18 -1
  26. package/lib/templates/install.mjs.tmpl +27 -2
  27. package/lib/types/command.d.ts +18 -0
  28. package/lib/utils/check-system-deps.d.ts +66 -1
  29. package/lib/utils/check-system-deps.js +139 -2
  30. package/lib/utils/detect-native-packages.js +12 -1
  31. package/lib/utils/discover-showcases.d.ts +0 -2
  32. package/lib/utils/discover-showcases.js +0 -1
  33. package/lib/utils/dlx-cache.d.ts +22 -2
  34. package/lib/utils/dlx-cache.js +62 -6
  35. package/lib/utils/force-exit.d.ts +10 -0
  36. package/lib/utils/force-exit.js +43 -0
  37. package/lib/utils/gi-search-path.d.ts +36 -0
  38. package/lib/utils/gi-search-path.js +146 -0
  39. package/lib/utils/gjs-source-escape.d.ts +10 -0
  40. package/lib/utils/gjs-source-escape.js +49 -0
  41. package/lib/utils/gjsify-shim.d.ts +29 -2
  42. package/lib/utils/gjsify-shim.js +131 -22
  43. package/lib/utils/install-global.d.ts +79 -2
  44. package/lib/utils/install-global.js +130 -8
  45. package/lib/utils/oxc-resolve.d.ts +1 -1
  46. package/lib/utils/oxc-resolve.js +10 -6
  47. package/lib/utils/stdin.d.ts +3 -13
  48. package/lib/utils/stdin.js +21 -100
  49. package/lib/utils/system-gi.js +10 -1
  50. package/package.json +22 -17
  51. package/showcases.json +8 -16
package/lib/config.js CHANGED
@@ -223,8 +223,12 @@ export class Config {
223
223
  const pkg = (await this.readPackageJSON(configFilePath));
224
224
  const tsConfig = (await this.readTSConfig(configFilePath));
225
225
  tsConfig.reflection ||= cliArgs.reflection;
226
- // TODO(open-todos: 10 small API gaps): replace with `cliArgs.logLevel`
227
- configData.verbose = cliArgs.verbose || false;
226
+ // `--verbose` and `--log-level` are two spellings of one question, and
227
+ // only the boolean reached here — so `--log-level debug` turned the
228
+ // BUNDLER's logging up (see the mapping further down, which sets
229
+ // `bundler.logLevel`) while the CLI's own verbose output stayed off.
230
+ // Either spelling now answers it; neither silences the other.
231
+ configData.verbose = cliArgs.verbose || cliArgs.logLevel === 'debug' || cliArgs.logLevel === 'verbose';
228
232
  configData.exclude = cliArgs.exclude || [];
229
233
  if (cliArgs.consoleShim !== undefined)
230
234
  configData.consoleShim = cliArgs.consoleShim;
package/lib/index.js CHANGED
@@ -17,7 +17,24 @@ catch (err) {
17
17
  // a scary `Gjs-CRITICAL **: Module … threw an exception` that buries the
18
18
  // real, already-actionable message (e.g. "gjsify build: no usable bundler
19
19
  // engine under GJS — build the facade first or run under Node"). See #597.
20
- console.error(err instanceof Error ? err.message : String(err));
20
+ //
21
+ // The `code` is printed WITH the message, not dropped. Errors in this CLI
22
+ // carry npm's own codes on purpose — `badPlatformError()` documents that it
23
+ // reproduces npm's `EBADPLATFORM` payload "so a consumer's error handling
24
+ // (and a human reading the message) sees what npm reports" — and npm itself
25
+ // prints the code (`npm ERR! code EBADPLATFORM`) rather than only the prose.
26
+ //
27
+ // It has to be explicit here because this catch became the SINGLE printer:
28
+ // before the `.fail()` handler in `cli-app.ts`, yargs logged the error OBJECT
29
+ // and the code came along for free. Printing `err.message` alone silently
30
+ // dropped it for every coded error, which is what turned
31
+ // `tests/e2e/install-optional-both-blocks` red on `main` — it asserts the
32
+ // code is visible, and the prose it still printed did not contain it.
33
+ const code = typeof err === 'object' && err !== null && typeof err.code === 'string'
34
+ ? err.code
35
+ : '';
36
+ const message = err instanceof Error ? err.message : String(err);
37
+ console.error(code && !message.includes(code) ? `${code}: ${message}` : message);
21
38
  if (process.env.GJSIFY_DEBUG && err instanceof Error && err.stack)
22
39
  console.error(err.stack);
23
40
  // `process.exitCode` is the gentle Node idiom (streams flush). GJS has no
@@ -58,8 +58,18 @@ const USER_AGENT = 'gjsify-installer/1.0';
58
58
  const CACHE_PREFIX = 'cli-';
59
59
  const CACHE_SUFFIX = '.gjs.mjs';
60
60
 
61
+ /**
62
+ * Under `--fetch-only` the ONLY thing on stdout must be the bundle path, so
63
+ * progress goes to stderr instead. A caller does
64
+ * `BOOTSTRAP=$(gjs -m install.mjs --fetch-only)`, and one stray `info()` line
65
+ * would make that a path with a log message glued to the front — a failure that
66
+ * surfaces as a confusing "file not found" rather than as bad output.
67
+ */
68
+ let logToStderr = false;
69
+
61
70
  function info(msg) {
62
- print(`[gjsify] ${msg}`);
71
+ if (logToStderr) printerr(`[gjsify] ${msg}`);
72
+ else print(`[gjsify] ${msg}`);
63
73
  }
64
74
  function error(msg) {
65
75
  printerr(`[gjsify] ERROR: ${msg}`);
@@ -71,6 +81,7 @@ function parseArgs() {
71
81
  let tag = 'latest';
72
82
  let force = false;
73
83
  let help = false;
84
+ let fetchOnly = false;
74
85
  let bootstrapUrl = GLib.getenv('GJSIFY_INSTALL_BOOTSTRAP_URL') || DEFAULT_BOOTSTRAP_URL;
75
86
  let bootstrapSha256Url = GLib.getenv('GJSIFY_INSTALL_BOOTSTRAP_SHA256_URL');
76
87
  if (bootstrapSha256Url === null || bootstrapSha256Url === undefined) {
@@ -87,8 +98,9 @@ function parseArgs() {
87
98
  else if (a.startsWith('--tag=')) tag = a.slice('--tag='.length);
88
99
  else if (a === '--bootstrap-url') bootstrapUrl = argv[++i];
89
100
  else if (a.startsWith('--bootstrap-url=')) bootstrapUrl = a.slice('--bootstrap-url='.length);
101
+ else if (a === '--fetch-only') fetchOnly = true;
90
102
  }
91
- return { target, tag, force, help, bootstrapUrl, bootstrapSha256Url };
103
+ return { target, tag, force, help, bootstrapUrl, bootstrapSha256Url, fetchOnly };
92
104
  }
93
105
 
94
106
  function printUsage() {
@@ -102,6 +114,10 @@ Options:
102
114
  --tag <tag> npm dist-tag or version (default: latest)
103
115
  --force, -f Reinstall even when present.
104
116
  --bootstrap-url <url> Override the cli.gjs.mjs download URL.
117
+ --fetch-only Download + verify the bootstrap bundle, print its cached
118
+ path to stdout and exit — no install. Lets a caller (CI)
119
+ reuse this downloader instead of growing a second one
120
+ beside it; progress goes to stderr so stdout is the path.
105
121
  --help, -h Show this message.
106
122
 
107
123
  Env vars:
@@ -318,6 +334,9 @@ async function main() {
318
334
  printUsage();
319
335
  exit(0);
320
336
  }
337
+ // Set BEFORE the first `info()` — resolveBootstrap logs while downloading,
338
+ // and those lines would otherwise land on stdout ahead of the path.
339
+ logToStderr = opts.fetchOnly;
321
340
  checkGjsVersion();
322
341
 
323
342
  const session = new Soup.Session();
@@ -329,6 +348,12 @@ async function main() {
329
348
  exit(1);
330
349
  }
331
350
 
351
+ if (opts.fetchOnly) {
352
+ // The one thing on stdout. Everything above went to stderr.
353
+ print(bundlePath);
354
+ exit(0);
355
+ }
356
+
332
357
  const spec = buildSpec(opts.target, opts.tag);
333
358
  try {
334
359
  await runInstall(bundlePath, spec);
@@ -7,3 +7,21 @@ export interface Command<T = unknown, U = T> {
7
7
  middlewares?: MiddlewareFunction[];
8
8
  deprecated?: boolean | string;
9
9
  }
10
+ /**
11
+ * A command that definitely runs something — the shape of every command except
12
+ * the builder-only parents.
13
+ *
14
+ * WHY IT EXISTS: `Command.handler` has to stay optional for parent commands,
15
+ * but that optionality then lands on every CALL site, including the ones
16
+ * outside `cli-app.ts`'s yargs registration where a missing handler would be a
17
+ * silent no-op rather than a type error. `src/affected-entry.ts` — the entry
18
+ * the committed `dist/affected.gjs.mjs` is built from — invoked
19
+ * `affectedCommand.handler(...)` through a cast that suppressed exactly that,
20
+ * and nothing noticed for as long as the package's tsconfig scoped the compile
21
+ * to the import closure of `src/index.ts`. Annotate a command that a standalone
22
+ * entry invokes directly with this type, and the guarantee is in the type
23
+ * instead of in a cast.
24
+ */
25
+ export type LeafCommand<T = unknown, U = T> = Command<T, U> & {
26
+ handler: NonNullable<Command<T, U>['handler']>;
27
+ };
@@ -12,7 +12,26 @@ export interface DepCheck {
12
12
  /** For optional deps: which @gjsify/* packages need this lib */
13
13
  requiredBy?: string[];
14
14
  }
15
- export type PackageManager = 'apt' | 'dnf' | 'pacman' | 'zypper' | 'apk' | 'brew' | 'unknown';
15
+ export type PackageManager = 'apt' | 'dnf' | 'pacman' | 'zypper' | 'apk' | 'brew' | 'winget' | 'unknown';
16
+ /**
17
+ * True when `cmd` is on PATH. Walks `process.env.PATH` with `existsSync`
18
+ * instead of shelling out to `which`(1).
19
+ *
20
+ * `which` is NOT a probe you can rely on: the Fedora 43/44 minimal containers
21
+ * CI runs in ship without it (only the `which-2.x` rpm provides it), so a
22
+ * `which`-based check silently answers "missing" for EVERY command. That is
23
+ * exactly where this file's answer matters most — `detectPackageManager()`
24
+ * returning `unknown` makes `buildInstallCommand()` return null, so the
25
+ * distro-specific install hint for a missing system library prints nothing on
26
+ * the hosts that needed it. Measured twice on 2026-08-05: ts-for-gir#437 had to
27
+ * discover `json-glib` by hand, bauplaner#40 `libsoup3` — both in such a
28
+ * container, both while this file already declared the dependency.
29
+ *
30
+ * `tests/e2e/helpers.mjs`'s `hasCommand()` carries the same walk for the same
31
+ * reason. It stays a second copy on purpose: it is `.mjs` under `tests/` and
32
+ * cannot be imported from the shipped CLI.
33
+ */
34
+ export declare function isOnPath(cmd: string): boolean;
16
35
  /**
17
36
  * The package manager whose install command we may suggest.
18
37
  *
@@ -28,6 +47,29 @@ export type PackageManager = 'apt' | 'dnf' | 'pacman' | 'zypper' | 'apk' | 'brew
28
47
  * MacPorts or Nix host, and it is why this must never fall back to "probably brew".
29
48
  */
30
49
  export declare function detectPackageManager(): PackageManager;
50
+ /**
51
+ * The Visual C++ runtime — an undeclared, undiagnosed win32 prerequisite (#997).
52
+ *
53
+ * `@gjsify/gtk-runtime-win32-x64` is built with gvsbuild, i.e. against the MSVC
54
+ * ABI, and its DLLs load into stock (also MSVC-ABI) Node. On a Windows host
55
+ * WITHOUT the Visual C++ redistributable that load fails — and nothing in the
56
+ * toolchain said so. The precedent for why that matters is PR #994: a missing
57
+ * load-time library made a typelib unloadable and the user-visible message was
58
+ * `Unsupported type void, deriving from fundamental void`, which names nothing
59
+ * a user could act on.
60
+ *
61
+ * Probed by FILE PRESENCE in the system directory rather than by asking a
62
+ * package database: the redistributable can arrive with Visual Studio, with
63
+ * another application's installer, or as part of a Windows image, and only one
64
+ * of those routes is visible to a package manager. What the loader actually
65
+ * needs is the DLL, so that is what is checked.
66
+ *
67
+ * `vcruntime140.dll` and `msvcp140.dll` are the C and C++ halves;
68
+ * `vcruntime140_1.dll` carries the x64 exception-handling machinery and is
69
+ * absent from pre-2019 redistributables, which is exactly the "installed, but
70
+ * too old" case that otherwise fails at first use rather than at load.
71
+ */
72
+ export declare function checkMsvcRuntime(): DepCheck;
31
73
  interface OptionalDep {
32
74
  /** DepCheck id */
33
75
  id: string;
@@ -38,6 +80,29 @@ interface OptionalDep {
38
80
  }
39
81
  /** Optional system deps keyed by their DepCheck id. Exported for the spec's declaration invariant. */
40
82
  export declare const OPTIONAL_DEPS: Record<string, OptionalDep>;
83
+ /**
84
+ * The declared system libraries a single `@gjsify/*` package needs that are NOT
85
+ * present on this host.
86
+ *
87
+ * Exists so a FAILURE can answer the question this file already knows the answer
88
+ * to. The tables below have declared `@gjsify/rolldown-native → json-glib` since
89
+ * #994, complete with per-distro package names — but the only reader was
90
+ * `runAllChecks()`, i.e. `gjsify system-check`, a command the user has to think
91
+ * to run. So when the engine was installed and simply would not LOAD, the build
92
+ * said "@gjsify/rolldown-native is NOT INSTALLED … install it" (it was) or
93
+ * guessed at a bypassed launcher. Two repositories spent a CI round rediscovering
94
+ * a library this file could have named: ts-for-gir#437, bauplaner#40.
95
+ *
96
+ * Cannot throw, by construction rather than by a catch: `checkPkgConfig` goes
97
+ * through `tryExecFile`, which returns null on any failure. That matters because
98
+ * the caller is `diagnoseNativeEngine()`, which runs *while explaining another
99
+ * failure* — a probe that dies there would replace the diagnosis with its own
100
+ * stack.
101
+ *
102
+ * Returns `[]` for an unknown package or one with no declared deps, so a caller
103
+ * needs no special case for "nothing to say".
104
+ */
105
+ export declare function missingSystemDepsFor(packageName: string): DepCheck[];
41
106
  /**
42
107
  * Map of @gjsify/* package name → ids of OPTIONAL_DEPS this package needs.
43
108
  * Generated by walking each package's `gi://` imports (excluding the always-
@@ -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
- const result = tryExecFile('which', [pm]);
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
- out[DYLD_FALLBACK_VAR] = [...systemDirs, ...(inherited.length > 0 ? inherited : ['/usr/lib'])].join(':');
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;
@@ -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
- /** Clean up `<cacheDir>/<oldPrepareDir>` siblings older than `maxAgeMinutes`. */
58
- export declare function cleanupStalePrepareDirs(cacheDir: string, _maxAgeMinutes?: number): void;
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 {};
@@ -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
- /** Clean up `<cacheDir>/<oldPrepareDir>` siblings older than `maxAgeMinutes`. */
160
- export function cleanupStalePrepareDirs(cacheDir, _maxAgeMinutes = DEFAULT_TTL_MIN) {
161
- // TODO(open-todos: 10 small API gaps): out of scope for Phase 1 — pnpm has the same stub. Kept so
162
- // call sites already exist when we do implement it.
163
- void cacheDir;
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
+ }