dsh-plugin-shop 0.8.3 → 0.8.4-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -8
- package/lib/client.js +1133 -421
- package/lib/index.js +1400 -178
- 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 +42 -2
- package/lib/types/client/module-table.d.ts +131 -0
- package/lib/types/client/present.d.ts +126 -2
- package/lib/types/client/restart-monitor.d.ts +58 -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/harness.d.ts +109 -0
- package/lib/types/host/hot.d.ts +8 -8
- package/lib/types/host/index.d.ts +197 -28
- package/lib/types/host/install.d.ts +1 -1
- package/lib/types/host/peers.d.ts +181 -10
- 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
|
|
@@ -1335,6 +1615,93 @@ const FAILURE_LOG_NOISE = [
|
|
|
1335
1615
|
/^\s+at /,
|
|
1336
1616
|
/^<anonymous(?:_script)?>/
|
|
1337
1617
|
];
|
|
1618
|
+
/** The line dsh 0.1.7 opens a refused install with (dsh-plugin-manager's
|
|
1619
|
+
* `rejected`), and the exemption command its CLI prints after it, one per
|
|
1620
|
+
* refused package (`runPlugin`). */
|
|
1621
|
+
const REFUSAL_OPENER = "dsh: installation rejected: ";
|
|
1622
|
+
const REFUSAL_COMMAND = /^dsh: to accept the risk, run: dsh plugin --profile .+ allow-version (.+)@([^@\s]+) --dsh-version (\S+) --accept-risk$/;
|
|
1623
|
+
const REFUSAL_FAILED = "dsh: plugin command failed";
|
|
1624
|
+
/** Where dsh's warning turns from the refused peers to its risk statement
|
|
1625
|
+
* and remedy (app-boot's `pluginCompatibilityWarning`); the detail states
|
|
1626
|
+
* the risk once and gives the command instead. */
|
|
1627
|
+
const REFUSAL_WARNING_TAIL = " Running it may cause crashes or data loss.";
|
|
1628
|
+
/**
|
|
1629
|
+
* dsh's own refusal of an install (0.1.7 on) as one detail, or null when the
|
|
1630
|
+
* log holds none. The install was dsh's to refuse, not pnpm's to fail: it
|
|
1631
|
+
* refuses before pnpm runs, when it can read the manifest — a registry spec,
|
|
1632
|
+
* which the catalog's peer verdict already predicts — and after pnpm
|
|
1633
|
+
* succeeded otherwise, restoring the profile. Reading that as a pnpm failure
|
|
1634
|
+
* sent the reader to `dsh plugin install`, which repairs nothing.
|
|
1635
|
+
*
|
|
1636
|
+
* The detail is what dsh refused, what it restored, and the command that
|
|
1637
|
+
* records its exact-version exemption, rebuilt from what dsh printed through
|
|
1638
|
+
* `allowVersionCommand` rather than repeated: dsh prints the INSTALLED
|
|
1639
|
+
* manifest's own name into it, which a tarball or git package chooses
|
|
1640
|
+
* itself, so only a name and versions dsh's exemption grammar accepts reach
|
|
1641
|
+
* a line meant for a terminal. The profile is the one this install ran in.
|
|
1642
|
+
* Anything dsh prints after `plugin command failed` — the diagnostics path,
|
|
1643
|
+
* and a hint about git build scripts dsh prints after every failed git
|
|
1644
|
+
* install — says nothing about this refusal.
|
|
1645
|
+
*/
|
|
1646
|
+
function refusalDetail(profile, usable) {
|
|
1647
|
+
const start = usable.findIndex((line) => line.startsWith(REFUSAL_OPENER));
|
|
1648
|
+
if (start === -1) return null;
|
|
1649
|
+
const refusedAt = usable.slice(start);
|
|
1650
|
+
const failedAt = refusedAt.findIndex((line) => line.startsWith(REFUSAL_FAILED));
|
|
1651
|
+
const block = failedAt === -1 ? refusedAt : refusedAt.slice(0, failedAt);
|
|
1652
|
+
const warnings = [];
|
|
1653
|
+
const commands = [];
|
|
1654
|
+
let restoration;
|
|
1655
|
+
for (const [index, line] of block.entries()) {
|
|
1656
|
+
const command = REFUSAL_COMMAND.exec(line);
|
|
1657
|
+
if (command !== null) {
|
|
1658
|
+
const [, name = "", version = "", runtimeVersion = ""] = command;
|
|
1659
|
+
const rebuilt = allowVersionCommand(profile, {
|
|
1660
|
+
name,
|
|
1661
|
+
version,
|
|
1662
|
+
runtimeVersion
|
|
1663
|
+
});
|
|
1664
|
+
if (rebuilt !== null) commands.push(rebuilt);
|
|
1665
|
+
} else if (index === 0) warnings.push(line.slice(28));
|
|
1666
|
+
else if (line.startsWith("dsh: ")) restoration ??= line.slice(5);
|
|
1667
|
+
else if (restoration === void 0) warnings.push(line);
|
|
1668
|
+
}
|
|
1669
|
+
const parts = [`dsh refused the install: ${warnings.map((warning) => {
|
|
1670
|
+
const tail = warning.indexOf(REFUSAL_WARNING_TAIL);
|
|
1671
|
+
return tail === -1 ? warning : warning.slice(0, tail);
|
|
1672
|
+
}).join(" ")}`];
|
|
1673
|
+
if (restoration !== void 0) parts.push(`${restoration.charAt(0).toUpperCase()}${restoration.slice(1)}`);
|
|
1674
|
+
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.`);
|
|
1675
|
+
return parts.join(" ");
|
|
1676
|
+
}
|
|
1677
|
+
/** What Rust's allocator prints before it aborts, which is how pnpm 12's
|
|
1678
|
+
* native binary dies when it cannot get the memory it asked for. Anchored at
|
|
1679
|
+
* both ends: node's own heap exhaustion ("Allocation failed - JavaScript heap
|
|
1680
|
+
* out of memory") is a different crash, from pnpm 11 or dsh itself, and the
|
|
1681
|
+
* pnpm 12 remedy below would be wrong for it. */
|
|
1682
|
+
const NATIVE_ALLOCATION_ABORT = /^memory allocation of \d+ bytes failed$/;
|
|
1683
|
+
/**
|
|
1684
|
+
* pnpm 12 aborting out of memory as one detail, or null when the log holds no
|
|
1685
|
+
* such abort.
|
|
1686
|
+
*
|
|
1687
|
+
* The known cause is pnpm/pnpm#15362, fixed in pnpm 12.7.0: the native peer
|
|
1688
|
+
* check intersected the ranges of a missing peer, doubling the intersection
|
|
1689
|
+
* for every package that wanted it. dsh writes `autoInstallPeers: false` into
|
|
1690
|
+
* every profile, so every `@deepseek-ai/dsh*` peer stays missing and plugins
|
|
1691
|
+
* that share one trip it. Reported on Windows with 8 GB, reproduced on Linux
|
|
1692
|
+
* through the real CLI of dsh 0.1.5-rc.3 and 0.1.7-rc.2 (2026-09-27). The
|
|
1693
|
+
* install aborted, so running it again on the same profile aborts again:
|
|
1694
|
+
* the detail names the way out instead of the usual recovery hint.
|
|
1695
|
+
*
|
|
1696
|
+
* Not every such abort is that bug (pnpm/pnpm#15867 is another one, with the
|
|
1697
|
+
* same words), so the detail says what to do if the reader's pnpm already has
|
|
1698
|
+
* the fix.
|
|
1699
|
+
*/
|
|
1700
|
+
function nativeAllocationAbortDetail(usable) {
|
|
1701
|
+
const abort = usable.find((line) => NATIVE_ALLOCATION_ABORT.test(line.trim()));
|
|
1702
|
+
if (abort === void 0) return null;
|
|
1703
|
+
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.`;
|
|
1704
|
+
}
|
|
1338
1705
|
/**
|
|
1339
1706
|
* The one log line worth putting in front of the user, plus the recovery hint.
|
|
1340
1707
|
*
|
|
@@ -1352,6 +1719,10 @@ const FAILURE_LOG_NOISE = [
|
|
|
1352
1719
|
function installFailureDetail(profile, log) {
|
|
1353
1720
|
const hint = `pnpm failed in the profile. Run: dsh plugin --profile ${profile} install`;
|
|
1354
1721
|
const usable = log.map((line) => line.replace(/\r+$/, "")).filter((line) => !FAILURE_LOG_NOISE.some((rx) => rx.test(line)));
|
|
1722
|
+
const refusal = refusalDetail(profile, usable);
|
|
1723
|
+
if (refusal !== null) return refusal;
|
|
1724
|
+
const abort = nativeAllocationAbortDetail(usable);
|
|
1725
|
+
if (abort !== null) return abort;
|
|
1355
1726
|
const reversed = [...usable].reverse();
|
|
1356
1727
|
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
1728
|
if (pick === void 0) return `${hint}.`;
|
|
@@ -1753,6 +2124,47 @@ function startUninstall(options) {
|
|
|
1753
2124
|
});
|
|
1754
2125
|
}
|
|
1755
2126
|
//#endregion
|
|
2127
|
+
//#region src/host/bundle-patch.ts
|
|
2128
|
+
/**
|
|
2129
|
+
* The patch files a bundle declares, read the way dsh reads them (design
|
|
2130
|
+
* 2026-09-26-dsh-017-readiness, B4).
|
|
2131
|
+
*
|
|
2132
|
+
* dsh 0.1.7's app-boot (`bundlePatchFiles`) accepts `dsh.bundle.patch` as one
|
|
2133
|
+
* package-relative file or as a list of them, applied in order, and throws on
|
|
2134
|
+
* anything else. dsh 0.1.5 reads only the string form: it joins the
|
|
2135
|
+
* declaration onto the package directory, so a list makes the profile fail to
|
|
2136
|
+
* start. What the RUNNING dsh accepts is `RunningHarness.patchLists`; this
|
|
2137
|
+
* reads the declaration itself, for the shop's own readers of a package's
|
|
2138
|
+
* patch — the entries it owns, the rows a hot mount replicates.
|
|
2139
|
+
*/
|
|
2140
|
+
/**
|
|
2141
|
+
* The declared patch files in application order: one for a string, the
|
|
2142
|
+
* listed ones for a list of strings. Null for a declaration dsh refuses —
|
|
2143
|
+
* neither a path nor a list of paths — which is distinct from an absent one:
|
|
2144
|
+
* only absence means the conventional `cordis.patch.yml`, and a caller that
|
|
2145
|
+
* fell back to it for a refused declaration would act on a patch dsh never
|
|
2146
|
+
* applies. Pure, and the list is a copy.
|
|
2147
|
+
*/
|
|
2148
|
+
function bundlePatchFiles(declared) {
|
|
2149
|
+
if (typeof declared === "string") return [declared];
|
|
2150
|
+
if (Array.isArray(declared) && declared.every((file) => typeof file === "string")) return [...declared];
|
|
2151
|
+
return null;
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* Whether an installed package's `dsh.bundle.patch` would stop the profile
|
|
2155
|
+
* from starting at the next boot, given whether the running dsh reads a list
|
|
2156
|
+
* (`RunningHarness.patchLists`). An absent declaration is no hazard: dsh adds
|
|
2157
|
+
* a package to the profile's bundles only when it declares a patch, so
|
|
2158
|
+
* nothing reads the field. An unknown harness (null) forms no verdict on a
|
|
2159
|
+
* list — the rule for every harness read — but a malformed declaration is
|
|
2160
|
+
* refused by every dsh, so it is named whatever is known. Pure.
|
|
2161
|
+
*/
|
|
2162
|
+
function patchDeclarationHazard(declared, patchLists) {
|
|
2163
|
+
if (declared === void 0) return null;
|
|
2164
|
+
if (bundlePatchFiles(declared) === null) return "malformed";
|
|
2165
|
+
return Array.isArray(declared) && patchLists === false ? "list-unsupported" : null;
|
|
2166
|
+
}
|
|
2167
|
+
//#endregion
|
|
1756
2168
|
//#region src/host/hot.ts
|
|
1757
2169
|
/**
|
|
1758
2170
|
* Restart-free installs: mount a freshly installed plugin into the running
|
|
@@ -1878,15 +2290,14 @@ function nextHotNumber(fs, dir) {
|
|
|
1878
2290
|
}
|
|
1879
2291
|
return max + 1;
|
|
1880
2292
|
}
|
|
1881
|
-
/**
|
|
1882
|
-
*
|
|
1883
|
-
*
|
|
1884
|
-
function
|
|
2293
|
+
/** The installed package's own `dsh.bundle.patch`, exactly as declared (the
|
|
2294
|
+
* Include input), or undefined when the package, its manifest or the field
|
|
2295
|
+
* is absent. */
|
|
2296
|
+
function declaredPatch(fs, packageDir) {
|
|
1885
2297
|
try {
|
|
1886
|
-
|
|
1887
|
-
return typeof patch === "string" ? { patch } : null;
|
|
2298
|
+
return JSON.parse(fs.read(join(packageDir, "package.json"))).dsh?.bundle?.patch;
|
|
1888
2299
|
} catch {
|
|
1889
|
-
return
|
|
2300
|
+
return;
|
|
1890
2301
|
}
|
|
1891
2302
|
}
|
|
1892
2303
|
/** Load the Include class through a computed dynamic import, or null when
|
|
@@ -1924,33 +2335,45 @@ function suppressWrite(treeClass) {
|
|
|
1924
2335
|
async function hotMount(ctx, profileDir, packageName, deps = {}) {
|
|
1925
2336
|
const { fs = nodeHotFs, dir = join(profileDir, HOT_DIR), timeoutMs = Number(process.env.DSH_SHOP_HOT_MOUNT_TIMEOUT_MS) || 1e4, now = Date.now } = deps;
|
|
1926
2337
|
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`);
|
|
2338
|
+
const declared = declaredPatch(fs, packageDir);
|
|
2339
|
+
const files = declared === void 0 ? ["cordis.patch.yml"] : bundlePatchFiles(declared);
|
|
2340
|
+
if (files === null || files.length === 0) {
|
|
2341
|
+
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
2342
|
return {
|
|
1943
2343
|
ok: false,
|
|
1944
2344
|
reason: "no-patch"
|
|
1945
2345
|
};
|
|
1946
2346
|
}
|
|
1947
|
-
const rows =
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
2347
|
+
const rows = [];
|
|
2348
|
+
for (const file of files) {
|
|
2349
|
+
const patchFile = resolve(packageDir, file);
|
|
2350
|
+
const inside = relative(packageDir, patchFile);
|
|
2351
|
+
if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
|
|
2352
|
+
ctx.logger?.warn(`hot-mount ${packageName}: the declared bundle patch is outside the package directory — restart will activate it`);
|
|
2353
|
+
return {
|
|
2354
|
+
ok: false,
|
|
2355
|
+
reason: "no-patch"
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
let patchText;
|
|
2359
|
+
try {
|
|
2360
|
+
patchText = fs.read(patchFile);
|
|
2361
|
+
} catch {
|
|
2362
|
+
ctx.logger?.warn(`hot-mount ${packageName}: no patch file to mount — restart will activate it`);
|
|
2363
|
+
return {
|
|
2364
|
+
ok: false,
|
|
2365
|
+
reason: "no-patch"
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
const parsed = parseSimplePatch(patchText);
|
|
2369
|
+
if (parsed === null) {
|
|
2370
|
+
ctx.logger?.warn(`hot-mount ${packageName}: patch has rows that cannot be hot-mounted — restart will activate it`);
|
|
2371
|
+
return {
|
|
2372
|
+
ok: false,
|
|
2373
|
+
reason: "not-simple"
|
|
2374
|
+
};
|
|
2375
|
+
}
|
|
2376
|
+
rows.push(...parsed);
|
|
1954
2377
|
}
|
|
1955
2378
|
let treeClass = deps.hotTreeClass;
|
|
1956
2379
|
if (treeClass === void 0) treeClass = await loadHotTreeClass();
|
|
@@ -2061,18 +2484,21 @@ function cleanHotDir(profileDir) {
|
|
|
2061
2484
|
* - A change to the BOOT COMPOSITION — a toggle, an uninstall — moves an
|
|
2062
2485
|
* entry the registry already enumerates, and the served graph follows it
|
|
2063
2486
|
* 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.
|
|
2487
|
+
* - A HOT MOUNT — an install — adds entries under the shop's own loader
|
|
2488
|
+
* entry, which the registry enumerates like any other: across a reload
|
|
2489
|
+
* following the mount the served graph gains the package and the page runs
|
|
2490
|
+
* its browser half (measured 2026-09-26 on 0.1.5-rc.3 and 0.1.7-rc.2, in
|
|
2491
|
+
* `web-full-flow.e2e.ts`). `true`.
|
|
2071
2492
|
*
|
|
2072
2493
|
* The asymmetry that decides every unknown still runs the same way: offering
|
|
2073
2494
|
* 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
|
-
*
|
|
2495
|
+
* that was needed is the defect this module exists to fix. Which step a hot
|
|
2496
|
+
* mount needs has been measured twice. On 2026-09-14 the graph came back
|
|
2497
|
+
* byte-identical across the reload, and installs moved to `restart` — but the
|
|
2498
|
+
* shop then registered the tree from whichever context made the RPC call, the
|
|
2499
|
+
* typert gateway's, and the registry never composed a tree hung there. Once
|
|
2500
|
+
* the tree hung off the shop's own entry (2026-09-26), the same measurement
|
|
2501
|
+
* found the package in the graph, and installs moved back to `reload`.
|
|
2076
2502
|
*/
|
|
2077
2503
|
function activationOf(input) {
|
|
2078
2504
|
if (!input.hostLive) return "restart";
|
|
@@ -2294,18 +2720,14 @@ function writeRepoPins(fs, path, pins) {
|
|
|
2294
2720
|
//#endregion
|
|
2295
2721
|
//#region src/host/profile.ts
|
|
2296
2722
|
/** 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]);
|
|
2723
|
+
/** The loader's `!!js` expression scalar, for the document edit: read as its
|
|
2724
|
+
* source text and written back under its own tag, never evaluated — the
|
|
2725
|
+
* loader evaluates it at entry activation. The same declaration the harness's
|
|
2726
|
+
* own writer of this file uses (`@deepseek-ai/dsh-plugin-manager` 0.1.7). */
|
|
2727
|
+
const USER_LAYER_TAGS = [{
|
|
2728
|
+
tag: "tag:yaml.org,2002:js",
|
|
2729
|
+
resolve: (source) => source
|
|
2730
|
+
}];
|
|
2309
2731
|
/**
|
|
2310
2732
|
* Find the profile directory that owns `startPath`.
|
|
2311
2733
|
*
|
|
@@ -2364,22 +2786,78 @@ function realpathNearestExisting(startPath) {
|
|
|
2364
2786
|
* {@link ownedEntryIds}), and they toggle together: writing them one at a
|
|
2365
2787
|
* time would read-modify-write the file once per entry, so a crash between
|
|
2366
2788
|
* two writes would leave the package half disabled.
|
|
2789
|
+
*
|
|
2790
|
+
* The edit changes one key and nothing else (design
|
|
2791
|
+
* 2026-09-26-market-borrowings §2). The layer is validated by the harness's
|
|
2792
|
+
* own parser first, so one it cannot load still throws and is never
|
|
2793
|
+
* rewritten; it is then edited as a YAML document, which keeps every comment,
|
|
2794
|
+
* every other row, every other key and every `!!js` scalar as the user wrote
|
|
2795
|
+
* them. The whole-file rewrite this replaced dropped each toggled id's row —
|
|
2796
|
+
* and with it a `config:` override the user had put there — and dumped the
|
|
2797
|
+
* list back through a library that models no comments, so the first toggle
|
|
2798
|
+
* erased the header dsh writes into every new profile.
|
|
2799
|
+
*
|
|
2800
|
+
* For each entry the row written is the LAST one carrying its id, when the
|
|
2801
|
+
* harness is sure to apply it: applyEntryPatches applies rows in order and
|
|
2802
|
+
* each key replaces the entry's value, so the last row that sets `disabled`
|
|
2803
|
+
* decides it. A row with `insert` is an insertion into a group, never an
|
|
2804
|
+
* override; a row naming another module is skipped by the harness; a named
|
|
2805
|
+
* row whose module the caller did not say cannot be judged. In each of those
|
|
2806
|
+
* cases a row is appended instead, which the harness applies last whatever
|
|
2807
|
+
* came before it — so appending is never wrong, only less tidy.
|
|
2808
|
+
*
|
|
2809
|
+
* An enable writes `disabled: false` rather than deleting the row, which is
|
|
2810
|
+
* what lost a user's row or the comment above it; it is also the harness's
|
|
2811
|
+
* own convention for this file from 0.1.7. The file keeps its permission bits
|
|
2812
|
+
* across the rename (a new one is created owner-only, as the harness's writer
|
|
2813
|
+
* does), because a config override can hold a credential.
|
|
2367
2814
|
*/
|
|
2368
2815
|
function setUserLayerRows(options) {
|
|
2369
2816
|
const file = join(options.profileDir, "cordis.patch.yml");
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2817
|
+
loadOptionalPatches("dsh-plugin-shop", file);
|
|
2818
|
+
let text = "[]\n";
|
|
2819
|
+
let mode = 384;
|
|
2820
|
+
if (existsSync(file)) {
|
|
2821
|
+
text = readFileSync(file, "utf8");
|
|
2822
|
+
mode = statSync(file).mode & 511;
|
|
2823
|
+
}
|
|
2824
|
+
const document = parseDocument(text, { customTags: USER_LAYER_TAGS });
|
|
2825
|
+
const parseError = document.errors[0];
|
|
2826
|
+
if (parseError !== void 0) throw parseError;
|
|
2827
|
+
const list = document.contents;
|
|
2828
|
+
if (!isSeq(list)) throw new Error(`dsh-plugin-shop: ${file} is not a YAML list of patch rows`);
|
|
2829
|
+
for (const row of options.rows) {
|
|
2830
|
+
const target = lastRowFor(list.items, row.id);
|
|
2831
|
+
if (target !== null && appliesTo(target, row.name)) {
|
|
2832
|
+
target.set("disabled", row.disabled);
|
|
2833
|
+
continue;
|
|
2834
|
+
}
|
|
2835
|
+
if (list.items.length === 0) list.flow = false;
|
|
2836
|
+
document.add({
|
|
2837
|
+
id: row.id,
|
|
2838
|
+
disabled: row.disabled
|
|
2839
|
+
});
|
|
2840
|
+
}
|
|
2376
2841
|
const tmp = `${file}.tmp`;
|
|
2377
|
-
writeFileSync(tmp,
|
|
2378
|
-
|
|
2379
|
-
schema: USER_LAYER_SCHEMA
|
|
2380
|
-
}));
|
|
2842
|
+
writeFileSync(tmp, document.toString({ lineWidth: 0 }), { mode });
|
|
2843
|
+
chmodSync(tmp, mode);
|
|
2381
2844
|
renameSync(tmp, file);
|
|
2382
2845
|
}
|
|
2846
|
+
/** The last override row carrying `id`, or null when there is none. A row
|
|
2847
|
+
* with `insert` is an insertion into group `id`, not an override of it. */
|
|
2848
|
+
function lastRowFor(items, id) {
|
|
2849
|
+
for (let index = items.length - 1; index >= 0; index--) {
|
|
2850
|
+
const item = items[index];
|
|
2851
|
+
if (isMap(item) && !item.has("insert") && item.get("id") === id) return item;
|
|
2852
|
+
}
|
|
2853
|
+
return null;
|
|
2854
|
+
}
|
|
2855
|
+
/** Whether the harness is sure to apply `row` to the entry mounting
|
|
2856
|
+
* `moduleName`: a row with no `name`, or one naming that very module. */
|
|
2857
|
+
function appliesTo(row, moduleName) {
|
|
2858
|
+
if (!row.has("name")) return true;
|
|
2859
|
+
return moduleName !== void 0 && row.get("name") === moduleName;
|
|
2860
|
+
}
|
|
2383
2861
|
/**
|
|
2384
2862
|
* The loader entry ids one installed package contributes to the tree.
|
|
2385
2863
|
*
|
|
@@ -2402,41 +2880,73 @@ function setUserLayerRows(options) {
|
|
|
2402
2880
|
* which only the inventory can answer.
|
|
2403
2881
|
*/
|
|
2404
2882
|
function ownedEntryIds(options) {
|
|
2883
|
+
return ownedEntries(options).map((entry) => entry.id);
|
|
2884
|
+
}
|
|
2885
|
+
/**
|
|
2886
|
+
* {@link ownedEntryIds} with the module each entry mounts beside its id. The
|
|
2887
|
+
* name is what the harness judges a NAMED user-layer row by — a row naming a
|
|
2888
|
+
* different module is skipped (applyEntryPatches) — so it is what lets the
|
|
2889
|
+
* toggle write a user's own named row rather than append one beside it. It is
|
|
2890
|
+
* read through the harness's parser, so a relative name arrives anchored to a
|
|
2891
|
+
* file URL exactly as the composed entry carries it; an `!!js` name has no
|
|
2892
|
+
* plain value and reads as undefined. Throws where `ownedEntryIds` does.
|
|
2893
|
+
*/
|
|
2894
|
+
function ownedEntries(options) {
|
|
2405
2895
|
const packageDir = join(options.profileDir, "node_modules", ...options.packageName.split("/"));
|
|
2406
2896
|
const manifestPath = join(packageDir, "package.json");
|
|
2407
2897
|
if (!existsSync(manifestPath)) return [];
|
|
2408
|
-
let
|
|
2898
|
+
let declared;
|
|
2409
2899
|
try {
|
|
2410
|
-
|
|
2900
|
+
declared = JSON.parse(readFileSync(manifestPath, "utf8")).dsh?.bundle?.patch;
|
|
2411
2901
|
} catch (error) {
|
|
2412
2902
|
throw new Error(`dsh-plugin-shop: failed to read ${manifestPath}: ${String(error)}`);
|
|
2413
2903
|
}
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
const
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2904
|
+
const files = bundlePatchFiles(declared);
|
|
2905
|
+
if (files === null) return [];
|
|
2906
|
+
const entries = [];
|
|
2907
|
+
for (const file of files) {
|
|
2908
|
+
const patchFile = resolve(packageDir, file);
|
|
2909
|
+
const inside = relative(packageDir, patchFile);
|
|
2910
|
+
if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) throw new Error(`dsh-plugin-shop: ${options.packageName} declares a bundle patch outside its own directory: ${file}`);
|
|
2911
|
+
collectInsertedEntries(loadOptionalPatches("dsh-plugin-shop", patchFile) ?? [], entries);
|
|
2912
|
+
}
|
|
2913
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2914
|
+
return entries.filter((entry) => !seen.has(entry.id) && seen.add(entry.id) !== void 0);
|
|
2421
2915
|
}
|
|
2422
|
-
/**
|
|
2423
|
-
*
|
|
2916
|
+
/**
|
|
2917
|
+
* An installed package's own `dsh.bundle.patch`, exactly as declared, or
|
|
2918
|
+
* undefined when the package, its manifest or the field is absent — or the
|
|
2919
|
+
* manifest cannot be read, which no post-install check can judge either.
|
|
2920
|
+
*/
|
|
2921
|
+
function declaredBundlePatch(options) {
|
|
2922
|
+
const manifestPath = join(options.profileDir, "node_modules", ...options.packageName.split("/"), "package.json");
|
|
2923
|
+
try {
|
|
2924
|
+
return JSON.parse(readFileSync(manifestPath, "utf8")).dsh?.bundle?.patch;
|
|
2925
|
+
} catch {
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
/** Walk a patch list, appending every INSERTED entry. A patch row without
|
|
2930
|
+
* `insert` targets an entry someone else composed — the loader's
|
|
2424
2931
|
* applyEntryPatches looks it up and skips it when absent, so it creates
|
|
2425
2932
|
* nothing and owns nothing. An inserted GROUP owns its children, which the
|
|
2426
2933
|
* loader reads from the group's own `config` array. */
|
|
2427
|
-
function
|
|
2934
|
+
function collectInsertedEntries(rows, into) {
|
|
2428
2935
|
for (const row of rows) {
|
|
2429
2936
|
if (row === null || typeof row !== "object") continue;
|
|
2430
2937
|
const inserted = row.insert;
|
|
2431
|
-
if (Array.isArray(inserted))
|
|
2938
|
+
if (Array.isArray(inserted)) collectEntries(inserted, into);
|
|
2432
2939
|
}
|
|
2433
2940
|
}
|
|
2434
|
-
function
|
|
2941
|
+
function collectEntries(entries, into) {
|
|
2435
2942
|
for (const entry of entries) {
|
|
2436
2943
|
if (entry === null || typeof entry !== "object") continue;
|
|
2437
|
-
const { id, group, config } = entry;
|
|
2438
|
-
if (typeof id === "string") into.push(
|
|
2439
|
-
|
|
2944
|
+
const { id, name, group, config } = entry;
|
|
2945
|
+
if (typeof id === "string") into.push({
|
|
2946
|
+
id,
|
|
2947
|
+
name: typeof name === "string" ? name : void 0
|
|
2948
|
+
});
|
|
2949
|
+
if (group === true && Array.isArray(config)) collectEntries(config, into);
|
|
2440
2950
|
}
|
|
2441
2951
|
}
|
|
2442
2952
|
/**
|
|
@@ -2820,25 +3330,240 @@ function createPrefetcher(options = {}) {
|
|
|
2820
3330
|
* not provide (design 2026-09-01-harness-compatibility), and — for the
|
|
2821
3331
|
* shop's OWN declared peers — which ones it provides at a version outside
|
|
2822
3332
|
* the declared range. */
|
|
3333
|
+
const NODE_LOOKUP_FS = {
|
|
3334
|
+
stat: (path) => statSync(path),
|
|
3335
|
+
readFile: (path) => readFileSync(path, "utf8")
|
|
3336
|
+
};
|
|
3337
|
+
/**
|
|
3338
|
+
* One segment of a bare package name, about to be spliced into a path.
|
|
3339
|
+
*
|
|
3340
|
+
* A leading `.` refuses the segment whole: `.` and `..` would walk the path
|
|
3341
|
+
* out of `node_modules`, and `.bin` or `.pnpm` are directories an install
|
|
3342
|
+
* carries that no package can be. The ESM resolver agrees as far as it goes:
|
|
3343
|
+
* it refuses an unscoped name that starts with `.`, and any name holding `%`
|
|
3344
|
+
* or `\`, with ERR_INVALID_MODULE_SPECIFIER (measured on Node 26.6.0 with
|
|
3345
|
+
* `.bin`, `a%2e`, `a\b`). `\` is also a separator on Windows, `:` names a
|
|
3346
|
+
* drive, an NTFS stream or a URL scheme (`node:fs`, `file:`), and NUL is a
|
|
3347
|
+
* byte no path may carry.
|
|
3348
|
+
*/
|
|
3349
|
+
function isNameSegment(segment) {
|
|
3350
|
+
return segment.length > 0 && !segment.startsWith(".") && !/[\\:%\0]/.test(segment);
|
|
3351
|
+
}
|
|
2823
3352
|
/**
|
|
2824
|
-
*
|
|
2825
|
-
*
|
|
2826
|
-
* what
|
|
2827
|
-
*
|
|
3353
|
+
* Whether `spec` is a bare package name — `name`, or `@scope/name` — and so
|
|
3354
|
+
* safe to look up. Peer names are catalog input, and hostile; this check is
|
|
3355
|
+
* what keeps one from becoming a path. The lookup this replaced resolved
|
|
3356
|
+
* `'../x/package.json'` relative to the profile and an absolute name as
|
|
3357
|
+
* itself, so a peer name could point it at any path on the reader's disk.
|
|
3358
|
+
*/
|
|
3359
|
+
function isBarePackageName(spec) {
|
|
3360
|
+
const segments = spec.split("/");
|
|
3361
|
+
const [first, second] = segments;
|
|
3362
|
+
if (first === void 0) return false;
|
|
3363
|
+
if (!first.startsWith("@")) return segments.length === 1 && isNameSegment(first);
|
|
3364
|
+
return segments.length === 2 && second !== void 0 && isNameSegment(first.slice(1)) && isNameSegment(second);
|
|
3365
|
+
}
|
|
3366
|
+
/** Whether `path` stats as a directory. Every stat failure answers false —
|
|
3367
|
+
* see `findPackageDir` for why that is Node's rule and not a shortcut. */
|
|
3368
|
+
function isDirectoryAt(fs, path) {
|
|
3369
|
+
try {
|
|
3370
|
+
return fs.stat(path).isDirectory();
|
|
3371
|
+
} catch {
|
|
3372
|
+
return false;
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
/** Whether `path` stats as a regular file. Every stat failure answers false,
|
|
3376
|
+
* for the reason `isDirectoryAt` gives. */
|
|
3377
|
+
function isFileAt(fs, path) {
|
|
3378
|
+
try {
|
|
3379
|
+
return fs.stat(path).isFile();
|
|
3380
|
+
} catch {
|
|
3381
|
+
return false;
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
/**
|
|
3385
|
+
* The directory the ESM resolver MATCHES for `spec` from `fromDir`, or null
|
|
3386
|
+
* when the walk reaches the filesystem root without one: for each ancestor
|
|
3387
|
+
* `dir`, the candidate `dir/node_modules/<spec>`, and the first that stats as
|
|
3388
|
+
* a DIRECTORY is the match. A candidate that is anything else — absent, a
|
|
3389
|
+
* file, or a path whose stat fails for ANY reason — is "not here", and the
|
|
3390
|
+
* walk moves on to the next ancestor.
|
|
3391
|
+
*
|
|
3392
|
+
* That is Node's own rule, copied from its reader rather than from the spec
|
|
3393
|
+
* prose. Node 26.6.0 `internal/modules/package_json_reader.js`
|
|
3394
|
+
* (`getPackageJSONURL`) does
|
|
3395
|
+
* `const stat = internalModuleStat(...); if (stat !== 1) { ...; continue }`:
|
|
3396
|
+
* 1 is "directory", and a failed stat is a negative errno, so EACCES and
|
|
3397
|
+
* ELOOP keep walking exactly like ENOENT. Measured on 26.6.0: a self-link
|
|
3398
|
+
* (ELOOP) at `node_modules/<name>` in front of an ancestor's real copy makes
|
|
3399
|
+
* the import load the ancestor's copy. This walk used to throw on every
|
|
3400
|
+
* failure but ENOENT and ENOTDIR, and `incompatibilityMap` turned the throw
|
|
3401
|
+
* into no verdict — so a missing peer, whose walk runs all the way to the
|
|
3402
|
+
* root, met any unsearchable or looping `node_modules` on the way (a
|
|
3403
|
+
* `~/node_modules` made by `sudo npm i` under umask 027, say), and every
|
|
3404
|
+
* missing-peer badge in the catalog went silent while those plugins still
|
|
3405
|
+
* failed to import.
|
|
3406
|
+
*
|
|
3407
|
+
* The walk stops at the first match whatever it finds inside: the loader
|
|
3408
|
+
* imports from that directory or fails there, and never falls through to an
|
|
3409
|
+
* ancestor (measured on 26.6.0: an empty `node_modules/<name>` in front of an
|
|
3410
|
+
* ancestor's working copy fails the import with ERR_MODULE_NOT_FOUND).
|
|
3411
|
+
* Whether the match is a PACKAGE is `packageDirectory`'s question.
|
|
3412
|
+
*
|
|
3413
|
+
* Throws only for a `spec` that is not a bare package name. No state of the
|
|
3414
|
+
* filesystem can make it throw.
|
|
3415
|
+
*/
|
|
3416
|
+
function findPackageDir(fromDir, spec, fs) {
|
|
3417
|
+
if (!isBarePackageName(spec)) throw new TypeError(`not a bare package name: ${JSON.stringify(spec)}`);
|
|
3418
|
+
const segments = spec.split("/");
|
|
3419
|
+
for (let dir = fromDir;;) {
|
|
3420
|
+
const candidate = join(dir, "node_modules", ...segments);
|
|
3421
|
+
if (isDirectoryAt(fs, candidate)) return candidate;
|
|
3422
|
+
const parent = dirname(dir);
|
|
3423
|
+
if (parent === dir) return null;
|
|
3424
|
+
dir = parent;
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
/**
|
|
3428
|
+
* The directory `spec` is installed at as seen from `fromDir`, or null when
|
|
3429
|
+
* it is not installed there: the walk's match (`findPackageDir`), and only
|
|
3430
|
+
* when that directory holds a `package.json` that stats as a FILE. Anything
|
|
3431
|
+
* else is ABSENT — a match with no manifest, a manifest that is not a file,
|
|
3432
|
+
* one whose stat fails — because the loader matches that directory and the
|
|
3433
|
+
* import fails there. The case this exists for: an uninstall that stops
|
|
3434
|
+
* part-way (on Windows a locked file is enough) can leave
|
|
3435
|
+
* `<profile>/node_modules/L/` holding nothing but a nested `node_modules/`,
|
|
3436
|
+
* and a plugin declaring L then fails with ERR_MODULE_NOT_FOUND; while any
|
|
3437
|
+
* directory counted as present, the badge naming L disappeared. The lookup
|
|
3438
|
+
* before that one, `require.resolve('<spec>/package.json')`, required the
|
|
3439
|
+
* manifest too.
|
|
3440
|
+
*
|
|
3441
|
+
* The manifest is stat-ed and never read here: presence is the file, and
|
|
3442
|
+
* what it says is the version resolver's question. So a manifest that exists
|
|
3443
|
+
* but cannot be read or parsed is a present package with no readable version.
|
|
3444
|
+
*
|
|
3445
|
+
* One edge is knowingly given up: a directory holding `index.js` and no
|
|
3446
|
+
* manifest DOES import — Node's legacy main resolution tries `index.js` in a
|
|
3447
|
+
* match without a manifest — but reads absent here. No package manager
|
|
3448
|
+
* installs that shape.
|
|
3449
|
+
*
|
|
3450
|
+
* Throws only for a `spec` that is not a bare package name. This one lookup
|
|
3451
|
+
* is behind the presence resolver, the version resolver, and the running
|
|
3452
|
+
* harness's own `@deepseek-ai/dsh-app-boot` (`harness.ts`).
|
|
3453
|
+
*/
|
|
3454
|
+
function packageDirectory(fromDir, spec, fs = NODE_LOOKUP_FS) {
|
|
3455
|
+
const match = findPackageDir(fromDir, spec, fs);
|
|
3456
|
+
return match !== null && isFileAt(fs, join(match, "package.json")) ? match : null;
|
|
3457
|
+
}
|
|
3458
|
+
/**
|
|
3459
|
+
* The directory the walk starts from. `createRequire` took a file URL or an
|
|
3460
|
+
* absolute path and so does this — the production anchor is
|
|
3461
|
+
* `<profile>/cordis.yml` as a file URL — and, like `createRequire`, a
|
|
3462
|
+
* trailing separator names the directory itself rather than a file in its
|
|
3463
|
+
* parent. Anything else throws here, at construction, which the gateway's
|
|
3464
|
+
* call sites already treat as "no anchor, no verdict".
|
|
3465
|
+
*/
|
|
3466
|
+
function anchorDirectory(baseUrl) {
|
|
3467
|
+
const path = isAbsolute(baseUrl) ? baseUrl : fileURLToPath(baseUrl);
|
|
3468
|
+
return path.endsWith("/") || path.endsWith(sep) ? path : dirname(path);
|
|
3469
|
+
}
|
|
3470
|
+
/** `nodeResolver` over an injected filesystem, walking up from `fromDir` —
|
|
3471
|
+
* the seam fixtures drive the lookup through. */
|
|
3472
|
+
function packageResolver(fromDir, fs) {
|
|
3473
|
+
return (spec) => packageDirectory(fromDir, spec, fs) !== null;
|
|
3474
|
+
}
|
|
3475
|
+
/**
|
|
3476
|
+
* The production resolver, anchored at the profile. A peer is present when
|
|
3477
|
+
* the first ancestor `node_modules/<spec>` that is a directory holds a
|
|
3478
|
+
* `package.json` file — the directory the ESM resolver that loads plugins'
|
|
3479
|
+
* host halves matches for a bare package, and a manifest in it
|
|
3480
|
+
* (`findPackageDir`, `packageDirectory`). From `<profile>/cordis.yml` the
|
|
3481
|
+
* walk reads `<profile>/node_modules`, then `$DSH_HOME/profiles/node_modules`
|
|
3482
|
+
* — the link farm dsh-app-boot's `healProfilesModuleFallback` keeps, pointing
|
|
3483
|
+
* into the global dsh install, on 0.1.5; 0.1.7 keeps none, and on a harness
|
|
3484
|
+
* that serves its packages another way `harnessPackageResolver` asks it in
|
|
3485
|
+
* front of this walk — and so on up to the root, past any candidate
|
|
3486
|
+
* it cannot stat, as Node's reader walks past one (measured on Node 26.6.0
|
|
3487
|
+
* with a looping link in front of a real copy: the import loads the copy).
|
|
3488
|
+
* The one shape where this and the loader knowingly disagree is a directory
|
|
3489
|
+
* holding `index.js` and no manifest, which imports and reads absent here;
|
|
3490
|
+
* no package manager installs it (`packageDirectory`).
|
|
3491
|
+
*
|
|
3492
|
+
* It used to ask `require.resolve('<spec>/package.json')`, a question that
|
|
3493
|
+
* answered wrongly twice:
|
|
3494
|
+
*
|
|
3495
|
+
* - Under a packaged dsh executable (`process.pkg`) the link farm holds ESM
|
|
3496
|
+
* proxy packages instead of symlinks, and a proxy's `exports` leaves
|
|
3497
|
+
* `./package.json` out on purpose, so resolving it threw
|
|
3498
|
+
* ERR_PACKAGE_PATH_NOT_EXPORTED for every proxied peer. Presence read that
|
|
3499
|
+
* as present; the version resolver below could read nothing. Reproduced:
|
|
3500
|
+
* a proxy at `0.2.0-rc.1` against a declared `^0.1.1-rc.2` gave presence
|
|
3501
|
+
* true, version null, mismatches `[]` — the load-time self-check could
|
|
3502
|
+
* never fire under a packaged dsh.
|
|
3503
|
+
* - Node caches a SUCCESSFUL CJS resolution for the life of the process
|
|
3504
|
+
* (`Module._pathCache`, keyed on request + lookup paths) and hands the
|
|
3505
|
+
* cached filename back without looking at the disk; a failure is not
|
|
3506
|
+
* cached. Measured on Node 26.6.0 in one process: absent → false,
|
|
3507
|
+
* installed → true, uninstalled → STILL true. A peer the reader removed
|
|
3508
|
+
* left every plugin declaring it unflagged until dsh restarted.
|
|
3509
|
+
*
|
|
3510
|
+
* So nothing is cached and `exports` is never consulted: every call stats
|
|
3511
|
+
* afresh, and a package with a manifest is present whatever its `exports`
|
|
3512
|
+
* says. `stat` follows symlinks, so a link to nothing reads absent — which
|
|
3513
|
+
* matters, because the link farm adds links and never prunes them: 29 of its
|
|
3514
|
+
* 511 dangled on the machine this was measured on, after upgrades.
|
|
3515
|
+
*
|
|
3516
|
+
* The global folders — `NODE_PATH`, `~/.node_modules`, `~/.node_libraries`,
|
|
3517
|
+
* `$PREFIX/lib/node` — are deliberately not searched: the ESM resolver that
|
|
3518
|
+
* loads plugin host code does not search them either. That is not academic.
|
|
3519
|
+
* pnpm's bin shims export `NODE_PATH` (this repo's own `node_modules/.bin/
|
|
3520
|
+
* vitest` points it into the virtual store), and under one the old lookup
|
|
3521
|
+
* found harness packages from any anchor at all, a temp directory included.
|
|
3522
|
+
*
|
|
3523
|
+
* It throws only for a `spec` that is not a bare package name, and
|
|
3524
|
+
* `incompatibilityMap` turns the throw into no verdict: a hostile name is not
|
|
3525
|
+
* a fact about this installation, and false is an accusation. Nothing on the
|
|
3526
|
+
* filesystem makes it throw. It used to throw on a stat failure other than
|
|
3527
|
+
* ENOENT or ENOTDIR as well, reasoning that such a candidate "may hold the
|
|
3528
|
+
* package" — but the loader never asks what a candidate it cannot stat holds;
|
|
3529
|
+
* it walks on (`findPackageDir`), so the throw silenced true badges and
|
|
3530
|
+
* established nothing.
|
|
2828
3531
|
*/
|
|
2829
3532
|
function nodeResolver(baseUrl) {
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
3533
|
+
return packageResolver(anchorDirectory(baseUrl), NODE_LOOKUP_FS);
|
|
3534
|
+
}
|
|
3535
|
+
/**
|
|
3536
|
+
* The manifest of the package the harness reaches for `spec` from
|
|
3537
|
+
* `parentURL`, or null when it reaches none, or one whose manifest no longer
|
|
3538
|
+
* stats as a file. The stat is fresh on every call for the reason §9.4 gives:
|
|
3539
|
+
* the service remembers a package it once found, and an uninstalled peer
|
|
3540
|
+
* must stop reading as present. Throws what the service throws, and a
|
|
3541
|
+
* TypeError for a name that is not a bare package name — which never reaches
|
|
3542
|
+
* the service, because peer names are catalog text.
|
|
3543
|
+
*/
|
|
3544
|
+
function harnessManifest(lookup, spec, parentURL, fs) {
|
|
3545
|
+
if (!isBarePackageName(spec)) throw new TypeError(`not a bare package name: ${JSON.stringify(spec)}`);
|
|
3546
|
+
const manifestPath = lookup.packageOf(spec, parentURL)?.manifestPath;
|
|
3547
|
+
return typeof manifestPath === "string" && isFileAt(fs, manifestPath) ? manifestPath : null;
|
|
3548
|
+
}
|
|
3549
|
+
/**
|
|
3550
|
+
* Presence on a harness that serves its packages through its runtime
|
|
3551
|
+
* resolution (`HarnessPackageLookup`): present when the harness reaches a
|
|
3552
|
+
* package for `spec` from `parentURL`, the profile anchor, and otherwise
|
|
3553
|
+
* whatever `walk` answers — `nodeResolver` over the same anchor, in
|
|
3554
|
+
* production. Asking the harness first asks the loader's own question, since
|
|
3555
|
+
* a plugin's import goes through the same resolution. The walk stays behind
|
|
3556
|
+
* it because either answer can only clear a badge, never raise one, and a
|
|
3557
|
+
* false badge is the failure this module exists to avoid.
|
|
3558
|
+
*
|
|
3559
|
+
* Throws, which `incompatibilityMap` turns into no verdict for the entries
|
|
3560
|
+
* declaring `spec`, for a name that is not a bare package name and for
|
|
3561
|
+
* anything the service throws. A harness that cannot answer is silence:
|
|
3562
|
+
* falling back to the walk alone would read every package it serves as
|
|
3563
|
+
* missing.
|
|
3564
|
+
*/
|
|
3565
|
+
function harnessPackageResolver(walk, lookup, parentURL, fs = NODE_LOOKUP_FS) {
|
|
3566
|
+
return (spec) => harnessManifest(lookup, spec, parentURL, fs) !== null || walk(spec);
|
|
2842
3567
|
}
|
|
2843
3568
|
/**
|
|
2844
3569
|
* Package name → the peer names that did not resolve. A key is present only
|
|
@@ -2876,29 +3601,79 @@ function incompatibilityMap(entries, resolve) {
|
|
|
2876
3601
|
}
|
|
2877
3602
|
return out;
|
|
2878
3603
|
}
|
|
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);
|
|
3604
|
+
/** `nodeVersionResolver` over an injected filesystem, walking up from
|
|
3605
|
+
* `fromDir` — the seam fixtures drive the lookup through. */
|
|
3606
|
+
function packageVersionResolver(fromDir, fs) {
|
|
2889
3607
|
return (spec) => {
|
|
2890
|
-
let
|
|
3608
|
+
let dir;
|
|
2891
3609
|
try {
|
|
2892
|
-
|
|
3610
|
+
dir = packageDirectory(fromDir, spec, fs);
|
|
2893
3611
|
} catch {
|
|
2894
3612
|
return null;
|
|
2895
3613
|
}
|
|
3614
|
+
return dir === null ? null : manifestVersion(fs, join(dir, "package.json"));
|
|
3615
|
+
};
|
|
3616
|
+
}
|
|
3617
|
+
/** The non-empty `version` string of the manifest at `manifestPath`, or null
|
|
3618
|
+
* for anything else. */
|
|
3619
|
+
function manifestVersion(fs, manifestPath) {
|
|
3620
|
+
let manifest;
|
|
3621
|
+
try {
|
|
3622
|
+
manifest = JSON.parse(fs.readFile(manifestPath));
|
|
3623
|
+
} catch {
|
|
3624
|
+
return null;
|
|
3625
|
+
}
|
|
3626
|
+
const version = typeof manifest === "object" && manifest !== null ? manifest.version : void 0;
|
|
3627
|
+
return typeof version === "string" && version.length > 0 ? version : null;
|
|
3628
|
+
}
|
|
3629
|
+
/**
|
|
3630
|
+
* The production version resolver: `nodeResolver`'s lookup, then the matched
|
|
3631
|
+
* directory's own `package.json`, read directly. Reading it directly is the
|
|
3632
|
+
* fix, not a shortcut: resolving `<spec>/package.json` is what an exports map
|
|
3633
|
+
* can refuse, and a packaged dsh's module proxy refuses it by construction
|
|
3634
|
+
* while carrying the proxied package's version in exactly that file (see
|
|
3635
|
+
* `nodeResolver`).
|
|
3636
|
+
*
|
|
3637
|
+
* The directory is the one the presence resolver finds present — the walk's
|
|
3638
|
+
* first directory match, holding a `package.json` file (`packageDirectory`;
|
|
3639
|
+
* `findPackageDir` cites Node's reader) — so the two never disagree about
|
|
3640
|
+
* which copy is installed: a candidate nobody can stat is walked past to the
|
|
3641
|
+
* copy further up, whose version is then the answer, exactly as the loader
|
|
3642
|
+
* imports that copy (measured on Node 26.6.0 with a looping link in front of
|
|
3643
|
+
* it); a match without a manifest is absent, and shadows every copy above
|
|
3644
|
+
* it. An `index.js`-only directory, which the loader would import, has no
|
|
3645
|
+
* manifest to read a version from either way.
|
|
3646
|
+
*
|
|
3647
|
+
* Anything that leaves no version to read answers null: a spec that is not a
|
|
3648
|
+
* bare package name, a peer that is absent, a manifest that is unreadable or
|
|
3649
|
+
* malformed, a `version` that is not a non-empty string. Absence is
|
|
3650
|
+
* deliberately NOT reported as a version violation: `incompatibilityMap` is
|
|
3651
|
+
* what covers a missing peer.
|
|
3652
|
+
*/
|
|
3653
|
+
function nodeVersionResolver(baseUrl) {
|
|
3654
|
+
return packageVersionResolver(anchorDirectory(baseUrl), NODE_LOOKUP_FS);
|
|
3655
|
+
}
|
|
3656
|
+
/**
|
|
3657
|
+
* `harnessPackageResolver`'s twin for versions: read afresh from the manifest
|
|
3658
|
+
* of the package the harness reaches for `spec` — the copy a plugin's import
|
|
3659
|
+
* loads, which the walk cannot see on dsh 0.1.7 — and `walk`'s answer only
|
|
3660
|
+
* when the harness reaches none. A manifest the harness names but nobody can
|
|
3661
|
+
* read or parse answers null, never the walk's version: the walk may match
|
|
3662
|
+
* another copy, and the version must describe the copy presence found (see
|
|
3663
|
+
* `nodeVersionResolver`).
|
|
3664
|
+
*
|
|
3665
|
+
* Null for a name that is not a bare package name and for a service that
|
|
3666
|
+
* throws, as null is already this resolver's no-verdict signal.
|
|
3667
|
+
*/
|
|
3668
|
+
function harnessPackageVersionResolver(walk, lookup, parentURL, fs = NODE_LOOKUP_FS) {
|
|
3669
|
+
return (spec) => {
|
|
3670
|
+
let manifestPath;
|
|
2896
3671
|
try {
|
|
2897
|
-
|
|
2898
|
-
return typeof version === "string" ? version : null;
|
|
3672
|
+
manifestPath = harnessManifest(lookup, spec, parentURL, fs);
|
|
2899
3673
|
} catch {
|
|
2900
3674
|
return null;
|
|
2901
3675
|
}
|
|
3676
|
+
return manifestPath === null ? walk(spec) : manifestVersion(fs, manifestPath);
|
|
2902
3677
|
};
|
|
2903
3678
|
}
|
|
2904
3679
|
/**
|
|
@@ -2974,6 +3749,186 @@ function createPeerVersionCheck(deps) {
|
|
|
2974
3749
|
};
|
|
2975
3750
|
}
|
|
2976
3751
|
//#endregion
|
|
3752
|
+
//#region src/host/harness.ts
|
|
3753
|
+
/**
|
|
3754
|
+
* The harness this process runs: which `@deepseek-ai/dsh` it is, and that
|
|
3755
|
+
* dsh's own profile-template table — the running side of the
|
|
3756
|
+
* `dsh.compatibility` verdict (design 2026-09-01-harness-compatibility §9.9).
|
|
3757
|
+
*
|
|
3758
|
+
* Both used to be read from somewhere that is not the running harness, and
|
|
3759
|
+
* both were wrong there:
|
|
3760
|
+
*
|
|
3761
|
+
* - The version was the first `node_modules/@deepseek-ai/dsh` found walking up
|
|
3762
|
+
* from the profile: what the profile can IMPORT, not what RUNS. A plugin
|
|
3763
|
+
* that depends on the dsh package gets its copy hoisted into
|
|
3764
|
+
* `<profile>/node_modules` — the listed `dsh-claude-tui@0.1.6` pulls
|
|
3765
|
+
* 0.1.2-rc.1 — a second install sharing DSH_HOME re-points the link farm,
|
|
3766
|
+
* and under a packaged dsh the bin-only package gets no link-farm proxy at
|
|
3767
|
+
* all, so the read was dead or stale. The first was reproduced with real
|
|
3768
|
+
* installs on 2026-09-25 (the design's section 9.9 amendment): every
|
|
3769
|
+
* verdict said "running 0.1.2-rc.1" while 0.1.5-rc.3 ran.
|
|
3770
|
+
* - The templates came from the shop's own `import * as appBoot`, which
|
|
3771
|
+
* resolves from the SHOP's real path: under a `link:` install that is this
|
|
3772
|
+
* repository's devDependency, 0.1.1-rc.2 with two templates, while
|
|
3773
|
+
* 0.1.5-rc.3 with five runs — and in production a copy hoisted from some
|
|
3774
|
+
* plugin's dependencies can shadow it the same way.
|
|
3775
|
+
*
|
|
3776
|
+
* Both now come from the one thing that names the running harness: the script
|
|
3777
|
+
* this process was started with. Resolved through symlinks, the package that
|
|
3778
|
+
* owns it IS the dsh CLI that is running, if it is one at all; its manifest
|
|
3779
|
+
* says its version, and the app-boot it imports says its templates.
|
|
3780
|
+
*/
|
|
3781
|
+
/** The package whose `PROFILE_TEMPLATES` the running dsh composes profiles
|
|
3782
|
+
* from. */
|
|
3783
|
+
const APP_BOOT = "@deepseek-ai/dsh-app-boot";
|
|
3784
|
+
/** What is known when nothing says which harness runs. */
|
|
3785
|
+
function noHarness() {
|
|
3786
|
+
return {
|
|
3787
|
+
dshVersion: null,
|
|
3788
|
+
templates: profileTemplatesOf(void 0),
|
|
3789
|
+
patchLists: null,
|
|
3790
|
+
peerCheck: null
|
|
3791
|
+
};
|
|
3792
|
+
}
|
|
3793
|
+
/** Whether `path` stats as a regular file. */
|
|
3794
|
+
function isFile(path) {
|
|
3795
|
+
try {
|
|
3796
|
+
return statSync(path).isFile();
|
|
3797
|
+
} catch {
|
|
3798
|
+
return false;
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
/**
|
|
3802
|
+
* The package that owns `start`: the FIRST directory at or above it holding a
|
|
3803
|
+
* `package.json` file, and its parsed manifest — or null when there is none
|
|
3804
|
+
* up to the root, or when that manifest cannot be read or parsed. The walk
|
|
3805
|
+
* stops at the first manifest whatever it names, the rule `owningEntry` in
|
|
3806
|
+
* `dsh-cli.ts` follows: a script owned by some other package is not dsh's,
|
|
3807
|
+
* and climbing on would find an unrelated ancestor — a workspace root, or dsh
|
|
3808
|
+
* itself above a package vendored inside it.
|
|
3809
|
+
*/
|
|
3810
|
+
function owningPackage(start) {
|
|
3811
|
+
for (let dir = start;;) {
|
|
3812
|
+
const manifestPath = join(dir, "package.json");
|
|
3813
|
+
if (isFile(manifestPath)) try {
|
|
3814
|
+
return {
|
|
3815
|
+
dir,
|
|
3816
|
+
manifest: JSON.parse(readFileSync(manifestPath, "utf8"))
|
|
3817
|
+
};
|
|
3818
|
+
} catch {
|
|
3819
|
+
return null;
|
|
3820
|
+
}
|
|
3821
|
+
const parent = dirname(dir);
|
|
3822
|
+
if (parent === dir) return null;
|
|
3823
|
+
dir = parent;
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
/**
|
|
3827
|
+
* What the app-boot the dsh at `dshDir` imports says about that dsh: its
|
|
3828
|
+
* template table, and whether it reads a list-valued bundle patch. Null when
|
|
3829
|
+
* no app-boot is on the resolution path.
|
|
3830
|
+
*
|
|
3831
|
+
* Found with the node_modules walk the peer check uses (`packageDirectory`)
|
|
3832
|
+
* from the dsh package directory — the copy nested under dsh first, then one
|
|
3833
|
+
* beside it, as dsh's own import finds it — and never through `NODE_PATH`:
|
|
3834
|
+
* pnpm's bin shims export one, and a bare CJS lookup finds a package through
|
|
3835
|
+
* it from any directory at all. Only the ABSOLUTE directory then reaches
|
|
3836
|
+
* Node's CJS resolver, which reads its `main` (an `exports` map applies to
|
|
3837
|
+
* bare specifiers only), and the entry is imported by file URL, which Windows
|
|
3838
|
+
* requires of an absolute path. Under the dsh that runs, that URL is the one
|
|
3839
|
+
* dsh itself loaded — the CJS resolver realpaths as the ESM loader does, and
|
|
3840
|
+
* app-boot's `main` and `exports` name the same file — so the import is a
|
|
3841
|
+
* cache hit, not a second instance. Measured against a global npm install of
|
|
3842
|
+
* 0.1.5-rc.3: this chain and the ESM resolution from dsh's own `lib/bin.js`
|
|
3843
|
+
* give the same URL, and the reader, handed `<prefix>/bin/dsh`, answers
|
|
3844
|
+
* 0.1.5-rc.3 with all five templates.
|
|
3845
|
+
*
|
|
3846
|
+
* Throws for whatever that chain cannot do; `readRunningHarness` turns any
|
|
3847
|
+
* throw into an empty table, an unknown `patchLists` and no peer check.
|
|
3848
|
+
*/
|
|
3849
|
+
async function appBootFacts(dshDir) {
|
|
3850
|
+
const appBootDir = packageDirectory(dshDir, APP_BOOT);
|
|
3851
|
+
if (appBootDir === null) return null;
|
|
3852
|
+
const entry = createRequire(import.meta.url).resolve(appBootDir);
|
|
3853
|
+
const exported = await import(pathToFileURL(entry).href);
|
|
3854
|
+
return {
|
|
3855
|
+
templates: profileTemplatesOf(exported.PROFILE_TEMPLATES),
|
|
3856
|
+
patchLists: typeof exported.bundlePatchFiles === "function",
|
|
3857
|
+
peerCheck: peerCheckOf(exported, appBootDir)
|
|
3858
|
+
};
|
|
3859
|
+
}
|
|
3860
|
+
/**
|
|
3861
|
+
* The peer check an app-boot exports, bound to its own version (see
|
|
3862
|
+
* `PeerCheck.evaluate`), or null when it exports only part of one — a rule
|
|
3863
|
+
* without the exemptions would refuse what a profile has already allowed —
|
|
3864
|
+
* or when its manifest carries no version, which is the one `evaluate`
|
|
3865
|
+
* judges by. Never throws, so a check that cannot be formed costs the
|
|
3866
|
+
* templates and the patch-list answer nothing.
|
|
3867
|
+
*/
|
|
3868
|
+
function peerCheckOf(exported, appBootDir) {
|
|
3869
|
+
const { evaluatePluginCompatibility: evaluate, readProfileVersionExemptions: exemptions } = exported;
|
|
3870
|
+
if (typeof evaluate !== "function" || typeof exemptions !== "function") return null;
|
|
3871
|
+
let version;
|
|
3872
|
+
try {
|
|
3873
|
+
version = JSON.parse(readFileSync(join(appBootDir, "package.json"), "utf8")).version;
|
|
3874
|
+
} catch {
|
|
3875
|
+
return null;
|
|
3876
|
+
}
|
|
3877
|
+
if (typeof version !== "string" || version.length === 0) return null;
|
|
3878
|
+
const runtime = version;
|
|
3879
|
+
const judge = evaluate;
|
|
3880
|
+
return {
|
|
3881
|
+
evaluate: (manifest, profileExemptions) => judge(manifest, profileExemptions, runtime),
|
|
3882
|
+
exemptions
|
|
3883
|
+
};
|
|
3884
|
+
}
|
|
3885
|
+
/**
|
|
3886
|
+
* The harness this process runs, identified from `script` — the gateway's
|
|
3887
|
+
* `restartScript`, `process.argv[1]` in production — or nothing, never a
|
|
3888
|
+
* guess:
|
|
3889
|
+
*
|
|
3890
|
+
* 1. `script` is resolved through symlinks, because `argv[1]` is the path the
|
|
3891
|
+
* shell ran — `<prefix>/bin/dsh`, a link — and only its target lies inside
|
|
3892
|
+
* the dsh package. No script, or one that does not resolve: nothing known.
|
|
3893
|
+
* An empty path is no script either: `realpathSync('')` answers the
|
|
3894
|
+
* working directory, which says where dsh was started, not what it is.
|
|
3895
|
+
* 2. The package that owns it (`owningPackage`) must be `@deepseek-ai/dsh`.
|
|
3896
|
+
* Anything else — a test runner, another host embedding the shop — means
|
|
3897
|
+
* nothing here can say which harness runs, and both halves of the verdict
|
|
3898
|
+
* stay silent.
|
|
3899
|
+
* 3. `dshVersion` is that manifest's `version` when it is a non-empty string.
|
|
3900
|
+
* 4. `templates`, `patchLists` and `peerCheck` come from that dsh's own
|
|
3901
|
+
* app-boot (`appBootFacts`). Any failure there costs all three and keeps
|
|
3902
|
+
* the version.
|
|
3903
|
+
*
|
|
3904
|
+
* Never throws and never rejects: a harness nobody could identify forms no
|
|
3905
|
+
* verdict, and must never be the reason the catalog fails to load.
|
|
3906
|
+
*/
|
|
3907
|
+
async function readRunningHarness(script) {
|
|
3908
|
+
try {
|
|
3909
|
+
if (script === void 0 || script === "") return noHarness();
|
|
3910
|
+
const owner = owningPackage(dirname(realpathSync(script)));
|
|
3911
|
+
if (owner === null || typeof owner.manifest !== "object" || owner.manifest === null) return noHarness();
|
|
3912
|
+
const { name, version } = owner.manifest;
|
|
3913
|
+
if (name !== "@deepseek-ai/dsh") return noHarness();
|
|
3914
|
+
const dshVersion = typeof version === "string" && version.length > 0 ? version : null;
|
|
3915
|
+
let facts;
|
|
3916
|
+
try {
|
|
3917
|
+
facts = await appBootFacts(owner.dir);
|
|
3918
|
+
} catch {
|
|
3919
|
+
facts = null;
|
|
3920
|
+
}
|
|
3921
|
+
return {
|
|
3922
|
+
dshVersion,
|
|
3923
|
+
templates: facts?.templates ?? profileTemplatesOf(void 0),
|
|
3924
|
+
patchLists: facts?.patchLists ?? null,
|
|
3925
|
+
peerCheck: facts?.peerCheck ?? null
|
|
3926
|
+
};
|
|
3927
|
+
} catch {
|
|
3928
|
+
return noHarness();
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
//#endregion
|
|
2977
3932
|
//#region src/host/index.ts
|
|
2978
3933
|
/** ShopGateway: the Host half of dsh-plugin-shop (§5.1). */
|
|
2979
3934
|
var __runInitializers = function(thisArg, initializers, value) {
|
|
@@ -3026,10 +3981,16 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
|
|
|
3026
3981
|
* service and to set an override that the platform check, being the first gate
|
|
3027
3982
|
* of the three, could never reach. */
|
|
3028
3983
|
const RESTART_BLOCKED_DETAIL = {
|
|
3984
|
+
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
3985
|
windows: "dsh-plugin-shop: restart is not supported on Windows yet; restart dsh manually to apply the change",
|
|
3030
3986
|
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
3987
|
"port-zero": "dsh-plugin-shop: restart is not supported when dsh was launched with --port 0; restart dsh manually"
|
|
3032
3988
|
};
|
|
3989
|
+
/** Why the shop changes nothing in the desktop profile, for every mutation
|
|
3990
|
+
* that spawns dsh's CLI: install, uninstall and self-update. dsh refuses
|
|
3991
|
+
* the profile before any of them would run (`isDesktopProfile`), and the
|
|
3992
|
+
* failure that surfaced instead read "pnpm failed in the profile". */
|
|
3993
|
+
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";
|
|
3033
3994
|
/** How many bytes a release tarball may be at the integrity check. The
|
|
3034
3995
|
* registry already refuses to publish a tarball over 32 MiB, so 64 MiB is
|
|
3035
3996
|
* headroom, not a gate of its own. */
|
|
@@ -3087,6 +4048,25 @@ async function verifyTarballSha256(fetchTarball, url, recordedSha256, maxBytes =
|
|
|
3087
4048
|
function ownDependencySpec(dependencies, name) {
|
|
3088
4049
|
return Object.hasOwn(dependencies, name) ? dependencies[name] : void 0;
|
|
3089
4050
|
}
|
|
4051
|
+
/**
|
|
4052
|
+
* Package names whose module this PROCESS may already hold in Node's module
|
|
4053
|
+
* cache (design 2026-09-26-market-borrowings §1.2).
|
|
4054
|
+
*
|
|
4055
|
+
* A hot mount can only deliver code the process has not imported yet. The hot
|
|
4056
|
+
* tree imports through the loader's `internal.import`, which is Node's own ESM
|
|
4057
|
+
* loader and caches a module by its URL, and an install rewrites a package's
|
|
4058
|
+
* files at the URL it already had (`nodeLinker: hoisted`). Once a name is in
|
|
4059
|
+
* here, a mount of it would re-run the cached module under whatever version is
|
|
4060
|
+
* now on disk.
|
|
4061
|
+
*
|
|
4062
|
+
* Module scope rather than a gateway field, because the cache it models is the
|
|
4063
|
+
* process's: a gateway rebuilt inside a running dsh — a restarted shop fiber —
|
|
4064
|
+
* must not forget what the process imported before it existed. Every gateway
|
|
4065
|
+
* seeds it with the profile's dependencies at construction (the boot
|
|
4066
|
+
* composition) and extends it with every name the hot path mounts. Nothing is
|
|
4067
|
+
* ever removed: an uninstall disposes the fiber, not the module record.
|
|
4068
|
+
*/
|
|
4069
|
+
const importedThisProcess = /* @__PURE__ */ new Set();
|
|
3090
4070
|
/** Remote-only service exposing the shop Remote methods of §7.3.
|
|
3091
4071
|
*
|
|
3092
4072
|
* @typert service shop */
|
|
@@ -3234,6 +4214,17 @@ let ShopGateway = (() => {
|
|
|
3234
4214
|
});
|
|
3235
4215
|
}
|
|
3236
4216
|
options = __runInitializers(this, _instanceExtraInitializers);
|
|
4217
|
+
/** The context this gateway was built with: the shop's own fiber. Inside an
|
|
4218
|
+
* RPC method `this.ctx` is NOT that — cordis hands a service out with `ctx`
|
|
4219
|
+
* rebound to the context that looked it up, so a call arriving over the
|
|
4220
|
+
* wire sees the caller's, the typert gateway's (measured on 0.1.5-rc.3 and
|
|
4221
|
+
* 0.1.7-rc.2). Anything whose PARENT matters is registered from here. The
|
|
4222
|
+
* hot tree above all: registered from the gateway's context it was owned by
|
|
4223
|
+
* the gateway's fiber rather than the shop's, was listed under the gateway's
|
|
4224
|
+
* loader entry, and — the part a reader saw — sat where dsh's client
|
|
4225
|
+
* registry never composed it, so a hot-installed browser half needed a
|
|
4226
|
+
* restart that a reload now does (design 2026-09-26-dsh-017-readiness). */
|
|
4227
|
+
home;
|
|
3237
4228
|
/** The profile dsh installs into; discovered from this module's own
|
|
3238
4229
|
* location when the caller does not supply one. */
|
|
3239
4230
|
profile;
|
|
@@ -3269,6 +4260,8 @@ let ShopGateway = (() => {
|
|
|
3269
4260
|
/** One pump for the whole gateway: batching is per profile and lives inside
|
|
3270
4261
|
* it, so a second instance would race the first for the same store. */
|
|
3271
4262
|
prefetcher;
|
|
4263
|
+
/** What this process may already have imported (see `importedThisProcess`). */
|
|
4264
|
+
imported;
|
|
3272
4265
|
/** The install gate runs against the last loaded snapshot, never a fresh
|
|
3273
4266
|
* fetch per request (§7.2: the Host's cached snapshot is the truth). */
|
|
3274
4267
|
/** Finished install records retained, so a poll sees the true terminal
|
|
@@ -3288,22 +4281,18 @@ let ShopGateway = (() => {
|
|
|
3288
4281
|
/** The user's own `registry=` from `~/.npmrc`, read at most once per
|
|
3289
4282
|
* gateway. A wrapper distinguishes a genuine `null` from an unread value. */
|
|
3290
4283
|
npmRegistryCache = null;
|
|
3291
|
-
/** The
|
|
3292
|
-
*
|
|
3293
|
-
*
|
|
3294
|
-
*
|
|
3295
|
-
|
|
3296
|
-
* `node_modules` for every distinct peer name again — and Node only
|
|
3297
|
-
* caches a SUCCESSFUL resolution, so a genuinely missing peer pays a full
|
|
3298
|
-
* failed walk on every single call. Recomputed only when `catalog()`
|
|
3299
|
-
* loads a snapshot that is not this exact object. */
|
|
3300
|
-
incompatibleCache = null;
|
|
4284
|
+
/** The harness this process runs, read at most once per gateway: which dsh
|
|
4285
|
+
* is running cannot change while it runs, and the read imports a module. A
|
|
4286
|
+
* promise, so concurrent first calls share one read; `readRunningHarness`
|
|
4287
|
+
* never rejects, so there is no failure to retry. */
|
|
4288
|
+
harnessRead = null;
|
|
3301
4289
|
/** Install records, running and finished; a poll finds one here or reports not found. */
|
|
3302
4290
|
installs = /* @__PURE__ */ new Map();
|
|
3303
4291
|
/** Every install id in insertion order, oldest first; finished-record eviction walks this from the front. */
|
|
3304
4292
|
installOrder = [];
|
|
3305
4293
|
constructor(ctx, options = {}) {
|
|
3306
4294
|
super(ctx, "shop");
|
|
4295
|
+
this.home = ctx;
|
|
3307
4296
|
this.options = options;
|
|
3308
4297
|
this.profile = options.profile ?? discoverProfile(fileURLToPath(import.meta.url), this.bootBaseDir()).name;
|
|
3309
4298
|
this.profileDir = options.profileDir;
|
|
@@ -3332,6 +4321,8 @@ let ShopGateway = (() => {
|
|
|
3332
4321
|
this.ppid = options.ppid ?? process.ppid;
|
|
3333
4322
|
this.fetchTarball = options.fetchTarball ?? ((url) => fetch(url));
|
|
3334
4323
|
this.prefetcher = options.prefetcher ?? createPrefetcher();
|
|
4324
|
+
this.imported = options.importedModules ?? importedThisProcess;
|
|
4325
|
+
for (const name of Object.keys(this.profileDependenciesOrNone() ?? {})) this.imported.add(name);
|
|
3335
4326
|
try {
|
|
3336
4327
|
cleanHotDir(this.profileDirResolved());
|
|
3337
4328
|
} catch {}
|
|
@@ -3347,7 +4338,7 @@ let ShopGateway = (() => {
|
|
|
3347
4338
|
try {
|
|
3348
4339
|
createPeerVersionCheck({
|
|
3349
4340
|
ranges: this.options.peerRanges ?? ownPeerRanges(),
|
|
3350
|
-
resolve: this.options.resolvePeerVersion ??
|
|
4341
|
+
resolve: this.options.resolvePeerVersion ?? this.peerVersionResolver(this.profileAnchor()),
|
|
3351
4342
|
warn: (message) => {
|
|
3352
4343
|
const logger = this.ctx.logger;
|
|
3353
4344
|
if (logger === void 0) console.warn(message);
|
|
@@ -3379,6 +4370,64 @@ let ShopGateway = (() => {
|
|
|
3379
4370
|
if (this.profileDir !== void 0) return this.profileDir;
|
|
3380
4371
|
return discoverProfile(fileURLToPath(import.meta.url), this.bootBaseDir()).dir;
|
|
3381
4372
|
}
|
|
4373
|
+
/** Where every "what can this installation's plugins import?" question
|
|
4374
|
+
* resolves from: the profile's own Loader root, which is where the harness
|
|
4375
|
+
* resolves plugins from. One definition for the peer presence check and the
|
|
4376
|
+
* load-time self-check, so neither can drift onto a different notion of
|
|
4377
|
+
* "the installation". NOT where the running harness is read from: a copy
|
|
4378
|
+
* of `@deepseek-ai/dsh` a plugin's dependencies hoist into the profile is
|
|
4379
|
+
* importable from here and is not what runs (`harness.ts`). Throws when no
|
|
4380
|
+
* profile directory can be discovered and none is given. */
|
|
4381
|
+
profileAnchor(profileDir = this.profileDirResolved()) {
|
|
4382
|
+
return pathToFileURL(join(profileDir, "cordis.yml")).href;
|
|
4383
|
+
}
|
|
4384
|
+
/** dsh's `pluginPackages` service, when the running harness provides one
|
|
4385
|
+
* (0.1.7 and later), else null. Read on each use, like `pluginInventory`.
|
|
4386
|
+
* It is how such a harness serves its own packages to plugins, which no
|
|
4387
|
+
* walk of the profile's disk can see (design
|
|
4388
|
+
* 2026-09-01-harness-compatibility §11). */
|
|
4389
|
+
harnessPackages() {
|
|
4390
|
+
const service = this.ctx.get?.("pluginPackages");
|
|
4391
|
+
return typeof service?.packageOf === "function" ? service : null;
|
|
4392
|
+
}
|
|
4393
|
+
/** dsh's `pluginManager` service, when the running harness provides one
|
|
4394
|
+
* (0.1.7 and later), else null. Read on each use, like `pluginPackages`.
|
|
4395
|
+
* It is the only way back for a bundle its Plugins page deselected: the
|
|
4396
|
+
* 0.1.7 CLI does not select an installed package again on `add`, while
|
|
4397
|
+
* 0.1.5's does (both measured 2026-09-27). */
|
|
4398
|
+
pluginManager() {
|
|
4399
|
+
const service = this.ctx.get?.("pluginManager");
|
|
4400
|
+
return typeof service?.setBundleEnabled === "function" ? service : null;
|
|
4401
|
+
}
|
|
4402
|
+
/** "Does this installation provide `spec`?", asked from `anchor`: the
|
|
4403
|
+
* harness's own resolution in front of the walk where the harness has one,
|
|
4404
|
+
* the walk alone where it has not. */
|
|
4405
|
+
peerResolver(anchor) {
|
|
4406
|
+
const walk = nodeResolver(anchor);
|
|
4407
|
+
const packages = this.harnessPackages();
|
|
4408
|
+
return packages === null ? walk : harnessPackageResolver(walk, packages, anchor);
|
|
4409
|
+
}
|
|
4410
|
+
/** `peerResolver`'s twin for the versions the load-time self-check reads. */
|
|
4411
|
+
peerVersionResolver(anchor) {
|
|
4412
|
+
const walk = nodeVersionResolver(anchor);
|
|
4413
|
+
const packages = this.harnessPackages();
|
|
4414
|
+
return packages === null ? walk : harnessPackageVersionResolver(walk, packages, anchor);
|
|
4415
|
+
}
|
|
4416
|
+
/** `profileDirResolved`, or null when no profile directory can be
|
|
4417
|
+
* discovered — for the reads that answer "cannot tell" rather than fail. */
|
|
4418
|
+
profileDirOrNone() {
|
|
4419
|
+
try {
|
|
4420
|
+
return this.profileDirResolved();
|
|
4421
|
+
} catch {
|
|
4422
|
+
return null;
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
4425
|
+
/** The harness this process runs, read once (see `harnessRead`). */
|
|
4426
|
+
runningHarness() {
|
|
4427
|
+
const read = this.options.readHarness ?? readRunningHarness;
|
|
4428
|
+
this.harnessRead ??= read(this.restartScript);
|
|
4429
|
+
return this.harnessRead;
|
|
4430
|
+
}
|
|
3382
4431
|
/** The inventory, through the wire remote: an envelope `{ ok, value }` or
|
|
3383
4432
|
* `{ ok: false, error }`. Each row is re-validated before trust (same
|
|
3384
4433
|
* discipline as the rowConfig cast). */
|
|
@@ -3403,8 +4452,9 @@ let ShopGateway = (() => {
|
|
|
3403
4452
|
* down. A disable can land while the entry's init is still in flight: the
|
|
3404
4453
|
* options flip but the finishing init brings the fiber up anyway, and a
|
|
3405
4454
|
* plain re-update no-ops on the empty diff (dsh-market themes.ts:74-93).
|
|
3406
|
-
*
|
|
3407
|
-
*
|
|
4455
|
+
* The uninstall path is the one caller: an update used to swap its running
|
|
4456
|
+
* instance for a hot mount through here, and no longer mounts at all
|
|
4457
|
+
* (design 2026-09-26-market-borrowings §1). */
|
|
3408
4458
|
/** The package's owned entry ids, or none when its bundle patch cannot be
|
|
3409
4459
|
* read. For the paths where a live disable is an optimization and the
|
|
3410
4460
|
* operation must succeed regardless; `setEnabled` reports the failure
|
|
@@ -3438,8 +4488,8 @@ let ShopGateway = (() => {
|
|
|
3438
4488
|
* Through `ownDependencySpec` for the reason spelled out there: a bare index
|
|
3439
4489
|
* read answers for `Object.prototype`, and `constructor` is a legal npm
|
|
3440
4490
|
* 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
|
-
*
|
|
4491
|
+
* weigh a function as the installed spec, and a phantom `isUpdate` would
|
|
4492
|
+
* refuse a hot mount to a package this process never imported.
|
|
3443
4493
|
*/
|
|
3444
4494
|
installedSpecOf(name) {
|
|
3445
4495
|
const dependencies = this.profileDependenciesOrNone();
|
|
@@ -3503,11 +4553,12 @@ let ShopGateway = (() => {
|
|
|
3503
4553
|
}
|
|
3504
4554
|
return allDown;
|
|
3505
4555
|
}
|
|
3506
|
-
/** Enable or disable one installed plugin, hot (§8):
|
|
3507
|
-
*
|
|
3508
|
-
*
|
|
3509
|
-
*
|
|
3510
|
-
*
|
|
4556
|
+
/** Enable or disable one installed plugin, hot (§8): the write sets the
|
|
4557
|
+
* `disabled` key of each owned entry's row in the user layer and changes
|
|
4558
|
+
* nothing else there (design 2026-09-26-market-borrowings §2) — the CLI's
|
|
4559
|
+
* watchUserPatches applies it through HMR. The shop's own row and the
|
|
4560
|
+
* framework's bundles are never toggleable: disabling the host chain would
|
|
4561
|
+
* break HMR itself. */
|
|
3511
4562
|
async setEnabled(args) {
|
|
3512
4563
|
if (args.name === "dsh-plugin-shop" || args.name.startsWith("@deepseek-ai/")) return {
|
|
3513
4564
|
ok: false,
|
|
@@ -3521,7 +4572,7 @@ let ShopGateway = (() => {
|
|
|
3521
4572
|
};
|
|
3522
4573
|
let owned;
|
|
3523
4574
|
try {
|
|
3524
|
-
owned =
|
|
4575
|
+
owned = ownedEntries({
|
|
3525
4576
|
profileDir,
|
|
3526
4577
|
packageName: args.name
|
|
3527
4578
|
});
|
|
@@ -3535,16 +4586,19 @@ let ShopGateway = (() => {
|
|
|
3535
4586
|
ok: false,
|
|
3536
4587
|
detail: `dsh-plugin-shop: ${args.name} contributes no plugin entries, so there is nothing to enable or disable`
|
|
3537
4588
|
};
|
|
3538
|
-
const
|
|
4589
|
+
const selected = this.runningProfileBundles(profileDir);
|
|
4590
|
+
if (selected !== null && !selected.includes(args.name)) return this.setDeselectedEnabled(args.name, args.enabled, profileDir, owned);
|
|
4591
|
+
const ownedSet = new Set(owned.map((entry) => entry.id));
|
|
3539
4592
|
if ((await this.listInventory()).filter((entry) => ownsEntryId(ownedSet, entry.entryId)).length === 0) return {
|
|
3540
4593
|
ok: false,
|
|
3541
4594
|
detail: `dsh-plugin-shop: ${args.name} is installed but its entries are not in the running plugin tree; restart dsh to compose them`
|
|
3542
4595
|
};
|
|
3543
4596
|
setUserLayerRows({
|
|
3544
4597
|
profileDir,
|
|
3545
|
-
rows: owned.map((id) => ({
|
|
4598
|
+
rows: owned.map(({ id, name }) => ({
|
|
3546
4599
|
id,
|
|
3547
|
-
disabled: !args.enabled
|
|
4600
|
+
disabled: !args.enabled,
|
|
4601
|
+
...name !== void 0 ? { name } : {}
|
|
3548
4602
|
}))
|
|
3549
4603
|
});
|
|
3550
4604
|
return {
|
|
@@ -3556,6 +4610,70 @@ let ShopGateway = (() => {
|
|
|
3556
4610
|
})
|
|
3557
4611
|
};
|
|
3558
4612
|
}
|
|
4613
|
+
/**
|
|
4614
|
+
* `setEnabled` for an installed package whose bundle the profile does not
|
|
4615
|
+
* select.
|
|
4616
|
+
*
|
|
4617
|
+
* Switching it off writes the plugin-level rows and nothing else: none of
|
|
4618
|
+
* it runs, and the rows keep it off if dsh selects the bundle again.
|
|
4619
|
+
* Switching it on selects the bundle through dsh's own `pluginManager`, the
|
|
4620
|
+
* writer behind the switch that deselected it, and then clears the
|
|
4621
|
+
* plugin-level rows as an ordinary enable does. A harness without that
|
|
4622
|
+
* service gets the way back in words instead. `dsh plugin add <name>` is
|
|
4623
|
+
* not printed: it selects the bundle again only on 0.1.5, and it lets pnpm
|
|
4624
|
+
* float a registry package to `latest`.
|
|
4625
|
+
*/
|
|
4626
|
+
async setDeselectedEnabled(name, enabled, profileDir, owned) {
|
|
4627
|
+
const rows = owned.map(({ id, name: moduleName }) => ({
|
|
4628
|
+
id,
|
|
4629
|
+
disabled: !enabled,
|
|
4630
|
+
...moduleName !== void 0 ? { name: moduleName } : {}
|
|
4631
|
+
}));
|
|
4632
|
+
if (!enabled) {
|
|
4633
|
+
setUserLayerRows({
|
|
4634
|
+
profileDir,
|
|
4635
|
+
rows
|
|
4636
|
+
});
|
|
4637
|
+
return {
|
|
4638
|
+
ok: true,
|
|
4639
|
+
activation: "live"
|
|
4640
|
+
};
|
|
4641
|
+
}
|
|
4642
|
+
const manager = this.pluginManager();
|
|
4643
|
+
if (manager === null) return {
|
|
4644
|
+
ok: false,
|
|
4645
|
+
detail: `dsh-plugin-shop: ${name} is installed but switched off at the bundle level: dsh.profile.bundles in ${join(profileDir, "package.json")} does not list it, so dsh composes none of it, and a restart will not change that. This dsh has no pluginManager service to select it again: add "${name}" back to that list and restart dsh, or uninstall it and install it again from the shop.`
|
|
4646
|
+
};
|
|
4647
|
+
let change;
|
|
4648
|
+
try {
|
|
4649
|
+
change = await manager.setBundleEnabled(name, true);
|
|
4650
|
+
} catch (error) {
|
|
4651
|
+
return {
|
|
4652
|
+
ok: false,
|
|
4653
|
+
detail: `dsh-plugin-shop: dsh could not select ${name} again: ${String(error)}`
|
|
4654
|
+
};
|
|
4655
|
+
}
|
|
4656
|
+
if (change.application === "failed" || change.application === "cancelled" || change.error !== void 0) return {
|
|
4657
|
+
ok: false,
|
|
4658
|
+
detail: `dsh-plugin-shop: dsh refused to select ${name} again (${typeof change.error?.code === "string" ? change.error.code : String(change.application)})${typeof change.error?.diagnostic === "string" ? `: ${change.error.diagnostic}` : ""}`
|
|
4659
|
+
};
|
|
4660
|
+
setUserLayerRows({
|
|
4661
|
+
profileDir,
|
|
4662
|
+
rows
|
|
4663
|
+
});
|
|
4664
|
+
if (change.application === "restart-required") return {
|
|
4665
|
+
ok: true,
|
|
4666
|
+
activation: "restart"
|
|
4667
|
+
};
|
|
4668
|
+
return {
|
|
4669
|
+
ok: true,
|
|
4670
|
+
activation: activationOf({
|
|
4671
|
+
hostLive: true,
|
|
4672
|
+
clientLive: true,
|
|
4673
|
+
hasClientHalf: this.packageHasClientHalf(name)
|
|
4674
|
+
})
|
|
4675
|
+
};
|
|
4676
|
+
}
|
|
3559
4677
|
rowConfig() {
|
|
3560
4678
|
if (this.options.catalogUrl !== void 0 && this.options.cacheDir !== void 0) return {
|
|
3561
4679
|
catalogUrl: this.options.catalogUrl,
|
|
@@ -3635,11 +4753,16 @@ let ShopGateway = (() => {
|
|
|
3635
4753
|
* static does. One ordered list, read by `restart()` before it commits and
|
|
3636
4754
|
* by `version()` so the client can say the same thing up front.
|
|
3637
4755
|
*
|
|
3638
|
-
* The order is
|
|
3639
|
-
*
|
|
3640
|
-
*
|
|
3641
|
-
*
|
|
4756
|
+
* The order is load-bearing for the copy a reader sees. The desktop
|
|
4757
|
+
* profile first: dsh itself refuses to launch it, so no platform,
|
|
4758
|
+
* supervisor or port could make a restart possible there. Then Windows,
|
|
4759
|
+
* because the platform check has no override and reporting the systemd one
|
|
4760
|
+
* there sends a Windows user to set `allowRestart: true`, which this gate
|
|
4761
|
+
* would still refuse.
|
|
3642
4762
|
*
|
|
4763
|
+
* - `desktop`: dsh's CLI refuses `--profile desktop` ("managed exclusively
|
|
4764
|
+
* by the Electron application"), so the relaunch would exit at once and
|
|
4765
|
+
* leave nothing on the port; the app restarts it.
|
|
3643
4766
|
* - `windows`: the handoff helper is a POSIX shell one-liner (restart.ts)
|
|
3644
4767
|
* and there is no `sh` on Windows. That spawn fails ASYNCHRONOUSLY, so
|
|
3645
4768
|
* committing would answer `ok: true`, exit this process, and leave nothing
|
|
@@ -3651,6 +4774,7 @@ let ShopGateway = (() => {
|
|
|
3651
4774
|
* - `port-zero`: the OS hands the NEW process a fresh port the browser
|
|
3652
4775
|
* cannot know, so a restart would strand the client on a dead origin. */
|
|
3653
4776
|
staticRestartBlock() {
|
|
4777
|
+
if (isDesktopProfile(this.profile)) return "desktop";
|
|
3654
4778
|
if (!this.restartPlatformSupported()) return "windows";
|
|
3655
4779
|
if (detectSupervisor(this.env, { ppid: this.ppid }) === "systemd" && !this.allowRestartConfigured()) return "systemd";
|
|
3656
4780
|
const portIndex = this.restartArgv.indexOf("--port");
|
|
@@ -3664,20 +4788,31 @@ let ShopGateway = (() => {
|
|
|
3664
4788
|
/** Browse the catalog (§7.3): cached snapshot, refreshed on demand. */
|
|
3665
4789
|
async catalog(args) {
|
|
3666
4790
|
const { snapshot, stale } = await this.loadCatalogOnce(args?.refresh ?? false);
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
4791
|
+
const harness = await this.runningHarness();
|
|
4792
|
+
const profileDir = this.profileDirOrNone();
|
|
4793
|
+
let incompatible = {};
|
|
4794
|
+
if (profileDir !== null) try {
|
|
4795
|
+
const resolve = this.options.resolvePeer ?? this.peerResolver(this.profileAnchor(profileDir));
|
|
4796
|
+
incompatible = incompatibilityMap(snapshot.entries, resolve);
|
|
4797
|
+
} catch {
|
|
4798
|
+
incompatible = {};
|
|
4799
|
+
}
|
|
4800
|
+
let incompatibleHarness;
|
|
4801
|
+
try {
|
|
4802
|
+
incompatibleHarness = compatibilityMap(snapshot.entries, {
|
|
4803
|
+
dshVersion: harness.dshVersion,
|
|
4804
|
+
profile: {
|
|
4805
|
+
name: this.profile,
|
|
4806
|
+
bundles: profileDir === null ? null : this.runningProfileBundles(profileDir)
|
|
4807
|
+
}
|
|
4808
|
+
}, harness.templates);
|
|
4809
|
+
} catch {
|
|
4810
|
+
incompatibleHarness = {};
|
|
3680
4811
|
}
|
|
4812
|
+
for (const [key, peers] of Object.entries(this.refusedByHarness(snapshot.entries, harness, profileDir))) incompatibleHarness[key] = {
|
|
4813
|
+
...incompatibleHarness[key],
|
|
4814
|
+
peers
|
|
4815
|
+
};
|
|
3681
4816
|
return {
|
|
3682
4817
|
schemaVersion: snapshot.schemaVersion,
|
|
3683
4818
|
builtAt: snapshot.builtAt,
|
|
@@ -3686,15 +4821,56 @@ let ShopGateway = (() => {
|
|
|
3686
4821
|
denied: snapshot.denied,
|
|
3687
4822
|
notAShop: snapshot.notAShop ?? [],
|
|
3688
4823
|
stars: snapshot.stars,
|
|
3689
|
-
incompatible
|
|
4824
|
+
incompatible,
|
|
4825
|
+
incompatibleHarness
|
|
3690
4826
|
};
|
|
3691
4827
|
}
|
|
3692
4828
|
/**
|
|
4829
|
+
* The installs the running dsh will refuse on their harness peers
|
|
4830
|
+
* (`peerVerdictsOf`), judged against the exemptions this profile holds —
|
|
4831
|
+
* read on every call like the other verdicts, because recording one is
|
|
4832
|
+
* exactly the event that must clear a card: the reader runs the command
|
|
4833
|
+
* the card shows, presses Refresh, and the install is allowed. Empty when
|
|
4834
|
+
* the running dsh has no such check (before 0.1.7) or no profile was
|
|
4835
|
+
* found, and when either read throws: a refusal nobody could establish is
|
|
4836
|
+
* never shown, least of all as a disabled button.
|
|
4837
|
+
*/
|
|
4838
|
+
refusedByHarness(entries, harness, profileDir) {
|
|
4839
|
+
if (harness.peerCheck === null || profileDir === null) return {};
|
|
4840
|
+
try {
|
|
4841
|
+
return peerVerdictsOf(entries, harness.peerCheck, harness.peerCheck.exemptions(profileDir), this.profile);
|
|
4842
|
+
} catch {
|
|
4843
|
+
return {};
|
|
4844
|
+
}
|
|
4845
|
+
}
|
|
4846
|
+
/**
|
|
4847
|
+
* The bundles the profile at `profileDir` composes, or null when its
|
|
4848
|
+
* manifest cannot be read — which is no verdict on the profile half rather
|
|
4849
|
+
* than an accusation. `dsh.profile.bundles` is the same list
|
|
4850
|
+
* `discoverProfile` uses to recognize a profile directory, read through the
|
|
4851
|
+
* harness's own parser so a profile this shop was booted inside is read the
|
|
4852
|
+
* way dsh reads it.
|
|
4853
|
+
*/
|
|
4854
|
+
runningProfileBundles(profileDir) {
|
|
4855
|
+
try {
|
|
4856
|
+
const bundles = readProfileManifest("dsh-plugin-shop", profileDir).dsh?.profile?.bundles;
|
|
4857
|
+
if (!Array.isArray(bundles) || !bundles.every((bundle) => typeof bundle === "string")) return null;
|
|
4858
|
+
return bundles;
|
|
4859
|
+
} catch {
|
|
4860
|
+
return null;
|
|
4861
|
+
}
|
|
4862
|
+
}
|
|
4863
|
+
/**
|
|
3693
4864
|
* Install one cataloged version into the profile (§7.2). The rejection
|
|
3694
4865
|
* paths run against this Host's snapshot before anything is spawned; only a
|
|
3695
4866
|
* passing request reaches the executor.
|
|
3696
4867
|
*/
|
|
3697
4868
|
async install(args) {
|
|
4869
|
+
if (isDesktopProfile(this.profile)) return {
|
|
4870
|
+
ok: false,
|
|
4871
|
+
code: "desktop-profile",
|
|
4872
|
+
detail: DESKTOP_PROFILE_DETAIL
|
|
4873
|
+
};
|
|
3698
4874
|
const snapshot = await this.snapshotNow();
|
|
3699
4875
|
const installedSpec = this.installedSpecOf(args.name);
|
|
3700
4876
|
const verdict = validateInstall(snapshot, args, installedSpec);
|
|
@@ -3721,9 +4897,8 @@ let ShopGateway = (() => {
|
|
|
3721
4897
|
};
|
|
3722
4898
|
spec = `github:${entry.repo}#${args.version}${entry.subdir !== void 0 ? `&path:${entry.subdir}` : ""}`;
|
|
3723
4899
|
} else spec = `${args.name}@${args.version}`;
|
|
3724
|
-
const
|
|
3725
|
-
const
|
|
3726
|
-
const priorClientHalf = isUpdate && this.packageHasClientHalf(args.name);
|
|
4900
|
+
const alreadyImported = installedSpec !== void 0 || this.imported.has(args.name);
|
|
4901
|
+
const harness = await this.runningHarness();
|
|
3727
4902
|
const running = startInstall({
|
|
3728
4903
|
profile: this.profile,
|
|
3729
4904
|
spec,
|
|
@@ -3736,29 +4911,37 @@ let ShopGateway = (() => {
|
|
|
3736
4911
|
packageName: args.name,
|
|
3737
4912
|
dependencies: Object.keys(this.profileDependenciesOrNone() ?? {})
|
|
3738
4913
|
});
|
|
3739
|
-
|
|
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
|
|
4914
|
+
const undo = ` It is on disk: run \`dsh plugin --profile ${this.profile} remove ${args.name}\` to undo this install.`;
|
|
4915
|
+
if (clash !== null) return `dsh-plugin-shop: ${args.name} declares the loader entry id "${clash.id}", which ${clash.holder} already declares. dsh refuses to load a plugin tree holding a duplicate entry id, so the profile would not start.` + undo;
|
|
4916
|
+
switch (patchDeclarationHazard(declaredBundlePatch({
|
|
4917
|
+
profileDir: this.profileDirResolved(),
|
|
4918
|
+
packageName: args.name
|
|
4919
|
+
}), harness.patchLists)) {
|
|
4920
|
+
case null: return null;
|
|
4921
|
+
case "list-unsupported": return `dsh-plugin-shop: ${args.name} lists its bundle patch as several files, which dsh reads from 0.1.7 on; this dsh${harness.dshVersion === null ? "" : ` (${harness.dshVersion})`} reads one and would not start with it installed.` + undo;
|
|
4922
|
+
case "malformed": return `dsh-plugin-shop: ${args.name} declares its bundle patch as neither a file path nor a list of them, which dsh refuses to load, so the profile would not start.` + undo;
|
|
4923
|
+
}
|
|
3741
4924
|
},
|
|
3742
4925
|
afterDone: async () => {
|
|
4926
|
+
if (alreadyImported) return {
|
|
4927
|
+
activation: "restart",
|
|
4928
|
+
restartReason: "already-loaded"
|
|
4929
|
+
};
|
|
3743
4930
|
const hot = this.hot ?? {
|
|
3744
4931
|
mount: hotMount,
|
|
3745
4932
|
unmount: hotUnmount
|
|
3746
4933
|
};
|
|
3747
|
-
|
|
3748
|
-
const result = await hot.mount({ plugin: (plugin, config) => this.
|
|
4934
|
+
this.imported.add(args.name);
|
|
4935
|
+
const result = await hot.mount({ plugin: (plugin, config) => this.home.plugin(plugin, config) }, this.profileDirResolved(), args.name);
|
|
3749
4936
|
if (!result.ok) return {
|
|
3750
4937
|
activation: "restart",
|
|
3751
4938
|
...result.reason !== null ? { restartReason: result.reason } : {}
|
|
3752
4939
|
};
|
|
3753
|
-
|
|
4940
|
+
return { activation: activationOf({
|
|
3754
4941
|
hostLive: true,
|
|
3755
|
-
clientLive:
|
|
3756
|
-
hasClientHalf:
|
|
3757
|
-
});
|
|
3758
|
-
return activation === "restart" ? {
|
|
3759
|
-
activation,
|
|
3760
|
-
restartReason: "client-half"
|
|
3761
|
-
} : { activation };
|
|
4942
|
+
clientLive: true,
|
|
4943
|
+
hasClientHalf: this.packageHasClientHalf(args.name)
|
|
4944
|
+
}) };
|
|
3762
4945
|
}
|
|
3763
4946
|
});
|
|
3764
4947
|
if (entry.source === "github") {
|
|
@@ -3844,10 +5027,13 @@ let ShopGateway = (() => {
|
|
|
3844
5027
|
for (const entry of await this.listInventory()) live.set(entry.entryId, entry.enabled);
|
|
3845
5028
|
haveInventory = true;
|
|
3846
5029
|
} catch {}
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
*
|
|
5030
|
+
const selected = this.runningProfileBundles(this.profileDirResolved());
|
|
5031
|
+
/** A package is enabled when the profile selects its bundle and every
|
|
5032
|
+
* entry it owns and that is live is enabled. Keyed by entry id, never by
|
|
5033
|
+
* module name: the entry a package inserts may mount a different
|
|
5034
|
+
* package's module (see ownedEntryIds). */
|
|
3850
5035
|
const enabledOf = (name) => {
|
|
5036
|
+
if (selected !== null && !selected.includes(name)) return false;
|
|
3851
5037
|
if (!haveInventory) return true;
|
|
3852
5038
|
let owned;
|
|
3853
5039
|
try {
|
|
@@ -3914,6 +5100,10 @@ let ShopGateway = (() => {
|
|
|
3914
5100
|
* dependencies the shop does not manage (the base bundle, the shop
|
|
3915
5101
|
* itself). The same install records/polling serve the client. */
|
|
3916
5102
|
async uninstall(args) {
|
|
5103
|
+
if (isDesktopProfile(this.profile)) return {
|
|
5104
|
+
ok: false,
|
|
5105
|
+
detail: DESKTOP_PROFILE_DETAIL
|
|
5106
|
+
};
|
|
3917
5107
|
const named = (await this.snapshotNow()).entries.filter((entry) => entry.name === args.name);
|
|
3918
5108
|
if (named.length === 0) return {
|
|
3919
5109
|
ok: false,
|
|
@@ -3975,6 +5165,7 @@ let ShopGateway = (() => {
|
|
|
3975
5165
|
ok: false,
|
|
3976
5166
|
detail: RESTART_BLOCKED_DETAIL[blocked]
|
|
3977
5167
|
};
|
|
5168
|
+
let logFile;
|
|
3978
5169
|
try {
|
|
3979
5170
|
const { cacheDir } = this.rowConfig();
|
|
3980
5171
|
const { command, args } = restartCommand({
|
|
@@ -3984,11 +5175,12 @@ let ShopGateway = (() => {
|
|
|
3984
5175
|
execArgv: process.execArgv,
|
|
3985
5176
|
script: this.restartScript
|
|
3986
5177
|
});
|
|
5178
|
+
logFile = join(cacheDir, "restart.log");
|
|
3987
5179
|
startRestart({
|
|
3988
5180
|
command,
|
|
3989
5181
|
args,
|
|
3990
5182
|
parentPid: this.restartParentPid,
|
|
3991
|
-
logFile
|
|
5183
|
+
logFile,
|
|
3992
5184
|
env: process.env
|
|
3993
5185
|
});
|
|
3994
5186
|
} catch (error) {
|
|
@@ -3998,27 +5190,57 @@ let ShopGateway = (() => {
|
|
|
3998
5190
|
};
|
|
3999
5191
|
}
|
|
4000
5192
|
setTimeout(() => this.exit(0), this.restartExitDelayMs);
|
|
4001
|
-
return {
|
|
5193
|
+
return {
|
|
5194
|
+
ok: true,
|
|
5195
|
+
logFile
|
|
5196
|
+
};
|
|
4002
5197
|
}
|
|
4003
5198
|
/** The shop's own version and whether npm has a newer one (§7.3). The
|
|
4004
5199
|
* check is advisory: a registry that cannot answer leaves `latest` null
|
|
4005
5200
|
* and the client shows the version alone. `installed` is the RUNNING
|
|
4006
5201
|
* version (own-version.ts), not the manifest's range spec. */
|
|
4007
5202
|
async version() {
|
|
4008
|
-
const installed =
|
|
5203
|
+
const installed = this.options.runningVersion ?? runningVersion();
|
|
4009
5204
|
const latest = await this.latestVersion();
|
|
4010
5205
|
return {
|
|
4011
5206
|
installed,
|
|
4012
5207
|
latest,
|
|
4013
5208
|
outdated: latest !== null && lt(installed, latest),
|
|
4014
|
-
restartBlocked: this.staticRestartBlock()
|
|
5209
|
+
restartBlocked: this.staticRestartBlock(),
|
|
5210
|
+
pendingVersion: this.pendingVersion(installed)
|
|
4015
5211
|
};
|
|
4016
5212
|
}
|
|
5213
|
+
/**
|
|
5214
|
+
* The version of the shop the profile has installed, when it is not the
|
|
5215
|
+
* one this process runs; null when they match or the copy cannot be read.
|
|
5216
|
+
*
|
|
5217
|
+
* Read from the profile's own copy — the directory `dsh plugin add` writes
|
|
5218
|
+
* and the next boot loads — rather than from the file beside this module.
|
|
5219
|
+
* For the hoisted install those are one directory, but a `link:` install
|
|
5220
|
+
* runs from its link target, which an update replaces in the profile and
|
|
5221
|
+
* never touches at the target, so reading beside the module would never see
|
|
5222
|
+
* the update land.
|
|
5223
|
+
*
|
|
5224
|
+
* Unreadable is null, never a throw: the version check is advisory, and a
|
|
5225
|
+
* missing field would tell the client this host predates it.
|
|
5226
|
+
*/
|
|
5227
|
+
pendingVersion(running) {
|
|
5228
|
+
try {
|
|
5229
|
+
const manifest = JSON.parse(readFileSync(join(this.profileDirResolved(), "node_modules", "dsh-plugin-shop", "package.json"), "utf8"));
|
|
5230
|
+
return typeof manifest.version === "string" && manifest.version !== running ? manifest.version : null;
|
|
5231
|
+
} catch {
|
|
5232
|
+
return null;
|
|
5233
|
+
}
|
|
5234
|
+
}
|
|
4017
5235
|
/** Update the shop itself to a published version (§7.3): the explicit pin
|
|
4018
5236
|
* is the only install form that bypasses pnpm's release cooldown. The
|
|
4019
5237
|
* version is re-validated as plain semver at the boundary — the spec
|
|
4020
5238
|
* `dsh-plugin-shop@<version>` is built here, never from the wire. */
|
|
4021
5239
|
async updateStart(args) {
|
|
5240
|
+
if (isDesktopProfile(this.profile)) return {
|
|
5241
|
+
ok: false,
|
|
5242
|
+
detail: DESKTOP_PROFILE_DETAIL
|
|
5243
|
+
};
|
|
4022
5244
|
if (valid(args.version) === null) return {
|
|
4023
5245
|
ok: false,
|
|
4024
5246
|
detail: `dsh-plugin-shop: ${args.version} is not a valid version`
|