@gjsify/cli 0.28.0 → 0.30.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.
@@ -11,6 +11,15 @@
11
11
  // toolchain handled is intentionally DROPPED in the oxc migration — no other
12
12
  // formatter is wired up for those file types.
13
13
  //
14
+ // A BARE `gjsify format` WRITES. `--check` is the read-only CI mode and
15
+ // `--no-write` the read-only local one; there is no flagless report mode.
16
+ // This is what every other formatter in the ecosystem does with its bare
17
+ // invocation (oxfmt itself, `cargo fmt`, `gofmt -w`-less `go fmt`), it is what
18
+ // the sibling `gjsify fix` already did, and the previous report-only default
19
+ // silently did nothing in a loop: `gjsify format` was run repeatedly against
20
+ // two unformatted files while `--check` kept flagging them, because the
21
+ // command that looks like the fix was the diagnosis.
22
+ //
14
23
  // `--init` writes recommended `.oxlintrc.json` + `.oxfmtrc.json` templates
15
24
  // tuned for GJS/GNOME projects (4-space, single-quote, printWidth 120,
16
25
  // trailing-comma all, semicolons always, arrow parens always).
@@ -28,9 +37,9 @@ export const formatCommand = {
28
37
  array: true,
29
38
  })
30
39
  .option('write', {
31
- description: 'Modify files in place (default: report drift without writing).',
40
+ description: 'Modify files in place (default: true). Pass --no-write to report drift instead.',
32
41
  type: 'boolean',
33
- default: false,
42
+ default: true,
34
43
  })
