dsh-plugin-shop 0.4.14 → 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({
@@ -60,16 +62,42 @@ const entrySchema = z.object({
60
62
  }).optional(),
61
63
  source: z.enum(["npm", "github"]).default("npm"),
62
64
  repo: z.string().optional(),
63
- subdir: z.string().regex(/^(?!.*(^|\/)\.\.?(\/|$))[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/).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()
64
71
  });
65
72
  const dataSchema = z.object({
66
73
  schemaVersion: z.number(),
67
74
  plugins: z.array(entrySchema),
68
75
  denied: z.array(z.object({
69
76
  name: z.string(),
70
- detail: z.string()
77
+ detail: z.string(),
78
+ replacement: z.string().optional()
71
79
  })).default([])
72
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
+ }
73
101
  const pointerSchema = z.object({
74
102
  schemaVersion: z.number(),
75
103
  builtAt: z.string(),
@@ -83,7 +111,7 @@ const pointerSchema = z.object({
83
111
  sha256: z.string()
84
112
  }).optional()
85
113
  });
86
- const nodeFs = {
114
+ const nodeFs$1 = {
87
115
  exists: (path) => existsSync(path),
88
116
  read: (path) => readFileSync(path, "utf8"),
89
117
  write: (path, data) => {
@@ -127,7 +155,7 @@ function parseStarsText(text) {
127
155
  * included, degrades to no stars (spec §5).
128
156
  */
129
157
  async function loadCatalog(options) {
130
- 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;
131
159
  const indexPath = join(cacheDir, "index.json");
132
160
  const metaPath = join(cacheDir, META_FILE);
133
161
  /** The timestamp freshness is measured from: the sidecar's fetch time when
@@ -142,30 +170,33 @@ async function loadCatalog(options) {
142
170
  return Number.isNaN(built) ? null : built;
143
171
  };
144
172
  const readCached = () => {
173
+ let pointer;
174
+ let data;
145
175
  try {
146
- const pointer = pointerSchema.parse(JSON.parse(fsImpl.read(indexPath)));
147
- if (pointer.schemaVersion > 4) throw new Error(`catalog schemaVersion ${pointer.schemaVersion} is newer than this build supports (4)`);
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)`);
148
178
  const dataPath = join(cacheDir, basename(pointer.plugins.url));
149
179
  const dataText = fsImpl.read(dataPath);
150
180
  const actual = createHash("sha256").update(dataText).digest("hex");
151
181
  if (actual !== pointer.plugins.sha256) throw new Error(`cached catalog data failed integrity check: expected ${pointer.plugins.sha256}, got ${actual}`);
152
- const data = dataSchema.parse(JSON.parse(dataText));
153
- if (data.schemaVersion > 4) throw new Error(`catalog schemaVersion ${data.schemaVersion} is newer than this build supports (4)`);
154
- let stars = {};
155
- if (pointer.stars !== void 0) try {
156
- const starsText = fsImpl.read(join(cacheDir, basename(pointer.stars.url)));
157
- if (createHash("sha256").update(starsText).digest("hex") === pointer.stars.sha256) stars = parseStarsText(starsText);
158
- } catch {}
159
- return {
160
- schemaVersion: pointer.schemaVersion,
161
- builtAt: pointer.builtAt,
162
- entries: data.plugins,
163
- denied: data.denied,
164
- stars
165
- };
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)`);
166
184
  } catch {
167
185
  return null;
168
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
+ };
169
200
  };
170
201
  if (!refresh && fsImpl.exists(indexPath)) {
171
202
  const cached = readCached();
@@ -191,7 +222,7 @@ async function loadCatalog(options) {
191
222
  throw error;
192
223
  }
193
224
  const pointer = pointerSchema.parse(JSON.parse(pointerText));
194
- if (pointer.schemaVersion > 4) throw new Error(`catalog schemaVersion ${pointer.schemaVersion} is newer than this build supports (4)`);
225
+ if (pointer.schemaVersion > 5) throw new Error(`catalog schemaVersion ${pointer.schemaVersion} is newer than this build supports (5)`);
195
226
  const dataUrl = resolveDataUrl(baseUrl, pointer.plugins.url);
196
227
  let dataText;
197
228
  try {
@@ -209,7 +240,8 @@ async function loadCatalog(options) {
209
240
  const actual = createHash("sha256").update(dataText).digest("hex");
210
241
  if (actual !== pointer.plugins.sha256) throw new Error(`catalog data failed integrity check: expected ${pointer.plugins.sha256}, got ${actual}`);
211
242
  const data = dataSchema.parse(JSON.parse(dataText));
212
- if (data.schemaVersion > 4) throw new Error(`catalog schemaVersion ${data.schemaVersion} is newer than this build supports (4)`);
243
+ if (data.schemaVersion > 5) throw new Error(`catalog schemaVersion ${data.schemaVersion} is newer than this build supports (5)`);
244
+ validateEntryCoherence(data.plugins);
213
245
  let stars = {};
214
246
  if (pointer.stars !== void 0) try {
215
247
  const starsResponse = await fetchImpl(resolveDataUrl(baseUrl, pointer.stars.url));
@@ -321,20 +353,30 @@ function confirmBundleRemoval(profile, home, expectedName) {
321
353
  * real-install test pins DSH_HOME to a temporary directory this way.
322
354
  * When `confirm` is given, a zero exit is checked against the profile
323
355
  * manifest before the command reports `done` (§7.2 step 6 and its uninstall
324
- * 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. */
325
362
  function spawnPluginCli(options) {
326
- const { profile, argv, dshBin, env, confirm, onStatus } = options;
363
+ const { profile, argv, dshBin, env, confirm, afterDone, onStatus } = options;
327
364
  const target = argv[1];
328
365
  if (target === void 0 || target.startsWith("-")) throw new Error(`dsh-plugin-shop: refusing to spawn with a flag-like operand: ${target ?? "(none)"}`);
329
366
  const installId = randomUUID();
330
367
  const log = [];
331
368
  let logBytes = 0;
332
369
  let state = "running";
370
+ let needsRestartOnDone = true;
371
+ let restartReason;
333
372
  let detail;
334
373
  const status = () => ({
335
374
  state,
336
375
  log: [...log],
337
- ...state === "done" ? { needsRestart: true } : {},
376
+ ...state === "done" ? {
377
+ needsRestart: needsRestartOnDone,
378
+ ...restartReason !== void 0 ? { restartReason } : {}
379
+ } : {},
338
380
  ...detail !== void 0 ? { detail } : {}
339
381
  });
340
382
  const append = (line) => {
@@ -376,17 +418,24 @@ function spawnPluginCli(options) {
376
418
  onStatus?.(status());
377
419
  resolve(status());
378
420
  });
379
- child.on("close", (exitCode) => {
421
+ child.on("close", async (exitCode) => {
380
422
  if (state !== "running") return;
381
423
  if (exitCode === 0) {
382
- state = "done";
383
- if (confirm !== void 0) {
384
- const confirmDetail = confirm(env?.DSH_HOME);
385
- if (confirmDetail !== null) {
386
- state = "failed";
387
- 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";
388
436
  }
389
- }
437
+ state = "done";
438
+ } else state = "done";
390
439
  } else {
391
440
  state = "failed";
392
441
  const lastLogLine = log[log.length - 1] ?? "";
@@ -401,16 +450,18 @@ function spawnPluginCli(options) {
401
450
  /**
402
451
  * Run one `dsh plugin --profile <profile> add <spec>` and track it.
403
452
  * When `expectedName` is given, a zero exit is confirmed against the profile
404
- * 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).
405
455
  */
406
456
  function startInstall(options) {
407
- const { profile, spec, dshBin = "dsh", env, expectedName, onStatus } = options;
457
+ const { profile, spec, dshBin = "dsh", env, expectedName, afterDone, onStatus } = options;
408
458
  return spawnPluginCli({
409
459
  profile,
410
460
  argv: ["add", spec],
411
461
  dshBin,
412
462
  env,
413
463
  confirm: expectedName !== void 0 ? (home) => confirmBundleActivation(profile, home, expectedName) : void 0,
464
+ afterDone,
414
465
  onStatus
415
466
  });
416
467
  }
@@ -418,20 +469,292 @@ function startInstall(options) {
418
469
  * Run one `dsh plugin --profile <profile> remove <name>` and track it.
419
470
  * When `expectedName` is given, a zero exit is confirmed against the profile
420
471
  * manifest — the bundle must actually have LEFT `dsh.profile.bundles` — before
421
- * 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).
422
474
  */
423
475
  function startUninstall(options) {
424
- const { profile, name, dshBin = "dsh", env, expectedName, onStatus } = options;
476
+ const { profile, name, dshBin = "dsh", env, expectedName, afterDone, onStatus } = options;
425
477
  return spawnPluginCli({
426
478
  profile,
427
479
  argv: ["remove", name],
428
480
  dshBin,
429
481
  env,
430
482
  confirm: expectedName !== void 0 ? (home) => confirmBundleRemoval(profile, home, expectedName) : void 0,
483
+ afterDone,
431
484
  onStatus
432
485
  });
433
486
  }
434
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
435
758
  //#region src/host/restart.ts
436
759
  /** Restart executor: hand the port to a new dsh instance, two-phase.
437
760
  *
@@ -501,6 +824,11 @@ async function fetchLatestVersion(fetchFn = fetch) {
501
824
  }
502
825
  }
503
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
504
832
  //#region src/host/repo-pins.ts
505
833
  /** Read the pins file; any irregularity degrades to an empty record. */
506
834
  function readRepoPins(fs, path) {
@@ -633,6 +961,50 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
633
961
  if (target) Object.defineProperty(target, contextIn.name, descriptor);
634
962
  done = true;
635
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
+ }
636
1008
  /** Remote-only service exposing the shop Remote methods of §7.3.
637
1009
  *
638
1010
  * @typert service shop */
@@ -772,6 +1144,8 @@ let ShopGateway = (() => {
772
1144
  profile;
773
1145
  profileDir;
774
1146
  inventory;
1147
+ hot;
1148
+ loaderEntriesInjected;
775
1149
  dshBin;
776
1150
  /** The argv `shop/restart` re-spawns: the real process argv minus node and
777
1151
  * the CLI script path, or a test-provided substitute. */
@@ -784,6 +1158,14 @@ let ShopGateway = (() => {
784
1158
  latestVersion;
785
1159
  hasGit;
786
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;
787
1169
  /** The install gate runs against the last loaded snapshot, never a fresh
788
1170
  * fetch per request (§7.2: the Host's cached snapshot is the truth). */
789
1171
  /** Finished install records retained, so a poll sees the true terminal
@@ -805,6 +1187,8 @@ let ShopGateway = (() => {
805
1187
  this.profile = options.profile ?? discoverProfile(fileURLToPath(import.meta.url), this.bootBaseDir()).name;
806
1188
  this.profileDir = options.profileDir;
807
1189
  this.inventory = options.inventory;
1190
+ this.hot = options.hot;
1191
+ this.loaderEntriesInjected = options.loaderEntries;
808
1192
  this.dshBin = options.dshBin ?? "dsh";
809
1193
  this.restartArgv = options.restartArgv ?? process.argv.slice(2);
810
1194
  this.exit = options.exit ?? ((code) => process.exit(code));
@@ -820,6 +1204,13 @@ let ShopGateway = (() => {
820
1204
  writeFileSync(path, data);
821
1205
  }
822
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 {}
823
1214
  }
824
1215
  /** The pins file lives in the shop's own cache, next to the catalog cache. */
825
1216
  pinsPath() {
@@ -850,6 +1241,36 @@ let ShopGateway = (() => {
850
1241
  if (inventory === void 0) throw new Error("dsh-plugin-shop: pluginInventory service is not mounted");
851
1242
  return inventory.list();
852
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
+ }
853
1274
  /** Enable or disable one installed plugin, hot (§8): a disable writes the
854
1275
  * row to the user layer, an enable drops it again so the bundle default
855
1276
  * rules — the CLI's watchUserPatches applies either through HMR. The shop's
@@ -888,6 +1309,13 @@ let ShopGateway = (() => {
888
1309
  cacheDir
889
1310
  };
890
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
+ }
891
1319
  /** Browse the catalog (§7.3): cached snapshot, refreshed on demand. */
892
1320
  async catalog(args) {
893
1321
  const { catalogUrl, cacheDir } = this.rowConfig();
@@ -907,7 +1335,7 @@ let ShopGateway = (() => {
907
1335
  };
908
1336
  }
909
1337
  /**
910
- * Install one cataloged version into the profile (§7.2). The four rejection
1338
+ * Install one cataloged version into the profile (§7.2). The rejection
911
1339
  * paths run against this Host's snapshot before anything is spawned; only a
912
1340
  * passing request reaches the executor.
913
1341
  */
@@ -933,7 +1361,15 @@ let ShopGateway = (() => {
933
1361
  detail: `dsh-plugin-shop: ${args.name} is not in the catalog`
934
1362
  };
935
1363
  let spec;
936
- 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") {
937
1373
  if (entry.repo === void 0 || !/^[0-9a-f]{40}$/.test(args.version)) return {
938
1374
  ok: false,
939
1375
  code: "version-mismatch",
@@ -946,11 +1382,24 @@ let ShopGateway = (() => {
946
1382
  };
947
1383
  spec = `github:${entry.repo}#${args.version}${entry.subdir !== void 0 ? `&path:${entry.subdir}` : ""}`;
948
1384
  } else spec = `${args.name}@${args.version}`;
1385
+ const isUpdate = (readProfileManifest("dsh-plugin-shop", this.profileDirResolved()).dependencies ?? {})[args.name] !== void 0;
949
1386
  const running = startInstall({
950
1387
  profile: this.profile,
951
1388
  spec,
952
1389
  dshBin: this.dshBin,
953
- 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
+ }
954
1403
  });
955
1404
  if (entry.source === "github") {
956
1405
  const pins = readRepoPins(this.pinFs, this.pinsPath());
@@ -1076,7 +1525,14 @@ let ShopGateway = (() => {
1076
1525
  profile: this.profile,
1077
1526
  name: args.name,
1078
1527
  dshBin: this.dshBin,
1079
- 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
+ }
1080
1536
  });
1081
1537
  const pins = readRepoPins(this.pinFs, this.pinsPath());
1082
1538
  if (pins[args.name] !== void 0) {
@@ -1097,6 +1553,10 @@ let ShopGateway = (() => {
1097
1553
  * response is out. The browser monitors the origin and refreshes when the
1098
1554
  * new server answers. Refusals are issued before anything is torn down. */
1099
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
+ };
1100
1560
  const portIndex = this.restartArgv.indexOf("--port");
1101
1561
  if (portIndex !== -1 && this.restartArgv[portIndex + 1] === "0") return {
1102
1562
  ok: false,
@@ -1130,7 +1590,8 @@ let ShopGateway = (() => {
1130
1590
  return {
1131
1591
  installed,
1132
1592
  latest,
1133
- outdated: latest !== null && lt(installed, latest)
1593
+ outdated: latest !== null && lt(installed, latest),
1594
+ restartSupported: detectSupervisor(this.env, { ppid: this.ppid }) === null || this.allowRestartConfigured()
1134
1595
  };
1135
1596
  }
1136
1597
  /** Update the shop itself to a published version (§7.3): the explicit pin