dsh-autorestart 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -6
- package/client.js +387 -0
- package/cordis.patch.yml +7 -0
- package/docs/README.zh.md +83 -5
- package/docs/platform-matrix.md +92 -0
- package/docs/rescue-drill.md +606 -0
- package/index.js +598 -3
- package/lib/diagnostics.js +8 -0
- package/lib/rescue-paths.js +253 -0
- package/lib/rescue.js +596 -0
- package/lib/scheduler-capability.js +300 -0
- package/lib/watchdog-arbiter.js +117 -0
- package/lib/watchdog-schedule.js +310 -0
- package/lib/watchdog-tier.js +182 -0
- package/lib/watchdog.js +479 -0
- package/locale/en.json +28 -1
- package/locale/zh.json +28 -1
- package/package.json +1 -1
- package/scripts/README.md +65 -0
- package/scripts/drill-rescue.ps1 +508 -0
- package/scripts/emergency-toggle.ps1 +136 -65
- package/scripts/restore-profile.cmd +44 -0
- package/scripts/restore-profile.ps1 +608 -0
- package/scripts/run-tests.ps1 +1 -1
package/lib/rescue.js
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — the rules of the rescue kit.
|
|
3
|
+
*
|
|
4
|
+
* The rescue kit ("自救包") has two halves: `scripts/restore-profile.cmd` puts a
|
|
5
|
+
* profile snapshot back when the host does not come up any more, and
|
|
6
|
+
* `scripts/drill-rescue.ps1` proves that it works — in an isolated directory.
|
|
7
|
+
*
|
|
8
|
+
* This module holds the RULES both halves obey, in a shape node can import and
|
|
9
|
+
* test directly:
|
|
10
|
+
*
|
|
11
|
+
* - which paths may be restored over (never a drive root, never a system
|
|
12
|
+
* directory, only a profile directory under `<home>/.dsh/profiles` or an
|
|
13
|
+
* explicitly named isolated directory),
|
|
14
|
+
* - which directory still looks like a DSH profile (even when it is broken),
|
|
15
|
+
* - which snapshot is the newest one that carries a manifest,
|
|
16
|
+
* - and what a restored profile has to look like before the kit may say
|
|
17
|
+
* "done".
|
|
18
|
+
*
|
|
19
|
+
* The PowerShell script implements the same rules on its own: a rescue kit that
|
|
20
|
+
* needs node on the PATH is not a rescue kit. This module is the tested
|
|
21
|
+
* reference, and the drill proves that the two agree.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
|
|
28
|
+
/** The plugin the kit looks for inside a restored profile. */
|
|
29
|
+
export const PLUGIN_NAME = 'dsh-autorestart';
|
|
30
|
+
|
|
31
|
+
/** The manifest that marks a directory as a snapshot. */
|
|
32
|
+
export const MANIFEST_NAME = 'manifest.json';
|
|
33
|
+
|
|
34
|
+
/** The directory inside a snapshot that holds the configuration files. */
|
|
35
|
+
export const CONFIG_DIR = 'config';
|
|
36
|
+
|
|
37
|
+
/** The directory inside a snapshot that holds the installed plugin tree. */
|
|
38
|
+
export const PLUGINS_DIR = 'node_modules';
|
|
39
|
+
|
|
40
|
+
/** The marker that separates a profile from the evidence of a broken one. */
|
|
41
|
+
export const BROKEN_SUFFIX = '.broken-';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The files that make a directory look like a DSH profile.
|
|
45
|
+
*
|
|
46
|
+
* A profile that is broken on purpose (that is the whole point of the kit) may
|
|
47
|
+
* have a package.json that no longer parses. So a directory counts as a profile
|
|
48
|
+
* when its package.json parses and carries a `dsh.profile` section, OR when it
|
|
49
|
+
* still carries at least TWO of these three markers — that is how a profile
|
|
50
|
+
* whose package.json was destroyed stays recognizable.
|
|
51
|
+
*/
|
|
52
|
+
export const PROFILE_MARKERS = Object.freeze(['package.json', 'cordis.patch.yml', PLUGINS_DIR]);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The DSH home: where the profiles and the state live.
|
|
56
|
+
*
|
|
57
|
+
* The plugin resolves it the same way (`DSH_HOME`, otherwise `~/.dsh`).
|
|
58
|
+
*
|
|
59
|
+
* @param {{env?: object, home?: string}} [options]
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function dshHomeDir({ env = process.env, home = os.homedir() } = {}) {
|
|
63
|
+
const explicit = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : '';
|
|
64
|
+
if (explicit !== '') return normalizePath(explicit);
|
|
65
|
+
return normalizePath(path.join(home, '.dsh'));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The directory that holds every profile.
|
|
70
|
+
*
|
|
71
|
+
* @param {{env?: object, home?: string}} [options]
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
export function profilesRootDir(options = {}) {
|
|
75
|
+
return normalizePath(path.join(dshHomeDir(options), 'profiles'));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The directory the plugin keeps its durable state in.
|
|
80
|
+
*
|
|
81
|
+
* @param {{env?: object, home?: string}} [options]
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
export function stateDirDir(options = {}) {
|
|
85
|
+
return normalizePath(path.join(dshHomeDir(options), 'storages', 'dsh-autorestart'));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The directory that holds the snapshots.
|
|
90
|
+
*
|
|
91
|
+
* @param {{env?: object, home?: string}} [options]
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
export function backupsRootDir(options = {}) {
|
|
95
|
+
return normalizePath(path.join(stateDirDir(options), 'backups'));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A full path without a trailing separator.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} target
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export function normalizePath(target) {
|
|
105
|
+
const raw = typeof target === 'string' ? target.trim() : '';
|
|
106
|
+
if (raw === '') return '';
|
|
107
|
+
let resolved = path.resolve(raw);
|
|
108
|
+
// "C:\" keeps its separator (it is the root), anything longer loses it.
|
|
109
|
+
if (resolved.length > 3) resolved = resolved.replace(/[\\/]+$/, '');
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Whether `child` lies inside `parent` (and is not `parent` itself).
|
|
115
|
+
*
|
|
116
|
+
* Windows paths are compared case-insensitively.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} child
|
|
119
|
+
* @param {string} parent
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
export function isInside(child, parent) {
|
|
123
|
+
const c = normalizePath(child).toLowerCase();
|
|
124
|
+
const p = normalizePath(parent).toLowerCase();
|
|
125
|
+
if (c === '' || p === '') return false;
|
|
126
|
+
if (c === p) return false;
|
|
127
|
+
const prefix = p.endsWith('\\') || p.endsWith('/') ? p : p + path.sep;
|
|
128
|
+
return c.startsWith(prefix);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The directories that must never be restored over.
|
|
133
|
+
*
|
|
134
|
+
* The environment is asked first (`SystemRoot`, `ProgramFiles`, …); the system
|
|
135
|
+
* drive is used as the fallback so the rule also holds on a machine whose
|
|
136
|
+
* environment variables were stripped.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} [env]
|
|
139
|
+
* @returns {string[]}
|
|
140
|
+
*/
|
|
141
|
+
export function systemDirectories(env = process.env) {
|
|
142
|
+
const out = [];
|
|
143
|
+
const add = (value) => {
|
|
144
|
+
if (typeof value !== 'string') return;
|
|
145
|
+
const trimmed = value.trim().replace(/[\\/]+$/, '');
|
|
146
|
+
if (trimmed === '') return;
|
|
147
|
+
out.push(trimmed);
|
|
148
|
+
};
|
|
149
|
+
add(env.SystemRoot);
|
|
150
|
+
add(env.windir);
|
|
151
|
+
add(env.ProgramFiles);
|
|
152
|
+
add(env['ProgramFiles(x86)']);
|
|
153
|
+
add(env.ProgramData);
|
|
154
|
+
const drive = typeof env.SystemDrive === 'string' && env.SystemDrive.trim() !== '' ? env.SystemDrive.trim() : 'C:';
|
|
155
|
+
add(drive + '\\Windows');
|
|
156
|
+
add(drive + '\\Program Files');
|
|
157
|
+
add(drive + '\\Program Files (x86)');
|
|
158
|
+
add(drive + '\\ProgramData');
|
|
159
|
+
add(drive + '\\$Recycle.Bin');
|
|
160
|
+
add(drive + '\\System Volume Information');
|
|
161
|
+
const seen = new Set();
|
|
162
|
+
return out
|
|
163
|
+
.map((entry) => normalizePath(entry))
|
|
164
|
+
.filter((entry) => {
|
|
165
|
+
if (entry === '' || seen.has(entry.toLowerCase())) return false;
|
|
166
|
+
seen.add(entry.toLowerCase());
|
|
167
|
+
return true;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Why a path may not be restored over — or an empty string when it may.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} target
|
|
175
|
+
* @param {{home?: string, env?: object}} [options]
|
|
176
|
+
* @returns {string}
|
|
177
|
+
*/
|
|
178
|
+
export function forbiddenReason(target, { home = os.homedir(), env = process.env } = {}) {
|
|
179
|
+
const dir = normalizePath(target);
|
|
180
|
+
if (dir === '') return 'the target path is empty';
|
|
181
|
+
if (/^[A-Za-z]:[\\/]?$/.test(dir)) return 'refusing to touch a drive root';
|
|
182
|
+
const fsRoot = path.parse(dir).root;
|
|
183
|
+
if (fsRoot !== '' && dir.toLowerCase() === fsRoot.toLowerCase()) {
|
|
184
|
+
return 'refusing to touch a filesystem root';
|
|
185
|
+
}
|
|
186
|
+
const homeDir = normalizePath(home);
|
|
187
|
+
if (homeDir !== '' && dir.toLowerCase() === homeDir.toLowerCase()) {
|
|
188
|
+
return 'refusing to touch the home directory itself';
|
|
189
|
+
}
|
|
190
|
+
for (const system of systemDirectories(env)) {
|
|
191
|
+
if (dir.toLowerCase() === system.toLowerCase() || isInside(dir, system)) {
|
|
192
|
+
return 'refusing to touch the system directory ' + system;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Somebody else's profile: <Users>\<name>.
|
|
196
|
+
const usersRoot = normalizePath(path.join(path.parse(homeDir).root, 'Users'));
|
|
197
|
+
if (usersRoot !== '' && isInside(dir, usersRoot)) {
|
|
198
|
+
if (path.dirname(dir).toLowerCase() === usersRoot.toLowerCase()) {
|
|
199
|
+
return 'refusing to touch another user profile';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return '';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Whether a path may be restored over at all.
|
|
207
|
+
*
|
|
208
|
+
* Two ways to pass:
|
|
209
|
+
*
|
|
210
|
+
* - `explicit` (the operator named the directory): any safe path is allowed,
|
|
211
|
+
* which is what the drill uses for its isolated directory;
|
|
212
|
+
* - no `explicit`: only a profile directory under `<home>/.dsh/profiles` is
|
|
213
|
+
* allowed, so a typo cannot turn into "restore over my Documents".
|
|
214
|
+
*
|
|
215
|
+
* @param {string} target
|
|
216
|
+
* @param {{home?: string, env?: object, profilesRoot?: string, explicit?: boolean}} [options]
|
|
217
|
+
* @returns {{ok: boolean, dir: string, scope: string, error: string}}
|
|
218
|
+
*/
|
|
219
|
+
export function checkProfileTarget(target, { home = os.homedir(), env = process.env, profilesRoot = '', explicit = false } = {}) {
|
|
220
|
+
const dir = normalizePath(target);
|
|
221
|
+
if (dir === '') {
|
|
222
|
+
return { ok: false, dir: '', scope: '', error: 'no profile directory was given' };
|
|
223
|
+
}
|
|
224
|
+
const forbidden = forbiddenReason(dir, { home, env });
|
|
225
|
+
if (forbidden !== '') {
|
|
226
|
+
return { ok: false, dir, scope: '', error: forbidden };
|
|
227
|
+
}
|
|
228
|
+
if (explicit === true) {
|
|
229
|
+
return { ok: true, dir, scope: 'explicit', error: '' };
|
|
230
|
+
}
|
|
231
|
+
const root = profilesRoot !== '' ? normalizePath(profilesRoot) : profilesRootDir({ env, home });
|
|
232
|
+
if (!isInside(dir, root)) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
dir,
|
|
236
|
+
scope: '',
|
|
237
|
+
error: 'only a profile under ' + root + ' may be restored (this one is not: ' + dir + ')',
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return { ok: true, dir, scope: 'profiles-root', error: '' };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Look at a directory and say what it looks like.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} dir
|
|
247
|
+
* @returns {{dir: string, kind: 'profile'|'broken-profile'|'not-a-profile', reason: string, markers: string[], packageJson: object|null}}
|
|
248
|
+
*/
|
|
249
|
+
export function inspectProfileDir(dir) {
|
|
250
|
+
const base = normalizePath(dir);
|
|
251
|
+
const markers = [];
|
|
252
|
+
const pkgPath = path.join(base, 'package.json');
|
|
253
|
+
const hasPackageJson = fs.existsSync(pkgPath);
|
|
254
|
+
if (hasPackageJson) markers.push('package.json');
|
|
255
|
+
if (fs.existsSync(path.join(base, 'cordis.patch.yml'))) markers.push('cordis.patch.yml');
|
|
256
|
+
if (fs.existsSync(path.join(base, PLUGINS_DIR))) markers.push(PLUGINS_DIR);
|
|
257
|
+
|
|
258
|
+
const base_result = { dir: base, kind: 'not-a-profile', reason: '', markers, packageJson: null };
|
|
259
|
+
if (hasPackageJson) {
|
|
260
|
+
let parsed = null;
|
|
261
|
+
try {
|
|
262
|
+
parsed = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
263
|
+
} catch {
|
|
264
|
+
parsed = null;
|
|
265
|
+
}
|
|
266
|
+
if (parsed && typeof parsed === 'object' && parsed.dsh && parsed.dsh.profile) {
|
|
267
|
+
return { ...base_result, kind: 'profile', reason: 'the profile package.json carries a dsh.profile section', packageJson: parsed };
|
|
268
|
+
}
|
|
269
|
+
if (markers.length >= 2) {
|
|
270
|
+
return {
|
|
271
|
+
...base_result,
|
|
272
|
+
kind: 'broken-profile',
|
|
273
|
+
reason: 'the profile is broken (its package.json does not identify a profile), but the profile files are still there',
|
|
274
|
+
packageJson: parsed,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
...base_result,
|
|
279
|
+
reason: 'the package.json does not carry a dsh.profile section, and nothing else marks this as a profile',
|
|
280
|
+
packageJson: parsed,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (markers.length > 0) {
|
|
284
|
+
return {
|
|
285
|
+
...base_result,
|
|
286
|
+
kind: 'broken-profile',
|
|
287
|
+
reason: 'the profile is broken (its package.json is missing), but the profile files are still there',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
return { ...base_result, reason: 'there is no package.json here — this does not look like a DSH profile' };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Read one snapshot manifest.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} dir
|
|
297
|
+
* @returns {object|null}
|
|
298
|
+
*/
|
|
299
|
+
export function readSnapshotManifest(dir) {
|
|
300
|
+
try {
|
|
301
|
+
let text = fs.readFileSync(path.join(normalizePath(dir), MANIFEST_NAME), 'utf8');
|
|
302
|
+
// Windows PowerShell writes UTF-8 files with a BOM; JSON.parse rejects it.
|
|
303
|
+
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
|
304
|
+
const parsed = JSON.parse(text);
|
|
305
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
306
|
+
} catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Everything that has to be true before the kit touches a profile directory.
|
|
313
|
+
*
|
|
314
|
+
* Three gates, in this order:
|
|
315
|
+
*
|
|
316
|
+
* 1. the path itself is safe (never a drive root, never a system directory,
|
|
317
|
+
* and — unless the operator named it explicitly — only a profile under
|
|
318
|
+
* `<home>/.dsh/profiles`);
|
|
319
|
+
* 2. the directory EXISTS: the kit restores INTO an existing profile, it
|
|
320
|
+
* never creates one (and it never creates a `profiles` directory);
|
|
321
|
+
* 3. the directory still looks like a DSH profile (a broken one is fine —
|
|
322
|
+
* that is what the kit is for).
|
|
323
|
+
*
|
|
324
|
+
* @param {string} target
|
|
325
|
+
* @param {{home?: string, env?: object, profilesRoot?: string, explicit?: boolean}} [options]
|
|
326
|
+
* @returns {{ok: boolean, dir: string, kind: string, reason: string, error: string}}
|
|
327
|
+
*/
|
|
328
|
+
export function checkRestoreTarget(target, options = {}) {
|
|
329
|
+
const gate = checkProfileTarget(target, options);
|
|
330
|
+
if (!gate.ok) {
|
|
331
|
+
return { ok: false, dir: gate.dir, kind: '', reason: gate.error, error: gate.error };
|
|
332
|
+
}
|
|
333
|
+
let exists = false;
|
|
334
|
+
try {
|
|
335
|
+
exists = fs.existsSync(gate.dir) && fs.statSync(gate.dir).isDirectory();
|
|
336
|
+
} catch {
|
|
337
|
+
exists = false;
|
|
338
|
+
}
|
|
339
|
+
if (!exists) {
|
|
340
|
+
const reason =
|
|
341
|
+
'the target profile directory does not exist (' + gate.dir + ') — the kit restores into an existing profile and never creates one';
|
|
342
|
+
return { ok: false, dir: gate.dir, kind: '', reason, error: reason };
|
|
343
|
+
}
|
|
344
|
+
const seen = inspectProfileDir(gate.dir);
|
|
345
|
+
if (seen.kind === 'not-a-profile') {
|
|
346
|
+
return { ok: false, dir: gate.dir, kind: seen.kind, reason: seen.reason, error: seen.reason };
|
|
347
|
+
}
|
|
348
|
+
return { ok: true, dir: gate.dir, kind: seen.kind, reason: seen.reason, error: '' };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The newest snapshot that carries a manifest.
|
|
353
|
+
*
|
|
354
|
+
* A directory without a manifest is not a snapshot (it may be a half-written
|
|
355
|
+
* copy), so it is skipped. The newest is decided by the manifest's own `at`
|
|
356
|
+
* stamp; the directory name breaks a tie.
|
|
357
|
+
*
|
|
358
|
+
* @param {string} backupsDir
|
|
359
|
+
* @returns {{ok: boolean, dir: string, name: string, manifest: object|null, candidates: Array<{name: string, dir: string, at: number|null}>, error: string}}
|
|
360
|
+
*/
|
|
361
|
+
export function selectSnapshot(backupsDir) {
|
|
362
|
+
const root = normalizePath(backupsDir);
|
|
363
|
+
const out = { ok: false, dir: '', name: '', manifest: null, candidates: [], error: '' };
|
|
364
|
+
let entries;
|
|
365
|
+
try {
|
|
366
|
+
entries = fs.readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory());
|
|
367
|
+
} catch {
|
|
368
|
+
out.error = 'the backups directory cannot be read: ' + root;
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
const candidates = [];
|
|
372
|
+
for (const entry of entries) {
|
|
373
|
+
const dir = path.join(root, entry.name);
|
|
374
|
+
const manifest = readSnapshotManifest(dir);
|
|
375
|
+
if (!manifest) continue;
|
|
376
|
+
const at = Number(manifest.at);
|
|
377
|
+
candidates.push({ name: entry.name, dir, manifest, at: Number.isFinite(at) ? at : null });
|
|
378
|
+
}
|
|
379
|
+
candidates.sort((a, b) => {
|
|
380
|
+
const left = a.at === null ? -Infinity : a.at;
|
|
381
|
+
const right = b.at === null ? -Infinity : b.at;
|
|
382
|
+
if (left !== right) return right > left ? 1 : -1;
|
|
383
|
+
return b.name.localeCompare(a.name);
|
|
384
|
+
});
|
|
385
|
+
out.candidates = candidates.map((entry) => ({ name: entry.name, dir: entry.dir, at: entry.at }));
|
|
386
|
+
if (candidates.length === 0) {
|
|
387
|
+
out.error = 'there is no snapshot with a manifest under ' + root;
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
const best = candidates[0];
|
|
391
|
+
out.ok = true;
|
|
392
|
+
out.dir = best.dir;
|
|
393
|
+
out.name = best.name;
|
|
394
|
+
out.manifest = best.manifest;
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The name of the directory the broken profile is kept in.
|
|
400
|
+
*
|
|
401
|
+
* @param {string} profileDir
|
|
402
|
+
* @param {number} [at]
|
|
403
|
+
* @returns {string}
|
|
404
|
+
*/
|
|
405
|
+
export function brokenDirName(profileDir, at = Date.now()) {
|
|
406
|
+
return normalizePath(profileDir) + BROKEN_SUFFIX + localStamp(at);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* A filesystem-safe local timestamp (`20261029-155530`).
|
|
411
|
+
*
|
|
412
|
+
* @param {number} at
|
|
413
|
+
* @returns {string}
|
|
414
|
+
*/
|
|
415
|
+
export function localStamp(at = Date.now()) {
|
|
416
|
+
const date = new Date(Number(at));
|
|
417
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
418
|
+
return (
|
|
419
|
+
String(date.getFullYear()) +
|
|
420
|
+
pad(date.getMonth() + 1) +
|
|
421
|
+
pad(date.getDate()) +
|
|
422
|
+
'-' +
|
|
423
|
+
pad(date.getHours()) +
|
|
424
|
+
pad(date.getMinutes()) +
|
|
425
|
+
pad(date.getSeconds())
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* A human timestamp (`2026-10-29 15:55`).
|
|
431
|
+
*
|
|
432
|
+
* @param {number} at
|
|
433
|
+
* @returns {string}
|
|
434
|
+
*/
|
|
435
|
+
export function localDisplay(at = Date.now()) {
|
|
436
|
+
const date = new Date(Number(at));
|
|
437
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
438
|
+
return (
|
|
439
|
+
String(date.getFullYear()) +
|
|
440
|
+
'-' +
|
|
441
|
+
pad(date.getMonth() + 1) +
|
|
442
|
+
'-' +
|
|
443
|
+
pad(date.getDate()) +
|
|
444
|
+
' ' +
|
|
445
|
+
pad(date.getHours()) +
|
|
446
|
+
':' +
|
|
447
|
+
pad(date.getMinutes())
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* What a snapshot says about itself, in words.
|
|
453
|
+
*
|
|
454
|
+
* @param {object|null} manifest
|
|
455
|
+
* @returns {{at: number|null, display: string, includePlugins: boolean}}
|
|
456
|
+
*/
|
|
457
|
+
export function describeSnapshot(manifest) {
|
|
458
|
+
const out = { at: null, display: 'an unknown time', includePlugins: false };
|
|
459
|
+
if (!manifest || typeof manifest !== 'object') return out;
|
|
460
|
+
const at = Number(manifest.at);
|
|
461
|
+
if (Number.isFinite(at)) {
|
|
462
|
+
out.at = at;
|
|
463
|
+
out.display = localDisplay(at);
|
|
464
|
+
}
|
|
465
|
+
if (typeof manifest.atLocal === 'string' && manifest.atLocal.trim() !== '') {
|
|
466
|
+
out.display = manifest.atLocal.trim().replace('T', ' ').slice(0, 16);
|
|
467
|
+
}
|
|
468
|
+
out.includePlugins = manifest.includePlugins === true;
|
|
469
|
+
return out;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Read a restored profile back and say whether it is whole.
|
|
474
|
+
*
|
|
475
|
+
* Three checks:
|
|
476
|
+
*
|
|
477
|
+
* 1. `package.json` parses;
|
|
478
|
+
* 2. `dsh.profile.bundles` has content;
|
|
479
|
+
* 3. the version of the plugin can be read — SKIPPED when the profile does
|
|
480
|
+
* not list the plugin (there is nothing to read), but a hard FAILURE when
|
|
481
|
+
* the profile DOES list it and the plugin tree is not there: the
|
|
482
|
+
* configuration says one thing and the disk another, and that is exactly
|
|
483
|
+
* the kind of broken profile the kit has to catch.
|
|
484
|
+
*
|
|
485
|
+
* @param {string} dir
|
|
486
|
+
* @param {{pluginName?: string}} [options]
|
|
487
|
+
* @returns {{ok: boolean, checks: Array<{id: string, label: string, status: 'passed'|'failed'|'skipped', detail: string}>, error: string, version: string}}
|
|
488
|
+
*/
|
|
489
|
+
export function validateRestoredProfile(dir, { pluginName = PLUGIN_NAME } = {}) {
|
|
490
|
+
const base = normalizePath(dir);
|
|
491
|
+
const checks = [];
|
|
492
|
+
|
|
493
|
+
const pkgPath = path.join(base, 'package.json');
|
|
494
|
+
let parsed = null;
|
|
495
|
+
let parseError = '';
|
|
496
|
+
if (!fs.existsSync(pkgPath)) {
|
|
497
|
+
checks.push({ id: 'package-json', label: 'package.json can be parsed', status: 'failed', detail: 'package.json is missing' });
|
|
498
|
+
} else {
|
|
499
|
+
try {
|
|
500
|
+
parsed = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
501
|
+
} catch (error) {
|
|
502
|
+
parseError = error && error.message ? error.message : String(error);
|
|
503
|
+
parsed = null;
|
|
504
|
+
}
|
|
505
|
+
checks.push({
|
|
506
|
+
id: 'package-json',
|
|
507
|
+
label: 'package.json can be parsed',
|
|
508
|
+
status: parsed && typeof parsed === 'object' ? 'passed' : 'failed',
|
|
509
|
+
detail: parsed && typeof parsed === 'object' ? '' : 'not valid JSON: ' + parseError,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const bundles =
|
|
514
|
+
parsed && parsed.dsh && parsed.dsh.profile && Array.isArray(parsed.dsh.profile.bundles)
|
|
515
|
+
? parsed.dsh.profile.bundles
|
|
516
|
+
: null;
|
|
517
|
+
checks.push({
|
|
518
|
+
id: 'bundles',
|
|
519
|
+
label: 'dsh.profile.bundles has content',
|
|
520
|
+
status: bundles && bundles.length > 0 ? 'passed' : 'failed',
|
|
521
|
+
detail: bundles ? bundles.length + ' bundle(s)' : 'dsh.profile.bundles is missing or empty',
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const pluginPkgPath = path.join(base, PLUGINS_DIR, pluginName, 'package.json');
|
|
525
|
+
let version = '';
|
|
526
|
+
// The plugin is only checked when the profile says it wants it.
|
|
527
|
+
const declaresPlugin = Array.isArray(bundles) && bundles.includes(pluginName);
|
|
528
|
+
if (!declaresPlugin) {
|
|
529
|
+
checks.push({
|
|
530
|
+
id: 'plugin-version',
|
|
531
|
+
label: 'the version of ' + pluginName + ' can be read',
|
|
532
|
+
status: 'skipped',
|
|
533
|
+
detail: 'this profile does not list ' + pluginName + ' (there is nothing to read)',
|
|
534
|
+
});
|
|
535
|
+
} else if (!fs.existsSync(pluginPkgPath)) {
|
|
536
|
+
checks.push({
|
|
537
|
+
id: 'plugin-version',
|
|
538
|
+
label: 'the version of ' + pluginName + ' can be read',
|
|
539
|
+
status: 'failed',
|
|
540
|
+
detail:
|
|
541
|
+
'the profile lists ' + pluginName + ', but node_modules/' + pluginName +
|
|
542
|
+
' is not there — the configuration says one thing and the disk another',
|
|
543
|
+
});
|
|
544
|
+
} else {
|
|
545
|
+
try {
|
|
546
|
+
const pluginPkg = JSON.parse(fs.readFileSync(pluginPkgPath, 'utf8'));
|
|
547
|
+
version = pluginPkg && typeof pluginPkg.version === 'string' ? pluginPkg.version.trim() : '';
|
|
548
|
+
} catch {
|
|
549
|
+
version = '';
|
|
550
|
+
}
|
|
551
|
+
checks.push({
|
|
552
|
+
id: 'plugin-version',
|
|
553
|
+
label: 'the version of ' + pluginName + ' can be read',
|
|
554
|
+
status: version !== '' ? 'passed' : 'failed',
|
|
555
|
+
detail: version !== '' ? version : 'the version number cannot be read',
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const failed = checks.filter((check) => check.status === 'failed');
|
|
560
|
+
return {
|
|
561
|
+
ok: failed.length === 0,
|
|
562
|
+
checks,
|
|
563
|
+
error: failed.map((check) => check.label).join('; '),
|
|
564
|
+
version,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Whether a snapshot holds a real profile.
|
|
570
|
+
*
|
|
571
|
+
* The kit never writes something into a profile directory that is not a DSH
|
|
572
|
+
* profile, so the snapshot has to carry one.
|
|
573
|
+
*
|
|
574
|
+
* @param {string} snapshotDir
|
|
575
|
+
* @returns {{ok: boolean, reason: string, profile: object|null}}
|
|
576
|
+
*/
|
|
577
|
+
export function inspectSnapshotProfile(snapshotDir) {
|
|
578
|
+
const pkgPath = path.join(normalizePath(snapshotDir), CONFIG_DIR, 'package.json');
|
|
579
|
+
if (!fs.existsSync(pkgPath)) {
|
|
580
|
+
return { ok: false, reason: 'the snapshot has no ' + CONFIG_DIR + '/package.json', profile: null };
|
|
581
|
+
}
|
|
582
|
+
let parsed = null;
|
|
583
|
+
try {
|
|
584
|
+
parsed = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
585
|
+
} catch (error) {
|
|
586
|
+
return {
|
|
587
|
+
ok: false,
|
|
588
|
+
reason: 'the snapshot\'s package.json is not valid JSON: ' + (error && error.message ? error.message : String(error)),
|
|
589
|
+
profile: null,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.dsh || !parsed.dsh.profile) {
|
|
593
|
+
return { ok: false, reason: "the snapshot's package.json has no dsh.profile section", profile: null };
|
|
594
|
+
}
|
|
595
|
+
return { ok: true, reason: 'the snapshot holds a DSH profile', profile: parsed };
|
|
596
|
+
}
|