@gjsify/cli 0.26.1 → 0.27.1

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 (58) hide show
  1. package/dist/cli.gjs.mjs +188 -183
  2. package/lib/bundler-pick.js +12 -4
  3. package/lib/cli-app.js +3 -1
  4. package/lib/commands/affected.js +1 -1
  5. package/lib/commands/build.js +21 -14
  6. package/lib/commands/check.js +40 -4
  7. package/lib/commands/clear.d.ts +8 -0
  8. package/lib/commands/clear.js +90 -0
  9. package/lib/commands/copy.d.ts +8 -0
  10. package/lib/commands/copy.js +87 -0
  11. package/lib/commands/dlx.js +8 -1
  12. package/lib/commands/index.d.ts +2 -0
  13. package/lib/commands/index.js +2 -0
  14. package/lib/commands/install.d.ts +4 -0
  15. package/lib/commands/install.js +164 -15
  16. package/lib/commands/onboard.js +17 -14
  17. package/lib/commands/pack.d.ts +32 -0
  18. package/lib/commands/pack.js +11 -2
  19. package/lib/commands/publish.js +18 -4
  20. package/lib/commands/storybook.js +5 -1
  21. package/lib/commands/test.js +29 -2
  22. package/lib/commands/uninstall.js +44 -2
  23. package/lib/commands/upgrade.js +1 -4
  24. package/lib/templates/install.mjs.tmpl +110 -19
  25. package/lib/utils/bin-shim.d.ts +73 -0
  26. package/lib/utils/bin-shim.js +97 -2
  27. package/lib/utils/build-cache.d.ts +7 -27
  28. package/lib/utils/build-cache.js +27 -2
  29. package/lib/utils/check-system-deps.d.ts +15 -1
  30. package/lib/utils/check-system-deps.js +126 -2
  31. package/lib/utils/clear-targets.d.ts +30 -0
  32. package/lib/utils/clear-targets.js +114 -0
  33. package/lib/utils/copy-targets.d.ts +41 -0
  34. package/lib/utils/copy-targets.js +145 -0
  35. package/lib/utils/detect-native-packages.d.ts +160 -5
  36. package/lib/utils/detect-native-packages.js +326 -50
  37. package/lib/utils/dir-link.d.ts +34 -6
  38. package/lib/utils/dir-link.js +23 -9
  39. package/lib/utils/install-backend-native.d.ts +109 -1
  40. package/lib/utils/install-backend-native.js +622 -59
  41. package/lib/utils/install-backend.js +17 -15
  42. package/lib/utils/install-global.js +6 -34
  43. package/lib/utils/install-lock.js +1 -1
  44. package/lib/utils/install-packument-cache.d.ts +36 -17
  45. package/lib/utils/install-packument-cache.js +59 -31
  46. package/lib/utils/oxc-resolve.d.ts +27 -1
  47. package/lib/utils/oxc-resolve.js +53 -26
  48. package/lib/utils/pkg-json.d.ts +24 -0
  49. package/lib/utils/pkg-json.js +27 -1
  50. package/lib/utils/platform-check.d.ts +215 -0
  51. package/lib/utils/platform-check.js +377 -0
  52. package/lib/utils/resolve-npm-package.js +9 -2
  53. package/lib/utils/run-gjs.d.ts +1 -1
  54. package/lib/utils/run-gjs.js +19 -3
  55. package/lib/utils/spawn.js +50 -2
  56. package/lib/utils/win32-command.d.ts +54 -0
  57. package/lib/utils/win32-command.js +140 -0
  58. package/package.json +19 -20
@@ -12,9 +12,9 @@
12
12
  //
13
13
  // `--no-package-lock` keeps the cache prepare dir hermetic; the cache key
14
14
  // already covers reproducibility. `--no-audit --no-fund` cuts ~5s off cold runs.
15
- import { spawn } from 'node:child_process';
16
15
  import { writeFileSync } from 'node:fs';
17
16
  import { join } from 'node:path';
17
+ import { spawnToCompletion } from './spawn.js';
18
18
  export { makeProgressReporter } from './install-progress.js';
19
19
  const DEFAULT_BACKEND = process.env.GJSIFY_INSTALL_BACKEND ?? 'native';
