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
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile 变更事务(Profile Transaction)——深模块。
|
|
3
|
+
* 设计语义见 docs/DESIGN.md §3.1;执行计划(含四轮评审处置)见
|
|
4
|
+
* docs/plans/2026-09-07-profile-transaction-implementation-plan.md。
|
|
5
|
+
*
|
|
6
|
+
* 模块契约(「类型之外的契约」):
|
|
7
|
+
* 1. 前置:request 必须已解析到底(版本/SHA/integrity 均已定);模块内除 `warmPackument` 外零网络。
|
|
8
|
+
* 2. throw 政策与未预期异常(phase-aware):预期的领域失败一律返回 TransactionResult;仅畸形
|
|
9
|
+
* request 抛 TypeError(isSafePkgName/isSafePluginTarget 不通过、github repo 非 owner/repo 形状、
|
|
10
|
+
* SHA 非 40 位十六进制、integrity 空白)——校验先于 runner factory 调用与任何读写,零写入。
|
|
11
|
+
* 未预期异常由顶层 catch 按阶段分流:快照完成前且无写入 → rejected + INTERNAL_ERROR;
|
|
12
|
+
* 快照后或 mutation 已开始 → failure code 保留 INTERNAL_ERROR,照常执行不可取消的统一
|
|
13
|
+
* 回滚/收敛/live 补偿——回滚成功 → rolled-back;失败 → manual-repair。
|
|
14
|
+
* 3. 四态语义与证据来源:`snapshotRestoreVerified` = 还原后重读比对的历史事实(committed 恒
|
|
15
|
+
* false,未发生还原);`profileConverged` = committed 门专属 verify 成功 / rolled-back 收敛
|
|
16
|
+
* 阶梯通过;rejected、manual-repair 恒 false。不声称 node_modules 字节级回滚。
|
|
17
|
+
* 4. signal 语义:`req.signal` 贯通 PnpmRunner 四操作、warmPackument 与 B3 退避 sleep
|
|
18
|
+
* (abort 即醒);排队期或 mutate 前 abort → rejected + ABORTED;mutate 已开始后 abort →
|
|
19
|
+
* 中止在途调用并执行不可取消的回滚(failure code 保留 ABORTED,终态 rolled-back 或
|
|
20
|
+
* manual-repair);回滚/收敛阶段不传外部 signal(不变量优先于取消)。
|
|
21
|
+
* 5. 串行:模块级 FIFO promise 链,进程内互斥;不得在 runner 回调里再调
|
|
22
|
+
* runProfileTransaction;跨进程并发不在覆盖范围。
|
|
23
|
+
* 6. uninstall 定序固定:validate(严格读取)→ 快照 → live-disable → 摘补丁 → remove →
|
|
24
|
+
* verify gone;失败回滚时 live 尽力反向。
|
|
25
|
+
* 7. 事务内 verify 相 profile 读取用严格读取器(模块私有):manifest 读异常 →
|
|
26
|
+
* PROFILE_MANIFEST_UNREADABLE、JSON 非法 → PROFILE_MANIFEST_INVALID、插件元数据不可读 →
|
|
27
|
+
* PLUGIN_METADATA_UNREADABLE;宽容读取只属列表页。B1/B2 自愈阶梯内部的 best-effort
|
|
28
|
+
* 读取保持宽容(阶梯失败本身会进失败路径)。
|
|
29
|
+
* 8. github 校验:存在键 k 使 depsNow[k] === spec 且 snapshotDeps[k] !== spec(相对前态
|
|
30
|
+
* 变化,防旧依赖误命中);无 → GITHUB_SPEC_MISMATCH。
|
|
31
|
+
* 9. 拒绝的假想 seam:FsPort、时钟 port、锁 port、每 doorway 一模块、版本解析 port。
|
|
32
|
+
* npm-integrity.ts 的可注入 fs 操作是内部 seam(仅供其自测)。
|
|
33
|
+
*/
|
|
34
|
+
import { Buffer } from 'node:buffer';
|
|
35
|
+
import { readFile } from 'node:fs/promises';
|
|
36
|
+
import { join } from 'node:path';
|
|
37
|
+
import { webProfileDir } from './env.js';
|
|
38
|
+
import { isSafePkgName, resolvePluginDir } from './installed.js';
|
|
39
|
+
import { setLivePluginDisabled } from './live-plugin.js';
|
|
40
|
+
import { npmPackument } from './versions.js';
|
|
41
|
+
import { assertNpmIntegrity, atomicWriteFile, readPnpmLockIntegrity, readPnpmLockOverrides, restoreSnapshots, snapshotFiles, verifySnapshots, } from './npm-integrity.js';
|
|
42
|
+
import { isSafePluginTarget, makeDshRunner, removePatchedDependencyEntries, PNPM_OUTCOME_CODES, } from './dsh-cli.js';
|
|
43
|
+
export const TX_HEAL_CODES = [
|
|
44
|
+
'RANGE_ANCHOR_ACCEPTED', 'B1_MANIFEST_KEYS_RESTORED', 'B1_FROZEN_REVERIFY_FAILED',
|
|
45
|
+
'B2_OVERRIDES_ALIGNED', 'B2_FROZEN_REVERIFY_OK', 'B2_LOCKFILE_REBUILT',
|
|
46
|
+
'B3_LAG_RETRY', 'B3_PACKUMENT_WARMED', 'BUILDS_ALLOWED',
|
|
47
|
+
'ROLLBACK_BYTES_RESTORED', 'ROLLBACK_CONVERGED_FROZEN', 'ROLLBACK_FALLBACK_REMOVED',
|
|
48
|
+
'ROLLBACK_VERIFY_FAILED', 'LIVE_DISABLED', 'LIVE_REENABLED', 'LIVE_REENABLE_FAILED',
|
|
49
|
+
'PATCH_ENTRIES_STRIPPED',
|
|
50
|
+
];
|
|
51
|
+
export const TX_FAILURE_CODES = [
|
|
52
|
+
'DEP_MISSING_AFTER_ADD', 'DEP_VERSION_MISMATCH', 'LOCKFILE_MISSING', 'LOCK_INTEGRITY_MISMATCH',
|
|
53
|
+
'GITHUB_SPEC_MISMATCH', 'ADD_RETRY_EXHAUSTED', 'ADD_FAILED', 'POST_MUTATION_CONVERGENCE_FAILED',
|
|
54
|
+
'NOT_INSTALLED', 'NOT_DSH_PLUGIN', 'REMOVE_FAILED', 'STILL_PRESENT_AFTER_REMOVE',
|
|
55
|
+
'ROLLBACK_FAILED', 'SNAPSHOT_FAILED', 'ABORTED', 'INTERNAL_ERROR',
|
|
56
|
+
'PROFILE_MANIFEST_UNREADABLE', 'PROFILE_MANIFEST_INVALID', 'PLUGIN_METADATA_UNREADABLE',
|
|
57
|
+
];
|
|
58
|
+
// ---------- 生产预热绑定 ----------
|
|
59
|
+
/**
|
|
60
|
+
* 生产预热绑定:market.ts / host-api.ts 生产路径使用
|
|
61
|
+
* `warmPackument: makeNpmWarmPackument(cfg.timeoutMs ?? 20_000)`。
|
|
62
|
+
* fetcher 参数仅测试注入;失败吞错。deps 未提供且调用方未绑定时,
|
|
63
|
+
* 事务内 B3 跳过预热仅重试——绝不调用 undefined。
|
|
64
|
+
*/
|
|
65
|
+
export function makeNpmWarmPackument(timeoutMs, fetcher = npmPackument) {
|
|
66
|
+
return async (pkg, signal) => {
|
|
67
|
+
try {
|
|
68
|
+
await fetcher(pkg, timeoutMs, signal);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
// R4c(终审复审):取消不是预热失败——abort 异常必须向上传播,让 B3 立即终止
|
|
72
|
+
if (signal?.aborted)
|
|
73
|
+
throw err;
|
|
74
|
+
/* 预热尽力而为:CDN 滞后场景下 packument 请求失败不阻塞重试 */
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// ---------- 展示层:renderFailure(中文散文唯一产地) ----------
|
|
79
|
+
function prefixOf(kind, code) {
|
|
80
|
+
if (code === 'LOCK_INTEGRITY_MISMATCH')
|
|
81
|
+
return 'integrity 校验失败';
|
|
82
|
+
if (kind === 'uninstall')
|
|
83
|
+
return '卸载失败';
|
|
84
|
+
if (kind === 'install-github')
|
|
85
|
+
return 'GitHub 安装失败';
|
|
86
|
+
return '安装失败';
|
|
87
|
+
}
|
|
88
|
+
/** 失败结果的中文散文(唯一产地;三条 legacy 文案逐字钉住在 tests/profile-transaction.test.mjs)。 */
|
|
89
|
+
export function renderFailure(r) {
|
|
90
|
+
const heals = r.healActions.map((h) => h.note).filter((n) => n !== '');
|
|
91
|
+
const healSuffix = heals.length > 0 ? `(${heals.join(';')})` : '';
|
|
92
|
+
const prefix = prefixOf(r.kind, r.failure.code);
|
|
93
|
+
switch (r.status) {
|
|
94
|
+
case 'rejected':
|
|
95
|
+
return `${prefix}前置校验未通过(${r.failure.code}):${r.failure.note}`;
|
|
96
|
+
case 'rolled-back':
|
|
97
|
+
return `${prefix},已回滚到安装前状态${healSuffix}:${r.failure.note}`;
|
|
98
|
+
case 'manual-repair':
|
|
99
|
+
return `${prefix},${r.failure.note},profile 可能需要人工修复`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** 领域失败以值携带(内部用);TransactionError 才是对外 throw 形态。 */
|
|
103
|
+
export class TransactionError extends Error {
|
|
104
|
+
result;
|
|
105
|
+
constructor(result) {
|
|
106
|
+
super(renderFailure(result));
|
|
107
|
+
this.result = result;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// ---------- 内部工具 ----------
|
|
111
|
+
/** Y2(第四轮复审):AggregateError 递归展开——多个底层错误都要进入事务 failure.note,不只最外层 message。 */
|
|
112
|
+
function errText(err) {
|
|
113
|
+
if (err instanceof AggregateError) {
|
|
114
|
+
const details = err.errors.map(errText).filter((t) => t !== '');
|
|
115
|
+
return details.length > 0 ? `${err.message}:${details.join(';')}` : err.message;
|
|
116
|
+
}
|
|
117
|
+
return err instanceof Error ? err.message : String(err);
|
|
118
|
+
}
|
|
119
|
+
function abortErr() {
|
|
120
|
+
const err = new Error('已取消');
|
|
121
|
+
err.name = 'AbortError';
|
|
122
|
+
return err;
|
|
123
|
+
}
|
|
124
|
+
function isAbortish(err, signal) {
|
|
125
|
+
return (err instanceof Error && err.name === 'AbortError') || signal?.aborted === true;
|
|
126
|
+
}
|
|
127
|
+
/** abort-aware sleep:abort 即醒(抛 AbortError)。 */
|
|
128
|
+
function sleepAbortable(ms, signal) {
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
if (signal?.aborted) {
|
|
131
|
+
reject(abortErr());
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const timer = setTimeout(() => resolve(), Math.max(0, ms));
|
|
135
|
+
signal?.addEventListener('abort', () => {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
reject(abortErr());
|
|
138
|
+
}, { once: true });
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** 旧版 dsh CLI(save-prefix ^)锚定在目标精确版本上的 spec 放行;精确性由 lockfile integrity 保证。 */
|
|
142
|
+
function specAnchoredAtVersion(spec, version) {
|
|
143
|
+
const s = String(spec || '').trim();
|
|
144
|
+
return s === version || s === `^${version}` || s === `~${version}`;
|
|
145
|
+
}
|
|
146
|
+
class DomainFailure extends Error {
|
|
147
|
+
failure;
|
|
148
|
+
constructor(failure) {
|
|
149
|
+
super(failure.note);
|
|
150
|
+
this.failure = failure;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
class RestoreVerifyMismatch extends Error {
|
|
154
|
+
}
|
|
155
|
+
/** 畸形 request:校验先于 runner factory 与任何读写,零写入。 */
|
|
156
|
+
function validateRequest(req) {
|
|
157
|
+
switch (req.kind) {
|
|
158
|
+
case 'install-npm':
|
|
159
|
+
if (!isSafePkgName(String(req.pkg ?? '')))
|
|
160
|
+
throw new TypeError(`畸形 request:非法包名 ${JSON.stringify(req.pkg)}`);
|
|
161
|
+
if (typeof req.version !== 'string' || req.version.trim() === '')
|
|
162
|
+
throw new TypeError('畸形 request:版本为空');
|
|
163
|
+
if (typeof req.integrity !== 'string' || req.integrity.trim() === '')
|
|
164
|
+
throw new TypeError('畸形 request:integrity 空白');
|
|
165
|
+
return;
|
|
166
|
+
case 'install-github':
|
|
167
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/.test(String(req.repo ?? '')))
|
|
168
|
+
throw new TypeError(`畸形 request:github repo 非 owner/repo 形状 ${JSON.stringify(req.repo)}`);
|
|
169
|
+
if (!/^[0-9a-f]{40}$/.test(String(req.sha ?? '')))
|
|
170
|
+
throw new TypeError(`畸形 request:SHA 非 40 位十六进制 ${JSON.stringify(req.sha)}`);
|
|
171
|
+
return;
|
|
172
|
+
case 'uninstall':
|
|
173
|
+
if (!isSafePkgName(String(req.pkg ?? '')) || !isSafePluginTarget(String(req.pkg ?? '')))
|
|
174
|
+
throw new TypeError(`畸形 request:非法包名 ${JSON.stringify(req.pkg)}`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// ---------- B1/B2 自愈阶梯(自 market.ts 搬移;消费 RunnerOutcome) ----------
|
|
179
|
+
async function readManifestDoc(profileDir) {
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'));
|
|
182
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
183
|
+
return null;
|
|
184
|
+
return parsed;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* B1(成功路径):安装链若把升级前 manifest 的顶层键丢掉(如 `pnpm.overrides`),
|
|
192
|
+
* 从安装前字节快照里找回并原子写回。只补「快照有、现在无」的键;无需修复返回 null。
|
|
193
|
+
*/
|
|
194
|
+
async function restoreManifestKeys(profileDir, snapshot) {
|
|
195
|
+
if (!snapshot?.existed || snapshot.bytes === null)
|
|
196
|
+
return null;
|
|
197
|
+
let prev;
|
|
198
|
+
try {
|
|
199
|
+
const parsed = JSON.parse(snapshot.bytes.toString('utf8'));
|
|
200
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
201
|
+
return null;
|
|
202
|
+
prev = parsed;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const cur = await readManifestDoc(profileDir);
|
|
208
|
+
if (!cur)
|
|
209
|
+
return null;
|
|
210
|
+
const missing = Object.keys(prev).filter((k) => !(k in cur));
|
|
211
|
+
if (missing.length === 0)
|
|
212
|
+
return null;
|
|
213
|
+
for (const key of missing)
|
|
214
|
+
cur[key] = prev[key];
|
|
215
|
+
const bytes = Buffer.from(`${JSON.stringify(cur, null, 2)}\n`, 'utf8');
|
|
216
|
+
await atomicWriteFile(join(profileDir, 'package.json'), bytes);
|
|
217
|
+
return missing;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* B2 第一层自愈:把 lockfile 记录的 overrides 并入 manifest 的 `pnpm.overrides`
|
|
221
|
+
* (manifest 已有条目优先)。未做任何修改返回 null。
|
|
222
|
+
*/
|
|
223
|
+
async function restoreOverridesFromLock(profileDir) {
|
|
224
|
+
let lockText;
|
|
225
|
+
try {
|
|
226
|
+
lockText = await readFile(join(profileDir, 'pnpm-lock.yaml'), 'utf8');
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
const lockOverrides = readPnpmLockOverrides(lockText);
|
|
232
|
+
const doc = await readManifestDoc(profileDir);
|
|
233
|
+
if (!doc)
|
|
234
|
+
return null;
|
|
235
|
+
const pnpm = doc.pnpm !== null && typeof doc.pnpm === 'object' && !Array.isArray(doc.pnpm)
|
|
236
|
+
? doc.pnpm
|
|
237
|
+
: {};
|
|
238
|
+
const current = pnpm.overrides !== null && typeof pnpm.overrides === 'object' && !Array.isArray(pnpm.overrides)
|
|
239
|
+
? pnpm.overrides
|
|
240
|
+
: {};
|
|
241
|
+
const merged = { ...lockOverrides, ...current };
|
|
242
|
+
if (JSON.stringify(merged) === JSON.stringify(current))
|
|
243
|
+
return null;
|
|
244
|
+
doc.pnpm = { ...pnpm, overrides: merged };
|
|
245
|
+
const bytes = Buffer.from(`${JSON.stringify(doc, null, 2)}\n`, 'utf8');
|
|
246
|
+
await atomicWriteFile(join(profileDir, 'package.json'), bytes);
|
|
247
|
+
const restored = Object.keys(lockOverrides).filter((k) => !(k in current));
|
|
248
|
+
return restored.length > 0 ? `(还原自 lockfile:${restored.join(', ')})` : '(与 lockfile overrides 对齐)';
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* B2 frozen 收敛阶梯(消费矩阵 frozenInstall 行):frozen → CONFIG_MISMATCH 时先
|
|
252
|
+
* overrides 对齐再复验 → 仍 config-drift(含 OUTDATED_LOCKFILE specifier 漂移)降级
|
|
253
|
+
* `--no-frozen-lockfile` 重建。永不 throw,一律 RunnerOutcome;自愈动作记入 healActions。
|
|
254
|
+
*/
|
|
255
|
+
async function frozenConvergeLadder(d, heal) {
|
|
256
|
+
let out = await d.runner.frozenInstall();
|
|
257
|
+
if (out.class === 'ok')
|
|
258
|
+
return out;
|
|
259
|
+
if (out.class === 'config-drift' && out.code === PNPM_OUTCOME_CODES.CONFIG_MISMATCH) {
|
|
260
|
+
const merged = await restoreOverridesFromLock(d.profileDir);
|
|
261
|
+
if (merged !== null) {
|
|
262
|
+
heal.push({ code: 'B2_OVERRIDES_ALIGNED', note: `已把 lockfile overrides 还原进 manifest ${merged}` });
|
|
263
|
+
const retry = await d.runner.frozenInstall();
|
|
264
|
+
if (retry.class === 'ok') {
|
|
265
|
+
heal.push({ code: 'B2_FROZEN_REVERIFY_OK', note: 'frozen 校验通过' });
|
|
266
|
+
return retry;
|
|
267
|
+
}
|
|
268
|
+
out = retry;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (out.class === 'config-drift') {
|
|
272
|
+
const rebuilt = await d.runner.rebuildInstall();
|
|
273
|
+
if (rebuilt.class === 'ok') {
|
|
274
|
+
heal.push({ code: 'B2_LOCKFILE_REBUILT', note: 'lockfile 已重建(--no-frozen-lockfile 完成一致性安装)' });
|
|
275
|
+
return rebuilt;
|
|
276
|
+
}
|
|
277
|
+
return { ...rebuilt, output: `${out.output};lockfile 重建(--no-frozen-lockfile)也失败:${rebuilt.output}`.slice(-800) };
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
// ---------- 严格读取器(verify 相专用) ----------
|
|
282
|
+
async function strictReadDeps(d) {
|
|
283
|
+
let raw;
|
|
284
|
+
try {
|
|
285
|
+
raw = await readFile(join(d.profileDir, 'package.json'), 'utf8');
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
throw new DomainFailure({ code: 'PROFILE_MANIFEST_UNREADABLE', note: `profile package.json 读取失败:${errText(err)}` });
|
|
289
|
+
}
|
|
290
|
+
let parsed;
|
|
291
|
+
try {
|
|
292
|
+
parsed = JSON.parse(raw);
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
throw new DomainFailure({ code: 'PROFILE_MANIFEST_INVALID', note: `profile package.json 不是合法 JSON:${errText(err)}` });
|
|
296
|
+
}
|
|
297
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
298
|
+
throw new DomainFailure({ code: 'PROFILE_MANIFEST_INVALID', note: 'profile package.json 顶层不是对象' });
|
|
299
|
+
}
|
|
300
|
+
const deps = parsed.dependencies;
|
|
301
|
+
if (deps === null || deps === undefined || typeof deps !== 'object' || Array.isArray(deps))
|
|
302
|
+
return {};
|
|
303
|
+
const out = {};
|
|
304
|
+
for (const [name, spec] of Object.entries(deps)) {
|
|
305
|
+
if (typeof spec === 'string' && spec !== '')
|
|
306
|
+
out[name] = spec;
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
// ---------- 结果 builder ----------
|
|
311
|
+
function truncate(text) {
|
|
312
|
+
const raw = String(text ?? '');
|
|
313
|
+
return raw.length <= 800 ? raw : raw.slice(-800);
|
|
314
|
+
}
|
|
315
|
+
function committed(kind, heal, output, payload) {
|
|
316
|
+
return {
|
|
317
|
+
kind, status: 'committed', ok: true, healActions: heal, output: truncate(output),
|
|
318
|
+
snapshotRestoreVerified: false, profileConverged: true, needsRestart: true,
|
|
319
|
+
...payload,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function rejected(failure, kind, output = '') {
|
|
323
|
+
return {
|
|
324
|
+
kind, status: 'rejected', ok: false, failure, healActions: [], output: truncate(output),
|
|
325
|
+
snapshotRestoreVerified: false, profileConverged: false,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function rolledBack(kind, failure, heal, output) {
|
|
329
|
+
return {
|
|
330
|
+
kind, status: 'rolled-back', ok: false, failure, healActions: heal, output: truncate(output),
|
|
331
|
+
snapshotRestoreVerified: true, profileConverged: true,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
function manualRepair(kind, failure, heal, output, snapshotRestoreVerified) {
|
|
335
|
+
return {
|
|
336
|
+
kind, status: 'manual-repair', ok: false, failure, healActions: heal, output: truncate(output),
|
|
337
|
+
snapshotRestoreVerified, profileConverged: false,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
// ---------- 回滚编排(两阶段不变量) ----------
|
|
341
|
+
function originallyAbsent(pkg, snapshots) {
|
|
342
|
+
const snap = snapshots[0];
|
|
343
|
+
if (!snap?.existed || snap.bytes === null)
|
|
344
|
+
return false;
|
|
345
|
+
try {
|
|
346
|
+
return !JSON.parse(snap.bytes.toString('utf8'))?.dependencies?.[pkg];
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/** 还原后立即重读比对(第一阶段验证动作;原语在 npm-integrity.verifySnapshots,F2 只吞 ENOENT)。 */
|
|
353
|
+
async function assertRestoredBytes(snapshots) {
|
|
354
|
+
try {
|
|
355
|
+
await verifySnapshots(snapshots);
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
// 读取异常与字节不一致统一按复验失败处理(ROLLBACK_VERIFY_FAILED 在案)
|
|
359
|
+
throw new RestoreVerifyMismatch(errText(err));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* 统一回滚:字节还原(重读比对)→ frozen 收敛阶梯 → (仅当前面失败时)originallyAbsent
|
|
364
|
+
* 补移除。回滚不可取消(不传外部 signal)。终态 rolled-back 或 manual-repair。
|
|
365
|
+
*
|
|
366
|
+
* 终审复审 R1/R2 契约:`rolled-back` 必须同时代表「字节还原已重读验证」与「终态一致性
|
|
367
|
+
* 已证明」——补移除成功本身不构成其中任何一个证明:字节还原未验证时移除只是补偿动作
|
|
368
|
+
* (manual-repair);收敛失败后移除成功必须再经 verify-gone + frozen 复验才允许 rolled-back。
|
|
369
|
+
* 本函数为 total function:收敛阶梯 / 补移除 / B2 写入的任何异常都转换为 manual-repair
|
|
370
|
+
* 结果,绝不 reject。
|
|
371
|
+
*/
|
|
372
|
+
async function rollbackAndConverge(d, kind, failure, heal, snapshots, fallbackRemovePkg) {
|
|
373
|
+
const healSuffixNote = (detail) => ({
|
|
374
|
+
code: failure.code,
|
|
375
|
+
note: `${failure.note};依赖回滚也失败(${detail})`,
|
|
376
|
+
});
|
|
377
|
+
// 阶段一:字节还原 + 重读比对验证
|
|
378
|
+
let restoreVerified = false;
|
|
379
|
+
let restoreErr = null;
|
|
380
|
+
let restoreMismatch = false;
|
|
381
|
+
try {
|
|
382
|
+
await restoreSnapshots(snapshots);
|
|
383
|
+
await assertRestoredBytes(snapshots);
|
|
384
|
+
restoreVerified = true;
|
|
385
|
+
heal.push({ code: 'ROLLBACK_BYTES_RESTORED', note: '三文件已按快照逐字节还原并重读复验' });
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
restoreErr = err;
|
|
389
|
+
restoreMismatch = err instanceof RestoreVerifyMismatch;
|
|
390
|
+
if (restoreMismatch) {
|
|
391
|
+
heal.push({ code: 'ROLLBACK_VERIFY_FAILED', note: `还原后重读比对不一致:${errText(err)}` });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// 阶段二:frozen 收敛阶梯(仅在字节还原验证通过后才有意义;R2:异常→manual-repair)
|
|
395
|
+
if (restoreVerified) {
|
|
396
|
+
let conv;
|
|
397
|
+
try {
|
|
398
|
+
conv = await frozenConvergeLadder(d, heal);
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
return manualRepair(kind, healSuffixNote(`回滚收敛异常:${errText(err)}`), heal, errText(err), true);
|
|
402
|
+
}
|
|
403
|
+
if (conv.class === 'ok') {
|
|
404
|
+
heal.push({ code: 'ROLLBACK_CONVERGED_FROZEN', note: '回滚后 frozen 校验一致' });
|
|
405
|
+
return rolledBack(kind, failure, heal, conv.output);
|
|
406
|
+
}
|
|
407
|
+
// 收敛失败 → originallyAbsent 补移除兜底(移除成功 ≠ 收敛已证,需复验)
|
|
408
|
+
if (fallbackRemovePkg !== undefined && originallyAbsent(fallbackRemovePkg, snapshots)) {
|
|
409
|
+
let rm;
|
|
410
|
+
try {
|
|
411
|
+
rm = await d.runner.remove(fallbackRemovePkg);
|
|
412
|
+
}
|
|
413
|
+
catch (rmErr) {
|
|
414
|
+
return manualRepair(kind, healSuffixNote(`${conv.output};移除 ${fallbackRemovePkg} 也失败(${errText(rmErr)})`), heal, conv.output, true);
|
|
415
|
+
}
|
|
416
|
+
if (rm.class !== 'ok') {
|
|
417
|
+
return manualRepair(kind, healSuffixNote(`${conv.output};移除 ${fallbackRemovePkg} 也失败(${rm.output})`), heal, conv.output, true);
|
|
418
|
+
}
|
|
419
|
+
heal.push({ code: 'ROLLBACK_FALLBACK_REMOVED', note: `恢复安装失败,已补移除原不存在的依赖 ${fallbackRemovePkg}` });
|
|
420
|
+
// R1:严格 verify gone + 再跑一次 frozen 收敛,通过才允许 rolled-back
|
|
421
|
+
try {
|
|
422
|
+
const depsNow = await strictReadDeps(d);
|
|
423
|
+
if (fallbackRemovePkg in depsNow) {
|
|
424
|
+
return manualRepair(kind, healSuffixNote(`${conv.output};补移除 ${fallbackRemovePkg} 后依赖仍存在`), heal, conv.output, true);
|
|
425
|
+
}
|
|
426
|
+
const reconverge = await frozenConvergeLadder(d, heal);
|
|
427
|
+
if (reconverge.class === 'ok') {
|
|
428
|
+
heal.push({ code: 'ROLLBACK_CONVERGED_FROZEN', note: '补移除后 frozen 校验一致' });
|
|
429
|
+
return rolledBack(kind, failure, heal, reconverge.output);
|
|
430
|
+
}
|
|
431
|
+
return manualRepair(kind, healSuffixNote(`${conv.output};补移除 ${fallbackRemovePkg} 后 frozen 复验仍未通过:${reconverge.output}`), heal, reconverge.output, true);
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
return manualRepair(kind, healSuffixNote(`${conv.output};补移除 ${fallbackRemovePkg} 后复验异常:${errText(err)}`), heal, conv.output, true);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return manualRepair(kind, healSuffixNote(conv.output), heal, conv.output, true);
|
|
438
|
+
}
|
|
439
|
+
// 字节还原本身失败 → 补移除仅作为补偿动作记录(R1:restoreVerified=false 一律 manual-repair)
|
|
440
|
+
const restoreDetail = errText(restoreErr);
|
|
441
|
+
if (fallbackRemovePkg !== undefined && originallyAbsent(fallbackRemovePkg, snapshots)) {
|
|
442
|
+
try {
|
|
443
|
+
const rm = await d.runner.remove(fallbackRemovePkg);
|
|
444
|
+
if (rm.class === 'ok') {
|
|
445
|
+
heal.push({ code: 'ROLLBACK_FALLBACK_REMOVED', note: `快照恢复失败,已补移除原不存在的依赖 ${fallbackRemovePkg}(字节还原未验证)` });
|
|
446
|
+
return manualRepair(kind, healSuffixNote(restoreDetail), heal, rm.output, false);
|
|
447
|
+
}
|
|
448
|
+
return manualRepair(kind, healSuffixNote(`${restoreDetail};移除 ${fallbackRemovePkg} 也失败(${rm.output})`), heal, restoreDetail, false);
|
|
449
|
+
}
|
|
450
|
+
catch (rmErr) {
|
|
451
|
+
return manualRepair(kind, healSuffixNote(`${restoreDetail};移除 ${fallbackRemovePkg} 也失败(${errText(rmErr)})`), heal, restoreDetail, false);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return manualRepair(kind, healSuffixNote(restoreDetail), heal, restoreDetail, false);
|
|
455
|
+
}
|
|
456
|
+
function resolveDeps(deps) {
|
|
457
|
+
const profileDir = deps?.profileDir ?? webProfileDir();
|
|
458
|
+
return {
|
|
459
|
+
profileDir,
|
|
460
|
+
warmPackument: deps?.warmPackument,
|
|
461
|
+
setLiveDisabled: deps?.setLiveDisabled ?? setLivePluginDisabled,
|
|
462
|
+
stripPatchedEntries: deps?.stripPatchedEntries ?? removePatchedDependencyEntries,
|
|
463
|
+
retryDelaysMs: deps?.retryDelaysMs ?? [5_000, 15_000],
|
|
464
|
+
paths: [
|
|
465
|
+
join(profileDir, 'package.json'),
|
|
466
|
+
join(profileDir, 'pnpm-lock.yaml'),
|
|
467
|
+
join(profileDir, 'pnpm-workspace.yaml'),
|
|
468
|
+
],
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
// ---------- install-npm 门 ----------
|
|
472
|
+
/** B3:retryable-lag 退避重试(abort-aware sleep + warmPackument 预热;R4:取消不吞)。 */
|
|
473
|
+
async function addWithLagRetry(req, spec, d, heal) {
|
|
474
|
+
let attempt = 0;
|
|
475
|
+
for (;;) {
|
|
476
|
+
const out = await d.runner.add(spec, req.signal);
|
|
477
|
+
if (out.class !== 'retryable-lag')
|
|
478
|
+
return out;
|
|
479
|
+
if (attempt >= d.retryDelaysMs.length)
|
|
480
|
+
return out;
|
|
481
|
+
const delay = d.retryDelaysMs[attempt] ?? 0;
|
|
482
|
+
attempt += 1;
|
|
483
|
+
heal.push({ code: 'B3_LAG_RETRY', note: `NO_MATCHING_VERSION 疑似 packument CDN 滞后,退避 ${delay}ms 后重试(第 ${attempt} 次)` });
|
|
484
|
+
if (delay > 0)
|
|
485
|
+
await sleepAbortable(delay, req.signal);
|
|
486
|
+
if (req.signal?.aborted)
|
|
487
|
+
throw abortErr();
|
|
488
|
+
if (d.warmPackument !== undefined) {
|
|
489
|
+
await d.warmPackument(req.pkg, req.signal);
|
|
490
|
+
// F4(复审补充):不信任 warm 正确传播取消——即使它正常 resolve,
|
|
491
|
+
// 取消已发生就不得再发起下一轮 add
|
|
492
|
+
if (req.signal?.aborted)
|
|
493
|
+
throw abortErr();
|
|
494
|
+
heal.push({ code: 'B3_PACKUMENT_WARMED', note: '重试前已预热 registry packument' });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* npm 门最终验证(R3:可重复执行)——manifest 依赖存在与锚定、lockfile 存在、
|
|
500
|
+
* 与 npm dist 一致的 resolution.integrity。firstPass 控制是否记录 range 放行 heal。
|
|
501
|
+
*/
|
|
502
|
+
async function verifyNpmCommitState(req, d, heal, firstPass) {
|
|
503
|
+
const depsNow = await strictReadDeps(d);
|
|
504
|
+
if (depsNow[req.pkg] === undefined) {
|
|
505
|
+
throw new DomainFailure({ code: 'DEP_MISSING_AFTER_ADD', note: `安装后未在 profile 依赖中找到 ${req.pkg}` });
|
|
506
|
+
}
|
|
507
|
+
if (depsNow[req.pkg] !== req.version) {
|
|
508
|
+
if (!specAnchoredAtVersion(depsNow[req.pkg], req.version)) {
|
|
509
|
+
throw new DomainFailure({ code: 'DEP_VERSION_MISMATCH', note: `profile 依赖版本 ${depsNow[req.pkg]} 与目标 ${req.version} 不一致` });
|
|
510
|
+
}
|
|
511
|
+
if (firstPass) {
|
|
512
|
+
heal.push({
|
|
513
|
+
code: 'RANGE_ANCHOR_ACCEPTED',
|
|
514
|
+
note: `安装链把依赖写成 range(${depsNow[req.pkg]},旧版 CLI save-prefix 行为);精确性由 lockfile integrity 校验继续保证`,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
let lockText;
|
|
519
|
+
try {
|
|
520
|
+
lockText = await readFile(join(d.profileDir, 'pnpm-lock.yaml'), 'utf8');
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
throw new DomainFailure({ code: 'LOCKFILE_MISSING', note: '安装后未找到 pnpm-lock.yaml,无法核对 integrity' });
|
|
524
|
+
}
|
|
525
|
+
let actual;
|
|
526
|
+
try {
|
|
527
|
+
actual = readPnpmLockIntegrity(lockText, req.pkg, req.version);
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
throw new DomainFailure({ code: 'LOCK_INTEGRITY_MISMATCH', note: errText(err) });
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
assertNpmIntegrity(req.integrity, actual, req.pkg, req.version);
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
throw new DomainFailure({ code: 'LOCK_INTEGRITY_MISMATCH', note: errText(err) });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/** mutate 已开始后的取消:进不可取消回滚(R4:runner 返回 ok 也不能提交已取消的变更)。 */
|
|
540
|
+
function abortedFailure(note) {
|
|
541
|
+
return { code: 'ABORTED', note };
|
|
542
|
+
}
|
|
543
|
+
async function installNpm(req, d, snapshots) {
|
|
544
|
+
const heal = [];
|
|
545
|
+
const spec = `${req.pkg}@${req.version}`;
|
|
546
|
+
try {
|
|
547
|
+
const addOut = await addWithLagRetry(req, spec, d, heal);
|
|
548
|
+
if (req.signal?.aborted) {
|
|
549
|
+
return await rollbackAndConverge(d, req.kind, abortedFailure('安装过程中已取消'), heal, snapshots, req.pkg);
|
|
550
|
+
}
|
|
551
|
+
if (addOut.class !== 'ok') {
|
|
552
|
+
const failure = req.signal?.aborted
|
|
553
|
+
? { code: 'ABORTED', note: addOut.output }
|
|
554
|
+
: addOut.class === 'retryable-lag'
|
|
555
|
+
? { code: 'ADD_RETRY_EXHAUSTED', note: addOut.output }
|
|
556
|
+
: { code: 'ADD_FAILED', note: addOut.output };
|
|
557
|
+
return await rollbackAndConverge(d, req.kind, failure, heal, snapshots, req.pkg);
|
|
558
|
+
}
|
|
559
|
+
// verify 相(严格读取;首次)
|
|
560
|
+
await verifyNpmCommitState(req, d, heal, true);
|
|
561
|
+
// B1(成功路径):安装链丢 manifest 顶层键 → 快照找回 + frozen 复验(复验失败 fail-closed 进回滚)
|
|
562
|
+
const restoredKeys = await restoreManifestKeys(d.profileDir, snapshots[0]);
|
|
563
|
+
if (restoredKeys) {
|
|
564
|
+
heal.push({ code: 'B1_MANIFEST_KEYS_RESTORED', note: `安装链丢失了 manifest 顶层键(${restoredKeys.join(', ')}),已从安装前快照找回` });
|
|
565
|
+
const conv = await frozenConvergeLadder(d, heal);
|
|
566
|
+
if (conv.class !== 'ok') {
|
|
567
|
+
heal.push({ code: 'B1_FROZEN_REVERIFY_FAILED', note: `frozen 复验未通过:${conv.output}` });
|
|
568
|
+
throw new DomainFailure({ code: 'POST_MUTATION_CONVERGENCE_FAILED', note: `B1 找回 manifest 键后 frozen 复验失败:${conv.output}` });
|
|
569
|
+
}
|
|
570
|
+
// R3:B1/B2 可能受控改写了 manifest/lockfile——提交前对最终状态重新执行完整验证
|
|
571
|
+
await verifyNpmCommitState(req, d, heal, false);
|
|
572
|
+
}
|
|
573
|
+
if (req.signal?.aborted) {
|
|
574
|
+
return await rollbackAndConverge(d, req.kind, abortedFailure('提交前已取消'), heal, snapshots, req.pkg);
|
|
575
|
+
}
|
|
576
|
+
return committed(req.kind, heal, addOut.output, {
|
|
577
|
+
pkg: req.pkg,
|
|
578
|
+
spec,
|
|
579
|
+
version: req.version,
|
|
580
|
+
usedAllowAllBuilds: addOut.usedAllowAllBuilds === true,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
if (err instanceof DomainFailure) {
|
|
585
|
+
return await rollbackAndConverge(d, req.kind, err.failure, heal, snapshots, req.pkg);
|
|
586
|
+
}
|
|
587
|
+
const failure = isAbortish(err, req.signal)
|
|
588
|
+
? { code: 'ABORTED', note: errText(err) }
|
|
589
|
+
: { code: 'INTERNAL_ERROR', note: errText(err) };
|
|
590
|
+
return await rollbackAndConverge(d, req.kind, failure, heal, snapshots, req.pkg);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
// ---------- install-github 门(Task 5) ----------
|
|
594
|
+
/** 从快照 manifest(宽容)读依赖表:github 前态比对用;无/坏 → {}。 */
|
|
595
|
+
function snapshotDepsOf(snapshot) {
|
|
596
|
+
if (!snapshot?.existed || snapshot.bytes === null)
|
|
597
|
+
return {};
|
|
598
|
+
try {
|
|
599
|
+
const parsed = JSON.parse(snapshot.bytes.toString('utf8'));
|
|
600
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
601
|
+
return {};
|
|
602
|
+
const deps = parsed.dependencies;
|
|
603
|
+
if (deps === null || deps === undefined || typeof deps !== 'object' || Array.isArray(deps))
|
|
604
|
+
return {};
|
|
605
|
+
const out = {};
|
|
606
|
+
for (const [name, spec] of Object.entries(deps)) {
|
|
607
|
+
if (typeof spec === 'string' && spec !== '')
|
|
608
|
+
out[name] = spec;
|
|
609
|
+
}
|
|
610
|
+
return out;
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
return {};
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async function installGithub(req, d, snapshots) {
|
|
617
|
+
const heal = [];
|
|
618
|
+
const spec = `github:${req.repo}#${req.sha}`;
|
|
619
|
+
try {
|
|
620
|
+
// github 门无 B3:retryable-lag 不适用,一律按 hard-fail 处置(分类消费矩阵 github 行)
|
|
621
|
+
const addOut = await d.runner.add(spec, req.signal);
|
|
622
|
+
if (req.signal?.aborted) {
|
|
623
|
+
return await rollbackAndConverge(d, req.kind, abortedFailure('安装过程中已取消'), heal, snapshots);
|
|
624
|
+
}
|
|
625
|
+
if (addOut.class !== 'ok') {
|
|
626
|
+
const failure = req.signal?.aborted
|
|
627
|
+
? { code: 'ABORTED', note: addOut.output }
|
|
628
|
+
: { code: 'ADD_FAILED', note: addOut.output };
|
|
629
|
+
return await rollbackAndConverge(d, req.kind, failure, heal, snapshots);
|
|
630
|
+
}
|
|
631
|
+
// verify(契约 8):存在键 k 使 depsNow[k] === spec 且 snapshotDeps[k] !== spec(相对前态变化)
|
|
632
|
+
const depsNow = await strictReadDeps(d);
|
|
633
|
+
const prevDeps = snapshotDepsOf(snapshots[0]);
|
|
634
|
+
const key = Object.keys(depsNow).find((k) => depsNow[k] === spec && prevDeps[k] !== spec);
|
|
635
|
+
if (key === undefined) {
|
|
636
|
+
throw new DomainFailure({
|
|
637
|
+
code: 'GITHUB_SPEC_MISMATCH',
|
|
638
|
+
note: `安装后未在 profile 依赖中找到受 SHA 锁定的新 spec(${spec});已存在的同 spec 旧依赖不算命中`,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
if (req.signal?.aborted) {
|
|
642
|
+
return await rollbackAndConverge(d, req.kind, abortedFailure('提交前已取消'), heal, snapshots);
|
|
643
|
+
}
|
|
644
|
+
return committed(req.kind, heal, addOut.output, {
|
|
645
|
+
pkg: key,
|
|
646
|
+
spec,
|
|
647
|
+
sha: req.sha,
|
|
648
|
+
tag: req.tag,
|
|
649
|
+
usedAllowAllBuilds: addOut.usedAllowAllBuilds === true,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
catch (err) {
|
|
653
|
+
if (err instanceof DomainFailure) {
|
|
654
|
+
return await rollbackAndConverge(d, req.kind, err.failure, heal, snapshots);
|
|
655
|
+
}
|
|
656
|
+
const failure = isAbortish(err, req.signal)
|
|
657
|
+
? { code: 'ABORTED', note: errText(err) }
|
|
658
|
+
: { code: 'INTERNAL_ERROR', note: errText(err) };
|
|
659
|
+
return await rollbackAndConverge(d, req.kind, failure, heal, snapshots);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
// ---------- uninstall 门(Task 5;定序写死:validate → 快照 → live-disable → 摘补丁 → remove → verify gone) ----------
|
|
663
|
+
/** validate(严格读取):全部 rejected,零写入零快照。返回 null 表示通过。 */
|
|
664
|
+
async function validateUninstall(req, d) {
|
|
665
|
+
let depsNow;
|
|
666
|
+
try {
|
|
667
|
+
depsNow = await strictReadDeps(d);
|
|
668
|
+
}
|
|
669
|
+
catch (err) {
|
|
670
|
+
if (err instanceof DomainFailure)
|
|
671
|
+
return rejected(err.failure, req.kind);
|
|
672
|
+
return rejected({ code: 'PROFILE_MANIFEST_UNREADABLE', note: errText(err) }, req.kind);
|
|
673
|
+
}
|
|
674
|
+
if (!(req.pkg in depsNow)) {
|
|
675
|
+
return rejected({ code: 'NOT_INSTALLED', note: `web profile 未安装该插件: ${req.pkg}` }, req.kind);
|
|
676
|
+
}
|
|
677
|
+
const dir = resolvePluginDir(d.profileDir, req.pkg, depsNow[req.pkg]);
|
|
678
|
+
if (dir === null) {
|
|
679
|
+
return rejected({ code: 'PLUGIN_METADATA_UNREADABLE', note: `无法解析插件目录: ${req.pkg}` }, req.kind);
|
|
680
|
+
}
|
|
681
|
+
let raw;
|
|
682
|
+
try {
|
|
683
|
+
raw = await readFile(join(dir, 'package.json'), 'utf8');
|
|
684
|
+
}
|
|
685
|
+
catch (err) {
|
|
686
|
+
return rejected({ code: 'PLUGIN_METADATA_UNREADABLE', note: `插件 package.json 读取失败(${dir}):${errText(err)}` }, req.kind);
|
|
687
|
+
}
|
|
688
|
+
let meta;
|
|
689
|
+
try {
|
|
690
|
+
meta = JSON.parse(raw);
|
|
691
|
+
}
|
|
692
|
+
catch (err) {
|
|
693
|
+
return rejected({ code: 'PLUGIN_METADATA_UNREADABLE', note: `插件 package.json 不是合法 JSON(${dir}):${errText(err)}` }, req.kind);
|
|
694
|
+
}
|
|
695
|
+
if (meta === null || typeof meta !== 'object' || Array.isArray(meta) || !('dsh' in meta)) {
|
|
696
|
+
return rejected({ code: 'NOT_DSH_PLUGIN', note: `不是 dsh 插件: ${req.pkg}` }, req.kind);
|
|
697
|
+
}
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
/** 卸载回滚:统一回滚后 live 尽力反向(R2:回滚异常也必须反向;补偿记录在案,不改变终态)。 */
|
|
701
|
+
async function rollbackWithLiveReverse(req, d, failure, heal, snapshots, liveDisabled) {
|
|
702
|
+
let result;
|
|
703
|
+
try {
|
|
704
|
+
result = await rollbackAndConverge(d, req.kind, failure, heal, snapshots);
|
|
705
|
+
}
|
|
706
|
+
catch (err) {
|
|
707
|
+
// 最后防线:rollbackAndConverge 理论上 total;仍异常时如实 manual-repair,随后照样 live 反向
|
|
708
|
+
result = manualRepair(req.kind, { code: failure.code, note: `${failure.note};依赖回滚也失败(${errText(err)})` }, heal, errText(err), false);
|
|
709
|
+
}
|
|
710
|
+
if (liveDisabled) {
|
|
711
|
+
try {
|
|
712
|
+
const back = await d.setLiveDisabled(req.pkg, false);
|
|
713
|
+
if (back)
|
|
714
|
+
heal.push({ code: 'LIVE_REENABLED', note: '回滚后已恢复插件运行(live 反向)' });
|
|
715
|
+
else
|
|
716
|
+
heal.push({ code: 'LIVE_REENABLE_FAILED', note: '回滚后恢复插件运行未确认(live 反向返回 false)' });
|
|
717
|
+
}
|
|
718
|
+
catch (err) {
|
|
719
|
+
heal.push({ code: 'LIVE_REENABLE_FAILED', note: `回滚后恢复插件运行失败(live 反向):${errText(err)}` });
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return result;
|
|
723
|
+
}
|
|
724
|
+
async function uninstall(req, d, snapshots) {
|
|
725
|
+
const heal = [];
|
|
726
|
+
let liveDisabled = false;
|
|
727
|
+
let orphanedPatchFiles = [];
|
|
728
|
+
try {
|
|
729
|
+
liveDisabled = await d.setLiveDisabled(req.pkg, true);
|
|
730
|
+
if (liveDisabled)
|
|
731
|
+
heal.push({ code: 'LIVE_DISABLED', note: '运行中的插件界面已先行下线' });
|
|
732
|
+
const patch = d.stripPatchedEntries(d.profileDir, req.pkg);
|
|
733
|
+
if (patch.changed)
|
|
734
|
+
heal.push({ code: 'PATCH_ENTRIES_STRIPPED', note: '已摘除该包的 pnpm 补丁条目(防残留补丁触发 unused-patch 整单失败)' });
|
|
735
|
+
orphanedPatchFiles = patch.orphanedPatchFiles;
|
|
736
|
+
const rmOut = await d.runner.remove(req.pkg, req.signal);
|
|
737
|
+
if (req.signal?.aborted) {
|
|
738
|
+
return await rollbackWithLiveReverse(req, d, abortedFailure('卸载过程中已取消'), heal, snapshots, liveDisabled);
|
|
739
|
+
}
|
|
740
|
+
if (rmOut.class !== 'ok') {
|
|
741
|
+
const failure = req.signal?.aborted
|
|
742
|
+
? { code: 'ABORTED', note: rmOut.output }
|
|
743
|
+
: { code: 'REMOVE_FAILED', note: rmOut.output };
|
|
744
|
+
return await rollbackWithLiveReverse(req, d, failure, heal, snapshots, liveDisabled);
|
|
745
|
+
}
|
|
746
|
+
const depsNow = await strictReadDeps(d);
|
|
747
|
+
if (req.pkg in depsNow) {
|
|
748
|
+
throw new DomainFailure({ code: 'STILL_PRESENT_AFTER_REMOVE', note: `移除后 profile 依赖中仍存在 ${req.pkg}` });
|
|
749
|
+
}
|
|
750
|
+
if (req.signal?.aborted) {
|
|
751
|
+
return await rollbackWithLiveReverse(req, d, abortedFailure('提交前已取消'), heal, snapshots, liveDisabled);
|
|
752
|
+
}
|
|
753
|
+
return committed(req.kind, heal, rmOut.output, { pkg: req.pkg, liveDisabled, orphanedPatchFiles });
|
|
754
|
+
}
|
|
755
|
+
catch (err) {
|
|
756
|
+
if (err instanceof DomainFailure) {
|
|
757
|
+
return await rollbackWithLiveReverse(req, d, err.failure, heal, snapshots, liveDisabled);
|
|
758
|
+
}
|
|
759
|
+
const failure = isAbortish(err, req.signal)
|
|
760
|
+
? { code: 'ABORTED', note: errText(err) }
|
|
761
|
+
: { code: 'INTERNAL_ERROR', note: errText(err) };
|
|
762
|
+
return await rollbackWithLiveReverse(req, d, failure, heal, snapshots, liveDisabled);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
// ---------- 入口:FIFO 互斥 + 分发 ----------
|
|
766
|
+
/** 模块级 FIFO 互斥锁(进程内串行;skillhub install-lock 同款思路)。 */
|
|
767
|
+
let txTail = Promise.resolve();
|
|
768
|
+
export async function runProfileTransaction(req, deps) {
|
|
769
|
+
validateRequest(req); // 畸形 request 快速失败:零排队、零写入
|
|
770
|
+
const exec = txTail.then(() => executeTransaction(req, deps), () => executeTransaction(req, deps));
|
|
771
|
+
txTail = exec.catch(() => undefined);
|
|
772
|
+
return exec;
|
|
773
|
+
}
|
|
774
|
+
async function executeTransaction(req, deps) {
|
|
775
|
+
const base = resolveDeps(deps);
|
|
776
|
+
// 排队期 abort
|
|
777
|
+
if (req.signal?.aborted) {
|
|
778
|
+
return rejected({ code: 'ABORTED', note: '排队期已取消' }, req.kind);
|
|
779
|
+
}
|
|
780
|
+
// runner factory(畸形校验已过;factory 异常 = 快照前 → rejected)
|
|
781
|
+
let runner;
|
|
782
|
+
try {
|
|
783
|
+
runner = (deps?.runner ?? makeDshRunner)(base.profileDir);
|
|
784
|
+
}
|
|
785
|
+
catch (err) {
|
|
786
|
+
return rejected({ code: 'INTERNAL_ERROR', note: `runner 构造失败:${errText(err)}` }, req.kind);
|
|
787
|
+
}
|
|
788
|
+
const d = { ...base, runner };
|
|
789
|
+
// uninstall 门 validate 先于快照(定序 1;全部 rejected 零写入零快照)
|
|
790
|
+
if (req.kind === 'uninstall') {
|
|
791
|
+
const rejectedResult = await validateUninstall(req, d);
|
|
792
|
+
if (rejectedResult !== null)
|
|
793
|
+
return rejectedResult;
|
|
794
|
+
}
|
|
795
|
+
// 快照(非 ENOENT 读取异常 → rejected,零写入)
|
|
796
|
+
let snapshots;
|
|
797
|
+
try {
|
|
798
|
+
snapshots = await snapshotFiles(d.paths);
|
|
799
|
+
}
|
|
800
|
+
catch (err) {
|
|
801
|
+
return rejected({ code: 'SNAPSHOT_FAILED', note: errText(err) }, req.kind);
|
|
802
|
+
}
|
|
803
|
+
// mutate 前 abort
|
|
804
|
+
if (req.signal?.aborted) {
|
|
805
|
+
return rejected({ code: 'ABORTED', note: '变更开始前已取消' }, req.kind);
|
|
806
|
+
}
|
|
807
|
+
// R2 第二层:phase-aware 最后防线。各门自身已 total;此兜底只处理门逻辑的意外遗漏,
|
|
808
|
+
// 快照已取 → 照常走统一回滚(rollbackAndConverge 已 total,不会再递归逃逸)。
|
|
809
|
+
try {
|
|
810
|
+
switch (req.kind) {
|
|
811
|
+
case 'install-npm':
|
|
812
|
+
return await installNpm(req, d, snapshots);
|
|
813
|
+
case 'install-github':
|
|
814
|
+
return await installGithub(req, d, snapshots);
|
|
815
|
+
case 'uninstall':
|
|
816
|
+
return await uninstall(req, d, snapshots);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
catch (err) {
|
|
820
|
+
const failure = isAbortish(err, req.signal)
|
|
821
|
+
? { code: 'ABORTED', note: errText(err) }
|
|
822
|
+
: { code: 'INTERNAL_ERROR', note: errText(err) };
|
|
823
|
+
return await rollbackAndConverge(d, req.kind, failure, [], snapshots);
|
|
824
|
+
}
|
|
825
|
+
}
|