dsh-autorestart 0.7.0 → 0.8.1
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 +47 -0
- package/README.md +137 -6
- package/client.js +387 -0
- package/cordis.patch.yml +7 -0
- package/docs/README.zh.md +83 -5
- package/docs/platform-matrix.md +92 -0
- package/docs/rescue-drill.md +606 -0
- package/index.js +598 -3
- package/lib/diagnostics.js +8 -0
- package/lib/rescue-paths.js +253 -0
- package/lib/rescue.js +596 -0
- package/lib/scheduler-capability.js +300 -0
- package/lib/watchdog-arbiter.js +117 -0
- package/lib/watchdog-schedule.js +310 -0
- package/lib/watchdog-tier.js +182 -0
- package/lib/watchdog.js +479 -0
- package/locale/en.json +28 -1
- package/locale/zh.json +28 -1
- package/package.json +6 -1
- package/scripts/README.md +65 -0
- package/scripts/drill-rescue.ps1 +508 -0
- package/scripts/drill-rescue.sh +427 -0
- package/scripts/drill-watchdog.mjs +341 -0
- package/scripts/emergency-disable.sh +314 -0
- package/scripts/emergency-toggle.ps1 +136 -65
- package/scripts/rescue-strings.zh.json +168 -0
- package/scripts/restore-profile.cmd +44 -0
- package/scripts/restore-profile.ps1 +608 -0
- package/scripts/restore-profile.sh +593 -0
- package/scripts/run-tests.ps1 +1 -1
- package/scripts/watchdog.mjs +203 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE WATCHDOG DRILL: three real scenarios, and the tiers on a real machine.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here touches the profile of a running DSH: the drill works in its
|
|
5
|
+
* own directory under the temp directory, with a FAKE host (a small script
|
|
6
|
+
* that either comes up and writes the ready marker, or stays down).
|
|
7
|
+
*
|
|
8
|
+
* 1. the host comes up -> the watchdog stands down, NO restore
|
|
9
|
+
* 2. the host never comes up -> restore, start again, RECOVERED
|
|
10
|
+
* 3. the host stays down -> restore ONCE, start again, FAILED
|
|
11
|
+
*
|
|
12
|
+
* And then the tiers, for real:
|
|
13
|
+
*
|
|
14
|
+
* - the Windows scheduled task is registered, read back, and REALLY FIRES
|
|
15
|
+
* (a marker file appears), and is deleted completely afterwards;
|
|
16
|
+
* - the detached process tier runs inside WSL (setsid nohup), and the
|
|
17
|
+
* systemd tier runs there too.
|
|
18
|
+
*
|
|
19
|
+
* The drill cleans up after itself: no leftover task, no leftover process,
|
|
20
|
+
* no leftover directory.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
24
|
+
import crypto from 'node:crypto';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
buildPending,
|
|
32
|
+
readReport,
|
|
33
|
+
readyPath,
|
|
34
|
+
writePending,
|
|
35
|
+
writeReady,
|
|
36
|
+
} from '../lib/watchdog.js';
|
|
37
|
+
import {
|
|
38
|
+
detachedPidPath,
|
|
39
|
+
localIso,
|
|
40
|
+
registerDetachedProcess,
|
|
41
|
+
registerScheduledTask,
|
|
42
|
+
removeScheduledTask,
|
|
43
|
+
} from '../lib/watchdog-schedule.js';
|
|
44
|
+
|
|
45
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
const pluginDir = path.join(here, '..');
|
|
47
|
+
const drillRoot = path.join(os.tmpdir(), 'dsh-watchdog-drill-' + crypto.randomUUID());
|
|
48
|
+
|
|
49
|
+
/** The checks of the drill: every one of them must pass. */
|
|
50
|
+
const failures = [];
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} what
|
|
53
|
+
* @param {boolean} ok
|
|
54
|
+
* @param {string} [detail]
|
|
55
|
+
*/
|
|
56
|
+
function check(what, ok, detail = '') {
|
|
57
|
+
const line = (ok ? ' ok ' : ' FAIL ') + what + (detail ? ' (' + detail + ')' : '');
|
|
58
|
+
console.log(line);
|
|
59
|
+
if (!ok) failures.push(line);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Wait for a file to appear.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} file
|
|
66
|
+
* @param {number} timeoutMs
|
|
67
|
+
* @returns {Promise<boolean>}
|
|
68
|
+
*/
|
|
69
|
+
async function waitForFile(file, timeoutMs) {
|
|
70
|
+
const deadline = Date.now() + timeoutMs;
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
if (fs.existsSync(file)) return true;
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
74
|
+
}
|
|
75
|
+
return fs.existsSync(file);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The fake host: it comes up (and writes the ready marker) or it stays down.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} dir
|
|
82
|
+
* @param {'up'|'down'} behaviour
|
|
83
|
+
*/
|
|
84
|
+
function fakeHost(dir, behaviour) {
|
|
85
|
+
const marker = path.join(dir, 'restore.log');
|
|
86
|
+
const script = path.join(dir, 'fake-host.mjs');
|
|
87
|
+
fs.writeFileSync(script, [
|
|
88
|
+
"import fs from 'node:fs';",
|
|
89
|
+
"import path from 'node:path';",
|
|
90
|
+
"const dir = process.argv[2];",
|
|
91
|
+
"const behaviour = process.argv[3];",
|
|
92
|
+
"if (behaviour === 'up') {",
|
|
93
|
+
" fs.writeFileSync(path.join(dir, 'watchdog-ready.json'), JSON.stringify({ at: Date.now() }));",
|
|
94
|
+
"}",
|
|
95
|
+
"fs.appendFileSync(path.join(dir, 'host.log'), behaviour + '\\n');",
|
|
96
|
+
"setTimeout(() => process.exit(0), 30000);",
|
|
97
|
+
'',
|
|
98
|
+
].join('\n'), 'utf8');
|
|
99
|
+
return { script, marker };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The fake restore: it counts the attempts, and it can make the host come up.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} dir
|
|
106
|
+
* @param {boolean} fixIt whether the restore makes the host come up
|
|
107
|
+
*/
|
|
108
|
+
function fakeRestore(dir, fixIt) {
|
|
109
|
+
const script = path.join(dir, 'fake-restore.mjs');
|
|
110
|
+
fs.writeFileSync(script, [
|
|
111
|
+
"import fs from 'node:fs';",
|
|
112
|
+
"import path from 'node:path';",
|
|
113
|
+
"const dir = process.argv[2];",
|
|
114
|
+
"fs.appendFileSync(path.join(dir, 'restore.log'), 'restore\\n');",
|
|
115
|
+
fixIt
|
|
116
|
+
? "fs.writeFileSync(path.join(dir, 'behaviour.txt'), 'up');"
|
|
117
|
+
: "fs.writeFileSync(path.join(dir, 'behaviour.txt'), 'down');",
|
|
118
|
+
'',
|
|
119
|
+
].join('\n'), 'utf8');
|
|
120
|
+
return script;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The "start the host again" command: it reads the behaviour file.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} dir
|
|
127
|
+
*/
|
|
128
|
+
function fakeStart(dir) {
|
|
129
|
+
const script = path.join(dir, 'fake-start.mjs');
|
|
130
|
+
fs.writeFileSync(script, [
|
|
131
|
+
"import { spawn } from 'node:child_process';",
|
|
132
|
+
"import fs from 'node:fs';",
|
|
133
|
+
"import path from 'node:path';",
|
|
134
|
+
"const dir = process.argv[2];",
|
|
135
|
+
"const behaviour = fs.existsSync(path.join(dir, 'behaviour.txt'))",
|
|
136
|
+
" ? fs.readFileSync(path.join(dir, 'behaviour.txt'), 'utf8').trim()",
|
|
137
|
+
" : 'down';",
|
|
138
|
+
"const child = spawn(process.execPath, [path.join(dir, 'fake-host.mjs'), dir, behaviour], { detached: true, stdio: 'ignore' });",
|
|
139
|
+
"child.unref();",
|
|
140
|
+
'',
|
|
141
|
+
].join('\n'), 'utf8');
|
|
142
|
+
return script;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Run one scenario of the drill.
|
|
147
|
+
*
|
|
148
|
+
* @param {{ name: string, behaviour: 'up'|'down', fixIt: boolean, expect: string }} options
|
|
149
|
+
*/
|
|
150
|
+
async function scenario({ name, behaviour, fixIt, expect }) {
|
|
151
|
+
const dir = path.join(drillRoot, name);
|
|
152
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
153
|
+
const restoreLog = path.join(dir, 'restore.log');
|
|
154
|
+
const readyMarker = readyPath(dir);
|
|
155
|
+
const host = fakeHost(dir, behaviour);
|
|
156
|
+
const restore = fakeRestore(dir, fixIt);
|
|
157
|
+
const start = fakeStart(dir);
|
|
158
|
+
fs.writeFileSync(path.join(dir, 'behaviour.txt'), behaviour);
|
|
159
|
+
|
|
160
|
+
// The pending marker: what the plugin writes before the restart.
|
|
161
|
+
writePending(dir, buildPending({
|
|
162
|
+
tier: 'detached-process',
|
|
163
|
+
tierReason: 'the drill starts the watchdog directly',
|
|
164
|
+
restoreCommand: { file: process.execPath, args: [restore, dir] },
|
|
165
|
+
relaunchCommand: { file: process.execPath, args: [start, dir] },
|
|
166
|
+
timeoutMs: 4000,
|
|
167
|
+
recheckMs: 1500,
|
|
168
|
+
readyTimeoutMs: 6000,
|
|
169
|
+
now: Date.now(),
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
// Scenario 1: the host comes up. The others: it stays down.
|
|
173
|
+
if (behaviour === 'up') {
|
|
174
|
+
const child = spawn(process.execPath, [host.script, dir, 'up'], { detached: true, stdio: 'ignore' });
|
|
175
|
+
child.unref();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The watchdog itself: the real CLI, in its own process.
|
|
179
|
+
const watchdog = spawn(process.execPath, [path.join(here, 'watchdog.mjs'), '--state-dir', dir], { stdio: 'ignore' });
|
|
180
|
+
await new Promise((resolve) => watchdog.on('exit', resolve));
|
|
181
|
+
|
|
182
|
+
const report = readReport(dir);
|
|
183
|
+
const restores = fs.existsSync(restoreLog) ? fs.readFileSync(restoreLog, 'utf8').trim().split('\n').filter(Boolean).length : 0;
|
|
184
|
+
check(name + ': the report says ' + expect, report !== null && report.result === expect, report ? report.result : 'no report');
|
|
185
|
+
if (expect === 'not-needed') {
|
|
186
|
+
check(name + ': nothing was restored', restores === 0, restores + ' restore(s)');
|
|
187
|
+
} else {
|
|
188
|
+
check(name + ': the restore ran exactly once', restores === 1, restores + ' restore(s)');
|
|
189
|
+
check(name + ': the report says which tier was used', report !== null && typeof report.tier === 'string' && report.tier !== '', report ? report.tier : '');
|
|
190
|
+
}
|
|
191
|
+
check(name + ': the pending marker is gone', !fs.existsSync(path.join(dir, 'watchdog-pending.json')));
|
|
192
|
+
check(name + ': the report was kept', fs.existsSync(path.join(dir, 'last-watchdog-report.json')));
|
|
193
|
+
return report;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The Windows scheduled task, for real: register it, read it back, let it
|
|
198
|
+
* fire, delete it.
|
|
199
|
+
*/
|
|
200
|
+
async function windowsTier() {
|
|
201
|
+
const dir = path.join(drillRoot, 'tier-windows');
|
|
202
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
203
|
+
const marker = path.join(dir, 'fired.txt');
|
|
204
|
+
const taskName = 'dsh-autorestart-drill-' + crypto.randomUUID().slice(0, 8);
|
|
205
|
+
const xmlPath = path.join(dir, 'task.xml');
|
|
206
|
+
const start = new Date(Date.now() + 60 * 1000);
|
|
207
|
+
const end = new Date(Date.now() + 10 * 60 * 1000);
|
|
208
|
+
const registered = registerScheduledTask({
|
|
209
|
+
taskName,
|
|
210
|
+
startIso: localIso(start),
|
|
211
|
+
endIso: localIso(end),
|
|
212
|
+
command: 'echo fired > "' + marker + '"',
|
|
213
|
+
xmlPath,
|
|
214
|
+
});
|
|
215
|
+
check('tier 1: the task was registered and read back', registered.ok === true, registered.error || '');
|
|
216
|
+
if (!registered.ok) return;
|
|
217
|
+
// It must REALLY fire.
|
|
218
|
+
const fired = await waitForFile(marker, 100000);
|
|
219
|
+
check('tier 1: the task really fired', fired);
|
|
220
|
+
const removed = removeScheduledTask({ taskName });
|
|
221
|
+
check('tier 1: the task was deleted', removed.ok === true, removed.error || '');
|
|
222
|
+
let gone = false;
|
|
223
|
+
try {
|
|
224
|
+
execFileSync('schtasks', ['/Query', '/TN', taskName], { stdio: 'ignore', timeout: 15000 });
|
|
225
|
+
} catch {
|
|
226
|
+
gone = true;
|
|
227
|
+
}
|
|
228
|
+
check('tier 1: the task is really gone', gone);
|
|
229
|
+
try {
|
|
230
|
+
fs.rmSync(xmlPath, { force: true });
|
|
231
|
+
} catch {
|
|
232
|
+
// It is gone either way.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The Linux tiers, for real: inside WSL.
|
|
238
|
+
*/
|
|
239
|
+
async function linuxTiers() {
|
|
240
|
+
const dir = path.join(drillRoot, 'tier-linux');
|
|
241
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
242
|
+
const marker = path.join(dir, 'fired.txt');
|
|
243
|
+
const wslDir = wslPath(dir);
|
|
244
|
+
const helper = path.join(dir, 'tier.mjs');
|
|
245
|
+
fs.writeFileSync(helper, [
|
|
246
|
+
"import { registerDetachedProcess, registerSystemdRun, cleanupSystemdUnit } from '" + wslPath(path.join(pluginDir, 'lib', 'watchdog-schedule.js')).replace(/\\/g, '/') + "';",
|
|
247
|
+
"const dir = process.argv[2];",
|
|
248
|
+
"const marker = process.argv[3];",
|
|
249
|
+
"const which = process.argv[4];",
|
|
250
|
+
"if (which === 'systemd') {",
|
|
251
|
+
" const unit = 'dsh-watchdog-drill-' + Date.now();",
|
|
252
|
+
" const answer = registerSystemdRun({ unitId: unit, delaySeconds: 2, command: ['sh', '-c', 'sleep 1; echo fired > ' + marker] });",
|
|
253
|
+
" console.log(JSON.stringify({ ok: answer.ok, error: answer.error }));",
|
|
254
|
+
" setTimeout(() => { cleanupSystemdUnit({ unitId: unit }); }, 6000);",
|
|
255
|
+
"} else {",
|
|
256
|
+
" const answer = registerDetachedProcess({ delaySeconds: 2, command: \"sh -c 'echo fired > \" + marker + \"'\", pidPath: dir + '/pid' });",
|
|
257
|
+
" console.log(JSON.stringify({ ok: answer.ok, pid: answer.pid, error: answer.error }));",
|
|
258
|
+
"}",
|
|
259
|
+
'',
|
|
260
|
+
].join('\n'), 'utf8');
|
|
261
|
+
|
|
262
|
+
// Tier 3: the detached process of our own.
|
|
263
|
+
try {
|
|
264
|
+
const out = execFileSync('wsl.exe', ['-e', 'node', wslPath(helper), wslDir, wslPath(marker), 'detached'], { encoding: 'utf8', timeout: 60000 });
|
|
265
|
+
const answer = JSON.parse(String(out).trim().split('\n').pop());
|
|
266
|
+
check('tier 3: the background process was started', answer.ok === true, answer.error || '');
|
|
267
|
+
check('tier 3: it really ran later', await waitForFile(marker, 20000));
|
|
268
|
+
} catch (error) {
|
|
269
|
+
check('tier 3: the background process was started', false, String(error && error.message ? error.message : error));
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
fs.rmSync(marker, { force: true });
|
|
273
|
+
} catch {
|
|
274
|
+
// It is gone either way.
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Tier 2: the systemd user timer.
|
|
278
|
+
try {
|
|
279
|
+
const out = execFileSync('wsl.exe', ['-e', 'node', wslPath(helper), wslDir, wslPath(marker), 'systemd'], { encoding: 'utf8', timeout: 60000 });
|
|
280
|
+
const answer = JSON.parse(String(out).trim().split('\n').pop());
|
|
281
|
+
check('tier 2: the systemd unit was registered', answer.ok === true, answer.error || '');
|
|
282
|
+
check('tier 2: it really ran later', await waitForFile(marker, 30000));
|
|
283
|
+
} catch (error) {
|
|
284
|
+
check('tier 2: the systemd unit was registered', false, String(error && error.message ? error.message : error));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The WSL path of a Windows path.
|
|
290
|
+
*
|
|
291
|
+
* @param {string} target
|
|
292
|
+
* @returns {string}
|
|
293
|
+
*/
|
|
294
|
+
function wslPath(target) {
|
|
295
|
+
const normalized = target.replace(/\\/g, '/');
|
|
296
|
+
const match = /^[A-Za-z]:\/(.*)$/.exec(normalized);
|
|
297
|
+
if (!match) return normalized;
|
|
298
|
+
return '/mnt/' + normalized[0].toLowerCase() + '/' + match[1];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Wait a moment (the drill is not in a hurry).
|
|
303
|
+
*
|
|
304
|
+
* @param {number} ms
|
|
305
|
+
*/
|
|
306
|
+
function wait(ms) {
|
|
307
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
console.log('dsh-autorestart watchdog drill');
|
|
311
|
+
console.log(' workspace: ' + drillRoot);
|
|
312
|
+
console.log('');
|
|
313
|
+
|
|
314
|
+
console.log('--- the three scenarios ---');
|
|
315
|
+
await scenario({ name: 'scenario-1-normal', behaviour: 'up', fixIt: false, expect: 'not-needed' });
|
|
316
|
+
await scenario({ name: 'scenario-2-recovered', behaviour: 'down', fixIt: true, expect: 'recovered' });
|
|
317
|
+
await scenario({ name: 'scenario-3-failed', behaviour: 'down', fixIt: false, expect: 'failed' });
|
|
318
|
+
|
|
319
|
+
console.log('');
|
|
320
|
+
console.log('--- the tiers, for real ---');
|
|
321
|
+
await windowsTier();
|
|
322
|
+
await linuxTiers();
|
|
323
|
+
|
|
324
|
+
console.log('');
|
|
325
|
+
console.log('--- cleanup ---');
|
|
326
|
+
try {
|
|
327
|
+
fs.rmSync(drillRoot, { recursive: true, force: true });
|
|
328
|
+
check('the drill directory is gone', !fs.existsSync(drillRoot));
|
|
329
|
+
} catch (error) {
|
|
330
|
+
check('the drill directory is gone', false, String(error && error.message ? error.message : error));
|
|
331
|
+
}
|
|
332
|
+
await wait(500);
|
|
333
|
+
|
|
334
|
+
console.log('');
|
|
335
|
+
if (failures.length === 0) {
|
|
336
|
+
console.log('the watchdog drill passed: every scenario behaved as it should, every tier really fires.');
|
|
337
|
+
process.exit(0);
|
|
338
|
+
}
|
|
339
|
+
console.log('the watchdog drill FAILED:');
|
|
340
|
+
for (const line of failures) console.log(' ' + line);
|
|
341
|
+
process.exit(1);
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# dsh-autorestart - the emergency switch (POSIX: Linux, WSL, macOS).
|
|
3
|
+
#
|
|
4
|
+
# Three traps this file avoids (the whole story: scripts/README.md):
|
|
5
|
+
# 1. the wording lives in rescue-strings.zh.json and is NESTED - it is read
|
|
6
|
+
# by path with node, never by a flat grep;
|
|
7
|
+
# 2. the English fallbacks below must NOT contain parentheses: dash counts
|
|
8
|
+
# them even inside single quotes, and one stray ")" breaks the script;
|
|
9
|
+
# 3. the sed expressions stay single-quoted: the shell eats the backslashes
|
|
10
|
+
# in a double-quoted sed program and truncates it.
|
|
11
|
+
#
|
|
12
|
+
# "I enabled the plugin and DSH does not start any more" is the hardest moment
|
|
13
|
+
# for a plugin: fixing it needs the plugin itself. This script touches ONE file
|
|
14
|
+
# - the package.json of the profile - and ONE array in it:
|
|
15
|
+
# `dsh.profile.bundles`.
|
|
16
|
+
#
|
|
17
|
+
# - disable: take "dsh-autorestart" out of the bundles (it was not there
|
|
18
|
+
# anyway -> report "already disabled", that is not a failure);
|
|
19
|
+
# - enable : put "dsh-autorestart" back into the bundles;
|
|
20
|
+
# - status : read only, says whether it is in the bundles right now.
|
|
21
|
+
#
|
|
22
|
+
# Two hard rules:
|
|
23
|
+
#
|
|
24
|
+
# 1. the file is backed up as `package.json.bak-emergency-<stamp>` before
|
|
25
|
+
# anything is written;
|
|
26
|
+
# 2. after writing, the JSON is parsed again and checked; if the check
|
|
27
|
+
# fails, the backup is put back and the failure is reported honestly.
|
|
28
|
+
#
|
|
29
|
+
# The indentation and the line endings of the file are kept as they are (the
|
|
30
|
+
# TEXT is edited, the JSON is not re-serialized).
|
|
31
|
+
#
|
|
32
|
+
# The file is pure ASCII on purpose: the Chinese wording lives in
|
|
33
|
+
# rescue-strings.zh.json (UTF-8 without a BOM).
|
|
34
|
+
set -eu
|
|
35
|
+
|
|
36
|
+
PACKAGE_NAME='dsh-autorestart'
|
|
37
|
+
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
38
|
+
STRINGS_FILE="$SCRIPT_DIR/rescue-strings.zh.json"
|
|
39
|
+
|
|
40
|
+
ACTION='disable'
|
|
41
|
+
DRY_RUN=0
|
|
42
|
+
PACKAGE_JSON=''
|
|
43
|
+
|
|
44
|
+
usage() {
|
|
45
|
+
printf 'Usage: %s [--action disable|enable|status] [--dry-run] [--package FILE]\n' "$0"
|
|
46
|
+
printf ' --action ... what to do (default: disable)\n'
|
|
47
|
+
printf ' --dry-run say what would be written, change nothing\n'
|
|
48
|
+
printf ' --package FILE the package.json to work on (default: the profile of this machine)\n'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
while [ $# -gt 0 ]; do
|
|
52
|
+
case "$1" in
|
|
53
|
+
--action) ACTION="${2:-}"; shift ;;
|
|
54
|
+
--dry-run|-n) DRY_RUN=1 ;;
|
|
55
|
+
--package) PACKAGE_JSON="${2:-}"; shift ;;
|
|
56
|
+
--help|-h) usage; exit 0 ;;
|
|
57
|
+
*) printf 'unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;;
|
|
58
|
+
esac
|
|
59
|
+
shift
|
|
60
|
+
done
|
|
61
|
+
|
|
62
|
+
case "$ACTION" in
|
|
63
|
+
disable|enable|status) ;;
|
|
64
|
+
*) printf 'unknown action: %s\n' "$ACTION" >&2; usage >&2; exit 2 ;;
|
|
65
|
+
esac
|
|
66
|
+
|
|
67
|
+
NODE_BIN=''
|
|
68
|
+
if command -v node >/dev/null 2>&1; then
|
|
69
|
+
NODE_BIN=$(command -v node)
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
# t <key> <english fallback>
|
|
73
|
+
#
|
|
74
|
+
# The key is a PATH into the strings file ("emergency.done"), because that file
|
|
75
|
+
# is nested. Node reads it (it is on every machine that runs DSH); without
|
|
76
|
+
# node every message falls back to English.
|
|
77
|
+
t() {
|
|
78
|
+
key="$1"
|
|
79
|
+
fallback="$2"
|
|
80
|
+
if [ -n "$NODE_BIN" ] && [ -f "$STRINGS_FILE" ]; then
|
|
81
|
+
value=$("$NODE_BIN" -e '
|
|
82
|
+
const fs = require("fs");
|
|
83
|
+
try {
|
|
84
|
+
const doc = JSON.parse(fs.readFileSync(process.argv[1], "utf8").replace(/^\uFEFF/, ""));
|
|
85
|
+
let value = doc;
|
|
86
|
+
for (const part of process.argv[2].split(".")) value = value == null ? undefined : value[part];
|
|
87
|
+
if (typeof value !== "string" || value === "") process.exit(1);
|
|
88
|
+
process.stdout.write(value);
|
|
89
|
+
} catch (error) { process.exit(1); }
|
|
90
|
+
' "$STRINGS_FILE" "$key" 2>/dev/null) || value=''
|
|
91
|
+
if [ -n "$value" ]; then
|
|
92
|
+
printf '%s' "$value"
|
|
93
|
+
return 0
|
|
94
|
+
fi
|
|
95
|
+
fi
|
|
96
|
+
printf '%s' "$fallback"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# fmt <text> <value> ...
|
|
100
|
+
#
|
|
101
|
+
# Node does the replacement when it is there: the Chinese wording is UTF-8 and
|
|
102
|
+
# a byte-wise sed would mangle it. Without node only the English fallbacks are
|
|
103
|
+
# used, and those are plain ASCII.
|
|
104
|
+
fmt() {
|
|
105
|
+
text="$1"
|
|
106
|
+
shift
|
|
107
|
+
if [ -n "$NODE_BIN" ]; then
|
|
108
|
+
if "$NODE_BIN" -e '
|
|
109
|
+
let text = process.argv[1];
|
|
110
|
+
process.argv.slice(2).forEach((value, index) => { text = text.split("{" + index + "}").join(value); });
|
|
111
|
+
process.stdout.write(text);
|
|
112
|
+
' "$text" "$@" 2>/dev/null; then
|
|
113
|
+
return 0
|
|
114
|
+
fi
|
|
115
|
+
fi
|
|
116
|
+
index=0
|
|
117
|
+
for value in "$@"; do
|
|
118
|
+
safe=$(printf '%s' "$value" | sed 's/[&|\\]/\\&/g')
|
|
119
|
+
text=$(printf '%s' "$text" | sed "s|{$index}|$safe|g")
|
|
120
|
+
index=$((index + 1))
|
|
121
|
+
done
|
|
122
|
+
printf '%s' "$text"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
say() {
|
|
126
|
+
printf '%s\n' "$1"
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
fail() {
|
|
130
|
+
printf '%s%s\n' "$(t 'emergency.failed' 'the emergency switch failed: {0}')" "$1" >&2
|
|
131
|
+
exit 1
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
full_path() {
|
|
135
|
+
target="${1:-}"
|
|
136
|
+
[ -n "$target" ] || return 0
|
|
137
|
+
case "$target" in
|
|
138
|
+
/*) resolved="$target" ;;
|
|
139
|
+
*) resolved="$PWD/$target" ;;
|
|
140
|
+
esac
|
|
141
|
+
while [ "${resolved%/}" != "$resolved" ] && [ "${resolved%/}" != '' ]; do
|
|
142
|
+
resolved="${resolved%/}"
|
|
143
|
+
done
|
|
144
|
+
printf '%s' "$resolved"
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
dsh_home() {
|
|
148
|
+
if [ -n "${DSH_HOME:-}" ]; then
|
|
149
|
+
full_path "$DSH_HOME"
|
|
150
|
+
return 0
|
|
151
|
+
fi
|
|
152
|
+
if [ -n "${HOME:-}" ]; then
|
|
153
|
+
full_path "$HOME/.dsh"
|
|
154
|
+
return 0
|
|
155
|
+
fi
|
|
156
|
+
printf ''
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# The package.json of the profile of this machine.
|
|
160
|
+
default_package_json() {
|
|
161
|
+
if [ -n "${DSH_PROFILE_DIR:-}" ]; then
|
|
162
|
+
explicit=$(full_path "$DSH_PROFILE_DIR")
|
|
163
|
+
if [ -n "$explicit" ] && [ -f "$explicit/package.json" ]; then
|
|
164
|
+
printf '%s' "$explicit/package.json"
|
|
165
|
+
return 0
|
|
166
|
+
fi
|
|
167
|
+
fi
|
|
168
|
+
root=$(dsh_home)
|
|
169
|
+
if [ -n "$root" ]; then
|
|
170
|
+
profiles="$root/profiles"
|
|
171
|
+
if [ -d "$profiles" ]; then
|
|
172
|
+
match=''
|
|
173
|
+
count=0
|
|
174
|
+
for entry in "$profiles"/*; do
|
|
175
|
+
[ -f "$entry/package.json" ] || continue
|
|
176
|
+
if grep -q "\"$PACKAGE_NAME\"" "$entry/package.json" 2>/dev/null; then
|
|
177
|
+
match="$entry/package.json"
|
|
178
|
+
count=$((count + 1))
|
|
179
|
+
fi
|
|
180
|
+
done
|
|
181
|
+
if [ "$count" -eq 1 ]; then
|
|
182
|
+
printf '%s' "$match"
|
|
183
|
+
return 0
|
|
184
|
+
fi
|
|
185
|
+
fi
|
|
186
|
+
if [ -n "${DSH_PROFILE:-}" ]; then
|
|
187
|
+
printf '%s' "$profiles/$DSH_PROFILE/package.json"
|
|
188
|
+
return 0
|
|
189
|
+
fi
|
|
190
|
+
printf '%s' "$profiles/desktop/package.json"
|
|
191
|
+
return 0
|
|
192
|
+
fi
|
|
193
|
+
printf ''
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
# The bundles of a package.json, one per line.
|
|
197
|
+
bundles_list() {
|
|
198
|
+
file="$1"
|
|
199
|
+
[ -f "$file" ] || return 1
|
|
200
|
+
if [ -n "$NODE_BIN" ]; then
|
|
201
|
+
"$NODE_BIN" -e 'try { const d = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8").replace(/^\uFEFF/, "")); const b = d && d.dsh && d.dsh.profile && d.dsh.profile.bundles; if (!Array.isArray(b)) process.exit(1); process.stdout.write(b.join("\n")) } catch (error) { process.exit(1) }' "$file" 2>/dev/null
|
|
202
|
+
return $?
|
|
203
|
+
fi
|
|
204
|
+
sed -n 's/.*"bundles"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p' "$file" | tr ',' '\n' | sed 's/["[:space:]]//g'
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
json_parses() {
|
|
208
|
+
file="$1"
|
|
209
|
+
[ -f "$file" ] || return 1
|
|
210
|
+
if [ -n "$NODE_BIN" ]; then
|
|
211
|
+
"$NODE_BIN" -e 'try { JSON.parse(require("fs").readFileSync(process.argv[1], "utf8").replace(/^\uFEFF/, "")); process.exit(0) } catch (error) { process.exit(1) }' "$file" >/dev/null 2>&1
|
|
212
|
+
return $?
|
|
213
|
+
fi
|
|
214
|
+
head -c 1 "$file" | grep -q '{' || return 1
|
|
215
|
+
return 0
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
# --- Start ---
|
|
219
|
+
|
|
220
|
+
if [ -n "$PACKAGE_JSON" ]; then
|
|
221
|
+
pkg=$(full_path "$PACKAGE_JSON")
|
|
222
|
+
else
|
|
223
|
+
pkg=$(default_package_json)
|
|
224
|
+
fi
|
|
225
|
+
if [ -z "$pkg" ]; then
|
|
226
|
+
fail "$(t 'emergency.notFound' 'the package.json of the profile cannot be found: {0}')" ''
|
|
227
|
+
fi
|
|
228
|
+
if [ ! -f "$pkg" ]; then
|
|
229
|
+
fail "$(fmt "$(t 'emergency.notFound' 'the package.json of the profile cannot be found: {0}')" "$pkg")"
|
|
230
|
+
fi
|
|
231
|
+
if ! json_parses "$pkg"; then
|
|
232
|
+
fail "$(fmt "$(t 'emergency.badJson' 'this package.json is not valid JSON: {0}')" "$pkg")"
|
|
233
|
+
fi
|
|
234
|
+
say "$(fmt "$(t 'emergency.file' 'file: {0}')" "$pkg")"
|
|
235
|
+
|
|
236
|
+
list=$(bundles_list "$pkg" || printf '')
|
|
237
|
+
if [ -z "$list" ]; then
|
|
238
|
+
fail "$(fmt "$(t 'emergency.noBundles' 'this profile has no dsh.profile.bundles list')" '')"
|
|
239
|
+
fi
|
|
240
|
+
|
|
241
|
+
in_bundles=0
|
|
242
|
+
if printf '%s\n' "$list" | grep -qx "$PACKAGE_NAME"; then
|
|
243
|
+
in_bundles=1
|
|
244
|
+
fi
|
|
245
|
+
|
|
246
|
+
if [ "$ACTION" = 'status' ]; then
|
|
247
|
+
if [ "$in_bundles" -eq 1 ]; then
|
|
248
|
+
say "$(fmt "$(t 'emergency.enabled' 'enabled: {0} is in the bundles.')" "$PACKAGE_NAME")"
|
|
249
|
+
else
|
|
250
|
+
say "$(fmt "$(t 'emergency.disabled' 'disabled: {0} is not in the bundles.')" "$PACKAGE_NAME")"
|
|
251
|
+
fi
|
|
252
|
+
exit 0
|
|
253
|
+
fi
|
|
254
|
+
|
|
255
|
+
if [ "$ACTION" = 'disable' ] && [ "$in_bundles" -eq 0 ]; then
|
|
256
|
+
say "$(fmt "$(t 'emergency.alreadyDisabled' 'already disabled: {0} was not in the bundles (no file was changed).')" "$PACKAGE_NAME")"
|
|
257
|
+
exit 0
|
|
258
|
+
fi
|
|
259
|
+
if [ "$ACTION" = 'enable' ] && [ "$in_bundles" -eq 1 ]; then
|
|
260
|
+
say "$(fmt "$(t 'emergency.alreadyEnabled' 'already enabled: {0} was in the bundles (no file was changed).')" "$PACKAGE_NAME")"
|
|
261
|
+
exit 0
|
|
262
|
+
fi
|
|
263
|
+
|
|
264
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
265
|
+
if [ "$ACTION" = 'disable' ]; then
|
|
266
|
+
say "$(fmt "$(t 'emergency.dryRunWill' '[dry run] {0} would be taken out of the bundles.')" "$PACKAGE_NAME")"
|
|
267
|
+
else
|
|
268
|
+
say "$(fmt "$(t 'emergency.dryRunWill' '[dry run] {0} would be taken out of the bundles.')" "$PACKAGE_NAME")"
|
|
269
|
+
fi
|
|
270
|
+
say "$(fmt "$(t 'emergency.dryRunBackup' '[dry run] it would be backed up as: {0}.bak-emergency-<stamp>')" "$pkg")"
|
|
271
|
+
say "$(t 'emergency.dryRunNoChange' '[dry run] no file was changed.')"
|
|
272
|
+
exit 0
|
|
273
|
+
fi
|
|
274
|
+
|
|
275
|
+
# --- Back up, then write ---
|
|
276
|
+
stamp=$(date -u +%Y%m%d-%H%M%S)
|
|
277
|
+
backup="$pkg.bak-emergency-$stamp"
|
|
278
|
+
if ! cp -- "$pkg" "$backup"; then
|
|
279
|
+
fail "$(fmt "$(t 'emergency.backupFailed' 'the backup failed - the original file was not touched: {0}')" "$backup")"
|
|
280
|
+
fi
|
|
281
|
+
|
|
282
|
+
tmp="$pkg.tmp-emergency-$$"
|
|
283
|
+
if [ "$ACTION" = 'disable' ]; then
|
|
284
|
+
sed "/^[[:space:]]*\"$PACKAGE_NAME\"[[:space:]]*,\{0,1\}[[:space:]]*$/d" "$pkg" > "$tmp"
|
|
285
|
+
else
|
|
286
|
+
sed "s/^\\([[:space:]]*\"bundles\"[[:space:]]*:[[:space:]]*\\[\\)$/\\1\\n \"$PACKAGE_NAME\",/" "$pkg" > "$tmp"
|
|
287
|
+
fi
|
|
288
|
+
if ! mv -- "$tmp" "$pkg"; then
|
|
289
|
+
rm -f -- "$tmp" 2>/dev/null || true
|
|
290
|
+
fail "$(fmt "$(t 'emergency.writeFailed' 'the write failed - the backup is at {0}: {1}')" "$backup" "$pkg")"
|
|
291
|
+
fi
|
|
292
|
+
|
|
293
|
+
# --- Read it back ---
|
|
294
|
+
if ! json_parses "$pkg"; then
|
|
295
|
+
cp -- "$backup" "$pkg" || true
|
|
296
|
+
fail "$(fmt "$(t 'emergency.verifyFailedRolledBack' 'the check failed - it was rolled back: {0}')" "$backup")"
|
|
297
|
+
fi
|
|
298
|
+
after=$(bundles_list "$pkg" || printf '')
|
|
299
|
+
if [ "$ACTION" = 'disable' ]; then
|
|
300
|
+
if printf '%s\n' "$after" | grep -qx "$PACKAGE_NAME"; then
|
|
301
|
+
cp -- "$backup" "$pkg" || true
|
|
302
|
+
fail "$(fmt "$(t 'emergency.verifyFailedRolledBack' 'the check failed - it was rolled back: {0}')" "$backup")"
|
|
303
|
+
fi
|
|
304
|
+
say "$(fmt "$(t 'emergency.done' 'done: {0}.')" "$(fmt "$(t 'emergency.summaryRemove' 'dsh-autorestart was taken out of the bundles')" '')")"
|
|
305
|
+
else
|
|
306
|
+
if ! printf '%s\n' "$after" | grep -qx "$PACKAGE_NAME"; then
|
|
307
|
+
cp -- "$backup" "$pkg" || true
|
|
308
|
+
fail "$(fmt "$(t 'emergency.verifyFailedRolledBack' 'the check failed - it was rolled back: {0}')" "$backup")"
|
|
309
|
+
fi
|
|
310
|
+
say "$(fmt "$(t 'emergency.done' 'done: {0}.')" "$(fmt "$(t 'emergency.summaryAdd' 'dsh-autorestart was put back into the bundles')" '')")"
|
|
311
|
+
fi
|
|
312
|
+
say "$(fmt "$(t 'emergency.backup' 'backup: {0}')" "$backup")"
|
|
313
|
+
say "$(t 'emergency.restartHint' 'Restart DSH for it to take effect.')"
|
|
314
|
+
exit 0
|