dsh-autorestart 0.8.4 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/README.md +465 -40
  3. package/assets/icon-9c245e5b.ico +0 -0
  4. package/assets/icon-rescue-1902bb15.ico +0 -0
  5. package/assets/icon-rescue-1902bb15.png +0 -0
  6. package/client.js +3002 -170
  7. package/cordis.patch.yml +12 -0
  8. package/docs/README.zh.md +231 -23
  9. package/docs/platform-matrix.md +171 -55
  10. package/docs/real-machine-verification-0.8.6.md +202 -0
  11. package/index.js +13272 -8513
  12. package/lib/autostart-advice.js +31 -2
  13. package/lib/backup.js +2768 -21
  14. package/lib/checkpoint.js +214 -7
  15. package/lib/cookie-clear.js +260 -0
  16. package/lib/lifecycle.js +23 -40
  17. package/lib/plugin-compare.js +499 -0
  18. package/lib/plugin-failure.js +382 -0
  19. package/lib/plugin-recover.js +1557 -0
  20. package/lib/plugin-watch.js +213 -0
  21. package/lib/rescue-paths.js +982 -56
  22. package/lib/restart-phase.js +5 -0
  23. package/lib/restart-verdict.js +379 -0
  24. package/lib/restart.js +244 -7
  25. package/lib/scheduler-capability.js +19 -3
  26. package/lib/sleep.js +190 -29
  27. package/lib/watchdog-disarm.js +46 -2
  28. package/lib/watchdog-schedule.js +834 -17
  29. package/lib/watchdog-tier.js +8 -1
  30. package/lib/watchdog.js +1222 -47
  31. package/locale/en.json +175 -68
  32. package/locale/zh.json +175 -68
  33. package/package.json +3 -2
  34. package/screenshots.json +6 -6
  35. package/scripts/README.md +6 -3
  36. package/scripts/build-icon.mjs +998 -0
  37. package/scripts/clear-dsh-auth-cookies.mjs +257 -0
  38. package/scripts/emergency-disable.sh +69 -2
  39. package/scripts/emergency-toggle.ps1 +85 -69
  40. package/scripts/rescue-strings.zh.json +36 -18
  41. package/scripts/restore-profile.ps1 +182 -1
  42. package/scripts/watchdog.mjs +432 -79
@@ -0,0 +1,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
+ }