@deepseek-ai/dsh-plugin-manager 0.1.7-rc.1 → 0.1.7-rc.2
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.i18n.yaml +54 -5
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/index.js +90 -22
- package/lib/types/operations.js +68 -7
- package/lib/types/run-tree.d.ts +7 -0
- package/lib/types/run-tree.js +20 -14
- package/package.json +28 -28
package/README.i18n.yaml
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
|
-
# Bilingual-pair consistency record (docs/i18n/README.md):
|
|
2
|
-
#
|
|
3
|
-
#
|
|
1
|
+
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
|
|
2
|
+
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
|
3
|
+
# After editing either side, bring the other along and re-record with:
|
|
4
4
|
# pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
/:
|
|
6
|
+
en: 65283627208ef4c9
|
|
7
|
+
zh: aa3f833ec24bb65a
|
|
8
|
+
/deepseek-ai-dsh-plugin-manager:
|
|
9
|
+
en: e6cbfe169f5fb57b
|
|
10
|
+
zh: be378036d645a242
|
|
11
|
+
/deepseek-ai-dsh-plugin-manager/summary:
|
|
12
|
+
en: d6afcaa1594efcdb
|
|
13
|
+
zh: 459cb6ac0dcb2d46
|
|
14
|
+
/deepseek-ai-dsh-plugin-manager/table-of-contents:
|
|
15
|
+
en: 87679c51aa3693f5
|
|
16
|
+
zh: 11684a48a5806afb
|
|
17
|
+
/deepseek-ai-dsh-plugin-manager/use-this-package:
|
|
18
|
+
en: b8d7061ee289b2a0
|
|
19
|
+
zh: 49aba7df4761fb23
|
|
20
|
+
/deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions:
|
|
21
|
+
en: 920c0acb3b9feaf3
|
|
22
|
+
zh: ba6e832a256880a7
|
|
23
|
+
/deepseek-ai-dsh-plugin-manager/use-this-package/configuration:
|
|
24
|
+
en: d5643a06f300c30a
|
|
25
|
+
zh: 9348b6a7676a2849
|
|
26
|
+
/deepseek-ai-dsh-plugin-manager/understand-the-implementation:
|
|
27
|
+
en: 1c737c375aeac163
|
|
28
|
+
zh: 009d7f139a749c79
|
|
29
|
+
/deepseek-ai-dsh-plugin-manager/further-exploration:
|
|
30
|
+
en: c6538243d8702a0d
|
|
31
|
+
zh: 04a82d923353c75f
|
|
32
|
+
/deepseek-ai-dsh-plugin-manager/model-experience:
|
|
33
|
+
en: 215e7ba838619b7b
|
|
34
|
+
zh: a311da3843709f90
|
|
35
|
+
/deepseek-ai-dsh-plugin-manager/model-experience/management-tool:
|
|
36
|
+
en: 1815ec6ce56c80d4
|
|
37
|
+
zh: 2d7ac75d3b25eb6b
|
|
38
|
+
/deepseek-ai-dsh-plugin-manager/model-experience/management-tool/what-the-model-sees:
|
|
39
|
+
en: 72d6c70869b12f22
|
|
40
|
+
zh: 45218d6f1e765fb1
|
|
41
|
+
/deepseek-ai-dsh-plugin-manager/model-experience/management-tool/token-effect:
|
|
42
|
+
en: bc70208bd361dbcd
|
|
43
|
+
zh: bb651d6066066c6c
|
|
44
|
+
/deepseek-ai-dsh-plugin-manager/model-experience/management-tool/kv-cache-effect:
|
|
45
|
+
en: c29f60be7f3edc1a
|
|
46
|
+
zh: d9b9388b96ef95cc
|
|
47
|
+
/deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work:
|
|
48
|
+
en: fde2d9491fe5dd4f
|
|
49
|
+
zh: ef6e6db15c4f19d2
|
|
50
|
+
/deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/failure-behavior:
|
|
51
|
+
en: 61450cf6e03c2819
|
|
52
|
+
zh: 4d52f5306825787b
|
|
53
|
+
/deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/dev-note:
|
|
54
|
+
en: e67f633dc6fe7773
|
|
55
|
+
zh: 3bb0c20c93f16f98
|
package/README.md
CHANGED
|
@@ -89,7 +89,7 @@ The CLI exposes `dsh plugin --profile <profile> version-exemptions`, `allow-vers
|
|
|
89
89
|
<details>
|
|
90
90
|
<summary>Implementation internals — click to expand</summary>
|
|
91
91
|
|
|
92
|
-
The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Dependency-only changes do not trigger configuration reloads.
|
|
92
|
+
The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Each operation records the pnpm run it starts in `.plugin-manager/run.json` and removes the record when the run ends. A lock whose holder process exited is taken over by the next writer, but that process's pnpm tree can still be running, so an operation that finds a record waits up to five seconds for the recorded run to stop and otherwise fails with a diagnostic that names the process and the record, without running pnpm. Dependency-only changes do not trigger configuration reloads.
|
|
93
93
|
|
|
94
94
|
Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. A failed or cancelled installation restores the manifest and lockfile it snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); a failed removal retains its partial changes and diagnostics. Installations are tracked by request id until their call settles, so a cancellation names one run and joins its settlement without taking the profile lock. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output. No invariant companion is published because the manager reads files and Loader state directly and owns no independent state projection.
|
|
95
95
|
|
package/README.zh.md
CHANGED
|
@@ -89,7 +89,7 @@ CLI 提供 `dsh plugin --profile <profile> version-exemptions`、`allow-version
|
|
|
89
89
|
<details>
|
|
90
90
|
<summary>实现细节——点击展开</summary>
|
|
91
91
|
|
|
92
|
-
服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981
|
|
92
|
+
服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。每个操作把它启动的 pnpm 运行记录在 `.plugin-manager/run.json` 中,并在运行结束时删除该记录。持有者进程已退出的锁会被下一个写入方接管,但该进程的 pnpm 进程树可能仍在运行,因此发现记录的操作最多等待五秒让记录中的运行停止,否则不运行 pnpm,并以指明该进程与记录文件的诊断失败。仅依赖字段变化不会触发配置重载。
|
|
93
93
|
|
|
94
94
|
结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。失败或被取消的安装会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));失败的删除保留部分改动和诊断。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。
|
|
95
95
|
|
package/lib/index.js
CHANGED
|
@@ -140,29 +140,35 @@ function targetOf(pid, grouped, platform) {
|
|
|
140
140
|
return platform !== "win32" && grouped ? -pid : pid;
|
|
141
141
|
}
|
|
142
142
|
/**
|
|
143
|
-
*
|
|
144
|
-
* unlocks the profile only after the scripts it started stopped writing.
|
|
143
|
+
* Whether any member of a run's tree is still alive.
|
|
145
144
|
* @param tree The run's process id and whether it leads its own group.
|
|
146
145
|
* @param internals Injectable process operations.
|
|
147
|
-
* @returns
|
|
146
|
+
* @returns True while the probe finds the process or a group member.
|
|
148
147
|
*/
|
|
149
|
-
|
|
148
|
+
function treeAlive(tree, internals = {}) {
|
|
150
149
|
const pid = tree.pid;
|
|
151
|
-
if (pid === void 0) return;
|
|
150
|
+
if (pid === void 0) return false;
|
|
152
151
|
const platform = internals.platform ?? process.platform;
|
|
153
152
|
const alive = internals.alive ?? ((target) => {
|
|
154
153
|
process.kill(target, 0);
|
|
155
154
|
return true;
|
|
156
155
|
});
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
156
|
+
try {
|
|
157
|
+
return alive(targetOf(pid, tree.grouped, platform));
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Wait until a terminated run's tree is gone, so the caller restores and
|
|
164
|
+
* unlocks the profile only after the scripts it started stopped writing.
|
|
165
|
+
* @param tree The run's process id and whether it leads its own group.
|
|
166
|
+
* @param internals Injectable process operations.
|
|
167
|
+
* @returns Fulfillment once no member remains, or the wait bound elapsed.
|
|
168
|
+
*/
|
|
169
|
+
async function awaitTreeGone(tree, internals = {}) {
|
|
164
170
|
const deadline = Date.now() + (internals.waitMs ?? TREE_WAIT_MS);
|
|
165
|
-
while (
|
|
171
|
+
while (treeAlive(tree, internals)) {
|
|
166
172
|
if (Date.now() >= deadline) return;
|
|
167
173
|
await new Promise((resolve) => setTimeout(resolve, TREE_POLL_MS));
|
|
168
174
|
}
|
|
@@ -347,6 +353,44 @@ function optionalFile(path) {
|
|
|
347
353
|
throw error;
|
|
348
354
|
}
|
|
349
355
|
}
|
|
356
|
+
/** Where an operation records the pnpm run it started, for a successor when the operation's own process ends first. */
|
|
357
|
+
function runRecordPath(dir) {
|
|
358
|
+
return join(dir, ".plugin-manager", "run.json");
|
|
359
|
+
}
|
|
360
|
+
/** Record a started run; a successor that takes over the profile lock from an exited process waits for it. */
|
|
361
|
+
async function recordRun(dir, tree) {
|
|
362
|
+
if (tree.pid === void 0) return;
|
|
363
|
+
await writeFileAtomic(runRecordPath(dir), `${JSON.stringify(tree)}\n`, {
|
|
364
|
+
mode: 384,
|
|
365
|
+
dirMode: 448
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Why this operation must not run: a record left by an operation whose process
|
|
370
|
+
* ended mid-run names a run that is still writing the profile. The profile
|
|
371
|
+
* lock is taken over once its holder exits, but its pnpm tree can outlive it.
|
|
372
|
+
* A recorded run that stopped, within a bounded wait, has its record removed.
|
|
373
|
+
* @param dir Profile directory.
|
|
374
|
+
* @returns The diagnostic, or undefined when no recorded run is active.
|
|
375
|
+
*/
|
|
376
|
+
async function activeRecordedRun(dir) {
|
|
377
|
+
const path = runRecordPath(dir);
|
|
378
|
+
const text = optionalFile(path);
|
|
379
|
+
if (text === void 0) return void 0;
|
|
380
|
+
let tree;
|
|
381
|
+
try {
|
|
382
|
+
const value = JSON.parse(text);
|
|
383
|
+
const { pid, grouped } = typeof value === "object" && value !== null ? value : {};
|
|
384
|
+
if (Number.isSafeInteger(pid) && pid > 0 && typeof grouped === "boolean") tree = {
|
|
385
|
+
pid,
|
|
386
|
+
grouped
|
|
387
|
+
};
|
|
388
|
+
} catch (error) {}
|
|
389
|
+
if (tree === void 0) return `dsh: ${path} does not name a package run; delete it once no earlier package operation is still running in this profile\n`;
|
|
390
|
+
await awaitTreeGone(tree);
|
|
391
|
+
if (treeAlive(tree)) return `dsh: process ${String(tree.pid)}, started by an earlier package operation whose own process ended, is still running in this profile; wait for it or stop it, then retry. If process ${String(tree.pid)} is not that package run, delete ${path}.\n`;
|
|
392
|
+
await rm(path, { force: true });
|
|
393
|
+
}
|
|
350
394
|
/** pnpm can install plugins through any direct-dependency field. */
|
|
351
395
|
function directDependencies(manifest) {
|
|
352
396
|
const extra = manifest;
|
|
@@ -393,13 +437,7 @@ function bundleComponentManifests(manifest, dir, anchor) {
|
|
|
393
437
|
*/
|
|
394
438
|
async function runProfilePnpm(context, args, options) {
|
|
395
439
|
const dir = context.dir ?? resolveProfileDir(context.profile, context.home);
|
|
396
|
-
const
|
|
397
|
-
const savedFiles = ["package.json", "pnpm-lock.yaml"].map((name) => ({
|
|
398
|
-
path: join(dir, name),
|
|
399
|
-
text: optionalFile(join(dir, name))
|
|
400
|
-
}));
|
|
401
|
-
const beforeDependencies = directDependencies(before);
|
|
402
|
-
const installedBefore = new Map(Object.keys(beforeDependencies).map((name) => [name, optionalFile(join(dir, "node_modules", name, "package.json"))]));
|
|
440
|
+
const active = await activeRecordedRun(dir);
|
|
403
441
|
const logRoot = join(dir, ".plugin-manager", "logs");
|
|
404
442
|
await mkdir(logRoot, {
|
|
405
443
|
recursive: true,
|
|
@@ -407,6 +445,25 @@ async function runProfilePnpm(context, args, options) {
|
|
|
407
445
|
});
|
|
408
446
|
const logPath = join(await mkdtemp(join(logRoot, "operation-")), "pnpm.log");
|
|
409
447
|
const log = await open(logPath, "wx", 384);
|
|
448
|
+
if (active !== void 0) {
|
|
449
|
+
await log.write(active);
|
|
450
|
+
await log.close();
|
|
451
|
+
options.onOutput?.(active, "stderr");
|
|
452
|
+
const bytes = Buffer.from(active);
|
|
453
|
+
return {
|
|
454
|
+
exitCode: 1,
|
|
455
|
+
output: bytes.subarray(Math.max(0, bytes.length - options.outputBytes)).toString("utf8"),
|
|
456
|
+
truncated: bytes.length > options.outputBytes,
|
|
457
|
+
logPath
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
const before = readProfileManifest("dsh", dir);
|
|
461
|
+
const savedFiles = ["package.json", "pnpm-lock.yaml"].map((name) => ({
|
|
462
|
+
path: join(dir, name),
|
|
463
|
+
text: optionalFile(join(dir, name))
|
|
464
|
+
}));
|
|
465
|
+
const beforeDependencies = directDependencies(before);
|
|
466
|
+
const installedBefore = new Map(Object.keys(beforeDependencies).map((name) => [name, optionalFile(join(dir, "node_modules", name, "package.json"))]));
|
|
410
467
|
let output = Buffer.alloc(0);
|
|
411
468
|
let truncated = false;
|
|
412
469
|
const append = (bytes) => {
|
|
@@ -469,6 +526,7 @@ async function runProfilePnpm(context, args, options) {
|
|
|
469
526
|
stdin: options.execution === "cli" ? "inherit" : "ignore",
|
|
470
527
|
cancelSignal: options.signal === void 0 ? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal])
|
|
471
528
|
});
|
|
529
|
+
const exited = once(child.nodeChildProcess, "exit").catch(() => void 0);
|
|
472
530
|
let writes = Promise.resolve();
|
|
473
531
|
/** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */
|
|
474
532
|
const control = {
|
|
@@ -514,8 +572,12 @@ async function runProfilePnpm(context, args, options) {
|
|
|
514
572
|
if (collectors.length > 0) armIdle();
|
|
515
573
|
let exitCode;
|
|
516
574
|
try {
|
|
575
|
+
await recordRun(dir, {
|
|
576
|
+
pid: child.pid,
|
|
577
|
+
grouped
|
|
578
|
+
});
|
|
517
579
|
const settled = Promise.allSettled([child]);
|
|
518
|
-
await Promise.race([
|
|
580
|
+
await Promise.race([exited, settled]);
|
|
519
581
|
control.settled = true;
|
|
520
582
|
clearTimeout(idleTimer);
|
|
521
583
|
if (control.stalled) await awaitTreeGone({
|
|
@@ -585,7 +647,7 @@ async function runProfilePnpm(context, args, options) {
|
|
|
585
647
|
if (warnings.length > 0) {
|
|
586
648
|
await restore();
|
|
587
649
|
const repair = ["install", savedFiles.some((file) => file.path.endsWith("pnpm-lock.yaml") && file.text !== void 0) ? "--frozen-lockfile" : "--config.lockfile=false"];
|
|
588
|
-
const
|
|
650
|
+
const repairing = execa(options.command ?? "pnpm", [...options.args ?? [], ...repair], {
|
|
589
651
|
cwd: dir,
|
|
590
652
|
env: environment,
|
|
591
653
|
extendEnv: false,
|
|
@@ -593,6 +655,11 @@ async function runProfilePnpm(context, args, options) {
|
|
|
593
655
|
stdin: "ignore",
|
|
594
656
|
...options.idleTimeoutMs === void 0 ? {} : { timeout: options.idleTimeoutMs }
|
|
595
657
|
});
|
|
658
|
+
await recordRun(dir, {
|
|
659
|
+
pid: repairing.pid,
|
|
660
|
+
grouped: false
|
|
661
|
+
});
|
|
662
|
+
const repaired = await repairing;
|
|
596
663
|
exitCode = 1;
|
|
597
664
|
const restoration = repaired.exitCode === 0 ? "restored package.json, pnpm-lock.yaml, and node_modules" : "restored package.json and pnpm-lock.yaml, but node_modules could not be reinstalled; run 'dsh plugin install'";
|
|
598
665
|
const diagnostic = `\ndsh: installation rejected: ${warnings.join("\n")}\ndsh: ${restoration}.\n`;
|
|
@@ -604,6 +671,7 @@ async function runProfilePnpm(context, args, options) {
|
|
|
604
671
|
} finally {
|
|
605
672
|
control.settled = true;
|
|
606
673
|
clearTimeout(idleTimer);
|
|
674
|
+
await rm(runRecordPath(dir), { force: true });
|
|
607
675
|
await log.close();
|
|
608
676
|
}
|
|
609
677
|
return {
|
package/lib/types/operations.js
CHANGED
|
@@ -8,7 +8,7 @@ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
|
|
|
8
8
|
import { DEFAULT_PROFILE_BUNDLES, bundlePatchPaths, initProfile, PROFILE_TEMPLATES, readProfileManifest, resolveBundleDir, resolveProfileDir, loadOverlayPatches, composeEntries, readProfileVersionExemptions, evaluatePluginCompatibility, pluginCompatibilityWarning, } from '@deepseek-ai/dsh-app-boot';
|
|
9
9
|
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess';
|
|
10
10
|
import { parseInstallSpec } from "./install-spec.js";
|
|
11
|
-
import { awaitTreeGone, leadsOwnGroup } from "./run-tree.js";
|
|
11
|
+
import { awaitTreeGone, leadsOwnGroup, treeAlive } from "./run-tree.js";
|
|
12
12
|
import { incompatiblePlugin } from "./failure.js";
|
|
13
13
|
export { setProfileVersionExemption, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot';
|
|
14
14
|
/** Resolve relative package specs against the caller's directory.
|
|
@@ -152,6 +152,51 @@ function optionalFile(path) {
|
|
|
152
152
|
throw error;
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
|
+
/** Where an operation records the pnpm run it started, for a successor when the operation's own process ends first. */
|
|
156
|
+
function runRecordPath(dir) {
|
|
157
|
+
return join(dir, '.plugin-manager', 'run.json');
|
|
158
|
+
}
|
|
159
|
+
/** Record a started run; a successor that takes over the profile lock from an exited process waits for it. */
|
|
160
|
+
async function recordRun(dir, tree) {
|
|
161
|
+
if (tree.pid === undefined)
|
|
162
|
+
return;
|
|
163
|
+
await writeFileAtomic(runRecordPath(dir), `${JSON.stringify(tree)}\n`, { mode: 0o600, dirMode: 0o700 });
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Why this operation must not run: a record left by an operation whose process
|
|
167
|
+
* ended mid-run names a run that is still writing the profile. The profile
|
|
168
|
+
* lock is taken over once its holder exits, but its pnpm tree can outlive it.
|
|
169
|
+
* A recorded run that stopped, within a bounded wait, has its record removed.
|
|
170
|
+
* @param dir Profile directory.
|
|
171
|
+
* @returns The diagnostic, or undefined when no recorded run is active.
|
|
172
|
+
*/
|
|
173
|
+
async function activeRecordedRun(dir) {
|
|
174
|
+
const path = runRecordPath(dir);
|
|
175
|
+
const text = optionalFile(path);
|
|
176
|
+
if (text === undefined)
|
|
177
|
+
return undefined;
|
|
178
|
+
let tree;
|
|
179
|
+
try {
|
|
180
|
+
const value = JSON.parse(text);
|
|
181
|
+
const { pid, grouped } = (typeof value === 'object' && value !== null ? value : {});
|
|
182
|
+
if (Number.isSafeInteger(pid) && pid > 0 && typeof grouped === 'boolean')
|
|
183
|
+
tree = { pid: pid, grouped };
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
// Records are replaced atomically, so an unparsable one was written by something else and is reported below.
|
|
187
|
+
void error;
|
|
188
|
+
}
|
|
189
|
+
if (tree === undefined) {
|
|
190
|
+
return `dsh: ${path} does not name a package run; delete it once no earlier package operation is still running in this profile\n`;
|
|
191
|
+
}
|
|
192
|
+
await awaitTreeGone(tree);
|
|
193
|
+
if (treeAlive(tree)) {
|
|
194
|
+
return `dsh: process ${String(tree.pid)}, started by an earlier package operation whose own process ended, is still running in this profile; `
|
|
195
|
+
+ `wait for it or stop it, then retry. If process ${String(tree.pid)} is not that package run, delete ${path}.\n`;
|
|
196
|
+
}
|
|
197
|
+
await rm(path, { force: true });
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
155
200
|
/** pnpm can install plugins through any direct-dependency field. */
|
|
156
201
|
function directDependencies(manifest) {
|
|
157
202
|
const extra = manifest;
|
|
@@ -200,15 +245,24 @@ function bundleComponentManifests(manifest, dir, anchor) {
|
|
|
200
245
|
*/
|
|
201
246
|
export async function runProfilePnpm(context, args, options) {
|
|
202
247
|
const dir = context.dir ?? resolveProfileDir(context.profile, context.home);
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
const beforeDependencies = directDependencies(before);
|
|
206
|
-
const installedBefore = new Map(Object.keys(beforeDependencies).map(name => [name, optionalFile(join(dir, 'node_modules', name, 'package.json'))]));
|
|
248
|
+
// Before any profile file is read: a recorded run that is still active may be rewriting them.
|
|
249
|
+
const active = await activeRecordedRun(dir);
|
|
207
250
|
const logRoot = join(dir, '.plugin-manager', 'logs');
|
|
208
251
|
await mkdir(logRoot, { recursive: true, mode: 0o700 });
|
|
209
252
|
const logDir = await mkdtemp(join(logRoot, 'operation-'));
|
|
210
253
|
const logPath = join(logDir, 'pnpm.log');
|
|
211
254
|
const log = await open(logPath, 'wx', 0o600);
|
|
255
|
+
if (active !== undefined) {
|
|
256
|
+
await log.write(active);
|
|
257
|
+
await log.close();
|
|
258
|
+
options.onOutput?.(active, 'stderr');
|
|
259
|
+
const bytes = Buffer.from(active);
|
|
260
|
+
return { exitCode: 1, output: bytes.subarray(Math.max(0, bytes.length - options.outputBytes)).toString('utf8'), truncated: bytes.length > options.outputBytes, logPath };
|
|
261
|
+
}
|
|
262
|
+
const before = readProfileManifest('dsh', dir);
|
|
263
|
+
const savedFiles = ['package.json', 'pnpm-lock.yaml'].map(name => ({ path: join(dir, name), text: optionalFile(join(dir, name)) }));
|
|
264
|
+
const beforeDependencies = directDependencies(before);
|
|
265
|
+
const installedBefore = new Map(Object.keys(beforeDependencies).map(name => [name, optionalFile(join(dir, 'node_modules', name, 'package.json'))]));
|
|
212
266
|
let output = Buffer.alloc(0);
|
|
213
267
|
let truncated = false;
|
|
214
268
|
const append = (bytes) => {
|
|
@@ -276,6 +330,8 @@ export async function runProfilePnpm(context, args, options) {
|
|
|
276
330
|
buffer: false, stdin: options.execution === 'cli' ? 'inherit' : 'ignore', cancelSignal: options.signal === undefined
|
|
277
331
|
? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]),
|
|
278
332
|
});
|
|
333
|
+
// Listened for before the record is written, so an exit during that write is not missed.
|
|
334
|
+
const exited = once(child.nodeChildProcess, 'exit').catch(() => undefined);
|
|
279
335
|
let writes = Promise.resolve();
|
|
280
336
|
/** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */
|
|
281
337
|
const control = { settled: false, stalled: false };
|
|
@@ -330,11 +386,12 @@ export async function runProfilePnpm(context, args, options) {
|
|
|
330
386
|
armIdle();
|
|
331
387
|
let exitCode;
|
|
332
388
|
try {
|
|
389
|
+
await recordRun(dir, { pid: child.pid, grouped });
|
|
333
390
|
// execa resolves its promise only once the piped stdio has ended, so the
|
|
334
391
|
// process's own exit — the run's completion — is read from the raw child. A
|
|
335
392
|
// spawn failure settles without one.
|
|
336
393
|
const settled = Promise.allSettled([child]);
|
|
337
|
-
await Promise.race([
|
|
394
|
+
await Promise.race([exited, settled]);
|
|
338
395
|
control.settled = true;
|
|
339
396
|
clearTimeout(idleTimer);
|
|
340
397
|
// A stalled run stops its whole tree first, so the caller's rollback and lock
|
|
@@ -428,10 +485,12 @@ export async function runProfilePnpm(context, args, options) {
|
|
|
428
485
|
await restore();
|
|
429
486
|
const hadLockfile = savedFiles.some(file => file.path.endsWith('pnpm-lock.yaml') && file.text !== undefined);
|
|
430
487
|
const repair = ['install', hadLockfile ? '--frozen-lockfile' : '--config.lockfile=false'];
|
|
431
|
-
const
|
|
488
|
+
const repairing = execa(options.command ?? 'pnpm', [...options.args ?? [], ...repair], {
|
|
432
489
|
cwd: dir, env: environment, extendEnv: false, reject: false, stdin: 'ignore',
|
|
433
490
|
...options.idleTimeoutMs === undefined ? {} : { timeout: options.idleTimeoutMs },
|
|
434
491
|
});
|
|
492
|
+
await recordRun(dir, { pid: repairing.pid, grouped: false });
|
|
493
|
+
const repaired = await repairing;
|
|
435
494
|
exitCode = 1;
|
|
436
495
|
const restoration = repaired.exitCode === 0
|
|
437
496
|
? 'restored package.json, pnpm-lock.yaml, and node_modules'
|
|
@@ -449,6 +508,8 @@ export async function runProfilePnpm(context, args, options) {
|
|
|
449
508
|
finally {
|
|
450
509
|
control.settled = true;
|
|
451
510
|
clearTimeout(idleTimer);
|
|
511
|
+
// This process saw the run end, so no successor has to wait for it.
|
|
512
|
+
await rm(runRecordPath(dir), { force: true });
|
|
452
513
|
await log.close();
|
|
453
514
|
}
|
|
454
515
|
return {
|
package/lib/types/run-tree.d.ts
CHANGED
|
@@ -26,6 +26,13 @@ export interface RunTreeInternals {
|
|
|
26
26
|
* @returns True when the run leads its own process group.
|
|
27
27
|
*/
|
|
28
28
|
export declare function leadsOwnGroup(execution: 'cli' | 'service', platform?: NodeJS.Platform): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Whether any member of a run's tree is still alive.
|
|
31
|
+
* @param tree The run's process id and whether it leads its own group.
|
|
32
|
+
* @param internals Injectable process operations.
|
|
33
|
+
* @returns True while the probe finds the process or a group member.
|
|
34
|
+
*/
|
|
35
|
+
export declare function treeAlive(tree: RunTree, internals?: RunTreeInternals): boolean;
|
|
29
36
|
/**
|
|
30
37
|
* Wait until a terminated run's tree is gone, so the caller restores and
|
|
31
38
|
* unlocks the profile only after the scripts it started stopped writing.
|
package/lib/types/run-tree.js
CHANGED
|
@@ -21,28 +21,34 @@ function targetOf(pid, grouped, platform) {
|
|
|
21
21
|
return platform !== 'win32' && grouped ? -pid : pid;
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
* unlocks the profile only after the scripts it started stopped writing.
|
|
24
|
+
* Whether any member of a run's tree is still alive.
|
|
26
25
|
* @param tree The run's process id and whether it leads its own group.
|
|
27
26
|
* @param internals Injectable process operations.
|
|
28
|
-
* @returns
|
|
27
|
+
* @returns True while the probe finds the process or a group member.
|
|
29
28
|
*/
|
|
30
|
-
export
|
|
29
|
+
export function treeAlive(tree, internals = {}) {
|
|
31
30
|
const pid = tree.pid;
|
|
32
31
|
if (pid === undefined)
|
|
33
|
-
return;
|
|
32
|
+
return false;
|
|
34
33
|
const platform = internals.platform ?? process.platform;
|
|
35
34
|
const alive = internals.alive ?? ((target) => { process.kill(target, 0); return true; });
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
try {
|
|
36
|
+
return alive(targetOf(pid, tree.grouped, platform));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Wait until a terminated run's tree is gone, so the caller restores and
|
|
44
|
+
* unlocks the profile only after the scripts it started stopped writing.
|
|
45
|
+
* @param tree The run's process id and whether it leads its own group.
|
|
46
|
+
* @param internals Injectable process operations.
|
|
47
|
+
* @returns Fulfillment once no member remains, or the wait bound elapsed.
|
|
48
|
+
*/
|
|
49
|
+
export async function awaitTreeGone(tree, internals = {}) {
|
|
44
50
|
const deadline = Date.now() + (internals.waitMs ?? TREE_WAIT_MS);
|
|
45
|
-
while (
|
|
51
|
+
while (treeAlive(tree, internals)) {
|
|
46
52
|
if (Date.now() >= deadline)
|
|
47
53
|
return;
|
|
48
54
|
await new Promise(resolve => setTimeout(resolve, TREE_POLL_MS));
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepseek-ai/dsh-plugin-manager",
|
|
3
3
|
"description": "Current-profile plugin and bundle management shared by dsh CLI, Web and agent tools",
|
|
4
|
-
"version": "0.1.7-rc.
|
|
4
|
+
"version": "0.1.7-rc.2",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -59,23 +59,23 @@
|
|
|
59
59
|
"execa": "^10.0.0",
|
|
60
60
|
"yaml": "^2.9.0",
|
|
61
61
|
"zod": "^4.4.3",
|
|
62
|
-
"@deepseek-ai/dsh-
|
|
63
|
-
"@deepseek-ai/dsh-
|
|
62
|
+
"@deepseek-ai/dsh-atomic-write": "0.1.7-rc.2",
|
|
63
|
+
"@deepseek-ai/dsh-app-boot": "0.1.7-rc.2",
|
|
64
64
|
"@deepseek-ai/schemastery": "~3.18.4",
|
|
65
|
-
"@deepseek-ai/dsh-util-values": "0.1.7-rc.
|
|
65
|
+
"@deepseek-ai/dsh-util-values": "0.1.7-rc.2"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
68
|
"@deepseek-ai/cordis": "~4.0.4",
|
|
69
69
|
"@deepseek-ai/cordis-plugin-loader": "~1.0.5",
|
|
70
|
-
"@deepseek-ai/dsh-host-plugin-inventory": "0.1.7-rc.
|
|
71
|
-
"@deepseek-ai/dsh-subprocess": "0.1.7-rc.
|
|
72
|
-
"@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.
|
|
73
|
-
"@deepseek-ai/dsh-tools": "0.1.7-rc.
|
|
74
|
-
"@deepseek-ai/dsh-
|
|
75
|
-
"@deepseek-ai/dsh-
|
|
76
|
-
"@deepseek-ai/dsh-
|
|
77
|
-
"@deepseek-ai/dsh-
|
|
78
|
-
"@deepseek-ai/dsh-
|
|
70
|
+
"@deepseek-ai/dsh-host-plugin-inventory": "0.1.7-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-subprocess": "0.1.7-rc.2",
|
|
72
|
+
"@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.2",
|
|
73
|
+
"@deepseek-ai/dsh-tools": "0.1.7-rc.2",
|
|
74
|
+
"@deepseek-ai/dsh-sandbox-policy": "0.1.7-rc.2",
|
|
75
|
+
"@deepseek-ai/dsh-sandbox": "0.1.7-rc.2",
|
|
76
|
+
"@deepseek-ai/dsh-user-approval": "0.1.7-rc.2",
|
|
77
|
+
"@deepseek-ai/dsh-brand": "0.1.7-rc.2",
|
|
78
|
+
"@deepseek-ai/dsh-hmr": "0.1.7-rc.2"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
81
81
|
"@deepseek-ai/dsh-hmr": {
|
|
@@ -86,22 +86,22 @@
|
|
|
86
86
|
}
|
|
87
87
|
},
|
|
88
88
|
"devDependencies": {
|
|
89
|
-
"@deepseek-ai/dsh-package-manifest": "0.1.7-rc.
|
|
89
|
+
"@deepseek-ai/dsh-package-manifest": "0.1.7-rc.2",
|
|
90
90
|
"@deepseek-ai/cordis": "~4.0.4",
|
|
91
|
-
"@deepseek-ai/
|
|
92
|
-
"@deepseek-ai/dsh-
|
|
93
|
-
"@deepseek-ai/dsh-
|
|
94
|
-
"@deepseek-ai/dsh-
|
|
95
|
-
"@deepseek-ai/dsh-
|
|
96
|
-
"@deepseek-ai/dsh-
|
|
97
|
-
"@deepseek-ai/dsh-brand": "0.1.7-rc.
|
|
98
|
-
"@deepseek-ai/dsh-sandbox-policy": "0.1.7-rc.
|
|
99
|
-
"@deepseek-ai/dsh-agent": "0.1.7-rc.1",
|
|
100
|
-
"@deepseek-ai/dsh-session": "0.1.7-rc.1",
|
|
101
|
-
"@deepseek-ai/dsh-session-projection": "0.1.7-rc.1",
|
|
102
|
-
"@deepseek-ai/dsh-sandbox": "0.1.7-rc.1",
|
|
91
|
+
"@deepseek-ai/cordis-plugin-loader": "~1.0.5",
|
|
92
|
+
"@deepseek-ai/dsh-host-plugin-inventory": "0.1.7-rc.2",
|
|
93
|
+
"@deepseek-ai/dsh-subprocess": "0.1.7-rc.2",
|
|
94
|
+
"@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.2",
|
|
95
|
+
"@deepseek-ai/dsh-tools": "0.1.7-rc.2",
|
|
96
|
+
"@deepseek-ai/dsh-system-prompt": "0.1.7-rc.2",
|
|
97
|
+
"@deepseek-ai/dsh-brand": "0.1.7-rc.2",
|
|
98
|
+
"@deepseek-ai/dsh-sandbox-policy": "0.1.7-rc.2",
|
|
103
99
|
"@deepseek-ai/cordis-plugin-timer": "~1.1.6",
|
|
104
|
-
"@deepseek-ai/dsh-
|
|
105
|
-
"@deepseek-ai/
|
|
100
|
+
"@deepseek-ai/dsh-hmr": "0.1.7-rc.2",
|
|
101
|
+
"@deepseek-ai/dsh-session": "0.1.7-rc.2",
|
|
102
|
+
"@deepseek-ai/dsh-session-projection": "0.1.7-rc.2",
|
|
103
|
+
"@deepseek-ai/dsh-sandbox": "0.1.7-rc.2",
|
|
104
|
+
"@deepseek-ai/dsh-user-approval": "0.1.7-rc.2",
|
|
105
|
+
"@deepseek-ai/dsh-agent": "0.1.7-rc.2"
|
|
106
106
|
}
|
|
107
107
|
}
|