dsh-autorestart 0.8.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.
@@ -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