dsh-plugin-shop 0.8.3 → 0.8.4-beta.1
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 +11 -8
- package/lib/client.js +1282 -443
- package/lib/index.js +2296 -250
- package/lib/typert.host.js +69 -22
- package/lib/typert.remote-client.js +52 -13
- package/lib/types/client/ShopTab.d.ts +19 -2
- package/lib/types/client/locales.d.ts +44 -2
- package/lib/types/client/module-table.d.ts +131 -0
- package/lib/types/client/present.d.ts +127 -2
- package/lib/types/client/restart-monitor.d.ts +114 -0
- package/lib/types/host/activation.d.ts +12 -9
- package/lib/types/host/bundle-patch.d.ts +35 -0
- package/lib/types/host/catalog.d.ts +9 -3
- package/lib/types/host/compatibility.d.ts +223 -0
- package/lib/types/host/dsh-cli.d.ts +8 -0
- package/lib/types/host/executor.d.ts +50 -3
- package/lib/types/host/harness.d.ts +109 -0
- package/lib/types/host/hot.d.ts +8 -8
- package/lib/types/host/index.d.ts +259 -29
- package/lib/types/host/install.d.ts +1 -1
- package/lib/types/host/manager-runner.d.ts +51 -0
- package/lib/types/host/peers.d.ts +181 -10
- package/lib/types/host/plugin-manager.d.ts +100 -0
- package/lib/types/host/profile.d.ts +61 -4
- package/lib/types/host/restart.d.ts +5 -1
- package/lib/types/host/types.d.ts +27 -1
- package/lib/types/own-version.d.ts +18 -2
- package/package.json +2 -1
package/lib/index.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
3
3
|
import { loadOptionalPatches, readProfileManifest, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
4
|
-
import { lt, minVersion, satisfies, valid, validRange } from "semver";
|
|
4
|
+
import { lt, minVersion, parse, satisfies, valid, validRange } from "semver";
|
|
5
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
-
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
-
import { basename, delimiter, dirname, isAbsolute, join, posix, relative, resolve, win32 } from "node:path";
|
|
8
|
+
import { basename, delimiter, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { gunzipSync } from "node:zlib";
|
|
12
12
|
import { spawn, spawnSync } from "node:child_process";
|
|
13
13
|
import { StringDecoder } from "node:string_decoder";
|
|
14
|
-
import {
|
|
14
|
+
import { JSON_SCHEMA, Type, dump, load } from "js-yaml";
|
|
15
|
+
import { isMap, isSeq, parseDocument } from "yaml";
|
|
15
16
|
//#region src/own-version.ts
|
|
16
17
|
/** The shop's own published version, read from the package.json that ships
|
|
17
18
|
* next to this package — the RUNNING version, not the manifest's range
|
|
@@ -22,12 +23,34 @@ import { DEFAULT_SCHEMA, JSON_SCHEMA, Type, dump, load } from "js-yaml";
|
|
|
22
23
|
function ownManifest() {
|
|
23
24
|
return JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
|
|
24
25
|
}
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Read a version once and keep it: the answer to "which code is this".
|
|
28
|
+
*
|
|
29
|
+
* The file cannot answer that question later. A profile installs hoisted, so
|
|
30
|
+
* a self-update rewrites package.json in the directory the RUNNING code was
|
|
31
|
+
* loaded from, and every read after it names the version that will run after
|
|
32
|
+
* the next restart — which the version row then printed as the one running
|
|
33
|
+
* (design §7.3, 2026-09-27 amendment).
|
|
34
|
+
*
|
|
35
|
+
* A first read that throws is not kept: the module import must not fail over
|
|
36
|
+
* a version string, so the answer falls back to reading on demand, which is
|
|
37
|
+
* what the row always did.
|
|
38
|
+
*/
|
|
39
|
+
function versionAtLoad(read) {
|
|
40
|
+
let loaded;
|
|
41
|
+
try {
|
|
42
|
+
loaded = read();
|
|
43
|
+
} catch {
|
|
44
|
+
loaded = null;
|
|
45
|
+
}
|
|
46
|
+
return () => loaded ?? read();
|
|
27
47
|
}
|
|
48
|
+
/** The version of the code in this process, read when the module was first
|
|
49
|
+
* evaluated — at boot, before any self-update could rewrite the file. */
|
|
50
|
+
const runningVersion = versionAtLoad(() => ownManifest().version);
|
|
28
51
|
/**
|
|
29
52
|
* The peer ranges this build declares — the input to the load-time harness
|
|
30
|
-
* self-check. Read from the same shipped manifest as `
|
|
53
|
+
* self-check. Read from the same shipped manifest as `runningVersion`, so the
|
|
31
54
|
* ranges checked are the ones this build was actually published with, never
|
|
32
55
|
* a second copy that can drift from them.
|
|
33
56
|
*
|
|
@@ -609,6 +632,11 @@ const entrySchema = z.object({
|
|
|
609
632
|
}).optional(),
|
|
610
633
|
publisher: z.string().optional(),
|
|
611
634
|
peers: z.array(z.string()).optional(),
|
|
635
|
+
compatibility: z.object({
|
|
636
|
+
dsh: z.string().optional(),
|
|
637
|
+
profiles: z.array(z.string()).optional()
|
|
638
|
+
}).optional(),
|
|
639
|
+
dshPeers: z.record(z.string(), z.string()).optional(),
|
|
612
640
|
unpackedSize: z.number().int().nonnegative().optional(),
|
|
613
641
|
installSize: z.number().int().nonnegative().optional()
|
|
614
642
|
}).superRefine((entry, ctx) => {
|
|
@@ -1034,6 +1062,248 @@ function validateInstall(snapshot, args, installedSpec) {
|
|
|
1034
1062
|
};
|
|
1035
1063
|
}
|
|
1036
1064
|
//#endregion
|
|
1065
|
+
//#region src/host/compatibility.ts
|
|
1066
|
+
/** Harness compatibility as the AUTHOR states it: which `dsh.compatibility`
|
|
1067
|
+
* declarations the running installation does not meet (design
|
|
1068
|
+
* 2026-09-01-harness-compatibility §8.2, as amended §9.9). The presence check
|
|
1069
|
+
* in `peers.ts` infers from what a plugin imports; this reads what its author
|
|
1070
|
+
* wrote down. And, from dsh 0.1.7 on, what dsh ITSELF refuses: the harness
|
|
1071
|
+
* peers its installer rejects an install on (`peerVerdictsOf`, design
|
|
1072
|
+
* 2026-09-26-dsh-017-readiness, B1).
|
|
1073
|
+
*
|
|
1074
|
+
* Pure: the running version, the running profile, the harness's own
|
|
1075
|
+
* template table and its peer check arrive as arguments, so fixtures drive
|
|
1076
|
+
* every verdict and the reads of the installation stay outside — the running
|
|
1077
|
+
* dsh, its template table and its peer check in `harness.ts`, the running
|
|
1078
|
+
* profile's bundles and exemptions in the gateway. */
|
|
1079
|
+
/**
|
|
1080
|
+
* Normalize the harness's OWN `PROFILE_TEMPLATES` into template name → bundle
|
|
1081
|
+
* names, or an empty record for a shape this build does not know.
|
|
1082
|
+
*
|
|
1083
|
+
* Two shapes exist and both are live: 0.1.5-rc.3 maps a name to
|
|
1084
|
+
* `{ bundles, patchReload }` (five templates), while the version this package
|
|
1085
|
+
* pins as a devDependency maps it to a bare array (two). A name whose value is
|
|
1086
|
+
* neither, or whose bundle list holds a non-string, is dropped rather than
|
|
1087
|
+
* guessed at — an unreadable table means no profile verdict, never a wrong
|
|
1088
|
+
* one. Copied from nowhere: the caller passes the harness's exported object.
|
|
1089
|
+
*
|
|
1090
|
+
* The record has NO PROTOTYPE, empty answer included. It is indexed by the
|
|
1091
|
+
* profile names a catalog entry declares, which are hostile input, and a
|
|
1092
|
+
* record built on `{}` answered `constructor`, `__proto__`, `toString`,
|
|
1093
|
+
* `hasOwnProperty` and `valueOf` from `Object.prototype`: a value that passed
|
|
1094
|
+
* the `undefined` guard and threw out of `compatibilityMap`, so one entry
|
|
1095
|
+
* declaring `profiles: ["constructor"]` rejected every `catalog()` call for
|
|
1096
|
+
* every user. Without a prototype there is also no `__proto__` setter, so a
|
|
1097
|
+
* template exported under that name stays an own key instead of replacing
|
|
1098
|
+
* the record's prototype. `unmetProfile` takes own keys only as well — the
|
|
1099
|
+
* type is exported, and a caller may hand in a plain object.
|
|
1100
|
+
*/
|
|
1101
|
+
function profileTemplatesOf(exported) {
|
|
1102
|
+
const templates = Object.create(null);
|
|
1103
|
+
if (exported === null || typeof exported !== "object" || Array.isArray(exported)) return templates;
|
|
1104
|
+
for (const [name, value] of Object.entries(exported)) {
|
|
1105
|
+
const bundles = Array.isArray(value) ? value : value !== null && typeof value === "object" ? value.bundles : void 0;
|
|
1106
|
+
if (!Array.isArray(bundles)) continue;
|
|
1107
|
+
if (!bundles.every((bundle) => typeof bundle === "string")) continue;
|
|
1108
|
+
templates[name] = bundles;
|
|
1109
|
+
}
|
|
1110
|
+
return templates;
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Install identity → the half or halves of an entry's declaration this
|
|
1114
|
+
* installation does not meet. A key is present only when something was
|
|
1115
|
+
* declared AND is unmet, so an absent key means "runs here, or we could not
|
|
1116
|
+
* tell" — the rule `incompatibilityMap` follows, and for the same reason: one
|
|
1117
|
+
* false warning teaches a reader to ignore every warning.
|
|
1118
|
+
*
|
|
1119
|
+
* The halves are judged apart, and an unknown on either side silences only its
|
|
1120
|
+
* own half:
|
|
1121
|
+
*
|
|
1122
|
+
* - `dsh` needs a range semver can parse and a running version that is semver,
|
|
1123
|
+
* and is then `satisfies` with `includePrerelease`. That option is
|
|
1124
|
+
* load-bearing: the harness ships nothing but prereleases, and strict semver
|
|
1125
|
+
* refuses a prerelease against any range whose comparators carry none —
|
|
1126
|
+
* `0.1.5-rc.3` fails `>=0.1.0`, and even `*` — so every author who wrote a
|
|
1127
|
+
* plain range would be told they exclude a harness their range includes.
|
|
1128
|
+
*
|
|
1129
|
+
* What the option does NOT do is read a range the way its author probably
|
|
1130
|
+
* meant it. Under it a prerelease is compared like any other version, and a
|
|
1131
|
+
* prerelease sorts BELOW its own release (measured with semver 7.8.5; the
|
|
1132
|
+
* rows are pinned in compatibility.test.ts):
|
|
1133
|
+
* - An upper bound written without `-0` admits the next line's
|
|
1134
|
+
* prereleases: `<0.2.0` and `>=0.1.0 <0.2.0` both admit `0.2.0-rc.1`.
|
|
1135
|
+
* Only a bound that desugars to `<0.2.0-0` refuses it — `^0.1.0`,
|
|
1136
|
+
* `~0.1.0` and `0.1.x` do, as does `<0.2.0-0` written out.
|
|
1137
|
+
* - A floor written without `-0` refuses its own prereleases: `>=0.1.5`,
|
|
1138
|
+
* `^0.1.5` and `0.1.5` all refuse `0.1.5-rc.3`, because `0.1.5` sorts
|
|
1139
|
+
* above it. `>=0.1.5-0` admits every `0.1.5-rc.N`.
|
|
1140
|
+
* So `>=0.1.5-0` and `<0.2.0-0` are the spellings that say what an author
|
|
1141
|
+
* usually means. The comparison stays semver's own and is never rewritten
|
|
1142
|
+
* here: a verdict that coerced prereleases would answer differently from
|
|
1143
|
+
* semver, which is what an author checks a range against. An exact list of
|
|
1144
|
+
* other versions — `@xmanrui/dsh-im`'s declaration — is refused, as it
|
|
1145
|
+
* should be.
|
|
1146
|
+
* - `profile` is met when the running profile's NAME is one of the declared
|
|
1147
|
+
* names, or when it composes every bundle of a declared template.
|
|
1148
|
+
*
|
|
1149
|
+
* The name is trusted for the names dsh ships, because for those it is not
|
|
1150
|
+
* the reader's choice. On 0.1.5-rc.3 both paths that create a missing
|
|
1151
|
+
* profile build one of a shipped name from that name's template
|
|
1152
|
+
* (dsh-app-boot's `loadProfile`, and `dsh plugin`), and dsh refuses a
|
|
1153
|
+
* shipped name as the target of `--from-default-profile`, so a profile
|
|
1154
|
+
* carrying one was built from that template. That is all the name
|
|
1155
|
+
* guarantees: which template the profile was built from, not what it
|
|
1156
|
+
* composes now. Nothing holds the bundle list to the template afterwards:
|
|
1157
|
+
* `dsh plugin`'s reconcile, not app-boot, appends each bundle it installs,
|
|
1158
|
+
* and app-boot's `normalizeShippedProfile` rewrites only a list that is
|
|
1159
|
+
* exactly a retired tuple or the current one. Bundles alone could not
|
|
1160
|
+
* decide there: a profile keeps the bundle list it was created with, so the
|
|
1161
|
+
* first harness release that added a bundle to `web` would badge every
|
|
1162
|
+
* `profiles: ["web"]` plugin (`@xmanrui/dsh-im`) on every existing `web`
|
|
1163
|
+
* profile, with copy saying it supports web and this dsh was launched with
|
|
1164
|
+
* web.
|
|
1165
|
+
*
|
|
1166
|
+
* Any other name is the reader's choice — `dsh --profile rescue
|
|
1167
|
+
* --from-default-profile web` builds a profile called `rescue` from the web
|
|
1168
|
+
* bundles, and dsh records nothing about which template it came from — so a
|
|
1169
|
+
* running name no declaration matches is judged by what the profile IS:
|
|
1170
|
+
* each declared name is read as one of the harness's own templates, met
|
|
1171
|
+
* when every bundle of that template is in the running profile's bundles. A
|
|
1172
|
+
* declared name that is no template — an own key of the table, never an
|
|
1173
|
+
* inherited one — cannot be judged, and a list with such a name and no met
|
|
1174
|
+
* template gives no verdict. The half is unmet only when the running name is
|
|
1175
|
+
* not declared and every declared name is a template this profile does not
|
|
1176
|
+
* compose — see the amendment for the whole rule.
|
|
1177
|
+
*
|
|
1178
|
+
* The residual, knowingly kept: a custom-named profile is still judged by
|
|
1179
|
+
* bundles, so a template that later gained a bundle would badge the custom
|
|
1180
|
+
* profiles created before the change. No PUBLISHED
|
|
1181
|
+
* `@deepseek-ai/dsh-app-boot` had changed a template's bundle list as of
|
|
1182
|
+
* 2026-09-25: all 28 versions, read that day, give every template they
|
|
1183
|
+
* share the same list. One list was retired before the first publish:
|
|
1184
|
+
* every published app-boot, from the first, carries a retired `headless`
|
|
1185
|
+
* tuple (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`,
|
|
1186
|
+
* `@deepseek-ai/dsh-headless`) in `INSTALLATION_OWNED_PROFILE_TUPLES`, and
|
|
1187
|
+
* `normalizeShippedProfile` rewrites a `headless` profile carrying exactly
|
|
1188
|
+
* that list to the current template when it loads.
|
|
1189
|
+
*/
|
|
1190
|
+
function compatibilityMap(entries, runtime, templates) {
|
|
1191
|
+
const running = runtime.dshVersion !== null && valid(runtime.dshVersion) !== null ? runtime.dshVersion : null;
|
|
1192
|
+
const out = {};
|
|
1193
|
+
for (const entry of entries) {
|
|
1194
|
+
const declared = entry.compatibility;
|
|
1195
|
+
if (declared === void 0) continue;
|
|
1196
|
+
const verdict = {};
|
|
1197
|
+
const range = declared.dsh;
|
|
1198
|
+
if (range !== void 0 && running !== null && validRange(range) !== null && !satisfies(running, range, { includePrerelease: true })) verdict.dsh = {
|
|
1199
|
+
range,
|
|
1200
|
+
running
|
|
1201
|
+
};
|
|
1202
|
+
const unmet = unmetProfile(declared.profiles, runtime.profile, templates);
|
|
1203
|
+
if (unmet !== null) verdict.profile = {
|
|
1204
|
+
declared: [...unmet],
|
|
1205
|
+
running: runtime.profile.name
|
|
1206
|
+
};
|
|
1207
|
+
if (verdict.dsh !== void 0 || verdict.profile !== void 0) out[identityKey(entry)] = verdict;
|
|
1208
|
+
}
|
|
1209
|
+
return out;
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* The declared profile list to report, or null when nothing is owed: met,
|
|
1213
|
+
* unjudgeable, or nothing declared. See `compatibilityMap` for the rule, why
|
|
1214
|
+
* a matching name is enough, and why bundles decide for any other name.
|
|
1215
|
+
*/
|
|
1216
|
+
function unmetProfile(declared, running, templates) {
|
|
1217
|
+
if (declared === void 0 || declared.length === 0) return null;
|
|
1218
|
+
if (declared.includes(running.name)) return null;
|
|
1219
|
+
if (running.bundles === null) return null;
|
|
1220
|
+
const composed = new Set(running.bundles);
|
|
1221
|
+
let unjudged = false;
|
|
1222
|
+
for (const name of declared) {
|
|
1223
|
+
const bundles = Object.hasOwn(templates, name) ? templates[name] : void 0;
|
|
1224
|
+
if (bundles === void 0) {
|
|
1225
|
+
unjudged = true;
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
if (bundles.every((bundle) => composed.has(bundle))) return null;
|
|
1229
|
+
}
|
|
1230
|
+
return unjudged ? null : declared;
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Install identity → the refusal the running dsh will make, for each entry
|
|
1234
|
+
* whose harness peers it refuses and the profile has not exempted. A key is
|
|
1235
|
+
* present only for a refusal, so an absent key means "dsh installs it, or we
|
|
1236
|
+
* could not tell".
|
|
1237
|
+
*
|
|
1238
|
+
* The rule is dsh's own (`check`, from the running app-boot), handed the
|
|
1239
|
+
* manifest dsh's installer reads before it installs a registry spec — `pnpm
|
|
1240
|
+
* view <spec> name version peerDependencies`, measured in dsh-plugin-manager
|
|
1241
|
+
* 0.1.7-rc.2 — with the catalog's `dshPeers` for the peers. Those are the
|
|
1242
|
+
* only peers the rule reads, verbatim. What this cannot see errs toward
|
|
1243
|
+
* silence, never toward a refusal dsh would not make: a malformed peer range
|
|
1244
|
+
* elsewhere in the manifest, which dsh rejects only after installing, and
|
|
1245
|
+
* the plugins a bundle's patch rows name, which it checks then too. Those
|
|
1246
|
+
* reach the reader as the install's own failure (`installFailureDetail`).
|
|
1247
|
+
*
|
|
1248
|
+
* npm entries only. dsh keys an exemption by the INSTALLED manifest's
|
|
1249
|
+
* `name@version`, and a github entry's catalog version is a commit: its
|
|
1250
|
+
* exemption status cannot be read, and a refusal named here could never be
|
|
1251
|
+
* cleared from the shop. dsh refuses such an install itself, and says how
|
|
1252
|
+
* to exempt it.
|
|
1253
|
+
*
|
|
1254
|
+
* A throw from the check forms no verdict for that entry, as a resolver's
|
|
1255
|
+
* throw does in `incompatibilityMap`.
|
|
1256
|
+
*/
|
|
1257
|
+
function peerVerdictsOf(entries, check, exemptions, profile) {
|
|
1258
|
+
const out = {};
|
|
1259
|
+
for (const entry of entries) {
|
|
1260
|
+
if (entry.source !== "npm" || entry.dshPeers === void 0) continue;
|
|
1261
|
+
let issue;
|
|
1262
|
+
try {
|
|
1263
|
+
issue = check.evaluate({
|
|
1264
|
+
name: entry.name,
|
|
1265
|
+
version: entry.version,
|
|
1266
|
+
peerDependencies: entry.dshPeers
|
|
1267
|
+
}, exemptions);
|
|
1268
|
+
} catch {
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
if (issue === void 0 || issue.exempted) continue;
|
|
1272
|
+
out[identityKey(entry)] = {
|
|
1273
|
+
refused: { ...issue.peers },
|
|
1274
|
+
running: issue.runtimeVersion,
|
|
1275
|
+
allowCommand: allowVersionCommand(profile, issue)
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
return out;
|
|
1279
|
+
}
|
|
1280
|
+
/** npm's package-name grammar as dsh's exemption records accept it, verbatim
|
|
1281
|
+
* from app-boot 0.1.7-rc.2 (`PACKAGE_NAME` in profile-compatibility). */
|
|
1282
|
+
const EXEMPTION_PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
1283
|
+
/** A version exactly as dsh's exemption records spell one: canonical semver,
|
|
1284
|
+
* build metadata included (app-boot's `isExactPluginVersion`). */
|
|
1285
|
+
function isExactVersion(value) {
|
|
1286
|
+
const parsed = parse(value);
|
|
1287
|
+
return parsed !== null && value === `${parsed.version}${parsed.build.length === 0 ? "" : `+${parsed.build.join(".")}`}`;
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* The command that records dsh's exact-version exemption for one refused
|
|
1291
|
+
* install, spelled as dsh's own CLI prints it after a refusal — or null when
|
|
1292
|
+
* `dsh plugin allow-version` would refuse the package or either version,
|
|
1293
|
+
* which it checks against the grammar above. The name and version come from
|
|
1294
|
+
* the catalog, which is hostile input bound for a terminal, and the two
|
|
1295
|
+
* grammars admit no whitespace, quote, `$`, backtick, `;`, `|`, `&`,
|
|
1296
|
+
* redirection or bracket, so no catalog value can add a command to the line
|
|
1297
|
+
* it is pasted into.
|
|
1298
|
+
*
|
|
1299
|
+
* The profile is written as it is, as dsh prints it: it is the reader's own
|
|
1300
|
+
* choice of name, never catalog input.
|
|
1301
|
+
*/
|
|
1302
|
+
function allowVersionCommand(profile, issue) {
|
|
1303
|
+
if (!EXEMPTION_PACKAGE_NAME.test(issue.name) || !isExactVersion(issue.version) || !isExactVersion(issue.runtimeVersion)) return null;
|
|
1304
|
+
return `dsh plugin --profile ${profile} allow-version ${issue.name}@${issue.version} --dsh-version ${issue.runtimeVersion} --accept-risk`;
|
|
1305
|
+
}
|
|
1306
|
+
//#endregion
|
|
1037
1307
|
//#region src/host/dsh-cli.ts
|
|
1038
1308
|
/**
|
|
1039
1309
|
* How to start the dsh CLI as a child process.
|
|
@@ -1064,6 +1334,16 @@ const DSH_PACKAGE = "@deepseek-ai/dsh";
|
|
|
1064
1334
|
* also the unscoped name its plain-string `bin` form would take. */
|
|
1065
1335
|
const DSH_BIN_NAME = "dsh";
|
|
1066
1336
|
/**
|
|
1337
|
+
* Whether dsh's CLI refuses this profile outright: `desktop`, in any letter
|
|
1338
|
+
* case, "is managed exclusively by the Electron application" — refused for a
|
|
1339
|
+
* launch and for `dsh plugin` alike (`rejectElectronProfile` in the CLI's
|
|
1340
|
+
* `lib/bin.js`, the same in 0.1.5-rc.3 and 0.1.7-rc.2). Every command the
|
|
1341
|
+
* shop spawns names a profile, so none of them can run for this one.
|
|
1342
|
+
*/
|
|
1343
|
+
function isDesktopProfile(profile) {
|
|
1344
|
+
return profile.toLowerCase() === "desktop";
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1067
1347
|
* A `dshBin` that names a JavaScript entry rather than a program.
|
|
1068
1348
|
*
|
|
1069
1349
|
* Such a file cannot be spawned anywhere: Windows needs a PE image, and POSIX
|
|
@@ -1229,6 +1509,35 @@ function chain(profile, task) {
|
|
|
1229
1509
|
profileQueues.set(profile, next.catch(() => {}));
|
|
1230
1510
|
return next;
|
|
1231
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
|
+
}
|
|
1232
1541
|
/**
|
|
1233
1542
|
* Why a zero-exit install left `dsh.profile.bundles` without the entry.
|
|
1234
1543
|
*
|
|
@@ -1335,6 +1644,94 @@ const FAILURE_LOG_NOISE = [
|
|
|
1335
1644
|
/^\s+at /,
|
|
1336
1645
|
/^<anonymous(?:_script)?>/
|
|
1337
1646
|
];
|
|
1647
|
+
/** The line dsh 0.1.7 opens a refused install with (dsh-plugin-manager's
|
|
1648
|
+
* `rejected`), and the exemption command its CLI prints after it, one per
|
|
1649
|
+
* refused package (`runPlugin`). The opener is exported for the plugin
|
|
1650
|
+
* manager's answer, whose output carries the same block. */
|
|
1651
|
+
const REFUSAL_OPENER = "dsh: installation rejected: ";
|
|
1652
|
+
const REFUSAL_COMMAND = /^dsh: to accept the risk, run: dsh plugin --profile .+ allow-version (.+)@([^@\s]+) --dsh-version (\S+) --accept-risk$/;
|
|
1653
|
+
const REFUSAL_FAILED = "dsh: plugin command failed";
|
|
1654
|
+
/** Where dsh's warning turns from the refused peers to its risk statement
|
|
1655
|
+
* and remedy (app-boot's `pluginCompatibilityWarning`); the detail states
|
|
1656
|
+
* the risk once and gives the command instead. */
|
|
1657
|
+
const REFUSAL_WARNING_TAIL = " Running it may cause crashes or data loss.";
|
|
1658
|
+
/**
|
|
1659
|
+
* dsh's own refusal of an install (0.1.7 on) as one detail, or null when the
|
|
1660
|
+
* log holds none. The install was dsh's to refuse, not pnpm's to fail: it
|
|
1661
|
+
* refuses before pnpm runs, when it can read the manifest — a registry spec,
|
|
1662
|
+
* which the catalog's peer verdict already predicts — and after pnpm
|
|
1663
|
+
* succeeded otherwise, restoring the profile. Reading that as a pnpm failure
|
|
1664
|
+
* sent the reader to `dsh plugin install`, which repairs nothing.
|
|
1665
|
+
*
|
|
1666
|
+
* The detail is what dsh refused, what it restored, and the command that
|
|
1667
|
+
* records its exact-version exemption, rebuilt from what dsh printed through
|
|
1668
|
+
* `allowVersionCommand` rather than repeated: dsh prints the INSTALLED
|
|
1669
|
+
* manifest's own name into it, which a tarball or git package chooses
|
|
1670
|
+
* itself, so only a name and versions dsh's exemption grammar accepts reach
|
|
1671
|
+
* a line meant for a terminal. The profile is the one this install ran in.
|
|
1672
|
+
* Anything dsh prints after `plugin command failed` — the diagnostics path,
|
|
1673
|
+
* and a hint about git build scripts dsh prints after every failed git
|
|
1674
|
+
* install — says nothing about this refusal.
|
|
1675
|
+
*/
|
|
1676
|
+
function refusalDetail(profile, usable) {
|
|
1677
|
+
const start = usable.findIndex((line) => line.startsWith(REFUSAL_OPENER));
|
|
1678
|
+
if (start === -1) return null;
|
|
1679
|
+
const refusedAt = usable.slice(start);
|
|
1680
|
+
const failedAt = refusedAt.findIndex((line) => line.startsWith(REFUSAL_FAILED));
|
|
1681
|
+
const block = failedAt === -1 ? refusedAt : refusedAt.slice(0, failedAt);
|
|
1682
|
+
const warnings = [];
|
|
1683
|
+
const commands = [];
|
|
1684
|
+
let restoration;
|
|
1685
|
+
for (const [index, line] of block.entries()) {
|
|
1686
|
+
const command = REFUSAL_COMMAND.exec(line);
|
|
1687
|
+
if (command !== null) {
|
|
1688
|
+
const [, name = "", version = "", runtimeVersion = ""] = command;
|
|
1689
|
+
const rebuilt = allowVersionCommand(profile, {
|
|
1690
|
+
name,
|
|
1691
|
+
version,
|
|
1692
|
+
runtimeVersion
|
|
1693
|
+
});
|
|
1694
|
+
if (rebuilt !== null) commands.push(rebuilt);
|
|
1695
|
+
} else if (index === 0) warnings.push(line.slice(28));
|
|
1696
|
+
else if (line.startsWith("dsh: ")) restoration ??= line.slice(5);
|
|
1697
|
+
else if (restoration === void 0) warnings.push(line);
|
|
1698
|
+
}
|
|
1699
|
+
const parts = [`dsh refused the install: ${warnings.map((warning) => {
|
|
1700
|
+
const tail = warning.indexOf(REFUSAL_WARNING_TAIL);
|
|
1701
|
+
return tail === -1 ? warning : warning.slice(0, tail);
|
|
1702
|
+
}).join(" ")}`];
|
|
1703
|
+
if (restoration !== void 0) parts.push(`${restoration.charAt(0).toUpperCase()}${restoration.slice(1)}`);
|
|
1704
|
+
if (commands.length > 0) parts.push(`To accept the risk of crashes or data loss for ${commands.length === 1 ? "this exact version" : "these exact versions"}, run: ${commands.join("; ")} — then install again.`);
|
|
1705
|
+
return parts.join(" ");
|
|
1706
|
+
}
|
|
1707
|
+
/** What Rust's allocator prints before it aborts, which is how pnpm 12's
|
|
1708
|
+
* native binary dies when it cannot get the memory it asked for. Anchored at
|
|
1709
|
+
* both ends: node's own heap exhaustion ("Allocation failed - JavaScript heap
|
|
1710
|
+
* out of memory") is a different crash, from pnpm 11 or dsh itself, and the
|
|
1711
|
+
* pnpm 12 remedy below would be wrong for it. */
|
|
1712
|
+
const NATIVE_ALLOCATION_ABORT = /^memory allocation of \d+ bytes failed$/;
|
|
1713
|
+
/**
|
|
1714
|
+
* pnpm 12 aborting out of memory as one detail, or null when the log holds no
|
|
1715
|
+
* such abort.
|
|
1716
|
+
*
|
|
1717
|
+
* The known cause is pnpm/pnpm#15362, fixed in pnpm 12.7.0: the native peer
|
|
1718
|
+
* check intersected the ranges of a missing peer, doubling the intersection
|
|
1719
|
+
* for every package that wanted it. dsh writes `autoInstallPeers: false` into
|
|
1720
|
+
* every profile, so every `@deepseek-ai/dsh*` peer stays missing and plugins
|
|
1721
|
+
* that share one trip it. Reported on Windows with 8 GB, reproduced on Linux
|
|
1722
|
+
* through the real CLI of dsh 0.1.5-rc.3 and 0.1.7-rc.2 (2026-09-27). The
|
|
1723
|
+
* install aborted, so running it again on the same profile aborts again:
|
|
1724
|
+
* the detail names the way out instead of the usual recovery hint.
|
|
1725
|
+
*
|
|
1726
|
+
* Not every such abort is that bug (pnpm/pnpm#15867 is another one, with the
|
|
1727
|
+
* same words), so the detail says what to do if the reader's pnpm already has
|
|
1728
|
+
* the fix.
|
|
1729
|
+
*/
|
|
1730
|
+
function nativeAllocationAbortDetail(usable) {
|
|
1731
|
+
const abort = usable.find((line) => NATIVE_ALLOCATION_ABORT.test(line.trim()));
|
|
1732
|
+
if (abort === void 0) return null;
|
|
1733
|
+
return `pnpm crashed: its native binary ran out of memory (${abort.trim()}), which is not caused by this plugin. pnpm 12 before 12.7.0 has a known bug that exhausts memory when many packages share a missing peer, which every dsh profile has, because dsh keeps peers uninstalled (autoInstallPeers: false; pnpm/pnpm#15362). Upgrade pnpm to 12.7.0 or later, or use pnpm 11, then install again. If your pnpm is already 12.7.0 or later, report the line above to pnpm.`;
|
|
1734
|
+
}
|
|
1338
1735
|
/**
|
|
1339
1736
|
* The one log line worth putting in front of the user, plus the recovery hint.
|
|
1340
1737
|
*
|
|
@@ -1349,9 +1746,12 @@ const FAILURE_LOG_NOISE = [
|
|
|
1349
1746
|
* Scanning from the end: a pnpm error code first, then any thrown error, then
|
|
1350
1747
|
* the last line that is not noise.
|
|
1351
1748
|
*/
|
|
1352
|
-
function installFailureDetail(profile, log) {
|
|
1353
|
-
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`) {
|
|
1354
1750
|
const usable = log.map((line) => line.replace(/\r+$/, "")).filter((line) => !FAILURE_LOG_NOISE.some((rx) => rx.test(line)));
|
|
1751
|
+
const refusal = refusalDetail(profile, usable);
|
|
1752
|
+
if (refusal !== null) return refusal;
|
|
1753
|
+
const abort = nativeAllocationAbortDetail(usable);
|
|
1754
|
+
if (abort !== null) return abort;
|
|
1355
1755
|
const reversed = [...usable].reverse();
|
|
1356
1756
|
const pick = reversed.find((line) => /ERR_[A-Z][A-Z_]*/.test(line)) ?? reversed.find((line) => /(?:^|\s)\w*Error:/.test(line)) ?? usable[usable.length - 1];
|
|
1357
1757
|
if (pick === void 0) return `${hint}.`;
|
|
@@ -1454,6 +1854,23 @@ function dshScript() {
|
|
|
1454
1854
|
* the spawned command line is the one we add ourselves, after this gate. */
|
|
1455
1855
|
const UNSAFE_TARGET = /[\s"'`|<>^$();\\{}]|[\u0000-\u001f\u007f]/;
|
|
1456
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
|
+
/**
|
|
1457
1874
|
* The operand as the downstream dsh must receive it for pnpm to see it whole.
|
|
1458
1875
|
*
|
|
1459
1876
|
* dsh spawns pnpm with `shell: process.platform === 'win32'`
|
|
@@ -1491,6 +1908,32 @@ function shellSafeTarget(target, platform) {
|
|
|
1491
1908
|
if (platform !== "win32" || !target.includes("&")) return target;
|
|
1492
1909
|
return `"${target}"`;
|
|
1493
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
|
+
}
|
|
1494
1937
|
/** Run one `dsh plugin --profile <profile> <verb> <target>` and track it.
|
|
1495
1938
|
* Never rolls back; a failure surfaces stderr verbatim plus the recovery hint
|
|
1496
1939
|
* (§10). The shop never passes build-script flags: `allowBuilds` stays the
|
|
@@ -1507,14 +1950,11 @@ function shellSafeTarget(target, platform) {
|
|
|
1507
1950
|
* package IS installed; it reports `done` with the restart fallback. */
|
|
1508
1951
|
function spawnPluginCli(options) {
|
|
1509
1952
|
const { profile, argv, dshBin, env, platform = process.platform, beforeSpawn, prefetcher, confirm, afterDone, onStatus, timeoutMs = INSTALL_TIMEOUT_MS } = options;
|
|
1510
|
-
const target = argv[1];
|
|
1511
|
-
if (target === void 0 || target.startsWith("-")) throw new Error(`dsh-plugin-shop: refusing to spawn with a flag-like operand: ${target ?? "(none)"}`);
|
|
1512
|
-
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]);
|
|
1513
1954
|
const spawnArgv = [...argv];
|
|
1514
1955
|
spawnArgv[1] = shellSafeTarget(target, platform);
|
|
1515
1956
|
const installId = randomUUID();
|
|
1516
|
-
const log =
|
|
1517
|
-
let logBytes = 0;
|
|
1957
|
+
const log = createBoundedLog();
|
|
1518
1958
|
const ahead = enterQueue(profile);
|
|
1519
1959
|
let state = "running";
|
|
1520
1960
|
let activationOnDone = "restart";
|
|
@@ -1522,7 +1962,7 @@ function spawnPluginCli(options) {
|
|
|
1522
1962
|
let detail;
|
|
1523
1963
|
const status = () => ({
|
|
1524
1964
|
state,
|
|
1525
|
-
log:
|
|
1965
|
+
log: log.lines(),
|
|
1526
1966
|
...state === "done" ? {
|
|
1527
1967
|
activation: activationOnDone,
|
|
1528
1968
|
...restartReason !== void 0 ? { restartReason } : {}
|
|
@@ -1532,11 +1972,6 @@ function spawnPluginCli(options) {
|
|
|
1532
1972
|
const append = (line) => {
|
|
1533
1973
|
if (isTerminalInstallState(state)) return;
|
|
1534
1974
|
log.push(line);
|
|
1535
|
-
logBytes += Buffer.byteLength(line);
|
|
1536
|
-
while ((log.length > MAX_LOG_LINES || logBytes > MAX_LOG_BYTES) && log.length > 1) {
|
|
1537
|
-
const oldest = log.shift();
|
|
1538
|
-
if (oldest !== void 0) logBytes -= Buffer.byteLength(oldest);
|
|
1539
|
-
}
|
|
1540
1975
|
onStatus?.(status());
|
|
1541
1976
|
};
|
|
1542
1977
|
const failToStart = (error) => {
|
|
@@ -1546,19 +1981,14 @@ function spawnPluginCli(options) {
|
|
|
1546
1981
|
return status();
|
|
1547
1982
|
};
|
|
1548
1983
|
let prefetch = null;
|
|
1549
|
-
if (ahead > 0 && prefetcher !== void 0)
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
});
|
|
1557
|
-
} catch (error) {
|
|
1558
|
-
append(`dsh-plugin-shop: the download phase could not start — ${error.message}`);
|
|
1559
|
-
}
|
|
1984
|
+
if (ahead > 0 && prefetcher !== void 0) prefetch = requestDownloadPhase({
|
|
1985
|
+
prefetcher,
|
|
1986
|
+
profile,
|
|
1987
|
+
spec: target,
|
|
1988
|
+
env,
|
|
1989
|
+
log: append
|
|
1990
|
+
});
|
|
1560
1991
|
if (prefetch?.started === true) state = "downloading";
|
|
1561
|
-
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");
|
|
1562
1992
|
const finished = chain(profile, () => new Promise((resolve) => {
|
|
1563
1993
|
state = "running";
|
|
1564
1994
|
onStatus?.(status());
|
|
@@ -1627,7 +2057,7 @@ function spawnPluginCli(options) {
|
|
|
1627
2057
|
} else state = "done";
|
|
1628
2058
|
} else {
|
|
1629
2059
|
state = "failed";
|
|
1630
|
-
detail = installFailureDetail(profile, log);
|
|
2060
|
+
detail = installFailureDetail(profile, log.lines());
|
|
1631
2061
|
}
|
|
1632
2062
|
onStatus?.(status());
|
|
1633
2063
|
resolve(status());
|
|
@@ -1753,6 +2183,47 @@ function startUninstall(options) {
|
|
|
1753
2183
|
});
|
|
1754
2184
|
}
|
|
1755
2185
|
//#endregion
|
|
2186
|
+
//#region src/host/bundle-patch.ts
|
|
2187
|
+
/**
|
|
2188
|
+
* The patch files a bundle declares, read the way dsh reads them (design
|
|
2189
|
+
* 2026-09-26-dsh-017-readiness, B4).
|
|
2190
|
+
*
|
|
2191
|
+
* dsh 0.1.7's app-boot (`bundlePatchFiles`) accepts `dsh.bundle.patch` as one
|
|
2192
|
+
* package-relative file or as a list of them, applied in order, and throws on
|
|
2193
|
+
* anything else. dsh 0.1.5 reads only the string form: it joins the
|
|
2194
|
+
* declaration onto the package directory, so a list makes the profile fail to
|
|
2195
|
+
* start. What the RUNNING dsh accepts is `RunningHarness.patchLists`; this
|
|
2196
|
+
* reads the declaration itself, for the shop's own readers of a package's
|
|
2197
|
+
* patch — the entries it owns, the rows a hot mount replicates.
|
|
2198
|
+
*/
|
|
2199
|
+
/**
|
|
2200
|
+
* The declared patch files in application order: one for a string, the
|
|
2201
|
+
* listed ones for a list of strings. Null for a declaration dsh refuses —
|
|
2202
|
+
* neither a path nor a list of paths — which is distinct from an absent one:
|
|
2203
|
+
* only absence means the conventional `cordis.patch.yml`, and a caller that
|
|
2204
|
+
* fell back to it for a refused declaration would act on a patch dsh never
|
|
2205
|
+
* applies. Pure, and the list is a copy.
|
|
2206
|
+
*/
|
|
2207
|
+
function bundlePatchFiles(declared) {
|
|
2208
|
+
if (typeof declared === "string") return [declared];
|
|
2209
|
+
if (Array.isArray(declared) && declared.every((file) => typeof file === "string")) return [...declared];
|
|
2210
|
+
return null;
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* Whether an installed package's `dsh.bundle.patch` would stop the profile
|
|
2214
|
+
* from starting at the next boot, given whether the running dsh reads a list
|
|
2215
|
+
* (`RunningHarness.patchLists`). An absent declaration is no hazard: dsh adds
|
|
2216
|
+
* a package to the profile's bundles only when it declares a patch, so
|
|
2217
|
+
* nothing reads the field. An unknown harness (null) forms no verdict on a
|
|
2218
|
+
* list — the rule for every harness read — but a malformed declaration is
|
|
2219
|
+
* refused by every dsh, so it is named whatever is known. Pure.
|
|
2220
|
+
*/
|
|
2221
|
+
function patchDeclarationHazard(declared, patchLists) {
|
|
2222
|
+
if (declared === void 0) return null;
|
|
2223
|
+
if (bundlePatchFiles(declared) === null) return "malformed";
|
|
2224
|
+
return Array.isArray(declared) && patchLists === false ? "list-unsupported" : null;
|
|
2225
|
+
}
|
|
2226
|
+
//#endregion
|
|
1756
2227
|
//#region src/host/hot.ts
|
|
1757
2228
|
/**
|
|
1758
2229
|
* Restart-free installs: mount a freshly installed plugin into the running
|
|
@@ -1878,15 +2349,14 @@ function nextHotNumber(fs, dir) {
|
|
|
1878
2349
|
}
|
|
1879
2350
|
return max + 1;
|
|
1880
2351
|
}
|
|
1881
|
-
/**
|
|
1882
|
-
*
|
|
1883
|
-
*
|
|
1884
|
-
function
|
|
2352
|
+
/** The installed package's own `dsh.bundle.patch`, exactly as declared (the
|
|
2353
|
+
* Include input), or undefined when the package, its manifest or the field
|
|
2354
|
+
* is absent. */
|
|
2355
|
+
function declaredPatch(fs, packageDir) {
|
|
1885
2356
|
try {
|
|
1886
|
-
|
|
1887
|
-
return typeof patch === "string" ? { patch } : null;
|
|
2357
|
+
return JSON.parse(fs.read(join(packageDir, "package.json"))).dsh?.bundle?.patch;
|
|
1888
2358
|
} catch {
|
|
1889
|
-
return
|
|
2359
|
+
return;
|
|
1890
2360
|
}
|
|
1891
2361
|
}
|
|
1892
2362
|
/** Load the Include class through a computed dynamic import, or null when
|
|
@@ -1924,33 +2394,45 @@ function suppressWrite(treeClass) {
|
|
|
1924
2394
|
async function hotMount(ctx, profileDir, packageName, deps = {}) {
|
|
1925
2395
|
const { fs = nodeHotFs, dir = join(profileDir, HOT_DIR), timeoutMs = Number(process.env.DSH_SHOP_HOT_MOUNT_TIMEOUT_MS) || 1e4, now = Date.now } = deps;
|
|
1926
2396
|
const packageDir = join(profileDir, "node_modules", packageName);
|
|
1927
|
-
const
|
|
1928
|
-
const
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
ctx.logger?.warn(`hot-mount ${packageName}: the declared bundle patch is outside the package directory — restart will activate it`);
|
|
1932
|
-
return {
|
|
1933
|
-
ok: false,
|
|
1934
|
-
reason: "no-patch"
|
|
1935
|
-
};
|
|
1936
|
-
}
|
|
1937
|
-
let patchText;
|
|
1938
|
-
try {
|
|
1939
|
-
patchText = fs.read(patchFile);
|
|
1940
|
-
} catch {
|
|
1941
|
-
ctx.logger?.warn(`hot-mount ${packageName}: no patch file to mount — restart will activate it`);
|
|
2397
|
+
const declared = declaredPatch(fs, packageDir);
|
|
2398
|
+
const files = declared === void 0 ? ["cordis.patch.yml"] : bundlePatchFiles(declared);
|
|
2399
|
+
if (files === null || files.length === 0) {
|
|
2400
|
+
ctx.logger?.warn(`hot-mount ${packageName}: the bundle patch is declared as neither a file path nor a list of them — restart will activate it`);
|
|
1942
2401
|
return {
|
|
1943
2402
|
ok: false,
|
|
1944
2403
|
reason: "no-patch"
|
|
1945
2404
|
};
|
|
1946
2405
|
}
|
|
1947
|
-
const rows =
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
2406
|
+
const rows = [];
|
|
2407
|
+
for (const file of files) {
|
|
2408
|
+
const patchFile = resolve(packageDir, file);
|
|
2409
|
+
const inside = relative(packageDir, patchFile);
|
|
2410
|
+
if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
|
|
2411
|
+
ctx.logger?.warn(`hot-mount ${packageName}: the declared bundle patch is outside the package directory — restart will activate it`);
|
|
2412
|
+
return {
|
|
2413
|
+
ok: false,
|
|
2414
|
+
reason: "no-patch"
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
let patchText;
|
|
2418
|
+
try {
|
|
2419
|
+
patchText = fs.read(patchFile);
|
|
2420
|
+
} catch {
|
|
2421
|
+
ctx.logger?.warn(`hot-mount ${packageName}: no patch file to mount — restart will activate it`);
|
|
2422
|
+
return {
|
|
2423
|
+
ok: false,
|
|
2424
|
+
reason: "no-patch"
|
|
2425
|
+
};
|
|
2426
|
+
}
|
|
2427
|
+
const parsed = parseSimplePatch(patchText);
|
|
2428
|
+
if (parsed === null) {
|
|
2429
|
+
ctx.logger?.warn(`hot-mount ${packageName}: patch has rows that cannot be hot-mounted — restart will activate it`);
|
|
2430
|
+
return {
|
|
2431
|
+
ok: false,
|
|
2432
|
+
reason: "not-simple"
|
|
2433
|
+
};
|
|
2434
|
+
}
|
|
2435
|
+
rows.push(...parsed);
|
|
1954
2436
|
}
|
|
1955
2437
|
let treeClass = deps.hotTreeClass;
|
|
1956
2438
|
if (treeClass === void 0) treeClass = await loadHotTreeClass();
|
|
@@ -2061,18 +2543,21 @@ function cleanHotDir(profileDir) {
|
|
|
2061
2543
|
* - A change to the BOOT COMPOSITION — a toggle, an uninstall — moves an
|
|
2062
2544
|
* entry the registry already enumerates, and the served graph follows it
|
|
2063
2545
|
* within seconds (§2, measured 2026-09-11). `true`.
|
|
2064
|
-
* - A HOT MOUNT — an install
|
|
2065
|
-
*
|
|
2066
|
-
*
|
|
2067
|
-
*
|
|
2068
|
-
*
|
|
2069
|
-
* whole time. `false` — and a `restart` is then the only honest answer,
|
|
2070
|
-
* because there is nothing a reload could fetch.
|
|
2546
|
+
* - A HOT MOUNT — an install — adds entries under the shop's own loader
|
|
2547
|
+
* entry, which the registry enumerates like any other: across a reload
|
|
2548
|
+
* following the mount the served graph gains the package and the page runs
|
|
2549
|
+
* its browser half (measured 2026-09-26 on 0.1.5-rc.3 and 0.1.7-rc.2, in
|
|
2550
|
+
* `web-full-flow.e2e.ts`). `true`.
|
|
2071
2551
|
*
|
|
2072
2552
|
* The asymmetry that decides every unknown still runs the same way: offering
|
|
2073
2553
|
* a step that was not needed costs the reader one action, withholding one
|
|
2074
|
-
* that was needed is the defect this module exists to fix.
|
|
2075
|
-
*
|
|
2554
|
+
* that was needed is the defect this module exists to fix. Which step a hot
|
|
2555
|
+
* mount needs has been measured twice. On 2026-09-14 the graph came back
|
|
2556
|
+
* byte-identical across the reload, and installs moved to `restart` — but the
|
|
2557
|
+
* shop then registered the tree from whichever context made the RPC call, the
|
|
2558
|
+
* typert gateway's, and the registry never composed a tree hung there. Once
|
|
2559
|
+
* the tree hung off the shop's own entry (2026-09-26), the same measurement
|
|
2560
|
+
* found the package in the graph, and installs moved back to `reload`.
|
|
2076
2561
|
*/
|
|
2077
2562
|
function activationOf(input) {
|
|
2078
2563
|
if (!input.hostLive) return "restart";
|
|
@@ -2294,18 +2779,14 @@ function writeRepoPins(fs, path, pins) {
|
|
|
2294
2779
|
//#endregion
|
|
2295
2780
|
//#region src/host/profile.ts
|
|
2296
2781
|
/** Profile directory discovery and user-layer writes (§8: hot enable/disable). */
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
represent: (value) => value.__jsExpr
|
|
2306
|
-
});
|
|
2307
|
-
/** js-yaml's default schema plus the user-layer `!!js` round trip. */
|
|
2308
|
-
const USER_LAYER_SCHEMA = DEFAULT_SCHEMA.extend([JS_EXPR_TYPE]);
|
|
2782
|
+
/** The loader's `!!js` expression scalar, for the document edit: read as its
|
|
2783
|
+
* source text and written back under its own tag, never evaluated — the
|
|
2784
|
+
* loader evaluates it at entry activation. The same declaration the harness's
|
|
2785
|
+
* own writer of this file uses (`@deepseek-ai/dsh-plugin-manager` 0.1.7). */
|
|
2786
|
+
const USER_LAYER_TAGS = [{
|
|
2787
|
+
tag: "tag:yaml.org,2002:js",
|
|
2788
|
+
resolve: (source) => source
|
|
2789
|
+
}];
|
|
2309
2790
|
/**
|
|
2310
2791
|
* Find the profile directory that owns `startPath`.
|
|
2311
2792
|
*
|
|
@@ -2364,22 +2845,78 @@ function realpathNearestExisting(startPath) {
|
|
|
2364
2845
|
* {@link ownedEntryIds}), and they toggle together: writing them one at a
|
|
2365
2846
|
* time would read-modify-write the file once per entry, so a crash between
|
|
2366
2847
|
* two writes would leave the package half disabled.
|
|
2848
|
+
*
|
|
2849
|
+
* The edit changes one key and nothing else (design
|
|
2850
|
+
* 2026-09-26-market-borrowings §2). The layer is validated by the harness's
|
|
2851
|
+
* own parser first, so one it cannot load still throws and is never
|
|
2852
|
+
* rewritten; it is then edited as a YAML document, which keeps every comment,
|
|
2853
|
+
* every other row, every other key and every `!!js` scalar as the user wrote
|
|
2854
|
+
* them. The whole-file rewrite this replaced dropped each toggled id's row —
|
|
2855
|
+
* and with it a `config:` override the user had put there — and dumped the
|
|
2856
|
+
* list back through a library that models no comments, so the first toggle
|
|
2857
|
+
* erased the header dsh writes into every new profile.
|
|
2858
|
+
*
|
|
2859
|
+
* For each entry the row written is the LAST one carrying its id, when the
|
|
2860
|
+
* harness is sure to apply it: applyEntryPatches applies rows in order and
|
|
2861
|
+
* each key replaces the entry's value, so the last row that sets `disabled`
|
|
2862
|
+
* decides it. A row with `insert` is an insertion into a group, never an
|
|
2863
|
+
* override; a row naming another module is skipped by the harness; a named
|
|
2864
|
+
* row whose module the caller did not say cannot be judged. In each of those
|
|
2865
|
+
* cases a row is appended instead, which the harness applies last whatever
|
|
2866
|
+
* came before it — so appending is never wrong, only less tidy.
|
|
2867
|
+
*
|
|
2868
|
+
* An enable writes `disabled: false` rather than deleting the row, which is
|
|
2869
|
+
* what lost a user's row or the comment above it; it is also the harness's
|
|
2870
|
+
* own convention for this file from 0.1.7. The file keeps its permission bits
|
|
2871
|
+
* across the rename (a new one is created owner-only, as the harness's writer
|
|
2872
|
+
* does), because a config override can hold a credential.
|
|
2367
2873
|
*/
|
|
2368
2874
|
function setUserLayerRows(options) {
|
|
2369
2875
|
const file = join(options.profileDir, "cordis.patch.yml");
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2876
|
+
loadOptionalPatches("dsh-plugin-shop", file);
|
|
2877
|
+
let text = "[]\n";
|
|
2878
|
+
let mode = 384;
|
|
2879
|
+
if (existsSync(file)) {
|
|
2880
|
+
text = readFileSync(file, "utf8");
|
|
2881
|
+
mode = statSync(file).mode & 511;
|
|
2882
|
+
}
|
|
2883
|
+
const document = parseDocument(text, { customTags: USER_LAYER_TAGS });
|
|
2884
|
+
const parseError = document.errors[0];
|
|
2885
|
+
if (parseError !== void 0) throw parseError;
|
|
2886
|
+
const list = document.contents;
|
|
2887
|
+
if (!isSeq(list)) throw new Error(`dsh-plugin-shop: ${file} is not a YAML list of patch rows`);
|
|
2888
|
+
for (const row of options.rows) {
|
|
2889
|
+
const target = lastRowFor(list.items, row.id);
|
|
2890
|
+
if (target !== null && appliesTo(target, row.name)) {
|
|
2891
|
+
target.set("disabled", row.disabled);
|
|
2892
|
+
continue;
|
|
2893
|
+
}
|
|
2894
|
+
if (list.items.length === 0) list.flow = false;
|
|
2895
|
+
document.add({
|
|
2896
|
+
id: row.id,
|
|
2897
|
+
disabled: row.disabled
|
|
2898
|
+
});
|
|
2899
|
+
}
|
|
2376
2900
|
const tmp = `${file}.tmp`;
|
|
2377
|
-
writeFileSync(tmp,
|
|
2378
|
-
|
|
2379
|
-
schema: USER_LAYER_SCHEMA
|
|
2380
|
-
}));
|
|
2901
|
+
writeFileSync(tmp, document.toString({ lineWidth: 0 }), { mode });
|
|
2902
|
+
chmodSync(tmp, mode);
|
|
2381
2903
|
renameSync(tmp, file);
|
|
2382
2904
|
}
|
|
2905
|
+
/** The last override row carrying `id`, or null when there is none. A row
|
|
2906
|
+
* with `insert` is an insertion into group `id`, not an override of it. */
|
|
2907
|
+
function lastRowFor(items, id) {
|
|
2908
|
+
for (let index = items.length - 1; index >= 0; index--) {
|
|
2909
|
+
const item = items[index];
|
|
2910
|
+
if (isMap(item) && !item.has("insert") && item.get("id") === id) return item;
|
|
2911
|
+
}
|
|
2912
|
+
return null;
|
|
2913
|
+
}
|
|
2914
|
+
/** Whether the harness is sure to apply `row` to the entry mounting
|
|
2915
|
+
* `moduleName`: a row with no `name`, or one naming that very module. */
|
|
2916
|
+
function appliesTo(row, moduleName) {
|
|
2917
|
+
if (!row.has("name")) return true;
|
|
2918
|
+
return moduleName !== void 0 && row.get("name") === moduleName;
|
|
2919
|
+
}
|
|
2383
2920
|
/**
|
|
2384
2921
|
* The loader entry ids one installed package contributes to the tree.
|
|
2385
2922
|
*
|
|
@@ -2402,41 +2939,73 @@ function setUserLayerRows(options) {
|
|
|
2402
2939
|
* which only the inventory can answer.
|
|
2403
2940
|
*/
|
|
2404
2941
|
function ownedEntryIds(options) {
|
|
2942
|
+
return ownedEntries(options).map((entry) => entry.id);
|
|
2943
|
+
}
|
|
2944
|
+
/**
|
|
2945
|
+
* {@link ownedEntryIds} with the module each entry mounts beside its id. The
|
|
2946
|
+
* name is what the harness judges a NAMED user-layer row by — a row naming a
|
|
2947
|
+
* different module is skipped (applyEntryPatches) — so it is what lets the
|
|
2948
|
+
* toggle write a user's own named row rather than append one beside it. It is
|
|
2949
|
+
* read through the harness's parser, so a relative name arrives anchored to a
|
|
2950
|
+
* file URL exactly as the composed entry carries it; an `!!js` name has no
|
|
2951
|
+
* plain value and reads as undefined. Throws where `ownedEntryIds` does.
|
|
2952
|
+
*/
|
|
2953
|
+
function ownedEntries(options) {
|
|
2405
2954
|
const packageDir = join(options.profileDir, "node_modules", ...options.packageName.split("/"));
|
|
2406
2955
|
const manifestPath = join(packageDir, "package.json");
|
|
2407
2956
|
if (!existsSync(manifestPath)) return [];
|
|
2408
|
-
let
|
|
2957
|
+
let declared;
|
|
2409
2958
|
try {
|
|
2410
|
-
|
|
2959
|
+
declared = JSON.parse(readFileSync(manifestPath, "utf8")).dsh?.bundle?.patch;
|
|
2411
2960
|
} catch (error) {
|
|
2412
2961
|
throw new Error(`dsh-plugin-shop: failed to read ${manifestPath}: ${String(error)}`);
|
|
2413
2962
|
}
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
const
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2963
|
+
const files = bundlePatchFiles(declared);
|
|
2964
|
+
if (files === null) return [];
|
|
2965
|
+
const entries = [];
|
|
2966
|
+
for (const file of files) {
|
|
2967
|
+
const patchFile = resolve(packageDir, file);
|
|
2968
|
+
const inside = relative(packageDir, patchFile);
|
|
2969
|
+
if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) throw new Error(`dsh-plugin-shop: ${options.packageName} declares a bundle patch outside its own directory: ${file}`);
|
|
2970
|
+
collectInsertedEntries(loadOptionalPatches("dsh-plugin-shop", patchFile) ?? [], entries);
|
|
2971
|
+
}
|
|
2972
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2973
|
+
return entries.filter((entry) => !seen.has(entry.id) && seen.add(entry.id) !== void 0);
|
|
2974
|
+
}
|
|
2975
|
+
/**
|
|
2976
|
+
* An installed package's own `dsh.bundle.patch`, exactly as declared, or
|
|
2977
|
+
* undefined when the package, its manifest or the field is absent — or the
|
|
2978
|
+
* manifest cannot be read, which no post-install check can judge either.
|
|
2979
|
+
*/
|
|
2980
|
+
function declaredBundlePatch(options) {
|
|
2981
|
+
const manifestPath = join(options.profileDir, "node_modules", ...options.packageName.split("/"), "package.json");
|
|
2982
|
+
try {
|
|
2983
|
+
return JSON.parse(readFileSync(manifestPath, "utf8")).dsh?.bundle?.patch;
|
|
2984
|
+
} catch {
|
|
2985
|
+
return;
|
|
2986
|
+
}
|
|
2421
2987
|
}
|
|
2422
|
-
/** Walk a patch list, appending
|
|
2423
|
-
*
|
|
2988
|
+
/** Walk a patch list, appending every INSERTED entry. A patch row without
|
|
2989
|
+
* `insert` targets an entry someone else composed — the loader's
|
|
2424
2990
|
* applyEntryPatches looks it up and skips it when absent, so it creates
|
|
2425
2991
|
* nothing and owns nothing. An inserted GROUP owns its children, which the
|
|
2426
2992
|
* loader reads from the group's own `config` array. */
|
|
2427
|
-
function
|
|
2993
|
+
function collectInsertedEntries(rows, into) {
|
|
2428
2994
|
for (const row of rows) {
|
|
2429
2995
|
if (row === null || typeof row !== "object") continue;
|
|
2430
2996
|
const inserted = row.insert;
|
|
2431
|
-
if (Array.isArray(inserted))
|
|
2997
|
+
if (Array.isArray(inserted)) collectEntries(inserted, into);
|
|
2432
2998
|
}
|
|
2433
2999
|
}
|
|
2434
|
-
function
|
|
3000
|
+
function collectEntries(entries, into) {
|
|
2435
3001
|
for (const entry of entries) {
|
|
2436
3002
|
if (entry === null || typeof entry !== "object") continue;
|
|
2437
|
-
const { id, group, config } = entry;
|
|
2438
|
-
if (typeof id === "string") into.push(
|
|
2439
|
-
|
|
3003
|
+
const { id, name, group, config } = entry;
|
|
3004
|
+
if (typeof id === "string") into.push({
|
|
3005
|
+
id,
|
|
3006
|
+
name: typeof name === "string" ? name : void 0
|
|
3007
|
+
});
|
|
3008
|
+
if (group === true && Array.isArray(config)) collectEntries(config, into);
|
|
2440
3009
|
}
|
|
2441
3010
|
}
|
|
2442
3011
|
/**
|
|
@@ -2820,25 +3389,240 @@ function createPrefetcher(options = {}) {
|
|
|
2820
3389
|
* not provide (design 2026-09-01-harness-compatibility), and — for the
|
|
2821
3390
|
* shop's OWN declared peers — which ones it provides at a version outside
|
|
2822
3391
|
* the declared range. */
|
|
3392
|
+
const NODE_LOOKUP_FS = {
|
|
3393
|
+
stat: (path) => statSync(path),
|
|
3394
|
+
readFile: (path) => readFileSync(path, "utf8")
|
|
3395
|
+
};
|
|
3396
|
+
/**
|
|
3397
|
+
* One segment of a bare package name, about to be spliced into a path.
|
|
3398
|
+
*
|
|
3399
|
+
* A leading `.` refuses the segment whole: `.` and `..` would walk the path
|
|
3400
|
+
* out of `node_modules`, and `.bin` or `.pnpm` are directories an install
|
|
3401
|
+
* carries that no package can be. The ESM resolver agrees as far as it goes:
|
|
3402
|
+
* it refuses an unscoped name that starts with `.`, and any name holding `%`
|
|
3403
|
+
* or `\`, with ERR_INVALID_MODULE_SPECIFIER (measured on Node 26.6.0 with
|
|
3404
|
+
* `.bin`, `a%2e`, `a\b`). `\` is also a separator on Windows, `:` names a
|
|
3405
|
+
* drive, an NTFS stream or a URL scheme (`node:fs`, `file:`), and NUL is a
|
|
3406
|
+
* byte no path may carry.
|
|
3407
|
+
*/
|
|
3408
|
+
function isNameSegment(segment) {
|
|
3409
|
+
return segment.length > 0 && !segment.startsWith(".") && !/[\\:%\0]/.test(segment);
|
|
3410
|
+
}
|
|
3411
|
+
/**
|
|
3412
|
+
* Whether `spec` is a bare package name — `name`, or `@scope/name` — and so
|
|
3413
|
+
* safe to look up. Peer names are catalog input, and hostile; this check is
|
|
3414
|
+
* what keeps one from becoming a path. The lookup this replaced resolved
|
|
3415
|
+
* `'../x/package.json'` relative to the profile and an absolute name as
|
|
3416
|
+
* itself, so a peer name could point it at any path on the reader's disk.
|
|
3417
|
+
*/
|
|
3418
|
+
function isBarePackageName(spec) {
|
|
3419
|
+
const segments = spec.split("/");
|
|
3420
|
+
const [first, second] = segments;
|
|
3421
|
+
if (first === void 0) return false;
|
|
3422
|
+
if (!first.startsWith("@")) return segments.length === 1 && isNameSegment(first);
|
|
3423
|
+
return segments.length === 2 && second !== void 0 && isNameSegment(first.slice(1)) && isNameSegment(second);
|
|
3424
|
+
}
|
|
3425
|
+
/** Whether `path` stats as a directory. Every stat failure answers false —
|
|
3426
|
+
* see `findPackageDir` for why that is Node's rule and not a shortcut. */
|
|
3427
|
+
function isDirectoryAt(fs, path) {
|
|
3428
|
+
try {
|
|
3429
|
+
return fs.stat(path).isDirectory();
|
|
3430
|
+
} catch {
|
|
3431
|
+
return false;
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
/** Whether `path` stats as a regular file. Every stat failure answers false,
|
|
3435
|
+
* for the reason `isDirectoryAt` gives. */
|
|
3436
|
+
function isFileAt(fs, path) {
|
|
3437
|
+
try {
|
|
3438
|
+
return fs.stat(path).isFile();
|
|
3439
|
+
} catch {
|
|
3440
|
+
return false;
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
/**
|
|
3444
|
+
* The directory the ESM resolver MATCHES for `spec` from `fromDir`, or null
|
|
3445
|
+
* when the walk reaches the filesystem root without one: for each ancestor
|
|
3446
|
+
* `dir`, the candidate `dir/node_modules/<spec>`, and the first that stats as
|
|
3447
|
+
* a DIRECTORY is the match. A candidate that is anything else — absent, a
|
|
3448
|
+
* file, or a path whose stat fails for ANY reason — is "not here", and the
|
|
3449
|
+
* walk moves on to the next ancestor.
|
|
3450
|
+
*
|
|
3451
|
+
* That is Node's own rule, copied from its reader rather than from the spec
|
|
3452
|
+
* prose. Node 26.6.0 `internal/modules/package_json_reader.js`
|
|
3453
|
+
* (`getPackageJSONURL`) does
|
|
3454
|
+
* `const stat = internalModuleStat(...); if (stat !== 1) { ...; continue }`:
|
|
3455
|
+
* 1 is "directory", and a failed stat is a negative errno, so EACCES and
|
|
3456
|
+
* ELOOP keep walking exactly like ENOENT. Measured on 26.6.0: a self-link
|
|
3457
|
+
* (ELOOP) at `node_modules/<name>` in front of an ancestor's real copy makes
|
|
3458
|
+
* the import load the ancestor's copy. This walk used to throw on every
|
|
3459
|
+
* failure but ENOENT and ENOTDIR, and `incompatibilityMap` turned the throw
|
|
3460
|
+
* into no verdict — so a missing peer, whose walk runs all the way to the
|
|
3461
|
+
* root, met any unsearchable or looping `node_modules` on the way (a
|
|
3462
|
+
* `~/node_modules` made by `sudo npm i` under umask 027, say), and every
|
|
3463
|
+
* missing-peer badge in the catalog went silent while those plugins still
|
|
3464
|
+
* failed to import.
|
|
3465
|
+
*
|
|
3466
|
+
* The walk stops at the first match whatever it finds inside: the loader
|
|
3467
|
+
* imports from that directory or fails there, and never falls through to an
|
|
3468
|
+
* ancestor (measured on 26.6.0: an empty `node_modules/<name>` in front of an
|
|
3469
|
+
* ancestor's working copy fails the import with ERR_MODULE_NOT_FOUND).
|
|
3470
|
+
* Whether the match is a PACKAGE is `packageDirectory`'s question.
|
|
3471
|
+
*
|
|
3472
|
+
* Throws only for a `spec` that is not a bare package name. No state of the
|
|
3473
|
+
* filesystem can make it throw.
|
|
3474
|
+
*/
|
|
3475
|
+
function findPackageDir(fromDir, spec, fs) {
|
|
3476
|
+
if (!isBarePackageName(spec)) throw new TypeError(`not a bare package name: ${JSON.stringify(spec)}`);
|
|
3477
|
+
const segments = spec.split("/");
|
|
3478
|
+
for (let dir = fromDir;;) {
|
|
3479
|
+
const candidate = join(dir, "node_modules", ...segments);
|
|
3480
|
+
if (isDirectoryAt(fs, candidate)) return candidate;
|
|
3481
|
+
const parent = dirname(dir);
|
|
3482
|
+
if (parent === dir) return null;
|
|
3483
|
+
dir = parent;
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
/**
|
|
3487
|
+
* The directory `spec` is installed at as seen from `fromDir`, or null when
|
|
3488
|
+
* it is not installed there: the walk's match (`findPackageDir`), and only
|
|
3489
|
+
* when that directory holds a `package.json` that stats as a FILE. Anything
|
|
3490
|
+
* else is ABSENT — a match with no manifest, a manifest that is not a file,
|
|
3491
|
+
* one whose stat fails — because the loader matches that directory and the
|
|
3492
|
+
* import fails there. The case this exists for: an uninstall that stops
|
|
3493
|
+
* part-way (on Windows a locked file is enough) can leave
|
|
3494
|
+
* `<profile>/node_modules/L/` holding nothing but a nested `node_modules/`,
|
|
3495
|
+
* and a plugin declaring L then fails with ERR_MODULE_NOT_FOUND; while any
|
|
3496
|
+
* directory counted as present, the badge naming L disappeared. The lookup
|
|
3497
|
+
* before that one, `require.resolve('<spec>/package.json')`, required the
|
|
3498
|
+
* manifest too.
|
|
3499
|
+
*
|
|
3500
|
+
* The manifest is stat-ed and never read here: presence is the file, and
|
|
3501
|
+
* what it says is the version resolver's question. So a manifest that exists
|
|
3502
|
+
* but cannot be read or parsed is a present package with no readable version.
|
|
3503
|
+
*
|
|
3504
|
+
* One edge is knowingly given up: a directory holding `index.js` and no
|
|
3505
|
+
* manifest DOES import — Node's legacy main resolution tries `index.js` in a
|
|
3506
|
+
* match without a manifest — but reads absent here. No package manager
|
|
3507
|
+
* installs that shape.
|
|
3508
|
+
*
|
|
3509
|
+
* Throws only for a `spec` that is not a bare package name. This one lookup
|
|
3510
|
+
* is behind the presence resolver, the version resolver, and the running
|
|
3511
|
+
* harness's own `@deepseek-ai/dsh-app-boot` (`harness.ts`).
|
|
3512
|
+
*/
|
|
3513
|
+
function packageDirectory(fromDir, spec, fs = NODE_LOOKUP_FS) {
|
|
3514
|
+
const match = findPackageDir(fromDir, spec, fs);
|
|
3515
|
+
return match !== null && isFileAt(fs, join(match, "package.json")) ? match : null;
|
|
3516
|
+
}
|
|
3517
|
+
/**
|
|
3518
|
+
* The directory the walk starts from. `createRequire` took a file URL or an
|
|
3519
|
+
* absolute path and so does this — the production anchor is
|
|
3520
|
+
* `<profile>/cordis.yml` as a file URL — and, like `createRequire`, a
|
|
3521
|
+
* trailing separator names the directory itself rather than a file in its
|
|
3522
|
+
* parent. Anything else throws here, at construction, which the gateway's
|
|
3523
|
+
* call sites already treat as "no anchor, no verdict".
|
|
3524
|
+
*/
|
|
3525
|
+
function anchorDirectory(baseUrl) {
|
|
3526
|
+
const path = isAbsolute(baseUrl) ? baseUrl : fileURLToPath(baseUrl);
|
|
3527
|
+
return path.endsWith("/") || path.endsWith(sep) ? path : dirname(path);
|
|
3528
|
+
}
|
|
3529
|
+
/** `nodeResolver` over an injected filesystem, walking up from `fromDir` —
|
|
3530
|
+
* the seam fixtures drive the lookup through. */
|
|
3531
|
+
function packageResolver(fromDir, fs) {
|
|
3532
|
+
return (spec) => packageDirectory(fromDir, spec, fs) !== null;
|
|
3533
|
+
}
|
|
2823
3534
|
/**
|
|
2824
|
-
* The production resolver
|
|
2825
|
-
*
|
|
2826
|
-
*
|
|
2827
|
-
*
|
|
3535
|
+
* The production resolver, anchored at the profile. A peer is present when
|
|
3536
|
+
* the first ancestor `node_modules/<spec>` that is a directory holds a
|
|
3537
|
+
* `package.json` file — the directory the ESM resolver that loads plugins'
|
|
3538
|
+
* host halves matches for a bare package, and a manifest in it
|
|
3539
|
+
* (`findPackageDir`, `packageDirectory`). From `<profile>/cordis.yml` the
|
|
3540
|
+
* walk reads `<profile>/node_modules`, then `$DSH_HOME/profiles/node_modules`
|
|
3541
|
+
* — the link farm dsh-app-boot's `healProfilesModuleFallback` keeps, pointing
|
|
3542
|
+
* into the global dsh install, on 0.1.5; 0.1.7 keeps none, and on a harness
|
|
3543
|
+
* that serves its packages another way `harnessPackageResolver` asks it in
|
|
3544
|
+
* front of this walk — and so on up to the root, past any candidate
|
|
3545
|
+
* it cannot stat, as Node's reader walks past one (measured on Node 26.6.0
|
|
3546
|
+
* with a looping link in front of a real copy: the import loads the copy).
|
|
3547
|
+
* The one shape where this and the loader knowingly disagree is a directory
|
|
3548
|
+
* holding `index.js` and no manifest, which imports and reads absent here;
|
|
3549
|
+
* no package manager installs it (`packageDirectory`).
|
|
3550
|
+
*
|
|
3551
|
+
* It used to ask `require.resolve('<spec>/package.json')`, a question that
|
|
3552
|
+
* answered wrongly twice:
|
|
3553
|
+
*
|
|
3554
|
+
* - Under a packaged dsh executable (`process.pkg`) the link farm holds ESM
|
|
3555
|
+
* proxy packages instead of symlinks, and a proxy's `exports` leaves
|
|
3556
|
+
* `./package.json` out on purpose, so resolving it threw
|
|
3557
|
+
* ERR_PACKAGE_PATH_NOT_EXPORTED for every proxied peer. Presence read that
|
|
3558
|
+
* as present; the version resolver below could read nothing. Reproduced:
|
|
3559
|
+
* a proxy at `0.2.0-rc.1` against a declared `^0.1.1-rc.2` gave presence
|
|
3560
|
+
* true, version null, mismatches `[]` — the load-time self-check could
|
|
3561
|
+
* never fire under a packaged dsh.
|
|
3562
|
+
* - Node caches a SUCCESSFUL CJS resolution for the life of the process
|
|
3563
|
+
* (`Module._pathCache`, keyed on request + lookup paths) and hands the
|
|
3564
|
+
* cached filename back without looking at the disk; a failure is not
|
|
3565
|
+
* cached. Measured on Node 26.6.0 in one process: absent → false,
|
|
3566
|
+
* installed → true, uninstalled → STILL true. A peer the reader removed
|
|
3567
|
+
* left every plugin declaring it unflagged until dsh restarted.
|
|
3568
|
+
*
|
|
3569
|
+
* So nothing is cached and `exports` is never consulted: every call stats
|
|
3570
|
+
* afresh, and a package with a manifest is present whatever its `exports`
|
|
3571
|
+
* says. `stat` follows symlinks, so a link to nothing reads absent — which
|
|
3572
|
+
* matters, because the link farm adds links and never prunes them: 29 of its
|
|
3573
|
+
* 511 dangled on the machine this was measured on, after upgrades.
|
|
3574
|
+
*
|
|
3575
|
+
* The global folders — `NODE_PATH`, `~/.node_modules`, `~/.node_libraries`,
|
|
3576
|
+
* `$PREFIX/lib/node` — are deliberately not searched: the ESM resolver that
|
|
3577
|
+
* loads plugin host code does not search them either. That is not academic.
|
|
3578
|
+
* pnpm's bin shims export `NODE_PATH` (this repo's own `node_modules/.bin/
|
|
3579
|
+
* vitest` points it into the virtual store), and under one the old lookup
|
|
3580
|
+
* found harness packages from any anchor at all, a temp directory included.
|
|
3581
|
+
*
|
|
3582
|
+
* It throws only for a `spec` that is not a bare package name, and
|
|
3583
|
+
* `incompatibilityMap` turns the throw into no verdict: a hostile name is not
|
|
3584
|
+
* a fact about this installation, and false is an accusation. Nothing on the
|
|
3585
|
+
* filesystem makes it throw. It used to throw on a stat failure other than
|
|
3586
|
+
* ENOENT or ENOTDIR as well, reasoning that such a candidate "may hold the
|
|
3587
|
+
* package" — but the loader never asks what a candidate it cannot stat holds;
|
|
3588
|
+
* it walks on (`findPackageDir`), so the throw silenced true badges and
|
|
3589
|
+
* established nothing.
|
|
2828
3590
|
*/
|
|
2829
3591
|
function nodeResolver(baseUrl) {
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
3592
|
+
return packageResolver(anchorDirectory(baseUrl), NODE_LOOKUP_FS);
|
|
3593
|
+
}
|
|
3594
|
+
/**
|
|
3595
|
+
* The manifest of the package the harness reaches for `spec` from
|
|
3596
|
+
* `parentURL`, or null when it reaches none, or one whose manifest no longer
|
|
3597
|
+
* stats as a file. The stat is fresh on every call for the reason §9.4 gives:
|
|
3598
|
+
* the service remembers a package it once found, and an uninstalled peer
|
|
3599
|
+
* must stop reading as present. Throws what the service throws, and a
|
|
3600
|
+
* TypeError for a name that is not a bare package name — which never reaches
|
|
3601
|
+
* the service, because peer names are catalog text.
|
|
3602
|
+
*/
|
|
3603
|
+
function harnessManifest(lookup, spec, parentURL, fs) {
|
|
3604
|
+
if (!isBarePackageName(spec)) throw new TypeError(`not a bare package name: ${JSON.stringify(spec)}`);
|
|
3605
|
+
const manifestPath = lookup.packageOf(spec, parentURL)?.manifestPath;
|
|
3606
|
+
return typeof manifestPath === "string" && isFileAt(fs, manifestPath) ? manifestPath : null;
|
|
3607
|
+
}
|
|
3608
|
+
/**
|
|
3609
|
+
* Presence on a harness that serves its packages through its runtime
|
|
3610
|
+
* resolution (`HarnessPackageLookup`): present when the harness reaches a
|
|
3611
|
+
* package for `spec` from `parentURL`, the profile anchor, and otherwise
|
|
3612
|
+
* whatever `walk` answers — `nodeResolver` over the same anchor, in
|
|
3613
|
+
* production. Asking the harness first asks the loader's own question, since
|
|
3614
|
+
* a plugin's import goes through the same resolution. The walk stays behind
|
|
3615
|
+
* it because either answer can only clear a badge, never raise one, and a
|
|
3616
|
+
* false badge is the failure this module exists to avoid.
|
|
3617
|
+
*
|
|
3618
|
+
* Throws, which `incompatibilityMap` turns into no verdict for the entries
|
|
3619
|
+
* declaring `spec`, for a name that is not a bare package name and for
|
|
3620
|
+
* anything the service throws. A harness that cannot answer is silence:
|
|
3621
|
+
* falling back to the walk alone would read every package it serves as
|
|
3622
|
+
* missing.
|
|
3623
|
+
*/
|
|
3624
|
+
function harnessPackageResolver(walk, lookup, parentURL, fs = NODE_LOOKUP_FS) {
|
|
3625
|
+
return (spec) => harnessManifest(lookup, spec, parentURL, fs) !== null || walk(spec);
|
|
2842
3626
|
}
|
|
2843
3627
|
/**
|
|
2844
3628
|
* Package name → the peer names that did not resolve. A key is present only
|
|
@@ -2876,41 +3660,91 @@ function incompatibilityMap(entries, resolve) {
|
|
|
2876
3660
|
}
|
|
2877
3661
|
return out;
|
|
2878
3662
|
}
|
|
2879
|
-
/**
|
|
2880
|
-
*
|
|
2881
|
-
|
|
2882
|
-
* answers null — a peer that is absent, one that restricts `./package.json`
|
|
2883
|
-
* in its exports, an unreadable or malformed manifest, a manifest with no
|
|
2884
|
-
* `version`. Absence is deliberately NOT reported as a version violation:
|
|
2885
|
-
* `incompatibilityMap` is what covers a missing peer.
|
|
2886
|
-
*/
|
|
2887
|
-
function nodeVersionResolver(baseUrl) {
|
|
2888
|
-
const require = createRequire(baseUrl);
|
|
3663
|
+
/** `nodeVersionResolver` over an injected filesystem, walking up from
|
|
3664
|
+
* `fromDir` — the seam fixtures drive the lookup through. */
|
|
3665
|
+
function packageVersionResolver(fromDir, fs) {
|
|
2889
3666
|
return (spec) => {
|
|
2890
|
-
let
|
|
2891
|
-
try {
|
|
2892
|
-
manifestPath = require.resolve(`${spec}/package.json`);
|
|
2893
|
-
} catch {
|
|
2894
|
-
return null;
|
|
2895
|
-
}
|
|
3667
|
+
let dir;
|
|
2896
3668
|
try {
|
|
2897
|
-
|
|
2898
|
-
return typeof version === "string" ? version : null;
|
|
3669
|
+
dir = packageDirectory(fromDir, spec, fs);
|
|
2899
3670
|
} catch {
|
|
2900
3671
|
return null;
|
|
2901
3672
|
}
|
|
3673
|
+
return dir === null ? null : manifestVersion(fs, join(dir, "package.json"));
|
|
2902
3674
|
};
|
|
2903
3675
|
}
|
|
3676
|
+
/** The non-empty `version` string of the manifest at `manifestPath`, or null
|
|
3677
|
+
* for anything else. */
|
|
3678
|
+
function manifestVersion(fs, manifestPath) {
|
|
3679
|
+
let manifest;
|
|
3680
|
+
try {
|
|
3681
|
+
manifest = JSON.parse(fs.readFile(manifestPath));
|
|
3682
|
+
} catch {
|
|
3683
|
+
return null;
|
|
3684
|
+
}
|
|
3685
|
+
const version = typeof manifest === "object" && manifest !== null ? manifest.version : void 0;
|
|
3686
|
+
return typeof version === "string" && version.length > 0 ? version : null;
|
|
3687
|
+
}
|
|
2904
3688
|
/**
|
|
2905
|
-
* The
|
|
2906
|
-
*
|
|
3689
|
+
* The production version resolver: `nodeResolver`'s lookup, then the matched
|
|
3690
|
+
* directory's own `package.json`, read directly. Reading it directly is the
|
|
3691
|
+
* fix, not a shortcut: resolving `<spec>/package.json` is what an exports map
|
|
3692
|
+
* can refuse, and a packaged dsh's module proxy refuses it by construction
|
|
3693
|
+
* while carrying the proxied package's version in exactly that file (see
|
|
3694
|
+
* `nodeResolver`).
|
|
2907
3695
|
*
|
|
2908
|
-
*
|
|
2909
|
-
*
|
|
2910
|
-
*
|
|
2911
|
-
*
|
|
2912
|
-
*
|
|
2913
|
-
*
|
|
3696
|
+
* The directory is the one the presence resolver finds present — the walk's
|
|
3697
|
+
* first directory match, holding a `package.json` file (`packageDirectory`;
|
|
3698
|
+
* `findPackageDir` cites Node's reader) — so the two never disagree about
|
|
3699
|
+
* which copy is installed: a candidate nobody can stat is walked past to the
|
|
3700
|
+
* copy further up, whose version is then the answer, exactly as the loader
|
|
3701
|
+
* imports that copy (measured on Node 26.6.0 with a looping link in front of
|
|
3702
|
+
* it); a match without a manifest is absent, and shadows every copy above
|
|
3703
|
+
* it. An `index.js`-only directory, which the loader would import, has no
|
|
3704
|
+
* manifest to read a version from either way.
|
|
3705
|
+
*
|
|
3706
|
+
* Anything that leaves no version to read answers null: a spec that is not a
|
|
3707
|
+
* bare package name, a peer that is absent, a manifest that is unreadable or
|
|
3708
|
+
* malformed, a `version` that is not a non-empty string. Absence is
|
|
3709
|
+
* deliberately NOT reported as a version violation: `incompatibilityMap` is
|
|
3710
|
+
* what covers a missing peer.
|
|
3711
|
+
*/
|
|
3712
|
+
function nodeVersionResolver(baseUrl) {
|
|
3713
|
+
return packageVersionResolver(anchorDirectory(baseUrl), NODE_LOOKUP_FS);
|
|
3714
|
+
}
|
|
3715
|
+
/**
|
|
3716
|
+
* `harnessPackageResolver`'s twin for versions: read afresh from the manifest
|
|
3717
|
+
* of the package the harness reaches for `spec` — the copy a plugin's import
|
|
3718
|
+
* loads, which the walk cannot see on dsh 0.1.7 — and `walk`'s answer only
|
|
3719
|
+
* when the harness reaches none. A manifest the harness names but nobody can
|
|
3720
|
+
* read or parse answers null, never the walk's version: the walk may match
|
|
3721
|
+
* another copy, and the version must describe the copy presence found (see
|
|
3722
|
+
* `nodeVersionResolver`).
|
|
3723
|
+
*
|
|
3724
|
+
* Null for a name that is not a bare package name and for a service that
|
|
3725
|
+
* throws, as null is already this resolver's no-verdict signal.
|
|
3726
|
+
*/
|
|
3727
|
+
function harnessPackageVersionResolver(walk, lookup, parentURL, fs = NODE_LOOKUP_FS) {
|
|
3728
|
+
return (spec) => {
|
|
3729
|
+
let manifestPath;
|
|
3730
|
+
try {
|
|
3731
|
+
manifestPath = harnessManifest(lookup, spec, parentURL, fs);
|
|
3732
|
+
} catch {
|
|
3733
|
+
return null;
|
|
3734
|
+
}
|
|
3735
|
+
return manifestPath === null ? walk(spec) : manifestVersion(fs, manifestPath);
|
|
3736
|
+
};
|
|
3737
|
+
}
|
|
3738
|
+
/**
|
|
3739
|
+
* The declared peers whose provided version is outside the declared range,
|
|
3740
|
+
* ordered by peer name. Pure: the module graph arrives through `resolve`.
|
|
3741
|
+
*
|
|
3742
|
+
* `includePrerelease` is load-bearing, not a convenience. The harness ships
|
|
3743
|
+
* nothing but `-rc` versions, so under strict semver `^0.1.1-rc.2` excludes
|
|
3744
|
+
* every later rc on the same 0.1 line — including whichever one is installed
|
|
3745
|
+
* and working, named as a property because that version moves and a comment
|
|
3746
|
+
* naming it goes stale in place — and every future rc bump would raise a
|
|
3747
|
+
* false alarm. With it on, the range still excludes an older prerelease
|
|
2914
3748
|
* (`0.1.1-rc.1`) and a minor- or major-line move
|
|
2915
3749
|
* (`0.2.0-rc.1`, `1.0.0`), which are the moves that actually break a plugin
|
|
2916
3750
|
* path. Discrimination on both sides is the whole point: one false warning
|
|
@@ -2974,6 +3808,700 @@ function createPeerVersionCheck(deps) {
|
|
|
2974
3808
|
};
|
|
2975
3809
|
}
|
|
2976
3810
|
//#endregion
|
|
3811
|
+
//#region src/host/harness.ts
|
|
3812
|
+
/**
|
|
3813
|
+
* The harness this process runs: which `@deepseek-ai/dsh` it is, and that
|
|
3814
|
+
* dsh's own profile-template table — the running side of the
|
|
3815
|
+
* `dsh.compatibility` verdict (design 2026-09-01-harness-compatibility §9.9).
|
|
3816
|
+
*
|
|
3817
|
+
* Both used to be read from somewhere that is not the running harness, and
|
|
3818
|
+
* both were wrong there:
|
|
3819
|
+
*
|
|
3820
|
+
* - The version was the first `node_modules/@deepseek-ai/dsh` found walking up
|
|
3821
|
+
* from the profile: what the profile can IMPORT, not what RUNS. A plugin
|
|
3822
|
+
* that depends on the dsh package gets its copy hoisted into
|
|
3823
|
+
* `<profile>/node_modules` — the listed `dsh-claude-tui@0.1.6` pulls
|
|
3824
|
+
* 0.1.2-rc.1 — a second install sharing DSH_HOME re-points the link farm,
|
|
3825
|
+
* and under a packaged dsh the bin-only package gets no link-farm proxy at
|
|
3826
|
+
* all, so the read was dead or stale. The first was reproduced with real
|
|
3827
|
+
* installs on 2026-09-25 (the design's section 9.9 amendment): every
|
|
3828
|
+
* verdict said "running 0.1.2-rc.1" while 0.1.5-rc.3 ran.
|
|
3829
|
+
* - The templates came from the shop's own `import * as appBoot`, which
|
|
3830
|
+
* resolves from the SHOP's real path: under a `link:` install that is this
|
|
3831
|
+
* repository's devDependency, 0.1.1-rc.2 with two templates, while
|
|
3832
|
+
* 0.1.5-rc.3 with five runs — and in production a copy hoisted from some
|
|
3833
|
+
* plugin's dependencies can shadow it the same way.
|
|
3834
|
+
*
|
|
3835
|
+
* Both now come from the one thing that names the running harness: the script
|
|
3836
|
+
* this process was started with. Resolved through symlinks, the package that
|
|
3837
|
+
* owns it IS the dsh CLI that is running, if it is one at all; its manifest
|
|
3838
|
+
* says its version, and the app-boot it imports says its templates.
|
|
3839
|
+
*/
|
|
3840
|
+
/** The package whose `PROFILE_TEMPLATES` the running dsh composes profiles
|
|
3841
|
+
* from. */
|
|
3842
|
+
const APP_BOOT = "@deepseek-ai/dsh-app-boot";
|
|
3843
|
+
/** What is known when nothing says which harness runs. */
|
|
3844
|
+
function noHarness() {
|
|
3845
|
+
return {
|
|
3846
|
+
dshVersion: null,
|
|
3847
|
+
templates: profileTemplatesOf(void 0),
|
|
3848
|
+
patchLists: null,
|
|
3849
|
+
peerCheck: null
|
|
3850
|
+
};
|
|
3851
|
+
}
|
|
3852
|
+
/** Whether `path` stats as a regular file. */
|
|
3853
|
+
function isFile(path) {
|
|
3854
|
+
try {
|
|
3855
|
+
return statSync(path).isFile();
|
|
3856
|
+
} catch {
|
|
3857
|
+
return false;
|
|
3858
|
+
}
|
|
3859
|
+
}
|
|
3860
|
+
/**
|
|
3861
|
+
* The package that owns `start`: the FIRST directory at or above it holding a
|
|
3862
|
+
* `package.json` file, and its parsed manifest — or null when there is none
|
|
3863
|
+
* up to the root, or when that manifest cannot be read or parsed. The walk
|
|
3864
|
+
* stops at the first manifest whatever it names, the rule `owningEntry` in
|
|
3865
|
+
* `dsh-cli.ts` follows: a script owned by some other package is not dsh's,
|
|
3866
|
+
* and climbing on would find an unrelated ancestor — a workspace root, or dsh
|
|
3867
|
+
* itself above a package vendored inside it.
|
|
3868
|
+
*/
|
|
3869
|
+
function owningPackage(start) {
|
|
3870
|
+
for (let dir = start;;) {
|
|
3871
|
+
const manifestPath = join(dir, "package.json");
|
|
3872
|
+
if (isFile(manifestPath)) try {
|
|
3873
|
+
return {
|
|
3874
|
+
dir,
|
|
3875
|
+
manifest: JSON.parse(readFileSync(manifestPath, "utf8"))
|
|
3876
|
+
};
|
|
3877
|
+
} catch {
|
|
3878
|
+
return null;
|
|
3879
|
+
}
|
|
3880
|
+
const parent = dirname(dir);
|
|
3881
|
+
if (parent === dir) return null;
|
|
3882
|
+
dir = parent;
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
/**
|
|
3886
|
+
* What the app-boot the dsh at `dshDir` imports says about that dsh: its
|
|
3887
|
+
* template table, and whether it reads a list-valued bundle patch. Null when
|
|
3888
|
+
* no app-boot is on the resolution path.
|
|
3889
|
+
*
|
|
3890
|
+
* Found with the node_modules walk the peer check uses (`packageDirectory`)
|
|
3891
|
+
* from the dsh package directory — the copy nested under dsh first, then one
|
|
3892
|
+
* beside it, as dsh's own import finds it — and never through `NODE_PATH`:
|
|
3893
|
+
* pnpm's bin shims export one, and a bare CJS lookup finds a package through
|
|
3894
|
+
* it from any directory at all. Only the ABSOLUTE directory then reaches
|
|
3895
|
+
* Node's CJS resolver, which reads its `main` (an `exports` map applies to
|
|
3896
|
+
* bare specifiers only), and the entry is imported by file URL, which Windows
|
|
3897
|
+
* requires of an absolute path. Under the dsh that runs, that URL is the one
|
|
3898
|
+
* dsh itself loaded — the CJS resolver realpaths as the ESM loader does, and
|
|
3899
|
+
* app-boot's `main` and `exports` name the same file — so the import is a
|
|
3900
|
+
* cache hit, not a second instance. Measured against a global npm install of
|
|
3901
|
+
* 0.1.5-rc.3: this chain and the ESM resolution from dsh's own `lib/bin.js`
|
|
3902
|
+
* give the same URL, and the reader, handed `<prefix>/bin/dsh`, answers
|
|
3903
|
+
* 0.1.5-rc.3 with all five templates.
|
|
3904
|
+
*
|
|
3905
|
+
* Throws for whatever that chain cannot do; `readRunningHarness` turns any
|
|
3906
|
+
* throw into an empty table, an unknown `patchLists` and no peer check.
|
|
3907
|
+
*/
|
|
3908
|
+
async function appBootFacts(dshDir) {
|
|
3909
|
+
const appBootDir = packageDirectory(dshDir, APP_BOOT);
|
|
3910
|
+
if (appBootDir === null) return null;
|
|
3911
|
+
const entry = createRequire(import.meta.url).resolve(appBootDir);
|
|
3912
|
+
const exported = await import(pathToFileURL(entry).href);
|
|
3913
|
+
return {
|
|
3914
|
+
templates: profileTemplatesOf(exported.PROFILE_TEMPLATES),
|
|
3915
|
+
patchLists: typeof exported.bundlePatchFiles === "function",
|
|
3916
|
+
peerCheck: peerCheckOf(exported, appBootDir)
|
|
3917
|
+
};
|
|
3918
|
+
}
|
|
3919
|
+
/**
|
|
3920
|
+
* The peer check an app-boot exports, bound to its own version (see
|
|
3921
|
+
* `PeerCheck.evaluate`), or null when it exports only part of one — a rule
|
|
3922
|
+
* without the exemptions would refuse what a profile has already allowed —
|
|
3923
|
+
* or when its manifest carries no version, which is the one `evaluate`
|
|
3924
|
+
* judges by. Never throws, so a check that cannot be formed costs the
|
|
3925
|
+
* templates and the patch-list answer nothing.
|
|
3926
|
+
*/
|
|
3927
|
+
function peerCheckOf(exported, appBootDir) {
|
|
3928
|
+
const { evaluatePluginCompatibility: evaluate, readProfileVersionExemptions: exemptions } = exported;
|
|
3929
|
+
if (typeof evaluate !== "function" || typeof exemptions !== "function") return null;
|
|
3930
|
+
let version;
|
|
3931
|
+
try {
|
|
3932
|
+
version = JSON.parse(readFileSync(join(appBootDir, "package.json"), "utf8")).version;
|
|
3933
|
+
} catch {
|
|
3934
|
+
return null;
|
|
3935
|
+
}
|
|
3936
|
+
if (typeof version !== "string" || version.length === 0) return null;
|
|
3937
|
+
const runtime = version;
|
|
3938
|
+
const judge = evaluate;
|
|
3939
|
+
return {
|
|
3940
|
+
evaluate: (manifest, profileExemptions) => judge(manifest, profileExemptions, runtime),
|
|
3941
|
+
exemptions
|
|
3942
|
+
};
|
|
3943
|
+
}
|
|
3944
|
+
/**
|
|
3945
|
+
* The harness this process runs, identified from `script` — the gateway's
|
|
3946
|
+
* `restartScript`, `process.argv[1]` in production — or nothing, never a
|
|
3947
|
+
* guess:
|
|
3948
|
+
*
|
|
3949
|
+
* 1. `script` is resolved through symlinks, because `argv[1]` is the path the
|
|
3950
|
+
* shell ran — `<prefix>/bin/dsh`, a link — and only its target lies inside
|
|
3951
|
+
* the dsh package. No script, or one that does not resolve: nothing known.
|
|
3952
|
+
* An empty path is no script either: `realpathSync('')` answers the
|
|
3953
|
+
* working directory, which says where dsh was started, not what it is.
|
|
3954
|
+
* 2. The package that owns it (`owningPackage`) must be `@deepseek-ai/dsh`.
|
|
3955
|
+
* Anything else — a test runner, another host embedding the shop — means
|
|
3956
|
+
* nothing here can say which harness runs, and both halves of the verdict
|
|
3957
|
+
* stay silent.
|
|
3958
|
+
* 3. `dshVersion` is that manifest's `version` when it is a non-empty string.
|
|
3959
|
+
* 4. `templates`, `patchLists` and `peerCheck` come from that dsh's own
|
|
3960
|
+
* app-boot (`appBootFacts`). Any failure there costs all three and keeps
|
|
3961
|
+
* the version.
|
|
3962
|
+
*
|
|
3963
|
+
* Never throws and never rejects: a harness nobody could identify forms no
|
|
3964
|
+
* verdict, and must never be the reason the catalog fails to load.
|
|
3965
|
+
*/
|
|
3966
|
+
async function readRunningHarness(script) {
|
|
3967
|
+
try {
|
|
3968
|
+
if (script === void 0 || script === "") return noHarness();
|
|
3969
|
+
const owner = owningPackage(dirname(realpathSync(script)));
|
|
3970
|
+
if (owner === null || typeof owner.manifest !== "object" || owner.manifest === null) return noHarness();
|
|
3971
|
+
const { name, version } = owner.manifest;
|
|
3972
|
+
if (name !== "@deepseek-ai/dsh") return noHarness();
|
|
3973
|
+
const dshVersion = typeof version === "string" && version.length > 0 ? version : null;
|
|
3974
|
+
let facts;
|
|
3975
|
+
try {
|
|
3976
|
+
facts = await appBootFacts(owner.dir);
|
|
3977
|
+
} catch {
|
|
3978
|
+
facts = null;
|
|
3979
|
+
}
|
|
3980
|
+
return {
|
|
3981
|
+
dshVersion,
|
|
3982
|
+
templates: facts?.templates ?? profileTemplatesOf(void 0),
|
|
3983
|
+
patchLists: facts?.patchLists ?? null,
|
|
3984
|
+
peerCheck: facts?.peerCheck ?? null
|
|
3985
|
+
};
|
|
3986
|
+
} catch {
|
|
3987
|
+
return noHarness();
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
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
|
|
2977
4505
|
//#region src/host/index.ts
|
|
2978
4506
|
/** ShopGateway: the Host half of dsh-plugin-shop (§5.1). */
|
|
2979
4507
|
var __runInitializers = function(thisArg, initializers, value) {
|
|
@@ -3026,10 +4554,25 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
|
|
|
3026
4554
|
* service and to set an override that the platform check, being the first gate
|
|
3027
4555
|
* of the three, could never reach. */
|
|
3028
4556
|
const RESTART_BLOCKED_DETAIL = {
|
|
4557
|
+
desktop: "dsh-plugin-shop: the desktop profile is managed by the DeepSeek Harness desktop app, and dsh refuses to restart it from here; restart the app to apply the change",
|
|
3029
4558
|
windows: "dsh-plugin-shop: restart is not supported on Windows yet; restart dsh manually to apply the change",
|
|
3030
4559
|
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.",
|
|
3031
4560
|
"port-zero": "dsh-plugin-shop: restart is not supported when dsh was launched with --port 0; restart dsh manually"
|
|
3032
4561
|
};
|
|
4562
|
+
/** Why the shop changes nothing in the desktop profile, for every mutation
|
|
4563
|
+
* that spawns dsh's CLI: install, uninstall and self-update. dsh refuses
|
|
4564
|
+
* the profile before any of them would run (`isDesktopProfile`), and the
|
|
4565
|
+
* failure that surfaced instead read "pnpm failed in the profile". */
|
|
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.`;
|
|
3033
4576
|
/** How many bytes a release tarball may be at the integrity check. The
|
|
3034
4577
|
* registry already refuses to publish a tarball over 32 MiB, so 64 MiB is
|
|
3035
4578
|
* headroom, not a gate of its own. */
|
|
@@ -3087,6 +4630,29 @@ async function verifyTarballSha256(fetchTarball, url, recordedSha256, maxBytes =
|
|
|
3087
4630
|
function ownDependencySpec(dependencies, name) {
|
|
3088
4631
|
return Object.hasOwn(dependencies, name) ? dependencies[name] : void 0;
|
|
3089
4632
|
}
|
|
4633
|
+
/**
|
|
4634
|
+
* Package names whose module this PROCESS may already hold in Node's module
|
|
4635
|
+
* cache (design 2026-09-26-market-borrowings §1.2).
|
|
4636
|
+
*
|
|
4637
|
+
* A hot mount can only deliver code the process has not imported yet. The hot
|
|
4638
|
+
* tree imports through the loader's `internal.import`, which is Node's own ESM
|
|
4639
|
+
* loader and caches a module by its URL, and an install rewrites a package's
|
|
4640
|
+
* files at the URL it already had (`nodeLinker: hoisted`). Once a name is in
|
|
4641
|
+
* here, a mount of it would re-run the cached module under whatever version is
|
|
4642
|
+
* now on disk.
|
|
4643
|
+
*
|
|
4644
|
+
* Module scope rather than a gateway field, because the cache it models is the
|
|
4645
|
+
* process's: a gateway rebuilt inside a running dsh — a restarted shop fiber —
|
|
4646
|
+
* must not forget what the process imported before it existed. Every gateway
|
|
4647
|
+
* seeds it with the profile's dependencies at construction (the boot
|
|
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.
|
|
4654
|
+
*/
|
|
4655
|
+
const importedThisProcess = /* @__PURE__ */ new Set();
|
|
3090
4656
|
/** Remote-only service exposing the shop Remote methods of §7.3.
|
|
3091
4657
|
*
|
|
3092
4658
|
* @typert service shop */
|
|
@@ -3234,6 +4800,17 @@ let ShopGateway = (() => {
|
|
|
3234
4800
|
});
|
|
3235
4801
|
}
|
|
3236
4802
|
options = __runInitializers(this, _instanceExtraInitializers);
|
|
4803
|
+
/** The context this gateway was built with: the shop's own fiber. Inside an
|
|
4804
|
+
* RPC method `this.ctx` is NOT that — cordis hands a service out with `ctx`
|
|
4805
|
+
* rebound to the context that looked it up, so a call arriving over the
|
|
4806
|
+
* wire sees the caller's, the typert gateway's (measured on 0.1.5-rc.3 and
|
|
4807
|
+
* 0.1.7-rc.2). Anything whose PARENT matters is registered from here. The
|
|
4808
|
+
* hot tree above all: registered from the gateway's context it was owned by
|
|
4809
|
+
* the gateway's fiber rather than the shop's, was listed under the gateway's
|
|
4810
|
+
* loader entry, and — the part a reader saw — sat where dsh's client
|
|
4811
|
+
* registry never composed it, so a hot-installed browser half needed a
|
|
4812
|
+
* restart that a reload now does (design 2026-09-26-dsh-017-readiness). */
|
|
4813
|
+
home;
|
|
3237
4814
|
/** The profile dsh installs into; discovered from this module's own
|
|
3238
4815
|
* location when the caller does not supply one. */
|
|
3239
4816
|
profile;
|
|
@@ -3253,6 +4830,11 @@ let ShopGateway = (() => {
|
|
|
3253
4830
|
exit;
|
|
3254
4831
|
restartExitDelayMs;
|
|
3255
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;
|
|
3256
4838
|
latestVersion;
|
|
3257
4839
|
pinFs;
|
|
3258
4840
|
allowRestart;
|
|
@@ -3269,6 +4851,11 @@ let ShopGateway = (() => {
|
|
|
3269
4851
|
/** One pump for the whole gateway: batching is per profile and lives inside
|
|
3270
4852
|
* it, so a second instance would race the first for the same store. */
|
|
3271
4853
|
prefetcher;
|
|
4854
|
+
/** What this process may already have imported (see `importedThisProcess`). */
|
|
4855
|
+
imported;
|
|
4856
|
+
/** Routes the output dsh's `pluginManager` streams to the record whose
|
|
4857
|
+
* request id it carries (see the constructor). */
|
|
4858
|
+
managerLogs;
|
|
3272
4859
|
/** The install gate runs against the last loaded snapshot, never a fresh
|
|
3273
4860
|
* fetch per request (§7.2: the Host's cached snapshot is the truth). */
|
|
3274
4861
|
/** Finished install records retained, so a poll sees the true terminal
|
|
@@ -3288,22 +4875,19 @@ let ShopGateway = (() => {
|
|
|
3288
4875
|
/** The user's own `registry=` from `~/.npmrc`, read at most once per
|
|
3289
4876
|
* gateway. A wrapper distinguishes a genuine `null` from an unread value. */
|
|
3290
4877
|
npmRegistryCache = null;
|
|
3291
|
-
/** The
|
|
3292
|
-
*
|
|
3293
|
-
*
|
|
3294
|
-
*
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
*
|
|
3298
|
-
* failed walk on every single call. Recomputed only when `catalog()`
|
|
3299
|
-
* loads a snapshot that is not this exact object. */
|
|
3300
|
-
incompatibleCache = null;
|
|
3301
|
-
/** Install records, running and finished; a poll finds one here or reports not found. */
|
|
4878
|
+
/** The harness this process runs, read at most once per gateway: which dsh
|
|
4879
|
+
* is running cannot change while it runs, and the read imports a module. A
|
|
4880
|
+
* promise, so concurrent first calls share one read; `readRunningHarness`
|
|
4881
|
+
* never rejects, so there is no failure to retry. */
|
|
4882
|
+
harnessRead = null;
|
|
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. */
|
|
3302
4885
|
installs = /* @__PURE__ */ new Map();
|
|
3303
4886
|
/** Every install id in insertion order, oldest first; finished-record eviction walks this from the front. */
|
|
3304
4887
|
installOrder = [];
|
|
3305
4888
|
constructor(ctx, options = {}) {
|
|
3306
4889
|
super(ctx, "shop");
|
|
4890
|
+
this.home = ctx;
|
|
3307
4891
|
this.options = options;
|
|
3308
4892
|
this.profile = options.profile ?? discoverProfile(fileURLToPath(import.meta.url), this.bootBaseDir()).name;
|
|
3309
4893
|
this.profileDir = options.profileDir;
|
|
@@ -3332,6 +4916,17 @@ let ShopGateway = (() => {
|
|
|
3332
4916
|
this.ppid = options.ppid ?? process.ppid;
|
|
3333
4917
|
this.fetchTarball = options.fetchTarball ?? ((url) => fetch(url));
|
|
3334
4918
|
this.prefetcher = options.prefetcher ?? createPrefetcher();
|
|
4919
|
+
this.imported = options.importedModules ?? importedThisProcess;
|
|
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
|
+
});
|
|
3335
4930
|
try {
|
|
3336
4931
|
cleanHotDir(this.profileDirResolved());
|
|
3337
4932
|
} catch {}
|
|
@@ -3347,7 +4942,7 @@ let ShopGateway = (() => {
|
|
|
3347
4942
|
try {
|
|
3348
4943
|
createPeerVersionCheck({
|
|
3349
4944
|
ranges: this.options.peerRanges ?? ownPeerRanges(),
|
|
3350
|
-
resolve: this.options.resolvePeerVersion ??
|
|
4945
|
+
resolve: this.options.resolvePeerVersion ?? this.peerVersionResolver(this.profileAnchor()),
|
|
3351
4946
|
warn: (message) => {
|
|
3352
4947
|
const logger = this.ctx.logger;
|
|
3353
4948
|
if (logger === void 0) console.warn(message);
|
|
@@ -3379,6 +4974,65 @@ let ShopGateway = (() => {
|
|
|
3379
4974
|
if (this.profileDir !== void 0) return this.profileDir;
|
|
3380
4975
|
return discoverProfile(fileURLToPath(import.meta.url), this.bootBaseDir()).dir;
|
|
3381
4976
|
}
|
|
4977
|
+
/** Where every "what can this installation's plugins import?" question
|
|
4978
|
+
* resolves from: the profile's own Loader root, which is where the harness
|
|
4979
|
+
* resolves plugins from. One definition for the peer presence check and the
|
|
4980
|
+
* load-time self-check, so neither can drift onto a different notion of
|
|
4981
|
+
* "the installation". NOT where the running harness is read from: a copy
|
|
4982
|
+
* of `@deepseek-ai/dsh` a plugin's dependencies hoist into the profile is
|
|
4983
|
+
* importable from here and is not what runs (`harness.ts`). Throws when no
|
|
4984
|
+
* profile directory can be discovered and none is given. */
|
|
4985
|
+
profileAnchor(profileDir = this.profileDirResolved()) {
|
|
4986
|
+
return pathToFileURL(join(profileDir, "cordis.yml")).href;
|
|
4987
|
+
}
|
|
4988
|
+
/** dsh's `pluginPackages` service, when the running harness provides one
|
|
4989
|
+
* (0.1.7 and later), else null. Read on each use, like `pluginInventory`.
|
|
4990
|
+
* It is how such a harness serves its own packages to plugins, which no
|
|
4991
|
+
* walk of the profile's disk can see (design
|
|
4992
|
+
* 2026-09-01-harness-compatibility §11). */
|
|
4993
|
+
harnessPackages() {
|
|
4994
|
+
const service = this.ctx.get?.("pluginPackages");
|
|
4995
|
+
return typeof service?.packageOf === "function" ? service : null;
|
|
4996
|
+
}
|
|
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. */
|
|
5004
|
+
pluginManager() {
|
|
5005
|
+
return asPluginManager(this.ctx.get?.("pluginManager"));
|
|
5006
|
+
}
|
|
5007
|
+
/** "Does this installation provide `spec`?", asked from `anchor`: the
|
|
5008
|
+
* harness's own resolution in front of the walk where the harness has one,
|
|
5009
|
+
* the walk alone where it has not. */
|
|
5010
|
+
peerResolver(anchor) {
|
|
5011
|
+
const walk = nodeResolver(anchor);
|
|
5012
|
+
const packages = this.harnessPackages();
|
|
5013
|
+
return packages === null ? walk : harnessPackageResolver(walk, packages, anchor);
|
|
5014
|
+
}
|
|
5015
|
+
/** `peerResolver`'s twin for the versions the load-time self-check reads. */
|
|
5016
|
+
peerVersionResolver(anchor) {
|
|
5017
|
+
const walk = nodeVersionResolver(anchor);
|
|
5018
|
+
const packages = this.harnessPackages();
|
|
5019
|
+
return packages === null ? walk : harnessPackageVersionResolver(walk, packages, anchor);
|
|
5020
|
+
}
|
|
5021
|
+
/** `profileDirResolved`, or null when no profile directory can be
|
|
5022
|
+
* discovered — for the reads that answer "cannot tell" rather than fail. */
|
|
5023
|
+
profileDirOrNone() {
|
|
5024
|
+
try {
|
|
5025
|
+
return this.profileDirResolved();
|
|
5026
|
+
} catch {
|
|
5027
|
+
return null;
|
|
5028
|
+
}
|
|
5029
|
+
}
|
|
5030
|
+
/** The harness this process runs, read once (see `harnessRead`). */
|
|
5031
|
+
runningHarness() {
|
|
5032
|
+
const read = this.options.readHarness ?? readRunningHarness;
|
|
5033
|
+
this.harnessRead ??= read(this.restartScript);
|
|
5034
|
+
return this.harnessRead;
|
|
5035
|
+
}
|
|
3382
5036
|
/** The inventory, through the wire remote: an envelope `{ ok, value }` or
|
|
3383
5037
|
* `{ ok: false, error }`. Each row is re-validated before trust (same
|
|
3384
5038
|
* discipline as the rowConfig cast). */
|
|
@@ -3403,8 +5057,9 @@ let ShopGateway = (() => {
|
|
|
3403
5057
|
* down. A disable can land while the entry's init is still in flight: the
|
|
3404
5058
|
* options flip but the finishing init brings the fiber up anyway, and a
|
|
3405
5059
|
* plain re-update no-ops on the empty diff (dsh-market themes.ts:74-93).
|
|
3406
|
-
*
|
|
3407
|
-
*
|
|
5060
|
+
* The uninstall path is the one caller: an update used to swap its running
|
|
5061
|
+
* instance for a hot mount through here, and no longer mounts at all
|
|
5062
|
+
* (design 2026-09-26-market-borrowings §1). */
|
|
3408
5063
|
/** The package's owned entry ids, or none when its bundle patch cannot be
|
|
3409
5064
|
* read. For the paths where a live disable is an optimization and the
|
|
3410
5065
|
* operation must succeed regardless; `setEnabled` reports the failure
|
|
@@ -3438,13 +5093,20 @@ let ShopGateway = (() => {
|
|
|
3438
5093
|
* Through `ownDependencySpec` for the reason spelled out there: a bare index
|
|
3439
5094
|
* read answers for `Object.prototype`, and `constructor` is a legal npm
|
|
3440
5095
|
* name. The cost on this path is behavioural twice over — the gate would
|
|
3441
|
-
* weigh a function as the installed spec, and a phantom `isUpdate` would
|
|
3442
|
-
*
|
|
5096
|
+
* weigh a function as the installed spec, and a phantom `isUpdate` would
|
|
5097
|
+
* refuse a hot mount to a package this process never imported.
|
|
3443
5098
|
*/
|
|
3444
5099
|
installedSpecOf(name) {
|
|
3445
5100
|
const dependencies = this.profileDependenciesOrNone();
|
|
3446
5101
|
return dependencies === void 0 ? void 0 : ownDependencySpec(dependencies, name);
|
|
3447
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
|
+
}
|
|
3448
5110
|
ownedEntryIdsOrNone(packageName) {
|
|
3449
5111
|
try {
|
|
3450
5112
|
return ownedEntryIds({
|
|
@@ -3503,16 +5165,18 @@ let ShopGateway = (() => {
|
|
|
3503
5165
|
}
|
|
3504
5166
|
return allDown;
|
|
3505
5167
|
}
|
|
3506
|
-
/** Enable or disable one installed plugin, hot (§8):
|
|
3507
|
-
*
|
|
3508
|
-
*
|
|
3509
|
-
*
|
|
3510
|
-
*
|
|
5168
|
+
/** Enable or disable one installed plugin, hot (§8): the write sets the
|
|
5169
|
+
* `disabled` key of each owned entry's row in the user layer and changes
|
|
5170
|
+
* nothing else there (design 2026-09-26-market-borrowings §2) — the CLI's
|
|
5171
|
+
* watchUserPatches applies it through HMR. The shop's own row and the
|
|
5172
|
+
* framework's bundles are never toggleable: disabling the host chain would
|
|
5173
|
+
* break HMR itself. */
|
|
3511
5174
|
async setEnabled(args) {
|
|
3512
5175
|
if (args.name === "dsh-plugin-shop" || args.name.startsWith("@deepseek-ai/")) return {
|
|
3513
5176
|
ok: false,
|
|
3514
5177
|
detail: `dsh-plugin-shop: ${args.name} is part of the harness chain and cannot be toggled from the shop`
|
|
3515
5178
|
};
|
|
5179
|
+
const manager = this.pluginManager();
|
|
3516
5180
|
const profileDir = this.profileDirResolved();
|
|
3517
5181
|
const manifest = readProfileManifest("dsh-plugin-shop", profileDir);
|
|
3518
5182
|
if (!Object.hasOwn(manifest.dependencies ?? {}, args.name)) return {
|
|
@@ -3521,7 +5185,7 @@ let ShopGateway = (() => {
|
|
|
3521
5185
|
};
|
|
3522
5186
|
let owned;
|
|
3523
5187
|
try {
|
|
3524
|
-
owned =
|
|
5188
|
+
owned = ownedEntries({
|
|
3525
5189
|
profileDir,
|
|
3526
5190
|
packageName: args.name
|
|
3527
5191
|
});
|
|
@@ -3535,16 +5199,66 @@ let ShopGateway = (() => {
|
|
|
3535
5199
|
ok: false,
|
|
3536
5200
|
detail: `dsh-plugin-shop: ${args.name} contributes no plugin entries, so there is nothing to enable or disable`
|
|
3537
5201
|
};
|
|
3538
|
-
const
|
|
3539
|
-
if (
|
|
5202
|
+
const selected = this.runningProfileBundles(profileDir);
|
|
5203
|
+
if (selected !== null && !selected.includes(args.name)) return this.setDeselectedEnabled(args.name, args.enabled, profileDir, owned, manager);
|
|
5204
|
+
const ownedSet = new Set(owned.map((entry) => entry.id));
|
|
5205
|
+
const live = (await this.listInventory()).filter((entry) => ownsEntryId(ownedSet, entry.entryId));
|
|
5206
|
+
if (live.length === 0) return {
|
|
3540
5207
|
ok: false,
|
|
3541
5208
|
detail: `dsh-plugin-shop: ${args.name} is installed but its entries are not in the running plugin tree; restart dsh to compose them`
|
|
3542
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
|
+
}
|
|
3543
5256
|
setUserLayerRows({
|
|
3544
5257
|
profileDir,
|
|
3545
|
-
rows: owned.map((id) => ({
|
|
5258
|
+
rows: owned.map(({ id, name }) => ({
|
|
3546
5259
|
id,
|
|
3547
|
-
disabled: !args.enabled
|
|
5260
|
+
disabled: !args.enabled,
|
|
5261
|
+
...name !== void 0 ? { name } : {}
|
|
3548
5262
|
}))
|
|
3549
5263
|
});
|
|
3550
5264
|
return {
|
|
@@ -3556,6 +5270,119 @@ let ShopGateway = (() => {
|
|
|
3556
5270
|
})
|
|
3557
5271
|
};
|
|
3558
5272
|
}
|
|
5273
|
+
/**
|
|
5274
|
+
* `setEnabled` for an installed package whose bundle the profile does not
|
|
5275
|
+
* select.
|
|
5276
|
+
*
|
|
5277
|
+
* Switching it off writes the plugin-level rows and nothing else: none of
|
|
5278
|
+
* it runs, and the rows keep it off if dsh selects the bundle again.
|
|
5279
|
+
* Switching it on selects the bundle through dsh's own `pluginManager`, the
|
|
5280
|
+
* writer behind the switch that deselected it, and then clears the
|
|
5281
|
+
* plugin-level rows as an ordinary enable does. A harness without that
|
|
5282
|
+
* service gets the way back in words instead. `dsh plugin add <name>` is
|
|
5283
|
+
* not printed: it selects the bundle again only on 0.1.5, and it lets pnpm
|
|
5284
|
+
* float a registry package to `latest`.
|
|
5285
|
+
*/
|
|
5286
|
+
async setDeselectedEnabled(name, enabled, profileDir, owned, manager) {
|
|
5287
|
+
const rows = owned.map(({ id, name: moduleName }) => ({
|
|
5288
|
+
id,
|
|
5289
|
+
disabled: !enabled,
|
|
5290
|
+
...moduleName !== void 0 ? { name: moduleName } : {}
|
|
5291
|
+
}));
|
|
5292
|
+
if (!enabled) {
|
|
5293
|
+
setUserLayerRows({
|
|
5294
|
+
profileDir,
|
|
5295
|
+
rows
|
|
5296
|
+
});
|
|
5297
|
+
return {
|
|
5298
|
+
ok: true,
|
|
5299
|
+
activation: "live"
|
|
5300
|
+
};
|
|
5301
|
+
}
|
|
5302
|
+
if (manager === null) return {
|
|
5303
|
+
ok: false,
|
|
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.`
|
|
5305
|
+
};
|
|
5306
|
+
if (this.hasRunningCommand()) return {
|
|
5307
|
+
ok: false,
|
|
5308
|
+
detail: switchWhileBusyDetail(name)
|
|
5309
|
+
};
|
|
5310
|
+
let raw;
|
|
5311
|
+
try {
|
|
5312
|
+
raw = await manager.setBundleEnabled(name, true);
|
|
5313
|
+
} catch (error) {
|
|
5314
|
+
return {
|
|
5315
|
+
ok: false,
|
|
5316
|
+
detail: `dsh-plugin-shop: dsh could not select ${name} again${thrownTail(messageOf(error), isDesktopProfile(this.profile))}`
|
|
5317
|
+
};
|
|
5318
|
+
}
|
|
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 {
|
|
5325
|
+
ok: false,
|
|
5326
|
+
detail: failure
|
|
5327
|
+
};
|
|
5328
|
+
setUserLayerRows({
|
|
5329
|
+
profileDir,
|
|
5330
|
+
rows
|
|
5331
|
+
});
|
|
5332
|
+
if (change.application === "restart-required") return {
|
|
5333
|
+
ok: true,
|
|
5334
|
+
activation: "restart"
|
|
5335
|
+
};
|
|
5336
|
+
return {
|
|
5337
|
+
ok: true,
|
|
5338
|
+
activation: activationOf({
|
|
5339
|
+
hostLive: true,
|
|
5340
|
+
clientLive: true,
|
|
5341
|
+
hasClientHalf: this.packageHasClientHalf(name)
|
|
5342
|
+
})
|
|
5343
|
+
};
|
|
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
|
+
}
|
|
3559
5386
|
rowConfig() {
|
|
3560
5387
|
if (this.options.catalogUrl !== void 0 && this.options.cacheDir !== void 0) return {
|
|
3561
5388
|
catalogUrl: this.options.catalogUrl,
|
|
@@ -3635,11 +5462,16 @@ let ShopGateway = (() => {
|
|
|
3635
5462
|
* static does. One ordered list, read by `restart()` before it commits and
|
|
3636
5463
|
* by `version()` so the client can say the same thing up front.
|
|
3637
5464
|
*
|
|
3638
|
-
* The order is
|
|
3639
|
-
*
|
|
3640
|
-
*
|
|
3641
|
-
*
|
|
5465
|
+
* The order is load-bearing for the copy a reader sees. The desktop
|
|
5466
|
+
* profile first: dsh itself refuses to launch it, so no platform,
|
|
5467
|
+
* supervisor or port could make a restart possible there. Then Windows,
|
|
5468
|
+
* because the platform check has no override and reporting the systemd one
|
|
5469
|
+
* there sends a Windows user to set `allowRestart: true`, which this gate
|
|
5470
|
+
* would still refuse.
|
|
3642
5471
|
*
|
|
5472
|
+
* - `desktop`: dsh's CLI refuses `--profile desktop` ("managed exclusively
|
|
5473
|
+
* by the Electron application"), so the relaunch would exit at once and
|
|
5474
|
+
* leave nothing on the port; the app restarts it.
|
|
3643
5475
|
* - `windows`: the handoff helper is a POSIX shell one-liner (restart.ts)
|
|
3644
5476
|
* and there is no `sh` on Windows. That spawn fails ASYNCHRONOUSLY, so
|
|
3645
5477
|
* committing would answer `ok: true`, exit this process, and leave nothing
|
|
@@ -3651,6 +5483,7 @@ let ShopGateway = (() => {
|
|
|
3651
5483
|
* - `port-zero`: the OS hands the NEW process a fresh port the browser
|
|
3652
5484
|
* cannot know, so a restart would strand the client on a dead origin. */
|
|
3653
5485
|
staticRestartBlock() {
|
|
5486
|
+
if (isDesktopProfile(this.profile)) return "desktop";
|
|
3654
5487
|
if (!this.restartPlatformSupported()) return "windows";
|
|
3655
5488
|
if (detectSupervisor(this.env, { ppid: this.ppid }) === "systemd" && !this.allowRestartConfigured()) return "systemd";
|
|
3656
5489
|
const portIndex = this.restartArgv.indexOf("--port");
|
|
@@ -3664,20 +5497,31 @@ let ShopGateway = (() => {
|
|
|
3664
5497
|
/** Browse the catalog (§7.3): cached snapshot, refreshed on demand. */
|
|
3665
5498
|
async catalog(args) {
|
|
3666
5499
|
const { snapshot, stale } = await this.loadCatalogOnce(args?.refresh ?? false);
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
5500
|
+
const harness = await this.runningHarness();
|
|
5501
|
+
const profileDir = this.profileDirOrNone();
|
|
5502
|
+
let incompatible = {};
|
|
5503
|
+
if (profileDir !== null) try {
|
|
5504
|
+
const resolve = this.options.resolvePeer ?? this.peerResolver(this.profileAnchor(profileDir));
|
|
5505
|
+
incompatible = incompatibilityMap(snapshot.entries, resolve);
|
|
5506
|
+
} catch {
|
|
5507
|
+
incompatible = {};
|
|
5508
|
+
}
|
|
5509
|
+
let incompatibleHarness;
|
|
5510
|
+
try {
|
|
5511
|
+
incompatibleHarness = compatibilityMap(snapshot.entries, {
|
|
5512
|
+
dshVersion: harness.dshVersion,
|
|
5513
|
+
profile: {
|
|
5514
|
+
name: this.profile,
|
|
5515
|
+
bundles: profileDir === null ? null : this.runningProfileBundles(profileDir)
|
|
5516
|
+
}
|
|
5517
|
+
}, harness.templates);
|
|
5518
|
+
} catch {
|
|
5519
|
+
incompatibleHarness = {};
|
|
3680
5520
|
}
|
|
5521
|
+
for (const [key, peers] of Object.entries(this.refusedByHarness(snapshot.entries, harness, profileDir))) incompatibleHarness[key] = {
|
|
5522
|
+
...incompatibleHarness[key],
|
|
5523
|
+
peers
|
|
5524
|
+
};
|
|
3681
5525
|
return {
|
|
3682
5526
|
schemaVersion: snapshot.schemaVersion,
|
|
3683
5527
|
builtAt: snapshot.builtAt,
|
|
@@ -3686,15 +5530,57 @@ let ShopGateway = (() => {
|
|
|
3686
5530
|
denied: snapshot.denied,
|
|
3687
5531
|
notAShop: snapshot.notAShop ?? [],
|
|
3688
5532
|
stars: snapshot.stars,
|
|
3689
|
-
incompatible
|
|
5533
|
+
incompatible,
|
|
5534
|
+
incompatibleHarness
|
|
3690
5535
|
};
|
|
3691
5536
|
}
|
|
3692
5537
|
/**
|
|
5538
|
+
* The installs the running dsh will refuse on their harness peers
|
|
5539
|
+
* (`peerVerdictsOf`), judged against the exemptions this profile holds —
|
|
5540
|
+
* read on every call like the other verdicts, because recording one is
|
|
5541
|
+
* exactly the event that must clear a card: the reader runs the command
|
|
5542
|
+
* the card shows, presses Refresh, and the install is allowed. Empty when
|
|
5543
|
+
* the running dsh has no such check (before 0.1.7) or no profile was
|
|
5544
|
+
* found, and when either read throws: a refusal nobody could establish is
|
|
5545
|
+
* never shown, least of all as a disabled button.
|
|
5546
|
+
*/
|
|
5547
|
+
refusedByHarness(entries, harness, profileDir) {
|
|
5548
|
+
if (harness.peerCheck === null || profileDir === null) return {};
|
|
5549
|
+
try {
|
|
5550
|
+
return peerVerdictsOf(entries, harness.peerCheck, harness.peerCheck.exemptions(profileDir), this.profile);
|
|
5551
|
+
} catch {
|
|
5552
|
+
return {};
|
|
5553
|
+
}
|
|
5554
|
+
}
|
|
5555
|
+
/**
|
|
5556
|
+
* The bundles the profile at `profileDir` composes, or null when its
|
|
5557
|
+
* manifest cannot be read — which is no verdict on the profile half rather
|
|
5558
|
+
* than an accusation. `dsh.profile.bundles` is the same list
|
|
5559
|
+
* `discoverProfile` uses to recognize a profile directory, read through the
|
|
5560
|
+
* harness's own parser so a profile this shop was booted inside is read the
|
|
5561
|
+
* way dsh reads it.
|
|
5562
|
+
*/
|
|
5563
|
+
runningProfileBundles(profileDir) {
|
|
5564
|
+
try {
|
|
5565
|
+
const bundles = readProfileManifest("dsh-plugin-shop", profileDir).dsh?.profile?.bundles;
|
|
5566
|
+
if (!Array.isArray(bundles) || !bundles.every((bundle) => typeof bundle === "string")) return null;
|
|
5567
|
+
return bundles;
|
|
5568
|
+
} catch {
|
|
5569
|
+
return null;
|
|
5570
|
+
}
|
|
5571
|
+
}
|
|
5572
|
+
/**
|
|
3693
5573
|
* Install one cataloged version into the profile (§7.2). The rejection
|
|
3694
5574
|
* paths run against this Host's snapshot before anything is spawned; only a
|
|
3695
5575
|
* passing request reaches the executor.
|
|
3696
5576
|
*/
|
|
3697
5577
|
async install(args) {
|
|
5578
|
+
const manager = this.pluginManager();
|
|
5579
|
+
if (isDesktopProfile(this.profile) && manager === null) return {
|
|
5580
|
+
ok: false,
|
|
5581
|
+
code: "desktop-profile",
|
|
5582
|
+
detail: DESKTOP_PROFILE_DETAIL
|
|
5583
|
+
};
|
|
3698
5584
|
const snapshot = await this.snapshotNow();
|
|
3699
5585
|
const installedSpec = this.installedSpecOf(args.name);
|
|
3700
5586
|
const verdict = validateInstall(snapshot, args, installedSpec);
|
|
@@ -3722,61 +5608,160 @@ let ShopGateway = (() => {
|
|
|
3722
5608
|
spec = `github:${entry.repo}#${args.version}${entry.subdir !== void 0 ? `&path:${entry.subdir}` : ""}`;
|
|
3723
5609
|
} else spec = `${args.name}@${args.version}`;
|
|
3724
5610
|
const isUpdate = installedSpec !== void 0;
|
|
3725
|
-
const
|
|
3726
|
-
const
|
|
5611
|
+
const alreadyImported = isUpdate || this.imported.has(args.name);
|
|
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
|
+
}
|
|
3727
5626
|
const running = startInstall({
|
|
3728
5627
|
profile: this.profile,
|
|
3729
5628
|
spec,
|
|
3730
5629
|
dshBin: this.dshBin,
|
|
3731
5630
|
expectedName: args.name,
|
|
3732
5631
|
prefetcher: this.prefetcher,
|
|
3733
|
-
alsoConfirm: () => {
|
|
3734
|
-
const clash = collidingEntryId({
|
|
3735
|
-
profileDir: this.profileDirResolved(),
|
|
3736
|
-
packageName: args.name,
|
|
3737
|
-
dependencies: Object.keys(this.profileDependenciesOrNone() ?? {})
|
|
3738
|
-
});
|
|
3739
|
-
if (clash === null) return null;
|
|
3740
|
-
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. It is on disk: run \`dsh plugin --profile ${this.profile} remove ${args.name}\` to undo this install.`;
|
|
3741
|
-
},
|
|
5632
|
+
alsoConfirm: () => this.postInstallHazard(args.name, harness, ` It is on disk: run \`dsh plugin --profile ${this.profile} remove ${args.name}\` to undo this install.`),
|
|
3742
5633
|
afterDone: async () => {
|
|
5634
|
+
if (alreadyImported) return {
|
|
5635
|
+
activation: "restart",
|
|
5636
|
+
restartReason: "already-loaded"
|
|
5637
|
+
};
|
|
3743
5638
|
const hot = this.hot ?? {
|
|
3744
5639
|
mount: hotMount,
|
|
3745
5640
|
unmount: hotUnmount
|
|
3746
5641
|
};
|
|
3747
|
-
|
|
3748
|
-
const result = await hot.mount({ plugin: (plugin, config) => this.
|
|
5642
|
+
this.imported.add(args.name);
|
|
5643
|
+
const result = await hot.mount({ plugin: (plugin, config) => this.home.plugin(plugin, config) }, this.profileDirResolved(), args.name);
|
|
3749
5644
|
if (!result.ok) return {
|
|
3750
5645
|
activation: "restart",
|
|
3751
5646
|
...result.reason !== null ? { restartReason: result.reason } : {}
|
|
3752
5647
|
};
|
|
3753
|
-
|
|
5648
|
+
return { activation: activationOf({
|
|
3754
5649
|
hostLive: true,
|
|
3755
|
-
clientLive:
|
|
3756
|
-
hasClientHalf:
|
|
3757
|
-
});
|
|
3758
|
-
return activation === "restart" ? {
|
|
3759
|
-
activation,
|
|
3760
|
-
restartReason: "client-half"
|
|
3761
|
-
} : { activation };
|
|
5650
|
+
clientLive: true,
|
|
5651
|
+
hasClientHalf: this.packageHasClientHalf(args.name)
|
|
5652
|
+
}) };
|
|
3762
5653
|
}
|
|
3763
5654
|
});
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
writeRepoPins(this.pinFs, this.pinsPath(), {
|
|
3767
|
-
...pins,
|
|
3768
|
-
[identityKey(entry)]: entry.version
|
|
3769
|
-
});
|
|
3770
|
-
}
|
|
3771
|
-
this.installs.set(running.installId, running);
|
|
3772
|
-
this.installOrder.push(running.installId);
|
|
3773
|
-
this.evictFinishedInstalls();
|
|
5655
|
+
this.recordGithubPin(entry);
|
|
5656
|
+
this.track(running);
|
|
3774
5657
|
return {
|
|
3775
5658
|
ok: true,
|
|
3776
5659
|
installId: running.installId,
|
|
3777
5660
|
state: running.status().state
|
|
3778
5661
|
};
|
|
3779
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
|
+
}
|
|
3780
5765
|
/** Bound retained finished records at MAX_FINISHED_INSTALLS, evicting the
|
|
3781
5766
|
* oldest finished ones (insertion order, oldest first). Live records —
|
|
3782
5767
|
* running AND queued — are never evicted; an id absent from the map reports
|
|
@@ -3844,10 +5829,13 @@ let ShopGateway = (() => {
|
|
|
3844
5829
|
for (const entry of await this.listInventory()) live.set(entry.entryId, entry.enabled);
|
|
3845
5830
|
haveInventory = true;
|
|
3846
5831
|
} catch {}
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
*
|
|
5832
|
+
const selected = this.runningProfileBundles(this.profileDirResolved());
|
|
5833
|
+
/** A package is enabled when the profile selects its bundle and every
|
|
5834
|
+
* entry it owns and that is live is enabled. Keyed by entry id, never by
|
|
5835
|
+
* module name: the entry a package inserts may mount a different
|
|
5836
|
+
* package's module (see ownedEntryIds). */
|
|
3850
5837
|
const enabledOf = (name) => {
|
|
5838
|
+
if (selected !== null && !selected.includes(name)) return false;
|
|
3851
5839
|
if (!haveInventory) return true;
|
|
3852
5840
|
let owned;
|
|
3853
5841
|
try {
|
|
@@ -3914,6 +5902,11 @@ let ShopGateway = (() => {
|
|
|
3914
5902
|
* dependencies the shop does not manage (the base bundle, the shop
|
|
3915
5903
|
* itself). The same install records/polling serve the client. */
|
|
3916
5904
|
async uninstall(args) {
|
|
5905
|
+
const manager = this.pluginManager();
|
|
5906
|
+
if (isDesktopProfile(this.profile) && manager === null) return {
|
|
5907
|
+
ok: false,
|
|
5908
|
+
detail: DESKTOP_PROFILE_DETAIL
|
|
5909
|
+
};
|
|
3917
5910
|
const named = (await this.snapshotNow()).entries.filter((entry) => entry.name === args.name);
|
|
3918
5911
|
if (named.length === 0) return {
|
|
3919
5912
|
ok: false,
|
|
@@ -3927,6 +5920,23 @@ let ShopGateway = (() => {
|
|
|
3927
5920
|
const installedEntry = named.find((entry) => installedSpecMatches(entry, spec));
|
|
3928
5921
|
const priorEntryIds = this.ownedEntryIdsOrNone(args.name);
|
|
3929
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
|
+
}
|
|
3930
5940
|
const running = startUninstall({
|
|
3931
5941
|
profile: this.profile,
|
|
3932
5942
|
name: args.name,
|
|
@@ -3943,18 +5953,8 @@ let ShopGateway = (() => {
|
|
|
3943
5953
|
}) };
|
|
3944
5954
|
}
|
|
3945
5955
|
});
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
let forgot = false;
|
|
3949
|
-
for (const key of stalePins) {
|
|
3950
|
-
if (pins[key] === void 0) continue;
|
|
3951
|
-
delete pins[key];
|
|
3952
|
-
forgot = true;
|
|
3953
|
-
}
|
|
3954
|
-
if (forgot) writeRepoPins(this.pinFs, this.pinsPath(), pins);
|
|
3955
|
-
this.installs.set(running.installId, running);
|
|
3956
|
-
this.installOrder.push(running.installId);
|
|
3957
|
-
this.evictFinishedInstalls();
|
|
5956
|
+
this.forgetPins(args.name, installedEntry);
|
|
5957
|
+
this.track(running);
|
|
3958
5958
|
return {
|
|
3959
5959
|
ok: true,
|
|
3960
5960
|
installId: running.installId
|
|
@@ -3966,6 +5966,10 @@ let ShopGateway = (() => {
|
|
|
3966
5966
|
* response is out. The browser monitors the origin and refreshes when the
|
|
3967
5967
|
* new server answers. Refusals are issued before anything is torn down. */
|
|
3968
5968
|
async restart() {
|
|
5969
|
+
if (this.committedRestart !== null) return {
|
|
5970
|
+
ok: true,
|
|
5971
|
+
logFile: this.committedRestart.logFile
|
|
5972
|
+
};
|
|
3969
5973
|
if (this.hasRunningCommand()) return {
|
|
3970
5974
|
ok: false,
|
|
3971
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."
|
|
@@ -3975,6 +5979,7 @@ let ShopGateway = (() => {
|
|
|
3975
5979
|
ok: false,
|
|
3976
5980
|
detail: RESTART_BLOCKED_DETAIL[blocked]
|
|
3977
5981
|
};
|
|
5982
|
+
let logFile;
|
|
3978
5983
|
try {
|
|
3979
5984
|
const { cacheDir } = this.rowConfig();
|
|
3980
5985
|
const { command, args } = restartCommand({
|
|
@@ -3984,11 +5989,12 @@ let ShopGateway = (() => {
|
|
|
3984
5989
|
execArgv: process.execArgv,
|
|
3985
5990
|
script: this.restartScript
|
|
3986
5991
|
});
|
|
5992
|
+
logFile = join(cacheDir, "restart.log");
|
|
3987
5993
|
startRestart({
|
|
3988
5994
|
command,
|
|
3989
5995
|
args,
|
|
3990
5996
|
parentPid: this.restartParentPid,
|
|
3991
|
-
logFile
|
|
5997
|
+
logFile,
|
|
3992
5998
|
env: process.env
|
|
3993
5999
|
});
|
|
3994
6000
|
} catch (error) {
|
|
@@ -3997,42 +6003,82 @@ let ShopGateway = (() => {
|
|
|
3997
6003
|
detail: `dsh-plugin-shop: restart could not be started: ${error.message}`
|
|
3998
6004
|
};
|
|
3999
6005
|
}
|
|
6006
|
+
this.committedRestart = { logFile };
|
|
4000
6007
|
setTimeout(() => this.exit(0), this.restartExitDelayMs);
|
|
4001
|
-
return {
|
|
6008
|
+
return {
|
|
6009
|
+
ok: true,
|
|
6010
|
+
logFile
|
|
6011
|
+
};
|
|
4002
6012
|
}
|
|
4003
6013
|
/** The shop's own version and whether npm has a newer one (§7.3). The
|
|
4004
6014
|
* check is advisory: a registry that cannot answer leaves `latest` null
|
|
4005
6015
|
* and the client shows the version alone. `installed` is the RUNNING
|
|
4006
6016
|
* version (own-version.ts), not the manifest's range spec. */
|
|
4007
6017
|
async version() {
|
|
4008
|
-
const installed =
|
|
6018
|
+
const installed = this.options.runningVersion ?? runningVersion();
|
|
4009
6019
|
const latest = await this.latestVersion();
|
|
4010
6020
|
return {
|
|
4011
6021
|
installed,
|
|
4012
6022
|
latest,
|
|
4013
6023
|
outdated: latest !== null && lt(installed, latest),
|
|
4014
|
-
restartBlocked: this.staticRestartBlock()
|
|
6024
|
+
restartBlocked: this.staticRestartBlock(),
|
|
6025
|
+
pendingVersion: this.pendingVersion(installed)
|
|
4015
6026
|
};
|
|
4016
6027
|
}
|
|
6028
|
+
/**
|
|
6029
|
+
* The version of the shop the profile has installed, when it is not the
|
|
6030
|
+
* one this process runs; null when they match or the copy cannot be read.
|
|
6031
|
+
*
|
|
6032
|
+
* Read from the profile's own copy — the directory `dsh plugin add` writes
|
|
6033
|
+
* and the next boot loads — rather than from the file beside this module.
|
|
6034
|
+
* For the hoisted install those are one directory, but a `link:` install
|
|
6035
|
+
* runs from its link target, which an update replaces in the profile and
|
|
6036
|
+
* never touches at the target, so reading beside the module would never see
|
|
6037
|
+
* the update land.
|
|
6038
|
+
*
|
|
6039
|
+
* Unreadable is null, never a throw: the version check is advisory, and a
|
|
6040
|
+
* missing field would tell the client this host predates it.
|
|
6041
|
+
*/
|
|
6042
|
+
pendingVersion(running) {
|
|
6043
|
+
try {
|
|
6044
|
+
const manifest = JSON.parse(readFileSync(join(this.profileDirResolved(), "node_modules", "dsh-plugin-shop", "package.json"), "utf8"));
|
|
6045
|
+
return typeof manifest.version === "string" && manifest.version !== running ? manifest.version : null;
|
|
6046
|
+
} catch {
|
|
6047
|
+
return null;
|
|
6048
|
+
}
|
|
6049
|
+
}
|
|
4017
6050
|
/** Update the shop itself to a published version (§7.3): the explicit pin
|
|
4018
6051
|
* is the only install form that bypasses pnpm's release cooldown. The
|
|
4019
6052
|
* version is re-validated as plain semver at the boundary — the spec
|
|
4020
6053
|
* `dsh-plugin-shop@<version>` is built here, never from the wire. */
|
|
4021
6054
|
async updateStart(args) {
|
|
6055
|
+
const manager = this.pluginManager();
|
|
6056
|
+
if (isDesktopProfile(this.profile) && manager === null) return {
|
|
6057
|
+
ok: false,
|
|
6058
|
+
detail: DESKTOP_PROFILE_DETAIL
|
|
6059
|
+
};
|
|
4022
6060
|
if (valid(args.version) === null) return {
|
|
4023
6061
|
ok: false,
|
|
4024
6062
|
detail: `dsh-plugin-shop: ${args.version} is not a valid version`
|
|
4025
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
|
+
}
|
|
4026
6074
|
const running = startInstall({
|
|
4027
6075
|
profile: this.profile,
|
|
4028
|
-
spec
|
|
6076
|
+
spec,
|
|
4029
6077
|
dshBin: this.dshBin,
|
|
4030
6078
|
expectedName: "dsh-plugin-shop",
|
|
4031
6079
|
prefetcher: this.prefetcher
|
|
4032
6080
|
});
|
|
4033
|
-
this.
|
|
4034
|
-
this.installOrder.push(running.installId);
|
|
4035
|
-
this.evictFinishedInstalls();
|
|
6081
|
+
this.track(running);
|
|
4036
6082
|
return {
|
|
4037
6083
|
ok: true,
|
|
4038
6084
|
installId: running.installId,
|