dsh-m 0.2.8 → 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 +260 -38
- 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
|
}
|
|
@@ -327,8 +367,33 @@ export function errorDigest(out, maxChars = 800) {
|
|
|
327
367
|
}
|
|
328
368
|
return text.slice(-maxChars);
|
|
329
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;
|
|
330
388
|
export async function runCommand(command, args, options) {
|
|
331
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
|
+
}
|
|
332
397
|
const child = spawn(command, args, {
|
|
333
398
|
cwd: options.cwd,
|
|
334
399
|
env: { ...process.env, ...options.env, CI: 'true' },
|
|
@@ -338,41 +403,89 @@ export async function runCommand(command, args, options) {
|
|
|
338
403
|
});
|
|
339
404
|
let out = '';
|
|
340
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
|
+
};
|
|
341
425
|
const finish = (err) => {
|
|
342
426
|
if (settled)
|
|
343
427
|
return;
|
|
344
428
|
settled = true;
|
|
345
429
|
clearTimeout(timer);
|
|
430
|
+
if (killTimer !== undefined)
|
|
431
|
+
clearTimeout(killTimer);
|
|
432
|
+
if (pollTimer !== undefined)
|
|
433
|
+
clearTimeout(pollTimer);
|
|
346
434
|
options.signal?.removeEventListener('abort', onAbort);
|
|
347
435
|
if (err)
|
|
348
436
|
reject(err);
|
|
349
437
|
else
|
|
350
438
|
resolvePromise(out);
|
|
351
439
|
};
|
|
352
|
-
const
|
|
353
|
-
if (
|
|
440
|
+
const signalGroup = (sig) => {
|
|
441
|
+
if (groupSupported) {
|
|
354
442
|
try {
|
|
355
|
-
process.kill(-child.pid,
|
|
443
|
+
process.kill(-child.pid, sig);
|
|
356
444
|
return;
|
|
357
445
|
}
|
|
358
446
|
catch {
|
|
359
|
-
/* fall through */
|
|
447
|
+
/* fall through:组可能已消失 */
|
|
360
448
|
}
|
|
361
449
|
}
|
|
362
450
|
try {
|
|
363
|
-
child.kill(
|
|
451
|
+
child.kill(sig);
|
|
364
452
|
}
|
|
365
453
|
catch {
|
|
366
454
|
/* already gone */
|
|
367
455
|
}
|
|
368
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
|
+
};
|
|
369
484
|
const timer = setTimeout(() => {
|
|
370
|
-
|
|
371
|
-
finish(new Error(`命令超时 ${options.timeoutMs}ms`));
|
|
485
|
+
stopChild(new Error(`命令超时 ${options.timeoutMs}ms`));
|
|
372
486
|
}, options.timeoutMs);
|
|
373
487
|
const onAbort = () => {
|
|
374
|
-
|
|
375
|
-
finish(new Error('命令已取消'));
|
|
488
|
+
stopChild(commandAbortError());
|
|
376
489
|
};
|
|
377
490
|
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
378
491
|
child.stdout?.on('data', (chunk) => {
|
|
@@ -385,8 +498,20 @@ export async function runCommand(command, args, options) {
|
|
|
385
498
|
out = (out + text).slice(-256 * 1024);
|
|
386
499
|
options.onChunk?.(text);
|
|
387
500
|
});
|
|
388
|
-
child.on('error', (err) =>
|
|
501
|
+
child.on('error', (err) => {
|
|
502
|
+
// F1-R:停止协议进行中,迟到的 error(如 kill 竞态)只作诊断,不得提前收口
|
|
503
|
+
if (stopping)
|
|
504
|
+
return;
|
|
505
|
+
finish(err);
|
|
506
|
+
});
|
|
389
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
|
+
}
|
|
390
515
|
if (code === 0)
|
|
391
516
|
finish();
|
|
392
517
|
else
|
|
@@ -409,6 +534,7 @@ export async function runDshPlugin(profile, pluginArgs, deps = {}) {
|
|
|
409
534
|
return await run(argv.file, [...argv.args, 'plugin', '--profile', profile, ...prepared], {
|
|
410
535
|
cwd: argv.cwd,
|
|
411
536
|
timeoutMs: deps.timeoutMs ?? installTimeoutMs(),
|
|
537
|
+
signal: deps.signal,
|
|
412
538
|
env: { CI: 'true' },
|
|
413
539
|
viaShell: argv.viaShell,
|
|
414
540
|
detached: process.platform !== 'win32',
|
|
@@ -430,47 +556,143 @@ export async function runDshPlugin(profile, pluginArgs, deps = {}) {
|
|
|
430
556
|
}
|
|
431
557
|
}
|
|
432
558
|
/**
|
|
433
|
-
*
|
|
434
|
-
*
|
|
559
|
+
* 加装阶梯工厂(导出、可注入、可测试;生产与测试共用同一实现)。
|
|
560
|
+
* 阶梯:add → prepare 被拦时写 dangerouslyAllowAllBuilds 重试 →
|
|
561
|
+
* PUBLIC_HOIST_PATTERN_DIFF 时 `install --no-frozen-lockfile` 重建后重试 →
|
|
562
|
+
* 耗尽归类返回。对**原始错误文本**分类;永不 throw,一律返回 RunnerOutcome。
|
|
435
563
|
*/
|
|
436
|
-
export
|
|
437
|
-
const run = deps.runDshPlugin
|
|
564
|
+
export function makeAddViaLadder(deps) {
|
|
565
|
+
const run = deps.runDshPlugin;
|
|
438
566
|
const allowAllBuilds = deps.allowAllBuilds ?? writeDangerouslyAllowAllBuilds;
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
}
|
|
451
|
-
catch (err) {
|
|
452
|
-
const text = err instanceof Error ? err.message : String(err);
|
|
453
|
-
if (text.includes('ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF')) {
|
|
454
|
-
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」契约
|
|
571
|
+
try {
|
|
572
|
+
allowAllBuilds(profileDir);
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
const text = errText(err);
|
|
576
|
+
return { class: 'hard-fail', output: truncateOutput(text) };
|
|
577
|
+
}
|
|
455
578
|
try {
|
|
456
|
-
|
|
579
|
+
const output = await run(WEB_PROFILE, ['add', source], opts(signal));
|
|
580
|
+
return { class: 'ok', output: truncateOutput(output), usedAllowAllBuilds: true };
|
|
457
581
|
}
|
|
458
582
|
catch (retryErr) {
|
|
459
|
-
|
|
460
|
-
|
|
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) };
|
|
599
|
+
}
|
|
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) };
|
|
461
609
|
}
|
|
462
|
-
return retryAfterPrepare();
|
|
463
610
|
}
|
|
611
|
+
if (!isPrepareBlocked(text)) {
|
|
612
|
+
return { ...classifyPnpmError(text), output: truncateOutput(text) };
|
|
613
|
+
}
|
|
614
|
+
return retryAfterPrepare();
|
|
464
615
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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 };
|
|
468
634
|
}
|
|
635
|
+
throw rewritePnpmError(new Error(outcome.output));
|
|
469
636
|
}
|
|
470
637
|
/** 卸载(转发 pnpm remove;调用方须先做 live-disable)。 */
|
|
471
638
|
export async function removeDshPlugin(pkg, deps = {}) {
|
|
472
639
|
if (!isSafePluginTarget(pkg))
|
|
473
640
|
throw new Error(`无效插件包名: ${pkg}`);
|
|
474
641
|
const run = deps.runDshPlugin ?? runDshPlugin;
|
|
475
|
-
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
|
+
};
|
|
476
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
|
}
|