20
20
  export async function installPackages(opts) {
@@ -46,19 +46,21 @@ async function installViaNpm({ prefix, specs, verbose, registry }) {
46
46
  ...(verbose ? ['--loglevel', 'verbose'] : ['--loglevel', 'warn']),
47
47
  ...specs,
48
48
  ];
49
- await new Promise((resolve, reject) => {
50
- const child = spawn('npm', args, { stdio: 'inherit' });
51
- child.on('close', (code) => {
52
- if (code === 0)
53
- resolve();
54
- else
55
- reject(new Error(`npm install exited with code ${code}`));
56
- });
57
- child.on('error', (err) => {
58
- const msg = err.code === 'ENOENT'
59
- ? 'npm not found on PATH — install Node.js or set GJSIFY_INSTALL_BACKEND=native (not yet supported)'
60
- : `npm install failed: ${err.message}`;
61
- reject(new Error(msg));
62
- });
49
+ // Through `spawnToCompletion` so `npm` is resolved the way every other
50
+ // spawn in the CLI resolves it. A bare `spawn('npm', …)` is ENOENT on
51
+ // Windows — `CreateProcess` appends only `.exe` and npm ships a `.cmd`
52
+ // shim (see utils/win32-command.ts). That made this backend unusable
53
+ // there, and the ENOENT hint below actively misdiagnosed it: npm IS on
54
+ // PATH on such a host, and it told the user to select the backend that
55
+ // was already the default while calling it unsupported.
56
+ //
57
+ // `completion: 'return'` — `installPackages` resolves a promise to its
58
+ // caller instead of exiting, so under GJS this must not leave a main loop
59
+ // armed. (Moot in practice: this backend needs npm, hence Node.)
60
+ const { code } = await spawnToCompletion('npm', args, {
61
+ completion: 'return',
62
+ notFound: (err) => new Error(`npm not found on PATH — install Node.js, or unset GJSIFY_INSTALL_BACKEND to use the default native backend (${err.message})`),
63
63
  });
64
+ if (code !== 0)
65
+ throw new Error(`npm install exited with code ${code}`);
64
66
  }
@@ -24,7 +24,7 @@ import * as fs from 'node:fs';
24
24
  import * as os from 'node:os';
25
25
  import * as path from 'node:path';
26
26
  import { detectNativePackages } from './detect-native-packages.js';
27
- import { buildLauncherShims, buildNativeEnvPreamble, parseShebang } from './bin-shim.js';
27
+ import { buildLauncherShims, buildNativeEnvPreamble, buildShLauncher, isGjsBundlePath, parseShebang, pickBinMap, } from './bin-shim.js';
28
28
  /**
29
29
  * Compute the canonical global install layout for the current user. Honours
30
30
  * `XDG_DATA_HOME` (per the XDG Base Directory Spec) plus
@@ -168,10 +168,10 @@ export function linkGlobalBins(packageNames, layout) {
168
168
  if (!fs.existsSync(pkgJsonPath))
169
169
  continue;
170
170
  const pkgJson = readJson(pkgJsonPath);
171
- const binMap = pickBinMap(pkgName, pkgJson);
172
- if (!binMap || binMap.size === 0)
171
+ const picked = pickBinMap(pkgName, pkgJson);
172
+ if (picked === null || picked.map.size === 0)
173
173
  continue;
174
- for (const [binName, binTarget] of binMap) {
174
+ for (const [binName, binTarget] of picked.map) {
175
175
  const targetAbs = path.join(pkgDir, binTarget);
176
176
  if (!fs.existsSync(targetAbs))
177
177
  continue;
@@ -197,14 +197,11 @@ export function linkGlobalBins(packageNames, layout) {
197
197
  // exec'ing a shebang-less .mjs file falls back to /bin/sh which
198
198
  // then tries to parse JavaScript as shell. Plain Node scripts
199
199
  // with shebangs (lib/index.js) keep the direct-exec path.
200
- const isGjsBundle = targetAbs.endsWith('.gjs.mjs') || targetAbs.endsWith('.mjs');
200
+ const isGjsBundle = isGjsBundlePath(targetAbs);
201
201
  // Only GJS bundles need the GI typelib search path. Plain Node
202
202
  // scripts ignore GI_TYPELIB_PATH, so skipping the preamble there
203
203
  // keeps the launcher minimal.
204
- const launcher = isGjsBundle
205
- ? `#!/bin/sh\n${envPreamble}exec gjs -m ${shQuote(targetAbs)} "$@"\n`
206
- : `#!/bin/sh\nexec ${shQuote(targetAbs)} "$@"\n`;
207
- fs.writeFileSync(linkPath, launcher);
204
+ fs.writeFileSync(linkPath, buildShLauncher(targetAbs, { envPreamble, isGjsBundle }));
208
205
  fs.chmodSync(linkPath, 0o755);
209
206
  // Windows runs neither an extension-less file nor a `#!` line, so
210
207
  // the `sh` launcher above is only reachable from git-bash/MSYS/WSL.
@@ -241,9 +238,6 @@ export function linkGlobalBins(packageNames, layout) {
241
238
  }
242
239
  return created;
243
240
  }
244
- function shQuote(s) {
245
- return `'${s.replace(/'/g, `'\\''`)}'`;
246
- }
247
241
  /** Parse a bin file's `#!` line; `null` when unreadable or not a shebang. */
248
242
  function readShebangOf(file) {
249
243
  try {
@@ -254,28 +248,6 @@ function readShebangOf(file) {
254
248
  return null;
255
249
  }
256
250
  }
257
- function pickBinMap(pkgName, pkgJson) {
258
- const gjsifyEntry = pkgJson.gjsify;
259
- if (gjsifyEntry?.bin !== undefined) {
260
- return normalizeBin(pkgName, gjsifyEntry.bin);
261
- }
262
- const npmBin = pkgJson.bin;
263
- if (npmBin !== undefined) {
264
- return normalizeBin(pkgName, npmBin);
265
- }
266
- return null;
267
- }
268
- function normalizeBin(pkgName, bin) {
269
- const out = new Map();
270
- if (typeof bin === 'string') {
271
- const baseName = pkgName.startsWith('@') ? pkgName.slice(pkgName.indexOf('/') + 1) : pkgName;
272
- out.set(baseName, bin);
273
- return out;
274
- }
275
- for (const [k, v] of Object.entries(bin))
276
- out.set(k, v);
277
- return out;
278
- }
279
251
  function readJson(file) {
280
252
  return JSON.parse(fs.readFileSync(file, 'utf-8'));
281
253
  }
@@ -43,7 +43,7 @@
43
43
  // Escape hatch: GJSIFY_INSTALL_LOCK=0 disables locking entirely (e.g. for a
44
44
  // network filesystem with broken rename/mkdir atomicity, or an outer tool
45
45
  // that already serializes installs).
46
- import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
46
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
47
47
  import { dirname, join, resolve } from 'node:path';
48
48
  const LOCK_DIR_NAME = '.gjsify-install-lock';
49
49
  const OWNER_FILE = 'owner.json';
@@ -1,11 +1,18 @@
1
1
  import type { Packument } from '@gjsify/npm-registry';
2
+ /**
3
+ * Which document shape an entry holds. `'corgi'` = the abbreviated
4
+ * `application/vnd.npm.install-v1+json` install document (the default for every
5
+ * resolve); `'full'` = `application/json`. Same names pacote uses, so the two
6
+ * caches are describable in one sentence.
7
+ */
8
+ export type PackumentShape = 'corgi' | 'full';
2
9
  export interface CachedPackument {
3
10
  etag: string;
4
11
  packument: Packument;
5
12
  }
6
13
  /**
7
- * The cache key for a (registry, name) pair — exported so the filename rule
8
- * below is a PROPERTY a test can assert, not a claim in a comment.
14
+ * The cache key for a (shape, registry, name) triple — exported so the filename
15
+ * rule below is a PROPERTY a test can assert, not a claim in a comment.
9
16
  *
10
17
  * THE SEPARATOR IS NOT COSMETIC. It used to be `|`, producing names like
11
18
  * `corgi|https%3A%2F%2Fregistry.npmjs.org%2F|%40gjsify%2Fnode-gi.json` — and `|`
@@ -17,25 +24,37 @@ export interface CachedPackument {
17
24
  * entire reason this file exists never engaged once.
18
25
  *
19
26
  * `,` is legal on Win32 AND escaped by `encodeURIComponent`, which is what keeps
20
- * a flat two-part name injective: an encoded part cannot contain the separator,
21
- * so exactly one `(registry, name)` pair maps to any given filename.
27
+ * a flat three-part name injective: an encoded part cannot contain the
28
+ * separator, so exactly one triple maps to any given filename. The shape needs
29
+ * no encoding — it is one of two literals, neither containing the separator —
30
+ * and it LEADS so that listing a shard groups by shape.
22
31
  *
23
32
  * The shard key and the filename are derived from the SAME encoded parts. They
24
- * used to disagree — the key hashed the raw pair while the name used the encoded
25
- * one — which is how a reserved character in the name survived review of a
26
- * function whose other half looked correct.
33
+ * used to disagree — the key hashed the raw values while the name used the
34
+ * encoded ones — which is how a reserved character in the name survived review
35
+ * of a function whose other half looked correct.
27
36
  */
28
- export declare function packumentCacheKey(registry: string, name: string): string;
37
+ export declare function packumentCacheKey(registry: string, name: string, shape: PackumentShape): string;
29
38
  /**
30
- * Read a cached packument + its ETag for `(registry, name)`. Returns `null` on
31
- * a miss, when the cache is disabled, or when the entry is unreadable/corrupt
32
- * (treated as a miss — the caller just re-fetches).
39
+ * Read a cached packument + its ETag for `(registry, name)` in the given
40
+ * document `shape`. Returns `null` on a miss, when the cache is disabled, or
41
+ * when the entry is unreadable/corrupt (treated as a miss — the caller just
42
+ * re-fetches).
43
+ *
44
+ * `shape` defaults to `'corgi'` because that is what a resolve fetches and what
45
+ * every caller predating the full-document escalation meant. A caller that
46
+ * escalated MUST pass `'full'` — reading the corgi entry would return a body
47
+ * with no `libc`, i.e. the exact wrong answer the shape key exists to prevent.
33
48
  */
34
- export declare function getCachedPackument(registry: string, name: string): CachedPackument | null;
49
+ export declare function getCachedPackument(registry: string, name: string, shape?: PackumentShape): CachedPackument | null;
35
50
  /**
36
- * Persist a packument + ETag for `(registry, name)`. Writes to a `<path>.tmp.<pid>`
37
- * sibling then atomically renames so a concurrent reader never sees a partial
38
- * write. No-op when the cache is disabled, the ETag is empty, or the write
39
- * fails (read-only / out-of-disk cache volume must not break the install).
51
+ * Persist a packument + ETag for `(registry, name)` under its document `shape`.
52
+ * Writes to a `<path>.tmp.<pid>` sibling then atomically renames so a concurrent
53
+ * reader never sees a partial write. No-op when the cache is disabled, the ETag
54
+ * is empty, or the write fails (read-only / out-of-disk cache volume must not
55
+ * break the install).
56
+ *
57
+ * `shape` is last and defaults to `'corgi'` to keep the pre-escalation call
58
+ * shape valid; it must match the `accept` header the body was fetched with.
40
59
  */
41
- export declare function putCachedPackument(registry: string, name: string, etag: string, packument: Packument): void;
60
+ export declare function putCachedPackument(registry: string, name: string, etag: string, packument: Packument, shape?: PackumentShape): void;
@@ -13,22 +13,37 @@
13
13
  //
14
14
  // Layout:
15
15
  //
16
- // $XDG_CACHE_HOME/gjsify/metadata/v2/<shard>/<encoded-registry>,<encoded-name>.json
16
+ // $XDG_CACHE_HOME/gjsify/metadata/v3/<shard>/<shape>,<encoded-registry>,<encoded-name>.json
17
17
  //
18
- // The cache is keyed by (registry, name) — NOT name alone — so switching the
19
- // registry for a scope can never serve a packument from the wrong source on a
20
- // coincidental ETag match. `<shard>` is a 2-hex FNV-1a digest of the key, a
18
+ // The cache is keyed by (SHAPE, registry, name) — NOT name alone — so switching
19
+ // the registry for a scope can never serve a packument from the wrong source on
20
+ // a coincidental ETag match. `<shard>` is a 2-hex FNV-1a digest of the key, a
21
21
  // directory-fan-out step so the leaf dir never grows unbounded (same rationale
22
22
  // as the tarball cache's hex sharding). `v2` is a layout version.
23
23
  //
24
+ // WHY THE SHAPE IS PART OF THE KEY (the v1 → v2 bump): the same packument URL
25
+ // serves TWO different documents — the abbreviated "corgi" install document and
26
+ // the full one — and only the full one carries a version's `libc` field (see
27
+ // `@gjsify/npm-registry` `FetchOptions.fullMetadata`). With one key per (registry,
28
+ // name), an escalated read would be answered by whichever shape happened to be
29
+ // cached first: a musl-only package would come back `libc`-less, be judged
30
+ // compatible on a glibc host, and get installed — a WRONG ANSWER that looks
31
+ // exactly like a cache hit, with no failure until something dlopens the binary.
32
+ // pacote prefixes its own cache key with `full:`/`corgi:` for precisely this
33
+ // reason. The layout bump (rather than just a longer filename) makes the v1
34
+ // entries — written before the distinction existed and therefore of unknown
35
+ // shape — unreachable instead of ambiguous.
36
+ //
24
37
  // Disabled with `GJSIFY_PACKUMENT_CACHE=0` (or `false`). Honours
25
38
  // `XDG_CACHE_HOME` like the tarball + dlx caches.
26
39
  import { join } from 'node:path';
27
40
  import { atomicWrite, gjsifyCacheRoot, readCacheFile } from './install-cache-fs.js';
28
- // v2: the separator changed from the Win32-reserved `|` to `,` (see `pathFor`),
29
- // so every v1 entry is unreachable rather than merely stale — a new root retires
30
- // them instead of leaving orphans under the old one.
31
- const CACHE_LAYOUT_VERSION = 'v2';
41
+ // v3, and BOTH halves of the bump matter. v2 moved the separator off the
42
+ // Win32-reserved `|` (see `packumentCacheKey`); v3 additionally makes the
43
+ // document SHAPE part of the key. Either change alone makes older entries
44
+ // unreachable rather than merely stale, so a new root retires them instead of
45
+ // leaving ambiguous orphans under the old one.
46
+ const CACHE_LAYOUT_VERSION = 'v3';
32
47
  /** `true` unless `GJSIFY_PACKUMENT_CACHE` is `0` / `false` / empty. */
33
48
  function isEnabled() {
34
49
  const flag = process.env.GJSIFY_PACKUMENT_CACHE;
@@ -37,7 +52,7 @@ function isEnabled() {
37
52
  const trimmed = flag.trim();
38
53
  return !(trimmed === '0' || trimmed === 'false' || trimmed === '');
39
54
  }
40
- /** Root of the packument cache: `$XDG_CACHE_HOME/gjsify/metadata/v2`. */
55
+ /** Root of the packument cache: `$XDG_CACHE_HOME/gjsify/metadata/v3`. */
41
56
  function cacheRoot() {
42
57
  return gjsifyCacheRoot('metadata', CACHE_LAYOUT_VERSION);
43
58
  }
@@ -62,8 +77,8 @@ function encodePart(part) {
62
77
  return encodeURIComponent(part).replace(/\*/g, '%2A');
63
78
  }
64
79
  /**
65
- * The cache key for a (registry, name) pair — exported so the filename rule
66
- * below is a PROPERTY a test can assert, not a claim in a comment.
80
+ * The cache key for a (shape, registry, name) triple — exported so the filename
81
+ * rule below is a PROPERTY a test can assert, not a claim in a comment.
67
82
  *
68
83
  * THE SEPARATOR IS NOT COSMETIC. It used to be `|`, producing names like
69
84
  * `corgi|https%3A%2F%2Fregistry.npmjs.org%2F|%40gjsify%2Fnode-gi.json` — and `|`
@@ -75,30 +90,39 @@ function encodePart(part) {
75
90
  * entire reason this file exists never engaged once.
76
91
  *
77
92
  * `,` is legal on Win32 AND escaped by `encodeURIComponent`, which is what keeps
78
- * a flat two-part name injective: an encoded part cannot contain the separator,
79
- * so exactly one `(registry, name)` pair maps to any given filename.
93
+ * a flat three-part name injective: an encoded part cannot contain the
94
+ * separator, so exactly one triple maps to any given filename. The shape needs
95
+ * no encoding — it is one of two literals, neither containing the separator —
96
+ * and it LEADS so that listing a shard groups by shape.
80
97
  *
81
98
  * The shard key and the filename are derived from the SAME encoded parts. They
82
- * used to disagree — the key hashed the raw pair while the name used the encoded
83
- * one — which is how a reserved character in the name survived review of a
84
- * function whose other half looked correct.
99
+ * used to disagree — the key hashed the raw values while the name used the
100
+ * encoded ones — which is how a reserved character in the name survived review
101
+ * of a function whose other half looked correct.
85
102
  */
86
- export function packumentCacheKey(registry, name) {
87
- return `${encodePart(registry)},${encodePart(name)}`;
103
+ export function packumentCacheKey(registry, name, shape) {
104
+ return `${shape},${encodePart(registry)},${encodePart(name)}`;
88
105
  }
89
- function pathFor(registry, name) {
106
+ /** Filesystem path for a (shape, registry, name) triple, or `null` when disabled. */
107
+ function pathFor(registry, name, shape) {
90
108
  if (!isEnabled())
91
109
  return null;
92
- const key = packumentCacheKey(registry, name);
110
+ const key = packumentCacheKey(registry, name, shape);
93
111
  return join(cacheRoot(), shardFor(key), `${key}.json`);
94
112
  }
95
113
  /**
96
- * Read a cached packument + its ETag for `(registry, name)`. Returns `null` on
97
- * a miss, when the cache is disabled, or when the entry is unreadable/corrupt
98
- * (treated as a miss — the caller just re-fetches).
114
+ * Read a cached packument + its ETag for `(registry, name)` in the given
115
+ * document `shape`. Returns `null` on a miss, when the cache is disabled, or
116
+ * when the entry is unreadable/corrupt (treated as a miss — the caller just
117
+ * re-fetches).
118
+ *
119
+ * `shape` defaults to `'corgi'` because that is what a resolve fetches and what
120
+ * every caller predating the full-document escalation meant. A caller that
121
+ * escalated MUST pass `'full'` — reading the corgi entry would return a body
122
+ * with no `libc`, i.e. the exact wrong answer the shape key exists to prevent.
99
123
  */
100
- export function getCachedPackument(registry, name) {
101
- const path = pathFor(registry, name);
124
+ export function getCachedPackument(registry, name, shape = 'corgi') {
125
+ const path = pathFor(registry, name, shape);
102
126
  if (!path)
103
127
  return null;
104
128
  const buf = readCacheFile(path);
@@ -117,15 +141,19 @@ export function getCachedPackument(registry, name) {
117
141
  }
118
142
  }
119
143
  /**
120
- * Persist a packument + ETag for `(registry, name)`. Writes to a `<path>.tmp.<pid>`
121
- * sibling then atomically renames so a concurrent reader never sees a partial
122
- * write. No-op when the cache is disabled, the ETag is empty, or the write
123
- * fails (read-only / out-of-disk cache volume must not break the install).
144
+ * Persist a packument + ETag for `(registry, name)` under its document `shape`.
145
+ * Writes to a `<path>.tmp.<pid>` sibling then atomically renames so a concurrent
146
+ * reader never sees a partial write. No-op when the cache is disabled, the ETag
147
+ * is empty, or the write fails (read-only / out-of-disk cache volume must not
148
+ * break the install).
149
+ *
150
+ * `shape` is last and defaults to `'corgi'` to keep the pre-escalation call
151
+ * shape valid; it must match the `accept` header the body was fetched with.
124
152
  */
125
- export function putCachedPackument(registry, name, etag, packument) {
153
+ export function putCachedPackument(registry, name, etag, packument, shape = 'corgi') {
126
154
  if (!etag)
127
155
  return;
128
- const path = pathFor(registry, name);
156
+ const path = pathFor(registry, name, shape);
129
157
  if (!path)
130
158
  return;
131
159
  const entry = { etag, packument };
@@ -9,7 +9,33 @@ export declare function findOxcLauncher(tool: OxcTool, cwd?: string): string;
9
9
  export declare class OxcNotFoundError extends Error {
10
10
  tool: OxcTool;
11
11
  cwd: string;
12
- constructor(tool: OxcTool, cwd: string);
12
+ /**
13
+ * The interpreter that could not be executed, when the LAUNCHER resolved
14
+ * and the spawn still failed with `ENOENT`.
15
+ *
16
+ * Then the missing file is the interpreter, not the tool, and saying
17
+ * "install <tool>" sends the user in a circle: on a Node-less GJS host
18
+ * (postmarketOS/aarch64, gjs present, no node) `gjsify install -D oxlint`
19
+ * succeeds, the launcher lands on disk, and the next run repeats the very
20
+ * same advice. `spawnToCompletion`'s `notFound` fires for the COMMAND, and
21
+ * the command here is `node` — never the launcher, which
22
+ * {@link findOxcLauncher} already proved exists by not throwing.
23
+ */
24
+ missingInterpreter?: string | undefined;
25
+ constructor(tool: OxcTool, cwd: string,
26
+ /**
27
+ * The interpreter that could not be executed, when the LAUNCHER resolved
28
+ * and the spawn still failed with `ENOENT`.
29
+ *
30
+ * Then the missing file is the interpreter, not the tool, and saying
31
+ * "install <tool>" sends the user in a circle: on a Node-less GJS host
32
+ * (postmarketOS/aarch64, gjs present, no node) `gjsify install -D oxlint`
33
+ * succeeds, the launcher lands on disk, and the next run repeats the very
34
+ * same advice. `spawnToCompletion`'s `notFound` fires for the COMMAND, and
35
+ * the command here is `node` — never the launcher, which
36
+ * {@link findOxcLauncher} already proved exists by not throwing.
37
+ */
38
+ missingInterpreter?: string | undefined);
13
39
  }
14
40
  /**
15
41
  * Walk up from a starting directory to find the nearest oxlint config
@@ -46,10 +46,11 @@
46
46
  // lives in the Node launcher, so a native oxlint bridge could only run the
47
47
  // Rust rule subset. Lint stays Node-spawned until that trade-off is decided
48
48
  // (tracked in status/open-todos.md).
49
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
49
+ import { existsSync, readFileSync } from 'node:fs';
50
50
  import { join, resolve } from 'node:path';
51
51
  import { createRequire } from 'node:module';
52
52
  import { pathToFileURL } from 'node:url';
53
+ import { hostLibc } from './platform-check.js';
53
54
  import { findWorkspaceRoot } from './workspace-root.js';
54
55
  import { resolveNpmPackage } from './resolve-npm-package.js';
55
56
  import { nodeBinary } from './run-node.js';
@@ -85,24 +86,23 @@ function oxcBindingSuffix() {
85
86
  throw new Error(`[gjsify oxc] Unsupported arch on ${plat}: ${arch}`);
86
87
  if (plat === 'linux') {
87
88
  // musl detection on Linux — oxc ships separate musl/gnu bindings.
88
- // Same approach as biome's launcher: probe for the musl loader.
89
- // glibc systems have `/lib/ld-linux-*`, musl has `/lib/ld-musl-*`.
90
- let libc = 'gnu';
91
- try {
92
- // `readdirSync` is imported STATICALLY at the top of this file. It
93
- // used to be a lazy `require('node:fs')` inside this try — a bare
94
- // `require` in an ESM module, so it threw ReferenceError, the catch
95
- // below swallowed it, and `libc` silently stayed 'gnu': a musl host
96
- // would be told to install the WRONG binding package. Same defect
97
- // class as commands/affected.ts, and even quieter.
98
- const libEntries = readdirSync('/lib');
99
- if (libEntries.some((e) => e.startsWith('ld-musl-'))) {
100
- libc = 'musl';
101
- }
102
- }
103
- catch {
104
- // /lib unreadable — fall through, glibc is the safer default
105
- }
89
+ //
90
+ // ONE DEFINITION, in `platform-check.ts`. This used to be a local
91
+ // `readdirSync('/lib')` scan for `ld-musl-*`, which is the same question
92
+ // the install backend asks about a package's `libc` field — and the local
93
+ // copy is the one that broke: its `readdirSync` was a lazy
94
+ // `require('node:fs')`, a bare `require` in an ESM module, so it threw
95
+ // ReferenceError, the surrounding catch swallowed it, and every host was
96
+ // silently told to install the `-gnu` binding. `hostLibc()` is the shared
97
+ // probe (ldd text → process.report → loader scan), memoized and unit-
98
+ // tested with injected hosts, so a musl box gets the right answer here
99
+ // and in the resolver from the same code.
100
+ //
101
+ // Vocabulary hop: npm spells the families `glibc`/`musl`, napi-rs target
102
+ // triples spell them `gnu`/`musl`. An UNKNOWN libc maps to `gnu` — this
103
+ // string only names a package in an install HINT, and gnu is the right
104
+ // guess for the overwhelming majority of hosts we cannot probe.
105
+ const libc = hostLibc('linux') === 'musl' ? 'musl' : 'gnu';
106
106
  return `${plat}-${a}-${libc}`;
107
107
  }
108
108
  if (plat === 'win32')
@@ -158,7 +158,21 @@ export function findOxcLauncher(tool, cwd = process.cwd()) {
158
158
  export class OxcNotFoundError extends Error {
159
159
  tool;
160
160
  cwd;
161
- constructor(tool, cwd) {
161
+ missingInterpreter;
162
+ constructor(tool, cwd,
163
+ /**
164
+ * The interpreter that could not be executed, when the LAUNCHER resolved
165
+ * and the spawn still failed with `ENOENT`.
166
+ *
167
+ * Then the missing file is the interpreter, not the tool, and saying
168
+ * "install <tool>" sends the user in a circle: on a Node-less GJS host
169
+ * (postmarketOS/aarch64, gjs present, no node) `gjsify install -D oxlint`
170
+ * succeeds, the launcher lands on disk, and the next run repeats the very
171
+ * same advice. `spawnToCompletion`'s `notFound` fires for the COMMAND, and
172
+ * the command here is `node` — never the launcher, which
173
+ * {@link findOxcLauncher} already proved exists by not throwing.
174
+ */
175
+ missingInterpreter) {
162
176
  const binding = (() => {
163
177
  try {
164
178
  return bindingPackageName(tool);
@@ -167,13 +181,24 @@ export class OxcNotFoundError extends Error {
167
181
  return `@${tool === 'oxlint' ? 'oxlint' : 'oxfmt'}/binding-<platform>`;
168
182
  }
169
183
  })();
170
- super(`[gjsify oxc] ${tool} not found.\n` +
171
- ` Expected: ${tool}/bin/${tool} in node_modules of ${cwd} or any workspace root above it.\n` +
172
- ` Install it via: gjsify install -D ${tool}\n` +
173
- ` (this adds ${tool} to devDependencies; the matching ${binding} ` +
174
- `napi binding lands automatically as an optionalDependency.)`);
184
+ super(missingInterpreter === undefined
185
+ ? `[gjsify oxc] ${tool} not found.\n` +
186
+ ` Expected: ${tool}/bin/${tool} in node_modules of ${cwd} or any workspace root above it.\n` +
187
+ ` Install it via: gjsify install -D ${tool}\n` +
188
+ ` (this adds ${tool} to devDependencies; the matching ${binding} ` +
189
+ `napi binding lands automatically as an optionalDependency.)`
190
+ : `[gjsify oxc] cannot run ${tool}: no \`${missingInterpreter}\` binary on PATH.\n` +
191
+ ` ${tool} IS installed — its launcher was found. What is missing is the\n` +
192
+ ` interpreter that runs it: ${tool}'s launcher is a Node ESM script.\n` +
193
+ (tool === 'oxfmt'
194
+ ? ` For a Node-free formatter install the GI bridge:\n` +
195
+ ` gjsify install -D @gjsify/oxfmt-native\n` +
196
+ ` (\`gjsify format\`/\`fix\` then run oxfmt in-process under GJS.)`
197
+ : ` oxlint has no native GJS bridge yet — its JS-plugin host lives in the\n` +
198
+ ` Node launcher — so \`gjsify lint\` needs Node on the host for now.`));
175
199
  this.tool = tool;
176
200
  this.cwd = cwd;
201
+ this.missingInterpreter = missingInterpreter;
177
202
  this.name = 'OxcNotFoundError';
178
203
  }
179
204
  }
@@ -322,7 +347,9 @@ function spawnOxcLauncher(tool, args, opts = {}) {
322
347
  return spawnToCompletion(node, [launcher, ...args], {
323
348
  completion: 'return',
324
349
  cwd,
325
- notFound: () => new OxcNotFoundError(tool, cwd),
350
+ // ENOENT here is the COMMAND — `node` — not the launcher: `findOxcLauncher`
351
+ // above already proved that exists by not throwing.
352
+ notFound: () => new OxcNotFoundError(tool, cwd, node),
326
353
  }).then(({ code, signal }) => {
327
354
  if (signal !== null) {
328
355
  console.error(`[gjsify oxc] ${tool} terminated by signal ${signal}`);
@@ -1,6 +1,30 @@
1
1
  import type { PackageJson } from './pkg-json-edit.js';
2
+ /**
3
+ * Drop a leading UTF-8 BOM (`U+FEFF`, the decoded form of `EF BB BF`).
4
+ *
5
+ * `JSON.parse` rejects a BOM — it is not whitespace — so a manifest carrying
6
+ * one is "invalid JSON" to every reader here. That is not a hypothetical file
7
+ * shape on Windows: Windows PowerShell 5.1 writes a BOM for `-Encoding utf8`,
8
+ * which is its DEFAULT for `Out-File`/`Set-Content`, so any script that edits a
9
+ * manifest produces one. Notepad offers "UTF-8 with BOM" too.
10
+ *
11
+ * npm reads such a file without complaint (`npm pkg get name` on a BOM'd
12
+ * manifest returns the name), so the file is valid as far as the ecosystem is
13
+ * concerned and gjsify was the strict one. Stripping matches npm, VS Code and
14
+ * `parse-json`'s own guidance; nothing downstream wants the character.
15
+ *
16
+ * Only a LEADING BOM: `U+FEFF` elsewhere is a zero-width no-break space inside
17
+ * a string value, and removing it would corrupt the data.
18
+ */
19
+ export declare function stripBom(text: string): string;
2
20
  /**
3
21
  * Read + parse a `package.json` at `pkgPath`. Permissive: any error (missing
4
22
  * file, unreadable, invalid JSON) yields `null`.
23
+ *
24
+ * The BOM strip matters MORE here than in a throwing reader: this one reports
25
+ * an unparseable manifest as `null`, which every caller reads as "no
26
+ * package.json". On Windows a BOM therefore did not produce an error — it made
27
+ * a package look like it had no manifest at all, so `gjsify check` found no
28
+ * `check` script, discovery skipped the package, and nothing said why.
5
29
  */
6
30
  export declare function readPackageJson(pkgPath: string): PackageJson | null;
@@ -10,13 +10,39 @@
10
10
  // during `gjsify install <pkg>`. That contract is intentional and load-bearing
11
11
  // — do not fold it into this one.
12
12
  import { readFileSync } from 'node:fs';
13
+ /**
14
+ * Drop a leading UTF-8 BOM (`U+FEFF`, the decoded form of `EF BB BF`).
15
+ *
16
+ * `JSON.parse` rejects a BOM — it is not whitespace — so a manifest carrying
17
+ * one is "invalid JSON" to every reader here. That is not a hypothetical file
18
+ * shape on Windows: Windows PowerShell 5.1 writes a BOM for `-Encoding utf8`,
19
+ * which is its DEFAULT for `Out-File`/`Set-Content`, so any script that edits a
20
+ * manifest produces one. Notepad offers "UTF-8 with BOM" too.
21
+ *
22
+ * npm reads such a file without complaint (`npm pkg get name` on a BOM'd
23
+ * manifest returns the name), so the file is valid as far as the ecosystem is
24
+ * concerned and gjsify was the strict one. Stripping matches npm, VS Code and
25
+ * `parse-json`'s own guidance; nothing downstream wants the character.
26
+ *
27
+ * Only a LEADING BOM: `U+FEFF` elsewhere is a zero-width no-break space inside
28
+ * a string value, and removing it would corrupt the data.
29
+ */
30
+ export function stripBom(text) {
31
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
32
+ }
13
33
  /**
14
34
  * Read + parse a `package.json` at `pkgPath`. Permissive: any error (missing
15
35
  * file, unreadable, invalid JSON) yields `null`.
36
+ *
37
+ * The BOM strip matters MORE here than in a throwing reader: this one reports
38
+ * an unparseable manifest as `null`, which every caller reads as "no
39
+ * package.json". On Windows a BOM therefore did not produce an error — it made
40
+ * a package look like it had no manifest at all, so `gjsify check` found no
41
+ * `check` script, discovery skipped the package, and nothing said why.
16
42
  */
17
43
  export function readPackageJson(pkgPath) {
18
44
  try {
19
- return JSON.parse(readFileSync(pkgPath, 'utf-8'));
45
+ return JSON.parse(stripBom(readFileSync(pkgPath, 'utf-8')));
20
46
  }
21
47
  catch {
22
48
  return null;