dsh-plugin-shop 0.7.4 → 0.8.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/lib/index.js CHANGED
@@ -1,16 +1,17 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
3
3
  import { loadOptionalPatches, readProfileManifest, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
4
- import { lt, minVersion, valid } from "semver";
4
+ import { lt, minVersion, satisfies, valid, validRange } from "semver";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  import { z } from "zod";
11
11
  import { gunzipSync } from "node:zlib";
12
- import { spawn } from "node:child_process";
13
- import { JSON_SCHEMA, Type, dump, load } from "js-yaml";
12
+ import { spawn, spawnSync } from "node:child_process";
13
+ import { StringDecoder } from "node:string_decoder";
14
+ import { DEFAULT_SCHEMA, JSON_SCHEMA, Type, dump, load } from "js-yaml";
14
15
  //#region src/own-version.ts
15
16
  /** The shop's own published version, read from the package.json that ships
16
17
  * next to this package — the RUNNING version, not the manifest's range
@@ -18,8 +19,27 @@ import { JSON_SCHEMA, Type, dump, load } from "js-yaml";
18
19
  * both the source tree (tests) and the bundled `lib/index.js` sit exactly
19
20
  * one level below the package root, so the same relative URL resolves in
20
21
  * both. */
22
+ function ownManifest() {
23
+ return JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
24
+ }
21
25
  function ownVersion() {
22
- return JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
26
+ return ownManifest().version;
27
+ }
28
+ /**
29
+ * The peer ranges this build declares — the input to the load-time harness
30
+ * self-check. Read from the same shipped manifest as `ownVersion`, so the
31
+ * ranges checked are the ones this build was actually published with, never
32
+ * a second copy that can drift from them.
33
+ *
34
+ * A non-string range is dropped rather than repaired: only what we can
35
+ * compare against is worth a verdict.
36
+ */
37
+ function ownPeerRanges() {
38
+ const declared = ownManifest().peerDependencies;
39
+ if (declared === null || typeof declared !== "object") return {};
40
+ const ranges = {};
41
+ for (const [spec, range] of Object.entries(declared)) if (typeof range === "string") ranges[spec] = range;
42
+ return ranges;
23
43
  }
24
44
  //#endregion
25
45
  //#region src/host/origin.ts
@@ -51,6 +71,39 @@ function resolveDataUrl(baseUrl, url) {
51
71
  if (resolved.origin !== new URL(baseUrl).origin) throw new Error("catalog data url must be relative to the catalog base");
52
72
  return resolved.href;
53
73
  }
74
+ /** The largest catalog body this host reads into memory. */
75
+ const MAX_BODY_BYTES = 67108864;
76
+ /** Read a response body through a byte cap, converting transport failures. */
77
+ async function readCappedBytes(response, label, maxBytes = MAX_BODY_BYTES) {
78
+ if (response.body === null) throw new TransportError(`${label} returned no body`);
79
+ const reader = response.body.getReader();
80
+ const chunks = [];
81
+ let bytes = 0;
82
+ try {
83
+ for (;;) {
84
+ const { done, value } = await reader.read();
85
+ if (done) break;
86
+ bytes += value.byteLength;
87
+ if (bytes > maxBytes) {
88
+ try {
89
+ await reader.cancel();
90
+ } catch {}
91
+ throw new TransportError(`${label} exceeded the ${maxBytes}-byte cap`);
92
+ }
93
+ chunks.push(Buffer.from(value));
94
+ }
95
+ } catch (error) {
96
+ if (error instanceof TransportError) throw error;
97
+ throw new TransportError(`${label} body read failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
98
+ } finally {
99
+ reader.releaseLock();
100
+ }
101
+ return Buffer.concat(chunks);
102
+ }
103
+ /** Read a capped response body as UTF-8 text. */
104
+ async function readCappedText(response, label, maxBytes = MAX_BODY_BYTES) {
105
+ return (await readCappedBytes(response, label, maxBytes)).toString("utf8");
106
+ }
54
107
  /** The transport this project has always used: a static `v1/` tree. */
55
108
  function httpOrigin(baseUrl, fetchImpl) {
56
109
  const id = `http:${baseUrl}`;
@@ -65,21 +118,21 @@ function httpOrigin(baseUrl, fetchImpl) {
65
118
  throw new TransportError(`catalog pointer fetch failed for ${id}: ${detail}`, { cause: error });
66
119
  }
67
120
  if (!response.ok) throw new TransportError(`catalog pointer returned ${response.status} for ${id}`);
68
- const pointerText = await response.text();
121
+ const pointerText = await readCappedText(response, `catalog pointer fetch for ${id}`);
69
122
  return {
70
123
  id,
71
124
  pointer: async () => pointerText,
72
- file: async (url) => {
125
+ file: async (url, signal) => {
73
126
  const resolved = resolveDataUrl(baseUrl, url);
74
127
  let dataResponse;
75
128
  try {
76
- dataResponse = await fetchImpl(resolved);
129
+ dataResponse = await fetchImpl(resolved, signal === void 0 ? void 0 : { signal });
77
130
  } catch (error) {
78
131
  const detail = error instanceof Error ? error.message : String(error);
79
132
  throw new TransportError(`catalog data fetch failed for ${id}: ${detail}`, { cause: error });
80
133
  }
81
134
  if (!dataResponse.ok) throw new TransportError(`catalog data returned ${dataResponse.status} for ${id}`);
82
- return dataResponse.text();
135
+ return readCappedText(dataResponse, `catalog data fetch for ${id}`);
83
136
  }
84
137
  };
85
138
  }
@@ -158,6 +211,10 @@ const latestSchema = z.object({
158
211
  });
159
212
  /** Where the published package keeps the catalog tree (design §2). */
160
213
  const PACKAGE_ROOT = "package/v1/";
214
+ /** Maximum compressed package bytes read from a registry. */
215
+ const MAX_PACKAGE_BYTES = 33554432;
216
+ /** Maximum bytes allowed after gzip inflation. */
217
+ const MAX_INFLATED_BYTES = MAX_BODY_BYTES;
161
218
  /** Verify tarball bytes against npm's own Subresource-Integrity string.
162
219
  * `dist.integrity` may carry several space-separated digests; npm publishes
163
220
  * one, and the first is the one we check. */
@@ -216,7 +273,7 @@ function npmOrigin(rawRegistryUrl, packageName, fetchImpl) {
216
273
  throw new TransportError(`npm origin ${registryUrl} returned an unparsable manifest: ${detail}`, { cause: error });
217
274
  }
218
275
  let files = null;
219
- const load = async () => {
276
+ const load = async (loadSignal) => {
220
277
  if (files !== null) return files;
221
278
  let tarballUrl;
222
279
  try {
@@ -228,41 +285,38 @@ function npmOrigin(rawRegistryUrl, packageName, fetchImpl) {
228
285
  if (tarballUrl.origin !== new URL(registryUrl).origin) throw new TransportError(`npm origin ${registryUrl}: dist.tarball host ${tarballUrl.origin} is not the registry's`);
229
286
  let tarballResponse;
230
287
  try {
231
- tarballResponse = await fetchImpl(tarballUrl.href);
288
+ tarballResponse = await fetchImpl(tarballUrl.href, loadSignal === void 0 ? void 0 : { signal: loadSignal });
232
289
  } catch (error) {
233
290
  const detail = error instanceof Error ? error.message : String(error);
234
291
  throw new TransportError(`npm origin ${registryUrl} tarball fetch failed: ${detail}`, { cause: error });
235
292
  }
236
293
  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
- }
294
+ const declaredLength = Number(tarballResponse.headers.get("content-length"));
295
+ if (Number.isFinite(declaredLength) && declaredLength > 33554432) throw new TransportError(`npm origin ${registryUrl} tarball exceeded the ${MAX_PACKAGE_BYTES}-byte cap`);
296
+ const bytes = await readCappedBytes(tarballResponse, `npm origin ${registryUrl} tarball`, MAX_PACKAGE_BYTES);
244
297
  verifyIntegrity(bytes, manifest.dist.integrity, registryUrl);
245
298
  let parsed;
246
299
  try {
247
- parsed = readTar(gunzipSync(bytes));
300
+ parsed = readTar(gunzipSync(bytes, { maxOutputLength: MAX_INFLATED_BYTES }));
248
301
  } catch (error) {
249
302
  const detail = error instanceof Error ? error.message : String(error);
303
+ if (error.code === "ERR_BUFFER_TOO_LARGE" || /larger than|too large/i.test(detail)) throw new TransportError(`npm origin ${registryUrl}: the tarball inflates past the ${MAX_INFLATED_BYTES}-byte cap; refusing to read it`, { cause: error });
250
304
  throw new TransportError(`npm origin ${registryUrl} served an unparsable tarball: ${detail}`, { cause: error });
251
305
  }
252
306
  files = parsed;
253
307
  return files;
254
308
  };
255
- const read = async (name) => {
256
- const entry = (await load()).get(`${PACKAGE_ROOT}${name}`);
309
+ const read = async (name, readSignal) => {
310
+ const entry = (await load(readSignal)).get(`${PACKAGE_ROOT}${name}`);
257
311
  if (entry === void 0) throw new TransportError(`npm origin ${registryUrl}: ${name} is not in the catalog package`);
258
312
  return entry.toString("utf8");
259
313
  };
260
314
  return {
261
315
  id,
262
316
  pointer: async () => read("index.json"),
263
- file: async (url) => {
317
+ file: async (url, fileSignal) => {
264
318
  if (url.includes("/") || url.startsWith(".")) throw new Error(`npm origin ${registryUrl}: ${JSON.stringify(url)} must be a plain file name`);
265
- return read(url);
319
+ return read(url, fileSignal);
266
320
  }
267
321
  };
268
322
  }
@@ -318,6 +372,38 @@ function inCompletionOrder(promises) {
318
372
  }
319
373
  })();
320
374
  }
