dsh-plugin-shop 0.6.0-beta.2 → 0.7.0-beta.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/README.md CHANGED
@@ -52,9 +52,9 @@ install them once with `npm install -g @deepseek-ai/dsh pnpm` and verify with
52
52
  # holds back very recent releases, so a bare `add dsh-plugin-shop` can hand
53
53
  # you an older version for a while. Pin the current release — refresh it
54
54
  # with `npm view dsh-plugin-shop version`.
55
- dsh plugin --profile web add dsh-plugin-shop@0.5.4
55
+ dsh plugin --profile web add dsh-plugin-shop@0.6.0
56
56
  # or straight through npx, nothing installed:
57
- npx -y @deepseek-ai/dsh plugin --profile web add dsh-plugin-shop@0.5.4
57
+ npx -y @deepseek-ai/dsh plugin --profile web add dsh-plugin-shop@0.6.0
58
58
  ```
59
59
 
60
60
  Replace `web` with your profile if you use another one. Then restart `dsh` once — a
@@ -84,7 +84,7 @@ ls -1 "${DSH_HOME:-$HOME/.dsh}/profiles" | grep -v '^node_modules$'
84
84
  cooldown, and deterministic installs are the point of the agent path.
85
85
 
86
86
  ```sh
87
- dsh plugin --profile <profile> add dsh-plugin-shop@0.5.4
87
+ dsh plugin --profile <profile> add dsh-plugin-shop@0.6.0
88
88
  ```
89
89
 
90
90
  **3. Verify — do not skip this.** A zero exit from step 2 means pnpm resolved the
@@ -138,9 +138,16 @@ Compromising the browser half buys an attacker those nine calls and nothing more
138
138
 
139
139
  ## ⚙️ Configuration
140
140
 
141
+ The shop reads its catalog from whichever source answers first: the npm
142
+ package `dsh-plugin-shop-catalog` (via your configured registry, npmmirror,
143
+ or npmjs) or `https://LivXue.github.io/dsh-plugin-shop/v1/`. All of them
144
+ carry the same bytes; the race exists because the link to one of them can be
145
+ far slower than the link to another. Setting `DSH_SHOP_CATALOG_URL` opts out
146
+ of the race and uses only what you name.
147
+
141
148
  | Variable | Effect |
142
149
  |---|---|
143
- | `DSH_SHOP_CATALOG_URL` | Point the shop at your own catalog mirror instead of the public one |
150
+ | `DSH_SHOP_CATALOG_URL` | Read the catalog only from this base, instead of racing the default sources |
144
151
 
145
152
  ## 📚 The catalog
146
153
 
package/lib/client.js CHANGED
@@ -4437,7 +4437,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4437
4437
  },
4438
4438
  sourceLocation: {
4439
4439
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4440
- "line": 543,
4440
+ "line": 568,
4441
4441
  "column": 9
4442
4442
  }
4443
4443
  },
@@ -4455,7 +4455,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4455
4455
  },
4456
4456
  sourceLocation: {
4457
4457
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4458
- "line": 723,
4458
+ "line": 748,
4459
4459
  "column": 9
4460
4460
  }
4461
4461
  },
@@ -4483,7 +4483,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4483
4483
  },
4484
4484
  sourceLocation: {
4485
4485
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4486
- "line": 593,
4486
+ "line": 618,
4487
4487
  "column": 9
4488
4488
  }
4489
4489
  },
@@ -4510,7 +4510,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4510
4510
  },
4511
4511
  sourceLocation: {
4512
4512
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4513
- "line": 712,
4513
+ "line": 737,
4514
4514
  "column": 3
4515
4515
  }
4516
4516
  },
@@ -4528,7 +4528,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4528
4528
  },
4529
4529
  sourceLocation: {
4530
4530
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4531
- "line": 873,
4531
+ "line": 898,
4532
4532
  "column": 9
4533
4533
  }
4534
4534
  },
@@ -4555,7 +4555,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4555
4555
  },
4556
4556
  sourceLocation: {
4557
4557
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4558
- "line": 460,
4558
+ "line": 466,
4559
4559
  "column": 9
4560
4560
  }
4561
4561
  },
@@ -4583,7 +4583,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4583
4583
  },
