dsh-plugin-shop 0.4.13 → 0.5.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,11 +1,11 @@
1
1
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
2
2
  import { loadOptionalPatches, readProfileManifest, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
3
3
  import { lt, minVersion, valid } from "semver";
4
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
6
  import { spawn, spawnSync } from "node:child_process";
6
- import { fileURLToPath } from "node:url";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
8
  import { basename, dirname, join } from "node:path";
8
- import { createHash, randomUUID } from "node:crypto";
9
9
  import { z } from "zod";
10
10
  import { dump } from "js-yaml";
11
11
  //#region src/own-version.ts
@@ -39,6 +39,7 @@ const entrySchema = z.object({
39
39
  review: z.object({
40
40
  reviewedVersion: z.string().optional(),
41
41
  reviewedCommit: z.string().optional(),
42
+ reviewedSha256: z.string().optional(),
42
43
  reviewer: z.string(),
43
44
  reviewCommit: z.string(),
44
45
  notes: z.string()
@@ -50,6 +51,7 @@ const entrySchema = z.object({
50
51
  "ui",
51
52
  "workflow",
52
53
  "integration",
54
+ "theme",
53
55
  "other"
54
56
  ]),
55
57
  summary: z.object({
@@ -59,16 +61,43 @@ const entrySchema = z.object({
59
61
  capabilities: z.array(z.string())
60
62
  }).optional(),
61
63
  source: z.enum(["npm", "github"]).default("npm"),
62
- repo: z.string().optional()
64
+ repo: z.string().optional(),
65
+ subdir: z.string().regex(/^(?!.*(^|\/)\.\.?(\/|$))[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/).optional(),
66
+ added: z.string(),
67
+ tarball: z.object({
68
+ url: z.string(),
69
+ sha256: z.string()
70
+ }).optional()
63
71
  });
64
72
  const dataSchema = z.object({
65
73
  schemaVersion: z.number(),
66
74
  plugins: z.array(entrySchema),
67
75
  denied: z.array(z.object({
68
76
  name: z.string(),
69
- detail: z.string()
77
+ detail: z.string(),
78
+ replacement: z.string().optional()
70
79
  })).default([])
71
80
  });
81
+ /** The tarball URL must be the entry's own GitHub release — path segments
82
+ * `/<owner>/<repo>/releases/...` matching the entry's `repo` (case-
83
+ * insensitive). A catalog row that names a trusted repo but installs an
84
+ * archive from somewhere else is refused loudly, never installed
85
+ * (dsh-market's release-binding rule, their sources.ts:16-49). */
86
+ function validateEntryCoherence(entries) {
87
+ for (const entry of entries) {
88
+ if (entry.tarball === void 0) continue;
89
+ if (entry.source !== "github" || entry.repo === void 0) throw new Error(`catalog entry ${entry.name}: tarball requires a github entry with a repo`);
90
+ let parsed;
91
+ try {
92
+ parsed = new URL(entry.tarball.url);
93
+ } catch {
94
+ throw new Error(`catalog entry ${entry.name}: tarball url is unparseable`);
95
+ }
96
+ if (parsed.protocol !== "https:" || parsed.hostname !== "github.com") throw new Error(`catalog entry ${entry.name}: tarball url must be https on github.com`);
97
+ const segments = parsed.pathname.split("/").filter((s) => s !== "");
98
+ 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}`);
99
+ }
100
+ }
72
101
  const pointerSchema = z.object({
73
102
  schemaVersion: z.number(),
74
103
  builtAt: z.string(),
@@ -82,7 +111,7 @@ const pointerSchema = z.object({
82
111
  sha256: z.string()
83
112
  }).optional()
84
113
  });
85
- const nodeFs = {
114
+ const nodeFs$1 = {
86
115
  exists: (path) => existsSync(path),
87
116
  read: (path) => readFileSync(path, "utf8"),
88
117
  write: (path, data) => {
@@ -126,7 +155,7 @@ function parseStarsText(text) {
126
155
  * included, degrades to no stars (spec §5).
127
156
  */
128
157
  async function loadCatalog(options) {
129
- const { baseUrl, cacheDir, refresh = false, fetchImpl = fetch, now = () => /* @__PURE__ */ new Date(), fsImpl = nodeFs } = options;
158
+ const { baseUrl, cacheDir, refresh = false, fetchImpl = fetch, now = () => /* @__PURE__ */ new Date(), fsImpl = nodeFs$1 } = options;
130
159
  const indexPath = join(cacheDir, "index.json");
131
160
  const metaPath = join(cacheDir, META_FILE);
132
161
  /** The timestamp freshness is measured from: the sidecar's fetch time when
@@ -141,30 +170,33 @@ async function loadCatalog(options) {
141
170
  return Number.isNaN(built) ? null : built;
142
171
  };
143
172
  const readCached = () => {
173
+ let pointer;
174
+ let data;
144
175
  try {
145
- const pointer = pointerSchema.parse(JSON.parse(fsImpl.read(indexPath)));
146
- if (pointer.schemaVersion > 3) throw new Error(`catalog schemaVersion ${pointer.schemaVersion} is newer than this build supports (3)`);
176
+ pointer = pointerSchema.parse(JSON.parse(fsImpl.read(indexPath)));
177
+ if (pointer.schemaVersion > 5) throw new Error(`catalog schemaVersion ${pointer.schemaVersion} is newer than this build supports (5)`);
147
178
  const dataPath = join(cacheDir, basename(pointer.plugins.url));
148
179
  const dataText = fsImpl.read(dataPath);
149
180
  const actual = createHash("sha256").update(dataText).digest("hex");
150
181
  if (actual !== pointer.plugins.sha256) throw new Error(`cached catalog data failed integrity check: expected ${pointer.plugins.sha256}, got ${actual}`);
151
- const data = dataSchema.parse(JSON.parse(dataText));
152
- if (data.schemaVersion > 3) throw new Error(`catalog schemaVersion ${data.schemaVersion} is newer than this build supports (3)`);
153
- let stars = {};
154
- if (pointer.stars !== void 0) try {
155
- const starsText = fsImpl.read(join(cacheDir, basename(pointer.stars.url)));
156
- if (createHash("sha256").update(starsText).digest("hex") === pointer.stars.sha256) stars = parseStarsText(starsText);
157
- } catch {}
158
- return {
159
- schemaVersion: pointer.schemaVersion,
160
- builtAt: pointer.builtAt,
161
- entries: data.plugins,
162
- denied: data.denied,
163
- stars
164
- };
182
+ data = dataSchema.parse(JSON.parse(dataText));
183
+ if (data.schemaVersion > 5) throw new Error(`catalog schemaVersion ${data.schemaVersion} is newer than this build supports (5)`);
165
184
  } catch {
166
185
  return null;
167
186
  }
187
+ validateEntryCoherence(data.plugins);
188
+ let stars = {};
189
+ if (pointer.stars !== void 0) try {
190
+ const starsText = fsImpl.read(join(cacheDir, basename(pointer.stars.url)));
191
+ if (createHash("sha256").update(starsText).digest("hex") === pointer.stars.sha256) stars = parseStarsText(starsText);
192
+ } catch {}
193
+ return {
194
+ schemaVersion: pointer.schemaVersion,
195
+ builtAt: pointer.builtAt,
196
+ entries: data.plugins,
197
+ denied: data.denied,
198
+ stars
199
+ };
168
200
  };
169
201
  if (!refresh && fsImpl.exists(indexPath)) {
170
202
  const cached = readCached();
@@ -190,7 +222,7 @@ async function loadCatalog(options) {
190
222
  throw error;
191
223
  }
192
224
  const pointer = pointerSchema.parse(JSON.parse(pointerText));
193
- if (pointer.schemaVersion > 3) throw new Error(`catalog schemaVersion ${pointer.schemaVersion} is newer than this build supports (3)`);
225
+ if (pointer.schemaVersion > 5) throw new Error(`catalog schemaVersion ${pointer.schemaVersion} is newer than this build supports (5)`);
194
226
  const dataUrl = resolveDataUrl(baseUrl, pointer.plugins.url);
195
227
  let dataText;
196
228
  try {
@@ -208,7 +240,8 @@ async function loadCatalog(options) {
208
240
  const actual = createHash("sha256").update(dataText).digest("hex");
209
241
  if (actual !== pointer.plugins.sha256) throw new Error(`catalog data failed integrity check: expected ${pointer.plugins.sha256}, got ${actual}`);
210
242
  const data = dataSchema.parse(JSON.parse(dataText));
211
- if (data.schemaVersion > 3) throw new Error(`catalog schemaVersion ${data.schemaVersion} is newer than this build supports (3)`);
243
+ if (data.schemaVersion > 5) throw new Error(`catalog schemaVersion ${data.schemaVersion} is newer than this build supports (5)`);
244
+ validateEntryCoherence(data.plugins);
212
245
  let stars = {};
213
246
  if (pointer.stars !== void 0) try {
214
247
  const starsResponse = await fetchImpl(resolveDataUrl(baseUrl, pointer.stars.url));
@@ -320,20 +353,30 @@ function confirmBundleRemoval(profile, home, expectedName) {
320
353
  * real-install test pins DSH_HOME to a temporary directory this way.
321
354
  * When `confirm` is given, a zero exit is checked against the profile
322
355
  * manifest before the command reports `done` (§7.2 step 6 and its uninstall
323
- * mirror). */
356
+ * mirror). When `afterDone` is given, a zero exit that passes `confirm`
357
+ * withholds the terminal `done` until the callback — typically the hot-mount
358
+ * attempt — settles; its result sets `needsRestart` (default `true`) and
359
+ * `restartReason`. The client stops polling at `done`, so the hot outcome
360
+ * must settle before it. A throwing callback never fails the install — the
361
+ * package IS installed; it reports `done` with the restart fallback. */
324
362
  function spawnPluginCli(options) {
325
- const { profile, argv, dshBin, env, confirm, onStatus } = options;
363
+ const { profile, argv, dshBin, env, confirm, afterDone, onStatus } = options;
326
364
  const target = argv[1];
327
365
  if (target === void 0 || target.startsWith("-")) throw new Error(`dsh-plugin-shop: refusing to spawn with a flag-like operand: ${target ?? "(none)"}`);
328
366
  const installId = randomUUID();
329
367
  const log = [];
330
368
  let logBytes = 0;
331
369
  let state = "running";
370
+ let needsRestartOnDone = true;
371
+ let restartReason;
332
372
  let detail;
333
373
  const status = () => ({
334
374
  state,
335
375
  log: [...log],
336
- ...state === "done" ? { needsRestart: true } : {},
376
+ ...state === "done" ? {
377
+ needsRestart: needsRestartOnDone,
378
+ ...restartReason !== void 0 ? { restartReason } : {}
379
+ } : {},
337
380
  ...detail !== void 0 ? { detail } : {}
338
381
  });
339
382
  const append = (line) => {
@@ -375,17 +418,24 @@ function spawnPluginCli(options) {
375
418
  onStatus?.(status());
376
419
  resolve(status());
377
420
  });
378
- child.on("close", (exitCode) => {
421
+ child.on("close", async (exitCode) => {
379
422
  if (state !== "running") return;
380
423
  if (exitCode === 0) {
381
- state = "done";
382
- if (confirm !== void 0) {
383
- const confirmDetail = confirm(env?.DSH_HOME);
384
- if (confirmDetail !== null) {
385
- state = "failed";
386
- detail = confirmDetail;
424
+ const confirmDetail = confirm?.(env?.DSH_HOME);
425
+ if (confirmDetail != null) {
426
+ state = "failed";
427
+ detail = confirmDetail;
428
+ } else if (afterDone !== void 0) {
429
+ try {
430
+ const outcome = await afterDone(env?.DSH_HOME);
431
+ needsRestartOnDone = outcome?.needsRestart ?? true;
432
+ restartReason = outcome?.restartReason;
433
+ } catch {
434
+ needsRestartOnDone = true;
435
+ restartReason = "热挂载失败,重启后生效 / hot-mount failed — restart required";
387
436
  }
388
- }
437
+ state = "done";
438
+ } else state = "done";
389
439
  } else {
390
440
  state = "failed";
391
441
  const lastLogLine = log[log.length - 1] ?? "";
@@ -400,16 +450,18 @@ function spawnPluginCli(options) {
400
450
  /**
401
451
  * Run one `dsh plugin --profile <profile> add <spec>` and track it.
402
452
  * When `expectedName` is given, a zero exit is confirmed against the profile
403
- * manifest (§7.2 step 6) before the install reports `done`.
453
+ * manifest (§7.2 step 6) before the install reports `done`. When `afterDone`
454
+ * is given, the terminal `done` waits for it to settle (§D hot mount).
404
455
  */
405
456
  function startInstall(options) {
406
- const { profile, spec, dshBin = "dsh", env, expectedName, onStatus } = options;
457
+ const { profile, spec, dshBin = "dsh", env, expectedName, afterDone, onStatus } = options;
407
458
  return spawnPluginCli({
408
459
  profile,
409
460
  argv: ["add", spec],
410
461
  dshBin,
411
462
  env,
412
463
  confirm: expectedName !== void 0 ? (home) => confirmBundleActivation(profile, home, expectedName) : void 0,
464
+ afterDone,
413
465
  onStatus
414
466
  });
415
467
  }
@@ -417,20 +469,292 @@ function startInstall(options) {
417
469
  * Run one `dsh plugin --profile <profile> remove <name>` and track it.
418
470
  * When `expectedName` is given, a zero exit is confirmed against the profile
419
471
  * manifest — the bundle must actually have LEFT `dsh.profile.bundles` — before
420
- * the uninstall reports `done`.
472
+ * the uninstall reports `done`. When `afterDone` is given, the terminal `done`
473
+ * waits for it to settle (§D hot mount).
421
474
  */
422
475
  function startUninstall(options) {
423
- const { profile, name, dshBin = "dsh", env, expectedName, onStatus } = options;
476
+ const { profile, name, dshBin = "dsh", env, expectedName, afterDone, onStatus } = options;
424
477
  return spawnPluginCli({
425
478
  profile,
426
479
  argv: ["remove", name],
427
480
  dshBin,
428
481
  env,
429
482
  confirm: expectedName !== void 0 ? (home) => confirmBundleRemoval(profile, home, expectedName) : void 0,
483
+ afterDone,
430
484
  onStatus
431
485
  });
432
486
  }
433
487
  //#endregion
488
+ //#region src/host/hot.ts
489
+ /**
490
+ * Restart-free installs: mount a freshly installed plugin into the running
491
+ * composition through a shop-owned Include subtree (design 2026-08-31
492
+ * market-borrowings §4, mechanism ported from dsh-market's hot.ts).
493
+ *
494
+ * Durable state stays with the profile's `dsh.profile.bundles`, so the next
495
+ * boot loads the plugin through the normal bundle layer. The subtree exists
496
+ * only for this process: its input files live under `<profile>/.dsh-shop/`
497
+ * and are wiped on every boot, so a crash can never leave a file that
498
+ * collides with the bundle layer (inserting an id the bundle layer also
499
+ * inserts is a hard boot failure). Rows are prefixed `mkt-` for the same
500
+ * reason: within this session the hot entry must never share an id with a
501
+ * boot-layer entry, including a disabled one left behind by an update swap.
502
+ *
503
+ * The Include subclass suppresses `write()` — the loader otherwise persists
504
+ * tree changes back to the file it read (dsh-market hot.ts; the in-tree
505
+ * precedent is dsh's agent-presets PresetTree).
506
+ *
507
+ * Deliberate non-port: dsh-market's client-only shim (`mountClientOnlyDeps`
508
+ * and `shimNames`, which hot-mounted a package with no server-side entry by
509
+ * inserting a shim loader entry) is omitted. Our catalog never lists a
510
+ * package without `dsh.bundle`, so the shim branch is unreachable here
511
+ * (YAGNI). `hotMount` still distinguishes "no patch file / not
512
+ * hot-mountable" from "restart will fix it" through the bilingual `reason`.
513
+ */
514
+ const nodeFs = {
515
+ read: (path) => readFileSync(path, "utf8"),
516
+ write: (path, data) => {
517
+ mkdirSync(dirname(path), { recursive: true });
518
+ writeFileSync(path, data);
519
+ },
520
+ list: (path) => readdirSync(path)
521
+ };
522
+ /** This session's mounts, keyed by package name — the same key `hotUnmount`
523
+ * and the gateway's flows use. The subtree unwinds with the shop's own
524
+ * fiber; the map exists so a name can be disposed on demand. */
525
+ const hotHandles = /* @__PURE__ */ new Map();
526
+ /** The shop's own namespace for ephemeral mount inputs. */
527
+ const HOT_DIR = ".dsh-shop";
528
+ /** `hot-<n>.yml` files: the only things `cleanHotDir` wipes and the only
529
+ * files `hotMount` writes. */
530
+ const HOT_FILE_RE = /^hot-(\d+)\.yml$/;
531
+ /** Reasons, bilingual, distinguishing the P0-2 categories: a transient mount
532
+ * failure ("restart will fix it") versus a package that structurally cannot
533
+ * hot-mount. All tell the user to restart; the head names which category. */
534
+ const NO_PATCH_REASON = "该插件没有可热挂载的补丁文件,重启后生效 / the plugin has no patch file to hot-mount — restart required";
535
+ const NOT_SIMPLE_REASON = "该插件的补丁包含无法热挂载的配置,重启后生效 / the plugin's patch has config rows that cannot be hot-mounted — restart required";
536
+ const HOST_CANNOT_HOT_MOUNT_REASON = "当前环境不支持热挂载,重启后生效 / this harness cannot hot-mount — restart required";
537
+ const TIMEOUT_REASON = "热挂载超时,重启后生效 / hot-mount timed out — restart required";
538
+ const MOUNT_FAILED_REASON = "热挂载失败,重启后生效 / hot-mount failed — restart required";
539
+ const ID_LINE_RE = /^- id: (.+)$/;
540
+ const NAME_LINE_RE = /^ name: (.+)$/;
541
+ /**
542
+ * Parse a bundle patch into the plain insert rows a hot tree can replicate.
543
+ * Only `- id:` / `name:` pairs parse — a row with config, an expression
544
+ * name, or a dangling id returns null, and the caller falls back to restart
545
+ * activation. CRLF-aware: a patch authored with Windows line endings must
546
+ * not read as "contains config rows" (the Windows-patch regression their
547
+ * hot.ts documents). Pure: string in, rows out.
548
+ */
549
+ function parseSimplePatch(patchText) {
550
+ const lines = patchText.split(/\r?\n/);
551
+ const rows = [];
552
+ for (let i = 0; i < lines.length; i++) {
553
+ const raw = lines[i] ?? "";
554
+ if (raw.trim() === "" || raw.trim().startsWith("#")) continue;
555
+ const idMatch = ID_LINE_RE.exec(raw);
556
+ if (idMatch === null) return null;
557
+ const id = idMatch[1];
558
+ if (id === void 0) return null;
559
+ let name;
560
+ while (i + 1 < lines.length) {
561
+ i++;
562
+ const next = lines[i] ?? "";
563
+ if (next === "" || next.trim().startsWith("#")) continue;
564
+ const nameMatch = NAME_LINE_RE.exec(next);
565
+ if (nameMatch === null) return null;
566
+ const value = nameMatch[1];
567
+ if (value === void 0) return null;
568
+ if (value.includes("!!js/expression")) return null;
569
+ name = value;
570
+ break;
571
+ }
572
+ if (name === void 0) return null;
573
+ rows.push({
574
+ id,
575
+ name
576
+ });
577
+ }
578
+ return rows.length > 0 ? rows : null;
579
+ }
580
+ /** Render the accepted rows with prefixed ids — the exact input file the
581
+ * Include tree mounts. Only values the line scan accepted are emitted, and
582
+ * the output is itself a simple patch (round-trips through
583
+ * parseSimplePatch). */
584
+ function renderRows(rows, prefix) {
585
+ return rows.map((row) => `- id: ${prefix}${row.id}\n name: ${row.name}`).join("\n") + "\n";
586
+ }
587
+ /** The next free `hot-<n>` number: one past the highest existing file, or 1
588
+ * when the directory does not exist yet (the write recreates it). */
589
+ function nextHotNumber(fs, dir) {
590
+ let names;
591
+ try {
592
+ names = fs.list(dir);
593
+ } catch {
594
+ return 1;
595
+ }
596
+ let max = 0;
597
+ for (const name of names) {
598
+ const match = HOT_FILE_RE.exec(name);
599
+ if (match !== null && match[1] !== void 0) max = Math.max(max, Number(match[1]));
600
+ }
601
+ return max + 1;
602
+ }
603
+ /** Read the installed package's own `dsh` section to locate its bundle patch
604
+ * (the Include input), or null when the package or the field is absent —
605
+ * the "no patch file" rejection names this. */
606
+ function readPkgDsh(fs, packageDir) {
607
+ try {
608
+ const patch = JSON.parse(fs.read(join(packageDir, "package.json"))).dsh?.bundle?.patch;
609
+ return typeof patch === "string" ? { patch } : null;
610
+ } catch {
611
+ return null;
612
+ }
613
+ }
614
+ /** Load the Include class through a computed dynamic import, or null when
615
+ * the optional peer is missing (an older harness — every path then falls
616
+ * back to restart activation). */
617
+ async function loadHotTreeClass() {
618
+ const specifier = "@deepseek-ai/cordis-plugin-include";
619
+ try {
620
+ return (await import(specifier)).default;
621
+ } catch {
622
+ return null;
623
+ }
624
+ }
625
+ /** Wrap a tree class in a subclass that suppresses `write()` — the loader
626
+ * otherwise persists tree changes back to the file it read. The hot file is
627
+ * written once with the mkt- ids and must never be rewritten from live tree
628
+ * state. */
629
+ function suppressWrite(treeClass) {
630
+ const Base = treeClass;
631
+ return class HotTree extends Base {
632
+ write() {}
633
+ };
634
+ }
635
+ /**
636
+ * Mount one installed package into the running composition through an
637
+ * Include subtree: read its bundle patch, replicate the simple rows under
638
+ * `mkt-` ids into `<profile>/.dsh-shop/hot-<n>.yml`, register the tree, and
639
+ * race its activation against `timeoutMs`. Success returns `ok: true` with
640
+ * no reason; any fallback returns `ok: false` with a bilingual reason
641
+ * distinguishing "restart will fix it" (timeout, activation failure,
642
+ * unavailable include) from "this package can never hot-mount" (no patch
643
+ * file, or rows the hot tree cannot replicate). The plugin is installed in
644
+ * the bundle layer either way, so a restart always activates it.
645
+ */
646
+ async function hotMount(ctx, profileDir, packageName, deps = {}) {
647
+ const { fs = nodeFs, dir = join(profileDir, HOT_DIR), timeoutMs = Number(process.env.DSH_SHOP_HOT_MOUNT_TIMEOUT_MS) || 1e4, now = Date.now } = deps;
648
+ const packageDir = join(profileDir, "node_modules", packageName);
649
+ const dsh = readPkgDsh(fs, packageDir);
650
+ let patchText;
651
+ try {
652
+ patchText = fs.read(join(packageDir, dsh?.patch ?? "cordis.patch.yml"));
653
+ } catch {
654
+ ctx.logger?.warn(`hot-mount ${packageName}: no patch file to mount — restart will activate it`);
655
+ return {
656
+ ok: false,
657
+ reason: NO_PATCH_REASON
658
+ };
659
+ }
660
+ const rows = parseSimplePatch(patchText);
661
+ if (rows === null) {
662
+ ctx.logger?.warn(`hot-mount ${packageName}: patch has rows that cannot be hot-mounted — restart will activate it`);
663
+ return {
664
+ ok: false,
665
+ reason: NOT_SIMPLE_REASON
666
+ };
667
+ }
668
+ let treeClass = deps.hotTreeClass;
669
+ if (treeClass === void 0) treeClass = await loadHotTreeClass();
670
+ if (treeClass === null) {
671
+ ctx.logger?.warn(`hot-mount ${packageName}: the include plugin is unavailable in this harness — restart will activate it`);
672
+ return {
673
+ ok: false,
674
+ reason: HOST_CANNOT_HOT_MOUNT_REASON
675
+ };
676
+ }
677
+ const file = join(dir, `hot-${nextHotNumber(fs, dir)}.yml`);
678
+ fs.write(file, renderRows(rows, "mkt-"));
679
+ let handle;
680
+ try {
681
+ const HotTree = suppressWrite(treeClass);
682
+ handle = ctx.plugin(HotTree, { path: pathToFileURL(file).href });
683
+ } catch (error) {
684
+ ctx.logger?.warn(`hot-mount ${packageName}: mounting the include tree failed — restart will activate it (${String(error)})`);
685
+ return {
686
+ ok: false,
687
+ reason: MOUNT_FAILED_REASON
688
+ };
689
+ }
690
+ const previous = hotHandles.get(packageName);
691
+ if (previous !== void 0) {
692
+ try {
693
+ await previous.dispose();
694
+ } catch (error) {
695
+ ctx.logger?.warn(`hot-mount ${packageName}: the previous hot mount refused to dispose (${String(error)})`);
696
+ }
697
+ hotHandles.delete(packageName);
698
+ }
699
+ const outcome = await withTimeout(handle.await(), timeoutMs, now);
700
+ if (outcome === "settled") {
701
+ hotHandles.set(packageName, handle);
702
+ ctx.logger?.info(`hot-mounted ${packageName} (${file})`);
703
+ return {
704
+ ok: true,
705
+ reason: null
706
+ };
707
+ }
708
+ try {
709
+ await handle.dispose();
710
+ } catch (error) {
711
+ ctx.logger?.warn(`hot-mount ${packageName}: dispose after a failed mount also failed — a restart cleans up (${String(error)})`);
712
+ }
713
+ return {
714
+ ok: false,
715
+ reason: outcome === "timeout" ? TIMEOUT_REASON : MOUNT_FAILED_REASON
716
+ };
717
+ }
718
+ /** Race the fiber's activation against the deadline. The remaining time is
719
+ * measured on the injectable clock so tests can drive the timeout
720
+ * deterministically; the timer is cleared when the activation wins. */
721
+ function withTimeout(activation, timeoutMs, now) {
722
+ const deadline = now() + timeoutMs;
723
+ let timer;
724
+ const timeout = new Promise((resolve) => {
725
+ timer = setTimeout(() => resolve("timeout"), Math.max(0, deadline - now()));
726
+ });
727
+ return Promise.race([activation.then(() => "settled", () => "failed"), timeout]).finally(() => {
728
+ if (timer !== void 0) clearTimeout(timer);
729
+ });
730
+ }
731
+ /**
732
+ * Dispose a package's hot mount. Returns false (without touching anything)
733
+ * when the name is not mounted; the handle leaves the map before the
734
+ * dispose so a mount can never be disposed twice, and a throwing dispose
735
+ * propagates — the plugin may still be live in this session.
736
+ */
737
+ async function hotUnmount(packageName) {
738
+ const handle = hotHandles.get(packageName);
739
+ if (handle === void 0) return false;
740
+ hotHandles.delete(packageName);
741
+ await handle.dispose();
742
+ return true;
743
+ }
744
+ /** Wipe the session's mount inputs at host start: `hot-<n>.yml` files under
745
+ * `<profile>/.dsh-shop/` only — anything else in the namespace directory is
746
+ * left alone, and a missing directory is a no-op. */
747
+ function cleanHotDir(profileDir) {
748
+ const dir = join(profileDir, HOT_DIR);
749
+ let names;
750
+ try {
751
+ names = readdirSync(dir);
752
+ } catch {
753
+ return;
754
+ }
755
+ for (const name of names) if (HOT_FILE_RE.test(name)) rmSync(join(dir, name), { force: true });
756
+ }
757
+ //#endregion
434
758
  //#region src/host/restart.ts
435
759
  /** Restart executor: hand the port to a new dsh instance, two-phase.
436
760
  *
@@ -500,6 +824,11 @@ async function fetchLatestVersion(fetchFn = fetch) {
500
824
  }
501
825
  }
502
826
  //#endregion
827
+ //#region src/host/supervisor.ts
828
+ function detectSupervisor(env, proc) {
829
+ return (env.INVOCATION_ID !== void 0 || env.JOURNAL_STREAM !== void 0) && proc.ppid === 1 ? "systemd" : null;
830
+ }
831
+ //#endregion
503
832
  //#region src/host/repo-pins.ts
504
833
  /** Read the pins file; any irregularity degrades to an empty record. */
505
834
  function readRepoPins(fs, path) {
@@ -632,6 +961,50 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
632
961
  if (target) Object.defineProperty(target, contextIn.name, descriptor);
633
962
  done = true;
634
963
  };
964
+ /** How many bytes a release tarball may be at the integrity check. The
965
+ * registry already refuses to publish a tarball over 32 MiB, so 64 MiB is
966
+ * headroom, not a gate of its own. */
967
+ const MAX_TARBALL_BYTES = 67108864;
968
+ /**
969
+ * Fetch a release tarball and verify its sha256 against the catalog record
970
+ * (market borrowings §3.1). Returns a rejection detail, or null when the
971
+ * bytes match. The read streams through the byte cap, so an oversized or
972
+ * hostile body is refused without ever being buffered. Every failure — fetch
973
+ * throw, non-2xx, unreadable body, over-cap, hash mismatch — carries the same
974
+ * `tarball-integrity` code with a detail naming what happened, so the plugin
975
+ * author can read the cause.
976
+ */
977
+ async function verifyTarballSha256(fetchTarball, url, recordedSha256, maxBytes = MAX_TARBALL_BYTES) {
978
+ let response;
979
+ try {
980
+ response = await fetchTarball(url);
981
+ } catch (error) {
982
+ return `dsh-plugin-shop: the release tarball could not be fetched (network failure: ${error.message}); refusing to install`;
983
+ }
984
+ if (!response.ok) return `dsh-plugin-shop: the release tarball could not be fetched (HTTP ${response.status}); refusing to install`;
985
+ if (response.body === null) return "dsh-plugin-shop: the release tarball has no readable body; refusing to install";
986
+ const hash = createHash("sha256");
987
+ let bytes = 0;
988
+ const reader = response.body.getReader();
989
+ try {
990
+ for (;;) {
991
+ const { done, value } = await reader.read();
992
+ if (done) break;
993
+ bytes += value.byteLength;
994
+ if (bytes > maxBytes) {
995
+ try {
996
+ await reader.cancel();
997
+ } catch {}
998
+ return `dsh-plugin-shop: the release tarball exceeds the size cap (${maxBytes} bytes); refusing to install`;
999
+ }
1000
+ hash.update(value);
1001
+ }
1002
+ } catch (error) {
1003
+ return `dsh-plugin-shop: the release tarball download failed (${error.message}); refusing to install`;
1004
+ }
1005
+ if (hash.digest("hex") !== recordedSha256) return "dsh-plugin-shop: the release tarball failed sha256 verification against the catalog record; refusing to install";
1006
+ return null;
1007
+ }
635
1008
  /** Remote-only service exposing the shop Remote methods of §7.3.
636
1009
  *
637
1010
  * @typert service shop */
@@ -771,6 +1144,8 @@ let ShopGateway = (() => {
771
1144
  profile;
772
1145
  profileDir;
773
1146
  inventory;
1147
+ hot;
1148
+ loaderEntriesInjected;
774
1149
  dshBin;
775
1150
  /** The argv `shop/restart` re-spawns: the real process argv minus node and
776
1151
  * the CLI script path, or a test-provided substitute. */
@@ -783,6 +1158,14 @@ let ShopGateway = (() => {
783
1158
  latestVersion;
784
1159
  hasGit;
785
1160
  pinFs;
1161
+ allowRestart;
1162
+ env;
1163
+ /** The parent pid `detectSupervisor` inspects; production defaults to
1164
+ * process.ppid (a systemd unit's main process has ppid 1). */
1165
+ ppid;
1166
+ /** The release-tarball fetch for the install-time integrity check; global
1167
+ * fetch in production, a fixture response in tests. */
1168
+ fetchTarball;
786
1169
  /** The install gate runs against the last loaded snapshot, never a fresh
787
1170
  * fetch per request (§7.2: the Host's cached snapshot is the truth). */
788
1171
  /** Finished install records retained, so a poll sees the true terminal
@@ -804,6 +1187,8 @@ let ShopGateway = (() => {
804
1187
  this.profile = options.profile ?? discoverProfile(fileURLToPath(import.meta.url), this.bootBaseDir()).name;
805
1188
  this.profileDir = options.profileDir;
806
1189
  this.inventory = options.inventory;
1190
+ this.hot = options.hot;
1191
+ this.loaderEntriesInjected = options.loaderEntries;
807
1192
  this.dshBin = options.dshBin ?? "dsh";
808
1193
  this.restartArgv = options.restartArgv ?? process.argv.slice(2);
809
1194
  this.exit = options.exit ?? ((code) => process.exit(code));
@@ -819,6 +1204,13 @@ let ShopGateway = (() => {
819
1204
  writeFileSync(path, data);
820
1205
  }
821
1206
  };
1207
+ this.allowRestart = options.allowRestart;
1208
+ this.env = options.env ?? process.env;
1209
+ this.ppid = options.ppid ?? process.ppid;
1210
+ this.fetchTarball = options.fetchTarball ?? ((url) => fetch(url));
1211
+ try {
1212
+ cleanHotDir(this.profileDirResolved());
1213
+ } catch {}
822
1214
  }
823
1215
  /** The pins file lives in the shop's own cache, next to the catalog cache. */
824
1216
  pinsPath() {
@@ -849,10 +1241,46 @@ let ShopGateway = (() => {
849
1241
  if (inventory === void 0) throw new Error("dsh-plugin-shop: pluginInventory service is not mounted");
850
1242
  return inventory.list();
851
1243
  }
1244
+ /** The Loader's boot-layer entries; a harness without the loader answers
1245
+ * with an empty list (there is then nothing to live-disable). */
1246
+ loaderEntries() {
1247
+ if (this.loaderEntriesInjected !== void 0) return this.loaderEntriesInjected();
1248
+ const loader = this.ctx.loader;
1249
+ return loader === void 0 ? [] : [...loader.entries()];
1250
+ }
1251
+ /** Live-disable one boot-layer entry, retrying until its fiber is actually
1252
+ * down. A disable can land while the entry's init is still in flight: the
1253
+ * options flip but the finishing init brings the fiber up anyway, and a
1254
+ * plain re-update no-ops on the empty diff (dsh-market themes.ts:74-93).
1255
+ * For an update swap this sequencing is mandatory, not defensive: two live
1256
+ * instances of a service-providing plugin would collide at provision. */
1257
+ async liveDisable(name) {
1258
+ let found = false;
1259
+ for (const entry of this.loaderEntries()) {
1260
+ if (entry.options.name !== name) continue;
1261
+ for (let attempt = 0; attempt < 3; attempt++) {
1262
+ try {
1263
+ await entry.update({ disabled: true }, false, true);
1264
+ found = true;
1265
+ } catch {
1266
+ break;
1267
+ }
1268
+ if (entry.fiber === void 0) break;
1269
+ await new Promise((resolve) => setTimeout(resolve, 200));
1270
+ }
1271
+ }
1272
+ return found;
1273
+ }
852
1274
  /** Enable or disable one installed plugin, hot (§8): a disable writes the
853
1275
  * row to the user layer, an enable drops it again so the bundle default
854
- * rules — the CLI's watchUserPatches applies either through HMR. */
1276
+ * rules — the CLI's watchUserPatches applies either through HMR. The shop's
1277
+ * own row and the framework's bundles are never toggleable: disabling the
1278
+ * host chain would break HMR itself. */
855
1279
  setEnabled(args) {
1280
+ if (args.name === "dsh-plugin-shop" || args.name.startsWith("@deepseek-ai/")) return {
1281
+ ok: false,
1282
+ detail: `dsh-plugin-shop: ${args.name} is part of the harness chain and cannot be toggled from the shop`
1283
+ };
856
1284
  const entry = this.listInventory().find((entry) => entry.moduleName === args.name);
857
1285
  if (entry === void 0) return {
858
1286
  ok: false,
@@ -881,6 +1309,13 @@ let ShopGateway = (() => {
881
1309
  cacheDir
882
1310
  };
883
1311
  }
1312
+ /** The explicit restart override. Only the row's `config:` sub-object is
1313
+ * passed to a plugin — a top-level `allowRestart:` beside `name:` would be
1314
+ * silently ignored by the loader (dsh-market README, #227). */
1315
+ allowRestartConfigured() {
1316
+ if (this.allowRestart !== void 0) return this.allowRestart;
1317
+ return ((this.ctx.loader?.entries().find((entry) => entry.options.name === "dsh-plugin-shop"))?.options.config)?.allowRestart === true;
1318
+ }
884
1319
  /** Browse the catalog (§7.3): cached snapshot, refreshed on demand. */
885
1320
  async catalog(args) {
886
1321
  const { catalogUrl, cacheDir } = this.rowConfig();
@@ -900,7 +1335,7 @@ let ShopGateway = (() => {
900
1335
  };
901
1336
  }
902
1337
  /**
903
- * Install one cataloged version into the profile (§7.2). The four rejection
1338
+ * Install one cataloged version into the profile (§7.2). The rejection
904
1339
  * paths run against this Host's snapshot before anything is spawned; only a
905
1340
  * passing request reaches the executor.
906
1341
  */
@@ -926,7 +1361,15 @@ let ShopGateway = (() => {
926
1361
  detail: `dsh-plugin-shop: ${args.name} is not in the catalog`
927
1362
  };
928
1363
  let spec;
929
- if (entry.source === "github") {
1364
+ if (entry.source === "github" && entry.tarball !== void 0) {
1365
+ const integrity = await verifyTarballSha256(this.fetchTarball, entry.tarball.url, entry.tarball.sha256);
1366
+ if (integrity !== null) return {
1367
+ ok: false,
1368
+ code: "tarball-integrity",
1369
+ detail: integrity
1370
+ };
1371
+ spec = entry.tarball.url;
1372
+ } else if (entry.source === "github") {
930
1373
  if (entry.repo === void 0 || !/^[0-9a-f]{40}$/.test(args.version)) return {
931
1374
  ok: false,
932
1375
  code: "version-mismatch",
@@ -937,13 +1380,26 @@ let ShopGateway = (() => {
937
1380
  code: "git-missing",
938
1381
  detail: "dsh-plugin-shop: git is not on PATH, which github installs require; install git and retry"
939
1382
  };
940
- spec = `github:${entry.repo}#${args.version}`;
1383
+ spec = `github:${entry.repo}#${args.version}${entry.subdir !== void 0 ? `&path:${entry.subdir}` : ""}`;
941
1384
  } else spec = `${args.name}@${args.version}`;
1385
+ const isUpdate = (readProfileManifest("dsh-plugin-shop", this.profileDirResolved()).dependencies ?? {})[args.name] !== void 0;
942
1386
  const running = startInstall({
943
1387
  profile: this.profile,
944
1388
  spec,
945
1389
  dshBin: this.dshBin,
946
- expectedName: args.name
1390
+ expectedName: args.name,
1391
+ afterDone: async () => {
1392
+ const hot = this.hot ?? {
1393
+ mount: hotMount,
1394
+ unmount: hotUnmount
1395
+ };
1396
+ if (isUpdate) await this.liveDisable(args.name);
1397
+ const result = await hot.mount({ plugin: (plugin, config) => this.ctx.plugin(plugin, config) }, this.profileDirResolved(), args.name);
1398
+ return result.ok ? { needsRestart: false } : {
1399
+ needsRestart: true,
1400
+ restartReason: result.reason ?? void 0
1401
+ };
1402
+ }
947
1403
  });
948
1404
  if (entry.source === "github") {
949
1405
  const pins = readRepoPins(this.pinFs, this.pinsPath());
@@ -1001,6 +1457,10 @@ let ShopGateway = (() => {
1001
1457
  }
1002
1458
  const dependencies = readProfileManifest("dsh-plugin-shop", this.profileDirResolved()).dependencies ?? {};
1003
1459
  const pins = readRepoPins(this.pinFs, this.pinsPath());
1460
+ let byName = /* @__PURE__ */ new Map();
1461
+ try {
1462
+ byName = new Map(this.listInventory().map((entry) => [entry.moduleName, entry.enabled]));
1463
+ } catch {}
1004
1464
  const installed = [];
1005
1465
  for (const entry of this.lastSnapshot.entries) {
1006
1466
  const spec = dependencies[entry.name];
@@ -1011,13 +1471,15 @@ let ShopGateway = (() => {
1011
1471
  name: entry.name,
1012
1472
  installed: pin ?? spec,
1013
1473
  latest: entry.version,
1014
- outdated: pin !== void 0 && pin !== entry.version
1474
+ outdated: pin !== void 0 && pin !== entry.version,
1475
+ enabled: byName.get(entry.name) ?? true
1015
1476
  });
1016
1477
  } else installed.push({
1017
1478
  name: entry.name,
1018
1479
  installed: spec,
1019
1480
  latest: entry.version,
1020
- outdated: this.isBehind(spec, entry.version)
1481
+ outdated: this.isBehind(spec, entry.version),
1482
+ enabled: byName.get(entry.name) ?? true
1021
1483
  });
1022
1484
  }
1023
1485
  return installed;
@@ -1063,7 +1525,14 @@ let ShopGateway = (() => {
1063
1525
  profile: this.profile,
1064
1526
  name: args.name,
1065
1527
  dshBin: this.dshBin,
1066
- expectedName: args.name
1528
+ expectedName: args.name,
1529
+ afterDone: async () => {
1530
+ await (this.hot ?? {
1531
+ mount: hotMount,
1532
+ unmount: hotUnmount
1533
+ }).unmount(args.name) || await this.liveDisable(args.name);
1534
+ return { needsRestart: false };
1535
+ }
1067
1536
  });
1068
1537
  const pins = readRepoPins(this.pinFs, this.pinsPath());
1069
1538
  if (pins[args.name] !== void 0) {
@@ -1084,6 +1553,10 @@ let ShopGateway = (() => {
1084
1553
  * response is out. The browser monitors the origin and refreshes when the
1085
1554
  * new server answers. Refusals are issued before anything is torn down. */
1086
1555
  async restart() {
1556
+ if (detectSupervisor(this.env, { ppid: this.ppid }) === "systemd" && !this.allowRestartConfigured()) return {
1557
+ ok: false,
1558
+ detail: "dsh-plugin-shop: restart is disabled because this process is a systemd service — a restart would kill the takeover helper along with the unit, and the service would not come back. Set allowRestart: true in the shop row config to override."
1559
+ };
1087
1560
  const portIndex = this.restartArgv.indexOf("--port");
1088
1561
  if (portIndex !== -1 && this.restartArgv[portIndex + 1] === "0") return {
1089
1562
  ok: false,
@@ -1117,7 +1590,8 @@ let ShopGateway = (() => {
1117
1590
  return {
1118
1591
  installed,
1119
1592
  latest,
1120
- outdated: latest !== null && lt(installed, latest)
1593
+ outdated: latest !== null && lt(installed, latest),
1594
+ restartSupported: detectSupervisor(this.env, { ppid: this.ppid }) === null || this.allowRestartConfigured()
1121
1595
  };
1122
1596
  }
1123
1597
  /** Update the shop itself to a published version (§7.3): the explicit pin