375
+ //#endregion
376
+ //#region src/shared/identity.ts
377
+ /** The registry's uniqueness rule, shared with the presentation layer. */
378
+ function identityKey(identity) {
379
+ return identity.source === "npm" ? `npm:${identity.name}` : `github:${identity.repo ?? identity.name}#${identity.subdir ?? ""}`;
380
+ }
381
+ /** A GitHub commit pin. */
382
+ const COMMIT_SHA = /^[0-9a-f]{40}$/;
383
+ /** A GitHub release tag accepted as the version/pin of a rescued entry. */
384
+ const RELEASE_TAG = /^[A-Za-z0-9][A-Za-z0-9._+/-]{0,127}$/;
385
+ /** `owner/slug`, lowercased, or null when this is not a repository reference. */
386
+ const REPO = "([\\w.-]+)\\/([\\w.-]+?)";
387
+ const GITHUB_SHORTHAND = new RegExp(`^github:${REPO}(?:[#&].*)?$`);
388
+ const GITHUB_URL = new RegExp(`^(?:git\\+)?https?:\\/\\/(?:www\\.)?github\\.com\\/${REPO}(?:\\.git)?(?:[\\/?#].*)?$`);
389
+ /**
390
+ * Read the repository out of a profile dependency spec, or return null for
391
+ * an npm range/spec that does not identify a GitHub repository.
392
+ */
393
+ function parseRepoSpec(spec) {
394
+ const shorthand = GITHUB_SHORTHAND.exec(spec);
395
+ if (shorthand !== null && shorthand[1] !== void 0 && shorthand[2] !== void 0) return `${shorthand[1]}/${shorthand[2]}`.toLowerCase();
396
+ const url = GITHUB_URL.exec(spec);
397
+ if (url !== null && url[1] !== void 0 && url[2] !== void 0) return `${url[1]}/${url[2]}`.toLowerCase();
398
+ return null;
399
+ }
400
+ /** Whether an installed dependency spec names this catalog entry. */
401
+ function installedSpecMatches(entry, spec) {
402
+ const repo = parseRepoSpec(spec);
403
+ if (entry.source === "npm") return repo === null;
404
+ if (entry.repo === void 0) return false;
405
+ return repo === entry.repo.toLowerCase();
406
+ }
321
407
  /** A cached catalog younger than this is served without touching the network. */
322
408
  const FRESH_MS = 3e5;
323
409
  /** How long a probe may take before the race gives up on that origin. Long
@@ -332,27 +418,35 @@ const PROBE_TIMEOUT_MS = 1e4;
332
418
  * exists to prevent. Generous against every measured npm origin (12.53 MB/s
333
419
  * mirror -> 0.12 s for 1.5 MB; npmjs direct 1.99 MB/s -> 0.75 s). */
334
420
  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) {
421
+ /** Run one committed-origin read under a deadline and abort it on expiry. */
422
+ async function withCommitBudget(id, what, start) {
423
+ const controller = new AbortController();
341
424
  let timer;
342
425
  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);
426
+ return await Promise.race([start(controller.signal), new Promise((_resolve, reject) => {
427
+ timer = setTimeout(() => {
428
+ const expired = new TransportError(`${id} did not ${what} within ${COMMIT_TIMEOUT_MS} ms`);
429
+ controller.abort(expired);
430
+ reject(expired);
431
+ }, COMMIT_TIMEOUT_MS);
345
432
  })]);
346
433
  } finally {
347
434
  clearTimeout(timer);
348
435
  }
349
436
  }
437
+ /** npm's package-name grammar. The value becomes half of an install spec, so
438
+ * aliases and shell punctuation must not reach the CLI. */
439
+ const NPM_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
440
+ /** Canonical SemVer 2.0.0, including legal prerelease/build metadata. */
441
+ const NPM_VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/;
442
+ /** The repository binding used to build a GitHub install spec. */
443
+ const REPO_FULL_NAME = /^[\w.-]+\/[\w.-]+$/;
350
444
  /** Records when the loader itself wrote the cache; the pointer's `builtAt` is
351
445
  * the catalog's build time, not the cache's fetch time. */
352
446
  const META_FILE = "index.meta.json";