4584
4584
  sourceLocation: {
4585
4585
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4586
- "line": 815,
4586
+ "line": 840,
4587
4587
  "column": 9
4588
4588
  }
4589
4589
  },
@@ -4610,7 +4610,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4610
4610
  },
4611
4611
  sourceLocation: {
4612
4612
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4613
- "line": 933,
4613
+ "line": 958,
4614
4614
  "column": 9
4615
4615
  }
4616
4616
  },
@@ -4628,7 +4628,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4628
4628
  },
4629
4629
  sourceLocation: {
4630
4630
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4631
- "line": 917,
4631
+ "line": 942,
4632
4632
  "column": 9
4633
4633
  }
4634
4634
  }
@@ -4895,6 +4895,27 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4895
4895
  return `https://www.npmjs.com/package/${name}`;
4896
4896
  }
4897
4897
  /**
4898
+ * One entry's install identity — the value the catalog guarantees unique, and
4899
+ * therefore the only safe React key for a shelf card.
4900
+ *
4901
+ * `name` is NOT unique. The catalog's uniqueness invariant is the install
4902
+ * identity (registry `emit.ts` assertCatalogInvariants): `npm:<name>` for an
4903
+ * npm entry, `github:<repo>#<subdir>` for a repo one — so two GitHub
4904
+ * repositories publishing the same `package.json` name are two legitimate
4905
+ * entries under one name, as are two subpackages of one monorepo. The live
4906
+ * catalog holds 151 such names over 243 entries, five of them cookiecutter
4907
+ * templates that all name themselves `{{PKG_NAME}}`.
4908
+ *
4909
+ * Keying the shelf by name handed React duplicate keys, and React could then
4910
+ * no longer match a card to its DOM node: changing the filter left every
4911
+ * duplicate orphaned on the page — hundreds of stale cards from the previous
4912
+ * category, accumulating with each switch until the tab stopped responding.
4913
+ * This mirrors the registry's identity verbatim; the two must not drift.
4914
+ */
4915
+ function entryKey(entry) {
4916
+ return entry.source === "npm" ? `npm:${entry.name}` : `github:${entry.repo ?? entry.name}#${entry.subdir ?? ""}`;
4917
+ }
4918
+ /**
4898
4919
  * One entry's star count from the sidecar, or undefined when it has none.
4899
4920
  *
4900
4921
  * The sidecar keys a github entry by its repo full name and an npm entry by
@@ -6435,7 +6456,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6435
6456
  restart,
6436
6457
  restartSupported,
6437
6458
  setEnabled
6438
- }) }, entry.name)), incremental && visibleCount < filtered.length && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
6459
+ }) }, entryKey(entry))), incremental && visibleCount < filtered.length && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
6439
6460
  ref: sentinelRef,
6440
6461
  className: _dsh_plugin_shop_css_e3675a89_default.cardsSentry,
6441
6462
  "data-shop-sentry": true,
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: {
@@ -151,7 +151,7 @@ export const TYPERT_REMOTE = {
151
151
  typeSymbol: 'dsh-plugin-shop/types#ShopCatalogResult',
152
152
  schema: dsh_plugin_shop_shop_catalog_result$schema,
153
153
  },
154
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":543,"column":9},
154
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":568,"column":9},
155
155
  },
156
156
  {
157
157
  id: 'dsh-plugin-shop#shop/installed',
@@ -166,7 +166,7 @@ export const TYPERT_REMOTE = {
166
166
  typeSymbol: 'dsh-plugin-shop#shop/installed:result',
167
167
  schema: dsh_plugin_shop_shop_installed_result$schema,
168
168
  },
169
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":723,"column":9},
169
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":748,"column":9},
170
170
  },
171
171
  {
172
172
  id: 'dsh-plugin-shop#shop/installStart',
@@ -192,7 +192,7 @@ export const TYPERT_REMOTE = {
192
192
  typeSymbol: 'dsh-plugin-shop/types#ShopInstallResult',
193
193
  schema: dsh_plugin_shop_shop_installStart_result$schema,
194
194
  },
195
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":593,"column":9},
195
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":618,"column":9},
196
196
  },
197
197
  {
198
198
  id: 'dsh-plugin-shop#shop/installStatus',
@@ -217,7 +217,7 @@ export const TYPERT_REMOTE = {
217
217
  typeSymbol: 'dsh-plugin-shop/types#ShopInstallStatusResult',
218
218
  schema: dsh_plugin_shop_shop_installStatus_result$schema,
219
219
  },
220
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":712,"column":3},
220
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":737,"column":3},
221
221
  },
222
222
  {
223
223
  id: 'dsh-plugin-shop#shop/restart',
@@ -232,7 +232,7 @@ export const TYPERT_REMOTE = {
232
232
  typeSymbol: 'dsh-plugin-shop/types#ShopRestartResult',
233
233
  schema: dsh_plugin_shop_shop_restart_result$schema,
234
234
  },
235
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":873,"column":9},
235
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":898,"column":9},
236
236
  },
237
237
  {
238
238
  id: 'dsh-plugin-shop#shop/setEnabled',
@@ -257,7 +257,7 @@ export const TYPERT_REMOTE = {
257
257
  typeSymbol: 'dsh-plugin-shop/types#ShopSetEnabledResult',
258
258
  schema: dsh_plugin_shop_shop_setEnabled_result$schema,
259
259
  },
260
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":460,"column":9},
260
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":466,"column":9},
261
261
  },
262
262
  {
263
263
  id: 'dsh-plugin-shop#shop/uninstallStart',
@@ -283,7 +283,7 @@ export const TYPERT_REMOTE = {
283
283
  typeSymbol: 'dsh-plugin-shop/types#ShopUninstallResult',
284
284
  schema: dsh_plugin_shop_shop_uninstallStart_result$schema,
285
285
  },
286
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":815,"column":9},
286
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":840,"column":9},
287
287
  },
288
288
  {
289
289
  id: 'dsh-plugin-shop#shop/updateStart',
@@ -308,7 +308,7 @@ export const TYPERT_REMOTE = {
308
308
  typeSymbol: 'dsh-plugin-shop/types#ShopUpdateResult',
309
309
  schema: dsh_plugin_shop_shop_updateStart_result$schema,
310
310
  },
311
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":933,"column":9},
311
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":958,"column":9},
312
312
  },
313
313
  {
314
314
  id: 'dsh-plugin-shop#shop/version',
@@ -323,7 +323,7 @@ export const TYPERT_REMOTE = {
323
323
  typeSymbol: 'dsh-plugin-shop/types#ShopVersionResult',
324
324
  schema: dsh_plugin_shop_shop_version_result$schema,
325
325
  },
326
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":917,"column":9},
326
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":942,"column":9},
327
327
  },
328
328
  ],
329
329
  }
@@ -182,6 +182,25 @@ export declare function hasGithubHome(entry: CatalogEntry): boolean;
182
182
  */
