dsh-plugin-shop 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -7,7 +7,9 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync,
7
7
  import { spawn, spawnSync } from "node:child_process";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
10
+ import { homedir } from "node:os";
10
11
  import { z } from "zod";
12
+ import { gunzipSync } from "node:zlib";
11
13
  import { JSON_SCHEMA, Type, dump, load } from "js-yaml";
12
14
  //#region src/own-version.ts
13
15
  /** The shop's own published version, read from the package.json that ships
@@ -19,8 +21,332 @@ import { JSON_SCHEMA, Type, dump, load } from "js-yaml";
19
21
  function ownVersion() {
20
22
  return JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
21
23
  }
24
+ //#endregion
25
+ //#region src/host/origin.ts
26
+ /** The transport seam under `loadCatalog` (design §3).
27
+ *
28
+ * An origin answers a cheap probe, then serves the pointer and the files the
29
+ * pointer names. HTTP and npm are interchangeable behind it, so every line of
30
+ * cache and validation logic in `catalog.ts` stays transport-blind. */
31
+ /** A failure of the link, not of the content: the wire threw, or answered
32
+ * non-2xx. This is the ONLY class `loadCatalog` retries on another origin.
33
+ * A bad schema, a sha mismatch, or a refused url is an interpretation
34
+ * failure and throws — masking a corrupt origin behind a healthy one is
35
+ * exactly the silent-wrongness this project refuses. */
36
+ var TransportError = class extends Error {
37
+ constructor(message, options) {
38
+ super(message, options);
39
+ this.name = "TransportError";
40
+ }
41
+ };
42
+ /** Resolve the pointer's data URL against the catalog base. An absolute URL —
43
+ * any scheme, or a protocol-relative `//host/...` — would hand the pointer a
44
+ * fetch primitive to arbitrary hosts, so it is refused loudly before any
45
+ * fetch (§9.2). The guard is the resolved origin, not the raw string: WHATWG
46
+ * normalization strips leading whitespace and accepts backslash spellings
47
+ * before the string could be inspected, so only comparing the resolved URL's
48
+ * origin to the base's closes every spelling class. */
49
+ function resolveDataUrl(baseUrl, url) {
50
+ const resolved = new URL(url, baseUrl);
51
+ if (resolved.origin !== new URL(baseUrl).origin) throw new Error("catalog data url must be relative to the catalog base");
52
+ return resolved.href;
53
+ }
54
+ /** The transport this project has always used: a static `v1/` tree. */
55
+ function httpOrigin(baseUrl, fetchImpl) {
56
+ const id = `http:${baseUrl}`;
57
+ return {
58
+ id,
59
+ async probe(signal) {
60
+ let response;
61
+ try {
62
+ response = await fetchImpl(new URL("index.json", baseUrl).href, { signal });
63
+ } catch (error) {
64
+ const detail = error instanceof Error ? error.message : String(error);
65
+ throw new TransportError(`catalog pointer fetch failed for ${id}: ${detail}`, { cause: error });
66
+ }
67
+ if (!response.ok) throw new TransportError(`catalog pointer returned ${response.status} for ${id}`);
68
+ const pointerText = await response.text();
69
+ return {
70
+ id,
71
+ pointer: async () => pointerText,
72
+ file: async (url) => {
73
+ const resolved = resolveDataUrl(baseUrl, url);
74
+ let dataResponse;
75
+ try {
76
+ dataResponse = await fetchImpl(resolved);
77
+ } catch (error) {
78
+ const detail = error instanceof Error ? error.message : String(error);
79
+ throw new TransportError(`catalog data fetch failed for ${id}: ${detail}`, { cause: error });
80
+ }
81
+ if (!dataResponse.ok) throw new TransportError(`catalog data returned ${dataResponse.status} for ${id}`);
82
+ return dataResponse.text();
83
+ }
84
+ };
85
+ }
86
+ };
87
+ }
88
+ //#endregion
89
+ //#region src/host/tar.ts
90
+ /** A read-only ustar parser: the npm transport's only way into a tarball.
91
+ *
92
+ * Pure — bytes in, a path-to-bytes map out. It handles exactly what `npm
93
+ * pack` emits and refuses everything else loudly, because the alternative to
94
+ * a small strict reader is a fourth runtime dependency (design §4). */
95
+ /** Bytes up to the first NUL, as ASCII. Tar pads its fixed-width text fields
96
+ * with NULs, so a plain toString would carry them into the path. */
97
+ function cstring(field) {
98
+ const end = field.indexOf(0);
99
+ return field.subarray(0, end === -1 ? field.length : end).toString("ascii");
100
+ }
101
+ /** Tar sizes are octal text, NUL- or space-terminated. An unparseable size
102
+ * would desynchronise every subsequent header, so it throws rather than
103
+ * guessing zero. */
104
+ function parseOctal(field) {
105
+ const text = cstring(field).trim();
106
+ if (text === "") return 0;
107
+ const value = Number.parseInt(text, 8);
108
+ if (!Number.isInteger(value) || value < 0) throw new Error(`tar: unparseable size field ${JSON.stringify(text)}`);
109
+ return value;
110
+ }
111
+ /** `..` in any position, or a leading `/`, would let an archive write outside
112
+ * the directory it claims. Nothing we publish contains either, so a tarball
113
+ * that does is hostile or corrupt — refuse it rather than filter it. */
114
+ function assertContained(path) {
115
+ if (path.startsWith("/") || path.split("/").includes("..")) throw new Error(`tar: ${JSON.stringify(path)} escapes the archive root`);
116
+ return path;
117
+ }
118
+ /**
119
+ * Parse an uncompressed tar archive into path → bytes.
120
+ *
121
+ * Directory entries and every non-regular type (symlinks, pax and GNU
122
+ * extension headers) are skipped: npm packs regular files under `package/`,
123
+ * and a catalog tarball that needs anything else is not one we published.
124
+ */
125
+ function readTar(buffer) {
126
+ const files = /* @__PURE__ */ new Map();
127
+ let offset = 0;
128
+ while (offset + 512 <= buffer.length) {
129
+ const header = buffer.subarray(offset, offset + 512);
130
+ if (header.every((byte) => byte === 0)) break;
131
+ const name = cstring(header.subarray(0, 100));
132
+ const prefix = cstring(header.subarray(345, 500));
133
+ const size = parseOctal(header.subarray(124, 136));
134
+ const typeflag = String.fromCharCode(header[156] ?? 0);
135
+ const path = prefix === "" ? name : `${prefix}/${name}`;
136
+ offset += 512;
137
+ if (typeflag === "0" || typeflag === "\0") files.set(assertContained(path), buffer.subarray(offset, offset + size));
138
+ offset += Math.ceil(size / 512) * 512;
139
+ }
140
+ return files;
141
+ }
142
+ //#endregion
143
+ //#region src/host/npm-origin.ts
144
+ /** The npm transport (design §2, §3): the catalog as a package.
145
+ *
146
+ * Shell — this and `origin.ts`'s fetch half are the only places the catalog
147
+ * loader touches the network. The payoff is measured, not assumed: the same
148
+ * bytes reach a China-side machine at 12.53 MB/s from npmmirror against
149
+ * 0.03 MB/s from GitHub Pages. */
150
+ /** The abbreviated `latest` manifest. Non-strict: a registry may add keys,
151
+ * and stripping them is what keeps an old host working against a new one. */
152
+ const latestSchema = z.object({
153
+ version: z.string(),
154
+ dist: z.object({
155
+ tarball: z.string(),
156
+ integrity: z.string()
157
+ })
158
+ });
159
+ /** Where the published package keeps the catalog tree (design §2). */
160
+ const PACKAGE_ROOT = "package/v1/";
161
+ /** Verify tarball bytes against npm's own Subresource-Integrity string.
162
+ * `dist.integrity` may carry several space-separated digests; npm publishes
163
+ * one, and the first is the one we check. */
164
+ function verifyIntegrity(bytes, integrity, registryUrl) {
165
+ const first = integrity.trim().split(/\s+/)[0] ?? "";
166
+ const dash = first.indexOf("-");
167
+ const algorithm = dash === -1 ? "" : first.slice(0, dash);
168
+ const expected = dash === -1 ? "" : first.slice(dash + 1);
169
+ if (algorithm !== "sha512" && algorithm !== "sha256") throw new TransportError(`npm origin ${registryUrl}: unsupported dist.integrity algorithm ${JSON.stringify(algorithm)}`);
170
+ if (createHash(algorithm).update(bytes).digest("base64") !== expected) throw new Error(`npm origin ${registryUrl}: tarball failed dist.integrity check (${algorithm})`);
171
+ }
172
+ /** Normalise to a trailing slash so relative `URL` resolution against a
173
+ * registry that carries a path — every corporate registry, e.g.
174
+ * `https://artifactory.corp/api/npm/npm-repo` — keeps that path instead of
175
+ * eating its last segment; a host-root registry's trailing slash is already
176
+ * a no-op either way. Exported so `catalog.ts`'s dedupe compares against the
177
+ * same normalised form `npmOrigin` races on. */
178
+ function normalizeRegistryUrl(url) {
179
+ return url.endsWith("/") ? url : `${url}/`;
180
+ }
181
+ /**
182
+ * An origin that reads the catalog out of `<registryUrl>`'s copy of
183
+ * `<packageName>`.
184
+ *
185
+ * The probe is the abbreviated `latest` manifest — 13.5 KB against the live
186
+ * registry — so the race is decided without downloading anything large. The
187
+ * tarball is fetched lazily on the first `pointer()` or `file()` and kept on
188
+ * the handle, so one origin download serves the whole load.
189
+ */
190
+ function npmOrigin(rawRegistryUrl, packageName, fetchImpl) {
191
+ const registryUrl = normalizeRegistryUrl(rawRegistryUrl);
192
+ const id = `npm:${registryUrl}`;
193
+ return {
194
+ id,
195
+ async probe(signal) {
196
+ let url;
197
+ try {
198
+ url = new URL(`${encodeURIComponent(packageName)}/latest`, registryUrl).href;
199
+ } catch (error) {
200
+ const detail = error instanceof Error ? error.message : String(error);
201
+ throw new TransportError(`npm origin ${registryUrl} is not a usable registry url: ${detail}`, { cause: error });
202
+ }
203
+ let response;
204
+ try {
205
+ response = await fetchImpl(url, { signal });
206
+ } catch (error) {
207
+ const detail = error instanceof Error ? error.message : String(error);
208
+ throw new TransportError(`npm origin ${registryUrl} probe failed: ${detail}`, { cause: error });
209
+ }
210
+ if (!response.ok) throw new TransportError(`npm origin ${registryUrl} returned ${response.status}`);
211
+ let manifest;
212
+ try {
213
+ manifest = latestSchema.parse(await response.json());
214
+ } catch (error) {
215
+ const detail = error instanceof Error ? error.message : String(error);
216
+ throw new TransportError(`npm origin ${registryUrl} returned an unparsable manifest: ${detail}`, { cause: error });
217
+ }
218
+ let files = null;
219
+ const load = async () => {
220
+ if (files !== null) return files;
221
+ let tarballUrl;
222
+ try {
223
+ tarballUrl = new URL(manifest.dist.tarball);
224
+ } catch (error) {
225
+ const detail = error instanceof Error ? error.message : String(error);
226
+ throw new TransportError(`npm origin ${registryUrl}: dist.tarball is not a valid url: ${detail}`, { cause: error });
227
+ }
228
+ if (tarballUrl.origin !== new URL(registryUrl).origin) throw new TransportError(`npm origin ${registryUrl}: dist.tarball host ${tarballUrl.origin} is not the registry's`);
229
+ let tarballResponse;
230
+ try {
231
+ tarballResponse = await fetchImpl(tarballUrl.href);
232
+ } catch (error) {
233
+ const detail = error instanceof Error ? error.message : String(error);
234
+ throw new TransportError(`npm origin ${registryUrl} tarball fetch failed: ${detail}`, { cause: error });
235
+ }
236
+ if (!tarballResponse.ok) throw new TransportError(`npm origin ${registryUrl} tarball returned ${tarballResponse.status}`);
237
+ let bytes;
238
+ try {
239
+ bytes = Buffer.from(await tarballResponse.arrayBuffer());
240
+ } catch (error) {
241
+ const detail = error instanceof Error ? error.message : String(error);
242
+ throw new TransportError(`npm origin ${registryUrl} tarball body read failed: ${detail}`, { cause: error });
243
+ }
244
+ verifyIntegrity(bytes, manifest.dist.integrity, registryUrl);
245
+ let parsed;
246
+ try {
247
+ parsed = readTar(gunzipSync(bytes));
248
+ } catch (error) {
249
+ const detail = error instanceof Error ? error.message : String(error);
250
+ throw new TransportError(`npm origin ${registryUrl} served an unparsable tarball: ${detail}`, { cause: error });
251
+ }
252
+ files = parsed;
253
+ return files;
254
+ };
255
+ const read = async (name) => {
256
+ const entry = (await load()).get(`${PACKAGE_ROOT}${name}`);
257
+ if (entry === void 0) throw new TransportError(`npm origin ${registryUrl}: ${name} is not in the catalog package`);
258
+ return entry.toString("utf8");
259
+ };
260
+ return {
261
+ id,
262
+ pointer: async () => read("index.json"),
263
+ file: async (url) => {
264
+ if (url.includes("/") || url.startsWith(".")) throw new Error(`npm origin ${registryUrl}: ${JSON.stringify(url)} must be a plain file name`);
265
+ return read(url);
266
+ }
267
+ };
268
+ }
269
+ };
270
+ }
271
+ //#endregion
272
+ //#region src/host/race.ts
273
+ /**
274
+ * Yield each promise's outcome as it settles, tagged with its argument index.
275
+ *
276
+ * Deliberately NOT an `async function*`. Two properties depend on that:
277
+ *
278
+ * 1. **Handlers attach synchronously, at call time.** An async generator's
279
+ * body does not run until its first `next()`, so wiring the handlers
280
+ * inside one would leave a rejection unhandled for as long as the caller
281
+ * waits before iterating — which crashes the process under Node's default
282
+ * unhandled-rejection policy.
283
+ * 2. **Order is recorded when each promise settles**, not when a consumer
284
+ * asks. Re-racing the survivors on every turn tie-breaks on argument
285
+ * order instead: `Promise.race` over promises that are ALREADY settled
286
+ * resolves with the first in iteration order, not the first to have
287
+ * settled — and a consumer doing any work between yields, which is
288
+ * exactly this module's use case, is what lets two settle inside one turn.
289
+ */
290
+ function inCompletionOrder(promises) {
291
+ const settled = [];
292
+ let wake = null;
293
+ const record = (outcome) => {
294
+ settled.push(outcome);
295
+ const resume = wake;
296
+ wake = null;
297
+ resume?.();
298
+ };
299
+ for (const [index, promise] of promises.entries()) promise.then((value) => {
300
+ record({
301
+ index,
302
+ value
303
+ });
304
+ }, (reason) => {
305
+ record({
306
+ index,
307
+ reason
308
+ });
309
+ });
310
+ return (async function* () {
311
+ for (let delivered = 0; delivered < promises.length; delivered += 1) {
312
+ if (settled.length === delivered) await new Promise((resolve) => {
313
+ wake = resolve;
314
+ });
315
+ const outcome = settled[delivered];
316
+ if (outcome === void 0) throw new Error("inCompletionOrder: woke with nothing settled");
317
+ yield outcome;
318
+ }
319
+ })();
320
+ }
22
321
  /** A cached catalog younger than this is served without touching the network. */