353
447
  const entrySchema = z.object({
354
- name: z.string(),
355
- version: z.string(),
448
+ name: z.string().min(1).max(214).regex(/^[^\u0000-\u001f\u007f]+$/, "entry name carries a control character"),
449
+ version: z.string().min(1).max(256),
356
450
  integrity: z.string().nullable(),
357
451
  publishedAt: z.string().nullable(),
358
452
  repository: z.string().nullable(),
@@ -388,15 +482,39 @@ const entrySchema = z.object({
388
482
  capabilities: z.array(z.string())
389
483
  }).optional(),
390
484
  source: z.enum(["npm", "github"]).default("npm"),
391
- repo: z.string().optional(),
485
+ repo: z.string().regex(REPO_FULL_NAME, "repo must be owner/slug").optional(),
392
486
  subdir: z.string().regex(/^(?!.*(^|\/)\.\.?(\/|$))[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/).optional(),
393
487
  added: z.string().optional(),
394
488
  tarball: z.object({
395
489
  url: z.string(),
396
- sha256: z.string()
490
+ sha256: z.string().regex(/^[0-9a-f]{64}$/)
397
491
  }).optional(),
398
492
  publisher: z.string().optional(),
399
493
  peers: z.array(z.string()).optional()
494
+ }).superRefine((entry, ctx) => {
495
+ if (entry.source === "npm") {
496
+ if (!NPM_NAME.test(entry.name)) ctx.addIssue({
497
+ code: "custom",
498
+ path: ["name"],
499
+ message: `npm entry name ${JSON.stringify(entry.name)} is outside npm package-name grammar`
500
+ });
501
+ if (!NPM_VERSION.test(entry.version)) ctx.addIssue({
502
+ code: "custom",
503
+ path: ["version"],
504
+ message: `npm entry version ${JSON.stringify(entry.version)} is not a plain semver version`
505
+ });
506
+ return;
507
+ }
508
+ if (entry.repo === void 0) ctx.addIssue({
509
+ code: "custom",
510
+ path: ["repo"],
511
+ message: "a github entry must carry its repo — it is the entry's identity and the spec is built from it"
512
+ });
513
+ if (!COMMIT_SHA.test(entry.version) && !RELEASE_TAG.test(entry.version)) ctx.addIssue({
514
+ code: "custom",
515
+ path: ["version"],
516
+ message: `github entry version ${JSON.stringify(entry.version)} is neither a 40-character commit sha nor a release tag`
517
+ });
400
518
  });
401
519
  const dataSchema = z.object({
402
520
  schemaVersion: z.number(),
@@ -429,6 +547,7 @@ function validateEntryCoherence(entries) {
429
547
  throw new Error(`catalog entry ${entry.name}: tarball url is unparseable`);
430
548
  }
431
549
  if (parsed.protocol !== "https:" || parsed.hostname !== "github.com") throw new Error(`catalog entry ${entry.name}: tarball url must be https on github.com`);
550
+ if (parsed.search !== "" || parsed.hash !== "") throw new Error(`catalog entry ${entry.name}: tarball url must carry no query or fragment`);
432
551
  const segments = parsed.pathname.split("/").filter((s) => s !== "");
433
552
  if (`${segments[0] ?? ""}/${segments[1] ?? ""}`.toLowerCase() !== entry.repo.toLowerCase() || segments[2] !== "releases") throw new Error(`catalog entry ${entry.name}: tarball url is not a release of ${entry.repo}`);
434
553
  }
@@ -460,7 +579,7 @@ function parseStarsText(text) {
460
579
  try {
461
580
  const parsed = JSON.parse(text);
462
581
  if (typeof parsed.stars !== "object" || parsed.stars === null) return {};
463
- const out = {};
582
+ const out = Object.create(null);
464
583
  for (const [key, value] of Object.entries(parsed.stars)) if (typeof value === "number") out[key] = value;
465
584
  return out;
466
585
  } catch {
@@ -553,7 +672,7 @@ async function loadCatalog(options) {
553
672
  continue;
554
673
  }
555
674
  try {
556
- pointerText = await withCommitTimeout(settled.value.pointer(), settled.value.id);
675
+ pointerText = await withCommitBudget(settled.value.id, "produce a pointer", () => settled.value.pointer());
557
676
  handle = settled.value;
558
677
  break;
559
678
  } catch (error) {
@@ -566,7 +685,7 @@ async function loadCatalog(options) {
566
685
  if (pointer.schemaVersion > 6) throw new Error(`catalog schemaVersion ${pointer.schemaVersion} is newer than this build supports (6)`);
567
686
  let dataText;
568
687
  try {
569
- dataText = await handle.file(pointer.plugins.url);
688
+ dataText = await withCommitBudget(handle.id, `serve ${pointer.plugins.url}`, (signal) => handle.file(pointer.plugins.url, signal));
570
689
  } catch (error) {
571
690
  if (!(error instanceof TransportError)) throw error;
572
691
  return cachedOrThrow(error);
@@ -577,13 +696,16 @@ async function loadCatalog(options) {
577
696
  if (data.schemaVersion > 6) throw new Error(`catalog schemaVersion ${data.schemaVersion} is newer than this build supports (6)`);
578
697
  validateEntryCoherence(data.plugins);
579
698
  let stars = {};
580
- if (pointer.stars !== void 0) try {
581
- const starsText = await handle.file(pointer.stars.url);
582
- if (createHash("sha256").update(starsText).digest("hex") === pointer.stars.sha256) {
583
- stars = parseStarsText(starsText);
584
- fsImpl.write(join(cacheDir, basename(pointer.stars.url)), starsText);
585
- }
586
- } catch {}
699
+ if (pointer.stars !== void 0) {
700
+ const sidecar = pointer.stars;
701
+ try {
702
+ const starsText = await withCommitBudget(handle.id, `serve ${sidecar.url}`, (signal) => handle.file(sidecar.url, signal));
703
+ if (createHash("sha256").update(starsText).digest("hex") === sidecar.sha256) {
704
+ stars = parseStarsText(starsText);
705
+ fsImpl.write(join(cacheDir, basename(sidecar.url)), starsText);
706
+ }
707
+ } catch {}
708
+ }
587
709
  const snapshot = {
588
710
  schemaVersion: pointer.schemaVersion,
589
711
  builtAt: pointer.builtAt,
@@ -672,11 +794,7 @@ function npmrcRegistry(readFile, home) {
672
794
  }
673
795
  //#endregion
674
796
  //#region src/host/install.ts
675
- /**
676
- * Decide whether one install request may proceed, against the Host's own
677
- * snapshot (§5.3). The browser sends a name; nothing the browser says about
678
- * the package is trusted.
679
- */
797
+ /** Decide whether an install request may proceed and return the resolved row. */
680
798
  function validateInstall(snapshot, args) {
681
799
  const denied = snapshot.denied.find((d) => d.name === args.name);
682
800
  if (denied !== void 0) return {
@@ -684,7 +802,29 @@ function validateInstall(snapshot, args) {
684
802
  code: "denied",
685
803
  detail: `dsh-plugin-shop: ${args.name} is denied: ${denied.detail}`
686
804
  };
687
- const entry = snapshot.entries.find((e) => e.name === args.name);
805
+ let entry;
806
+ if (args.source === void 0) {
807
+ const named = snapshot.entries.filter((e) => e.name === args.name);
808
+ if (named.length > 1) return {
809
+ ok: false,
810
+ code: "ambiguous-identity",
811
+ detail: `dsh-plugin-shop: the catalog holds ${named.length} entries named ${args.name}, and this request does not say which one; refresh the shop and try again`
812
+ };
813
+ entry = named[0];
814
+ } else {
815
+ const wanted = identityKey({
816
+ source: args.source,
817
+ name: args.name,
818
+ repo: args.repo,
819
+ subdir: args.subdir
820
+ });
821
+ entry = snapshot.entries.find((candidate) => identityKey(candidate) === wanted);
822
+ if (entry === void 0) return {
823
+ ok: false,
824
+ code: "not-in-catalog",
825
+ detail: `dsh-plugin-shop: ${wanted} is not in the catalog`
826
+ };
827
+ }
688
828
  if (entry === void 0) return {
689
829
  ok: false,
690
830
  code: "not-in-catalog",
@@ -700,7 +840,10 @@ function validateInstall(snapshot, args) {
700
840
  code: "needs-acknowledgement",
701
841
  detail: entry.tier === "verified-stale" ? `dsh-plugin-shop: ${args.name} is verified-stale: a newer version than the review is current and has not been reviewed; acknowledgement is required` : `dsh-plugin-shop: ${args.name} is ${entry.tier}-tier and has not been reviewed; acknowledgement is required`
702
842
  };
703
- return { ok: true };
843
+ return {
844
+ ok: true,
845
+ entry
846
+ };
704
847
  }
705
848
  //#endregion
706
849
  //#region src/host/dsh-cli.ts
@@ -817,6 +960,11 @@ function resolveDshScript(fs, options) {
817
960
  * post-exit manifest confirmation. */
818
961
  const MAX_LOG_LINES = 200;
819
962
  const MAX_LOG_BYTES = 65536;
963
+ /** Bound one dsh command so a stalled install cannot hold the profile queue
964
+ * forever. Tests can pass a shorter value; production stays generous. */
965
+ const INSTALL_TIMEOUT_MS = Number(process.env.DSH_SHOP_INSTALL_TIMEOUT_MS) || 9e5;
966
+ /** Grace period for output already buffered after the child exits. */
967
+ const PIPE_DRAIN_MS = 500;
820
968
  const profileQueues = /* @__PURE__ */ new Map();
821
969
  function chain(profile, task) {
822
970
  const next = (profileQueues.get(profile) ?? Promise.resolve()).then(task, task);
@@ -909,6 +1057,54 @@ function spawnFailureDetail(code, message, dshBin, platform) {
909
1057
  if (code === "ENOENT") return "dsh not found on PATH — install the dsh CLI to manage profile plugins";
910
1058
  return `dsh spawn failed: ${message}`;
911
1059
  }
1060
+ /** The failure detail for a command stopped by the shop's deadline. */
1061
+ function installTimeoutDetail(profile, timeoutMs) {
1062
+ return `dsh-plugin-shop: the command did not finish within ${Math.max(1, Math.round(timeoutMs / 1e3))}s and was stopped. Run it yourself to see what it is waiting on: dsh plugin --profile ${profile} install`;
1063
+ }
1064
+ const nodeKills = {
1065
+ killGroup: (pid) => process.kill(-pid, "SIGKILL"),
1066
+ killPid: (pid) => process.kill(pid, "SIGKILL"),
1067
+ taskkill: (pid) => {
1068
+ spawnSync("taskkill", [
1069
+ "/PID",
1070
+ String(pid),
1071
+ "/T",
1072
+ "/F"
1073
+ ], { stdio: "ignore" });
1074
+ }
1075
+ };
1076
+ /** Kill a child and its descendants (process group on POSIX, taskkill tree on Windows). */
1077
+ function killTree(pid, platform, kills = nodeKills) {
1078
+ if (pid === void 0) return;
1079
+ if (platform === "win32") {
1080
+ kills.taskkill(pid);
1081
+ return;
1082
+ }
1083
+ try {
1084
+ kills.killGroup(pid);
1085
+ } catch {
1086
+ try {
1087
+ kills.killPid(pid);
1088
+ } catch {}
1089
+ }
1090
+ }
1091
+ function lineSink(emit) {
1092
+ const decoder = new StringDecoder("utf8");
1093
+ let pending = "";
1094
+ return {
1095
+ write(chunk) {
1096
+ pending += decoder.write(chunk);
1097
+ const parts = pending.split(/\r?\n/);
1098
+ pending = parts.pop() ?? "";
1099
+ for (const line of parts) if (line !== "") emit(line);
1100
+ },
1101
+ flush() {
1102
+ pending += decoder.end();
1103
+ if (pending !== "") emit(pending);
1104
+ pending = "";
1105
+ }
1106
+ };
1107
+ }
912
1108
  /** Read-only filesystem seam for the CLI lookup; the same shape as `pinFs`. */
913
1109
  const nodeFs$1 = {
914
1110
  exists: (path) => existsSync(path),
@@ -926,6 +1122,12 @@ function dshScript() {
926
1122
  });
927
1123
  return cachedScript;
928
1124
  }
1125
+ /** Shell punctuation that must never reach the downstream CLI as an operand.
1126
+ * The downstream dsh invokes pnpm with shell mode on Windows, where these
1127
+ * characters alter the command line. `&` is intentionally allowed here: the
1128
+ * legitimate monorepo spec uses it as `&path:<subdir>`, and catalog.ts
1129
+ * validates each component before it reaches this layer. */
1130
+ const UNSAFE_TARGET = /[\s"'`|<>^$();\\{}]|[\u0000-\u001f\u007f]/;
929
1131
  /** Run one `dsh plugin --profile <profile> <verb> <target>` and track it.
930
1132
  * Never rolls back; a failure surfaces stderr verbatim plus the recovery hint
931
1133
  * (§10). The shop never passes build-script flags: `allowBuilds` stays the
@@ -941,9 +1143,10 @@ function dshScript() {
941
1143
  * must settle before it. A throwing callback never fails the install — the
942
1144
  * package IS installed; it reports `done` with the restart fallback. */
943
1145
  function spawnPluginCli(options) {
944
- const { profile, argv, dshBin, env, confirm, afterDone, onStatus } = options;
1146
+ const { profile, argv, dshBin, env, confirm, afterDone, onStatus, timeoutMs = INSTALL_TIMEOUT_MS } = options;
945
1147
  const target = argv[1];
946
1148
  if (target === void 0 || target.startsWith("-")) throw new Error(`dsh-plugin-shop: refusing to spawn with a flag-like operand: ${target ?? "(none)"}`);
1149
+ if (UNSAFE_TARGET.test(target)) throw new Error(`dsh-plugin-shop: refusing to spawn with an unsafe operand: ${JSON.stringify(target)}`);
947
1150
  const installId = randomUUID();
948
1151
  const log = [];
949
1152
  let logBytes = 0;
@@ -961,6 +1164,7 @@ function spawnPluginCli(options) {
961
1164
  ...detail !== void 0 ? { detail } : {}
962
1165
  });
963
1166
  const append = (line) => {
1167
+ if (state !== "running") return;
964
1168
  log.push(line);
965
1169
  logBytes += Buffer.byteLength(line);
966
1170
  while ((log.length > MAX_LOG_LINES || logBytes > MAX_LOG_BYTES) && log.length > 1) {
@@ -999,24 +1203,33 @@ function spawnPluginCli(options) {
999
1203
  "pipe",
1000
1204
  "pipe"
1001
1205
  ],
1002
- env: env ?? process.env
1206
+ env: env ?? process.env,
1207
+ detached: process.platform !== "win32"
1003
1208
  });
1004
1209
  } catch (error) {
1005
1210
  resolve(failToStart(error));
1006
1211
  return;
1007
1212
  }
1008
- child.stdout.on("data", (chunk) => {
1009
- for (const line of chunk.toString().split(/\r?\n/)) if (line !== "") append(line);
1010
- });
1011
- child.stderr.on("data", (chunk) => {
1012
- for (const line of chunk.toString().split(/\r?\n/)) if (line !== "") append(line);
1013
- });
1014
- child.on("error", (error) => {
1015
- resolve(failToStart(error));
1016
- });
1017
- child.on("close", async (exitCode) => {
1213
+ let exited = false;
1214
+ let closed = false;
1215
+ let exitCode = null;
1216
+ let timedOut = false;
1217
+ let drainTimer;
1218
+ let deadlineTimer;
1219
+ const outLines = lineSink(append);
1220
+ const errLines = lineSink(append);
1221
+ const settle = async () => {
1018
1222
  if (state !== "running") return;
1019
- if (exitCode === 0) {
1223
+ clearTimeout(drainTimer);
1224
+ clearTimeout(deadlineTimer);
1225
+ outLines.flush();
1226
+ errLines.flush();
1227
+ child.stdout.destroy();
1228
+ child.stderr.destroy();
1229
+ if (timedOut) {
1230
+ state = "failed";
1231
+ detail = installTimeoutDetail(profile, timeoutMs);
1232
+ } else if (exitCode === 0) {
1020
1233
  const confirmDetail = confirm?.(env?.DSH_HOME);
1021
1234
  if (confirmDetail != null) {
1022
1235
  state = "failed";
@@ -1038,7 +1251,41 @@ function spawnPluginCli(options) {
1038
1251
  }
1039
1252
  onStatus?.(status());
1040
1253
  resolve(status());
1254
+ };
1255
+ const drainThenSettle = () => {
1256
+ clearTimeout(drainTimer);
1257
+ drainTimer = setTimeout(() => {
1258
+ settle();
1259
+ }, PIPE_DRAIN_MS);
1260
+ };
1261
+ child.stdout.on("data", (chunk) => {
1262
+ outLines.write(chunk);
1263
+ });
1264
+ child.stderr.on("data", (chunk) => {
1265
+ errLines.write(chunk);
1266
+ });
1267
+ child.on("error", (error) => {
1268
+ if (state !== "running") return;
1269
+ clearTimeout(drainTimer);
1270
+ clearTimeout(deadlineTimer);
1271
+ resolve(failToStart(error));
1272
+ });
1273
+ child.on("exit", (code) => {
1274
+ exited = true;
1275
+ exitCode = code;
1276
+ if (closed) settle();
1277
+ else drainThenSettle();
1041
1278
  });
1279
+ child.on("close", () => {
1280
+ closed = true;
1281
+ if (exited) settle();
1282
+ });
1283
+ deadlineTimer = setTimeout(() => {
1284
+ if (state !== "running") return;
1285
+ timedOut = true;
1286
+ killTree(child.pid, process.platform);
1287
+ drainThenSettle();
1288
+ }, timeoutMs);
1042
1289
  }))
1043
1290
  };
1044
1291
  }
@@ -1049,7 +1296,7 @@ function spawnPluginCli(options) {
1049
1296
  * is given, the terminal `done` waits for it to settle (§D hot mount).
1050
1297
  */
1051
1298
  function startInstall(options) {
1052
- const { profile, spec, dshBin = "dsh", env, expectedName, afterDone, onStatus } = options;
1299
+ const { profile, spec, dshBin = "dsh", env, expectedName, afterDone, onStatus, timeoutMs } = options;
1053
1300
  return spawnPluginCli({
1054
1301
  profile,
1055
1302
  argv: ["add", spec],
@@ -1057,7 +1304,8 @@ function startInstall(options) {
1057
1304
  env,
1058
1305
  confirm: expectedName !== void 0 ? (home) => confirmBundleActivation(profile, home, expectedName) : void 0,
1059
1306
  afterDone,
1060
- onStatus
1307
+ onStatus,
1308
+ timeoutMs
1061
1309
  });
1062
1310
  }
1063
1311
  /**
@@ -1068,7 +1316,7 @@ function startInstall(options) {
1068
1316
  * waits for it to settle (§D hot mount).
1069
1317
  */
1070
1318
  function startUninstall(options) {
1071
- const { profile, name, dshBin = "dsh", env, expectedName, afterDone, onStatus } = options;
1319
+ const { profile, name, dshBin = "dsh", env, expectedName, afterDone, onStatus, timeoutMs } = options;
1072
1320
  return spawnPluginCli({
1073
1321
  profile,
1074
1322
  argv: ["remove", name],
@@ -1076,7 +1324,8 @@ function startUninstall(options) {
1076
1324
  env,
1077
1325
  confirm: expectedName !== void 0 ? (home) => confirmBundleRemoval(profile, home, expectedName) : void 0,
1078
1326
  afterDone,
1079
- onStatus
1327
+ onStatus,
1328
+ timeoutMs
1080
1329
  });
1081
1330
  }
1082
1331
  //#endregion
@@ -1181,7 +1430,13 @@ function parseSimplePatch(patchText) {
1181
1430
  * the output is itself a simple patch (round-trips through
1182
1431
  * parseSimplePatch). */
1183
1432
  function renderRows(rows, prefix) {
1184
- return rows.map((row) => `- id: ${prefix}${row.id}\n name: ${row.name}`).join("\n") + "\n";
1433
+ return dump(rows.map((row) => ({
1434
+ id: `${prefix}${row.id}`,
1435
+ name: row.name
1436
+ })), {
1437
+ noRefs: true,
1438
+ lineWidth: -1
1439
+ });
1185
1440
  }
1186
1441
  /** The next free `hot-<n>` number: one past the highest existing file, or 1
1187
1442
  * when the directory does not exist yet (the write recreates it). */
@@ -1246,9 +1501,18 @@ async function hotMount(ctx, profileDir, packageName, deps = {}) {
1246
1501
  const { fs = nodeFs, dir = join(profileDir, HOT_DIR), timeoutMs = Number(process.env.DSH_SHOP_HOT_MOUNT_TIMEOUT_MS) || 1e4, now = Date.now } = deps;
1247
1502
  const packageDir = join(profileDir, "node_modules", packageName);
1248
1503
  const dsh = readPkgDsh(fs, packageDir);
1504
+ const patchFile = resolve(packageDir, dsh?.patch ?? "cordis.patch.yml");
1505
+ const inside = relative(packageDir, patchFile);
1506
+ if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
1507
+ ctx.logger?.warn(`hot-mount ${packageName}: the declared bundle patch is outside the package directory — restart will activate it`);
1508
+ return {
1509
+ ok: false,
1510
+ reason: "no-patch"
1511
+ };
1512
+ }
1249
1513
  let patchText;
1250
1514
  try {
1251
- patchText = fs.read(join(packageDir, dsh?.patch ?? "cordis.patch.yml"));
1515
+ patchText = fs.read(patchFile);
1252
1516
  } catch {
1253
1517
  ctx.logger?.warn(`hot-mount ${packageName}: no patch file to mount — restart will activate it`);
1254
1518
  return {
@@ -1382,17 +1646,41 @@ function cleanHotDir(profileDir) {
1382
1646
  * function has already returned and the caller has committed — so the gateway
1383
1647
  * refuses on Windows before reaching here rather than exiting into nothing
1384
1648
  * (index.ts `restartPlatformSupported`). */
1649
+ /**
1650
+ * Resolve the command line that re-runs this process.
1651
+ *
1652
+ * A bare `dsh` from PATH is not necessarily the process serving the current
1653
+ * port: a dsh launched through `node`, `npx`, or `pnpm dlx` carries its own
1654
+ * script and `execArgv`. Reconstructing that exact node invocation preserves
1655
+ * the entry point and runtime flags. An explicitly named binary remains an
1656
+ * explicit choice and is passed through unchanged.
1657
+ */
1658
+ function restartCommand(options) {
1659
+ const { dshBin, argv, execPath, execArgv, script } = options;
1660
+ if (dshBin === "dsh" && script !== void 0) return {
1661
+ command: execPath,
1662
+ args: [
1663
+ ...execArgv,
1664
+ script,
1665
+ ...argv
1666
+ ]
1667
+ };
1668
+ return {
1669
+ command: dshBin,
1670
+ args: [...argv]
1671
+ };
1672
+ }
1385
1673
  function startRestart(options) {
1386
- const { dshBin, argv, parentPid, logFile, env } = options;
1674
+ const { command, args, parentPid, logFile, env } = options;
1387
1675
  const logFd = openSync(logFile, "a");
1388
1676
  try {
1389
- spawn("sh", [
1677
+ const helper = spawn("sh", [
1390
1678
  "-c",
1391
1679
  "while kill -0 \"$1\" 2>/dev/null; do sleep 0.2; done; shift; exec \"$@\"",
1392
1680
  "sh",
1393
1681
  String(parentPid),
1394
- dshBin,
1395
- ...argv
1682
+ command,
1683
+ ...args
1396
1684
  ], {
1397
1685
  stdio: [
1398
1686
  "ignore",
@@ -1401,7 +1689,13 @@ function startRestart(options) {
1401
1689
  ],
1402
1690
  env: env ?? process.env,
1403
1691
  detached: true
1404
- }).unref();
1692
+ });
1693
+ helper.on("error", (error) => {
1694
+ try {
1695
+ appendFileSync(logFile, `dsh-plugin-shop: the restart helper could not start: ${error.message}\n`);
1696
+ } catch {}
1697
+ });
1698
+ helper.unref();
1405
1699
  } finally {
1406
1700
  closeSync(logFd);
1407
1701
  }
@@ -1415,12 +1709,22 @@ function startRestart(options) {
1415
1709
  * harvested into its own catalog. */
1416
1710
  /** The npm package the shop itself is published as. */
1417
1711
  const SHOP_PACKAGE = "dsh-plugin-shop";
1418
- /** Fetch the shop's `latest` dist-tag, or `null` when the registry cannot
1419
- * answer (network failure, unexpected payload — anything). The caller
1420
- * renders `null` as "no update check", never as a failure. */
1421
- async function fetchLatestVersion(fetchFn = fetch) {
1712
+ /** The registry that always gets asked, last. */
1713
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
1714
+ /** How long one registry gets to answer. `shop/version` is warmed at every
1715
+ * web boot, so a silent socket must not hold the boot indefinitely. */
1716
+ const VERSION_CHECK_TIMEOUT_MS = 5e3;
1717
+ /** Ask one registry. Any failure — network, non-2xx, unexpected payload,
1718
+ * deadline — is the same outcome: no answer from this registry. */
1719
+ async function askRegistry(fetchFn, registry, timeoutMs) {
1720
+ const url = `${registry}${SHOP_PACKAGE}`;
1721
+ const signal = AbortSignal.timeout(timeoutMs);
1422
1722
  try {
1423
- const response = await fetchFn(`https://registry.npmjs.org/${SHOP_PACKAGE}`);
1723
+ const response = await Promise.race([fetchFn(url, { signal }), new Promise((_resolve, reject) => {
1724
+ signal.addEventListener("abort", () => {
1725
+ reject(/* @__PURE__ */ new Error(`the version check did not answer within ${timeoutMs} ms`));
1726
+ }, { once: true });
1727
+ })]);
1424
1728
  if (!response.ok) return null;
1425
1729
  const latest = (await response.json())["dist-tags"]?.latest;
1426
1730
  return typeof latest === "string" ? latest : null;
@@ -1428,6 +1732,24 @@ async function fetchLatestVersion(fetchFn = fetch) {
1428
1732
  return null;
1429
1733
  }
1430
1734
  }
1735
+ /**
1736
+ * Fetch the shop's `latest` dist-tag, or `null` when no registry can answer.
1737
+ * The user's own registry is tried first when known; npmjs is always the
1738
+ * fallback so a mirror that lags cannot hide a release.
1739
+ */
1740
+ async function fetchLatestVersion(fetchFn = fetch, options = {}) {
1741
+ const { registry = null, timeoutMs = VERSION_CHECK_TIMEOUT_MS } = options;
1742
+ const asked = /* @__PURE__ */ new Set();
1743
+ const registries = registry === null ? [DEFAULT_REGISTRY] : [registry, DEFAULT_REGISTRY];
1744
+ for (const base of registries) {
1745
+ const normalized = base.endsWith("/") ? base : `${base}/`;
1746
+ if (asked.has(normalized)) continue;
1747
+ asked.add(normalized);
1748
+ const latest = await askRegistry(fetchFn, normalized, timeoutMs);
1749
+ if (latest !== null) return latest;
1750
+ }
1751
+ return null;
1752
+ }
1431
1753
  //#endregion
1432
1754
  //#region src/host/supervisor.ts
1433
1755
  function detectSupervisor(env, proc) {
@@ -1435,14 +1757,25 @@ function detectSupervisor(env, proc) {
1435
1757
  }
1436
1758
  //#endregion
1437
1759
  //#region src/host/repo-pins.ts
1760
+ /**
1761
+ * The Host's memory of which commit it installed for each github entry.
1762
+ *
1763
+ * pnpm writes the manifest dependency as `github:owner/slug` — no commit —
1764
+ * and the resolved commit lives only in the lockfile. The shop records the
1765
+ * commit it pinned at install time in its own cache so `installed()` can
1766
+ * report `outdated` honestly without parsing pnpm's lockfile. The profile
1767
+ * manifest remains the source of truth for *presence*: a pin whose bundle
1768
+ * is not a manifest dependency is a leftover from a failed install and is
1769
+ * never reported.
1770
+ */
1438
1771
  /** Read the pins file; any irregularity degrades to an empty record. */
1439
1772
  function readRepoPins(fs, path) {
1440
1773
  if (!fs.exists(path)) return {};
1441
1774
  try {
1442
1775
  const parsed = JSON.parse(fs.read(path));
1443
1776
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
1444
- const out = {};
1445
- for (const [name, commit] of Object.entries(parsed)) if (typeof commit === "string" && /^[0-9a-f]{40}$/.test(commit)) out[name] = commit;
1777
+ const out = Object.create(null);
1778
+ for (const [name, pin] of Object.entries(parsed)) if (typeof pin === "string" && (COMMIT_SHA.test(pin) || RELEASE_TAG.test(pin))) out[name] = pin;
1446
1779
  return out;
1447
1780
  } catch {
1448
1781
  return {};
@@ -1455,6 +1788,18 @@ function writeRepoPins(fs, path, pins) {
1455
1788
  //#endregion
1456
1789
  //#region src/host/profile.ts
1457
1790
  /** Profile directory discovery and user-layer writes (§8: hot enable/disable). */
1791
+ function isJsExpr(value) {
1792
+ return typeof value === "object" && value !== null && typeof value.__jsExpr === "string";
1793
+ }
1794
+ const JS_EXPR_TYPE = new Type("tag:yaml.org,2002:js", {
1795
+ kind: "scalar",
1796
+ resolve: () => true,
1797
+ construct: (data) => ({ __jsExpr: data }),
1798
+ predicate: isJsExpr,
1799
+ represent: (value) => value.__jsExpr
1800
+ });
1801
+ /** js-yaml's default schema plus the user-layer `!!js` round trip. */
1802
+ const USER_LAYER_SCHEMA = DEFAULT_SCHEMA.extend([JS_EXPR_TYPE]);
1458
1803
  /**
1459
1804
  * Find the profile directory that owns `startPath`.
1460
1805
  *
@@ -1523,7 +1868,10 @@ function setUserLayerRows(options) {
1523
1868
  disabled: true
1524
1869
  }))];
1525
1870
  const tmp = `${file}.tmp`;
1526
- writeFileSync(tmp, dump(next, { noRefs: true }));
1871
+ writeFileSync(tmp, dump(next, {
1872
+ noRefs: true,
1873
+ schema: USER_LAYER_SCHEMA
1874
+ }));
1527
1875
  renameSync(tmp, file);
1528
1876
  }
1529
1877
  /**
@@ -1618,7 +1966,9 @@ function ownsEntryId(owned, entryId) {
1618
1966
  //#endregion
1619
1967
  //#region src/host/peers.ts
1620
1968
  /** Harness compatibility: which declared peers the running installation does
1621
- * not provide (design 2026-09-01-harness-compatibility). */
1969
+ * not provide (design 2026-09-01-harness-compatibility), and — for the
1970
+ * shop's OWN declared peers — which ones it provides at a version outside
1971
+ * the declared range. */
1622
1972
  /**
1623
1973
  * The production resolver: the same question the harness's own
1624
1974
  * ClientModuleRegistry asks, through a require anchored at the profile. Asking
@@ -1632,7 +1982,9 @@ function nodeResolver(baseUrl) {
1632
1982
  require.resolve(`${spec}/package.json`);
1633
1983
  return true;
1634
1984
  } catch (error) {
1635
- if (error.code === "MODULE_NOT_FOUND") return false;
1985
+ const code = error.code;
1986
+ if (code === "MODULE_NOT_FOUND") return false;
1987
+ if (code === "ERR_PACKAGE_PATH_NOT_EXPORTED") return true;
1636
1988
  throw error;
1637
1989
  }
1638
1990
  };
@@ -1669,10 +2021,105 @@ function incompatibilityMap(entries, resolve) {
1669
2021
  }
1670
2022
  if (!present) missing.push(spec);
1671
2023
  }
1672
- if (usable && missing.length > 0) out[entry.name] = missing;
2024
+ if (usable && missing.length > 0) out[identityKey(entry)] = missing;
1673
2025
  }
1674
2026
  return out;
1675
2027
  }
2028
+ /**
2029
+ * The production resolver: `nodeResolver`'s own resolution, kept this time
2030
+ * instead of collapsed to a boolean. Anything that leaves no version to read
2031
+ * answers null — a peer that is absent, one that restricts `./package.json`
2032
+ * in its exports, an unreadable or malformed manifest, a manifest with no
2033
+ * `version`. Absence is deliberately NOT reported as a version violation:
2034
+ * `incompatibilityMap` is what covers a missing peer.
2035
+ */
2036
+ function nodeVersionResolver(baseUrl) {
2037
+ const require = createRequire(baseUrl);
2038
+ return (spec) => {
2039
+ let manifestPath;
2040
+ try {
2041
+ manifestPath = require.resolve(`${spec}/package.json`);
2042
+ } catch {
2043
+ return null;
2044
+ }
2045
+ try {
2046
+ const version = JSON.parse(readFileSync(manifestPath, "utf8")).version;
2047
+ return typeof version === "string" ? version : null;
2048
+ } catch {
2049
+ return null;
2050
+ }
2051
+ };
2052
+ }
2053
+ /**
2054
+ * The declared peers whose provided version is outside the declared range,
2055
+ * ordered by peer name. Pure: the module graph arrives through `resolve`.
2056
+ *
2057
+ * `includePrerelease` is load-bearing, not a convenience. The harness ships
2058
+ * nothing but `-rc` versions, so under strict semver `^0.1.1-rc.2` excludes
2059
+ * `0.1.2-rc.1` — the version that is installed and works — and every future
2060
+ * rc bump would raise a false alarm. With it on, the range still excludes an
2061
+ * older prerelease (`0.1.1-rc.1`) and a minor- or major-line move
2062
+ * (`0.2.0-rc.1`, `1.0.0`), which are the moves that actually break a plugin
2063
+ * path. Discrimination on both sides is the whole point: one false warning
2064
+ * teaches a reader to ignore every warning.
2065
+ *
2066
+ * Anything unreadable yields NO verdict for that peer and never a violation:
2067
+ * a resolver that answers null or throws, a found version that is not
2068
+ * semver, a declared range semver cannot parse. The other peers are still
2069
+ * judged.
2070
+ */
2071
+ function peerVersionMismatches(ranges, resolve) {
2072
+ const mismatches = [];
2073
+ for (const spec of Object.keys(ranges).sort()) {
2074
+ const range = ranges[spec];
2075
+ if (range === void 0 || validRange(range) === null) continue;
2076
+ let found;
2077
+ try {
2078
+ found = resolve(spec);
2079
+ } catch {
2080
+ continue;
2081
+ }
2082
+ if (found === null || valid(found) === null) continue;
2083
+ if (satisfies(found, range, { includePrerelease: true })) continue;
2084
+ mismatches.push({
2085
+ spec,
2086
+ range,
2087
+ found
2088
+ });
2089
+ }
2090
+ return mismatches;
2091
+ }
2092
+ /**
2093
+ * The load-time message for a set of mismatches, or null when there is
2094
+ * nothing to say. Every mismatch is named with its declared range and the
2095
+ * version found, in ONE message: the reader needs the whole picture in the
2096
+ * line they will scroll past, and the message says the shop still loads
2097
+ * because refusing to load would cost them the shop over a diagnostic.
2098
+ */
2099
+ function peerVersionWarning(mismatches) {
2100
+ if (mismatches.length === 0) return null;
2101
+ return `dsh-plugin-shop: the harness does not provide the peer versions this shop declares — ${mismatches.map(({ spec, range, found }) => `${spec} ${range}, found ${found}`).join("; ")}. The shop still loads; if a path misbehaves, check this first.`;
2102
+ }
2103
+ /**
2104
+ * A self-check that warns AT MOST ONCE, however many times it is called.
2105
+ * The guard is what keeps a diagnostic a diagnostic: a message repeated per
2106
+ * call is noise a reader learns to skip, and the shop's load path is not the
2107
+ * only place that could reasonably ask this question.
2108
+ *
2109
+ * It never throws. A check that could fail a load would be worse than the
2110
+ * mismatch it reports — the user would lose the whole shop over a warning.
2111
+ */
2112
+ function createPeerVersionCheck(deps) {
2113
+ let spoken = false;
2114
+ return () => {
2115
+ if (spoken) return;
2116
+ spoken = true;
2117
+ try {
2118
+ const message = peerVersionWarning(peerVersionMismatches(deps.ranges, deps.resolve));
2119
+ if (message !== null) deps.warn(message);
2120
+ } catch {}
2121
+ };
2122
+ }
1676
2123
  //#endregion
1677
2124
  //#region src/host/index.ts
1678
2125
  /** ShopGateway: the Host half of dsh-plugin-shop (§5.1). */
@@ -1905,6 +2352,8 @@ let ShopGateway = (() => {
1905
2352
  /** The argv `shop/restart` re-spawns: the real process argv minus node and
1906
2353
  * the CLI script path, or a test-provided substitute. */
1907
2354
  restartArgv;
2355
+ /** The script the restart re-runs — this process's own entry. */
2356
+ restartScript;
1908
2357
  /** The exit the restart calls once the response is out; `process.exit` in
1909
2358
  * production, a spy in tests. */
1910
2359
  exit;
@@ -1934,9 +2383,14 @@ let ShopGateway = (() => {
1934
2383
  /** The install gate runs against the last loaded snapshot, never a fresh
1935
2384
  * fetch per request (§7.2: the Host's cached snapshot is the truth). */
1936
2385
  lastSnapshot = null;
2386
+ /** A cold-cache catalog load shared by concurrent RPC calls. */
2387
+ inFlightLoad = null;
1937
2388
  /** The origin list built for the last-seen `catalogUrl`, memoised so the
1938
2389
  * user's npmrc is read at most once per gateway (see `originsFor`). */
1939
2390
  originCache = null;
2391
+ /** The user's own `registry=` from `~/.npmrc`, read at most once per
2392
+ * gateway. A wrapper distinguishes a genuine `null` from an unread value. */
2393
+ npmRegistryCache = null;
1940
2394
  /** The incompatibility map already computed for `lastSnapshot`, keyed by
1941
2395
  * that snapshot's own object identity. Design §3 asks for the verdict
1942
2396
  * once per loaded snapshot, not once per RPC call: `loadCatalog` serves
@@ -1961,10 +2415,11 @@ let ShopGateway = (() => {
1961
2415
  this.loaderEntriesInjected = options.loaderEntries;
1962
2416
  this.dshBin = options.dshBin ?? "dsh";
1963
2417
  this.restartArgv = options.restartArgv ?? process.argv.slice(2);
2418
+ this.restartScript = options.restartScript ?? process.argv[1];
1964
2419
  this.exit = options.exit ?? ((code) => process.exit(code));
1965
2420
  this.restartExitDelayMs = options.restartExitDelayMs ?? ShopGateway.RESTART_EXIT_DELAY_MS;
1966
2421
  this.restartParentPid = options.restartParentPid ?? process.pid;
1967
- this.latestVersion = options.fetchLatestVersion ?? (() => fetchLatestVersion());
2422
+ this.latestVersion = options.fetchLatestVersion ?? (() => fetchLatestVersion(fetch, { registry: this.npmRegistry() }));
1968
2423
  this.pinFs = options.pinFs ?? {
1969
2424
  exists: (path) => existsSync(path),
1970
2425
  read: (path) => readFileSync(path, "utf8"),
@@ -1981,6 +2436,26 @@ let ShopGateway = (() => {
1981
2436
  try {
1982
2437
  cleanHotDir(this.profileDirResolved());
1983
2438
  } catch {}
2439
+ this.checkPeerVersions();
2440
+ }
2441
+ /**
2442
+ * Warn once, at load, about any declared peer the harness provides outside
2443
+ * its declared range. Advisory by construction: it cannot throw, and it
2444
+ * never refuses the load — losing the whole shop is a worse outcome than
2445
+ * running against a harness that moved.
2446
+ */
2447
+ checkPeerVersions() {
2448
+ try {
2449
+ createPeerVersionCheck({
2450
+ ranges: this.options.peerRanges ?? ownPeerRanges(),
2451
+ resolve: this.options.resolvePeerVersion ?? nodeVersionResolver(pathToFileURL(join(this.profileDirResolved(), "cordis.yml")).href),
2452
+ warn: (message) => {
2453
+ const logger = this.ctx.logger;
2454
+ if (logger === void 0) console.warn(message);
2455
+ else logger.warn(message);
2456
+ }
2457
+ })();
2458
+ } catch {}
1984
2459
  }
1985
2460
  /** The pins file lives in the shop's own cache, next to the catalog cache. */
1986
2461
  pinsPath() {
@@ -2123,25 +2598,59 @@ let ShopGateway = (() => {
2123
2598
  cacheDir
2124
2599
  };
2125
2600
  }
2126
- /** The origins to race for this row's catalog. Read once per gateway: the
2127
- * user's npmrc does not change under a running dsh, and re-reading it on
2128
- * every catalog call would put a filesystem read on the hot path. */
2129
- originsFor(catalogUrl) {
2130
- if (this.originCache?.catalogUrl === catalogUrl) return this.originCache.origins;
2131
- const registry = npmrcRegistry((path) => {
2601
+ /** The user's own registry, read once. The self-update check and catalog
2602
+ * race share this preference so neither path repeatedly touches ~/.npmrc. */
2603
+ npmRegistry() {
2604
+ const cached = this.npmRegistryCache;
2605
+ if (cached !== null) return cached.value;
2606
+ const value = npmrcRegistry((path) => {
2132
2607
  try {
2133
2608
  return readFileSync(path, "utf8");
2134
2609
  } catch {
2135
2610
  return null;
2136
2611
  }
2137
2612
  }, homedir());
2138
- const origins = catalogOrigins(catalogUrl, fetch, registry);
2613
+ this.npmRegistryCache = { value };
2614
+ return value;
2615
+ }
2616
+ /** The origins to race for this row's catalog. Read once per gateway: the
2617
+ * user's npmrc does not change under a running dsh, and re-reading it on
2618
+ * every catalog call would put a filesystem read on the hot path. */
2619
+ originsFor(catalogUrl) {
2620
+ if (this.originCache?.catalogUrl === catalogUrl) return this.originCache.origins;
2621
+ const origins = catalogOrigins(catalogUrl, fetch, this.npmRegistry());
2139
2622
  this.originCache = {
2140
2623
  catalogUrl,
2141
2624
  origins
2142
2625
  };
2143
2626
  return origins;
2144
2627
  }
2628
+ /** Join an in-flight load; refresh requests always start a new load. */
2629
+ loadCatalogOnce(refresh) {
2630
+ const existing = this.inFlightLoad;
2631
+ if (!refresh && existing !== null) return existing;
2632
+ const { catalogUrl, cacheDir } = this.rowConfig();
2633
+ const started = (this.options.loadCatalog ?? loadCatalog)({
2634
+ origins: this.originsFor(catalogUrl),
2635
+ cacheDir,
2636
+ refresh
2637
+ }).then((result) => {
2638
+ this.lastSnapshot = result.snapshot;
2639
+ return result;
2640
+ });
2641
+ this.inFlightLoad = started;
2642
+ const forget = () => {
2643
+ if (this.inFlightLoad === started) this.inFlightLoad = null;
2644
+ };
2645
+ started.then(forget, forget);
2646
+ return started;
2647
+ }
2648
+ /** Return the current snapshot, loading it once when needed. */
2649
+ async snapshotNow() {
2650
+ if (this.lastSnapshot !== null) return this.lastSnapshot;
2651
+ const { snapshot } = await this.loadCatalogOnce(false);
2652
+ return snapshot;
2653
+ }
2145
2654
  /** The explicit restart override. Only the row's `config:` sub-object is
2146
2655
  * passed to a plugin — a top-level `allowRestart:` beside `name:` would be
2147
2656
  * silently ignored by the loader (dsh-market README, #227). */
@@ -2156,13 +2665,7 @@ let ShopGateway = (() => {
2156
2665
  }
2157
2666
  /** Browse the catalog (§7.3): cached snapshot, refreshed on demand. */
2158
2667
  async catalog(args) {
2159
- const { catalogUrl, cacheDir } = this.rowConfig();
2160
- const { snapshot, stale } = await (this.options.loadCatalog ?? loadCatalog)({
2161
- origins: this.originsFor(catalogUrl),
2162
- cacheDir,
2163
- refresh: args?.refresh ?? false
2164
- });
2165
- this.lastSnapshot = snapshot;
2668
+ const { snapshot, stale } = await this.loadCatalogOnce(args?.refresh ?? false);
2166
2669
  let incompatible;
2167
2670
  if (this.incompatibleCache !== null && this.incompatibleCache.snapshot === snapshot) incompatible = this.incompatibleCache.map;
2168
2671
  else {
@@ -2194,26 +2697,13 @@ let ShopGateway = (() => {
2194
2697
  * passing request reaches the executor.
2195
2698
  */
2196
2699
  async install(args) {
2197
- if (this.lastSnapshot === null) {
2198
- const { catalogUrl, cacheDir } = this.rowConfig();
2199
- const { snapshot } = await (this.options.loadCatalog ?? loadCatalog)({
2200
- origins: this.originsFor(catalogUrl),
2201
- cacheDir
2202
- });
2203
- this.lastSnapshot = snapshot;
2204
- }
2205
- const verdict = validateInstall(this.lastSnapshot, args);
2700
+ const verdict = validateInstall(await this.snapshotNow(), args);
2206
2701
  if (!verdict.ok) return {
2207
2702
  ok: false,
2208
2703
  code: verdict.code,
2209
2704
  detail: verdict.detail
2210
2705
  };
2211
- const entry = this.lastSnapshot.entries.find((e) => e.name === args.name);
2212
- if (entry === void 0) return {
2213
- ok: false,
2214
- code: "not-in-catalog",
2215
- detail: `dsh-plugin-shop: ${args.name} is not in the catalog`
2216
- };
2706
+ const entry = verdict.entry;
2217
2707
  let spec;
2218
2708
  if (entry.source === "github" && entry.tarball !== void 0) {
2219
2709
  const integrity = await verifyTarballSha256(this.fetchTarball, entry.tarball.url, entry.tarball.sha256);
@@ -2255,7 +2745,7 @@ let ShopGateway = (() => {
2255
2745
  const pins = readRepoPins(this.pinFs, this.pinsPath());
2256
2746
  writeRepoPins(this.pinFs, this.pinsPath(), {
2257
2747
  ...pins,
2258
- [args.name]: args.version
2748
+ [identityKey(entry)]: entry.version
2259
2749
  });
2260
2750
  }
2261
2751
  this.installs.set(running.installId, running);
@@ -2278,6 +2768,11 @@ let ShopGateway = (() => {
2278
2768
  const excess = Math.max(0, finishedIds.length - ShopGateway.MAX_FINISHED_INSTALLS);
2279
2769
  for (const id of finishedIds.slice(0, excess)) this.installs.delete(id);
2280
2770
  }
2771
+ /** Whether any command this gateway started is still running. */
2772
+ hasRunningCommand() {
2773
+ for (const record of this.installs.values()) if (record.status().state === "running") return true;
2774
+ return false;
2775
+ }
2281
2776
  /** Poll one install's progress (§7.2); unknown ids report `found: false`. */
2282
2777
  installStatus(args) {
2283
2778
  const running = this.installs.get(args.installId);
@@ -2297,14 +2792,7 @@ let ShopGateway = (() => {
2297
2792
  * attached. The tab's shelf cards and its installed section both derive
2298
2793
  * from this one list. */
2299
2794
  async installed() {
2300
- if (this.lastSnapshot === null) {
2301
- const { catalogUrl, cacheDir } = this.rowConfig();
2302
- const { snapshot } = await (this.options.loadCatalog ?? loadCatalog)({
2303
- origins: this.originsFor(catalogUrl),
2304
- cacheDir
2305
- });
2306
- this.lastSnapshot = snapshot;
2307
- }
2795
+ const snapshot = await this.snapshotNow();
2308
2796
  const dependencies = readProfileManifest("dsh-plugin-shop", this.profileDirResolved()).dependencies ?? {};
2309
2797
  const pins = readRepoPins(this.pinFs, this.pinsPath());
2310
2798
  const live = /* @__PURE__ */ new Map();
@@ -2332,13 +2820,20 @@ let ShopGateway = (() => {
2332
2820
  return present.length === 0 || present.every(([, enabled]) => enabled);
2333
2821
  };
2334
2822
  const installed = [];
2335
- for (const entry of this.lastSnapshot.entries) {
2823
+ for (const entry of snapshot.entries) {
2336
2824
  const spec = dependencies[entry.name];
2337
2825
  if (spec === void 0) continue;
2826
+ if (!installedSpecMatches(entry, spec)) continue;
2827
+ const identity = {
2828
+ source: entry.source,
2829
+ repo: entry.repo,
2830
+ subdir: entry.subdir
2831
+ };
2338
2832
  if (entry.source === "github") {
2339
- const pin = pins[entry.name];
2833
+ const pin = pins[identityKey(entry)] ?? pins[entry.name];
2340
2834
  installed.push({
2341
2835
  name: entry.name,
2836
+ ...identity,
2342
2837
  installed: pin ?? spec,
2343
2838
  latest: entry.version,
2344
2839
  outdated: pin !== void 0 && pin !== entry.version,
@@ -2346,6 +2841,7 @@ let ShopGateway = (() => {
2346
2841
  });
2347
2842
  } else installed.push({
2348
2843
  name: entry.name,
2844
+ ...identity,
2349
2845
  installed: spec,
2350
2846
  latest: entry.version,
2351
2847
  outdated: this.isBehind(spec, entry.version),
@@ -2375,22 +2871,17 @@ let ShopGateway = (() => {
2375
2871
  * dependencies the shop does not manage (the base bundle, the shop
2376
2872
  * itself). The same install records/polling serve the client. */
2377
2873
  async uninstall(args) {
2378
- if (this.lastSnapshot === null) {
2379
- const { catalogUrl, cacheDir } = this.rowConfig();
2380
- const { snapshot } = await (this.options.loadCatalog ?? loadCatalog)({
2381
- origins: this.originsFor(catalogUrl),
2382
- cacheDir
2383
- });
2384
- this.lastSnapshot = snapshot;
2385
- }
2386
- if (!this.lastSnapshot.entries.some((entry) => entry.name === args.name)) return {
2874
+ const named = (await this.snapshotNow()).entries.filter((entry) => entry.name === args.name);
2875
+ if (named.length === 0) return {
2387
2876
  ok: false,
2388
2877
  detail: `dsh-plugin-shop: ${args.name} is not in the catalog`
2389
2878
  };
2390
- if ((readProfileManifest("dsh-plugin-shop", this.profileDirResolved()).dependencies ?? {})[args.name] === void 0) return {
2879
+ const spec = (readProfileManifest("dsh-plugin-shop", this.profileDirResolved()).dependencies ?? {})[args.name];
2880
+ if (spec === void 0) return {
2391
2881
  ok: false,
2392
2882
  detail: `dsh-plugin-shop: ${args.name} is not installed`
2393
2883
  };
2884
+ const installedEntry = named.find((entry) => installedSpecMatches(entry, spec));
2394
2885
  const priorEntryIds = this.ownedEntryIdsOrNone(args.name);
2395
2886
  const running = startUninstall({
2396
2887
  profile: this.profile,
@@ -2406,10 +2897,14 @@ let ShopGateway = (() => {
2406
2897
  }
2407
2898
  });
2408
2899
  const pins = readRepoPins(this.pinFs, this.pinsPath());
2409
- if (pins[args.name] !== void 0) {
2410
- delete pins[args.name];
2411
- writeRepoPins(this.pinFs, this.pinsPath(), pins);
2900
+ const stalePins = [args.name, ...installedEntry === void 0 ? [] : [identityKey(installedEntry)]];
2901
+ let forgot = false;
2902
+ for (const key of stalePins) {
2903
+ if (pins[key] === void 0) continue;
2904
+ delete pins[key];
2905
+ forgot = true;
2412
2906
  }
2907
+ if (forgot) writeRepoPins(this.pinFs, this.pinsPath(), pins);
2413
2908
  this.installs.set(running.installId, running);
2414
2909
  this.installOrder.push(running.installId);
2415
2910
  this.evictFinishedInstalls();
@@ -2424,6 +2919,10 @@ let ShopGateway = (() => {
2424
2919
  * response is out. The browser monitors the origin and refreshes when the
2425
2920
  * new server answers. Refusals are issued before anything is torn down. */
2426
2921
  async restart() {
2922
+ if (this.hasRunningCommand()) return {
2923
+ ok: false,
2924
+ detail: "dsh-plugin-shop: an install is still running in this profile; a restart now would boot the new dsh against a half-written profile. Wait for it to finish and try again."
2925
+ };
2427
2926
  if (!this.restartPlatformSupported()) return {
2428
2927
  ok: false,
2429
2928
  detail: "dsh-plugin-shop: restart is not supported on Windows yet; restart dsh manually to apply the change"
@@ -2439,9 +2938,16 @@ let ShopGateway = (() => {
2439
2938
  };
2440
2939
  try {
2441
2940
  const { cacheDir } = this.rowConfig();
2442
- startRestart({
2941
+ const { command, args } = restartCommand({
2443
2942
  dshBin: this.dshBin,
2444
2943
  argv: this.restartArgv,
2944
+ execPath: process.execPath,
2945
+ execArgv: process.execArgv,
2946
+ script: this.restartScript
2947
+ });
2948
+ startRestart({
2949
+ command,
2950
+ args,
2445
2951
  parentPid: this.restartParentPid,
2446
2952
  logFile: join(cacheDir, "restart.log"),
2447
2953
  env: process.env