dsh-autorestart 0.8.4 → 0.8.6

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/README.md +465 -40
  3. package/assets/icon-9c245e5b.ico +0 -0
  4. package/assets/icon-rescue-1902bb15.ico +0 -0
  5. package/assets/icon-rescue-1902bb15.png +0 -0
  6. package/client.js +3002 -170
  7. package/cordis.patch.yml +12 -0
  8. package/docs/README.zh.md +231 -23
  9. package/docs/platform-matrix.md +171 -55
  10. package/docs/real-machine-verification-0.8.6.md +202 -0
  11. package/index.js +13272 -8513
  12. package/lib/autostart-advice.js +31 -2
  13. package/lib/backup.js +2768 -21
  14. package/lib/checkpoint.js +214 -7
  15. package/lib/cookie-clear.js +260 -0
  16. package/lib/lifecycle.js +23 -40
  17. package/lib/plugin-compare.js +499 -0
  18. package/lib/plugin-failure.js +382 -0
  19. package/lib/plugin-recover.js +1557 -0
  20. package/lib/plugin-watch.js +213 -0
  21. package/lib/rescue-paths.js +982 -56
  22. package/lib/restart-phase.js +5 -0
  23. package/lib/restart-verdict.js +379 -0
  24. package/lib/restart.js +244 -7
  25. package/lib/scheduler-capability.js +19 -3
  26. package/lib/sleep.js +190 -29
  27. package/lib/watchdog-disarm.js +46 -2
  28. package/lib/watchdog-schedule.js +834 -17
  29. package/lib/watchdog-tier.js +8 -1
  30. package/lib/watchdog.js +1222 -47
  31. package/locale/en.json +175 -68
  32. package/locale/zh.json +175 -68
  33. package/package.json +3 -2
  34. package/screenshots.json +6 -6
  35. package/scripts/README.md +6 -3
  36. package/scripts/build-icon.mjs +998 -0
  37. package/scripts/clear-dsh-auth-cookies.mjs +257 -0
  38. package/scripts/emergency-disable.sh +69 -2
  39. package/scripts/emergency-toggle.ps1 +85 -69
  40. package/scripts/rescue-strings.zh.json +36 -18
  41. package/scripts/restore-profile.ps1 +182 -1
  42. package/scripts/watchdog.mjs +432 -79
