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,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
|
-
|
|
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
|
|
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
|
-
#
|
|
158
|
-
#
|
|
159
|
-
|
|
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
|
-
$
|
|
163
|
-
$
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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
|
-
$
|
|
258
|
-
$
|
|
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
|
-
$
|
|
266
|
-
|
|
267
|
-
$
|
|
268
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
$
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
$
|
|
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}
|
|
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": [
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
"
|
|
138
|
-
|
|
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}",
|