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/README.md +6 -5
- package/lib/client.js +455 -197
- package/lib/index.js +712 -164
- package/lib/typert.host.js +38 -25
- package/lib/typert.remote-client.js +23 -18
- package/lib/types/client/ShopTab.d.ts +4 -0
- package/lib/types/client/locales.d.ts +20 -2
- package/lib/types/client/present.d.ts +60 -9
- package/lib/types/client/useFlows.d.ts +66 -0
- package/lib/types/client/useInstall.d.ts +17 -27
- package/lib/types/client/useUninstall.d.ts +24 -17
- package/lib/types/client/useUpdateSelf.d.ts +1 -1
- package/lib/types/host/activation.d.ts +55 -0
- package/lib/types/host/client-half.d.ts +28 -0
- package/lib/types/host/dsh-cli.d.ts +11 -0
- package/lib/types/host/executor.d.ts +18 -5
- package/lib/types/host/hot.d.ts +16 -5
- package/lib/types/host/index.d.ts +111 -16
- package/lib/types/host/peers.d.ts +5 -3
- package/lib/types/host/prefetch.d.ts +67 -0
- package/lib/types/shared/install-state.d.ts +16 -0
- package/package.json +1 -1
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$
|
|
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$
|
|
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
|
-
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
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
|
-
|
|
1515
|
-
|
|
1516
|
-
script: dshScript()
|
|
1581
|
+
env: env ?? process.env,
|
|
1582
|
+
detached: platform !== "win32"
|
|
1517
1583
|
});
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
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 =
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
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
|
|
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 =
|
|
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
|
-
*
|
|
2454
|
-
*
|
|
2455
|
-
*
|
|
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 /
|
|
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
|
-
|
|
2980
|
-
|
|
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
|
|
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)
|
|
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
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
3207
|
-
|
|
3208
|
-
restartReason: result.reason
|
|
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).
|
|
3229
|
-
* are never evicted; an id absent from the map reports
|
|
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
|
|
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
|
|
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
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
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
|
-
|
|
3415
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
};
|