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
package/lib/backup.js CHANGED
@@ -16,7 +16,9 @@
16
16
  * and for a manual recovery from inside the host.
17
17
  */
18
18
 
19
+ import { createHash } from 'node:crypto';
19
20
  import fs from 'node:fs';
21
+ import { treeDigest } from './plugin-recover.js'
20
22
  import path from 'node:path';
21
23
 
22
24
  /** The profile files that decide whether the host can boot. */
@@ -28,11 +30,1354 @@ export const CONFIG_DIR = 'config';
28
30
  /** The directory inside one backup that holds the installed plugin tree. */
29
31
  export const PLUGINS_DIR = 'node_modules';
30
32
 
33
+ /**
34
+ * The directory inside one backup that holds the CONTENTS of linked plugins.
35
+ *
36
+ * WHY A SEPARATE DIRECTORY (user decision, 2026-10-30). A plugin installed with
37
+ * `link:` / a junction is not a copy: `node_modules/<name>` is a reparse point
38
+ * pointing at a development directory. Copying it as part of the tree either
39
+ * copies the LINK (and a restore would put back a link to a directory that may
40
+ * be gone) or follows it into a tree that is not the profile's. The contents
41
+ * are therefore kept apart, one directory per plugin, and the restore decides
42
+ * per plugin whether the link can stay or the contents must take its place.
43
+ */
44
+ export const LINKS_DIR = 'node_modules-link';
45
+
46
+ /**
47
+ * NAMES NEVER COPIED INTO A BACKUP (2026-11-01).
48
+ *
49
+ * `.git` only, on purpose: it is rebuildable (clone/fetch) and it is the bulk of
50
+ * a linked plugin — measured on the real machine, one plugin's history was 962
51
+ * files / ~74 MB inside EVERY backup. Anything else (lock files, build output,
52
+ * a stray `.cache`) is left alone: this list is not a place to guess.
53
+ */
54
+ export const BACKUP_SKIP_NAMES = Object.freeze(['.git']);
55
+
56
+ /**
57
+ * HOW MUCH A SINGLE JUNK SWEEP MAY DELETE (2026-11-01).
58
+ *
59
+ * A ceiling, not a target: on the real machine 24 directories / 1.78 GB had
60
+ * piled up, and a boot path that silently deletes "however much it finds" is a
61
+ * worse bug than the disk space. Over the ceiling the sweep stops and REPORTS.
62
+ */
63
+ export const DEFAULT_JUNK_LIMITS = Object.freeze({
64
+ dirs: 32,
65
+ bytes: 2 * 1024 * 1024 * 1024,
66
+ });
67
+
68
+ /**
69
+ * HOW OLD A DIRECTORY MUST BE BEFORE A SWEEP MAY JUDGE IT (2026-11-02).
70
+ *
71
+ * A copy that is still being written is not junk — it is unfinished work, and a
72
+ * sweep that deletes it mid-write makes a backup vanish without a word. Two
73
+ * minutes is comfortably longer than a copy takes, and short enough that the
74
+ * leftovers of a crashed restart are reclaimed on the next boot.
75
+ */
76
+ export const DEFAULT_HYGIENE_MIN_AGE_MS = 120_000;
77
+
78
+ /** The two kinds of reparse point this module knows. */
79
+ export const LINK_KINDS = Object.freeze({
80
+ SYMLINK: 'symbolic-link',
81
+ JUNCTION: 'junction',
82
+ });
83
+
84
+ /**
85
+ * The outcomes of the link decision. They are also what the restore script
86
+ * prints, one line per plugin - nothing is ever skipped silently.
87
+ */
88
+ export const LINK_ACTIONS = Object.freeze({
89
+ KEPT: 'kept',
90
+ REPLACED: 'replaced',
91
+ TARGET_MISSING: 'target-missing',
92
+ NO_BACKUP: 'no-backup',
93
+ });
94
+
95
+ /**
96
+ * THE RANGE AND THE DEFAULT THE SETTINGS PAGE OFFERS (user decision, 2026-11-02).
97
+ *
98
+ * ONE PLACE, ONE TRUTH: before this, four numbers described the same setting and
99
+ * disagreed - `DEFAULT_MAX_BACKUPS = 2`, a schema description that said "default
100
+ * 3", a schema bound of `.min(1)`, a validation floor of 2 and a dropdown of
101
+ * 2..10. The user met that as "I set 3 copies and it did not take effect". They
102
+ * all read from `BACKUP_LIMIT` now, and a test pins that changing it here moves
103
+ * the schema bound, the validation floor and the reported range together.
104
+ */
105
+ export const BACKUP_LIMIT = Object.freeze({ min: 2, default: 2, max: 10 });
106
+
107
+ /** The smallest limit a user may set (see `BACKUP_LIMIT`). */
108
+ export const BACKUP_LIMIT_MIN = BACKUP_LIMIT.min;
109
+ /** The largest limit a user may set. */
110
+ export const BACKUP_LIMIT_MAX = BACKUP_LIMIT.max;
111
+ /** What the setting is when nobody chose - also what the schema declares. */
112
+ export const DEFAULT_MAX_BACKUPS = BACKUP_LIMIT.default;
113
+
114
+ /** The block the settings page reads instead of hard-coding 2..10. */
115
+ export function backupLimitInfo() {
116
+ return { min: BACKUP_LIMIT_MIN, max: BACKUP_LIMIT_MAX, default: DEFAULT_MAX_BACKUPS };
117
+ }
118
+
119
+ /**
120
+ * WHY A RESTORE DID NOT HAPPEN - IN A WORD THAT DOES NOT JUDGE THE COPY
121
+ * (user decision, 2026-11-02).
122
+ *
123
+ * The old value was `no-known-good`: a refusal whose name asserts that the copies
124
+ * on disk were not good, which is a verdict the code is not in a position to give
125
+ * (the watchdog refuses because *nobody recorded a boot*, not because the content
126
+ * is bad). Neutral wording keeps the report honest: "there was no USABLE backup"
127
+ * says what the system could act on.
128
+ *
129
+ * THE OLD VALUE IS STILL READ, AND ONLY READ. Installed builds and old report
130
+ * files carry `no-known-good`; a reader that stopped understanding it would turn
131
+ * every historical report into "unknown error", which is worse than the wording it
132
+ * replaces. `normalizeRestoreRefusal` is that compatibility layer, and no code
133
+ * path writes the old string any more (pinned by a test).
134
+ */
135
+ export const RESTORE_REFUSAL = Object.freeze({
136
+ NO_USABLE_BACKUP: 'no-usable-backup',
137
+ });
138
+
139
+ /** The value older builds wrote. Read compatibility only - never produced. */
140
+ export const LEGACY_RESTORE_REFUSAL = 'no-known-good';
141
+
142
+ /**
143
+ * Map a refusal token (new or legacy) onto today's token.
144
+ *
145
+ * @param {unknown} value
146
+ * @returns {string} the current token, or the trimmed input when it is neither
147
+ */
148
+ export function normalizeRestoreRefusal(value) {
149
+ const text = typeof value === 'string' ? value.trim() : '';
150
+ if (text === LEGACY_RESTORE_REFUSAL) return RESTORE_REFUSAL.NO_USABLE_BACKUP;
151
+ return text;
152
+ }
153
+
154
+ /**
155
+ * The refusal token a report's `reason` starts with, normalized.
156
+ *
157
+ * Reports are written as `'<token>: <sentence>'`; an old file says
158
+ * `no-known-good: ...`, and this makes it read as the current token so nothing
159
+ * downstream has to know history.
160
+ *
161
+ * @param {unknown} reason
162
+ * @returns {string} '' when the text carries no token
163
+ */
164
+ export function restoreRefusalOf(reason) {
165
+ if (typeof reason !== 'string') return '';
166
+ const colon = reason.indexOf(':');
167
+ if (colon <= 0) return '';
168
+ return normalizeRestoreRefusal(reason.slice(0, colon));
169
+ }
170
+
171
+ /**
172
+ * IS THIS A LEGAL BACKUP LIMIT? `{ ok: true, value }` or `{ ok: false, reason }`.
173
+ *
174
+ * A reason, never a silent clamp: a UI that quietly turns 0 into 1 leaves the
175
+ * user believing a number that was never applied.
176
+ *
177
+ * @param {unknown} raw
178
+ * @returns {{ ok: true, value: number }|{ ok: false, reason: string }}
179
+ */
180
+ export function validateBackupLimit(raw) {
181
+ const value = typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : raw;
182
+ if (typeof value !== 'number' || Number.isFinite(value) !== true) {
183
+ return { ok: false, reason: 'the copy limit must be a number' };
184
+ }
185
+ if (Number.isInteger(value) !== true) {
186
+ return { ok: false, reason: 'the copy limit must be a whole number' };
187
+ }
188
+ if (value < BACKUP_LIMIT_MIN || value > BACKUP_LIMIT_MAX) {
189
+ // THE FLOOR EXPLAINS ITSELF (user decision, 2026-11-02): one copy is the
190
+ // scene from the restart that just happened, which may itself be the broken
191
+ // one, so one copy is no fallback at all.
192
+ const reason = value < BACKUP_LIMIT_MIN
193
+ ? 'the copy limit must be at least ' + BACKUP_LIMIT_MIN
194
+ + ': one copy is the state from the restart that just happened (which may itself be the broken one), so at least two are kept — that scene plus an earlier reference'
195
+ : 'the copy limit must be at most ' + BACKUP_LIMIT_MAX;
196
+ return { ok: false, reason };
197
+ }
198
+ return { ok: true, value };
199
+ }
200
+
201
+ /**
202
+ * WHAT SHRINKING TO `requested` COPIES WOULD ACTUALLY DO.
203
+ *
204
+ * Certified copies are never deleted, so the number the user picked is a
205
+ * REQUEST: the honest answer is "I asked for 2, 3 are kept because one of them
206
+ * is certified". `keptWillExceedRequest` is that sentence in data form — the
207
+ * page says it out loud instead of pretending the request won.
208
+ *
209
+ * @param {{ requested: number, items: Array<{ certified?: boolean }> }} input
210
+ * @returns {{ requested: number, current: number, deleteCount: number, keepCount: number, certifiedKept: number, keptWillExceedRequest: boolean }}
211
+ */
212
+ export function planBackupLimit(input) {
213
+ const source = input !== null && typeof input === 'object' ? input : {};
214
+ const requested = Math.max(BACKUP_LIMIT_MIN, Math.min(BACKUP_LIMIT_MAX, Math.trunc(Number(source.requested) || 0)));
215
+ const items = Array.isArray(source.items) ? source.items : [];
216
+ const current = items.length;
217
+ // A certified copy survives pruning, so the floor is how many of them exist.
218
+ const certifiedKept = items.filter((item) => item !== null && typeof item === 'object' && item.certified === true).length;
219
+ const floor = Math.max(requested, certifiedKept);
220
+ const keepCount = Math.min(current, Math.max(floor, 0));
221
+ return {
222
+ requested,
223
+ current,
224
+ deleteCount: Math.max(0, current - keepCount),
225
+ keepCount,
226
+ certifiedKept,
227
+ keptWillExceedRequest: keepCount > requested,
228
+ };
229
+ }
230
+
31
231
  /** The manifest written into every backup. */
32
232
  export const MANIFEST_NAME = 'manifest.json';
33
233
 
