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,257 @@
1
+ /**
2
+ * dsh-autorestart - remove the stale handshake cookies of the desktop app.
3
+ *
4
+ * WHY THIS FILE EXISTS:
5
+ *
6
+ * The desktop app mints ONE session cookie per launch:
7
+ *
8
+ * dsh-auth-<base64url(sha256(host:port))>
9
+ *
10
+ * The name carries the port and the port is random on every launch, so every
11
+ * launch leaves a NEW cookie behind. The cookie lives 30 days (Max-Age=2592000)
12
+ * and a cookie does NOT carry a port - so the browser sends ALL of them, on
13
+ * every request, to 127.0.0.1.
14
+ *
15
+ * The host answers with Node's default max header size of 16384 bytes. With 71
16
+ * cookies the Cookie header alone is 16188 bytes; add the browser's own headers
17
+ * and the request is 16559 bytes -> the host answers 431 (Request Header Fields
18
+ * Too Large), the desktop app reports the misleading "desktop welcome: Web
19
+ * authentication failed" and dies on startup. The crash loop then triggers the
20
+ * official native-fatal recovery, which wipes the profile.
21
+ *
22
+ * Measured on this machine (real cookies, real browser headers):
23
+ * 70 cookies -> 16331 bytes -> 200 OK
24
+ * 71 cookies -> 16559 bytes -> 431 Request Header Fields Too Large
25
+ *
26
+ * The restart helper runs this BETWEEN the kill and the relaunch: at that
27
+ * moment Chromium has already exited and the database is not locked any more.
28
+ *
29
+ * THE MATCH IS DELIBERATELY WIDE (user decision, 2026-10-30):
30
+ *
31
+ * host_key = '127.0.0.1' AND name LIKE 'dsh-auth-%'
32
+ *
33
+ * The official code that mints these cookies always writes `Path=/`, so the
34
+ * old condition `path = '/'` looked safe - and it was one assumption away from
35
+ * silently missing a cookie. A missed cookie still travels to 127.0.0.1 and
36
+ * still counts against the 16 KB header, while deleting one cookie too many
37
+ * costs a single re-login. The path is therefore NOT part of the condition.
38
+ * Nothing else in the jar is addressed: another host, another prefix, another
39
+ * column - all untouched.
40
+ *
41
+ * THE RESULT IS WRITTEN DOWN: this is the only process that knows how many
42
+ * rows it deleted, so it writes `<stateDir>/last-cookie-clear.json`
43
+ * (`{at, ok, removed, reason}`) and the plugin's health route reports it. The
44
+ * state directory is `DSH_AUTORESTART_STATE_DIR` when it is set (a drill or a
45
+ * test redirects it), otherwise the plugin's own state directory.
46
+ *
47
+ * RULES:
48
+ * - Only the dsh-auth-* cookies of 127.0.0.1 are touched. The rest of the
49
+ * jar is never read and never written.
50
+ * - The whole Cookies FILE is never deleted.
51
+ * - Every failure ends with exit code 0: a restart must never be blocked by
52
+ * a cleanup that could not run.
53
+ * - Pure ASCII, no byte order mark (see test/scripts-encoding.test.mjs).
54
+ */
55
+
56
+ import fs from 'node:fs';
57
+ import os from 'node:os';
58
+ import path from 'node:path';
59
+
60
+ /** The host whose cookies are addressed. */
61
+ const HOST_KEY = '127.0.0.1';
62
+
63
+ /** The name prefix the desktop app mints (`%` is the LIKE wildcard). */
64
+ const COOKIE_PREFIX = 'dsh-auth-%';
65
+
66
+ /** How many times the delete is retried while the database is still locked. */
67
+ export const RETRIES = 20;
68
+
69
+ /** How long the script waits between two attempts, in milliseconds. */
70
+ export const RETRY_DELAY_MS = 500;
71
+
72
+ /** The name of the file this script leaves its result in. */
73
+ export const REPORT_NAME = 'last-cookie-clear.json';
74
+
75
+ /** Where the outcome is written, so a failed cleanup is never silent. */
76
+ const LOG_DIR = path.join(os.homedir(), '.dsh', 'storages', 'dsh-autorestart');
77
+
78
+ /** The log file next to the result. */
79
+ const LOG_FILE = path.join(LOG_DIR, 'clear-cookies.log');
80
+
81
+ /**
82
+ * The state directory this run belongs to.
83
+ *
84
+ * `DSH_AUTORESTART_STATE_DIR` exists so a drill (or a test) can point the
85
+ * cleanup at its own directory instead of the real installation.
86
+ *
87
+ * @returns {string}
88
+ */
89
+ export function stateDirOf() {
90
+ const override = typeof process.env.DSH_AUTORESTART_STATE_DIR === 'string'
91
+ ? process.env.DSH_AUTORESTART_STATE_DIR.trim()
92
+ : '';
93
+ return override === '' ? LOG_DIR : override;
94
+ }
95
+
96
+ /** The cookie jar of the desktop app. */
97
+ export function cookieJarPath() {
98
+ const roaming = typeof process.env.APPDATA === 'string' && process.env.APPDATA !== ''
99
+ ? process.env.APPDATA
100
+ : path.join(os.homedir(), 'AppData', 'Roaming');
101
+ return path.join(roaming, '@deepseek-ai', 'dsh-desktop', 'Network', 'Cookies');
102
+ }
103
+
104
+ /**
105
+ * Append one line to the log. Logging must never break the restart, so every
106
+ * error here is swallowed on purpose.
107
+ *
108
+ * @param {string} line
109
+ * @param {string} [logFile]
110
+ */
111
+ export function log(line, logFile = LOG_FILE) {
112
+ try {
113
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
114
+ fs.appendFileSync(logFile, new Date().toISOString() + ' ' + line + '\n', 'utf8');
115
+ } catch {
116
+ // A log that cannot be written is not a reason to stop the restart.
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Write the result of this run next to the state, so the plugin can report it.
122
+ *
123
+ * Written with `.tmp` + rename: a cleanup that is killed halfway must not leave
124
+ * a truncated record behind (the health route would report a broken file).
125
+ *
126
+ * @param {{ stateDir?: string, at?: number, ok: boolean, removed: number|null, reason: string }} options
127
+ * @returns {string} the path it was written to ('' when there is no state dir)
128
+ */
129
+ export function writeResult({ stateDir = stateDirOf(), at = Date.now(), ok, removed, reason }) {
130
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
131
+ if (dir === '') return '';
132
+ const target = path.join(dir, REPORT_NAME);
133
+ const report = {
134
+ version: 1,
135
+ at,
136
+ atIso: new Date(at).toISOString(),
137
+ ok: ok === true,
138
+ removed: Number.isFinite(Number(removed)) ? Math.max(0, Math.trunc(Number(removed))) : null,
139
+ reason: typeof reason === 'string' ? reason : '',
140
+ };
141
+ try {
142
+ fs.mkdirSync(dir, { recursive: true });
143
+ const temporary = target + '.tmp';
144
+ fs.writeFileSync(temporary, JSON.stringify(report, null, 2) + '\n', 'utf8');
145
+ fs.renameSync(temporary, target);
146
+ return target;
147
+ } catch {
148
+ return '';
149
+ }
150
+ }
151
+
152
+ /** Wait a moment. @param {number} ms */
153
+ function sleep(ms) {
154
+ return new Promise((resolve) => setTimeout(resolve, ms));
155
+ }
156
+
157
+ /**
158
+ * Delete the stale handshake cookies. Retries while the database is locked by
159
+ * a Chromium that has not exited yet.
160
+ *
161
+ * The condition is `host_key` + `name LIKE` ONLY - see the header: a cookie
162
+ * with another path would still be sent to 127.0.0.1, so the path is not part
163
+ * of the match.
164
+ *
165
+ * @param {{
166
+ * jarPath?: string, retries?: number, retryDelayMs?: number,
167
+ * openDb?: Function,
168
+ * }} [options]
169
+ * @returns {Promise<{ ok: boolean, removed: number, error: string|null }>}
170
+ */
171
+ export async function clearCookies(options = {}) {
172
+ const jar = typeof options.jarPath === 'string' && options.jarPath !== '' ? options.jarPath : cookieJarPath();
173
+ const retries = Number.isFinite(Number(options.retries)) && Number(options.retries) > 0
174
+ ? Math.trunc(Number(options.retries))
175
+ : RETRIES;
176
+ const retryDelayMs = Number.isFinite(Number(options.retryDelayMs)) && Number(options.retryDelayMs) >= 0
177
+ ? Math.trunc(Number(options.retryDelayMs))
178
+ : RETRY_DELAY_MS;
179
+ const open = typeof options.openDb === 'function'
180
+ ? options.openDb
181
+ : async (target) => {
182
+ const { DatabaseSync } = await import('node:sqlite');
183
+ return new DatabaseSync(target);
184
+ };
185
+ let lastError = 'the cookie jar was never opened';
186
+ for (let attempt = 1; attempt <= retries; attempt += 1) {
187
+ try {
188
+ const db = await open(jar);
189
+ try {
190
+ const statement = db.prepare('delete from cookies where host_key = ? and name like ?');
191
+ const info = statement.run(HOST_KEY, COOKIE_PREFIX);
192
+ return { ok: true, removed: Number(info.changes || 0), error: null };
193
+ } finally {
194
+ if (db && typeof db.close === 'function') db.close();
195
+ }
196
+ } catch (error) {
197
+ lastError = String((error && error.message) || error);
198
+ if (attempt < retries) {
199
+ await sleep(retryDelayMs);
200
+ }
201
+ }
202
+ }
203
+ return { ok: false, removed: 0, error: lastError };
204
+ }
205
+
206
+ /**
207
+ * The whole job of this script: clear, write the result, log it.
208
+ *
209
+ * @param {{
210
+ * jarPath?: string, stateDir?: string, retries?: number, retryDelayMs?: number,
211
+ * logFile?: string, now?: number,
212
+ * }} [options]
213
+ * @returns {Promise<{ ok: boolean, removed: number, reason: string, reportPath: string }>}
214
+ */
215
+ export async function run(options = {}) {
216
+ const stateDir = typeof options.stateDir === 'string' ? options.stateDir : stateDirOf();
217
+ const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now();
218
+ const result = await clearCookies({
219
+ jarPath: options.jarPath,
220
+ retries: options.retries,
221
+ retryDelayMs: options.retryDelayMs,
222
+ });
223
+ const reason = result.ok === true ? '' : String(result.error || 'the cleanup did not run');
224
+ const reportPath = writeResult({
225
+ stateDir,
226
+ at: now,
227
+ ok: result.ok === true,
228
+ removed: result.removed,
229
+ reason,
230
+ });
231
+ const jar = typeof options.jarPath === 'string' && options.jarPath !== '' ? options.jarPath : cookieJarPath();
232
+ if (result.ok === true) {
233
+ log('ok removed=' + result.removed + ' jar=' + jar, options.logFile || LOG_FILE);
234
+ } else {
235
+ log('skipped reason=' + reason + ' jar=' + jar, options.logFile || LOG_FILE);
236
+ }
237
+ return { ok: result.ok === true, removed: result.removed, reason, reportPath };
238
+ }
239
+
240
+ // The file is a script AND a module: the tests import the pieces above, the
241
+ // restart helper RUNS it. Only a direct run does the work.
242
+ const invokedDirectly = (() => {
243
+ try {
244
+ const entry = process.argv[1];
245
+ if (!entry) return false;
246
+ const self = new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1');
247
+ return path.resolve(entry) === path.resolve(decodeURIComponent(self));
248
+ } catch {
249
+ return false;
250
+ }
251
+ })();
252
+
253
+ if (invokedDirectly) {
254
+ await run();
255
+ // Always 0: a restart is never blocked by this cleanup.
256
+ process.exit(0);
257
+ }
@@ -215,6 +215,56 @@ json_parses() {
215
215
  return 0
216
216
  }
217
217
 
218
+ # Is every field of this package.json on ONE line?
219
+ #
220
+ # A compact profile (a generator, a bundler, a human with `jq -c`) puts the whole
221
+ # document on a single line. The line-oriented sed below cannot work on that: it
222
+ # deletes a WHOLE line (which would delete the entire file) and it appends after
223
+ # a line that ENDS with `[` (which never happens when the array has members on
224
+ # the same line). So a compact file takes the node path instead.
225
+ compact_file() {
226
+ file="$1"
227
+ [ -n "$NODE_BIN" ] || return 1
228
+ [ -f "$file" ] || return 1
229
+ "$NODE_BIN" -e '
230
+ const fs = require("fs");
231
+ try {
232
+ const text = fs.readFileSync(process.argv[1], "utf8").replace(/^\uFEFF/, "");
233
+ process.exit(text.replace(/\r?\n$/, "").includes("\n") ? 1 : 0);
234
+ } catch (error) { process.exit(1); }
235
+ ' "$file" >/dev/null 2>&1
236
+ }
237
+
238
+ # Rewrite a COMPACT package.json on still ONE line, without touching anything but
239
+ # the bundles array: the document is parsed, the name is removed from (or pushed
240
+ # into) `dsh.profile.bundles`, and the result is serialized back to a single line
241
+ # with the keys in their original order. Every other field, the name of the
242
+ # profile included, keeps its value.
243
+ #
244
+ # Prints the new document on stdout; nothing is written here (the caller writes
245
+ # it into the temp file it already knows how to swap in).
246
+ compact_edit() {
247
+ file="$1"
248
+ action="$2"
249
+ "$NODE_BIN" -e '
250
+ const fs = require("fs");
251
+ const [file, action, name] = process.argv.slice(1);
252
+ const text = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
253
+ let doc;
254
+ try { doc = JSON.parse(text); } catch (error) { process.exit(3); }
255
+ const profile = doc && doc.dsh && doc.dsh.profile;
256
+ const bundles = profile && Array.isArray(profile.bundles) ? profile.bundles : null;
257
+ if (bundles === null) process.exit(4);
258
+ const next = bundles.filter((entry) => entry !== name);
259
+ // FIRST in the list, like the multi-line sed path does (it inserts right
260
+ // after the opening bracket). The two paths agree on order.
261
+ if (action === "enable") next.unshift(name);
262
+ profile.bundles = next;
263
+ const single = JSON.stringify(doc);
264
+ process.stdout.write(single + (text.endsWith("\n") ? "\n" : ""));
265
+ ' "$file" "$action" "$PACKAGE_NAME"
266
+ }
267
+
218
268
  # --- Start ---
219
269
 
220
270
  if [ -n "$PACKAGE_JSON" ]; then
@@ -273,14 +323,31 @@ if [ "$DRY_RUN" -eq 1 ]; then
273
323
  fi
274
324
 
275
325
  # --- Back up, then write ---
276
- stamp=$(date -u +%Y%m%d-%H%M%S)
326
+ # NEVER OVERWRITE AN EARLIER BACKUP (2026-11-03): the stamp carries milliseconds, and
327
+ # when that name is still taken a serial number is appended. Before this the stamp
328
+ # stopped at the second, so two writes inside one second left ONE backup.
329
+ stamp=$(date -u +%Y%m%d-%H%M%S-%3N)
277
330
  backup="$pkg.bak-emergency-$stamp"
331
+ serial=1
332
+ while [ -e "$backup" ]; do
333
+ serial=$((serial + 1))
334
+ backup="$pkg.bak-emergency-$stamp-$serial"
335
+ done
278
336
  if ! cp -- "$pkg" "$backup"; then
279
337
  fail "$(fmt "$(t 'emergency.backupFailed' 'the backup failed - the original file was not touched: {0}')" "$backup")"
280
338
  fi
281
339
 
282
340
  tmp="$pkg.tmp-emergency-$$"
283
- if [ "$ACTION" = 'disable' ]; then
341
+ if compact_file "$pkg"; then
342
+ # ONE LINE IN, ONE LINE OUT: the document is edited as JSON and written back
343
+ # in the same style, because the line-oriented sed below has no line to work
344
+ # on (it would delete the whole file, or append after a line that never ends
345
+ # with the opening bracket).
346
+ if ! compact_edit "$pkg" "$ACTION" > "$tmp"; then
347
+ rm -f -- "$tmp" 2>/dev/null || true
348
+ fail "$(fmt "$(t 'emergency.writeFailed' 'the write failed - the backup is at {0}: {1}')" "$backup" "$pkg")"
349
+ fi
350
+ elif [ "$ACTION" = 'disable' ]; then
284
351
  sed "/^[[:space:]]*\"$PACKAGE_NAME\"[[:space:]]*,\{0,1\}[[:space:]]*$/d" "$pkg" > "$tmp"
285
352
  else
286
353
  sed "s/^\\([[:space:]]*\"bundles\"[[:space:]]*:[[:space:]]*\\[\\)$/\\1\\n \"$PACKAGE_NAME\",/" "$pkg" > "$tmp"
@@ -154,33 +154,65 @@ function Get-ArrayRange {
154
154
  throw 'the bundles array is not closed'
155
155
  }
156
156
 
157
- # Every item of the array: the name, the indentation, the comma, the position
158
- # in the text.
159
- function Get-Items {
157
+ # The names of the bundles array, IN ORDER, whatever the layout is.
158
+ #
159
+ # Only the body of the array is scanned: Get-ArrayRange has already located it,
160
+ # so a name can never be picked up from another part of the file. A
161
+ # pretty-printed file (one item per line) and a MINIFIED one (["a","b"]) are
162
+ # read the same way: the old line-based reader returned ZERO items for a
163
+ # minified array, so "disable" answered "not in the bundles" and changed
164
+ # nothing.
165
+ function Get-BundleNames {
160
166
  param([string] $Text, [int] $Open, [int] $Close)
161
167
  $body = $Text.Substring($Open + 1, $Close - $Open - 1)
162
- $items = @()
163
- $pattern = '(?m)^(?<indent>[ \t]*)"(?<name>[^"]*)"(?<comma>,?)[ \t]*\r?$'
164
- foreach ($match in [regex]::Matches($body, $pattern)) {
165
- $lineStart = $Open + 1 + $match.Index
166
- $lineEnd = $lineStart + $match.Length
167
- # The line ending of that line (\r\n or \n) belongs to the line.
168
- if ($lineEnd -lt $Text.Length -and $Text[$lineEnd] -eq "`n") { $lineEnd++ }
169
- $nameGroup = $match.Groups['name']
170
- $commaGroup = $match.Groups['comma']
171
- $items += [pscustomobject]@{
172
- Name = $nameGroup.Value
173
- Indent = $match.Groups['indent'].Value
174
- HasComma = ($commaGroup.Value -eq ',')
175
- CommaIndex = $commaGroup.Index
176
- # The position right after the closing quote of the name (where the
177
- # comma goes).
178
- ContentEnd = $nameGroup.Index + $nameGroup.Length + 1
179
- LineStart = $lineStart
180
- LineEnd = $lineEnd
181
- }
168
+ $names = @()
169
+ foreach ($match in [regex]::Matches($body, '"(?<name>(?:[^"\\]|\\.)*)"')) {
170
+ $names += $match.Groups['name'].Value
171
+ }
172
+ return $names
173
+ }
174
+
175
+ # How the array is laid out today, so the rewritten array looks like the old one.
176
+ function Get-BundlesLayout {
177
+ param([string] $Text, [int] $Open, [int] $Close)
178
+ $body = $Text.Substring($Open + 1, $Close - $Open - 1)
179
+ $multiLine = $body.Contains("`n")
180
+ $indent = ''
181
+ $closeIndent = ''
182
+ if ($multiLine) {
183
+ $firstItem = [regex]::Match($body, '(?m)^(?<indent>[ \t]*)"')
184
+ if ($firstItem.Success) { $indent = $firstItem.Groups['indent'].Value }
185
+ else { $indent = (Get-LineIndent $Text $Close) + ' ' }
186
+ $closeIndent = Get-LineIndent $Text $Close
182
187
  }
183
- return $items
188
+ return [pscustomobject]@{
189
+ MultiLine = $multiLine
190
+ Indent = $indent
191
+ CloseIndent = $closeIndent
192
+ Eol = (Get-LineEnding $Text)
193
+ }
194
+ }
195
+
196
+ # The body of a bundles array for these names (the brackets stay where they are).
197
+ #
198
+ # THIS is what makes the comma impossible to get wrong: it is written BETWEEN
199
+ # two items by the serializer, never by hand at a guessed offset. A one-item
200
+ # array gets no comma at all, and no comma can land inside a name.
201
+ #
202
+ # A compact array stays compact (["a","b"]); a pretty-printed one stays one item
203
+ # per line with the indentation the file already used.
204
+ function Format-BundlesBody {
205
+ param([string[]] $Names, [object] $Layout)
206
+ $quoted = @()
207
+ foreach ($name in $Names) {
208
+ $escaped = $name -replace '\\', '\\'
209
+ $escaped = $escaped -replace '"', '\"'
210
+ $quoted += '"' + $escaped + '"'
211
+ }
212
+ if (-not $Layout.MultiLine) { return ($quoted -join ',') }
213
+ if ($quoted.Count -eq 0) { return ($Layout.Eol + $Layout.CloseIndent) }
214
+ $body = $Layout.Eol + $Layout.Indent + ($quoted -join (',' + $Layout.Eol + $Layout.Indent))
215
+ return ($body + $Layout.Eol + $Layout.CloseIndent)
184
216
  }
185
217
 
186
218
  # The indentation of a line (so a new line can look the same).
@@ -254,27 +286,18 @@ if ($Action -eq 'enable' -and -not $hasBundles) {
254
286
 
255
287
  $new = $raw
256
288
  $range = Get-ArrayRange $raw
257
- $items = Get-Items $raw $range[0] $range[1]
258
- $eol = Get-LineEnding $raw
289
+ $names = @(Get-BundleNames $raw $range[0] $range[1])
290
+ $layout = Get-BundlesLayout $raw $range[0] $range[1]
259
291
 
260
292
  if ($Action -eq 'disable') {
261
293
  if (-not $present) {
262
294
  Say ((T 'emergency.alreadyDisabled' 'already disabled: {0} was not in the bundles anyway (no file was changed).') -f $PackageName) 'Yellow'
263
295
  exit 0
264
296
  }
265
- $target = $items | Where-Object { $_.Name -eq $PackageName } | Select-Object -First 1
266
- if ($null -eq $target) { Fail ((T 'emergency.notInBundles' '{0} cannot be found in the bundles') -f $PackageName) }
267
- $shift = 0
268
- if (-not $target.HasComma) {
269
- # It is the last item of the array: the comma of the item before it has to
270
- # go, otherwise the JSON is invalid.
271
- $previous = $items | Where-Object { $_.LineEnd -le $target.LineStart } | Select-Object -Last 1
272
- if ($null -ne $previous -and $previous.HasComma) {
273
- $new = $new.Remove($previous.CommaIndex, 1)
274
- $shift = -1
275
- }
276
- }
277
- $new = $new.Remove($target.LineStart + $shift, $target.LineEnd - $target.LineStart)
297
+ $kept = @()
298
+ foreach ($name in $names) { if ($name -ne $PackageName) { $kept += $name } }
299
+ $body = Format-BundlesBody $kept $layout
300
+ $new = $raw.Substring(0, $range[0] + 1) + $body + $raw.Substring($range[1])
278
301
  $summary = (T 'emergency.summaryRemove' 'take {0} out of the bundles') -f $PackageName
279
302
  } else {
280
303
  # enable
@@ -282,37 +305,20 @@ if ($Action -eq 'disable') {
282
305
  Say ((T 'emergency.alreadyEnabled' 'already enabled: {0} was in the bundles anyway (no file was changed).') -f $PackageName) 'Yellow'
283
306
  exit 0
284
307
  }
285
- $anchor = $items | Where-Object { $_.Name -eq $AnchorName } | Select-Object -First 1
286
- $line = ''
287
- $insertAt = 0
288
- if ($null -ne $anchor) {
289
- if ($anchor.HasComma) {
290
- $insertAt = $anchor.LineEnd
291
- $line = $anchor.Indent + '"' + $PackageName + '",'
292
- } else {
293
- # The anchor is the last item: give it a comma first, then insert the new
294
- # item (the new one has no comma).
295
- $new = $new.Insert($anchor.ContentEnd, ',')
296
- $insertAt = $anchor.LineEnd + 1
297
- $line = $anchor.Indent + '"' + $PackageName + '"'
298
- }
299
- } else {
300
- $last = $items | Select-Object -Last 1
301
- if ($null -ne $last) {
302
- if ($last.HasComma) {
303
- $insertAt = $last.LineEnd
304
- } else {
305
- $new = $new.Insert($last.ContentEnd, ',')
306
- $insertAt = $last.LineEnd + 1
307
- }
308
- $line = $last.Indent + '"' + $PackageName + '"'
309
- } else {
310
- # The array is empty.
311
- $insertAt = $range[1]
312
- $line = (Get-LineIndent $raw $range[1]) + ' "' + $PackageName + '"'
308
+ # Put it back right after the anchor when the anchor is there, otherwise at
309
+ # the end: the position does not matter, the JSON does.
310
+ $kept = @()
311
+ $inserted = $false
312
+ foreach ($name in $names) {
313
+ $kept += $name
314
+ if (-not $inserted -and $name -eq $AnchorName) {
315
+ $kept += $PackageName
316
+ $inserted = $true
313
317
  }
314
318
  }
315
- $new = $new.Insert($insertAt, $line + $eol)
319
+ if (-not $inserted) { $kept += $PackageName }
320
+ $body = Format-BundlesBody $kept $layout
321
+ $new = $raw.Substring(0, $range[0] + 1) + $body + $raw.Substring($range[1])
316
322
  $summary = (T 'emergency.summaryAdd' 'put {0} back into the bundles') -f $PackageName
317
323
  }
318
324
 
@@ -329,7 +335,17 @@ if ($DryRun) {
329
335
  # back.
330
336
  # ---------------------------------------------------------------------------
331
337
 
332
- $backup = $path + '.bak-emergency-' + (Get-Date -Format 'yyyyMMdd-HHmmss')
338
+ $stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
339
+ $backup = $path + '.bak-emergency-' + $stamp
340
+ # NEVER OVERWRITE AN EARLIER BACKUP (2026-11-03): the name carries milliseconds, and
341
+ # if that name is still taken (two writes inside the same millisecond) a serial number
342
+ # is appended. Before this, the name stopped at the second, so taking the plugin out
343
+ # and putting it back within one second left ONE backup where there should be two.
344
+ $serial = 1
345
+ while (Test-Path -LiteralPath $backup) {
346
+ $serial += 1
347
+ $backup = $path + '.bak-emergency-' + $stamp + '-' + $serial
348
+ }
333
349
  try {
334
350
  Copy-Item -LiteralPath $path -Destination $backup -Force
335
351
  } catch {
@@ -5,14 +5,12 @@
5
5
  "placeholders": "Values may contain {0}, {1} ... placeholders that the scripts fill in with -f formatting.",
6
6
  "fallback": "Every key has an English fallback inside the .ps1. If this file is missing or broken, the scripts say so in English and keep working in English."
7
7
  },
8
-
9
8
  "common": {
10
9
  "stringsMissing": "中文文案文件不在:{0}(改用英文兜底)",
11
10
  "stringsBroken": "中文文案文件读不出来:{0}(改用英文兜底)",
12
11
  "stringsMissingEn": "the Chinese strings file is missing: {0} (falling back to English messages)",
13
12
  "stringsBrokenEn": "the Chinese strings file could not be read: {0} (falling back to English messages)"
14
13
  },
15
-
16
14
  "restore": {
17
15
  "title": "dsh-autorestart —— profile 快照还原器",
18
16
  "failPrefix": "还原失败:",
@@ -30,7 +28,7 @@
30
28
  "usingSnapshot": "要用快照:{0}({1})",
31
29
  "snapshotMissing": "找不到这份快照:{0}",
32
30
  "snapshotNoManifest": "这份快照没有 manifest.json,不是一份快照:{0}",
33
- "snapshotSuffix": "{0}(快照:{1})",
31
+ "snapshotSuffix": "{0}(这份快照)",
34
32
  "dryRunMove": "[试运行] 会先把现在的 profile 挪到:{0}",
35
33
  "dryRunCopy": "[试运行] 会把 {0} 里的配置和插件复制回:{1}",
36
34
  "dryRunVerify": "[试运行] 会回读校验 package.json / dsh.profile.bundles / {0} 的版本号。",
@@ -47,20 +45,17 @@
47
45
  "noteNoPlugins": "(只有配置文件,没有插件)",
48
46
  "done": "已还原到 {0} 的快照{1};现在可以重新打开 DSH。",
49
47
  "brokenLeft": "原来那份留在了:{0}(确认没问题之后可以自己删掉它)",
50
-
51
48
  "gateEmptyPath": "目标路径是空的",
52
49
  "gateDriveRoot": "拒绝动盘符根目录",
53
50
  "gateFsRoot": "拒绝动文件系统根目录",
54
51
  "gateHome": "拒绝动用户主目录本身",
55
52
  "gateSystemDir": "拒绝动系统目录:{0}",
56
53
  "gateOtherUser": "拒绝动别人的用户目录",
57
-
58
54
  "fingerprintNotProfile": "这里没有 package.json —— 不像 DSH profile",
59
55
  "fingerprintProfile": "profile 的 package.json 里有 dsh.profile",
60
56
  "fingerprintBrokenNoDsh": "profile 坏了(package.json 不再是 profile),但 profile 的文件还在",
61
57
  "fingerprintBrokenNoPkg": "profile 坏了(package.json 没了),但 profile 的文件还在",
62
58
  "fingerprintNotDshProfile": "package.json 里没有 dsh.profile,也没有别的东西能证明这是 profile",
63
-
64
59
  "snapshotDirMissing": "找不到快照目录:{0}",
65
60
  "snapshotNone": "这个目录里没有一份带 manifest 的快照:{0}",
66
61
  "snapshotNoConfigPkg": "这份快照里没有 config\\package.json",
@@ -68,7 +63,6 @@
68
63
  "snapshotNoDshProfile": "这份快照的 package.json 里没有 dsh.profile",
69
64
  "snapshotIsProfile": "这份快照里装的是一个 DSH profile",
70
65
  "snapshotUnknownTime": "时间不明",
71
-
72
66
  "verifyPkgLabel": "package.json 能解析",
73
67
  "verifyPkgMissing": "package.json 不存在",
74
68
  "verifyPkgBad": "不是合法的 JSON",
@@ -78,9 +72,20 @@
78
72
  "verifyPluginLabel": "{0} 的版本号读得到",
79
73
  "verifyPluginNotDeclared": "这个 profile 没有声明要装 {0}(没什么可读)",
80
74
  "verifyPluginMissing": "profile 里写着要装 {0},但 node_modules\\{0} 不在 —— 配置说一套、磁盘是另一套",
81
- "verifyPluginNoVersion": "读不到版本号"
75
+ "verifyPluginNoVersion": "读不到版本号",
76
+ "linkNone": "本快照里没有链接安装的插件",
77
+ "linkHeader": "本快照里链接安装的插件:{0} 个",
78
+ "linkKept": " [ok] {0}:保留链接(目标目录还在)",
79
+ "linkReplaced": " [ok] {0}:已用快照内容顶替链接({1})",
80
+ "linkDryRunReplaced": " [演练] {0}:会用快照内容顶替",
81
+ "linkTargetMissing": " [bad] {0}:链接目标已不存在,快照里也没有它的副本",
82
+ "linkNoBackup": " [warn] {0}:快照里没有这个插件的副本,链接保持原样",
83
+ "linkRemoveFailed": " [bad] {0}:删不掉这个链接,副本没有放进去",
84
+ "linkCopyFailed": " [bad] {0}:副本放不进去:{1}",
85
+ "linkEvidence": " (链接处置结论已写到 {0})",
86
+ "linkEvidenceFailed": " [warn] 链接处置结论没能写下来。",
87
+ "linkManifestUnreadable": " [warn] 读不了备份的 manifest.json,无法检查链接安装的插件。"
82
88
  },
83
-
84
89
  "drill": {
85
90
  "dir": "演练目录:{0}",
86
91
  "broke": "已经故意弄坏:package.json 写成非法 JSON,插件目录删掉了。",
@@ -102,7 +107,6 @@
102
107
  "error": "演练出错:{0}",
103
108
  "noScript": "演练失败:找不到 {0}",
104
109
  "dirOccupied": "演练失败:这个演练目录已经被占用了:{0}",
105
-
106
110
  "assertDryRunExit": "试运行退出码是 0",
107
111
  "assertDryRunUntouched": "试运行没有改动 package.json",
108
112
  "assertDryRunNoBroken": "试运行没有留下 broken 目录",
@@ -128,17 +132,31 @@
128
132
  "assertCmdAscii": "restore-profile.cmd 是纯 ASCII",
129
133
  "assertOnlyTemp": "演练只在临时目录里动手(broken 目录在临时目录下面)",
130
134
  "assertLockFile": "演练目录里有锁文件",
131
-
132
135
  "keywords": {
133
- "notProfile": ["不像", "不是 profile"],
134
- "driveRoot": ["盘符", "根目录"],
135
- "systemDir": ["系统目录", "不允许"],
136
- "missingDir": ["不存在"],
137
- "notValidated": ["没通过校验", "校验"],
138
- "dryRun": ["试运行"]
136
+ "notProfile": [
137
+ "不像",
138
+ "不是 profile"
139
+ ],
140
+ "driveRoot": [
141
+ "盘符",
142
+ "根目录"
143
+ ],
144
+ "systemDir": [
145
+ "系统目录",
146
+ "不允许"
147
+ ],
148
+ "missingDir": [
149
+ "不存在"
150
+ ],
151
+ "notValidated": [
152
+ "没通过校验",
153
+ "校验"
154
+ ],
155
+ "dryRun": [
156
+ "试运行"
157
+ ]
139
158
  }
140
159
  },
141
-
142
160
  "emergency": {
143
161
  "failed": "急救开关失败:{0}",
144
162
  "notFound": "找不到 profile 的 package.json:{0}",