@gjsify/cli 0.26.1 → 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 (55) hide show
  1. package/dist/cli.gjs.mjs +184 -182
  2. package/lib/cli-app.js +3 -1
  3. package/lib/commands/affected.js +1 -1
  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 +27 -2
  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 +34 -6
  35. package/lib/utils/dir-link.js +23 -9
  36. package/lib/utils/install-backend-native.d.ts +109 -1
  37. package/lib/utils/install-backend-native.js +622 -59
  38. package/lib/utils/install-backend.js +17 -15
  39. package/lib/utils/install-global.js +6 -34
  40. package/lib/utils/install-lock.js +1 -1
  41. package/lib/utils/install-packument-cache.d.ts +36 -17
  42. package/lib/utils/install-packument-cache.js +59 -31
  43. package/lib/utils/oxc-resolve.d.ts +27 -1
  44. package/lib/utils/oxc-resolve.js +53 -26
  45. package/lib/utils/pkg-json.d.ts +24 -0
  46. package/lib/utils/pkg-json.js +27 -1
  47. package/lib/utils/platform-check.d.ts +215 -0
  48. package/lib/utils/platform-check.js +377 -0
  49. package/lib/utils/resolve-npm-package.js +9 -2
  50. package/lib/utils/run-gjs.d.ts +1 -1
  51. package/lib/utils/run-gjs.js +19 -3
  52. package/lib/utils/spawn.js +50 -2
  53. package/lib/utils/win32-command.d.ts +54 -0
  54. package/lib/utils/win32-command.js +140 -0
  55. package/package.json +19 -20
