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,382 @@
1
+ /**
2
+ * dsh-autorestart — "which plugin did not load?" (the REPORTED kind of failure).
3
+ *
4
+ * WHY THIS MODULE EXISTS. A plugin that fails to import does not always keep the
5
+ * host from booting: the host comes up, the entry silently never activates, and
6
+ * the feature behind it simply 404s. The startup log says so, in a handful of
7
+ * lines. This module turns that text into structured failures; nothing else.
8
+ *
9
+ * EVERYTHING HERE IS PURE: text in, array out. No file system, no DSH, no npm.
10
+ * The caller decides which log to read (lib/plugin-recover.js owns the file
11
+ * discovery) and what to do about the answer.
12
+ *
13
+ * THE FOUR LINE SHAPES THIS RECOGNISES (all four were seen in real startup logs
14
+ * and in the diagnostic dump of a boot audit):
15
+ *
16
+ * 1. `dsh: warning: 1 entry did not activate` — the aggregate warning
17
+ * 2. `<plugin>: failed to import: <error>` — the import error itself
18
+ * 3. `<plugin>: import failed ...` — the same, other wording
19
+ * 4. `- <plugin>: loading` — an entry still loading
20
+ * when the audit ran, i.e.
21
+ * it never activated
22
+ *
23
+ * EVERYTHING ELSE IS IGNORED, on purpose. In particular the process-level dump
24
+ * (`dsh: startup failed: 2 required plugins did not activate` followed by the
25
+ * `Failed plugins (1):` block with `Package:` / `Error:` lines) is NOT this
26
+ * module's business: a boot that does not come up is the existing watchdog's
27
+ * failure, not a plugin to be swapped out. Recognising it here would make the
28
+ * two mechanisms fight over the same incident.
29
+ *
30
+ * @module dsh-autorestart/lib/plugin-failure
31
+ */
32
+
33
+ import crypto from 'node:crypto';
34
+
35
+ /**
36
+ * How much of one error message is kept.
37
+ *
38
+ * A stack trace of thirty frames is useless on a card and unreadable in a JSON
39
+ * report; the first line plus a little context is what tells a user what broke.
40
+ */
41
+ export const MAX_ERROR_CHARS = 240;
42
+
43
+ /** How long the raw log line is kept for the report. */
44
+ export const MAX_TEXT_CHARS = 400;
45
+
46
+ /** The kinds this parser distinguishes (they end up in the report). */
47
+ export const FAILURE_KINDS = Object.freeze({
48
+ /** `<plugin>: failed to import: <error>` */
49
+ FAILED_TO_IMPORT: 'failed-to-import',
50
+ /** `<plugin>: import failed ...` */
51
+ IMPORT_FAILED: 'import-failed',
52
+ /** `- <plugin>: loading` (never activated) */
53
+ NOT_ACTIVATED: 'not-activated',
54
+ /** `dsh: warning: N entry did not activate` (names no plugin) */
55
+ WARNING: 'warning',
56
+ });
57
+
58
+ /**
59
+ * The aggregate warning line: `dsh: warning: 1 entry did not activate`.
60
+ *
61
+ * It names no plugin, so it cannot be recovered on its own — but it is the line
62
+ * that says "look at the lines around me", and dropping it would make a report
63
+ * say "no failure found" next to the host's own warning.
64
+ *
65
+ * @type {RegExp}
66
+ */
67
+ const WARNING_LINE = /^(?:dsh:\s*)?(?:warning:\s*)?(\d+)\s+entr(?:y|ies)\s+(?:did not activate|failed to activate|was not activated)\b/i;
68
+
69
+ /**
70
+ * `<plugin>: failed to import: <error>` / `<plugin>: import failed: <error>`.
71
+ *
72
+ * The optional `- ` bullet is what the audit's list uses, and the optional
73
+ * `(required)` / `(optional)` suffix is what the process-level dump uses.
74
+ *
75
+ * @type {RegExp}
76
+ */
77
+ const IMPORT_FAILED_LINE = /^(?:dsh:\s*)?[-*]?\s*([@A-Za-z0-9_][\w@./-]*)\s*(?:\((?:required|optional)\))?\s*:\s*(failed to import|import failed)\b\s*:?\s*(.*)$/i;
78
+
79
+ /**
80
+ * `- <plugin>: loading` — the entry was still loading when the audit ran.
81
+ *
82
+ * A plugin in this state did not activate; the line is a failure even though it
83
+ * carries no error message. The match is anchored at the end of the line on
84
+ * purpose: `dsh: loading profile web` is the host talking about its own work,
85
+ * not a plugin that never activated.
86
+ *
87
+ * @type {RegExp}
88
+ */
89
+ const LOADING_LINE = /^(?:dsh:\s*)?[-*]?\s*([@A-Za-z0-9_][\w@./-]*)\s*(?:\((?:required|optional)\))?\s*:\s*loading\b\s*$/i;
90
+
91
+ /**
92
+ * Names that belong to the HOST, never to a plugin.
93
+ *
94
+ * `dsh: <something>` is the host's own prefix; treating `dsh` as a plugin name
95
+ * would try to restore the harness itself.
96
+ *
97
+ * @type {Set<string>}
98
+ */
99
+ const RESERVED_NAMES = new Set(['dsh', 'harness', 'node', 'npm']);
100
+
101
+ /** An ISO timestamp at the start of a line (the host's own log prefix). */
102
+ const ISO_PREFIX = /^\[?(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(Z|[+-]\d{2}:?\d{2})?\]?/;
103
+
104
+ /** A bare clock at the start of a line: `[15:33:37]`. */
105
+ const CLOCK_PREFIX = /^\[(\d{2}):(\d{2}):(\d{2})\]/;
106
+
107
+ /**
108
+ * The timestamp prefix of a log line, as it is STRIPPED before matching.
109
+ *
110
+ * A real startup log prefixes each line with its time; the patterns below only
111
+ * describe the message, so the timestamp is removed first (and read separately
112
+ * by {@link parseLogLineTime}).
113
+ *
114
+ * @type {RegExp}
115
+ */
116
+ const TIMESTAMP_PREFIX = /^\[?\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2})?\]?[ \t]*/;
117
+
118
+ /** A bracketed clock prefix: `[15:33:37] `. */
119
+ const CLOCK_ONLY_PREFIX = /^\[\d{2}:\d{2}:\d{2}\][ \t]*/;
120
+
121
+ /**
122
+ * Drop the timestamp a log line starts with (and any `dsh: ` host prefix), so
123
+ * the message patterns can be anchored at the message itself.
124
+ *
125
+ * @param {string} line
126
+ * @returns {string}
127
+ */
128
+ function messageOf(line) {
129
+ const text = typeof line === 'string' ? line.trim() : '';
130
+ return text.replace(TIMESTAMP_PREFIX, '').replace(CLOCK_ONLY_PREFIX, '').trim();
131
+ }
132
+
133
+ /**
134
+ * Trim one value to a bounded, single-line string.
135
+ *
136
+ * Control characters are dropped (a log line may carry an ANSI colour code, and
137
+ * a JSON report with escape sequences in it is unreadable), whitespace is
138
+ * collapsed, and an over-long value is cut with an ellipsis so the caller can
139
+ * tell "short" from "shortened".
140
+ *
141
+ * @param {unknown} value
142
+ * @param {number} [max]
143
+ * @returns {string}
144
+ */
145
+ export function clipText(value, max = MAX_ERROR_CHARS) {
146
+ const text = typeof value === 'string' ? value : (value === null || value === undefined ? '' : String(value));
147
+ const limit = Number.isFinite(Number(max)) && Number(max) > 0 ? Math.trunc(Number(max)) : MAX_ERROR_CHARS;
148
+ // eslint-disable-next-line no-control-regex
149
+ const flat = text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').replace(/\s+/g, ' ').trim();
150
+ if (flat.length <= limit) return flat;
151
+ return flat.slice(0, Math.max(0, limit - 1)) + '…';
152
+ }
153
+
154
+ /**
155
+ * The FIRST line of an error message — the part that says what broke.
156
+ *
157
+ * @param {unknown} value
158
+ * @returns {string}
159
+ */
160
+ export function errorFirstLine(value) {
161
+ const text = typeof value === 'string' ? value : (value === null || value === undefined ? '' : String(value));
162
+ const first = text.split(/\r?\n/).find((line) => line.trim() !== '') || '';
163
+ return clipText(first, MAX_ERROR_CHARS);
164
+ }
165
+
166
+ /**
167
+ * The time a log line carries, in milliseconds (null when it carries none).
168
+ *
169
+ * Two prefixes are recognised because two forms are common: the host's ISO
170
+ * prefix and a bracketed clock. A bare clock has no date, so TODAY's date (taken
171
+ * from `now`) is used — it is only ever compared against other lines of the same
172
+ * file, so the day boundary is the only inaccuracy, and a log does not span days.
173
+ *
174
+ * @param {string} line
175
+ * @param {number} [now]
176
+ * @returns {number|null}
177
+ */
178
+ export function parseLogLineTime(line, now = Date.now()) {
179
+ const text = typeof line === 'string' ? line : '';
180
+ const iso = ISO_PREFIX.exec(text);
181
+ if (iso) {
182
+ const [, year, month, day, hour, minute, second, fraction = '', zone = 'Z'] = iso;
183
+ const millis = fraction === '' ? '000' : (fraction + '000').slice(0, 3);
184
+ const normalizedZone = zone === 'Z' || zone === undefined
185
+ ? 'Z'
186
+ : (zone.includes(':') ? zone : zone.slice(0, 3) + ':' + zone.slice(3));
187
+ const stamp = Date.parse(`${year}-${month}-${day}T${hour}:${minute}:${second}.${millis}${normalizedZone}`);
188
+ if (Number.isFinite(stamp)) return stamp;
189
+ return null;
190
+ }
191
+ const clock = CLOCK_PREFIX.exec(text);
192
+ if (clock) {
193
+ const base = new Date(Number.isFinite(Number(now)) ? Number(now) : Date.now());
194
+ base.setHours(Number(clock[1]), Number(clock[2]), Number(clock[3]), 0);
195
+ const stamp = base.getTime();
196
+ return Number.isFinite(stamp) ? stamp : null;
197
+ }
198
+ return null;
199
+ }
200
+
201
+ /**
202
+ * THE FINGERPRINT of one failure: "the same failure seen twice".
203
+ *
204
+ * Built from the plugin, the FIRST LINE of the error and the log time (rounded
205
+ * to the second, so a re-read of the same file produces the same value). It is
206
+ * what makes "this exact failure was already handled" decidable across boots:
207
+ * the same old log line read again next boot yields the same fingerprint, so it
208
+ * is NOT treated as a new incident and does not trigger another restore.
209
+ *
210
+ * The error TEXT is deliberately reduced to its first line: stack traces differ
211
+ * between runs (line numbers, addresses), the message does not.
212
+ *
213
+ * @param {{ plugin?: string|null, error?: string|null, at?: number|null }} failure
214
+ * @returns {string}
215
+ */
216
+ export function failureFingerprint(failure = {}) {
217
+ const plugin = typeof failure.plugin === 'string' ? failure.plugin : '';
218
+ const error = errorFirstLine(failure.error);
219
+ const at = Number.isFinite(Number(failure.at)) ? String(Math.floor(Number(failure.at) / 1000)) : '';
220
+ return crypto.createHash('sha1').update([plugin, error, at].join('\u0000')).digest('hex').slice(0, 16);
221
+ }
222
+
223
+ /**
224
+ * Recognise the plugin failures of one startup log.
225
+ *
226
+ * @param {string} text the log text
227
+ * @param {{
228
+ * sinceMs?: number|null,
229
+ * logAt?: number|null,
230
+ * now?: number,
231
+ * dedupe?: boolean,
232
+ * }} [options]
233
+ * `sinceMs` — lines stamped EARLIER than this are not new information and are
234
+ * dropped (the caller passes the start of the boot the log belongs to). An
235
+ * entry whose time cannot be determined is KEPT: throwing it away would hide
236
+ * the only record of the failure.
237
+ * `logAt` — the fallback time for a line without a timestamp (normally the
238
+ * log file's mtime).
239
+ * `dedupe` — drop repeated fingerprints inside the same text (default true).
240
+ * @returns {Array<{
241
+ * plugin: string|null,
242
+ * error: string,
243
+ * line: number,
244
+ * text: string,
245
+ * at: number|null,
246
+ * kind: string,
247
+ * fingerprint: string,
248
+ * }>}
249
+ */
250
+ export function findPluginFailures(text, options = {}) {
251
+ const source = typeof text === 'string' ? text : '';
252
+ if (source.trim() === '') return [];
253
+ const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now();
254
+ const sinceMs = Number.isFinite(Number(options.sinceMs)) ? Number(options.sinceMs) : null;
255
+ const logAt = Number.isFinite(Number(options.logAt)) ? Number(options.logAt) : null;
256
+ const dedupe = options.dedupe !== false;
257
+
258
+ const lines = source.split(/\r?\n/);
259
+ const failures = [];
260
+ const seen = new Set();
261
+
262
+ for (let index = 0; index < lines.length; index += 1) {
263
+ const raw = typeof lines[index] === 'string' ? lines[index] : '';
264
+ const trimmed = raw.trim();
265
+ if (trimmed === '') continue;
266
+ // The timestamp (and the `dsh: ` host prefix) is dropped before matching, so
267
+ // a real prefixed log line and a bare one are the same message.
268
+ const message = messageOf(trimmed);
269
+
270
+ /** @type {{ plugin: string|null, error: string, kind: string, matched: string }|null} */
271
+ let found = null;
272
+
273
+ const warning = WARNING_LINE.exec(message);
274
+ if (warning) {
275
+ // No plugin is named: the aggregate line is a SIGNAL (kind 'warning') and
276
+ // the recovery planner never acts on it — there is nothing to swap.
277
+ found = {
278
+ plugin: null,
279
+ error: clipText(`warning: ${warning[1]} entry/entries did not activate`),
280
+ kind: FAILURE_KINDS.WARNING,
281
+ matched: trimmed,
282
+ };
283
+ }
284
+
285
+ if (found === null) {
286
+ const failed = IMPORT_FAILED_LINE.exec(message);
287
+ if (failed) {
288
+ let detail = typeof failed[3] === 'string' ? failed[3].trim() : '';
289
+ if (detail === '') {
290
+ // The message may sit on the next (indented) line of the log.
291
+ for (let look = index + 1; look < Math.min(lines.length, index + 4); look += 1) {
292
+ const next = typeof lines[look] === 'string' ? lines[look] : '';
293
+ if (next.trim() === '') continue;
294
+ if (!/^\s+/.test(next)) break;
295
+ detail = next.trim();
296
+ break;
297
+ }
298
+ }
299
+ found = {
300
+ plugin: failed[1],
301
+ error: clipText(detail === '' ? 'the import failed without a message' : detail),
302
+ kind: /failed to import/i.test(failed[2])
303
+ ? FAILURE_KINDS.FAILED_TO_IMPORT
304
+ : FAILURE_KINDS.IMPORT_FAILED,
305
+ matched: trimmed,
306
+ };
307
+ }
308
+ }
309
+
310
+ if (found === null) {
311
+ const loading = LOADING_LINE.exec(message);
312
+ if (loading) {
313
+ found = {
314
+ plugin: loading[1],
315
+ error: clipText('the entry was still loading when the startup audit ran: it never activated'),
316
+ kind: FAILURE_KINDS.NOT_ACTIVATED,
317
+ matched: trimmed,
318
+ };
319
+ }
320
+ }
321
+
322
+ // Anything else is not a plugin failure this module knows (a stack frame, a
323
+ // config line, the process-level dump, the usual chatter): ignored.
324
+ if (found === null) continue;
325
+ // `dsh: …` is the HOST talking: the harness itself is not a plugin to swap.
326
+ if (typeof found.plugin === 'string' && RESERVED_NAMES.has(found.plugin.toLowerCase())) continue;
327
+
328
+ const at = parseLogLineTime(trimmed, now) ?? logAt;
329
+ if (sinceMs !== null && at !== null && at < sinceMs) continue;
330
+
331
+ const entry = {
332
+ plugin: found.plugin,
333
+ error: found.error,
334
+ line: index + 1,
335
+ text: clipText(trimmed, MAX_TEXT_CHARS),
336
+ at: at === null ? null : Math.trunc(at),
337
+ kind: found.kind,
338
+ fingerprint: '',
339
+ };
340
+ entry.fingerprint = failureFingerprint(entry);
341
+ if (dedupe && seen.has(entry.fingerprint)) continue;
342
+ seen.add(entry.fingerprint);
343
+ failures.push(entry);
344
+ }
345
+
346
+ return failures;
347
+ }
348
+
349
+ /**
350
+ * The plugins a failure list really names, one entry per plugin, newest first.
351
+ *
352
+ * Several lines can describe the SAME plugin (the aggregate warning, an import
353
+ * error, a "still loading" line). The recovery works per plugin, and the BEST
354
+ * description is the one that carries an error message — so a silent
355
+ * "did not activate" line never overwrites a real error.
356
+ *
357
+ * @param {Array<object>} failures
358
+ * @returns {Array<object>}
359
+ */
360
+ export function mergeFailuresByPlugin(failures = []) {
361
+ const byPlugin = new Map();
362
+ for (const failure of Array.isArray(failures) ? failures : []) {
363
+ if (!failure || typeof failure !== 'object') continue;
364
+ const plugin = typeof failure.plugin === 'string' ? failure.plugin : '';
365
+ // An aggregate warning names no plugin: it can never be restored, and it is
366
+ // not merged into anybody (it is reported on its own).
367
+ const key = plugin === '' ? `\u0000warning:${failure.line}:${failure.fingerprint}` : plugin;
368
+ const previous = byPlugin.get(key);
369
+ if (previous === undefined) {
370
+ byPlugin.set(key, failure);
371
+ continue;
372
+ }
373
+ const previousHasError = typeof previous.kind === 'string'
374
+ && previous.kind !== FAILURE_KINDS.NOT_ACTIVATED
375
+ && previous.kind !== FAILURE_KINDS.WARNING;
376
+ const currentHasError = typeof failure.kind === 'string'
377
+ && failure.kind !== FAILURE_KINDS.NOT_ACTIVATED
378
+ && failure.kind !== FAILURE_KINDS.WARNING;
379
+ if (!previousHasError && currentHasError) byPlugin.set(key, failure);
380
+ }
381
+ return [...byPlugin.values()];
382
+ }