@intx/tool-packaging 0.2.2
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/LICENSE +176 -0
- package/README.md +116 -0
- package/dist/atomic-apply.d.ts +65 -0
- package/dist/atomic-apply.js +223 -0
- package/dist/cache.d.ts +89 -0
- package/dist/cache.js +750 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +30 -0
- package/dist/loader.d.ts +189 -0
- package/dist/loader.js +1387 -0
- package/dist/package-json-extract.d.ts +56 -0
- package/dist/package-json-extract.js +125 -0
- package/dist/resolver.d.ts +241 -0
- package/dist/resolver.js +821 -0
- package/package.json +39 -0
package/dist/resolver.js
ADDED
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference -- npm-team packages ship no types; declarations.d.ts must be visible to downstream typecheckers that import from this package's source.
|
|
2
|
+
/// <reference path="./declarations.d.ts" />
|
|
3
|
+
// Hub-side npm closure resolver.
|
|
4
|
+
//
|
|
5
|
+
// Walks a list of agent-pinned tool packages, resolves each pin against
|
|
6
|
+
// a configured registry source (HTTP-backed `npm-registry-fetch` or an
|
|
7
|
+
// in-process `package-registry` asset), recurses on transitive
|
|
8
|
+
// `dependencies` and `optionalDependencies`, and produces a
|
|
9
|
+
// `ToolPackageManifest` carrying the full pinned closure with per-entry
|
|
10
|
+
// source tags and integrity strings.
|
|
11
|
+
//
|
|
12
|
+
// Per-package responsibilities:
|
|
13
|
+
//
|
|
14
|
+
// - Spec parsing via npm-package-arg.
|
|
15
|
+
// - Packument fetching via a `RegistrySource`:
|
|
16
|
+
// - `HttpRegistrySource` wraps `npm-registry-fetch`.
|
|
17
|
+
// - `AssetRegistrySource` opens tarballs out of a
|
|
18
|
+
// `package-registry` asset via the asset service's in-process
|
|
19
|
+
// read API.
|
|
20
|
+
// - Version picking via npm-pick-manifest.
|
|
21
|
+
// - Scope routing: `@scope/foo` requests can be routed to a specific
|
|
22
|
+
// registry name from the scopeRouting table; everything else uses
|
|
23
|
+
// the registry named by `defaultRegistry`.
|
|
24
|
+
// - Per-entry source materialization: the walker asks the
|
|
25
|
+
// `RegistrySource` for the entry's `source` and `tarballUrl`. HTTP
|
|
26
|
+
// sources emit `kind: "registry"` plus the picked tarball URL;
|
|
27
|
+
// asset sources emit `kind: "asset"` carrying the asset id and the
|
|
28
|
+
// in-asset path.
|
|
29
|
+
// - Peer-dependency validation: peerDependencies declared by any
|
|
30
|
+
// entry must be satisfied by some other entry in the closure;
|
|
31
|
+
// unsatisfied peers throw ManifestInvalidError before the closure
|
|
32
|
+
// is returned. The walker captures peer-dep metadata during the
|
|
33
|
+
// walk so validation does not require a second pass over the
|
|
34
|
+
// network.
|
|
35
|
+
import npmPickManifest from "npm-pick-manifest";
|
|
36
|
+
import npmRegistryFetch from "npm-registry-fetch";
|
|
37
|
+
import npmPackageArg from "npm-package-arg";
|
|
38
|
+
import semver from "semver";
|
|
39
|
+
import ssri from "ssri";
|
|
40
|
+
import { getLogger } from "@intx/log";
|
|
41
|
+
import { extractTarballPackageJSON } from "./package-json-extract.js";
|
|
42
|
+
const logger = getLogger(["hub", "tool-packaging", "resolver"]);
|
|
43
|
+
import {} from "@intx/types/tool-packages";
|
|
44
|
+
/**
|
|
45
|
+
* Thrown by `resolveClosure` when the resolved closure does not satisfy
|
|
46
|
+
* one or more peer-dependency declarations, by the closure walker for
|
|
47
|
+
* duplicate-name pins, and by the hub-side session-launch path when a
|
|
48
|
+
* direct package-registry attachment overlaps a resolver-driven pin
|
|
49
|
+
* for the same asset. The deploy-assembly path maps every shape to
|
|
50
|
+
* the same `manifest.invalid` deploy-apply error category before the
|
|
51
|
+
* deploy ships.
|
|
52
|
+
*
|
|
53
|
+
* The category is intentionally broad: peer-dep violations and
|
|
54
|
+
* duplicate-name pins are structurally distinct defects but both
|
|
55
|
+
* indicate a closure the operator cannot ship without changing the
|
|
56
|
+
* pin set. Adding a per-shape category would expand the wire
|
|
57
|
+
* taxonomy for no operator-facing gain; readers parsing
|
|
58
|
+
* deploy-apply errors should look at the message for the structural
|
|
59
|
+
* distinction.
|
|
60
|
+
*/
|
|
61
|
+
export class ManifestInvalidError extends Error {
|
|
62
|
+
/**
|
|
63
|
+
* Populated only when the constructor was invoked with the
|
|
64
|
+
* structured peer-dependency form. String-constructed instances
|
|
65
|
+
* (duplicate-name pins, direct-vs-resolver-asset conflicts) carry
|
|
66
|
+
* an empty array — readers that need the structural distinction
|
|
67
|
+
* should consult `message` rather than treating `violations.length
|
|
68
|
+
* === 0` as a signal.
|
|
69
|
+
*/
|
|
70
|
+
violations;
|
|
71
|
+
constructor(violationsOrMessage) {
|
|
72
|
+
if (typeof violationsOrMessage === "string") {
|
|
73
|
+
super(violationsOrMessage);
|
|
74
|
+
this.violations = [];
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
super(`manifest peer-dependency violations: ${violationsOrMessage
|
|
78
|
+
.map((v) => `${v.dependent.name}@${v.dependent.version} requires ${v.peer.name}@${v.peer.range}`)
|
|
79
|
+
.join("; ")}`);
|
|
80
|
+
this.violations = violationsOrMessage;
|
|
81
|
+
}
|
|
82
|
+
this.name = "ManifestInvalidError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* HTTP-backed registry source. Wraps `npm-registry-fetch` for packument
|
|
87
|
+
* lookups and emits registry-shaped manifest entries.
|
|
88
|
+
*/
|
|
89
|
+
export class HttpRegistrySource {
|
|
90
|
+
name;
|
|
91
|
+
#config;
|
|
92
|
+
#fetchPackument;
|
|
93
|
+
constructor(args) {
|
|
94
|
+
this.name = args.name;
|
|
95
|
+
this.#config = args.config;
|
|
96
|
+
this.#fetchPackument = args.fetchPackument ?? makeDefaultHttpFetcher();
|
|
97
|
+
}
|
|
98
|
+
async fetchPackument(name) {
|
|
99
|
+
return this.#fetchPackument(name, this.#config);
|
|
100
|
+
}
|
|
101
|
+
materializeRefForEntry(_name, _version, picked) {
|
|
102
|
+
return {
|
|
103
|
+
source: { kind: "registry", registry: this.name },
|
|
104
|
+
tarballUrl: picked.dist.tarball,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Asset-backed registry source. Reads tarballs from a
|
|
110
|
+
* `package-registry` asset via the asset service's in-process read
|
|
111
|
+
* API, extracts each tarball's `package.json`, and synthesizes a
|
|
112
|
+
* packument keyed by package name with one version entry per tarball.
|
|
113
|
+
*
|
|
114
|
+
* Caller passes bound `readBlob`/`listBlobs` methods so the resolver
|
|
115
|
+
* does not import the asset service directly; the session service
|
|
116
|
+
* adapts `AssetService.readAssetBlob` / `AssetService.listAssetBlobs`
|
|
117
|
+
* to these signatures at construction.
|
|
118
|
+
*
|
|
119
|
+
* The packument is cached in-instance for the lifetime of the source,
|
|
120
|
+
* so one resolution pass touches each tarball at most once even when
|
|
121
|
+
* a name is asked for multiple times.
|
|
122
|
+
*/
|
|
123
|
+
export class AssetRegistrySource {
|
|
124
|
+
name;
|
|
125
|
+
#assetId;
|
|
126
|
+
#readBlob;
|
|
127
|
+
#listBlobs;
|
|
128
|
+
// Packuments keyed by package name. Built on first access by
|
|
129
|
+
// scanning every `tarballs/*.tgz` blob.
|
|
130
|
+
#packumentsByName = null;
|
|
131
|
+
// Asset-relative path keyed by `${name}@${version}` so
|
|
132
|
+
// materializeRefForEntry can emit the entry's source tag without a
|
|
133
|
+
// second scan of the asset tree.
|
|
134
|
+
#pathByNameVersion = new Map();
|
|
135
|
+
constructor(args) {
|
|
136
|
+
this.name = args.name;
|
|
137
|
+
this.#assetId = args.assetId;
|
|
138
|
+
this.#readBlob = args.readBlob;
|
|
139
|
+
this.#listBlobs = args.listBlobs;
|
|
140
|
+
}
|
|
141
|
+
async fetchPackument(name) {
|
|
142
|
+
if (this.#packumentsByName === null) {
|
|
143
|
+
this.#packumentsByName = await this.#buildPackuments();
|
|
144
|
+
}
|
|
145
|
+
const p = this.#packumentsByName.get(name);
|
|
146
|
+
if (p === undefined) {
|
|
147
|
+
throw new Error(`asset registry "${this.name}" (asset ${this.#assetId}) has no tarball publishing package "${name}"`);
|
|
148
|
+
}
|
|
149
|
+
return p;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Resolve a `(name, version)` pair to its asset-relative tarball
|
|
153
|
+
* path. Must be called after `fetchPackument` has populated the
|
|
154
|
+
* internal index for that package — the walker calls them in that
|
|
155
|
+
* order, so the ordering is implicit at the call site. Non-walker
|
|
156
|
+
* callers must call `fetchPackument` first or accept the structured
|
|
157
|
+
* error this method throws when the index lookup misses.
|
|
158
|
+
*/
|
|
159
|
+
materializeRefForEntry(name, version, _picked) {
|
|
160
|
+
const key = `${name}@${version}`;
|
|
161
|
+
const path = this.#pathByNameVersion.get(key);
|
|
162
|
+
if (path === undefined) {
|
|
163
|
+
// The index entry is missing for one of two reasons: either the
|
|
164
|
+
// registry's `fetchPackument` was not called for `name` before
|
|
165
|
+
// this materializer ran (caller-side ordering bug), or
|
|
166
|
+
// `fetchPackument` ran but found no tarball publishing the
|
|
167
|
+
// exact `name@version` pair (the resolver picked a version the
|
|
168
|
+
// registry's listing did not advertise). Either is a precondition
|
|
169
|
+
// violation; surface the situation rather than the inferred cause.
|
|
170
|
+
throw new Error(`asset registry "${this.name}" (asset ${this.#assetId}) has no recorded path for ${key}; either fetchPackument was not called on this registry for "${name}" or the registry does not publish this version`);
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
source: {
|
|
174
|
+
kind: "asset",
|
|
175
|
+
assetId: this.#assetId,
|
|
176
|
+
path,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
async #buildPackuments() {
|
|
181
|
+
const blobs = await this.#listBlobs("tarballs");
|
|
182
|
+
const byName = new Map();
|
|
183
|
+
for (const filename of blobs) {
|
|
184
|
+
// Defensive: the package-registry kind handler enforces the
|
|
185
|
+
// `tarballs/<filename>.tgz` shape on push, but a corrupt repo or
|
|
186
|
+
// an out-of-band write could land non-tarball entries here. Skip
|
|
187
|
+
// anything that does not look like a tarball rather than
|
|
188
|
+
// accepting it as a synthetic packument entry.
|
|
189
|
+
if (!filename.endsWith(".tgz"))
|
|
190
|
+
continue;
|
|
191
|
+
const repoPath = `tarballs/${filename}`;
|
|
192
|
+
const bytes = await this.#readBlob(repoPath);
|
|
193
|
+
const integrity = ssri
|
|
194
|
+
.fromData(bytes, { algorithms: ["sha512"] })
|
|
195
|
+
.toString();
|
|
196
|
+
const extracted = await extractPackageJSON(this.name, filename, bytes);
|
|
197
|
+
const validated = extracted.parsed;
|
|
198
|
+
const packument = byName.get(validated.name);
|
|
199
|
+
const versionEntry = {
|
|
200
|
+
name: validated.name,
|
|
201
|
+
version: validated.version,
|
|
202
|
+
dist: { tarball: repoPath, integrity },
|
|
203
|
+
...readDependencyFields(extracted.raw),
|
|
204
|
+
};
|
|
205
|
+
if (packument === undefined) {
|
|
206
|
+
byName.set(validated.name, {
|
|
207
|
+
name: validated.name,
|
|
208
|
+
versions: { [validated.version]: versionEntry },
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
packument.versions[validated.version] = versionEntry;
|
|
213
|
+
}
|
|
214
|
+
this.#pathByNameVersion.set(`${validated.name}@${validated.version}`, repoPath);
|
|
215
|
+
logger.debug `asset registry "${this.name}" cataloged ${validated.name}@${validated.version} at ${repoPath}`;
|
|
216
|
+
}
|
|
217
|
+
return byName;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** True for a `peerDependenciesMeta` object: a record whose values are
|
|
221
|
+
* objects carrying an optional boolean `optional` flag. */
|
|
222
|
+
function isPeerMetaRecord(value) {
|
|
223
|
+
if (!isPlainObject(value))
|
|
224
|
+
return false;
|
|
225
|
+
return Object.values(value).every((entry) => isPlainObject(entry) &&
|
|
226
|
+
(entry["optional"] === undefined ||
|
|
227
|
+
typeof entry["optional"] === "boolean"));
|
|
228
|
+
}
|
|
229
|
+
function readDependencyFields(raw) {
|
|
230
|
+
if (!isPlainObject(raw))
|
|
231
|
+
return {};
|
|
232
|
+
const out = {};
|
|
233
|
+
const deps = raw["dependencies"];
|
|
234
|
+
if (isStringRecord(deps))
|
|
235
|
+
out.dependencies = deps;
|
|
236
|
+
const optionalDeps = raw["optionalDependencies"];
|
|
237
|
+
if (isStringRecord(optionalDeps))
|
|
238
|
+
out.optionalDependencies = optionalDeps;
|
|
239
|
+
const peerDeps = raw["peerDependencies"];
|
|
240
|
+
if (isStringRecord(peerDeps))
|
|
241
|
+
out.peerDependencies = peerDeps;
|
|
242
|
+
const peerMeta = raw["peerDependenciesMeta"];
|
|
243
|
+
if (isPeerMetaRecord(peerMeta))
|
|
244
|
+
out.peerDependenciesMeta = peerMeta;
|
|
245
|
+
const osField = raw["os"];
|
|
246
|
+
if (isStringArray(osField))
|
|
247
|
+
out.os = osField;
|
|
248
|
+
const cpuField = raw["cpu"];
|
|
249
|
+
if (isStringArray(cpuField))
|
|
250
|
+
out.cpu = cpuField;
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
function isPlainObject(value) {
|
|
254
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
255
|
+
}
|
|
256
|
+
function isStringRecord(value) {
|
|
257
|
+
// Reject arrays explicitly. `typeof [] === "object"` is true and
|
|
258
|
+
// `Object.values(["foo"])` yields `["foo"]`, so an array of strings
|
|
259
|
+
// would otherwise satisfy this predicate — and the resolver's
|
|
260
|
+
// closure walk would then iterate `Object.entries(arr)` and treat
|
|
261
|
+
// the numeric indices ("0", "1") as package names to fetch
|
|
262
|
+
// packuments for. The sibling `isPlainObject` already rejects
|
|
263
|
+
// arrays; mirror that here so the same guarantee holds at every
|
|
264
|
+
// shape check.
|
|
265
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
for (const v of Object.values(value)) {
|
|
269
|
+
if (typeof v !== "string")
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
function isStringArray(value) {
|
|
275
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* npm scope shape: `@` followed by a name character class that matches
|
|
279
|
+
* what npm itself accepts in `validate-npm-package-name`. We validate
|
|
280
|
+
* the routing entries here rather than during pin resolution because
|
|
281
|
+
* a typo (`intx` for `@intx`) silently never matches and the package
|
|
282
|
+
* falls through to the default registry — the exact failure mode the
|
|
283
|
+
* scope-routing feature exists to prevent.
|
|
284
|
+
*/
|
|
285
|
+
const NPM_SCOPE_RE = /^@[a-z0-9][a-z0-9-_]*$/;
|
|
286
|
+
export function createClosureResolver(config) {
|
|
287
|
+
if (config.registries.size === 0) {
|
|
288
|
+
throw new Error("createClosureResolver: registries cannot be empty");
|
|
289
|
+
}
|
|
290
|
+
const maybeDefault = config.registries.get(config.defaultRegistry);
|
|
291
|
+
if (maybeDefault === undefined) {
|
|
292
|
+
throw new Error(`createClosureResolver: defaultRegistry "${config.defaultRegistry}" not present in registries map`);
|
|
293
|
+
}
|
|
294
|
+
if (config.scopeRouting !== undefined) {
|
|
295
|
+
for (const route of config.scopeRouting) {
|
|
296
|
+
if (!NPM_SCOPE_RE.test(route.scope)) {
|
|
297
|
+
throw new Error(`createClosureResolver: scopeRouting entry has invalid scope ${JSON.stringify(route.scope)}; scopes must match ${NPM_SCOPE_RE.toString()} (e.g. "@intx")`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const defaultSource = maybeDefault;
|
|
302
|
+
function resolveSource(packageName) {
|
|
303
|
+
const scope = parseScope(packageName);
|
|
304
|
+
if (scope !== null && config.scopeRouting !== undefined) {
|
|
305
|
+
const route = config.scopeRouting.find((r) => r.scope === scope);
|
|
306
|
+
if (route !== undefined) {
|
|
307
|
+
const src = config.registries.get(route.registry);
|
|
308
|
+
if (src === undefined) {
|
|
309
|
+
throw new Error(`scopeRouting references unknown registry "${route.registry}" for scope "${scope}"`);
|
|
310
|
+
}
|
|
311
|
+
return src;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return defaultSource;
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
async resolveClosure(pins) {
|
|
318
|
+
// Reject duplicate-name pins up front. The walker's
|
|
319
|
+
// `topLevelResolved` map is keyed by name, so two pins with the
|
|
320
|
+
// same name but different ranges would silently collapse to the
|
|
321
|
+
// first arrival's resolved version — the second pin would never
|
|
322
|
+
// be looked up at the topLevel emit site and the manifest would
|
|
323
|
+
// record only one of the two as a top-level. The contract every
|
|
324
|
+
// pin set must satisfy is one entry per name; surface a
|
|
325
|
+
// violation at the resolver boundary rather than letting the
|
|
326
|
+
// silent collapse happen downstream.
|
|
327
|
+
const seenPinNames = new Set();
|
|
328
|
+
for (const pin of pins) {
|
|
329
|
+
if (seenPinNames.has(pin.name)) {
|
|
330
|
+
throw new ManifestInvalidError(`duplicate pin name ${pin.name}: each ToolPackagePin set must contain at most one entry per package name`);
|
|
331
|
+
}
|
|
332
|
+
seenPinNames.add(pin.name);
|
|
333
|
+
}
|
|
334
|
+
const entries = new Map();
|
|
335
|
+
const peerDeclarations = [];
|
|
336
|
+
// Per-walk packument cache. The dedup that gates the queue is
|
|
337
|
+
// keyed by `(name, range, subtreeId)` to keep subtree poisoning
|
|
338
|
+
// contained — the same `(name, range)` seen first inside one
|
|
339
|
+
// optional subtree must still be reprocessed when it later arrives
|
|
340
|
+
// through an unrelated subtree, otherwise dropping the first
|
|
341
|
+
// subtree would silently drop the dep for the second one too.
|
|
342
|
+
// That correctness requirement would multiply packument requests
|
|
343
|
+
// against the upstream registry; this cache keeps each
|
|
344
|
+
// `(registry, name)` pair to one network round-trip per walk.
|
|
345
|
+
const packumentCache = new Map();
|
|
346
|
+
function fetchPackumentCached(source, name) {
|
|
347
|
+
// Length-prefix the registry name so two registries whose names
|
|
348
|
+
// happen to overlap with the package-name separator (e.g. a
|
|
349
|
+
// hypothetical registry literally named `foo::bar`) cannot
|
|
350
|
+
// collide on the cache key with another registry. The prefix
|
|
351
|
+
// makes the encoding injective on `(registryName, packageName)`
|
|
352
|
+
// regardless of which characters appear in either string.
|
|
353
|
+
const key = `${source.name.length.toString()}:${source.name}:${name}`;
|
|
354
|
+
const existing = packumentCache.get(key);
|
|
355
|
+
if (existing !== undefined)
|
|
356
|
+
return existing;
|
|
357
|
+
const promise = source.fetchPackument(name);
|
|
358
|
+
packumentCache.set(key, promise);
|
|
359
|
+
return promise;
|
|
360
|
+
}
|
|
361
|
+
// BFS so root-pin entries are visited before their transitive
|
|
362
|
+
// descendants; output order is stable for tests.
|
|
363
|
+
//
|
|
364
|
+
// `subtreeId` identifies an optional subtree the entry belongs
|
|
365
|
+
// to (null for the hard closure). Every `optionalDependencies`
|
|
366
|
+
// edge opens a new subtree; its transitive descendants inherit
|
|
367
|
+
// the same id so a failure anywhere inside drops the whole
|
|
368
|
+
// subtree silently, matching npm's "the whole optional subtree
|
|
369
|
+
// is best-effort" contract. Failures outside any subtree abort
|
|
370
|
+
// the walk as before.
|
|
371
|
+
const queue = pins.map((p) => ({
|
|
372
|
+
name: p.name,
|
|
373
|
+
range: p.version,
|
|
374
|
+
subtreeId: null,
|
|
375
|
+
}));
|
|
376
|
+
// Track `name@range` requests we have already issued so two
|
|
377
|
+
// transitive paths to the same dependency at the same range do
|
|
378
|
+
// not trigger duplicate packument fetches. Post-pick dedup
|
|
379
|
+
// (`entries.has(key)` below) catches name@version duplicates
|
|
380
|
+
// but only after the round-trip; this catches them before.
|
|
381
|
+
const requested = new Set();
|
|
382
|
+
// Track the resolved (concrete) version each top-level pin
|
|
383
|
+
// walked to, keyed by pin name. The sidecar's loader matches
|
|
384
|
+
// top-level entries by `${name}@${concrete-version}` to decide
|
|
385
|
+
// which packages contribute tool factories; emitting the pin's
|
|
386
|
+
// raw range here would cause every range-form pin to silently
|
|
387
|
+
// fail that lookup at apply time. Only the first arrival on
|
|
388
|
+
// each pin name counts: a transitive dep that shares a
|
|
389
|
+
// top-level name must not overwrite the top-level's resolved
|
|
390
|
+
// version with its own.
|
|
391
|
+
//
|
|
392
|
+
// INVARIANT: the BFS queue is seeded from `pins` before any
|
|
393
|
+
// transitive descendants are enqueued (see queue construction
|
|
394
|
+
// above), so the first-arrival-wins record below is only safe
|
|
395
|
+
// because BFS guarantees every top-level pin reaches its
|
|
396
|
+
// `topLevelResolved.set` call before any transitive dep that
|
|
397
|
+
// shares its name. Changing the walk order (e.g. switching to
|
|
398
|
+
// DFS, or interleaving roots with transitives) would silently
|
|
399
|
+
// invert this rule and let a transitive dep overwrite a
|
|
400
|
+
// top-level resolution.
|
|
401
|
+
const topLevelNames = new Set(pins.map((p) => p.name));
|
|
402
|
+
const topLevelResolved = new Map();
|
|
403
|
+
// Optional-subtree bookkeeping. Subtree-tagged entries and peer
|
|
404
|
+
// declarations stay in their per-subtree stash until the whole
|
|
405
|
+
// subtree finishes successfully, at which point they merge into
|
|
406
|
+
// the main `entries` / `peerDeclarations` collections. If any
|
|
407
|
+
// resolution failure inside the subtree fires, the subtree is
|
|
408
|
+
// marked poisoned, its stash is dropped, and subsequent queue
|
|
409
|
+
// entries tagged with that subtree id are skipped silently.
|
|
410
|
+
let nextSubtreeId = 0;
|
|
411
|
+
const subtreeEntries = new Map();
|
|
412
|
+
const subtreePeerDeclarations = new Map();
|
|
413
|
+
const subtreePending = new Map();
|
|
414
|
+
const poisonedSubtrees = new Set();
|
|
415
|
+
// Parent/child relationships between nested optional subtrees.
|
|
416
|
+
// When an `optionalDependencies` edge fires inside another
|
|
417
|
+
// optional subtree, the new subtree's id is registered as a
|
|
418
|
+
// descendant of the enclosing one. Poisoning the outer subtree
|
|
419
|
+
// cascades to every descendant so a stash that completed inside
|
|
420
|
+
// a nested optional cannot leak entries into the top-level
|
|
421
|
+
// closure once its enclosing best-effort context is dropped.
|
|
422
|
+
const subtreeChildren = new Map();
|
|
423
|
+
function poisonSubtree(subtreeId, reason) {
|
|
424
|
+
if (poisonedSubtrees.has(subtreeId))
|
|
425
|
+
return;
|
|
426
|
+
poisonedSubtrees.add(subtreeId);
|
|
427
|
+
subtreeEntries.delete(subtreeId);
|
|
428
|
+
subtreePeerDeclarations.delete(subtreeId);
|
|
429
|
+
subtreePending.delete(subtreeId);
|
|
430
|
+
logger.debug `optional subtree ${String(subtreeId)} dropped: ${reason}`;
|
|
431
|
+
const children = subtreeChildren.get(subtreeId);
|
|
432
|
+
if (children !== undefined) {
|
|
433
|
+
subtreeChildren.delete(subtreeId);
|
|
434
|
+
for (const childId of children) {
|
|
435
|
+
poisonSubtree(childId, `parent subtree ${String(subtreeId)} poisoned`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
function noteSubtreeArrival(subtreeId) {
|
|
440
|
+
if (subtreeId === null)
|
|
441
|
+
return;
|
|
442
|
+
subtreePending.set(subtreeId, (subtreePending.get(subtreeId) ?? 0) + 1);
|
|
443
|
+
}
|
|
444
|
+
function noteSubtreeCompletion(subtreeId) {
|
|
445
|
+
if (subtreeId === null)
|
|
446
|
+
return;
|
|
447
|
+
if (poisonedSubtrees.has(subtreeId))
|
|
448
|
+
return;
|
|
449
|
+
const remaining = (subtreePending.get(subtreeId) ?? 0) - 1;
|
|
450
|
+
if (remaining > 0) {
|
|
451
|
+
subtreePending.set(subtreeId, remaining);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
subtreePending.delete(subtreeId);
|
|
455
|
+
const stash = subtreeEntries.get(subtreeId);
|
|
456
|
+
if (stash !== undefined) {
|
|
457
|
+
for (const [key, entry] of stash) {
|
|
458
|
+
if (!entries.has(key))
|
|
459
|
+
entries.set(key, entry);
|
|
460
|
+
}
|
|
461
|
+
subtreeEntries.delete(subtreeId);
|
|
462
|
+
}
|
|
463
|
+
const peerStash = subtreePeerDeclarations.get(subtreeId);
|
|
464
|
+
if (peerStash !== undefined) {
|
|
465
|
+
for (const decl of peerStash)
|
|
466
|
+
peerDeclarations.push(decl);
|
|
467
|
+
subtreePeerDeclarations.delete(subtreeId);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// The root pins all live in the hard closure; subtree-arrival
|
|
471
|
+
// bookkeeping starts the moment an optional edge is followed
|
|
472
|
+
// (see the dependency fan-out below).
|
|
473
|
+
while (queue.length > 0) {
|
|
474
|
+
const next = queue.shift();
|
|
475
|
+
if (next === undefined)
|
|
476
|
+
break;
|
|
477
|
+
if (next.subtreeId !== null && poisonedSubtrees.has(next.subtreeId)) {
|
|
478
|
+
noteSubtreeCompletion(next.subtreeId);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
// Dedup is keyed by `(name, range, subtreeId)` rather than
|
|
482
|
+
// `(name, range)` alone: a request first seen inside an
|
|
483
|
+
// optional subtree A and then re-seen inside an unrelated
|
|
484
|
+
// subtree B must be reprocessed under B's id so dropping A
|
|
485
|
+
// does not silently drop the dep from B's slice of the
|
|
486
|
+
// closure. The hard closure (`subtreeId === null`) keys with
|
|
487
|
+
// the literal "root" sentinel so its dedup behaviour is
|
|
488
|
+
// unchanged from the pre-fix walker.
|
|
489
|
+
const subtreeTag = next.subtreeId === null ? "root" : String(next.subtreeId);
|
|
490
|
+
const requestKey = `${subtreeTag}::${next.name}@${next.range}`;
|
|
491
|
+
if (requested.has(requestKey)) {
|
|
492
|
+
noteSubtreeCompletion(next.subtreeId);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
requested.add(requestKey);
|
|
496
|
+
const source = resolveSource(next.name);
|
|
497
|
+
let packument;
|
|
498
|
+
let picked;
|
|
499
|
+
try {
|
|
500
|
+
packument = await fetchPackumentCached(source, next.name);
|
|
501
|
+
picked = npmPickManifest(packument, next.range);
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
if (next.subtreeId !== null) {
|
|
505
|
+
// Tag the poisoning reason with whether the failure was
|
|
506
|
+
// transport-shaped (connection refused, DNS, 5xx, etc.) or
|
|
507
|
+
// structural (404, malformed packument, unsatisfiable
|
|
508
|
+
// range). Operators reading the debug log need the
|
|
509
|
+
// distinction to triage: transport failures point at the
|
|
510
|
+
// registry or the network, structural failures point at
|
|
511
|
+
// the pin set or the registry's published manifest.
|
|
512
|
+
poisonSubtree(next.subtreeId, `${next.name}@${next.range} unresolvable (${classifyResolveError(err)}): ${err instanceof Error ? err.message : String(err)}`);
|
|
513
|
+
noteSubtreeCompletion(next.subtreeId);
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
throw err;
|
|
517
|
+
}
|
|
518
|
+
if (topLevelNames.has(next.name) && !topLevelResolved.has(next.name)) {
|
|
519
|
+
topLevelResolved.set(next.name, picked.version);
|
|
520
|
+
}
|
|
521
|
+
const key = `${picked.name}@${picked.version}`;
|
|
522
|
+
const alreadyInHardClosure = entries.has(key);
|
|
523
|
+
const alreadyInThisSubtree = next.subtreeId !== null &&
|
|
524
|
+
(subtreeEntries.get(next.subtreeId)?.has(key) ?? false);
|
|
525
|
+
if (alreadyInHardClosure || alreadyInThisSubtree) {
|
|
526
|
+
noteSubtreeCompletion(next.subtreeId);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (picked.dist.integrity === undefined) {
|
|
530
|
+
if (next.subtreeId !== null) {
|
|
531
|
+
poisonSubtree(next.subtreeId, `${key} served with no dist.integrity`);
|
|
532
|
+
noteSubtreeCompletion(next.subtreeId);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
throw new Error(`registry "${source.name}" served ${key} with no dist.integrity`);
|
|
536
|
+
}
|
|
537
|
+
// `materializeRefForEntry` throws when the registry's per-version
|
|
538
|
+
// bookkeeping is inconsistent (e.g. an asset packument lists a
|
|
539
|
+
// version the path index does not know about). Route the throw
|
|
540
|
+
// through the same poison-vs-abort split that fetch and pick
|
|
541
|
+
// failures use above, so a malformed asset-side packument
|
|
542
|
+
// reached through an optional subtree contains the failure
|
|
543
|
+
// rather than aborting the whole closure walk.
|
|
544
|
+
let ref;
|
|
545
|
+
try {
|
|
546
|
+
ref = source.materializeRefForEntry(picked.name, picked.version, picked);
|
|
547
|
+
}
|
|
548
|
+
catch (err) {
|
|
549
|
+
if (next.subtreeId !== null) {
|
|
550
|
+
poisonSubtree(next.subtreeId, `${key} materializeRefForEntry failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
551
|
+
noteSubtreeCompletion(next.subtreeId);
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
throw err;
|
|
555
|
+
}
|
|
556
|
+
const entry = {
|
|
557
|
+
name: picked.name,
|
|
558
|
+
version: picked.version,
|
|
559
|
+
integrity: picked.dist.integrity,
|
|
560
|
+
source: ref.source,
|
|
561
|
+
...(ref.tarballUrl !== undefined
|
|
562
|
+
? { tarballUrl: ref.tarballUrl }
|
|
563
|
+
: {}),
|
|
564
|
+
...(picked.os !== undefined ? { os: [...picked.os] } : {}),
|
|
565
|
+
...(picked.cpu !== undefined ? { cpu: [...picked.cpu] } : {}),
|
|
566
|
+
};
|
|
567
|
+
if (next.subtreeId === null) {
|
|
568
|
+
entries.set(key, entry);
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
let stash = subtreeEntries.get(next.subtreeId);
|
|
572
|
+
if (stash === undefined) {
|
|
573
|
+
stash = new Map();
|
|
574
|
+
subtreeEntries.set(next.subtreeId, stash);
|
|
575
|
+
}
|
|
576
|
+
stash.set(key, entry);
|
|
577
|
+
}
|
|
578
|
+
for (const [name, range] of Object.entries(picked.dependencies ?? {})) {
|
|
579
|
+
// A hard dep of an optional entry inherits the same subtree:
|
|
580
|
+
// dropping the parent silently on failure must not leave a
|
|
581
|
+
// dangling requirement on its children, and a failure on
|
|
582
|
+
// such a child must drop the whole subtree.
|
|
583
|
+
const childId = next.subtreeId;
|
|
584
|
+
noteSubtreeArrival(childId);
|
|
585
|
+
queue.push({ name, range, subtreeId: childId });
|
|
586
|
+
}
|
|
587
|
+
for (const [name, range] of Object.entries(picked.optionalDependencies ?? {})) {
|
|
588
|
+
// Every optionalDependencies edge opens a new subtree, even
|
|
589
|
+
// when the requirer already lives inside one — the inner
|
|
590
|
+
// subtree's failure should be containable without poisoning
|
|
591
|
+
// the outer. The reverse is not symmetric: poisoning the
|
|
592
|
+
// outer must cascade into the inner so its merged entries do
|
|
593
|
+
// not survive as orphans (see `subtreeChildren`).
|
|
594
|
+
const childId = nextSubtreeId++;
|
|
595
|
+
if (next.subtreeId !== null) {
|
|
596
|
+
let kin = subtreeChildren.get(next.subtreeId);
|
|
597
|
+
if (kin === undefined) {
|
|
598
|
+
kin = new Set();
|
|
599
|
+
subtreeChildren.set(next.subtreeId, kin);
|
|
600
|
+
}
|
|
601
|
+
kin.add(childId);
|
|
602
|
+
}
|
|
603
|
+
noteSubtreeArrival(childId);
|
|
604
|
+
queue.push({ name, range, subtreeId: childId });
|
|
605
|
+
}
|
|
606
|
+
for (const [name, range] of Object.entries(picked.peerDependencies ?? {})) {
|
|
607
|
+
const decl = {
|
|
608
|
+
dependent: { name: picked.name, version: picked.version },
|
|
609
|
+
peer: { name, range },
|
|
610
|
+
optional: picked.peerDependenciesMeta?.[name]?.optional === true,
|
|
611
|
+
};
|
|
612
|
+
if (next.subtreeId === null) {
|
|
613
|
+
peerDeclarations.push(decl);
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
let stash = subtreePeerDeclarations.get(next.subtreeId);
|
|
617
|
+
if (stash === undefined) {
|
|
618
|
+
stash = [];
|
|
619
|
+
subtreePeerDeclarations.set(next.subtreeId, stash);
|
|
620
|
+
}
|
|
621
|
+
stash.push(decl);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
noteSubtreeCompletion(next.subtreeId);
|
|
625
|
+
}
|
|
626
|
+
const violations = checkPeerDependencies(peerDeclarations, entries);
|
|
627
|
+
if (violations.length > 0) {
|
|
628
|
+
throw new ManifestInvalidError(violations);
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
schemaVersion: "1",
|
|
632
|
+
topLevel: pins.map((p) => {
|
|
633
|
+
const resolved = topLevelResolved.get(p.name);
|
|
634
|
+
if (resolved === undefined) {
|
|
635
|
+
// Every pin enters the queue at the head of the BFS and
|
|
636
|
+
// each queue entry either records a resolved version or
|
|
637
|
+
// throws — reaching this point would indicate a defect
|
|
638
|
+
// in the walker, not in the input.
|
|
639
|
+
throw new Error(`resolver internal error: top-level pin ${p.name}@${p.version} did not resolve to a concrete version`);
|
|
640
|
+
}
|
|
641
|
+
return { name: p.name, version: resolved };
|
|
642
|
+
}),
|
|
643
|
+
entries: Array.from(entries.values()),
|
|
644
|
+
};
|
|
645
|
+
},
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
function checkPeerDependencies(declarations, entries) {
|
|
649
|
+
const versionsByName = new Map();
|
|
650
|
+
for (const e of entries.values()) {
|
|
651
|
+
const list = versionsByName.get(e.name);
|
|
652
|
+
if (list === undefined)
|
|
653
|
+
versionsByName.set(e.name, [e.version]);
|
|
654
|
+
else
|
|
655
|
+
list.push(e.version);
|
|
656
|
+
}
|
|
657
|
+
const violations = [];
|
|
658
|
+
for (const d of declarations) {
|
|
659
|
+
const candidates = versionsByName.get(d.peer.name) ?? [];
|
|
660
|
+
const satisfying = candidates.filter((v) => semver.satisfies(v, d.peer.range, { includePrerelease: true }));
|
|
661
|
+
if (satisfying.length === 0 && !d.optional) {
|
|
662
|
+
violations.push({
|
|
663
|
+
dependent: d.dependent,
|
|
664
|
+
peer: d.peer,
|
|
665
|
+
satisfiedBy: null,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return violations;
|
|
670
|
+
}
|
|
671
|
+
function parseScope(packageName) {
|
|
672
|
+
if (!packageName.startsWith("@"))
|
|
673
|
+
return null;
|
|
674
|
+
const slash = packageName.indexOf("/");
|
|
675
|
+
if (slash === -1)
|
|
676
|
+
return null;
|
|
677
|
+
// npm package names are case-insensitive at the registry layer but
|
|
678
|
+
// npm's own validator forbids uppercase. Scope-routing lookups are
|
|
679
|
+
// gated on NPM_SCOPE_RE which rejects uppercase, so an uppercase
|
|
680
|
+
// scope in a package name would silently fail to match any routing
|
|
681
|
+
// entry. Lowercase the lookup so a `@INTX/foo` import still routes
|
|
682
|
+
// through a `@intx` routing entry rather than falling through to
|
|
683
|
+
// the default registry.
|
|
684
|
+
//
|
|
685
|
+
// Pin-time validation rejects uppercase top-level names, but
|
|
686
|
+
// transitive names sourced from registry packuments are not gated
|
|
687
|
+
// against the same rule. The lowercasing here is the only defense
|
|
688
|
+
// against a misconfigured registry returning mixed-case names that
|
|
689
|
+
// would otherwise mis-route at fetch time.
|
|
690
|
+
return packageName.slice(0, slash).toLowerCase();
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Categorize a closure-walk resolution failure as transport or
|
|
694
|
+
* structural so the poison-reason log can route accordingly. Inputs
|
|
695
|
+
* are the errors that bubble out of `npm-registry-fetch` (which
|
|
696
|
+
* decorates HTTP errors with `statusCode` and DNS/connect errors
|
|
697
|
+
* with `code`) and `npm-pick-manifest` (which throws for missing
|
|
698
|
+
* versions / unsatisfiable ranges with no extra fields).
|
|
699
|
+
*
|
|
700
|
+
* Routing rules:
|
|
701
|
+
* - `code` in the ECONN/EAI/ENET family or `statusCode >= 500`
|
|
702
|
+
* → transport.
|
|
703
|
+
* - `statusCode === 404` or any non-2xx without the 5xx shape →
|
|
704
|
+
* structural (the registry answered; the answer was a
|
|
705
|
+
* missing-or-malformed packument).
|
|
706
|
+
* - Everything else (npm-pick-manifest's range-not-satisfiable,
|
|
707
|
+
* malformed-packument JSON parse failures) → structural.
|
|
708
|
+
*/
|
|
709
|
+
function classifyResolveError(err) {
|
|
710
|
+
if (err === null || typeof err !== "object")
|
|
711
|
+
return "structural";
|
|
712
|
+
const code = "code" in err ? err.code : undefined;
|
|
713
|
+
if (typeof code === "string") {
|
|
714
|
+
if (code.startsWith("ECONN") ||
|
|
715
|
+
code.startsWith("EAI") ||
|
|
716
|
+
code.startsWith("ENET") ||
|
|
717
|
+
code === "ETIMEDOUT" ||
|
|
718
|
+
code === "EPIPE" ||
|
|
719
|
+
code === "EHOSTUNREACH") {
|
|
720
|
+
return "transport";
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const status = "statusCode" in err
|
|
724
|
+
? err.statusCode
|
|
725
|
+
: undefined;
|
|
726
|
+
if (typeof status === "number" && status >= 500)
|
|
727
|
+
return "transport";
|
|
728
|
+
return "structural";
|
|
729
|
+
}
|
|
730
|
+
function makeDefaultHttpFetcher() {
|
|
731
|
+
return async (packageName, registry) => {
|
|
732
|
+
const fetchOpts = { registry: registry.url };
|
|
733
|
+
if (registry.auth?.token !== undefined) {
|
|
734
|
+
fetchOpts.token = registry.auth.token;
|
|
735
|
+
}
|
|
736
|
+
if (registry.auth?.basic !== undefined) {
|
|
737
|
+
const { user, pass } = registry.auth.basic;
|
|
738
|
+
// `npm-registry-fetch` builds the `Authorization: Basic` header by
|
|
739
|
+
// base64-encoding `<username>:<password>` itself. Pre-encoding
|
|
740
|
+
// `pass` would double-encode the password component (the registry
|
|
741
|
+
// would see `base64(plaintext)` as the password, not `plaintext`).
|
|
742
|
+
fetchOpts.forceAuth = { username: user, password: pass };
|
|
743
|
+
}
|
|
744
|
+
const url = `/${encodeNpmName(packageName)}`;
|
|
745
|
+
return await npmRegistryFetch.json(url, fetchOpts);
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
function encodeNpmName(name) {
|
|
749
|
+
// Scoped names need the slash percent-encoded for the registry route.
|
|
750
|
+
return name.startsWith("@") ? name.replace("/", "%2f") : name;
|
|
751
|
+
}
|
|
752
|
+
async function extractPackageJSON(registryName, filename, bytes) {
|
|
753
|
+
const outcome = await extractTarballPackageJSON(bytes);
|
|
754
|
+
if (outcome.kind === "missing-entry") {
|
|
755
|
+
throw new ManifestInvalidError(`tarball ${filename} has no top-level package.json entry`);
|
|
756
|
+
}
|
|
757
|
+
if (outcome.kind === "multiple-entries") {
|
|
758
|
+
// The hub's package-registry kind handler rejects uploads with
|
|
759
|
+
// multiple top-level package.json entries; a tarball reaching the
|
|
760
|
+
// resolver with this shape implies a registry bypass or a
|
|
761
|
+
// corrupted asset blob. Fail loudly rather than picking the first
|
|
762
|
+
// entry — the sidecar's `tar.extract({ strip: 1 })` would load a
|
|
763
|
+
// different descriptor than the resolver here.
|
|
764
|
+
throw new ManifestInvalidError(`asset registry "${registryName}" tarball ${filename} contains multiple top-level package.json entries (${outcome.paths
|
|
765
|
+
.map((p) => JSON.stringify(p))
|
|
766
|
+
.join(", ")}); registry contents must hold exactly one`);
|
|
767
|
+
}
|
|
768
|
+
if (outcome.kind === "parse-error") {
|
|
769
|
+
throw new ManifestInvalidError(`tarball ${filename} failed to parse: ${outcome.message}`);
|
|
770
|
+
}
|
|
771
|
+
if (outcome.kind === "json-error") {
|
|
772
|
+
throw new ManifestInvalidError(`tarball ${filename} package.json is not valid JSON: ${outcome.message}`);
|
|
773
|
+
}
|
|
774
|
+
if (outcome.kind === "shape-invalid") {
|
|
775
|
+
throw new ManifestInvalidError(`asset registry "${registryName}" tarball ${filename} package.json failed validation: ${outcome.message}`);
|
|
776
|
+
}
|
|
777
|
+
return { parsed: outcome.parsed, raw: outcome.raw };
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Parse and canonicalize a `name@range` spec into a pin. Throws on
|
|
781
|
+
* unparseable specs or invalid version ranges. Callers that already
|
|
782
|
+
* have a `ToolPackagePin` do not need this helper.
|
|
783
|
+
*
|
|
784
|
+
* A bare `*` is accepted because npm semantics treat it as the
|
|
785
|
+
* any-version range; the resolver then picks whatever the registry
|
|
786
|
+
* advertises as latest at deploy-assembly time. Operators who care
|
|
787
|
+
* about reproducibility of tool-package closures should pin to a
|
|
788
|
+
* concrete range (`^1.2.3`) — `*` lets the closure shift under the
|
|
789
|
+
* agent without any change to the pin set.
|
|
790
|
+
*
|
|
791
|
+
* NOTE: `*` is special-cased in two places — here and inside the
|
|
792
|
+
* `ToolPackagePinArray` narrow at `@intx/types/tool-packages`. The
|
|
793
|
+
* sites live in separate packages by design (resolver vs. wire-type
|
|
794
|
+
* validation) and cannot import each other; any new magic-range
|
|
795
|
+
* additions need to be made at both call sites to keep the
|
|
796
|
+
* REST-boundary validator and the resolver in agreement.
|
|
797
|
+
*/
|
|
798
|
+
export function parsePin(spec) {
|
|
799
|
+
const parsed = npmPackageArg(spec);
|
|
800
|
+
if (parsed.name === null) {
|
|
801
|
+
throw new Error(`unparseable pin spec: ${spec}`);
|
|
802
|
+
}
|
|
803
|
+
// npm itself rejects uppercase in package names; packuments arrive
|
|
804
|
+
// lowercased, so a mixed-case pin would self-resolve through
|
|
805
|
+
// `topLevelResolved` and then silently fail the sidecar loader's
|
|
806
|
+
// `${name}@${version}` lookup against the lowercase entry the
|
|
807
|
+
// packument produced. Reject loudly at the boundary rather than
|
|
808
|
+
// lowercasing the input — pin sets that mix cases are a bug in the
|
|
809
|
+
// caller and should surface as such.
|
|
810
|
+
if (parsed.name !== parsed.name.toLowerCase()) {
|
|
811
|
+
throw new Error(`pin name must be lowercase (npm package-name rules): ${parsed.name}`);
|
|
812
|
+
}
|
|
813
|
+
const range = parsed.fetchSpec;
|
|
814
|
+
if (range === undefined || range === null) {
|
|
815
|
+
throw new Error(`pin spec is missing a version range: ${spec}`);
|
|
816
|
+
}
|
|
817
|
+
if (range !== "*" && semver.validRange(range) === null) {
|
|
818
|
+
throw new Error(`invalid version range for ${parsed.name}: ${range}`);
|
|
819
|
+
}
|
|
820
|
+
return { name: parsed.name, version: range };
|
|
821
|
+
}
|