23
322
  const FRESH_MS = 3e5;
323
+ /** How long a probe may take before the race gives up on that origin. Long
324
+ * enough for a slow but working link, short enough that a black-holed origin
325
+ * does not hold the shelf closed. */
326
+ const PROBE_TIMEOUT_MS = 1e4;
327
+ /** How long the committed origin has to produce its pointer. `httpOrigin`
328
+ * answers instantly — its probe already fetched the bytes — but `npmOrigin`
329
+ * downloads its tarball here, so this is a bulk-transfer budget, not a probe
330
+ * one. Without it a winner that stalls mid-body parks the race forever while
331
+ * healthy origins sit settled and unread, which is the exact failure the race
332
+ * exists to prevent. Generous against every measured npm origin (12.53 MB/s
333
+ * mirror -> 0.12 s for 1.5 MB; npmjs direct 1.99 MB/s -> 0.75 s). */
334
+ const COMMIT_TIMEOUT_MS = 3e4;
335
+ /** Reject with a TransportError if `work` outlives `COMMIT_TIMEOUT_MS`. The
336
+ * underlying fetch is left to finish or fail on its own and its result is
337
+ * discarded: aborting it would need a signal threaded through OriginHandle,
338
+ * and a stalled origin we have already abandoned costs nothing but its own
339
+ * socket. */
340
+ async function withCommitTimeout(work, id) {
341
+ let timer;
342
+ try {
343
+ return await Promise.race([work, new Promise((_resolve, reject) => {
344
+ timer = setTimeout(() => reject(new TransportError(`${id} did not produce a pointer within ${COMMIT_TIMEOUT_MS} ms`)), COMMIT_TIMEOUT_MS);
345
+ })]);
346
+ } finally {
347
+ clearTimeout(timer);
348
+ }
349
+ }
24
350
  /** Records when the loader itself wrote the cache; the pointer's `builtAt` is
25
351
  * the catalog's build time, not the cache's fetch time. */