34
- /** How many backups are kept by default. */
35
- export const DEFAULT_MAX_BACKUPS = 2;
234
+ /**
235
+ * How many backups are kept by default.
236
+ *
237
+ * TWO (user decision, 2026-11-02: at most two copies — one verified "safe" copy
238
+ * plus the newest unverified one, which replaces the old verified copy once it
239
+ * passes its checks). It was TWENTY before, and that is exactly why a hosted
240
+ * instance that never configured the value kept seventeen copies: nothing was
241
+ * ever over the ceiling, so the prune never had a budget to spend.
242
+ *
243
+ * An explicit `maxBackups` in the profile still wins: this is a DEFAULT, not a
244
+ * cap on what the user may ask for.
245
+ *
246
+ * The newest copy is never pruned whatever this number says, and neither is a copy
247
+ * whose restart record says it came up with matching content — losing those would
248
+ * leave the machine with nothing to roll back to.
249
+ *
250
+ * THE VALUE ITSELF LIVES IN `BACKUP_LIMIT` (2026-11-02): it used to be 2 here
251
+ * while the schema description said "default 3", which is exactly the disagreement
252
+ * the user hit. `DEFAULT_MAX_BACKUPS` is now an alias of that one source.
253
+ */
254
+
255
+ /**
256
+ * THE IDENTITY OF A BACKUP (user decision, 2026-10-30).
257
+ *
258
+ * A pre-restart snapshot is taken BEFORE the restart: it holds exactly the state
259
+ * that is about to be tested, i.e. possibly the state that breaks the boot. So a
260
+ * restore may only ever use a backup that HAS BOOTED: the known-good one.
261
+ *
262
+ * Two identities:
263
+ * 'snapshot' taken before a restart, not proven yet;
264
+ * 'known-good' a restart with this content really came up — or the seed taken
265
+ * on first install (nothing to prove yet, nothing to lose either).
266
+ *
267
+ * A directory without a readable meta.json is 'unknown': it was written before
268
+ * this rule existed and is NEVER used for an automatic rollback.
269
+ */
270
+ export const META_NAME = 'meta.json';
271
+ export const KIND_SNAPSHOT = 'snapshot';
272
+ export const KIND_KNOWN_GOOD = 'known-good';
273
+ export const KIND_UNKNOWN = 'unknown';
274
+
275
+ /**
276
+ * Coerce anything into one of the three identities.
277
+ *
278
+ * @param {unknown} raw
279
+ * @returns {string}
280
+ */
281
+ function normalizeKind(raw) {
282
+ if (raw === KIND_KNOWN_GOOD) return KIND_KNOWN_GOOD;
283
+ if (raw === KIND_SNAPSHOT) return KIND_SNAPSHOT;
284
+ return KIND_UNKNOWN;
285
+ }
286
+
287
+ /**
288
+ * WHAT PUT THIS COPY ON DISK — the fact that replaced the old identity label
289
+ * (user decision, 2026-11-02: "记录里也一起清掉").
290
+ *
291
+ * WHY THE LABEL IS GONE FROM THE FILE: `kind: 'known-good'` and
292
+ * `certifiedBy: 'full-checks'` were written in a register that read like a
293
+ * verdict ("this copy is the good one", "this copy is certified"). A user who
294
+ * opened `meta.json` — or read a report built from it — was told the plugin had
295
+ * vouched for the copy, while what had really been observed was one boot:
296
+ * "after that restart the host came up, and it was running this content".
297
+ * Those are different claims, and only the second one is provable.
298
+ *
299
+ * SO THE FILE NOW ANSWERS THREE QUESTIONS, ALL OF THEM FACTS:
300
+ * - when was this copy taken (`takenAt` / `at`),
301
+ * - what put it there (`trigger`),
302
+ * - what happened on the boot that followed, if one did (`boot.started`,
303
+ * `boot.contentMatched`, `boot.loadFailures`, `boot.intervention`).
304
+ *
305
+ * The old vocabulary is still DERIVED on read (see `verificationOf`), because
306
+ * the selector and the reports above this layer were written against it, and
307
+ * because every `meta.json` already on a machine speaks it. Derived, never
308
+ * written: a value that only exists in this process cannot be mistaken for a
309
+ * record by the next reader of the file.
310
+ */
311
+ export const BACKUP_TRIGGERS = Object.freeze({
312
+ /** The copy a fresh installation starts with (`reason: 'first-install'`). */
313
+ FIRST_INSTALL: 'first-install',
314
+ /** The snapshot taken before a restart, i.e. the state that is about to be tested. */
315
+ PRE_RESTART: 'pre-restart',
316
+ /** The copy taken before a manual rollback, so that rollback can be undone. */
317
+ MANUAL_ROLLBACK: 'manual-rollback',
318
+ });
319
+
320
+ const TRIGGER_SET = new Set(Object.values(BACKUP_TRIGGERS));
321
+
322
+ /**
323
+ * HOW THE BOOT THAT FOLLOWED WAS OBSERVED — again a fact, not a grade.
324
+ *
325
+ * "startup-checks" means the four startup checks ran on that boot; "health-probe"
326
+ * means a command-mode restart got an answer from the health route and nothing
327
+ * else was looked at; "manual-rollback" means a human rolled back and confirmed.
328
+ * None of the three says the plugins in the copy are good, and the record does
329
+ * not pretend otherwise.
330
+ */
331
+ export const BOOT_OBSERVATIONS = Object.freeze({
332
+ STARTUP_CHECKS: 'startup-checks',
333
+ HEALTH_PROBE: 'health-probe',
334
+ MANUAL_ROLLBACK: 'manual-rollback',
335
+ });
336
+
337
+ const BOOT_OBSERVATION_SET = new Set(Object.values(BOOT_OBSERVATIONS));
338
+
339
+ /** Coerce anything into one of the three triggers. */
340
+ function normalizeTrigger(raw) {
341
+ return typeof raw === 'string' && TRIGGER_SET.has(raw) ? raw : null;
342
+ }
343
+
344
+ /**
345
+ * WHAT PUT THIS COPY ON DISK, from whatever the caller could say about it.
346
+ *
347
+ * An explicit `trigger` wins. Otherwise it is read off the old-shaped inputs the
348
+ * call sites still pass: `kind: 'known-good'` is the first-install seed (that is
349
+ * the only copy born with that kind), `reason: 'before-manual-rollback'` is the
350
+ * undo copy of a manual rollback, and everything else is a pre-restart snapshot.
351
+ *
352
+ * @param {{ trigger?: unknown, kind?: unknown, reason?: unknown }} [input]
353
+ * @returns {string} one of BACKUP_TRIGGERS
354
+ */
355
+ function triggerOf({ trigger = null, kind = null, reason = null } = {}) {
356
+ const explicit = normalizeTrigger(trigger);
357
+ if (explicit !== null) return explicit;
358
+ if (normalizeKind(kind) === KIND_KNOWN_GOOD) return BACKUP_TRIGGERS.FIRST_INSTALL;
359
+ if (reason === 'first-install') return BACKUP_TRIGGERS.FIRST_INSTALL;
360
+ if (reason === 'before-manual-rollback') return BACKUP_TRIGGERS.MANUAL_ROLLBACK;
361
+ return BACKUP_TRIGGERS.PRE_RESTART;
362
+ }
363
+
364
+ /** Was this copy written by a build that records facts (and not only a verdict)? */
365
+ function isFactRecord(record) {
366
+ if (record === null || typeof record !== 'object') return false;
367
+ if (normalizeTrigger(record.trigger) !== null) return true;
368
+ return record.boot !== null && record.boot !== undefined && typeof record.boot === 'object';
369
+ }
370
+
371
+ /**
372
+ * The plugin load failures a boot reported, as plain facts.
373
+ *
374
+ * `null` means NOT RECORDED — which is not the same answer as `[]` ("that boot
375
+ * reported no load failure"), and must never be rounded down to it.
376
+ */
377
+ function normalizeLoadFailures(raw) {
378
+ if (raw === null || raw === undefined) return null;
379
+ if (!Array.isArray(raw)) return null;
380
+ return raw.map((entry) => {
381
+ if (entry === null || typeof entry !== 'object') {
382
+ return { plugin: null, version: null, error: String(entry ?? '') };
383
+ }
384
+ return {
385
+ plugin: typeof entry.plugin === 'string' && entry.plugin !== '' ? entry.plugin : null,
386
+ version: typeof entry.version === 'string' && entry.version !== '' ? entry.version : null,
387
+ error: typeof entry.error === 'string' && entry.error !== '' ? entry.error : null,
388
+ };
389
+ });
390
+ }
391
+
392
+ /**
393
+ * THE BOOT THAT FOLLOWED THIS COPY — the fact block, or `null` when no boot has
394
+ * been observed for it yet.
395
+ *
396
+ * `started: null` is "nobody recorded whether the host came up"; `contentMatched`
397
+ * is the answer to "was that boot running exactly the content this copy holds",
398
+ * and `null` again means "not recorded". Both are kept three-valued on purpose:
399
+ * the selector refuses an unknown answer, and a `false` invented for convenience
400
+ * would be the same mistake as the stamp this replaces.
401
+ *
402
+ * @param {unknown} raw
403
+ * @returns {{ started: boolean|null, at: string|null, observedBy: string|null, ranContent: string|null, contentMatched: boolean|null, loadFailures: Array<object>|null }|null}
404
+ */
405
+ function bootFactsOf(raw) {
406
+ if (raw === null || raw === undefined || typeof raw !== 'object') return null;
407
+ const observedBy = typeof raw.observedBy === 'string' && BOOT_OBSERVATION_SET.has(raw.observedBy)
408
+ ? raw.observedBy
409
+ : null;
410
+ const ranContent = typeof raw.ranContent === 'string' && raw.ranContent.trim() !== ''
411
+ ? raw.ranContent
412
+ : null;
413
+ return {
414
+ started: typeof raw.started === 'boolean' ? raw.started : null,
415
+ at: normalizeIsoTime(raw.at),
416
+ observedBy,
417
+ ranContent,
418
+ contentMatched: typeof raw.contentMatched === 'boolean' ? raw.contentMatched : null,
419
+ loadFailures: normalizeLoadFailures(raw.loadFailures),
420
+ };
421
+ }
422
+
423
+ /**
424
+ * THE OLD VOCABULARY, DERIVED — never written to a file.
425
+ *
426
+ * WHY IT STILL EXISTS AT ALL: the restore selector, the watchdog report and the
427
+ * settings payload were written against `certifiedBy` / `certificationEvidence`,
428
+ * and every `meta.json` already on a machine carries them. Translating the facts
429
+ * into that shape ON READ is what lets the file be honest without a second
430
+ * selector — the algorithm does not change, only where its input comes from.
431
+ *
432
+ * THE MAPPING IS ONE-TO-ONE, SO NOTHING IS LOOSENED:
433
+ * - `trigger: 'first-install'` -> `seed` (the seed's own positioning: it was
434
+ * never proven, and it is not recorded as if it had been);
435
+ * - a boot observed by the startup checks / the health probe / a manual
436
+ * rollback -> `full-checks` / `command-probe` / `manual-rollback`;
437
+ * - `'fingerprint-match'` only when that boot actually started AND was running
438
+ * the content this copy holds — i.e. exactly the old criterion;
439
+ * - anything else -> `'none'`, which is what an unrecorded answer has always
440
+ * meant here.
441
+ *
442
+ * `kind` is derived too: a copy with a boot on record is `known-good` (the old
443
+ * writer's meaning of that kind), the first-install seed is `known-good`, and a
444
+ * copy nobody has booted yet is `snapshot`.
445
+ *
446
+ * @param {object|null|undefined} record a parsed `meta.json`, new or old
447
+ * @returns {{ certifiedBy: string, certificationEvidence: string|null, certifiedAt: string|null, kind: string }}
448
+ */
449
+ function verificationOf(record) {
450
+ const source = record !== null && typeof record === 'object' ? record : {};
451
+ if (!isFactRecord(source)) {
452
+ // AN OLD meta.json: read it as what it says, and read it as "no proof" when it
453
+ // names a source but no criterion — the shape of the historical pollution.
454
+ return {
455
+ certifiedBy: normalizeCertifiedBy(source.certifiedBy),
456
+ certificationEvidence: normalizeCertificationEvidence(source.certificationEvidence),
457
+ certifiedAt: normalizeIsoTime(source.certifiedAt),
458
+ kind: normalizeKind(source.kind),
459
+ };
460
+ }
461
+ const trigger = normalizeTrigger(source.trigger) ?? BACKUP_TRIGGERS.PRE_RESTART;
462
+ const boot = bootFactsOf(source.boot);
463
+ let certifiedBy = CERTIFIED_BY.NONE;
464
+ if (boot !== null && boot.observedBy === BOOT_OBSERVATIONS.STARTUP_CHECKS) certifiedBy = CERTIFIED_BY.FULL_CHECKS;
465
+ else if (boot !== null && boot.observedBy === BOOT_OBSERVATIONS.HEALTH_PROBE) certifiedBy = CERTIFIED_BY.COMMAND_PROBE;
466
+ else if (boot !== null && boot.observedBy === BOOT_OBSERVATIONS.MANUAL_ROLLBACK) certifiedBy = CERTIFIED_BY.MANUAL_ROLLBACK;
467
+ else if (trigger === BACKUP_TRIGGERS.FIRST_INSTALL) certifiedBy = CERTIFIED_BY.SEED;
468
+ const proven = boot !== null && boot.started === true && boot.contentMatched === true;
469
+ return {
470
+ certifiedBy,
471
+ certificationEvidence: proven ? CERTIFICATION_EVIDENCE.FINGERPRINT_MATCH : CERTIFICATION_EVIDENCE.NONE,
472
+ certifiedAt: proven ? boot.at : null,
473
+ kind: boot !== null || trigger === BACKUP_TRIGGERS.FIRST_INSTALL ? KIND_KNOWN_GOOD : KIND_SNAPSHOT,
474
+ };
475
+ }
476
+
477
+ /**
478
+ * WHY THIS COPY COUNTS AS SAFE — the evidence, stored inside the copy itself.
479
+ *
480
+ * WHY INSIDE THE COPY AND NOT IN A SEPARATE REGISTER: a known-good state is only
481
+ * worth something if it survives together with the thing it certifies. A register
482
+ * kept elsewhere can be lost, half-written, or keep pointing at a copy that has
483
+ * since been pruned. So the evidence travels in `meta.json`, right next to the
484
+ * identity it justifies.
485
+ *
486
+ * THE ROOT CAUSE THIS REPLACES (2026-11-01): `markKnownGood` stamped the
487
+ * PRE-RESTART snapshot unconditionally, with the fixed reason 'boot-ok'. When
488
+ * that boot only came up because a human intervened — or because the watchdog had
489
+ * restored a backup — the state BEFORE the restart was already broken, and it got
490
+ * certified as the safe one anyway. The watchdog then "restored" onto that broken
491
+ * state and the report said everything was fine.
492
+ *
493
+ * Fedora greenboot / RAUC / Android A/B / Kubernetes all agree on the opposite
494
+ * rule: what gets certified is the copy that is RUNNING NOW and was EXPLICITLY
495
+ * CHECKED — never "whatever was lying around before the restart". Windows' Last
496
+ * Known Good is the cautionary tale: after one successful logon the bad
497
+ * configuration had become the LKG, and Microsoft ended up removing the feature.
498
+ *
499
+ * Five sources, so the report can answer "why is this copy considered safe?":
500
+ * 'full-checks' all four startup checks passed on this exact content;
501
+ * 'command-probe' the host answered its health route in command mode;
502
+ * 'seed' the first-install copy (nothing to prove yet, nothing to
503
+ * lose either);
504
+ * 'manual-rollback' a human rolled back and confirmed the result;
505
+ * 'none' NOT certified. Every meta.json written before this layer
506
+ * reads as 'none' — "unknown" is never rounded up to "good".
507
+ */
508
+ export const CERTIFIED_BY = Object.freeze({
509
+ FULL_CHECKS: 'full-checks',
510
+ COMMAND_PROBE: 'command-probe',
511
+ SEED: 'seed',
512
+ MANUAL_ROLLBACK: 'manual-rollback',
513
+ NONE: 'none',
514
+ });
515
+
516
+ /**
517
+ * The four sources that really prove something. `'none'` is deliberately NOT in
518
+ * here: a copy nobody checked is a candidate, not a fallback.
519
+ */
520
+ const CERTIFYING_SOURCES = new Set([
521
+ CERTIFIED_BY.FULL_CHECKS,
522
+ CERTIFIED_BY.COMMAND_PROBE,
523
+ CERTIFIED_BY.SEED,
524
+ CERTIFIED_BY.MANUAL_ROLLBACK,
525
+ ]);
526
+
527
+ /**
528
+ * WHICH CRITERION PROVED IT — the second half of the evidence (2026-11-01).
529
+ *
530
+ * THE DEFECT THIS CLOSES, with the numbers from the hosted instance: after the
531
+ * rule became "certify only when the configuration fingerprints match", two
532
+ * fresh snapshots were correctly certified (`cfg=691B`, `certifiedBy=full-checks`,
533
+ * fingerprint recorded) — but the two BROKEN configurations stamped by the OLD
534
+ * code before that fix (`cfg=71B` and `cfg=70B`) still read
535
+ * `certifiedBy=full-checks` in their `meta.json`. The watchdog picks the restore
536
+ * target by `certifiedBy`, so it could not tell those two apart from the good
537
+ * copy and would have restored a 71-byte broken configuration as "the safe one".
538
+ *
539
+ * ROOT CAUSE, in one sentence: `certifiedBy` says WHO signed, never WITH WHICH
540
+ * CRITERION, and the old criterion ("the host came up") and the new one ("this
541
+ * boot ran exactly the content the copy holds") both end up as `full-checks`.
542
+ *
543
+ * THE RULE THAT FOLLOWS: a stamp nobody can verify is not a stamp — and it is
544
+ * WORSE than no stamp, because it gets trusted. So the criterion is recorded next
545
+ * to the source, and a copy only counts as safe when BOTH are present:
546
+ *
547
+ * 'fingerprint-match' the copy's configuration fingerprint was compared with
548
+ * the fingerprint of the configuration that was really
549
+ * running, and the two were equal;
550
+ * null no criterion on record: a stamp written before this
551
+ * field existed (the historical pollution), or a copy
552
+ * nobody ever certified.
553
+ *
554
+ * ONLY TWO VALUES, ON PURPOSE. The first-install seed is NOT a third kind here:
555
+ * the seed is a copy taken before anything was proven, it is not a boot that was
556
+ * checked, and folding it into 'fingerprint-match' would re-create exactly the
557
+ * unverifiable stamp this field exists to remove. The seed keeps its own
558
+ * positioning (see `isRestoreEligible`), which is a different question from
559
+ * "has this copy been proven".
560
+ */
561
+ export const CERTIFICATION_EVIDENCE = Object.freeze({
562
+ FINGERPRINT_MATCH: 'fingerprint-match',
563
+ NONE: null,
564
+ });
565
+
566
+ /** The one evidence value that proves a copy, in a Set for cheap membership. */
567
+ const PROVING_EVIDENCE = new Set([CERTIFICATION_EVIDENCE.FINGERPRINT_MATCH]);
568
+
569
+ /**
570
+ * Is this `certificationEvidence` value a proof?
571
+ *
572
+ * Reading an old `meta.json` must never throw and must never be promoted: a
573
+ * missing field, an empty string, `'boot-ok'`, `'trust-me'` or anything else
574
+ * unrecognised reads as `null` — "no criterion on record", which is NOT a proof.
575
+ *
576
+ * @param {unknown} raw
577
+ * @returns {boolean}
578
+ */
579
+ export function isProvingEvidence(raw) {
580
+ return typeof raw === 'string' && PROVING_EVIDENCE.has(raw);
581
+ }
582
+
583
+ /** Coerce anything into `'fingerprint-match'` or `null`. */
584
+ function normalizeCertificationEvidence(raw) {
585
+ return isProvingEvidence(raw) ? raw : CERTIFICATION_EVIDENCE.NONE;
586
+ }
587
+
588
+ /**
589
+ * MAY THIS COPY BE TREATED AS A SAFE ONE — the one definition, in one place.
590
+ *
591
+ * THE OLD DEFINITION IS DELIBERATELY GONE: `isCertifyingSource(certifiedBy)`
592
+ * alone. It answers "does the field name a source the code knows?", which is
593
+ * true of every copy the old code stamped with its criterion-free seal — the
594
+ * 71B and 70B broken configurations included. Reading that as "safe" is what
595
+ * would put a broken configuration back over a working profile.
596
+ *
597
+ * THE NEW DEFINITION NEEDS BOTH HALVES:
598
+ * - `certifiedBy` names a real source (not `'none'`, not missing), AND
599
+ * - `certificationEvidence === 'fingerprint-match'` (the criterion that can be
600
+ * re-checked was really applied).
601
+ * A copy with a source but no criterion is NOT certified — it is historical
602
+ * pollution, and the answer for it is "not trusted", never "trusted with a
603
+ * warning": a warning is only read by whoever happens to look, while the
604
+ * selector acts on the flag.
605
+ *
606
+ * WHY THIS IS THE SAFE DIRECTION: a false negative costs a fallback copy (the
607
+ * fingerprint is recomputed and compared on the next boot, which is the upgrade
608
+ * channel that brings good old copies back — see `snapshotConfigFingerprint`).
609
+ * A false positive costs the profile, which is the whole point of a backup.
610
+ *
611
+ * @param {{ certifiedBy?: unknown, certificationEvidence?: unknown }|null|undefined} meta
612
+ * @returns {boolean}
613
+ */
614
+ export function isCertifiedBackup(meta) {
615
+ const verification = verificationOf(meta);
616
+ if (verification.certifiedBy === CERTIFIED_BY.NONE) return false;
617
+ return verification.certificationEvidence === CERTIFICATION_EVIDENCE.FINGERPRINT_MATCH;
618
+ }
619
+
620
+ /**
621
+ * MAY THIS COPY STILL BE USED AS THE AUTOMATIC FALLBACK — a wider question than
622
+ * "is it certified".
623
+ *
624
+ * WHY THE SEED IS HERE AND NOT IN `isCertifiedBackup`: a fresh installation has
625
+ * nothing proven yet and nothing to lose either, so the first-install copy is a
626
+ * legitimate thing to roll back to — that is its own positioning, it is not a
627
+ * proof, and it must not be recorded as one. That is the entire reason this
628
+ * second predicate exists instead of folding the seed into `'fingerprint-match'`.
629
+ *
630
+ * EVERYTHING ELSE MUST BE CERTIFIED. A copy stamped by the old criterion has a
631
+ * source but no criterion (§ `CERTIFICATION_EVIDENCE`), so it fails here too: it
632
+ * is not a fallback, exactly like a copy nobody ever certified.
633
+ *
634
+ * @param {{ certifiedBy?: unknown, certificationEvidence?: unknown }|null|undefined} meta
635
+ * @returns {boolean}
636
+ */
637
+ export function isRestoreEligible(meta) {
638
+ const verification = verificationOf(meta);
639
+ if (verification.certifiedBy === CERTIFIED_BY.SEED) return true;
640
+ return isCertifiedBackup(meta);
641
+ }
642
+
643
+ /**
644
+ * Does this `certifiedBy` value prove a boot?
645
+ *
646
+ * Anything that is not one of the four sources — including a missing field, an
647
+ * empty string and the literal `'none'` — is NOT certified. Reading an old meta
648
+ * must never throw and must never be promoted: "we cannot tell" is an answer,
649
+ * and it is the safe one.
650
+ *
651
+ * @param {unknown} raw
652
+ * @returns {boolean}
653
+ */
654
+ export function isCertifyingSource(raw) {
655
+ return typeof raw === 'string' && CERTIFYING_SOURCES.has(raw);
656
+ }
657
+
658
+ /** Coerce anything into one of the five certification sources. */
659
+ function normalizeCertifiedBy(raw) {
660
+ return isCertifyingSource(raw) ? raw : CERTIFIED_BY.NONE;
661
+ }
662
+
663
+ /**
664
+ * WHAT A COPY HOLDS — the scope the certification is about.
665
+ *
666
+ * WHY IT MATTERS: "this copy booted" is only meaningful together with "what was
667
+ * in it". A configuration-only copy that booted says nothing about the plugin
668
+ * tree; a slim copy says nothing about plugins that were never copied. The
669
+ * report has to be able to say which of those it is instead of implying that
670
+ * every copy holds everything.
671
+ */
672
+ export const CONTENT_SCOPES = Object.freeze({
673
+ CONFIG_ONLY: 'config-only',
674
+ CONFIG_PLUGINS: 'config+plugins',
675
+ CONFIG_LINKS: 'config+links',
676
+ SLIM: 'slim',
677
+ });
678
+
679
+ const CONTENT_SCOPE_SET = new Set(Object.values(CONTENT_SCOPES));
680
+
681
+ /** An unknown scope string is `null`, never the field's neighbour by luck. */
682
+ function normalizeContentScope(raw) {
683
+ return typeof raw === 'string' && CONTENT_SCOPE_SET.has(raw) ? raw : null;
684
+ }
685
+
686
+ /** A fingerprint is a non-empty string; everything else is "not recorded". */
687
+ function normalizeFingerprint(raw) {
688
+ return typeof raw === 'string' && raw.trim() !== '' ? raw : null;
689
+ }
690
+
691
+ /**
692
+ * Normalize a moment into an ISO string, or `null` when there is no moment.
693
+ *
694
+ * ISO ON PURPOSE: `at` is epoch milliseconds (a number, cheap to compare) while
695
+ * this one is read by humans in a report, so it is stored the way it is shown.
696
+ */
697
+ function normalizeIsoTime(raw, fallbackMs = null) {
698
+ if (typeof raw === 'string' && raw.trim() !== '') {
699
+ const parsed = Date.parse(raw);
700
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
701
+ }
702
+ if (Number.isFinite(raw)) return new Date(Number(raw)).toISOString();
703
+ if (Number.isFinite(fallbackMs)) return new Date(Number(fallbackMs)).toISOString();
704
+ return null;
705
+ }
706
+
707
+ /**
708
+ * Read the identity of one backup directory.
709
+ *
710
+ * A missing or unreadable meta.json is NOT an error: it means the backup was
711
+ * written before the identity layer existed, and the answer is `'unknown'`.
712
+ *
713
+ * TWO SHAPES, ONE ANSWER (2026-11-02). A `meta.json` on any machine today is one
714
+ * of these:
715
+ * - facts (`trigger` / `boot`) — written by this build;
716
+ * - verdict (`kind` / `reason` / `certifiedBy` / `certificationEvidence`) —
717
+ * written by older builds.
718
+ * Both are read here, and the old vocabulary is DERIVED from the facts for the
719
+ * readers above this layer (see `verificationOf`). Nothing throws, and a record
720
+ * that cannot answer a question answers `null` — "not recorded" — never a guess:
721
+ * an unknown judgement is kept as an unknown judgement and only ever read as a
722
+ * fact.
723
+ *
724
+ * @param {string} backupDir
725
+ * @returns {{ kind: string, at: number|null, takenAt: string|null, trigger: string|null, version: string|null, bootId: string|null, requestId: number|null, reason: string|null, upgradedAt: number|null, boot: object|null, certifiedBy: string, certificationEvidence: string|null, certifiedAt: string|null, contentFingerprint: string|null, contentScope: string|null, intervention: boolean|null }}
726
+ */
727
+ /**
728
+ * THE BOOT AN OLD RECORD REALLY DESCRIBES (2026-11-02).
729
+ *
730
+ * A meta written before this layer existed has no `boot` object: it has
731
+ * `certifiedBy` / `certificationEvidence` / `certifiedAt`. Those three already said
732
+ * two facts — "a boot was observed" and "(or not) that boot was running this
733
+ * copy's content" — so an old record is READ as the observation it recorded. That
734
+ * is the legacy translation path, and it is why changing the selector to facts
735
+ * does not make every existing install unable to restore anything.
736
+ *
737
+ * A seed is NOT translated into a boot: it was taken before anything ran, so
738
+ * saying "the instance came up" about it would be inventing an observation.
739
+ *
740
+ * @param {object|null} parsed a meta.json as it is on disk
741
+ * @returns {object|null}
742
+ */
743
+ export function legacyBootFactsOf(parsed) {
744
+ if (!parsed || typeof parsed !== 'object') return null;
745
+ if (!isCertifyingSource(parsed.certifiedBy)) return null;
746
+ const source = normalizeCertifiedBy(parsed.certifiedBy);
747
+ if (source === CERTIFIED_BY.SEED) return null;
748
+ return {
749
+ started: true,
750
+ at: normalizeIsoTime(parsed.certifiedAt, Number.isFinite(parsed.at) ? parsed.at : null),
751
+ observedBy: source === CERTIFIED_BY.FULL_CHECKS
752
+ ? BOOT_OBSERVATIONS.STARTUP_CHECKS
753
+ : (source === CERTIFIED_BY.COMMAND_PROBE
754
+ ? BOOT_OBSERVATIONS.HEALTH_PROBE
755
+ : BOOT_OBSERVATIONS.MANUAL_ROLLBACK),
756
+ ranContent: normalizeFingerprint(parsed.contentFingerprint),
757
+ contentMatched: isProvingEvidence(parsed.certificationEvidence),
758
+ loadFailures: null,
759
+ };
760
+ }
761
+
762
+ export function readBackupMeta(backupDir) {
763
+ const empty = {
764
+ kind: KIND_UNKNOWN,
765
+ at: null,
766
+ takenAt: null,
767
+ trigger: null,
768
+ version: null,
769
+ bootId: null,
770
+ requestId: null,
771
+ reason: null,
772
+ upgradedAt: null,
773
+ // NO BOOT IS ON RECORD for a copy whose meta cannot be read: `null`, not an
774
+ // empty boot object (which would say "a boot was observed and it told us
775
+ // nothing").
776
+ boot: null,
777
+ // AN OLD meta.json HAS NONE OF THESE. It must read as "not certified" — not
778
+ // as an error, and above all not as safe: those copies were written before
779
+ // there was any evidence to store.
780
+ certifiedBy: CERTIFIED_BY.NONE,
781
+ // AND AN OLD meta.json HAS NO CRITERION EITHER — including the two broken
782
+ // configurations the previous code stamped `certifiedBy=full-checks` on the
783
+ // hosted instance. `null` is what turns those stamps into "not trusted"
784
+ // instead of "trusted without a check" (see `isCertifiedBackup`).
785
+ certificationEvidence: CERTIFICATION_EVIDENCE.NONE,
786
+ certifiedAt: null,
787
+ contentFingerprint: null,
788
+ contentScope: null,
789
+ intervention: null,
790
+ };
791
+ let parsed = null;
792
+ try {
793
+ parsed = JSON.parse(fs.readFileSync(path.join(String(backupDir ?? ''), META_NAME), 'utf8'));
794
+ } catch {
795
+ return empty;
796
+ }
797
+ if (!parsed || typeof parsed !== 'object') return empty;
798
+ // THE OLD VOCABULARY IS DERIVED HERE, ONCE, for both shapes.
799
+ const verification = verificationOf(parsed);
800
+ const boots = isFactRecord(parsed);
801
+ return {
802
+ kind: verification.kind,
803
+ at: Number.isFinite(parsed.at) ? parsed.at : null,
804
+ // A NEW RECORD SAYS WHEN IT WAS TAKEN IN ISO FORM. An old one did not, so the
805
+ // answer falls back to the epoch value it does carry — the same moment, said
806
+ // the way the newer field says it.
807
+ takenAt: normalizeIsoTime(parsed.takenAt, Number.isFinite(parsed.at) ? parsed.at : null),
808
+ // AN OLD SEED KEEPS WHAT IT IS: `certifiedBy: 'seed'` recorded "taken at
809
+ // install time, before anything ran", which is exactly the `first-install`
810
+ // trigger. Without this the prune could not tell the seed from any other old
811
+ // copy when deciding what to spare.
812
+ trigger: !boots && normalizeCertifiedBy(parsed.certifiedBy) === CERTIFIED_BY.SEED
813
+ ? BACKUP_TRIGGERS.FIRST_INSTALL
814
+ : (boots ? normalizeTrigger(parsed.trigger) ?? BACKUP_TRIGGERS.PRE_RESTART : null),
815
+ version: typeof parsed.version === 'string' && parsed.version !== '' ? parsed.version : null,
816
+ bootId: typeof parsed.bootId === 'string' && parsed.bootId !== '' ? parsed.bootId : null,
817
+ requestId: Number.isFinite(parsed.requestId) ? parsed.requestId : null,
818
+ // `reason` IS ONLY READ, NEVER WRITTEN (2026-11-02): in a new record the
819
+ // trigger and the boot facts say what it used to say, and saying it twice
820
+ // would give the old wording a second life on disk.
821
+ reason: typeof parsed.reason === 'string' && parsed.reason !== '' ? parsed.reason : null,
822
+ upgradedAt: Number.isFinite(parsed.upgradedAt) ? parsed.upgradedAt : null,
823
+ // AN OLD RECORD'S `certifiedBy` IS READ AS THE BOOT IT DESCRIBED (see
824
+ // `legacyBootFactsOf`): the selector and the prune read facts, and an old
825
+ // record still has to answer "did this copy's content come up".
826
+ boot: boots ? bootFactsOf(parsed.boot) : legacyBootFactsOf(parsed),
827
+ certifiedBy: verification.certifiedBy,
828
+ certificationEvidence: verification.certificationEvidence,
829
+ certifiedAt: verification.certifiedAt,
830
+ contentFingerprint: normalizeFingerprint(parsed.contentFingerprint),
831
+ contentScope: normalizeContentScope(parsed.contentScope),
832
+ intervention: typeof parsed.intervention === 'boolean' ? parsed.intervention : null,
833
+ };
834
+ }
835
+
836
+ /**
837
+ * Write the identity of one backup directory — AS FACTS.
838
+ *
839
+ * WHAT IS NO LONGER WRITTEN, AND WHY (user decision, 2026-11-02: "记录里也一起清
840
+ * 掉"): `kind: 'known-good'`, `reason: 'boot-ok'`, `certifiedBy: 'full-checks'`,
841
+ * `certificationEvidence`, `certifiedAt`. Every one of them read like a judgement
842
+ * about the plugins in the copy ("this is the good one", "this is certified"),
843
+ * while the only thing that had really been observed was one boot. A record that
844
+ * cannot be distinguished from a promise is the wrong thing to leave on a user's
845
+ * disk.
846
+ *
847
+ * WHAT IS WRITTEN INSTEAD: when it was taken, what triggered it, what it holds,
848
+ * and what the boot that followed reported (`null` when there was none). See
849
+ * `BACKUP_TRIGGERS` / `BOOT_OBSERVATIONS`.
850
+ *
851
+ * LEGACY INPUTS ARE STILL ACCEPTED AND TRANSLATED, not stored: the call sites and
852
+ * the fixtures that still pass `kind` / `certifiedBy` are answering a question in
853
+ * the old vocabulary, so the answer is converted into facts here instead of
854
+ * being copied to disk. That is also what keeps old tests and old callers working
855
+ * without letting the old wording survive in a file.
856
+ *
857
+ * Best effort: a backup whose meta cannot be written stays a usable `'unknown'`
858
+ * one (it is never used for an AUTOMATIC rollback, but it is still on disk).
859
+ *
860
+ * @param {string} backupDir
861
+ * @param {object} [meta] facts, or the legacy inputs they are translated from
862
+ * @returns {boolean} whether it was written
863
+ */
864
+ export function writeBackupMeta(backupDir, meta = {}) {
865
+ const at = Number.isFinite(meta.at) ? meta.at : Date.now();
866
+ let boot = bootFactsOf(meta.boot);
867
+ // WHAT PUT THIS COPY THERE. An explicitly named trigger wins; an explicitly
868
+ // "unknown" kind (or a word this build does not know) records NO trigger at all,
869
+ // because the honest answer to "what is this copy?" is then "not recorded" — and
870
+ // a record without a trigger reads back as `unknown`, exactly like a copy written
871
+ // before this layer existed.
872
+ const explicitTrigger = normalizeTrigger(meta.trigger);
873
+ let triggerValue;
874
+ if (explicitTrigger !== null) triggerValue = explicitTrigger;
875
+ else if (normalizeKind(meta.kind) === KIND_UNKNOWN) triggerValue = null;
876
+ else triggerValue = triggerOf({ kind: meta.kind, reason: meta.reason });
877
+ // A LEGACY WRITE IS TRANSLATED HERE, NOT STORED (2026-11-02). `certifiedBy` named
878
+ // who signed and `certificationEvidence` named the criterion; together they
879
+ // already said two facts — "a boot was observed" and "(or not) it was running
880
+ // this content" — so that is what gets written, and the sentence on disk is the
881
+ // one that can be checked.
882
+ if (boot === null && isCertifyingSource(meta.certifiedBy)) {
883
+ const source = normalizeCertifiedBy(meta.certifiedBy);
884
+ if (source === CERTIFIED_BY.SEED) {
885
+ // The seed is not a boot: it was taken before anything ran. It keeps its own
886
+ // positioning, which is exactly what the trigger says.
887
+ triggerValue = BACKUP_TRIGGERS.FIRST_INSTALL;
888
+ } else {
889
+ boot = {
890
+ // Every one of these inputs came from a promotion, i.e. from a boot that
891
+ // had already come up — the one thing `reason: 'boot-ok'` used to say.
892
+ started: true,
893
+ at: normalizeIsoTime(meta.certifiedAt, at),
894
+ observedBy: source === CERTIFIED_BY.FULL_CHECKS
895
+ ? BOOT_OBSERVATIONS.STARTUP_CHECKS
896
+ : (source === CERTIFIED_BY.COMMAND_PROBE
897
+ ? BOOT_OBSERVATIONS.HEALTH_PROBE
898
+ : BOOT_OBSERVATIONS.MANUAL_ROLLBACK),
899
+ ranContent: normalizeFingerprint(meta.contentFingerprint),
900
+ contentMatched: isProvingEvidence(meta.certificationEvidence),
901
+ // Nobody recorded the plugin load failures through this shape of call.
902
+ loadFailures: null,
903
+ };
904
+ }
905
+ }
906
+ const record = {
907
+ at,
908
+ takenAt: normalizeIsoTime(meta.takenAt, at),
909
+ trigger: triggerValue,
910
+ version: typeof meta.version === 'string' && meta.version !== '' ? meta.version : null,
911
+ bootId: typeof meta.bootId === 'string' && meta.bootId !== '' ? meta.bootId : null,
912
+ requestId: Number.isFinite(meta.requestId) ? meta.requestId : null,
913
+ upgradedAt: Number.isFinite(meta.upgradedAt) ? meta.upgradedAt : null,
914
+ // THE CONTENT, as it is: a digest of the configuration files and the scope of
915
+ // what was copied. Both are facts about bytes, not grades.
916
+ contentFingerprint: normalizeFingerprint(meta.contentFingerprint),
917
+ contentScope: normalizeContentScope(meta.contentScope),
918
+ // WHETHER THE BOOT THAT FOLLOWED NEEDED HELP. Three values: true / false /
919
+ // null ("nobody recorded it").
920
+ intervention: typeof meta.intervention === 'boolean' ? meta.intervention : null,
921
+ boot,
922
+ };
923
+ try {
924
+ fs.writeFileSync(path.join(String(backupDir ?? ''), META_NAME), JSON.stringify(record, null, 2), 'utf8');
925
+ return true;
926
+ } catch {
927
+ return false;
928
+ }
929
+ }
930
+
931
+ /**
932
+ * The fingerprint of what a copy CONTAINS (configuration level).
933
+ *
934
+ * WHY A FINGERPRINT AND NOT JUST "THIS DIRECTORY": a certification is about a
935
+ * CONTENT, not about a path. If the same directory is edited after the fact — a
936
+ * half-finished hand edit is exactly how the 2026-11-01 incident started — then
937
+ * "this copy booted" stops being true of what is in it now, and the fingerprint
938
+ * is what makes that visible. It covers the files that decide whether the host
939
+ * comes up, by name and by content, so editing one byte of one of them changes
940
+ * the digest, and so does adding or removing one.
941
+ *
942
+ * The length is hashed in front of every content so that two different
943
+ * name/content splits can never produce the same input stream.
944
+ *
945
+ * @param {string} backupDir
946
+ * @returns {string} `sha256:<hex>` — never null, even for an empty copy
947
+ */
948
+ export function contentFingerprintOf(backupDir) {
949
+ const dir = typeof backupDir === 'string' ? backupDir : '';
950
+ const hash = createHash('sha256');
951
+ for (const name of CONFIG_FILES) {
952
+ let text = null;
953
+ try {
954
+ text = fs.readFileSync(path.join(dir, CONFIG_DIR, name), 'utf8');
955
+ } catch {
956
+ text = null;
957
+ }
958
+ if (text === null) {
959
+ hash.update(name + '\u0000missing\u0000');
960
+ continue;
961
+ }
962
+ hash.update(name + '\u0000' + Buffer.byteLength(text, 'utf8') + '\u0000' + text);
963
+ }
964
+ return 'sha256:' + hash.digest('hex');
965
+ }
966
+
967
+ /**
968
+ * WHAT THIS COPY HOLDS — from what is really on disk in it.
969
+ *
970
+ * It describes the copy, it does not grade it: a `config-only` copy that booted
971
+ * is a perfectly good answer for "the configuration boots", and a lie for "the
972
+ * plugin tree boots". The report needs both halves to say anything true.
973
+ *
974
+ * `includePlugins` / `slim` are taken from the creator when they are known,
975
+ * because the directory alone cannot tell a slim copy (third-party tree
976
+ * deliberately left out, it is reinstallable from the lock file) from a full copy
977
+ * of a profile that simply had no `node_modules`.
978
+ *
979
+ * @param {string} backupDir
980
+ * @param {{ includePlugins?: boolean|null, slim?: boolean|null }} [options]
981
+ * @returns {string} one of CONTENT_SCOPES
982
+ */
983
+ export function contentScopeOf(backupDir, { includePlugins = null, slim = null } = {}) {
984
+ const dir = typeof backupDir === 'string' ? backupDir.trim() : '';
985
+ const manifest = dir === '' ? null : readManifest(dir);
986
+ const pluginsRequested = includePlugins === null
987
+ ? Boolean(manifest && manifest.includePlugins === true)
988
+ : includePlugins === true;
989
+ if (!pluginsRequested) return CONTENT_SCOPES.CONFIG_ONLY;
990
+ let hasTree = false;
991
+ try {
992
+ hasTree = fs.existsSync(path.join(dir, PLUGINS_DIR)) === true;
993
+ } catch {
994
+ hasTree = false;
995
+ }
996
+ if (hasTree) return CONTENT_SCOPES.CONFIG_PLUGINS;
997
+ let hasLinks = false;
998
+ try {
999
+ const linksDir = path.join(dir, LINKS_DIR);
1000
+ hasLinks = fs.existsSync(linksDir) === true && fs.readdirSync(linksDir).length > 0;
1001
+ } catch {
1002
+ hasLinks = false;
1003
+ }
1004
+ if (hasLinks) return CONTENT_SCOPES.CONFIG_LINKS;
1005
+ return slim === true ? CONTENT_SCOPES.SLIM : CONTENT_SCOPES.CONFIG_ONLY;
1006
+ }
1007
+
1008
+ /**
1009
+ * IS THIS DIRECTORY A COMPLETE BACKUP? — the question nothing used to ask.
1010
+ *
1011
+ * WHAT WENT WRONG WITHOUT IT (2026-11-01): 23 directories under the real backups
1012
+ * root were listed, compared against and offered for restore while holding
1013
+ * **nothing but `node_modules-link/`**: no `config/`, no `manifest.json`, no
1014
+ * `meta.json`. Each reader had its own half-answer — `listBackups` skipped the
1015
+ * ones without a manifest, `contentScopeOf` guessed a scope from the pieces it
1016
+ * found — so "is this a backup?" was never answered in one place, and a
1017
+ * directory shaped like the incident could still be described as restorable.
1018
+ *
1019
+ * The answer is deliberately strict and cheap: a backup is complete when it has
1020
+ * a readable manifest AND at least one configuration file. It does NOT decide
1021
+ * whether the copy is TRUSTED (that is `isRestoreEligible`, which needs
1022
+ * certification) and it is not a content comparison — it only refuses to call a
1023
+ * gutted directory a backup.
1024
+ *
1025
+ * @param {string} backupDir
1026
+ * @returns {boolean}
1027
+ */
1028
+ export function backupIsComplete(backupDir) {
1029
+ const dir = typeof backupDir === 'string' ? backupDir.trim() : '';
1030
+ if (dir === '') return false;
1031
+ const manifest = readManifest(dir);
1032
+ if (manifest === null) return false;
1033
+ for (const name of CONFIG_FILES) {
1034
+ try {
1035
+ if (fs.existsSync(path.join(dir, CONFIG_DIR, name)) === true) return true;
1036
+ } catch {
1037
+ // Keep looking: one unreadable entry does not answer for the others.
1038
+ }
1039
+ }
1040
+ return false;
1041
+ }
1042
+
1043
+ /**
1044
+ * RECORD WHAT HAPPENED AFTER THIS COPY: the boot that followed it, if one did.
1045
+ *
1046
+ * THE NAME IS HISTORICAL (kept so the call sites and the fixtures that use it keep
1047
+ * working): this used to PROMOTE a copy to "known-good" and stamp it as safe. Since
1048
+ * 2026-11-02 it records FACTS — when the boot was observed, what looked at it, and
1049
+ * whether that boot was running exactly the content the copy holds — and nothing
1050
+ * about the plugins inside. "Is this copy good?" is not a question the file
1051
+ * answers any more; "did a boot on this content come up?" is.
1052
+ *
1053
+ * WHAT CHANGED AND WHY (2026-11-01): this used to promote unconditionally, with
1054
+ * a fixed reason. That is how a bad pre-restart state got certified: the boot
1055
+ * only came up because someone intervened, so "the copy from before that boot"
1056
+ * was the broken one. A record now requires the caller to say WHAT was observed:
1057
+ *
1058
+ * - nothing stated (no legacy `certifiedBy`, no `observedBy`) → NOTHING is
1059
+ * written, the meta is NOT rewritten, and the caller is told why. An old call
1060
+ * site that still says `markKnownGood(dir, { reason: 'boot-ok' })` therefore
1061
+ * degrades into "no boot on record" — the safe direction to fail in (better no
1062
+ * fallback than a false one).
1063
+ * - a real observation (`startup-checks` / `health-probe` / `manual-rollback`,
1064
+ * or the legacy source name that means the same thing) → the facts are
1065
+ * written: when it was observed, what was running, whether the content
1066
+ * matched, and which plugin load failures that boot reported.
1067
+ *
1068
+ * The CONTENT never changes: "it booted on this content" IS the fact, and it is
1069
+ * written where the next rollback looks for it.
1070
+ *
1071
+ * The startup checks and the health probe are NOT interchangeable — a health probe
1072
+ * in command mode proves the process answers; the four startup checks prove the
1073
+ * routes, plugins and recovery work. Both are recorded as what they are, because
1074
+ * `observedBy` says which one looked.
1075
+ *
1076
+ * AND THE ANSWER TO "WAS IT THIS CONTENT" TRAVELS WITH IT (2026-11-01): a record
1077
+ * states `contentMatched: true` only when the copy's configuration was compared
1078
+ * with the one that really booted and the two were equal. A record that says
1079
+ * nothing about it still keeps the boot facts — the caller may need them — but the
1080
+ * copy is NOT a restore candidate afterwards, because `isCertifiedBackup` needs
1081
+ * both halves. Recording the source without the comparison is exactly the shape
1082
+ * the two broken configurations on the hosted instance had, so the difference MUST
1083
+ * be visible in the meta instead of assumed away.
1084
+ *
1085
+ * THE ONE THING THIS FUNCTION REFUSES TO DO: erase a comparison that is already on
1086
+ * record. `contentMatched` falls back to what the copy carries, so a later
1087
+ * promotion that says nothing about it cannot silently un-prove a proven copy.
1088
+ * Removing that record is a decision of its own, not a side effect of re-writing
1089
+ * the same file.
1090
+ *
1091
+ * @param {string} backupDir
1092
+ * @param {{
1093
+ * reason?: string, (legacy, no longer stored)
1094
+ * version?: string|null,
1095
+ * bootId?: string|null,
1096
+ * now?: number,
1097
+ * certifiedBy?: string, (legacy input, translated)
1098
+ * certificationEvidence?: any, (legacy input, translated)
1099
+ * certifiedAt?: string|number|null, (legacy input → when the boot was seen)
1100
+ * trigger?: string|null,
1101
+ * observedBy?: string|null,
1102
+ * hostStarted?: boolean|null,
1103
+ * contentMatched?: boolean|null,
1104
+ * loadFailures?: Array<object>|null,
1105
+ * contentFingerprint?: string|null,
1106
+ * contentScope?: string|null,
1107
+ * intervention?: boolean|null,
1108
+ * }} [options]
1109
+ * @returns {{ promoted: boolean, reason: string, meta: object|null }}
1110
+ * `promoted` is false whenever nothing was written — and `reason` always says
1111
+ * why, so a caller that logs it is telling the truth when nothing happened.
1112
+ */
1113
+ export function markKnownGood(backupDir, {
1114
+ // LEGACY, KEPT FOR THE CALLERS THAT STILL SPEAK IT (2026-11-02): `reason` is no
1115
+ // longer stored, and `certifiedBy` / `certificationEvidence` / `certifiedAt` are
1116
+ // translated into facts below instead of being written.
1117
+ reason = null,
1118
+ version = null,
1119
+ bootId = null,
1120
+ now = Date.now(),
1121
+ certifiedBy = null,
1122
+ certificationEvidence = null,
1123
+ certifiedAt = null,
1124
+ // THE FACTS A CALLER STATES TODAY (see `BACKUP_TRIGGERS` / `BOOT_OBSERVATIONS`).
1125
+ observedBy = null,
1126
+ hostStarted = null,
1127
+ contentMatched = null,
1128
+ loadFailures = null,
1129
+ trigger = null,
1130
+ contentFingerprint = null,
1131
+ contentScope = null,
1132
+ intervention = null,
1133
+ } = {}) {
1134
+ // NO OBSERVATION, NO PROMOTION. Read the meta only to hand it back (untouched).
1135
+ // The two refusals are worded differently on purpose: "you told me nothing" and
1136
+ // "you told me something that proves nothing" are different mistakes, and the
1137
+ // log has to be able to tell them apart.
1138
+ //
1139
+ // A CALLER MAY STATE THE FACT INSTEAD OF THE OLD VERDICT (2026-11-02): passing
1140
+ // `observedBy` (one of BOOT_OBSERVATIONS) says what was really looked at on that
1141
+ // boot, and that is accepted in place of `certifiedBy`. Both inputs end up as the
1142
+ // same facts on disk; neither of them survives as a judgement.
1143
+ const statedObservation = typeof observedBy === 'string' && BOOT_OBSERVATION_SET.has(observedBy);
1144
+ if (!isCertifyingSource(certifiedBy) && !statedObservation) {
1145
+ const current = readBackupMeta(backupDir);
1146
+ const said = certifiedBy === undefined || certifiedBy === null
1147
+ ? 'no boot record was given, so nothing says the copy ever came up'
1148
+ : 'the source ' + JSON.stringify(String(certifiedBy)) + ' was given, but that does not record a boot';
1149
+ return { promoted: false, reason: said, meta: current };
1150
+ }
1151
+ const source = normalizeCertifiedBy(certifiedBy);
1152
+ const current = readBackupMeta(backupDir);
1153
+ const manifest = readManifest(backupDir);
1154
+ // Nothing to promote: neither an identity nor a manifest to stand on.
1155
+ if (manifest === null && current.kind === KIND_UNKNOWN) {
1156
+ return { promoted: false, reason: 'the copy has neither a readable meta.json nor a manifest', meta: current };
1157
+ }
1158
+ const at = current.at !== null ? current.at : (manifest && Number.isFinite(manifest.at) ? manifest.at : now);
1159
+ // THE OBSERVATION BEHIND THE OLD SOURCE NAME, translated: a legacy caller that
1160
+ // says "full-checks" is saying the four startup checks ran on that boot.
1161
+ const observationOfSource = (value) => {
1162
+ if (value === CERTIFIED_BY.FULL_CHECKS) return BOOT_OBSERVATIONS.STARTUP_CHECKS;
1163
+ if (value === CERTIFIED_BY.COMMAND_PROBE) return BOOT_OBSERVATIONS.HEALTH_PROBE;
1164
+ if (value === CERTIFIED_BY.MANUAL_ROLLBACK) return BOOT_OBSERVATIONS.MANUAL_ROLLBACK;
1165
+ return null;
1166
+ };
1167
+ const previousBoot = current.boot !== null && typeof current.boot === 'object' ? current.boot : null;
1168
+ // A SEED IS NOT A BOOT: the first-install copy was taken before anything ran, so
1169
+ // stating `seed` must not invent a boot record for it. Everything else is a
1170
+ // promotion that follows a boot, and the boot is what gets written down.
1171
+ const boot = source === CERTIFIED_BY.SEED
1172
+ ? previousBoot
1173
+ : {
1174
+ // "the host came up" — the one thing a promotion always implies, and the
1175
+ // legacy `reason: 'boot-ok'` used to say. A caller that knows better says so.
1176
+ started: typeof hostStarted === 'boolean' ? hostStarted : true,
1177
+ // WHEN IT WAS OBSERVED. The promotion is the moment the evidence appeared.
1178
+ at: normalizeIsoTime(certifiedAt, now),
1179
+ observedBy: statedObservation ? observedBy : observationOfSource(source),
1180
+ // WHAT WAS RUNNING: the fingerprint the comparison accepted, when the caller
1181
+ // passes it. `null` = not recorded.
1182
+ ranContent: normalizeFingerprint(contentFingerprint),
1183
+ // WAS IT THE CONTENT THIS COPY HOLDS — the old `fingerprint-match`, said as
1184
+ // the answer to a question. A copy that already carries a matched answer
1185
+ // keeps it when this promotion says nothing about it: re-writing the record
1186
+ // of a copy must never quietly un-prove it.
1187
+ contentMatched: typeof contentMatched === 'boolean'
1188
+ ? contentMatched
1189
+ : (isProvingEvidence(certificationEvidence) || previousBoot?.contentMatched === true),
1190
+ // THE PLUGIN LOAD FAILURES THAT BOOT REPORTED. `[]` = it reported none,
1191
+ // `null` = nobody looked. Never invented as an empty list.
1192
+ loadFailures: normalizeLoadFailures(loadFailures) ?? previousBoot?.loadFailures ?? null,
1193
+ };
1194
+ const written = writeBackupMeta(backupDir, {
1195
+ at,
1196
+ // WHEN THE COPY ITSELF WAS TAKEN — not when this record was rewritten.
1197
+ takenAt: current.takenAt,
1198
+ // WHAT PUT IT THERE. Stating the seed IS stating the trigger; otherwise the
1199
+ // trigger the copy already carries is preserved.
1200
+ trigger: source === CERTIFIED_BY.SEED
1201
+ ? BACKUP_TRIGGERS.FIRST_INSTALL
1202
+ : (normalizeTrigger(trigger) ?? current.trigger ?? BACKUP_TRIGGERS.PRE_RESTART),
1203
+ version: version !== null && version !== undefined ? version : current.version,
1204
+ bootId: bootId !== null && bootId !== undefined ? bootId : current.bootId,
1205
+ requestId: current.requestId,
1206
+ upgradedAt: now,
1207
+ boot,
1208
+ // The fingerprint is recomputed from THIS copy when the caller does not pass
1209
+ // one, so "the content on record" always names a real content. A fingerprint
1210
+ // already stored at creation time is kept when it matches nothing else.
1211
+ contentFingerprint: typeof contentFingerprint === 'string' && contentFingerprint !== ''
1212
+ ? contentFingerprint
1213
+ : (current.contentFingerprint || contentFingerprintOf(backupDir)),
1214
+ contentScope: typeof contentScope === 'string' && contentScope !== ''
1215
+ ? contentScope
1216
+ : (current.contentScope || contentScopeOf(backupDir)),
1217
+ // The intervention belongs to THIS boot. A caller that knows nothing passes
1218
+ // null and it stays null; it is never invented as `false`.
1219
+ intervention: typeof intervention === 'boolean' ? intervention : current.intervention,
1220
+ });
1221
+ if (!written) {
1222
+ return { promoted: false, reason: 'the meta.json of this copy could not be written', meta: current };
1223
+ }
1224
+ const meta = readBackupMeta(backupDir);
1225
+ // THE REASON SAYS WHICH OF THE TWO THINGS HAPPENED (2026-11-01): the facts were
1226
+ // recorded WITH a matched content, or WITHOUT one. A caller that logs this line
1227
+ // must not be able to read a criterion-free record as a success — that is the
1228
+ // reading that made the 71B copy look safe.
1229
+ return {
1230
+ promoted: true,
1231
+ reason: isCertifiedBackup(meta)
1232
+ ? 'a boot was observed (' + meta.certifiedBy + ') and it was running this content'
1233
+ : 'the source ' + (meta.certifiedBy === CERTIFIED_BY.NONE ? 'none' : meta.certifiedBy)
1234
+ + ' was recorded without a checkable criterion, so nothing says the content came up',
1235
+ meta,
1236
+ };
1237
+ }
1238
+
1239
+ /**
1240
+ * MAY THIS COPY BE PUT BACK BY THE SYSTEM? (2026-11-02 - the ONE answer)
1241
+ *
1242
+ * TWO CALLERS USED TO DISAGREE, and this function exists to stop that:
1243
+ * `selectRestoreTarget` (the watchdog) read `boot.started && boot.contentMatched`,
1244
+ * while `latestKnownGood` (single-plugin recovery) read `kind === 'known-good'`
1245
+ * plus `restoreEligible` - two rulers over the same directory, so the same copy
1246
+ * could be "a restore point" to one path and "not a restore point" to the other.
1247
+ * A copy that one path would roll back to and the other would refuse is a bug the
1248
+ * user pays for at the worst possible moment.
1249
+ *
1250
+ * THE CRITERION IS RECORDED FACTS ONLY - never a stamp, never a verdict:
1251
+ * - `boot.started === true` the instance came up after this copy was taken;
1252
+ * - `boot.contentMatched === true` what came up was this copy's content;
1253
+ * - no rescue was needed (`intervention`), because a copy whose own boot had to
1254
+ * be rescued has already shown it does not come up on its own.
1255
+ *
1256
+ * ON `intervention === null` (nobody recorded it): the contract names
1257
+ * `intervention !== true` as the bound, and this reads it the strict way - a
1258
+ * missing record is not a licence, it is `intervention-unrecorded`. That is the
1259
+ * reading the watchdog has always had and the one its tests pin; the other way
1260
+ * would mean the system restores what it could not check.
1261
+ *
1262
+ * OLD RECORDS: `readBackupMeta` translates a pre-boot record into the facts it
1263
+ * described (`legacyBootFactsOf`), so an old copy is judged by what its record
1264
+ * says - and a record that claims a check but carries no boot fact stays out.
1265
+ *
1266
+ * @param {object|null} meta a meta.json as `readBackupMeta` returns it
1267
+ * @returns {boolean}
1268
+ */
1269
+ export function isUsableRestorePoint(meta) {
1270
+ if (meta === null || typeof meta !== 'object') return false;
1271
+ const boot = meta.boot;
1272
+ // THE INSTALL-TIME COPY IS A FACT, NOT A LABEL (see `trigger`): it was taken
1273
+ // before anything ran, so "did this content come up" has no answer to give -
1274
+ // and the machine that is now running is by definition the state the user
1275
+ // installed. It stays usable while it is still the record of that state.
1276
+ if (meta.trigger === BACKUP_TRIGGERS.FIRST_INSTALL) return true;
1277
+ if (boot === null || typeof boot !== 'object') return false;
1278
+ if (boot.started !== true || boot.contentMatched !== true) return false;
1279
+ // AN UNRECORDED RESCUE IS NOT A LICENCE (user decision, 2026-11-02): only an
1280
+ // explicit "no rescue was needed" counts. "nobody wrote it down" is
1281
+ // exactly the case where the system must not act on its own.
1282
+ return meta.intervention === false;
1283
+ }
1284
+
1285
+ /**
1286
+ * Why `isUsableRestorePoint` said no - one sentence per reason, for reports.
1287
+ *
1288
+ * @param {object|null} meta
1289
+ * @returns {string|null} null when the copy IS usable
1290
+ */
1291
+ export function restorePointRefusal(meta) {
1292
+ if (meta === null || typeof meta !== 'object') return 'no record of this copy could be read';
1293
+ const boot = meta.boot;
1294
+ if (boot === null || typeof boot !== 'object') {
1295
+ return 'it carries no record of a restart, so nobody knows whether its content ever came up';
1296
+ }
1297
+ if (boot.started !== true || boot.contentMatched !== true) {
1298
+ return 'its restart record does not say both that the instance came up AND that the content matched'
1299
+ + ' (started=' + (boot.started === true) + ', contentMatched=' + (boot.contentMatched === true) + ')';
1300
+ }
1301
+ if (meta.intervention === true) {
1302
+ return 'its own boot had to be rescued, so it has already shown it does not come up on its own';
1303
+ }
1304
+ if (meta.intervention !== false) {
1305
+ return 'nobody recorded whether that boot had to be rescued';
1306
+ }
1307
+ return null;
1308
+ }
1309
+
1310
+ /**
1311
+ * THE NEWEST BACKUP THAT MAY STILL BE USED AS THE AUTOMATIC FALLBACK — the only
1312
+ * valid target of an automatic rollback.
1313
+ *
1314
+ * IT ASKS THE SAME QUESTION AS THE WATCHDOG (2026-11-02): `isUsableRestorePoint`,
1315
+ * over recorded facts. It used to ask `kind === 'known-good' && restoreEligible`,
1316
+ * which is a different ruler over the same directory - see the comment on that
1317
+ * function for why two rulers were a bug.
1318
+ *
1319
+ * @param {string} backupsDir the backups ROOT (the directory holding the copies)
1320
+ * @returns {object|null}
1321
+ */
1322
+ export function latestKnownGood(backupsDir) {
1323
+ for (const entry of listBackupIdentities(backupsDir)) {
1324
+ if (isUsableRestorePoint(entry) === true) return entry;
1325
+ }
1326
+ return null;
1327
+ }
1328
+
1329
+ /**
1330
+ * Every backup with its identity, newest first (what the settings page and the
1331
+ * health route show: identity, time, size, version).
1332
+ *
1333
+ * @param {string} backupsDir the backups ROOT
1334
+ * @returns {Array<object>}
1335
+ */
1336
+ export function listBackupIdentities(backupsDir) {
1337
+ return listBackups(backupsDir).map((backup) => {
1338
+ const meta = readBackupMeta(backup.dir);
1339
+ return {
1340
+ ...backup,
1341
+ kind: meta.kind,
1342
+ version: meta.version,
1343
+ reason: meta.reason,
1344
+ requestId: meta.requestId,
1345
+ upgradedAt: meta.upgradedAt,
1346
+ // WHAT PUT IT THERE, AND WHAT THE BOOT AFTER IT REPORTED (2026-11-02): the
1347
+ // facts the settings page and the reports read. `trigger` is null only for a
1348
+ // record written before this layer existed; `boot` is null whenever no boot
1349
+ // has been observed for this copy.
1350
+ takenAt: meta.takenAt,
1351
+ trigger: meta.trigger,
1352
+ boot: meta.boot,
1353
+ // THE OLD VOCABULARY, DERIVED (2026-11-02): kept because the selector and
1354
+ // the reports above this layer read it, and because a copy written by an
1355
+ // older build only speaks it. It is never written to a file.
1356
+ certifiedBy: meta.certifiedBy,
1357
+ // THE EVIDENCE TRAVELS WITH THE LISTING. The report and the settings page
1358
+ // have to be able to say "safe because X, checked with Y, at T, this
1359
+ // content" — a bare kind:'known-good' is exactly the label that lied in the
1360
+ // incident.
1361
+ certifiedBy: meta.certifiedBy,
1362
+ // WHICH CRITERION, so the interface can show the difference the user must
1363
+ // see: a copy proven by the fingerprint comparison vs. a copy whose stamp
1364
+ // cannot be verified any more (and is therefore not used).
1365
+ certificationEvidence: meta.certificationEvidence,
1366
+ certifiedAt: meta.certifiedAt,
1367
+ contentFingerprint: meta.contentFingerprint,
1368
+ contentScope: meta.contentScope,
1369
+ intervention: meta.intervention,
1370
+ // THE ONE DEFINITION OF "SAFE" — both halves, see `isCertifiedBackup`.
1371
+ // Historical pollution (a source without a criterion) is `false` here, and
1372
+ // that is what the watchdog selector reads.
1373
+ certified: isCertifiedBackup(meta),
1374
+ // The wider question (a copy an automatic rollback may use): the seed is
1375
+ // included here and NOT in `certified`, because the seed was never proven
1376
+ // and must not be recorded as if it had been.
1377
+ restoreEligible: isRestoreEligible(meta),
1378
+ };
1379
+ });
1380
+ }
36
1381
 