@@ -0,0 +1,215 @@
1
+ /**
2
+ * A manifest's platform declaration. npm allows BOTH a bare string and an
3
+ * array for each field (`"os": "darwin"` and `"os": ["darwin"]` are the same
4
+ * declaration), which is why every consumer goes through {@link checkList}.
5
+ */
6
+ export type PlatformFieldValue = string | string[];
7
+ /** The `os`/`cpu`/`libc` slice of a package manifest (or packument version). */
8
+ export interface PlatformDeclaration {
9
+ os?: PlatformFieldValue;
10
+ cpu?: PlatformFieldValue;
11
+ libc?: PlatformFieldValue;
12
+ }
13
+ /**
14
+ * The host a package is being checked AGAINST. `os`/`cpu` use Node's
15
+ * `process.platform`/`process.arch` vocabulary (`linux`, `darwin`, `win32`;
16
+ * `x64`, `arm64`, …) — the same spelling `gjsify.platforms` uses, per the OS
17
+ * axis in AGENTS.md.
18
+ *
19
+ * `libc` is `'glibc'` | `'musl'` | undefined. Undefined means UNKNOWN, not
20
+ * "no libc": npm treats a package that DECLARES a libc while the host's is
21
+ * unknown as incompatible (better to skip an optional binary than to hand a
22
+ * musl binary to a glibc process), and so does {@link checkPlatform}.
23
+ */
24
+ export interface PlatformTarget {
25
+ os: string;
26
+ cpu: string;
27
+ libc?: string;
28
+ }
29
+ /**
30
+ * Result of one compatibility check. `current`/`required` carry npm's
31
+ * EBADPLATFORM payload shape verbatim so a log line and a thrown error read
32
+ * the same and can be diffed against npm's own output.
33
+ */
34
+ export interface PlatformVerdict {
35
+ ok: boolean;
36
+ current: PlatformTarget;
37
+ required: PlatformDeclaration;
38
+ }
39
+ /** Error thrown for an INCOMPATIBLE REQUIRED dependency. Mirrors npm's shape. */
40
+ export interface BadPlatformError extends Error {
41
+ code: 'EBADPLATFORM';
42
+ pkgid: string;
43
+ current: PlatformTarget;
44
+ required: PlatformDeclaration;
45
+ }
46
+ /**
47
+ * Does `value` satisfy an npm platform field list?
48
+ *
49
+ * Verbatim port of `checkList` in npm-install-checks
50
+ * (refs/npm-cli/node_modules/npm-install-checks/lib/index.js). Four rules,
51
+ * and the ORDER of the last two is what makes negation work:
52
+ *
53
+ * 1. a bare string coerces to a one-element list;
54
+ * 2. a list that is EXACTLY `['any']` always passes — note the length check:
55
+ * `['any', 'x']` does NOT short-circuit, `'any'` is then just a token
56
+ * that matches nothing. Reproduced deliberately;
57
+ * 3. an exact match on a NEGATED entry (`!win32`) fails immediately, even if
58
+ * a positive entry later in the list would have matched;
59
+ * 4. otherwise pass when at least one positive entry matched, OR when every
60
+ * entry was a negation (a pure blocklist means "everything else is fine").
61
+ *
62
+ * Consequence worth knowing before you "fix" it: an EMPTY list passes (rule 4,
63
+ * `negated === list.length === 0`). npm behaves that way and manifests in the
64
+ * wild do ship `"cpu": []`.
65
+ *
66
+ * `value` may be undefined (an unknown host libc). Undefined never equals a
67
+ * list entry, so it only ever matches through the all-negations branch — which
68
+ * is why {@link checkPlatform} needs its extra unknown-libc rule.
69
+ */
70
+ export declare function checkList(value: string | undefined, list: PlatformFieldValue): boolean;
71
+ /**
72
+ * Check a manifest's `os`/`cpu`/`libc` against a host triple.
73
+ *
74
+ * Port of `checkPlatform` (same source file). Two subtleties kept as-is:
75
+ *
76
+ * - a field is consulted only when TRUTHY, so `""` reads as "not declared"
77
+ * (while `[]` is truthy and goes through {@link checkList}, which passes it);
78
+ * - a package that DECLARES `libc` while the host libc is unknown is
79
+ * incompatible, unconditionally. This is not derivable from `checkList`
80
+ * (`['!musl']` would otherwise pass on an unknown host) and it is the
81
+ * conservative direction: a musl binary loaded into a glibc process fails
82
+ * at `dlopen`, long after the install said it succeeded.
83
+ *
84
+ * Returns a verdict instead of throwing, because the CALLER decides what an
85
+ * incompatibility means — fatal for a required dependency, silently inert for
86
+ * an optional one (npm makes the same split, in arborist rather than here).
87
+ */
88
+ export declare function checkPlatform(target: PlatformDeclaration, host: PlatformTarget): PlatformVerdict;
89
+ /** Does this manifest slice restrict its platform at all? */
90
+ export declare function declaresPlatform(target: PlatformDeclaration): boolean;
91
+ /**
92
+ * The EBADPLATFORM error npm throws for a required dependency the host cannot
93
+ * run. Same `code`/`pkgid`/`current`/`required` properties, so a consumer's
94
+ * error handling (and a human reading the message) sees what npm reports.
95
+ */
96
+ export declare function badPlatformError(pkgid: string, verdict: PlatformVerdict): BadPlatformError;
97
+ /**
98
+ * Everything the libc probe touches outside itself. Injected so the musl and
99
+ * report-less branches are unit-testable from a glibc host.
100
+ */
101
+ export interface LibcProbeHost {
102
+ /** Read a file as UTF-8. `null` when it cannot be read at all. */
103
+ readTextFile(path: string): string | null;
104
+ /** List a directory. `null` when it cannot be read. */
105
+ listDir(path: string): string[] | null;
106
+ /**
107
+ * The two libc-relevant facts out of Node's diagnostic report, or `null` on
108
+ * a runtime that has none. GJS has no `process.report`, which is exactly
109
+ * why this is a separate seam rather than an inline `process.report` read.
110
+ *
111
+ * FLATTENED on purpose: the raw report nests the first field under
112
+ * `header`. A probe that had to mirror Node's report schema would be
113
+ * tedious to fake in a spec for no gain — the only question asked of it is
114
+ * these two values.
115
+ */
116
+ diagnosticReport(): {
117
+ glibcVersionRuntime?: unknown;
118
+ sharedObjects?: unknown;
119
+ } | null;
120
+ }
121
+ /**
122
+ * Last-resort probe for a runtime with no `process.report`: look for the
123
+ * dynamic loader itself. `ld-musl-<arch>.so.1` means musl,
124
+ * `ld-linux-<arch>.so.2` (or `ld-linux.so.2`) means glibc.
125
+ *
126
+ * This logic used to live inline in `oxc-resolve.ts`, where it decided which
127
+ * `@oxlint/binding-*` package to name in an install hint — and where a lazy
128
+ * `require('node:fs')` inside its try/catch threw a ReferenceError that the
129
+ * catch swallowed, silently pinning every host to `gnu`. It is here now so
130
+ * there is ONE definition with ONE set of tests behind it; `oxc-resolve.ts`
131
+ * calls this.
132
+ *
133
+ * glibc WINS a tie: a glibc workstation with a musl cross-toolchain installed
134
+ * carries both loaders and is the realistic mixed case, whereas an Alpine box
135
+ * carrying an `ld-linux-*` stub is not. (npm's report probe orders the two the
136
+ * same way.)
137
+ */
138
+ export declare function libcFromLoaderScan(host: LibcProbeHost, dirs?: readonly string[]): string | null;
139
+ /**
140
+ * Detect the host libc family. `undefined` = unknown (and, per
141
+ * {@link checkPlatform}, that makes every libc-DECLARING package incompatible).
142
+ *
143
+ * Only meaningful on Linux; npm returns undefined for every other OS and so do
144
+ * we — a declaration like `"libc": ["glibc"]` on a darwin package would
145
+ * otherwise be judged against a value the platform has no concept of.
146
+ *
147
+ * Chain: ldd text → `process.report` when the runtime HAS one → loader scan.
148
+ * The middle step keeps NODE PARITY exactly, including its `null` answer: we
149
+ * deliberately do NOT fall through from a null report into the loader scan,
150
+ * because the same lockfile must resolve to the same tree under Node and under
151
+ * GJS. Diverging there would make "install once, run the bundle anywhere"
152
+ * depend on which runtime ran the installer.
153
+ */
154
+ export declare function detectLibc(os: string, host: LibcProbeHost): string | undefined;
155
+ /** The real host probe: node:fs reads (which `@gjsify/fs` serves under GJS). */
156
+ export declare function defaultLibcProbeHost(): LibcProbeHost;
157
+ /** Test-only: forget the memoized libc so a spec can re-probe with a new host. */
158
+ export declare function resetLibcCache(): void;
159
+ /** Memoizing wrapper over {@link detectLibc}. */
160
+ export declare function hostLibc(os: string, host?: LibcProbeHost): string | undefined;
161
+ /**
162
+ * Explicit target overrides. npm exposes these as the config keys `os`, `cpu`
163
+ * and `libc`, which means an env `npm_config_<key>` sets them just as a
164
+ * `--<key>` flag does; `gjsify install` mirrors both spellings (its
165
+ * `--os/--cpu/--libc` flags write these keys, and `loadNpmrc` already honours
166
+ * `npm_config_registry` the same way).
167
+ *
168
+ * Pure: the env block is a parameter. Empty/whitespace values are ignored
169
+ * rather than treated as "the empty platform", so `npm_config_libc=` behaves
170
+ * like an absent key.
171
+ */
172
+ export declare function readPlatformOverrides(env: Record<string, string | undefined>): Partial<PlatformTarget>;
173
+ /**
174
+ * Is the platform check bypassed? npm's `--force` (config key `force`)
175
+ * suppresses the EBADPLATFORM THROW for a required dependency — it does NOT
176
+ * un-skip an incompatible OPTIONAL one, which npm marks inert regardless
177
+ * ("We ignore the --force and --engine-strict flags" — arborist
178
+ * build-ideal-tree.js, the `node.optional && !node.inert` branch). Callers
179
+ * must keep that asymmetry: forcing a required install is a user overriding a
180
+ * declaration about their own machine; forcing an optional one would only
181
+ * download a binary nothing can load.
182
+ */
183
+ export declare function readPlatformForce(env: Record<string, string | undefined>): boolean;
184
+ /** Sources {@link resolveHostPlatform} may read. All optional, all injectable. */
185
+ export interface HostPlatformSources {
186
+ /** Defaults to `process.platform`. */
187
+ platform?: string;
188
+ /** Defaults to `process.arch`. */
189
+ arch?: string;
190
+ /** Defaults to `process.env`; read for the npm config-key overrides. */
191
+ env?: Record<string, string | undefined>;
192
+ /** Defaults to {@link defaultLibcProbeHost}. */
193
+ probe?: LibcProbeHost;
194
+ }
195
+ /**
196
+ * The triple an install resolves against: the running host, with the npm
197
+ * config keys applied on top.
198
+ *
199
+ * The libc probe runs only when the target OS is linux AND libc was not
200
+ * overridden — so `--os=darwin` on a Linux host does no libc I/O and yields
201
+ * `libc: undefined`, exactly as npm's `currentEnv.libc(os)` does. That is what
202
+ * makes a single host able to test every branch (requirement behind the
203
+ * `--os/--cpu/--libc` flags), and what keeps the check honest when a user
204
+ * resolves a tree for a machine they are not on.
205
+ *
206
+ * Under GJS `process.platform`/`process.arch` come from `@gjsify/process`,
207
+ * which derives them from `uname` through
208
+ * `packages/gjs/utils/src/platform-names.ts`. That table is written against
209
+ * NODE's vocabulary (`linux`/`darwin`/`win32`, `x64`/`arm64`), which is the
210
+ * same vocabulary npm's `os`/`cpu` fields use — so the values are directly
211
+ * comparable and need no translation layer here.
212
+ */
213
+ export declare function resolveHostPlatform(sources?: HostPlatformSources): PlatformTarget;
214
+ /** Human-readable target, for a one-line install log. */
215
+ export declare function describePlatformTarget(target: PlatformTarget): string;
@@ -0,0 +1,377 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Adapted from npm-install-checks 9.0.0
3
+ // (refs/npm-cli/node_modules/npm-install-checks/lib/{index,current-env}.js).
4
+ // Copyright (c) Isaac Z. Schlueter and npm contributors. ISC.
5
+ // Modifications: TypeScript; host values are PARAMETERS instead of ambient
6
+ // reads; the libc probe gains a GJS path (no `process.report` there).
7
+ //
8
+ // `os` / `cpu` / `libc` compatibility for `gjsify install`.
9
+ //
10
+ // WHY THIS EXISTS: the resolver used to place every optionalDependency a
11
+ // packument listed, without ever reading the version's `os`/`cpu`/`libc`
12
+ // fields. Measured on a cold tree of this workspace: 183 foreign-platform
13
+ // packages / 3361 MB (darwin/win32/arm64 binary siblings of rolldown, oxlint,
14
+ // lightningcss, esbuild, …) plus 9 musl-only packages / 308 MB — 3.67 GB of a
15
+ // 5.6 GB node_modules that npm would never have written. That is not a size
16
+ // nit: those trees also make `detectNativePackages` walk foreign prebuilds,
17
+ // and an extract of several GB is what pushed installs into the 30-minute
18
+ // wall-clock budget (see the comment in commands/install.ts).
19
+ //
20
+ // PARITY IS THE POINT, so this is a PORT, not an interpretation: the rules
21
+ // below are npm's, quirks included (see `checkList`). A gjsify install that
22
+ // placed a different SET than npm for the same manifest would be a worse
23
+ // failure than placing too much — a consumer's build then works under one
24
+ // installer and not the other, with nothing in either tree to point at.
25
+ //
26
+ // PURITY: every decision function takes the host triple as an argument. Only
27
+ // `resolveHostPlatform()` (and the default probe it builds) reads
28
+ // `process.platform`/`process.arch`/the filesystem, so a Linux host can
29
+ // unit-test the darwin/win32/musl branches by injection — the philosophy
30
+ // `detect-native-packages.ts` documents at its top, applied here.
31
+ import { readFileSync, readdirSync } from 'node:fs';
32
+ /**
33
+ * Does `value` satisfy an npm platform field list?
34
+ *
35
+ * Verbatim port of `checkList` in npm-install-checks
36
+ * (refs/npm-cli/node_modules/npm-install-checks/lib/index.js). Four rules,
37
+ * and the ORDER of the last two is what makes negation work:
38
+ *
39
+ * 1. a bare string coerces to a one-element list;
40
+ * 2. a list that is EXACTLY `['any']` always passes — note the length check:
41
+ * `['any', 'x']` does NOT short-circuit, `'any'` is then just a token
42
+ * that matches nothing. Reproduced deliberately;
43
+ * 3. an exact match on a NEGATED entry (`!win32`) fails immediately, even if
44
+ * a positive entry later in the list would have matched;
45
+ * 4. otherwise pass when at least one positive entry matched, OR when every
46
+ * entry was a negation (a pure blocklist means "everything else is fine").
47
+ *
48
+ * Consequence worth knowing before you "fix" it: an EMPTY list passes (rule 4,
49
+ * `negated === list.length === 0`). npm behaves that way and manifests in the
50
+ * wild do ship `"cpu": []`.
51
+ *
52
+ * `value` may be undefined (an unknown host libc). Undefined never equals a
53
+ * list entry, so it only ever matches through the all-negations branch — which
54
+ * is why {@link checkPlatform} needs its extra unknown-libc rule.
55
+ */
56
+ export function checkList(value, list) {
57
+ const entries = typeof list === 'string' ? [list] : list;
58
+ if (entries.length === 1 && entries[0] === 'any')
59
+ return true;
60
+ let negated = 0;
61
+ let match = false;
62
+ for (const entry of entries) {
63
+ const negate = entry.charAt(0) === '!';
64
+ const test = negate ? entry.slice(1) : entry;
65
+ if (negate) {
66
+ negated++;
67
+ if (value === test)
68
+ return false;
69
+ }
70
+ else {
71
+ match = match || value === test;
72
+ }
73
+ }
74
+ return match || negated === entries.length;
75
+ }
76
+ /**
77
+ * Check a manifest's `os`/`cpu`/`libc` against a host triple.
78
+ *
79
+ * Port of `checkPlatform` (same source file). Two subtleties kept as-is:
80
+ *
81
+ * - a field is consulted only when TRUTHY, so `""` reads as "not declared"
82
+ * (while `[]` is truthy and goes through {@link checkList}, which passes it);
83
+ * - a package that DECLARES `libc` while the host libc is unknown is
84
+ * incompatible, unconditionally. This is not derivable from `checkList`
85
+ * (`['!musl']` would otherwise pass on an unknown host) and it is the
86
+ * conservative direction: a musl binary loaded into a glibc process fails
87
+ * at `dlopen`, long after the install said it succeeded.
88
+ *
89
+ * Returns a verdict instead of throwing, because the CALLER decides what an
90
+ * incompatibility means — fatal for a required dependency, silently inert for
91
+ * an optional one (npm makes the same split, in arborist rather than here).
92
+ */
93
+ export function checkPlatform(target, host) {
94
+ const osOk = target.os ? checkList(host.os, target.os) : true;
95
+ const cpuOk = target.cpu ? checkList(host.cpu, target.cpu) : true;
96
+ let libcOk = target.libc ? checkList(host.libc, target.libc) : true;
97
+ if (target.libc && !host.libc)
98
+ libcOk = false;
99
+ return {
100
+ ok: osOk && cpuOk && libcOk,
101
+ current: { os: host.os, cpu: host.cpu, libc: host.libc },
102
+ required: { os: target.os, cpu: target.cpu, libc: target.libc },
103
+ };
104
+ }
105
+ /** Does this manifest slice restrict its platform at all? */
106
+ export function declaresPlatform(target) {
107
+ return Boolean(target.os || target.cpu || target.libc);
108
+ }
109
+ /**
110
+ * The EBADPLATFORM error npm throws for a required dependency the host cannot
111
+ * run. Same `code`/`pkgid`/`current`/`required` properties, so a consumer's
112
+ * error handling (and a human reading the message) sees what npm reports.
113
+ */
114
+ export function badPlatformError(pkgid, verdict) {
115
+ const err = new Error(`Unsupported platform for ${pkgid}: wanted ${JSON.stringify(verdict.required)} ` +
116
+ `(current: ${JSON.stringify(verdict.current)})`);
117
+ err.code = 'EBADPLATFORM';
118
+ err.pkgid = pkgid;
119
+ err.current = verdict.current;
120
+ err.required = verdict.required;
121
+ return err;
122
+ }
123
+ /** npm's `ldd` location — a data file to us, never executed. */
124
+ const LDD_PATH = '/usr/bin/ldd';
125
+ /**
126
+ * Directories probed for a dynamic loader when there is no `process.report`.
127
+ * `/lib` and `/lib64` are the classic locations; `/usr/lib` covers the
128
+ * merged-/usr layouts (Fedora, Arch, Debian ≥ bookworm) where `/lib` is a
129
+ * symlink and, on some containers, absent from the image entirely.
130
+ */
131
+ const LOADER_DIRS = ['/lib', '/lib64', '/usr/lib'];
132
+ function isMuslSharedObject(file) {
133
+ // npm's own predicate, verbatim: both spellings appear across distros.
134
+ return file.includes('libc.musl-') || file.includes('ld-musl-');
135
+ }
136
+ /**
137
+ * Read the libc family out of `/usr/bin/ldd`, which on every mainstream distro
138
+ * is a TEXT script naming its own implementation. This is npm's primary probe
139
+ * and it is pure file I/O, so it works unchanged under GJS.
140
+ *
141
+ * Three-valued on purpose, and the distinction drives the fallback chain:
142
+ * - `'musl'` / `'glibc'` — decided;
143
+ * - `null` — the file was READ but names neither implementation. npm treats
144
+ * that as final (do not consult the report), so we do too;
145
+ * - `undefined` — unreadable (no ldd at all: a scratch/distroless image),
146
+ * the only case that falls through.
147
+ */
148
+ function libcFromLddFile(host) {
149
+ const content = host.readTextFile(LDD_PATH);
150
+ if (content === null)
151
+ return undefined;
152
+ if (content.includes('musl'))
153
+ return 'musl';
154
+ if (content.includes('GNU C Library'))
155
+ return 'glibc';
156
+ return null;
157
+ }
158
+ /**
159
+ * npm's `process.report`-based probe (Node only). Takes the already-collected
160
+ * report rather than the probe host: `getReport()` walks the whole process
161
+ * state, so `detectLibc` collects it once and asks both questions of the same
162
+ * snapshot.
163
+ */
164
+ function libcFromReportData(report) {
165
+ if (report.glibcVersionRuntime)
166
+ return 'glibc';
167
+ if (Array.isArray(report.sharedObjects) && report.sharedObjects.some((so) => isMuslSharedObject(String(so)))) {
168
+ return 'musl';
169
+ }
170
+ return null;
171
+ }
172
+ /**
173
+ * Last-resort probe for a runtime with no `process.report`: look for the
174
+ * dynamic loader itself. `ld-musl-<arch>.so.1` means musl,
175
+ * `ld-linux-<arch>.so.2` (or `ld-linux.so.2`) means glibc.
176
+ *
177
+ * This logic used to live inline in `oxc-resolve.ts`, where it decided which
178
+ * `@oxlint/binding-*` package to name in an install hint — and where a lazy
179
+ * `require('node:fs')` inside its try/catch threw a ReferenceError that the
180
+ * catch swallowed, silently pinning every host to `gnu`. It is here now so
181
+ * there is ONE definition with ONE set of tests behind it; `oxc-resolve.ts`
182
+ * calls this.
183
+ *
184
+ * glibc WINS a tie: a glibc workstation with a musl cross-toolchain installed
185
+ * carries both loaders and is the realistic mixed case, whereas an Alpine box
186
+ * carrying an `ld-linux-*` stub is not. (npm's report probe orders the two the
187
+ * same way.)
188
+ */
189
+ export function libcFromLoaderScan(host, dirs = LOADER_DIRS) {
190
+ let sawMusl = false;
191
+ for (const dir of dirs) {
192
+ const entries = host.listDir(dir);
193
+ if (!entries)
194
+ continue;
195
+ for (const entry of entries) {
196
+ if (entry.startsWith('ld-linux'))
197
+ return 'glibc';
198
+ if (entry.startsWith('ld-musl-'))
199
+ sawMusl = true;
200
+ }
201
+ }
202
+ return sawMusl ? 'musl' : null;
203
+ }
204
+ /**
205
+ * Detect the host libc family. `undefined` = unknown (and, per
206
+ * {@link checkPlatform}, that makes every libc-DECLARING package incompatible).
207
+ *
208
+ * Only meaningful on Linux; npm returns undefined for every other OS and so do
209
+ * we — a declaration like `"libc": ["glibc"]` on a darwin package would
210
+ * otherwise be judged against a value the platform has no concept of.
211
+ *
212
+ * Chain: ldd text → `process.report` when the runtime HAS one → loader scan.
213
+ * The middle step keeps NODE PARITY exactly, including its `null` answer: we
214
+ * deliberately do NOT fall through from a null report into the loader scan,
215
+ * because the same lockfile must resolve to the same tree under Node and under
216
+ * GJS. Diverging there would make "install once, run the bundle anywhere"
217
+ * depend on which runtime ran the installer.
218
+ */
219
+ export function detectLibc(os, host) {
220
+ if (os !== 'linux')
221
+ return undefined;
222
+ const fromFile = libcFromLddFile(host);
223
+ if (fromFile !== undefined)
224
+ return fromFile ?? undefined;
225
+ const report = host.diagnosticReport();
226
+ if (report !== null)
227
+ return libcFromReportData(report) ?? undefined;
228
+ return libcFromLoaderScan(host) ?? undefined;
229
+ }
230
+ /** The real host probe: node:fs reads (which `@gjsify/fs` serves under GJS). */
231
+ export function defaultLibcProbeHost() {
232
+ return {
233
+ readTextFile(path) {
234
+ try {
235
+ return readFileSync(path, 'utf-8');
236
+ }
237
+ catch {
238
+ // Genuinely expected: distroless/scratch images ship no ldd,
239
+ // and `/lib64` is absent on plenty of layouts. The caller
240
+ // treats null as "probe the next source", so swallowing here
241
+ // is the control flow, not a hidden failure.
242
+ return null;
243
+ }
244
+ },
245
+ listDir(path) {
246
+ try {
247
+ return readdirSync(path);
248
+ }
249
+ catch {
250
+ return null;
251
+ }
252
+ },
253
+ diagnosticReport() {
254
+ // `process.report` is a Node diagnostic API that GJS's `process`
255
+ // polyfill does not implement, so the existence check below is a
256
+ // genuine per-runtime variance probe, not paranoia. The cast is
257
+ // needed for a second reason: `excludeNetwork` is real (npm sets it)
258
+ // but missing from @types/node's `ProcessReport`, so the typed
259
+ // `process.report` cannot express the assignment.
260
+ const report = process.report;
261
+ if (!report || typeof report.getReport !== 'function')
262
+ return null;
263
+ // npm flips `excludeNetwork` first: gathering the network section
264
+ // does DNS work that can take seconds on a host with an
265
+ // unreachable resolver. Restore it so we don't mutate global
266
+ // diagnostic state for the rest of the process.
267
+ const previous = report.excludeNetwork;
268
+ report.excludeNetwork = true;
269
+ try {
270
+ const raw = report.getReport();
271
+ return { glibcVersionRuntime: raw.header?.glibcVersionRuntime, sharedObjects: raw.sharedObjects };
272
+ }
273
+ finally {
274
+ report.excludeNetwork = previous;
275
+ }
276
+ },
277
+ };
278
+ }
279
+ /**
280
+ * Memoized libc answer. npm caches the same way (a module-level `family`):
281
+ * the probe reads files, and a resolve asks for the host triple once per
282
+ * package. `libcProbed` is a separate flag because `undefined` is a legitimate
283
+ * ANSWER (unknown libc), not a "not computed yet" marker.
284
+ */
285
+ let cachedLibc;
286
+ let libcProbed = false;
287
+ /** Test-only: forget the memoized libc so a spec can re-probe with a new host. */
288
+ export function resetLibcCache() {
289
+ cachedLibc = undefined;
290
+ libcProbed = false;
291
+ }
292
+ /** Memoizing wrapper over {@link detectLibc}. */
293
+ export function hostLibc(os, host = defaultLibcProbeHost()) {
294
+ if (!libcProbed) {
295
+ cachedLibc = detectLibc(os, host);
296
+ libcProbed = true;
297
+ }
298
+ return cachedLibc;
299
+ }
300
+ /**
301
+ * Explicit target overrides. npm exposes these as the config keys `os`, `cpu`
302
+ * and `libc`, which means an env `npm_config_<key>` sets them just as a
303
+ * `--<key>` flag does; `gjsify install` mirrors both spellings (its
304
+ * `--os/--cpu/--libc` flags write these keys, and `loadNpmrc` already honours
305
+ * `npm_config_registry` the same way).
306
+ *
307
+ * Pure: the env block is a parameter. Empty/whitespace values are ignored
308
+ * rather than treated as "the empty platform", so `npm_config_libc=` behaves
309
+ * like an absent key.
310
+ */
311
+ export function readPlatformOverrides(env) {
312
+ const pick = (key) => {
313
+ const raw = env[key];
314
+ if (typeof raw !== 'string')
315
+ return undefined;
316
+ const trimmed = raw.trim();
317
+ return trimmed === '' ? undefined : trimmed;
318
+ };
319
+ const out = {};
320
+ const os = pick('npm_config_os');
321
+ const cpu = pick('npm_config_cpu');
322
+ const libc = pick('npm_config_libc');
323
+ if (os !== undefined)
324
+ out.os = os;
325
+ if (cpu !== undefined)
326
+ out.cpu = cpu;
327
+ if (libc !== undefined)
328
+ out.libc = libc;
329
+ return out;
330
+ }
331
+ /**
332
+ * Is the platform check bypassed? npm's `--force` (config key `force`)
333
+ * suppresses the EBADPLATFORM THROW for a required dependency — it does NOT
334
+ * un-skip an incompatible OPTIONAL one, which npm marks inert regardless
335
+ * ("We ignore the --force and --engine-strict flags" — arborist
336
+ * build-ideal-tree.js, the `node.optional && !node.inert` branch). Callers
337
+ * must keep that asymmetry: forcing a required install is a user overriding a
338
+ * declaration about their own machine; forcing an optional one would only
339
+ * download a binary nothing can load.
340
+ */
341
+ export function readPlatformForce(env) {
342
+ const raw = env.npm_config_force;
343
+ if (typeof raw !== 'string')
344
+ return false;
345
+ const v = raw.trim().toLowerCase();
346
+ return v === 'true' || v === '1';
347
+ }
348
+ /**
349
+ * The triple an install resolves against: the running host, with the npm
350
+ * config keys applied on top.
351
+ *
352
+ * The libc probe runs only when the target OS is linux AND libc was not
353
+ * overridden — so `--os=darwin` on a Linux host does no libc I/O and yields
354
+ * `libc: undefined`, exactly as npm's `currentEnv.libc(os)` does. That is what
355
+ * makes a single host able to test every branch (requirement behind the
356
+ * `--os/--cpu/--libc` flags), and what keeps the check honest when a user
357
+ * resolves a tree for a machine they are not on.
358
+ *
359
+ * Under GJS `process.platform`/`process.arch` come from `@gjsify/process`,
360
+ * which derives them from `uname` through
361
+ * `packages/gjs/utils/src/platform-names.ts`. That table is written against
362
+ * NODE's vocabulary (`linux`/`darwin`/`win32`, `x64`/`arm64`), which is the
363
+ * same vocabulary npm's `os`/`cpu` fields use — so the values are directly
364
+ * comparable and need no translation layer here.
365
+ */
366
+ export function resolveHostPlatform(sources = {}) {
367
+ const env = sources.env ?? process.env;
368
+ const overrides = readPlatformOverrides(env);
369
+ const os = overrides.os ?? sources.platform ?? process.platform;
370
+ const cpu = overrides.cpu ?? sources.arch ?? process.arch;
371
+ const libc = overrides.libc ?? (os === 'linux' ? hostLibc(os, sources.probe ?? defaultLibcProbeHost()) : undefined);
372
+ return { os, cpu, libc };
373
+ }
374
+ /** Human-readable target, for a one-line install log. */
375
+ export function describePlatformTarget(target) {
376
+ return `${target.os}-${target.cpu}${target.libc ? `-${target.libc}` : ''}`;
377
+ }
@@ -36,7 +36,7 @@
36
36
  // (parent-dir walker precedent).