183
183
  export declare function authorOf(entry: CatalogEntry): string | null;
184
184
  export declare function npmPageUrl(entry: CatalogEntry): string | null;
185
+ /**
186
+ * One entry's install identity — the value the catalog guarantees unique, and
187
+ * therefore the only safe React key for a shelf card.
188
+ *
189
+ * `name` is NOT unique. The catalog's uniqueness invariant is the install
190
+ * identity (registry `emit.ts` assertCatalogInvariants): `npm:<name>` for an
191
+ * npm entry, `github:<repo>#<subdir>` for a repo one — so two GitHub
192
+ * repositories publishing the same `package.json` name are two legitimate
193
+ * entries under one name, as are two subpackages of one monorepo. The live
194
+ * catalog holds 151 such names over 243 entries, five of them cookiecutter
195
+ * templates that all name themselves `{{PKG_NAME}}`.
196
+ *
197
+ * Keying the shelf by name handed React duplicate keys, and React could then
198
+ * no longer match a card to its DOM node: changing the filter left every
199
+ * duplicate orphaned on the page — hundreds of stale cards from the previous
200
+ * category, accumulating with each switch until the tab stopped responding.
201
+ * This mirrors the registry's identity verbatim; the two must not drift.
202
+ */
203
+ export declare function entryKey(entry: CatalogEntry): string;
185
204
  /**
186
205
  * One entry's star count from the sidecar, or undefined when it has none.
187
206
  *
@@ -1,4 +1,5 @@
1
1
  /** Catalog fetch, verification, and disk cache — the Host's only network path. */
