@gjsify/cli 0.26.0 → 0.27.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 (57) hide show
  1. package/dist/cli.gjs.mjs +184 -182
  2. package/lib/cli-app.js +3 -1
  3. package/lib/commands/affected.js +146 -15
  4. package/lib/commands/build.js +21 -14
  5. package/lib/commands/check.js +40 -4
  6. package/lib/commands/clear.d.ts +8 -0
  7. package/lib/commands/clear.js +90 -0
  8. package/lib/commands/copy.d.ts +8 -0
  9. package/lib/commands/copy.js +87 -0
  10. package/lib/commands/dlx.js +8 -1
  11. package/lib/commands/index.d.ts +2 -0
  12. package/lib/commands/index.js +2 -0
  13. package/lib/commands/install.d.ts +4 -0
  14. package/lib/commands/install.js +164 -15
  15. package/lib/commands/onboard.js +17 -14
  16. package/lib/commands/publish.js +18 -4
  17. package/lib/commands/storybook.js +5 -1
  18. package/lib/commands/test.js +29 -2
  19. package/lib/commands/uninstall.js +44 -2
  20. package/lib/commands/upgrade.js +1 -4
  21. package/lib/templates/install.mjs.tmpl +110 -19
  22. package/lib/utils/bin-shim.d.ts +73 -0
  23. package/lib/utils/bin-shim.js +97 -2
  24. package/lib/utils/build-cache.d.ts +7 -27
  25. package/lib/utils/build-cache.js +34 -4
  26. package/lib/utils/check-system-deps.d.ts +15 -1
  27. package/lib/utils/check-system-deps.js +126 -2
  28. package/lib/utils/clear-targets.d.ts +30 -0
  29. package/lib/utils/clear-targets.js +114 -0
  30. package/lib/utils/copy-targets.d.ts +41 -0
  31. package/lib/utils/copy-targets.js +145 -0
  32. package/lib/utils/detect-native-packages.d.ts +160 -5
  33. package/lib/utils/detect-native-packages.js +326 -50
  34. package/lib/utils/dir-link.d.ts +86 -0
  35. package/lib/utils/dir-link.js +140 -0
  36. package/lib/utils/dlx-cache.d.ts +30 -3
  37. package/lib/utils/dlx-cache.js +61 -7
  38. package/lib/utils/install-backend-native.d.ts +109 -1
  39. package/lib/utils/install-backend-native.js +622 -59
  40. package/lib/utils/install-backend.js +17 -15
  41. package/lib/utils/install-global.js +6 -34
  42. package/lib/utils/install-lock.js +1 -1
  43. package/lib/utils/install-packument-cache.d.ts +51 -9
  44. package/lib/utils/install-packument-cache.js +88 -25
  45. package/lib/utils/oxc-resolve.d.ts +27 -1
  46. package/lib/utils/oxc-resolve.js +53 -26
  47. package/lib/utils/pkg-json.d.ts +24 -0
  48. package/lib/utils/pkg-json.js +27 -1
  49. package/lib/utils/platform-check.d.ts +215 -0
  50. package/lib/utils/platform-check.js +377 -0
  51. package/lib/utils/resolve-npm-package.js +9 -2
  52. package/lib/utils/run-gjs.d.ts +1 -1
  53. package/lib/utils/run-gjs.js +19 -3
  54. package/lib/utils/spawn.js +50 -2
  55. package/lib/utils/win32-command.d.ts +54 -0
  56. package/lib/utils/win32-command.js +140 -0
  57. package/package.json +19 -20
@@ -1,10 +1,17 @@
1
1
  // Utility to find npm packages with gjsify native prebuilds.
2
2
  // Packages declare: "gjsify": { "prebuilds": "<dir>" } in their package.json,
3
- // and (since the OS-axis audit) the `<os>-<arch>` targets they promise in
4
- // "gjsify": { "platforms": [...] }.
3
+ // and (since the OS-axis audit) the `<os>-<arch>[-musl]` targets they promise in
4
+ // "gjsify": { "platforms": [...] }. The libc half of that token is the newest
5
+ // axis; which libc a package's artifacts actually NEED is a separate, measured
6
+ // claim (npm's `libc` field + `gjsify.glibcRequires`, held to the binaries by the
7
+ // `prebuild-libc` conformance rule) — this file only resolves DIRECTORIES.
5
8
  //