37
37
  import { createRequire } from 'node:module';
38
38
  import { pathToFileURL } from 'node:url';
39
- import { join, resolve } from 'node:path';
39
+ import { delimiter, join, resolve } from 'node:path';
40
40
  import { findWorkspaceRoot } from './workspace-root.js';
41
41
  /**
42
42
  * Resolve a bare npm specifier through multiple createRequire anchors.
@@ -54,7 +54,14 @@ export function resolveNpmPackage(specifier, opts = {}) {
54
54
  // in the lookup. Mirrors Node's NODE_PATH semantics.
55
55
  const envPath = process.env['GJSIFY_NODE_PATH'];
56
56
  if (envPath) {
57
- for (const dir of envPath.split(':').filter(Boolean)) {
57
+ // `path.delimiter`, not `':'` — Node's own NODE_PATH uses `;` on
58
+ // Windows, and this override claims to mirror NODE_PATH semantics. Split
59
+ // on a colon there and even a SINGLE entry is destroyed: `C:\tools`
60
+ // becomes `['C', '\tools']`, neither of which resolves, so the documented
61
+ // override silently did nothing on Windows and fell through to anchor 2.
62
+ // `commands/run.ts` and `utils/run-lifecycle-script.ts` already get this
63
+ // right; this was the site that was missed.
64
+ for (const dir of envPath.split(delimiter).filter(Boolean)) {
58
65
  const hit = tryResolveFromDir(specifier, resolve(dir));
59
66
  if (hit)
60
67
  return hit;
@@ -6,7 +6,7 @@ import { type NativeEnv } from './detect-native-packages.js';
6
6
  * `$ …` echo. Which library variable is set is host-dependent
7
7
  * (`LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH` / `PATH`) — see `buildNativeEnv`.
8
8
  */
