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 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
- - **安装(npm 源)**:装最新版并以**精确版本锁定**(不用 `^` 范围;用户指定版本必须为精确 semver,经该精确版本 endpoint 查询)。安装前对 profile 的 `package.json` / `pnpm-lock.yaml` / `pnpm-workspace.yaml` 做**字节快照**;安装后核验 importer 依赖为该精确版本(旧版 CLI 写入的 `^v`/`~v` 锚定 range 视为等价放行,2026-09-05 实机实证;精确性由紧随的 lockfile 解析与 integrity 兜底,其余 spec 仍 fail closed),并在 lockfile `packages` 条目中比对与 npm dist 一致的 `resolution.integrity`——缺失或不一致 **fail closed** 并执行 **best-effort dependency rollback**:原子恢复快照字节 + frozen 自愈阶梯(`pnpm install --frozen-lockfile` → 仍报 `CONFIG_MISMATCH` 时把 lockfile 记录的 overrides 对齐回 `package.json#pnpm.overrides` 再复验 → `CONFIG_MISMATCH` / `OUTDATED_LOCKFILE` 顽固失配才降级 `--no-frozen-lockfile` 重建并明确告知「lockfile 已重建」;全阶梯失败才报「可能需要人工修复」)。成功路径若发现安装链丢失了 manifest 顶层未知键(如 `pnpm.overrides`,2026-09-05 升级回滚事故),从快照找回并复验 frozen 一致性,输出带 `[dsh-m 自愈]` 报告。刚发布 ~1 分钟内的 `ERR_PNPM_NO_MATCHING_VERSION` 多为 packument CDN 滞后:退避重试 2 次(5s/15s),每次重试前拉一次完整 packument 预热。不声称 node_modules 与间接依赖已字节级回滚。
67
- - **安装(GitHub 源)**:解析并**锁定 commit SHA**(`github:owner/repo#sha`),skillhub 同款。
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
- - **卸载**:live-disable(先让 client bundle 下线,避免 404)→ 摘除该包在 profile 的补丁条目(`pnpm-workspace.yaml` 顶层 `patchedDependencies` 与 `package.json#pnpm.patchedDependencies`;依赖移除后残留条目会令 pnpm 以 `ERR_PNPM_UNUSED_PATCH` 整单失败,只精确匹配 `pkg` / `pkg@ver`,补丁文件本体保留并计入残留报告)→ `dsh plugin remove`。**不清理插件产生的数据/配置**,但把检测到的疑似残留路径(如 `~/.dsh/<plugin>.json`)列出报告。
70
- - **升级**:**按需检查**(`dshm_outdated` / `dshm_list` 时实时比对本地版本 vs npm latest / GitHub main),半自动——展示升级计划,确认后执行。**不做后台定时器**。
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, withMutationLock, } from './core/market.js';
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 withMutationLock(() => d.installFromRegistry(id, cfg, { version, namespace: 'cli' }));
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 withMutationLock(() => d.upgradePlugin(target, cfg, { namespace: 'cli' }));
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 withMutationLock(() => d.uninstallPlugin(target, cfg, { namespace: 'cli' }));
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('、')}`);
@@ -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 killChild = () => {
313
- if (process.platform !== 'win32' && child.pid !== undefined) {
440
+ const signalGroup = (sig) => {
441
+ if (groupSupported) {
314
442
  try {
315
- process.kill(-child.pid, 'SIGTERM');
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('SIGTERM');
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
- killChild();
331
- finish(new Error(`命令超时 ${options.timeoutMs}ms`));
485
+ stopChild(new Error(`命令超时 ${options.timeoutMs}ms`));
332
486
  }, options.timeoutMs);
333
487
  const onAbort = () => {
334
- killChild();
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) => finish(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.trim().slice(-800) || 'no output'}`));
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
- * 安装。返回 usedAllowAllBuilds 供 UI 明确报告「该插件执行了构建脚本」。
394
- * source 形如:`pkg@1.2.3`(npm 精确锁定)或 `github:owner/repo#sha`(锁 SHA)。
559
+ * 加装阶梯工厂(导出、可注入、可测试;生产与测试共用同一实现)。
560
+ * 阶梯:add → prepare 被拦时写 dangerouslyAllowAllBuilds 重试 →
561
+ * PUBLIC_HOIST_PATTERN_DIFF 时 `install --no-frozen-lockfile` 重建后重试 →
562
+ * 耗尽归类返回。对**原始错误文本**分类;永不 throw,一律返回 RunnerOutcome。
395
563
  */
396
- export async function addDshPlugin(source, deps = {}) {
397
- const run = deps.runDshPlugin ?? runDshPlugin;
564
+ export function makeAddViaLadder(deps) {
565
+ const run = deps.runDshPlugin;
398
566
  const allowAllBuilds = deps.allowAllBuilds ?? writeDangerouslyAllowAllBuilds;
399
- const retryAfterPrepare = async () => {
400
- const changed = allowAllBuilds(deps.profileDir ?? webProfileDir());
401
- try {
402
- return { output: await run(WEB_PROFILE, ['add', source]), usedAllowAllBuilds: true };
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
- return { output: await run(WEB_PROFILE, ['add', source]), usedAllowAllBuilds: false };
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
- if (!isPrepareBlocked(retryErr instanceof Error ? retryErr.message : String(retryErr))) {
420
- throw rewritePnpmError(retryErr);
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
- return retryAfterPrepare();
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
- if (!isPrepareBlocked(text))
426
- throw rewritePnpmError(err);
427
- return retryAfterPrepare();
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
  }
@@ -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, addDshPlugin, publicInstallStatus } from './dsh-cli.js';
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
- const result = await ctx.onMutation(() => addDshPlugin(`${ctx.pkg.name}@${latest.version}`));
169
- payload = { pkg: ctx.pkg.name, version: latest.version, usedAllowAllBuilds: result.usedAllowAllBuilds, needsRestart: true };
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 ctx.onMutation(() => d.installFromRegistry(id, cfg(), { version, namespace: 'host' }));
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 ctx.onMutation(() => d.uninstallPlugin(target, cfg(), { namespace: 'host' }));
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 ctx.onMutation(() => d.upgradePlugin(target, cfg(), { namespace: 'host' }));
271
+ const result = await d.upgradePlugin(target, cfg(), { namespace: 'host', signal });
240
272
  payload = { ...result };
241
273
  break;
242
274
  }