@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.
- package/dist/cli.gjs.mjs +791 -0
- package/lib/actions/build.js +4 -17
- package/lib/bundler-pick.d.ts +79 -0
- package/lib/bundler-pick.js +436 -0
- package/lib/commands/foreach.d.ts +17 -0
- package/lib/commands/foreach.js +341 -0
- package/lib/commands/index.d.ts +2 -0
- package/lib/commands/index.js +2 -0
- package/lib/commands/install.d.ts +1 -0
- package/lib/commands/install.js +401 -27
- package/lib/commands/run.d.ts +1 -1
- package/lib/commands/run.js +113 -20
- package/lib/commands/workspace.d.ts +8 -0
- package/lib/commands/workspace.js +79 -0
- package/lib/config.js +12 -1
- package/lib/index.js +11 -3
- package/lib/types/config-data.d.ts +10 -1
- package/lib/utils/install-backend-native.d.ts +5 -1
- package/lib/utils/install-backend-native.js +329 -70
- package/lib/utils/install-backend.d.ts +11 -1
- package/lib/utils/install-backend.js +4 -2
- package/lib/utils/pkg-json-edit.d.ts +47 -0
- package/lib/utils/pkg-json-edit.js +108 -0
- package/lib/utils/workspace-root.d.ts +1 -0
- package/lib/utils/workspace-root.js +46 -0
- package/package.json +70 -44
- package/src/actions/build.ts +0 -431
- package/src/actions/index.ts +0 -1
- package/src/commands/build.ts +0 -146
- package/src/commands/check.ts +0 -87
- package/src/commands/create.ts +0 -63
- package/src/commands/dlx.ts +0 -195
- package/src/commands/flatpak/build.ts +0 -225
- package/src/commands/flatpak/ci.ts +0 -173
- package/src/commands/flatpak/deps.ts +0 -120
- package/src/commands/flatpak/index.ts +0 -53
- package/src/commands/flatpak/init.ts +0 -191
- package/src/commands/flatpak/utils.ts +0 -76
- package/src/commands/gettext.ts +0 -258
- package/src/commands/gresource.ts +0 -97
- package/src/commands/gsettings.ts +0 -87
- package/src/commands/index.ts +0 -12
- package/src/commands/info.ts +0 -70
- package/src/commands/install.ts +0 -195
- package/src/commands/run.ts +0 -33
- package/src/commands/showcase.ts +0 -149
- package/src/config.ts +0 -304
- package/src/constants.ts +0 -1
- package/src/index.ts +0 -37
- package/src/types/cli-build-options.ts +0 -100
- package/src/types/command.ts +0 -10
- package/src/types/config-data-library.ts +0 -5
- package/src/types/config-data-typescript.ts +0 -6
- package/src/types/config-data.ts +0 -225
- package/src/types/cosmiconfig-result.ts +0 -5
- package/src/types/index.ts +0 -6
- package/src/utils/check-system-deps.ts +0 -480
- package/src/utils/detect-native-packages.ts +0 -153
- package/src/utils/discover-showcases.ts +0 -75
- package/src/utils/dlx-cache.ts +0 -135
- package/src/utils/install-backend-native.ts +0 -363
- package/src/utils/install-backend.ts +0 -88
- package/src/utils/install-global.ts +0 -182
- package/src/utils/normalize-bundler-options.ts +0 -129
- package/src/utils/parse-spec.ts +0 -48
- package/src/utils/resolve-gjs-entry.ts +0 -96
- package/src/utils/resolve-plugin-by-name.ts +0 -106
- package/src/utils/run-gjs.ts +0 -90
- 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
|
-
//
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
60
|
-
const
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|
117
|
-
const sorted = [...nodes].sort((a, b) =>
|
|
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.
|
|
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(([
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
21
|
+
await installViaNpm(opts);
|
|
22
|
+
return { installed: [] };
|
|
22
23
|
}
|
|
23
24
|
const { installPackagesNative } = await import('./install-backend-native.js');
|
|
24
|
-
|
|
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;
|