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.
- package/CHANGELOG.md +313 -0
- package/README.md +465 -40
- package/assets/icon-9c245e5b.ico +0 -0
- package/assets/icon-rescue-1902bb15.ico +0 -0
- package/assets/icon-rescue-1902bb15.png +0 -0
- package/client.js +3002 -170
- package/cordis.patch.yml +12 -0
- package/docs/README.zh.md +231 -23
- package/docs/platform-matrix.md +171 -55
- package/docs/real-machine-verification-0.8.6.md +202 -0
- package/index.js +13272 -8513
- package/lib/autostart-advice.js +31 -2
- package/lib/backup.js +2768 -21
- package/lib/checkpoint.js +214 -7
- package/lib/cookie-clear.js +260 -0
- package/lib/lifecycle.js +23 -40
- package/lib/plugin-compare.js +499 -0
- package/lib/plugin-failure.js +382 -0
- package/lib/plugin-recover.js +1557 -0
- package/lib/plugin-watch.js +213 -0
- package/lib/rescue-paths.js +982 -56
- package/lib/restart-phase.js +5 -0
- package/lib/restart-verdict.js +379 -0
- package/lib/restart.js +244 -7
- package/lib/scheduler-capability.js +19 -3
- package/lib/sleep.js +190 -29
- package/lib/watchdog-disarm.js +46 -2
- package/lib/watchdog-schedule.js +834 -17
- package/lib/watchdog-tier.js +8 -1
- package/lib/watchdog.js +1222 -47
- package/locale/en.json +175 -68
- package/locale/zh.json +175 -68
- package/package.json +3 -2
- package/screenshots.json +6 -6
- package/scripts/README.md +6 -3
- package/scripts/build-icon.mjs +998 -0
- package/scripts/clear-dsh-auth-cookies.mjs +257 -0
- package/scripts/emergency-disable.sh +69 -2
- package/scripts/emergency-toggle.ps1 +85 -69
- package/scripts/rescue-strings.zh.json +36 -18
- package/scripts/restore-profile.ps1 +182 -1
- 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
|
-
/**
|
|
35
|
-
|
|
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 {
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|