@gjsify/cli 0.3.21 → 0.4.3

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 (69) hide show
  1. package/dist/cli.gjs.mjs +791 -0
  2. package/lib/actions/build.js +4 -17
  3. package/lib/bundler-pick.d.ts +79 -0
  4. package/lib/bundler-pick.js +436 -0
  5. package/lib/commands/foreach.d.ts +17 -0
  6. package/lib/commands/foreach.js +341 -0
  7. package/lib/commands/index.d.ts +2 -0
  8. package/lib/commands/index.js +2 -0
  9. package/lib/commands/install.d.ts +1 -0
  10. package/lib/commands/install.js +401 -27
  11. package/lib/commands/run.d.ts +1 -1
  12. package/lib/commands/run.js +113 -20
  13. package/lib/commands/workspace.d.ts +8 -0
  14. package/lib/commands/workspace.js +79 -0
  15. package/lib/config.js +12 -1
  16. package/lib/index.js +11 -3
  17. package/lib/types/config-data.d.ts +10 -1
  18. package/lib/utils/install-backend-native.d.ts +5 -1
  19. package/lib/utils/install-backend-native.js +329 -70
  20. package/lib/utils/install-backend.d.ts +11 -1
  21. package/lib/utils/install-backend.js +4 -2
  22. package/lib/utils/pkg-json-edit.d.ts +47 -0
  23. package/lib/utils/pkg-json-edit.js +108 -0
  24. package/lib/utils/workspace-root.d.ts +1 -0
  25. package/lib/utils/workspace-root.js +46 -0
  26. package/package.json +70 -44
  27. package/src/actions/build.ts +0 -431
  28. package/src/actions/index.ts +0 -1
  29. package/src/commands/build.ts +0 -146
  30. package/src/commands/check.ts +0 -87
  31. package/src/commands/create.ts +0 -63
  32. package/src/commands/dlx.ts +0 -195
  33. package/src/commands/flatpak/build.ts +0 -225
  34. package/src/commands/flatpak/ci.ts +0 -173
  35. package/src/commands/flatpak/deps.ts +0 -120
  36. package/src/commands/flatpak/index.ts +0 -53
  37. package/src/commands/flatpak/init.ts +0 -191
  38. package/src/commands/flatpak/utils.ts +0 -76
  39. package/src/commands/gettext.ts +0 -258
  40. package/src/commands/gresource.ts +0 -97
  41. package/src/commands/gsettings.ts +0 -87
  42. package/src/commands/index.ts +0 -12
  43. package/src/commands/info.ts +0 -70
  44. package/src/commands/install.ts +0 -195
  45. package/src/commands/run.ts +0 -33
  46. package/src/commands/showcase.ts +0 -149
  47. package/src/config.ts +0 -304
  48. package/src/constants.ts +0 -1
  49. package/src/index.ts +0 -37
  50. package/src/types/cli-build-options.ts +0 -100
  51. package/src/types/command.ts +0 -10
  52. package/src/types/config-data-library.ts +0 -5
  53. package/src/types/config-data-typescript.ts +0 -6
  54. package/src/types/config-data.ts +0 -225
  55. package/src/types/cosmiconfig-result.ts +0 -5
  56. package/src/types/index.ts +0 -6
  57. package/src/utils/check-system-deps.ts +0 -480
  58. package/src/utils/detect-native-packages.ts +0 -153
  59. package/src/utils/discover-showcases.ts +0 -75
  60. package/src/utils/dlx-cache.ts +0 -135
  61. package/src/utils/install-backend-native.ts +0 -363
  62. package/src/utils/install-backend.ts +0 -88
  63. package/src/utils/install-global.ts +0 -182
  64. package/src/utils/normalize-bundler-options.ts +0 -129
  65. package/src/utils/parse-spec.ts +0 -48
  66. package/src/utils/resolve-gjs-entry.ts +0 -96
  67. package/src/utils/resolve-plugin-by-name.ts +0 -106
  68. package/src/utils/run-gjs.ts +0 -90
  69. package/tsconfig.json +0 -16
@@ -5,17 +5,23 @@
5
5
  // node_modules/ via @gjsify/tar. Output layout matches `npm install` so the