37
1382
  /** The report the detached helper writes when it rolls a backup back. */
38
1383
  export const ROLLBACK_REPORT_NAME = 'last-rollback.json';
@@ -218,6 +1563,333 @@ export function measureTree(target) {
218
1563
  return directorySize(target);
219
1564
  }
220
1565
 
1566
+ /**
1567
+ * The bundles a profile's package.json enables (`dsh.profile.bundles`).
1568
+ *
1569
+ * @param {string} packageJsonText
1570
+ * @returns {string[]}
1571
+ */
1572
+ export function enabledBundlesOf(packageJsonText) {
1573
+ const text = typeof packageJsonText === 'string'
1574
+ ? (packageJsonText.charCodeAt(0) === 0xfeff ? packageJsonText.slice(1) : packageJsonText)
1575
+ : '';
1576
+ if (text.trim() === '') return [];
1577
+ let parsed = null;
1578
+ try {
1579
+ parsed = JSON.parse(text);
1580
+ } catch {
1581
+ return [];
1582
+ }
1583
+ const profile = parsed && typeof parsed === 'object' ? parsed.dsh : null;
1584
+ const container = profile && typeof profile === 'object' ? profile.profile : null;
1585
+ const raw = container && typeof container === 'object' ? container.bundles : null;
1586
+ const bundles = [];
1587
+ if (Array.isArray(raw)) {
1588
+ for (const entry of raw) {
1589
+ if (typeof entry === 'string' && entry.trim() !== '') bundles.push(entry.trim());
1590
+ }
1591
+ } else if (typeof raw === 'string' && raw.trim() !== '') {
1592
+ bundles.push(raw.trim());
1593
+ }
1594
+ return bundles;
1595
+ }
1596
+
1597
+ /**
1598
+ * The LINKED plugins of a profile: `node_modules` entries that are symbol
1599
+ * links / junctions pointing at a directory.
1600
+ *
1601
+ * WHY THIS EXISTS (user decision, 2026-10-30). Five plugins on this machine are
1602
+ * installed with `link:` - including dsh-autorestart itself. A backup that
1603
+ * copies `node_modules` as a tree records the LINKS and none of their contents,
1604
+ * so the plugin that does the rescuing could not be put back: a real gap, found
1605
+ * by measurement (118 real copies in the backup, 5 links, 0 linked plugins).
1606
+ *
1607
+ * Only bundles the profile ENABLES are collected: a link that is switched off is
1608
+ * not something a restore has to bring back.
1609
+ *
1610
+ * Every filesystem call is injected, so this is testable without a real profile.
1611
+ *
1612
+ * @param {{
1613
+ * profileDir?: string,
1614
+ * bundles?: string[]|null,
1615
+ * lstatFn?: Function,
1616
+ * readlinkFn?: Function,
1617
+ * readFileFn?: Function,
1618
+ * existsFn?: Function,
1619
+ * readdirFn?: Function,
1620
+ * measureFn?: Function,
1621
+ * platform?: string,
1622
+ * }} [options]
1623
+ * @returns {{
1624
+ * ok: boolean,
1625
+ * links: Array<{ name: string, target: string, kind: string, exists: boolean, readable: boolean, files: number, bytes: number }>,
1626
+ * enabled: string[],
1627
+ * error: string|null,
1628
+ * }}
1629
+ */
1630
+ export function collectLinkedPlugins(options = {}) {
1631
+ const profileDir = typeof options.profileDir === 'string' ? options.profileDir : '';
1632
+ const lstat = typeof options.lstatFn === 'function' ? options.lstatFn : (target) => fs.lstatSync(target);
1633
+ const readlink = typeof options.readlinkFn === 'function' ? options.readlinkFn : (target) => fs.readlinkSync(target);
1634
+ const readFile = typeof options.readFileFn === 'function' ? options.readFileFn : (target) => fs.readFileSync(target, 'utf8');
1635
+ const exists = typeof options.existsFn === 'function' ? options.existsFn : (target) => fs.existsSync(target);
1636
+ const readdir = typeof options.readdirFn === 'function'
1637
+ ? options.readdirFn
1638
+ : (target) => fs.readdirSync(target, { withFileTypes: true });
1639
+ const measure = typeof options.measureFn === 'function' ? options.measureFn : measureTree;
1640
+ const platform = typeof options.platform === 'string' ? options.platform : process.platform;
1641
+
1642
+ if (profileDir === '') {
1643
+ return { ok: false, links: [], enabled: [], error: 'collectLinkedPlugins requires profileDir' };
1644
+ }
1645
+ const pluginsDir = path.join(profileDir, PLUGINS_DIR);
1646
+ if (exists(pluginsDir) !== true) {
1647
+ return { ok: true, links: [], enabled: [], error: null };
1648
+ }
1649
+ // Which bundles the profile answers "yes" to. `null` means "no filter", which
1650
+ // the caller uses when the package.json itself is unreadable: a restore then
1651
+ // still knows about the links instead of silently dropping them.
1652
+ const enabled = Array.isArray(options.bundles)
1653
+ ? options.bundles.map(String)
1654
+ : (() => {
1655
+ try {
1656
+ return enabledBundlesOf(readFile(path.join(profileDir, 'package.json')));
1657
+ } catch {
1658
+ return null;
1659
+ }
1660
+ })();
1661
+
1662
+ const links = [];
1663
+ let entries = [];
1664
+ try {
1665
+ entries = readdir(pluginsDir);
1666
+ } catch (error) {
1667
+ return {
1668
+ ok: false,
1669
+ links: [],
1670
+ enabled: enabled === null ? [] : enabled,
1671
+ error: 'the plugin directory cannot be read: ' + (error && error.message ? error.message : String(error)),
1672
+ };
1673
+ }
1674
+ for (const entry of entries) {
1675
+ const name = entry && typeof entry.name === 'string' ? entry.name : String(entry);
1676
+ if (name === '' || name.startsWith('.')) continue;
1677
+ if (enabled !== null && enabled.includes(name) !== true) continue;
1678
+ const full = path.join(pluginsDir, name);
1679
+ let info = null;
1680
+ try {
1681
+ info = lstat(full);
1682
+ } catch {
1683
+ continue;
1684
+ }
1685
+ if (!info || typeof info.isSymbolicLink !== 'function' || info.isSymbolicLink() !== true) continue;
1686
+ let raw = '';
1687
+ try {
1688
+ raw = String(readlink(full));
1689
+ } catch {
1690
+ raw = '';
1691
+ }
1692
+ // A Windows junction always reports an ABSOLUTE target; a symbol link keeps
1693
+ // whatever it was created with (this machine's five links are relative).
1694
+ // Node exposes no reparse tag, so this is the best distinction available -
1695
+ // and it only ever shows up as a label in the manifest.
1696
+ const kind = platform === 'win32' && path.isAbsolute(raw) ? LINK_KINDS.JUNCTION : LINK_KINDS.SYMLINK;
1697
+ const target = raw === ''
1698
+ ? ''
1699
+ : (path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(pluginsDir, raw));
1700
+ const targetExists = target !== '' && exists(target) === true;
1701
+ let readable = false;
1702
+ let files = 0;
1703
+ let bytes = 0;
1704
+ if (targetExists) {
1705
+ try {
1706
+ const measured = measure(target);
1707
+ files = Math.max(0, Number(measured && measured.files) || 0);
1708
+ bytes = Math.max(0, Number(measured && measured.bytes) || 0);
1709
+ readable = true;
1710
+ } catch {
1711
+ readable = false;
1712
+ }
1713
+ }
1714
+ links.push({ name, target, kind, exists: targetExists, readable, files, bytes });
1715
+ }
1716
+ links.sort((left, right) => (left.name < right.name ? -1 : (left.name > right.name ? 1 : 0)));
1717
+ return { ok: true, links, enabled: enabled === null ? [] : enabled, error: null };
1718
+ }
1719
+
1720
+ /**
1721
+ * WHICH INSTALLED PLUGINS ARE "LOCAL", AND WHICH ARE PUBLISHED AS-IS.
1722
+ *
1723
+ * The user's rule (2026-11-02) has exactly TWO reliable signals, and this
1724
+ * function uses nothing else:
1725
+ *
1726
+ * 1. the entry is a SYMBOLIC LINK (`node_modules/<name>` → somewhere else) —
1727
+ * a hand-written plugin, installed with `link:`;
1728
+ * 2. the entry is a real npm directory that CONTAINS `.git` — it was cloned or
1729
+ * re-inited and then edited here, so its contents exist nowhere else.
1730
+ *
1731
+ * Anything else is "third-party as published": reinstalling from the pinned
1732
+ * lockfile reproduces it byte for byte, so it is only LISTED (name + version),
1733
+ * never copied.
1734
+ *
1735
+ * What is deliberately NOT used: comparing the directory against the published
1736
+ * tarball. The lockfile records one integrity hash per PACKAGE, not per file, so
1737
+ * that comparison would need the tarball itself (network or npm cache) — an
1738
+ * answer that is sometimes available is not a判据. `.git` is skipped inside the
1739
+ * copy (BACKUP_SKIP_NAMES): the history is rebuildable.
1740
+ *
1741
+ * @param {{ profileDir?: string }} [options]
1742
+ * @returns {{ ok: boolean, localized: Array<{name: string, dir: string, version: string|null}>, thirdParty: Array<{name: string, version: string|null}>, error: string|null }}
1743
+ */
1744
+ export function collectLocalPluginDirs(options = {}) {
1745
+ const profileDir = typeof options.profileDir === 'string' ? options.profileDir : '';
1746
+ const empty = { ok: false, localized: [], thirdParty: [], error: null };
1747
+ if (profileDir === '') return { ...empty, error: 'collectLocalPluginDirs requires profileDir' };
1748
+ const pluginsDir = path.join(profileDir, PLUGINS_DIR);
1749
+ if (fs.existsSync(pluginsDir) !== true) return { ok: true, localized: [], thirdParty: [] };
1750
+
1751
+ /** `<dir>/node_modules/*` plus one level of `@scope/*`. */
1752
+ const entriesOf = (base) => {
1753
+ try {
1754
+ return fs.readdirSync(base, { withFileTypes: true });
1755
+ } catch {
1756
+ return [];
1757
+ }
1758
+ };
1759
+
1760
+ const candidates = [];
1761
+ for (const entry of entriesOf(pluginsDir)) {
1762
+ const name = entry && typeof entry.name === 'string' ? entry.name : '';
1763
+ if (name === '' || name.startsWith('.')) continue;
1764
+ if (name.startsWith('@')) {
1765
+ // A scope is not a plugin: look one level deeper.
1766
+ const scopeDir = path.join(pluginsDir, name);
1767
+ for (const inner of entriesOf(scopeDir)) {
1768
+ const innerName = inner && typeof inner.name === 'string' ? inner.name : '';
1769
+ if (innerName === '' || innerName.startsWith('.')) continue;
1770
+ candidates.push({ name: name + '/' + innerName, dir: path.join(scopeDir, innerName) });
1771
+ }
1772
+ continue;
1773
+ }
1774
+ candidates.push({ name, dir: path.join(pluginsDir, name) });
1775
+ }
1776
+
1777
+ const localized = [];
1778
+ const thirdParty = [];
1779
+ for (const candidate of candidates) {
1780
+ let info = null;
1781
+ try {
1782
+ info = fs.lstatSync(candidate.dir);
1783
+ } catch {
1784
+ continue;
1785
+ }
1786
+ // A LINK is the first signal, handled by collectLinkedPlugins; it is not
1787
+ // counted here so the two lists never describe the same plugin twice.
1788
+ if (info && typeof info.isSymbolicLink === 'function' && info.isSymbolicLink() === true) continue;
1789
+ if (!info || typeof info.isDirectory !== 'function' || info.isDirectory() !== true) continue;
1790
+ const hasGit = fs.existsSync(path.join(candidate.dir, '.git')) === true;
1791
+ const version = pluginVersionIn(candidate.dir);
1792
+ if (hasGit === true) {
1793
+ localized.push({ name: candidate.name, dir: candidate.dir, version });
1794
+ } else {
1795
+ thirdParty.push({ name: candidate.name, version });
1796
+ }
1797
+ }
1798
+ const byName = (left, right) => (left.name < right.name ? -1 : (left.name > right.name ? 1 : 0));
1799
+ localized.sort(byName);
1800
+ thirdParty.sort(byName);
1801
+ return { ok: true, localized, thirdParty, error: null };
1802
+ }
1803
+
1804
+ /**
1805
+ * THE VERSION OF ONE PLUGIN DIRECTORY, or null.
1806
+ *
1807
+ * Read from the directory itself (`package.json`), never from the live profile:
1808
+ * the manifest describes the COPY, and half the point of a backup is that the
1809
+ * installed thing may already have moved on. Never throws.
1810
+ *
1811
+ * @param {string} pluginDir
1812
+ * @returns {string|null}
1813
+ */
1814
+ function pluginVersionIn(pluginDir) {
1815
+ try {
1816
+ const parsed = JSON.parse(fs.readFileSync(path.join(pluginDir, 'package.json'), 'utf8'));
1817
+ return parsed && typeof parsed.version === 'string' && parsed.version !== '' ? parsed.version : null;
1818
+ } catch {
1819
+ return null;
1820
+ }
1821
+ }
1822
+
1823
+ /**
1824
+ * MAY THE LINK STAY, OR MUST THE BACKUP CONTENT TAKE ITS PLACE?
1825
+ *
1826
+ * The same decision is used by the restore script and by the watchdog, so a
1827
+ * manual restore and an automatic one cannot disagree:
1828
+ *
1829
+ * - the link target is there and readable -> KEPT (the development directory
1830
+ * keeps working, which is the whole point of a `link:` installation);
1831
+ * - the target is gone / unreadable -> REPLACED (a host cannot start
1832
+ * from a plugin that is not there);
1833
+ * - forced, or a second attempt -> REPLACED (the first attempt is
1834
+ * the link's chance; a plugin that already failed once is not given a
1835
+ * third);
1836
+ * - there is no backup content for it -> TARGET_MISSING / NO_BACKUP and
1837
+ * the link is left exactly as it is (we never delete what we cannot replace).
1838
+ *
1839
+ * @param {{
1840
+ * targetExists?: boolean, targetReadable?: boolean, hasBackup?: boolean,
1841
+ * force?: boolean, attempts?: number,
1842
+ * }} [options]
1843
+ * @returns {{ action: string, replace: boolean, reason: string }}
1844
+ */
1845
+ export function decideLinkRestore(options = {}) {
1846
+ const targetExists = options.targetExists === true;
1847
+ const targetReadable = options.targetReadable !== false && targetExists === true;
1848
+ const hasBackup = options.hasBackup === true;
1849
+ const forced = options.force === true;
1850
+ const attempts = Number.isFinite(Number(options.attempts)) ? Math.trunc(Number(options.attempts)) : 1;
1851
+
1852
+ if (!targetExists && !hasBackup) {
1853
+ return {
1854
+ action: LINK_ACTIONS.TARGET_MISSING,
1855
+ replace: false,
1856
+ reason: 'the link target is gone and the backup holds no content for this plugin',
1857
+ };
1858
+ }
1859
+ if (!hasBackup) {
1860
+ return {
1861
+ action: LINK_ACTIONS.NO_BACKUP,
1862
+ replace: false,
1863
+ reason: 'the backup holds no content for this plugin, so the link is left untouched',
1864
+ };
1865
+ }
1866
+ if (forced) {
1867
+ return { action: LINK_ACTIONS.REPLACED, replace: true, reason: 'forced: the backup content replaces the link' };
1868
+ }
1869
+ if (!targetExists) {
1870
+ return {
1871
+ action: LINK_ACTIONS.REPLACED,
1872
+ replace: true,
1873
+ reason: 'the link target is gone: the backup content takes its place',
1874
+ };
1875
+ }
1876
+ if (!targetReadable) {
1877
+ return {
1878
+ action: LINK_ACTIONS.REPLACED,
1879
+ replace: true,
1880
+ reason: 'the link target cannot be read: the backup content takes its place',
1881
+ };
1882
+ }
1883
+ if (attempts >= 2) {
1884
+ return {
1885
+ action: LINK_ACTIONS.REPLACED,
1886
+ replace: true,
1887
+ reason: 'attempt ' + attempts + ': the link was given its chance and the host still did not come up',
1888
+ };
1889
+ }
1890
+ return { action: LINK_ACTIONS.KEPT, replace: false, reason: 'the link target is there and readable: the link stays' };
1891
+ }
1892
+
221
1893
  /**
222
1894
  * Copy a directory tree FILE BY FILE, reporting real progress and honouring a
223
1895
  * cancel request.
@@ -252,6 +1924,13 @@ export async function copyTreeWithProgress({
252
1924
  destination,
253
1925
  onProgress = null,
254
1926
  shouldCancel = null,
1927
+ // NAMES THAT MUST NOT BE COPIED AT ALL (2026-11-01). A `link:`-installed
1928
+ // plugin is a working checkout, so it carries its `.git` — measured on the
1929
+ // real machine: one linked plugin alone dragged 962 files / ~74 MB of history
1930
+ // into every single backup. `.git` is rebuildable by design (clone/fetch), so
1931
+ // it is not "a thing that cannot be fetched again" and does not belong in a
1932
+ // restore point.
1933
+ excludeNames = null,
255
1934
  progressEveryFiles = 25,
256
1935
  yieldEveryFiles = 40,
257
1936
  yieldEveryMs = 120,
@@ -267,6 +1946,11 @@ export async function copyTreeWithProgress({
267
1946
  ? yieldToEventLoop
268
1947
  : () => new Promise((resolve) => { setImmediate(resolve); });
269
1948
  const clock = typeof now === 'function' ? now : Date.now;
1949
+ const skip = new Set(
1950
+ Array.isArray(excludeNames)
1951
+ ? excludeNames.map((name) => String(name)).filter((name) => name !== '')
1952
+ : [],
1953
+ );
270
1954
  let files = 0;
271
1955
  let bytes = 0;
272
1956
  let cancelled = false;
@@ -302,6 +1986,7 @@ export async function copyTreeWithProgress({
302
1986
  cancelled = true;
303
1987
  return;
304
1988
  }
1989
+ if (skip.size > 0 && skip.has(entry.name)) continue;
305
1990
  const childSource = path.join(sourceDir, entry.name);
306
1991
  const childTarget = path.join(targetDir, entry.name);
307
1992
  if (entry.isDirectory()) {
@@ -363,11 +2048,40 @@ export function removeBackupDir(dir) {
363
2048
  }
364
2049
 
365
2050
  /**
366
- * Keep only the newest `maxBackups` backup directories.
2051
+ * Keep only the newest `maxBackups` backup directories — but never at the price
2052
+ * of the last trusted fallback.
2053
+ *
2054
+ * THREE FLOORS, in this order:
2055
+ *
2056
+ * 1. THE LAST CERTIFIED COPY IS NEVER REMOVED (2026-11-01). "Certified" means
2057
+ * `isRestoreEligible`: a source AND the criterion that can be re-checked
2058
+ * (`fingerprint-match`), or the first-install seed. Whatever the limit says,
2059
+ * the newest such copy survives: it is the one thing a failed boot can be
2060
+ * rolled back to. A copy stamped by the old criterion is NOT in this list any
2061
+ * more — it is not a fallback, so keeping it "because it says certified"
2062
+ * would protect exactly the copy nobody may restore.
2063
+ * 2. NO CERTIFIED COPY AT ALL → NOTHING IS REMOVED. This is the strict-mode
2064
+ * decision: disk space is cheaper than deleting the only copy that might
2065
+ * still come in handy, and the caller has to be able to tell the customer
2066
+ * "there is no trusted restore point". The answer says so (`skipped`), so
2067
+ * the upper layer can say it out loud instead of quietly doing nothing.
2068
+ * 3. The pre-existing floors stay as they were: the newest known-good copy and
2069
+ * the oldest copy are never victims.
2070
+ *
2071
+ * WHAT THIS IS NOT: it is not "keep only the newest copy" and it is not a
2072
+ * smarter policy — it is a floor under the existing one.
367
2073
  *
368
2074
  * @param {string} backupsDir
369
2075
  * @param {number} [maxBackups]
370
- * @returns {string[]} the names that were removed
2076
+ * @returns {{
2077
+ * removed: string[],
2078
+ * skipped: boolean,
2079
+ * reason: string|null,
2080
+ * budget: number,
2081
+ * certifiedCount: number,
2082
+ * keptCertified: string|null,
2083
+ * }} `removed` keeps its old meaning (the names that were really deleted);
2084
+ * `skipped` is true when the floor above decided to delete nothing.
371
2085
  */
