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/lib/core/market.js
CHANGED
|
@@ -8,16 +8,12 @@
|
|
|
8
8
|
* 新发布后 NO_MATCHING_VERSION 的退避重试 + packument 预热(B3)。
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync } from 'node:fs';
|
|
11
|
-
import { Buffer } from 'node:buffer';
|
|
12
|
-
import { readFile } from 'node:fs/promises';
|
|
13
11
|
import { join } from 'node:path';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import { listInstalledPlugins as defaultListInstalledPlugins, readProfileDeps, removeInstalledPlugin, } from './installed.js';
|
|
18
|
-
import { setLivePluginDisabled } from './live-plugin.js';
|
|
12
|
+
import { dshHome } from './env.js';
|
|
13
|
+
import { runProfileTransaction, makeNpmWarmPackument, TransactionError, } from './profile-transaction.js';
|
|
14
|
+
import { listInstalledPlugins as defaultListInstalledPlugins, } from './installed.js';
|
|
19
15
|
import { loadRegistry as defaultLoadRegistry, } from './registry.js';
|
|
20
|
-
import { githubLatestTag as defaultGithubLatestTag, isNewerVersion, npmLatest as defaultNpmLatest,
|
|
16
|
+
import { githubLatestTag as defaultGithubLatestTag, isNewerVersion, npmLatest as defaultNpmLatest, npmVersion, } from './versions.js';
|
|
21
17
|
// ---------- 通用工具 ----------
|
|
22
18
|
const DEFAULT_DEADLINE_MS = 60_000;
|
|
23
19
|
const WITH_LATEST_MAX = 50;
|
|
@@ -401,171 +397,6 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
|
|
|
401
397
|
});
|
|
402
398
|
return { items, others: installed.others, profileDir: installed.profileDir, registryState };
|
|
403
399
|
}
|
|
404
|
-
async function defaultRestoreInstall(profileDir) {
|
|
405
|
-
return runCommand('pnpm', ['--dir', profileDir, 'install', '--frozen-lockfile'], { timeoutMs: installTimeoutMs() });
|
|
406
|
-
}
|
|
407
|
-
async function defaultRebuildInstall(profileDir) {
|
|
408
|
-
return runCommand('pnpm', ['--dir', profileDir, 'install', '--no-frozen-lockfile'], { timeoutMs: installTimeoutMs() });
|
|
409
|
-
}
|
|
410
|
-
/** B3 默认退避:新发布 ~1 分钟内的升级失败多为 packument CDN 滞后(2026-09-05 实证)。 */
|
|
411
|
-
const NO_MATCHING_VERSION_RETRY_DELAYS_MS = [5_000, 15_000];
|
|
412
|
-
// ---------- B1/B2:manifest 保留与 frozen 自愈(2026-09-05 事故加固) ----------
|
|
413
|
-
const LOCKFILE_CONFIG_MISMATCH_RE = /ERR_PNPM_LOCKFILE_CONFIG_MISMATCH/;
|
|
414
|
-
const OUTDATED_LOCKFILE_RE = /ERR_PNPM_OUTDATED_LOCKFILE/;
|
|
415
|
-
const NO_MATCHING_VERSION_RE = /ERR_PNPM_NO_MATCHING_VERSION/;
|
|
416
|
-
function errText(err) {
|
|
417
|
-
return err instanceof Error ? err.message : String(err);
|
|
418
|
-
}
|
|
419
|
-
function sleep(ms) {
|
|
420
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
421
|
-
}
|
|
422
|
-
async function readManifestDoc(profileDir) {
|
|
423
|
-
try {
|
|
424
|
-
const parsed = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'));
|
|
425
|
-
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
426
|
-
return null;
|
|
427
|
-
return parsed;
|
|
428
|
-
}
|
|
429
|
-
catch {
|
|
430
|
-
return null;
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
/**
|
|
434
|
-
* B1(成功路径):安装链(pnpm / 宿主 CLI)若把升级前 manifest 的顶层键丢掉
|
|
435
|
-
* (如 `pnpm.overrides` 事故前态),从安装前字节快照里找回并原子写回。
|
|
436
|
-
* 只补「快照有、现在无」的键,绝不覆盖安装刚写入的 dependencies/dsh 变更。
|
|
437
|
-
* 返回找回的键名列表;无需修复返回 null。
|
|
438
|
-
*/
|
|
439
|
-
async function restoreManifestKeys(profileDir, snapshot) {
|
|
440
|
-
if (!snapshot?.existed || snapshot.bytes === null)
|
|
441
|
-
return null;
|
|
442
|
-
let prev;
|
|
443
|
-
try {
|
|
444
|
-
const parsed = JSON.parse(snapshot.bytes.toString('utf8'));
|
|
445
|
-
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
446
|
-
return null;
|
|
447
|
-
prev = parsed;
|
|
448
|
-
}
|
|
449
|
-
catch {
|
|
450
|
-
return null;
|
|
451
|
-
}
|
|
452
|
-
const cur = await readManifestDoc(profileDir);
|
|
453
|
-
if (!cur)
|
|
454
|
-
return null;
|
|
455
|
-
const missing = Object.keys(prev).filter((k) => !(k in cur));
|
|
456
|
-
if (missing.length === 0)
|
|
457
|
-
return null;
|
|
458
|
-
for (const key of missing)
|
|
459
|
-
cur[key] = prev[key];
|
|
460
|
-
const bytes = Buffer.from(`${JSON.stringify(cur, null, 2)}\n`, 'utf8');
|
|
461
|
-
await atomicWriteFile(join(profileDir, 'package.json'), bytes);
|
|
462
|
-
return missing;
|
|
463
|
-
}
|
|
464
|
-
/**
|
|
465
|
-
* B2 第一层自愈:frozen 校验报 overrides 失配时,把 lockfile 记录的 overrides
|
|
466
|
-
* 并入 manifest 的 `pnpm.overrides`(manifest 已有条目优先,绝不删用户手写的键)。
|
|
467
|
-
* lockfile 的 overrides 是上一次解析实际生效的钉版,以此为准可让 frozen 直接通过
|
|
468
|
-
* 而无需重建依赖树(与 2026-09-05 人工修复路径等价)。未做任何修改返回 null。
|
|
469
|
-
*/
|
|
470
|
-
async function restoreOverridesFromLock(profileDir) {
|
|
471
|
-
let lockText;
|
|
472
|
-
try {
|
|
473
|
-
lockText = await readFile(join(profileDir, 'pnpm-lock.yaml'), 'utf8');
|
|
474
|
-
}
|
|
475
|
-
catch {
|
|
476
|
-
return null;
|
|
477
|
-
}
|
|
478
|
-
const lockOverrides = readPnpmLockOverrides(lockText);
|
|
479
|
-
const doc = await readManifestDoc(profileDir);
|
|
480
|
-
if (!doc)
|
|
481
|
-
return null;
|
|
482
|
-
const pnpm = doc.pnpm !== null && typeof doc.pnpm === 'object' && !Array.isArray(doc.pnpm)
|
|
483
|
-
? doc.pnpm
|
|
484
|
-
: {};
|
|
485
|
-
const current = pnpm.overrides !== null && typeof pnpm.overrides === 'object' && !Array.isArray(pnpm.overrides)
|
|
486
|
-
? pnpm.overrides
|
|
487
|
-
: {};
|
|
488
|
-
const merged = { ...lockOverrides, ...current };
|
|
489
|
-
if (JSON.stringify(merged) === JSON.stringify(current))
|
|
490
|
-
return null;
|
|
491
|
-
doc.pnpm = { ...pnpm, overrides: merged };
|
|
492
|
-
const bytes = Buffer.from(`${JSON.stringify(doc, null, 2)}\n`, 'utf8');
|
|
493
|
-
await atomicWriteFile(join(profileDir, 'package.json'), bytes);
|
|
494
|
-
const restored = Object.keys(lockOverrides).filter((k) => !(k in current));
|
|
495
|
-
return restored.length > 0 ? `(还原自 lockfile:${restored.join(', ')})` : '(与 lockfile overrides 对齐)';
|
|
496
|
-
}
|
|
497
|
-
/**
|
|
498
|
-
* B2 frozen 自愈阶梯:frozen install → CONFIG_MISMATCH 时先 overrides 对齐再重试 →
|
|
499
|
-
* 仍失配(或 OUTDATED_LOCKFILE specifier 漂移,实机实证:override 钉直接依赖时回滚
|
|
500
|
-
* 快照本身即 specifier 不一致)则降级 `--no-frozen-lockfile` 重建一致性。自愈动作按序
|
|
501
|
-
* 记入 notes(最终呈现给用户);阶梯走完仍失败时抛最后一个错误。
|
|
502
|
-
*/
|
|
503
|
-
async function frozenInstallWithHeal(profileDir, d, notes) {
|
|
504
|
-
try {
|
|
505
|
-
await d.restoreInstall(profileDir);
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
508
|
-
catch (frozenErr) {
|
|
509
|
-
const text = errText(frozenErr);
|
|
510
|
-
const isConfigMismatch = LOCKFILE_CONFIG_MISMATCH_RE.test(text);
|
|
511
|
-
if (!isConfigMismatch && !OUTDATED_LOCKFILE_RE.test(text))
|
|
512
|
-
throw frozenErr;
|
|
513
|
-
if (isConfigMismatch) {
|
|
514
|
-
const merged = await restoreOverridesFromLock(profileDir);
|
|
515
|
-
if (merged !== null) {
|
|
516
|
-
notes.push(`已把 lockfile overrides 还原进 manifest ${merged}`);
|
|
517
|
-
try {
|
|
518
|
-
await d.restoreInstall(profileDir);
|
|
519
|
-
notes.push('frozen 校验通过');
|
|
520
|
-
return;
|
|
521
|
-
}
|
|
522
|
-
catch {
|
|
523
|
-
/* 对齐后仍失配 → 走重建降级 */
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
try {
|
|
528
|
-
await d.rebuildInstall(profileDir);
|
|
529
|
-
}
|
|
530
|
-
catch (rebuildErr) {
|
|
531
|
-
throw new Error(`${text};lockfile 重建(--no-frozen-lockfile)也失败:${errText(rebuildErr)}`);
|
|
532
|
-
}
|
|
533
|
-
notes.push('lockfile 已重建(--no-frozen-lockfile 完成一致性安装)');
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
/**
|
|
537
|
-
* B3:ERR_PNPM_NO_MATCHING_VERSION 在刚发布的窗口内几乎都是 packument CDN 滞后
|
|
538
|
-
* (2026-09-05 实证:/latest 已新、完整 packument 仍旧)。按 retryDelaysMs 退避重试,
|
|
539
|
-
* 每次重试前拉一次完整 packument 预热/校验;其他错误与重试耗尽后原样抛出。
|
|
540
|
-
*/
|
|
541
|
-
async function addDshPluginWithRetry(spec, pkg, d, retryDelaysMs, timeoutMs, signal) {
|
|
542
|
-
let attempt = 0;
|
|
543
|
-
for (;;) {
|
|
544
|
-
try {
|
|
545
|
-
return await d.addDshPlugin(spec);
|
|
546
|
-
}
|
|
547
|
-
catch (err) {
|
|
548
|
-
if (!NO_MATCHING_VERSION_RE.test(errText(err)) || attempt >= retryDelaysMs.length)
|
|
549
|
-
throw err;
|
|
550
|
-
const delay = retryDelaysMs[attempt] ?? 0;
|
|
551
|
-
attempt += 1;
|
|
552
|
-
if (delay > 0)
|
|
553
|
-
await sleep(delay);
|
|
554
|
-
await d.npmPackument(pkg, timeoutMs, signal).catch(() => undefined);
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
/**
|
|
559
|
-
* 旧版 dsh CLI(save-prefix ^)安装后会把依赖写成 `^x.y.z` 而非精确版本
|
|
560
|
-
* (2026-09-05 实机实证:升级报「profile 依赖版本 ^0.2.5 与目标 0.2.5 不一致」)。
|
|
561
|
-
* 仅当 spec 恰好锚定在目标精确版本上(v 本身 / ^v / ~v)才放行;真正的精确性
|
|
562
|
-
* 不靠 manifest 字符串,而由紧随其后的 lockfile importer 精确解析 + integrity
|
|
563
|
-
* 比对保证——其它任何 spec 仍然 fail closed。
|
|
564
|
-
*/
|
|
565
|
-
function specAnchoredAtVersion(spec, version) {
|
|
566
|
-
const s = String(spec || '').trim();
|
|
567
|
-
return s === version || s === `^${version}` || s === `~${version}`;
|
|
568
|
-
}
|
|
569
400
|
/** 从 registry 收录条目安装(npm → 精确锁定最新版;github → 锁 HEAD SHA)。 */
|
|
570
401
|
export async function installFromRegistry(id, cfg = {}, opts = {}, deps) {
|
|
571
402
|
const loaded = await (deps?.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host' });
|
|
@@ -582,19 +413,10 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
|
|
|
582
413
|
const d = {
|
|
583
414
|
npmLatest: deps?.npmLatest ?? defaultNpmLatest,
|
|
584
415
|
npmVersion: deps?.npmVersion ?? npmVersion,
|
|
585
|
-
npmPackument: deps?.npmPackument ?? defaultNpmPackument,
|
|
586
|
-
addDshPlugin: deps?.addDshPlugin ?? addDshPlugin,
|
|
587
|
-
removeDshPlugin: deps?.removeDshPlugin ?? removeDshPlugin,
|
|
588
|
-
readProfileDeps: deps?.readProfileDeps ?? readProfileDeps,
|
|
589
|
-
readLockIntegrity: deps?.readLockIntegrity ?? readPnpmLockIntegrity,
|
|
590
|
-
restoreInstall: deps?.restoreInstall ?? defaultRestoreInstall,
|
|
591
|
-
rebuildInstall: deps?.rebuildInstall ?? defaultRebuildInstall,
|
|
592
416
|
};
|
|
593
|
-
const retryDelaysMs = deps?.retryDelaysMs ?? NO_MATCHING_VERSION_RETRY_DELAYS_MS;
|
|
594
|
-
const profileDir = deps?.profileDir ?? webProfileDir();
|
|
595
417
|
if (entry.source === 'npm' && entry.npm) {
|
|
596
418
|
const pkg = entry.npm;
|
|
597
|
-
// npm:无论 latest 还是用户指定 exact,都先读取该精确版本的 dist metadata
|
|
419
|
+
// npm:无论 latest 还是用户指定 exact,都先读取该精确版本的 dist metadata(事务外解析)
|
|
598
420
|
let version;
|
|
599
421
|
let expectedIntegrity;
|
|
600
422
|
if (opts.version) {
|
|
@@ -609,124 +431,60 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
|
|
|
609
431
|
}
|
|
610
432
|
if (!expectedIntegrity)
|
|
611
433
|
throw new Error(`npm metadata 缺少 dist integrity:${pkg}@${version},拒绝安装`);
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
const
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
throw new Error(`profile 依赖版本 ${depsNow[pkg]} 与目标 ${version} 不一致`);
|
|
631
|
-
}
|
|
632
|
-
healNotes.push(`安装链把依赖写成 range(${depsNow[pkg]},旧版 CLI save-prefix 行为);精确性由 lockfile integrity 校验继续保证`);
|
|
633
|
-
}
|
|
634
|
-
let lockText;
|
|
635
|
-
try {
|
|
636
|
-
lockText = await readFile(join(profileDir, 'pnpm-lock.yaml'), 'utf8');
|
|
637
|
-
}
|
|
638
|
-
catch {
|
|
639
|
-
throw new Error('安装后未找到 pnpm-lock.yaml,无法核对 integrity');
|
|
640
|
-
}
|
|
641
|
-
const actual = d.readLockIntegrity(lockText, pkg, version);
|
|
642
|
-
assertNpmIntegrity(expectedIntegrity, actual, pkg, version);
|
|
643
|
-
// B1(成功路径):安装链若丢了 manifest 顶层键(如 pnpm.overrides),从快照找回并复验 frozen 一致性
|
|
644
|
-
const restoredKeys = await restoreManifestKeys(profileDir, snapshots[0]);
|
|
645
|
-
if (restoredKeys) {
|
|
646
|
-
healNotes.push(`安装链丢失了 manifest 顶层键(${restoredKeys.join(', ')}),已从安装前快照找回`);
|
|
647
|
-
try {
|
|
648
|
-
await frozenInstallWithHeal(profileDir, d, healNotes);
|
|
649
|
-
}
|
|
650
|
-
catch (healErr) {
|
|
651
|
-
healNotes.push(`frozen 一致性自愈未完成,profile 可能需要人工检查:${errText(healErr)}`);
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
return {
|
|
655
|
-
id: entry.id,
|
|
656
|
-
pkg,
|
|
657
|
-
spec,
|
|
658
|
-
version,
|
|
659
|
-
usedAllowAllBuilds: res.usedAllowAllBuilds,
|
|
660
|
-
needsRestart: true,
|
|
661
|
-
output: res.output.slice(-800) + (healNotes.length > 0 ? `\n[dsh-m 自愈] ${healNotes.join(';')}` : ''),
|
|
662
|
-
};
|
|
663
|
-
}
|
|
664
|
-
catch (err) {
|
|
665
|
-
// best-effort rollback:原子恢复 manifest/lock/workspace 快照字节,再按 frozen 自愈阶梯收敛
|
|
666
|
-
let rollbackError = null;
|
|
667
|
-
const rollbackNotes = [];
|
|
668
|
-
try {
|
|
669
|
-
await restoreSnapshots(snapshots);
|
|
670
|
-
await frozenInstallWithHeal(profileDir, d, rollbackNotes);
|
|
671
|
-
}
|
|
672
|
-
catch (rerr) {
|
|
673
|
-
rollbackError = errText(rerr);
|
|
674
|
-
// 原先不存在该依赖且恢复安装失败:尝试移除
|
|
675
|
-
const originallyAbsent = snapshots[0] && snapshots[0].existed && (() => {
|
|
676
|
-
try {
|
|
677
|
-
return !JSON.parse(snapshots[0].bytes.toString('utf8'))?.dependencies?.[pkg];
|
|
678
|
-
}
|
|
679
|
-
catch {
|
|
680
|
-
return false;
|
|
681
|
-
}
|
|
682
|
-
})();
|
|
683
|
-
if (originallyAbsent) {
|
|
684
|
-
try {
|
|
685
|
-
await d.removeDshPlugin(pkg);
|
|
686
|
-
rollbackError = null;
|
|
687
|
-
}
|
|
688
|
-
catch (rmErr) {
|
|
689
|
-
rollbackError = `${rollbackError};移除 ${pkg} 也失败(${errText(rmErr)})`;
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
const prefix = phase === 'install' ? '安装失败' : 'integrity 校验失败';
|
|
694
|
-
if (rollbackError) {
|
|
695
|
-
throw new Error(`${prefix}(${errText(err)});依赖回滚也失败(${rollbackError}),profile 可能需要人工修复`);
|
|
696
|
-
}
|
|
697
|
-
const suffix = rollbackNotes.length > 0 ? `(${rollbackNotes.join(';')})` : '';
|
|
698
|
-
throw new Error(`${prefix},已回滚到安装前状态${suffix}:${errText(err)}`);
|
|
699
|
-
}
|
|
434
|
+
// 生产预热绑定:未注入时 B3 用 makeNpmWarmPackument(保留 timeout/signal、失败吞错)
|
|
435
|
+
const result = await runProfileTransaction({ kind: 'install-npm', pkg, version, integrity: expectedIntegrity, signal: opts.signal }, {
|
|
436
|
+
...deps?.transaction,
|
|
437
|
+
warmPackument: deps?.transaction?.warmPackument ?? makeNpmWarmPackument(timeoutMs),
|
|
438
|
+
});
|
|
439
|
+
if (!result.ok)
|
|
440
|
+
throw new TransactionError(result);
|
|
441
|
+
const notes = result.healActions.map((h) => h.note).filter((n) => n !== '');
|
|
442
|
+
return {
|
|
443
|
+
id: entry.id,
|
|
444
|
+
pkg: result.pkg ?? pkg,
|
|
445
|
+
spec: result.spec ?? `${pkg}@${version}`,
|
|
446
|
+
version: result.version ?? version,
|
|
447
|
+
usedAllowAllBuilds: result.usedAllowAllBuilds === true,
|
|
448
|
+
needsRestart: true,
|
|
449
|
+
output: result.output + (notes.length > 0 ? `\n[dsh-m 自愈] ${notes.join(';')}` : ''),
|
|
450
|
+
healActions: result.healActions,
|
|
451
|
+
};
|
|
700
452
|
}
|
|
701
453
|
if (entry.github) {
|
|
702
|
-
|
|
703
|
-
const
|
|
704
|
-
const
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
throw new Error(`安装后未在 profile 依赖中找到 ${entry.github}`);
|
|
709
|
-
void tag;
|
|
454
|
+
// 版本解析在事务外(DI 修正:githubLatestTag 此前绕过注入)
|
|
455
|
+
const { tag, sha } = await (deps?.githubLatestTag ?? defaultGithubLatestTag)(entry.github, timeoutMs, opts.signal);
|
|
456
|
+
const result = await runProfileTransaction({ kind: 'install-github', repo: entry.github, sha, tag, signal: opts.signal }, deps?.transaction ?? {});
|
|
457
|
+
if (!result.ok)
|
|
458
|
+
throw new TransactionError(result);
|
|
459
|
+
const notes = result.healActions.map((h) => h.note).filter((n) => n !== '');
|
|
710
460
|
return {
|
|
711
461
|
id: entry.id,
|
|
712
|
-
pkg:
|
|
713
|
-
spec
|
|
714
|
-
sha,
|
|
715
|
-
|
|
462
|
+
pkg: result.pkg ?? entry.github,
|
|
463
|
+
spec: result.spec ?? `github:${entry.github}#${sha}`,
|
|
464
|
+
sha: result.sha ?? sha,
|
|
465
|
+
tag: result.tag ?? tag,
|
|
466
|
+
usedAllowAllBuilds: result.usedAllowAllBuilds === true,
|
|
716
467
|
needsRestart: true,
|
|
717
|
-
output:
|
|
468
|
+
output: result.output + (notes.length > 0 ? `\n[dsh-m 自愈] ${notes.join(';')}` : ''),
|
|
469
|
+
healActions: result.healActions,
|
|
718
470
|
};
|
|
719
471
|
}
|
|
720
472
|
throw new Error(`条目 ${entry.id} 缺少可安装来源`);
|
|
721
473
|
}
|
|
722
|
-
/** 卸载:live-disable →
|
|
723
|
-
export async function uninstallPlugin(pkg, _cfg = {},
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
const leftovers = [...new Set([...leftoverCandidates(pkg), ...
|
|
729
|
-
return {
|
|
474
|
+
/** 卸载:validate(严格读取)→ live-disable → 摘补丁 → pnpm remove → verify gone(DESIGN.md §3:删包不删数据)。 */
|
|
475
|
+
export async function uninstallPlugin(pkg, _cfg = {}, opts = {}, deps = {}) {
|
|
476
|
+
const result = await runProfileTransaction({ kind: 'uninstall', pkg, signal: opts.signal }, deps.transaction ?? {});
|
|
477
|
+
if (!result.ok)
|
|
478
|
+
throw new TransactionError(result);
|
|
479
|
+
const orphaned = result.orphanedPatchFiles ?? [];
|
|
480
|
+
const leftovers = [...new Set([...leftoverCandidates(pkg), ...orphaned])];
|
|
481
|
+
return {
|
|
482
|
+
pkg,
|
|
483
|
+
liveDisabled: result.liveDisabled === true,
|
|
484
|
+
needsRestart: true,
|
|
485
|
+
leftovers,
|
|
486
|
+
healActions: result.healActions,
|
|
487
|
+
};
|
|
730
488
|
}
|
|
731
489
|
/** 升级 = 按最新重新安装(npm 拉最新精确版;github 重新锁 HEAD)。 */
|
|
732
490
|
export async function upgradePlugin(pkg, cfg = {}, opts = {}, deps) {
|
|
@@ -744,14 +502,6 @@ export async function upgradePlugin(pkg, cfg = {}, opts = {}, deps) {
|
|
|
744
502
|
const result = await installEntry(entry, cfg, opts, deps);
|
|
745
503
|
return { ...result, fromVersion: target.version };
|
|
746
504
|
}
|
|
747
|
-
// ---------- 变更互斥 ----------
|
|
748
|
-
/** 变更互斥:安装/卸载/升级串行执行(skillhub install-lock 同款思路)。 */
|
|
749
|
-
let mutationTail = Promise.resolve();
|
|
750
|
-
export function withMutationLock(task) {
|
|
751
|
-
const next = mutationTail.then(task, task);
|
|
752
|
-
mutationTail = next.catch(() => undefined);
|
|
753
|
-
return next;
|
|
754
|
-
}
|
|
755
505
|
/** 疑似残留路径(存在才列出):删包不删数据,只报告。 */
|
|
756
506
|
export function leftoverCandidates(pkg) {
|
|
757
507
|
const home = dshHome();
|
|
@@ -126,33 +126,159 @@ export function readPnpmLockOverrides(lockText) {
|
|
|
126
126
|
}
|
|
127
127
|
return result;
|
|
128
128
|
}
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
/**
|
|
130
|
+
* 原子写(Task 2 加固;终审复审 Y1/F3/F5 再加固):POSIX 直接 `rename` 原子覆盖目标
|
|
131
|
+
* (不再先 rm——旧实现的「删目标 → rename」窗口期内崩溃会直接丢文件)。仅 Windows 形态
|
|
132
|
+
* 的 `EPERM`/`EEXIST` 走备份协议:`target→backup`、`tmp→target`,任一步失败恢复
|
|
133
|
+
* `backup→target`,全部成功后删除 backup。失败清理遵守:
|
|
134
|
+
* - F5:`O_EXCL` open 成功才取得 tmp 所有权,未取得所有权绝不清理(可能是他人 in-flight 的碰撞文件);
|
|
135
|
+
* - F3:close 失败不吞(但不掩盖 write/sync 的原始异常);
|
|
136
|
+
* - 任何失败路径(含 write/sync/close/rename)都清理自有 tmp;备份恢复本身失败时报告
|
|
137
|
+
* backup 路径与双重错误,不静默。
|
|
138
|
+
*/
|
|
139
|
+
async function atomicWriteFile(path, bytes, fsOps = {}) {
|
|
140
|
+
const mkdirOp = fsOps.mkdir ?? mkdir;
|
|
141
|
+
const openOp = fsOps.open ?? open;
|
|
142
|
+
const renameOp = fsOps.rename ?? rename;
|
|
143
|
+
const rmOp = fsOps.rm ?? rm;
|
|
144
|
+
await mkdirOp(dirname(path), { recursive: true });
|
|
131
145
|
const tmp = `${path}.restore-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
132
|
-
const
|
|
146
|
+
const backup = `${path}.backup-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
147
|
+
let completed = false;
|
|
148
|
+
let tmpOwned = false;
|
|
149
|
+
let mainErr;
|
|
150
|
+
let threw = false;
|
|
151
|
+
let cleanupErr;
|
|
133
152
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
153
|
+
try {
|
|
154
|
+
const fh = await openOp(tmp, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
|
|
155
|
+
tmpOwned = true; // O_EXCL 成功 = tmp 由本调用创建
|
|
156
|
+
// Y1(第三轮复审):write/sync 与 close 的错误全部保留(AggregateError),不互相覆盖
|
|
157
|
+
const errors = [];
|
|
158
|
+
try {
|
|
159
|
+
await fh.write(bytes);
|
|
160
|
+
await fh.sync();
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
errors.push(err);
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
await fh.close();
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
errors.push(err); // F3:close 失败不吞
|
|
170
|
+
}
|
|
171
|
+
if (errors.length === 1)
|
|
172
|
+
throw errors[0];
|
|
173
|
+
if (errors.length > 1)
|
|
174
|
+
throw new AggregateError(errors, '临时文件写入/同步/关闭失败');
|
|
175
|
+
try {
|
|
176
|
+
await renameOp(tmp, path);
|
|
177
|
+
completed = true;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
const code = err?.code;
|
|
182
|
+
if (code !== 'EPERM' && code !== 'EEXIST') {
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Windows 备份协议
|
|
187
|
+
try {
|
|
188
|
+
await renameOp(path, backup);
|
|
189
|
+
}
|
|
190
|
+
catch (bakErr) {
|
|
191
|
+
throw bakErr;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
await renameOp(tmp, path);
|
|
195
|
+
}
|
|
196
|
+
catch (moveErr) {
|
|
197
|
+
try {
|
|
198
|
+
await renameOp(backup, path);
|
|
199
|
+
}
|
|
200
|
+
catch (restoreErr) {
|
|
201
|
+
throw new Error(`原子写失败:${errTextOf(moveErr)};备份恢复也失败(${errTextOf(restoreErr)}),原文件现位于备份 ${backup}`);
|
|
202
|
+
}
|
|
203
|
+
throw moveErr;
|
|
204
|
+
}
|
|
205
|
+
await rmOp(backup, { force: true }).catch(() => undefined);
|
|
206
|
+
completed = true;
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
threw = true;
|
|
210
|
+
mainErr = err;
|
|
211
|
+
}
|
|
136
212
|
}
|
|
137
213
|
finally {
|
|
138
|
-
|
|
214
|
+
if (tmpOwned && !completed) {
|
|
215
|
+
try {
|
|
216
|
+
await rmOp(tmp, { force: true });
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
cleanupErr = err; // Y1:清理失败不覆盖主错误,随后聚合上报(含 tmp 路径)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
139
222
|
}
|
|
140
|
-
|
|
141
|
-
|
|
223
|
+
if (threw) {
|
|
224
|
+
if (cleanupErr !== undefined) {
|
|
225
|
+
throw new AggregateError([mainErr, cleanupErr], `原子写失败且临时文件清理也失败(可能残留 tmp:${tmp})`);
|
|
226
|
+
}
|
|
227
|
+
throw mainErr;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function errTextOf(err) {
|
|
231
|
+
return err instanceof Error ? err.message : String(err);
|
|
142
232
|
}
|
|
143
233
|
export { atomicWriteFile };
|
|
144
|
-
/**
|
|
145
|
-
|
|
234
|
+
/**
|
|
235
|
+
* 记录 profile 关键文件的字节快照(package.json / pnpm-lock.yaml / pnpm-workspace.yaml)。
|
|
236
|
+
* Task 2 加固:仅 `ENOENT`(文件本不存在)→ `existed:false`;其他读取异常(EACCES、
|
|
237
|
+
* EISDIR 等)一律 throw,由调用方 fail closed(零写入),不再把不可读误当成「不存在」。
|
|
238
|
+
*/
|
|
239
|
+
export async function snapshotFiles(paths, fsOps = {}) {
|
|
240
|
+
const read = fsOps.readFile ?? readFile;
|
|
146
241
|
return Promise.all(paths.map(async (path) => {
|
|
147
242
|
try {
|
|
148
|
-
const bytes = await
|
|
243
|
+
const bytes = await read(path);
|
|
149
244
|
return { path, existed: true, bytes };
|
|
150
245
|
}
|
|
151
|
-
catch {
|
|
152
|
-
|
|
246
|
+
catch (err) {
|
|
247
|
+
if (err?.code === 'ENOENT') {
|
|
248
|
+
return { path, existed: false, bytes: null };
|
|
249
|
+
}
|
|
250
|
+
throw err;
|
|
153
251
|
}
|
|
154
252
|
}));
|
|
155
253
|
}
|
|
254
|
+
/**
|
|
255
|
+
* 还原后立即重读比对(第一阶段验证动作;Task 2 起)。F2(第三轮复审):仅 `ENOENT`
|
|
256
|
+
* 视为文件不存在;EACCES/EIO/EISDIR 等其他读取异常一律 fail closed(抛错)。
|
|
257
|
+
* `fsOps` 为内部测试缝(缺省真 fs),确定性注入读取异常,不依赖 chmod。
|
|
258
|
+
*/
|
|
259
|
+
export async function verifySnapshots(snapshots, fsOps = {}) {
|
|
260
|
+
const read = fsOps.readFile ?? readFile;
|
|
261
|
+
for (const snap of snapshots) {
|
|
262
|
+
let current;
|
|
263
|
+
try {
|
|
264
|
+
current = await read(snap.path);
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
if (err?.code !== 'ENOENT') {
|
|
268
|
+
throw new Error(`还原后复验读取失败(${snap.path}):${errTextOf(err)}`);
|
|
269
|
+
}
|
|
270
|
+
current = null;
|
|
271
|
+
}
|
|
272
|
+
if (snap.existed && snap.bytes !== null) {
|
|
273
|
+
if (current === null || !current.equals(snap.bytes)) {
|
|
274
|
+
throw new Error(`还原后复验不一致:${snap.path}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
else if (current !== null) {
|
|
278
|
+
throw new Error(`还原后应删除的新生成文件仍存在:${snap.path}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
156
282
|
/**
|
|
157
283
|
* 恢复快照:原先存在的文件原样写回(原子 rename);原先不存在的删除安装过程中
|
|
158
284
|
* 新生成的文件。恢复动作本身失败由调用方汇总报告。
|