6
- // One algorithm — `detectNativePackages(startDir)` — walks up from `startDir`
7
- // and exhaustively scans every `node_modules` it finds. Used by:
9
+ // TWO passes, and the second one exists because the first cannot see an isolated
10
+ // (pnpm-style) layout: `detectNativePackages(startDir)` walks up from `startDir`
11
+ // and exhaustively scans every `node_modules` it finds, then resolves any package
12
+ // that DECLARED a prebuild without shipping one through its per-platform
13
+ // companion package (`<name>-<token>`), searching from THAT package's own
14
+ // directory. See `resolvePlatformSibling`. Used by:
8
15
  // * `gjsify run`, `gjsify info`, `gjsify install` — startDir = process.cwd()
9
16
  // * `runGjsBundle()` — startDir = dirname(bundlePath), so DLX-cache layouts
10
17
  // (`~/.cache/gjsify/dlx/<sha>/.../node_modules/<pkg>/dist/bundle.js`) get
@@ -13,11 +20,12 @@
13
20
  // for packages whose Vala typelibs live in *indirect* deps.
14
21
  //
15
22
  // PLATFORM RESOLUTION IS A PURE FUNCTION. Everything that depends on the host
16
- // OS/CPU lives in `resolvePrebuildDirName()` / `buildNativeEnv()`, both of
17
- // which take `platform` / `arch` as parameters instead of reading
18
- // `process.platform` inline. That is what makes the darwin and win32 branches
19
- // unit-testable from a Linux host (see `detect-native-packages.spec.ts`) —
20
- // the host values are only read at the outermost call site, as a default.
23
+ // OS/CPU/libc lives in `resolvePrebuildDirName()` / `buildNativeEnv()` /
24
+ // `resolveHostLibc()`, all of which take `platform` / `arch` / `libc` as
25
+ // parameters instead of reading `process.platform` inline. That is what makes
26
+ // the darwin, win32 AND musl branches unit-testable from a glibc Linux host
27
+ // (see `detect-native-packages.spec.ts`) — the host values are only read at the
28
+ // outermost call site, as a default.
21
29
  import { readdirSync, existsSync } from 'node:fs';
22
30
  import { join, resolve } from 'node:path';
23
31
  import { readPackageJson } from './pkg-json.js';
@@ -39,10 +47,11 @@ const NODE_ARCH_TO_LEGACY_UNAME = {
39
47
  };