372
2086
  export function pruneBackups(backupsDir, maxBackups = DEFAULT_MAX_BACKUPS) {
373
2087
  const limit = Number.isFinite(Number(maxBackups)) && Number(maxBackups) >= 1
@@ -380,19 +2094,218 @@ export function pruneBackups(backupsDir, maxBackups = DEFAULT_MAX_BACKUPS) {
380
2094
  .map((entry) => entry.name)
381
2095
  .sort();
382
2096
  } catch {
383
- return [];
2097
+ return { removed: [], skipped: false, reason: 'the backups directory could not be read', budget: 0, certifiedCount: 0, keptCertified: null };
384
2098
  }
385
- const removed = [];
386
- for (const name of names.slice(0, Math.max(0, names.length - limit))) {
387
- const target = path.join(backupsDir, name);
388
- try {
389
- fs.rmSync(target, { recursive: true, force: true });
390
- removed.push(name);
391
- } catch {
392
- // A backup that cannot be removed must not fail the restart.
2099
+ try {
2100
+ const metas = new Map();
2101
+ for (const name of names) metas.set(name, readBackupMeta(path.join(backupsDir, name)));
2102
+ // THE COPIES THAT MAY STILL BE RESTORED, oldest first: `names` is already
2103
+ // sorted, so the last entry of this list is the newest such copy.
2104
+ //
2105
+ // THE DEFINITION IS `isRestoreEligible`, NOT "does the meta name a source"
2106
+ // (2026-11-01): a criterion-free stamp is not a fallback, so it must not be the
2107
+ // reason a copy is kept alive OR the reason pruning believes it has something
2108
+ // to protect.
2109
+ const certified = names.filter((name) => isRestoreEligible(metas.get(name)));
2110
+ if (certified.length === 0) {
2111
+ // NO SAFE COPY YET — AND THE HISTORY STILL HAS TO CONVERGE (2026-11-02).
2112
+ //
2113
+ // The old FLOOR 2 rule kept EVERYTHING when nothing was certified, "because
2114
+ // there is no trusted fallback to keep". That protection was aimed at the
2115
+ // wrong thing: an UNPROVEN copy cannot be restored at all (the user's own
2116
+ // words), so keeping it protects nothing and only spends disk. A hosted WSL
2117
+ // instance certifies nothing, so it accumulated FIFTEEN copies — one per
2118
+ // restart, never converging.
2119
+ //
2120
+ // The design is simple: at most `maxBackups` copies; once a proof exists one
2121
+ // of them is the proven one. With no proof at all only the newest
2122
+ // `maxBackups` copies stay.
2123
+ //
2124
+ // Deliberately NOT protected in this branch: the "oldest reference" copy
2125
+ // (it earns its place only as a comparison against a proven copy, and there
2126
+ // is none here) and anything else — with no proof, "keep it just in case"
2127
+ // keeps nothing.
2128
+ const overLimitBudget = Math.max(0, names.length - limit);
2129
+ const unit = limit === 1 ? 'copy' : 'copies';
2130
+ if (overLimitBudget === 0) {
2131
+ return {
2132
+ removed: [],
2133
+ skipped: false,
2134
+ reason: null,
2135
+ budget: 0,
2136
+ certifiedCount: 0,
2137
+ keptCertified: null,
2138
+ };
2139
+ }
2140
+ const keep = new Set(names.slice(Math.max(0, names.length - limit)));
2141
+ const overLimit = names.filter((name) => !keep.has(name));
2142
+ // A DIRECTORY THAT WAS NEVER FINISHED GOES FIRST: no manifest and no
2143
+ // timestamp means a leftover, not a point in time.
2144
+ const unfinished = overLimit.filter((name) => {
2145
+ const manifest = readManifest(path.join(backupsDir, name));
2146
+ const meta = metas.get(name);
2147
+ return manifest === null || !meta || !Number.isFinite(meta.at);
2148
+ });
2149
+ const finished = overLimit.filter((name) => !unfinished.includes(name));
2150
+ const victims = [...unfinished, ...finished].slice(0, overLimitBudget);
2151
+ const removed = [];
2152
+ for (const name of victims) {
2153
+ const target = path.join(backupsDir, name);
2154
+ try {
2155
+ fs.rmSync(target, { recursive: true, force: true });
2156
+ removed.push(name);
2157
+ } catch {
2158
+ // A backup that cannot be removed must not fail the restart.
2159
+ }
2160
+ }
2161
+ return {
2162
+ removed,
2163
+ skipped: false,
2164
+ reason: removed.length === 0
2165
+ ? 'nothing was removed: the ' + overLimitBudget + ' ' + unit + ' over the limit could not be deleted'
2166
+ : 'no copy has a restart record saying it came up with matching content: trimmed to the newest '
2167
+ + limit + ' ' + unit + ' anyway (a copy with no such record is not a fallback, so it is not kept just in case)',
2168
+ budget: overLimitBudget,
2169
+ certifiedCount: 0,
2170
+ keptCertified: null,
2171
+ };
2172
+ }
2173
+ const keptCertified = certified[certified.length - 1];
2174
+ // WHO IS PROTECTED IS A RECORDED FACT, NOT A VERDICT (2026-11-02).
2175
+ //
2176
+ // The old protection set was built out of wording: "the newest known-good
2177
+ // copy" and "the last certified one". Those are opinions the plugin wrote
2178
+ // about a copy; the user's point is that a backup is either a directory with
2179
+ // contents or it is not, and what the plugin may keep has to follow from what
2180
+ // actually happened, not from an adjective it awarded itself.
2181
+ //
2182
+ // So there are exactly two keepers, both facts:
2183
+ // 1. the NEWEST copy — the one this restart just wrote; deleting it is how
2184
+ // the 2026-11-02 report happened at all (that is pinned by a test);
2185
+ // 2. the newest copy whose restart record says the instance CAME UP and the
2186
+ // content MATCHED (`boot.started` + `boot.contentMatched`) — the one copy
2187
+ // a failed boot can honestly fall back to.
2188
+ // The previous "last certified one" protection is GONE: it kept a copy alive
2189
+ // because of a label, and a label is not a reason to spend disk.
2190
+ //
2191
+ // A COPY WITH NO `meta.json` HAS NO FACTS AT ALL (2026-11-02): the real hosted
2192
+ // machine had two such directories; `metas.get(name)` returned null and the
2193
+ // old code read `null.kind`, which threw and killed the whole prune. A meta
2194
+ // that cannot be read is simply "not the booted copy".
2195
+ const bootedName = (() => {
2196
+ for (const name of names.slice().reverse()) {
2197
+ const meta = metas.get(name);
2198
+ const boot = meta && meta.boot;
2199
+ if (boot && boot.started === true && boot.contentMatched === true) return name;
2200
+ }
2201
+ return null;
2202
+ })();
2203
+ // THE OLDEST BACKUP IS *USUALLY* KEPT TOO (2026-11-01): it is the "how it
2204
+ // looked when this machine was still fine" reference, and losing it is what
2205
+ // made the comparison against a backup meaningless after three restarts.
2206
+ //
2207
+ // BUT IT IS A PREFERENCE, NOT A PROTECTION (2026-11-02). Two facts forced
2208
+ // that: (a) the newest copy must never be the victim — on the real machine it
2209
+ // was the ONLY unprotected candidate, so "trim to the limit" deleted the
2210
+ // snapshot the restart had just written while a 0.1 MB leftover survived;
2211
+ // (b) keeping the oldest copy absolutely makes the list unable to converge at
2212
+ // all when oldest, booted and newest are three different copies — exactly the
2213
+ // real machine, which would then never get back to `maxBackups`.
2214
+ //
2215
+ // THE FIRST-INSTALL COPY IS A PREFERENCE OF THE SAME WEIGHT (2026-11-02): it
2216
+ // used to be kept forever because its meta said `certifiedBy: 'seed'`. It is
2217
+ // the only copy of "what the machine looked like before anything happened",
2218
+ // so it is worth sparing — but only while there is room, exactly like the
2219
+ // oldest one. Nothing is kept forever on the strength of a label.
2220
+ //
2221
+ // A rollback that is IN PROGRESS is protected another way: its undo copy is
2222
+ // written with `pruneEnabled: false`, so no prune can touch the copy being
2223
+ // restored.
2224
+ const oldestName = names.length > 0 ? names[0] : null
2225
+ const newestName = names.length > 0 ? names[names.length - 1] : null
2226
+ const seedName = (() => {
2227
+ for (const name of names) {
2228
+ const meta = metas.get(name);
2229
+ if (!meta) continue;
2230
+ // WHAT PUT IT THERE is a fact written when the copy was made. A seed has
2231
+ // no boot record at all (nothing had run yet when it was taken), so
2232
+ // `trigger` is the honest way to recognise it — plus the legacy stamp for
2233
+ // metas written by an older build.
2234
+ if (meta.trigger === BACKUP_TRIGGERS.FIRST_INSTALL) return name;
2235
+ if (meta.certifiedBy === CERTIFIED_BY.SEED) return name;
2236
+ }
2237
+ return null;
2238
+ })();
2239
+ const hardProtected = new Set(
2240
+ [newestName, bootedName].filter((name) => typeof name === 'string'),
2241
+ )
2242
+ const budget = Math.max(0, names.length - limit)
2243
+ const rest = names.filter((name) => !hardProtected.has(name))
2244
+ // A COPY THAT CANNOT BE READ GOES FIRST (2026-11-02): no `meta.json` or no
2245
+ // manifest means it is not a point in time at all, so when the budget is
2246
+ // short it is never the thing worth keeping. This keeps the "oldest first"
2247
+ // order WITHIN each group, so the keepers above still mean what they meant.
2248
+ const unreadable = (name) => {
2249
+ const meta = metas.get(name);
2250
+ const manifest = readManifest(path.join(backupsDir, name));
2251
+ return meta === null || manifest === null || !Number.isFinite(meta.at);
2252
+ };
2253
+ const ordered = rest.filter(unreadable).concat(rest.filter((name) => !unreadable(name)));
2254
+ // THE TWO PREFERRED COPIES ARE SPARED ONLY WHEN THERE IS ROOM: sparing them
2255
+ // while the budget is larger than the remaining copies would leave the list
2256
+ // over the limit forever.
2257
+ const preferred = new Set(
2258
+ [oldestName, seedName].filter((name) => typeof name === 'string'),
2259
+ );
2260
+ const withoutPreferred = ordered.filter((name) => !preferred.has(name));
2261
+ const pool = withoutPreferred.length >= budget ? withoutPreferred : ordered;
2262
+ const victims = pool.slice(0, budget);
2263
+ const removed = [];
2264
+ for (const name of victims) {
2265
+ const target = path.join(backupsDir, name);
2266
+ try {
2267
+ fs.rmSync(target, { recursive: true, force: true });
2268
+ removed.push(name);
2269
+ } catch {
2270
+ // A backup that cannot be removed must not fail the restart.
2271
+ }
393
2272
  }
2273
+ // A budget nobody could be spent on (every candidate is protected) is reported
2274
+ // too: "nothing was deleted" is information, and silence would look like a
2275
+ // successful prune. The same goes for "the limit is still exceeded": with the
2276
+ // newest copy protected, a limit of 1 over three copies cannot be reached, and
2277
+ // saying nothing about it would look like a prune that did its job.
2278
+ const unit = budget === 1 ? 'copy' : 'copies';
2279
+ const remaining = names.length - removed.length;
2280
+ return {
2281
+ removed,
2282
+ skipped: false,
2283
+ reason: removed.length === 0 && budget > 0
2284
+ ? 'nothing was removed: all ' + budget + ' ' + unit + ' over the limit are kept (the newest copy, or the copy whose restart record says the instance came up with matching content)'
2285
+ : (remaining > limit
2286
+ ? 'the limit of ' + limit + ' was not reached: ' + remaining + ' copies remain, because the newest copy and the copy whose restart record says the instance came up with matching content are never deleted'
2287
+ : null),
2288
+ budget,
2289
+ certifiedCount: certified.length,
2290
+ keptCertified,
2291
+ };
2292
+ } catch (error) {
2293
+ // THE PRUNE MUST NEVER FAIL SILENTLY (2026-11-02). A real hosted machine
2294
+ // kept eighteen copies while `backups.max` was already 2, with an EMPTY
2295
+ // pruneReason: one old copy had no `meta.json`, `null.kind` threw inside the
2296
+ // known-good lookup, and the whole prune died before deleting anything. An
2297
+ // exception here is reported, never swallowed and never turned into "the
2298
+ // prune did not run".
2299
+ return {
2300
+ removed: [],
2301
+ skipped: false,
2302
+ reason: 'the prune failed: ' + (error && error.message ? String(error.message) : String(error)),
2303
+ budget: 0,
2304
+ certifiedCount: 0,
2305
+ keptCertified: null,
2306
+ failed: true,
2307
+ };
394
2308
  }
395
- return removed;
396
2309
  }
397
2310
 
398
2311
  /**
@@ -418,7 +2331,21 @@ export function listBackups(backupsDir) {
418
2331
  for (const name of names.sort().reverse()) {
419
2332
  const dir = path.join(backupsDir, name);
420
2333
  const manifest = readManifest(dir);
421
- const at = manifest && Number.isFinite(manifest.at) ? manifest.at : null;
2334
+ const meta = readBackupMeta(dir);
2335
+ // A DIRECTORY THAT WAS NEVER FINISHED IS NOT A BACKUP.
2336
+ //
2337
+ // Measured on the real machine (2026-11-01): a half-written snapshot (no
2338
+ // manifest, no meta.json, only node_modules-link) was listed like any other,
2339
+ // so it could be picked as "the newest backup" and compared against. A backup
2340
+ // without a timestamp is not a point in time; it is a leftover.
2341
+ //
2342
+ // It is NOT removed here on purpose: whether to spend the disk space is a
2343
+ // separate decision (pruneBackups owns it), not a listing decision.
2344
+ const atSource = manifest && Number.isFinite(manifest.at)
2345
+ ? manifest
2346
+ : (meta && Number.isFinite(meta.at) ? meta : null);
2347
+ if (atSource === null) continue;
2348
+ const at = Number(atSource.at);
422
2349
  out.push({
423
2350
  name,
424
2351
  dir,
@@ -452,6 +2379,75 @@ export function readManifest(backupDir) {
452
2379
  }
453
2380
  }
454
2381
 
2382
+ /**
2383
+ * THE PLUGIN LIST A COPY RECORDS ABOUT ITSELF.
2384
+ *
2385
+ * `[]` means "this copy holds no plugin files" — true for a config-only copy and
2386
+ * for a slim one. `null` means "this copy is too old to say" (no `plugins` field
2387
+ * at all), and the caller must scan the directory instead of believing a count.
2388
+ *
2389
+ * @param {string} backupDir
2390
+ * @returns {Array<{ name: string, version: string|null, install: string }>|null}
2391
+ */
2392
+ export function manifestPluginsOf(backupDir) {
2393
+ const manifest = readManifest(backupDir);
2394
+ if (manifest === null) return null;
2395
+ if (!Array.isArray(manifest.plugins)) return null;
2396
+ const out = [];
2397
+ for (const entry of manifest.plugins) {
2398
+ if (!entry || typeof entry !== 'object') continue;
2399
+ const name = typeof entry.name === 'string' ? entry.name.trim() : '';
2400
+ if (name === '') continue;
2401
+ out.push({
2402
+ name,
2403
+ version: typeof entry.version === 'string' && entry.version !== '' ? entry.version : null,
2404
+ install: typeof entry.install === 'string' && entry.install !== '' ? entry.install : 'link',
2405
+ // HOW it got in, and therefore WHETHER it is in the copy: 'link' = written
2406
+ // by hand, 'localized' = an npm plugin edited here (it has a .git). The
2407
+ // page needs the difference to say which plugins it can roll back.
2408
+ source: entry.source === 'localized' ? 'localized' : 'link',
2409
+ });
2410
+ }
2411
+ return out;
2412
+ }
2413
+
2414
+ /**
2415
+ * WHERE IS ONE PLUGIN INSIDE ONE COPY?
2416
+ *
2417
+ * THE 误报 THIS EXISTS FOR (user report, 2026-11-02): `dsh-codekg`'s files were
2418
+ * sitting in `node_modules-link/dsh-codekg` and the page still said "the backup
2419
+ * has no copy of this plugin", because the copy directory was picked from the
2420
+ * LIVE install shape (`node_modules/<name>` is a symlink?) — and that changes
2421
+ * independently of what the copy holds.
2422
+ *
2423
+ * So the answer comes from the copy itself, in this order:
2424
+ * 1. the manifest's own plugin list (a copy that recorded what it wrote);
2425
+ * 2. the directory itself (`node_modules-link/<name>` present = the files are
2426
+ * there, whatever the manifest says — this is the scan fallback for older
2427
+ * copies);
2428
+ * 3. the live flag, as the last resort (a copy with neither).
2429
+ *
2430
+ * @param {string} backupDir
2431
+ * @param {string} name
2432
+ * @param {{ linked?: boolean }} [options]
2433
+ * @returns {string}
2434
+ */
2435
+ export function backupPluginDirIn(backupDir, name, options = {}) {
2436
+ const root = typeof backupDir === 'string' ? backupDir : '';
2437
+ const plugin = typeof name === 'string' ? name.trim() : '';
2438
+ if (root === '' || plugin === '') return '';
2439
+ const linkDir = path.join(root, LINKS_DIR, plugin);
2440
+ const installedDir = path.join(root, PLUGINS_DIR, plugin);
2441
+ const recorded = manifestPluginsOf(root);
2442
+ if (recorded !== null && recorded.some((entry) => entry.name === plugin)) return linkDir;
2443
+ try {
2444
+ if (fs.existsSync(linkDir) === true) return linkDir;
2445
+ } catch {
2446
+ // An unreadable directory is the next candidate's problem.
2447
+ }
2448
+ return options.linked === true ? linkDir : installedDir;
2449
+ }
2450
+
455
2451
  /**
456
2452
  * Take one backup of the profile.
457
2453
  *
@@ -473,11 +2469,17 @@ export function readManifest(backupDir) {
473
2469
  * includePlugins?: boolean,
474
2470
  * maxBackups?: number,
475
2471
  * now?: number,
2472
+ * kind?: string,
2473
+ * reason?: string|null,
2474
+ * version?: string|null,
2475
+ * bootId?: string|null,
2476
+ * requestId?: number|null,
2477
+ * intervention?: boolean|null,
476
2478
  * onProgress?: ((progress: { files: number, bytes: number, total: number, filesTotal: number }) => void)|null,
477
2479
  * shouldCancel?: (() => boolean)|null,
478
2480
  * measure?: ((profileDir: string, includePlugins: boolean) => { files: number, bytes: number })|null,
479
2481
  * }} options
480
- * @returns {{ ok: boolean, cancelled: boolean, dir: string|null, stamp: string|null, at: number|null, atLocal: string|null, durationMs: number, bytes: number, totalBytes: number, fileCount: number, files: string[], includePlugins: boolean, pruned: string[], error: string|null }}
2482
+ * @returns {{ ok: boolean, cancelled: boolean, dir: string|null, stamp: string|null, at: number|null, atLocal: string|null, kind: string, durationMs: number, bytes: number, totalBytes: number, fileCount: number, files: string[], includePlugins: boolean, pruned: string[], pruneSkipped: boolean, pruneReason: string|null, error: string|null }}
481
2483
  */
482
2484
  export async function createBackup({
483
2485
  profileDir,
@@ -485,13 +2487,52 @@ export async function createBackup({
485
2487
  includePlugins = true,
486
2488
  maxBackups = DEFAULT_MAX_BACKUPS,
487
2489
  now = Date.now(),
2490
+ kind = KIND_SNAPSHOT,
2491
+ reason = null,
2492
+ // WHAT PUT THIS COPY ON DISK, stated directly (see `BACKUP_TRIGGERS`). A caller
2493
+ // that says nothing gets it derived from `kind` / `reason`, so every existing
2494
+ // call site keeps its meaning without passing anything new.
2495
+ trigger = null,
2496
+ version = null,
2497
+ bootId = null,
2498
+ requestId = null,
2499
+ // Did THIS boot need an intervention (the watchdog restored a copy, a human
2500
+ // had to step in)? The caller knows; `null` means "not recorded", which is not
2501
+ // the same answer as `false`.
2502
+ intervention = null,
488
2503
  onProgress = null,
489
2504
  shouldCancel = null,
490
2505
  measure = null,
2506
+ linkProgress = null,
2507
+ // SLIM BACKUP, ON BY DEFAULT (user decision, 2026-11-01).
2508
+ //
2509
+ // Only the things that cannot be fetched again are stored: the configuration
2510
+ // and the plugins installed as links (i.e. the ones written by hand).
2511
+ // Third-party plugins are NOT copied - `config/pnpm-lock.yaml` pins every
2512
+ // version and integrity hash, so reinstalling restores EXACTLY the same
2513
+ // versions, not the latest ones.
2514
+ //
2515
+ // Measured on the real machine: a backup went from 308 MB to about 100 MB.
2516
+ // Pass `slimBackup: false` for the old, full behaviour.
2517
+ slimBackup = true,
2518
+ // WHETHER THIS COPY MAY TRIM THE LIST (default: yes, every regular backup keeps
2519
+ // the history at `maxBackups`). The manual rollback turns it OFF for its undo
2520
+ // copy (2026-11-02): that copy is written in the MIDDLE of a rollback, and a
2521
+ // prune there can delete the very copy the user is rolling back to — measured
2522
+ // on the real fixtures, where the safety copy pushed the list over the limit
2523
+ // and the picked copy was the one that went. The next regular backup trims.
2524
+ pruneEnabled = true,
491
2525
  } = {}) {
492
2526
  const profile = typeof profileDir === 'string' ? profileDir.trim() : '';
493
2527
  const root = typeof backupsDir === 'string' ? backupsDir.trim() : '';
2528
+ // A snapshot taken before a restart is NOT proven yet; only an explicit
2529
+ // 'known-good' (the first-install seed) starts out proven.
2530
+ const metaKind = kind === KIND_KNOWN_GOOD || kind === KIND_UNKNOWN ? kind : KIND_SNAPSHOT;
494
2531
  const startedAt = Date.now();
2532
+ // WHERE THE TIME WENT (2026-11-01): the copy, the signatures and the linking
2533
+ // are timed separately, so "this backup took three minutes" can be answered
2534
+ // with a number instead of a guess.
2535
+ let copyMs = 0;
495
2536
  const base = {
496
2537
  ok: false,
497
2538
  cancelled: false,
@@ -499,13 +2540,34 @@ export async function createBackup({
499
2540
  stamp: null,
500
2541
  at: null,
501
2542
  atLocal: null,
2543
+ kind: metaKind,
502
2544
  durationMs: 0,
503
2545
  bytes: 0,
504
2546
  totalBytes: 0,
505
2547
  fileCount: 0,
506
2548
  files: [],
507
2549
  includePlugins: includePlugins === true,
2550
+ links: [],
2551
+ linkFiles: 0,
2552
+ linkBytes: 0,
2553
+ // HOW MANY PLUGINS WERE COPIED AS "LOCAL", AND HOW MANY WERE ONLY LISTED
2554
+ // (user decision, 2026-11-02): a third-party plugin is reproducible from the
2555
+ // pinned lockfile, so it is named but not copied.
2556
+ localized: 0,
2557
+ thirdParty: 0,
508
2558
  pruned: [],
2559
+ // WHY NOTHING WAS PRUNED, when nothing was: `skipped` marks the strict-mode
2560
+ // floor (no certified copy exists, so the history is left untouched).
2561
+ pruneSkipped: false,
2562
+ pruneReason: null,
2563
+ // WHERE THE TIME WENT: copying the tree, computing signatures, linking.
2564
+ timings: { copyMs: 0, signMs: 0, dedupMs: 0 },
2565
+ // DEDUP COUNTERS (2026-11-01): how many plugin copies were reused by hard
2566
+ // link instead of written again, and how many were really copied. `bytes`
2567
+ // keeps its old meaning (the LOGICAL size of the backup) - these two say
2568
+ // what it cost on disk.
2569
+ linked: 0,
2570
+ copied: 0,
509
2571
  error: null,
510
2572
  };
511
2573
  if (profile === '') return { ...base, error: 'createBackup requires profileDir' };
@@ -543,7 +2605,11 @@ export async function createBackup({
543
2605
  })();
544
2606
 
545
2607
  const stamp = backupStamp(now);
546
- const dir = path.join(root, stamp);
2608
+ const stampDir = path.join(root, stamp);
2609
+ // STAGING (2026-11-01): everything is written into a hidden staging directory
2610
+ // and renamed into place only once the backup is complete. A half-written
2611
+ // backup can then never appear among the real ones.
2612
+ const dir = path.join(root, '.staging-' + stamp);
547
2613
  const files = [];
548
2614
  let bytes = 0;
549
2615
  let fileCount = 0;
@@ -602,9 +2668,47 @@ export async function createBackup({
602
2668
  }
603
2669
  }
604
2670
 
605
- if (!cancelled && includePlugins === true) {
2671
+ // FILE-LEVEL COMPLETENESS FOR THE CONFIGURATION (2026-11-01).
2672
+ //
2673
+ // The gate further down only proves that the CONFIG DIRECTORY exists, and an
2674
+ // EMPTY config directory passes it. That is exactly the shape of the incident
2675
+ // that made this check exist: 23 backups on the real machine held nothing but
2676
+ // `node_modules-link/` — no configuration and no manifest — and every reader
2677
+ // treated them as ordinary copies. A backup whose configuration was silently
2678
+ // skipped is not a backup; it is a directory that looks like one.
2679
+ //
2680
+ // Every configuration file the SOURCE had must be in the copy. A source file
2681
+ // that is missing is not an error (a profile need not have a lock file); a
2682
+ // source file that exists and did NOT get copied is.
2683
+ const configSourceNames = [];
2684
+ for (const name of CONFIG_FILES) {
2685
+ try {
2686
+ if (fs.existsSync(path.join(profile, name)) === true) configSourceNames.push(name);
2687
+ } catch {
2688
+ // An unreadable source file is not one this backup can promise.
2689
+ }
2690
+ }
2691
+ const configNotCopied = configSourceNames.filter(
2692
+ (name) => files.includes(path.join(CONFIG_DIR, name)) !== true,
2693
+ );
2694
+ if (!cancelled && configNotCopied.length > 0) {
2695
+ removeBackupDir(dir);
2696
+ return {
2697
+ ...base,
2698
+ stamp,
2699
+ totalBytes: measured.bytes,
2700
+ error: 'the backup is incomplete: the configuration was not copied ('
2701
+ + configNotCopied.join(', ') + '), so the directory was removed',
2702
+ incomplete: true,
2703
+ missingConfig: configNotCopied,
2704
+ };
2705
+ }
2706
+
2707
+ // SLIM BACKUP: the third-party tree is not copied at all (it is reinstallable).
2708
+ if (!cancelled && includePlugins === true && slimBackup !== true) {
606
2709
  const source = path.join(profile, PLUGINS_DIR);
607
2710
  if (fs.existsSync(source)) {
2711
+ const copyStartedAt = Date.now()
608
2712
  const copied = await copyTreeWithProgress({
609
2713
  source,
610
2714
  destination: path.join(dir, PLUGINS_DIR),
@@ -615,6 +2719,7 @@ export async function createBackup({
615
2719
  report(false);
616
2720
  },
617
2721
  });
2722
+ copyMs = Date.now() - copyStartedAt
618
2723
  if (copied.cancelled) {
619
2724
  cancelled = true;
620
2725
  } else if (copied.ok || copied.files > 0) {
@@ -625,6 +2730,116 @@ export async function createBackup({
625
2730
  }
626
2731
  }
627
2732
 
2733
+ // THE LINKED PLUGINS (user decision, 2026-10-30). `node_modules/<name>` is a
2734
+ // reparse point for a `link:` installation, so the tree copy above records
2735
+ // the LINK and none of its contents. The contents are copied into their own
2736
+ // directory, and the manifest says which plugin came from where - that is
2737
+ // what makes a linked plugin (this one included) restorable at all.
2738
+ let links = [];
2739
+ let linkFiles = 0;
2740
+ let linkBytes = 0;
2741
+ // The plugins that are only NAMED in the manifest (third-party as published).
2742
+ let thirdParty = [];
2743
+ if (!cancelled && includePlugins === true) {
2744
+ const collected = collectLinkedPlugins({ profileDir: profile });
2745
+ if (typeof linkProgress === 'function') {
2746
+ try {
2747
+ linkProgress(collected);
2748
+ } catch {
2749
+ // Reporting the links never fails a backup.
2750
+ }
2751
+ }
2752
+ for (const link of collected.links) {
2753
+ if (isCancelled()) break;
2754
+ const entry = { ...link, copied: false, copyError: null };
2755
+ if (link.exists !== true || link.readable !== true) {
2756
+ entry.copyError = 'the link target is missing or unreadable: it cannot be copied';
2757
+ links.push(entry);
2758
+ continue;
2759
+ }
2760
+ const copied = await copyTreeWithProgress({
2761
+ source: link.target,
2762
+ destination: path.join(dir, LINKS_DIR, link.name),
2763
+ shouldCancel: isCancelled,
2764
+ // NEVER THE HISTORY: `.git` is rebuildable and was the bulk of every
2765
+ // backup (962 files / ~74 MB for one linked plugin on the real machine).
2766
+ excludeNames: BACKUP_SKIP_NAMES,
2767
+ });
2768
+ if (copied.cancelled) {
2769
+ cancelled = true;
2770
+ break;
2771
+ }
2772
+ if (copied.ok !== true && copied.files === 0) {
2773
+ entry.copyError = copied.error || 'the link target could not be copied';
2774
+ links.push(entry);
2775
+ continue;
2776
+ }
2777
+ entry.copied = true;
2778
+ entry.files = copied.files;
2779
+ entry.bytes = copied.bytes;
2780
+ linkFiles += copied.files;
2781
+ linkBytes += copied.bytes;
2782
+ fileCount += copied.files;
2783
+ bytes += copied.bytes;
2784
+ files.push(path.join(LINKS_DIR, link.name));
2785
+ report(false);
2786
+ links.push(entry);
2787
+ }
2788
+
2789
+ // LOCALIZED NPM PLUGINS (user decision, 2026-11-02).
2790
+ //
2791
+ // A plugin installed from npm and then EDITED here carries its own `.git`
2792
+ // (it was cloned or re-inited). Those edits exist nowhere else, so the tree
2793
+ // is copied exactly like a linked plugin — into `node_modules/<name>`, which
2794
+ // is where a restore looks for it. `.git` itself is skipped (rebuildable).
2795
+ //
2796
+ // An npm plugin WITHOUT `.git` is "as published": reinstalling reproduces
2797
+ // it, so it is only LISTED, never copied. A copy that cannot be written is
2798
+ // REPORTED (copyError), never silently skipped.
2799
+ const local = collectLocalPluginDirs({ profileDir: profile });
2800
+ for (const plugin of local.localized) {
2801
+ if (isCancelled()) break;
2802
+ const entry = {
2803
+ name: plugin.name,
2804
+ target: plugin.dir,
2805
+ kind: 'directory',
2806
+ exists: true,
2807
+ readable: true,
2808
+ files: 0,
2809
+ bytes: 0,
2810
+ copied: false,
2811
+ copyError: null,
2812
+ source: 'localized',
2813
+ };
2814
+ const copied = await copyTreeWithProgress({
2815
+ source: plugin.dir,
2816
+ destination: path.join(dir, PLUGINS_DIR, plugin.name),
2817
+ shouldCancel: isCancelled,
2818
+ excludeNames: BACKUP_SKIP_NAMES,
2819
+ });
2820
+ if (copied.cancelled) {
2821
+ cancelled = true;
2822
+ break;
2823
+ }
2824
+ if (copied.ok !== true && copied.files === 0) {
2825
+ entry.copyError = copied.error || 'the plugin directory could not be copied';
2826
+ links.push(entry);
2827
+ continue;
2828
+ }
2829
+ entry.copied = true;
2830
+ entry.files = copied.files;
2831
+ entry.bytes = copied.bytes;
2832
+ linkFiles += copied.files;
2833
+ linkBytes += copied.bytes;
2834
+ fileCount += copied.files;
2835
+ bytes += copied.bytes;
2836
+ files.push(path.join(PLUGINS_DIR, plugin.name));
2837
+ report(false);
2838
+ links.push(entry);
2839
+ }
2840
+ thirdParty = local.thirdParty;
2841
+ }
2842
+
628
2843
  if (cancelled) {
629
2844
  removeBackupDir(dir);
630
2845
  return {
@@ -637,6 +2852,36 @@ export async function createBackup({
637
2852
  };
638
2853
  }
639
2854
 
2855
+ // WHAT ACTUALLY WENT IN (user report, 2026-11-02).
2856
+ //
2857
+ // The page used to GUESS from the live profile whether a plugin is inside a
2858
+ // copy — and when the live install changed shape (a `link:` replaced by an
2859
+ // npm install, or the other way round), the guess said "the backup has no copy
2860
+ // of this plugin" while `node_modules-link/<name>` sat right there, complete.
2861
+ // The copy therefore RECORDS its own plugin list. An older copy without this
2862
+ // field is still read correctly: the interface scans the directory then.
2863
+ const copiedPlugins = includePlugins === true
2864
+ ? links
2865
+ .filter((entry) => entry.copied === true)
2866
+ .map((entry) => {
2867
+ const source = entry.source === 'localized' ? 'localized' : 'link'
2868
+ const inside = source === 'localized'
2869
+ ? path.join(dir, PLUGINS_DIR, entry.name)
2870
+ : path.join(dir, LINKS_DIR, entry.name)
2871
+ return {
2872
+ name: entry.name,
2873
+ // The VERSION OF THE COPY, not of whatever is installed now: the whole
2874
+ // point of the list is "what would come back if I restored this".
2875
+ version: pluginVersionIn(inside),
2876
+ // HOW it was installed, and WHY it is in the copy: a link is hand
2877
+ // written, a localized one is an npm plugin edited here.
2878
+ install: source === 'localized' ? 'npm' : 'link',
2879
+ source,
2880
+ kind: typeof entry.kind === 'string' ? entry.kind : '',
2881
+ }
2882
+ })
2883
+ : []
2884
+
640
2885
  const manifest = {
641
2886
  stamp,
642
2887
  at: Number(now),
@@ -645,10 +2890,28 @@ export async function createBackup({
645
2890
  atLocal: backupLocalTime(Number(now)),
646
2891
  profileDir: profile,
647
2892
  includePlugins: includePlugins === true,
2893
+ // THE PLUGINS THAT ARE NOT IN THE COPY, AND WHY (user decision, 2026-11-02):
2894
+ // installed from npm, never edited here, so a reinstall brings them back
2895
+ // exactly. They are named with their version so the page can say so plainly
2896
+ // instead of implying they can be rolled back.
2897
+ thirdParty: includePlugins === true
2898
+ ? thirdParty.map((entry) => ({ name: entry.name, version: entry.version ?? null }))
2899
+ : [],
648
2900
  files,
649
2901
  bytes,
650
2902
  totalBytes: measured.bytes,
651
2903
  fileCount,
2904
+ // THE LINKED PLUGINS: one entry per `link:` installation, with the target
2905
+ // it pointed at and what was really copied. Only ADDED to the manifest -
2906
+ // every existing field keeps its name and meaning, because three readers
2907
+ // (the restore script, the watchdog and lib/rescue.js) depend on them.
2908
+ links,
2909
+ linkFiles,
2910
+ linkBytes,
2911
+ // THE PLUGIN LIST OF THIS COPY: `{ name, version, install }` for every
2912
+ // plugin whose FILES are in `node_modules-link/`. A config-only or slim copy
2913
+ // honestly says `[]` — it really holds none.
2914
+ plugins: copiedPlugins,
652
2915
  };
653
2916
  try {
654
2917
  fs.writeFileSync(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2), 'utf8');
@@ -656,20 +2919,193 @@ export async function createBackup({
656
2919
  return { ...base, stamp, files, bytes, totalBytes: measured.bytes, fileCount, error: 'could not write the backup manifest: ' + (error && error.message ? error.message : String(error)) };
657
2920
  }
658
2921
 
659
- const pruned = pruneBackups(root, maxBackups);
2922
+ // THE IDENTITY, right next to the manifest: what this copy is and where it
2923
+ // came from. Best effort — a copy whose meta cannot be written stays an
2924
+ // 'unknown' one on disk (never used for an automatic rollback).
2925
+ // INTEGRITY SELF-CHECK (2026-11-01): a backup that is quietly missing files is
2926
+ // worse than no backup — it looks restorable and is not. The copy above reports
2927
+ // how many files it wrote; the measurement at the start says how many the
2928
+ // profile holds. When the copy came up short, the half-written directory is
2929
+ // removed, no meta.json is written, and the caller is told why.
2930
+ // A backup must also still have its top-level pieces: losing a whole directory
2931
+ // is exactly the shape of the 2026-11-01 incident, and a file COUNT alone can
2932
+ // hide it when another directory gained files.
2933
+ // ONLY WHAT THE SOURCE HAD. A profile with no node_modules produces a backup
2934
+ // with no node_modules, and that is a complete backup, not a broken one.
2935
+ const requiredDirs = [CONFIG_DIR]
2936
+ // A SLIM BACKUP DELIBERATELY HAS NO node_modules: requiring it would judge a
2937
+ // perfectly good slim backup incomplete and throw it away.
2938
+ if (includePlugins === true && slimBackup !== true) {
2939
+ let sourceHasPlugins = false
2940
+ try {
2941
+ sourceHasPlugins = fs.existsSync(path.join(profile, PLUGINS_DIR)) === true
2942
+ } catch {
2943
+ sourceHasPlugins = false
2944
+ }
2945
+ if (sourceHasPlugins) requiredDirs.push(PLUGINS_DIR)
2946
+ }
2947
+ const missingDirs = []
2948
+ for (const name of requiredDirs) {
2949
+ try {
2950
+ if (fs.existsSync(path.join(dir, name)) !== true) missingDirs.push(name)
2951
+ } catch {
2952
+ missingDirs.push(name)
2953
+ }
2954
+ }
2955
+ if (!cancelled && missingDirs.length > 0) {
2956
+ try {
2957
+ fs.rmSync(dir, { recursive: true, force: true })
2958
+ } catch {
2959
+ // If it cannot be removed, the error below still says it is unusable.
2960
+ }
2961
+ return {
2962
+ ...base,
2963
+ stamp,
2964
+ totalBytes: measured.bytes,
2965
+ error: 'the backup is incomplete: missing ' + missingDirs.join(', ') + ', so the directory was removed',
2966
+ incomplete: true,
2967
+ missingDirs,
2968
+ }
2969
+ }
2970
+ // The measurement counts the WHOLE profile, node_modules included. A slim backup
2971
+ // deliberately leaves that tree out, so comparing against it would call every
2972
+ // slim backup incomplete. The comparison only makes sense for a full backup.
2973
+ if (!cancelled && includePlugins === true && slimBackup !== true && measured.files > 0 && fileCount < measured.files) {
2974
+ try {
2975
+ fs.rmSync(dir, { recursive: true, force: true })
2976
+ } catch {
2977
+ // A directory that cannot be removed must not throw here; the error below
2978
+ // already tells the caller the backup is not usable.
2979
+ }
2980
+ return {
2981
+ ...base,
2982
+ stamp,
2983
+ totalBytes: measured.bytes,
2984
+ error: 'the backup is incomplete: wrote ' + fileCount + ' of ' + measured.files
2985
+ + ' files, so the directory was removed',
2986
+ incomplete: true,
2987
+ wroteFiles: fileCount,
2988
+ sourceFiles: measured.files,
2989
+ };
2990
+ }
2991
+
2992
+ // DEDUP (user decision, 2026-11-01): a backup should record CHANGES, not the
2993
+ // number of restarts. After the tree has been copied, every plugin whose
2994
+ // content digest already exists in an OLDER backup is replaced - inside THIS
2995
+ // new backup only - by hard links to that older copy. Nothing older is ever
2996
+ // touched, and `bytes` keeps its old meaning (the logical size), so the
2997
+ // numbers the interface already shows do not move.
2998
+ writeBackupMeta(dir, {
2999
+ at: Number(now),
3000
+ // WHAT PUT THIS COPY THERE — the fact that replaced the old `kind` label and
3001
+ // the old `reason` string (2026-11-02). A copy taken here has NO BOOT on
3002
+ // record, so the record says nothing about one: `boot` stays null, and the
3003
+ // copy is a candidate at best, never "the good one".
3004
+ // The first-install copy keeps its own positioning through the trigger: it is
3005
+ // what a fresh installation has, and it was never proven.
3006
+ trigger: triggerOf({ trigger, kind: metaKind, reason }),
3007
+ version,
3008
+ bootId,
3009
+ requestId,
3010
+ // WHAT IT HOLDS, from the bytes that were just written: a later edit of the
3011
+ // same directory is then detectable instead of invisible.
3012
+ contentFingerprint: contentFingerprintOf(dir),
3013
+ contentScope: contentScopeOf(dir, { includePlugins: includePlugins === true, slim: slimBackup === true }),
3014
+ // WHETHER THIS RESTART NEEDED HELP is known to the caller, not here: an
3015
+ // unrecorded answer stays null (never `false`, which would claim we know).
3016
+ intervention: typeof intervention === 'boolean' ? intervention : null,
3017
+ });
3018
+
3019
+ // ONLY A COMPLETE COPY MAY WEAR THE REAL NAME (2026-11-02). The rename below is
3020
+ // the one moment a copy becomes visible to every reader (`listBackups`,
3021
+ // `selectRestoreTarget`, `restore-profile.ps1` and the watchdog all look for a
3022
+ // manifest), so the copy is CHECKED here instead of trusted: a staging directory
3023
+ // that lost its manifest or its configuration on the way is removed and
3024
+ // reported, and it never appears under the name a real backup carries.
3025
+ //
3026
+ // WHY THIS EXISTS AT ALL: on the real machine 24 directories carrying a backup
3027
+ // stamp held nothing but `node_modules-link/` - no manifest, no configuration -
3028
+ // and every reader had to learn to skip them. A name that promises a backup and
3029
+ // delivers half of one is the bug this check removes.
3030
+ if (backupIsComplete(dir) !== true) {
3031
+ try {
3032
+ fs.rmSync(dir, { recursive: true, force: true });
3033
+ } catch {
3034
+ // A half-written copy that cannot be removed is still not a backup.
3035
+ }
3036
+ return {
3037
+ ...base,
3038
+ stamp,
3039
+ totalBytes: measured.bytes,
3040
+ error: 'the backup is incomplete (no manifest or no configuration), so it was not published',
3041
+ };
3042
+ }
3043
+
3044
+ // ATOMIC PUBLISH: only now does the backup get its real name.
3045
+ try {
3046
+ fs.renameSync(dir, stampDir);
3047
+ } catch (error) {
3048
+ try {
3049
+ fs.rmSync(dir, { recursive: true, force: true });
3050
+ } catch {
3051
+ // A staging directory that cannot be removed is still not a backup.
3052
+ }
3053
+ return {
3054
+ ...base,
3055
+ stamp,
3056
+ totalBytes: measured.bytes,
3057
+ error: 'the finished backup could not be moved into place: '
3058
+ + String(error && error.message ? error.message : error),
3059
+ };
3060
+ }
3061
+
3062
+ const prune = pruneEnabled === false
3063
+ ? { removed: [], skipped: false, reason: 'the caller asked for no trimming (a copy taken in the middle of a rollback)', budget: 0, certifiedCount: 0, keptCertified: null }
3064
+ : pruneBackups(root, maxBackups);
3065
+ // THE GARBAGE THAT WAS SITTING THERE (2026-11-01): on the real machine 24 of
3066
+ // the 25 directories held NEITHER a manifest NOR a configuration file — 1.78 GB
3067
+ // that could never be selected as a restore point. They are removed here, with
3068
+ // a hard budget, and what was removed travels back to the caller.
3069
+ const hygiene = removeIncompleteBackups(root);
660
3070
  return {
661
3071
  ...base,
662
3072
  ok: true,
663
- dir,
3073
+ timings: { copyMs, signMs: 0, dedupMs: 0 },
3074
+ dir: stampDir,
664
3075
  stamp,
665
3076
  at: Number(now),
666
3077
  atLocal: manifest.atLocal,
3078
+ kind: metaKind,
667
3079
  durationMs: Math.max(0, Date.now() - startedAt),
668
3080
  bytes,
669
3081
  totalBytes: measured.bytes,
670
3082
  fileCount,
671
3083
  files,
672
- pruned,
3084
+ // THE LOCAL VALUES, NOT THE BASE ONES: `...base` carries the EMPTY
3085
+ // placeholders (`links: []`, `linkFiles: 0`), so a caller that read
3086
+ // `result.links` was told "no linked plugins" while the manifest beside it
3087
+ // listed five and their contents had really been copied (measured on the
3088
+ // real profile, 2026-10-30: manifest 5 links / 1 217 files, return `[]`).
3089
+ // The manifest was right and the ANSWER was wrong.
3090
+ linkFiles,
3091
+ linkBytes,
3092
+ links,
3093
+ // HOW MANY PLUGINS WENT IN AS "LOCAL", AND HOW MANY WERE ONLY NAMED
3094
+ // (user decision, 2026-11-02). The page has to be able to say "three are
3095
+ // backed up, eleven are not and will be reinstalled" without re-reading the
3096
+ // manifest itself.
3097
+ localized: copiedPlugins.filter((entry) => entry.source === 'localized').length,
3098
+ thirdParty: manifest.thirdParty.length,
3099
+ // `pruned` KEEPS ITS OLD MEANING: the names that were really deleted. The
3100
+ // two new fields are the answer to "nothing was deleted — why?": when no
3101
+ // copy carries certification, pruning deliberately leaves everything alone
3102
+ // and the upper layer has to be able to say so instead of staying silent.
3103
+ pruned: prune.removed,
3104
+ pruneSkipped: prune.skipped,
3105
+ pruneReason: prune.reason,
3106
+ // WHAT THE JUNK SWEEP DID (count + bytes), so the caller can say out loud
3107
+ // where the disk space went instead of silently deleting things.
3108
+ hygiene,
673
3109
  };
674
3110
  }
675
3111
 
@@ -745,3 +3181,314 @@ export function clearRollbackReport(stateDir) {
745
3181
  return false;
746
3182
  }
747
3183
  }
3184
+
3185
+ /**
3186
+ * REMOVE THE DIRECTORIES THAT ARE NOT BACKUPS AT ALL (2026-11-01).
3187
+ *
3188
+ * MEASURED ON THE REAL MACHINE: 25 directories under the backups root, 24 of
3189
+ * them holding **neither a manifest nor a configuration file** — 1.78 GB — while
3190
+ * the only usable copy was 0.1 MB. Those directories can never be selected as a
3191
+ * restore point (see `listBackups` / `backupIsComplete`), so all they did was
3192
+ * take up disk space forever.
3193
+ *
3194
+ * WHAT IS A VICTIM, EXACTLY: a directory that has NO `manifest.json` **AND** no
3195
+ * configuration file, that is older than `minAgeMs` (a copy being written right
3196
+ * now is left alone) and is not a dot entry. A manifest alone is enough to keep
3197
+ * a directory: a copy that only lacks `meta.json` predates it and is restorable.
3198
+ *
3199
+ * WHY THE CONFIGURATION NO LONGER SAVES A DIRECTORY (user decision, 2026-11-02):
3200
+ * the real machine kept producing directories that copied the whole plugin tree
3201
+ * (~150 MB, hundreds of files) and died before writing the manifest. They have a
3202
+ * configuration file, so the old rule spared them, and nothing could ever restore
3203
+ * from them. Without a manifest a directory is not a point in time — it is the
3204
+ * wreck of one — so the manifest is the only thing that decides, and anything
3205
+ * else is deleted once it is old enough.
3206
+ *
3207
+ * WHAT IS NEVER TOUCHED:
3208
+ * - anything starting with `.` — that is `.staging-<stamp>`, a backup being
3209
+ * written right now; deleting it would corrupt a concurrent backup;
3210
+ * - anything that would pass `backupIsComplete`;
3211
+ * - anything that is not a directory.
3212
+ *
3213
+ * BUDGET: a hard ceiling on both the number of directories and the bytes per
3214
+ * call (32 dirs / 2 GB by default). Once the ceiling is reached the rest is only
3215
+ * COUNTED (`skipped`) and reported — a boot path must never decide to delete an
3216
+ * unbounded amount of the user's disk.
3217
+ *
3218
+ * @param {string} backupsDir
3219
+ * @param {{ dirs?: number, bytes?: number, dryRun?: boolean }} [options]
3220
+ * @returns {{ scanned: number, removed: string[], skipped: string[], bytes: number, capped: boolean, reason: string }}
3221
+ */
3222
+ export function removeIncompleteBackups(backupsDir, options) {
3223
+ const settings = options !== null && typeof options === 'object' ? options : {};
3224
+ const limitDirs = Number.isFinite(Number(settings.dirs)) && Number(settings.dirs) > 0
3225
+ ? Math.trunc(Number(settings.dirs))
3226
+ : DEFAULT_JUNK_LIMITS.dirs;
3227
+ const limitBytes = Number.isFinite(Number(settings.bytes)) && Number(settings.bytes) > 0
3228
+ ? Math.trunc(Number(settings.bytes))
3229
+ : DEFAULT_JUNK_LIMITS.bytes;
3230
+ const dryRun = settings.dryRun === true;
3231
+ // NO AGE GATE BY DEFAULT HERE, ON PURPOSE: this sweep runs (a) at the end of a
3232
+ // backup, where the copy that was just published carries a manifest and the one
3233
+ // being written is still `.staging-*` (excluded by name), and (b) at boot, when
3234
+ // this process is the only writer. The 120 s gate belongs to the boot sweep that
3235
+ // walks directories left behind by an EARLIER run (`sweepIncompleteBackups`).
3236
+ const minAgeMs = Number.isFinite(Number(settings.minAgeMs)) && Number(settings.minAgeMs) >= 0
3237
+ ? Math.trunc(Number(settings.minAgeMs))
3238
+ : 0;
3239
+ const now = Number.isFinite(Number(settings.now)) ? Number(settings.now) : Date.now();
3240
+ const out = { scanned: 0, removed: [], skipped: [], bytes: 0, capped: false, reason: '' };
3241
+ const root = typeof backupsDir === 'string' ? backupsDir.trim() : '';
3242
+ if (root === '') {
3243
+ out.reason = 'no backups directory';
3244
+ return out;
3245
+ }
3246
+ let entries = [];
3247
+ try {
3248
+ entries = fs.readdirSync(root, { withFileTypes: true });
3249
+ } catch {
3250
+ out.reason = 'the backups directory could not be read';
3251
+ return out;
3252
+ }
3253
+ const names = entries
3254
+ .filter((entry) => entry.isDirectory() === true)
3255
+ .map((entry) => entry.name)
3256
+ .sort();
3257
+ for (const name of names) {
3258
+ // `.staging-*` is a backup being written RIGHT NOW (and any other dot entry
3259
+ // is not ours to judge).
3260
+ if (name.startsWith('.')) continue;
3261
+ out.scanned += 1;
3262
+ const dir = path.join(root, name);
3263
+ const hasManifest = (() => {
3264
+ try {
3265
+ return fs.existsSync(path.join(dir, MANIFEST_NAME)) === true;
3266
+ } catch {
3267
+ return true; // unreadable → not provably manifest-less → keep it
3268
+ }
3269
+ })();
3270
+ // A MANIFEST IS WHAT MAKES A DIRECTORY A COPY (user decision, 2026-11-02).
3271
+ // Before this rule the test was "no manifest AND no configuration", which
3272
+ // spared exactly the shape that keeps appearing on real machines: a backup
3273
+ // that died after copying the plugin tree (hundreds of files, ~150 MB) but
3274
+ // before writing its manifest. Nothing can restore from it, `listBackups`
3275
+ // refuses to list it, and it stays on disk forever. A copy with a manifest
3276
+ // but no `meta.json` is a DIFFERENT case: it is restorable (old copies
3277
+ // legitimately predate `meta.json`), so it is kept and only counted.
3278
+ if (hasManifest === true) continue; // a copy
3279
+ const ageOk = (() => {
3280
+ try {
3281
+ return minAgeMs <= 0 || now - fs.statSync(dir).mtimeMs >= minAgeMs;
3282
+ } catch {
3283
+ return false; // the age cannot be established → do not delete
3284
+ }
3285
+ })();
3286
+ if (ageOk !== true) {
3287
+ out.skipped.push(name); // too young: a writer may be filling it right now
3288
+ continue;
3289
+ }
3290
+ const bytes = (() => {
3291
+ try {
3292
+ return directorySize(dir).bytes;
3293
+ } catch {
3294
+ return 0;
3295
+ }
3296
+ })();
3297
+ if (out.removed.length >= limitDirs || (out.bytes + bytes) > limitBytes) {
3298
+ out.capped = true;
3299
+ out.skipped.push(name);
3300
+ continue;
3301
+ }
3302
+ if (dryRun === true) {
3303
+ out.removed.push(name);
3304
+ out.bytes += bytes;
3305
+ continue;
3306
+ }
3307
+ if (removeBackupDir(dir) === true) {
3308
+ out.removed.push(name);
3309
+ out.bytes += bytes;
3310
+ } else {
3311
+ out.skipped.push(name);
3312
+ }
3313
+ }
3314
+ return out;
3315
+ }
3316
+
3317
+ /**
3318
+ * REMOVE LEFTOVER STAGING DIRECTORIES (2026-11-01).
3319
+ *
3320
+ * A backup is written into `.staging-<stamp>` and renamed into place when it is
3321
+ * complete, so a crash mid-copy leaves a staging directory behind. Those are not
3322
+ * backups and must never be mistaken for one — but nothing else will ever clean
3323
+ * them up, so this does, at boot.
3324
+ *
3325
+ * It ONLY touches names starting with `.staging-`. A real backup directory is
3326
+ * never removed here, whatever state it is in.
3327
+ *
3328
+ * @param {string} backupsDir
3329
+ * @param {{ fs?: object }} [options]
3330
+ * @returns {{ removed: string[], reason: string }}
3331
+ */
3332
+ export function cleanupStaleStaging(backupsDir, options) {
3333
+ // `null` must not throw either — this runs at boot.
3334
+ const { fs: fsLike = fs } = options !== null && typeof options === 'object' ? options : {}
3335
+ const out = { removed: [], reason: '' }
3336
+ try {
3337
+ const root = typeof backupsDir === 'string' ? backupsDir.trim() : ''
3338
+ if (root === '') {
3339
+ out.reason = 'no backups directory'
3340
+ return out
3341
+ }
3342
+ let entries = []
3343
+ try {
3344
+ entries = fsLike.readdirSync(root, { withFileTypes: true })
3345
+ } catch {
3346
+ out.reason = 'the backups directory could not be read'
3347
+ return out
3348
+ }
3349
+ for (const entry of entries) {
3350
+ if (entry.isDirectory() !== true) continue
3351
+ if (entry.name.startsWith('.staging-') !== true) continue
3352
+ try {
3353
+ fsLike.rmSync(path.join(root, entry.name), { recursive: true, force: true })
3354
+ out.removed.push(entry.name)
3355
+ } catch {
3356
+ // A staging directory that will not go away is reported by its absence
3357
+ // from `removed`; it is never escalated into a failed boot.
3358
+ }
3359
+ }
3360
+ return out
3361
+ } catch (error) {
3362
+ out.reason = String(error && error.message ? error.message : error)
3363
+ return out
3364
+ }
3365
+ }
3366
+
3367
+ /**
3368
+ * WHICH BACKUP DIRECTORIES ARE NOT USABLE COPIES (2026-11-02).
3369
+ *
3370
+ * A directory under `backups/` is a COPY only when it carries a manifest. One
3371
+ * without a manifest is either a leftover of a restart that died mid-write or
3372
+ * something else entirely — either way it is not a point in time, and
3373
+ * `listBackups` already refuses to list it. It stays on disk forever otherwise,
3374
+ * which is how a machine ended up with three directories while the page said
3375
+ * two copies.
3376
+ *
3377
+ * The judgement is deliberately narrow: a directory is a copy only when it
3378
+ * carries a manifest, and a copy with a manifest but no `meta.json` is merely
3379
+ * REPORTED (`incomplete`), never deleted — old copies legitimately predate
3380
+ * `meta.json`. Without a manifest nothing can restore from it, no matter how
3381
+ * many files it holds.
3382
+ *
3383
+ * @param {string} backupsDir
3384
+ * @returns {{ scanned: number, incomplete: number, names: string[] }}
3385
+ */
3386
+ export function countIncompleteBackups(backupsDir) {
3387
+ const out = { scanned: 0, incomplete: 0, names: [] };
3388
+ const root = typeof backupsDir === 'string' ? backupsDir.trim() : '';
3389
+ if (root === '') return out;
3390
+ let entries = [];
3391
+ try {
3392
+ entries = fs.readdirSync(root, { withFileTypes: true });
3393
+ } catch {
3394
+ return out;
3395
+ }
3396
+ for (const entry of entries) {
3397
+ if (entry.isDirectory() !== true || entry.name.startsWith('.') === true) continue;
3398
+ out.scanned += 1;
3399
+ const dir = path.join(root, entry.name);
3400
+ const hasManifest = (() => { try { return fs.existsSync(path.join(dir, MANIFEST_NAME)) === true; } catch { return true; } })();
3401
+ const hasMeta = (() => { try { return fs.existsSync(path.join(dir, META_NAME)) === true; } catch { return true; } })();
3402
+ if (hasManifest === true && hasMeta === true) continue;
3403
+ out.incomplete += 1;
3404
+ out.names.push(entry.name);
3405
+ }
3406
+ return out;
3407
+ }
3408
+
3409
+ /**
3410
+ * REMOVE THE BACKUP DIRECTORIES THAT ARE PROVABLY JUNK, AT BOOT (2026-11-02).
3411
+ *
3412
+ * `removeIncompleteBackups` already exists, but it is called from the
3413
+ * first-install path, which only runs on a NEW boot with snapshots enabled — so
3414
+ * on an ordinary restart nothing swept, and a directory left behind by a backup
3415
+ * that died mid-write stayed forever (a real machine: three directories on disk,
3416
+ * two copies on the page).
3417
+ *
3418
+ * WHAT IS DELETED: a directory with NO manifest, whatever it holds — a copy that
3419
+ * died after writing hundreds of files but before its manifest is exactly that,
3420
+ * and it can never be restored (user decision, 2026-11-02).
3421
+ * WHAT IS KEPT: anything with a manifest (a copy missing only `meta.json` is old,
3422
+ * not broken), anything whose mtime is inside `minAgeMs` (a write may be in
3423
+ * flight), and dot entries (`.staging-*`).
3424
+ *
3425
+ * WHAT IT REPORTS: the names removed, the names kept, how many were scanned and
3426
+ * the BYTES RECLAIMED (summed before the deletion — afterwards there is nothing
3427
+ * left to measure).
3428
+ *
3429
+ * Nothing here throws: a failure is returned.
3430
+ *
3431
+ * @param {string} backupsDir
3432
+ * @param {{ minAgeMs?: number, now?: number }} [options]
3433
+ * @returns {{ skipped: boolean, reason: string|null, removed: string[], kept: string[], scanned: number, error: string|null }}
3434
+ */
3435
+ export function sweepIncompleteBackups(backupsDir, { minAgeMs = DEFAULT_HYGIENE_MIN_AGE_MS, now = Date.now() } = {}) {
3436
+ const result = { skipped: false, reason: null, removed: [], kept: [], scanned: 0, bytes: 0, error: null };
3437
+ const root = typeof backupsDir === 'string' ? backupsDir.trim() : '';
3438
+ if (root === '') {
3439
+ result.skipped = true;
3440
+ result.reason = 'no backups directory';
3441
+ return result;
3442
+ }
3443
+ let entries = [];
3444
+ try {
3445
+ entries = fs.readdirSync(root, { withFileTypes: true });
3446
+ } catch (error) {
3447
+ if (error && error.code === 'ENOENT') {
3448
+ result.skipped = true;
3449
+ result.reason = 'no backups directory yet';
3450
+ return result;
3451
+ }
3452
+ result.error = error && error.message ? String(error.message) : String(error);
3453
+ return result;
3454
+ }
3455
+ for (const entry of entries) {
3456
+ if (entry.isDirectory() !== true || entry.name.startsWith('.') === true) continue;
3457
+ result.scanned += 1;
3458
+ const dir = path.join(root, entry.name);
3459
+ try {
3460
+ // A MANIFEST IS WHAT MAKES A DIRECTORY A COPY (user decision, 2026-11-02).
3461
+ // The rule used to be "no manifest AND nothing inside", which spared the one
3462
+ // shape that actually appears on real machines: a backup that copied the
3463
+ // plugin tree (hundreds of files, ~150 MB) and then never wrote its
3464
+ // manifest. It cannot be restored, it is not listed, and it stayed forever.
3465
+ // A copy with a manifest but no `meta.json` is NOT junk — old copies predate
3466
+ // `meta.json` and are restorable — so a manifest alone is enough to keep it.
3467
+ if (fs.existsSync(path.join(dir, MANIFEST_NAME)) === true) {
3468
+ result.kept.push(entry.name); // a copy (possibly one that only lacks meta.json)
3469
+ continue;
3470
+ }
3471
+ const stat = fs.statSync(dir);
3472
+ if (minAgeMs > 0 && now - stat.mtimeMs < minAgeMs) {
3473
+ result.kept.push(entry.name); // maybe being written right now
3474
+ continue;
3475
+ }
3476
+ // COUNTED BEFORE THE DELETE: after it, the bytes are gone and the report
3477
+ // would have nothing to add up.
3478
+ const bytes = (() => {
3479
+ try {
3480
+ return directorySize(dir).bytes;
3481
+ } catch {
3482
+ return 0;
3483
+ }
3484
+ })();
3485
+ fs.rmSync(dir, { recursive: true, force: true });
3486
+ result.removed.push(entry.name);
3487
+ result.bytes += bytes;
3488
+ } catch (error) {
3489
+ result.kept.push(entry.name);
3490
+ result.error = error && error.message ? String(error.message) : String(error);
3491
+ }
3492
+ }
3493
+ return result;
3494
+ }