dsh-m 0.2.8 → 0.2.10
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/client.js +319 -239
- package/lib/core/dsh-cli.js +245 -52
- package/lib/core/host-api.js +38 -6
- package/lib/core/installed.js +89 -38
- package/lib/core/market.js +52 -301
- 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 +15 -24
- package/package.json +2 -1
- package/registry.json +33 -0
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
|
}
|
|
@@ -274,19 +314,6 @@ function writeDangerouslyAllowAllBuilds(profileDirectory) {
|
|
|
274
314
|
writeFileSync(file, next);
|
|
275
315
|
return true;
|
|
276
316
|
}
|
|
277
|
-
export function rewritePnpmError(err) {
|
|
278
|
-
const text = err instanceof Error ? err.message : String(err);
|
|
279
|
-
if (/ERR_PNPM_UNUSED_PATCH/.test(text)) {
|
|
280
|
-
return new Error('profile 的补丁配置(patchedDependencies)里存在不再使用的条目,pnpm 拒绝执行。卸载时 dsh-m 会自动摘除目标包自己的补丁条目;仍报此错通常是其他包留有失效补丁,请手工清理 profile 的 pnpm-workspace.yaml。');
|
|
281
|
-
}
|
|
282
|
-
if (isPrepareBlocked(text)) {
|
|
283
|
-
return new Error('该插件需要执行构建脚本(prepare),pnpm 默认拦截。dsh-m 已写入 profile 的 dangerouslyAllowAllBuilds 并重试;若仍失败请检查 web profile 是否可写。');
|
|
284
|
-
}
|
|
285
|
-
if (/ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF/.test(text)) {
|
|
286
|
-
return new Error('当前 profile 的 node_modules 由不同主版本的 pnpm 生成,安装前需要先重建依赖。');
|
|
287
|
-
}
|
|
288
|
-
return err instanceof Error ? err : new Error(text);
|
|
289
|
-
}
|
|
290
317
|
/**
|
|
291
318
|
* 失败摘要:命令失败时从完整输出里提取可诊断的行,而不是盲取末尾。
|
|
292
319
|
* 2026-09-05 实证(dsh-better-sidebar 安装失败):pnpm ndjson 错误行 ~1.2KB,
|
|
@@ -327,8 +354,33 @@ export function errorDigest(out, maxChars = 800) {
|
|
|
327
354
|
}
|
|
328
355
|
return text.slice(-maxChars);
|
|
329
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* 停止宽限(毫秒)归一:允许显式 0(立即 SIGKILL);负数/NaN/Infinity/垃圾值一律回落
|
|
359
|
+
* 缺省。Y1(第四轮复审):`Number(raw) || 5000` 会把 -1 当真值、把显式 0 吞成缺省。
|
|
360
|
+
*/
|
|
361
|
+
const KILL_GRACE_DEFAULT_MS = 5_000;
|
|
362
|
+
function normalizeKillGrace(raw) {
|
|
363
|
+
const value = typeof raw === 'number' ? raw : Number(raw);
|
|
364
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
365
|
+
}
|
|
366
|
+
export { normalizeKillGrace };
|
|
367
|
+
function killGraceDefault() {
|
|
368
|
+
const raw = process.env.DSH_KILL_GRACE_MS;
|
|
369
|
+
if (raw === undefined || raw.trim() === '')
|
|
370
|
+
return KILL_GRACE_DEFAULT_MS;
|
|
371
|
+
return normalizeKillGrace(raw) ?? KILL_GRACE_DEFAULT_MS;
|
|
372
|
+
}
|
|
373
|
+
/** SIGKILL 后组存活轮询的硬上限(毫秒):防 D 态进程导致永不 settle。 */
|
|
374
|
+
const GROUP_POLL_CAP_MS = 10_000;
|
|
330
375
|
export async function runCommand(command, args, options) {
|
|
331
376
|
return new Promise((resolvePromise, reject) => {
|
|
377
|
+
// R4a(终审复审):AbortSignal 的事件不会对后注册的 listener 重放——调用前已取消的
|
|
378
|
+
// signal 必须在 spawn 前拒绝,否则命令会完整执行副作用。检查与 listener 注册之间为
|
|
379
|
+
// 同步代码,无交织窗口。
|
|
380
|
+
if (options.signal?.aborted) {
|
|
381
|
+
reject(commandAbortError());
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
332
384
|
const child = spawn(command, args, {
|
|
333
385
|
cwd: options.cwd,
|
|
334
386
|
env: { ...process.env, ...options.env, CI: 'true' },
|
|
@@ -338,41 +390,89 @@ export async function runCommand(command, args, options) {
|
|
|
338
390
|
});
|
|
339
391
|
let out = '';
|
|
340
392
|
let settled = false;
|
|
393
|
+
let stopping = false;
|
|
394
|
+
let pendingError;
|
|
395
|
+
let killTimer;
|
|
396
|
+
let pollTimer;
|
|
397
|
+
let pollDeadline = 0;
|
|
398
|
+
// 组语义仅 POSIX + detached(child 是组长)才成立;Windows/非 detached 只保证 direct child
|
|
399
|
+
const groupSupported = process.platform !== 'win32' && child.pid !== undefined;
|
|
400
|
+
const groupAlive = () => {
|
|
401
|
+
if (!groupSupported)
|
|
402
|
+
return false;
|
|
403
|
+
try {
|
|
404
|
+
process.kill(-child.pid, 0);
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
// ESRCH = 组不存在;EPERM 等保守视为仍存活
|
|
409
|
+
return err.code !== 'ESRCH';
|
|
410
|
+
}
|
|
411
|
+
};
|
|
341
412
|
const finish = (err) => {
|
|
342
413
|
if (settled)
|
|
343
414
|
return;
|
|
344
415
|
settled = true;
|
|
345
416
|
clearTimeout(timer);
|
|
417
|
+
if (killTimer !== undefined)
|
|
418
|
+
clearTimeout(killTimer);
|
|
419
|
+
if (pollTimer !== undefined)
|
|
420
|
+
clearTimeout(pollTimer);
|
|
346
421
|
options.signal?.removeEventListener('abort', onAbort);
|
|
347
422
|
if (err)
|
|
348
423
|
reject(err);
|
|
349
424
|
else
|
|
350
425
|
resolvePromise(out);
|
|
351
426
|
};
|
|
352
|
-
const
|
|
353
|
-
if (
|
|
427
|
+
const signalGroup = (sig) => {
|
|
428
|
+
if (groupSupported) {
|
|
354
429
|
try {
|
|
355
|
-
process.kill(-child.pid,
|
|
430
|
+
process.kill(-child.pid, sig);
|
|
356
431
|
return;
|
|
357
432
|
}
|
|
358
433
|
catch {
|
|
359
|
-
/* fall through */
|
|
434
|
+
/* fall through:组可能已消失 */
|
|
360
435
|
}
|
|
361
436
|
}
|
|
362
437
|
try {
|
|
363
|
-
child.kill(
|
|
438
|
+
child.kill(sig);
|
|
364
439
|
}
|
|
365
440
|
catch {
|
|
366
441
|
/* already gone */
|
|
367
442
|
}
|
|
368
443
|
};
|
|
444
|
+
const pollGroupGone = () => {
|
|
445
|
+
if (settled)
|
|
446
|
+
return;
|
|
447
|
+
// SIGKILL 已发:组消失(或超过轮询硬上限,防 D 态进程)才 settle
|
|
448
|
+
if (!groupAlive() || Date.now() > pollDeadline) {
|
|
449
|
+
finish(pendingError);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
pollTimer = setTimeout(pollGroupGone, 25);
|
|
453
|
+
};
|
|
454
|
+
/**
|
|
455
|
+
* F1-R(第三轮复审):停止协议以「进程组不存在」为 settle 前提——SIGTERM 进程组 →
|
|
456
|
+
* 宽限后 SIGKILL → 轮询确认组消失。direct child 提前 close 不清除 kill 定时器
|
|
457
|
+
* (同组后代可能仍忽略 SIGTERM 并写 profile)。
|
|
458
|
+
*/
|
|
459
|
+
const stopChild = (err) => {
|
|
460
|
+
if (settled || stopping)
|
|
461
|
+
return;
|
|
462
|
+
stopping = true;
|
|
463
|
+
pendingError = err;
|
|
464
|
+
signalGroup('SIGTERM');
|
|
465
|
+
killTimer = setTimeout(() => {
|
|
466
|
+
signalGroup('SIGKILL');
|
|
467
|
+
pollDeadline = Date.now() + GROUP_POLL_CAP_MS;
|
|
468
|
+
pollGroupGone();
|
|
469
|
+
}, normalizeKillGrace(options.killGraceMs) ?? killGraceDefault());
|
|
470
|
+
};
|
|
369
471
|
const timer = setTimeout(() => {
|
|
370
|
-
|
|
371
|
-
finish(new Error(`命令超时 ${options.timeoutMs}ms`));
|
|
472
|
+
stopChild(new Error(`命令超时 ${options.timeoutMs}ms`));
|
|
372
473
|
}, options.timeoutMs);
|
|
373
474
|
const onAbort = () => {
|
|
374
|
-
|
|
375
|
-
finish(new Error('命令已取消'));
|
|
475
|
+
stopChild(commandAbortError());
|
|
376
476
|
};
|
|
377
477
|
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
378
478
|
child.stdout?.on('data', (chunk) => {
|
|
@@ -385,8 +485,20 @@ export async function runCommand(command, args, options) {
|
|
|
385
485
|
out = (out + text).slice(-256 * 1024);
|
|
386
486
|
options.onChunk?.(text);
|
|
387
487
|
});
|
|
388
|
-
child.on('error', (err) =>
|
|
488
|
+
child.on('error', (err) => {
|
|
489
|
+
// F1-R:停止协议进行中,迟到的 error(如 kill 竞态)只作诊断,不得提前收口
|
|
490
|
+
if (stopping)
|
|
491
|
+
return;
|
|
492
|
+
finish(err);
|
|
493
|
+
});
|
|
389
494
|
child.on('close', (code) => {
|
|
495
|
+
if (stopping) {
|
|
496
|
+
// direct child close ≠ 进程组停止:组仍存活 → 保留 SIGKILL 定时器/轮询,不 settle
|
|
497
|
+
if (groupAlive())
|
|
498
|
+
return;
|
|
499
|
+
finish(pendingError);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
390
502
|
if (code === 0)
|
|
391
503
|
finish();
|
|
392
504
|
else
|
|
@@ -409,6 +521,7 @@ export async function runDshPlugin(profile, pluginArgs, deps = {}) {
|
|
|
409
521
|
return await run(argv.file, [...argv.args, 'plugin', '--profile', profile, ...prepared], {
|
|
410
522
|
cwd: argv.cwd,
|
|
411
523
|
timeoutMs: deps.timeoutMs ?? installTimeoutMs(),
|
|
524
|
+
signal: deps.signal,
|
|
412
525
|
env: { CI: 'true' },
|
|
413
526
|
viaShell: argv.viaShell,
|
|
414
527
|
detached: process.platform !== 'win32',
|
|
@@ -430,47 +543,127 @@ export async function runDshPlugin(profile, pluginArgs, deps = {}) {
|
|
|
430
543
|
}
|
|
431
544
|
}
|
|
432
545
|
/**
|
|
433
|
-
*
|
|
434
|
-
*
|
|
546
|
+
* 加装阶梯工厂(导出、可注入、可测试;生产与测试共用同一实现)。
|
|
547
|
+
* 阶梯:add → prepare 被拦时写 dangerouslyAllowAllBuilds 重试 →
|
|
548
|
+
* PUBLIC_HOIST_PATTERN_DIFF 时 `install --no-frozen-lockfile` 重建后重试 →
|
|
549
|
+
* 耗尽归类返回。对**原始错误文本**分类;永不 throw,一律返回 RunnerOutcome。
|
|
435
550
|
*/
|
|
436
|
-
export
|
|
437
|
-
const run = deps.runDshPlugin
|
|
551
|
+
export function makeAddViaLadder(deps) {
|
|
552
|
+
const run = deps.runDshPlugin;
|
|
438
553
|
const allowAllBuilds = deps.allowAllBuilds ?? writeDangerouslyAllowAllBuilds;
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
444
|
-
catch (retryErr) {
|
|
445
|
-
throw rewritePnpmError(retryErr);
|
|
446
|
-
}
|
|
447
|
-
};
|
|
448
|
-
try {
|
|
449
|
-
return { output: await run(WEB_PROFILE, ['add', source]), usedAllowAllBuilds: false };
|
|
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']);
|
|
554
|
+
const opts = (signal) => (signal !== undefined ? { signal } : undefined);
|
|
555
|
+
return async (source, profileDir, signal) => {
|
|
556
|
+
const retryAfterPrepare = async () => {
|
|
557
|
+
// Y2(终审复审):allowAll 写入本身失败也必须转换为结果,维持「永不 throw」契约
|
|
455
558
|
try {
|
|
456
|
-
|
|
559
|
+
allowAllBuilds(profileDir);
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
const text = errText(err);
|
|
563
|
+
return { class: 'hard-fail', output: truncateOutput(text) };
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
const output = await run(WEB_PROFILE, ['add', source], opts(signal));
|
|
567
|
+
return { class: 'ok', output: truncateOutput(output), usedAllowAllBuilds: true };
|
|
457
568
|
}
|
|
458
569
|
catch (retryErr) {
|
|
459
|
-
|
|
460
|
-
|
|
570
|
+
return { ...classifyPnpmError(errText(retryErr)), output: truncateOutput(errText(retryErr)) };
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
try {
|
|
574
|
+
const output = await run(WEB_PROFILE, ['add', source], opts(signal));
|
|
575
|
+
return { class: 'ok', output: truncateOutput(output), usedAllowAllBuilds: false };
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
const text = errText(err);
|
|
579
|
+
if (text.includes(PNPM_OUTCOME_CODES.PUBLIC_HOIST_PATTERN_DIFF)) {
|
|
580
|
+
try {
|
|
581
|
+
await run(WEB_PROFILE, ['install', '--no-frozen-lockfile'], opts(signal));
|
|
582
|
+
}
|
|
583
|
+
catch (rebuildErr) {
|
|
584
|
+
const rebuildText = errText(rebuildErr);
|
|
585
|
+
return { ...classifyPnpmError(rebuildText), output: truncateOutput(rebuildText) };
|
|
461
586
|
}
|
|
462
|
-
|
|
587
|
+
try {
|
|
588
|
+
const output = await run(WEB_PROFILE, ['add', source], opts(signal));
|
|
589
|
+
return { class: 'ok', output: truncateOutput(output), usedAllowAllBuilds: false };
|
|
590
|
+
}
|
|
591
|
+
catch (retryErr) {
|
|
592
|
+
const retryText = errText(retryErr);
|
|
593
|
+
if (isPrepareBlocked(retryText))
|
|
594
|
+
return retryAfterPrepare();
|
|
595
|
+
return { ...classifyPnpmError(retryText), output: truncateOutput(retryText) };
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (!isPrepareBlocked(text)) {
|
|
599
|
+
return { ...classifyPnpmError(text), output: truncateOutput(text) };
|
|
463
600
|
}
|
|
601
|
+
return retryAfterPrepare();
|
|
464
602
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
function errText(err) {
|
|
606
|
+
return err instanceof Error ? err.message : String(err);
|
|
469
607
|
}
|
|
470
608
|
/** 卸载(转发 pnpm remove;调用方须先做 live-disable)。 */
|
|
471
609
|
export async function removeDshPlugin(pkg, deps = {}) {
|
|
472
610
|
if (!isSafePluginTarget(pkg))
|
|
473
611
|
throw new Error(`无效插件包名: ${pkg}`);
|
|
474
612
|
const run = deps.runDshPlugin ?? runDshPlugin;
|
|
475
|
-
return run(WEB_PROFILE, ['remove', pkg]
|
|
613
|
+
return run(WEB_PROFILE, ['remove', pkg], deps.profileDir !== undefined || deps.signal !== undefined
|
|
614
|
+
? { profileDir: deps.profileDir, signal: deps.signal }
|
|
615
|
+
: undefined);
|
|
616
|
+
}
|
|
617
|
+
function rawOutcome(text) {
|
|
618
|
+
const out = String(text ?? '');
|
|
619
|
+
return { ...classifyPnpmError(out), output: out.length <= 800 ? out : out.slice(-800) };
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* 生产 runner:add 走 makeAddViaLadder(prepare/hoist 在途自愈在 adapter 内耗尽);
|
|
623
|
+
* remove 包 removeDshPlugin(原始文本分类);frozen/rebuild 直接 spawn pnpm。
|
|
624
|
+
* 四操作统一收 signal;output 截 800。
|
|
625
|
+
*/
|
|
626
|
+
export function makeDshRunner(profileDir) {
|
|
627
|
+
const ladder = makeAddViaLadder({ runDshPlugin });
|
|
628
|
+
return {
|
|
629
|
+
async add(spec, signal) {
|
|
630
|
+
return ladder(spec, profileDir, signal);
|
|
631
|
+
},
|
|
632
|
+
async remove(pkg, signal) {
|
|
633
|
+
try {
|
|
634
|
+
const output = await removeDshPlugin(pkg, { profileDir, signal });
|
|
635
|
+
return { class: 'ok', output: output.length <= 800 ? output : output.slice(-800) };
|
|
636
|
+
}
|
|
637
|
+
catch (err) {
|
|
638
|
+
return rawOutcome(errText(err));
|
|
639
|
+
}
|
|
640
|
+
},
|
|
641
|
+
async frozenInstall(signal) {
|
|
642
|
+
try {
|
|
643
|
+
// F1-R:detached 使 pnpm(及其后代)进入独立进程组,abort/超时可整组终止
|
|
644
|
+
const output = await runCommand('pnpm', ['--dir', profileDir, 'install', '--frozen-lockfile'], {
|
|
645
|
+
timeoutMs: installTimeoutMs(),
|
|
646
|
+
signal,
|
|
647
|
+
detached: process.platform !== 'win32',
|
|
648
|
+
});
|
|
649
|
+
return { class: 'ok', output: output.length <= 800 ? output : output.slice(-800) };
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
return rawOutcome(errText(err));
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
async rebuildInstall(signal) {
|
|
656
|
+
try {
|
|
657
|
+
const output = await runCommand('pnpm', ['--dir', profileDir, 'install', '--no-frozen-lockfile'], {
|
|
658
|
+
timeoutMs: installTimeoutMs(),
|
|
659
|
+
signal,
|
|
660
|
+
detached: process.platform !== 'win32',
|
|
661
|
+
});
|
|
662
|
+
return { class: 'ok', output: output.length <= 800 ? output : output.slice(-800) };
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
return rawOutcome(errText(err));
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
};
|
|
476
669
|
}
|
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
|
}
|
package/lib/core/installed.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 已装插件识别(DESIGN.md §3):profile 的 package.json 是唯一事实源,
|
|
3
3
|
* 不引入额外状态文件。移植自 skillhub installed-plugins.ts(去 README 暂缓)。
|
|
4
|
+
*
|
|
5
|
+
* 完整性契约(2026-09-09):枚举结果必含 `complete`——只有当顶层 manifest 与每个
|
|
6
|
+
* 依赖的 package.json 都可读、可解析为非数组对象、且每项都能归类(DSH 插件 → items /
|
|
7
|
+
* 确认非 DSH → others)时才为 true;任一项「无法判断」即 complete:false(partial 结果
|
|
8
|
+
* 保留,该依赖不计入 others)。当前 complete 仅被 listMarket → dshm_search 消费;
|
|
9
|
+
* 已装列表(listInstalledWithMeta)/ dshm_list / CLI list·outdated / 升级路径的
|
|
10
|
+
* incomplete 展示与处理为后续独立任务。
|
|
4
11
|
*/
|
|
5
12
|
import { open, readFile } from 'node:fs/promises';
|
|
6
13
|
import { join, resolve } from 'node:path';
|
|
7
|
-
import { isSafePluginTarget, removeDshPlugin } from './dsh-cli.js';
|
|
8
14
|
import { webProfileDir } from './env.js';
|
|
9
15
|
const PKG_NAME_RE = /^(@[A-Za-z0-9-*~][A-Za-z0-9-*._~]*\/)?[A-Za-z0-9-._~]+$/;
|
|
10
16
|
export function isSafePkgName(raw) {
|
|
@@ -58,30 +64,74 @@ export function githubRepoFromRepository(raw) {
|
|
|
58
64
|
const m = /github\.com[/:]([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?$/i.exec(url.trim());
|
|
59
65
|
return m ? `${m[1]}/${m[2]}` : null;
|
|
60
66
|
}
|
|
61
|
-
|
|
67
|
+
/** JSON 合法根:非 null、非数组的对象(typeof [] === 'object',数组必须显式排除)。 */
|
|
68
|
+
function isRecord(value) {
|
|
69
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
70
|
+
}
|
|
71
|
+
/** 单包 package.json 唯一读取实现:读不到 / 坏 JSON / 根非对象一律 ok:false。 */
|
|
72
|
+
async function readPkgJsonResult(dir) {
|
|
73
|
+
let raw;
|
|
62
74
|
try {
|
|
63
|
-
|
|
64
|
-
return raw && typeof raw === 'object' ? raw : null;
|
|
75
|
+
raw = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'));
|
|
65
76
|
}
|
|
66
77
|
catch {
|
|
67
|
-
return
|
|
78
|
+
return { ok: false };
|
|
68
79
|
}
|
|
80
|
+
if (!isRecord(raw))
|
|
81
|
+
return { ok: false };
|
|
82
|
+
return { ok: true, value: raw };
|
|
69
83
|
}
|
|
70
|
-
export async function
|
|
84
|
+
export async function readPkgJson(dir) {
|
|
85
|
+
const result = await readPkgJsonResult(dir);
|
|
86
|
+
return result.ok ? result.value ?? null : null;
|
|
87
|
+
}
|
|
88
|
+
/** 空 deps 统一无原型容器:与逐项解析产物保持同一原型语义,继承属性不得伪装成依赖成员。 */
|
|
89
|
+
function emptyDeps() {
|
|
90
|
+
return Object.create(null);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 顶层 profile package.json 唯一读取实现(完整性 + partial 语义):
|
|
94
|
+
* - 读不到 / 坏 JSON / 根非数组对象 → complete:false, deps:{}(manifest 缺失 ≠ 合法空 profile);
|
|
95
|
+
* - 合法但无 dependencies 字段,或 dependencies 为空对象 → complete:true, deps:{}(唯一合法空形态);
|
|
96
|
+
* - dependencies 存在但类型非法(null/数组/标量)→ complete:false, deps:{};
|
|
97
|
+
* - 逐项:key 不安全(isSafePkgName 拒绝 `__proto__`、`_`/`.` 开头分段等)或 spec 非字符串/纯空白
|
|
98
|
+
* → 该项计入 incomplete(complete:false)并跳过,其余合法项保留。
|
|
99
|
+
* 容器为无原型对象(Object.create(null))——第二层防御:特殊属性名不受 Object.prototype
|
|
100
|
+
* setter/继承语义影响,数据结构与早退路径的空容器保持一致。
|
|
101
|
+
*/
|
|
102
|
+
async function readProfileDepsResult(profileDir) {
|
|
103
|
+
let raw;
|
|
71
104
|
try {
|
|
72
|
-
|
|
73
|
-
if (!raw || typeof raw !== 'object' || !raw.dependencies || typeof raw.dependencies !== 'object')
|
|
74
|
-
return {};
|
|
75
|
-
const out = {};
|
|
76
|
-
for (const [name, spec] of Object.entries(raw.dependencies)) {
|
|
77
|
-
if (typeof spec === 'string' && spec !== '')
|
|
78
|
-
out[name] = spec;
|
|
79
|
-
}
|
|
80
|
-
return out;
|
|
105
|
+
raw = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'));
|
|
81
106
|
}
|
|
82
107
|
catch {
|
|
83
|
-
return {};
|
|
108
|
+
return { complete: false, deps: emptyDeps() };
|
|
109
|
+
}
|
|
110
|
+
if (!isRecord(raw))
|
|
111
|
+
return { complete: false, deps: emptyDeps() };
|
|
112
|
+
if (!Object.prototype.hasOwnProperty.call(raw, 'dependencies'))
|
|
113
|
+
return { complete: true, deps: emptyDeps() };
|
|
114
|
+
const dependencies = raw.dependencies;
|
|
115
|
+
if (!isRecord(dependencies))
|
|
116
|
+
return { complete: false, deps: emptyDeps() };
|
|
117
|
+
let complete = true;
|
|
118
|
+
const deps = emptyDeps();
|
|
119
|
+
for (const [name, spec] of Object.entries(dependencies)) {
|
|
120
|
+
if (!isSafePkgName(name)) {
|
|
121
|
+
complete = false;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (typeof spec !== 'string' || spec.trim() === '') {
|
|
125
|
+
complete = false;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
deps[name] = spec;
|
|
84
129
|
}
|
|
130
|
+
return { complete, deps };
|
|
131
|
+
}
|
|
132
|
+
/** 宽松读取(README 路径):只返回可确认的依赖项,个别非法项被跳过而不是整体丢弃。 */
|
|
133
|
+
export async function readProfileDeps(profileDir) {
|
|
134
|
+
return (await readProfileDepsResult(profileDir)).deps;
|
|
85
135
|
}
|
|
86
136
|
function sanitizePkgJson(raw, fallbackName) {
|
|
87
137
|
return {
|
|
@@ -93,17 +143,31 @@ function sanitizePkgJson(raw, fallbackName) {
|
|
|
93
143
|
githubRepo: githubRepoFromRepository(raw.repository),
|
|
94
144
|
};
|
|
95
145
|
}
|
|
96
|
-
/** 枚举 web profile 已安装插件(只读)。 */
|
|
146
|
+
/** 枚举 web profile 已安装插件(只读)。complete:false 时 items/others 为 partial 结果(已确认部分保留)。 */
|
|
97
147
|
export async function listInstalledPlugins(profileDir = webProfileDir()) {
|
|
98
148
|
const root = resolve(profileDir);
|
|
99
|
-
const
|
|
149
|
+
const result = await readProfileDepsResult(root);
|
|
150
|
+
let complete = result.complete;
|
|
151
|
+
const deps = result.deps;
|
|
100
152
|
const items = [];
|
|
101
153
|
let others = 0;
|
|
102
154
|
for (const pkg of Object.keys(deps).sort()) {
|
|
103
155
|
const spec = deps[pkg];
|
|
104
156
|
const dir = resolvePluginDir(root, pkg, spec);
|
|
105
|
-
|
|
106
|
-
|
|
157
|
+
if (!dir) {
|
|
158
|
+
// 依赖键不安全、目录无法解析 → 无法判断(不冒充非 DSH)
|
|
159
|
+
complete = false;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const rawResult = await readPkgJsonResult(dir);
|
|
163
|
+
if (!rawResult.ok) {
|
|
164
|
+
// package.json 缺失/不可读/坏 JSON/根非对象 → 无法判断
|
|
165
|
+
complete = false;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const raw = rawResult.value;
|
|
169
|
+
if (!('dsh' in raw)) {
|
|
170
|
+
// 确认非 DSH 依赖
|
|
107
171
|
others += 1;
|
|
108
172
|
continue;
|
|
109
173
|
}
|
|
@@ -121,23 +185,7 @@ export async function listInstalledPlugins(profileDir = webProfileDir()) {
|
|
|
121
185
|
githubRepo: githubRepoFromRepository(raw.repository),
|
|
122
186
|
});
|
|
123
187
|
}
|
|
124
|
-
return { items, others, profileDir: root };
|
|
125
|
-
}
|
|
126
|
-
/** 从 web profile 卸载已安装的 dsh 插件。pkg 必须来自 profile 依赖(先 live-disable,见 market.ts)。 */
|
|
127
|
-
export async function removeInstalledPlugin(pkg, profileDir = webProfileDir(), deps = {}) {
|
|
128
|
-
const key = String(pkg || '').trim();
|
|
129
|
-
if (!isSafePkgName(key) || !isSafePluginTarget(key))
|
|
130
|
-
throw new Error(`无效插件包名: ${pkg}`);
|
|
131
|
-
const root = resolve(profileDir);
|
|
132
|
-
const listed = await readProfileDeps(root);
|
|
133
|
-
if (!(key in listed))
|
|
134
|
-
throw new Error(`web profile 未安装该插件: ${key}`);
|
|
135
|
-
const dir = resolvePluginDir(root, key, listed[key]);
|
|
136
|
-
const raw = dir ? await readPkgJson(dir) : null;
|
|
137
|
-
if (!raw || !('dsh' in raw))
|
|
138
|
-
throw new Error(`不是 dsh 插件: ${key}`);
|
|
139
|
-
await removeDshPlugin(key, deps);
|
|
140
|
-
return { pkg: key };
|
|
188
|
+
return { items, others, complete, profileDir: root };
|
|
141
189
|
}
|
|
142
190
|
// ---------- README 预览(借鉴 skillhub,64KB 截断) ----------
|
|
143
191
|
const README_MAX_BYTES = 64 * 1024;
|
|
@@ -170,8 +218,11 @@ export async function readInstalledPluginReadme(pkg, profileDir = webProfileDir(
|
|
|
170
218
|
throw new Error(`无效插件包名: ${pkg}`);
|
|
171
219
|
const root = resolve(profileDir);
|
|
172
220
|
const deps = await readProfileDeps(root);
|
|
173
|
-
|
|
221
|
+
// 授权边界必须用 own-property 判定:`in` 会沿原型链命中继承属性(如 'constructor'),
|
|
222
|
+
// 绕过「pkg 必须来自 profile dependencies」的成员约束
|
|
223
|
+
if (!Object.hasOwn(deps, key)) {
|
|
174
224
|
throw new Error(`web profile 未安装该插件: ${key}`);
|
|
225
|
+
}
|
|
175
226
|
const dir = resolvePluginDir(root, key, deps[key]);
|
|
176
227
|
if (!dir)
|
|
177
228
|
throw new Error(`无法解析插件目录: ${key}`);
|