6
6
  // existing `runGjsBundle()` prebuild detection works without branching.
7
7
  //
8
- // Out of scope (deferred to Phase 4): lockfile, peerDependencies validation,
8
+ // Phase D.7b version-conflict resolution via nested `node_modules`.
9
+ // The resolver tracks per-package placement: a dep is hoisted to the
10
+ // root when no conflict exists, nested under the requesting package
11
+ // when its required version is incompatible with what's already at
12
+ // the root. Mirrors npm v3+ behavior.
13
+ //
14
+ // Out of scope (still deferred): peerDependencies validation,
9
15
  // lifecycle scripts, git/file specs.
10
16
  import * as fs from "node:fs";
11
17
  import * as path from "node:path";
12
18
  import * as os from "node:os";
13
- import { Range, SemVer, maxSatisfying, } from "@gjsify/semver";
19
+ import { Range, SemVer, maxSatisfying, satisfies, } from "@gjsify/semver";
14
20
  import { DEFAULT_REGISTRY, fetchPackument, fetchTarball, parseNpmrc, } from "@gjsify/npm-registry";
15
21
  import { extractTarball } from "@gjsify/tar";
16
22
  const DEFAULT_CONCURRENCY = Number(process.env.GJSIFY_INSTALL_CONCURRENCY ?? "8") || 8;
17
23
  const LOCKFILE_NAME = "gjsify-lock.json";