26
352
  const META_FILE = "index.meta.json";
@@ -122,18 +448,6 @@ const nodeFs$1 = {
122
448
  writeFileSync(path, data);
123
449
  }
124
450
  };
125
- /** Resolve the pointer's data URL against the catalog base. An absolute URL —
126
- * any scheme, or a protocol-relative `//host/...` — would hand the pointer a
127
- * fetch primitive to arbitrary hosts, so it is refused loudly before any
128
- * fetch (§9.2). The guard is the resolved origin, not the raw string: WHATWG
129
- * normalization strips leading whitespace and accepts backslash spellings
130
- * before the string could be inspected, so only comparing the resolved URL's
131
- * origin to the base's closes every spelling class. */
132
- function resolveDataUrl(baseUrl, url) {
133
- const resolved = new URL(url, baseUrl);
134
- if (resolved.origin !== new URL(baseUrl).origin) throw new Error("catalog data url must be relative to the catalog base");
135
- return resolved.href;
136
- }
137
451
  /** Read and verify a cached/fetched stars sidecar; ANY irregularity degrades
138
452
  * to an empty map — stars are advisory (spec §5). */
139
453
  function parseStarsText(text) {
@@ -158,7 +472,10 @@ function parseStarsText(text) {
158
472
  * included, degrades to no stars (spec §5).
159
473
  */
160
474
  async function loadCatalog(options) {
161
- const { baseUrl, cacheDir, refresh = false, fetchImpl = fetch, now = () => /* @__PURE__ */ new Date(), fsImpl = nodeFs$1 } = options;
475
+ const { cacheDir, refresh = false, fetchImpl = fetch, now = () => /* @__PURE__ */ new Date(), fsImpl = nodeFs$1 } = options;
476
+ if (options.baseUrl === void 0 === (options.origins === void 0)) throw new Error("loadCatalog: exactly one of baseUrl or origins is required");
477
+ const originList = options.origins ?? [httpOrigin(options.baseUrl, fetchImpl)];
478
+ if (originList.length === 0) throw new Error("loadCatalog: no origins");
162
479
  const indexPath = join(cacheDir, "index.json");
163
480
  const metaPath = join(cacheDir, META_FILE);
164
481
  /** The timestamp freshness is measured from: the sidecar's fetch time when
@@ -211,34 +528,41 @@ async function loadCatalog(options) {
211
528
  };
212
529
  }
213
530
  }
214
- let pointerText;
215
- try {
216
- const response = await fetchImpl(new URL("index.json", baseUrl).href);
217
- if (!response.ok) throw new Error(`catalog pointer returned ${response.status}`);
218
- pointerText = await response.text();
219
- } catch (error) {
531
+ const cachedOrThrow = (error) => {
220
532
  const cached = readCached();
221
533
  if (cached !== null) return {
222
534
  snapshot: cached,
223
535
  stale: true
224
536
  };
225
537
  throw error;
538
+ };
539
+ let handle = null;
540
+ let pointerText = "";
541
+ let lastTransportError = new TransportError("no catalog origin was reachable");
542
+ for await (const settled of inCompletionOrder(originList.map((origin) => origin.probe(AbortSignal.timeout(PROBE_TIMEOUT_MS))))) {
543
+ if (!("value" in settled)) {
544
+ if (!(settled.reason instanceof TransportError)) throw settled.reason;
545
+ lastTransportError = settled.reason;
546
+ continue;
547
+ }
548
+ try {
549
+ pointerText = await withCommitTimeout(settled.value.pointer(), settled.value.id);
550
+ handle = settled.value;
551
+ break;
552
+ } catch (error) {
553
+ if (!(error instanceof TransportError)) throw error;
554
+ lastTransportError = error;
555
+ }
226
556
  }
557
+ if (handle === null) return cachedOrThrow(lastTransportError);
227
558
  const pointer = pointerSchema.parse(JSON.parse(pointerText));
228
559
  if (pointer.schemaVersion > 6) throw new Error(`catalog schemaVersion ${pointer.schemaVersion} is newer than this build supports (6)`);
229
- const dataUrl = resolveDataUrl(baseUrl, pointer.plugins.url);
230
560
  let dataText;
231
561
  try {
232
- const dataResponse = await fetchImpl(dataUrl);
233
- if (!dataResponse.ok) throw new Error(`catalog data returned ${dataResponse.status}`);
234
- dataText = await dataResponse.text();
562
+ dataText = await handle.file(pointer.plugins.url);
235
563
  } catch (error) {
236
- const cached = readCached();
237
- if (cached !== null) return {
238
- snapshot: cached,
239
- stale: true
240
- };
241
- throw error;
564
+ if (!(error instanceof TransportError)) throw error;
565
+ return cachedOrThrow(error);
242
566
  }
243
567
  const actual = createHash("sha256").update(dataText).digest("hex");
244
568
  if (actual !== pointer.plugins.sha256) throw new Error(`catalog data failed integrity check: expected ${pointer.plugins.sha256}, got ${actual}`);
@@ -247,13 +571,10 @@ async function loadCatalog(options) {
247
571
  validateEntryCoherence(data.plugins);
248
572
  let stars = {};
249
573
  if (pointer.stars !== void 0) try {
250
- const starsResponse = await fetchImpl(resolveDataUrl(baseUrl, pointer.stars.url));
251
- if (starsResponse.ok) {
252
- const starsText = await starsResponse.text();
253
- if (createHash("sha256").update(starsText).digest("hex") === pointer.stars.sha256) {
254
- stars = parseStarsText(starsText);
255
- fsImpl.write(join(cacheDir, basename(pointer.stars.url)), starsText);
256
- }
574
+ const starsText = await handle.file(pointer.stars.url);
575
+ if (createHash("sha256").update(starsText).digest("hex") === pointer.stars.sha256) {
576
+ stars = parseStarsText(starsText);
577
+ fsImpl.write(join(cacheDir, basename(pointer.stars.url)), starsText);
257
578
  }
258
579
  } catch {}
259
580
  const snapshot = {
@@ -271,6 +592,76 @@ async function loadCatalog(options) {
271
592
  stale: false
272
593
  };
273
594
  }
595
+ /** The npm package carrying the same `v1/` tree (design §2). */
596
+ const CATALOG_PACKAGE = "dsh-plugin-shop-catalog";
597
+ /** Registries raced by default: the domestic mirror first for legibility —
598
+ * the race, not the order, decides the winner. */
599
+ const DEFAULT_REGISTRIES = ["https://registry.npmmirror.com/", "https://registry.npmjs.org/"];
600
+ /**
601
+ * The origins to race for this installation (design §3).
602
+ *
603
+ * @param catalogUrl - the row's configured base.
604
+ * @param npmRegistry - the user's own registry from `~/.npmrc`, or null.
605
+ */
606
+ function catalogOrigins(catalogUrl, fetchImpl, npmRegistry) {
607
+ if (catalogUrl !== "https://LivXue.github.io/dsh-plugin-shop/v1/") return [httpOrigin(catalogUrl, fetchImpl)];
608
+ const registries = [...DEFAULT_REGISTRIES];
609
+ const normalizedNpmRegistry = npmRegistry === null ? null : normalizeRegistryUrl(npmRegistry);
610
+ if (normalizedNpmRegistry !== null && !registries.includes(normalizedNpmRegistry)) registries.unshift(normalizedNpmRegistry);
611
+ return [...registries.map((registry) => npmOrigin(registry, CATALOG_PACKAGE, fetchImpl)), httpOrigin(catalogUrl, fetchImpl)];
612
+ }
613
+ //#endregion
614
+ //#region src/host/npmrc.ts
615
+ /** The user's configured npm registry, if they have one (design §3).
616
+ *
617
+ * Pure: the caller injects the read. This is a deliberately partial reading
618
+ * of npm's config resolution — only the user-level `registry=` line — and
619
+ * that is safe precisely because the origin list is raced: a registry we
620
+ * guess wrong about loses a 400-byte request and nothing else.
621
+ *
622
+ * That property only holds for a value that is actually a URL, which is why
623
+ * the value is VALIDATED here rather than left to the caller. `npmOrigin`
624
+ * addresses its probe with `new URL(<pkg>/latest, registryUrl)`, which
625
+ * throws a raw `TypeError` — not a `TransportError` — for anything that is
626
+ * not an absolute URL, and `catalog.ts`'s race loop rethrows everything that
627
+ * is not a `TransportError`. An unvalidated `registry=` line would therefore
628
+ * fail the WHOLE load with npmmirror, npmjs and Pages all healthy and no
629
+ * cache fallback: the opposite of the stated property. Not a hypothetical
630
+ * shape either — `registry=${NPM_REGISTRY}/` is npm's own documented config
631
+ * expansion, it works perfectly for npm, and a reader that does not expand
632
+ * it captures the literal. */
633
+ /** The value, if it is an absolute `http:`/`https:` URL; otherwise null.
634
+ *
635
+ * Both halves earn their place. The parse rejects a bare host, a relative
636
+ * path, and an unexpanded `${VAR}`. The scheme check then rejects what
637
+ * `new URL` happily accepts but the raced origins cannot fetch from —
638
+ * `file:` and `ftp:` parse fine and are not registries a `fetch` can read.
639
+ * The raw string is returned rather than the parsed href, so the value the
640
+ * user wrote is what reaches `normalizeRegistryUrl` and the origin id. */
641
+ function asRegistryUrl(value) {
642
+ let parsed;
643
+ try {
644
+ parsed = new URL(value);
645
+ } catch {
646
+ return null;
647
+ }
648
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? value : null;
649
+ }
650
+ /**
651
+ * @param readFile - returns the file's text, or null when it does not exist.
652
+ * @param home - the user's home directory.
653
+ */
654
+ function npmrcRegistry(readFile, home) {
655
+ const text = readFile(join(home, ".npmrc"));
656
+ if (text === null) return null;
657
+ for (const line of text.split("\n")) {
658
+ const value = /^\s*registry\s*=\s*(\S+)\s*$/.exec(line)?.[1];
659
+ if (value === void 0) continue;
660
+ const url = asRegistryUrl(value);
661
+ if (url !== null) return url;
662
+ }
663
+ return null;
664
+ }
274
665
  //#endregion
275
666
  //#region src/host/install.ts
276
667
  /**
@@ -1334,6 +1725,9 @@ let ShopGateway = (() => {
1334
1725
  /** The install gate runs against the last loaded snapshot, never a fresh
1335
1726
  * fetch per request (§7.2: the Host's cached snapshot is the truth). */
1336
1727
  lastSnapshot = null;
1728
+ /** The origin list built for the last-seen `catalogUrl`, memoised so the
1729
+ * user's npmrc is read at most once per gateway (see `originsFor`). */
1730
+ originCache = null;
1337
1731
  /** The incompatibility map already computed for `lastSnapshot`, keyed by
1338
1732
  * that snapshot's own object identity. Design §3 asks for the verdict
1339
1733
  * once per loaded snapshot, not once per RPC call: `loadCatalog` serves
@@ -1520,6 +1914,25 @@ let ShopGateway = (() => {
1520
1914
  cacheDir
1521
1915
  };
1522
1916
  }
1917
+ /** The origins to race for this row's catalog. Read once per gateway: the
1918
+ * user's npmrc does not change under a running dsh, and re-reading it on
1919
+ * every catalog call would put a filesystem read on the hot path. */
1920
+ originsFor(catalogUrl) {
1921
+ if (this.originCache?.catalogUrl === catalogUrl) return this.originCache.origins;
1922
+ const registry = npmrcRegistry((path) => {
1923
+ try {
1924
+ return readFileSync(path, "utf8");
1925
+ } catch {
1926
+ return null;
1927
+ }
1928
+ }, homedir());
1929
+ const origins = catalogOrigins(catalogUrl, fetch, registry);
1930
+ this.originCache = {
1931
+ catalogUrl,
1932
+ origins
1933
+ };
1934
+ return origins;
1935
+ }
1523
1936
  /** The explicit restart override. Only the row's `config:` sub-object is
1524
1937
  * passed to a plugin — a top-level `allowRestart:` beside `name:` would be
1525
1938
  * silently ignored by the loader (dsh-market README, #227). */
@@ -1531,7 +1944,7 @@ let ShopGateway = (() => {
1531
1944
  async catalog(args) {
1532
1945
  const { catalogUrl, cacheDir } = this.rowConfig();
1533
1946
  const { snapshot, stale } = await (this.options.loadCatalog ?? loadCatalog)({
1534
- baseUrl: catalogUrl,
1947
+ origins: this.originsFor(catalogUrl),
1535
1948
  cacheDir,
1536
1949
  refresh: args?.refresh ?? false
1537
1950
  });
@@ -1569,7 +1982,7 @@ let ShopGateway = (() => {
1569
1982
  if (this.lastSnapshot === null) {
1570
1983
  const { catalogUrl, cacheDir } = this.rowConfig();
1571
1984
  const { snapshot } = await (this.options.loadCatalog ?? loadCatalog)({
1572
- baseUrl: catalogUrl,
1985
+ origins: this.originsFor(catalogUrl),
1573
1986
  cacheDir
1574
1987
  });
1575
1988
  this.lastSnapshot = snapshot;
@@ -1677,7 +2090,7 @@ let ShopGateway = (() => {
1677
2090
  if (this.lastSnapshot === null) {
1678
2091
  const { catalogUrl, cacheDir } = this.rowConfig();
1679
2092
  const { snapshot } = await (this.options.loadCatalog ?? loadCatalog)({
1680
- baseUrl: catalogUrl,
2093
+ origins: this.originsFor(catalogUrl),
1681
2094
  cacheDir
1682
2095
  });
1683
2096
  this.lastSnapshot = snapshot;
@@ -1755,7 +2168,7 @@ let ShopGateway = (() => {
1755
2168
  if (this.lastSnapshot === null) {
1756
2169
  const { catalogUrl, cacheDir } = this.rowConfig();
1757
2170
  const { snapshot } = await (this.options.loadCatalog ?? loadCatalog)({
1758
- baseUrl: catalogUrl,
2171
+ origins: this.originsFor(catalogUrl),
1759
2172
  cacheDir
1760
2173
  });
1761
2174
  this.lastSnapshot = snapshot;
@@ -154,7 +154,7 @@ export const TYPERT = {
154
154
  typeSymbol: 'dsh-plugin-shop/types#ShopCatalogResult',
155
155
  schema: dsh_plugin_shop_shop_catalog_result$schema,
156
156
  },
157
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":543,"column":9},
157
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":568,"column":9},
158
158
  },
159
159
  {
160
160
  id: 'dsh-plugin-shop#shop/installed',
@@ -169,7 +169,7 @@ export const TYPERT = {
169
169
  typeSymbol: 'dsh-plugin-shop#shop/installed:result',
170
170
  schema: dsh_plugin_shop_shop_installed_result$schema,
171
171
  },
172
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":723,"column":9},
172
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":748,"column":9},
173
173
  },
174
174
  {
175
175
  id: 'dsh-plugin-shop#shop/installStart',
@@ -195,7 +195,7 @@ export const TYPERT = {
195
195
  typeSymbol: 'dsh-plugin-shop/types#ShopInstallResult',
196
196
  schema: dsh_plugin_shop_shop_installStart_result$schema,
197
197
  },
198
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":593,"column":9},
198
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":618,"column":9},
199
199
  },
200
200
  {
201
201
  id: 'dsh-plugin-shop#shop/installStatus',
@@ -220,7 +220,7 @@ export const TYPERT = {
220
220
  typeSymbol: 'dsh-plugin-shop/types#ShopInstallStatusResult',
221
221
  schema: dsh_plugin_shop_shop_installStatus_result$schema,
222
222
  },
223
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":712,"column":3},
223
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":737,"column":3},
224
224
  },
225
225
  {
226
226
  id: 'dsh-plugin-shop#shop/restart',
@@ -235,7 +235,7 @@ export const TYPERT = {
235
235
  typeSymbol: 'dsh-plugin-shop/types#ShopRestartResult',
236
236
  schema: dsh_plugin_shop_shop_restart_result$schema,
237
237
  },
238
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":873,"column":9},
238
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":898,"column":9},
239
239
  },
240
240
  {
241
241
  id: 'dsh-plugin-shop#shop/setEnabled',
@@ -260,7 +260,7 @@ export const TYPERT = {
260
260
  typeSymbol: 'dsh-plugin-shop/types#ShopSetEnabledResult',
261
261
  schema: dsh_plugin_shop_shop_setEnabled_result$schema,
262
262
  },
263
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":460,"column":9},
263
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":466,"column":9},
264
264
  },
265
265
  {
266
266
  id: 'dsh-plugin-shop#shop/uninstallStart',
@@ -286,7 +286,7 @@ export const TYPERT = {
286
286
  typeSymbol: 'dsh-plugin-shop/types#ShopUninstallResult',
287
287
  schema: dsh_plugin_shop_shop_uninstallStart_result$schema,
288
288
  },
289
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":815,"column":9},
289
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":840,"column":9},
290
290
  },
291
291
  {
292
292
  id: 'dsh-plugin-shop#shop/updateStart',
@@ -311,7 +311,7 @@ export const TYPERT = {
311
311
  typeSymbol: 'dsh-plugin-shop/types#ShopUpdateResult',
312
312
  schema: dsh_plugin_shop_shop_updateStart_result$schema,
313
313
  },
314
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":933,"column":9},
314
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":958,"column":9},
315
315
  },
316
316
  {
317
317
  id: 'dsh-plugin-shop#shop/version',
@@ -326,7 +326,7 @@ export const TYPERT = {
326
326
  typeSymbol: 'dsh-plugin-shop/types#ShopVersionResult',
327
327
  schema: dsh_plugin_shop_shop_version_result$schema,
328
328
  },
329
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":917,"column":9},
329
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":942,"column":9},
330
330
  },
331
331
  ],
332
332
  model: {