35
44
  .option('check', {
36
45
  description: 'Report formatting drift without modifying files; exit non-zero if any file is unformatted. Useful for CI.',
@@ -69,13 +78,13 @@ export const formatCommand = {
69
78
  if (args.check) {
70
79
  oxfmtArgs.push('--check');
71
80
  }
72
- else if (args.write) {
73
- oxfmtArgs.push('--write');
81
+ else if (args.write === false) {
82
+ // `--no-write` — report drift without modifying files. oxfmt's own
83
+ // bare default is `--write` too, so this branch must be explicit.
84
+ oxfmtArgs.push('--list-different');
74
85
  }
75
86
  else {
76
- // No flag: report drift without modifying files. oxfmt's bare
77
- // default is `--write`, so be explicit with `--list-different`.
78
- oxfmtArgs.push('--list-different');
87
+ oxfmtArgs.push('--write');
79
88
  }
80
89
  const configPath = args.configPath ?? findOxfmtConfig(cwd) ?? undefined;
81
90
  if (configPath)
@@ -107,5 +116,5 @@ function handleInit({ cwd, force }) {
107
116
  };
108
117
  writeOne('.oxlintrc.json', loadOxlintTemplate());
109
118
  writeOne('.oxfmtrc.json', loadOxfmtTemplate());
110
- console.log('[gjsify format] Run `gjsify format --write .` to apply the formatter to the project.');
119
+ console.log('[gjsify format] Run `gjsify format` to apply the formatter to the project.');
111
120
  }
@@ -1,4 +1,5 @@
1
1
  import type { Command } from '../types/index.js';
2
+ import { type PackageJson } from '../utils/pkg-json-edit.js';
2
3
  interface InstallOptions {
3
4
  packages?: string[];
4
5
  global?: boolean;
@@ -18,6 +19,32 @@ interface InstallOptions {
18
19
  force?: boolean;
19
20
  }
20
21
  export declare const installCommand: Command<unknown, InstallOptions>;
22
+ /**
23
+ * Which dependency NAMES are optional across the given manifests.
24
+ *
25
+ * The specs handed to the install backend are flat `"<name>@<range>"` strings —
26
+ * `projectSpecsFromPackageJson` merges the three dependency blocks — so the kind
27
+ * has to travel beside them. It matters for exactly one decision: an
28
+ * incompatible `os`/`cpu`/`libc` skips an OPTIONAL dependency and fails a
29
+ * REQUIRED one (see `NativeInstallOptions.optionalSpecs`).
30
+ *
31
+ * REQUIRED WINS ACROSS MANIFESTS, OPTIONAL WINS WITHIN ONE — and the two halves
32
+ * are different rules for different reasons, so neither is a special case of the
33
+ * other:
34
+ * - Across manifests: in a workspace the same package can be a plain dependency
35
+ * of one member and an optionalDependency of another. Those are two real
36
+ * edges; if one of them is not allowed to be missing, the install is not
37
+ * allowed to silently miss it. Subtracting rather than "last block seen" also
38
+ * makes the answer independent of manifest iteration order.
39
+ * - Within one manifest: a name in BOTH blocks is OPTIONAL, because npm says so
40
+ * ("entries in optionalDependencies will override entries of the same name in
41
+ * dependencies") — there is only ONE edge and the optional block names its
42
+ * kind. Treating it as required is the root-edge twin of the transitive defect
43
+ * `requiredDepEntries` fixes: `optionalDependencies: { fsevents }` alongside a
44
+ * `dependencies: { fsevents }` line would fail every Linux install with
45
+ * EBADPLATFORM over a package npm never installs there.
46
+ */
47
+ export declare function optionalDependencyNames(manifests: readonly PackageJson[]): Set<string>;
21
48
  export declare function buildBinShim(wsLocation: string, nodeTarget?: string, gjsTarget?: string, nativePrebuildDirs?: string[], platform?: string, scanRoot?: string): string;
22
49
  /**
23
50
  * `.cmd` / `.ps1` companions for {@link buildBinShim}.
@@ -446,21 +446,38 @@ function isWorkspaceRoot(cwd) {
446
446
  * incompatible `os`/`cpu`/`libc` skips an OPTIONAL dependency and fails a
447
447
  * REQUIRED one (see `NativeInstallOptions.optionalSpecs`).
448
448
  *
449
- * REQUIRED WINS. In a workspace, the same package can be a plain dependency of
450
- * one member and an optionalDependency of another; if anything is not allowed to
451
- * be missing, the whole install is not allowed to silently miss it. Subtracting
452
- * rather than "last block seen" also makes the answer independent of manifest
453
- * iteration order.
449
+ * REQUIRED WINS ACROSS MANIFESTS, OPTIONAL WINS WITHIN ONE — and the two halves
450
+ * are different rules for different reasons, so neither is a special case of the
451
+ * other:
452
+ * - Across manifests: in a workspace the same package can be a plain dependency
453
+ * of one member and an optionalDependency of another. Those are two real
454
+ * edges; if one of them is not allowed to be missing, the install is not
455
+ * allowed to silently miss it. Subtracting rather than "last block seen" also
456
+ * makes the answer independent of manifest iteration order.
457
+ * - Within one manifest: a name in BOTH blocks is OPTIONAL, because npm says so
458
+ * ("entries in optionalDependencies will override entries of the same name in
459
+ * dependencies") — there is only ONE edge and the optional block names its
460
+ * kind. Treating it as required is the root-edge twin of the transitive defect
461
+ * `requiredDepEntries` fixes: `optionalDependencies: { fsevents }` alongside a
462
+ * `dependencies: { fsevents }` line would fail every Linux install with
463
+ * EBADPLATFORM over a package npm never installs there.
454
464
  */
455
- function optionalDependencyNames(manifests) {
465
+ export function optionalDependencyNames(manifests) {
456
466
  const optional = new Set();
457
467
  const required = new Set();
458
468
  for (const manifest of manifests) {
459
- for (const name of Object.keys(manifest.optionalDependencies ?? {}))
469
+ const optionalHere = new Set(Object.keys(manifest.optionalDependencies ?? {}));
470
+ for (const name of optionalHere)
460
471
  optional.add(name);
461
472
  for (const kind of ['dependencies', 'devDependencies']) {
462
- for (const name of Object.keys(manifest[kind] ?? {}))
473
+ for (const name of Object.keys(manifest[kind] ?? {})) {
474
+ // Overridden by THIS manifest's own optional block — not a
475
+ // required edge, so it must not veto another manifest's optional
476
+ // declaration either.
477
+ if (optionalHere.has(name))
478
+ continue;
463
479
  required.add(name);
480
+ }
464
481
  }
465
482
  }
466
483
  for (const name of required)
@@ -1,4 +1,4 @@
1
- import { runAllChecks, detectPackageManager, buildInstallCommand } from '../utils/check-system-deps.js';
1
+ import { runAllChecks, detectPackageManager, buildInstallCommand, checkTypeSkew } from '../utils/check-system-deps.js';
2
2
  export const systemCheckCommand = {
3
3
  command: 'system-check',
4
4
  description: 'Check that required system dependencies (GJS, GTK4, libsoup3, …) are installed. Optional dependencies are detected only when their @gjsify/* package is in your project. (Previously called `gjsify check`; the bare name now runs TypeScript checks across the workspace — see `gjsify check --help`.)',
@@ -12,11 +12,12 @@ export const systemCheckCommand = {
12
12
  handler: async (args) => {
13
13
  const results = runAllChecks(process.cwd());
14
14
  const pm = detectPackageManager();
15
+ const typeSkew = checkTypeSkew(process.cwd());
15
16
  const missingRequired = results.filter((r) => !r.found && r.severity === 'required');
16
17
  const missingOptional = results.filter((r) => !r.found && r.severity === 'optional');
17
18
  const allMissing = [...missingRequired, ...missingOptional];
18
19
  if (args.json) {
19
- console.log(JSON.stringify({ packageManager: pm, deps: results }, null, 2));
20
+ console.log(JSON.stringify({ packageManager: pm, deps: results, typeSkew }, null, 2));
20
21
  // Only required deps influence the exit code.
21
22
  return process.exit(missingRequired.length > 0 ? 1 : 0);
22
23
  }
@@ -41,6 +42,25 @@ export const systemCheckCommand = {
41
42
  console.log(` ${icon} ${dep.name}${ver}${requiredBy}`);
42
43
  }
43
44
  }
45
+ // Printed BEFORE the all-found early return: a version skew is invisible to
46
+ // the presence checks by construction (both sides are installed and both
47
+ // report a version), so "All dependencies found." is exactly the message it
48
+ // would otherwise hide behind.
49
+ if (typeSkew.length > 0) {
50
+ console.log('\nType/runtime version skew:');
51
+ for (const skew of typeSkew) {
52
+ const arrow = skew.relation === 'ahead' ? '⚠' : 'ℹ';
53
+ console.log(` ${arrow} ${skew.typePackage} describes ${skew.declared}, ` +
54
+ `installed ${skew.pkgConfig} is ${skew.installed}`);
55
+ }
56
+ if (typeSkew.some((skew) => skew.relation === 'ahead')) {
57
+ console.log('\n ⚠ types are AHEAD of the installed library: code using newer API\n' +
58
+ ' type-checks and then fails at runtime with an undefined property.\n' +
59
+ ' Either install the matching library version, or pin the older\n' +
60
+ ' @girs/* release. GIRepository cannot detect this — it matches the\n' +
61
+ ' API version (e.g. 4.0) only, so any 4.x typelib satisfies gi://Gtk.');
62
+ }
63
+ }
44
64
  console.log(`\nPackage manager: ${pm}`);
45
65
  if (allMissing.length === 0) {
46
66
  console.log('\nAll dependencies found.');
package/lib/config.js CHANGED
@@ -223,7 +223,7 @@ 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 replace with `cliArgs.logLevel`
226
+ // TODO(open-todos: 10 small API gaps): replace with `cliArgs.logLevel`
227
227
  configData.verbose = cliArgs.verbose || false;
228
228
  configData.exclude = cliArgs.exclude || [];
229
229
  if (cliArgs.consoleShim !== undefined)
@@ -61,6 +61,17 @@ export declare function buildLauncherShims(opts: {
61
61
  * path the prebuilds have to be reachable through.
62
62
  */
63
63
  prependEnv?: Readonly<Record<string, string>>;
64
+ /**
65
+ * Second interpreter + script to run when the primary interpreter is not on
66
+ * `PATH`. The batch/PowerShell counterpart of {@link buildShLauncher}'s
67
+ * `nodeFallbackAbs` — a Windows host with no `gjs` is the majority case, and
68
+ * the `.cmd`/`.ps1` members are the ones cmd.exe and pwsh actually reach.
69
+ */
70
+ fallback?: {
71
+ interpreter: string;
72
+ interpreterArgs?: readonly string[];
73
+ target: string;
74
+ };
64
75
  }): {
65
76
  cmd: string;
66
77
  ps1: string;
@@ -119,6 +130,32 @@ export declare function buildLauncherShims(opts: {
119
130
  * script chain: the self-relative `@loader_path` rpath that
120
131
  * `check-prebuild-loader-path.mjs` enforces is what actually has to carry it.
121
132
  *
133
+ * NOT EXTENDED WITH THE HOST'S GI LIBDIRS — a decision, not an oversight.
134
+ * `buildNativeEnv()` now puts `systemGiLibraryDirs()` on the gjs CHILD's
135
+ * `DYLD_FALLBACK_LIBRARY_PATH`, which repairs every command that goes through this
136
+ * CLI. A launcher that `exec`s a GJS bundle DIRECTLY — `gjsify install -g` on a
137
+ * package whose `gjsify.bin` is a GTK app — has no CLI in the loop and still
138
+ * carries the gap.
139
+ *
140
+ * The MECHANISM would work, and that was measured on the macOS 15.7.8 VM rather
141
+ * than assumed. It is a finer point than the caveat above: what SIP strips is an
142
+ * INHERITED `DYLD_*` at the `/bin/sh` exec, so a value this preamble exports
143
+ * ITSELF survives the following `exec gjs` (an unprotected Homebrew binary) and
144
+ * `Gtk.init()` then succeeds. What is missing is a shape worth having:
145
+ *
146
+ * - BAKING `systemGiLibraryDirs()` in at write time reintroduces precisely the
147
+ * snapshot this function exists to remove — a launcher is routinely written
148
+ * BEFORE `brew install gtk4`, and would bake nothing while saying nothing.
149
+ * - RE-DERIVING it in shell means a THIRD copy of a rule that already has two,
150
+ * pinned in agreement by `system-gi.spec.ts` — and shell can express only one
151
+ * of its three sources (the probed prefixes; `pkg-config` would be a spawn on
152
+ * every launch), so that copy would be knowingly partial, in the one language
153
+ * nothing here type-checks.
154
+ *
155
+ * So the gap is recorded in `status/open-todos.md` with the measurement attached
156
+ * instead of being closed by a mechanism that drifts. Closing it properly means
157
+ * teaching such a launcher to defer to the CLI, not to re-derive.
158
+ *
122
159
  * @param scanRoot Directory whose `node_modules` is globbed at launch time.
123
160
  * @param bakedDirs Prebuild dirs found at write time; entries outside `scanRoot`
124
161
  * are embedded (entries inside it are dropped — the scan finds them).
@@ -156,6 +193,26 @@ export declare function normalizeBinMap(pkgName: string, bin: string | Record<st
156
193
  *
157
194
  * `isGjsBin` lets the caller keep plain-Node packages on the plain symlink path
158
195
  * — this must not change how `lodash`'s bin is linked.
196
+ *
197
+ * WHY THE npm MAP COMES BACK TOO
198
+ *
199
+ * The reasoning above is sound and INCOMPLETE: it was written from the Node-less
200
+ * GJS host and is blind to the mirror case. `@gjsify/cli` declares BOTH fields,
201
+ * so a host with no `gjs` — macOS and Windows, the two active port targets —
202
+ * got `exec gjs -m <bundle>`, which is exit 127, while a working Node entry sat
203
+ * beside it in the same package. `cli-cross-platform.yml` recorded that on both
204
+ * OSes for three consecutive releases (v0.27.1, v0.28.0, v0.29.0); it is
205
+ * advisory, so it stayed red. The install even KNEW: its own post-install check
206
+ * printed `Missing required system dependencies: ✗ GJS` and then wrote a
207
+ * gjs-only launcher anyway.
208
+ *
209
+ * So `nodeFallback` carries the npm map alongside, and the LAUNCHER decides per
210
+ * invocation. Deliberately not an install-time host probe: an install-time
211
+ * decision is a SNAPSHOT, and this repo has already paid for that exact mistake
212
+ * once — `buildNativeEnvPreamble` used to bake the install-time prebuild scan,
213
+ * so a package installed later was invisible and v0.24.1 shipped a launcher with
214
+ * no preamble at all. A host probe here fails the same way in reverse: install
215
+ * without gjs, install gjs afterwards, shim still points at Node forever.
159
216
  */
160
217
  export declare function pickBinMap(pkgName: string, pkgJson: {
161
218
  bin?: string | Record<string, string>;
@@ -165,6 +222,7 @@ export declare function pickBinMap(pkgName: string, pkgJson: {
165
222
  }): {
166
223
  map: Map<string, string>;
167
224
  isGjsBin: boolean;
225
+ nodeFallback: Map<string, string> | null;
168
226
  } | null;
169
227
  /**
170
228
  * The POSIX `sh` launcher gjsify writes for a bin it owns.
@@ -179,10 +237,17 @@ export declare function pickBinMap(pkgName: string, pkgJson: {
179
237
  * because not every published bundle carries a `#!/usr/bin/env -S gjs -m` line,
180
238
  * and direct-exec'ing a shebang-less `.mjs` falls through to `/bin/sh`, which
181
239
  * then parses JavaScript as shell.
240
+ *
241
+ * `nodeFallbackAbs` makes the runtime choice per INVOCATION rather than per
242
+ * install — see {@link pickBinMap} for why a host probe at install time is the
243
+ * wrong shape. `gjs` stays first: that is today's behaviour on a host carrying
244
+ * both, and which runtime wins is a separate decision from making the shim
245
+ * runnable at all.
182
246
  */
183
247
  export declare function buildShLauncher(targetAbs: string, opts: {
184
248
  envPreamble?: string;
185
249
  isGjsBundle: boolean;
250
+ nodeFallbackAbs?: string;
186
251
  }): string;
187
252
  /** True when a bin target is a GJS-runnable bundle rather than a Node script. */
188
253
  export declare function isGjsBundlePath(target: string): boolean;
@@ -223,26 +223,63 @@ export function buildCmdShim(relTargetPosix, shebang) {
223
223
  * fails the same way on every OS.
224
224
  */
225
225
  export function buildLauncherShims(opts) {
226
- const { interpreter, interpreterArgs = [], target, prependEnv = {} } = opts;
226
+ const { interpreter, interpreterArgs = [], target, prependEnv = {}, fallback } = opts;
227
227
  const winTarget = target.split('/').join('\\');
228
228
  const argv = interpreterArgs.length > 0 ? `${interpreterArgs.join(' ')} ` : '';
229
229
  const entries = Object.entries(prependEnv);
230
+ const fbTarget = fallback === undefined ? '' : fallback.target.split('/').join('\\');
231
+ const fbArgv = fallback?.interpreterArgs !== undefined && fallback.interpreterArgs.length > 0
232
+ ? `${fallback.interpreterArgs.join(' ')} `
233
+ : '';
230
234
  let cmd = CMD_HEAD;
231
235
  for (const [key, value] of entries)
232
236
  cmd += `@SET "${key}=${value};%${key}%"\r\n`;
233
- cmd += `${interpreter} ${argv}"${winTarget}" %*\r\n`;
237
+ if (fallback === undefined) {
238
+ cmd += `${interpreter} ${argv}"${winTarget}" %*\r\n`;
239
+ }
240
+ else {
241
+ // `where` is the batch equivalent of `command -v`; it writes to stdout,
242
+ // so both streams are silenced. ERRORLEVEL is read via IF/ELSE rather
243
+ // than `&&`/`||` chaining, which would swallow the child's own exit code.
244
+ cmd +=
245
+ `@where ${interpreter} >NUL 2>NUL\r\n` +
246
+ `@IF %ERRORLEVEL% EQU 0 (\r\n` +
247
+ ` ${interpreter} ${argv}"${winTarget}" %*\r\n` +
248
+ `) ELSE (\r\n` +
249
+ ` ${fallback.interpreter} ${fbArgv}"${fbTarget}" %*\r\n` +
250
+ `)\r\n`;
251
+ }
234
252
  let ps1 = '#!/usr/bin/env pwsh\n';
235
253
  for (const [key, value] of entries)
236
254
  ps1 += `$env:${key} = "${value};" + $env:${key}\n`;
237
255
  const ps1Args = interpreterArgs.map((a) => `"${a}" `).join('');
238
- ps1 +=
239
- '# Support pipeline input\n' +
240
- 'if ($MyInvocation.ExpectingInput) {\n' +
241
- ` $input | & "${interpreter}" ${ps1Args}"${winTarget}" $args\n` +
242
- '} else {\n' +
243
- ` & "${interpreter}" ${ps1Args}"${winTarget}" $args\n` +
244
- '}\n' +
245
- 'exit $LASTEXITCODE\n';
256
+ if (fallback === undefined) {
257
+ ps1 +=
258
+ '# Support pipeline input\n' +
259
+ 'if ($MyInvocation.ExpectingInput) {\n' +
260
+ ` $input | & "${interpreter}" ${ps1Args}"${winTarget}" $args\n` +
261
+ '} else {\n' +
262
+ ` & "${interpreter}" ${ps1Args}"${winTarget}" $args\n` +
263
+ '}\n';
264
+ }
265
+ else {
266
+ // Bind the three parts once, then call once — the alternative (a full
267
+ // if/else around two near-identical invocation blocks, each of which
268
+ // already branches on pipeline input) is four copies of the same call.
269
+ const psList = (a) => a.length === 0 ? '@()' : `@(${a.map((x) => `"${x}"`).join(', ')})`;
270
+ ps1 +=
271
+ `$exe = "${interpreter}"; $exeArgs = ${psList(interpreterArgs)}; $script = "${winTarget}"\n` +
272
+ `if (-not (Get-Command "${interpreter}" -ErrorAction SilentlyContinue)) {\n` +
273
+ ` $exe = "${fallback.interpreter}"; $exeArgs = ${psList(fallback.interpreterArgs ?? [])}; $script = "${fbTarget}"\n` +
274
+ '}\n' +
275
+ '# Support pipeline input\n' +
276
+ 'if ($MyInvocation.ExpectingInput) {\n' +
277
+ ' $input | & $exe @exeArgs $script $args\n' +
278
+ '} else {\n' +
279
+ ' & $exe @exeArgs $script $args\n' +
280
+ '}\n';
281
+ }
282
+ ps1 += 'exit $LASTEXITCODE\n';
246
283
  return { cmd, ps1 };
247
284
  }
248
285
  /** POSIX `sh` single-quoting — the one place the escape lives. */
@@ -303,6 +340,32 @@ function shQuote(s) {
303
340
  * script chain: the self-relative `@loader_path` rpath that
304
341
  * `check-prebuild-loader-path.mjs` enforces is what actually has to carry it.
305
342
  *
343
+ * NOT EXTENDED WITH THE HOST'S GI LIBDIRS — a decision, not an oversight.
344
+ * `buildNativeEnv()` now puts `systemGiLibraryDirs()` on the gjs CHILD's
345
+ * `DYLD_FALLBACK_LIBRARY_PATH`, which repairs every command that goes through this
346
+ * CLI. A launcher that `exec`s a GJS bundle DIRECTLY — `gjsify install -g` on a
347
+ * package whose `gjsify.bin` is a GTK app — has no CLI in the loop and still
348
+ * carries the gap.
349
+ *
350
+ * The MECHANISM would work, and that was measured on the macOS 15.7.8 VM rather
351
+ * than assumed. It is a finer point than the caveat above: what SIP strips is an
352
+ * INHERITED `DYLD_*` at the `/bin/sh` exec, so a value this preamble exports
353
+ * ITSELF survives the following `exec gjs` (an unprotected Homebrew binary) and
354
+ * `Gtk.init()` then succeeds. What is missing is a shape worth having:
355
+ *
356
+ * - BAKING `systemGiLibraryDirs()` in at write time reintroduces precisely the
357
+ * snapshot this function exists to remove — a launcher is routinely written
358
+ * BEFORE `brew install gtk4`, and would bake nothing while saying nothing.
359
+ * - RE-DERIVING it in shell means a THIRD copy of a rule that already has two,
360
+ * pinned in agreement by `system-gi.spec.ts` — and shell can express only one
361
+ * of its three sources (the probed prefixes; `pkg-config` would be a spawn on
362
+ * every launch), so that copy would be knowingly partial, in the one language
363
+ * nothing here type-checks.
364
+ *
365
+ * So the gap is recorded in `status/open-todos.md` with the measurement attached
366
+ * instead of being closed by a mechanism that drifts. Closing it properly means
367
+ * teaching such a launcher to defer to the CLI, not to re-derive.
368
+ *
306
369
  * @param scanRoot Directory whose `node_modules` is globbed at launch time.
307
370
  * @param bakedDirs Prebuild dirs found at write time; entries outside `scanRoot`
308
371
  * are embedded (entries inside it are dropped — the scan finds them).
@@ -411,14 +474,39 @@ export function normalizeBinMap(pkgName, bin) {
411
474
  *
412
475
  * `isGjsBin` lets the caller keep plain-Node packages on the plain symlink path
413
476
  * — this must not change how `lodash`'s bin is linked.
477
+ *
478
+ * WHY THE npm MAP COMES BACK TOO
479
+ *
480
+ * The reasoning above is sound and INCOMPLETE: it was written from the Node-less
481
+ * GJS host and is blind to the mirror case. `@gjsify/cli` declares BOTH fields,
482
+ * so a host with no `gjs` — macOS and Windows, the two active port targets —
483
+ * got `exec gjs -m <bundle>`, which is exit 127, while a working Node entry sat
484
+ * beside it in the same package. `cli-cross-platform.yml` recorded that on both
485
+ * OSes for three consecutive releases (v0.27.1, v0.28.0, v0.29.0); it is
486
+ * advisory, so it stayed red. The install even KNEW: its own post-install check
487
+ * printed `Missing required system dependencies: ✗ GJS` and then wrote a
488
+ * gjs-only launcher anyway.
489
+ *
490
+ * So `nodeFallback` carries the npm map alongside, and the LAUNCHER decides per
491
+ * invocation. Deliberately not an install-time host probe: an install-time
492
+ * decision is a SNAPSHOT, and this repo has already paid for that exact mistake
493
+ * once — `buildNativeEnvPreamble` used to bake the install-time prebuild scan,
494
+ * so a package installed later was invisible and v0.24.1 shipped a launcher with
495
+ * no preamble at all. A host probe here fails the same way in reverse: install
496
+ * without gjs, install gjs afterwards, shim still points at Node forever.
414
497
  */
415
498
  export function pickBinMap(pkgName, pkgJson) {
416
499
  const gjsifyBin = pkgJson.gjsify?.bin;
417
- if (gjsifyBin !== undefined)
418
- return { map: normalizeBinMap(pkgName, gjsifyBin), isGjsBin: true };
419
500
  const npmBin = pkgJson.bin;
501
+ if (gjsifyBin !== undefined) {
502
+ return {
503
+ map: normalizeBinMap(pkgName, gjsifyBin),
504
+ isGjsBin: true,
505
+ nodeFallback: npmBin === undefined ? null : normalizeBinMap(pkgName, npmBin),
506
+ };
507
+ }
420
508
  if (npmBin !== undefined)
421
- return { map: normalizeBinMap(pkgName, npmBin), isGjsBin: false };
509
+ return { map: normalizeBinMap(pkgName, npmBin), isGjsBin: false, nodeFallback: null };
422
510
  return null;
423
511
  }
424
512
  /**
@@ -434,9 +522,23 @@ export function pickBinMap(pkgName, pkgJson) {
434
522
  * because not every published bundle carries a `#!/usr/bin/env -S gjs -m` line,
435
523
  * and direct-exec'ing a shebang-less `.mjs` falls through to `/bin/sh`, which
436
524
  * then parses JavaScript as shell.
525
+ *
526
+ * `nodeFallbackAbs` makes the runtime choice per INVOCATION rather than per
527
+ * install — see {@link pickBinMap} for why a host probe at install time is the
528
+ * wrong shape. `gjs` stays first: that is today's behaviour on a host carrying
529
+ * both, and which runtime wins is a separate decision from making the shim
530
+ * runnable at all.
437
531
  */
438
532
  export function buildShLauncher(targetAbs, opts) {
439
533
  const preamble = opts.isGjsBundle ? (opts.envPreamble ?? '') : '';
534
+ if (opts.isGjsBundle && opts.nodeFallbackAbs !== undefined) {
535
+ // The preamble only ever configures the GI search path, so it stays
536
+ // inside the gjs branch — exporting it around a Node exec would be
537
+ // inert at best and misleading to anyone reading the shim.
538
+ return (`#!/bin/sh\nif command -v gjs >/dev/null 2>&1; then\n${preamble}` +
539
+ `exec gjs -m ${shQuote(targetAbs)} "$@"\nfi\n` +
540
+ `exec node ${shQuote(opts.nodeFallbackAbs)} "$@"\n`);
541
+ }
440
542
  const exec = opts.isGjsBundle ? `exec gjs -m ${shQuote(targetAbs)} "$@"` : `exec ${shQuote(targetAbs)} "$@"`;
441
543
  return `#!/bin/sh\n${preamble}${exec}\n`;
442
544
  }
@@ -28,6 +28,27 @@ export type PackageManager = 'apt' | 'dnf' | 'pacman' | 'zypper' | 'apk' | 'brew
28
28
  * MacPorts or Nix host, and it is why this must never fall back to "probably brew".
29
29
  */
30
30
  export declare function detectPackageManager(): PackageManager;
31
+ interface OptionalDep {
32
+ /** DepCheck id */
33
+ id: string;
34
+ /** Display name */
35
+ name: string;
36
+ /** pkg-config library name */
37
+ pkgName: string;
38
+ }
39
+ /** Optional system deps keyed by their DepCheck id. Exported for the spec's declaration invariant. */
40
+ export declare const OPTIONAL_DEPS: Record<string, OptionalDep>;
41
+ /**
42
+ * Map of @gjsify/* package name → ids of OPTIONAL_DEPS this package needs.
43
+ * Generated by walking each package's `gi://` imports (excluding the always-
44
+ * available trio GLib/GObject/Gio).
45
+ *
46
+ * Used to compute the set of optional deps to check for a given project.
47
+ *
48
+ * Exported for the spec's declaration invariant: every id named here must exist in
49
+ * OPTIONAL_DEPS, or the dep is silently never checked.
50
+ */
51
+ export declare const PACKAGE_DEPS: Record<string, string[]>;
31
52
  /**
32
53
  * Run all dependency checks. Used by `gjsify check` to show full system status.
33
54
  *
@@ -52,3 +73,38 @@ export declare function checkGwebgl(cwd: string): DepCheck;
52
73
  * Returns null when package manager is unknown or no installable deps are missing.
53
74
  */
54
75
  export declare function buildInstallCommand(pm: PackageManager, missing: DepCheck[]): string | null;
76
+ export interface TypeSkew {
77
+ typePackage: string;
78
+ pkgConfig: string;
79
+ /** `libraryVersion` declared by the installed `@girs/*` package. */
80
+ declared: string;
81
+ /** `pkg-config --modversion` of the library the host actually has. */
82
+ installed: string;
83
+ /**
84
+ * `ahead` = the types describe a NEWER library than is installed. This is the
85
+ * direction that compiles and then throws, so it is the one worth reporting.
86
+ * `behind` = API the host has is invisible to the compiler; limiting, not wrong.
87
+ */
88
+ relation: 'ahead' | 'behind';
89
+ }
90
+ /**
91
+ * Readers the comparison depends on, injectable so every branch is unit-testable
92
+ * from ANY host. Same reasoning as `resolvePrebuildDirName`, which is a pure
93
+ * function of injected values precisely so its darwin/win32 branches are tested
94
+ * from Linux: a check whose only test is "run it on the maintainer's machine and
95
+ * look" is a check whose skew cases are never exercised.
96
+ */
97
+ export interface TypeSkewReaders {
98
+ /** `libraryVersion` of an installed `@girs/*` package, or null when absent. */
99
+ readDeclared?: (cwd: string, typePackage: string) => string | null;
100
+ /** `pkg-config --modversion` of a library, or null when absent. */
101
+ readInstalled?: (pkgConfig: string) => string | null;
102
+ }
103
+ /**
104
+ * Compare each installed `@girs/*` package's declared upstream version against the
105
+ * library the host actually has. Only reports a `major.minor` difference: a micro
106
+ * difference does not change the API set, and reporting it would train people to
107
+ * ignore the output.
108
+ */
109
+ export declare function checkTypeSkew(cwd: string, readers?: TypeSkewReaders): TypeSkew[];
110
+ export {};