dsh-m 0.2.7 → 0.2.9
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/docs/DESIGN.md +19 -5
- package/lib/cli.js +5 -4
- package/lib/core/dsh-cli.js +301 -39
- package/lib/core/host-api.js +38 -6
- package/lib/core/market.js +50 -300
- package/lib/core/npm-integrity.js +139 -13
- package/lib/core/profile-transaction.js +825 -0
- package/lib/host.js +1 -2
- package/lib/tools.js +8 -5
- package/package.json +1 -1
- package/registry.json +33 -0
package/docs/DESIGN.md
CHANGED
|
@@ -63,12 +63,26 @@
|
|
|
63
63
|
|
|
64
64
|
底层原语(本机实证):`dsh plugin --profile web add|remove|update`(转发 pnpm,作用于 `$DSH_HOME/profiles/web`)。**profile 的 `package.json` 就是唯一事实源**——不引入任何额外状态文件。
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
### 3.1 Profile 变更事务(`src/core/profile-transaction.ts`,2026-09-07 重构)
|
|
67
|
+
|
|
68
|
+
npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模块的四个入口**(自升级 = `install-npm` + 自身包名)。编排层(market.ts / host-api.ts / tools.ts / cli.ts)只做「版本/收录条目解析(事务外)→ 开事务 → 包装结果」,互斥锁收编在模块内部(模块级 FIFO,进程内串行)。
|
|
69
|
+
|
|
70
|
+
- **两阶段不变量(失败保证)**:变更失败时,①先把三个关键文件(package.json / pnpm-lock.yaml / pnpm-workspace.yaml)**逐字节还原到变更前快照**,还原后立即重读比对验证(`snapshotRestoreVerified` 报告此历史事实);②再跑 frozen 收敛阶梯处理依赖一致性——阶梯中的 overrides 对齐 / `--no-frozen-lockfile` 重建允许对 manifest/lockfile 做**有记录的**受控改写(`healActions`)。失败终态承诺「**一致**」(`status='rolled-back'`)不必然「等同」;收敛耗尽则 `status='manual-repair'`(还原事实独立如实报告)。不声称 node_modules 字节级回滚。
|
|
71
|
+
- **结果为四分支判别联合**:`committed` / `rejected`(快照失败、前置校验失败、排队期 abort——零写入)/ `rolled-back` / `manual-repair`;`healActions[{code, note}]` + `failure{code, note}`(英文稳定 code,机器可断言);中文散文只在展示层由 `renderFailure` 生成(唯一产地)。消费方拿结构化结果自行渲染;host-api 失败体附 `detail` 白名单投影(不含 raw output,GUI 零改动只读 `error`)。
|
|
72
|
+
- **pnpm 结果只以六类分类穿过接缝**(`dsh-cli.ts` 是分类器唯一产地):ok / retryable-lag(CDN 滞后)/ config-drift(配置漂移)/ unused-patch / needs-builds / hard-fail;**分类发生在任何文案改写之前**,事务与 market 永不 regex pnpm 原始输出、只消费 `PNPM_OUTCOME_CODES` 常量。分类消费矩阵(add/remove/frozen/rebuild × 六类)由参数化测试全枚举钉死。
|
|
73
|
+
- **自愈阶梯**(全部以 healActions 记录):B1 安装链丢 manifest 顶层键 → 快照找回 + frozen 复验(复验失败 fail-closed 进回滚);B2 frozen 收敛:CONFIG_MISMATCH → 把 lockfile 记录的 overrides 对齐回 `package.json#pnpm.overrides` 再复验 → 顽固失配(含 OUTDATED_LOCKFILE specifier 漂移)降级 `--no-frozen-lockfile` 重建;B3 刚发布 `ERR_PNPM_NO_MATCHING_VERSION` 退避重试 2 次(5s/15s,abort-aware),每次重试前预热完整 packument(生产绑定 `makeNpmWarmPackument`,失败吞错);构建脚本被拦 → `dangerouslyAllowAllBuilds` 放行并**必须明确报告**(在途重试在 adapter 的 `makeAddViaLadder` 内耗尽)。
|
|
74
|
+
- **signal 语义**:request 可带 AbortSignal,贯通 runner 四操作、预热与退避 sleep(abort 即醒);mutate 前 abort → `rejected`;mutate 后 abort → 中止在途调用并执行**不可取消的**回滚(不变量优先于取消);mutation 返回与提交前复查 signal——runner 返回 ok 但已取消的事务同样进 ABORTED 回滚,不提交。
|
|
75
|
+
- **互斥边界(已知限制)**:FIFO 锁为**进程内**互斥——Web Host / GUI / Agent tools 与独立 `dshm` CLI 可能运行在不同进程、操作同一 web profile,跨进程并发不在覆盖范围。变更执行期间避免同时从 GUI/Agent/CLI 发起另一次变更;跨进程锁(lockfile + stale 恢复)留作后续演进。
|
|
76
|
+
- **快照/原子写原语**(`npm-integrity.ts`):快照仅吞 ENOENT(其他读取异常 fail closed 零写入);原子写 POSIX 直接 rename(无「先删后改名」窗口),Windows EPERM/EEXIST 走备份协议;**任何失败路径(含 write/sync/close)都清理 tmp**,备份恢复自身失败时报告 backup 路径与双重错误。
|
|
77
|
+
|
|
78
|
+
### 3.2 各入口语义
|
|
79
|
+
|
|
80
|
+
- **安装(npm 源)**:装最新版并以**精确版本锁定**(不用 `^` 范围;用户指定版本必须为精确 semver,经该精确版本 endpoint 查询;dist integrity 缺失 fail closed 不进事务)。事务内核验 importer 依赖为该精确版本(旧版 CLI 写入的 `^v`/`~v` 锚定 range 视为等价放行并记录 `RANGE_ANCHOR_ACCEPTED`;精确性由紧随的 lockfile 解析与 integrity 兜底,其余 spec 仍 fail closed),并在 lockfile `packages` 条目中比对与 npm dist 一致的 `resolution.integrity`。
|
|
81
|
+
- **安装(GitHub 源)**:解析并**锁定 commit SHA**(`github:owner/repo#sha`)。核验采用**前态比对**:新 spec 必须相对快照是新写入(预置同 spec 旧依赖不误命中),否则 `GITHUB_SPEC_MISMATCH` 回滚。
|
|
68
82
|
- **已装识别**:读 profile `package.json` dependencies,与 registry 匹配 → 标注「市场安装」;不匹配的也列出,标注「非市场安装 / 来源未知」。卸载/升级对两类都可用。
|
|
69
|
-
-
|
|
70
|
-
- **升级**:**按需检查**(`dshm_outdated` / `dshm_list` 时实时比对本地版本 vs npm latest / GitHub
|
|
71
|
-
- **自更新**:dsh-m 对自己同样做版本比对 +
|
|
83
|
+
- **卸载**(事务定序写死):validate(严格读取:NOT_INSTALLED / NOT_DSH_PLUGIN / PLUGIN_METADATA_UNREADABLE,全部零写入零快照)→ 快照 → live-disable(先让 client bundle 下线,避免 404;回滚时尽力反向)→ 摘除该包补丁条目(`pnpm-workspace.yaml` 顶层 `patchedDependencies` 与 `package.json#pnpm.patchedDependencies`;残留条目会令 pnpm 以 `ERR_PNPM_UNUSED_PATCH` 整单失败,只精确匹配 `pkg` / `pkg@ver`,补丁文件本体保留并计入残留报告)→ remove → verify gone(仍在则回滚)。**不清理插件产生的数据/配置**,但把检测到的疑似残留路径(如 `~/.dsh/<plugin>.json`)列出报告。
|
|
84
|
+
- **升级**:**按需检查**(`dshm_outdated` / `dshm_list` 时实时比对本地版本 vs npm latest / GitHub release,半自动——展示升级计划,确认后执行,即重开一次安装事务)。**不做后台定时器**。
|
|
85
|
+
- **自更新**:dsh-m 对自己同样做版本比对 + 提示升级(设置页呈现);执行即 `install-npm` 事务(integrity 缺失直接拒绝,不进事务)。
|
|
72
86
|
- **重启**:内置**一键重启**,复用 skillhub 验证过的重启路径(本机 `dsh-web.service` 是转发 shim,不新建 systemd 单元、不监听 3080)。安装/卸载/升级完成后 GUI 弹「需重启生效 [一键重启]」横幅,工具返回重启提示。
|
|
73
87
|
- **安全基线(5 条)**:
|
|
74
88
|
1. 所有拉取仅 HTTPS + 响应大小上限 + 超时;
|
package/lib/cli.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 独立 CLI 固定 namespace:'cli'(与 Host 的 host namespace cache 互不影响);
|
|
6
6
|
* registry/search/list/outdated 在清单不可用时打印配置/实际生效地址并 exit 1。
|
|
7
7
|
*/
|
|
8
|
-
import { installFromRegistry, listInstalledWithMeta, listMarket, uninstallPlugin, upgradePlugin,
|
|
8
|
+
import { installFromRegistry, listInstalledWithMeta, listMarket, uninstallPlugin, upgradePlugin, } from './core/market.js';
|
|
9
9
|
import { loadRegistry } from './core/registry.js';
|
|
10
10
|
import { scheduleRestart } from './core/restart.js';
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
@@ -92,6 +92,7 @@ const HELP = `dshm — DSH Marketplace(个人自用 DSH 插件市场)
|
|
|
92
92
|
dshm upgrade --pkg <包名> --yes
|
|
93
93
|
dshm uninstall --pkg <包名> --yes
|
|
94
94
|
dshm restart --yes
|
|
95
|
+
注意:变更互斥仅在进程内生效——变更执行期间不要同时从 GUI / Agent 工具发起另一次变更。
|
|
95
96
|
|
|
96
97
|
环境变量:DSHM_REGISTRY_URL(registry 源覆盖)、DSHM_TIMEOUT_MS、DSHM_CACHE_TTL_MIN、DSHM_CACHE_DIR
|
|
97
98
|
`;
|
|
@@ -214,7 +215,7 @@ async function runCliDispatch(argv, deps, io) {
|
|
|
214
215
|
case 'install': {
|
|
215
216
|
const id = needFlag(flags, 'id');
|
|
216
217
|
const version = typeof flags.version === 'string' ? flags.version : undefined;
|
|
217
|
-
const res = await
|
|
218
|
+
const res = await d.installFromRegistry(id, cfg, { version, namespace: 'cli' });
|
|
218
219
|
out(`✅ 已安装 ${res.pkg}(${res.spec})`);
|
|
219
220
|
if (res.usedAllowAllBuilds)
|
|
220
221
|
out('⚠️ 该插件执行了构建脚本(已按策略放行)。');
|
|
@@ -224,7 +225,7 @@ async function runCliDispatch(argv, deps, io) {
|
|
|
224
225
|
case 'upgrade': {
|
|
225
226
|
const target = needFlag(flags, 'pkg');
|
|
226
227
|
requireYes(flags, '升级');
|
|
227
|
-
const res = await
|
|
228
|
+
const res = await d.upgradePlugin(target, cfg, { namespace: 'cli' });
|
|
228
229
|
const from = res.fromVersion ? `v${res.fromVersion} → ` : '';
|
|
229
230
|
const to = res.version ? `v${res.version}` : res.sha ? res.sha.slice(0, 7) : '最新';
|
|
230
231
|
out(`✅ 已升级 ${res.pkg}(${from}${to})`);
|
|
@@ -236,7 +237,7 @@ async function runCliDispatch(argv, deps, io) {
|
|
|
236
237
|
case 'uninstall': {
|
|
237
238
|
const target = needFlag(flags, 'pkg');
|
|
238
239
|
requireYes(flags, '卸载');
|
|
239
|
-
const res = await
|
|
240
|
+
const res = await d.uninstallPlugin(target, cfg, { namespace: 'cli' });
|
|
240
241
|
out(`✅ 已卸载 ${res.pkg}${res.liveDisabled ? '(运行中的界面已先下线)' : ''}`);
|
|
241
242
|
if (res.leftovers.length)
|
|
242
243
|
out(`ℹ️ 疑似残留数据(未删除):${res.leftovers.join('、')}`);
|
package/lib/core/dsh-cli.js
CHANGED
|
@@ -201,6 +201,46 @@ function syncProgress(tracker) {
|
|
|
201
201
|
if (snap.error !== null)
|
|
202
202
|
progress.error = snap.error;
|
|
203
203
|
}
|
|
204
|
+
export const PNPM_OUTCOME_CODES = {
|
|
205
|
+
CONFIG_MISMATCH: 'ERR_PNPM_LOCKFILE_CONFIG_MISMATCH',
|
|
206
|
+
OUTDATED_LOCKFILE: 'ERR_PNPM_OUTDATED_LOCKFILE',
|
|
207
|
+
NO_MATCHING_VERSION: 'ERR_PNPM_NO_MATCHING_VERSION',
|
|
208
|
+
UNUSED_PATCH: 'ERR_PNPM_UNUSED_PATCH',
|
|
209
|
+
PUBLIC_HOIST_PATTERN_DIFF: 'ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF',
|
|
210
|
+
};
|
|
211
|
+
/**
|
|
212
|
+
* 对 pnpm/dsh 原始输出文本做六类归一解释。分类发生在任何文案改写之前;
|
|
213
|
+
* 上层(事务/market)只消费 class + code 常量,永不 regex 原始输出。
|
|
214
|
+
*/
|
|
215
|
+
export function classifyPnpmError(text) {
|
|
216
|
+
const raw = String(text ?? '');
|
|
217
|
+
if (raw.includes(PNPM_OUTCOME_CODES.NO_MATCHING_VERSION)) {
|
|
218
|
+
return { class: 'retryable-lag', code: PNPM_OUTCOME_CODES.NO_MATCHING_VERSION };
|
|
219
|
+
}
|
|
220
|
+
if (raw.includes(PNPM_OUTCOME_CODES.CONFIG_MISMATCH)) {
|
|
221
|
+
return { class: 'config-drift', code: PNPM_OUTCOME_CODES.CONFIG_MISMATCH };
|
|
222
|
+
}
|
|
223
|
+
if (raw.includes(PNPM_OUTCOME_CODES.OUTDATED_LOCKFILE)) {
|
|
224
|
+
return { class: 'config-drift', code: PNPM_OUTCOME_CODES.OUTDATED_LOCKFILE };
|
|
225
|
+
}
|
|
226
|
+
if (raw.includes(PNPM_OUTCOME_CODES.UNUSED_PATCH)) {
|
|
227
|
+
return { class: 'unused-patch', code: PNPM_OUTCOME_CODES.UNUSED_PATCH };
|
|
228
|
+
}
|
|
229
|
+
if (isPrepareBlocked(raw))
|
|
230
|
+
return { class: 'needs-builds' };
|
|
231
|
+
const m = /(ERR_PNPM_[A-Z0-9_]+)/.exec(raw);
|
|
232
|
+
return { class: 'hard-fail', code: m?.[1] };
|
|
233
|
+
}
|
|
234
|
+
/** 统一的命令取消错误形态(runCommand 调用前已取消与运行中取消同款)。 */
|
|
235
|
+
function commandAbortError() {
|
|
236
|
+
const err = new Error('命令已取消');
|
|
237
|
+
err.name = 'AbortError';
|
|
238
|
+
return err;
|
|
239
|
+
}
|
|
240
|
+
function truncateOutput(text) {
|
|
241
|
+
const raw = String(text ?? '');
|
|
242
|
+
return raw.length <= 800 ? raw : raw.slice(-800);
|
|
243
|
+
}
|
|
204
244
|
export function webProfileName() {
|
|
205
245
|
return WEB_PROFILE;
|
|
206
246
|
}
|
|
@@ -287,8 +327,73 @@ export function rewritePnpmError(err) {
|
|
|
287
327
|
}
|
|
288
328
|
return err instanceof Error ? err : new Error(text);
|
|
289
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* 失败摘要:命令失败时从完整输出里提取可诊断的行,而不是盲取末尾。
|
|
332
|
+
* 2026-09-05 实证(dsh-better-sidebar 安装失败):pnpm ndjson 错误行 ~1.2KB,
|
|
333
|
+
* 错误码/信息/hint 在行首,尾截 800 只剩纯栈帧——`ERR_PNPM_IGNORED_BUILDS`
|
|
334
|
+
* 与 `node-pty` 关键字全部丢失,isPrepareBlocked 匹配不到,
|
|
335
|
+
* dangerouslyAllowAllBuilds 自愈重试从未触发,用户只看到一屏栈。
|
|
336
|
+
* 优先级:ndjson 错误行(code — message — hint)→ 含 ERR_ 码的行 + 尾部 → 纯尾部。
|
|
337
|
+
*/
|
|
338
|
+
export function errorDigest(out, maxChars = 800) {
|
|
339
|
+
const text = out.trim();
|
|
340
|
+
if (text === '')
|
|
341
|
+
return '';
|
|
342
|
+
if (text.length <= maxChars)
|
|
343
|
+
return text;
|
|
344
|
+
const lines = text.split('\n');
|
|
345
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
346
|
+
const line = lines[i].trim();
|
|
347
|
+
if (!line.startsWith('{') || !line.includes('"level":"error"'))
|
|
348
|
+
continue;
|
|
349
|
+
try {
|
|
350
|
+
const doc = JSON.parse(line);
|
|
351
|
+
const code = typeof doc.code === 'string' && doc.code !== '' ? doc.code
|
|
352
|
+
: typeof doc.err?.code === 'string' ? doc.err.code : null;
|
|
353
|
+
const message = typeof doc.err?.message === 'string' ? doc.err.message : null;
|
|
354
|
+
const hint = typeof doc.hint === 'string' ? doc.hint : null;
|
|
355
|
+
const parts = [code, message, hint].filter((p) => p !== null && p !== '');
|
|
356
|
+
if (parts.length > 0)
|
|
357
|
+
return parts.join(' — ');
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
/* 非 JSON 行,继续向前找 */
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
const errCodeLines = lines.filter((l) => /ERR_[A-Z0-9_]+/.test(l)).slice(-5);
|
|
364
|
+
if (errCodeLines.length > 0) {
|
|
365
|
+
const digest = [...errCodeLines, text.slice(-maxChars)].join('\n').slice(-maxChars * 2);
|
|
366
|
+
return digest;
|
|
367
|
+
}
|
|
368
|
+
return text.slice(-maxChars);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* 停止宽限(毫秒)归一:允许显式 0(立即 SIGKILL);负数/NaN/Infinity/垃圾值一律回落
|
|
372
|
+
* 缺省。Y1(第四轮复审):`Number(raw) || 5000` 会把 -1 当真值、把显式 0 吞成缺省。
|
|
373
|
+
*/
|
|
374
|
+
const KILL_GRACE_DEFAULT_MS = 5_000;
|
|
375
|
+
function normalizeKillGrace(raw) {
|
|
376
|
+
const value = typeof raw === 'number' ? raw : Number(raw);
|
|
377
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
378
|
+
}
|
|
379
|
+
export { normalizeKillGrace };
|
|
380
|
+
function killGraceDefault() {
|
|
381
|
+
const raw = process.env.DSH_KILL_GRACE_MS;
|
|
382
|
+
if (raw === undefined || raw.trim() === '')
|
|
383
|
+
return KILL_GRACE_DEFAULT_MS;
|
|
384
|
+
return normalizeKillGrace(raw) ?? KILL_GRACE_DEFAULT_MS;
|
|
385
|
+
}
|
|
386
|
+
/** SIGKILL 后组存活轮询的硬上限(毫秒):防 D 态进程导致永不 settle。 */
|
|
387
|
+
const GROUP_POLL_CAP_MS = 10_000;
|
|
290
388
|
export async function runCommand(command, args, options) {
|
|
291
389
|
return new Promise((resolvePromise, reject) => {
|
|
390
|
+
// R4a(终审复审):AbortSignal 的事件不会对后注册的 listener 重放——调用前已取消的
|
|
391
|
+
// signal 必须在 spawn 前拒绝,否则命令会完整执行副作用。检查与 listener 注册之间为
|
|
392
|
+
// 同步代码,无交织窗口。
|
|
393
|
+
if (options.signal?.aborted) {
|
|
394
|
+
reject(commandAbortError());
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
292
397
|
const child = spawn(command, args, {
|
|
293
398
|
cwd: options.cwd,
|
|
294
399
|
env: { ...process.env, ...options.env, CI: 'true' },
|
|
@@ -298,41 +403,89 @@ export async function runCommand(command, args, options) {
|
|
|
298
403
|
});
|
|
299
404
|
let out = '';
|
|
300
405
|
let settled = false;
|
|
406
|
+
let stopping = false;
|
|
407
|
+
let pendingError;
|
|
408
|
+
let killTimer;
|
|
409
|
+
let pollTimer;
|
|
410
|
+
let pollDeadline = 0;
|
|
411
|
+
// 组语义仅 POSIX + detached(child 是组长)才成立;Windows/非 detached 只保证 direct child
|
|
412
|
+
const groupSupported = process.platform !== 'win32' && child.pid !== undefined;
|
|
413
|
+
const groupAlive = () => {
|
|
414
|
+
if (!groupSupported)
|
|
415
|
+
return false;
|
|
416
|
+
try {
|
|
417
|
+
process.kill(-child.pid, 0);
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
// ESRCH = 组不存在;EPERM 等保守视为仍存活
|
|
422
|
+
return err.code !== 'ESRCH';
|
|
423
|
+
}
|
|
424
|
+
};
|
|
301
425
|
const finish = (err) => {
|
|
302
426
|
if (settled)
|
|
303
427
|
return;
|
|
304
428
|
settled = true;
|
|
305
429
|
clearTimeout(timer);
|
|
430
|
+
if (killTimer !== undefined)
|
|
431
|
+
clearTimeout(killTimer);
|
|
432
|
+
if (pollTimer !== undefined)
|
|
433
|
+
clearTimeout(pollTimer);
|
|
306
434
|
options.signal?.removeEventListener('abort', onAbort);
|
|
307
435
|
if (err)
|
|
308
436
|
reject(err);
|
|
309
437
|
else
|
|
310
438
|
resolvePromise(out);
|
|
311
439
|
};
|
|
312
|
-
const
|
|
313
|
-
if (
|
|
440
|
+
const signalGroup = (sig) => {
|
|
441
|
+
if (groupSupported) {
|
|
314
442
|
try {
|
|
315
|
-
process.kill(-child.pid,
|
|
443
|
+
process.kill(-child.pid, sig);
|
|
316
444
|
return;
|
|
317
445
|
}
|
|
318
446
|
catch {
|
|
319
|
-
/* fall through */
|
|
447
|
+
/* fall through:组可能已消失 */
|
|
320
448
|
}
|
|
321
449
|
}
|
|
322
450
|
try {
|
|
323
|
-
child.kill(
|
|
451
|
+
child.kill(sig);
|
|
324
452
|
}
|
|
325
453
|
catch {
|
|
326
454
|
/* already gone */
|
|
327
455
|
}
|
|
328
456
|
};
|
|
457
|
+
const pollGroupGone = () => {
|
|
458
|
+
if (settled)
|
|
459
|
+
return;
|
|
460
|
+
// SIGKILL 已发:组消失(或超过轮询硬上限,防 D 态进程)才 settle
|
|
461
|
+
if (!groupAlive() || Date.now() > pollDeadline) {
|
|
462
|
+
finish(pendingError);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
pollTimer = setTimeout(pollGroupGone, 25);
|
|
466
|
+
};
|
|
467
|
+
/**
|
|
468
|
+
* F1-R(第三轮复审):停止协议以「进程组不存在」为 settle 前提——SIGTERM 进程组 →
|
|
469
|
+
* 宽限后 SIGKILL → 轮询确认组消失。direct child 提前 close 不清除 kill 定时器
|
|
470
|
+
* (同组后代可能仍忽略 SIGTERM 并写 profile)。
|
|
471
|
+
*/
|
|
472
|
+
const stopChild = (err) => {
|
|
473
|
+
if (settled || stopping)
|
|
474
|
+
return;
|
|
475
|
+
stopping = true;
|
|
476
|
+
pendingError = err;
|
|
477
|
+
signalGroup('SIGTERM');
|
|
478
|
+
killTimer = setTimeout(() => {
|
|
479
|
+
signalGroup('SIGKILL');
|
|
480
|
+
pollDeadline = Date.now() + GROUP_POLL_CAP_MS;
|
|
481
|
+
pollGroupGone();
|
|
482
|
+
}, normalizeKillGrace(options.killGraceMs) ?? killGraceDefault());
|
|
483
|
+
};
|
|
329
484
|
const timer = setTimeout(() => {
|
|
330
|
-
|
|
331
|
-
finish(new Error(`命令超时 ${options.timeoutMs}ms`));
|
|
485
|
+
stopChild(new Error(`命令超时 ${options.timeoutMs}ms`));
|
|
332
486
|
}, options.timeoutMs);
|
|
333
487
|
const onAbort = () => {
|
|
334
|
-
|
|
335
|
-
finish(new Error('命令已取消'));
|
|
488
|
+
stopChild(commandAbortError());
|
|
336
489
|
};
|
|
337
490
|
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
338
491
|
child.stdout?.on('data', (chunk) => {
|
|
@@ -345,12 +498,24 @@ export async function runCommand(command, args, options) {
|
|
|
345
498
|
out = (out + text).slice(-256 * 1024);
|
|
346
499
|
options.onChunk?.(text);
|
|
347
500
|
});
|
|
348
|
-
child.on('error', (err) =>
|
|
501
|
+
child.on('error', (err) => {
|
|
502
|
+
// F1-R:停止协议进行中,迟到的 error(如 kill 竞态)只作诊断,不得提前收口
|
|
503
|
+
if (stopping)
|
|
504
|
+
return;
|
|
505
|
+
finish(err);
|
|
506
|
+
});
|
|
349
507
|
child.on('close', (code) => {
|
|
508
|
+
if (stopping) {
|
|
509
|
+
// direct child close ≠ 进程组停止:组仍存活 → 保留 SIGKILL 定时器/轮询,不 settle
|
|
510
|
+
if (groupAlive())
|
|
511
|
+
return;
|
|
512
|
+
finish(pendingError);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
350
515
|
if (code === 0)
|
|
351
516
|
finish();
|
|
352
517
|
else
|
|
353
|
-
finish(new Error(`命令失败 (exit ${code}): ${out
|
|
518
|
+
finish(new Error(`命令失败 (exit ${code}): ${errorDigest(out) || 'no output'}`));
|
|
354
519
|
});
|
|
355
520
|
});
|
|
356
521
|
}
|
|
@@ -369,6 +534,7 @@ export async function runDshPlugin(profile, pluginArgs, deps = {}) {
|
|
|
369
534
|
return await run(argv.file, [...argv.args, 'plugin', '--profile', profile, ...prepared], {
|
|
370
535
|
cwd: argv.cwd,
|
|
371
536
|
timeoutMs: deps.timeoutMs ?? installTimeoutMs(),
|
|
537
|
+
signal: deps.signal,
|
|
372
538
|
env: { CI: 'true' },
|
|
373
539
|
viaShell: argv.viaShell,
|
|
374
540
|
detached: process.platform !== 'win32',
|
|
@@ -390,47 +556,143 @@ export async function runDshPlugin(profile, pluginArgs, deps = {}) {
|
|
|
390
556
|
}
|
|
391
557
|
}
|
|
392
558
|
/**
|
|
393
|
-
*
|
|
394
|
-
*
|
|
559
|
+
* 加装阶梯工厂(导出、可注入、可测试;生产与测试共用同一实现)。
|
|
560
|
+
* 阶梯:add → prepare 被拦时写 dangerouslyAllowAllBuilds 重试 →
|
|
561
|
+
* PUBLIC_HOIST_PATTERN_DIFF 时 `install --no-frozen-lockfile` 重建后重试 →
|
|
562
|
+
* 耗尽归类返回。对**原始错误文本**分类;永不 throw,一律返回 RunnerOutcome。
|
|
395
563
|
*/
|
|
396
|
-
export
|
|
397
|
-
const run = deps.runDshPlugin
|
|
564
|
+
export function makeAddViaLadder(deps) {
|
|
565
|
+
const run = deps.runDshPlugin;
|
|
398
566
|
const allowAllBuilds = deps.allowAllBuilds ?? writeDangerouslyAllowAllBuilds;
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
catch (retryErr) {
|
|
405
|
-
throw rewritePnpmError(retryErr);
|
|
406
|
-
}
|
|
407
|
-
};
|
|
408
|
-
try {
|
|
409
|
-
return { output: await run(WEB_PROFILE, ['add', source]), usedAllowAllBuilds: false };
|
|
410
|
-
}
|
|
411
|
-
catch (err) {
|
|
412
|
-
const text = err instanceof Error ? err.message : String(err);
|
|
413
|
-
if (text.includes('ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF')) {
|
|
414
|
-
await run(WEB_PROFILE, ['install', '--no-frozen-lockfile']);
|
|
567
|
+
const opts = (signal) => (signal !== undefined ? { signal } : undefined);
|
|
568
|
+
return async (source, profileDir, signal) => {
|
|
569
|
+
const retryAfterPrepare = async () => {
|
|
570
|
+
// Y2(终审复审):allowAll 写入本身失败也必须转换为结果,维持「永不 throw」契约
|
|
415
571
|
try {
|
|
416
|
-
|
|
572
|
+
allowAllBuilds(profileDir);
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
const text = errText(err);
|
|
576
|
+
return { class: 'hard-fail', output: truncateOutput(text) };
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
const output = await run(WEB_PROFILE, ['add', source], opts(signal));
|
|
580
|
+
return { class: 'ok', output: truncateOutput(output), usedAllowAllBuilds: true };
|
|
417
581
|
}
|
|
418
582
|
catch (retryErr) {
|
|
419
|
-
|
|
420
|
-
|
|
583
|
+
return { ...classifyPnpmError(errText(retryErr)), output: truncateOutput(errText(retryErr)) };
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
try {
|
|
587
|
+
const output = await run(WEB_PROFILE, ['add', source], opts(signal));
|
|
588
|
+
return { class: 'ok', output: truncateOutput(output), usedAllowAllBuilds: false };
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
const text = errText(err);
|
|
592
|
+
if (text.includes(PNPM_OUTCOME_CODES.PUBLIC_HOIST_PATTERN_DIFF)) {
|
|
593
|
+
try {
|
|
594
|
+
await run(WEB_PROFILE, ['install', '--no-frozen-lockfile'], opts(signal));
|
|
595
|
+
}
|
|
596
|
+
catch (rebuildErr) {
|
|
597
|
+
const rebuildText = errText(rebuildErr);
|
|
598
|
+
return { ...classifyPnpmError(rebuildText), output: truncateOutput(rebuildText) };
|
|
421
599
|
}
|
|
422
|
-
|
|
600
|
+
try {
|
|
601
|
+
const output = await run(WEB_PROFILE, ['add', source], opts(signal));
|
|
602
|
+
return { class: 'ok', output: truncateOutput(output), usedAllowAllBuilds: false };
|
|
603
|
+
}
|
|
604
|
+
catch (retryErr) {
|
|
605
|
+
const retryText = errText(retryErr);
|
|
606
|
+
if (isPrepareBlocked(retryText))
|
|
607
|
+
return retryAfterPrepare();
|
|
608
|
+
return { ...classifyPnpmError(retryText), output: truncateOutput(retryText) };
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (!isPrepareBlocked(text)) {
|
|
612
|
+
return { ...classifyPnpmError(text), output: truncateOutput(text) };
|
|
423
613
|
}
|
|
614
|
+
return retryAfterPrepare();
|
|
424
615
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
function errText(err) {
|
|
619
|
+
return err instanceof Error ? err.message : String(err);
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* 安装。返回 usedAllowAllBuilds 供 UI 明确报告「该插件执行了构建脚本」。
|
|
623
|
+
* source 形如:`pkg@1.2.3`(npm 精确锁定)或 `github:owner/repo#sha`(锁 SHA)。
|
|
624
|
+
* 薄包装:调 makeAddViaLadder,非 ok 时 throw(对 legacy 调用方保持现行报错形状)。
|
|
625
|
+
*/
|
|
626
|
+
export async function addDshPlugin(source, deps = {}) {
|
|
627
|
+
const ladder = makeAddViaLadder({
|
|
628
|
+
runDshPlugin: deps.runDshPlugin ?? runDshPlugin,
|
|
629
|
+
allowAllBuilds: deps.allowAllBuilds,
|
|
630
|
+
});
|
|
631
|
+
const outcome = await ladder(source, deps.profileDir ?? webProfileDir());
|
|
632
|
+
if (outcome.class === 'ok') {
|
|
633
|
+
return { output: outcome.output, usedAllowAllBuilds: outcome.usedAllowAllBuilds === true };
|
|
428
634
|
}
|
|
635
|
+
throw rewritePnpmError(new Error(outcome.output));
|
|
429
636
|
}
|
|
430
637
|
/** 卸载(转发 pnpm remove;调用方须先做 live-disable)。 */
|
|
431
638
|
export async function removeDshPlugin(pkg, deps = {}) {
|
|
432
639
|
if (!isSafePluginTarget(pkg))
|
|
433
640
|
throw new Error(`无效插件包名: ${pkg}`);
|
|
434
641
|
const run = deps.runDshPlugin ?? runDshPlugin;
|
|
435
|
-
return run(WEB_PROFILE, ['remove', pkg]
|
|
642
|
+
return run(WEB_PROFILE, ['remove', pkg], deps.profileDir !== undefined || deps.signal !== undefined
|
|
643
|
+
? { profileDir: deps.profileDir, signal: deps.signal }
|
|
644
|
+
: undefined);
|
|
645
|
+
}
|
|
646
|
+
function rawOutcome(text) {
|
|
647
|
+
const out = String(text ?? '');
|
|
648
|
+
return { ...classifyPnpmError(out), output: out.length <= 800 ? out : out.slice(-800) };
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* 生产 runner:add 走 makeAddViaLadder(prepare/hoist 在途自愈在 adapter 内耗尽);
|
|
652
|
+
* remove 包 removeDshPlugin(原始文本分类);frozen/rebuild 直接 spawn pnpm。
|
|
653
|
+
* 四操作统一收 signal;output 截 800。
|
|
654
|
+
*/
|
|
655
|
+
export function makeDshRunner(profileDir) {
|
|
656
|
+
const ladder = makeAddViaLadder({ runDshPlugin });
|
|
657
|
+
return {
|
|
658
|
+
async add(spec, signal) {
|
|
659
|
+
return ladder(spec, profileDir, signal);
|
|
660
|
+
},
|
|
661
|
+
async remove(pkg, signal) {
|
|
662
|
+
try {
|
|
663
|
+
const output = await removeDshPlugin(pkg, { profileDir, signal });
|
|
664
|
+
return { class: 'ok', output: output.length <= 800 ? output : output.slice(-800) };
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
return rawOutcome(errText(err));
|
|
668
|
+
}
|
|
669
|
+
},
|
|
670
|
+
async frozenInstall(signal) {
|
|
671
|
+
try {
|
|
672
|
+
// F1-R:detached 使 pnpm(及其后代)进入独立进程组,abort/超时可整组终止
|
|
673
|
+
const output = await runCommand('pnpm', ['--dir', profileDir, 'install', '--frozen-lockfile'], {
|
|
674
|
+
timeoutMs: installTimeoutMs(),
|
|
675
|
+
signal,
|
|
676
|
+
detached: process.platform !== 'win32',
|
|
677
|
+
});
|
|
678
|
+
return { class: 'ok', output: output.length <= 800 ? output : output.slice(-800) };
|
|
679
|
+
}
|
|
680
|
+
catch (err) {
|
|
681
|
+
return rawOutcome(errText(err));
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
async rebuildInstall(signal) {
|
|
685
|
+
try {
|
|
686
|
+
const output = await runCommand('pnpm', ['--dir', profileDir, 'install', '--no-frozen-lockfile'], {
|
|
687
|
+
timeoutMs: installTimeoutMs(),
|
|
688
|
+
signal,
|
|
689
|
+
detached: process.platform !== 'win32',
|
|
690
|
+
});
|
|
691
|
+
return { class: 'ok', output: output.length <= 800 ? output : output.slice(-800) };
|
|
692
|
+
}
|
|
693
|
+
catch (err) {
|
|
694
|
+
return rawOutcome(errText(err));
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
};
|
|
436
698
|
}
|
package/lib/core/host-api.js
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* 顶层必须是非 null/非数组对象且有 method → `ping` 跳过 guard,否则
|
|
7
7
|
* trustedRestartRequest host-equivalence guard → typed method/业务错误映射 → 其他 500。
|
|
8
8
|
*/
|
|
9
|
-
import { BOOT_ID,
|
|
9
|
+
import { BOOT_ID, publicInstallStatus } from './dsh-cli.js';
|
|
10
10
|
import { listInstalledWithMeta, listMarket, installFromRegistry, uninstallPlugin, upgradePlugin, } from './market.js';
|
|
11
|
+
import { runProfileTransaction, TransactionError, makeNpmWarmPackument } from './profile-transaction.js';
|
|
11
12
|
import { readInstalledPluginReadme } from './installed.js';
|
|
12
13
|
import { isNewerVersion, npmLatest } from './versions.js';
|
|
13
14
|
import { RegistryConfigError } from './registry-controller.js';
|
|
@@ -129,6 +130,25 @@ function errorStatus(err) {
|
|
|
129
130
|
}
|
|
130
131
|
if (err instanceof ApiProtocolError)
|
|
131
132
|
return { status: err.status, payload: { ok: false, error: err.message } };
|
|
133
|
+
if (err instanceof TransactionError) {
|
|
134
|
+
// detail 白名单投影:结构化事实,不含 raw output(GUI 只读 error,零改动)
|
|
135
|
+
const r = err.result;
|
|
136
|
+
return {
|
|
137
|
+
status: 500,
|
|
138
|
+
payload: {
|
|
139
|
+
ok: false,
|
|
140
|
+
error: err.message,
|
|
141
|
+
detail: {
|
|
142
|
+
status: r.status,
|
|
143
|
+
kind: r.kind,
|
|
144
|
+
failure: r.failure,
|
|
145
|
+
healActions: r.healActions,
|
|
146
|
+
snapshotRestoreVerified: r.snapshotRestoreVerified,
|
|
147
|
+
profileConverged: r.profileConverged,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
132
152
|
return { status: 500, payload: { ok: false, error: err instanceof Error ? err.message : String(err) } };
|
|
133
153
|
}
|
|
134
154
|
export function createApiDispatcher(ctx) {
|
|
@@ -140,6 +160,7 @@ export function createApiDispatcher(ctx) {
|
|
|
140
160
|
upgradePlugin: ctx.deps?.upgradePlugin ?? upgradePlugin,
|
|
141
161
|
checkRegistryEntries: ctx.deps?.checkRegistryEntries ?? checkRegistryEntries,
|
|
142
162
|
npmLatest: ctx.deps?.npmLatest ?? npmLatest,
|
|
163
|
+
runTransaction: ctx.deps?.runTransaction ?? runProfileTransaction,
|
|
143
164
|
};
|
|
144
165
|
const cfg = () => ctx.controller.config;
|
|
145
166
|
return async function handleApi(req, res) {
|
|
@@ -165,8 +186,19 @@ export function createApiDispatcher(ctx) {
|
|
|
165
186
|
}
|
|
166
187
|
case 'self-upgrade': {
|
|
167
188
|
const latest = await d.npmLatest(ctx.pkg.name, cfg().timeoutMs ?? 20_000);
|
|
168
|
-
|
|
169
|
-
|
|
189
|
+
// 缺 integrity 一律 fail closed,不进事务
|
|
190
|
+
if (!latest.integrity) {
|
|
191
|
+
throw new Error(`npm metadata 缺少 dist integrity:${ctx.pkg.name}@${latest.version},拒绝升级`);
|
|
192
|
+
}
|
|
193
|
+
const result = await d.runTransaction({ kind: 'install-npm', pkg: ctx.pkg.name, version: latest.version, integrity: latest.integrity, signal }, { warmPackument: makeNpmWarmPackument(cfg().timeoutMs ?? 20_000) });
|
|
194
|
+
if (!result.ok)
|
|
195
|
+
throw new TransactionError(result);
|
|
196
|
+
payload = {
|
|
197
|
+
pkg: ctx.pkg.name,
|
|
198
|
+
version: latest.version,
|
|
199
|
+
usedAllowAllBuilds: result.usedAllowAllBuilds === true,
|
|
200
|
+
needsRestart: true,
|
|
201
|
+
};
|
|
170
202
|
break;
|
|
171
203
|
}
|
|
172
204
|
case 'registry': {
|
|
@@ -220,7 +252,7 @@ export function createApiDispatcher(ctx) {
|
|
|
220
252
|
if (!id)
|
|
221
253
|
throw new ApiProtocolError(400, '缺少 id');
|
|
222
254
|
const version = typeof body.version === 'string' ? body.version : undefined;
|
|
223
|
-
const result = await
|
|
255
|
+
const result = await d.installFromRegistry(id, cfg(), { version, namespace: 'host', signal });
|
|
224
256
|
payload = { ...result };
|
|
225
257
|
break;
|
|
226
258
|
}
|
|
@@ -228,7 +260,7 @@ export function createApiDispatcher(ctx) {
|
|
|
228
260
|
const target = strArg(body, 'pkg');
|
|
229
261
|
if (!target)
|
|
230
262
|
throw new ApiProtocolError(400, '缺少 pkg');
|
|
231
|
-
const result = await
|
|
263
|
+
const result = await d.uninstallPlugin(target, cfg(), { namespace: 'host', signal });
|
|
232
264
|
payload = { ...result };
|
|
233
265
|
break;
|
|
234
266
|
}
|
|
@@ -236,7 +268,7 @@ export function createApiDispatcher(ctx) {
|
|
|
236
268
|
const target = strArg(body, 'pkg');
|
|
237
269
|
if (!target)
|
|
238
270
|
throw new ApiProtocolError(400, '缺少 pkg');
|
|
239
|
-
const result = await
|
|
271
|
+
const result = await d.upgradePlugin(target, cfg(), { namespace: 'host', signal });
|
|
240
272
|
payload = { ...result };
|
|
241
273
|
break;
|
|
242
274
|
}
|