dsh-plugin-shop 0.4.13 → 0.5.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/LICENSE +201 -21
- package/README.md +4 -4
- package/lib/client.js +211 -118
- package/lib/index.js +523 -49
- package/lib/typert.host.js +31 -20
- package/lib/typert.remote-client.js +22 -11
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/client/present.d.ts +11 -2
- package/lib/types/host/catalog.d.ts +5 -2
- package/lib/types/host/executor.d.ts +13 -2
- package/lib/types/host/hot.d.ts +105 -0
- package/lib/types/host/index.d.ts +80 -2
- package/lib/types/host/install.d.ts +2 -2
- package/lib/types/host/supervisor.d.ts +15 -0
- package/lib/types/host/types.d.ts +18 -1
- package/package.json +7 -2
package/lib/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
2
2
|
import { loadOptionalPatches, readProfileManifest, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
3
3
|
import { lt, minVersion, valid } from "semver";
|
|
4
|
-
import {
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
6
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
8
|
import { basename, dirname, join } from "node:path";
|
|
8
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { dump } from "js-yaml";
|
|
11
11
|
//#region src/own-version.ts
|
|
@@ -39,6 +39,7 @@ const entrySchema = z.object({
|
|
|
39
39
|
review: z.object({
|
|
40
40
|
reviewedVersion: z.string().optional(),
|
|
41
41
|
reviewedCommit: z.string().optional(),
|
|
42
|
+
reviewedSha256: z.string().optional(),
|
|
42
43
|
reviewer: z.string(),
|
|
43
44
|
reviewCommit: z.string(),
|
|
44
45
|
notes: z.string()
|
|
@@ -50,6 +51,7 @@ const entrySchema = z.object({
|
|
|
50
51
|
"ui",
|
|
51
52
|
"workflow",
|
|
52
53
|
"integration",
|
|
54
|
+
"theme",
|
|
53
55
|
"other"
|
|
54
56
|
]),
|
|
55
57
|
summary: z.object({
|
|
@@ -59,16 +61,43 @@ const entrySchema = z.object({
|
|
|
59
61
|
capabilities: z.array(z.string())
|
|
60
62
|
}).optional(),
|
|
61
63
|
source: z.enum(["npm", "github"]).default("npm"),
|
|
62
|
-
repo: z.string().optional()
|
|
64
|
+
repo: z.string().optional(),
|
|
65
|
+
subdir: z.string().regex(/^(?!.*(^|\/)\.\.?(\/|$))[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/).optional(),
|
|
66
|
+
added: z.string(),
|
|
67
|
+
tarball: z.object({
|
|
68
|
+
url: z.string(),
|
|
69
|
+
sha256: z.string()
|
|
70
|
+
}).optional()
|
|
63
71
|
});
|
|
64
72
|
const dataSchema = z.object({
|
|
65
73
|
schemaVersion: z.number(),
|
|
66
74
|
plugins: z.array(entrySchema),
|
|
67
75
|
denied: z.array(z.object({
|
|
68
76
|
name: z.string(),
|
|
69
|
-
detail: z.string()
|
|
77
|
+
detail: z.string(),
|
|
78
|
+
replacement: z.string().optional()
|
|
70
79
|
})).default([])
|
|
71
80
|
});
|
|
81
|
+
/** The tarball URL must be the entry's own GitHub release — path segments
|
|
82
|
+
* `/<owner>/<repo>/releases/...` matching the entry's `repo` (case-
|
|
83
|
+
* insensitive). A catalog row that names a trusted repo but installs an
|
|
84
|
+
* archive from somewhere else is refused loudly, never installed
|
|
85
|
+
* (dsh-market's release-binding rule, their sources.ts:16-49). */
|
|
86
|
+
function validateEntryCoherence(entries) {
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (entry.tarball === void 0) continue;
|
|
89
|
+
if (entry.source !== "github" || entry.repo === void 0) throw new Error(`catalog entry ${entry.name}: tarball requires a github entry with a repo`);
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = new URL(entry.tarball.url);
|
|
93
|
+
} catch {
|
|
94
|
+
throw new Error(`catalog entry ${entry.name}: tarball url is unparseable`);
|
|
95
|
+
}
|
|
96
|
+
if (parsed.protocol !== "https:" || parsed.hostname !== "github.com") throw new Error(`catalog entry ${entry.name}: tarball url must be https on github.com`);
|
|
97
|
+
const segments = parsed.pathname.split("/").filter((s) => s !== "");
|
|
98
|
+
if (`${segments[0] ?? ""}/${segments[1] ?? ""}`.toLowerCase() !== entry.repo.toLowerCase() || segments[2] !== "releases") throw new Error(`catalog entry ${entry.name}: tarball url is not a release of ${entry.repo}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
72
101
|
const pointerSchema = z.object({
|
|
73
102
|
schemaVersion: z.number(),
|
|
74
103
|
builtAt: z.string(),
|
|
@@ -82,7 +111,7 @@ const pointerSchema = z.object({
|
|
|
82
111
|
sha256: z.string()
|
|
83
112
|
}).optional()
|
|
84
113
|
});
|
|
85
|
-
const nodeFs = {
|
|
114
|
+
const nodeFs$1 = {
|
|
86
115
|
exists: (path) => existsSync(path),
|
|
87
116
|
read: (path) => readFileSync(path, "utf8"),
|
|
88
117
|
write: (path, data) => {
|
|
@@ -126,7 +155,7 @@ function parseStarsText(text) {
|
|
|
126
155
|
* included, degrades to no stars (spec §5).
|
|
127
156
|
*/
|
|
128
157
|
async function loadCatalog(options) {
|
|
129
|
-
const { baseUrl, cacheDir, refresh = false, fetchImpl = fetch, now = () => /* @__PURE__ */ new Date(), fsImpl = nodeFs } = options;
|
|
158
|
+
const { baseUrl, cacheDir, refresh = false, fetchImpl = fetch, now = () => /* @__PURE__ */ new Date(), fsImpl = nodeFs$1 } = options;
|
|
130
159
|
const indexPath = join(cacheDir, "index.json");
|
|
131
160
|
const metaPath = join(cacheDir, META_FILE);
|
|
132
161
|
/** The timestamp freshness is measured from: the sidecar's fetch time when
|
|
@@ -141,30 +170,33 @@ async function loadCatalog(options) {
|
|
|
141
170
|
return Number.isNaN(built) ? null : built;
|
|
142
171
|
};
|
|
143
172
|
const readCached = () => {
|
|
173
|
+
let pointer;
|
|
174
|
+
let data;
|
|
144
175
|
try {
|
|
145
|
-
|
|
146
|
-
if (pointer.schemaVersion >
|
|
176
|
+
pointer = pointerSchema.parse(JSON.parse(fsImpl.read(indexPath)));
|
|
177
|
+
if (pointer.schemaVersion > 5) throw new Error(`catalog schemaVersion ${pointer.schemaVersion} is newer than this build supports (5)`);
|
|
147
178
|
const dataPath = join(cacheDir, basename(pointer.plugins.url));
|
|
148
179
|
const dataText = fsImpl.read(dataPath);
|
|
149
180
|
const actual = createHash("sha256").update(dataText).digest("hex");
|
|
150
181
|
if (actual !== pointer.plugins.sha256) throw new Error(`cached catalog data failed integrity check: expected ${pointer.plugins.sha256}, got ${actual}`);
|
|
151
|
-
|
|
152
|
-
if (data.schemaVersion >
|
|
153
|
-
let stars = {};
|
|
154
|
-
if (pointer.stars !== void 0) try {
|
|
155
|
-
const starsText = fsImpl.read(join(cacheDir, basename(pointer.stars.url)));
|
|
156
|
-
if (createHash("sha256").update(starsText).digest("hex") === pointer.stars.sha256) stars = parseStarsText(starsText);
|
|
157
|
-
} catch {}
|
|
158
|
-
return {
|
|
159
|
-
schemaVersion: pointer.schemaVersion,
|
|
160
|
-
builtAt: pointer.builtAt,
|
|
161
|
-
entries: data.plugins,
|
|
162
|
-
denied: data.denied,
|
|
163
|
-
stars
|
|
164
|
-
};
|
|
182
|
+
data = dataSchema.parse(JSON.parse(dataText));
|
|
183
|
+
if (data.schemaVersion > 5) throw new Error(`catalog schemaVersion ${data.schemaVersion} is newer than this build supports (5)`);
|
|
165
184
|
} catch {
|
|
166
185
|
return null;
|
|
167
186
|
}
|
|
187
|
+
validateEntryCoherence(data.plugins);
|
|
188
|
+
let stars = {};
|
|
189
|
+
if (pointer.stars !== void 0) try {
|
|
190
|
+
const starsText = fsImpl.read(join(cacheDir, basename(pointer.stars.url)));
|
|
191
|
+
if (createHash("sha256").update(starsText).digest("hex") === pointer.stars.sha256) stars = parseStarsText(starsText);
|
|
192
|
+
} catch {}
|
|
193
|
+
return {
|
|
194
|
+
schemaVersion: pointer.schemaVersion,
|
|
195
|
+
builtAt: pointer.builtAt,
|
|
196
|
+
entries: data.plugins,
|
|
197
|
+
denied: data.denied,
|
|
198
|
+
stars
|
|
199
|
+
};
|
|
168
200
|
};
|
|
169
201
|
if (!refresh && fsImpl.exists(indexPath)) {
|
|
170
202
|
const cached = readCached();
|
|
@@ -190,7 +222,7 @@ async function loadCatalog(options) {
|
|
|
190
222
|
throw error;
|
|
191
223
|
}
|
|
192
224
|
const pointer = pointerSchema.parse(JSON.parse(pointerText));
|
|
193
|
-
if (pointer.schemaVersion >
|
|
225
|
+
if (pointer.schemaVersion > 5) throw new Error(`catalog schemaVersion ${pointer.schemaVersion} is newer than this build supports (5)`);
|
|
194
226
|
const dataUrl = resolveDataUrl(baseUrl, pointer.plugins.url);
|
|
195
227
|
let dataText;
|
|
196
228
|
try {
|
|
@@ -208,7 +240,8 @@ async function loadCatalog(options) {
|
|
|
208
240
|
const actual = createHash("sha256").update(dataText).digest("hex");
|
|
209
241
|
if (actual !== pointer.plugins.sha256) throw new Error(`catalog data failed integrity check: expected ${pointer.plugins.sha256}, got ${actual}`);
|
|
210
242
|
const data = dataSchema.parse(JSON.parse(dataText));
|
|
211
|
-
if (data.schemaVersion >
|
|
243
|
+
if (data.schemaVersion > 5) throw new Error(`catalog schemaVersion ${data.schemaVersion} is newer than this build supports (5)`);
|
|
244
|
+
validateEntryCoherence(data.plugins);
|
|
212
245
|
let stars = {};
|
|
213
246
|
if (pointer.stars !== void 0) try {
|
|
214
247
|
const starsResponse = await fetchImpl(resolveDataUrl(baseUrl, pointer.stars.url));
|
|
@@ -320,20 +353,30 @@ function confirmBundleRemoval(profile, home, expectedName) {
|
|
|
320
353
|
* real-install test pins DSH_HOME to a temporary directory this way.
|
|
321
354
|
* When `confirm` is given, a zero exit is checked against the profile
|
|
322
355
|
* manifest before the command reports `done` (§7.2 step 6 and its uninstall
|
|
323
|
-
* mirror).
|
|
356
|
+
* mirror). When `afterDone` is given, a zero exit that passes `confirm`
|
|
357
|
+
* withholds the terminal `done` until the callback — typically the hot-mount
|
|
358
|
+
* attempt — settles; its result sets `needsRestart` (default `true`) and
|
|
359
|
+
* `restartReason`. The client stops polling at `done`, so the hot outcome
|
|
360
|
+
* must settle before it. A throwing callback never fails the install — the
|
|
361
|
+
* package IS installed; it reports `done` with the restart fallback. */
|
|
324
362
|
function spawnPluginCli(options) {
|
|
325
|
-
const { profile, argv, dshBin, env, confirm, onStatus } = options;
|
|
363
|
+
const { profile, argv, dshBin, env, confirm, afterDone, onStatus } = options;
|
|
326
364
|
const target = argv[1];
|
|
327
365
|
if (target === void 0 || target.startsWith("-")) throw new Error(`dsh-plugin-shop: refusing to spawn with a flag-like operand: ${target ?? "(none)"}`);
|
|
328
366
|
const installId = randomUUID();
|
|
329
367
|
const log = [];
|
|
330
368
|
let logBytes = 0;
|
|
331
369
|
let state = "running";
|
|
370
|
+
let needsRestartOnDone = true;
|
|
371
|
+
let restartReason;
|
|
332
372
|
let detail;
|
|
333
373
|
const status = () => ({
|
|
334
374
|
state,
|
|
335
375
|
log: [...log],
|
|
336
|
-
...state === "done" ? {
|
|
376
|
+
...state === "done" ? {
|
|
377
|
+
needsRestart: needsRestartOnDone,
|
|
378
|
+
...restartReason !== void 0 ? { restartReason } : {}
|
|
379
|
+
} : {},
|
|
337
380
|
...detail !== void 0 ? { detail } : {}
|
|
338
381
|
});
|
|
339
382
|
const append = (line) => {
|
|
@@ -375,17 +418,24 @@ function spawnPluginCli(options) {
|
|
|
375
418
|
onStatus?.(status());
|
|
376
419
|
resolve(status());
|
|
377
420
|
});
|
|
378
|
-
child.on("close", (exitCode) => {
|
|
421
|
+
child.on("close", async (exitCode) => {
|
|
379
422
|
if (state !== "running") return;
|
|
380
423
|
if (exitCode === 0) {
|
|
381
|
-
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
424
|
+
const confirmDetail = confirm?.(env?.DSH_HOME);
|
|
425
|
+
if (confirmDetail != null) {
|
|
426
|
+
state = "failed";
|
|
427
|
+
detail = confirmDetail;
|
|
428
|
+
} else if (afterDone !== void 0) {
|
|
429
|
+
try {
|
|
430
|
+
const outcome = await afterDone(env?.DSH_HOME);
|
|
431
|
+
needsRestartOnDone = outcome?.needsRestart ?? true;
|
|
432
|
+
restartReason = outcome?.restartReason;
|
|
433
|
+
} catch {
|
|
434
|
+
needsRestartOnDone = true;
|
|
435
|
+
restartReason = "热挂载失败,重启后生效 / hot-mount failed — restart required";
|
|
387
436
|
}
|
|
388
|
-
|
|
437
|
+
state = "done";
|
|
438
|
+
} else state = "done";
|
|
389
439
|
} else {
|
|
390
440
|
state = "failed";
|
|
391
441
|
const lastLogLine = log[log.length - 1] ?? "";
|
|
@@ -400,16 +450,18 @@ function spawnPluginCli(options) {
|
|
|
400
450
|
/**
|
|
401
451
|
* Run one `dsh plugin --profile <profile> add <spec>` and track it.
|
|
402
452
|
* When `expectedName` is given, a zero exit is confirmed against the profile
|
|
403
|
-
* manifest (§7.2 step 6) before the install reports `done`.
|
|
453
|
+
* manifest (§7.2 step 6) before the install reports `done`. When `afterDone`
|
|
454
|
+
* is given, the terminal `done` waits for it to settle (§D hot mount).
|
|
404
455
|
*/
|
|
405
456
|
function startInstall(options) {
|
|
406
|
-
const { profile, spec, dshBin = "dsh", env, expectedName, onStatus } = options;
|
|
457
|
+
const { profile, spec, dshBin = "dsh", env, expectedName, afterDone, onStatus } = options;
|
|
407
458
|
return spawnPluginCli({
|
|
408
459
|
profile,
|
|
409
460
|
argv: ["add", spec],
|
|
410
461
|
dshBin,
|
|
411
462
|
env,
|
|
412
463
|
confirm: expectedName !== void 0 ? (home) => confirmBundleActivation(profile, home, expectedName) : void 0,
|
|
464
|
+
afterDone,
|
|
413
465
|
onStatus
|
|
414
466
|
});
|
|
415
467
|
}
|
|
@@ -417,20 +469,292 @@ function startInstall(options) {
|
|
|
417
469
|
* Run one `dsh plugin --profile <profile> remove <name>` and track it.
|
|
418
470
|
* When `expectedName` is given, a zero exit is confirmed against the profile
|
|
419
471
|
* manifest — the bundle must actually have LEFT `dsh.profile.bundles` — before
|
|
420
|
-
* the uninstall reports `done`.
|
|
472
|
+
* the uninstall reports `done`. When `afterDone` is given, the terminal `done`
|
|
473
|
+
* waits for it to settle (§D hot mount).
|
|
421
474
|
*/
|
|
422
475
|
function startUninstall(options) {
|
|
423
|
-
const { profile, name, dshBin = "dsh", env, expectedName, onStatus } = options;
|
|
476
|
+
const { profile, name, dshBin = "dsh", env, expectedName, afterDone, onStatus } = options;
|
|
424
477
|
return spawnPluginCli({
|
|
425
478
|
profile,
|
|
426
479
|
argv: ["remove", name],
|
|
427
480
|
dshBin,
|
|
428
481
|
env,
|
|
429
482
|
confirm: expectedName !== void 0 ? (home) => confirmBundleRemoval(profile, home, expectedName) : void 0,
|
|
483
|
+
afterDone,
|
|
430
484
|
onStatus
|
|
431
485
|
});
|
|
432
486
|
}
|
|
433
487
|
//#endregion
|
|
488
|
+
//#region src/host/hot.ts
|
|
489
|
+
/**
|
|
490
|
+
* Restart-free installs: mount a freshly installed plugin into the running
|
|
491
|
+
* composition through a shop-owned Include subtree (design 2026-08-31
|
|
492
|
+
* market-borrowings §4, mechanism ported from dsh-market's hot.ts).
|
|
493
|
+
*
|
|
494
|
+
* Durable state stays with the profile's `dsh.profile.bundles`, so the next
|
|
495
|
+
* boot loads the plugin through the normal bundle layer. The subtree exists
|
|
496
|
+
* only for this process: its input files live under `<profile>/.dsh-shop/`
|
|
497
|
+
* and are wiped on every boot, so a crash can never leave a file that
|
|
498
|
+
* collides with the bundle layer (inserting an id the bundle layer also
|
|
499
|
+
* inserts is a hard boot failure). Rows are prefixed `mkt-` for the same
|
|
500
|
+
* reason: within this session the hot entry must never share an id with a
|
|
501
|
+
* boot-layer entry, including a disabled one left behind by an update swap.
|
|
502
|
+
*
|
|
503
|
+
* The Include subclass suppresses `write()` — the loader otherwise persists
|
|
504
|
+
* tree changes back to the file it read (dsh-market hot.ts; the in-tree
|
|
505
|
+
* precedent is dsh's agent-presets PresetTree).
|
|
506
|
+
*
|
|
507
|
+
* Deliberate non-port: dsh-market's client-only shim (`mountClientOnlyDeps`
|
|
508
|
+
* and `shimNames`, which hot-mounted a package with no server-side entry by
|
|
509
|
+
* inserting a shim loader entry) is omitted. Our catalog never lists a
|
|
510
|
+
* package without `dsh.bundle`, so the shim branch is unreachable here
|
|
511
|
+
* (YAGNI). `hotMount` still distinguishes "no patch file / not
|
|
512
|
+
* hot-mountable" from "restart will fix it" through the bilingual `reason`.
|
|
513
|
+
*/
|
|
514
|
+
const nodeFs = {
|
|
515
|
+
read: (path) => readFileSync(path, "utf8"),
|
|
516
|
+
write: (path, data) => {
|
|
517
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
518
|
+
writeFileSync(path, data);
|
|
519
|
+
},
|
|
520
|
+
list: (path) => readdirSync(path)
|
|
521
|
+
};
|
|
522
|
+
/** This session's mounts, keyed by package name — the same key `hotUnmount`
|
|
523
|
+
* and the gateway's flows use. The subtree unwinds with the shop's own
|
|
524
|
+
* fiber; the map exists so a name can be disposed on demand. */
|
|
525
|
+
const hotHandles = /* @__PURE__ */ new Map();
|
|
526
|
+
/** The shop's own namespace for ephemeral mount inputs. */
|
|
527
|
+
const HOT_DIR = ".dsh-shop";
|
|
528
|
+
/** `hot-<n>.yml` files: the only things `cleanHotDir` wipes and the only
|
|
529
|
+
* files `hotMount` writes. */
|
|
530
|
+
const HOT_FILE_RE = /^hot-(\d+)\.yml$/;
|
|
531
|
+
/** Reasons, bilingual, distinguishing the P0-2 categories: a transient mount
|
|
532
|
+
* failure ("restart will fix it") versus a package that structurally cannot
|
|
533
|
+
* hot-mount. All tell the user to restart; the head names which category. */
|
|
534
|
+
const NO_PATCH_REASON = "该插件没有可热挂载的补丁文件,重启后生效 / the plugin has no patch file to hot-mount — restart required";
|
|
535
|
+
const NOT_SIMPLE_REASON = "该插件的补丁包含无法热挂载的配置,重启后生效 / the plugin's patch has config rows that cannot be hot-mounted — restart required";
|
|
536
|
+
const HOST_CANNOT_HOT_MOUNT_REASON = "当前环境不支持热挂载,重启后生效 / this harness cannot hot-mount — restart required";
|
|
537
|
+
const TIMEOUT_REASON = "热挂载超时,重启后生效 / hot-mount timed out — restart required";
|
|
538
|
+
const MOUNT_FAILED_REASON = "热挂载失败,重启后生效 / hot-mount failed — restart required";
|
|
539
|
+
const ID_LINE_RE = /^- id: (.+)$/;
|
|
540
|
+
const NAME_LINE_RE = /^ name: (.+)$/;
|
|
541
|
+
/**
|
|
542
|
+
* Parse a bundle patch into the plain insert rows a hot tree can replicate.
|
|
543
|
+
* Only `- id:` / `name:` pairs parse — a row with config, an expression
|
|
544
|
+
* name, or a dangling id returns null, and the caller falls back to restart
|
|
545
|
+
* activation. CRLF-aware: a patch authored with Windows line endings must
|
|
546
|
+
* not read as "contains config rows" (the Windows-patch regression their
|
|
547
|
+
* hot.ts documents). Pure: string in, rows out.
|
|
548
|
+
*/
|
|
549
|
+
function parseSimplePatch(patchText) {
|
|
550
|
+
const lines = patchText.split(/\r?\n/);
|
|
551
|
+
const rows = [];
|
|
552
|
+
for (let i = 0; i < lines.length; i++) {
|
|
553
|
+
const raw = lines[i] ?? "";
|
|
554
|
+
if (raw.trim() === "" || raw.trim().startsWith("#")) continue;
|
|
555
|
+
const idMatch = ID_LINE_RE.exec(raw);
|
|
556
|
+
if (idMatch === null) return null;
|
|
557
|
+
const id = idMatch[1];
|
|
558
|
+
if (id === void 0) return null;
|
|
559
|
+
let name;
|
|
560
|
+
while (i + 1 < lines.length) {
|
|
561
|
+
i++;
|
|
562
|
+
const next = lines[i] ?? "";
|
|
563
|
+
if (next === "" || next.trim().startsWith("#")) continue;
|
|
564
|
+
const nameMatch = NAME_LINE_RE.exec(next);
|
|
565
|
+
if (nameMatch === null) return null;
|
|
566
|
+
const value = nameMatch[1];
|
|
567
|
+
if (value === void 0) return null;
|
|
568
|
+
if (value.includes("!!js/expression")) return null;
|
|
569
|
+
name = value;
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
if (name === void 0) return null;
|
|
573
|
+
rows.push({
|
|
574
|
+
id,
|
|
575
|
+
name
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
return rows.length > 0 ? rows : null;
|
|
579
|
+
}
|
|
580
|
+
/** Render the accepted rows with prefixed ids — the exact input file the
|
|
581
|
+
* Include tree mounts. Only values the line scan accepted are emitted, and
|
|
582
|
+
* the output is itself a simple patch (round-trips through
|
|
583
|
+
* parseSimplePatch). */
|
|
584
|
+
function renderRows(rows, prefix) {
|
|
585
|
+
return rows.map((row) => `- id: ${prefix}${row.id}\n name: ${row.name}`).join("\n") + "\n";
|
|
586
|
+
}
|
|
587
|
+
/** The next free `hot-<n>` number: one past the highest existing file, or 1
|
|
588
|
+
* when the directory does not exist yet (the write recreates it). */
|
|
589
|
+
function nextHotNumber(fs, dir) {
|
|
590
|
+
let names;
|
|
591
|
+
try {
|
|
592
|
+
names = fs.list(dir);
|
|
593
|
+
} catch {
|
|
594
|
+
return 1;
|
|
595
|
+
}
|
|
596
|
+
let max = 0;
|
|
597
|
+
for (const name of names) {
|
|
598
|
+
const match = HOT_FILE_RE.exec(name);
|
|
599
|
+
if (match !== null && match[1] !== void 0) max = Math.max(max, Number(match[1]));
|
|
600
|
+
}
|
|
601
|
+
return max + 1;
|
|
602
|
+
}
|
|
603
|
+
/** Read the installed package's own `dsh` section to locate its bundle patch
|
|
604
|
+
* (the Include input), or null when the package or the field is absent —
|
|
605
|
+
* the "no patch file" rejection names this. */
|
|
606
|
+
function readPkgDsh(fs, packageDir) {
|
|
607
|
+
try {
|
|
608
|
+
const patch = JSON.parse(fs.read(join(packageDir, "package.json"))).dsh?.bundle?.patch;
|
|
609
|
+
return typeof patch === "string" ? { patch } : null;
|
|
610
|
+
} catch {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
/** Load the Include class through a computed dynamic import, or null when
|
|
615
|
+
* the optional peer is missing (an older harness — every path then falls
|
|
616
|
+
* back to restart activation). */
|
|
617
|
+
async function loadHotTreeClass() {
|
|
618
|
+
const specifier = "@deepseek-ai/cordis-plugin-include";
|
|
619
|
+
try {
|
|
620
|
+
return (await import(specifier)).default;
|
|
621
|
+
} catch {
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/** Wrap a tree class in a subclass that suppresses `write()` — the loader
|
|
626
|
+
* otherwise persists tree changes back to the file it read. The hot file is
|
|
627
|
+
* written once with the mkt- ids and must never be rewritten from live tree
|
|
628
|
+
* state. */
|
|
629
|
+
function suppressWrite(treeClass) {
|
|
630
|
+
const Base = treeClass;
|
|
631
|
+
return class HotTree extends Base {
|
|
632
|
+
write() {}
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Mount one installed package into the running composition through an
|
|
637
|
+
* Include subtree: read its bundle patch, replicate the simple rows under
|
|
638
|
+
* `mkt-` ids into `<profile>/.dsh-shop/hot-<n>.yml`, register the tree, and
|
|
639
|
+
* race its activation against `timeoutMs`. Success returns `ok: true` with
|
|
640
|
+
* no reason; any fallback returns `ok: false` with a bilingual reason
|
|
641
|
+
* distinguishing "restart will fix it" (timeout, activation failure,
|
|
642
|
+
* unavailable include) from "this package can never hot-mount" (no patch
|
|
643
|
+
* file, or rows the hot tree cannot replicate). The plugin is installed in
|
|
644
|
+
* the bundle layer either way, so a restart always activates it.
|
|
645
|
+
*/
|
|
646
|
+
async function hotMount(ctx, profileDir, packageName, deps = {}) {
|
|
647
|
+
const { fs = nodeFs, dir = join(profileDir, HOT_DIR), timeoutMs = Number(process.env.DSH_SHOP_HOT_MOUNT_TIMEOUT_MS) || 1e4, now = Date.now } = deps;
|
|
648
|
+
const packageDir = join(profileDir, "node_modules", packageName);
|
|
649
|
+
const dsh = readPkgDsh(fs, packageDir);
|
|
650
|
+
let patchText;
|
|
651
|
+
try {
|
|
652
|
+
patchText = fs.read(join(packageDir, dsh?.patch ?? "cordis.patch.yml"));
|
|
653
|
+
} catch {
|
|
654
|
+
ctx.logger?.warn(`hot-mount ${packageName}: no patch file to mount — restart will activate it`);
|
|
655
|
+
return {
|
|
656
|
+
ok: false,
|
|
657
|
+
reason: NO_PATCH_REASON
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
const rows = parseSimplePatch(patchText);
|
|
661
|
+
if (rows === null) {
|
|
662
|
+
ctx.logger?.warn(`hot-mount ${packageName}: patch has rows that cannot be hot-mounted — restart will activate it`);
|
|
663
|
+
return {
|
|
664
|
+
ok: false,
|
|
665
|
+
reason: NOT_SIMPLE_REASON
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
let treeClass = deps.hotTreeClass;
|
|
669
|
+
if (treeClass === void 0) treeClass = await loadHotTreeClass();
|
|
670
|
+
if (treeClass === null) {
|
|
671
|
+
ctx.logger?.warn(`hot-mount ${packageName}: the include plugin is unavailable in this harness — restart will activate it`);
|
|
672
|
+
return {
|
|
673
|
+
ok: false,
|
|
674
|
+
reason: HOST_CANNOT_HOT_MOUNT_REASON
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
const file = join(dir, `hot-${nextHotNumber(fs, dir)}.yml`);
|
|
678
|
+
fs.write(file, renderRows(rows, "mkt-"));
|
|
679
|
+
let handle;
|
|
680
|
+
try {
|
|
681
|
+
const HotTree = suppressWrite(treeClass);
|
|
682
|
+
handle = ctx.plugin(HotTree, { path: pathToFileURL(file).href });
|
|
683
|
+
} catch (error) {
|
|
684
|
+
ctx.logger?.warn(`hot-mount ${packageName}: mounting the include tree failed — restart will activate it (${String(error)})`);
|
|
685
|
+
return {
|
|
686
|
+
ok: false,
|
|
687
|
+
reason: MOUNT_FAILED_REASON
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
const previous = hotHandles.get(packageName);
|
|
691
|
+
if (previous !== void 0) {
|
|
692
|
+
try {
|
|
693
|
+
await previous.dispose();
|
|
694
|
+
} catch (error) {
|
|
695
|
+
ctx.logger?.warn(`hot-mount ${packageName}: the previous hot mount refused to dispose (${String(error)})`);
|
|
696
|
+
}
|
|
697
|
+
hotHandles.delete(packageName);
|
|
698
|
+
}
|
|
699
|
+
const outcome = await withTimeout(handle.await(), timeoutMs, now);
|
|
700
|
+
if (outcome === "settled") {
|
|
701
|
+
hotHandles.set(packageName, handle);
|
|
702
|
+
ctx.logger?.info(`hot-mounted ${packageName} (${file})`);
|
|
703
|
+
return {
|
|
704
|
+
ok: true,
|
|
705
|
+
reason: null
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
await handle.dispose();
|
|
710
|
+
} catch (error) {
|
|
711
|
+
ctx.logger?.warn(`hot-mount ${packageName}: dispose after a failed mount also failed — a restart cleans up (${String(error)})`);
|
|
712
|
+
}
|
|
713
|
+
return {
|
|
714
|
+
ok: false,
|
|
715
|
+
reason: outcome === "timeout" ? TIMEOUT_REASON : MOUNT_FAILED_REASON
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
/** Race the fiber's activation against the deadline. The remaining time is
|
|
719
|
+
* measured on the injectable clock so tests can drive the timeout
|
|
720
|
+
* deterministically; the timer is cleared when the activation wins. */
|
|
721
|
+
function withTimeout(activation, timeoutMs, now) {
|
|
722
|
+
const deadline = now() + timeoutMs;
|
|
723
|
+
let timer;
|
|
724
|
+
const timeout = new Promise((resolve) => {
|
|
725
|
+
timer = setTimeout(() => resolve("timeout"), Math.max(0, deadline - now()));
|
|
726
|
+
});
|
|
727
|
+
return Promise.race([activation.then(() => "settled", () => "failed"), timeout]).finally(() => {
|
|
728
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Dispose a package's hot mount. Returns false (without touching anything)
|
|
733
|
+
* when the name is not mounted; the handle leaves the map before the
|
|
734
|
+
* dispose so a mount can never be disposed twice, and a throwing dispose
|
|
735
|
+
* propagates — the plugin may still be live in this session.
|
|
736
|
+
*/
|
|
737
|
+
async function hotUnmount(packageName) {
|
|
738
|
+
const handle = hotHandles.get(packageName);
|
|
739
|
+
if (handle === void 0) return false;
|
|
740
|
+
hotHandles.delete(packageName);
|
|
741
|
+
await handle.dispose();
|
|
742
|
+
return true;
|
|
743
|
+
}
|
|
744
|
+
/** Wipe the session's mount inputs at host start: `hot-<n>.yml` files under
|
|
745
|
+
* `<profile>/.dsh-shop/` only — anything else in the namespace directory is
|
|
746
|
+
* left alone, and a missing directory is a no-op. */
|
|
747
|
+
function cleanHotDir(profileDir) {
|
|
748
|
+
const dir = join(profileDir, HOT_DIR);
|
|
749
|
+
let names;
|
|
750
|
+
try {
|
|
751
|
+
names = readdirSync(dir);
|
|
752
|
+
} catch {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
for (const name of names) if (HOT_FILE_RE.test(name)) rmSync(join(dir, name), { force: true });
|
|
756
|
+
}
|
|
757
|
+
//#endregion
|
|
434
758
|
//#region src/host/restart.ts
|
|
435
759
|
/** Restart executor: hand the port to a new dsh instance, two-phase.
|
|
436
760
|
*
|
|
@@ -500,6 +824,11 @@ async function fetchLatestVersion(fetchFn = fetch) {
|
|
|
500
824
|
}
|
|
501
825
|
}
|
|
502
826
|
//#endregion
|
|
827
|
+
//#region src/host/supervisor.ts
|
|
828
|
+
function detectSupervisor(env, proc) {
|
|
829
|
+
return (env.INVOCATION_ID !== void 0 || env.JOURNAL_STREAM !== void 0) && proc.ppid === 1 ? "systemd" : null;
|
|
830
|
+
}
|
|
831
|
+
//#endregion
|
|
503
832
|
//#region src/host/repo-pins.ts
|
|
504
833
|
/** Read the pins file; any irregularity degrades to an empty record. */
|
|
505
834
|
function readRepoPins(fs, path) {
|
|
@@ -632,6 +961,50 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
|
|
|
632
961
|
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
633
962
|
done = true;
|
|
634
963
|
};
|
|
964
|
+
/** How many bytes a release tarball may be at the integrity check. The
|
|
965
|
+
* registry already refuses to publish a tarball over 32 MiB, so 64 MiB is
|
|
966
|
+
* headroom, not a gate of its own. */
|
|
967
|
+
const MAX_TARBALL_BYTES = 67108864;
|
|
968
|
+
/**
|
|
969
|
+
* Fetch a release tarball and verify its sha256 against the catalog record
|
|
970
|
+
* (market borrowings §3.1). Returns a rejection detail, or null when the
|
|
971
|
+
* bytes match. The read streams through the byte cap, so an oversized or
|
|
972
|
+
* hostile body is refused without ever being buffered. Every failure — fetch
|
|
973
|
+
* throw, non-2xx, unreadable body, over-cap, hash mismatch — carries the same
|
|
974
|
+
* `tarball-integrity` code with a detail naming what happened, so the plugin
|
|
975
|
+
* author can read the cause.
|
|
976
|
+
*/
|
|
977
|
+
async function verifyTarballSha256(fetchTarball, url, recordedSha256, maxBytes = MAX_TARBALL_BYTES) {
|
|
978
|
+
let response;
|
|
979
|
+
try {
|
|
980
|
+
response = await fetchTarball(url);
|
|
981
|
+
} catch (error) {
|
|
982
|
+
return `dsh-plugin-shop: the release tarball could not be fetched (network failure: ${error.message}); refusing to install`;
|
|
983
|
+
}
|
|
984
|
+
if (!response.ok) return `dsh-plugin-shop: the release tarball could not be fetched (HTTP ${response.status}); refusing to install`;
|
|
985
|
+
if (response.body === null) return "dsh-plugin-shop: the release tarball has no readable body; refusing to install";
|
|
986
|
+
const hash = createHash("sha256");
|
|
987
|
+
let bytes = 0;
|
|
988
|
+
const reader = response.body.getReader();
|
|
989
|
+
try {
|
|
990
|
+
for (;;) {
|
|
991
|
+
const { done, value } = await reader.read();
|
|
992
|
+
if (done) break;
|
|
993
|
+
bytes += value.byteLength;
|
|
994
|
+
if (bytes > maxBytes) {
|
|
995
|
+
try {
|
|
996
|
+
await reader.cancel();
|
|
997
|
+
} catch {}
|
|
998
|
+
return `dsh-plugin-shop: the release tarball exceeds the size cap (${maxBytes} bytes); refusing to install`;
|
|
999
|
+
}
|
|
1000
|
+
hash.update(value);
|
|
1001
|
+
}
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
return `dsh-plugin-shop: the release tarball download failed (${error.message}); refusing to install`;
|
|
1004
|
+
}
|
|
1005
|
+
if (hash.digest("hex") !== recordedSha256) return "dsh-plugin-shop: the release tarball failed sha256 verification against the catalog record; refusing to install";
|
|
1006
|
+
return null;
|
|
1007
|
+
}
|
|
635
1008
|
/** Remote-only service exposing the shop Remote methods of §7.3.
|
|
636
1009
|
*
|
|
637
1010
|
* @typert service shop */
|
|
@@ -771,6 +1144,8 @@ let ShopGateway = (() => {
|
|
|
771
1144
|
profile;
|
|
772
1145
|
profileDir;
|
|
773
1146
|
inventory;
|
|
1147
|
+
hot;
|
|
1148
|
+
loaderEntriesInjected;
|
|
774
1149
|
dshBin;
|
|
775
1150
|
/** The argv `shop/restart` re-spawns: the real process argv minus node and
|
|
776
1151
|
* the CLI script path, or a test-provided substitute. */
|
|
@@ -783,6 +1158,14 @@ let ShopGateway = (() => {
|
|
|
783
1158
|
latestVersion;
|
|
784
1159
|
hasGit;
|
|
785
1160
|
pinFs;
|
|
1161
|
+
allowRestart;
|
|
1162
|
+
env;
|
|
1163
|
+
/** The parent pid `detectSupervisor` inspects; production defaults to
|
|
1164
|
+
* process.ppid (a systemd unit's main process has ppid 1). */
|
|
1165
|
+
ppid;
|
|
1166
|
+
/** The release-tarball fetch for the install-time integrity check; global
|
|
1167
|
+
* fetch in production, a fixture response in tests. */
|
|
1168
|
+
fetchTarball;
|
|
786
1169
|
/** The install gate runs against the last loaded snapshot, never a fresh
|
|
787
1170
|
* fetch per request (§7.2: the Host's cached snapshot is the truth). */
|
|
788
1171
|
/** Finished install records retained, so a poll sees the true terminal
|
|
@@ -804,6 +1187,8 @@ let ShopGateway = (() => {
|
|
|
804
1187
|
this.profile = options.profile ?? discoverProfile(fileURLToPath(import.meta.url), this.bootBaseDir()).name;
|
|
805
1188
|
this.profileDir = options.profileDir;
|
|
806
1189
|
this.inventory = options.inventory;
|
|
1190
|
+
this.hot = options.hot;
|
|
1191
|
+
this.loaderEntriesInjected = options.loaderEntries;
|
|
807
1192
|
this.dshBin = options.dshBin ?? "dsh";
|
|
808
1193
|
this.restartArgv = options.restartArgv ?? process.argv.slice(2);
|
|
809
1194
|
this.exit = options.exit ?? ((code) => process.exit(code));
|
|
@@ -819,6 +1204,13 @@ let ShopGateway = (() => {
|
|
|
819
1204
|
writeFileSync(path, data);
|
|
820
1205
|
}
|
|
821
1206
|
};
|
|
1207
|
+
this.allowRestart = options.allowRestart;
|
|
1208
|
+
this.env = options.env ?? process.env;
|
|
1209
|
+
this.ppid = options.ppid ?? process.ppid;
|
|
1210
|
+
this.fetchTarball = options.fetchTarball ?? ((url) => fetch(url));
|
|
1211
|
+
try {
|
|
1212
|
+
cleanHotDir(this.profileDirResolved());
|
|
1213
|
+
} catch {}
|
|
822
1214
|
}
|
|
823
1215
|
/** The pins file lives in the shop's own cache, next to the catalog cache. */
|
|
824
1216
|
pinsPath() {
|
|
@@ -849,10 +1241,46 @@ let ShopGateway = (() => {
|
|
|
849
1241
|
if (inventory === void 0) throw new Error("dsh-plugin-shop: pluginInventory service is not mounted");
|
|
850
1242
|
return inventory.list();
|
|
851
1243
|
}
|
|
1244
|
+
/** The Loader's boot-layer entries; a harness without the loader answers
|
|
1245
|
+
* with an empty list (there is then nothing to live-disable). */
|
|
1246
|
+
loaderEntries() {
|
|
1247
|
+
if (this.loaderEntriesInjected !== void 0) return this.loaderEntriesInjected();
|
|
1248
|
+
const loader = this.ctx.loader;
|
|
1249
|
+
return loader === void 0 ? [] : [...loader.entries()];
|
|
1250
|
+
}
|
|
1251
|
+
/** Live-disable one boot-layer entry, retrying until its fiber is actually
|
|
1252
|
+
* down. A disable can land while the entry's init is still in flight: the
|
|
1253
|
+
* options flip but the finishing init brings the fiber up anyway, and a
|
|
1254
|
+
* plain re-update no-ops on the empty diff (dsh-market themes.ts:74-93).
|
|
1255
|
+
* For an update swap this sequencing is mandatory, not defensive: two live
|
|
1256
|
+
* instances of a service-providing plugin would collide at provision. */
|
|
1257
|
+
async liveDisable(name) {
|
|
1258
|
+
let found = false;
|
|
1259
|
+
for (const entry of this.loaderEntries()) {
|
|
1260
|
+
if (entry.options.name !== name) continue;
|
|
1261
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1262
|
+
try {
|
|
1263
|
+
await entry.update({ disabled: true }, false, true);
|
|
1264
|
+
found = true;
|
|
1265
|
+
} catch {
|
|
1266
|
+
break;
|
|
1267
|
+
}
|
|
1268
|
+
if (entry.fiber === void 0) break;
|
|
1269
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
return found;
|
|
1273
|
+
}
|
|
852
1274
|
/** Enable or disable one installed plugin, hot (§8): a disable writes the
|
|
853
1275
|
* row to the user layer, an enable drops it again so the bundle default
|
|
854
|
-
* rules — the CLI's watchUserPatches applies either through HMR.
|
|
1276
|
+
* rules — the CLI's watchUserPatches applies either through HMR. The shop's
|
|
1277
|
+
* own row and the framework's bundles are never toggleable: disabling the
|
|
1278
|
+
* host chain would break HMR itself. */
|
|
855
1279
|
setEnabled(args) {
|
|
1280
|
+
if (args.name === "dsh-plugin-shop" || args.name.startsWith("@deepseek-ai/")) return {
|
|
1281
|
+
ok: false,
|
|
1282
|
+
detail: `dsh-plugin-shop: ${args.name} is part of the harness chain and cannot be toggled from the shop`
|
|
1283
|
+
};
|
|
856
1284
|
const entry = this.listInventory().find((entry) => entry.moduleName === args.name);
|
|
857
1285
|
if (entry === void 0) return {
|
|
858
1286
|
ok: false,
|
|
@@ -881,6 +1309,13 @@ let ShopGateway = (() => {
|
|
|
881
1309
|
cacheDir
|
|
882
1310
|
};
|
|
883
1311
|
}
|
|
1312
|
+
/** The explicit restart override. Only the row's `config:` sub-object is
|
|
1313
|
+
* passed to a plugin — a top-level `allowRestart:` beside `name:` would be
|
|
1314
|
+
* silently ignored by the loader (dsh-market README, #227). */
|
|
1315
|
+
allowRestartConfigured() {
|
|
1316
|
+
if (this.allowRestart !== void 0) return this.allowRestart;
|
|
1317
|
+
return ((this.ctx.loader?.entries().find((entry) => entry.options.name === "dsh-plugin-shop"))?.options.config)?.allowRestart === true;
|
|
1318
|
+
}
|
|
884
1319
|
/** Browse the catalog (§7.3): cached snapshot, refreshed on demand. */
|
|
885
1320
|
async catalog(args) {
|
|
886
1321
|
const { catalogUrl, cacheDir } = this.rowConfig();
|
|
@@ -900,7 +1335,7 @@ let ShopGateway = (() => {
|
|
|
900
1335
|
};
|
|
901
1336
|
}
|
|
902
1337
|
/**
|
|
903
|
-
* Install one cataloged version into the profile (§7.2). The
|
|
1338
|
+
* Install one cataloged version into the profile (§7.2). The rejection
|
|
904
1339
|
* paths run against this Host's snapshot before anything is spawned; only a
|
|
905
1340
|
* passing request reaches the executor.
|
|
906
1341
|
*/
|
|
@@ -926,7 +1361,15 @@ let ShopGateway = (() => {
|
|
|
926
1361
|
detail: `dsh-plugin-shop: ${args.name} is not in the catalog`
|
|
927
1362
|
};
|
|
928
1363
|
let spec;
|
|
929
|
-
if (entry.source === "github") {
|
|
1364
|
+
if (entry.source === "github" && entry.tarball !== void 0) {
|
|
1365
|
+
const integrity = await verifyTarballSha256(this.fetchTarball, entry.tarball.url, entry.tarball.sha256);
|
|
1366
|
+
if (integrity !== null) return {
|
|
1367
|
+
ok: false,
|
|
1368
|
+
code: "tarball-integrity",
|
|
1369
|
+
detail: integrity
|
|
1370
|
+
};
|
|
1371
|
+
spec = entry.tarball.url;
|
|
1372
|
+
} else if (entry.source === "github") {
|
|
930
1373
|
if (entry.repo === void 0 || !/^[0-9a-f]{40}$/.test(args.version)) return {
|
|
931
1374
|
ok: false,
|
|
932
1375
|
code: "version-mismatch",
|
|
@@ -937,13 +1380,26 @@ let ShopGateway = (() => {
|
|
|
937
1380
|
code: "git-missing",
|
|
938
1381
|
detail: "dsh-plugin-shop: git is not on PATH, which github installs require; install git and retry"
|
|
939
1382
|
};
|
|
940
|
-
spec = `github:${entry.repo}#${args.version}`;
|
|
1383
|
+
spec = `github:${entry.repo}#${args.version}${entry.subdir !== void 0 ? `&path:${entry.subdir}` : ""}`;
|
|
941
1384
|
} else spec = `${args.name}@${args.version}`;
|
|
1385
|
+
const isUpdate = (readProfileManifest("dsh-plugin-shop", this.profileDirResolved()).dependencies ?? {})[args.name] !== void 0;
|
|
942
1386
|
const running = startInstall({
|
|
943
1387
|
profile: this.profile,
|
|
944
1388
|
spec,
|
|
945
1389
|
dshBin: this.dshBin,
|
|
946
|
-
expectedName: args.name
|
|
1390
|
+
expectedName: args.name,
|
|
1391
|
+
afterDone: async () => {
|
|
1392
|
+
const hot = this.hot ?? {
|
|
1393
|
+
mount: hotMount,
|
|
1394
|
+
unmount: hotUnmount
|
|
1395
|
+
};
|
|
1396
|
+
if (isUpdate) await this.liveDisable(args.name);
|
|
1397
|
+
const result = await hot.mount({ plugin: (plugin, config) => this.ctx.plugin(plugin, config) }, this.profileDirResolved(), args.name);
|
|
1398
|
+
return result.ok ? { needsRestart: false } : {
|
|
1399
|
+
needsRestart: true,
|
|
1400
|
+
restartReason: result.reason ?? void 0
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
947
1403
|
});
|
|
948
1404
|
if (entry.source === "github") {
|
|
949
1405
|
const pins = readRepoPins(this.pinFs, this.pinsPath());
|
|
@@ -1001,6 +1457,10 @@ let ShopGateway = (() => {
|
|
|
1001
1457
|
}
|
|
1002
1458
|
const dependencies = readProfileManifest("dsh-plugin-shop", this.profileDirResolved()).dependencies ?? {};
|
|
1003
1459
|
const pins = readRepoPins(this.pinFs, this.pinsPath());
|
|
1460
|
+
let byName = /* @__PURE__ */ new Map();
|
|
1461
|
+
try {
|
|
1462
|
+
byName = new Map(this.listInventory().map((entry) => [entry.moduleName, entry.enabled]));
|
|
1463
|
+
} catch {}
|
|
1004
1464
|
const installed = [];
|
|
1005
1465
|
for (const entry of this.lastSnapshot.entries) {
|
|
1006
1466
|
const spec = dependencies[entry.name];
|
|
@@ -1011,13 +1471,15 @@ let ShopGateway = (() => {
|
|
|
1011
1471
|
name: entry.name,
|
|
1012
1472
|
installed: pin ?? spec,
|
|
1013
1473
|
latest: entry.version,
|
|
1014
|
-
outdated: pin !== void 0 && pin !== entry.version
|
|
1474
|
+
outdated: pin !== void 0 && pin !== entry.version,
|
|
1475
|
+
enabled: byName.get(entry.name) ?? true
|
|
1015
1476
|
});
|
|
1016
1477
|
} else installed.push({
|
|
1017
1478
|
name: entry.name,
|
|
1018
1479
|
installed: spec,
|
|
1019
1480
|
latest: entry.version,
|
|
1020
|
-
outdated: this.isBehind(spec, entry.version)
|
|
1481
|
+
outdated: this.isBehind(spec, entry.version),
|
|
1482
|
+
enabled: byName.get(entry.name) ?? true
|
|
1021
1483
|
});
|
|
1022
1484
|
}
|
|
1023
1485
|
return installed;
|
|
@@ -1063,7 +1525,14 @@ let ShopGateway = (() => {
|
|
|
1063
1525
|
profile: this.profile,
|
|
1064
1526
|
name: args.name,
|
|
1065
1527
|
dshBin: this.dshBin,
|
|
1066
|
-
expectedName: args.name
|
|
1528
|
+
expectedName: args.name,
|
|
1529
|
+
afterDone: async () => {
|
|
1530
|
+
await (this.hot ?? {
|
|
1531
|
+
mount: hotMount,
|
|
1532
|
+
unmount: hotUnmount
|
|
1533
|
+
}).unmount(args.name) || await this.liveDisable(args.name);
|
|
1534
|
+
return { needsRestart: false };
|
|
1535
|
+
}
|
|
1067
1536
|
});
|
|
1068
1537
|
const pins = readRepoPins(this.pinFs, this.pinsPath());
|
|
1069
1538
|
if (pins[args.name] !== void 0) {
|
|
@@ -1084,6 +1553,10 @@ let ShopGateway = (() => {
|
|
|
1084
1553
|
* response is out. The browser monitors the origin and refreshes when the
|
|
1085
1554
|
* new server answers. Refusals are issued before anything is torn down. */
|
|
1086
1555
|
async restart() {
|
|
1556
|
+
if (detectSupervisor(this.env, { ppid: this.ppid }) === "systemd" && !this.allowRestartConfigured()) return {
|
|
1557
|
+
ok: false,
|
|
1558
|
+
detail: "dsh-plugin-shop: restart is disabled because this process is a systemd service — a restart would kill the takeover helper along with the unit, and the service would not come back. Set allowRestart: true in the shop row config to override."
|
|
1559
|
+
};
|
|
1087
1560
|
const portIndex = this.restartArgv.indexOf("--port");
|
|
1088
1561
|
if (portIndex !== -1 && this.restartArgv[portIndex + 1] === "0") return {
|
|
1089
1562
|
ok: false,
|
|
@@ -1117,7 +1590,8 @@ let ShopGateway = (() => {
|
|
|
1117
1590
|
return {
|
|
1118
1591
|
installed,
|
|
1119
1592
|
latest,
|
|
1120
|
-
outdated: latest !== null && lt(installed, latest)
|
|
1593
|
+
outdated: latest !== null && lt(installed, latest),
|
|
1594
|
+
restartSupported: detectSupervisor(this.env, { ppid: this.ppid }) === null || this.allowRestartConfigured()
|
|
1121
1595
|
};
|
|
1122
1596
|
}
|
|
1123
1597
|
/** Update the shop itself to a published version (§7.3): the explicit pin
|