@@ -0,0 +1,1557 @@
1
+ /**
2
+ * dsh-autorestart — put a FAILED PLUGIN back to the version that worked.
3
+ *
4
+ * The failure this module answers is the REPORTED one: an entry did not
5
+ * activate, so the host is up but the feature behind the plugin 404s
6
+ * (lib/plugin-failure.js reads that verdict out of the startup log). The
7
+ * process-level failure — the host does not come up at all — is NOT this
8
+ * module's business: that is the pre-restart snapshot and the watchdog.
9
+ *
10
+ * WHAT IT DOES, per failed plugin:
11
+ *
12
+ * - the newest KNOWN-GOOD backup holds a real copy (`node_modules/<name>`)
13
+ * -> `overwrite`: the live directory is renamed aside as
14
+ * `<name>.broken-<ts>` (EVIDENCE: nothing is ever deleted) and the
15
+ * backup copy takes its place;
16
+ * - the backup holds the CONTENTS of a `link:` installation
17
+ * (`node_modules-link/<name>`, see lib/backup.js) -> `replace-link`: the
18
+ * live link is moved aside the same way and the copied contents take its
19
+ * place (the same meaning as the existing `force` restore rule);
20
+ * - the backup holds nothing for it -> `no-backup`: nothing is touched, the
21
+ * REASON is written down;
22
+ * - the plugin is not in the profile's enabled list -> `no-backup` as well
23
+ * (we do not write into a plugin tree we cannot account for).
24
+ *
25
+ * TWO LOOPS ARE POSSIBLE AND BOTH ARE STOPPED (user decision, 2026-11-01):
26
+ *
27
+ * 1. THE SAME OLD LOG LINE READ AGAIN. Every boot would "find" the line the
28
+ * previous boot left behind -> restore + restart, forever. Two guards:
29
+ * `findPluginFailures` drops lines older than the boot the log belongs to,
30
+ * and every failure carries a FINGERPRINT (plugin + first error line + log
31
+ * time) that is recorded as `handled` in `plugin-recovery.json`: one
32
+ * fingerprint is acted on ONCE (`duplicate` afterwards).
33
+ * 2. THE CAUSE IS NOT IN THE PLUGIN (a missing peer dependency, a missing host
34
+ * capability). Swapping the version back cannot fix that, so the plugin
35
+ * would fail on every boot. Guard: one plugin is restored ONCE per cycle;
36
+ * a second failure of the same plugin reports `needs-human` and does NOT
37
+ * touch anything and does NOT request a restart. A boot whose log holds no
38
+ * plugin failure resets the cycle.
39
+ *
40
+ * AND IT IS IDEMPOTENT: before copying, the live directory is compared with the
41
+ * backup copy (relative path + size + sha256 of every file). When they already
42
+ * match there is nothing to restore, so nothing is copied and no restart is
43
+ * asked for (`already-consistent`).
44
+ *
45
+ * ESM only, Node builtins only. The file system is injectable so every branch
46
+ * (including the rename that leaves the evidence) is testable without touching
47
+ * a real profile.
48
+ *
49
+ * @module dsh-autorestart/lib/plugin-recover
50
+ */
51
+
52
+ import crypto from 'node:crypto';
53
+ import fsDefault from 'node:fs';
54
+ import path from 'node:path';
55
+
56
+ import {
57
+ KIND_KNOWN_GOOD,
58
+ LINKS_DIR,
59
+ META_NAME,
60
+ PLUGINS_DIR,
61
+ latestKnownGood,
62
+ readBackupMeta,
63
+ } from './backup.js';
64
+ import { failureFingerprint, findPluginFailures, mergeFailuresByPlugin } from './plugin-failure.js';
65
+
66
+ /** The durable bookkeeping of the recovery cycles (next to the checkpoint). */
67
+ export const RECOVERY_STATE_NAME = 'plugin-recovery.json';
68
+
69
+ /** The readable report of the last recovery (what the interface shows). */
70
+ export const RECOVERY_REPORT_NAME = 'last-plugin-recovery.json';
71
+
72
+ /** The version of the bookkeeping file's shape. */
73
+ export const RECOVERY_STATE_VERSION = 1;
74
+
75
+ /** How many handled fingerprints are remembered (oldest are dropped first). */
76
+ export const HANDLED_LIMIT = 64;
77
+
78
+ /** How many plugins one cycle may hold an attempt record for. */
79
+ export const ENTRY_LIMIT = 32;
80
+
81
+ /** How many restore attempts one plugin gets per cycle (hard requirement). */
82
+ export const DEFAULT_ATTEMPT_LIMIT = 1;
83
+
84
+ /**
85
+ * The decisions a plan can carry.
86
+ *
87
+ * The first three are the restore modes of the contract; the rest are the
88
+ * "nothing is touched, so nothing is restarted" answers.
89
+ */
90
+ export const RECOVERY_MODES = Object.freeze({
91
+ /** A real copy in the backup takes the place of the live directory. */
92
+ OVERWRITE: 'overwrite',
93
+ /** The contents of a `link:` installation take the place of the live link. */
94
+ REPLACE_LINK: 'replace-link',
95
+ /** The backup holds nothing for this plugin: nothing is touched. */
96
+ NO_BACKUP: 'no-backup',
97
+ /** The live directory already equals the backup copy: nothing to do. */
98
+ ALREADY_CONSISTENT: 'already-consistent',
99
+ /** This exact failure was already handled once: not a new incident. */
100
+ DUPLICATE: 'duplicate',
101
+ /** This plugin already used its one attempt: a human has to look. */
102
+ NEEDS_HUMAN: 'needs-human',
103
+ /**
104
+ * THE BACKUP DELIBERATELY DOES NOT HOLD THIS PLUGIN (2026-11-01).
105
+ *
106
+ * A slim backup keeps only what cannot be fetched again: the configuration and
107
+ * the plugins installed as links. A third-party plugin is NOT in it, so there is
108
+ * nothing to copy back — the honest answer is "reinstall it", not "no backup".
109
+ */
110
+ NEEDS_ONLINE_REINSTALL: 'needs-online-reinstall',
111
+ });
112
+
113
+ /** The modes that really change files (and are therefore the only ones that restart). */
114
+ export const RESTORE_MODES = Object.freeze([
115
+ RECOVERY_MODES.OVERWRITE,
116
+ RECOVERY_MODES.REPLACE_LINK,
117
+ ]);
118
+
119
+ /** How many files one idempotency comparison may hash. */
120
+ export const DIGEST_MAX_FILES = 400;
121
+
122
+ /** How large one file may be to be hashed for the comparison. */
123
+ export const DIGEST_MAX_FILE_BYTES = 4 * 1024 * 1024;
124
+
125
+ /**
126
+ * A compact local stamp for the evidence directory: `20261101T073000`.
127
+ *
128
+ * The rename target is `<name>.broken-<ts>`, so the stamp must be file-system
129
+ * safe on every platform: no colons (Windows refuses them), no spaces.
130
+ *
131
+ * @param {number} [now]
132
+ * @returns {string}
133
+ */
134
+ export function recoveryStamp(now = Date.now()) {
135
+ const date = new Date(Number.isFinite(Number(now)) ? Number(now) : Date.now());
136
+ const pad = (value, width = 2) => String(value).padStart(width, '0');
137
+ return [
138
+ date.getUTCFullYear(),
139
+ pad(date.getUTCMonth() + 1),
140
+ pad(date.getUTCDate()),
141
+ 'T',
142
+ pad(date.getUTCHours()),
143
+ pad(date.getUTCMinutes()),
144
+ pad(date.getUTCSeconds()),
145
+ ].join('');
146
+ }
147
+
148
+ /**
149
+ * The name the live directory is moved to when it is replaced.
150
+ *
151
+ * @param {string} plugin
152
+ * @param {number} [now]
153
+ * @returns {string} a plain directory name (never a path)
154
+ */
155
+ export function brokenNameFor(plugin, now = Date.now()) {
156
+ const name = typeof plugin === 'string' ? plugin.trim() : '';
157
+ const safe = name.replace(/[\\/:*?"<>|]/g, '_');
158
+ return `${safe}.broken-${recoveryStamp(now)}`;
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // The bookkeeping file (`<stateDir>/plugin-recovery.json`)
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /** An empty bookkeeping record. */
166
+ export function createRecoveryState() {
167
+ return { version: RECOVERY_STATE_VERSION, entries: [], handled: [] };
168
+ }
169
+
170
+ /**
171
+ * Coerce anything read from disk into the bookkeeping shape.
172
+ *
173
+ * A file written by a future build, a hand-edited one or a half-written one must
174
+ * never take the boot down: unknown fields are dropped and the rest is repaired.
175
+ *
176
+ * @param {unknown} raw
177
+ * @returns {{ version: number, entries: Array<object>, handled: Array<object> }}
178
+ */
179
+ export function normalizeRecoveryState(raw) {
180
+ const state = createRecoveryState();
181
+ if (!raw || typeof raw !== 'object') return state;
182
+ const entries = Array.isArray(raw.entries) ? raw.entries : [];
183
+ for (const entry of entries) {
184
+ if (!entry || typeof entry !== 'object') continue;
185
+ const plugin = typeof entry.plugin === 'string' ? entry.plugin.trim() : '';
186
+ if (plugin === '') continue;
187
+ if (state.entries.some((known) => known.plugin === plugin)) continue;
188
+ if (state.entries.length >= ENTRY_LIMIT) break;
189
+ state.entries.push({
190
+ plugin,
191
+ attempts: Number.isFinite(Number(entry.attempts)) && Number(entry.attempts) > 0
192
+ ? Math.trunc(Number(entry.attempts))
193
+ : 0,
194
+ firstAt: Number.isFinite(Number(entry.firstAt)) ? Math.trunc(Number(entry.firstAt)) : null,
195
+ lastAt: Number.isFinite(Number(entry.lastAt)) ? Math.trunc(Number(entry.lastAt)) : null,
196
+ restartedAt: Number.isFinite(Number(entry.restartedAt)) ? Math.trunc(Number(entry.restartedAt)) : null,
197
+ });
198
+ }
199
+ const handled = Array.isArray(raw.handled) ? raw.handled : [];
200
+ for (const record of handled) {
201
+ if (!record || typeof record !== 'object') continue;
202
+ const fingerprint = typeof record.fingerprint === 'string' ? record.fingerprint.trim() : '';
203
+ if (fingerprint === '') continue;
204
+ if (state.handled.some((known) => known.fingerprint === fingerprint)) continue;
205
+ state.handled.push({
206
+ fingerprint,
207
+ plugin: typeof record.plugin === 'string' && record.plugin !== '' ? record.plugin : null,
208
+ at: Number.isFinite(Number(record.at)) ? Math.trunc(Number(record.at)) : null,
209
+ });
210
+ }
211
+ // Only the newest records are kept: an unbounded list would grow with every
212
+ // incident this machine ever had.
213
+ if (state.handled.length > HANDLED_LIMIT) {
214
+ state.handled = state.handled.slice(state.handled.length - HANDLED_LIMIT);
215
+ }
216
+ return state;
217
+ }
218
+
219
+ /**
220
+ * Read the bookkeeping of the recovery cycles.
221
+ *
222
+ * A missing or unreadable file is NOT an error: it means "no cycle has run yet".
223
+ *
224
+ * @param {string} stateDir
225
+ * @param {{ fs?: object }} [options]
226
+ * @returns {{ version: number, entries: Array<object>, handled: Array<object>, corrupt: boolean }}
227
+ */
228
+ export function readRecoveryState(stateDir, { fs = fsDefault } = {}) {
229
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
230
+ const empty = { ...createRecoveryState(), corrupt: false };
231
+ if (dir === '') return empty;
232
+ try {
233
+ const text = fs.readFileSync(path.join(dir, RECOVERY_STATE_NAME), 'utf8');
234
+ const parsed = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
235
+ return { ...normalizeRecoveryState(parsed), corrupt: false };
236
+ } catch (error) {
237
+ const missing = error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
238
+ return { ...empty, corrupt: missing !== true };
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Write the bookkeeping back. Never throws: a failed write only costs the loop
244
+ * guard, and a boot must not die for it.
245
+ *
246
+ * @param {string} stateDir
247
+ * @param {object} state
248
+ * @param {{ fs?: object }} [options]
249
+ * @returns {boolean} whether it was written
250
+ */
251
+ export function writeRecoveryState(stateDir, state, { fs = fsDefault } = {}) {
252
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
253
+ if (dir === '') return false;
254
+ const normalized = normalizeRecoveryState(state);
255
+ try {
256
+ fs.mkdirSync(dir, { recursive: true });
257
+ fs.writeFileSync(path.join(dir, RECOVERY_STATE_NAME), JSON.stringify(normalized, null, 2), 'utf8');
258
+ return true;
259
+ } catch {
260
+ return false;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * How many restore attempts one plugin has already used in this cycle.
266
+ *
267
+ * @param {object} state
268
+ * @param {string} plugin
269
+ * @returns {number}
270
+ */
271
+ export function attemptsFor(state, plugin) {
272
+ const name = typeof plugin === 'string' ? plugin : '';
273
+ if (name === '' || !state || !Array.isArray(state.entries)) return 0;
274
+ const entry = state.entries.find((candidate) => candidate.plugin === name);
275
+ return entry && Number.isFinite(Number(entry.attempts)) ? Number(entry.attempts) : 0;
276
+ }
277
+
278
+ /**
279
+ * May this plugin be restored again in this cycle?
280
+ *
281
+ * ATTEMPT 1 IS ALLOWED, ATTEMPT 2 IS REFUSED — that is the whole point: a plugin
282
+ * that fails twice with a restored-good version has a cause outside the plugin,
283
+ * and another restart would only cost the user another boot.
284
+ *
285
+ * @param {object} state
286
+ * @param {string} plugin
287
+ * @param {{ limit?: number }} [options]
288
+ * @returns {{ allowed: boolean, attempts: number, limit: number, reason: string }}
289
+ */
290
+ export function decideAttempt(state, plugin, { limit = DEFAULT_ATTEMPT_LIMIT } = {}) {
291
+ const budget = Number.isFinite(Number(limit)) && Number(limit) >= 1
292
+ ? Math.trunc(Number(limit))
293
+ : DEFAULT_ATTEMPT_LIMIT;
294
+ const attempts = attemptsFor(state, plugin);
295
+ if (attempts >= budget) {
296
+ return {
297
+ allowed: false,
298
+ attempts,
299
+ limit: budget,
300
+ reason: `this plugin already used its ${budget} restore attempt(s) in this cycle: `
301
+ + 'the cause is probably not the plugin version (a missing peer dependency or a missing host '
302
+ + 'capability is not fixed by swapping the copy back), so nothing is touched and a human has to look',
303
+ };
304
+ }
305
+ return {
306
+ allowed: true,
307
+ attempts,
308
+ limit: budget,
309
+ reason: attempts === 0
310
+ ? 'no restore attempt has been made for this plugin in this cycle'
311
+ : `attempt ${attempts + 1} of ${budget}`,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Record that one plugin was restored (the attempt is spent NOW).
317
+ *
318
+ * @param {object} state
319
+ * @param {string} plugin
320
+ * @param {{ now?: number, restarted?: boolean }} [options]
321
+ * @returns {object|null} the entry that was written
322
+ */
323
+ export function recordAttempt(state, plugin, { now = Date.now(), restarted = false } = {}) {
324
+ const target = state && typeof state === 'object' ? state : null;
325
+ const name = typeof plugin === 'string' ? plugin.trim() : '';
326
+ if (target === null || name === '') return null;
327
+ if (!Array.isArray(target.entries)) target.entries = [];
328
+ const at = Number.isFinite(Number(now)) ? Math.trunc(Number(now)) : Date.now();
329
+ let entry = target.entries.find((candidate) => candidate.plugin === name);
330
+ if (!entry) {
331
+ entry = { plugin: name, attempts: 0, firstAt: at, lastAt: at, restartedAt: null };
332
+ target.entries.push(entry);
333
+ }
334
+ entry.attempts = Number(entry.attempts || 0) + 1;
335
+ entry.lastAt = at;
336
+ if (entry.firstAt === null || entry.firstAt === undefined) entry.firstAt = at;
337
+ if (restarted === true) entry.restartedAt = at;
338
+ if (target.entries.length > ENTRY_LIMIT) target.entries = target.entries.slice(target.entries.length - ENTRY_LIMIT);
339
+ return entry;
340
+ }
341
+
342
+ /**
343
+ * Was THIS exact failure already handled once?
344
+ *
345
+ * @param {object} state
346
+ * @param {string} fingerprint
347
+ * @returns {object|null} the record, or null
348
+ */
349
+ export function handledRecord(state, fingerprint) {
350
+ const wanted = typeof fingerprint === 'string' ? fingerprint.trim() : '';
351
+ if (wanted === '' || !state || !Array.isArray(state.handled)) return null;
352
+ return state.handled.find((record) => record.fingerprint === wanted) || null;
353
+ }
354
+
355
+ /**
356
+ * Remember that one failure was handled (it is never acted on again).
357
+ *
358
+ * @param {object} state
359
+ * @param {{ fingerprint?: string, plugin?: string|null, now?: number }} [options]
360
+ * @returns {object|null} the record that was written
361
+ */
362
+ export function markHandled(state, { fingerprint = '', plugin = null, now = Date.now() } = {}) {
363
+ const target = state && typeof state === 'object' ? state : null;
364
+ const value = typeof fingerprint === 'string' ? fingerprint.trim() : '';
365
+ if (target === null || value === '') return null;
366
+ if (!Array.isArray(target.handled)) target.handled = [];
367
+ const existing = target.handled.find((record) => record.fingerprint === value);
368
+ if (existing) return existing;
369
+ const record = {
370
+ fingerprint: value,
371
+ plugin: typeof plugin === 'string' && plugin !== '' ? plugin : null,
372
+ at: Number.isFinite(Number(now)) ? Math.trunc(Number(now)) : Date.now(),
373
+ };
374
+ target.handled.push(record);
375
+ if (target.handled.length > HANDLED_LIMIT) {
376
+ target.handled = target.handled.slice(target.handled.length - HANDLED_LIMIT);
377
+ }
378
+ return record;
379
+ }
380
+
381
+ /**
382
+ * End the cycle: one boot without any plugin failure proves the machine is fine.
383
+ *
384
+ * @param {object} state
385
+ * @param {{ now?: number }} [options]
386
+ * @returns {{ reset: boolean, entries: number, handled: number, at: number }}
387
+ */
388
+ export function resetRecoveryCycle(state, { now = Date.now() } = {}) {
389
+ const target = state && typeof state === 'object' ? state : createRecoveryState();
390
+ const entries = Array.isArray(target.entries) ? target.entries.length : 0;
391
+ const handled = Array.isArray(target.handled) ? target.handled.length : 0;
392
+ target.version = RECOVERY_STATE_VERSION;
393
+ target.entries = [];
394
+ target.handled = [];
395
+ return {
396
+ reset: entries > 0 || handled > 0,
397
+ entries,
398
+ handled,
399
+ at: Number.isFinite(Number(now)) ? Math.trunc(Number(now)) : Date.now(),
400
+ };
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // The startup logs (`<DSH_HOME>/logs/startup-*.log`)
405
+ // ---------------------------------------------------------------------------
406
+
407
+ /**
408
+ * The boot time a startup log file name carries: `startup-<ISO>-<id>.log`.
409
+ *
410
+ * The name is the ONLY place a log dates itself without opening it, and it is
411
+ * what makes "the lines of this log belong to THAT boot" decidable.
412
+ *
413
+ * @param {string} name
414
+ * @returns {number|null}
415
+ */
416
+ export function parseStartupLogName(name) {
417
+ const text = typeof name === 'string' ? name : '';
418
+ const match = /^startup-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})(?:\.(\d{1,3}))?Z/i.exec(text);
419
+ if (!match) return null;
420
+ const [, year, month, day, hour, minute, second, fraction = '000'] = match;
421
+ const millis = (fraction + '000').slice(0, 3);
422
+ const stamp = Date.parse(`${year}-${month}-${day}T${hour}:${minute}:${second}.${millis}Z`);
423
+ return Number.isFinite(stamp) ? stamp : null;
424
+ }
425
+
426
+ /**
427
+ * Every startup log of this machine, newest first.
428
+ *
429
+ * @param {string} logsDir
430
+ * @param {{ fs?: object }} [options]
431
+ * @returns {Array<{ name: string, path: string, at: number|null, mtime: number, size: number }>}
432
+ */
433
+ export function listStartupLogs(logsDir, { fs = fsDefault } = {}) {
434
+ const dir = typeof logsDir === 'string' ? logsDir.trim() : '';
435
+ if (dir === '') return [];
436
+ let names = [];
437
+ try {
438
+ names = fs.readdirSync(dir);
439
+ } catch {
440
+ return [];
441
+ }
442
+ const logs = [];
443
+ for (const name of Array.isArray(names) ? names : []) {
444
+ const text = typeof name === 'string' ? name : String(name);
445
+ if (!/^startup-.*\.log$/i.test(text)) continue;
446
+ const full = path.join(dir, text);
447
+ let mtime = 0;
448
+ let size = 0;
449
+ try {
450
+ const stat = fs.statSync(full);
451
+ mtime = Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : new Date(stat.mtime).getTime();
452
+ size = Number.isFinite(stat.size) ? stat.size : 0;
453
+ } catch {
454
+ continue;
455
+ }
456
+ logs.push({ name: text, path: full, at: parseStartupLogName(text), mtime, size });
457
+ }
458
+ logs.sort((left, right) => (right.mtime - left.mtime) || (right.name < left.name ? -1 : 1));
459
+ return logs;
460
+ }
461
+
462
+ /**
463
+ * The log of the PREVIOUS boot — the one that can hold a plugin failure.
464
+ *
465
+ * WHY `before`: the host writes its own startup log from the first second of the
466
+ * boot. Reading "the newest log" would therefore read the log of the boot that
467
+ * is looking for failures — a log which cannot yet contain its own audit
468
+ * verdict. Only a log written BEFORE this boot started describes a finished
469
+ * boot, so that is the one that is read. It also keeps an older, already
470
+ * handled incident from being dug up again.
471
+ *
472
+ * @param {string} logsDir
473
+ * @param {{ before?: number|null, fs?: object }} [options]
474
+ * @returns {{ name: string, path: string, at: number|null, mtime: number, size: number }|null}
475
+ */
476
+ export function findLatestStartupLog(logsDir, { before = null, fs = fsDefault } = {}) {
477
+ const logs = listStartupLogs(logsDir, { fs });
478
+ if (logs.length === 0) return null;
479
+ const cutoff = Number.isFinite(Number(before)) ? Number(before) : null;
480
+ if (cutoff === null) return logs[0];
481
+ const older = logs.find((log) => log.mtime <= cutoff);
482
+ // No older log: this is the very first boot that logs at all, so there is no
483
+ // finished boot to look at and nothing to recover.
484
+ return older || null;
485
+ }
486
+
487
+ // ---------------------------------------------------------------------------
488
+ // Idempotency: is the live directory already the backup copy?
489
+ // ---------------------------------------------------------------------------
490
+
491
+ /**
492
+ * A stable digest of a directory tree: relative path + size + sha256 per file.
493
+ *
494
+ * Files that cannot be read, a tree that is too large or one that is missing all
495
+ * answer `ok: false` — "unknown", never "identical". A comparison that cannot be
496
+ * made must not be reported as "nothing to do".
497
+ *
498
+ * @param {string} dir
499
+ * @param {{ fs?: object, maxFiles?: number, maxFileBytes?: number }} [options]
500
+ * @returns {{ ok: boolean, exists: boolean, files: number, bytes: number, digest: string|null, error: string|null }}
501
+ */
502
+ export function treeDigest(dir, {
503
+ fs = fsDefault,
504
+ maxFiles = DIGEST_MAX_FILES,
505
+ maxFileBytes = DIGEST_MAX_FILE_BYTES,
506
+ } = {}) {
507
+ const root = typeof dir === 'string' ? dir.trim() : '';
508
+ const empty = { ok: false, exists: false, files: 0, bytes: 0, digest: null, error: null };
509
+ if (root === '') return { ...empty, error: 'treeDigest requires a directory' };
510
+ let stat = null;
511
+ try {
512
+ stat = fs.lstatSync(root);
513
+ } catch (error) {
514
+ const missing = error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
515
+ return missing ? { ...empty, error: null } : { ...empty, error: String(error) };
516
+ }
517
+ // A LINK is never "the same" as a copied tree: the whole point of the
518
+ // replace-link mode is that the contents must take its place.
519
+ if (typeof stat.isSymbolicLink === 'function' && stat.isSymbolicLink() === true) {
520
+ return { ...empty, exists: true, error: 'the live entry is a link' };
521
+ }
522
+ if (typeof stat.isDirectory === 'function' && stat.isDirectory() !== true) {
523
+ return { ...empty, exists: true, error: 'the live entry is not a directory' };
524
+ }
525
+ const rows = [];
526
+ let files = 0;
527
+ let bytes = 0;
528
+ const walk = (current, relative) => {
529
+ let names = [];
530
+ try {
531
+ names = fs.readdirSync(current);
532
+ } catch (error) {
533
+ throw new Error(`cannot read ${current}: ${error && error.message ? error.message : String(error)}`);
534
+ }
535
+ for (const name of names) {
536
+ const entryName = typeof name === 'string' ? name : String(name);
537
+ const full = path.join(current, entryName);
538
+ const rel = relative === '' ? entryName : `${relative}/${entryName}`;
539
+ const info = fs.lstatSync(full);
540
+ if (typeof info.isSymbolicLink === 'function' && info.isSymbolicLink() === true) {
541
+ rows.push(`${rel}\u0000link`);
542
+ files += 1;
543
+ continue;
544
+ }
545
+ if (typeof info.isDirectory === 'function' && info.isDirectory() === true) {
546
+ walk(full, rel);
547
+ continue;
548
+ }
549
+ const size = Number.isFinite(info.size) ? Number(info.size) : 0;
550
+ if (size > maxFileBytes) {
551
+ throw new Error(`${rel} is larger than the comparison limit`);
552
+ }
553
+ const hash = crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex');
554
+ rows.push(`${rel}\u0000${size}\u0000${hash}`);
555
+ files += 1;
556
+ bytes += size;
557
+ if (files > maxFiles) throw new Error(`more than ${maxFiles} files`);
558
+ }
559
+ };
560
+ try {
561
+ walk(root, '');
562
+ } catch (error) {
563
+ return {
564
+ ok: false,
565
+ exists: true,
566
+ files,
567
+ bytes,
568
+ digest: null,
569
+ error: error && error.message ? error.message : String(error),
570
+ };
571
+ }
572
+ rows.sort();
573
+ const digest = crypto.createHash('sha256').update(rows.join('\n')).digest('hex');
574
+ return { ok: true, exists: true, files, bytes, digest, error: null };
575
+ }
576
+
577
+ /**
578
+ * Is the live plugin directory already the backup copy?
579
+ *
580
+ * @param {string} liveDir
581
+ * @param {string} backupDir
582
+ * @param {{ fs?: object }} [options]
583
+ * @returns {{ same: boolean, known: boolean, live: object, backup: object, reason: string }}
584
+ */
585
+ export function sameAsBackup(liveDir, backupDir, { fs = fsDefault } = {}) {
586
+ const live = treeDigest(liveDir, { fs });
587
+ const backup = treeDigest(backupDir, { fs });
588
+ if (live.ok !== true || backup.ok !== true) {
589
+ return {
590
+ same: false,
591
+ known: false,
592
+ live,
593
+ backup,
594
+ reason: 'the two trees could not be compared, so the copy is taken',
595
+ };
596
+ }
597
+ if (live.digest === backup.digest) {
598
+ return {
599
+ same: true,
600
+ known: true,
601
+ live,
602
+ backup,
603
+ reason: `the live directory already equals the backup (${live.files} files, ${live.bytes} bytes)`,
604
+ };
605
+ }
606
+ return {
607
+ same: false,
608
+ known: true,
609
+ live,
610
+ backup,
611
+ reason: `the live directory differs from the backup (${live.files} files / ${live.bytes} bytes against ${backup.files} files / ${backup.bytes} bytes)`,
612
+ };
613
+ }
614
+
615
+ // ---------------------------------------------------------------------------
616
+ // The plan
617
+ // ---------------------------------------------------------------------------
618
+
619
+ /**
620
+ * Where the backup keeps one plugin, and in which of the two shapes.
621
+ *
622
+ * @param {{ backupDir: string, plugin: string, fs?: object }} options
623
+ * @returns {{ source: string|null, mode: string, reason: string }}
624
+ */
625
+ export function locateBackupCopy({ backupDir, plugin, fs = fsDefault, slim = false, isLinkInstall = false } = {}) {
626
+ const root = typeof backupDir === 'string' ? backupDir.trim() : '';
627
+ const name = typeof plugin === 'string' ? plugin.trim() : '';
628
+ if (root === '' || name === '') {
629
+ return { source: null, mode: RECOVERY_MODES.NO_BACKUP, reason: 'no backup directory or no plugin name was given' };
630
+ }
631
+ const copied = path.join(root, PLUGINS_DIR, name);
632
+ try {
633
+ const info = fs.lstatSync(copied);
634
+ const isLink = typeof info.isSymbolicLink === 'function' && info.isSymbolicLink() === true;
635
+ if (isLink !== true && info.isDirectory() === true) {
636
+ return {
637
+ source: copied,
638
+ mode: RECOVERY_MODES.OVERWRITE,
639
+ reason: 'the backup holds a real copy of this plugin: it overwrites the live directory',
640
+ };
641
+ }
642
+ } catch {
643
+ /* nothing at the copied path: try the linked contents */
644
+ }
645
+ const linked = path.join(root, LINKS_DIR, name);
646
+ try {
647
+ const info = fs.lstatSync(linked);
648
+ if (info.isDirectory() === true) {
649
+ return {
650
+ source: linked,
651
+ mode: RECOVERY_MODES.REPLACE_LINK,
652
+ reason: 'the backup holds the CONTENTS of a link: installation, so they take the place of the live link',
653
+ };
654
+ }
655
+ } catch {
656
+ /* nothing there either */
657
+ }
658
+ // A SLIM BACKUP DOES NOT HOLD THIRD-PARTY PLUGINS AT ALL. That is not a broken
659
+ // backup and not a missing one — it is a backup that expects the plugin to be
660
+ // reinstalled from package.json + pnpm-lock.yaml. Saying "no backup" would send
661
+ // the user looking for a bug; this says what to do instead.
662
+ //
663
+ // Only reachable when the caller says the backup is slim, so every existing
664
+ // recovery path answers exactly as it did before.
665
+ if (slim === true && isLinkInstall !== true) {
666
+ return {
667
+ source: null,
668
+ mode: RECOVERY_MODES.NEEDS_ONLINE_REINSTALL,
669
+ reason: 'this backup keeps only the configuration and the hand-written link plugins; '
670
+ + 'the plugin ' + name + ' has to be reinstalled (package.json + pnpm-lock.yaml)',
671
+ };
672
+ }
673
+ return {
674
+ source: null,
675
+ mode: RECOVERY_MODES.NO_BACKUP,
676
+ reason: 'the backup holds neither a copy nor the contents of this plugin',
677
+ };
678
+ }
679
+
680
+ /**
681
+ * Is a plugin part of what this profile enables?
682
+ *
683
+ * The names in a log are entry ids (`webserver`) while the profile lists package
684
+ * names (`@deepseek-ai/dsh-host-webserver`); the package name's last segment is
685
+ * therefore accepted as well, so a real entry is not dismissed as "unknown".
686
+ *
687
+ * @param {string[]|null} enabledPlugins
688
+ * @param {string} plugin
689
+ * @returns {boolean}
690
+ */
691
+ export function isEnabledPlugin(enabledPlugins, plugin) {
692
+ if (enabledPlugins === null || enabledPlugins === undefined) return true;
693
+ if (!Array.isArray(enabledPlugins)) return true;
694
+ if (enabledPlugins.length === 0) return true;
695
+ const name = typeof plugin === 'string' ? plugin.trim() : '';
696
+ if (name === '') return false;
697
+ const short = name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name;
698
+ for (const candidate of enabledPlugins) {
699
+ const value = typeof candidate === 'string' ? candidate.trim() : '';
700
+ if (value === '') continue;
701
+ if (value === name) return true;
702
+ if (value === short) return true;
703
+ const candidateShort = value.includes('/') ? value.slice(value.lastIndexOf('/') + 1) : value;
704
+ if (candidateShort === short) return true;
705
+ }
706
+ return false;
707
+ }
708
+
709
+ /**
710
+ * Decide, per failed plugin, what should happen.
711
+ *
712
+ * NOTHING IS TOUCHED HERE. The plan only says what the restore would do; the
713
+ * caller applies it (and the plan is what the tests drive).
714
+ *
715
+ * @param {{
716
+ * failures?: Array<object>,
717
+ * backupDir?: string|null,
718
+ * profileDir?: string,
719
+ * backupMeta?: object|null,
720
+ * enabledPlugins?: string[]|null,
721
+ * recovery?: object|null,
722
+ * attemptLimit?: number,
723
+ * fs?: object,
724
+ * }} [options]
725
+ * @returns {{
726
+ * backupDir: string|null,
727
+ * backupKind: string|null,
728
+ * restoredAt: number|null,
729
+ * actions: Array<object>,
730
+ * needsHuman: boolean,
731
+ * restorable: number,
732
+ * }}
733
+ */
734
+ export function planPluginRecovery({
735
+ failures = [],
736
+ backupDir = null,
737
+ profileDir = '',
738
+ backupMeta = null,
739
+ enabledPlugins = null,
740
+ recovery = null,
741
+ attemptLimit = DEFAULT_ATTEMPT_LIMIT,
742
+ fs = fsDefault,
743
+ } = {}) {
744
+ const root = typeof backupDir === 'string' && backupDir.trim() !== '' ? backupDir.trim() : null;
745
+ const profile = typeof profileDir === 'string' ? profileDir.trim() : '';
746
+ // The identity of the backup: it is what the report says the plugin was
747
+ // restored FROM ("restored to which point in time").
748
+ const meta = backupMeta && typeof backupMeta === 'object'
749
+ ? backupMeta
750
+ : (root === null ? null : readBackupMeta(root));
751
+ const backupKind = meta && typeof meta.kind === 'string' ? meta.kind : null;
752
+ const restoredAt = meta && Number.isFinite(Number(meta.at)) ? Math.trunc(Number(meta.at)) : null;
753
+ const state = recovery && typeof recovery === 'object' ? recovery : createRecoveryState();
754
+ const actions = [];
755
+ let needsHuman = false;
756
+ let restorable = 0;
757
+
758
+ for (const failure of Array.isArray(failures) ? failures : []) {
759
+ if (!failure || typeof failure !== 'object') continue;
760
+ const plugin = typeof failure.plugin === 'string' ? failure.plugin.trim() : '';
761
+ if (plugin === '') continue;
762
+ const fingerprint = typeof failure.fingerprint === 'string' && failure.fingerprint !== ''
763
+ ? failure.fingerprint
764
+ : failureFingerprint(failure);
765
+ const base = {
766
+ plugin,
767
+ fingerprint,
768
+ error: typeof failure.error === 'string' ? failure.error : '',
769
+ failedAt: Number.isFinite(Number(failure.at)) ? Math.trunc(Number(failure.at)) : null,
770
+ backupKind,
771
+ restoredAt,
772
+ restoredFrom: null,
773
+ source: null,
774
+ applied: false,
775
+ changed: false,
776
+ evidence: null,
777
+ needsHuman: false,
778
+ reason: '',
779
+ mode: RECOVERY_MODES.NO_BACKUP,
780
+ };
781
+
782
+ // (1) THE SAME FAILURE, ALREADY HANDLED: the loop guard of the log line.
783
+ if (handledRecord(state, fingerprint) !== null) {
784
+ actions.push({
785
+ ...base,
786
+ mode: RECOVERY_MODES.DUPLICATE,
787
+ reason: 'this exact failure was already handled in an earlier cycle: it is not a new incident, '
788
+ + 'so nothing is restored and no restart is asked for',
789
+ });
790
+ continue;
791
+ }
792
+
793
+ // (2) THIS PLUGIN ALREADY USED ITS ONE ATTEMPT: report, never act.
794
+ const decision = decideAttempt(state, plugin, { limit: attemptLimit });
795
+ if (decision.allowed !== true) {
796
+ needsHuman = true;
797
+ actions.push({
798
+ ...base,
799
+ mode: RECOVERY_MODES.NEEDS_HUMAN,
800
+ needsHuman: true,
801
+ reason: decision.reason,
802
+ });
803
+ continue;
804
+ }
805
+
806
+ // (3) A PLUGIN THIS PROFILE DOES NOT ENABLE is not ours to write into.
807
+ if (isEnabledPlugin(enabledPlugins, plugin) !== true) {
808
+ actions.push({
809
+ ...base,
810
+ mode: RECOVERY_MODES.NO_BACKUP,
811
+ reason: 'the plugin is not in the enabled plugin list of this profile: it is left exactly as it is',
812
+ });
813
+ continue;
814
+ }
815
+
816
+ // (4) NO BACKUP AT ALL: nothing to restore from.
817
+ if (root === null) {
818
+ actions.push({
819
+ ...base,
820
+ mode: RECOVERY_MODES.NO_BACKUP,
821
+ reason: 'no copy on this machine has a restart record saying it came up with matching content, so there is nothing to restore from',
822
+ });
823
+ continue;
824
+ }
825
+
826
+ // (5) WHERE THE COPY IS (real copy, link contents, or nowhere).
827
+ // A slim backup deliberately holds no node_modules. Ask the BACKUP which kind
828
+ // it is instead of assuming: old full backups must keep working.
829
+ const slim = !fs.existsSync(path.join(root, PLUGINS_DIR));
830
+ const located = locateBackupCopy({ backupDir: root, plugin, fs, slim });
831
+ if (located.source === null) {
832
+ actions.push({ ...base, mode: RECOVERY_MODES.NO_BACKUP, reason: located.reason });
833
+ continue;
834
+ }
835
+
836
+ restorable += 1;
837
+ actions.push({
838
+ ...base,
839
+ mode: located.mode,
840
+ // Where the copy comes from, and the identity of the backup it belongs to.
841
+ source: located.source,
842
+ restoredFrom: located.source,
843
+ reason: located.reason,
844
+ });
845
+ }
846
+
847
+ // A plan is only "human needed" when a plugin really ran out of attempts; a
848
+ // duplicate or a missing backup is a normal outcome.
849
+ return {
850
+ backupDir: root,
851
+ backupKind,
852
+ restoredAt,
853
+ actions,
854
+ needsHuman,
855
+ restorable,
856
+ };
857
+ }
858
+
859
+ // ---------------------------------------------------------------------------
860
+ // Applying the plan
861
+ // ---------------------------------------------------------------------------
862
+
863
+ /**
864
+ * Copy a directory tree. The default is the platform's own recursive copy.
865
+ *
866
+ * @param {string} source
867
+ * @param {string} destination
868
+ * @param {{ fs?: object }} [options]
869
+ * @returns {{ ok: boolean, error: string|null }}
870
+ */
871
+ export function copyTree(source, destination, { fs = fsDefault } = {}) {
872
+ try {
873
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
874
+ fs.cpSync(source, destination, { recursive: true, force: true });
875
+ return { ok: true, error: null };
876
+ } catch (error) {
877
+ return { ok: false, error: error && error.message ? error.message : String(error) };
878
+ }
879
+ }
880
+
881
+ /**
882
+ * Apply a plan: move the live directory aside (EVIDENCE, never a delete) and let
883
+ * the backup copy take its place.
884
+ *
885
+ * The plan is NOT mutated: the returned actions are the record of what happened,
886
+ * which is what the report and the tests read.
887
+ *
888
+ * @param {{
889
+ * plan: object,
890
+ * profileDir?: string,
891
+ * now?: number,
892
+ * fs?: object,
893
+ * copy?: Function|null,
894
+ * compare?: boolean,
895
+ * }} [options]
896
+ * @returns {{ changed: boolean, actions: Array<object>, needsHuman: boolean, restorable: number }}
897
+ */
898
+ export function applyPluginRecovery({
899
+ plan = null,
900
+ profileDir = '',
901
+ now = Date.now(),
902
+ fs = fsDefault,
903
+ copy = null,
904
+ compare = true,
905
+ } = {}) {
906
+ const source = plan && typeof plan === 'object' ? plan : {};
907
+ const actions = Array.isArray(source.actions) ? source.actions : [];
908
+ const profile = typeof profileDir === 'string' ? profileDir.trim() : '';
909
+ const copyFn = typeof copy === 'function' ? copy : ((from, to) => copyTree(from, to, { fs }));
910
+ const out = [];
911
+ let changed = false;
912
+ let needsHuman = source.needsHuman === true;
913
+
914
+ for (const action of actions) {
915
+ const record = { ...action, applied: false, changed: false, evidence: null };
916
+ if (action.needsHuman === true) needsHuman = true;
917
+ // Only the two restore modes touch the disk; everything else is a decision
918
+ // that says "nothing happens" and is reported as it is.
919
+ if (!RESTORE_MODES.includes(action.mode) || typeof action.source !== 'string') {
920
+ out.push(record);
921
+ continue;
922
+ }
923
+ if (profile === '') {
924
+ out.push({ ...record, mode: RECOVERY_MODES.NO_BACKUP, reason: 'no profile directory was resolved, so nothing could be restored' });
925
+ continue;
926
+ }
927
+ const livePath = path.join(profile, PLUGINS_DIR, action.plugin);
928
+ // IDEMPOTENCY: an already restored plugin is not copied again, and — the
929
+ // point of the whole check — no restart is asked for it.
930
+ if (compare !== false) {
931
+ const comparison = sameAsBackup(livePath, action.source, { fs });
932
+ if (comparison.same === true) {
933
+ out.push({
934
+ ...record,
935
+ mode: RECOVERY_MODES.ALREADY_CONSISTENT,
936
+ reason: comparison.reason,
937
+ });
938
+ continue;
939
+ }
940
+ record.reason = comparison.reason;
941
+ }
942
+ let evidence = null;
943
+ let livePresent = false;
944
+ try {
945
+ fs.lstatSync(livePath);
946
+ livePresent = true;
947
+ } catch {
948
+ livePresent = false;
949
+ }
950
+ if (livePresent) {
951
+ const brokenPath = path.join(profile, PLUGINS_DIR, brokenNameFor(action.plugin, now));
952
+ try {
953
+ // THE EVIDENCE. The broken tree is renamed, never deleted: a user (or a
954
+ // colleague) must be able to look at what was really there.
955
+ fs.renameSync(livePath, brokenPath);
956
+ evidence = { renamedFrom: livePath, renamedTo: brokenPath };
957
+ } catch (error) {
958
+ out.push({
959
+ ...record,
960
+ applied: false,
961
+ error: error && error.message ? error.message : String(error),
962
+ reason: 'the live directory could not be moved aside, so it was left alone (nothing was deleted)',
963
+ });
964
+ continue;
965
+ }
966
+ }
967
+ const copied = copyFn(action.source, livePath);
968
+ if (copied && copied.ok === false) {
969
+ out.push({
970
+ ...record,
971
+ applied: false,
972
+ evidence,
973
+ error: copied.error || 'the copy failed',
974
+ reason: 'the backup copy could not be written: the live directory was left as it was',
975
+ });
976
+ continue;
977
+ }
978
+ changed = true;
979
+ out.push({
980
+ ...record,
981
+ applied: true,
982
+ changed: true,
983
+ evidence,
984
+ restoredFrom: action.source,
985
+ reason: 'the backup copy now takes the place of the failed plugin',
986
+ });
987
+ }
988
+
989
+ return { changed, actions: out, needsHuman, restorable: Number(source.restorable) || 0 };
990
+ }
991
+
992
+ // ---------------------------------------------------------------------------
993
+ // The report (`<stateDir>/last-plugin-recovery.json`)
994
+ // ---------------------------------------------------------------------------
995
+
996
+ /**
997
+ * Build the readable report of one recovery pass.
998
+ *
999
+ * The shape is the contract with the interface: `at` / `atIso` (when), `failures`
1000
+ * (what the log said), `actions` (what was done, with `restoredAt` / `backupKind`
1001
+ * from the BACKUP'S OWN meta), `restartRequested` (whether a restart was asked
1002
+ * for) and `needsHuman` (whether somebody has to look).
1003
+ *
1004
+ * @param {{
1005
+ * at?: number,
1006
+ * atIso?: string|null,
1007
+ * log?: string|null,
1008
+ * failures?: Array<object>,
1009
+ * actions?: Array<object>,
1010
+ * restartRequested?: boolean,
1011
+ * needsHuman?: boolean,
1012
+ * backupDir?: string|null,
1013
+ * backupKind?: string|null,
1014
+ * restoredAt?: number|null,
1015
+ * }} [options]
1016
+ * @returns {object}
1017
+ */
1018
+ export function buildRecoveryReport({
1019
+ at = Date.now(),
1020
+ atIso = null,
1021
+ log = null,
1022
+ failures = [],
1023
+ actions = [],
1024
+ restartRequested = false,
1025
+ needsHuman = false,
1026
+ backupDir = null,
1027
+ backupKind = null,
1028
+ restoredAt = null,
1029
+ } = {}) {
1030
+ const when = Number.isFinite(Number(at)) ? Math.trunc(Number(at)) : Date.now();
1031
+ const failureList = (Array.isArray(failures) ? failures : []).map((failure) => ({
1032
+ plugin: typeof failure.plugin === 'string' ? failure.plugin : null,
1033
+ error: typeof failure.error === 'string' ? failure.error : '',
1034
+ at: Number.isFinite(Number(failure.at)) ? Math.trunc(Number(failure.at)) : null,
1035
+ kind: typeof failure.kind === 'string' ? failure.kind : null,
1036
+ fingerprint: typeof failure.fingerprint === 'string' ? failure.fingerprint : null,
1037
+ }));
1038
+ const actionList = (Array.isArray(actions) ? actions : []).map((action) => ({
1039
+ plugin: typeof action.plugin === 'string' ? action.plugin : '',
1040
+ mode: typeof action.mode === 'string' ? action.mode : RECOVERY_MODES.NO_BACKUP,
1041
+ restoredFrom: typeof action.restoredFrom === 'string' ? action.restoredFrom : null,
1042
+ restoredAt: Number.isFinite(Number(action.restoredAt)) ? Math.trunc(Number(action.restoredAt)) : restoredAt,
1043
+ backupKind: typeof action.backupKind === 'string' ? action.backupKind : backupKind,
1044
+ evidence: action.evidence && typeof action.evidence === 'object'
1045
+ ? {
1046
+ renamedFrom: typeof action.evidence.renamedFrom === 'string' ? action.evidence.renamedFrom : null,
1047
+ renamedTo: typeof action.evidence.renamedTo === 'string' ? action.evidence.renamedTo : null,
1048
+ }
1049
+ : null,
1050
+ applied: action.applied === true,
1051
+ changed: action.changed === true,
1052
+ needsHuman: action.needsHuman === true,
1053
+ reason: typeof action.reason === 'string' ? action.reason : '',
1054
+ }));
1055
+ return {
1056
+ at: when,
1057
+ atIso: typeof atIso === 'string' && atIso !== '' ? atIso : new Date(when).toISOString(),
1058
+ log: typeof log === 'string' && log !== '' ? log : null,
1059
+ backupDir: typeof backupDir === 'string' && backupDir !== '' ? backupDir : null,
1060
+ backupKind: typeof backupKind === 'string' ? backupKind : null,
1061
+ restoredAt: Number.isFinite(Number(restoredAt)) ? Math.trunc(Number(restoredAt)) : null,
1062
+ failures: failureList,
1063
+ actions: actionList,
1064
+ restartRequested: restartRequested === true,
1065
+ needsHuman: needsHuman === true,
1066
+ changed: actionList.some((action) => action.changed === true),
1067
+ };
1068
+ }
1069
+
1070
+ /**
1071
+ * Write the report. Never throws: a report that cannot be written must not take
1072
+ * the boot down (the log line is the caller's job).
1073
+ *
1074
+ * @param {string} stateDir
1075
+ * @param {object} report
1076
+ * @param {{ fs?: object }} [options]
1077
+ * @returns {boolean} whether it was written
1078
+ */
1079
+ export function writeRecoveryReport(stateDir, report, { fs = fsDefault } = {}) {
1080
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
1081
+ if (dir === '' || !report || typeof report !== 'object') return false;
1082
+ try {
1083
+ fs.mkdirSync(dir, { recursive: true });
1084
+ fs.writeFileSync(path.join(dir, RECOVERY_REPORT_NAME), JSON.stringify(report, null, 2), 'utf8');
1085
+ return true;
1086
+ } catch {
1087
+ return false;
1088
+ }
1089
+ }
1090
+
1091
+ /**
1092
+ * Read the report of the last recovery (null when there is none).
1093
+ *
1094
+ * @param {string} stateDir
1095
+ * @param {{ fs?: object }} [options]
1096
+ * @returns {object|null}
1097
+ */
1098
+ export function readRecoveryReport(stateDir, { fs = fsDefault } = {}) {
1099
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
1100
+ if (dir === '') return null;
1101
+ try {
1102
+ const text = fs.readFileSync(path.join(dir, RECOVERY_REPORT_NAME), 'utf8');
1103
+ const parsed = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
1104
+ return parsed && typeof parsed === 'object' ? parsed : null;
1105
+ } catch {
1106
+ return null;
1107
+ }
1108
+ }
1109
+
1110
+ /**
1111
+ * The report, cut down to what a health answer carries.
1112
+ *
1113
+ * The health route is polled every couple of seconds, so the answer stays small:
1114
+ * the plugin names, the mode of each action and the two timestamps that answer
1115
+ * "what broke" and "restored to when".
1116
+ *
1117
+ * @param {object|null} report
1118
+ * @returns {object|null}
1119
+ */
1120
+ export function summarizeRecoveryReport(report) {
1121
+ if (!report || typeof report !== 'object') return null;
1122
+ const failures = Array.isArray(report.failures) ? report.failures : [];
1123
+ const actions = Array.isArray(report.actions) ? report.actions : [];
1124
+ return {
1125
+ at: Number.isFinite(Number(report.at)) ? Math.trunc(Number(report.at)) : null,
1126
+ atIso: typeof report.atIso === 'string' ? report.atIso : null,
1127
+ backupKind: typeof report.backupKind === 'string' ? report.backupKind : null,
1128
+ restoredAt: Number.isFinite(Number(report.restoredAt)) ? Math.trunc(Number(report.restoredAt)) : null,
1129
+ restartRequested: report.restartRequested === true,
1130
+ needsHuman: report.needsHuman === true,
1131
+ failures: failures.slice(0, 10).map((failure) => ({
1132
+ plugin: typeof failure.plugin === 'string' ? failure.plugin : null,
1133
+ error: typeof failure.error === 'string' ? failure.error : '',
1134
+ })),
1135
+ actions: actions.slice(0, 10).map((action) => ({
1136
+ plugin: typeof action.plugin === 'string' ? action.plugin : '',
1137
+ mode: typeof action.mode === 'string' ? action.mode : RECOVERY_MODES.NO_BACKUP,
1138
+ restoredAt: Number.isFinite(Number(action.restoredAt)) ? Math.trunc(Number(action.restoredAt)) : null,
1139
+ backupKind: typeof action.backupKind === 'string' ? action.backupKind : null,
1140
+ evidence: action.evidence && typeof action.evidence === 'object' && typeof action.evidence.renamedTo === 'string'
1141
+ ? { renamedTo: action.evidence.renamedTo }
1142
+ : null,
1143
+ })),
1144
+ };
1145
+ }
1146
+
1147
+ // ---------------------------------------------------------------------------
1148
+ // AT BOOT: LOOK ONLY (user decision, 2026-11-01 — "只提示,不改文件")
1149
+ // ---------------------------------------------------------------------------
1150
+
1151
+ /**
1152
+ * Read the previous boot's log, recognise the failed plugins and WRITE THE
1153
+ * REPORT. Nothing else.
1154
+ *
1155
+ * WHY THIS REPLACED THE AUTOMATIC RECOVERY: a plugin that fails to import never
1156
+ * stops the host from coming up — measured on the desktop (22 s) and on the
1157
+ * hosted `dsh web` instance (2.0–2.6 s), with the profile's `package.json`
1158
+ * byte-identical and no repeated restarts under `supervise-web.sh`. The host
1159
+ * already isolates a broken entry, so an automatic restore only added a second
1160
+ * writer into the profile at boot and an automatic restart on top of it.
1161
+ *
1162
+ * WHAT IT DOES NOT DO, ON PURPOSE (both are asserted by the tests):
1163
+ *
1164
+ * - it never calls {@link applyPluginRecovery} (no file inside the profile is
1165
+ * touched, no `<plugin>.broken-<ts>` evidence is created);
1166
+ * - it never calls `requestRestart` (a boot never asks for another boot).
1167
+ *
1168
+ * The only file it writes is the plugin's OWN report under `stateDir`
1169
+ * (`last-plugin-recovery.json`), which is what the health route and the popup
1170
+ * read.
1171
+ *
1172
+ * The two pure functions {@link planPluginRecovery} and
1173
+ * {@link applyPluginRecovery} stay public: {@link restoreOnePlugin} uses them
1174
+ * when the USER asks for one plugin to be put back.
1175
+ *
1176
+ * @param {{
1177
+ * stateDir?: string,
1178
+ * backupsDir?: string,
1179
+ * logsDir?: string,
1180
+ * bootAt?: number,
1181
+ * now?: number,
1182
+ * fs?: object,
1183
+ * logPath?: string|null,
1184
+ * write?: boolean,
1185
+ * }} [options]
1186
+ * @returns {object} the outcome (never null; `ran:false` carries a `reason`)
1187
+ */
1188
+ export function detectPluginRecovery({
1189
+ stateDir = '',
1190
+ backupsDir = '',
1191
+ logsDir = '',
1192
+ bootAt = Date.now(),
1193
+ now = Date.now(),
1194
+ fs = fsDefault,
1195
+ logPath = null,
1196
+ write = true,
1197
+ } = {}) {
1198
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
1199
+ const at = Number.isFinite(Number(now)) ? Math.trunc(Number(now)) : Date.now();
1200
+ const boot = Number.isFinite(Number(bootAt)) ? Math.trunc(Number(bootAt)) : at;
1201
+ const empty = {
1202
+ ran: false,
1203
+ found: false,
1204
+ changed: false,
1205
+ restartRequested: false,
1206
+ needsHuman: false,
1207
+ report: null,
1208
+ log: null,
1209
+ failures: [],
1210
+ backupDir: null,
1211
+ backupKind: null,
1212
+ restoredAt: null,
1213
+ };
1214
+ if (dir === '') return { ...empty, reason: 'no state directory was resolved' };
1215
+
1216
+ // (1) WHICH LOG: the PREVIOUS boot's own log (never this one — its audit
1217
+ // verdict does not exist yet).
1218
+ const log = typeof logPath === 'string' && logPath !== ''
1219
+ ? { name: path.basename(logPath), path: logPath, at: null, mtime: 0, size: 0 }
1220
+ : findLatestStartupLog(logsDir, { before: boot, fs });
1221
+ if (log === null) {
1222
+ return { ...empty, reason: 'no finished boot log was found, so there is nothing to judge' };
1223
+ }
1224
+ let text = ''
1225
+ try {
1226
+ text = String(fs.readFileSync(log.path, 'utf8'));
1227
+ } catch (error) {
1228
+ return {
1229
+ ...empty,
1230
+ log: log.path,
1231
+ reason: 'the startup log could not be read: ' + (error && error.message ? error.message : String(error)),
1232
+ };
1233
+ }
1234
+
1235
+ // (2) DETECT.
1236
+ const sinceMs = Number.isFinite(Number(log.at)) ? Number(log.at) : null;
1237
+ const failures = findPluginFailures(text, {
1238
+ sinceMs,
1239
+ logAt: Number.isFinite(Number(log.mtime)) && log.mtime > 0 ? Math.trunc(log.mtime) : null,
1240
+ now: at,
1241
+ });
1242
+ const named = mergeFailuresByPlugin(failures);
1243
+ if (named.length === 0) {
1244
+ return {
1245
+ ...empty,
1246
+ ran: true,
1247
+ log: log.path,
1248
+ reason: 'the previous boot reported no plugin failure',
1249
+ };
1250
+ }
1251
+
1252
+ // (3) WHICH BACKUP THE USER COULD RESTORE FROM. Two cheap `lstat`s per plugin
1253
+ // less than a `sameAsBackup` digest, so the boot pays next to nothing for
1254
+ // saying "a backup exists / does not exist" in the popup.
1255
+ const knownGood = typeof backupsDir === 'string' && backupsDir.trim() !== ''
1256
+ ? latestKnownGood(backupsDir.trim())
1257
+ : null;
1258
+ const backupDir = knownGood && typeof knownGood.dir === 'string' ? knownGood.dir : null;
1259
+ const meta = backupDir === null ? null : readBackupMeta(backupDir);
1260
+ const backupKind = meta && typeof meta.kind === 'string' ? meta.kind : null;
1261
+ const restoredAt = meta && Number.isFinite(Number(meta.at)) ? Math.trunc(Number(meta.at)) : null;
1262
+
1263
+ // (4) THE REPORT. `actions` stays EMPTY: nothing was done, and saying
1264
+ // "overwrite" here would claim a restore that never happened.
1265
+ const report = buildRecoveryReport({
1266
+ at,
1267
+ log: log.path,
1268
+ failures: named,
1269
+ actions: [],
1270
+ restartRequested: false,
1271
+ needsHuman: false,
1272
+ backupDir,
1273
+ backupKind,
1274
+ restoredAt,
1275
+ });
1276
+ if (write === true) writeRecoveryReport(dir, report, { fs });
1277
+
1278
+ return {
1279
+ ran: true,
1280
+ found: true,
1281
+ changed: false,
1282
+ restartRequested: false,
1283
+ needsHuman: false,
1284
+ report,
1285
+ log: log.path,
1286
+ failures: named,
1287
+ backupDir,
1288
+ backupKind,
1289
+ restoredAt,
1290
+ reason: 'the previous boot reported a plugin failure: it is reported, nothing was changed',
1291
+ };
1292
+ }
1293
+
1294
+ // ---------------------------------------------------------------------------
1295
+ // ON DEMAND: restore exactly ONE plugin (the popup's own button)
1296
+ // ---------------------------------------------------------------------------
1297
+
1298
+ /** The record of the restores a human asked for. */
1299
+ export const RESTORE_HISTORY_NAME = 'plugin-restore-history.json';
1300
+
1301
+ /** How many of them are kept (newest first). */
1302
+ export const RESTORE_HISTORY_LIMIT = 20;
1303
+
1304
+ /** An empty history. */
1305
+ export function createRestoreHistory() {
1306
+ return { version: 1, entries: [] };
1307
+ }
1308
+
1309
+ /**
1310
+ * Read the restore history. Never throws: a corrupt file is an empty history of
1311
+ * its own, not a broken popup.
1312
+ *
1313
+ * @param {string} stateDir
1314
+ * @param {{ fs?: object }} [options]
1315
+ * @returns {{ version: number, entries: Array<object> }}
1316
+ */
1317
+ export function readRestoreHistory(stateDir, { fs = fsDefault } = {}) {
1318
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
1319
+ if (dir === '') return createRestoreHistory();
1320
+ try {
1321
+ const text = fs.readFileSync(path.join(dir, RESTORE_HISTORY_NAME), 'utf8');
1322
+ const raw = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
1323
+ const parsed = JSON.parse(raw);
1324
+ const entries = Array.isArray(parsed && parsed.entries) ? parsed.entries : [];
1325
+ return { version: 1, entries: entries.filter((entry) => entry && typeof entry === 'object') };
1326
+ } catch {
1327
+ return createRestoreHistory();
1328
+ }
1329
+ }
1330
+
1331
+ /**
1332
+ * Append one restore to the history, newest first, capped.
1333
+ *
1334
+ * @param {string} stateDir
1335
+ * @param {object} entry
1336
+ * @param {{ fs?: object, limit?: number }} [options]
1337
+ * @returns {{ ok: boolean, entries: Array<object>, error: string|null }}
1338
+ */
1339
+ export function appendRestoreHistory(stateDir, entry, { fs = fsDefault, limit = RESTORE_HISTORY_LIMIT } = {}) {
1340
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
1341
+ const cap = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.trunc(Number(limit)) : RESTORE_HISTORY_LIMIT;
1342
+ if (dir === '') return { ok: false, entries: [], error: 'no state directory was resolved' };
1343
+ const history = readRestoreHistory(dir, { fs });
1344
+ const entries = [entry, ...history.entries].filter((item) => item && typeof item === 'object').slice(0, cap);
1345
+ try {
1346
+ fs.mkdirSync(dir, { recursive: true });
1347
+ fs.writeFileSync(path.join(dir, RESTORE_HISTORY_NAME), JSON.stringify({ version: 1, entries }, null, 2), 'utf8');
1348
+ return { ok: true, entries, error: null };
1349
+ } catch (error) {
1350
+ return { ok: false, entries, error: error && error.message ? error.message : String(error) };
1351
+ }
1352
+ }
1353
+
1354
+ /**
1355
+ * The newest restore, cut down to what the health answer carries.
1356
+ *
1357
+ * @param {{ entries?: Array<object> }|Array<object>|null} history
1358
+ * @returns {object|null}
1359
+ */
1360
+ export function summarizeRestoreHistory(history) {
1361
+ const entries = Array.isArray(history)
1362
+ ? history
1363
+ : (history && Array.isArray(history.entries) ? history.entries : []);
1364
+ const first = entries.find((entry) => entry && typeof entry === 'object');
1365
+ if (!first) return null;
1366
+ return {
1367
+ at: Number.isFinite(Number(first.at)) ? Math.trunc(Number(first.at)) : null,
1368
+ atIso: typeof first.atIso === 'string' ? first.atIso : null,
1369
+ plugin: typeof first.plugin === 'string' ? first.plugin : null,
1370
+ mode: typeof first.mode === 'string' ? first.mode : null,
1371
+ ok: first.ok === true,
1372
+ changed: first.changed === true,
1373
+ needsRestart: first.needsRestart === true,
1374
+ backupKind: typeof first.backupKind === 'string' ? first.backupKind : null,
1375
+ restoredAt: Number.isFinite(Number(first.restoredAt)) ? Math.trunc(Number(first.restoredAt)) : null,
1376
+ reason: typeof first.reason === 'string' ? first.reason : '',
1377
+ };
1378
+ }
1379
+
1380
+ /**
1381
+ * PUT ONE PLUGIN BACK — the only path that still writes into the profile, and
1382
+ * only because a human pressed the button.
1383
+ *
1384
+ * The difference to the removed automatic pass is the WHO and the ONE:
1385
+ *
1386
+ * - it restores the plugin it was ASKED for, never the list a log suggests;
1387
+ * - it skips the loop guards of the automatic pass on purpose (`duplicate` /
1388
+ * `needs-human` are about "should the machine act on its own"); a person
1389
+ * asking again is not a loop, and the idempotency check still protects the
1390
+ * disk: a plugin that already equals the backup answers
1391
+ * `already-consistent`, writes nothing and asks for no restart;
1392
+ * - it writes `<stateDir>/plugin-restore-history.json` (newest 20) so the
1393
+ * user can see afterwards what was put back and from when.
1394
+ *
1395
+ * @param {{
1396
+ * stateDir?: string,
1397
+ * profileDir?: string,
1398
+ * backupsDir?: string,
1399
+ * plugin?: string,
1400
+ * now?: number,
1401
+ * fs?: object,
1402
+ * copy?: Function|null,
1403
+ * historyLimit?: number,
1404
+ * }} [options]
1405
+ * @returns {{
1406
+ * ok: boolean,
1407
+ * plugin: string,
1408
+ * mode: string|null,
1409
+ * restoredFrom: string|null,
1410
+ * restoredAt: number|null,
1411
+ * backupKind: string|null,
1412
+ * needsRestart: boolean,
1413
+ * changed: boolean,
1414
+ * evidence: object|null,
1415
+ * at: number,
1416
+ * atIso: string,
1417
+ * reason: string,
1418
+ * }}
1419
+ */
1420
+ export function restoreOnePlugin({
1421
+ stateDir = '',
1422
+ profileDir = '',
1423
+ backupsDir = '',
1424
+ plugin = '',
1425
+ now = Date.now(),
1426
+ fs = fsDefault,
1427
+ copy = null,
1428
+ historyLimit = RESTORE_HISTORY_LIMIT,
1429
+ } = {}) {
1430
+ const at = Number.isFinite(Number(now)) ? Math.trunc(Number(now)) : Date.now();
1431
+ const name = typeof plugin === 'string' ? plugin.trim() : '';
1432
+ const state = typeof stateDir === 'string' ? stateDir.trim() : '';
1433
+ const profile = typeof profileDir === 'string' ? profileDir.trim() : '';
1434
+ const base = {
1435
+ ok: false,
1436
+ plugin: name,
1437
+ mode: null,
1438
+ restoredFrom: null,
1439
+ restoredAt: null,
1440
+ backupKind: null,
1441
+ needsRestart: false,
1442
+ changed: false,
1443
+ evidence: null,
1444
+ at,
1445
+ atIso: new Date(at).toISOString(),
1446
+ reason: '',
1447
+ };
1448
+ const finish = (outcome) => {
1449
+ const entry = { ...base, ...outcome };
1450
+ // The history is a convenience, never a reason to fail the restore.
1451
+ const written = appendRestoreHistory(state, entry, { fs, limit: historyLimit });
1452
+ return { ...entry, history: { ok: written.ok === true, count: written.entries.length, error: written.error } };
1453
+ };
1454
+
1455
+ if (name === '') return finish({ reason: 'no plugin name was given', mode: RECOVERY_MODES.NO_BACKUP });
1456
+ if (profile === '') return finish({ reason: 'no profile directory was resolved', mode: RECOVERY_MODES.NO_BACKUP });
1457
+
1458
+ const root = typeof backupsDir === 'string' ? backupsDir.trim() : '';
1459
+ const knownGood = root === '' ? null : latestKnownGood(root);
1460
+ if (knownGood === null || typeof knownGood.dir !== 'string') {
1461
+ return finish({
1462
+ mode: RECOVERY_MODES.NO_BACKUP,
1463
+ reason: 'no copy on this machine has a restart record saying it came up with matching content, so there is nothing to restore from',
1464
+ });
1465
+ }
1466
+ const meta = readBackupMeta(knownGood.dir);
1467
+ const backupKind = meta && typeof meta.kind === 'string' ? meta.kind : null;
1468
+ const restoredAt = meta && Number.isFinite(Number(meta.at)) ? Math.trunc(Number(meta.at)) : null;
1469
+
1470
+ // Same detection as above: old full backups keep working.
1471
+ const slim = !fs.existsSync(path.join(knownGood.dir, PLUGINS_DIR));
1472
+ const located = locateBackupCopy({ backupDir: knownGood.dir, plugin: name, fs, slim });
1473
+ if (located.source === null) {
1474
+ return finish({
1475
+ mode: RECOVERY_MODES.NO_BACKUP,
1476
+ reason: located.reason,
1477
+ backupKind,
1478
+ restoredAt,
1479
+ });
1480
+ }
1481
+
1482
+ // ONE action, built by hand: no `handled` fingerprint and no attempt budget,
1483
+ // so a person can ask twice. The idempotency check inside the applier is the
1484
+ // only thing that decides "nothing to do".
1485
+ const plan = {
1486
+ backupDir: knownGood.dir,
1487
+ backupKind,
1488
+ restoredAt,
1489
+ needsHuman: false,
1490
+ restorable: 1,
1491
+ actions: [{
1492
+ plugin: name,
1493
+ fingerprint: '',
1494
+ error: '',
1495
+ failedAt: null,
1496
+ backupKind,
1497
+ restoredAt,
1498
+ restoredFrom: located.source,
1499
+ source: located.source,
1500
+ mode: located.mode,
1501
+ applied: false,
1502
+ changed: false,
1503
+ evidence: null,
1504
+ needsHuman: false,
1505
+ reason: located.reason,
1506
+ }],
1507
+ };
1508
+ const applied = applyPluginRecovery({ plan, profileDir: profile, now: at, fs, copy });
1509
+ const action = Array.isArray(applied.actions) && applied.actions.length > 0 ? applied.actions[0] : null;
1510
+ if (action === null) {
1511
+ return finish({ mode: located.mode, backupKind, restoredAt, reason: 'the restore produced no action' });
1512
+ }
1513
+ if (action.mode === RECOVERY_MODES.ALREADY_CONSISTENT) {
1514
+ return finish({
1515
+ ok: true,
1516
+ plugin: name,
1517
+ mode: RECOVERY_MODES.ALREADY_CONSISTENT,
1518
+ restoredFrom: located.source,
1519
+ restoredAt,
1520
+ backupKind,
1521
+ needsRestart: false,
1522
+ changed: false,
1523
+ reason: action.reason,
1524
+ });
1525
+ }
1526
+ if (action.changed !== true) {
1527
+ // A failed copy or a directory that could not be moved aside.
1528
+ return finish({
1529
+ mode: action.mode,
1530
+ restoredFrom: located.source,
1531
+ restoredAt,
1532
+ backupKind,
1533
+ evidence: action.evidence && typeof action.evidence === 'object' ? action.evidence : null,
1534
+ reason: action.error
1535
+ ? 'the restore failed: ' + String(action.error)
1536
+ : (action.reason || 'nothing was changed'),
1537
+ });
1538
+ }
1539
+ return finish({
1540
+ ok: true,
1541
+ plugin: name,
1542
+ mode: action.mode,
1543
+ restoredFrom: action.restoredFrom === undefined ? located.source : action.restoredFrom,
1544
+ restoredAt,
1545
+ backupKind,
1546
+ needsRestart: true,
1547
+ changed: true,
1548
+ evidence: action.evidence && typeof action.evidence === 'object' ? action.evidence : null,
1549
+ reason: 'the backup copy now takes the place of the failed plugin: restart so the next boot loads it',
1550
+ });
1551
+ }
1552
+
1553
+ /**
1554
+ * The default backup directory name of a state directory — re-exported so the
1555
+ * caller does not have to know the layout (`lib/backup.js` owns it).
1556
+ */
1557
+ export { META_NAME, KIND_KNOWN_GOOD, LINKS_DIR, PLUGINS_DIR };