@kira905/dsh-butler-install 0.2.0 → 0.3.0

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.
@@ -0,0 +1,541 @@
1
+ /**
2
+ * 页面背后的**执行器** —— 只编排,不重写安装逻辑。
3
+ *
4
+ * 纪律(改这个文件之前先读):
5
+ * 1. 装什么 / 怎么装 / 怎么判,全部还是 `lib/plan.mjs` + `lib/run.mjs` 说了算。本文件**只**做:
6
+ * 加锁(同一时刻一批)、跑那一批、把 `run.mjs` 吐出来的进度事件收成内存对象、
7
+ * 把【取消】翻成 AbortSignal、把两个撤回动作转给 `lib/ui-rollback.mjs`。
8
+ * 2. **状态只在内存**(方案 §11.6 · B4):进程死了这一批就断了,重开页面按 `buildSuiteState()` +
9
+ * `verifyItem()` **现跑**给真实状态 —— 这比读一份可能过时的状态文件更准。
10
+ * ⇒ 所以"interrupted"不落盘,也绝不往数据根写字(除了 runSuite 自己该拍的快照)。
11
+ * 3. 进度事件**只收摘要行**,最多留最近 `MAX_LINES` 行、每行截到 `LINE_MAX` 字:
12
+ * 16 项里有 6 项是 `npm i -g`,原始输出是 MB 级的,塞进内存和页面就是灾难。
13
+ * 4. 🔴 不许 monkey-patch `process.stdout.write` 抓进度(会污染整个向导页进程的输出:
14
+ * .cmd 窗口的日志与多请求之间互相穿插)。进度只从 `runSuite(options, hooks)` 的回调来。
15
+ */
16
+ import path from 'node:path';
17
+ import crypto from 'node:crypto';
18
+ import { buildSuiteState } from './plan.mjs';
19
+ import { runSuite } from './run.mjs';
20
+ import { EXIT } from './cli-args.mjs';
21
+ import { formatCommandLine } from './exec.mjs';
22
+ import { declarationFingerprint, checkPrecondition, uninstallBatch, restoreDeclarations } from './ui-rollback.mjs';
23
+ import { runLabel } from './item-status.mjs';
24
+
25
+ /** 进度里最多留多少行摘要(够看"卡在哪一项",又不至于把内存堆满) */
26
+ export const MAX_LINES = 60;
27
+ /** 每行摘要最多留多少字 */
28
+ export const LINE_MAX = 400;
29
+ /** `--ui` 路径的墙钟上限天花板:30 分钟(16 项里有 6 个 npm -g,冷缓存首装很慢) */
30
+ export const UI_BUDGET_CAP_MS = 30 * 60 * 1000;
31
+ /** `--budget` 的 CLI 缺省值(`lib/cli-args.mjs` 里那个 300s):等于它 ⇒ 认为"用户没显式给" */
32
+ export const CLI_DEFAULT_BUDGET_MS = 300000;
33
+
34
+ function stamp() {
35
+ // 与 run.mjs 的 stamp() 同形,但多带毫秒与 pid:页面里快速连点两次不会撞同一个快照目录
36
+ const iso = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-');
37
+ return `${iso.replace(/\..*$/, '')}-${String(new Date().getMilliseconds()).padStart(3, '0')}-${process.pid}`;
38
+ }
39
+
40
+ /** 这一批的墙钟上限:只有"没被显式改过"的 --budget 才抬,显式的尊重用户 */
41
+ export function effectiveBudgetMs({ options = {}, installCount = 1 } = {}) {
42
+ const perStep = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 120000;
43
+ const bySteps = Math.min(UI_BUDGET_CAP_MS, perStep * Math.max(1, installCount));
44
+ const asked = Number.isFinite(options.budgetMs) ? options.budgetMs : CLI_DEFAULT_BUDGET_MS;
45
+ if (asked !== CLI_DEFAULT_BUDGET_MS) return asked;
46
+ return Math.max(asked, bySteps);
47
+ }
48
+
49
+ /**
50
+ * 预计时长:按"最慢的那类"给区间(插件走宿主 → 约 25s;npm -g 冷缓存 → 约 45s)。
51
+ * 这是给人看的一句话,不是承诺;页面同时会显示真实预算。
52
+ */
53
+ export function estimateDurationMs(items = []) {
54
+ let min = 0;
55
+ let max = 0;
56
+ for (const item of items) {
57
+ const cli = item.type === 'cli';
58
+ min += cli ? 25 * 1000 : 15 * 1000;
59
+ max += cli ? 60 * 1000 : 35 * 1000;
60
+ }
61
+ return { minMs: min, maxMs: max, text: `${Math.max(1, Math.round(min / 60000))}–${Math.max(1, Math.round(max / 60000))} 分钟` };
62
+ }
63
+
64
+ /** 给页面的"等价命令行"(方案 §11.6 · S3:必须带 --apply,不然用户拿到的是演练命令) */
65
+ export function equivalentCommand({ options = {}, profile, budgetMs }) {
66
+ const args = ['--profile', profile];
67
+ if (options.manifest) args.push('--manifest', options.manifest);
68
+ if (options.home) args.push('--home', options.home);
69
+ if (options.npmPrefix) args.push('--npm-prefix', options.npmPrefix);
70
+ if (options.upgrade) args.push('--upgrade');
71
+ if (Array.isArray(options.only) && options.only.length) args.push('--only', options.only.join(','));
72
+ if (Array.isArray(options.skip) && options.skip.length) args.push('--skip', options.skip.join(','));
73
+ args.push('--apply');
74
+ if (Number.isFinite(budgetMs)) args.push('--budget', String(Math.max(1, Math.round(budgetMs / 1000))));
75
+ return formatCommandLine('dsh-butler-install', args);
76
+ }
77
+
78
+ function statusMessage(status, { exitCode, aborted, rollback }) {
79
+ if (status === 'cancelled') {
80
+ return [
81
+ '已取消 —— 装了一半,这不是"干净撤回":已经装好的项留着;正在跑的那一项被按失败处理(默认 step 回滚:退到这一步之前)。',
82
+ rollback && rollback.clean === false ? '注意:那一步的回滚也不干净,报告里会列出没卸掉的项。' : '',
83
+ '要让"已装"真的变回"未装",用【卸载这次装的】。',
84
+ ].filter(Boolean).join('');
85
+ }
86
+ if (status === 'done') return '这一批全绿:每一项都过了当场判据(命令退出码 0 不算成功,判据过了才算)。';
87
+ if (status === 'failed') {
88
+ return `这一批有失败项(退出码 ${exitCode}):${exitCode === EXIT.ROLLED_BACK ? '已按回滚策略回滚,报告里写着回滚干净了没有' : exitCode === EXIT.UNCLEAN ? '回滚不干净,要人看报告里哪份文件对不上' : '前置条件/清单有问题,什么都没改'}。`;
89
+ }
90
+ if (status === 'error') return '安装器自己抛错了(不是判据失败):这一批的结果不可信,请按控制台日志排查。';
91
+ if (status === 'cancelling') return '正在停:在杀当前那条命令的进程树。';
92
+ return aborted ? '已取消。' : '';
93
+ }
94
+
95
+ /**
96
+ * 建一个执行器(一个服务进程一个)。
97
+ * @param {{options: object, onLog?: (line: string) => void}} args options = CLI 选项同形
98
+ */
99
+ export function createInstallRunner({ options = {}, onLog = null } = {}) {
100
+ const state = {
101
+ status: 'idle',
102
+ batchId: null,
103
+ startedAt: null,
104
+ finishedAt: null,
105
+ index: 0,
106
+ total: 0,
107
+ current: null,
108
+ items: [],
109
+ lines: [],
110
+ lastLineAt: null,
111
+ lastEventAt: null,
112
+ exitCode: null,
113
+ message: '',
114
+ result: null,
115
+ context: null,
116
+ settlement: null,
117
+ rollback: null,
118
+ busy: false,
119
+ };
120
+ let controller = null;
121
+ let activePromise = null;
122
+
123
+ const log = (line) => {
124
+ if (typeof onLog === 'function') {
125
+ try { onLog(line); } catch { /* 日志出口坏了不影响安装 */ }
126
+ }
127
+ };
128
+
129
+ const pushLine = (line) => {
130
+ const text = String(line == null ? '' : line).slice(0, LINE_MAX);
131
+ if (!text) return;
132
+ state.lines.push(text);
133
+ if (state.lines.length > MAX_LINES) state.lines.splice(0, state.lines.length - MAX_LINES);
134
+ state.lastLineAt = new Date().toISOString();
135
+ };
136
+
137
+ function progressOf() {
138
+ const counts = { pending: 0, running: 0, ok: 0, skip: 0, fail: 0, cancelled: 0 };
139
+ for (const item of state.items) counts[item.status] = (counts[item.status] || 0) + 1;
140
+ const sinceLastLineMs = state.lastLineAt ? Date.now() - Date.parse(state.lastLineAt) : null;
141
+ return {
142
+ ok: true,
143
+ status: state.status,
144
+ batchId: state.batchId,
145
+ startedAt: state.startedAt,
146
+ finishedAt: state.finishedAt,
147
+ index: state.index,
148
+ total: state.total,
149
+ current: state.current,
150
+ // label 由 lib/item-status.mjs 算好一起下发:页面只读不算(同源,§11.6 · S1)
151
+ items: state.items.map((i) => ({ ...i, label: runLabel(i.status) })),
152
+ lines: state.lines.slice(),
153
+ counts,
154
+ lastLineAt: state.lastLineAt,
155
+ lastEventAt: state.lastEventAt,
156
+ sinceLastLineMs,
157
+ // 静默期本来就可能 >60s(npm 静默下载):这不是"卡死",只是"没有新行"。两个读数都给出来
158
+ quietFor60s: sinceLastLineMs !== null && sinceLastLineMs > 60000,
159
+ exitCode: state.exitCode,
160
+ message: state.message,
161
+ result: state.result,
162
+ // 撤回的可用性:B 要靠"本轮收尾时的 sha",进程重启过就没有了 —— 如实说,不假装能撤
163
+ settlement: state.settlement ? { at: state.settlement.at } : null,
164
+ rollback: state.rollback,
165
+ can: {
166
+ start: !state.busy,
167
+ cancel: state.status === 'running',
168
+ rollback: !state.busy && Boolean(state.result),
169
+ },
170
+ };
171
+ }
172
+
173
+ async function buildPreview() {
174
+ const built = await buildSuiteState({ ...options, apply: false, uninstall: false, json: false });
175
+ if (!built.ok) {
176
+ return {
177
+ ok: false,
178
+ exitCode: built.exitCode,
179
+ profile: options.profile,
180
+ errors: built.log.filter((l) => l && l.trim()),
181
+ };
182
+ }
183
+ const st = built.state;
184
+ const items = st.items.map((i) => ({
185
+ step: i.step,
186
+ name: i.name,
187
+ group: i.group,
188
+ type: i.type,
189
+ action: i.action,
190
+ command: i.command,
191
+ criteria: i.criteria,
192
+ installedVersion: i.installedVersion,
193
+ inRange: i.inRange,
194
+ }));
195
+ const willInstall = items.filter((i) => i.action === 'install');
196
+ const budgetMs = effectiveBudgetMs({ options, installCount: willInstall.length });
197
+ const estimate = estimateDurationMs(willInstall);
198
+ return {
199
+ ok: true,
200
+ profile: st.profile,
201
+ home: st.home,
202
+ npmPrefix: st.npmPrefix,
203
+ npmPrefixSource: st.npmPrefixSource,
204
+ profileExists: st.profileExists,
205
+ counts: st.counts,
206
+ preflight: st.preflight,
207
+ items,
208
+ willInstall: willInstall.map((i) => i.name),
209
+ skipped: items.filter((i) => i.action === 'skip').map((i) => i.name),
210
+ budgetMs,
211
+ estimate,
212
+ snapshotHint: path.join(st.home, 'butler-install', 'snapshots', '<开跑那一刻的时间戳>-ui/'),
213
+ equivalentCommand: equivalentCommand({ options, profile: st.profile, budgetMs }),
214
+ // 两步确认的第一半:这里只回答"将做什么",一条命令都不跑
215
+ note: '这一步只算给你看:不跑任何命令、不写任何文件。真的要装,页面还要再点一次【确认,开始】(带 confirm:true)。',
216
+ };
217
+ }
218
+
219
+ function handleEvent(event) {
220
+ const at = new Date().toISOString();
221
+ state.lastEventAt = at;
222
+ if (!event || typeof event !== 'object') return;
223
+ if (event.kind === 'log') {
224
+ pushLine(event.detail);
225
+ return;
226
+ }
227
+ if (event.kind === 'step-start') {
228
+ state.index = event.index;
229
+ state.total = event.total;
230
+ state.current = { index: event.index, name: event.item, action: event.action, startedAt: at };
231
+ const item = state.items.find((i) => i.index === event.index);
232
+ if (item && item.status === 'pending') item.status = 'running';
233
+ return;
234
+ }
235
+ if (event.kind === 'step-done') {
236
+ const item = state.items.find((i) => i.index === event.index);
237
+ if (item) {
238
+ if (event.ok) item.status = /已满足/.test(String(event.detail || '')) ? 'skip' : 'ok';
239
+ else item.status = /取消/.test(String(event.detail || '')) ? 'cancelled' : 'fail';
240
+ item.detail = String(event.detail || '').slice(0, LINE_MAX);
241
+ }
242
+ if (state.current && state.current.index === event.index) state.current = null;
243
+ return;
244
+ }
245
+ if (event.kind === 'batch-done') {
246
+ pushLine(`== 这一批结束:${event.detail || `退出码 ${event.exitCode}`} ==`);
247
+ }
248
+ }
249
+
250
+ async function runBatch(preview) {
251
+ const profileDir = path.join(preview.home, 'profiles', preview.profile);
252
+ const snapshotRoot = path.join(preview.home, 'butler-install', 'snapshots', `${stamp()}-ui`);
253
+ controller = new AbortController();
254
+ state.status = 'running';
255
+ state.batchId = `ui-${stamp()}-${crypto.randomBytes(3).toString('hex')}`;
256
+ state.startedAt = new Date().toISOString();
257
+ state.finishedAt = null;
258
+ state.index = 0;
259
+ state.total = preview.items.length;
260
+ state.current = null;
261
+ state.items = preview.items.map((i) => ({
262
+ index: i.step,
263
+ name: i.name,
264
+ action: i.action,
265
+ status: i.action === 'skip' ? 'skip' : 'pending',
266
+ detail: i.action === 'skip' ? '已满足,跳过' : '',
267
+ }));
268
+ state.lines = [];
269
+ state.lastLineAt = null;
270
+ state.exitCode = null;
271
+ state.message = '';
272
+ state.result = null;
273
+ state.rollback = null;
274
+ state.settlement = null;
275
+ state.context = {
276
+ home: preview.home,
277
+ profile: preview.profile,
278
+ profileDir,
279
+ snapshotRoot,
280
+ budgetMs: preview.budgetMs,
281
+ equivalentCommand: preview.equivalentCommand,
282
+ };
283
+
284
+ const runOptions = {
285
+ ...options,
286
+ // 页面内部走的永远是"真装"分支;`--ui` 与 `--apply` 的互斥只在 CLI 参数层
287
+ apply: true,
288
+ uninstall: false,
289
+ json: false,
290
+ budgetMs: preview.budgetMs,
291
+ // 快照目录由我们点名:撤回时要靠它找 pre-run,不能让 runSuite 自己生成一个我们看不见的路径
292
+ snapshotDir: snapshotRoot,
293
+ };
294
+
295
+ log(`[ui] 开始安装:${preview.profile} · ${preview.willInstall.length} 项 · 预算 ${Math.round(preview.budgetMs / 1000)}s · 快照 ${snapshotRoot}`);
296
+ try {
297
+ const res = await runSuite(runOptions, { signal: controller.signal, onEvent: handleEvent });
298
+ state.exitCode = res.exitCode;
299
+ state.result = {
300
+ exitCode: res.exitCode,
301
+ installed: (res.summary && Array.isArray(res.summary.installedThisRun)) ? res.summary.installedThisRun.slice() : [],
302
+ snapshotRoot,
303
+ rollback: (res.summary && res.summary.rollback) ? res.summary.rollback : null,
304
+ sweep: (res.summary && res.summary.sweep) ? res.summary.sweep : null,
305
+ equivalentCommand: preview.equivalentCommand,
306
+ };
307
+ // 记下"本轮收尾时的 sha":撤回动作 B 的前置核对就靠它
308
+ state.settlement = { at: new Date().toISOString(), files: declarationFingerprint(profileDir) };
309
+ const aborted = Boolean(controller && controller.signal.aborted);
310
+ state.status = aborted ? 'cancelled' : (res.exitCode === EXIT.OK ? 'done' : 'failed');
311
+ state.message = statusMessage(state.status, { exitCode: res.exitCode, aborted, rollback: state.result.rollback });
312
+ } catch (err) {
313
+ const aborted = Boolean(controller && controller.signal.aborted);
314
+ state.status = aborted ? 'cancelled' : 'error';
315
+ state.message = aborted
316
+ ? '已取消(收尾时安装器自己抛了个错,这一批的状态以快照与页面判据为准)'
317
+ : `安装器自己抛错了:${String(err && err.message ? err.message : err)}(不是判据失败;结果不可信)`;
318
+ state.settlement = { at: new Date().toISOString(), files: declarationFingerprint(profileDir) };
319
+ log(`[ui] 异常:${state.message}`);
320
+ } finally {
321
+ state.finishedAt = new Date().toISOString();
322
+ state.current = null;
323
+ state.busy = false;
324
+ controller = null;
325
+ activePromise = null;
326
+ log(`[ui] 这一批结束:status=${state.status} exitCode=${state.exitCode}`);
327
+ }
328
+ }
329
+
330
+ return {
331
+ /** 只算不做(两步确认的第一半) */
332
+ async preview() {
333
+ const preview = await buildPreview();
334
+ if (!preview.ok) return { status: preview.exitCode === EXIT.USAGE ? 400 : 500, body: preview };
335
+ return { status: 200, body: preview };
336
+ },
337
+
338
+ /** 真的开跑(两步确认的第二半:必须 confirm:true) */
339
+ async start(body = {}) {
340
+ if (state.busy) {
341
+ return {
342
+ status: 409,
343
+ body: {
344
+ ok: false,
345
+ error: 'busy',
346
+ message: '已经有一批在跑:同一时刻只允许一批(单飞锁在进程内存里,不落盘)',
347
+ progress: progressOf(),
348
+ },
349
+ };
350
+ }
351
+ if (body && body.confirm !== true) {
352
+ return {
353
+ status: 400,
354
+ body: {
355
+ ok: false,
356
+ error: 'confirm-required',
357
+ message: '这是两步确认的第二步:请求体必须带 confirm:true。先调一次 /install/preview 让用户看清"将对哪个 profile 做哪几件事"。',
358
+ },
359
+ };
360
+ }
361
+ // 🔴 先占锁再 await:buildPreview 里有 await,不先占锁的话两个并发 start 会双双通过检查
362
+ state.busy = true;
363
+ let preview;
364
+ try {
365
+ preview = await buildPreview();
366
+ } catch (err) {
367
+ state.busy = false;
368
+ return { status: 500, body: { ok: false, error: 'internal-error', message: String(err && err.message ? err.message : err) } };
369
+ }
370
+ if (!preview.ok) {
371
+ state.busy = false;
372
+ return { status: 400, body: preview };
373
+ }
374
+ if (preview.preflight && preview.preflight.length) {
375
+ state.busy = false;
376
+ return { status: 400, body: { ok: false, error: 'preflight', message: preview.preflight.join(' / '), preflight: preview.preflight } };
377
+ }
378
+ if (!preview.willInstall.length) {
379
+ state.busy = false;
380
+ return { status: 400, body: { ok: false, error: 'nothing-to-do', message: '这一批没有要装的项(全部已满足);要强制重跑,用命令行的 --upgrade' } };
381
+ }
382
+ // 起跑:不 await(HTTP 立刻回 202,进度靠 /install/progress 轮询)
383
+ activePromise = runBatch(preview);
384
+ return {
385
+ status: 202,
386
+ body: { ok: true, status: 'running', batchId: state.batchId, progress: progressOf(), equivalentCommand: preview.equivalentCommand },
387
+ };
388
+ },
389
+
390
+ /** 取消:只对"正在跑"的批次有效;重复取消返回"已经在停" */
391
+ cancel() {
392
+ if (state.status === 'cancelling') {
393
+ return { status: 202, body: { ok: true, status: 'cancelling', alreadyStopping: true, progress: progressOf() } };
394
+ }
395
+ if (state.status !== 'running' || !controller) {
396
+ return {
397
+ status: 409,
398
+ body: { ok: false, error: 'not-running', message: '现在没有在跑的批次(单飞锁是空的),没什么可取消的', progress: progressOf() },
399
+ };
400
+ }
401
+ state.status = 'cancelling';
402
+ state.lastEventAt = new Date().toISOString();
403
+ pushLine('⏹ 收到取消:正在杀当前那条命令的进程树(AbortSignal → killTree)。已经装好的项会留着,不会自动整批回滚。');
404
+ controller.abort();
405
+ return {
406
+ status: 202,
407
+ body: {
408
+ ok: true,
409
+ status: 'cancelling',
410
+ message: '正在停:当前那条命令的进程树会被杀掉(目标 ≤3 秒)。⚠️ 这不是"干净撤回"——已装好的项会留着,当前项可能只装了一半。',
411
+ progress: progressOf(),
412
+ },
413
+ };
414
+ },
415
+
416
+ /** 进度:纯内存读,幂等,不写日志、不 spawn(页面按 500–1000ms 轮询它) */
417
+ progress() {
418
+ return { status: 200, body: progressOf() };
419
+ },
420
+
421
+ /**
422
+ * 撤回的两个动作(方案 §11.6 · B5):
423
+ * action='uninstall' → 【卸载这次装的】(默认推荐)
424
+ * action='restore-declarations' → 【恢复声明层到装前】(必须先过前置核对)
425
+ */
426
+ async rollback(body = {}) {
427
+ const action = String((body && body.action) || '');
428
+ if (state.busy) {
429
+ return { status: 409, body: { ok: false, error: 'busy', message: '有一批正在跑(或在撤),不能同时改同一个 profile', progress: progressOf() } };
430
+ }
431
+ if (action === 'uninstall') {
432
+ const names = state.result && Array.isArray(state.result.installed) ? state.result.installed : [];
433
+ if (!names.length) {
434
+ return {
435
+ status: 400,
436
+ body: {
437
+ ok: false,
438
+ error: 'nothing-installed',
439
+ message: '这一轮没有装成任何项(或者本轮还没跑过),没有"这次装的"可卸',
440
+ },
441
+ };
442
+ }
443
+ state.busy = true;
444
+ const previousStatus = state.status;
445
+ state.status = 'rolling-back';
446
+ const before = state.items.map((i) => ({ ...i }));
447
+ state.items = names.map((name, idx) => ({ index: idx + 1, name, action: 'uninstall', status: 'pending', detail: '' }));
448
+ state.total = names.length;
449
+ state.index = 0;
450
+ try {
451
+ const result = await uninstallBatch({
452
+ options: { ...options, snapshotDir: null },
453
+ names,
454
+ hooks: {
455
+ onEvent: (ev) => {
456
+ if (ev.kind === 'step-start') { state.index = ev.index; state.current = { index: ev.index, name: ev.item, action: 'uninstall' }; }
457
+ else if (ev.kind === 'step-done') {
458
+ const item = state.items.find((i) => i.index === ev.index);
459
+ if (item) { item.status = ev.ok ? 'ok' : 'fail'; item.detail = String(ev.detail || '').slice(0, LINE_MAX); }
460
+ } else if (ev.kind === 'log') pushLine(ev.detail);
461
+ },
462
+ },
463
+ });
464
+ state.rollback = { action: 'uninstall', at: new Date().toISOString(), ok: result.ok, exitCode: result.exitCode, names };
465
+ // 卸载动过声明层 ⇒ "本轮收尾时的 sha"不再成立,B 的基准作废(如实处置,不假装还能撤)
466
+ state.settlement = null;
467
+ return {
468
+ status: 200,
469
+ body: {
470
+ ok: result.ok,
471
+ action: 'uninstall',
472
+ exitCode: result.exitCode,
473
+ names,
474
+ text: result.text,
475
+ honest: result.honest,
476
+ progress: progressOf(),
477
+ },
478
+ };
479
+ } finally {
480
+ state.busy = false;
481
+ state.current = null;
482
+ state.status = previousStatus;
483
+ state.items = before;
484
+ }
485
+ }
486
+
487
+ if (action === 'restore-declarations') {
488
+ if (!state.result || !state.result.snapshotRoot) {
489
+ return { status: 400, body: { ok: false, error: 'no-snapshot', message: '这一轮还没跑过安装(或者服务重启过),没有可用的快照' } };
490
+ }
491
+ const profileDir = state.context ? state.context.profileDir : null;
492
+ const pre = checkPrecondition({ settlement: state.settlement, profileDir });
493
+ if (!pre.ok) {
494
+ return {
495
+ status: 412,
496
+ body: {
497
+ ok: false,
498
+ error: 'precondition-failed',
499
+ reason: pre.reason,
500
+ changed: pre.changed,
501
+ message: pre.message,
502
+ hint: '要真的让"已装"变回"未装",用【卸载这次装的】(它不覆盖文件,看的是现在装着什么)。',
503
+ },
504
+ };
505
+ }
506
+ const result = restoreDeclarations({ snapshotRoot: state.result.snapshotRoot, profileDir });
507
+ state.rollback = { action: 'restore-declarations', at: new Date().toISOString(), ok: result.ok, snapshotDir: result.snapshotDir };
508
+ // 声明层已经被写回装前 ⇒ 原来的"收尾 sha"作废
509
+ state.settlement = null;
510
+ return {
511
+ status: 200,
512
+ body: {
513
+ ok: result.ok,
514
+ action: 'restore-declarations',
515
+ snapshotDir: result.snapshotDir,
516
+ actions: result.actions,
517
+ verify: result.verify,
518
+ error: result.error,
519
+ warning: result.warning,
520
+ honest: result.honest,
521
+ progress: progressOf(),
522
+ },
523
+ };
524
+ }
525
+
526
+ return {
527
+ status: 400,
528
+ body: {
529
+ ok: false,
530
+ error: 'unknown-action',
531
+ message: 'action 只能是 uninstall(卸载这次装的)或 restore-declarations(恢复声明层到装前)',
532
+ },
533
+ };
534
+ },
535
+
536
+ /** 夹具/退出钩子用:拿到"正在跑的批次"的控制权(没有就 null) */
537
+ _debug() {
538
+ return { busy: state.busy, status: state.status, controller, activePromise };
539
+ },
540
+ };
541
+ }