@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.
@@ -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
+ }