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
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT IS INSTALLED, AND HOW IT DIFFERS FROM THE LAST BACKUP.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS (user decision, 2026-11-01): the in-process detection of "which
|
|
5
|
+
* plugin did not activate" was tried twice and is closed — the live cordis
|
|
6
|
+
* registry reports its own internals rather than activation state, and
|
|
7
|
+
* `ctx.get('tools').execute(...)` is refused during boot because it wants a whole
|
|
8
|
+
* execution context the plugin cannot obtain. So the user decides instead: the
|
|
9
|
+
* settings page shows every installed plugin, marks how each one differs from the
|
|
10
|
+
* newest backup, and lets the human pick one to put back.
|
|
11
|
+
*
|
|
12
|
+
* WHAT A DIFFERENCE DOES AND DOES NOT MEAN — the honest limit, and the reason the
|
|
13
|
+
* wording here never says "broken":
|
|
14
|
+
*
|
|
15
|
+
* IT FINDS: a file that was changed, deleted, or is absent from the backup.
|
|
16
|
+
* IT DOES NOT FIND: a plugin whose files are all correct but which fails while
|
|
17
|
+
* loading (a missing peer dependency, a throwing initialiser, a version the
|
|
18
|
+
* host refuses).
|
|
19
|
+
*
|
|
20
|
+
* A difference is therefore a FIRST-PASS SIGNAL, not a verdict. "different" must
|
|
21
|
+
* never be rendered as "this plugin failed to load".
|
|
22
|
+
*
|
|
23
|
+
* PERFORMANCE IS A HARD REQUIREMENT: the real profile is ~150 MB and the backup
|
|
24
|
+
* ~295 MB. Nothing here hashes a tree. A comparison is "the set of relative paths
|
|
25
|
+
* plus each file's size", which is a stat per file and no reads at all. Every walk
|
|
26
|
+
* is bounded, and a bounded walk says so (`truncated: true`) instead of quietly
|
|
27
|
+
* reporting a short answer as a complete one.
|
|
28
|
+
*
|
|
29
|
+
* EVERYTHING IS INJECTABLE AND NOTHING THROWS: the filesystem is passed in so this
|
|
30
|
+
* is testable without a profile, and a path that cannot be read becomes
|
|
31
|
+
* `status: 'unknown'` with a `reason` — this feeds an HTTP route, where an
|
|
32
|
+
* exception would be a broken page.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import fsDefault from 'node:fs'
|
|
36
|
+
import path from 'node:path'
|
|
37
|
+
|
|
38
|
+
/** How one plugin stands relative to the backup. */
|
|
39
|
+
export const PLUGIN_STATUS = Object.freeze({
|
|
40
|
+
/** Identical by path set and file sizes. */
|
|
41
|
+
SAME: 'same',
|
|
42
|
+
/** Some file was added, removed or changed size. */
|
|
43
|
+
DIFFERENT: 'different',
|
|
44
|
+
/** The plugin has a live directory but the backup has none. */
|
|
45
|
+
MISSING: 'missing',
|
|
46
|
+
/** The plugin has no live directory at all. */
|
|
47
|
+
NOT_INSTALLED: 'not-installed',
|
|
48
|
+
/** It could not be decided (unreadable, no backup, too large to walk). */
|
|
49
|
+
UNKNOWN: 'unknown',
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/** What kind of change one path is. */
|
|
53
|
+
export const CHANGE_KIND = Object.freeze({
|
|
54
|
+
/** Present live, absent from the backup. */
|
|
55
|
+
ADDED: 'added',
|
|
56
|
+
/** Present in the backup, absent live. */
|
|
57
|
+
REMOVED: 'removed',
|
|
58
|
+
/** Present in both, different size. */
|
|
59
|
+
MODIFIED: 'modified',
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
/** How many files one plugin may contain before the walk stops and says so. */
|
|
63
|
+
export const MAX_FILES_PER_PLUGIN = 20000
|
|
64
|
+
|
|
65
|
+
/** How deep the walk goes inside one plugin directory. */
|
|
66
|
+
export const MAX_DEPTH = 12
|
|
67
|
+
|
|
68
|
+
/** How many change rows a single answer carries (the count stays exact). */
|
|
69
|
+
export const MAX_CHANGES = 200
|
|
70
|
+
|
|
71
|
+
/** The directory a profile keeps its plugins in (same constant as the backup). */
|
|
72
|
+
const PLUGINS_DIR = 'node_modules'
|
|
73
|
+
/**
|
|
74
|
+
* WHERE A HAND-WRITTEN PLUGIN LIVES.
|
|
75
|
+
*
|
|
76
|
+
* `dsh plugin add ./my-plugin` does not copy the tree into `node_modules` - it
|
|
77
|
+
* writes a LINK into `node_modules-link`. Looking for such a plugin only under
|
|
78
|
+
* `node_modules` therefore reported it as "missing", which downstream was read
|
|
79
|
+
* as "this is not our own plugin", which removed the one button that could put
|
|
80
|
+
* it back. The data was never cosmetic.
|
|
81
|
+
*/
|
|
82
|
+
const LINK_PLUGINS_DIR = 'node_modules-link'
|
|
83
|
+
|
|
84
|
+
/** A path we are willing to walk: a non-empty string. */
|
|
85
|
+
function dirOf(value) {
|
|
86
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : ''
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Read one JSON file, or null (never throws). */
|
|
90
|
+
function readJson(fs, file) {
|
|
91
|
+
try {
|
|
92
|
+
const text = fs.readFileSync(file, 'utf8')
|
|
93
|
+
const parsed = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text)
|
|
94
|
+
return parsed && typeof parsed === 'object' ? parsed : null
|
|
95
|
+
} catch {
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Does this path exist as a directory? */
|
|
101
|
+
function isDirectory(fs, target) {
|
|
102
|
+
try {
|
|
103
|
+
return fs.statSync(target).isDirectory()
|
|
104
|
+
} catch {
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Is this path ITSELF a symbolic link (without following it)?
|
|
111
|
+
*
|
|
112
|
+
* `node_modules/<name>` is a real directory for an npm install and a SYMLINK for
|
|
113
|
+
* a hand-written plugin added with `dsh plugin add ./my-plugin`. That difference
|
|
114
|
+
* is the only reliable way to tell "our own plugin" from "somebody else's":
|
|
115
|
+
* a broken link still has to be recognised as ours, because that is exactly the
|
|
116
|
+
* case where the user needs the restore button.
|
|
117
|
+
*
|
|
118
|
+
* lstat, not stat: stat follows the link and reports the target, so it can never
|
|
119
|
+
* answer this question.
|
|
120
|
+
*/
|
|
121
|
+
function isSymbolicLink(fs, target) {
|
|
122
|
+
try {
|
|
123
|
+
const lstat = typeof fs.lstatSync === 'function' ? fs.lstatSync : fs.statSync
|
|
124
|
+
return lstat(target).isSymbolicLink() === true
|
|
125
|
+
} catch {
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Walk `root` and return `Map<relativePath, size>`.
|
|
132
|
+
*
|
|
133
|
+
* BOUNDED ON PURPOSE: it stops at `maxFiles` entries and `maxDepth` levels and
|
|
134
|
+
* reports `truncated: true`, because a truncated answer that claims to be
|
|
135
|
+
* complete is worse than no answer at all.
|
|
136
|
+
*
|
|
137
|
+
* Only files are recorded; empty directories carry no meaning for a comparison.
|
|
138
|
+
*
|
|
139
|
+
* @returns {{ files: Map<string, number>, truncated: boolean, reason: string }}
|
|
140
|
+
*/
|
|
141
|
+
export function walkFiles(root, { fs = fsDefault, maxFiles = MAX_FILES_PER_PLUGIN, maxDepth = MAX_DEPTH } = {}) {
|
|
142
|
+
const files = new Map()
|
|
143
|
+
const out = { files, truncated: false, reason: '' }
|
|
144
|
+
const start = dirOf(root)
|
|
145
|
+
if (start === '') {
|
|
146
|
+
out.reason = 'no directory'
|
|
147
|
+
return out
|
|
148
|
+
}
|
|
149
|
+
if (isDirectory(fs, start) !== true) {
|
|
150
|
+
out.reason = 'not a directory'
|
|
151
|
+
return out
|
|
152
|
+
}
|
|
153
|
+
// An explicit stack instead of recursion: a deep tree cannot blow the call stack.
|
|
154
|
+
const stack = [{ dir: start, depth: 0 }]
|
|
155
|
+
while (stack.length > 0) {
|
|
156
|
+
const { dir, depth } = stack.pop()
|
|
157
|
+
let names
|
|
158
|
+
try {
|
|
159
|
+
names = fs.readdirSync(dir, { withFileTypes: true })
|
|
160
|
+
} catch {
|
|
161
|
+
// A directory we cannot read is skipped, not fatal.
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
for (const entry of names) {
|
|
165
|
+
const full = path.join(dir, entry.name)
|
|
166
|
+
let stat = null
|
|
167
|
+
try {
|
|
168
|
+
stat = fs.statSync(full)
|
|
169
|
+
} catch {
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
if (stat.isDirectory() === true) {
|
|
173
|
+
if (depth + 1 <= maxDepth) stack.push({ dir: full, depth: depth + 1 })
|
|
174
|
+
else out.truncated = true
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
if (stat.isFile() !== true) continue
|
|
178
|
+
if (files.size >= maxFiles) {
|
|
179
|
+
out.truncated = true
|
|
180
|
+
return out
|
|
181
|
+
}
|
|
182
|
+
files.set(path.relative(start, full).split(path.sep).join('/'), Number(stat.size) || 0)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The plugins a profile enables.
|
|
190
|
+
*
|
|
191
|
+
* Two sources, both real: `dependencies` (what npm put there) and
|
|
192
|
+
* `dsh.profile.bundles` (what the host was told to load). A bundle can be enabled
|
|
193
|
+
* without being a dependency of the profile package, so the union is the honest
|
|
194
|
+
* answer.
|
|
195
|
+
*
|
|
196
|
+
* NEVER THROWS: an unreadable profile is an empty list plus a `reason`.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} profileDir
|
|
199
|
+
* @param {{ fs?: object }} [options]
|
|
200
|
+
* @returns {{ plugins: Array<{ name: string, version: string|null, installed: boolean, enabled: boolean, dir: string }>, reason: string }}
|
|
201
|
+
*/
|
|
202
|
+
export function listInstalledPlugins(profileDir, { fs = fsDefault } = {}) {
|
|
203
|
+
const dir = dirOf(profileDir)
|
|
204
|
+
const out = { plugins: [], reason: '' }
|
|
205
|
+
if (dir === '') {
|
|
206
|
+
out.reason = 'no profile directory'
|
|
207
|
+
return out
|
|
208
|
+
}
|
|
209
|
+
const pkg = readJson(fs, path.join(dir, 'package.json'))
|
|
210
|
+
if (pkg === null) {
|
|
211
|
+
out.reason = 'the profile package.json could not be read'
|
|
212
|
+
return out
|
|
213
|
+
}
|
|
214
|
+
const dependencies = pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {}
|
|
215
|
+
const dsh = pkg.dsh && typeof pkg.dsh === 'object' ? pkg.dsh : {}
|
|
216
|
+
const profile = dsh.profile && typeof dsh.profile === 'object' ? dsh.profile : {}
|
|
217
|
+
const bundles = Array.isArray(profile.bundles) ? profile.bundles : []
|
|
218
|
+
const enabled = new Set()
|
|
219
|
+
for (const entry of bundles) {
|
|
220
|
+
// A bundle is either a bare name or `{ name }` — both spellings exist.
|
|
221
|
+
const name = typeof entry === 'string' ? entry : (entry && typeof entry.name === 'string' ? entry.name : '')
|
|
222
|
+
if (name !== '') enabled.add(name)
|
|
223
|
+
}
|
|
224
|
+
const names = new Set()
|
|
225
|
+
for (const name of Object.keys(dependencies)) if (typeof name === 'string' && name !== '') names.add(name)
|
|
226
|
+
for (const name of enabled) names.add(name)
|
|
227
|
+
|
|
228
|
+
for (const name of [...names].sort()) {
|
|
229
|
+
// ONE directory. In a profile a hand-written plugin is a SYMLINK inside
|
|
230
|
+
// node_modules - there is no node_modules-link there at all.
|
|
231
|
+
const pluginDir = path.join(dir, PLUGINS_DIR, name)
|
|
232
|
+
const linked = isSymbolicLink(fs, pluginDir)
|
|
233
|
+
const installed = isDirectory(fs, pluginDir)
|
|
234
|
+
const manifest = installed ? readJson(fs, path.join(pluginDir, 'package.json')) : null
|
|
235
|
+
out.plugins.push({
|
|
236
|
+
name,
|
|
237
|
+
version: manifest !== null && typeof manifest.version === 'string' ? manifest.version : null,
|
|
238
|
+
installed,
|
|
239
|
+
enabled: enabled.has(name),
|
|
240
|
+
linked,
|
|
241
|
+
dir: pluginDir,
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
return out
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* HOW ONE PLUGIN DIFFERS FROM ITS COPY IN THE BACKUP.
|
|
249
|
+
*
|
|
250
|
+
* The comparison is "path set + size" and nothing else: no hashing, no file
|
|
251
|
+
* contents, one `stat` per file. That is what keeps a 120-plugin, 295 MB backup
|
|
252
|
+
* inside a page load.
|
|
253
|
+
*
|
|
254
|
+
* @param {{ liveDir?: string, backupDir?: string }} input
|
|
255
|
+
* @param {{ fs?: object, maxFiles?: number, maxDepth?: number, maxChanges?: number }} [options]
|
|
256
|
+
* @returns {{ status: string, changes: Array<{ path: string, kind: string }>,
|
|
257
|
+
* changeCount: number, liveCount: number, backupCount: number,
|
|
258
|
+
* truncated: boolean, reason: string }}
|
|
259
|
+
*/
|
|
260
|
+
export function comparePlugin(input, { fs = fsDefault, maxFiles = MAX_FILES_PER_PLUGIN, maxDepth = MAX_DEPTH, maxChanges = MAX_CHANGES } = {}) {
|
|
261
|
+
// Destructuring `null` throws, and this function promises it never does — so the
|
|
262
|
+
// input is normalised before anything is read from it.
|
|
263
|
+
const { liveDir, backupDir } = input !== null && typeof input === 'object' ? input : {}
|
|
264
|
+
const out = {
|
|
265
|
+
status: PLUGIN_STATUS.UNKNOWN,
|
|
266
|
+
changes: [],
|
|
267
|
+
changeCount: 0,
|
|
268
|
+
liveCount: 0,
|
|
269
|
+
backupCount: 0,
|
|
270
|
+
truncated: false,
|
|
271
|
+
reason: '',
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
const live = dirOf(liveDir)
|
|
275
|
+
const backup = dirOf(backupDir)
|
|
276
|
+
if (live !== '' && isDirectory(fs, live) !== true) {
|
|
277
|
+
// The profile has no directory for it at all: nothing to compare.
|
|
278
|
+
out.status = PLUGIN_STATUS.NOT_INSTALLED
|
|
279
|
+
out.reason = 'the plugin has no directory in the profile'
|
|
280
|
+
return out
|
|
281
|
+
}
|
|
282
|
+
if (live === '') {
|
|
283
|
+
out.reason = 'no live directory was given'
|
|
284
|
+
return out
|
|
285
|
+
}
|
|
286
|
+
if (backup === '' || isDirectory(fs, backup) !== true) {
|
|
287
|
+
// It is here now but the backup never had it.
|
|
288
|
+
out.status = PLUGIN_STATUS.MISSING
|
|
289
|
+
out.reason = 'the backup has no copy of this plugin'
|
|
290
|
+
return out
|
|
291
|
+
}
|
|
292
|
+
const liveWalk = walkFiles(live, { fs, maxFiles, maxDepth })
|
|
293
|
+
const backupWalk = walkFiles(backup, { fs, maxFiles, maxDepth })
|
|
294
|
+
out.liveCount = liveWalk.files.size
|
|
295
|
+
out.backupCount = backupWalk.files.size
|
|
296
|
+
if (liveWalk.truncated === true || backupWalk.truncated === true) out.truncated = true
|
|
297
|
+
|
|
298
|
+
const all = new Set([...liveWalk.files.keys(), ...backupWalk.files.keys()])
|
|
299
|
+
const changes = []
|
|
300
|
+
for (const relPath of all) {
|
|
301
|
+
const liveSize = liveWalk.files.get(relPath)
|
|
302
|
+
const backupSize = backupWalk.files.get(relPath)
|
|
303
|
+
if (liveSize === undefined) {
|
|
304
|
+
changes.push({ path: relPath, kind: CHANGE_KIND.REMOVED })
|
|
305
|
+
} else if (backupSize === undefined) {
|
|
306
|
+
changes.push({ path: relPath, kind: CHANGE_KIND.ADDED })
|
|
307
|
+
} else if (liveSize !== backupSize) {
|
|
308
|
+
changes.push({ path: relPath, kind: CHANGE_KIND.MODIFIED })
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
changes.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
|
|
312
|
+
out.changeCount = changes.length
|
|
313
|
+
// The count is exact even when the rows are cut — the interface can say
|
|
314
|
+
// "+37 more" without lying about how many there are.
|
|
315
|
+
out.changes = changes.slice(0, maxChanges)
|
|
316
|
+
out.status = changes.length === 0 ? PLUGIN_STATUS.SAME : PLUGIN_STATUS.DIFFERENT
|
|
317
|
+
return out
|
|
318
|
+
} catch (error) {
|
|
319
|
+
out.status = PLUGIN_STATUS.UNKNOWN
|
|
320
|
+
out.reason = String(error && error.message ? error.message : error)
|
|
321
|
+
return out
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The newest backup directory under `backupsDir`, or null.
|
|
327
|
+
*
|
|
328
|
+
* Names are timestamps (`20261101T024043Z`), so the lexical maximum is the newest;
|
|
329
|
+
* a directory that does not parse still competes by modification time, because a
|
|
330
|
+
* backup this code cannot name is still a backup.
|
|
331
|
+
*/
|
|
332
|
+
function newestBackupDir(backupsDir, fs) {
|
|
333
|
+
const root = dirOf(backupsDir)
|
|
334
|
+
if (root === '' || isDirectory(fs, root) !== true) return null
|
|
335
|
+
let names
|
|
336
|
+
try {
|
|
337
|
+
names = fs.readdirSync(root, { withFileTypes: true })
|
|
338
|
+
} catch {
|
|
339
|
+
return null
|
|
340
|
+
}
|
|
341
|
+
let best = null
|
|
342
|
+
let bestKey = ''
|
|
343
|
+
let bestTime = -1
|
|
344
|
+
for (const entry of names) {
|
|
345
|
+
if (entry.isDirectory() !== true) continue
|
|
346
|
+
const full = path.join(root, entry.name)
|
|
347
|
+
// A DIRECTORY WITHOUT A TIMESTAMP IS NOT A BACKUP (2026-11-01).
|
|
348
|
+
//
|
|
349
|
+
// The name and the mtime alone are not enough: a half-written snapshot has a
|
|
350
|
+
// perfectly good-looking name and a very recent mtime, which is exactly how
|
|
351
|
+
// the broken 06-06-50 directory was picked as "the newest backup" and used as
|
|
352
|
+
// the baseline for the comparison. The rule is the same one listBackups uses:
|
|
353
|
+
// a manifest or a meta.json with a finite `at`, or it does not compete.
|
|
354
|
+
//
|
|
355
|
+
// It is not removed or moved here — only excluded from the competition.
|
|
356
|
+
const meta = readJson(fs, path.join(full, 'manifest.json'))
|
|
357
|
+
?? readJson(fs, path.join(full, 'meta.json'))
|
|
358
|
+
if (meta === null || !Number.isFinite(Number(meta.at))) continue
|
|
359
|
+
let time = -1
|
|
360
|
+
try {
|
|
361
|
+
time = Number(fs.statSync(full).mtimeMs) || 0
|
|
362
|
+
} catch {
|
|
363
|
+
time = 0
|
|
364
|
+
}
|
|
365
|
+
// A timestamp-shaped name wins outright; otherwise the newest mtime does.
|
|
366
|
+
const looksStamped = /^\d{8}T\d{6}Z?$/.test(entry.name) || /^\d{8}-\d{6}/.test(entry.name)
|
|
367
|
+
const key = (looksStamped ? '1' : '0') + entry.name
|
|
368
|
+
if (best === null || key > bestKey || (key === bestKey && time > bestTime)) {
|
|
369
|
+
best = { dir: full, name: entry.name, at: time }
|
|
370
|
+
bestKey = key
|
|
371
|
+
bestTime = time
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return best
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* THE WHOLE LIST, MEASURED AGAINST THE NEWEST BACKUP.
|
|
379
|
+
*
|
|
380
|
+
* With no backup at all this does NOT fail: it returns the installed plugins with
|
|
381
|
+
* `status: 'unknown'` and `reason: 'no backup'`, because "there is nothing to
|
|
382
|
+
* compare against yet" is a normal state on a fresh machine, not an error.
|
|
383
|
+
*
|
|
384
|
+
* @param {{ profileDir?: string, backupsDir?: string, now?: number }} input
|
|
385
|
+
* @param {{ fs?: object, maxFiles?: number, maxDepth?: number, maxChanges?: number }} [options]
|
|
386
|
+
* @returns {{ backupAt: number|null, backupDir: string|null, backupName: string|null,
|
|
387
|
+
* plugins: Array<object>, reason: string }}
|
|
388
|
+
*/
|
|
389
|
+
export function inventory(input, { fs = fsDefault, maxFiles = MAX_FILES_PER_PLUGIN, maxDepth = MAX_DEPTH, maxChanges = MAX_CHANGES } = {}) {
|
|
390
|
+
const { profileDir, backupsDir, now } = input !== null && typeof input === 'object' ? input : {}
|
|
391
|
+
const out = { backupAt: null, backupDir: null, backupName: null, plugins: [], reason: '' }
|
|
392
|
+
try {
|
|
393
|
+
const listed = listInstalledPlugins(profileDir, { fs })
|
|
394
|
+
if (listed.reason !== '') out.reason = listed.reason
|
|
395
|
+
const backup = newestBackupDir(backupsDir, fs)
|
|
396
|
+
if (backup === null) {
|
|
397
|
+
// Nothing to compare against: list what is installed and say why there is
|
|
398
|
+
// no verdict. This is the fresh-machine case, not a failure.
|
|
399
|
+
out.plugins = listed.plugins.map((plugin) => ({
|
|
400
|
+
name: plugin.name,
|
|
401
|
+
version: plugin.version,
|
|
402
|
+
installed: plugin.installed,
|
|
403
|
+
enabled: plugin.enabled,
|
|
404
|
+
status: PLUGIN_STATUS.UNKNOWN,
|
|
405
|
+
backupVersion: null,
|
|
406
|
+
changeCount: 0,
|
|
407
|
+
linked: plugin.linked === true,
|
|
408
|
+
}))
|
|
409
|
+
out.reason = out.reason === '' ? 'no backup' : out.reason + '; no backup'
|
|
410
|
+
return out
|
|
411
|
+
}
|
|
412
|
+
out.backupDir = backup.dir
|
|
413
|
+
out.backupName = backup.name
|
|
414
|
+
const meta = readJson(fs, path.join(backup.dir, 'meta.json'))
|
|
415
|
+
const at = meta !== null && Number.isFinite(Number(meta.at)) ? Math.trunc(Number(meta.at)) : null
|
|
416
|
+
out.backupAt = at !== null ? at : (Number.isFinite(Number(now)) ? Math.trunc(Number(now)) : null)
|
|
417
|
+
|
|
418
|
+
for (const plugin of listed.plugins) {
|
|
419
|
+
// The copy of a LINKED plugin is under node_modules-link in the backup too;
|
|
420
|
+
// looking in node_modules made a perfectly good backup look absent.
|
|
421
|
+
const backupPluginDir = path.join(
|
|
422
|
+
backup.dir,
|
|
423
|
+
plugin.linked === true ? LINK_PLUGINS_DIR : PLUGINS_DIR,
|
|
424
|
+
plugin.name,
|
|
425
|
+
)
|
|
426
|
+
const compared = comparePlugin(
|
|
427
|
+
{ liveDir: plugin.dir, backupDir: backupPluginDir },
|
|
428
|
+
{ fs, maxFiles, maxDepth, maxChanges: 0 },
|
|
429
|
+
)
|
|
430
|
+
const backupManifest = isDirectory(fs, backupPluginDir)
|
|
431
|
+
? readJson(fs, path.join(backupPluginDir, 'package.json'))
|
|
432
|
+
: null
|
|
433
|
+
out.plugins.push({
|
|
434
|
+
name: plugin.name,
|
|
435
|
+
version: plugin.version,
|
|
436
|
+
installed: plugin.installed,
|
|
437
|
+
enabled: plugin.enabled,
|
|
438
|
+
status: compared.status,
|
|
439
|
+
backupVersion: backupManifest !== null && typeof backupManifest.version === 'string' ? backupManifest.version : null,
|
|
440
|
+
changeCount: compared.changeCount,
|
|
441
|
+
truncated: compared.truncated === true,
|
|
442
|
+
linked: plugin.linked === true,
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
return out
|
|
446
|
+
} catch (error) {
|
|
447
|
+
out.reason = String(error && error.message ? error.message : error)
|
|
448
|
+
return out
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The change rows of ONE plugin, for the detail view.
|
|
454
|
+
*
|
|
455
|
+
* Same comparison, but with the rows kept (still capped) — the list view only needs
|
|
456
|
+
* counts, the detail view needs the paths.
|
|
457
|
+
*
|
|
458
|
+
* @returns {{ plugin: string, status: string, changes: Array<{ path: string, kind: string }>,
|
|
459
|
+
* changeCount: number, truncated: boolean, reason: string }}
|
|
460
|
+
*/
|
|
461
|
+
export function pluginDetail(input, { fs = fsDefault, maxFiles = MAX_FILES_PER_PLUGIN, maxDepth = MAX_DEPTH, maxChanges = MAX_CHANGES } = {}) {
|
|
462
|
+
const { profileDir, backupsDir, plugin } = input !== null && typeof input === 'object' ? input : {}
|
|
463
|
+
const name = typeof plugin === 'string' ? plugin.trim() : ''
|
|
464
|
+
const out = {
|
|
465
|
+
plugin: name,
|
|
466
|
+
status: PLUGIN_STATUS.UNKNOWN,
|
|
467
|
+
changes: [],
|
|
468
|
+
changeCount: 0,
|
|
469
|
+
truncated: false,
|
|
470
|
+
reason: '',
|
|
471
|
+
}
|
|
472
|
+
if (name === '') {
|
|
473
|
+
out.reason = 'no plugin name was given'
|
|
474
|
+
return out
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
const profile = dirOf(profileDir)
|
|
478
|
+
const liveDir = profile === '' ? '' : path.join(profile, PLUGINS_DIR, name)
|
|
479
|
+
const isLinked = liveDir !== '' && isSymbolicLink(fs, liveDir)
|
|
480
|
+
const backup = newestBackupDir(backupsDir, fs)
|
|
481
|
+
if (backup === null) {
|
|
482
|
+
out.status = PLUGIN_STATUS.UNKNOWN
|
|
483
|
+
out.reason = 'no backup'
|
|
484
|
+
return out
|
|
485
|
+
}
|
|
486
|
+
const backupDir = path.join(backup.dir, isLinked === true ? LINK_PLUGINS_DIR : PLUGINS_DIR, name)
|
|
487
|
+
const compared = comparePlugin({ liveDir, backupDir }, { fs, maxFiles, maxDepth, maxChanges })
|
|
488
|
+
out.status = compared.status
|
|
489
|
+
out.changes = compared.changes
|
|
490
|
+
out.changeCount = compared.changeCount
|
|
491
|
+
out.truncated = compared.truncated
|
|
492
|
+
out.reason = compared.reason
|
|
493
|
+
return out
|
|
494
|
+
} catch (error) {
|
|
495
|
+
out.status = PLUGIN_STATUS.UNKNOWN
|
|
496
|
+
out.reason = String(error && error.message ? error.message : error)
|
|
497
|
+
return out
|
|
498
|
+
}
|
|
499
|
+
}
|