dsh-plugin-shop 0.8.4-beta.0 → 0.8.4
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 +3 -3
- package/lib/client.js +464 -337
- package/lib/index.js +918 -94
- package/lib/typert.host.js +10 -10
- package/lib/typert.remote-client.js +10 -10
- package/lib/types/client/locales.d.ts +2 -0
- package/lib/types/client/present.d.ts +1 -0
- package/lib/types/client/restart-monitor.d.ts +62 -6
- package/lib/types/host/executor.d.ts +50 -3
- package/lib/types/host/index.d.ts +82 -21
- package/lib/types/host/manager-runner.d.ts +51 -0
- package/lib/types/host/plugin-manager.d.ts +100 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1509,6 +1509,35 @@ function chain(profile, task) {
|
|
|
1509
1509
|
profileQueues.set(profile, next.catch(() => {}));
|
|
1510
1510
|
return next;
|
|
1511
1511
|
}
|
|
1512
|
+
/** Run `task` in `profile`'s queue, after every command already in it: the
|
|
1513
|
+
* queue `spawnPluginCli` uses, shared with the plugin manager's runner so the
|
|
1514
|
+
* two paths are never concurrent in one profile. `ahead` is how many commands
|
|
1515
|
+
* were queued or running when the slot was taken. */
|
|
1516
|
+
function inProfileQueue(profile, task) {
|
|
1517
|
+
return {
|
|
1518
|
+
ahead: enterQueue(profile),
|
|
1519
|
+
finished: chain(profile, task).finally(() => {
|
|
1520
|
+
leaveQueue(profile);
|
|
1521
|
+
})
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
/** An install record's log: the newest lines within MAX_LOG_LINES and
|
|
1525
|
+
* MAX_LOG_BYTES, never dropping the newest one. */
|
|
1526
|
+
function createBoundedLog() {
|
|
1527
|
+
const lines = [];
|
|
1528
|
+
let bytes = 0;
|
|
1529
|
+
return {
|
|
1530
|
+
push(line) {
|
|
1531
|
+
lines.push(line);
|
|
1532
|
+
bytes += Buffer.byteLength(line);
|
|
1533
|
+
while ((lines.length > MAX_LOG_LINES || bytes > MAX_LOG_BYTES) && lines.length > 1) {
|
|
1534
|
+
const oldest = lines.shift();
|
|
1535
|
+
if (oldest !== void 0) bytes -= Buffer.byteLength(oldest);
|
|
1536
|
+
}
|
|
1537
|
+
},
|
|
1538
|
+
lines: () => [...lines]
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1512
1541
|
/**
|
|
1513
1542
|
* Why a zero-exit install left `dsh.profile.bundles` without the entry.
|
|
1514
1543
|
*
|
|
@@ -1617,7 +1646,8 @@ const FAILURE_LOG_NOISE = [
|
|
|
1617
1646
|
];
|
|
1618
1647
|
/** The line dsh 0.1.7 opens a refused install with (dsh-plugin-manager's
|
|
1619
1648
|
* `rejected`), and the exemption command its CLI prints after it, one per
|
|
1620
|
-
* refused package (`runPlugin`).
|
|
1649
|
+
* refused package (`runPlugin`). The opener is exported for the plugin
|
|
1650
|
+
* manager's answer, whose output carries the same block. */
|
|
1621
1651
|
const REFUSAL_OPENER = "dsh: installation rejected: ";
|
|
1622
1652
|
const REFUSAL_COMMAND = /^dsh: to accept the risk, run: dsh plugin --profile .+ allow-version (.+)@([^@\s]+) --dsh-version (\S+) --accept-risk$/;
|
|
1623
1653
|
const REFUSAL_FAILED = "dsh: plugin command failed";
|
|
@@ -1716,8 +1746,7 @@ function nativeAllocationAbortDetail(usable) {
|
|
|
1716
1746
|
* Scanning from the end: a pnpm error code first, then any thrown error, then
|
|
1717
1747
|
* the last line that is not noise.
|
|
1718
1748
|
*/
|
|
1719
|
-
function installFailureDetail(profile, log) {
|
|
1720
|
-
const hint = `pnpm failed in the profile. Run: dsh plugin --profile ${profile} install`;
|
|
1749
|
+
function installFailureDetail(profile, log, hint = `pnpm failed in the profile. Run: dsh plugin --profile ${profile} install`) {
|
|
1721
1750
|
const usable = log.map((line) => line.replace(/\r+$/, "")).filter((line) => !FAILURE_LOG_NOISE.some((rx) => rx.test(line)));
|
|
1722
1751
|
const refusal = refusalDetail(profile, usable);
|
|
1723
1752
|
if (refusal !== null) return refusal;
|
|
@@ -1825,6 +1854,23 @@ function dshScript() {
|
|
|
1825
1854
|
* the spawned command line is the one we add ourselves, after this gate. */
|
|
1826
1855
|
const UNSAFE_TARGET = /[\s"'`|<>^$();\\{}]|[\u0000-\u001f\u007f]/;
|
|
1827
1856
|
/**
|
|
1857
|
+
* The operand gate, one function for every caller: it refuses, by throwing,
|
|
1858
|
+
* an operand the dsh CLI would parse as a flag, and one carrying the shell
|
|
1859
|
+
* punctuation `UNSAFE_TARGET` names. A legitimate operand (a catalog name for
|
|
1860
|
+
* a removal; a `name@version`, `github:` or tarball spec for an install)
|
|
1861
|
+
* never begins with `-`, so the first refusal cannot reject a real install or
|
|
1862
|
+
* uninstall, and failing loudly beats letting the CLI reinterpret an operand
|
|
1863
|
+
* as an option. `spawnPluginCli` runs it before it spawns anything or takes a
|
|
1864
|
+
* queue slot; the gateway's plugin manager path runs it before a spec reaches
|
|
1865
|
+
* `installBundle` or the download phase, whose pump relies on it (see
|
|
1866
|
+
* `requestDownloadPhase`). Returns the operand, known to be present.
|
|
1867
|
+
*/
|
|
1868
|
+
function gateOperand(target) {
|
|
1869
|
+
if (target === void 0 || target.startsWith("-")) throw new Error(`dsh-plugin-shop: refusing to spawn with a flag-like operand: ${target ?? "(none)"}`);
|
|
1870
|
+
if (UNSAFE_TARGET.test(target)) throw new Error(`dsh-plugin-shop: refusing to spawn with an unsafe operand: ${JSON.stringify(target)}`);
|
|
1871
|
+
return target;
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1828
1874
|
* The operand as the downstream dsh must receive it for pnpm to see it whole.
|
|
1829
1875
|
*
|
|
1830
1876
|
* dsh spawns pnpm with `shell: process.platform === 'win32'`
|
|
@@ -1862,6 +1908,32 @@ function shellSafeTarget(target, platform) {
|
|
|
1862
1908
|
if (platform !== "win32" || !target.includes("&")) return target;
|
|
1863
1909
|
return `"${target}"`;
|
|
1864
1910
|
}
|
|
1911
|
+
const DOWNLOAD_PHASE_FAILED_PREFIX = "dsh-plugin-shop: the download phase could not start — ";
|
|
1912
|
+
const DOWNLOAD_PHASE_NO_PNPM = "dsh-plugin-shop: no download phase — pnpm not found on PATH";
|
|
1913
|
+
const DOWNLOAD_PHASE_UNSUPPORTED = "dsh-plugin-shop: no download phase for this spec form; the install fetches it directly";
|
|
1914
|
+
/**
|
|
1915
|
+
* Ask the pump to warm `spec` while an operation waits its turn, and say in
|
|
1916
|
+
* that operation's log why there is no download phase when there is none.
|
|
1917
|
+
* Shared by the CLI executor and the plugin manager runner.
|
|
1918
|
+
*/
|
|
1919
|
+
function requestDownloadPhase(options) {
|
|
1920
|
+
const { prefetcher, profile, spec, env, log } = options;
|
|
1921
|
+
let prefetch;
|
|
1922
|
+
try {
|
|
1923
|
+
prefetch = prefetcher.request({
|
|
1924
|
+
profile,
|
|
1925
|
+
spec,
|
|
1926
|
+
cwd: resolveProfileDir(profile, env?.DSH_HOME),
|
|
1927
|
+
env,
|
|
1928
|
+
log
|
|
1929
|
+
});
|
|
1930
|
+
} catch (error) {
|
|
1931
|
+
log(`${DOWNLOAD_PHASE_FAILED_PREFIX}${error.message}`);
|
|
1932
|
+
return null;
|
|
1933
|
+
}
|
|
1934
|
+
if (!prefetch.started) log(prefetch.reason === "no-pnpm" ? DOWNLOAD_PHASE_NO_PNPM : DOWNLOAD_PHASE_UNSUPPORTED);
|
|
1935
|
+
return prefetch;
|
|
1936
|
+
}
|
|
1865
1937
|
/** Run one `dsh plugin --profile <profile> <verb> <target>` and track it.
|
|
1866
1938
|
* Never rolls back; a failure surfaces stderr verbatim plus the recovery hint
|
|
1867
1939
|
* (§10). The shop never passes build-script flags: `allowBuilds` stays the
|
|
@@ -1878,14 +1950,11 @@ function shellSafeTarget(target, platform) {
|
|
|
1878
1950
|
* package IS installed; it reports `done` with the restart fallback. */
|
|
1879
1951
|
function spawnPluginCli(options) {
|
|
1880
1952
|
const { profile, argv, dshBin, env, platform = process.platform, beforeSpawn, prefetcher, confirm, afterDone, onStatus, timeoutMs = INSTALL_TIMEOUT_MS } = options;
|
|
1881
|
-
const target = argv[1];
|
|
1882
|
-
if (target === void 0 || target.startsWith("-")) throw new Error(`dsh-plugin-shop: refusing to spawn with a flag-like operand: ${target ?? "(none)"}`);
|
|
1883
|
-
if (UNSAFE_TARGET.test(target)) throw new Error(`dsh-plugin-shop: refusing to spawn with an unsafe operand: ${JSON.stringify(target)}`);
|
|
1953
|
+
const target = gateOperand(argv[1]);
|
|
1884
1954
|
const spawnArgv = [...argv];
|
|
1885
1955
|
spawnArgv[1] = shellSafeTarget(target, platform);
|
|
1886
1956
|
const installId = randomUUID();
|
|
1887
|
-
const log =
|
|
1888
|
-
let logBytes = 0;
|
|
1957
|
+
const log = createBoundedLog();
|
|
1889
1958
|
const ahead = enterQueue(profile);
|
|
1890
1959
|
let state = "running";
|
|
1891
1960
|
let activationOnDone = "restart";
|
|
@@ -1893,7 +1962,7 @@ function spawnPluginCli(options) {
|
|
|
1893
1962
|
let detail;
|
|
1894
1963
|
const status = () => ({
|
|
1895
1964
|
state,
|
|
1896
|
-
log:
|
|
1965
|
+
log: log.lines(),
|
|
1897
1966
|
...state === "done" ? {
|
|
1898
1967
|
activation: activationOnDone,
|
|
1899
1968
|
...restartReason !== void 0 ? { restartReason } : {}
|
|
@@ -1903,11 +1972,6 @@ function spawnPluginCli(options) {
|
|
|
1903
1972
|
const append = (line) => {
|
|
1904
1973
|
if (isTerminalInstallState(state)) return;
|
|
1905
1974
|
log.push(line);
|
|
1906
|
-
logBytes += Buffer.byteLength(line);
|
|
1907
|
-
while ((log.length > MAX_LOG_LINES || logBytes > MAX_LOG_BYTES) && log.length > 1) {
|
|
1908
|
-
const oldest = log.shift();
|
|
1909
|
-
if (oldest !== void 0) logBytes -= Buffer.byteLength(oldest);
|
|
1910
|
-
}
|
|
1911
1975
|
onStatus?.(status());
|
|
1912
1976
|
};
|
|
1913
1977
|
const failToStart = (error) => {
|
|
@@ -1917,19 +1981,14 @@ function spawnPluginCli(options) {
|
|
|
1917
1981
|
return status();
|
|
1918
1982
|
};
|
|
1919
1983
|
let prefetch = null;
|
|
1920
|
-
if (ahead > 0 && prefetcher !== void 0)
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
});
|
|
1928
|
-
} catch (error) {
|
|
1929
|
-
append(`dsh-plugin-shop: the download phase could not start — ${error.message}`);
|
|
1930
|
-
}
|
|
1984
|
+
if (ahead > 0 && prefetcher !== void 0) prefetch = requestDownloadPhase({
|
|
1985
|
+
prefetcher,
|
|
1986
|
+
profile,
|
|
1987
|
+
spec: target,
|
|
1988
|
+
env,
|
|
1989
|
+
log: append
|
|
1990
|
+
});
|
|
1931
1991
|
if (prefetch?.started === true) state = "downloading";
|
|
1932
|
-
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");
|
|
1933
1992
|
const finished = chain(profile, () => new Promise((resolve) => {
|
|
1934
1993
|
state = "running";
|
|
1935
1994
|
onStatus?.(status());
|
|
@@ -1998,7 +2057,7 @@ function spawnPluginCli(options) {
|
|
|
1998
2057
|
} else state = "done";
|
|
1999
2058
|
} else {
|
|
2000
2059
|
state = "failed";
|
|
2001
|
-
detail = installFailureDetail(profile, log);
|
|
2060
|
+
detail = installFailureDetail(profile, log.lines());
|
|
2002
2061
|
}
|
|
2003
2062
|
onStatus?.(status());
|
|
2004
2063
|
resolve(status());
|
|
@@ -3929,6 +3988,520 @@ async function readRunningHarness(script) {
|
|
|
3929
3988
|
}
|
|
3930
3989
|
}
|
|
3931
3990
|
//#endregion
|
|
3991
|
+
//#region src/host/plugin-manager.ts
|
|
3992
|
+
/**
|
|
3993
|
+
* dsh's `pluginManager` service (0.1.7 on), as far as this shop calls it:
|
|
3994
|
+
* design 2026-09-26-plugin-manager-delegation. Structural throughout: the
|
|
3995
|
+
* build compiles against the 0.1.1-rc.2 harness floor, where
|
|
3996
|
+
* `@deepseek-ai/dsh-plugin-manager` does not exist.
|
|
3997
|
+
*/
|
|
3998
|
+
const REQUIRED = [
|
|
3999
|
+
"installBundle",
|
|
4000
|
+
"removeBundle",
|
|
4001
|
+
"setPluginEnabled",
|
|
4002
|
+
"setBundleEnabled"
|
|
4003
|
+
];
|
|
4004
|
+
/** The service, when `service` offers every required operation; else null.
|
|
4005
|
+
* All or nothing: half a service would take an install and fail its
|
|
4006
|
+
* uninstall. */
|
|
4007
|
+
function asPluginManager(service) {
|
|
4008
|
+
if (typeof service !== "object" || service === null) return null;
|
|
4009
|
+
const candidate = service;
|
|
4010
|
+
if (!REQUIRED.every((method) => typeof candidate[method] === "function")) return null;
|
|
4011
|
+
if (candidate.cancelInstall !== void 0 && typeof candidate.cancelInstall !== "function") return null;
|
|
4012
|
+
return service;
|
|
4013
|
+
}
|
|
4014
|
+
const text = (value) => typeof value === "string" ? value : null;
|
|
4015
|
+
const record = (value) => typeof value === "object" && value !== null ? value : {};
|
|
4016
|
+
function incompatibleList(value) {
|
|
4017
|
+
if (!Array.isArray(value)) return [];
|
|
4018
|
+
return value.flatMap((item) => {
|
|
4019
|
+
const entry = record(item);
|
|
4020
|
+
const name = text(entry.name);
|
|
4021
|
+
const version = text(entry.version);
|
|
4022
|
+
const runtimeVersion = text(entry.runtimeVersion);
|
|
4023
|
+
if (name === null || version === null || runtimeVersion === null) return [];
|
|
4024
|
+
return [{
|
|
4025
|
+
name,
|
|
4026
|
+
version,
|
|
4027
|
+
runtimeVersion,
|
|
4028
|
+
peers: Object.fromEntries(Object.entries(record(entry.peers)).filter((pair) => typeof pair[1] === "string"))
|
|
4029
|
+
}];
|
|
4030
|
+
});
|
|
4031
|
+
}
|
|
4032
|
+
/** Read one `ChangeResult`. Every field is optional: one a later harness
|
|
4033
|
+
* drops reads as absent, never as a crash. */
|
|
4034
|
+
function readChange(raw) {
|
|
4035
|
+
const result = record(raw);
|
|
4036
|
+
const error = record(result.error);
|
|
4037
|
+
const run = record(result.packageResult);
|
|
4038
|
+
const fromError = incompatibleList(error.incompatible);
|
|
4039
|
+
return {
|
|
4040
|
+
application: text(result.application),
|
|
4041
|
+
stage: text(result.stage),
|
|
4042
|
+
errorCode: text(error.code),
|
|
4043
|
+
diagnostic: text(error.diagnostic),
|
|
4044
|
+
incompatible: fromError.length > 0 ? fromError : incompatibleList(run.incompatible),
|
|
4045
|
+
kind: text(run.kind),
|
|
4046
|
+
output: text(run.output) ?? "",
|
|
4047
|
+
pendingBuilds: Array.isArray(result.pendingBuilds) ? result.pendingBuilds.filter((name) => typeof name === "string") : [],
|
|
4048
|
+
failedAt: text(result.failedAt),
|
|
4049
|
+
changed: typeof result.changed === "boolean" ? result.changed : null,
|
|
4050
|
+
truncated: run.truncated === true
|
|
4051
|
+
};
|
|
4052
|
+
}
|
|
4053
|
+
/** True when `sentence` names a `dsh plugin` command. */
|
|
4054
|
+
function namesCliCommand(sentence) {
|
|
4055
|
+
return sentence.includes("dsh plugin");
|
|
4056
|
+
}
|
|
4057
|
+
const isTerminal = (character) => character === "." || character === "!" || character === "?";
|
|
4058
|
+
const isSpace = (character) => /\s/.test(character);
|
|
4059
|
+
/** Splits `line` into sentences, each still carrying whatever whitespace
|
|
4060
|
+
* followed its `.`, `!` or `?`, so joining every piece back together
|
|
4061
|
+
* reproduces `line` exactly. A sentence ends after a run of terminal
|
|
4062
|
+
* punctuation that whitespace or the end of the line follows; the text after
|
|
4063
|
+
* the last such run is one more piece, so a line with no terminal
|
|
4064
|
+
* punctuation at all is one sentence spanning the whole line, and an empty
|
|
4065
|
+
* line gives none. One pass from left to right: the regex this replaced
|
|
4066
|
+
* backtracked quadratically on a long run of punctuation with no whitespace
|
|
4067
|
+
* after it, and dsh's output holds up to 16 KB of whatever pnpm printed. */
|
|
4068
|
+
function sentencesIn(line) {
|
|
4069
|
+
const pieces = [];
|
|
4070
|
+
let start = 0;
|
|
4071
|
+
let index = 0;
|
|
4072
|
+
while (index < line.length) {
|
|
4073
|
+
if (!isTerminal(line.charAt(index))) {
|
|
4074
|
+
index += 1;
|
|
4075
|
+
continue;
|
|
4076
|
+
}
|
|
4077
|
+
let runEnd = index;
|
|
4078
|
+
while (runEnd < line.length && isTerminal(line.charAt(runEnd))) runEnd += 1;
|
|
4079
|
+
if (runEnd < line.length && !isSpace(line.charAt(runEnd))) {
|
|
4080
|
+
index = runEnd;
|
|
4081
|
+
continue;
|
|
4082
|
+
}
|
|
4083
|
+
let end = runEnd;
|
|
4084
|
+
while (end < line.length && isSpace(line.charAt(end))) end += 1;
|
|
4085
|
+
pieces.push(line.slice(start, end));
|
|
4086
|
+
start = end;
|
|
4087
|
+
index = end;
|
|
4088
|
+
}
|
|
4089
|
+
if (start < line.length) pieces.push(line.slice(start));
|
|
4090
|
+
return pieces;
|
|
4091
|
+
}
|
|
4092
|
+
/** Where `sentence`'s terminator starts: its closing punctuation and the
|
|
4093
|
+
* whitespace after it. Scanned back from the end, so no run in dsh's output
|
|
4094
|
+
* can make it slow. */
|
|
4095
|
+
function terminatorStart(sentence) {
|
|
4096
|
+
let end = sentence.length;
|
|
4097
|
+
while (end > 0 && /\s/.test(sentence.charAt(end - 1))) end -= 1;
|
|
4098
|
+
while (end > 0 && ".!?".includes(sentence.charAt(end - 1))) end -= 1;
|
|
4099
|
+
return end;
|
|
4100
|
+
}
|
|
4101
|
+
/** `sentence`, with every clause that names a `dsh plugin` command removed.
|
|
4102
|
+
* A clause is a run between `; ` separators, and what is kept keeps the
|
|
4103
|
+
* sentence's terminator. A sentence with nothing to remove is returned
|
|
4104
|
+
* unchanged; one that loses every clause is dropped (null). */
|
|
4105
|
+
function scrubSentence(sentence) {
|
|
4106
|
+
if (!namesCliCommand(sentence)) return sentence;
|
|
4107
|
+
const end = terminatorStart(sentence);
|
|
4108
|
+
const kept = sentence.slice(0, end).split("; ").filter((clause) => !namesCliCommand(clause));
|
|
4109
|
+
return kept.length === 0 ? null : `${kept.join("; ")}${sentence.slice(end)}`;
|
|
4110
|
+
}
|
|
4111
|
+
/** `line`, with every clause that names a `dsh plugin` command removed (see
|
|
4112
|
+
* `scrubSentence`). A line with nothing to remove is returned unchanged (so
|
|
4113
|
+
* it stays exactly what it was); one that loses every sentence is dropped
|
|
4114
|
+
* (null). */
|
|
4115
|
+
function scrubLine(line) {
|
|
4116
|
+
const sentences = sentencesIn(line);
|
|
4117
|
+
if (sentences.length === 0) return line;
|
|
4118
|
+
const kept = sentences.map(scrubSentence).filter((sentence) => sentence !== null);
|
|
4119
|
+
return kept.length === 0 ? null : kept.join("");
|
|
4120
|
+
}
|
|
4121
|
+
function scrubText(value) {
|
|
4122
|
+
return value.split("\n").map(scrubLine).filter((line) => line !== null).join("\n");
|
|
4123
|
+
}
|
|
4124
|
+
/** `change`, with every clause naming a `dsh plugin` command removed from
|
|
4125
|
+
* `diagnostic` and `output`: dsh's CLI refuses every `plugin` subcommand for
|
|
4126
|
+
* the desktop profile, so a step dsh wrote assuming that command exists must
|
|
4127
|
+
* not reach a reader there. Two dsh texts this exists for: dsh-app-boot's
|
|
4128
|
+
* `pluginCompatibilityWarning` ("... with `dsh plugin allow-version` or the
|
|
4129
|
+
* plugin manager ..."), whose sentence goes whole, and the restoration line
|
|
4130
|
+
* after a failed repair ("..., but node_modules could not be reinstalled;
|
|
4131
|
+
* run 'dsh plugin install'"), which keeps what it says dsh restored. A
|
|
4132
|
+
* clause is a run between `; ` separators within a sentence; a sentence left
|
|
4133
|
+
* with no clause is dropped, and so is a line left with no sentence. Every
|
|
4134
|
+
* other line stays untouched, and a diagnostic left empty afterward reads as
|
|
4135
|
+
* absent, the same way `codeReason` already treats one. */
|
|
4136
|
+
function forDesktopReader(change) {
|
|
4137
|
+
return {
|
|
4138
|
+
...change,
|
|
4139
|
+
diagnostic: change.diagnostic === null ? null : scrubText(change.diagnostic),
|
|
4140
|
+
output: scrubText(change.output)
|
|
4141
|
+
};
|
|
4142
|
+
}
|
|
4143
|
+
/** The end of a detail that passes on what a service call threw: `: ` and
|
|
4144
|
+
* the message, scrubbed for a desktop reader as `forDesktopReader` scrubs
|
|
4145
|
+
* dsh's answer, since a thrown message is dsh's text as well. A message left
|
|
4146
|
+
* with nothing to say ends the detail with a period instead. */
|
|
4147
|
+
function thrownTail(message, desktop) {
|
|
4148
|
+
const said = desktop ? scrubText(message) : message;
|
|
4149
|
+
return said.trim() === "" ? "." : `: ${said}`;
|
|
4150
|
+
}
|
|
4151
|
+
/** What each management code means, in dsh 0.1.7-rc.2's own terms (the
|
|
4152
|
+
* implementation plan of 2026-09-27, Task 2, records where each is raised). */
|
|
4153
|
+
const CODE_SENTENCE = {
|
|
4154
|
+
"unknown-plugin": "dsh lists no plugin entry by that id",
|
|
4155
|
+
"invalid-spec": "dsh could not read the install spec",
|
|
4156
|
+
"ambiguous-install": "dsh could not tell which package the install added",
|
|
4157
|
+
"not-bundle": "the package is not a dsh bundle",
|
|
4158
|
+
"not-removable": "dsh does not allow removing this bundle",
|
|
4159
|
+
"stop-profile": "the bundle is running and this dsh cannot unload it live, so the profile has to be stopped first",
|
|
4160
|
+
"bundle-in-use": "the bundle was switched off, but some of its plugins are still running",
|
|
4161
|
+
"stale-approval": "a build approval names a package pnpm no longer holds",
|
|
4162
|
+
"management-required": "the plugin belongs to dsh itself",
|
|
4163
|
+
unaddressable: "the plugin is not a row of the profile's own patch, so dsh cannot address it",
|
|
4164
|
+
"operation-error": "dsh hit an unexpected error"
|
|
4165
|
+
};
|
|
4166
|
+
/** What each classified pnpm failure was, as dsh classifies it. */
|
|
4167
|
+
const KIND_SENTENCE = {
|
|
4168
|
+
"pnpm-missing": "pnpm could not be started",
|
|
4169
|
+
timeout: "pnpm did not finish within dsh's own time bound",
|
|
4170
|
+
"not-found": "no such package was found",
|
|
4171
|
+
"no-matching-version": "no version matching the requested one was found",
|
|
4172
|
+
network: "the network failed",
|
|
4173
|
+
"disk-full": "the disk is full",
|
|
4174
|
+
permission: "permission was denied",
|
|
4175
|
+
integrity: "the downloaded package failed its integrity check"
|
|
4176
|
+
};
|
|
4177
|
+
const WHERE = {
|
|
4178
|
+
registry: " at the registry",
|
|
4179
|
+
"spec-host": " at the host the package is fetched from"
|
|
4180
|
+
};
|
|
4181
|
+
/** An own-property read of one of the three tables above. They are object
|
|
4182
|
+
* literals, so a bare index read answers for Object.prototype, and a code of
|
|
4183
|
+
* `toString` would publish a function's source as dsh's reason (R43). */
|
|
4184
|
+
const lookup = (table, key) => Object.hasOwn(table, key) ? table[key] : void 0;
|
|
4185
|
+
/** The pnpm-failure hint a desktop reader gets: it names no command, which
|
|
4186
|
+
* the CLI would refuse for that profile. */
|
|
4187
|
+
const DESKTOP_FAILURE_HINT = "pnpm failed in the profile";
|
|
4188
|
+
/** What dsh says it restored after refusing, from its own restoration line:
|
|
4189
|
+
* the first `dsh: ` line after the one opening the refusal
|
|
4190
|
+
* (dsh-plugin-manager's `rejected`), read as the CLI path's `refusalDetail`
|
|
4191
|
+
* reads it, capitalized and ending in dsh's own period. Null when the output
|
|
4192
|
+
* holds no such line, being empty or cut short. */
|
|
4193
|
+
function restorationSentence(output) {
|
|
4194
|
+
const lines = output.split(/\r?\n/);
|
|
4195
|
+
const start = lines.findIndex((line) => line.startsWith(REFUSAL_OPENER));
|
|
4196
|
+
if (start === -1) return null;
|
|
4197
|
+
const said = lines.slice(start + 1).find((later) => later.startsWith("dsh: "))?.slice(5).trim() ?? "";
|
|
4198
|
+
return said === "" ? null : `${said.charAt(0).toUpperCase()}${said.slice(1)}`;
|
|
4199
|
+
}
|
|
4200
|
+
/** The refusal of a package the running dsh rejects on its peers, for the
|
|
4201
|
+
* operation dsh refused: an install, an update, or an uninstall whose pnpm
|
|
4202
|
+
* run touched an incompatible sibling. What dsh restored is dsh's to say,
|
|
4203
|
+
* so its restoration line is passed on. Without one, an install or update
|
|
4204
|
+
* says what dsh's own pre-check says, and an uninstall says nothing of it. */
|
|
4205
|
+
function versionRefusalDetail(context, change) {
|
|
4206
|
+
const refused = change.incompatible.map((issue) => `${issue.name}@${issue.version} declares ${Object.entries(issue.peers).map(([peer, range]) => `${peer} ${range}`).join(", ")}, which dsh ${issue.runtimeVersion} does not satisfy`);
|
|
4207
|
+
const restored = restorationSentence(change.output) ?? (context.operation === "uninstall" || change.truncated ? null : "Nothing was installed.");
|
|
4208
|
+
const base = `dsh-plugin-shop: dsh refused the ${context.operation}: ${refused.join("; ")}.${restored === null ? "" : ` ${restored}`}`;
|
|
4209
|
+
if (context.desktop) return `${base} dsh's CLI, which grants version exemptions, does not manage the desktop profile, and this shop grants none.`;
|
|
4210
|
+
const commands = change.incompatible.map((issue) => allowVersionCommand(context.profile, issue)).filter((command) => command !== null);
|
|
4211
|
+
if (commands.length === 0) return base;
|
|
4212
|
+
return `${base} To accept the risk of crashes or data loss for ${commands.length === 1 ? "this exact version" : "these exact versions"}, run: ${commands.join("; ")} - then ${context.operation} again.`;
|
|
4213
|
+
}
|
|
4214
|
+
/** The sentence a management code gets, plus dsh's own diagnostic. The
|
|
4215
|
+
* diagnostic is trimmed and stripped of one trailing period so one that
|
|
4216
|
+
* already ends in a period is not doubled; empty after that reads as
|
|
4217
|
+
* absent. Exported: Task 7 builds the same tail for a non-terminal notice. */
|
|
4218
|
+
function codeReason(change, code) {
|
|
4219
|
+
const sentence = lookup(CODE_SENTENCE, code);
|
|
4220
|
+
const diagnostic = change.diagnostic !== null ? change.diagnostic.trim().replace(/\.$/, "") : "";
|
|
4221
|
+
const said = diagnostic !== "" ? ` dsh reported: ${diagnostic}.` : "";
|
|
4222
|
+
return `${sentence !== void 0 ? `: ${sentence}` : ""}.${said}`;
|
|
4223
|
+
}
|
|
4224
|
+
/** A desktop reader's detail for build scripts pnpm held. The app bundles
|
|
4225
|
+
* its own package manager, so `pnpm approve-builds` is no step for this
|
|
4226
|
+
* reader, and the shop never allows build scripts itself. dsh's own Plugins
|
|
4227
|
+
* page offers "Allow these scripts and retry" on the failed screen of an
|
|
4228
|
+
* install it runs, listing the held packages (dsh-client-ui-plugin-manager
|
|
4229
|
+
* 0.1.7-rc.2: `installApproveAndRetry` in lib/client.js, and its README), so
|
|
4230
|
+
* that is named, but only for an install whose held builds dsh listed. Its
|
|
4231
|
+
* Add plugin dialog refuses a name the profile already holds, so it cannot
|
|
4232
|
+
* rerun an update, and with no name listed it offers no such button. */
|
|
4233
|
+
function desktopBuildsDetail(context, change, held) {
|
|
4234
|
+
const base = `dsh-plugin-shop: pnpm is holding the build scripts of ${held}, which it blocks by default, and this shop never allows them.`;
|
|
4235
|
+
if (context.operation !== "install" || change.pendingBuilds.length === 0) return base;
|
|
4236
|
+
return `${base} dsh's Plugins page offers "Allow these scripts and retry" when an install it runs stops on them.`;
|
|
4237
|
+
}
|
|
4238
|
+
/** What a `cancelled` answer means, for every reader: the shop's deadline
|
|
4239
|
+
* asked dsh to stop the operation, and dsh answers `cancelled` only once its
|
|
4240
|
+
* repository check or pnpm has exited and package.json and pnpm-lock.yaml
|
|
4241
|
+
* are back as they were (dsh-plugin-manager 0.1.7-rc.2: cancelInstall, and
|
|
4242
|
+
* InstallCancelledError, thrown after the files are restored). Nothing was
|
|
4243
|
+
* installed, so the detail names no command: the CLI's `install`, which the
|
|
4244
|
+
* CLI path's own timeout detail names, would no longer involve the package.
|
|
4245
|
+
* Only installBundle can be cancelled, so the operation is an install or an
|
|
4246
|
+
* update. */
|
|
4247
|
+
function cancelledDetail(context) {
|
|
4248
|
+
const seconds = Math.max(1, Math.round(context.timeoutMs / 1e3));
|
|
4249
|
+
return `dsh-plugin-shop: the ${context.operation} did not finish within ${seconds}s, so the shop cancelled it. dsh stopped it, restored the profile's package.json and pnpm-lock.yaml, and installed nothing. Try the ${context.operation} again from the shop.`;
|
|
4250
|
+
}
|
|
4251
|
+
/** A `ChangeResult` as the shop's terminal install record: design
|
|
4252
|
+
* 2026-09-26-plugin-manager-delegation, section 5, read by the first rule
|
|
4253
|
+
* that matches. A removal that failed after dsh changed the profile says,
|
|
4254
|
+
* after whichever rule read it, that the package is still installed and
|
|
4255
|
+
* switched off. */
|
|
4256
|
+
function managerOutcome(raw, context) {
|
|
4257
|
+
const change = context.desktop ? forDesktopReader(readChange(raw)) : readChange(raw);
|
|
4258
|
+
const outcome = outcomeByRule(change, context);
|
|
4259
|
+
if (context.operation === "uninstall" && outcome.state === "failed" && change.changed === true) {
|
|
4260
|
+
const switchedOff = `${context.name} is still installed, but dsh has switched it off.`;
|
|
4261
|
+
if (outcome.detail === void 0) return {
|
|
4262
|
+
...outcome,
|
|
4263
|
+
detail: switchedOff
|
|
4264
|
+
};
|
|
4265
|
+
const needsPeriod = !/[.!?]$/.test(outcome.detail);
|
|
4266
|
+
return {
|
|
4267
|
+
...outcome,
|
|
4268
|
+
detail: `${outcome.detail}${needsPeriod ? "." : ""} ${switchedOff}`
|
|
4269
|
+
};
|
|
4270
|
+
}
|
|
4271
|
+
return outcome;
|
|
4272
|
+
}
|
|
4273
|
+
/** The section 5 rules, in order; the first that matches wins. */
|
|
4274
|
+
function outcomeByRule(change, context) {
|
|
4275
|
+
if (change.errorCode === "incompatible-version" || change.errorCode === "operation-error" && change.incompatible.length > 0) return {
|
|
4276
|
+
state: "failed",
|
|
4277
|
+
detail: versionRefusalDetail(context, change)
|
|
4278
|
+
};
|
|
4279
|
+
if (change.application === "failed" && change.stage === "enable") {
|
|
4280
|
+
const code = change.errorCode ?? "failed";
|
|
4281
|
+
return {
|
|
4282
|
+
state: "failed",
|
|
4283
|
+
detail: `dsh-plugin-shop: ${context.name} is installed, but dsh could not enable it (${code})${codeReason(change, code)} Uninstall it from the shop to undo the install.`
|
|
4284
|
+
};
|
|
4285
|
+
}
|
|
4286
|
+
if (change.application === "failed" && change.kind !== null && (change.errorCode === null || change.errorCode === "operation-error")) {
|
|
4287
|
+
if (change.kind === "build-blocked") {
|
|
4288
|
+
const held = change.pendingBuilds.length > 0 ? change.pendingBuilds.join(", ") : "a dependency";
|
|
4289
|
+
if (context.desktop) return {
|
|
4290
|
+
state: "failed",
|
|
4291
|
+
detail: desktopBuildsDetail(context, change, held)
|
|
4292
|
+
};
|
|
4293
|
+
return {
|
|
4294
|
+
state: "failed",
|
|
4295
|
+
detail: `dsh-plugin-shop: pnpm is holding the build scripts of ${held}, which it blocks by default: run \`pnpm approve-builds\` in the profile directory to allow them, then ${context.operation} again.`
|
|
4296
|
+
};
|
|
4297
|
+
}
|
|
4298
|
+
if (change.kind === "timeout" && change.failedAt === "spec-host") return {
|
|
4299
|
+
state: "failed",
|
|
4300
|
+
detail: `dsh-plugin-shop: the ${context.operation} failed at the host the package is fetched from: dsh checks that a GitHub repository is reachable before pnpm runs, and that check did not finish within its bound (5 s by default), so pnpm never ran.`
|
|
4301
|
+
};
|
|
4302
|
+
const sentence = lookup(KIND_SENTENCE, change.kind);
|
|
4303
|
+
if (sentence !== void 0) {
|
|
4304
|
+
const where = change.failedAt === null ? "" : lookup(WHERE, change.failedAt) ?? "";
|
|
4305
|
+
return {
|
|
4306
|
+
state: "failed",
|
|
4307
|
+
detail: `dsh-plugin-shop: the ${context.operation} failed${where}: ${sentence}.`
|
|
4308
|
+
};
|
|
4309
|
+
}
|
|
4310
|
+
const lines = change.output.split(/\r?\n/).filter((line) => line !== "");
|
|
4311
|
+
return {
|
|
4312
|
+
state: "failed",
|
|
4313
|
+
detail: context.desktop ? installFailureDetail(context.profile, lines, DESKTOP_FAILURE_HINT) : installFailureDetail(context.profile, lines)
|
|
4314
|
+
};
|
|
4315
|
+
}
|
|
4316
|
+
if (change.errorCode !== null) return {
|
|
4317
|
+
state: "failed",
|
|
4318
|
+
detail: `dsh-plugin-shop: dsh ${change.errorCode === "operation-error" ? `could not ${context.operation} ${context.name}` : `refused the ${context.operation} of ${context.name}`} (${change.errorCode})${codeReason(change, change.errorCode)}`
|
|
4319
|
+
};
|
|
4320
|
+
if (change.application === "cancelled") return {
|
|
4321
|
+
state: "failed",
|
|
4322
|
+
detail: cancelledDetail(context)
|
|
4323
|
+
};
|
|
4324
|
+
if (change.application === "restart-required") return context.operation === "update" ? {
|
|
4325
|
+
state: "done",
|
|
4326
|
+
activation: "restart",
|
|
4327
|
+
restartReason: "already-loaded"
|
|
4328
|
+
} : {
|
|
4329
|
+
state: "done",
|
|
4330
|
+
activation: "restart"
|
|
4331
|
+
};
|
|
4332
|
+
if (change.application === "applied" || change.application === "overridden") {
|
|
4333
|
+
const note = change.application === "overridden" ? { detail: "Saved, but a higher-priority layer (the home or invocation patch) decides whether it runs." } : {};
|
|
4334
|
+
if (context.operation !== "uninstall" && context.alreadyImported) return {
|
|
4335
|
+
state: "done",
|
|
4336
|
+
activation: "restart",
|
|
4337
|
+
restartReason: "already-loaded",
|
|
4338
|
+
...note
|
|
4339
|
+
};
|
|
4340
|
+
return {
|
|
4341
|
+
state: "done",
|
|
4342
|
+
activation: activationOf({
|
|
4343
|
+
hostLive: true,
|
|
4344
|
+
clientLive: true,
|
|
4345
|
+
hasClientHalf: context.hasClientHalf
|
|
4346
|
+
}),
|
|
4347
|
+
...note
|
|
4348
|
+
};
|
|
4349
|
+
}
|
|
4350
|
+
return {
|
|
4351
|
+
state: "failed",
|
|
4352
|
+
detail: `dsh-plugin-shop: dsh answered the ${context.operation} with "${change.application ?? "nothing"}", which this shop does not know how to read.`
|
|
4353
|
+
};
|
|
4354
|
+
}
|
|
4355
|
+
//#endregion
|
|
4356
|
+
//#region src/host/manager-runner.ts
|
|
4357
|
+
/**
|
|
4358
|
+
* One package operation through dsh's `pluginManager` service, tracked as the
|
|
4359
|
+
* same `RunningInstall` the CLI executor produces: design
|
|
4360
|
+
* 2026-09-26-plugin-manager-delegation, section 6. It runs in the same
|
|
4361
|
+
* per-profile queue as CLI commands, so the two paths are never concurrent in
|
|
4362
|
+
* one profile.
|
|
4363
|
+
*/
|
|
4364
|
+
/** The first line of every record this runner writes. The shop's log panel
|
|
4365
|
+
* shows it and the 0.1.7 e2e asserts it, so a silent fall back to the CLI
|
|
4366
|
+
* fails a case instead of passing it. */
|
|
4367
|
+
const MECHANISM_PREFIX = "via dsh's plugin manager:";
|
|
4368
|
+
/** Routes the service's `plugin-manager/install-log` chunks to the record
|
|
4369
|
+
* whose request id they carry. A chunk for any other id is dropped. dsh
|
|
4370
|
+
* tags each chunk with the stream it came from; a non-string stream reads
|
|
4371
|
+
* as `stdout`, matching a harness that predates the tag. */
|
|
4372
|
+
var ManagerLogs = class {
|
|
4373
|
+
sinks = /* @__PURE__ */ new Map();
|
|
4374
|
+
open(requestId, sink) {
|
|
4375
|
+
this.sinks.set(requestId, sink);
|
|
4376
|
+
return () => {
|
|
4377
|
+
this.sinks.delete(requestId);
|
|
4378
|
+
};
|
|
4379
|
+
}
|
|
4380
|
+
chunk(requestId, text, stream) {
|
|
4381
|
+
if (typeof requestId !== "string" || typeof text !== "string") return;
|
|
4382
|
+
this.sinks.get(requestId)?.(text, typeof stream === "string" ? stream : "stdout");
|
|
4383
|
+
}
|
|
4384
|
+
};
|
|
4385
|
+
/** What every value this cannot describe reads as. */
|
|
4386
|
+
const UNREADABLE = "an error with no readable message";
|
|
4387
|
+
/** What a thrown value says about itself, and total: describing an error is
|
|
4388
|
+
* never itself the reason a settle fails. An `Error` gives its `message`,
|
|
4389
|
+
* anything else `String(value)`. A message that is not a string, or a
|
|
4390
|
+
* description that throws anywhere, reads as one fixed string: a Proxy whose
|
|
4391
|
+
* getPrototypeOf trap throws defeats `instanceof` itself, a `message` getter
|
|
4392
|
+
* can throw, and a null-prototype object has no conversion to a string. */
|
|
4393
|
+
const messageOf = (error) => {
|
|
4394
|
+
try {
|
|
4395
|
+
const said = error instanceof Error ? error.message : String(error);
|
|
4396
|
+
if (typeof said === "string") return said;
|
|
4397
|
+
} catch {}
|
|
4398
|
+
return UNREADABLE;
|
|
4399
|
+
};
|
|
4400
|
+
function startManagerOperation(options) {
|
|
4401
|
+
const { profile, requestId, mechanism, logs, run, cancel, outcome, alsoConfirm, prefetcher, spec, env, desktop = false } = options;
|
|
4402
|
+
const timeoutMs = options.timeoutMs ?? INSTALL_TIMEOUT_MS;
|
|
4403
|
+
const mechanismLine = `${MECHANISM_PREFIX} ${mechanism}`;
|
|
4404
|
+
const log = createBoundedLog();
|
|
4405
|
+
let state = "running";
|
|
4406
|
+
let settled = null;
|
|
4407
|
+
let streamed = false;
|
|
4408
|
+
const status = () => ({
|
|
4409
|
+
state,
|
|
4410
|
+
log: [mechanismLine, ...log.lines()],
|
|
4411
|
+
...settled?.state === "done" && settled.activation !== void 0 ? { activation: settled.activation } : {},
|
|
4412
|
+
...settled?.state === "done" && settled.restartReason !== void 0 ? { restartReason: settled.restartReason } : {},
|
|
4413
|
+
...settled?.detail !== void 0 ? { detail: settled.detail } : {}
|
|
4414
|
+
});
|
|
4415
|
+
const append = (line) => {
|
|
4416
|
+
if (isTerminalInstallState(state)) return;
|
|
4417
|
+
log.push(line);
|
|
4418
|
+
};
|
|
4419
|
+
const streams = /* @__PURE__ */ new Map();
|
|
4420
|
+
const streamFor = (stream) => {
|
|
4421
|
+
let sink = streams.get(stream);
|
|
4422
|
+
if (sink === void 0) {
|
|
4423
|
+
sink = lineSink(append);
|
|
4424
|
+
streams.set(stream, sink);
|
|
4425
|
+
}
|
|
4426
|
+
return sink;
|
|
4427
|
+
};
|
|
4428
|
+
const flushStreams = () => {
|
|
4429
|
+
for (const sink of streams.values()) sink.flush();
|
|
4430
|
+
};
|
|
4431
|
+
const close = logs.open(requestId, (text, stream) => {
|
|
4432
|
+
streamed = true;
|
|
4433
|
+
streamFor(stream).write(Buffer.from(text, "utf8"));
|
|
4434
|
+
});
|
|
4435
|
+
const queued = inProfileQueue(profile, async () => {
|
|
4436
|
+
state = "running";
|
|
4437
|
+
let deadline;
|
|
4438
|
+
if (cancel !== void 0) deadline = setTimeout(() => {
|
|
4439
|
+
Promise.resolve().then(() => cancel(requestId)).catch(() => {});
|
|
4440
|
+
}, timeoutMs);
|
|
4441
|
+
let raw;
|
|
4442
|
+
try {
|
|
4443
|
+
raw = await run(requestId);
|
|
4444
|
+
} catch (error) {
|
|
4445
|
+
flushStreams();
|
|
4446
|
+
settled = {
|
|
4447
|
+
state: "failed",
|
|
4448
|
+
detail: `dsh-plugin-shop: dsh's plugin manager failed${thrownTail(messageOf(error), desktop)}`
|
|
4449
|
+
};
|
|
4450
|
+
state = "failed";
|
|
4451
|
+
return status();
|
|
4452
|
+
} finally {
|
|
4453
|
+
clearTimeout(deadline);
|
|
4454
|
+
}
|
|
4455
|
+
let result;
|
|
4456
|
+
try {
|
|
4457
|
+
flushStreams();
|
|
4458
|
+
if (!streamed) {
|
|
4459
|
+
for (const line of readChange(raw).output.split(/\r?\n/)) if (line !== "") append(line);
|
|
4460
|
+
}
|
|
4461
|
+
result = outcome(raw);
|
|
4462
|
+
} catch (error) {
|
|
4463
|
+
settled = {
|
|
4464
|
+
state: "failed",
|
|
4465
|
+
detail: `dsh-plugin-shop: the shop could not read dsh's answer: ${messageOf(error)}`
|
|
4466
|
+
};
|
|
4467
|
+
state = "failed";
|
|
4468
|
+
return status();
|
|
4469
|
+
}
|
|
4470
|
+
if (result.state === "done" && alsoConfirm !== void 0) try {
|
|
4471
|
+
const objection = alsoConfirm();
|
|
4472
|
+
if (objection !== null) result = {
|
|
4473
|
+
state: "failed",
|
|
4474
|
+
detail: objection
|
|
4475
|
+
};
|
|
4476
|
+
} catch (error) {
|
|
4477
|
+
result = {
|
|
4478
|
+
state: "failed",
|
|
4479
|
+
detail: `dsh-plugin-shop: the shop could not check what dsh installed: ${messageOf(error)}`
|
|
4480
|
+
};
|
|
4481
|
+
}
|
|
4482
|
+
settled = result;
|
|
4483
|
+
state = result.state;
|
|
4484
|
+
return status();
|
|
4485
|
+
});
|
|
4486
|
+
if ((queued.ahead > 0 && prefetcher !== void 0 && spec !== void 0 ? requestDownloadPhase({
|
|
4487
|
+
prefetcher,
|
|
4488
|
+
profile,
|
|
4489
|
+
spec,
|
|
4490
|
+
env,
|
|
4491
|
+
log: append
|
|
4492
|
+
}) : null)?.started === true && !isTerminalInstallState(state)) state = "downloading";
|
|
4493
|
+
const finished = queued.finished.finally(() => {
|
|
4494
|
+
close();
|
|
4495
|
+
if (prefetcher !== void 0 && spec !== void 0) prefetcher.release(profile, spec);
|
|
4496
|
+
});
|
|
4497
|
+
finished.catch(() => {});
|
|
4498
|
+
return {
|
|
4499
|
+
installId: requestId,
|
|
4500
|
+
status,
|
|
4501
|
+
finished
|
|
4502
|
+
};
|
|
4503
|
+
}
|
|
4504
|
+
//#endregion
|
|
3932
4505
|
//#region src/host/index.ts
|
|
3933
4506
|
/** ShopGateway: the Host half of dsh-plugin-shop (§5.1). */
|
|
3934
4507
|
var __runInitializers = function(thisArg, initializers, value) {
|
|
@@ -3991,6 +4564,15 @@ const RESTART_BLOCKED_DETAIL = {
|
|
|
3991
4564
|
* the profile before any of them would run (`isDesktopProfile`), and the
|
|
3992
4565
|
* failure that surfaced instead read "pnpm failed in the profile". */
|
|
3993
4566
|
const DESKTOP_PROFILE_DETAIL = "dsh-plugin-shop: the desktop profile is managed by the DeepSeek Harness desktop app, and dsh refuses to change it from the command line the shop runs; add and remove its plugins from the app instead";
|
|
4567
|
+
/** Why a switch through dsh's `pluginManager` waits for an operation this
|
|
4568
|
+
* gateway started: dsh holds its profile lock for a whole install or removal
|
|
4569
|
+
* (`change()` wraps pnpm) and makes any other change wait for it up to its
|
|
4570
|
+
* `lockWaitMs`, 120 s, so a switch clicked mid-install would spin for two
|
|
4571
|
+
* minutes and then fail on the lock. The check is F-5's, `hasRunningCommand`.
|
|
4572
|
+
* The shop's own row writer takes no dsh lock, so what it writes alone (the
|
|
4573
|
+
* CLI path's switch, and switching a deselected package off) is not
|
|
4574
|
+
* refused. */
|
|
4575
|
+
const switchWhileBusyDetail = (name) => `dsh-plugin-shop: an install, update or uninstall is still running in this profile, and dsh holds the profile until it ends; switch ${name} after it finishes.`;
|
|
3994
4576
|
/** How many bytes a release tarball may be at the integrity check. The
|
|
3995
4577
|
* registry already refuses to publish a tarball over 32 MiB, so 64 MiB is
|
|
3996
4578
|
* headroom, not a gate of its own. */
|
|
@@ -4063,8 +4645,12 @@ function ownDependencySpec(dependencies, name) {
|
|
|
4063
4645
|
* process's: a gateway rebuilt inside a running dsh — a restarted shop fiber —
|
|
4064
4646
|
* must not forget what the process imported before it existed. Every gateway
|
|
4065
4647
|
* seeds it with the profile's dependencies at construction (the boot
|
|
4066
|
-
* composition) and extends it with every name the hot path mounts.
|
|
4067
|
-
*
|
|
4648
|
+
* composition) and extends it with every name the hot path mounts. It extends
|
|
4649
|
+
* it again with every name one of its own installs through dsh's
|
|
4650
|
+
* `pluginManager` brought to the enable stage, and with the profile's
|
|
4651
|
+
* dependencies whenever that service reports an install: dsh's own Plugins
|
|
4652
|
+
* page installs through it, and the report names no package. Nothing is ever
|
|
4653
|
+
* removed: an uninstall disposes the fiber, not the module record.
|
|
4068
4654
|
*/
|
|
4069
4655
|
const importedThisProcess = /* @__PURE__ */ new Set();
|
|
4070
4656
|
/** Remote-only service exposing the shop Remote methods of §7.3.
|
|
@@ -4244,6 +4830,11 @@ let ShopGateway = (() => {
|
|
|
4244
4830
|
exit;
|
|
4245
4831
|
restartExitDelayMs;
|
|
4246
4832
|
restartParentPid;
|
|
4833
|
+
/** The restart this process committed, once it has: every later
|
|
4834
|
+
* `shop/restart` answers with it. A page that lost the first answer to an
|
|
4835
|
+
* HMR swap asks again (client/restart-monitor.ts), and inside the exit
|
|
4836
|
+
* delay that ask reaches this same process. */
|
|
4837
|
+
committedRestart = null;
|
|
4247
4838
|
latestVersion;
|
|
4248
4839
|
pinFs;
|
|
4249
4840
|
allowRestart;
|
|
@@ -4262,6 +4853,9 @@ let ShopGateway = (() => {
|
|
|
4262
4853
|
prefetcher;
|
|
4263
4854
|
/** What this process may already have imported (see `importedThisProcess`). */
|
|
4264
4855
|
imported;
|
|
4856
|
+
/** Routes the output dsh's `pluginManager` streams to the record whose
|
|
4857
|
+
* request id it carries (see the constructor). */
|
|
4858
|
+
managerLogs;
|
|
4265
4859
|
/** The install gate runs against the last loaded snapshot, never a fresh
|
|
4266
4860
|
* fetch per request (§7.2: the Host's cached snapshot is the truth). */
|
|
4267
4861
|
/** Finished install records retained, so a poll sees the true terminal
|
|
@@ -4286,7 +4880,8 @@ let ShopGateway = (() => {
|
|
|
4286
4880
|
* promise, so concurrent first calls share one read; `readRunningHarness`
|
|
4287
4881
|
* never rejects, so there is no failure to retry. */
|
|
4288
4882
|
harnessRead = null;
|
|
4289
|
-
/** Install records, running and finished
|
|
4883
|
+
/** Install records, running and finished, from the CLI and from dsh's
|
|
4884
|
+
* `pluginManager` alike; a poll finds one here or reports not found. */
|
|
4290
4885
|
installs = /* @__PURE__ */ new Map();
|
|
4291
4886
|
/** Every install id in insertion order, oldest first; finished-record eviction walks this from the front. */
|
|
4292
4887
|
installOrder = [];
|
|
@@ -4322,7 +4917,16 @@ let ShopGateway = (() => {
|
|
|
4322
4917
|
this.fetchTarball = options.fetchTarball ?? ((url) => fetch(url));
|
|
4323
4918
|
this.prefetcher = options.prefetcher ?? createPrefetcher();
|
|
4324
4919
|
this.imported = options.importedModules ?? importedThisProcess;
|
|
4325
|
-
|
|
4920
|
+
this.recordDependenciesImported();
|
|
4921
|
+
this.managerLogs = new ManagerLogs();
|
|
4922
|
+
const on = this.ctx.on;
|
|
4923
|
+
on?.call(this.ctx, "plugin-manager/install-log", (payload) => {
|
|
4924
|
+
const chunk = payload;
|
|
4925
|
+
this.managerLogs.chunk(chunk?.requestId, chunk?.text, chunk?.stream);
|
|
4926
|
+
});
|
|
4927
|
+
on?.call(this.ctx, "plugin-manager/changed", (payload) => {
|
|
4928
|
+
if (payload?.reason === "install") this.recordDependenciesImported();
|
|
4929
|
+
});
|
|
4326
4930
|
try {
|
|
4327
4931
|
cleanHotDir(this.profileDirResolved());
|
|
4328
4932
|
} catch {}
|
|
@@ -4390,14 +4994,15 @@ let ShopGateway = (() => {
|
|
|
4390
4994
|
const service = this.ctx.get?.("pluginPackages");
|
|
4391
4995
|
return typeof service?.packageOf === "function" ? service : null;
|
|
4392
4996
|
}
|
|
4393
|
-
/** dsh's `pluginManager` service, when the running harness provides
|
|
4394
|
-
* (0.1.7 and later), else null.
|
|
4395
|
-
*
|
|
4396
|
-
*
|
|
4397
|
-
*
|
|
4997
|
+
/** dsh's `pluginManager` service, when the running harness provides all of
|
|
4998
|
+
* it (0.1.7 and later), else null. Never cached across calls, like
|
|
4999
|
+
* `pluginPackages`, but read once per RPC, at its start, and passed down:
|
|
5000
|
+
* read again after an await, a service disposed in between would let a
|
|
5001
|
+
* desktop mutation past the desktop gate and onto the CLI path, which dsh
|
|
5002
|
+
* refuses for that profile. Design 2026-09-26-plugin-manager-delegation,
|
|
5003
|
+
* section 3.1. */
|
|
4398
5004
|
pluginManager() {
|
|
4399
|
-
|
|
4400
|
-
return typeof service?.setBundleEnabled === "function" ? service : null;
|
|
5005
|
+
return asPluginManager(this.ctx.get?.("pluginManager"));
|
|
4401
5006
|
}
|
|
4402
5007
|
/** "Does this installation provide `spec`?", asked from `anchor`: the
|
|
4403
5008
|
* harness's own resolution in front of the walk where the harness has one,
|
|
@@ -4495,6 +5100,13 @@ let ShopGateway = (() => {
|
|
|
4495
5100
|
const dependencies = this.profileDependenciesOrNone();
|
|
4496
5101
|
return dependencies === void 0 ? void 0 : ownDependencySpec(dependencies, name);
|
|
4497
5102
|
}
|
|
5103
|
+
/** Add every dependency the profile manifest holds to the `imported`
|
|
5104
|
+
* record (see `importedThisProcess`): each is a package dsh's loader may
|
|
5105
|
+
* have loaded in this process, at boot or by an install since. An
|
|
5106
|
+
* unreadable manifest adds nothing. */
|
|
5107
|
+
recordDependenciesImported() {
|
|
5108
|
+
for (const name of Object.keys(this.profileDependenciesOrNone() ?? {})) this.imported.add(name);
|
|
5109
|
+
}
|
|
4498
5110
|
ownedEntryIdsOrNone(packageName) {
|
|
4499
5111
|
try {
|
|
4500
5112
|
return ownedEntryIds({
|
|
@@ -4564,6 +5176,7 @@ let ShopGateway = (() => {
|
|
|
4564
5176
|
ok: false,
|
|
4565
5177
|
detail: `dsh-plugin-shop: ${args.name} is part of the harness chain and cannot be toggled from the shop`
|
|
4566
5178
|
};
|
|
5179
|
+
const manager = this.pluginManager();
|
|
4567
5180
|
const profileDir = this.profileDirResolved();
|
|
4568
5181
|
const manifest = readProfileManifest("dsh-plugin-shop", profileDir);
|
|
4569
5182
|
if (!Object.hasOwn(manifest.dependencies ?? {}, args.name)) return {
|
|
@@ -4587,12 +5200,59 @@ let ShopGateway = (() => {
|
|
|
4587
5200
|
detail: `dsh-plugin-shop: ${args.name} contributes no plugin entries, so there is nothing to enable or disable`
|
|
4588
5201
|
};
|
|
4589
5202
|
const selected = this.runningProfileBundles(profileDir);
|
|
4590
|
-
if (selected !== null && !selected.includes(args.name)) return this.setDeselectedEnabled(args.name, args.enabled, profileDir, owned);
|
|
5203
|
+
if (selected !== null && !selected.includes(args.name)) return this.setDeselectedEnabled(args.name, args.enabled, profileDir, owned, manager);
|
|
4591
5204
|
const ownedSet = new Set(owned.map((entry) => entry.id));
|
|
4592
|
-
|
|
5205
|
+
const live = (await this.listInventory()).filter((entry) => ownsEntryId(ownedSet, entry.entryId));
|
|
5206
|
+
if (live.length === 0) return {
|
|
4593
5207
|
ok: false,
|
|
4594
5208
|
detail: `dsh-plugin-shop: ${args.name} is installed but its entries are not in the running plugin tree; restart dsh to compose them`
|
|
4595
5209
|
};
|
|
5210
|
+
if (manager !== null) {
|
|
5211
|
+
if (this.hasRunningCommand()) return {
|
|
5212
|
+
ok: false,
|
|
5213
|
+
detail: switchWhileBusyDetail(args.name)
|
|
5214
|
+
};
|
|
5215
|
+
let restart = false;
|
|
5216
|
+
let switched = 0;
|
|
5217
|
+
const failed = (detail) => {
|
|
5218
|
+
if (switched === 0) return {
|
|
5219
|
+
ok: false,
|
|
5220
|
+
detail
|
|
5221
|
+
};
|
|
5222
|
+
return {
|
|
5223
|
+
ok: false,
|
|
5224
|
+
detail: `${/[.!?]$/.test(detail) ? detail : `${detail}.`} ${switched} of its ${live.length} plugins had already switched ${args.enabled ? "on" : "off"}.`
|
|
5225
|
+
};
|
|
5226
|
+
};
|
|
5227
|
+
for (const entry of live) {
|
|
5228
|
+
let raw;
|
|
5229
|
+
try {
|
|
5230
|
+
raw = await manager.setPluginEnabled(entry.entryId, args.enabled);
|
|
5231
|
+
} catch (error) {
|
|
5232
|
+
return failed(`dsh-plugin-shop: dsh could not switch ${args.name}${thrownTail(messageOf(error), isDesktopProfile(this.profile))}`);
|
|
5233
|
+
}
|
|
5234
|
+
const { change, failure } = this.readManagerAnswer(raw, {
|
|
5235
|
+
act: `switch ${args.name}`,
|
|
5236
|
+
request: `the switch for ${args.name}`,
|
|
5237
|
+
stays: args.enabled ? "off" : "on"
|
|
5238
|
+
});
|
|
5239
|
+
if (failure !== null) return failed(failure);
|
|
5240
|
+
switched += 1;
|
|
5241
|
+
if (change.application === "restart-required") restart = true;
|
|
5242
|
+
}
|
|
5243
|
+
if (restart) return {
|
|
5244
|
+
ok: true,
|
|
5245
|
+
activation: "restart"
|
|
5246
|
+
};
|
|
5247
|
+
return {
|
|
5248
|
+
ok: true,
|
|
5249
|
+
activation: activationOf({
|
|
5250
|
+
hostLive: true,
|
|
5251
|
+
clientLive: true,
|
|
5252
|
+
hasClientHalf: this.packageHasClientHalf(args.name)
|
|
5253
|
+
})
|
|
5254
|
+
};
|
|
5255
|
+
}
|
|
4596
5256
|
setUserLayerRows({
|
|
4597
5257
|
profileDir,
|
|
4598
5258
|
rows: owned.map(({ id, name }) => ({
|
|
@@ -4623,7 +5283,7 @@ let ShopGateway = (() => {
|
|
|
4623
5283
|
* not printed: it selects the bundle again only on 0.1.5, and it lets pnpm
|
|
4624
5284
|
* float a registry package to `latest`.
|
|
4625
5285
|
*/
|
|
4626
|
-
async setDeselectedEnabled(name, enabled, profileDir, owned) {
|
|
5286
|
+
async setDeselectedEnabled(name, enabled, profileDir, owned, manager) {
|
|
4627
5287
|
const rows = owned.map(({ id, name: moduleName }) => ({
|
|
4628
5288
|
id,
|
|
4629
5289
|
disabled: !enabled,
|
|
@@ -4639,23 +5299,31 @@ let ShopGateway = (() => {
|
|
|
4639
5299
|
activation: "live"
|
|
4640
5300
|
};
|
|
4641
5301
|
}
|
|
4642
|
-
const manager = this.pluginManager();
|
|
4643
5302
|
if (manager === null) return {
|
|
4644
5303
|
ok: false,
|
|
4645
5304
|
detail: `dsh-plugin-shop: ${name} is installed but switched off at the bundle level: dsh.profile.bundles in ${join(profileDir, "package.json")} does not list it, so dsh composes none of it, and a restart will not change that. This dsh has no pluginManager service to select it again: add "${name}" back to that list and restart dsh, or uninstall it and install it again from the shop.`
|
|
4646
5305
|
};
|
|
4647
|
-
|
|
5306
|
+
if (this.hasRunningCommand()) return {
|
|
5307
|
+
ok: false,
|
|
5308
|
+
detail: switchWhileBusyDetail(name)
|
|
5309
|
+
};
|
|
5310
|
+
let raw;
|
|
4648
5311
|
try {
|
|
4649
|
-
|
|
5312
|
+
raw = await manager.setBundleEnabled(name, true);
|
|
4650
5313
|
} catch (error) {
|
|
4651
5314
|
return {
|
|
4652
5315
|
ok: false,
|
|
4653
|
-
detail: `dsh-plugin-shop: dsh could not select ${name} again
|
|
5316
|
+
detail: `dsh-plugin-shop: dsh could not select ${name} again${thrownTail(messageOf(error), isDesktopProfile(this.profile))}`
|
|
4654
5317
|
};
|
|
4655
5318
|
}
|
|
4656
|
-
|
|
5319
|
+
const { change, failure } = this.readManagerAnswer(raw, {
|
|
5320
|
+
act: `select ${name} again`,
|
|
5321
|
+
request: `the selection of ${name}`,
|
|
5322
|
+
stays: "off"
|
|
5323
|
+
});
|
|
5324
|
+
if (failure !== null) return {
|
|
4657
5325
|
ok: false,
|
|
4658
|
-
detail:
|
|
5326
|
+
detail: failure
|
|
4659
5327
|
};
|
|
4660
5328
|
setUserLayerRows({
|
|
4661
5329
|
profileDir,
|
|
@@ -4674,6 +5342,47 @@ let ShopGateway = (() => {
|
|
|
4674
5342
|
})
|
|
4675
5343
|
};
|
|
4676
5344
|
}
|
|
5345
|
+
/**
|
|
5346
|
+
* dsh's answer to a switch or a bundle selection, read the one way both
|
|
5347
|
+
* callers need it (R32): the change, scrubbed for a desktop reader, and the
|
|
5348
|
+
* detail that fails the request, or null when dsh applied it. `act` is what
|
|
5349
|
+
* was asked, as "switch <name>", `request` names the request itself, as
|
|
5350
|
+
* "the switch for <name>", and `stays` is the state the plugin keeps when
|
|
5351
|
+
* the request does not take effect. An application this shop does not know
|
|
5352
|
+
* fails rather than reading as applied: a later harness may answer
|
|
5353
|
+
* something new.
|
|
5354
|
+
*
|
|
5355
|
+
* `overridden` fails too (R39). dsh answers it when, after it wrote the row
|
|
5356
|
+
* and reloaded, the live entry still differs from the request
|
|
5357
|
+
* (dsh-plugin-manager 0.1.7-rc.2, setPluginEnabled), and its README names
|
|
5358
|
+
* what outranks the profile's own patch: home and invocation patches keep
|
|
5359
|
+
* their higher priority. The change is saved and the plugin runs as it
|
|
5360
|
+
* did, so a success would flip the client's switch over a plugin that did
|
|
5361
|
+
* not change, and that switch never re-reads the live state.
|
|
5362
|
+
* setBundleEnabled never answers it; a selection reads it the same way.
|
|
5363
|
+
*/
|
|
5364
|
+
readManagerAnswer(raw, words) {
|
|
5365
|
+
const change = isDesktopProfile(this.profile) ? forDesktopReader(readChange(raw)) : readChange(raw);
|
|
5366
|
+
if (change.application === "failed" || change.application === "cancelled" || change.errorCode !== null) {
|
|
5367
|
+
const code = change.errorCode ?? change.application ?? "failed";
|
|
5368
|
+
return {
|
|
5369
|
+
change,
|
|
5370
|
+
failure: `dsh-plugin-shop: dsh ${code === "operation-error" ? "could not" : "refused to"} ${words.act} (${code})${codeReason(change, code)}`
|
|
5371
|
+
};
|
|
5372
|
+
}
|
|
5373
|
+
if (change.application === "overridden") return {
|
|
5374
|
+
change,
|
|
5375
|
+
failure: `dsh-plugin-shop: dsh saved ${words.request}, but a home or invocation patch, which outranks the profile's own, keeps it ${words.stays}.`
|
|
5376
|
+
};
|
|
5377
|
+
if (change.application === "applied" || change.application === "restart-required") return {
|
|
5378
|
+
change,
|
|
5379
|
+
failure: null
|
|
5380
|
+
};
|
|
5381
|
+
return {
|
|
5382
|
+
change,
|
|
5383
|
+
failure: `dsh-plugin-shop: dsh answered ${words.request} with "${change.application ?? "nothing"}", which this shop does not know how to read.`
|
|
5384
|
+
};
|
|
5385
|
+
}
|
|
4677
5386
|
rowConfig() {
|
|
4678
5387
|
if (this.options.catalogUrl !== void 0 && this.options.cacheDir !== void 0) return {
|
|
4679
5388
|
catalogUrl: this.options.catalogUrl,
|
|
@@ -4866,7 +5575,8 @@ let ShopGateway = (() => {
|
|
|
4866
5575
|
* passing request reaches the executor.
|
|
4867
5576
|
*/
|
|
4868
5577
|
async install(args) {
|
|
4869
|
-
|
|
5578
|
+
const manager = this.pluginManager();
|
|
5579
|
+
if (isDesktopProfile(this.profile) && manager === null) return {
|
|
4870
5580
|
ok: false,
|
|
4871
5581
|
code: "desktop-profile",
|
|
4872
5582
|
detail: DESKTOP_PROFILE_DETAIL
|
|
@@ -4897,31 +5607,29 @@ let ShopGateway = (() => {
|
|
|
4897
5607
|
};
|
|
4898
5608
|
spec = `github:${entry.repo}#${args.version}${entry.subdir !== void 0 ? `&path:${entry.subdir}` : ""}`;
|
|
4899
5609
|
} else spec = `${args.name}@${args.version}`;
|
|
4900
|
-
const
|
|
5610
|
+
const isUpdate = installedSpec !== void 0;
|
|
5611
|
+
const alreadyImported = isUpdate || this.imported.has(args.name);
|
|
4901
5612
|
const harness = await this.runningHarness();
|
|
5613
|
+
if (manager !== null) {
|
|
5614
|
+
const running = this.startManagerInstall(manager, spec, `install ${spec}`, (raw) => {
|
|
5615
|
+
if (!isUpdate && readChange(raw).stage === "enable") this.imported.add(args.name);
|
|
5616
|
+
return managerOutcome(raw, this.outcomeContext(args.name, isUpdate ? "update" : "install", alreadyImported, this.packageHasClientHalf(args.name)));
|
|
5617
|
+
}, () => this.postInstallHazard(args.name, harness, " It is on disk: uninstall it from the shop to undo this install."));
|
|
5618
|
+
this.track(running);
|
|
5619
|
+
this.recordGithubPin(entry);
|
|
5620
|
+
return {
|
|
5621
|
+
ok: true,
|
|
5622
|
+
installId: running.installId,
|
|
5623
|
+
state: running.status().state
|
|
5624
|
+
};
|
|
5625
|
+
}
|
|
4902
5626
|
const running = startInstall({
|
|
4903
5627
|
profile: this.profile,
|
|
4904
5628
|
spec,
|
|
4905
5629
|
dshBin: this.dshBin,
|
|
4906
5630
|
expectedName: args.name,
|
|
4907
5631
|
prefetcher: this.prefetcher,
|
|
4908
|
-
alsoConfirm: () => {
|
|
4909
|
-
const clash = collidingEntryId({
|
|
4910
|
-
profileDir: this.profileDirResolved(),
|
|
4911
|
-
packageName: args.name,
|
|
4912
|
-
dependencies: Object.keys(this.profileDependenciesOrNone() ?? {})
|
|
4913
|
-
});
|
|
4914
|
-
const undo = ` It is on disk: run \`dsh plugin --profile ${this.profile} remove ${args.name}\` to undo this install.`;
|
|
4915
|
-
if (clash !== null) return `dsh-plugin-shop: ${args.name} declares the loader entry id "${clash.id}", which ${clash.holder} already declares. dsh refuses to load a plugin tree holding a duplicate entry id, so the profile would not start.` + undo;
|
|
4916
|
-
switch (patchDeclarationHazard(declaredBundlePatch({
|
|
4917
|
-
profileDir: this.profileDirResolved(),
|
|
4918
|
-
packageName: args.name
|
|
4919
|
-
}), harness.patchLists)) {
|
|
4920
|
-
case null: return null;
|
|
4921
|
-
case "list-unsupported": return `dsh-plugin-shop: ${args.name} lists its bundle patch as several files, which dsh reads from 0.1.7 on; this dsh${harness.dshVersion === null ? "" : ` (${harness.dshVersion})`} reads one and would not start with it installed.` + undo;
|
|
4922
|
-
case "malformed": return `dsh-plugin-shop: ${args.name} declares its bundle patch as neither a file path nor a list of them, which dsh refuses to load, so the profile would not start.` + undo;
|
|
4923
|
-
}
|
|
4924
|
-
},
|
|
5632
|
+
alsoConfirm: () => this.postInstallHazard(args.name, harness, ` It is on disk: run \`dsh plugin --profile ${this.profile} remove ${args.name}\` to undo this install.`),
|
|
4925
5633
|
afterDone: async () => {
|
|
4926
5634
|
if (alreadyImported) return {
|
|
4927
5635
|
activation: "restart",
|
|
@@ -4944,22 +5652,116 @@ let ShopGateway = (() => {
|
|
|
4944
5652
|
}) };
|
|
4945
5653
|
}
|
|
4946
5654
|
});
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
writeRepoPins(this.pinFs, this.pinsPath(), {
|
|
4950
|
-
...pins,
|
|
4951
|
-
[identityKey(entry)]: entry.version
|
|
4952
|
-
});
|
|
4953
|
-
}
|
|
4954
|
-
this.installs.set(running.installId, running);
|
|
4955
|
-
this.installOrder.push(running.installId);
|
|
4956
|
-
this.evictFinishedInstalls();
|
|
5655
|
+
this.recordGithubPin(entry);
|
|
5656
|
+
this.track(running);
|
|
4957
5657
|
return {
|
|
4958
5658
|
ok: true,
|
|
4959
5659
|
installId: running.installId,
|
|
4960
5660
|
state: running.status().state
|
|
4961
5661
|
};
|
|
4962
5662
|
}
|
|
5663
|
+
/**
|
|
5664
|
+
* What would stop the profile from starting now that `name`'s files are on
|
|
5665
|
+
* disk, or null: an entry id another installed package already declares,
|
|
5666
|
+
* or a bundle patch declaration this dsh cannot read. Neither is knowable
|
|
5667
|
+
* before the files land. The package stays on disk either way, so each
|
|
5668
|
+
* detail ends with `undo`, the sentence that says how to take the install
|
|
5669
|
+
* back on the path that made it.
|
|
5670
|
+
*/
|
|
5671
|
+
postInstallHazard(name, harness, undo) {
|
|
5672
|
+
const clash = collidingEntryId({
|
|
5673
|
+
profileDir: this.profileDirResolved(),
|
|
5674
|
+
packageName: name,
|
|
5675
|
+
dependencies: Object.keys(this.profileDependenciesOrNone() ?? {})
|
|
5676
|
+
});
|
|
5677
|
+
if (clash !== null) return `dsh-plugin-shop: ${name} declares the loader entry id "${clash.id}", which ${clash.holder} already declares. dsh refuses to load a plugin tree holding a duplicate entry id, so the profile would not start.` + undo;
|
|
5678
|
+
switch (patchDeclarationHazard(declaredBundlePatch({
|
|
5679
|
+
profileDir: this.profileDirResolved(),
|
|
5680
|
+
packageName: name
|
|
5681
|
+
}), harness.patchLists)) {
|
|
5682
|
+
case null: return null;
|
|
5683
|
+
case "list-unsupported": return `dsh-plugin-shop: ${name} lists its bundle patch as several files, which dsh reads from 0.1.7 on; this dsh${harness.dshVersion === null ? "" : ` (${harness.dshVersion})`} reads one and would not start with it installed.` + undo;
|
|
5684
|
+
case "malformed": return `dsh-plugin-shop: ${name} declares its bundle patch as neither a file path nor a list of them, which dsh refuses to load, so the profile would not start.` + undo;
|
|
5685
|
+
}
|
|
5686
|
+
}
|
|
5687
|
+
/** Remember a github entry's pinned commit: the manifest records only
|
|
5688
|
+
* `github:owner/slug`, so the pins file is how `installed()` reports
|
|
5689
|
+
* outdated honestly. A failed install leaves a pin behind, but the
|
|
5690
|
+
* manifest presence gate keeps it invisible. */
|
|
5691
|
+
recordGithubPin(entry) {
|
|
5692
|
+
if (entry.source !== "github") return;
|
|
5693
|
+
const pins = readRepoPins(this.pinFs, this.pinsPath());
|
|
5694
|
+
writeRepoPins(this.pinFs, this.pinsPath(), {
|
|
5695
|
+
...pins,
|
|
5696
|
+
[identityKey(entry)]: entry.version
|
|
5697
|
+
});
|
|
5698
|
+
}
|
|
5699
|
+
/** Forget an uninstalled package's pins: its own, and its identity pin when
|
|
5700
|
+
* a catalog row matched the installed spec. Both uninstall paths call this
|
|
5701
|
+
* once their operation has started, so a stale pin never outlives the
|
|
5702
|
+
* uninstall in the shop's cache either way. The plugin manager path tracks
|
|
5703
|
+
* its record first, so a write here that throws cannot hide an operation
|
|
5704
|
+
* already queued. */
|
|
5705
|
+
forgetPins(name, installedEntry) {
|
|
5706
|
+
const pins = readRepoPins(this.pinFs, this.pinsPath());
|
|
5707
|
+
const stalePins = [name, ...installedEntry === void 0 ? [] : [identityKey(installedEntry)]];
|
|
5708
|
+
let forgot = false;
|
|
5709
|
+
for (const key of stalePins) {
|
|
5710
|
+
if (pins[key] === void 0) continue;
|
|
5711
|
+
delete pins[key];
|
|
5712
|
+
forgot = true;
|
|
5713
|
+
}
|
|
5714
|
+
if (forgot) writeRepoPins(this.pinFs, this.pinsPath(), pins);
|
|
5715
|
+
}
|
|
5716
|
+
/** What `managerOutcome` reads dsh's answer against. The gateway's own
|
|
5717
|
+
* facts are set here once, for every operation through the service: the
|
|
5718
|
+
* profile, whether it is the desktop one, and the deadline a timeout
|
|
5719
|
+
* detail names. */
|
|
5720
|
+
outcomeContext(name, operation, alreadyImported, hasClientHalf) {
|
|
5721
|
+
return {
|
|
5722
|
+
profile: this.profile,
|
|
5723
|
+
name,
|
|
5724
|
+
operation,
|
|
5725
|
+
alreadyImported,
|
|
5726
|
+
hasClientHalf,
|
|
5727
|
+
desktop: isDesktopProfile(this.profile),
|
|
5728
|
+
timeoutMs: INSTALL_TIMEOUT_MS
|
|
5729
|
+
};
|
|
5730
|
+
}
|
|
5731
|
+
/**
|
|
5732
|
+
* One install-shaped operation through dsh's `pluginManager` (design
|
|
5733
|
+
* 2026-09-26-plugin-manager-delegation, section 6): `installBundle(spec)`
|
|
5734
|
+
* under the record's own id as its request id, so the output dsh streams
|
|
5735
|
+
* for it reaches this record, in the profile's queue with the download
|
|
5736
|
+
* phase the CLI path has. At the shop's deadline dsh is asked to cancel it;
|
|
5737
|
+
* a service that offers no `cancelInstall` gets no deadline, and its record
|
|
5738
|
+
* settles when dsh answers. `install` and `updateStart` differ only in the
|
|
5739
|
+
* mechanism line and in how they read the answer.
|
|
5740
|
+
*/
|
|
5741
|
+
startManagerInstall(manager, spec, mechanism, outcome, alsoConfirm) {
|
|
5742
|
+
gateOperand(spec);
|
|
5743
|
+
const cancel = manager.cancelInstall?.bind(manager);
|
|
5744
|
+
return startManagerOperation({
|
|
5745
|
+
profile: this.profile,
|
|
5746
|
+
requestId: randomUUID(),
|
|
5747
|
+
mechanism,
|
|
5748
|
+
logs: this.managerLogs,
|
|
5749
|
+
run: (requestId) => manager.installBundle(spec, { requestId }),
|
|
5750
|
+
...cancel !== void 0 ? { cancel } : {},
|
|
5751
|
+
outcome,
|
|
5752
|
+
...alsoConfirm !== void 0 ? { alsoConfirm } : {},
|
|
5753
|
+
prefetcher: this.prefetcher,
|
|
5754
|
+
spec,
|
|
5755
|
+
desktop: isDesktopProfile(this.profile)
|
|
5756
|
+
});
|
|
5757
|
+
}
|
|
5758
|
+
/** Keep a started record where `installStatus`, the restart gate and
|
|
5759
|
+
* eviction find it, then bound the finished ones. */
|
|
5760
|
+
track(running) {
|
|
5761
|
+
this.installs.set(running.installId, running);
|
|
5762
|
+
this.installOrder.push(running.installId);
|
|
5763
|
+
this.evictFinishedInstalls();
|
|
5764
|
+
}
|
|
4963
5765
|
/** Bound retained finished records at MAX_FINISHED_INSTALLS, evicting the
|
|
4964
5766
|
* oldest finished ones (insertion order, oldest first). Live records —
|
|
4965
5767
|
* running AND queued — are never evicted; an id absent from the map reports
|
|
@@ -5100,7 +5902,8 @@ let ShopGateway = (() => {
|
|
|
5100
5902
|
* dependencies the shop does not manage (the base bundle, the shop
|
|
5101
5903
|
* itself). The same install records/polling serve the client. */
|
|
5102
5904
|
async uninstall(args) {
|
|
5103
|
-
|
|
5905
|
+
const manager = this.pluginManager();
|
|
5906
|
+
if (isDesktopProfile(this.profile) && manager === null) return {
|
|
5104
5907
|
ok: false,
|
|
5105
5908
|
detail: DESKTOP_PROFILE_DETAIL
|
|
5106
5909
|
};
|
|
@@ -5117,6 +5920,23 @@ let ShopGateway = (() => {
|
|
|
5117
5920
|
const installedEntry = named.find((entry) => installedSpecMatches(entry, spec));
|
|
5118
5921
|
const priorEntryIds = this.ownedEntryIdsOrNone(args.name);
|
|
5119
5922
|
const hadClientHalf = this.packageHasClientHalf(args.name);
|
|
5923
|
+
if (manager !== null) {
|
|
5924
|
+
const running = startManagerOperation({
|
|
5925
|
+
profile: this.profile,
|
|
5926
|
+
requestId: randomUUID(),
|
|
5927
|
+
mechanism: `remove ${args.name}`,
|
|
5928
|
+
logs: this.managerLogs,
|
|
5929
|
+
run: () => manager.removeBundle(args.name),
|
|
5930
|
+
outcome: (raw) => managerOutcome(raw, this.outcomeContext(args.name, "uninstall", false, hadClientHalf)),
|
|
5931
|
+
desktop: isDesktopProfile(this.profile)
|
|
5932
|
+
});
|
|
5933
|
+
this.track(running);
|
|
5934
|
+
this.forgetPins(args.name, installedEntry);
|
|
5935
|
+
return {
|
|
5936
|
+
ok: true,
|
|
5937
|
+
installId: running.installId
|
|
5938
|
+
};
|
|
5939
|
+
}
|
|
5120
5940
|
const running = startUninstall({
|
|
5121
5941
|
profile: this.profile,
|
|
5122
5942
|
name: args.name,
|
|
@@ -5133,18 +5953,8 @@ let ShopGateway = (() => {
|
|
|
5133
5953
|
}) };
|
|
5134
5954
|
}
|
|
5135
5955
|
});
|
|
5136
|
-
|
|
5137
|
-
|
|
5138
|
-
let forgot = false;
|
|
5139
|
-
for (const key of stalePins) {
|
|
5140
|
-
if (pins[key] === void 0) continue;
|
|
5141
|
-
delete pins[key];
|
|
5142
|
-
forgot = true;
|
|
5143
|
-
}
|
|
5144
|
-
if (forgot) writeRepoPins(this.pinFs, this.pinsPath(), pins);
|
|
5145
|
-
this.installs.set(running.installId, running);
|
|
5146
|
-
this.installOrder.push(running.installId);
|
|
5147
|
-
this.evictFinishedInstalls();
|
|
5956
|
+
this.forgetPins(args.name, installedEntry);
|
|
5957
|
+
this.track(running);
|
|
5148
5958
|
return {
|
|
5149
5959
|
ok: true,
|
|
5150
5960
|
installId: running.installId
|
|
@@ -5156,6 +5966,10 @@ let ShopGateway = (() => {
|
|
|
5156
5966
|
* response is out. The browser monitors the origin and refreshes when the
|
|
5157
5967
|
* new server answers. Refusals are issued before anything is torn down. */
|
|
5158
5968
|
async restart() {
|
|
5969
|
+
if (this.committedRestart !== null) return {
|
|
5970
|
+
ok: true,
|
|
5971
|
+
logFile: this.committedRestart.logFile
|
|
5972
|
+
};
|
|
5159
5973
|
if (this.hasRunningCommand()) return {
|
|
5160
5974
|
ok: false,
|
|
5161
5975
|
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."
|
|
@@ -5189,6 +6003,7 @@ let ShopGateway = (() => {
|
|
|
5189
6003
|
detail: `dsh-plugin-shop: restart could not be started: ${error.message}`
|
|
5190
6004
|
};
|
|
5191
6005
|
}
|
|
6006
|
+
this.committedRestart = { logFile };
|
|
5192
6007
|
setTimeout(() => this.exit(0), this.restartExitDelayMs);
|
|
5193
6008
|
return {
|
|
5194
6009
|
ok: true,
|
|
@@ -5237,7 +6052,8 @@ let ShopGateway = (() => {
|
|
|
5237
6052
|
* version is re-validated as plain semver at the boundary — the spec
|
|
5238
6053
|
* `dsh-plugin-shop@<version>` is built here, never from the wire. */
|
|
5239
6054
|
async updateStart(args) {
|
|
5240
|
-
|
|
6055
|
+
const manager = this.pluginManager();
|
|
6056
|
+
if (isDesktopProfile(this.profile) && manager === null) return {
|
|
5241
6057
|
ok: false,
|
|
5242
6058
|
detail: DESKTOP_PROFILE_DETAIL
|
|
5243
6059
|
};
|
|
@@ -5245,16 +6061,24 @@ let ShopGateway = (() => {
|
|
|
5245
6061
|
ok: false,
|
|
5246
6062
|
detail: `dsh-plugin-shop: ${args.version} is not a valid version`
|
|
5247
6063
|
};
|
|
6064
|
+
const spec = `dsh-plugin-shop@${args.version}`;
|
|
6065
|
+
if (manager !== null) {
|
|
6066
|
+
const running = this.startManagerInstall(manager, spec, `update ${spec}`, (raw) => managerOutcome(raw, this.outcomeContext("dsh-plugin-shop", "update", true, true)));
|
|
6067
|
+
this.track(running);
|
|
6068
|
+
return {
|
|
6069
|
+
ok: true,
|
|
6070
|
+
installId: running.installId,
|
|
6071
|
+
state: running.status().state
|
|
6072
|
+
};
|
|
6073
|
+
}
|
|
5248
6074
|
const running = startInstall({
|
|
5249
6075
|
profile: this.profile,
|
|
5250
|
-
spec
|
|
6076
|
+
spec,
|
|
5251
6077
|
dshBin: this.dshBin,
|
|
5252
6078
|
expectedName: "dsh-plugin-shop",
|
|
5253
6079
|
prefetcher: this.prefetcher
|
|
5254
6080
|
});
|
|
5255
|
-
this.
|
|
5256
|
-
this.installOrder.push(running.installId);
|
|
5257
|
-
this.evictFinishedInstalls();
|
|
6081
|
+
this.track(running);
|
|
5258
6082
|
return {
|
|
5259
6083
|
ok: true,
|
|
5260
6084
|
installId: running.installId,
|