40
48
  /**
41
49
  * Arch spellings that name the same target, folded onto the NODE spelling.
42
- * Mirrors `ARCH_ALIASES` in `scripts/audit-runtimes.mjs` — that script
43
- * canonicalises `package.json#gjsify.platforms` the same way for the
44
- * platform-support matrix, and the two MUST agree or a package could pass the
45
- * audit while the CLI fails to find its artifact.
50
+ * Mirrors `ARCH_ALIASES` in `packages/infra/manifest-conformance/lib/platforms.mjs`
51
+ * — the table `scripts/audit-runtimes.mjs` canonicalises
52
+ * `package.json#gjsify.platforms` through for the platform-support matrix and the
53
+ * prebuild rules. The two MUST agree or a package could pass the audit while the
54
+ * CLI fails to find its artifact.
46
55
  *
47
56
  * The direction matters: `${process.platform}-${process.arch}` is what a
48
57
  * running process can compute about itself, so folding *onto* it means the
@@ -54,18 +63,104 @@ const ARCH_ALIASES = {
54
63
  aarch64: 'arm64',
55
64
  };
56
65
  /**
57
- * Canonical `<os>-<arch>` form — the node spelling — so `linux-x86_64` and
58
- * `linux-x64` compare equal. Only the *arch* half is normalised; the OS half
59
- * is always a `process.platform` token (`linux`/`darwin`/`win32`), which has
60
- * no competing spelling.
66
+ * The libc suffix a prebuild target may carry. Only ONE value is spelled out,
67
+ * and that asymmetry is the whole design of the grammar — see
68
+ * {@link canonicalPlatformToken}.
69
+ */
70
+ const MUSL_SUFFIX = '-musl';
71
+ /**
72
+ * Split a prebuild target into its three axes.
73
+ *
74
+ * TOKEN GRAMMAR: `<os>-<arch>[-musl]`.
75
+ *
76
+ * An UNSUFFIXED token means "the default build". Note what it does NOT mean: it
77
+ * is not a synonym for glibc. For an artifact that links `libc.so.6` the default
78
+ * build IS glibc; for one that links only GLib/GObject/GIO (plus GnuTLS resp.
79
+ * GStreamer) it is libc-AGNOSTIC and loads against whatever libc the host's GLib
80
+ * was built for. The directory name cannot express that difference and does not
81
+ * need to: the package's own `libc` field answers it at install time, and the
82
+ * `prebuild-libc` conformance rule verifies that field against the binaries
83
+ * (`readElfNeeded` — the DT_NEEDED list is where the answer actually lives).
84
+ *
85
+ * DELIBERATELY NOT CHOSEN: renaming every existing `linux-<arch>` directory to
86
+ * `linux-<arch>-gnu`, which is what a symmetric grammar would demand. It would
87
+ * rename ~60 COMMITTED directories, and — per `status/open-todos.md`'s
88
+ * "Nine fixtures re-implement the prebuild-target name instead of importing it"
89
+ * — nine e2e fixtures COMPOSE the token themselves instead of importing it, so
90
+ * the rename would have to be swept by hand in two shapes (the literal path and
91
+ * the computed one), which is exactly how the last vocabulary change missed
92
+ * `tests/e2e/self-host/run.mjs`. The asymmetric grammar buys the same
93
+ * expressiveness for zero renames, and the cost — that "unsuffixed" is not
94
+ * self-describing — is paid by a field that is machine-checked anyway.
95
+ *
96
+ * A `-musl` suffix is only meaningful when the OS half is `linux`: musl targets
97
+ * no other kernel, and npm's own `libc` field is documented as Linux-only. A
98
+ * `darwin-arm64-musl` token is therefore not a target with an odd name, it is a
99
+ * malformed one, and this returns `libc: null` for it so callers report the
100
+ * token rather than half-honouring it.
101
+ */
102
+ export function parsePlatformToken(token) {
103
+ const isMusl = token.endsWith(MUSL_SUFFIX);
104
+ const base = isMusl ? token.slice(0, -MUSL_SUFFIX.length) : token;
105
+ const dash = base.indexOf('-');
106
+ const os = dash < 0 ? base : base.slice(0, dash);
107
+ const arch = dash < 0 ? '' : base.slice(dash + 1);
108
+ return { os, arch, libc: isMusl && os === 'linux' ? 'musl' : null };
109
+ }
110
+ /**
111
+ * Canonical `<os>-<arch>[-musl]` form — the node spelling — so `linux-x86_64`
112
+ * and `linux-x64` compare equal. Only the *arch* half is normalised: the OS half
113
+ * is always a `process.platform` token (`linux`/`darwin`/`win32`), which has no
114
+ * competing spelling, and the libc half has exactly one spelling by
115
+ * construction.
116
+ *
117
+ * A `-musl` suffix on a non-Linux token is preserved verbatim rather than
118
+ * dropped. Silently canonicalising `darwin-arm64-musl` to `darwin-arm64` would
119
+ * make a malformed declaration compare EQUAL to a valid one, so the audit that
120
+ * rejects it would never see it — the same class of bug as folding the legacy
121
+ * uname spelling on a WRITE path.
61
122
  */
62
123
  export function canonicalPlatformToken(token) {
63
- const dash = token.indexOf('-');
64
- if (dash < 0)
124
+ const { os, arch, libc } = parsePlatformToken(token);
125
+ if (!arch)
65
126
  return token;
66
- const os = token.slice(0, dash);
67
- const arch = token.slice(dash + 1);
68
- return `${os}-${ARCH_ALIASES[arch] ?? arch}`;
127
+ const canonical = `${os}-${ARCH_ALIASES[arch] ?? arch}`;
128
+ if (libc === 'musl')
129
+ return `${canonical}${MUSL_SUFFIX}`;
130
+ // Not a Linux musl token, so `-musl` (if present at all) is not a libc
131
+ // suffix this grammar recognises — keep the token intact.
132
+ return token.endsWith(MUSL_SUFFIX) ? `${canonical}${MUSL_SUFFIX}` : canonical;
133
+ }
134
+ /**
135
+ * The target tokens that describe THIS host, most-specific first — the WRITE-side
136
+ * vocabulary.
137
+ *
138
+ * This is the single definition of the musl preference order, and three callers
139
+ * share it so it cannot drift: the directory probe ({@link prebuildDirCandidates}),
140
+ * the per-platform sibling package probe ({@link platformPackageName}), and
141
+ * `scripts/stage-prebuild.mjs`, which must stage a musl build into
142
+ * `linux-x64-musl/` and never into `linux-x64/`.
143
+ *
144
+ * On a musl host the suffixed token comes FIRST and the unsuffixed one stays as a
145
+ * fallback: an unsuffixed directory is the default build, which for the
146
+ * libc-agnostic bridges (they record no `libc.so.6` at all) genuinely does load
147
+ * on musl. On glibc the `-musl` token is not offered at all — a musl artifact
148
+ * cannot load against glibc, so probing for it could only ever produce a
149
+ * false positive.
150
+ *
151
+ * @param platform `process.platform` value.
152
+ * @param arch `process.arch` value.
153
+ * @param libc host C library; `undefined`/`null` behaves as glibc (see
154
+ * {@link HostTarget.libc} for why that is the safe default).
155
+ */
156
+ export function hostPlatformTokens(platform, arch, libc) {
157
+ const canonical = `${platform}-${arch}`;
158
+ // The suffix is Linux-only by construction, so a `libc: 'musl'` handed in
159
+ // for darwin/win32 is ignored rather than trusted — the caller's host facts
160
+ // do not get to invent a target the grammar does not have.
161
+ if (libc === 'musl' && platform === 'linux')
162
+ return [`${canonical}${MUSL_SUFFIX}`, canonical];
163
+ return [canonical];
69
164
  }