9
- export declare function computeNativeEnvForBundle(bundlePath: string, cwd?: string): {
9
+ export declare function computeNativeEnvForBundle(bundlePath: string, cwd?: string, inherited?: Record<string, string | undefined>): {
10
10
  env: NativeEnv;
11
11
  envPrefix: string;
12
12
  };
@@ -26,15 +26,31 @@ import { detectNativePackages, buildNativeEnv } from './detect-native-packages.j
26
26
  * `$ …` echo. Which library variable is set is host-dependent
27
27
  * (`LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH` / `PATH`) — see `buildNativeEnv`.
28
28
  */
29
- export function computeNativeEnvForBundle(bundlePath, cwd = process.cwd()) {
29
+ export function computeNativeEnvForBundle(bundlePath, cwd = process.cwd(), inherited = process.env) {
30
30
  const resolvedBundle = resolve(bundlePath);
31
31
  const cwdPackages = detectNativePackages(cwd);
32
32
  const bundlePackages = detectNativePackages(dirname(resolvedBundle));
33
33
  const seen = new Set(cwdPackages.map((p) => p.name));
34
34
  const nativePackages = [...cwdPackages, ...bundlePackages.filter((p) => !seen.has(p.name))];
35
- const env = buildNativeEnv(nativePackages);
35
+ // `inherited` feeds BOTH halves — the composition and the comparison. Split
36
+ // them and the win32 branch disagrees with itself: `buildNativeEnv` writes
37
+ // the library variable back under the host's own spelling (`Path` in a
38
+ // stock env block), so a comparison keyed on the canonical `PATH` would
39
+ // look up a key that is not there and call every run a change.
40
+ const env = buildNativeEnv(nativePackages, { env: inherited });
41
+ // Show only what this CLI actually CHANGED. `buildNativeEnv` prepends the
42
+ // detected directories to the inherited value and returns the result
43
+ // unconditionally, so with no native packages it hands back the host's own
44
+ // variable verbatim — and the echo then claimed the CLI had set it.
45
+ //
46
+ // Harmless on Linux, where `LD_LIBRARY_PATH` is usually unset and the empty
47
+ // filter caught it. On Windows the library variable IS `PATH`, which is
48
+ // never empty, so every run printed a ~2 kB dump of the host PATH in front
49
+ // of a command the user is invited to copy — burying the command, and
50
+ // claiming a change that never happened. Comparing against the inherited
51
+ // value is what makes the echo honest on both.
36
52
  const envPrefix = Object.entries(env)
37
- .filter(([, value]) => value !== undefined && value !== '')
53
+ .filter(([key, value]) => value !== undefined && value !== '' && value !== inherited[key])
38
54
  .map(([key, value]) => `${key}=${value}`)
39
55
  .join(' ');
40
56
  return { env, envPrefix };