dsh-plugin-shop 0.4.14 → 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 +118 -51
- package/lib/index.js +506 -45
- package/lib/typert.host.js +27 -18
- package/lib/typert.remote-client.js +20 -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 +4 -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 +76 -1
- 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 +15 -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({
|
|
@@ -60,16 +62,42 @@ const entrySchema = z.object({
|
|
|
60
62
|
}).optional(),
|
|
61
63
|
source: z.enum(["npm", "github"]).default("npm"),
|
|
62
64
|
repo: z.string().optional(),
|
|
63
|
-
subdir: z.string().regex(/^(?!.*(^|\/)\.\.?(\/|$))[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/).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()
|
|
64
71
|
});
|
|
65
72
|
const dataSchema = z.object({
|
|
66
73
|
schemaVersion: z.number(),
|
|
67
74
|
plugins: z.array(entrySchema),
|
|
68
75
|
denied: z.array(z.object({
|
|
69
76
|
name: z.string(),
|
|
70
|
-
detail: z.string()
|
|
77
|
+
detail: z.string(),
|
|
78
|
+
replacement: z.string().optional()
|
|
71
79
|
})).default([])
|
|
72
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
|
+
}
|
|
73
101
|
const pointerSchema = z.object({
|
|
74
102
|
schemaVersion: z.number(),
|
|
75
103
|
builtAt: z.string(),
|
|
@@ -83,7 +111,7 @@ const pointerSchema = z.object({
|
|
|
83
111
|
sha256: z.string()
|
|
84
112
|
}).optional()
|
|
85
113
|
});
|
|
86
|
-
const nodeFs = {
|
|
114
|
+
const nodeFs$1 = {
|
|
87
115
|
exists: (path) => existsSync(path),
|
|
88
116
|
read: (path) => readFileSync(path, "utf8"),
|
|
89
117
|
write: (path, data) => {
|
|
@@ -127,7 +155,7 @@ function parseStarsText(text) {
|
|
|
127
155
|
* included, degrades to no stars (spec §5).
|
|
128
156
|
*/
|
|
129
157
|
async function loadCatalog(options) {
|
|
130
|
-
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;
|
|
131
159
|
const indexPath = join(cacheDir, "index.json");
|
|
132
160
|
const metaPath = join(cacheDir, META_FILE);
|
|
133
161
|
/** The timestamp freshness is measured from: the sidecar's fetch time when
|
|
@@ -142,30 +170,33 @@ async function loadCatalog(options) {
|
|
|
142
170
|
return Number.isNaN(built) ? null : built;
|
|
143
171
|
};
|
|
144
172
|
const readCached = () => {
|
|
173
|
+
let pointer;
|
|
174
|
+
let data;
|
|
145
175
|
try {
|
|
146
|
-
|
|
147
|
-
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)`);
|
|
148
178
|
const dataPath = join(cacheDir, basename(pointer.plugins.url));
|
|
149
179
|
const dataText = fsImpl.read(dataPath);
|
|
150
180
|
const actual = createHash("sha256").update(dataText).digest("hex");
|
|
151
181
|
if (actual !== pointer.plugins.sha256) throw new Error(`cached catalog data failed integrity check: expected ${pointer.plugins.sha256}, got ${actual}`);
|
|
152
|
-
|
|
153
|
-
if (data.schemaVersion >
|
|
154
|
-
let stars = {};
|
|
155
|
-
if (pointer.stars !== void 0) try {
|
|
156
|
-
const starsText = fsImpl.read(join(cacheDir, basename(pointer.stars.url)));
|
|
157
|
-
if (createHash("sha256").update(starsText).digest("hex") === pointer.stars.sha256) stars = parseStarsText(starsText);
|
|
158
|
-
} catch {}
|
|
159
|
-
return {
|
|
160
|
-
schemaVersion: pointer.schemaVersion,
|
|
161
|
-
builtAt: pointer.builtAt,
|
|
162
|
-
entries: data.plugins,
|
|
163
|
-
denied: data.denied,
|
|
164
|
-
stars
|
|
165
|
-
};
|
|
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)`);
|
|
166
184
|
} catch {
|
|
167
185
|
return null;
|
|
168
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
|
+
};
|
|
169
200
|
};
|
|
170
201
|
if (!refresh && fsImpl.exists(indexPath)) {
|
|
171
202
|
const cached = readCached();
|
|
@@ -191,7 +222,7 @@ async function loadCatalog(options) {
|
|
|
191
222
|
throw error;
|
|
192
223
|
}
|
|
193
224
|
const pointer = pointerSchema.parse(JSON.parse(pointerText));
|
|
194
|
-
if (pointer.schemaVersion >
|
|
225
|
+
if (pointer.schemaVersion > 5) throw new Error(`catalog schemaVersion ${pointer.schemaVersion} is newer than this build supports (5)`);
|
|
195
226
|
const dataUrl = resolveDataUrl(baseUrl, pointer.plugins.url);
|
|
196
227
|
let dataText;
|
|
197
228
|
try {
|
|
@@ -209,7 +240,8 @@ async function loadCatalog(options) {
|
|
|
209
240
|
const actual = createHash("sha256").update(dataText).digest("hex");
|
|
210
241
|
if (actual !== pointer.plugins.sha256) throw new Error(`catalog data failed integrity check: expected ${pointer.plugins.sha256}, got ${actual}`);
|
|
211
242
|
const data = dataSchema.parse(JSON.parse(dataText));
|
|
212
|
-
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);
|
|
213
245
|
let stars = {};
|
|
214
246
|
if (pointer.stars !== void 0) try {
|
|
215
247
|
const starsResponse = await fetchImpl(resolveDataUrl(baseUrl, pointer.stars.url));
|
|
@@ -321,20 +353,30 @@ function confirmBundleRemoval(profile, home, expectedName) {
|
|
|
321
353
|
* real-install test pins DSH_HOME to a temporary directory this way.
|
|
322
354
|
* When `confirm` is given, a zero exit is checked against the profile
|
|
323
355
|
* manifest before the command reports `done` (§7.2 step 6 and its uninstall
|
|
324
|
-
* 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. */
|
|
325
362
|
function spawnPluginCli(options) {
|
|
326
|
-
const { profile, argv, dshBin, env, confirm, onStatus } = options;
|
|
363
|
+
const { profile, argv, dshBin, env, confirm, afterDone, onStatus } = options;
|
|
327
364
|
const target = argv[1];
|
|
328
365
|
if (target === void 0 || target.startsWith("-")) throw new Error(`dsh-plugin-shop: refusing to spawn with a flag-like operand: ${target ?? "(none)"}`);
|
|
329
366
|
const installId = randomUUID();
|
|
330
367
|
const log = [];
|
|
331
368
|
let logBytes = 0;
|
|
332
369
|
let state = "running";
|
|
370
|
+
let needsRestartOnDone = true;
|
|
371
|
+
let restartReason;
|
|
333
372
|
let detail;
|
|
334
373
|
const status = () => ({
|
|
335
374
|
state,
|
|
336
375
|
log: [...log],
|
|
337
|
-
...state === "done" ? {
|
|
376
|
+
...state === "done" ? {
|
|
377
|
+
needsRestart: needsRestartOnDone,
|
|
378
|
+
...restartReason !== void 0 ? { restartReason } : {}
|
|
379
|
+
} : {},
|
|
338
380
|
...detail !== void 0 ? { detail } : {}
|
|
339
381
|
});
|
|
340
382
|
const append = (line) => {
|
|
@@ -376,17 +418,24 @@ function spawnPluginCli(options) {
|
|
|
376
418
|
onStatus?.(status());
|
|
377
419
|
resolve(status());
|
|
378
420
|
});
|
|
379
|
-
child.on("close", (exitCode) => {
|
|
421
|
+
child.on("close", async (exitCode) => {
|
|
380
422
|
if (state !== "running") return;
|
|
381
423
|
if (exitCode === 0) {
|
|
382
|
-
|
|
383
|
-
if (
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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";
|
|
388
436
|
}
|
|
389
|
-
|
|
437
|
+
state = "done";
|
|
438
|
+
} else state = "done";
|
|
390
439
|
} else {
|
|
391
440
|
state = "failed";
|
|
392
441
|
const lastLogLine = log[log.length - 1] ?? "";
|
|
@@ -401,16 +450,18 @@ function spawnPluginCli(options) {
|
|
|
401
450
|
/**
|
|
402
451
|
* Run one `dsh plugin --profile <profile> add <spec>` and track it.
|
|
403
452
|
* When `expectedName` is given, a zero exit is confirmed against the profile
|
|
404
|
-
* 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).
|
|
405
455
|
*/
|
|
406
456
|
function startInstall(options) {
|
|
407
|
-
const { profile, spec, dshBin = "dsh", env, expectedName, onStatus } = options;
|
|
457
|
+
const { profile, spec, dshBin = "dsh", env, expectedName, afterDone, onStatus } = options;
|
|
408
458
|
return spawnPluginCli({
|
|
409
459
|
profile,
|
|
410
460
|
argv: ["add", spec],
|
|
411
461
|
dshBin,
|
|
412
462
|
env,
|
|
413
463
|
confirm: expectedName !== void 0 ? (home) => confirmBundleActivation(profile, home, expectedName) : void 0,
|
|
464
|
+
afterDone,
|
|
414
465
|
onStatus
|
|
415
466
|
});
|
|
416
467
|
}
|
|
@@ -418,20 +469,292 @@ function startInstall(options) {
|
|
|
418
469
|
* Run one `dsh plugin --profile <profile> remove <name>` and track it.
|
|
419
470
|
* When `expectedName` is given, a zero exit is confirmed against the profile
|
|
420
471
|
* manifest — the bundle must actually have LEFT `dsh.profile.bundles` — before
|
|
421
|
-
* 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).
|
|
422
474
|
*/
|
|
423
475
|
function startUninstall(options) {
|
|
424
|
-
const { profile, name, dshBin = "dsh", env, expectedName, onStatus } = options;
|
|
476
|
+
const { profile, name, dshBin = "dsh", env, expectedName, afterDone, onStatus } = options;
|
|
425
477
|
return spawnPluginCli({
|
|
426
478
|
profile,
|
|
427
479
|
argv: ["remove", name],
|
|
428
480
|
dshBin,
|
|
429
481
|
env,
|
|
430
482
|
confirm: expectedName !== void 0 ? (home) => confirmBundleRemoval(profile, home, expectedName) : void 0,
|
|
483
|
+
afterDone,
|
|
431
484
|
onStatus
|
|
432
485
|
});
|
|
433
486
|
}
|
|
434
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
|
|
435
758
|
//#region src/host/restart.ts
|
|
436
759
|
/** Restart executor: hand the port to a new dsh instance, two-phase.
|
|
437
760
|
*
|
|
@@ -501,6 +824,11 @@ async function fetchLatestVersion(fetchFn = fetch) {
|
|
|
501
824
|
}
|
|
502
825
|
}
|
|
503
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
|
|
504
832
|
//#region src/host/repo-pins.ts
|
|
505
833
|
/** Read the pins file; any irregularity degrades to an empty record. */
|
|
506
834
|
function readRepoPins(fs, path) {
|
|
@@ -633,6 +961,50 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
|
|
|
633
961
|
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
634
962
|
done = true;
|
|
635
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
|
+
}
|
|
636
1008
|
/** Remote-only service exposing the shop Remote methods of §7.3.
|
|
637
1009
|
*
|
|
638
1010
|
* @typert service shop */
|
|
@@ -772,6 +1144,8 @@ let ShopGateway = (() => {
|
|
|
772
1144
|
profile;
|
|
773
1145
|
profileDir;
|
|
774
1146
|
inventory;
|
|
1147
|
+
hot;
|
|
1148
|
+
loaderEntriesInjected;
|
|
775
1149
|
dshBin;
|
|
776
1150
|
/** The argv `shop/restart` re-spawns: the real process argv minus node and
|
|
777
1151
|
* the CLI script path, or a test-provided substitute. */
|
|
@@ -784,6 +1158,14 @@ let ShopGateway = (() => {
|
|
|
784
1158
|
latestVersion;
|
|
785
1159
|
hasGit;
|
|
786
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;
|
|
787
1169
|
/** The install gate runs against the last loaded snapshot, never a fresh
|
|
788
1170
|
* fetch per request (§7.2: the Host's cached snapshot is the truth). */
|
|
789
1171
|
/** Finished install records retained, so a poll sees the true terminal
|
|
@@ -805,6 +1187,8 @@ let ShopGateway = (() => {
|
|
|
805
1187
|
this.profile = options.profile ?? discoverProfile(fileURLToPath(import.meta.url), this.bootBaseDir()).name;
|
|
806
1188
|
this.profileDir = options.profileDir;
|
|
807
1189
|
this.inventory = options.inventory;
|
|
1190
|
+
this.hot = options.hot;
|
|
1191
|
+
this.loaderEntriesInjected = options.loaderEntries;
|
|
808
1192
|
this.dshBin = options.dshBin ?? "dsh";
|
|
809
1193
|
this.restartArgv = options.restartArgv ?? process.argv.slice(2);
|
|
810
1194
|
this.exit = options.exit ?? ((code) => process.exit(code));
|
|
@@ -820,6 +1204,13 @@ let ShopGateway = (() => {
|
|
|
820
1204
|
writeFileSync(path, data);
|
|
821
1205
|
}
|
|
822
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 {}
|
|
823
1214
|
}
|
|
824
1215
|
/** The pins file lives in the shop's own cache, next to the catalog cache. */
|
|
825
1216
|
pinsPath() {
|
|
@@ -850,6 +1241,36 @@ let ShopGateway = (() => {
|
|
|
850
1241
|
if (inventory === void 0) throw new Error("dsh-plugin-shop: pluginInventory service is not mounted");
|
|
851
1242
|
return inventory.list();
|
|
852
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
|
+
}
|
|
853
1274
|
/** Enable or disable one installed plugin, hot (§8): a disable writes the
|
|
854
1275
|
* row to the user layer, an enable drops it again so the bundle default
|
|
855
1276
|
* rules — the CLI's watchUserPatches applies either through HMR. The shop's
|
|
@@ -888,6 +1309,13 @@ let ShopGateway = (() => {
|
|
|
888
1309
|
cacheDir
|
|
889
1310
|
};
|
|
890
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
|
+
}
|
|
891
1319
|
/** Browse the catalog (§7.3): cached snapshot, refreshed on demand. */
|
|
892
1320
|
async catalog(args) {
|
|
893
1321
|
const { catalogUrl, cacheDir } = this.rowConfig();
|
|
@@ -907,7 +1335,7 @@ let ShopGateway = (() => {
|
|
|
907
1335
|
};
|
|
908
1336
|
}
|
|
909
1337
|
/**
|
|
910
|
-
* Install one cataloged version into the profile (§7.2). The
|
|
1338
|
+
* Install one cataloged version into the profile (§7.2). The rejection
|
|
911
1339
|
* paths run against this Host's snapshot before anything is spawned; only a
|
|
912
1340
|
* passing request reaches the executor.
|
|
913
1341
|
*/
|
|
@@ -933,7 +1361,15 @@ let ShopGateway = (() => {
|
|
|
933
1361
|
detail: `dsh-plugin-shop: ${args.name} is not in the catalog`
|
|
934
1362
|
};
|
|
935
1363
|
let spec;
|
|
936
|
-
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") {
|
|
937
1373
|
if (entry.repo === void 0 || !/^[0-9a-f]{40}$/.test(args.version)) return {
|
|
938
1374
|
ok: false,
|
|
939
1375
|
code: "version-mismatch",
|
|
@@ -946,11 +1382,24 @@ let ShopGateway = (() => {
|
|
|
946
1382
|
};
|
|
947
1383
|
spec = `github:${entry.repo}#${args.version}${entry.subdir !== void 0 ? `&path:${entry.subdir}` : ""}`;
|
|
948
1384
|
} else spec = `${args.name}@${args.version}`;
|
|
1385
|
+
const isUpdate = (readProfileManifest("dsh-plugin-shop", this.profileDirResolved()).dependencies ?? {})[args.name] !== void 0;
|
|
949
1386
|
const running = startInstall({
|
|
950
1387
|
profile: this.profile,
|
|
951
1388
|
spec,
|
|
952
1389
|
dshBin: this.dshBin,
|
|
953
|
-
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
|
+
}
|
|
954
1403
|
});
|
|
955
1404
|
if (entry.source === "github") {
|
|
956
1405
|
const pins = readRepoPins(this.pinFs, this.pinsPath());
|
|
@@ -1076,7 +1525,14 @@ let ShopGateway = (() => {
|
|
|
1076
1525
|
profile: this.profile,
|
|
1077
1526
|
name: args.name,
|
|
1078
1527
|
dshBin: this.dshBin,
|
|
1079
|
-
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
|
+
}
|
|
1080
1536
|
});
|
|
1081
1537
|
const pins = readRepoPins(this.pinFs, this.pinsPath());
|
|
1082
1538
|
if (pins[args.name] !== void 0) {
|
|
@@ -1097,6 +1553,10 @@ let ShopGateway = (() => {
|
|
|
1097
1553
|
* response is out. The browser monitors the origin and refreshes when the
|
|
1098
1554
|
* new server answers. Refusals are issued before anything is torn down. */
|
|
1099
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
|
+
};
|
|
1100
1560
|
const portIndex = this.restartArgv.indexOf("--port");
|
|
1101
1561
|
if (portIndex !== -1 && this.restartArgv[portIndex + 1] === "0") return {
|
|
1102
1562
|
ok: false,
|
|
@@ -1130,7 +1590,8 @@ let ShopGateway = (() => {
|
|
|
1130
1590
|
return {
|
|
1131
1591
|
installed,
|
|
1132
1592
|
latest,
|
|
1133
|
-
outdated: latest !== null && lt(installed, latest)
|
|
1593
|
+
outdated: latest !== null && lt(installed, latest),
|
|
1594
|
+
restartSupported: detectSupervisor(this.env, { ppid: this.ppid }) === null || this.allowRestartConfigured()
|
|
1134
1595
|
};
|
|
1135
1596
|
}
|
|
1136
1597
|
/** Update the shop itself to a published version (§7.3): the explicit pin
|