dsh-plugin-shop 0.8.1 → 0.8.2

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
@@ -5,7 +5,7 @@ import { lt, minVersion, satisfies, valid, validRange } from "semver";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
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
- import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
8
+ import { basename, delimiter, dirname, isAbsolute, join, posix, relative, resolve, win32 } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  import { z } from "zod";
11
11
  import { gunzipSync } from "node:zlib";
@@ -688,7 +688,7 @@ const pointerSchema = z.object({
688
688
  sha256: z.string()
689
689
  }).optional()
690
690
  });
691
- const nodeFs$2 = {
691
+ const nodeFs$1 = {
692
692
  exists: (path) => existsSync(path),
693
693
  read: (path) => readFileSync(path, "utf8"),
694
694
  write: (path, data) => {
@@ -720,7 +720,7 @@ function parseStarsText(text) {
720
720
  * included, degrades to no stars (spec §5).
721
721
  */
722
722
  async function loadCatalog(options) {
723
- const { cacheDir, refresh = false, fetchImpl = fetch, now = () => /* @__PURE__ */ new Date(), fsImpl = nodeFs$2 } = options;
723
+ const { cacheDir, refresh = false, fetchImpl = fetch, now = () => /* @__PURE__ */ new Date(), fsImpl = nodeFs$1 } = options;
724
724
  if (options.baseUrl === void 0 === (options.origins === void 0)) throw new Error("loadCatalog: exactly one of baseUrl or origins is required");
725
725
  const originList = options.origins ?? [httpOrigin(options.baseUrl, fetchImpl)];
726
726
  if (originList.length === 0) throw new Error("loadCatalog: no origins");
@@ -1079,6 +1079,23 @@ const DSH_BIN_NAME = "dsh";
1079
1079
  */
1080
1080
  const JS_ENTRY = /\.[cm]?js$/;
1081
1081
  /**
1082
+ * The command that starts `bin` through `execPath` when `bin` names a
1083
+ * JavaScript entry, or `null` when `bin` is a program to spawn as given.
1084
+ *
1085
+ * Extracted from {@link dshCommand} so `prefetch.ts` can reach the same
1086
+ * decision for pnpm without a second copy of {@link JS_ENTRY}: both need it
1087
+ * for the same two reasons — a packaged JS entry is what a caller pinning an
1088
+ * installation can name, and a `.mjs` test fixture is the only fake CLI that
1089
+ * can be spawned on Windows at all.
1090
+ */
1091
+ function jsEntryCommand(bin, args, execPath) {
1092
+ if (!JS_ENTRY.test(bin)) return null;
1093
+ return {
1094
+ command: execPath,
1095
+ args: [bin, ...args]
1096
+ };
1097
+ }
1098
+ /**
1082
1099
  * The command that starts the dsh CLI with `args`.
1083
1100
  *
1084
1101
  * For the bare name the node route replaces exactly one thing: looking `dsh`
@@ -1101,11 +1118,7 @@ function dshCommand(options) {
1101
1118
  args: [...args]
1102
1119
  };
1103
1120
  }
1104
- if (JS_ENTRY.test(dshBin)) return {
1105
- command: execPath,
1106
- args: [dshBin, ...args]
1107
- };
1108
- return {
1121
+ return jsEntryCommand(dshBin, args, execPath) ?? {
1109
1122
  command: dshBin,
1110
1123
  args: [...args]
1111
1124
  };
@@ -1169,6 +1182,13 @@ function resolveDshScript(fs, options) {
1169
1182
  return null;
1170
1183
  }
1171
1184
  //#endregion
1185
+ //#region src/shared/install-state.ts
1186
+ /** Whether the host is done with this record: it will not change again, and a
1187
+ * poller may stop. */
1188
+ function isTerminalInstallState(state) {
1189
+ return state === "done" || state === "failed";
1190
+ }
1191
+ //#endregion
1172
1192
  //#region src/host/executor.ts
1173
1193
  /** Plugin-command executor: spawn the dsh CLI, stream its output, serialize
1174
1194
  * per profile. One implementation drives both `dsh plugin add` (install) and
@@ -1182,6 +1202,23 @@ const INSTALL_TIMEOUT_MS = Number(process.env.DSH_SHOP_INSTALL_TIMEOUT_MS) || 9e
1182
1202
  /** Grace period for output already buffered after the child exits. */
1183
1203
  const PIPE_DRAIN_MS = 500;
1184
1204
  const profileQueues = /* @__PURE__ */ new Map();
1205
+ /** How many commands are queued or running per profile.
1206
+ *
1207
+ * `profileQueues` cannot answer this: its entries are only ever set, never
1208
+ * deleted, so `has(profile)` is true forever after a profile's first install
1209
+ * and would call every later lone install queued — earning it a pointless
1210
+ * prefetch and a `Downloading…` label that is simply false. Promise state is
1211
+ * not observable, so a counter is the only honest signal available.
1212
+ */
1213
+ const profileDepth = /* @__PURE__ */ new Map();
1214
+ function enterQueue(profile) {
1215
+ const ahead = profileDepth.get(profile) ?? 0;
1216
+ profileDepth.set(profile, ahead + 1);
1217
+ return ahead;
1218
+ }
1219
+ function leaveQueue(profile) {
1220
+ profileDepth.set(profile, Math.max(0, (profileDepth.get(profile) ?? 1) - 1));
1221
+ }
1185
1222
  function chain(profile, task) {
1186
1223
  const next = (profileQueues.get(profile) ?? Promise.resolve()).then(task, task);
1187
1224
  profileQueues.set(profile, next.catch(() => {}));
@@ -1338,6 +1375,10 @@ function spawnFailureDetail(code, message, dshBin, platform) {
1338
1375
  function installTimeoutDetail(profile, timeoutMs) {
1339
1376
  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`;
1340
1377
  }
1378
+ /** The real kill. Exported for the tests that inject a RECORDING `KillFns`:
1379
+ * a recorder that only records asserts that a kill was requested and leaves
1380
+ * the child running, which for a `detached` fixture is an immortal node
1381
+ * process per run — see `prefetch.test.ts`'s delegating recorder. */
1341
1382
  const nodeKills = {
1342
1383
  killGroup: (pid) => process.kill(-pid, "SIGKILL"),
1343
1384
  killPid: (pid) => process.kill(pid, "SIGKILL"),
@@ -1383,7 +1424,7 @@ function lineSink(emit) {
1383
1424
  };
1384
1425
  }
1385
1426
  /** Read-only filesystem seam for the CLI lookup; the same shape as `pinFs`. */
1386
- const nodeFs$1 = {
1427
+ const nodeFs = {
1387
1428
  exists: (path) => existsSync(path),
1388
1429
  read: (path) => readFileSync(path, "utf8")
1389
1430
  };
@@ -1393,7 +1434,7 @@ let cachedScript;
1393
1434
  * so no platform but Windows pays for it. */
1394
1435
  function dshScript() {
1395
1436
  if (process.platform !== "win32") return null;
1396
- if (cachedScript === void 0) cachedScript = resolveDshScript(nodeFs$1, {
1437
+ if (cachedScript === void 0) cachedScript = resolveDshScript(nodeFs, {
1397
1438
  argv1: process.argv[1],
1398
1439
  path: process.env.PATH
1399
1440
  });
@@ -1455,12 +1496,12 @@ function shellSafeTarget(target, platform) {
1455
1496
  * manifest before the command reports `done` (§7.2 step 6 and its uninstall
1456
1497
  * mirror). When `afterDone` is given, a zero exit that passes `confirm`
1457
1498
  * withholds the terminal `done` until the callback — typically the hot-mount
1458
- * attempt — settles; its result sets `needsRestart` (default `true`) and
1499
+ * attempt — settles; its result sets `activation` (default `restart`) and
1459
1500
  * `restartReason`. The client stops polling at `done`, so the hot outcome
1460
1501
  * must settle before it. A throwing callback never fails the install — the
1461
1502
  * package IS installed; it reports `done` with the restart fallback. */
1462
1503
  function spawnPluginCli(options) {
1463
- const { profile, argv, dshBin, env, platform = process.platform, beforeSpawn, confirm, afterDone, onStatus, timeoutMs = INSTALL_TIMEOUT_MS } = options;
1504
+ const { profile, argv, dshBin, env, platform = process.platform, beforeSpawn, prefetcher, confirm, afterDone, onStatus, timeoutMs = INSTALL_TIMEOUT_MS } = options;
1464
1505
  const target = argv[1];
1465
1506
  if (target === void 0 || target.startsWith("-")) throw new Error(`dsh-plugin-shop: refusing to spawn with a flag-like operand: ${target ?? "(none)"}`);
1466
1507
  if (UNSAFE_TARGET.test(target)) throw new Error(`dsh-plugin-shop: refusing to spawn with an unsafe operand: ${JSON.stringify(target)}`);
@@ -1469,21 +1510,22 @@ function spawnPluginCli(options) {
1469
1510
  const installId = randomUUID();
1470
1511
  const log = [];
1471
1512
  let logBytes = 0;
1513
+ const ahead = enterQueue(profile);
1472
1514
  let state = "running";
1473
- let needsRestartOnDone = true;
1515
+ let activationOnDone = "restart";
1474
1516
  let restartReason;
1475
1517
  let detail;
1476
1518
  const status = () => ({
1477
1519
  state,
1478
1520
  log: [...log],
1479
1521
  ...state === "done" ? {
1480
- needsRestart: needsRestartOnDone,
1522
+ activation: activationOnDone,
1481
1523
  ...restartReason !== void 0 ? { restartReason } : {}
1482
1524
  } : {},
1483
1525
  ...detail !== void 0 ? { detail } : {}
1484
1526
  });
1485
1527
  const append = (line) => {
1486
- if (state !== "running") return;
1528
+ if (isTerminalInstallState(state)) return;
1487
1529
  log.push(line);
1488
1530
  logBytes += Buffer.byteLength(line);
1489
1531
  while ((log.length > MAX_LOG_LINES || logBytes > MAX_LOG_BYTES) && log.length > 1) {
@@ -1498,115 +1540,136 @@ function spawnPluginCli(options) {
1498
1540
  onStatus?.(status());
1499
1541
  return status();
1500
1542
  };
1501
- return {
1502
- installId,
1503
- status,
1504
- finished: chain(profile, () => new Promise((resolve) => {
1505
- beforeSpawn?.(env?.DSH_HOME);
1506
- const { command, args } = dshCommand({
1507
- dshBin,
1508
- args: [
1509
- "plugin",
1510
- "--profile",
1511
- profile,
1512
- ...spawnArgv
1543
+ let prefetch = null;
1544
+ if (ahead > 0 && prefetcher !== void 0) try {
1545
+ prefetch = prefetcher.request({
1546
+ profile,
1547
+ spec: target,
1548
+ cwd: resolveProfileDir(profile, env?.DSH_HOME),
1549
+ env,
1550
+ log: append
1551
+ });
1552
+ } catch (error) {
1553
+ append(`dsh-plugin-shop: the download phase could not start — ${error.message}`);
1554
+ }
1555
+ if (prefetch?.started === true) state = "downloading";
1556
+ else if (prefetch !== null) append(prefetch.reason === "no-pnpm" ? "dsh-plugin-shop: no download phase — pnpm not found on PATH" : "dsh-plugin-shop: no download phase for this spec form; the install fetches it directly");
1557
+ const finished = chain(profile, () => new Promise((resolve) => {
1558
+ state = "running";
1559
+ onStatus?.(status());
1560
+ beforeSpawn?.(env?.DSH_HOME);
1561
+ const { command, args } = dshCommand({
1562
+ dshBin,
1563
+ args: [
1564
+ "plugin",
1565
+ "--profile",
1566
+ profile,
1567
+ ...spawnArgv
1568
+ ],
1569
+ platform,
1570
+ execPath: process.execPath,
1571
+ script: dshScript()
1572
+ });
1573
+ let child;
1574
+ try {
1575
+ child = spawn(command, args, {
1576
+ stdio: [
1577
+ "ignore",
1578
+ "pipe",
1579
+ "pipe"
1513
1580
  ],
1514
- platform,
1515
- execPath: process.execPath,
1516
- script: dshScript()
1581
+ env: env ?? process.env,
1582
+ detached: platform !== "win32"
1517
1583
  });
1518
- let child;
1519
- try {
1520
- child = spawn(command, args, {
1521
- stdio: [
1522
- "ignore",
1523
- "pipe",
1524
- "pipe"
1525
- ],
1526
- env: env ?? process.env,
1527
- detached: platform !== "win32"
1528
- });
1529
- } catch (error) {
1530
- resolve(failToStart(error));
1531
- return;
1532
- }
1533
- let exited = false;
1534
- let closed = false;
1535
- let exitCode = null;
1536
- let timedOut = false;
1537
- let drainTimer;
1538
- let deadlineTimer;
1539
- const outLines = lineSink(append);
1540
- const errLines = lineSink(append);
1541
- const settle = async () => {
1542
- if (state !== "running") return;
1543
- clearTimeout(drainTimer);
1544
- clearTimeout(deadlineTimer);
1545
- outLines.flush();
1546
- errLines.flush();
1547
- child.stdout.destroy();
1548
- child.stderr.destroy();
1549
- if (timedOut) {
1550
- state = "failed";
1551
- detail = installTimeoutDetail(profile, timeoutMs);
1552
- } else if (exitCode === 0) {
1553
- const confirmDetail = confirm?.(env?.DSH_HOME);
1554
- if (confirmDetail != null) {
1555
- state = "failed";
1556
- detail = confirmDetail;
1557
- } else if (afterDone !== void 0) {
1558
- try {
1559
- const outcome = await afterDone(env?.DSH_HOME);
1560
- needsRestartOnDone = outcome?.needsRestart ?? true;
1561
- restartReason = outcome?.restartReason;
1562
- } catch {
1563
- needsRestartOnDone = true;
1564
- restartReason = "mount-failed";
1565
- }
1566
- state = "done";
1567
- } else state = "done";
1568
- } else {
1584
+ } catch (error) {
1585
+ resolve(failToStart(error));
1586
+ return;
1587
+ }
1588
+ let exited = false;
1589
+ let closed = false;
1590
+ let exitCode = null;
1591
+ let timedOut = false;
1592
+ let drainTimer;
1593
+ let deadlineTimer;
1594
+ const outLines = lineSink(append);
1595
+ const errLines = lineSink(append);
1596
+ const settle = async () => {
1597
+ if (state !== "running") return;
1598
+ clearTimeout(drainTimer);
1599
+ clearTimeout(deadlineTimer);
1600
+ outLines.flush();
1601
+ errLines.flush();
1602
+ child.stdout.destroy();
1603
+ child.stderr.destroy();
1604
+ if (timedOut) {
1605
+ state = "failed";
1606
+ detail = installTimeoutDetail(profile, timeoutMs);
1607
+ } else if (exitCode === 0) {
1608
+ const confirmDetail = confirm?.(env?.DSH_HOME);
1609
+ if (confirmDetail != null) {
1569
1610
  state = "failed";
1570
- detail = installFailureDetail(profile, log);
1571
- }
1572
- onStatus?.(status());
1573
- resolve(status());
1574
- };
1575
- const drainThenSettle = () => {
1576
- clearTimeout(drainTimer);
1577
- drainTimer = setTimeout(() => {
1578
- settle();
1579
- }, PIPE_DRAIN_MS);
1580
- };
1581
- child.stdout.on("data", (chunk) => {
1582
- outLines.write(chunk);
1583
- });
1584
- child.stderr.on("data", (chunk) => {
1585
- errLines.write(chunk);
1586
- });
1587
- child.on("error", (error) => {
1588
- if (state !== "running") return;
1589
- clearTimeout(drainTimer);
1590
- clearTimeout(deadlineTimer);
1591
- resolve(failToStart(error));
1592
- });
1593
- child.on("exit", (code) => {
1594
- exited = true;
1595
- exitCode = code;
1596
- if (closed) settle();
1597
- else drainThenSettle();
1598
- });
1599
- child.on("close", () => {
1600
- closed = true;
1601
- if (exited) settle();
1602
- });
1603
- deadlineTimer = setTimeout(() => {
1604
- if (state !== "running") return;
1605
- timedOut = true;
1606
- killTree(child.pid, platform);
1607
- drainThenSettle();
1608
- }, timeoutMs);
1609
- }))
1611
+ detail = confirmDetail;
1612
+ } else if (afterDone !== void 0) {
1613
+ try {
1614
+ const outcome = await afterDone(env?.DSH_HOME);
1615
+ activationOnDone = outcome?.activation ?? "restart";
1616
+ restartReason = outcome?.restartReason;
1617
+ } catch {
1618
+ activationOnDone = "restart";
1619
+ restartReason = "mount-failed";
1620
+ }
1621
+ state = "done";
1622
+ } else state = "done";
1623
+ } else {
1624
+ state = "failed";
1625
+ detail = installFailureDetail(profile, log);
1626
+ }
1627
+ onStatus?.(status());
1628
+ resolve(status());
1629
+ };
1630
+ const drainThenSettle = () => {
1631
+ clearTimeout(drainTimer);
1632
+ drainTimer = setTimeout(() => {
1633
+ settle();
1634
+ }, PIPE_DRAIN_MS);
1635
+ };
1636
+ child.stdout.on("data", (chunk) => {
1637
+ outLines.write(chunk);
1638
+ });
1639
+ child.stderr.on("data", (chunk) => {
1640
+ errLines.write(chunk);
1641
+ });
1642
+ child.on("error", (error) => {
1643
+ if (state !== "running") return;
1644
+ clearTimeout(drainTimer);
1645
+ clearTimeout(deadlineTimer);
1646
+ resolve(failToStart(error));
1647
+ });
1648
+ child.on("exit", (code) => {
1649
+ exited = true;
1650
+ exitCode = code;
1651
+ if (closed) settle();
1652
+ else drainThenSettle();
1653
+ });
1654
+ child.on("close", () => {
1655
+ closed = true;
1656
+ if (exited) settle();
1657
+ });
1658
+ deadlineTimer = setTimeout(() => {
1659
+ if (state !== "running") return;
1660
+ timedOut = true;
1661
+ killTree(child.pid, platform);
1662
+ drainThenSettle();
1663
+ }, timeoutMs);
1664
+ })).finally(() => {
1665
+ leaveQueue(profile);
1666
+ prefetcher?.release(profile, target);
1667
+ });
1668
+ finished.catch(() => {});
1669
+ return {
1670
+ installId,
1671
+ status,
1672
+ finished
1610
1673
  };
1611
1674
  }
1612
1675
  /**
@@ -1621,7 +1684,7 @@ function spawnPluginCli(options) {
1621
1684
  * (§D hot mount).
1622
1685
  */
1623
1686
  function startInstall(options) {
1624
- const { profile, spec, dshBin = "dsh", env, platform, expectedName, alsoConfirm, afterDone, onStatus, timeoutMs } = options;
1687
+ const { profile, spec, dshBin = "dsh", env, platform, expectedName, prefetcher, alsoConfirm, afterDone, onStatus, timeoutMs } = options;
1625
1688
  let before = null;
1626
1689
  return spawnPluginCli({
1627
1690
  profile,
@@ -1632,6 +1695,7 @@ function startInstall(options) {
1632
1695
  beforeSpawn: expectedName !== void 0 ? (home) => {
1633
1696
  before = readProfileDependencies(profile, home);
1634
1697
  } : void 0,
1698
+ prefetcher,
1635
1699
  confirm: expectedName !== void 0 ? (home) => confirmBundleActivation(profile, home, expectedName, before) ?? alsoConfirm?.(home) ?? null : alsoConfirm,
1636
1700
  afterDone,
1637
1701
  onStatus,
@@ -1723,7 +1787,7 @@ const PATCH_SCHEMA = JSON_SCHEMA.extend(new Type("tag:yaml.org,2002:js", {
1723
1787
  resolve: () => true,
1724
1788
  construct: (data) => ({ __jsExpr: data })
1725
1789
  }));
1726
- const nodeFs = {
1790
+ const nodeHotFs = {
1727
1791
  read: (path) => readFileSync(path, "utf8"),
1728
1792
  write: (path, data) => {
1729
1793
  mkdirSync(dirname(path), { recursive: true });
@@ -1853,7 +1917,7 @@ function suppressWrite(treeClass) {
1853
1917
  * the bundle layer either way, so a restart always activates it.
1854
1918
  */
1855
1919
  async function hotMount(ctx, profileDir, packageName, deps = {}) {
1856
- const { fs = nodeFs, dir = join(profileDir, HOT_DIR), timeoutMs = Number(process.env.DSH_SHOP_HOT_MOUNT_TIMEOUT_MS) || 1e4, now = Date.now } = deps;
1920
+ const { fs = nodeHotFs, dir = join(profileDir, HOT_DIR), timeoutMs = Number(process.env.DSH_SHOP_HOT_MOUNT_TIMEOUT_MS) || 1e4, now = Date.now } = deps;
1857
1921
  const packageDir = join(profileDir, "node_modules", packageName);
1858
1922
  const dsh = readPkgDsh(fs, packageDir);
1859
1923
  const patchFile = resolve(packageDir, dsh?.patch ?? "cordis.patch.yml");
@@ -1973,6 +2037,88 @@ function cleanHotDir(profileDir) {
1973
2037
  for (const name of names) if (HOT_FILE_RE.test(name)) rmSync(join(dir, name), { force: true });
1974
2038
  }
1975
2039
  //#endregion
2040
+ //#region src/host/activation.ts
2041
+ /**
2042
+ * Decide what the reader must do.
2043
+ *
2044
+ * @param input.hostLive - whether the plugin's host half is in its intended
2045
+ * post-change state in this process right now: the hot-mount outcome for an
2046
+ * install or update, whether the fiber actually went away for an uninstall,
2047
+ * and true for a toggle (the user layer is hot-reloaded).
2048
+ * @param input.hasClientHalf - whether the package declares `dsh.client`. An
2049
+ * unreadable manifest reports `true`; see `client-half.ts` for why.
2050
+ * @param input.clientLive - whether THIS change's browser half is in the
2051
+ * graph the webserver hands a reloading tab.
2052
+ *
2053
+ * `clientLive` is the difference between the two routes a change can take,
2054
+ * and it is measured rather than assumed:
2055
+ *
2056
+ * - A change to the BOOT COMPOSITION — a toggle, an uninstall — moves an
2057
+ * entry the registry already enumerates, and the served graph follows it
2058
+ * within seconds (§2, measured 2026-09-11). `true`.
2059
+ * - A HOT MOUNT — an install or an update — adds to the live loader entries
2060
+ * without entering that composition, so the graph a tab reloads into does
2061
+ * not contain the package. Measured 2026-09-14 against dsh 0.1.5-rc.1 in
2062
+ * `web-full-flow.e2e.ts`: across a reload following a hot mount the graph
2063
+ * is byte-identical, same `rev`, while the package's host half is live the
2064
+ * whole time. `false` — and a `restart` is then the only honest answer,
2065
+ * because there is nothing a reload could fetch.
2066
+ *
2067
+ * The asymmetry that decides every unknown still runs the same way: offering
2068
+ * a step that was not needed costs the reader one action, withholding one
2069
+ * that was needed is the defect this module exists to fix. What changed on
2070
+ * 2026-09-14 is which step is the needed one after a hot mount.
2071
+ */
2072
+ function activationOf(input) {
2073
+ if (!input.hostLive) return "restart";
2074
+ if (!input.hasClientHalf) return "live";
2075
+ return input.clientLive ? "reload" : "restart";
2076
+ }
2077
+ //#endregion
2078
+ //#region src/host/client-half.ts
2079
+ /**
2080
+ * Does an installed package have a browser half? (design
2081
+ * 2026-09-11-activation-model §3.)
2082
+ *
2083
+ * The harness's `ClientModuleRegistry` scans the loader's entries for
2084
+ * packages declaring `dsh.client` and composes `window.__DSH_BOOT__` from
2085
+ * them. That declaration is therefore the whole question: a package that
2086
+ * declares it puts something in a browser tab, and a tab opened before the
2087
+ * change is showing the state from before it.
2088
+ *
2089
+ * The read goes through `HotFs`, the same injected seam `hot.ts` uses to
2090
+ * read the same file for `dsh.bundle.patch`, so tests never touch disk and
2091
+ * exactly one production call site does.
2092
+ */
2093
+ /**
2094
+ * Whether `packageName`, as installed in `profileDir`, declares `dsh.client`.
2095
+ *
2096
+ * **An unreadable manifest answers `true`.** Offering a reload that was not
2097
+ * needed costs the reader one keystroke; withholding one that was needed is
2098
+ * the defect this module exists to fix, so the fallback is the safe side of
2099
+ * a lopsided asymmetry rather than a guess.
2100
+ *
2101
+ * The VALUE of `dsh.client` is not inspected — an empty object is a
2102
+ * declaration. Only a non-object (the manifest saying something else
2103
+ * entirely) reads as no declaration.
2104
+ */
2105
+ function hasClientHalf(fs, profileDir, packageName) {
2106
+ let text;
2107
+ try {
2108
+ text = fs.read(join(profileDir, "node_modules", packageName, "package.json"));
2109
+ } catch {
2110
+ return true;
2111
+ }
2112
+ let manifest;
2113
+ try {
2114
+ manifest = JSON.parse(text);
2115
+ } catch {
2116
+ return true;
2117
+ }
2118
+ const client = manifest?.dsh?.client;
2119
+ return typeof client === "object" && client !== null && !Array.isArray(client);
2120
+ }
2121
+ //#endregion
1976
2122
  //#region src/host/restart.ts
1977
2123
  /** Restart executor: hand the port to a new dsh instance, two-phase.
1978
2124
  *
@@ -2358,6 +2504,312 @@ function collidingEntryId(options) {
2358
2504
  return null;
2359
2505
  }
2360
2506
  //#endregion
2507
+ //#region src/host/prefetch.ts
2508
+ /**
2509
+ * The download phase that runs in front of the per-profile install mutex.
2510
+ *
2511
+ * `dsh plugin add` is one opaque call: it resolves, fetches, links and writes
2512
+ * in a single pnpm invocation, so the shop cannot split it. What it can do is
2513
+ * warm pnpm's content store first, from outside the mutex, so the serialized
2514
+ * install is a store hit. Measured 2026-09-10: an npm or github spec warmed
2515
+ * this way installs with `downloaded 0`. Design doc §3.
2516
+ *
2517
+ * Best-effort by construction. Every failure here — pnpm absent, a non-zero
2518
+ * batch, a timeout, a store guessed wrong — leaves `dsh plugin add` to fetch
2519
+ * what the store lacks, exactly as it does today. Nothing about whether an
2520
+ * install SUCCEEDS may depend on this module.
2521
+ */
2522
+ /** How long one batch may run before it is killed. Far below
2523
+ * `INSTALL_TIMEOUT_MS`: a batch still running after this is no longer hiding
2524
+ * any latency, and the install behind it simply proceeds cold. */
2525
+ const BATCH_TIMEOUT_MS = Number(process.env.DSH_SHOP_PREFETCH_TIMEOUT_MS) || 9e4;
2526
+ /**
2527
+ * Whether warming the store for `spec` can save the install any work.
2528
+ *
2529
+ * A raw https tarball URL cannot, measured twice on 2026-09-10: pnpm
2530
+ * re-fetches such a URL on EVERY install even when the store already holds
2531
+ * that exact tarball — it must read the `package.json` inside it and that
2532
+ * read does not go through the store — and `pnpm store add` on a URL resolves
2533
+ * no dependency closure. So a prefetch of that form is one extra full
2534
+ * download for no measured saving. The three spec forms are indistinguishable
2535
+ * at the `store add` boundary (all exit 0, all print `+ <spec>`), which is why
2536
+ * this exclusion is recorded rather than left to look like an oversight.
2537
+ * Design doc §3.
2538
+ */
2539
+ function isPrefetchableSpec(spec) {
2540
+ return !/^https?:\/\//i.test(spec);
2541
+ }
2542
+ /**
2543
+ * `cmd.exe`'s exit code for a command line it could not resolve — the Windows
2544
+ * answer to the ENOENT node never sends there.
2545
+ *
2546
+ * On the one platform where this module passes `shell: true`, the child node
2547
+ * starts is `cmd.exe`, which EXISTS: the spawn succeeds, its "not recognized
2548
+ * as an internal or external command" complaint goes to a stderr this module
2549
+ * discards, and no `error` event ever arrives. The ENOENT latch below is
2550
+ * therefore dead on Windows by construction. Measured 2026-09-14 on this
2551
+ * Linux box, whose shell answers 127 instead of 9009 but is otherwise the same
2552
+ * shape: `spawn('definitely-not-here', argv, { shell: true })` emits only
2553
+ * `exit`, while the identical spawn without the shell emits `error: ENOENT`.
2554
+ * PR #45's `windows` job is the half that cannot run here — it is where the
2555
+ * missing latch showed up, as the case "reports pnpm absent to the install it
2556
+ * was serving, and refuses the next by name" sitting red for the full 10s of
2557
+ * its `vi.waitFor` because the line it filters for was never announced.
2558
+ *
2559
+ * **That arm did not turn the leg green, and this is why.** A temporary
2560
+ * diagnostic, gated on `shell` so only the Windows runner printed it, came
2561
+ * back with four exits, in test-file order:
2562
+ *
2563
+ * exit code=1 bin="…\dsh-prefetch-*\definitely-not-here"
2564
+ * exit code=9009 bin="pnpm"
2565
+ * exit code=1 bin="pnpm"
2566
+ * exit code=0 bin="pnpm"
2567
+ *
2568
+ * The first line is the failing case's own bin, and it is the whole finding:
2569
+ * that case passes a PATH, `cmd.exe` answers **1** for a path it cannot find,
2570
+ * and 1 is also what a command that RAN and failed answers. The two things
2571
+ * this latch must tell apart are the same number, so no value of this
2572
+ * constant could have made the arm fire — it was gated on a signal that case
2573
+ * does not produce. (The 9009 line is not evidence for the constant either.
2574
+ * It is the second shell-gated exit the suite emits, and the only real
2575
+ * `cmd.exe` child started before it is the one above: the cases that pass a
2576
+ * bare `pnpm` inject a scripted child that emits its own code — this file's
2577
+ * `scriptedSpawn` — so 9009 there is a fixture value echoed back, not a
2578
+ * measurement of the shell.)
2579
+ *
2580
+ * Absence is therefore asked BEFORE the spawn now, by {@link resolvesBin},
2581
+ * which is the primary mechanism. This arm stays as the one thing resolving
2582
+ * cannot cover: a name that IS found and still cannot be run. Its number is
2583
+ * `cmd.exe`'s long-standing answer for an unresolvable name and is unverified
2584
+ * by this repo's own runs — nothing reaches it without `platform === 'win32'`,
2585
+ * and nothing on the Windows runner has produced one. Should it be wrong the
2586
+ * behaviour is what Windows had before the arm existed: the latch does not set
2587
+ * and the generic exit line stands. A false positive is bounded by this
2588
+ * module's own contract — the worst a stray 9009 can do is refuse the rest of
2589
+ * the process's prefetches, a lost optimization and never a failed, slower or
2590
+ * altered install.
2591
+ */
2592
+ const SHELL_COMMAND_NOT_FOUND = 9009;
2593
+ /** What a bare name may be resolved AS on Windows, on top of the name itself.
2594
+ *
2595
+ * `cmd.exe` resolves a bare command through `PATHEXT`, and these are the two
2596
+ * shapes an executor actually arrives in: `.cmd` is what npm's pnpm shim is,
2597
+ * which is the reason this module passes `shell: true` there at all, and
2598
+ * `.exe` is what a native install (a packed binary, a Node SEA) puts on PATH.
2599
+ * The bare name is tried as well, which makes this a superset of what the
2600
+ * shell resolves. That asymmetry is deliberate, because the two mistakes do
2601
+ * not cost the same: a false PRESENT spends one doomed child, which
2602
+ * {@link SHELL_COMMAND_NOT_FOUND} or `error` then latches a moment later,
2603
+ * while a false ABSENT both disables the optimization for the rest of the
2604
+ * process and tells the user pnpm is missing from a machine that has it. */
2605
+ const WIN32_SUFFIXES = [
2606
+ "",
2607
+ ".cmd",
2608
+ ".exe"
2609
+ ];
2610
+ /**
2611
+ * Every plausible reading of a PATH string, as the directories it could hold.
2612
+ *
2613
+ * Three readings, because the two ends of that string disagree about who
2614
+ * decides the separator. `platform` says what the SHELL is — this module takes
2615
+ * it as an argument, and the tests drive the win32 branch from a Linux runner
2616
+ * with it — while the string itself was produced by the HOST that built the
2617
+ * batch's environment. So whichever separator `platform` names, the string may
2618
+ * have been joined with the other one, and BOTH mismatches are reachable; only
2619
+ * one of them was guarded.
2620
+ *
2621
+ * The one that bit: a single-entry PATH whose entry is a Windows absolute path
2622
+ * — `C:\Users\…\dsh-prefetch-X`, which is exactly what a test's temp directory
2623
+ * is on the Windows runner — read with `platform: 'linux'` splits on ':' into
2624
+ * `C` and `\Users\…`. Neither is the directory, so the pnpm that was right
2625
+ * there was reported absent, and the latch that follows means it stays absent
2626
+ * for the rest of the process. The string's own reading comes first for that
2627
+ * shape: it IS one directory, and that is the most direct true answer there
2628
+ * is. (A real PATH yields its real entries under one of the two splits; this
2629
+ * reading costs a stat on a string that is not a directory, which is what a
2630
+ * multi-entry PATH always is.)
2631
+ *
2632
+ * Trying all three costs an `existsSync` or two on a string none of them
2633
+ * resolves, and it is the safe direction to err in — see `WIN32_SUFFIXES`: a
2634
+ * false PRESENT spends one doomed child, while a false ABSENT both disables
2635
+ * the optimization for the rest of the process and tells the user pnpm is
2636
+ * missing from a machine that has it. The price is that a directory named
2637
+ * exactly like a PATH string — legal, if perverse — is read as the entry it
2638
+ * spells, and that lands on the cheap side of the same asymmetry.
2639
+ *
2640
+ * Order decides only how much work a MISS costs: a hit in any reading is
2641
+ * present, so the list is a set and not a preference.
2642
+ */
2643
+ const pathReadings = (path) => {
2644
+ const raw = path ?? "";
2645
+ return [
2646
+ raw,
2647
+ ...raw.split(posix.delimiter),
2648
+ ...raw.split(win32.delimiter)
2649
+ ];
2650
+ };
2651
+ /**
2652
+ * Whether `bin` names something a batch could actually start — asked of the
2653
+ * filesystem before the spawn, because on Windows the failure cannot answer it
2654
+ * (see {@link SHELL_COMMAND_NOT_FOUND}: a path that is not there and a command
2655
+ * that ran and failed both exit 1).
2656
+ *
2657
+ * Pure path arithmetic over `existsSync`, which is what makes it testable on
2658
+ * the Linux runners — the exit-code route was not, and that is why the Windows
2659
+ * leg was the only place the defect could appear. No process is started to ask
2660
+ * the question, so a bin that is not there is never spawned.
2661
+ *
2662
+ * `path` is the batch's OWN PATH, not the shop's: an install may carry a
2663
+ * narrowed environment, the batch inherits exactly that, and the name has to
2664
+ * resolve where the child will look for it. An absent `PATH` falls back to the
2665
+ * process's, the same way the child's environment would. The string is read
2666
+ * into entries by {@link pathReadings}, which tries every separator rather
2667
+ * than betting on the one `platform` names.
2668
+ *
2669
+ * An empty PATH entry is skipped, as `dsh-cli.ts` skips it: it means "the
2670
+ * current directory", which is not where a bare `pnpm` lives, and a relative
2671
+ * `existsSync` here would be answering about the shop's cwd instead.
2672
+ *
2673
+ * A RELATIVE bin is looked up against the process's own cwd rather than the
2674
+ * batch's, which is the one thing this questions less precisely than the OS
2675
+ * will. No caller produces one today — production passes the bare name, and
2676
+ * the fixtures pass absolute paths — and the backstop covers it if one ever
2677
+ * does, since the child then simply fails where this said it would start.
2678
+ */
2679
+ const resolvesBin = (bin, platform, path) => {
2680
+ if (bin.includes("/") || bin.includes("\\")) return existsSync(bin);
2681
+ const suffixes = platform === "win32" ? WIN32_SUFFIXES : [""];
2682
+ for (const dir of pathReadings(path)) {
2683
+ if (dir === "") continue;
2684
+ for (const suffix of suffixes) if (existsSync(join(dir, bin + suffix))) return true;
2685
+ }
2686
+ return false;
2687
+ };
2688
+ function createPrefetcher(options = {}) {
2689
+ const { pnpmBin = "pnpm", spawn: spawn$1 = spawn, platform = process.platform, execPath = process.execPath, timeoutMs = BATCH_TIMEOUT_MS, kills } = options;
2690
+ const lanes = /* @__PURE__ */ new Map();
2691
+ let pnpmAbsent = false;
2692
+ const lane = (profile, cwd, env) => {
2693
+ const existing = lanes.get(profile);
2694
+ if (existing !== void 0) {
2695
+ existing.cwd = cwd;
2696
+ existing.env = env;
2697
+ return existing;
2698
+ }
2699
+ const created = {
2700
+ pending: /* @__PURE__ */ new Map(),
2701
+ inFlight: /* @__PURE__ */ new Map(),
2702
+ child: null,
2703
+ timer: null,
2704
+ cwd,
2705
+ env
2706
+ };
2707
+ lanes.set(profile, created);
2708
+ return created;
2709
+ };
2710
+ /** Tell every install the running batch served. */
2711
+ const announce = (current, line) => {
2712
+ for (const log of current.inFlight.values()) log(line);
2713
+ };
2714
+ /** pnpm is not on PATH: stop prefetching for the rest of this process, and
2715
+ * tell the installs the running batch served why.
2716
+ *
2717
+ * Three ways absence arrives land here — {@link resolvesBin}, which is the
2718
+ * primary one and the only one that asks before spawning; node's ENOENT for
2719
+ * a binary it could not start; and cmd.exe's {@link SHELL_COMMAND_NOT_FOUND}
2720
+ * for a name it could not resolve — so the latch and the line a user reads
2721
+ * cannot drift apart between them. */
2722
+ const latchAbsent = (current) => {
2723
+ pnpmAbsent = true;
2724
+ announce(current, "dsh-plugin-shop: no download phase — pnpm not found on PATH");
2725
+ };
2726
+ const finish = (profile, current) => {
2727
+ if (current.timer !== null) clearTimeout(current.timer);
2728
+ current.timer = null;
2729
+ current.child = null;
2730
+ current.inFlight.clear();
2731
+ if (current.pending.size > 0 && !pnpmAbsent) start(profile, current);
2732
+ };
2733
+ const start = (profile, current) => {
2734
+ const specs = [...current.pending.keys()];
2735
+ for (const [spec, log] of current.pending) current.inFlight.set(spec, log);
2736
+ current.pending.clear();
2737
+ if (!resolvesBin(pnpmBin, platform, current.env?.PATH ?? process.env.PATH)) {
2738
+ latchAbsent(current);
2739
+ finish(profile, current);
2740
+ return;
2741
+ }
2742
+ const argv = [
2743
+ "store",
2744
+ "add",
2745
+ ...specs
2746
+ ];
2747
+ const routed = jsEntryCommand(pnpmBin, argv, execPath);
2748
+ const shell = routed === null && platform === "win32";
2749
+ const args = shell ? argv.map((arg) => shellSafeTarget(arg, platform)) : routed?.args ?? argv;
2750
+ let child;
2751
+ try {
2752
+ child = spawn$1(routed?.command ?? pnpmBin, args, {
2753
+ cwd: current.cwd,
2754
+ env: current.env,
2755
+ stdio: "ignore",
2756
+ detached: platform !== "win32",
2757
+ shell
2758
+ });
2759
+ } catch (error) {
2760
+ announce(current, `dsh-plugin-shop: the download phase could not start — ${error.message}`);
2761
+ finish(profile, current);
2762
+ return;
2763
+ }
2764
+ current.child = child;
2765
+ const isCurrent = () => current.child === child;
2766
+ child.on("error", (error) => {
2767
+ if (!isCurrent()) return;
2768
+ if (error.code === "ENOENT") latchAbsent(current);
2769
+ else announce(current, `dsh-plugin-shop: the download phase could not start — ${error.message}`);
2770
+ finish(profile, current);
2771
+ });
2772
+ child.on("exit", (code) => {
2773
+ if (!isCurrent()) return;
2774
+ if (shell && code === SHELL_COMMAND_NOT_FOUND) latchAbsent(current);
2775
+ else if (code === 0) announce(current, "dsh-plugin-shop: packages fetched ahead of the install");
2776
+ else if (code !== null) announce(current, `dsh-plugin-shop: the download phase exit ${code}; the install will fetch what is missing`);
2777
+ finish(profile, current);
2778
+ });
2779
+ current.timer = setTimeout(() => {
2780
+ if (!isCurrent()) return;
2781
+ announce(current, "dsh-plugin-shop: the download phase exceeded its bound; the install will fetch what is missing");
2782
+ killTree(child.pid, platform, kills);
2783
+ finish(profile, current);
2784
+ }, timeoutMs);
2785
+ };
2786
+ return {
2787
+ request: ({ profile, spec, cwd, env, log = () => {} }) => {
2788
+ if (!isPrefetchableSpec(spec)) return {
2789
+ started: false,
2790
+ reason: "unsupported-spec"
2791
+ };
2792
+ if (pnpmAbsent) return {
2793
+ started: false,
2794
+ reason: "no-pnpm"
2795
+ };
2796
+ const current = lane(profile, cwd, env);
2797
+ current.pending.set(spec, log);
2798
+ if (current.child === null) queueMicrotask(() => {
2799
+ if (current.child === null && current.pending.size > 0 && !pnpmAbsent) start(profile, current);
2800
+ });
2801
+ return { started: true };
2802
+ },
2803
+ release: (profile, spec) => {
2804
+ const current = lanes.get(profile);
2805
+ if (current === void 0) return;
2806
+ current.pending.delete(spec);
2807
+ current.inFlight.delete(spec);
2808
+ if (current.child !== null && current.inFlight.size === 0 && current.pending.size === 0) killTree(current.child.pid, platform, kills);
2809
+ }
2810
+ };
2811
+ }
2812
+ //#endregion
2361
2813
  //#region src/host/peers.ts
2362
2814
  /** Harness compatibility: which declared peers the running installation does
2363
2815
  * not provide (design 2026-09-01-harness-compatibility), and — for the
@@ -2450,9 +2902,11 @@ function nodeVersionResolver(baseUrl) {
2450
2902
  *
2451
2903
  * `includePrerelease` is load-bearing, not a convenience. The harness ships
2452
2904
  * nothing but `-rc` versions, so under strict semver `^0.1.1-rc.2` excludes
2453
- * `0.1.2-rc.1` the version that is installed and works and every future
2454
- * rc bump would raise a false alarm. With it on, the range still excludes an
2455
- * older prerelease (`0.1.1-rc.1`) and a minor- or major-line move
2905
+ * every later rc on the same 0.1 line including whichever one is installed
2906
+ * and working, named as a property because that version moves and a comment
2907
+ * naming it goes stale in place — and every future rc bump would raise a
2908
+ * false alarm. With it on, the range still excludes an older prerelease
2909
+ * (`0.1.1-rc.1`) and a minor- or major-line move
2456
2910
  * (`0.2.0-rc.1`, `1.0.0`), which are the moves that actually break a plugin
2457
2911
  * path. Discrimination on both sides is the whole point: one false warning
2458
2912
  * teaches a reader to ignore every warning.
@@ -2557,6 +3011,20 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
2557
3011
  if (target) Object.defineProperty(target, contextIn.name, descriptor);
2558
3012
  done = true;
2559
3013
  };
3014
+ /** Each blocked reason's author-readable refusal, written once.
3015
+ *
3016
+ * `restart()` returns these as its `detail` and `version()` returns the bare
3017
+ * reason for the client to localize, so the two can no longer disagree about
3018
+ * WHY a restart is impossible. They did: `version()` reported a boolean that
3019
+ * covered two of the refusals, the client had one string for that boolean, and
3020
+ * that string named systemd — so a Windows user was told to restart a systemd
3021
+ * service and to set an override that the platform check, being the first gate
3022
+ * of the three, could never reach. */
3023
+ const RESTART_BLOCKED_DETAIL = {
3024
+ windows: "dsh-plugin-shop: restart is not supported on Windows yet; restart dsh manually to apply the change",
3025
+ systemd: "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.",
3026
+ "port-zero": "dsh-plugin-shop: restart is not supported when dsh was launched with --port 0; restart dsh manually"
3027
+ };
2560
3028
  /** How many bytes a release tarball may be at the integrity check. The
2561
3029
  * registry already refuses to publish a tarball over 32 MiB, so 64 MiB is
2562
3030
  * headroom, not a gate of its own. */
@@ -2767,6 +3235,7 @@ let ShopGateway = (() => {
2767
3235
  profileDir;
2768
3236
  inventory;
2769
3237
  hot;
3238
+ hotFs;
2770
3239
  loaderEntriesInjected;
2771
3240
  dshBin;
2772
3241
  /** The argv `shop/restart` re-spawns: the real process argv minus node and
@@ -2792,10 +3261,13 @@ let ShopGateway = (() => {
2792
3261
  /** The release-tarball fetch for the install-time integrity check; global
2793
3262
  * fetch in production, a fixture response in tests. */
2794
3263
  fetchTarball;
3264
+ /** One pump for the whole gateway: batching is per profile and lives inside
3265
+ * it, so a second instance would race the first for the same store. */
3266
+ prefetcher;
2795
3267
  /** The install gate runs against the last loaded snapshot, never a fresh
2796
3268
  * fetch per request (§7.2: the Host's cached snapshot is the truth). */
2797
3269
  /** Finished install records retained, so a poll sees the true terminal
2798
- * state (§8: done / needsRestart / failure detail). Oldest evicted on add. */
3270
+ * state (§8: done / activation / failure detail). Oldest evicted on add. */
2799
3271
  static MAX_FINISHED_INSTALLS = 32;
2800
3272
  /** How long the gateway waits after a successful restart response before
2801
3273
  * exiting the old process — the browser must receive the URL first. */
@@ -2832,6 +3304,7 @@ let ShopGateway = (() => {
2832
3304
  this.profileDir = options.profileDir;
2833
3305
  this.inventory = options.inventory;
2834
3306
  this.hot = options.hot;
3307
+ this.hotFs = options.hotFs;
2835
3308
  this.loaderEntriesInjected = options.loaderEntries;
2836
3309
  this.dshBin = options.dshBin ?? "dsh";
2837
3310
  this.restartArgv = options.restartArgv ?? process.argv.slice(2);
@@ -2853,6 +3326,7 @@ let ShopGateway = (() => {
2853
3326
  this.platform = options.platform ?? process.platform;
2854
3327
  this.ppid = options.ppid ?? process.ppid;
2855
3328
  this.fetchTarball = options.fetchTarball ?? ((url) => fetch(url));
3329
+ this.prefetcher = options.prefetcher ?? createPrefetcher();
2856
3330
  try {
2857
3331
  cleanHotDir(this.profileDirResolved());
2858
3332
  } catch {}
@@ -2976,24 +3450,53 @@ let ShopGateway = (() => {
2976
3450
  return [];
2977
3451
  }
2978
3452
  }
2979
- async liveDisableIds(ids) {
2980
- if (ids.length === 0) return false;
3453
+ /** Whether an installed package declares `dsh.client`. Reads through the
3454
+ * `hotFs` option — `HotFs` is `hot.ts`'s type, but this gateway forwards
3455
+ * the option only HERE, never into `hotMount`, which takes its own `fs`
3456
+ * from `HotDeps`. A fixture therefore drives this read alone, which is the
3457
+ * point: it is the only way to state what a package declared BEFORE an
3458
+ * update overwrote its manifest. */
3459
+ packageHasClientHalf(packageName) {
3460
+ return hasClientHalf(this.hotFs ?? nodeHotFs, this.profileDirResolved(), packageName);
3461
+ }
3462
+ /**
3463
+ * Bring every live entry the package owns down, best effort, and report
3464
+ * whether its host half is DOWN when this returns.
3465
+ *
3466
+ * "Nothing matched" is down: a package with no live entry is not running,
3467
+ * which is the ordinary case for removing a plugin that never loaded this
3468
+ * session. Only a matched entry whose fiber outlives the retries — or
3469
+ * whose `update` throws — leaves the plugin UP, and that is the one case
3470
+ * an uninstall must not describe as stopped.
3471
+ *
3472
+ * The old spelling answered "did any update succeed", which is a different
3473
+ * question: `update` resolving says the row was accepted, not that the
3474
+ * instance went away. The retry loop below exists precisely because those
3475
+ * two come apart, so reading the first as the second threw away the answer
3476
+ * the loop was computing.
3477
+ */
3478
+ async liveEntriesDown(ids) {
3479
+ if (ids.length === 0) return true;
2981
3480
  const owned = new Set(ids);
2982
- let found = false;
3481
+ let allDown = true;
2983
3482
  for (const entry of this.loaderEntries()) {
2984
3483
  if (entry.id === void 0 || !ownsEntryId(owned, entry.id)) continue;
3484
+ let down = false;
2985
3485
  for (let attempt = 0; attempt < 3; attempt++) {
2986
3486
  try {
2987
3487
  await entry.update({ disabled: true }, false, true);
2988
- found = true;
2989
3488
  } catch {
2990
3489
  break;
2991
3490
  }
2992
- if (entry.fiber === void 0) break;
3491
+ if (entry.fiber === void 0) {
3492
+ down = true;
3493
+ break;
3494
+ }
2993
3495
  await new Promise((resolve) => setTimeout(resolve, 200));
2994
3496
  }
3497
+ if (!down) allDown = false;
2995
3498
  }
2996
- return found;
3499
+ return allDown;
2997
3500
  }
2998
3501
  /** Enable or disable one installed plugin, hot (§8): a disable writes the
2999
3502
  * row to the user layer, an enable drops it again so the bundle default
@@ -3039,7 +3542,14 @@ let ShopGateway = (() => {
3039
3542
  disabled: !args.enabled
3040
3543
  }))
3041
3544
  });
3042
- return { ok: true };
3545
+ return {
3546
+ ok: true,
3547
+ activation: activationOf({
3548
+ hostLive: true,
3549
+ clientLive: true,
3550
+ hasClientHalf: this.packageHasClientHalf(args.name)
3551
+ })
3552
+ };
3043
3553
  }
3044
3554
  rowConfig() {
3045
3555
  if (this.options.catalogUrl !== void 0 && this.options.cacheDir !== void 0) return {
@@ -3116,6 +3626,32 @@ let ShopGateway = (() => {
3116
3626
  restartPlatformSupported() {
3117
3627
  return this.platform !== "win32";
3118
3628
  }
3629
+ /** Why a restart would be refused for this process, or null when nothing
3630
+ * static does. One ordered list, read by `restart()` before it commits and
3631
+ * by `version()` so the client can say the same thing up front.
3632
+ *
3633
+ * The order is the order the refusals were written in and is load-bearing
3634
+ * for the copy a reader sees: Windows first, because the platform check has
3635
+ * no override and reporting the systemd one there sends a Windows user to
3636
+ * set `allowRestart: true`, which this gate would still refuse.
3637
+ *
3638
+ * - `windows`: the handoff helper is a POSIX shell one-liner (restart.ts)
3639
+ * and there is no `sh` on Windows. That spawn fails ASYNCHRONOUSLY, so
3640
+ * committing would answer `ok: true`, exit this process, and leave nothing
3641
+ * to take the port — dsh would simply be gone.
3642
+ * - `systemd`: under a unit the two-phase handoff kills itself, because the
3643
+ * main process exiting also kills the unit's cgroup and takes the detached
3644
+ * helper with it; the service never comes back. Overridable, and the only
3645
+ * one of the three that is.
3646
+ * - `port-zero`: the OS hands the NEW process a fresh port the browser
3647
+ * cannot know, so a restart would strand the client on a dead origin. */
3648
+ staticRestartBlock() {
3649
+ if (!this.restartPlatformSupported()) return "windows";
3650
+ if (detectSupervisor(this.env, { ppid: this.ppid }) === "systemd" && !this.allowRestartConfigured()) return "systemd";
3651
+ const portIndex = this.restartArgv.indexOf("--port");
3652
+ if (portIndex !== -1 && this.restartArgv[portIndex + 1] === "0") return "port-zero";
3653
+ return null;
3654
+ }
3119
3655
  allowRestartConfigured() {
3120
3656
  if (this.allowRestart !== void 0) return this.allowRestart;
3121
3657
  return ((this.ctx.loader?.entries().find((entry) => entry.options.name === "dsh-plugin-shop"))?.options.config)?.allowRestart === true;
@@ -3182,11 +3718,13 @@ let ShopGateway = (() => {
3182
3718
  } else spec = `${args.name}@${args.version}`;
3183
3719
  const isUpdate = installedSpec !== void 0;
3184
3720
  const priorEntryIds = isUpdate ? this.ownedEntryIdsOrNone(args.name) : [];
3721
+ const priorClientHalf = isUpdate && this.packageHasClientHalf(args.name);
3185
3722
  const running = startInstall({
3186
3723
  profile: this.profile,
3187
3724
  spec,
3188
3725
  dshBin: this.dshBin,
3189
3726
  expectedName: args.name,
3727
+ prefetcher: this.prefetcher,
3190
3728
  alsoConfirm: () => {
3191
3729
  const clash = collidingEntryId({
3192
3730
  profileDir: this.profileDirResolved(),
@@ -3201,12 +3739,21 @@ let ShopGateway = (() => {
3201
3739
  mount: hotMount,
3202
3740
  unmount: hotUnmount
3203
3741
  };
3204
- if (isUpdate) await this.liveDisableIds(priorEntryIds);
3742
+ if (isUpdate) await this.liveEntriesDown(priorEntryIds);
3205
3743
  const result = await hot.mount({ plugin: (plugin, config) => this.ctx.plugin(plugin, config) }, this.profileDirResolved(), args.name);
3206
- return result.ok ? { needsRestart: false } : {
3207
- needsRestart: true,
3208
- restartReason: result.reason ?? void 0
3744
+ if (!result.ok) return {
3745
+ activation: "restart",
3746
+ ...result.reason !== null ? { restartReason: result.reason } : {}
3209
3747
  };
3748
+ const activation = activationOf({
3749
+ hostLive: true,
3750
+ clientLive: false,
3751
+ hasClientHalf: priorClientHalf || this.packageHasClientHalf(args.name)
3752
+ });
3753
+ return activation === "restart" ? {
3754
+ activation,
3755
+ restartReason: "client-half"
3756
+ } : { activation };
3210
3757
  }
3211
3758
  });
3212
3759
  if (entry.source === "github") {
@@ -3221,24 +3768,27 @@ let ShopGateway = (() => {
3221
3768
  this.evictFinishedInstalls();
3222
3769
  return {
3223
3770
  ok: true,
3224
- installId: running.installId
3771
+ installId: running.installId,
3772
+ state: running.status().state
3225
3773
  };
3226
3774
  }
3227
3775
  /** Bound retained finished records at MAX_FINISHED_INSTALLS, evicting the
3228
- * oldest finished ones (insertion order, oldest first). Running records
3229
- * are never evicted; an id absent from the map reports `found: false`. */
3776
+ * oldest finished ones (insertion order, oldest first). Live records
3777
+ * running AND queued — are never evicted; an id absent from the map reports
3778
+ * `found: false`. */
3230
3779
  evictFinishedInstalls() {
3231
3780
  const finishedIds = [];
3232
3781
  for (const id of this.installOrder) {
3233
3782
  const record = this.installs.get(id);
3234
- if (record !== void 0 && record.status().state !== "running") finishedIds.push(id);
3783
+ if (record !== void 0 && isTerminalInstallState(record.status().state)) finishedIds.push(id);
3235
3784
  }
3236
3785
  const excess = Math.max(0, finishedIds.length - ShopGateway.MAX_FINISHED_INSTALLS);
3237
3786
  for (const id of finishedIds.slice(0, excess)) this.installs.delete(id);
3238
3787
  }
3239
- /** Whether any command this gateway started is still running. */
3788
+ /** Whether any command this gateway started is still running — or still
3789
+ * waiting its turn. */
3240
3790
  hasRunningCommand() {
3241
- for (const record of this.installs.values()) if (record.status().state === "running") return true;
3791
+ for (const record of this.installs.values()) if (!isTerminalInstallState(record.status().state)) return true;
3242
3792
  return false;
3243
3793
  }
3244
3794
  /** Poll one install's progress (§7.2); unknown ids report `found: false`. */
@@ -3371,17 +3921,21 @@ let ShopGateway = (() => {
3371
3921
  };
3372
3922
  const installedEntry = named.find((entry) => installedSpecMatches(entry, spec));
3373
3923
  const priorEntryIds = this.ownedEntryIdsOrNone(args.name);
3924
+ const hadClientHalf = this.packageHasClientHalf(args.name);
3374
3925
  const running = startUninstall({
3375
3926
  profile: this.profile,
3376
3927
  name: args.name,
3377
3928
  dshBin: this.dshBin,
3378
3929
  expectedName: args.name,
3379
3930
  afterDone: async () => {
3380
- await (this.hot ?? {
3381
- mount: hotMount,
3382
- unmount: hotUnmount
3383
- }).unmount(args.name) || await this.liveDisableIds(priorEntryIds);
3384
- return { needsRestart: false };
3931
+ return { activation: activationOf({
3932
+ hostLive: await (this.hot ?? {
3933
+ mount: hotMount,
3934
+ unmount: hotUnmount
3935
+ }).unmount(args.name) || await this.liveEntriesDown(priorEntryIds),
3936
+ clientLive: true,
3937
+ hasClientHalf: hadClientHalf
3938
+ }) };
3385
3939
  }
3386
3940
  });
3387
3941
  const pins = readRepoPins(this.pinFs, this.pinsPath());
@@ -3411,18 +3965,10 @@ let ShopGateway = (() => {
3411
3965
  ok: false,
3412
3966
  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."
3413
3967
  };
3414
- if (!this.restartPlatformSupported()) return {
3415
- ok: false,
3416
- detail: "dsh-plugin-shop: restart is not supported on Windows yet; restart dsh manually to apply the change"
3417
- };
3418
- if (detectSupervisor(this.env, { ppid: this.ppid }) === "systemd" && !this.allowRestartConfigured()) return {
3968
+ const blocked = this.staticRestartBlock();
3969
+ if (blocked !== null) return {
3419
3970
  ok: false,
3420
- 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."
3421
- };
3422
- const portIndex = this.restartArgv.indexOf("--port");
3423
- if (portIndex !== -1 && this.restartArgv[portIndex + 1] === "0") return {
3424
- ok: false,
3425
- detail: "dsh-plugin-shop: restart is not supported when dsh was launched with --port 0; restart dsh manually"
3971
+ detail: RESTART_BLOCKED_DETAIL[blocked]
3426
3972
  };
3427
3973
  try {
3428
3974
  const { cacheDir } = this.rowConfig();
@@ -3460,7 +4006,7 @@ let ShopGateway = (() => {
3460
4006
  installed,
3461
4007
  latest,
3462
4008
  outdated: latest !== null && lt(installed, latest),
3463
- restartSupported: this.restartPlatformSupported() && (detectSupervisor(this.env, { ppid: this.ppid }) === null || this.allowRestartConfigured())
4009
+ restartBlocked: this.staticRestartBlock()
3464
4010
  };
3465
4011
  }
3466
4012
  /** Update the shop itself to a published version (§7.3): the explicit pin
@@ -3476,14 +4022,16 @@ let ShopGateway = (() => {
3476
4022
  profile: this.profile,
3477
4023
  spec: `dsh-plugin-shop@${args.version}`,
3478
4024
  dshBin: this.dshBin,
3479
- expectedName: "dsh-plugin-shop"
4025
+ expectedName: "dsh-plugin-shop",
4026
+ prefetcher: this.prefetcher
3480
4027
  });
3481
4028
  this.installs.set(running.installId, running);
3482
4029
  this.installOrder.push(running.installId);
3483
4030
  this.evictFinishedInstalls();
3484
4031
  return {
3485
4032
  ok: true,
3486
- installId: running.installId
4033
+ installId: running.installId,
4034
+ state: running.status().state
3487
4035
  };
3488
4036
  }
3489
4037
  };