70
165
  /**
71
166
  * The prebuild directory names to probe for a host, most-specific first.
@@ -95,28 +190,46 @@ export function canonicalPlatformToken(token) {
95
190
  * listing — no extra I/O — so tolerance on the READ side costs nothing, while
96
191
  * the single spelling is enforced everywhere a name is WRITTEN.
97
192
  *
193
+ * The LIBC AXIS rides in front of all of that: on a musl host the
194
+ * `<os>-<arch>-musl` token is preferred over `<os>-<arch>`, and on glibc the
195
+ * suffixed token is NEVER offered (a musl artifact cannot load against glibc, so
196
+ * a probe for it can only produce a false positive). The `libc` value is a
197
+ * PARAMETER rather than a `process` read for the same reason `platform` and
198
+ * `arch` are: it is the only way the musl branch gets exercised at all, since CI
199
+ * and every developer machine here run glibc.
200
+ *
98
201
  * @param platform `process.platform` value.
99
202
  * @param arch `process.arch` value.
100
203
  * @param declaredPlatforms `package.json#gjsify.platforms`, when present.
204
+ * @param libc host C library; `undefined`/`null` = behave as before the axis
205
+ * existed.
101
206
  */
102
- export function prebuildDirCandidates(platform, arch, declaredPlatforms) {
103
- const canonical = `${platform}-${arch}`;
207
+ export function prebuildDirCandidates(platform, arch, declaredPlatforms, libc) {
208
+ const hostTokens = hostPlatformTokens(platform, arch, libc);
104
209
  const legacyArch = NODE_ARCH_TO_LEGACY_UNAME[arch] ?? arch;
105
210
  const out = [];
106
211
  const push = (name) => {
107
212
  if (!out.includes(name))
108
213
  out.push(name);
109
214
  };
110
- // 1. The package's own declared spelling for THIS host, if it declares one.
111
- for (const declared of declaredPlatforms ?? []) {
112
- if (typeof declared !== 'string')
113
- continue;
114
- if (canonicalPlatformToken(declared) === canonical)
115
- push(declared);
215
+ // 1. The package's own declared spelling for each host token, most-specific
216
+ // token first. Keyed off the SAME host-token list as step 2, so a musl
217
+ // host that finds `linux-x64-musl` in `gjsify.platforms` prefers it over
218
+ // a declared `linux-x64` — the declaration probe must not be able to
219
+ // reorder the libc preference.
220
+ for (const token of hostTokens) {
221
+ for (const declared of declaredPlatforms ?? []) {
222
+ if (typeof declared !== 'string')
223
+ continue;
224
+ if (canonicalPlatformToken(declared) === token)
225
+ push(declared);
226
+ }
227
+ // 2. The canonical node spelling — what everything in the train stages.
228
+ push(token);
116
229
  }
117
- // 2. The canonical node spelling — what everything in the train stages.
118
- push(canonical);
119
- // 3. The legacy uname spelling, for undeclared pre-rename tarballs.
230
+ // 3. The legacy uname spelling, for undeclared pre-rename tarballs. No musl
231
+ // variant: the rename predates the libc axis by years, so no tarball on
232
+ // npm can carry a uname-spelled musl directory.
120
233
  push(`${platform}-${legacyArch}`);
121
234
  return out;
122
235
  }
@@ -131,28 +244,59 @@ export function prebuildDirCandidates(platform, arch, declaredPlatforms) {
131
244
  * ships nothing loadable on this host.
132
245
  */
133
246
  export function resolvePrebuildDirName(input) {
134
- const { platform, arch, declaredPlatforms, existingDirs } = input;
135
- for (const candidate of prebuildDirCandidates(platform, arch, declaredPlatforms)) {
247
+ const { platform, arch, declaredPlatforms, existingDirs, libc } = input;
248
+ for (const candidate of prebuildDirCandidates(platform, arch, declaredPlatforms, libc)) {
136
249
  if (existingDirs.includes(candidate))
137
250
  return candidate;
138
251
  }
139
252
  return null;
140
253
  }
254
+ /**
255
+ * The npm name of the per-platform companion package for a target.
256
+ *
257
+ * `@gjsify/rolldown-native` + `linux-x64` → `@gjsify/rolldown-native-linux-x64`,
258
+ * the pattern `@gjsify/gtk-runtime-darwin-arm64` already ships (and the one
259
+ * napi-rs uses for its `<pkg>-<triple>` siblings, which
260
+ * `napi-node-addon.ts`'s `isNapiRsSibling` already knows).
261
+ *
262
+ * Exported so there is exactly ONE definition of it. The heavy bridges are being
263
+ * split into per-platform packages, and that rollout, the sibling resolution
264
+ * below and the workspace audit all need this string — three independent
265
+ * `` `${name}-${token}` `` template literals would be three places for the
266
+ * separator, the scope handling or the token spelling to drift, and a drifted
267
+ * copy fails as "prebuild not found" in a consumer while every test here stays
268
+ * green.
269
+ *
270
+ * @param baseName the depending package's npm name, scope included.
271
+ * @param token an `<os>-<arch>[-musl]` target.
272
+ */
273
+ export function platformPackageName(baseName, token) {
274
+ return `${baseName}-${token}`;
275
+ }
141
276
  /**
142
277
  * Scan all packages in a node_modules directory for gjsify native prebuilds.
143
278
  * Handles scoped packages (@scope/name) as well as flat packages.
144
279
  */
145
280
  function scanNodeModules(nodeModulesDir, target) {
146
- const results = [];
281
+ const resolved = [];
282
+ const declared = [];
147
283
  if (!existsSync(nodeModulesDir))
148
- return results;
284
+ return { resolved, declared };
149
285
  let entries;
150
286
  try {
151
287
  entries = readdirSync(nodeModulesDir);
152
288
  }
153
289
  catch {
154
- return results;
290
+ return { resolved, declared };
155
291
  }
292
+ const take = (probe) => {
293
+ if (!probe)
294
+ return;
295
+ if (probe.kind === 'resolved')
296
+ resolved.push(probe.pkg);
297
+ else
298
+ declared.push(probe.candidate);
299
+ };
156
300
  for (const entry of entries) {
157
301
  if (entry.startsWith('.'))
158
302
  continue;
@@ -168,19 +312,15 @@ function scanNodeModules(nodeModulesDir, target) {
168
312
  }
169
313
  for (const scopedPkg of scopeEntries) {
170
314
  const pkgDir = join(scopeDir, scopedPkg);
171
- const native = checkPackage(pkgDir, `${entry}/${scopedPkg}`, target);
172
- if (native)
173
- results.push(native);
315
+ take(checkPackage(pkgDir, `${entry}/${scopedPkg}`, target));
174
316
  }
175
317
  }
176
318
  else {
177
319
  const pkgDir = join(nodeModulesDir, entry);
178
- const native = checkPackage(pkgDir, entry, target);
179
- if (native)
180
- results.push(native);
320
+ take(checkPackage(pkgDir, entry, target));
181
321
  }
182
322
  }
183
- return results;
323
+ return { resolved, declared };
184
324
  }
185
325
  /** Check a single package directory for gjsify prebuilds metadata. */
186
326
  function checkPackage(pkgDir, name, target) {
@@ -208,17 +348,66 @@ function checkPackage(pkgDir, name, target) {
208
348
  .map((e) => e.name);
209
349
  }
210
350
  catch {
211
- return null;
351
+ // No `prebuilds/` tree at all. Still a DECLARED prebuild package — the
352
+ // per-platform-package layout puts the binaries in a sibling, so the
353
+ // absence of a local directory is now a normal state, not an absence of
354
+ // information.
355
+ return { kind: 'declared', candidate: { name, pkgDir } };
212
356
  }
213
357
  const dirName = resolvePrebuildDirName({
214
358
  platform: target.platform,
215
359
  arch: target.arch,
216
360
  declaredPlatforms,
217
361
  existingDirs,
362
+ libc: target.libc,
218
363
  });
219
364
  if (!dirName)
220
- return null;
221
- return { name, prebuildsDir: join(prebuildsRoot, dirName) };
365
+ return { kind: 'declared', candidate: { name, pkgDir } };
366
+ return { kind: 'resolved', pkg: { name, prebuildsDir: join(prebuildsRoot, dirName) } };
367
+ }
368
+ /**
369
+ * Find a declared package's prebuild in its PER-PLATFORM COMPANION PACKAGE.
370
+ *
371
+ * The heavy bridges are being split the way napi-rs and `@gjsify/gtk-runtime-*`
372
+ * already are: `@gjsify/rolldown-native` keeps the declaration and the JS facade,
373
+ * `@gjsify/rolldown-native-linux-x64` carries the binary. `detectNativePackages`
374
+ * finds such a companion in a HOISTED layout by accident — the up-walk from the
375
+ * start directory eventually lists it as just another `node_modules` entry — but
376
+ * NOT in an isolated one, where pnpm places it in the virtual store under the
377
+ * depending package and no ancestor of the CWD ever contains it.
378
+ *
379
+ * So the search starts at the DEPENDING PACKAGE's own directory instead of the
380
+ * caller's. That is not a second algorithm; it is the SAME up-walk with a
381
+ * different origin, and the first step of that walk — `<pkgDir>/node_modules/…` —
382
+ * is the isolated-layout case. It is also exactly what napi-rs achieves
383
+ * implicitly by calling `require()` from inside the package: Node's resolver
384
+ * walks up from the requiring FILE, not from the process CWD. We cannot use
385
+ * `require`/`import.meta.resolve` here because the companion package's `exports`
386
+ * map need not expose anything importable — the artifact is a directory, not a
387
+ * module — so the walk is done by hand.
388
+ *
389
+ * @returns the companion's own resolved prebuild directory, or null.
390
+ */
391
+ function resolvePlatformSibling(candidate, target) {
392
+ const tokens = hostPlatformTokens(target.platform, target.arch, target.libc);
393
+ let dir = resolve(candidate.pkgDir);
394
+ while (true) {
395
+ for (const token of tokens) {
396
+ const siblingName = platformPackageName(candidate.name, token);
397
+ const siblingDir = join(dir, 'node_modules', ...siblingName.split('/'));
398
+ const probe = checkPackage(siblingDir, siblingName, target);
399
+ // A companion package resolves through the ORDINARY path: it
400
+ // declares its own `gjsify.prebuilds` + the one target it exists
401
+ // for. Reusing `checkPackage` is what keeps the split layout from
402
+ // becoming a second contract nothing audits.
403
+ if (probe?.kind === 'resolved')
404
+ return probe.pkg;
405
+ }
406
+ const parent = resolve(dir, '..');
407
+ if (parent === dir)
408
+ return null; // reached filesystem root
409
+ dir = parent;
410
+ }
222
411
  }
223
412
  /**
224
413
  * Walk up the directory tree from `startDir` and merge native packages found
@@ -237,30 +426,117 @@ function checkPackage(pkgDir, name, target) {
237
426
  * production call sites take the defaults.
238
427
  */
239
428
  export function detectNativePackages(startDir, target = {}) {
240
- const resolved = {
429
+ const host = {
241
430
  platform: target.platform ?? process.platform,
242
431
  arch: target.arch ?? process.arch,
432
+ // The ONE place the host's libc is read, and only as a default — every
433
+ // function below takes it as a parameter so the musl branch is reachable
434
+ // from a glibc test host.
435
+ //
436
+ // `!== undefined` rather than `??`: an explicit `libc: null` means "no
437
+ // libc axis / do not classify", and `??` would treat it as absent and
438
+ // probe the host anyway — which is the one value a caller passes when it
439
+ // specifically does NOT want the host consulted.
440
+ libc: target.libc !== undefined ? target.libc : detectHostLibc(target.platform ?? process.platform),
243
441
  };
244
442
  const merged = [];
245
443
  const seen = new Set();
444
+ /** Declared-but-unresolved packages, first occurrence wins as above. */
445
+ const candidates = [];
246
446
  let dir = resolve(startDir);
247
447
  while (true) {
248
448
  const nodeModulesDir = join(dir, 'node_modules');
249
449
  if (existsSync(nodeModulesDir)) {
250
- for (const pkg of scanNodeModules(nodeModulesDir, resolved)) {
450
+ const found = scanNodeModules(nodeModulesDir, host);
451
+ for (const pkg of found.resolved) {
251
452
  if (seen.has(pkg.name))
252
453
  continue;
253
454
  seen.add(pkg.name);
254
455
  merged.push(pkg);
255
456
  }
457
+ for (const candidate of found.declared) {
458
+ if (candidates.some((c) => c.name === candidate.name))
459
+ continue;
460
+ candidates.push(candidate);
461
+ }
256
462
  }
257
463
  const parent = resolve(dir, '..');
258
464
  if (parent === dir)
259
465
  break; // reached filesystem root
260
466
  dir = parent;
261
467
  }
468
+ // Second pass: per-platform companion packages. Runs AFTER the whole walk so
469
+ // it can dedupe against everything the walk found — in a hoisted layout the
470
+ // companion is already in `merged` under its own name (the walk lists it like
471
+ // any other package), and adding it twice would put the same directory on
472
+ // `GI_TYPELIB_PATH` twice for no benefit. It also cannot ADD a name that a
473
+ // closer `node_modules` already resolved, which keeps Node's
474
+ // nearest-wins semantics intact.
475
+ for (const candidate of candidates) {
476
+ if (seen.has(candidate.name))
477
+ continue;
478
+ const sibling = resolvePlatformSibling(candidate, host);
479
+ if (!sibling || seen.has(sibling.name))
480
+ continue;
481
+ seen.add(sibling.name);
482
+ merged.push(sibling);
483
+ }
262
484
  return merged;
263
485
  }
486
+ /**
487
+ * Decide a host's C library from independently-gathered facts. PURE.
488
+ *
489
+ * Two probes, because neither is available everywhere this CLI runs:
490
+ *
491
+ * 1. `process.report.getReport().header.glibcVersionRuntime` — present iff the
492
+ * running process is linked against glibc. This is the probe `detect-libc`
493
+ * and node-gyp use, and it is authoritative when it answers. It is a
494
+ * NODE-only API: under the committed GJS bundle `@gjsify/process` has no
495
+ * `report`, so on the runtime this project targets FIRST it never answers.
496
+ * 2. Whether musl's dynamic loader is installed. musl always installs it as
497
+ * `/lib/ld-musl-<arch>.so.1`, which is a fact about the SYSTEM rather than
498
+ * about the running process, so it answers under GJS too.
499
+ *
500
+ * The order matters only for speed; the two cannot disagree on a sane host.
501
+ * When NEITHER answers we return `'glibc'`, and that is a claim about the
502
+ * evidence rather than a guess: probe 2 not finding a musl loader means the host
503
+ * has no musl at all, so `-musl` directories are not what it wants. The failure
504
+ * mode of the opposite default — treating an unclassified host as musl — is
505
+ * probing for suffixed directories on every glibc machine, which is noise with no
506
+ * upside.
507
+ *
508
+ * @param platform `process.platform` value; the axis is Linux-only (as npm's own
509
+ * `libc` field is), so every other OS returns null.
510
+ */
511
+ export function resolveHostLibc(input) {
512
+ if (input.platform !== 'linux')
513
+ return null;
514
+ if (typeof input.glibcVersionRuntime === 'string' && input.glibcVersionRuntime.length > 0)
515
+ return 'glibc';
516
+ return input.muslLoaderPresent ? 'musl' : 'glibc';
517
+ }
518
+ /**
519
+ * Gather the two host facts {@link resolveHostLibc} decides from.
520
+ *
521
+ * BOTH are gathered unconditionally, even though the first one alone usually
522
+ * answers. They are independent observations — one about the running process, one
523
+ * about the system — and a host can genuinely exhibit both (a glibc-linked Node
524
+ * on a distro that also installs musl, e.g. under gcompat). Collecting them
525
+ * separately keeps the decision itself in one pure function a test can pin,
526
+ * instead of encoding the precedence in an if/else chain here where nothing can
527
+ * reach it.
528
+ */
529
+ export function detectHostLibc(platform) {
530
+ if (platform !== 'linux')
531
+ return null;
532
+ const header = process.report?.getReport()?.header;
533
+ const glibcVersionRuntime = typeof header?.glibcVersionRuntime === 'string' ? header.glibcVersionRuntime : undefined;
534
+ // Read the directory rather than testing per-arch loader names: those names
535
+ // (`ld-musl-x86_64`, `ld-musl-aarch64`, …) are a THIRD arch vocabulary, and
536
+ // this file already documents why the repo keeps exactly one.
537
+ const muslLoaderPresent = existsSync('/lib') && readdirSync('/lib').some((f) => f.startsWith('ld-musl-'));
538
+ return resolveHostLibc({ platform, glibcVersionRuntime, muslLoaderPresent });
539
+ }
264
540
  /**
265
541
  * The environment-variable name the host's dynamic loader consults for an
266
542
  * additional shared-library search path, plus the separator its search paths
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Which kind of directory link this platform uses.
3
+ *
4
+ * Spelled with a NAME for the POSIX case rather than `undefined`, so both
5
+ * branches can be requested explicitly. `dirLinkTarget` takes this as a
6
+ * defaulted parameter, and a defaulted parameter cannot receive `undefined`:
7
+ * passing it IS how you ask for the default. So while POSIX was spelled
8
+ * `undefined`, `dirLinkTarget(link, target, undefined)` meant "use the host's
9
+ * kind" — the POSIX branch was unexercisable on Windows, and the win32 branch
10
+ * unexercisable anywhere else. The suite read as if it covered both and did
11
+ * not, which is precisely the shape the header above blames for the `'dir'`
12
+ * bug reaching a release.
13
+ */
14
+ export type DirLinkKind = 'junction' | 'symlink';
15
+ /** The link kind directory links use on this platform. */
16
+ export declare const DIR_LINK_KIND: DirLinkKind;
17
+ /**
18
+ * The link type a directory link must pass to `fs.symlink`.
19
+ *
20
+ * `undefined` on POSIX is deliberate: Node's default is correct there, and
21
+ * passing a type would be noise. Derived from {@link DIR_LINK_KIND} so the
22
+ * platform decision has exactly one source.
23
+ */
24
+ export declare const DIR_LINK_TYPE: 'junction' | undefined;
25
+ /** True when directory links on this platform are NTFS junctions. */
26
+ export declare const dirLinksAreJunctions: () => boolean;
27
+ /**
28
+ * Spell the `target` argument for a link at `linkPath` pointing at `absTarget`.
29
+ *
30
+ * Absolute on Windows because a junction demands it (gotcha 1 above); relative
31
+ * on POSIX so the tree stays movable (gotcha 2).
32
+ *
33
+ * `linkKind` is a PARAMETER, defaulting to the host's, for the same reason
34
+ * `detect-native-packages.ts` takes `platform`/`arch`: the Windows branch is
35
+ * then unit-testable from a Linux host, which is the only place anyone runs the
36
+ * tests. A branch nobody can exercise is how the `'dir'` bug survived to a
37
+ * release — and see {@link DirLinkKind} for why the POSIX side had to be named
38
+ * rather than spelled `undefined` before that promise actually held.
39
+ *
40
+ * BOTH paths must be in the SAME canonical space — either both canonical or
41
+ * both spelled through the same symlinks. The POSIX target is relative to the
42
+ * link's own directory, but the kernel resolves it from the link's REAL
43
+ * directory, so mixing the two spaces yields a link that points nowhere. macOS
44
+ * is where this actually happens: `os.tmpdir()` is `/var/folders/…` and `/var`
45
+ * is a symlink to `/private/var`, so a `linkPath` built from `tmpdir()` and an
46
+ * `absTarget` that has been through `realpathSync` disagree, and the relative
47
+ * path between them walks out of the real tree. `realpathSync` whichever side
48
+ * is not already canonical before calling this.
49
+ *
50
+ * @param linkPath where the link itself will live
51
+ * @param absTarget the directory it should point at, as an ABSOLUTE path
52
+ * @param linkKind override the host's link kind (tests only)
53
+ */
54
+ export declare function dirLinkTarget(linkPath: string, absTarget: string, linkKind?: DirLinkKind): string;
55
+ /**
56
+ * Link `linkPath` → `absTarget`, choosing symlink-vs-junction and the target
57
+ * spelling for the host.
58
+ *
59
+ * `absTarget` must be absolute; passing a relative path is the mistake gotcha 1
60
+ * describes, and it would only misbehave on Windows, so it is rejected here on
61
+ * every platform rather than left to surface on the one host nobody tests on.
62
+ */
63
+ export declare function linkDirSync(linkPath: string, absTarget: string): void;
64
+ /** Promise-returning twin of {@link linkDirSync}. */
65
+ export declare function linkDir(linkPath: string, absTarget: string): Promise<void>;
66
+ /**
67
+ * Recreate an EXISTING link at a new path, preserving what it points at.
68
+ *
69
+ * A different job from {@link linkDirSync}, and it cannot reuse it: the source
70
+ * link's target is whatever it already is — frequently relative, deliberately so
71
+ * — while a junction demands an absolute one. Copying the target verbatim is
72
+ * therefore right on POSIX and wrong on Windows the moment the target is a
73
+ * directory.
74
+ *
75
+ * So the target's KIND decides: a link to a directory becomes a junction with
76
+ * the target resolved against the source link's own directory (the resolution
77
+ * Node will not do for you — see gotcha 1 above); anything else stays a plain
78
+ * file symlink, which Windows allows unprivileged.
79
+ *
80
+ * A dangling source link keeps the verbatim target: it points nowhere either
81
+ * way, and guessing a kind for it would turn a faithful copy into a fabrication.
82
+ *
83
+ * Primitive fs ops only — this runs under the GJS facade as well as Node, which
84
+ * is why there is no `fs.cp`/`opendir` here.
85
+ */
86
+ export declare function replicateLinkSync(srcLink: string, dstLink: string): void;