18
- const LOCKFILE_VERSION = 1;
24
+ const LOCKFILE_VERSION = 2;
19
25
  export async function installPackagesNative(opts) {
20
26
  if (opts.specs.length === 0) {
21
27
  throw new Error("installPackagesNative: empty specs list");
@@ -26,14 +32,29 @@ export async function installPackagesNative(opts) {
26
32
  const lockfilePath = path.join(opts.prefix, LOCKFILE_NAME);
27
33
  const existingLock = readLockfile(lockfilePath);
28
34
  let nodes;
29
- if (existingLock && (opts.frozen || lockfileMatchesRequest(existingLock, opts.specs))) {
35
+ if (opts.frozen) {
36
+ // --immutable / --frozen: lockfile is the authoritative source.
37
+ // Reject if the file is missing, version-mismatched, or its
38
+ // `requested` set has drifted from the live request — silently
39
+ // honoring a stale lockfile would mask real dep churn (the original
40
+ // bug --immutable exists to catch).
41
+ if (!existingLock) {
42
+ throw new Error(`install: --immutable requires ${LOCKFILE_NAME} at ${opts.prefix} — none found. ` +
43
+ `Run \`gjsify install\` (without --immutable) to generate one and commit it.`);
44
+ }
45
+ const drift = describeLockfileDrift(existingLock, opts.specs);
46
+ if (drift) {
47
+ throw new Error(`install: --immutable but ${lockfilePath} is stale.\n${drift}\n` +
48
+ `Re-run \`gjsify install\` (without --immutable) to refresh the lockfile.`);
49
+ }
50
+ log("install: --immutable, using lockfile (%d package(s))", Object.keys(existingLock.packages).length);
51
+ nodes = lockfileToNodes(existingLock);
52
+ }
53
+ else if (existingLock && lockfileMatchesRequest(existingLock, opts.specs)) {
30
54
  log("install: using lockfile (%d package(s))", Object.keys(existingLock.packages).length);
31
55
  nodes = lockfileToNodes(existingLock);
32
56
  }
33
57
  else {
34
- if (opts.frozen) {
35
- throw new Error(`install: --frozen requested but ${lockfilePath} is missing or stale (specs differ)`);
36
- }
37
58
  log("install: resolving %d top-level spec(s) → %s", opts.specs.length, opts.prefix);
38
59
  nodes = await resolveDeps(opts.specs, npmrc, log);
39
60
  if (opts.lockfile) {
@@ -45,7 +66,54 @@ export async function installPackagesNative(opts) {
45
66
  await downloadAndExtractAll(nodes, opts.prefix, npmrc, log);
46
67
  await linkBins(nodes, opts.prefix, log);
47
68
  log("install: done");
69
+ // Surface the top-level requested packages so callers can update
70
+ // package.json with the resolved version (mirrors `npm install --save`
71
+ // behavior). Sub-deps are not included.
72
+ return topLevelResolutions(opts.specs, nodes);
73
+ }
74
+ function topLevelResolutions(specs, nodes) {
75
+ // Top-level installs live at `node_modules/<name>` (no nesting). Build
76
+ // a name → root-node lookup limited to the top-level set.
77
+ const byName = new Map();
78
+ for (const n of nodes) {
79
+ if (n.installPath === `node_modules/${n.name}`)
80
+ byName.set(n.name, n);
81
+ }
82
+ const out = [];
83
+ for (const spec of specs) {
84
+ const name = parseSpecName(spec);
85
+ const node = byName.get(name);
86
+ if (node)
87
+ out.push({ name: node.name, version: node.version });
88
+ }
89
+ return out;
90
+ }
91
+ function parseSpecName(spec) {
92
+ if (spec.startsWith("@")) {
93
+ const slash = spec.indexOf("/");
94
+ if (slash === -1)
95
+ return spec;
96
+ const at = spec.indexOf("@", slash + 1);
97
+ return at === -1 ? spec : spec.slice(0, at);
98
+ }
99
+ const at = spec.indexOf("@");
100
+ return at === -1 ? spec : spec.slice(0, at);
48
101
  }
102
+ /**
103
+ * Tree-aware dependency resolution with npm v3+ hoisting semantics.
104
+ *
105
+ * - A dep is HOISTED (placed at `node_modules/<dep>`) when no existing
106
+ * placement conflicts with its required range — either it's not
107
+ * placed yet, or it's already at the root with a satisfying version.
108
+ * - A dep is NESTED (placed at `<requester>/node_modules/<dep>`) when
109
+ * the root has an incompatible version. Subsequent dependents of the
110
+ * same conflicting version reuse the nested placement.
111
+ *
112
+ * The walk is BFS over (requester, depName, depRange) edges. Top-level
113
+ * specs are seeded with a synthetic `null` requester so they hoist to
114
+ * the root. Each placement returns a `ResolvedNode` whose `installPath`
115
+ * captures where it lives in the tree.
116
+ */
49
117
  async function resolveDeps(specs, npmrc, log) {
50
118
  const packumentCache = new Map();
51
119
  const fetchPkg = (name) => {
@@ -56,45 +124,155 @@ async function resolveDeps(specs, npmrc, log) {
56
124
  packumentCache.set(name, fresh);
57
125
  return fresh;
58
126
  };
59
- const resolved = new Map();
60
- const queue = specs.map(parseSpec);
127
+ /** Every installed package keyed by `installPath`. */
128
+ const byPath = new Map();
129
+ /** Root placements indexed by name for the hoist-vs-nest decision. */
130
+ const root = new Map();
131
+ const queue = specs.map(parseSpec).map((s) => ({
132
+ from: null,
133
+ name: s.name,
134
+ range: s.range,
135
+ required: true,
136
+ }));
61
137
  while (queue.length > 0) {
62
- const spec = queue.shift();
63
- if (resolved.has(spec.name)) {
64
- // Single-version-per-name policy (npm v6 semantics). Phase 4 v2
65
- // (when peer-dep validation lands) revisits this for duplication.
138
+ const edge = queue.shift();
139
+ // Walk the ancestor chain to see whether a satisfying placement is
140
+ // already visible from the requester's `node_modules` lookup. npm's
141
+ // resolver does this each level of nesting acts as a fallback.
142
+ const visible = findVisible(edge.from, edge.name, byPath);
143
+ if (visible && satisfiesRange(visible.version, edge.range)) {
144
+ // Compatible placement reachable; reuse, no new install.
66
145
  continue;
67
146
  }
68
- const packument = await fetchPkg(spec.name);
69
- const version = pickVersion(packument, spec.range);
70
- if (!version) {
71
- throw new Error(`No version of ${spec.name} satisfies ${spec.range}`);
72
- }
73
- const v = packument.versions[version];
74
- if (!v) {
75
- throw new Error(`Packument for ${spec.name} promised ${version} but no entry exists`);
147
+ // No compatible existing placement. Resolve a fresh version.
148
+ let version = null;
149
+ try {
150
+ const packument = await fetchPkg(edge.name);
151
+ version = pickVersion(packument, edge.range);
152
+ if (!version) {
153
+ if (!edge.required)
154
+ continue;
155
+ throw new Error(`No version of ${edge.name} satisfies ${edge.range}`);
156
+ }
157
+ const v = packument.versions[version];
158
+ if (!v) {
159
+ throw new Error(`Packument for ${edge.name} promised ${version} but no entry exists`);
160
+ }
161
+ // Decision: hoist to root, or nest under the requester?
162
+ // - Hoist iff the root has no conflicting placement (i.e. the
163
+ // root slot for `name` is empty OR holds the same version).
164
+ // - Otherwise nest. Top-level specs (from === null) always
165
+ // hoist; the resolver guarantees they never conflict with
166
+ // each other because the input set is checked once.
167
+ const installPath = decidePlacement(edge.from, edge.name, version, root);
168
+ const node = {
169
+ name: edge.name,
170
+ version,
171
+ tarballUrl: v.dist.tarball,
172
+ integrity: v.dist.integrity,
173
+ installPath,
174
+ dependencies: v.dependencies ?? {},
175
+ optionalDependencies: v.optionalDependencies ?? {},
176
+ bin: v.bin,
177
+ };
178
+ byPath.set(installPath, node);
179
+ if (installPath === `node_modules/${edge.name}`) {
180
+ root.set(edge.name, node);
181
+ }
182
+ log("resolve: %s@%s ← %s (at %s)", edge.name, version, edge.range, installPath);
183
+ for (const [depName, depRange] of Object.entries(node.dependencies)) {
184
+ queue.push({ from: installPath, name: depName, range: depRange, required: true });
185
+ }
186
+ for (const [depName, depRange] of Object.entries(node.optionalDependencies)) {
187
+ queue.push({ from: installPath, name: depName, range: depRange, required: false });
188
+ }
76
189
  }
77
- const node = {
78
- name: spec.name,
79
- version,
80
- tarballUrl: v.dist.tarball,
81
- integrity: v.dist.integrity,
82
- dependencies: v.dependencies ?? {},
83
- optionalDependencies: v.optionalDependencies ?? {},
84
- bin: v.bin,
85
- };
86
- resolved.set(spec.name, node);
87
- log("resolve: %s@%s ← %s", spec.name, version, spec.range);
88
- for (const [depName, depRange] of Object.entries(node.dependencies)) {
89
- if (!resolved.has(depName))
90
- queue.push({ name: depName, range: depRange });
190
+ catch (e) {
191
+ // Optional deps that fail to resolve are skipped — yarn/npm
192
+ // behavior. Required deps re-throw.
193
+ if (!edge.required) {
194
+ log("resolve: optional dep %s@%s skipped (%s)", edge.name, edge.range, e.message);
195
+ continue;
196
+ }
197
+ throw e;
91
198
  }
92
- for (const [depName, depRange] of Object.entries(node.optionalDependencies)) {
93
- if (!resolved.has(depName))
94
- queue.push({ name: depName, range: depRange });
199
+ }
200
+ return Array.from(byPath.values());
201
+ }
202
+ /**
203
+ * Walk the ancestor `node_modules` chain from `requesterPath` upward,
204
+ * looking for a placement of `name` that the requester would resolve
205
+ * through Node's CommonJS lookup. Returns the first match — that's the
206
+ * one the requester actually sees at runtime.
207
+ */
208
+ function findVisible(requesterPath, name, byPath) {
209
+ // From the requester's directory, Node walks up node_modules dirs
210
+ // looking for `<dir>/node_modules/<name>`. Translate that to lockfile
211
+ // paths: any prefix of the requester's `installPath` that ends in a
212
+ // package directory gives a candidate `<prefix>/node_modules/<name>`.
213
+ //
214
+ // The requester itself ALSO checks its OWN `node_modules` first
215
+ // (i.e. `<requesterPath>/node_modules/<name>` — nested deps shadow
216
+ // ancestor ones). Then it walks up.
217
+ const candidates = [];
218
+ if (requesterPath !== null) {
219
+ candidates.push(`${requesterPath}/node_modules/${name}`);
220
+ // Walk up: strip the last `/node_modules/<pkg>` segment and try again.
221
+ let p = requesterPath;
222
+ // eslint-disable-next-line no-constant-condition
223
+ while (true) {
224
+ // Find the deepest `/node_modules/<pkg>` in `p`, strip it.
225
+ const idx = p.lastIndexOf("/node_modules/");
226
+ if (idx < 0)
227
+ break;
228
+ p = p.slice(0, idx);
229
+ candidates.push(`${p}/node_modules/${name}`);
230
+ if (p === "")
231
+ break;
95
232
  }
96
233
  }
97
- return Array.from(resolved.values());
234
+ // The root `node_modules/<name>` is the final candidate (covers the
235
+ // `requesterPath === null` case too).
236
+ candidates.push(`node_modules/${name}`);
237
+ for (const candidate of candidates) {
238
+ const hit = byPath.get(candidate);
239
+ if (hit)
240
+ return hit;
241
+ }
242
+ return null;
243
+ }
244
+ /**
245
+ * Decide where to install `name@version` for a request from `requesterPath`.
246
+ *
247
+ * - Root is empty for `name`: hoist (return `node_modules/<name>`).
248
+ * - Root has the SAME version: reuse the root placement.
249
+ * - Root has a DIFFERENT version: nest under the requester.
250
+ *
251
+ * Top-level requesters (requesterPath === null) always hoist.
252
+ */
253
+ function decidePlacement(requesterPath, name, version, root) {
254
+ const rootSlot = root.get(name);
255
+ if (!rootSlot)
256
+ return `node_modules/${name}`;
257
+ if (rootSlot.version === version)
258
+ return `node_modules/${name}`;
259
+ if (requesterPath === null) {
260
+ // Top-level specs are deduplicated by the caller before reaching
261
+ // here; this branch is defensive (would only fire on a duplicate
262
+ // top-level spec with conflicting versions).
263
+ return `node_modules/${name}`;
264
+ }
265
+ return `${requesterPath}/node_modules/${name}`;
266
+ }
267
+ function satisfiesRange(version, range) {
268
+ // dist-tag (e.g. `latest`) cannot be matched here — caller passed a
269
+ // raw range. Dist-tags only meaningful at fresh-resolve time.
270
+ try {
271
+ return satisfies(version, new Range(range));
272
+ }
273
+ catch {
274
+ return false;
275
+ }
98
276
  }
99
277
  function readLockfile(lockfilePath) {
100
278
  if (!fs.existsSync(lockfilePath))
@@ -113,10 +291,10 @@ function readLockfile(lockfilePath) {
113
291
  }
114
292
  function writeLockfile(lockfilePath, specs, nodes) {
115
293
  const packages = {};
116
- // Sort for deterministic output (diff-friendly).
117
- const sorted = [...nodes].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
294
+ // Sort by install path for deterministic, diff-friendly output.
295
+ const sorted = [...nodes].sort((a, b) => a.installPath < b.installPath ? -1 : a.installPath > b.installPath ? 1 : 0);
118
296
  for (const node of sorted) {
119
- packages[node.name] = {
297
+ packages[node.installPath] = {
120
298
  version: node.version,
121
299
  resolved: node.tarballUrl,
122
300
  integrity: node.integrity,
@@ -132,16 +310,26 @@ function writeLockfile(lockfilePath, specs, nodes) {
132
310
  fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2) + "\n");
133
311
  }
134
312
  function lockfileToNodes(lockfile) {
135
- return Object.entries(lockfile.packages).map(([name, entry]) => ({
136
- name,
313
+ return Object.entries(lockfile.packages).map(([installPath, entry]) => ({
314
+ // Recover the package name from the path: the last segment is
315
+ // either `<name>` (unscoped) or `@scope/<name>` (scoped).
316
+ name: nameFromInstallPath(installPath),
137
317
  version: entry.version,
138
318
  tarballUrl: entry.resolved,
139
319
  integrity: entry.integrity,
320
+ installPath,
140
321
  dependencies: entry.dependencies ?? {},
141
322
  optionalDependencies: {},
142
323
  bin: entry.bin,
143
324
  }));
144
325
  }
326
+ function nameFromInstallPath(installPath) {
327
+ // Last `node_modules/` boundary, then the rest is the package name
328
+ // (single segment unscoped, or `@scope/pkg` scoped).
329
+ const idx = installPath.lastIndexOf("/node_modules/");
330
+ const after = idx < 0 ? installPath.replace(/^node_modules\//, "") : installPath.slice(idx + "/node_modules/".length);
331
+ return after;
332
+ }
145
333
  function lockfileMatchesRequest(lockfile, specs) {
146
334
  if (lockfile.requested.length !== specs.length)
147
335
  return false;
@@ -149,6 +337,32 @@ function lockfileMatchesRequest(lockfile, specs) {
149
337
  const b = [...specs].sort();
150
338
  return a.every((v, i) => v === b[i]);
151
339
  }
340
+ /**
341
+ * Human-readable diff between `lockfile.requested` and the live request.
342
+ * Returns null when the two sets are identical (the lockfile is in sync).
343
+ * Used by `--immutable` to surface exactly which deps drifted, so CI
344
+ * failures don't force the user to diff lockfile JSON by hand.
345
+ */
346
+ function describeLockfileDrift(lockfile, specs) {
347
+ const lockSet = new Set(lockfile.requested);
348
+ const liveSet = new Set(specs);
349
+ const added = [];
350
+ const removed = [];
351
+ for (const s of liveSet)
352
+ if (!lockSet.has(s))
353
+ added.push(s);
354
+ for (const s of lockSet)
355
+ if (!liveSet.has(s))
356
+ removed.push(s);
357
+ if (added.length === 0 && removed.length === 0)
358
+ return null;
359
+ const lines = [];
360
+ if (added.length > 0)
361
+ lines.push(` + ${added.sort().join("\n + ")}`);
362
+ if (removed.length > 0)
363
+ lines.push(` - ${removed.sort().join("\n - ")}`);
364
+ return lines.join("\n");
365
+ }
152
366
  function parseSpec(raw) {
153
367
  if (raw.startsWith("@")) {
154
368
  const slash = raw.indexOf("/");
@@ -188,42 +402,77 @@ function pickVersion(packument, range) {
188
402
  return maxSatisfying(versions, parsedRange);
189
403
  }
190
404
  async function downloadAndExtractAll(nodes, prefix, npmrc, log) {
191
- const queue = [...nodes];
405
+ // Sort by install-path depth ascending so parents extract before
406
+ // children. Extracting a parent on top of an existing child would
407
+ // wipe out the child.
408
+ const queue = [...nodes].sort((a, b) => depth(a.installPath) - depth(b.installPath) ||
409
+ (a.installPath < b.installPath ? -1 : 1));
192
410
  const workers = [];
193
411
  const concurrency = Math.max(1, Math.min(DEFAULT_CONCURRENCY, queue.length));
412
+ // Parents (depth 1) are extracted serially first to avoid concurrent
413
+ // `rm -rf` + extract races with their children. Once depth-1 is done,
414
+ // depths >=2 run with full concurrency.
415
+ let cursor = 0;
416
+ const depth1End = queue.findIndex((n) => depth(n.installPath) > 1);
417
+ const splitAt = depth1End < 0 ? queue.length : depth1End;
418
+ // Serial root pass.
419
+ while (cursor < splitAt) {
420
+ const node = queue[cursor++];
421
+ if (!node)
422
+ break;
423
+ await extractOne(node, prefix, npmrc, log);
424
+ }
425
+ // Concurrent nested pass.
194
426
  for (let i = 0; i < concurrency; i++) {
195
- workers.push(worker());
427
+ workers.push((async () => {
428
+ while (true) {
429
+ const idx = cursor++;
430
+ if (idx >= queue.length)
431
+ return;
432
+ const node = queue[idx];
433
+ if (!node)
434
+ return;
435
+ await extractOne(node, prefix, npmrc, log);
436
+ }
437
+ })());
196
438
  }
197
439
  await Promise.all(workers);
198
- async function worker() {
199
- while (queue.length > 0) {
200
- const node = queue.shift();
201
- if (!node)
202
- return;
203
- const dest = path.join(prefix, "node_modules", node.name);
204
- log("fetch: %s@%s ← %s", node.name, node.version, node.tarballUrl);
205
- const bytes = await fetchTarball(node.tarballUrl, {
206
- npmrc,
207
- integrity: node.integrity,
208
- });
209
- fs.rmSync(dest, { recursive: true, force: true });
210
- fs.mkdirSync(dest, { recursive: true });
211
- await extractTarball(bytes, dest);
212
- }
213
- }
440
+ }
441
+ async function extractOne(node, prefix, npmrc, log) {
442
+ const dest = path.join(prefix, node.installPath);
443
+ log("fetch: %s@%s ← %s (→ %s)", node.name, node.version, node.tarballUrl, node.installPath);
444
+ const bytes = await fetchTarball(node.tarballUrl, {
445
+ npmrc,
446
+ integrity: node.integrity,
447
+ });
448
+ fs.rmSync(dest, { recursive: true, force: true });
449
+ fs.mkdirSync(dest, { recursive: true });
450
+ await extractTarball(bytes, dest);
451
+ }
452
+ function depth(installPath) {
453
+ // Count `node_modules/` segments to know nesting depth.
454
+ // `node_modules/foo` = 1, `node_modules/foo/node_modules/bar` = 2, etc.
455
+ return installPath.split("/node_modules/").length;
214
456
  }
215
457
  async function linkBins(nodes, prefix, log) {
458
+ // Only root-level packages publish bins into the top-level
459
+ // `node_modules/.bin/`. Nested-package bins are addressable by their
460
+ // direct dependents through the nested .bin (npm matches this) — we
461
+ // omit nested-bin linking for now since no consumer of the install
462
+ // backend depends on it (gjsify's own use cases all hit root bins).
216
463
  const binDir = path.join(prefix, "node_modules", ".bin");
217
464
  let created = 0;
218
465
  for (const node of nodes) {
219
466
  if (!node.bin)
220
467
  continue;
468
+ if (depth(node.installPath) !== 1)
469
+ continue;
221
470
  const map = normalizeBin(node.name, node.bin);
222
471
  if (map.size === 0)
223
472
  continue;
224
473
  fs.mkdirSync(binDir, { recursive: true });
225
474
  for (const [binName, binTarget] of map) {
226
- const targetAbs = path.join(prefix, "node_modules", node.name, binTarget);
475
+ const targetAbs = path.join(prefix, node.installPath, binTarget);
227
476
  if (!fs.existsSync(targetAbs))
228
477
  continue;
229
478
  try {
@@ -265,25 +514,35 @@ function normalizeBin(pkgName, bin) {
265
514
  }
266
515
  async function loadNpmrc(opts) {
267
516
  const home = os.homedir();
268
- const homeRc = path.join(home, ".npmrc");
269
517
  let parsed = {
270
518
  registry: opts.registry ?? DEFAULT_REGISTRY,
271
519
  scopes: {},
272
520
  authTokens: {},
273
521
  basicAuth: {},
274
522
  };
275
- if (fs.existsSync(homeRc)) {
523
+ // Layered .npmrc lookup (most-specific wins): home → project (cwd's
524
+ // prefix). npm itself merges through `XDG_CONFIG_HOME/npm/npmrc` and a
525
+ // workspace-root one too; the gjsify project-local case is what users
526
+ // hit most often (mock-registry tests, scoped-registry overrides), so
527
+ // we cover that explicitly.
528
+ for (const candidate of [path.join(home, ".npmrc"), path.join(opts.prefix, ".npmrc")]) {
529
+ if (!fs.existsSync(candidate))
530
+ continue;
276
531
  try {
277
- parsed = parseNpmrc(fs.readFileSync(homeRc, "utf-8"));
532
+ const projectParsed = parseNpmrc(fs.readFileSync(candidate, "utf-8"));
533
+ parsed = { ...parsed, ...projectParsed, scopes: { ...parsed.scopes, ...projectParsed.scopes } };
278
534
  }
279
535
  catch (e) {
280
- // Don't let a busted .npmrc prevent installs from anonymous registries.
281
- console.warn(`gjsify install: ignoring malformed ${homeRc}: ${e.message}`);
536
+ console.warn(`gjsify install: ignoring malformed ${candidate}: ${e.message}`);
282
537
  }
283
538
  }
284
- if (opts.registry) {
539
+ // env-var override (npm convention: `npm_config_registry`).
540
+ const envRegistry = process.env.npm_config_registry;
541
+ if (envRegistry)
542
+ parsed.registry = envRegistry;
543
+ // Explicit caller-provided registry trumps everything else.
544
+ if (opts.registry)
285
545
  parsed.registry = opts.registry;
286
- }
287
546
  return parsed;
288
547
  }
289
548
  function makeLogger(verbose) {
@@ -16,4 +16,14 @@ export interface InstallOptions {
16
16
  /** Use `<prefix>/gjsify-lock.json` as the source of truth — fail if missing. */
17
17
  frozen?: boolean;
18
18
  }
19
- export declare function installPackages(opts: InstallOptions): Promise<void>;
19
+ export interface InstallResult {
20
+ /** Top-level packages that were requested, with the version each
21
+ * resolved to. Empty for the npm backend (parsing npm's stdout would
22
+ * be unreliable; callers that need this should set
23
+ * GJSIFY_INSTALL_BACKEND=native). */
24
+ installed: Array<{
25
+ name: string;
26
+ version: string;
27
+ }>;
28
+ }
29
+ export declare function installPackages(opts: InstallOptions): Promise<InstallResult>;
@@ -18,10 +18,12 @@ import { join } from 'node:path';
18
18
  const DEFAULT_BACKEND = process.env.GJSIFY_INSTALL_BACKEND ?? 'native';
19
19
  export async function installPackages(opts) {
20
20
  if (DEFAULT_BACKEND === 'npm') {
21
- return installViaNpm(opts);
21
+ await installViaNpm(opts);
22
+ return { installed: [] };
22
23
  }
23
24
  const { installPackagesNative } = await import('./install-backend-native.js');
24
- return installPackagesNative(opts);
25
+ const installed = await installPackagesNative(opts);
26
+ return { installed };
25
27
  }
26
28
  async function installViaNpm({ prefix, specs, verbose, registry }) {
27
29
  if (specs.length === 0) {
@@ -0,0 +1,47 @@
1
+ export type DependencyKind = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies';
2
+ export interface PackageJson {
3
+ name?: string;
4
+ version?: string;
5
+ type?: string;
6
+ workspaces?: string[] | {
7
+ packages?: string[];
8
+ nohoist?: string[];
9
+ };
10
+ dependencies?: Record<string, string>;
11
+ devDependencies?: Record<string, string>;
12
+ peerDependencies?: Record<string, string>;
13
+ optionalDependencies?: Record<string, string>;
14
+ [key: string]: unknown;
15
+ }
16
+ export declare function readPackageJson(pkgPath: string): PackageJson | null;
17
+ export declare function writePackageJson(pkgPath: string, pkg: PackageJson): void;
18
+ /**
19
+ * Parse a user spec into `{ name, range }`:
20
+ * `react` → { name: 'react', range: undefined }
21
+ * `react@^18` → { name: 'react', range: '^18' }
22
+ * `@types/node` → { name: '@types/node', range: undefined }
23
+ * `@types/node@1` → { name: '@types/node', range: '1' }
24
+ */
25
+ export declare function parseSpec(spec: string): {
26
+ name: string;
27
+ range?: string;
28
+ };
29
+ /**
30
+ * Collect existing dependencies + devDependencies + optionalDependencies
31
+ * from a project package.json into installable specs of the form
32
+ * `name@range`. Used by `gjsify install` (no args) to seed the resolver
33
+ * with the project's existing dependency manifest — equivalent to
34
+ * `npm install` reading `package.json`.
35
+ */
36
+ export declare function projectSpecsFromPackageJson(pkg: PackageJson): string[];
37
+ /**
38
+ * Add or update a dependency entry in `pkg`. If the spec didn't include
39
+ * a range, callers fill in the installed version after resolution and
40
+ * call this again with `installedVersion` set.
41
+ */
42
+ export declare function addDependencyEntry(pkg: PackageJson, name: string, range: string, kind: DependencyKind): void;
43
+ /**
44
+ * Default version range when the user didn't pin one: `^x.y.z` from the
45
+ * installed version. Mirrors npm's `save-prefix` default (`^`).
46
+ */
47
+ export declare function defaultRangeFromVersion(version: string): string;