@gjsify/cli 0.27.1 → 0.29.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.
@@ -1,6 +1,7 @@
1
1
  import type { Command } from '../types/index.js';
2
2
  interface DebugCliOptions {
3
3
  busName?: string;
4
+ address?: string;
4
5
  profile?: string;
5
6
  globals?: string;
6
7
  out?: string;
@@ -43,26 +43,32 @@ function deriveBrowserAppId(name) {
43
43
  function hasDep(pkg, name) {
44
44
  return Boolean(pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
45
45
  }
46
- function generateEntry(profile, busNameBase, name, version) {
46
+ function generateEntry(profile, busNameBase, name, version, address) {
47
+ const base = JSON.stringify(busNameBase);
48
+ // Only emit the option when an address was actually given: an `address:
49
+ // undefined` in the generated entry would read like a decision, when the
50
+ // bridge's own precedence (env var → published address file → session bus)
51
+ // is what should apply.
52
+ const options = address ? `, { address: ${JSON.stringify(address)} }` : '';
47
53
  if (profile === 'storybook') {
48
54
  return `// Auto-generated by \`gjsify debug\` — do not edit by hand.
49
55
  import { runDevtoolsMcp, storybookProfile } from '@gjsify/devtools-mcp';
50
56
 
51
- await runDevtoolsMcp(storybookProfile(${JSON.stringify(busNameBase)}));
57
+ await runDevtoolsMcp(storybookProfile(${base}${options}));
52
58
  `;
53
59
  }
54
60
  if (profile === 'browser') {
55
61
  return `// Auto-generated by \`gjsify debug\` — do not edit by hand.
56
62
  import { runDevtoolsMcp, browserProfile } from '@gjsify/devtools-mcp';
57
63
 
58
- await runDevtoolsMcp(browserProfile(${JSON.stringify(busNameBase)}));
64
+ await runDevtoolsMcp(browserProfile(${base}${options}));
59
65
  `;
60
66
  }
61
67
  if (profile === 'cdp') {
62
68
  return `// Auto-generated by \`gjsify debug\` — do not edit by hand.
63
69
  import { runDevtoolsMcp, cdpProfile } from '@gjsify/devtools-mcp';
64
70
 
65
- await runDevtoolsMcp(cdpProfile(${JSON.stringify(busNameBase)}));
71
+ await runDevtoolsMcp(cdpProfile(${base}${options}));
66
72
  `;
67
73
  }
68
74
  return `// Auto-generated by \`gjsify debug\` — do not edit by hand.
@@ -71,7 +77,7 @@ import { runDevtoolsMcp } from '@gjsify/devtools-mcp';
71
77
  await runDevtoolsMcp({
72
78
  name: ${JSON.stringify(name)},
73
79
  version: ${JSON.stringify(version)},
74
- busNameBase: ${JSON.stringify(busNameBase)},
80
+ busNameBase: ${base},${address ? `\n address: ${JSON.stringify(address)},` : ''}
75
81
  });
76
82
  `;
77
83
  }
@@ -82,6 +88,12 @@ export const debugCommand = {
82
88
  .option('bus-name', {
83
89
  description: 'App DBus base name (default: gjsify.devtools.busNameBase, or the storybook app-id)',
84
90
  type: 'string',
91
+ })
92
+ .option('address', {
93
+ description: 'Peer D-Bus address of the app (unix:path=… / nonce-tcp:…) instead of the session bus — for ' +
94
+ 'macOS/Windows, which have no session bus. Default: GJSIFY_DEVTOOLS_ADDRESS, then the address ' +
95
+ 'file the app publishes, then the session bus',
96
+ type: 'string',
85
97
  })
86
98
  .option('profile', {
87
99
  description: 'Tool profile: generic | storybook | browser | cdp (default: auto — storybook if @gjsify/storybook is a dep, browser if @gjsify/devtools-browser is, cdp if @gjsify/devtools-cdp is, else generic)',
@@ -133,9 +145,10 @@ export const debugCommand = {
133
145
  mkdirSync(cacheDir, { recursive: true });
134
146
  const entryPath = join(cacheDir, 'entry.ts');
135
147
  const outPath = args.out ? resolve(cwd, args.out) : join(cacheDir, 'bridge.gjs.mjs');
136
- writeFileSync(entryPath, generateEntry(profile, busNameBase, name, version));
148
+ const address = args.address ?? config.address;
149
+ writeFileSync(entryPath, generateEntry(profile, busNameBase, name, version, address));
137
150
  // stderr only — stdout is the MCP JSON-RPC channel.
138
- console.error(`[gjsify debug] profile=${profile} bus=${busNameBase}`);
151
+ console.error(`[gjsify debug] profile=${profile} bus=${busNameBase}${address ? ` address=${address}` : ''}`);
139
152
  // In-process build (runtime-agnostic), then launch with stdio inherited
140
153
  // so the bridge's JSON-RPC stdin/stdout are the client's pipes.
141
154
  const { runCli } = await import('../cli-app.js');
@@ -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.');
@@ -119,6 +119,32 @@ export declare function buildLauncherShims(opts: {
119
119
  * script chain: the self-relative `@loader_path` rpath that
120
120
  * `check-prebuild-loader-path.mjs` enforces is what actually has to carry it.
121
121
  *
122
+ * NOT EXTENDED WITH THE HOST'S GI LIBDIRS — a decision, not an oversight.
123
+ * `buildNativeEnv()` now puts `systemGiLibraryDirs()` on the gjs CHILD's
124
+ * `DYLD_FALLBACK_LIBRARY_PATH`, which repairs every command that goes through this
125
+ * CLI. A launcher that `exec`s a GJS bundle DIRECTLY — `gjsify install -g` on a
126
+ * package whose `gjsify.bin` is a GTK app — has no CLI in the loop and still
127
+ * carries the gap.
128
+ *
129
+ * The MECHANISM would work, and that was measured on the macOS 15.7.8 VM rather
130
+ * than assumed. It is a finer point than the caveat above: what SIP strips is an
131
+ * INHERITED `DYLD_*` at the `/bin/sh` exec, so a value this preamble exports
132
+ * ITSELF survives the following `exec gjs` (an unprotected Homebrew binary) and
133
+ * `Gtk.init()` then succeeds. What is missing is a shape worth having:
134
+ *
135
+ * - BAKING `systemGiLibraryDirs()` in at write time reintroduces precisely the
136
+ * snapshot this function exists to remove — a launcher is routinely written
137
+ * BEFORE `brew install gtk4`, and would bake nothing while saying nothing.
138
+ * - RE-DERIVING it in shell means a THIRD copy of a rule that already has two,
139
+ * pinned in agreement by `system-gi.spec.ts` — and shell can express only one
140
+ * of its three sources (the probed prefixes; `pkg-config` would be a spawn on
141
+ * every launch), so that copy would be knowingly partial, in the one language
142
+ * nothing here type-checks.
143
+ *
144
+ * So the gap is recorded in `status/open-todos.md` with the measurement attached
145
+ * instead of being closed by a mechanism that drifts. Closing it properly means
146
+ * teaching such a launcher to defer to the CLI, not to re-derive.
147
+ *
122
148
  * @param scanRoot Directory whose `node_modules` is globbed at launch time.
123
149
  * @param bakedDirs Prebuild dirs found at write time; entries outside `scanRoot`
124
150
  * are embedded (entries inside it are dropped — the scan finds them).
@@ -303,6 +303,32 @@ function shQuote(s) {
303
303
  * script chain: the self-relative `@loader_path` rpath that
304
304
  * `check-prebuild-loader-path.mjs` enforces is what actually has to carry it.
305
305
  *
306
+ * NOT EXTENDED WITH THE HOST'S GI LIBDIRS — a decision, not an oversight.
307
+ * `buildNativeEnv()` now puts `systemGiLibraryDirs()` on the gjs CHILD's
308
+ * `DYLD_FALLBACK_LIBRARY_PATH`, which repairs every command that goes through this
309
+ * CLI. A launcher that `exec`s a GJS bundle DIRECTLY — `gjsify install -g` on a
310
+ * package whose `gjsify.bin` is a GTK app — has no CLI in the loop and still
311
+ * carries the gap.
312
+ *
313
+ * The MECHANISM would work, and that was measured on the macOS 15.7.8 VM rather
314
+ * than assumed. It is a finer point than the caveat above: what SIP strips is an
315
+ * INHERITED `DYLD_*` at the `/bin/sh` exec, so a value this preamble exports
316
+ * ITSELF survives the following `exec gjs` (an unprotected Homebrew binary) and
317
+ * `Gtk.init()` then succeeds. What is missing is a shape worth having:
318
+ *
319
+ * - BAKING `systemGiLibraryDirs()` in at write time reintroduces precisely the
320
+ * snapshot this function exists to remove — a launcher is routinely written
321
+ * BEFORE `brew install gtk4`, and would bake nothing while saying nothing.
322
+ * - RE-DERIVING it in shell means a THIRD copy of a rule that already has two,
323
+ * pinned in agreement by `system-gi.spec.ts` — and shell can express only one
324
+ * of its three sources (the probed prefixes; `pkg-config` would be a spawn on
325
+ * every launch), so that copy would be knowingly partial, in the one language
326
+ * nothing here type-checks.
327
+ *
328
+ * So the gap is recorded in `status/open-todos.md` with the measurement attached
329
+ * instead of being closed by a mechanism that drifts. Closing it properly means
330
+ * teaching such a launcher to defer to the CLI, not to re-derive.
331
+ *
306
332
  * @param scanRoot Directory whose `node_modules` is globbed at launch time.
307
333
  * @param bakedDirs Prebuild dirs found at write time; entries outside `scanRoot`
308
334
  * are embedded (entries inside it are dropped — the scan finds them).
@@ -52,3 +52,37 @@ export declare function checkGwebgl(cwd: string): DepCheck;
52
52
  * Returns null when package manager is unknown or no installable deps are missing.
53
53
  */
54
54
  export declare function buildInstallCommand(pm: PackageManager, missing: DepCheck[]): string | null;
55
+ export interface TypeSkew {
56
+ typePackage: string;
57
+ pkgConfig: string;
58
+ /** `libraryVersion` declared by the installed `@girs/*` package. */
59
+ declared: string;
60
+ /** `pkg-config --modversion` of the library the host actually has. */
61
+ installed: string;
62
+ /**
63
+ * `ahead` = the types describe a NEWER library than is installed. This is the
64
+ * direction that compiles and then throws, so it is the one worth reporting.
65
+ * `behind` = API the host has is invisible to the compiler; limiting, not wrong.
66
+ */
67
+ relation: 'ahead' | 'behind';
68
+ }
69
+ /**
70
+ * Readers the comparison depends on, injectable so every branch is unit-testable
71
+ * from ANY host. Same reasoning as `resolvePrebuildDirName`, which is a pure
72
+ * function of injected values precisely so its darwin/win32 branches are tested
73
+ * from Linux: a check whose only test is "run it on the maintainer's machine and
74
+ * look" is a check whose skew cases are never exercised.
75
+ */
76
+ export interface TypeSkewReaders {
77
+ /** `libraryVersion` of an installed `@girs/*` package, or null when absent. */
78
+ readDeclared?: (cwd: string, typePackage: string) => string | null;
79
+ /** `pkg-config --modversion` of a library, or null when absent. */
80
+ readInstalled?: (pkgConfig: string) => string | null;
81
+ }
82
+ /**
83
+ * Compare each installed `@girs/*` package's declared upstream version against the
84
+ * library the host actually has. Only reports a `major.minor` difference: a micro
85
+ * difference does not change the API set, and reporting it would train people to
86
+ * ignore the output.
87
+ */
88
+ export declare function checkTypeSkew(cwd: string, readers?: TypeSkewReaders): TypeSkew[];
@@ -11,7 +11,7 @@
11
11
  // is in the project's dependency tree. A user with only @gjsify/fs in their
12
12
  // project never sees a warning about libmanette.
13
13
  import { execFileSync } from 'node:child_process';
14
- import { join, resolve } from 'node:path';
14
+ import { dirname, join, resolve } from 'node:path';
15
15
  import { createRequire } from 'node:module';
16
16
  import { pathToFileURL } from 'node:url';
17
17
  import { readFileSync, existsSync, readdirSync } from 'node:fs';
@@ -546,3 +546,123 @@ export function buildInstallCommand(pm, missing) {
546
546
  }
547
547
  return lines.length > 0 ? lines.join('\n ') : null;
548
548
  }
549
+ /**
550
+ * Only libraries whose GIR carries a real `<package version>`, verified against
551
+ * the published packages rather than assumed. A binding whose value turns out
552
+ * degenerate is skipped at runtime by `isDegenerate`, so a wrong entry here
553
+ * cannot produce a false alarm — it can only produce silence.
554
+ */
555
+ const TYPE_PACKAGE_BINDINGS = [
556
+ { pkgConfig: 'gtk4', typePackage: '@girs/gtk-4.0', namespaceVersion: '4.0' },
557
+ { pkgConfig: 'libadwaita-1', typePackage: '@girs/adw-1', namespaceVersion: '1' },
558
+ { pkgConfig: 'libsoup-3.0', typePackage: '@girs/soup-3.0', namespaceVersion: '3.0' },
559
+ { pkgConfig: 'glib-2.0', typePackage: '@girs/glib-2.0', namespaceVersion: '2.0' },
560
+ { pkgConfig: 'gobject-2.0', typePackage: '@girs/gobject-2.0', namespaceVersion: '2.0' },
561
+ { pkgConfig: 'gio-2.0', typePackage: '@girs/gio-2.0', namespaceVersion: '2.0' },
562
+ { pkgConfig: 'gstreamer-1.0', typePackage: '@girs/gst-1.0', namespaceVersion: '1.0' },
563
+ { pkgConfig: 'webkitgtk-6.0', typePackage: '@girs/webkit-6.0', namespaceVersion: '6.0' },
564
+ { pkgConfig: 'manette-0.2', typePackage: '@girs/manette-0.2', namespaceVersion: '0.2' },
565
+ { pkgConfig: 'pango', typePackage: '@girs/pango-1.0', namespaceVersion: '1.0' },
566
+ { pkgConfig: 'harfbuzz', typePackage: '@girs/harfbuzz-0.0', namespaceVersion: '0.0' },
567
+ ];
568
+ /**
569
+ * Read `libraryVersion` from an installed `@girs/*` package.
570
+ *
571
+ * Resolves the package's MAIN entry and walks up to its root `package.json`
572
+ * rather than resolving the `package.json` subpath: `@girs/*` exports maps do not
573
+ * expose `./package.json` (verified on all 32 installed packages), so
574
+ * `require.resolve('@girs/gtk-4.0/package.json')` throws
575
+ * `ERR_PACKAGE_PATH_NOT_EXPORTED`. The same trap silently disabled the
576
+ * `@nativescript/vite` major-version gate until it was found by measurement.
577
+ */
578
+ function readGirsLibraryVersion(cwd, typePackage) {
579
+ for (const from of [pathToFileURL(join(cwd, '_girs_.js')).href, import.meta.url]) {
580
+ let dir;
581
+ try {
582
+ dir = dirname(createRequire(from).resolve(typePackage));
583
+ }
584
+ catch {
585
+ continue;
586
+ }
587
+ // Walk up to the package root — the entry may sit in a subdirectory.
588
+ for (let i = 0; i < 5; i++) {
589
+ const manifest = join(dir, 'package.json');
590
+ if (existsSync(manifest)) {
591
+ try {
592
+ const parsed = JSON.parse(readFileSync(manifest, 'utf-8'));
593
+ if (parsed.name === typePackage)
594
+ return parsed.libraryVersion ?? null;
595
+ }
596
+ catch {
597
+ return null; // present but unreadable — nothing to compare against
598
+ }
599
+ }
600
+ const parent = dirname(dir);
601
+ if (parent === dir)
602
+ break;
603
+ dir = parent;
604
+ }
605
+ }
606
+ return null;
607
+ }
608
+ /** `[major, minor]`, or null when the string is not a comparable version. */
609
+ function majorMinor(version) {
610
+ const match = /^(\d+)\.(\d+)/.exec(version.trim());
611
+ if (!match)
612
+ return null;
613
+ return [Number(match[1]), Number(match[2])];
614
+ }
615
+ /**
616
+ * True when `libraryVersion` is ts-for-gir's namespace-version fallback rather
617
+ * than a real upstream release — `@girs/gdk-4.0`'s `4.0.0` against namespace `4.0`.
618
+ *
619
+ * Compares against every `.0`-padded spelling of the namespace version, not just
620
+ * the three-component one: `4.0` and `4.0.0` are the same claim, and matching only
621
+ * the padded form let the bare spelling through as a bogus 18-minor "behind"
622
+ * finding. Deliberately NOT a major.minor comparison — `@girs/adw-1` has namespace
623
+ * `1` and a real `libraryVersion` of `1.10.0`, which any major-only rule discards.
624
+ */
625
+ function isDegenerate(declared, namespaceVersion) {
626
+ const trimmed = declared.trim();
627
+ const parts = namespaceVersion.split('.');
628
+ for (let n = parts.length; n <= 3; n++) {
629
+ if (trimmed === [...parts, ...Array(n - parts.length).fill('0')].join('.'))
630
+ return true;
631
+ }
632
+ return false;
633
+ }
634
+ /**
635
+ * Compare each installed `@girs/*` package's declared upstream version against the
636
+ * library the host actually has. Only reports a `major.minor` difference: a micro
637
+ * difference does not change the API set, and reporting it would train people to
638
+ * ignore the output.
639
+ */
640
+ export function checkTypeSkew(cwd, readers = {}) {
641
+ const readDeclared = readers.readDeclared ?? readGirsLibraryVersion;
642
+ const readInstalled = readers.readInstalled ?? ((pkgConfig) => tryExecFile('pkg-config', ['--modversion', pkgConfig]));
643
+ const findings = [];
644
+ for (const binding of TYPE_PACKAGE_BINDINGS) {
645
+ const declared = readDeclared(cwd, binding.typePackage);
646
+ if (declared === null)
647
+ continue; // types not installed, or no declaration
648
+ if (isDegenerate(declared, binding.namespaceVersion))
649
+ continue;
650
+ const installed = readInstalled(binding.pkgConfig);
651
+ if (installed === null)
652
+ continue; // library absent — the presence check owns that
653
+ const want = majorMinor(declared);
654
+ const have = majorMinor(installed.split('\n')[0] ?? installed);
655
+ if (!want || !have)
656
+ continue;
657
+ if (want[0] === have[0] && want[1] === have[1])
658
+ continue;
659
+ findings.push({
660
+ typePackage: binding.typePackage,
661
+ pkgConfig: binding.pkgConfig,
662
+ declared,
663
+ installed: installed.split('\n')[0] ?? installed,
664
+ relation: want[0] > have[0] || (want[0] === have[0] && want[1] > have[1]) ? 'ahead' : 'behind',
665
+ });
666
+ }
667
+ return findings;
668
+ }
@@ -1,3 +1,4 @@
1
+ import { type SystemGiOptions } from './system-gi.js';
1
2
  export interface NativePackage {
2
3
  /** npm package name, e.g. "@gjsify/webgl" */
3
4
  name: string;
@@ -42,8 +43,15 @@ export interface NativeEnv {
42
43
  GI_TYPELIB_PATH: string;
43
44
  /** ELF loader (Linux and other ELF platforms). */
44
45
  LD_LIBRARY_PATH?: string;
45
- /** Mach-O loader (macOS). `dyld` ignores `LD_LIBRARY_PATH` entirely. */
46
+ /** Mach-O loader (macOS), OVERRIDING resolution by leaf. `dyld` ignores `LD_LIBRARY_PATH` entirely. */
46
47
  DYLD_LIBRARY_PATH?: string;
48
+ /**
49
+ * Mach-O loader (macOS), consulted only AFTER the normal search fails. This
50
+ * is where the HOST's GI libdirs go — never `DYLD_LIBRARY_PATH`, which would
51
+ * shadow correctly-resolved libraries process-wide. See
52
+ * {@link buildNativeEnv}.
53
+ */
54
+ DYLD_FALLBACK_LIBRARY_PATH?: string;
47
55
  [key: string]: string | undefined;
48
56
  }
49
57
  /**
@@ -287,8 +295,44 @@ export declare function libraryPathVar(platform: string): {
287
295
  * (a stock Windows env block uses `Path`) — Windows env names are
288
296
  * case-insensitive, but a plain JS object is not, and `{...process.env,
289
297
  * PATH: …}` would hand a child process two competing entries.
298
+ *
299
+ * ── DARWIN ALSO GETS THE *HOST'S* GI LIBDIRS ─────────────────────────────────
300
+ *
301
+ * Everything above is about the prebuilds gjsify itself ships. On macOS there is
302
+ * a second, independent gap that has nothing to do with gjsify: a typelib names
303
+ * its library by BARE LEAF (`libgtk-4.1.dylib`), and dyld carries neither
304
+ * `/usr/local/lib` nor `/opt/homebrew/lib` in its default fallback path.
305
+ * Homebrew's `gjs` has an rpath into GLIB's keg only, so it resolves no other
306
+ * keg's leaves — measured: bare `gjs -c "imports.gi.Gtk; Gtk.init()"`, no gjsify
307
+ * in the process, dies on that dlopen (full trace in `utils/system-gi.ts`). This
308
+ * is the ONE place `gjsify run`, `showcase`, `storybook`, `tsc` and `info` all
309
+ * pass through, so it is where the child's launch env is repaired.
310
+ *
311
+ * Two properties are load-bearing:
312
+ *
313
+ * 1. **`DYLD_FALLBACK_LIBRARY_PATH`, never `DYLD_LIBRARY_PATH`.** The override
314
+ * variable wins by LEAF for every dylib in the process, including ones the
315
+ * host already resolved correctly — pointing it at a whole system libdir is
316
+ * how you replace a linked-against library with a same-named different one.
317
+ * The fallback is consulted only after the normal search fails, which is
318
+ * exactly the "nothing else found it" case. Consequence, and it is the
319
+ * intended precedence: gjsify's own prebuilds keep priority through the
320
+ * override variable, the system stack fills in behind it.
321
+ * 2. **Our dirs go FIRST, an inherited fallback is kept BEHIND them, and
322
+ * `/usr/lib` is appended when there is none.** Setting the variable
323
+ * REPLACES dyld's own default fallback list, so writing only our dirs would
324
+ * silently remove `/usr/lib` from the search — a host that exported the
325
+ * variable itself already carries whatever tail it wants. Same composition
326
+ * as node-gi's re-exec.
327
+ *
328
+ * Emitted even when `packages` is empty: the gap is in the host's loader, not in
329
+ * a gjsify prebuild, so a plain `gjsify run script.gjs.js` that touches GTK needs
330
+ * it just as much. Empty off darwin — `ld.so`'s configured cache already resolves
331
+ * these leaves on Linux, and Windows re-reads `PATH` per `LoadLibrary`.
290
332
  */
291
333
  export declare function buildNativeEnv(packages: NativePackage[], opts?: {
292
334
  platform?: string;
293
335
  env?: Record<string, string | undefined>;
336
+ /** Injectable for tests — the darwin branch is asserted from Linux. */
337
+ systemGiDirs?: (opts: SystemGiOptions) => string[];
294
338
  }): NativeEnv;