2
+ import { type CatalogOrigin } from './origin.ts';
2
3
  import type { CatalogEntry, DeniedEntry } from './types.ts';
3
4
  /** Highest schemaVersion this build understands; a higher one is refused (§10).
4
5
  * 3 adds `source` and repo entries (github install channel); 4 adds `subdir`
@@ -27,7 +28,11 @@ export interface CatalogFs {
27
28
  write: (path: string, data: string) => void;
28
29
  }
29
30
  export interface LoadCatalogOptions {
30
- baseUrl: string;
31
+ /** A single HTTP origin — the explicit-override spelling. Mutually
32
+ * exclusive with `origins`; exactly one must be given. */
33
+ baseUrl?: string;
34
+ /** Origins to race (design §3). */
35
+ origins?: CatalogOrigin[];
31
36
  cacheDir: string;
32
37
  refresh?: boolean;
33
38
  fetchImpl?: typeof fetch;
@@ -46,3 +51,16 @@ export interface LoadCatalogOptions {
46
51
  * included, degrades to no stars (spec §5).
47
52
  */
48
53
  export declare function loadCatalog(options: LoadCatalogOptions): Promise<CatalogResult>;
54
+ /** The catalog base the shipped `cordis.patch.yml` names. A row carrying
55
+ * exactly this value expresses no preference, so the loader races its
56
+ * defaults; anything else is a deliberate override and is used alone. */
57
+ export declare const DEFAULT_CATALOG_URL = "https://LivXue.github.io/dsh-plugin-shop/v1/";
58
+ /** The npm package carrying the same `v1/` tree (design §2). */
59
+ export declare const CATALOG_PACKAGE = "dsh-plugin-shop-catalog";
60
+ /**
61
+ * The origins to race for this installation (design §3).
62
+ *
63
+ * @param catalogUrl - the row's configured base.
64
+ * @param npmRegistry - the user's own registry from `~/.npmrc`, or null.
65
+ */
66
+ export declare function catalogOrigins(catalogUrl: string, fetchImpl: typeof fetch, npmRegistry: string | null): CatalogOrigin[];
@@ -238,6 +238,9 @@ export declare class ShopGateway extends TypertRemoteService {
238
238
  /** The install gate runs against the last loaded snapshot, never a fresh
239
239
  * fetch per request (§7.2: the Host's cached snapshot is the truth). */
240
240
  private lastSnapshot;
241
+ /** The origin list built for the last-seen `catalogUrl`, memoised so the
242
+ * user's npmrc is read at most once per gateway (see `originsFor`). */
243
+ private originCache;
241
244
  /** The incompatibility map already computed for `lastSnapshot`, keyed by
242
245
  * that snapshot's own object identity. Design §3 asks for the verdict
243
246
  * once per loaded snapshot, not once per RPC call: `loadCatalog` serves
@@ -294,6 +297,10 @@ export declare class ShopGateway extends TypertRemoteService {
294
297
  enabled: boolean;
295
298
  }): Promise<ShopSetEnabledResult>;
296
299
  private rowConfig;
300
+ /** The origins to race for this row's catalog. Read once per gateway: the
301
+ * user's npmrc does not change under a running dsh, and re-reading it on
302
+ * every catalog call would put a filesystem read on the hot path. */
303
+ private originsFor;
297
304
  /** The explicit restart override. Only the row's `config:` sub-object is
298
305
  * passed to a plugin — a top-level `allowRestart:` beside `name:` would be
299
306
  * silently ignored by the loader (dsh-market README, #227). */
@@ -0,0 +1,24 @@
1
+ /** The npm transport (design §2, §3): the catalog as a package.
2
+ *
3
+ * Shell — this and `origin.ts`'s fetch half are the only places the catalog
4
+ * loader touches the network. The payoff is measured, not assumed: the same
5
+ * bytes reach a China-side machine at 12.53 MB/s from npmmirror against
6
+ * 0.03 MB/s from GitHub Pages. */
7
+ import { type CatalogOrigin } from './origin.ts';
8
+ /** Normalise to a trailing slash so relative `URL` resolution against a
9
+ * registry that carries a path — every corporate registry, e.g.
10
+ * `https://artifactory.corp/api/npm/npm-repo` — keeps that path instead of
11
+ * eating its last segment; a host-root registry's trailing slash is already
12
+ * a no-op either way. Exported so `catalog.ts`'s dedupe compares against the
13
+ * same normalised form `npmOrigin` races on. */
14
+ export declare function normalizeRegistryUrl(url: string): string;
15
+ /**
16
+ * An origin that reads the catalog out of `<registryUrl>`'s copy of
17
+ * `<packageName>`.
18
+ *
19
+ * The probe is the abbreviated `latest` manifest — 13.5 KB against the live
20
+ * registry — so the race is decided without downloading anything large. The
21
+ * tarball is fetched lazily on the first `pointer()` or `file()` and kept on
22
+ * the handle, so one origin download serves the whole load.
23
+ */
24
+ export declare function npmOrigin(rawRegistryUrl: string, packageName: string, fetchImpl: typeof fetch): CatalogOrigin;
@@ -0,0 +1,23 @@
1
+ /** The user's configured npm registry, if they have one (design §3).
2
+ *
3
+ * Pure: the caller injects the read. This is a deliberately partial reading
4
+ * of npm's config resolution — only the user-level `registry=` line — and
5
+ * that is safe precisely because the origin list is raced: a registry we
6
+ * guess wrong about loses a 400-byte request and nothing else.
7
+ *
8
+ * That property only holds for a value that is actually a URL, which is why
9
+ * the value is VALIDATED here rather than left to the caller. `npmOrigin`
10
+ * addresses its probe with `new URL(<pkg>/latest, registryUrl)`, which
11
+ * throws a raw `TypeError` — not a `TransportError` — for anything that is
12
+ * not an absolute URL, and `catalog.ts`'s race loop rethrows everything that
13
+ * is not a `TransportError`. An unvalidated `registry=` line would therefore
14
+ * fail the WHOLE load with npmmirror, npmjs and Pages all healthy and no
15
+ * cache fallback: the opposite of the stated property. Not a hypothetical
16
+ * shape either — `registry=${NPM_REGISTRY}/` is npm's own documented config
17
+ * expansion, it works perfectly for npm, and a reader that does not expand
18
+ * it captures the literal. */
19
+ /**
20
+ * @param readFile - returns the file's text, or null when it does not exist.
21
+ * @param home - the user's home directory.
22
+ */
23
+ export declare function npmrcRegistry(readFile: (path: string) => string | null, home: string): string | null;
@@ -0,0 +1,40 @@
1
+ /** The transport seam under `loadCatalog` (design §3).
2
+ *
3
+ * An origin answers a cheap probe, then serves the pointer and the files the
4
+ * pointer names. HTTP and npm are interchangeable behind it, so every line of
5
+ * cache and validation logic in `catalog.ts` stays transport-blind. */
6
+ /** A failure of the link, not of the content: the wire threw, or answered
7
+ * non-2xx. This is the ONLY class `loadCatalog` retries on another origin.
8
+ * A bad schema, a sha mismatch, or a refused url is an interpretation
9
+ * failure and throws — masking a corrupt origin behind a healthy one is
10
+ * exactly the silent-wrongness this project refuses. */
11
+ export declare class TransportError extends Error {
12
+ constructor(message: string, options?: {
13
+ cause?: unknown;
14
+ });
15
+ }
16
+ export interface OriginHandle {
17
+ readonly id: string;
18
+ /** The pointer JSON text. Cheap: the probe has already paid for it. */
19
+ pointer: () => Promise<string>;
20
+ /** One file named by the pointer, by the pointer's own raw url string.
21
+ * Callers pass that string verbatim — never a basename, which would strip
22
+ * a hostile absolute url into a fetchable relative one. */
23
+ file: (url: string) => Promise<string>;
24
+ }
25
+ export interface CatalogOrigin {
26
+ readonly id: string;
27
+ /** Cheap reachability + identity request. Resolving means this origin can
28
+ * serve; the expensive work happens on the returned handle. */
29
+ probe: (signal: AbortSignal) => Promise<OriginHandle>;
30
+ }
31
+ /** Resolve the pointer's data URL against the catalog base. An absolute URL —
32
+ * any scheme, or a protocol-relative `//host/...` — would hand the pointer a
33
+ * fetch primitive to arbitrary hosts, so it is refused loudly before any
34
+ * fetch (§9.2). The guard is the resolved origin, not the raw string: WHATWG
35
+ * normalization strips leading whitespace and accepts backslash spellings
36
+ * before the string could be inspected, so only comparing the resolved URL's
37
+ * origin to the base's closes every spelling class. */
38
+ export declare function resolveDataUrl(baseUrl: string, url: string): string;
39
+ /** The transport this project has always used: a static `v1/` tree. */
40
+ export declare function httpOrigin(baseUrl: string, fetchImpl: typeof fetch): CatalogOrigin;
@@ -0,0 +1,41 @@
1
+ /** Settle a set of promises in the order they finish (design §3).
2
+ *
3
+ * Pure and timer-free. `Promise.any` would give only the first success and
4
+ * discard the rest; the origin race needs the losers too, in order, so a
5
+ * probe that "won" but then fails to produce a pointer can fall through to
6
+ * the next-finishing probe instead of failing the whole load — this is how
7
+ * an npm origin's bulk tarball download, which happens inside `pointer()`,
8
+ * gets a fall-through on failure.
9
+ *
10
+ * That is deliberately narrower than "any bulk fetch falls through to
11
+ * another origin." This module only ever wraps `probe()` promises; once
12
+ * `catalog.ts` has committed to a handle and calls `file()` for the data
13
+ * file, that call happens outside this generator entirely, and a
14
+ * TransportError there falls back to the disk cache instead (see
15
+ * `catalog.ts`'s `cachedOrThrow`), never back into a race. Do not "fix"
16
+ * that path to match this comment — the split is intentional. */
17
+ export type Settled<T> = {
18
+ index: number;
19
+ value: T;
20
+ } | {
21
+ index: number;
22
+ reason: unknown;
23
+ };
24
+ /**
25
+ * Yield each promise's outcome as it settles, tagged with its argument index.
26
+ *
27
+ * Deliberately NOT an `async function*`. Two properties depend on that:
28
+ *
29
+ * 1. **Handlers attach synchronously, at call time.** An async generator's
30
+ * body does not run until its first `next()`, so wiring the handlers
31
+ * inside one would leave a rejection unhandled for as long as the caller
32
+ * waits before iterating — which crashes the process under Node's default
33
+ * unhandled-rejection policy.
34
+ * 2. **Order is recorded when each promise settles**, not when a consumer
35
+ * asks. Re-racing the survivors on every turn tie-breaks on argument
36
+ * order instead: `Promise.race` over promises that are ALREADY settled
37
+ * resolves with the first in iteration order, not the first to have
38
+ * settled — and a consumer doing any work between yields, which is
39
+ * exactly this module's use case, is what lets two settle inside one turn.
40
+ */
41
+ export declare function inCompletionOrder<T>(promises: readonly Promise<T>[]): AsyncGenerator<Settled<T>>;
@@ -0,0 +1,13 @@
1
+ /** A read-only ustar parser: the npm transport's only way into a tarball.
2
+ *
3
+ * Pure — bytes in, a path-to-bytes map out. It handles exactly what `npm
4
+ * pack` emits and refuses everything else loudly, because the alternative to
5
+ * a small strict reader is a fourth runtime dependency (design §4). */
6
+ /**
7
+ * Parse an uncompressed tar archive into path → bytes.
8
+ *
9
+ * Directory entries and every non-regular type (symlinks, pax and GNU
10
+ * extension headers) are skipped: npm packs regular files under `package/`,
11
+ * and a catalog tarball that needs anything else is not one we published.
12
+ */
13
+ export declare function readTar(buffer: Buffer): Map<string, Buffer>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-shop",
3
- "version": "0.6.0-beta.2",
3
+ "version": "0.7.0-beta.0",
4
4
  "description": "The DeepSeek Harness plugin shop: browse, install, enable, and update dsh plugins from a git-auditable catalog.",
5
5
  "repository": {
6
6
  "type": "git",