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.
- package/CHANGELOG.md +47 -0
- package/README.md +1 -1
- package/docs/README.zh.md +1 -1
- package/index.js +1 -1
- package/package.json +6 -1
- package/scripts/drill-rescue.sh +427 -0
- package/scripts/drill-watchdog.mjs +341 -0
- package/scripts/emergency-disable.sh +314 -0
- package/scripts/rescue-strings.zh.json +168 -0
- package/scripts/restore-profile.sh +593 -0
- package/scripts/watchdog.mjs +203 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# dsh-autorestart - profile snapshot restorer (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
|
+
# The same rules as restore-profile.ps1: the newest snapshot that carries a
|
|
13
|
+
# manifest is put back into a profile that is ALREADY there, the broken profile
|
|
14
|
+
# is kept (never deleted), the result is read back, and DSH is NOT restarted
|
|
15
|
+
# for you.
|
|
16
|
+
#
|
|
17
|
+
# The file is pure ASCII on purpose: the Chinese wording lives in
|
|
18
|
+
# rescue-strings.zh.json (UTF-8 without a BOM), like everywhere else in this
|
|
19
|
+
# plugin.
|
|
20
|
+
set -eu
|
|
21
|
+
|
|
22
|
+
PACKAGE_NAME='dsh-autorestart'
|
|
23
|
+
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
24
|
+
STRINGS_FILE="$SCRIPT_DIR/rescue-strings.zh.json"
|
|
25
|
+
|
|
26
|
+
DRY_RUN=0
|
|
27
|
+
SNAPSHOT=''
|
|
28
|
+
PROFILE_DIR=''
|
|
29
|
+
STATE_DIR=''
|
|
30
|
+
|
|
31
|
+
usage() {
|
|
32
|
+
printf 'Usage: %s [--dry-run] [--snapshot DIR] [--profile DIR] [--state-dir DIR]\n' "$0"
|
|
33
|
+
printf ' --dry-run say what would happen, change nothing\n'
|
|
34
|
+
printf ' --snapshot DIR use this snapshot (default: the newest one with a manifest)\n'
|
|
35
|
+
printf ' --profile DIR the profile to restore into (default: the profile of this machine)\n'
|
|
36
|
+
printf ' --state-dir DIR where the snapshots live (default: <DSH_HOME>/storages/dsh-autorestart)\n'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
while [ $# -gt 0 ]; do
|
|
40
|
+
case "$1" in
|
|
41
|
+
--dry-run|-n) DRY_RUN=1 ;;
|
|
42
|
+
--snapshot) SNAPSHOT="${2:-}"; shift ;;
|
|
43
|
+
--profile) PROFILE_DIR="${2:-}"; shift ;;
|
|
44
|
+
--state-dir) STATE_DIR="${2:-}"; shift ;;
|
|
45
|
+
--help|-h) usage; exit 0 ;;
|
|
46
|
+
*) printf 'unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;;
|
|
47
|
+
esac
|
|
48
|
+
shift
|
|
49
|
+
done
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# The Chinese wording (and the English fallback)
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
NODE_BIN=''
|
|
56
|
+
if command -v node >/dev/null 2>&1; then
|
|
57
|
+
NODE_BIN=$(command -v node)
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
strings_error=''
|
|
61
|
+
|
|
62
|
+
# t <key> <english fallback>
|
|
63
|
+
#
|
|
64
|
+
# The key is a PATH into the strings file ("restore.title"), because that file
|
|
65
|
+
# is nested. Node reads it (it is on every machine that runs DSH); without
|
|
66
|
+
# node every message falls back to English.
|
|
67
|
+
t() {
|
|
68
|
+
key="$1"
|
|
69
|
+
fallback="$2"
|
|
70
|
+
if [ -n "$NODE_BIN" ] && [ -f "$STRINGS_FILE" ]; then
|
|
71
|
+
value=$("$NODE_BIN" -e '
|
|
72
|
+
const fs = require("fs");
|
|
73
|
+
try {
|
|
74
|
+
const doc = JSON.parse(fs.readFileSync(process.argv[1], "utf8").replace(/^\uFEFF/, ""));
|
|
75
|
+
let value = doc;
|
|
76
|
+
for (const part of process.argv[2].split(".")) value = value == null ? undefined : value[part];
|
|
77
|
+
if (typeof value !== "string" || value === "") process.exit(1);
|
|
78
|
+
process.stdout.write(value);
|
|
79
|
+
} catch (error) { process.exit(1); }
|
|
80
|
+
' "$STRINGS_FILE" "$key" 2>/dev/null) || value=''
|
|
81
|
+
if [ -n "$value" ]; then
|
|
82
|
+
printf '%s' "$value"
|
|
83
|
+
return 0
|
|
84
|
+
fi
|
|
85
|
+
fi
|
|
86
|
+
printf '%s' "$fallback"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# fmt <text> <value> <value> ... (replaces {0}, {1}, ...)
|
|
90
|
+
#
|
|
91
|
+
# Node does the replacement when it is there: the Chinese wording is UTF-8 and
|
|
92
|
+
# a byte-wise sed would mangle it. Without node only the English fallbacks are
|
|
93
|
+
# used, and those are plain ASCII.
|
|
94
|
+
fmt() {
|
|
95
|
+
text="$1"
|
|
96
|
+
shift
|
|
97
|
+
if [ -n "$NODE_BIN" ]; then
|
|
98
|
+
if "$NODE_BIN" -e '
|
|
99
|
+
let text = process.argv[1];
|
|
100
|
+
process.argv.slice(2).forEach((value, index) => { text = text.split("{" + index + "}").join(value); });
|
|
101
|
+
process.stdout.write(text);
|
|
102
|
+
' "$text" "$@" 2>/dev/null; then
|
|
103
|
+
return 0
|
|
104
|
+
fi
|
|
105
|
+
fi
|
|
106
|
+
index=0
|
|
107
|
+
for value in "$@"; do
|
|
108
|
+
safe=$(printf '%s' "$value" | sed 's/[&|\\]/\\&/g')
|
|
109
|
+
text=$(printf '%s' "$text" | sed "s|{$index}|$safe|g")
|
|
110
|
+
index=$((index + 1))
|
|
111
|
+
done
|
|
112
|
+
printf '%s' "$text"
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
say() {
|
|
116
|
+
printf '%s\n' "$1"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
fail() {
|
|
120
|
+
printf '%s%s\n' "$(t 'restore.failPrefix' 'restore failed: ')" "$1" >&2
|
|
121
|
+
exit 1
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Paths
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
full_path() {
|
|
129
|
+
target="${1:-}"
|
|
130
|
+
if [ -z "$target" ]; then
|
|
131
|
+
printf ''
|
|
132
|
+
return 0
|
|
133
|
+
fi
|
|
134
|
+
case "$target" in
|
|
135
|
+
/*) resolved="$target" ;;
|
|
136
|
+
*) resolved="$PWD/$target" ;;
|
|
137
|
+
esac
|
|
138
|
+
# Remove the trailing slashes (but keep the root "/").
|
|
139
|
+
while [ "${resolved%/}" != "$resolved" ] && [ "${resolved%/}" != '' ]; do
|
|
140
|
+
resolved="${resolved%/}"
|
|
141
|
+
done
|
|
142
|
+
printf '%s' "$resolved"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
same_path() {
|
|
146
|
+
[ "$(full_path "$1")" = "$(full_path "$2")" ]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
# Is the child inside the parent (the parent itself does not count)?
|
|
150
|
+
is_inside() {
|
|
151
|
+
child=$(full_path "$1")
|
|
152
|
+
parent=$(full_path "$2")
|
|
153
|
+
[ -n "$child" ] || return 1
|
|
154
|
+
[ -n "$parent" ] || return 1
|
|
155
|
+
[ "$child" != "$parent" ] || return 1
|
|
156
|
+
case "$child" in
|
|
157
|
+
"$parent"/*) return 0 ;;
|
|
158
|
+
*) return 1 ;;
|
|
159
|
+
esac
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# The system directories of a POSIX machine.
|
|
163
|
+
system_directories() {
|
|
164
|
+
printf '%s\n' /usr /etc /bin /sbin /lib /lib64 /boot /dev /proc /sys /run /var/lib /var/log /opt /System /Library /Applications
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# Why may this path not be touched? An empty string means: it may.
|
|
168
|
+
forbidden_reason() {
|
|
169
|
+
target=$(full_path "$1")
|
|
170
|
+
home_path=$(full_path "${HOME:-}")
|
|
171
|
+
if [ -z "$target" ]; then
|
|
172
|
+
t 'restore.gateEmptyPath' 'the target path is empty'
|
|
173
|
+
return 0
|
|
174
|
+
fi
|
|
175
|
+
if [ "$target" = '/' ]; then
|
|
176
|
+
t 'restore.gateFsRoot' 'refusing to touch the filesystem root'
|
|
177
|
+
return 0
|
|
178
|
+
fi
|
|
179
|
+
if [ -n "$home_path" ] && same_path "$target" "$home_path"; then
|
|
180
|
+
t 'restore.gateHome' 'refusing to touch the home directory itself'
|
|
181
|
+
return 0
|
|
182
|
+
fi
|
|
183
|
+
for system in $(system_directories); do
|
|
184
|
+
if same_path "$target" "$system" || is_inside "$target" "$system"; then
|
|
185
|
+
fmt "$(t 'restore.gateSystemDir' 'refusing to touch the system directory: {0}')" "$system"
|
|
186
|
+
return 0
|
|
187
|
+
fi
|
|
188
|
+
done
|
|
189
|
+
# Somebody else's home: /home/<somebody else> (or /Users/<somebody else>).
|
|
190
|
+
if [ -n "$home_path" ]; then
|
|
191
|
+
for users_root in /home /Users; do
|
|
192
|
+
if is_inside "$target" "$users_root"; then
|
|
193
|
+
parent=$(full_path "$(dirname "$target")")
|
|
194
|
+
if [ "$parent" = "$users_root" ]; then
|
|
195
|
+
t 'restore.gateOtherUser' "refusing to touch another user's home directory"
|
|
196
|
+
return 0
|
|
197
|
+
fi
|
|
198
|
+
fi
|
|
199
|
+
done
|
|
200
|
+
fi
|
|
201
|
+
printf ''
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
dsh_home() {
|
|
205
|
+
if [ -n "${DSH_HOME:-}" ]; then
|
|
206
|
+
full_path "$DSH_HOME"
|
|
207
|
+
return 0
|
|
208
|
+
fi
|
|
209
|
+
if [ -n "${HOME:-}" ]; then
|
|
210
|
+
full_path "$HOME/.dsh"
|
|
211
|
+
return 0
|
|
212
|
+
fi
|
|
213
|
+
printf ''
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
profiles_root() {
|
|
217
|
+
root=$(dsh_home)
|
|
218
|
+
if [ -z "$root" ]; then
|
|
219
|
+
printf ''
|
|
220
|
+
return 0
|
|
221
|
+
fi
|
|
222
|
+
full_path "$root/profiles"
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
backups_root() {
|
|
226
|
+
if [ -n "$STATE_DIR" ]; then
|
|
227
|
+
full_path "$STATE_DIR/backups"
|
|
228
|
+
return 0
|
|
229
|
+
fi
|
|
230
|
+
root=$(dsh_home)
|
|
231
|
+
if [ -z "$root" ]; then
|
|
232
|
+
printf ''
|
|
233
|
+
return 0
|
|
234
|
+
fi
|
|
235
|
+
full_path "$root/storages/dsh-autorestart/backups"
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
# Without --profile: where is the profile of this machine?
|
|
239
|
+
default_profile_dir() {
|
|
240
|
+
# 1) The environment variable the host exports.
|
|
241
|
+
if [ -n "${DSH_PROFILE_DIR:-}" ]; then
|
|
242
|
+
explicit=$(full_path "$DSH_PROFILE_DIR")
|
|
243
|
+
if [ -n "$explicit" ] && [ -d "$explicit" ]; then
|
|
244
|
+
printf '%s' "$explicit"
|
|
245
|
+
return 0
|
|
246
|
+
fi
|
|
247
|
+
fi
|
|
248
|
+
# 2) The only profile that has this plugin in it - that is the one.
|
|
249
|
+
root=$(profiles_root)
|
|
250
|
+
if [ -n "$root" ] && [ -d "$root" ]; then
|
|
251
|
+
match=''
|
|
252
|
+
count=0
|
|
253
|
+
for entry in "$root"/*; do
|
|
254
|
+
[ -d "$entry" ] || continue
|
|
255
|
+
pkg="$entry/package.json"
|
|
256
|
+
[ -f "$pkg" ] || continue
|
|
257
|
+
if declares_plugin "$pkg"; then
|
|
258
|
+
match="$entry"
|
|
259
|
+
count=$((count + 1))
|
|
260
|
+
fi
|
|
261
|
+
done
|
|
262
|
+
if [ "$count" -eq 1 ]; then
|
|
263
|
+
printf '%s' "$match"
|
|
264
|
+
return 0
|
|
265
|
+
fi
|
|
266
|
+
fi
|
|
267
|
+
# 3) The profile name the host asked for.
|
|
268
|
+
if [ -n "${DSH_PROFILE:-}" ]; then
|
|
269
|
+
full_path "$root/$DSH_PROFILE"
|
|
270
|
+
return 0
|
|
271
|
+
fi
|
|
272
|
+
# 4) The default name.
|
|
273
|
+
full_path "$root/desktop"
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# The JSON bits (node when it is there, a light check otherwise)
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
# Does this package.json parse?
|
|
281
|
+
json_parses() {
|
|
282
|
+
file="$1"
|
|
283
|
+
[ -f "$file" ] || return 1
|
|
284
|
+
if [ -n "$NODE_BIN" ]; then
|
|
285
|
+
"$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
|
|
286
|
+
return $?
|
|
287
|
+
fi
|
|
288
|
+
head -c 1 "$file" | grep -q '{' || return 1
|
|
289
|
+
return 0
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
# Does this package.json carry a dsh.profile section?
|
|
293
|
+
has_profile_section() {
|
|
294
|
+
file="$1"
|
|
295
|
+
[ -f "$file" ] || return 1
|
|
296
|
+
if [ -n "$NODE_BIN" ]; then
|
|
297
|
+
"$NODE_BIN" -e 'try { const d = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8").replace(/^\uFEFF/, "")); process.exit(d && d.dsh && d.dsh.profile ? 0 : 1) } catch (error) { process.exit(1) }' "$file" >/dev/null 2>&1
|
|
298
|
+
return $?
|
|
299
|
+
fi
|
|
300
|
+
grep -q '"dsh"' "$file" 2>/dev/null && grep -q '"profile"' "$file" 2>/dev/null
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# How many bundles does the profile declare?
|
|
304
|
+
bundles_count() {
|
|
305
|
+
file="$1"
|
|
306
|
+
if [ -n "$NODE_BIN" ]; then
|
|
307
|
+
"$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(String(b.length)); process.exit(b.length > 0 ? 0 : 1) } catch (error) { process.exit(1) }' "$file" 2>/dev/null
|
|
308
|
+
return $?
|
|
309
|
+
fi
|
|
310
|
+
count=$(sed -n 's/.*"bundles"[[:space:]]*:[[:space:]]*\[[^]]*\].*/x/p' "$file" | wc -l | tr -d ' ')
|
|
311
|
+
[ "$count" -gt 0 ] || return 1
|
|
312
|
+
printf '0'
|
|
313
|
+
return 0
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
# Does the profile declare this plugin?
|
|
317
|
+
declares_plugin() {
|
|
318
|
+
file="$1"
|
|
319
|
+
[ -f "$file" ] || return 1
|
|
320
|
+
if [ -n "$NODE_BIN" ]; then
|
|
321
|
+
"$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; process.exit(Array.isArray(b) && b.indexOf(process.argv[2]) >= 0 ? 0 : 1) } catch (error) { process.exit(1) }' "$file" "$PACKAGE_NAME" >/dev/null 2>&1
|
|
322
|
+
return $?
|
|
323
|
+
fi
|
|
324
|
+
grep -q "\"$PACKAGE_NAME\"" "$file" 2>/dev/null
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
# The version of the installed plugin (empty when it cannot be read).
|
|
328
|
+
plugin_version() {
|
|
329
|
+
file="$1"
|
|
330
|
+
[ -f "$file" ] || return 1
|
|
331
|
+
if [ -n "$NODE_BIN" ]; then
|
|
332
|
+
"$NODE_BIN" -e 'try { const d = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8").replace(/^\uFEFF/, "")); const v = d && d.version ? String(d.version).trim() : ""; if (!v) process.exit(1); process.stdout.write(v) } catch (error) { process.exit(1) }' "$file" 2>/dev/null
|
|
333
|
+
return $?
|
|
334
|
+
fi
|
|
335
|
+
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$file" | head -n 1
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
# The snapshot
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
manifest_value() {
|
|
343
|
+
file="$1"
|
|
344
|
+
key="$2"
|
|
345
|
+
sed -n 's/.*"'"$key"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$file" | head -n 1
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
manifest_number() {
|
|
349
|
+
file="$1"
|
|
350
|
+
key="$2"
|
|
351
|
+
sed -n 's/.*"'"$key"'"[[:space:]]*:[[:space:]]*\([0-9]\{1,\}\).*/\1/p' "$file" | head -n 1
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
has_manifest() {
|
|
355
|
+
[ -f "$1/manifest.json" ]
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
# The newest snapshot that carries a manifest.
|
|
359
|
+
select_snapshot() {
|
|
360
|
+
root="$1"
|
|
361
|
+
if [ ! -d "$root" ]; then
|
|
362
|
+
fail "$(fmt "$(t 'restore.snapshotDirMissing' 'the snapshot directory cannot be found: {0}')" "$root")"
|
|
363
|
+
fi
|
|
364
|
+
best=''
|
|
365
|
+
best_at=0
|
|
366
|
+
best_name=''
|
|
367
|
+
for entry in "$root"/*; do
|
|
368
|
+
[ -d "$entry" ] || continue
|
|
369
|
+
has_manifest "$entry" || continue
|
|
370
|
+
at=$(manifest_number "$entry/manifest.json" 'at')
|
|
371
|
+
[ -n "$at" ] || at=0
|
|
372
|
+
name=$(basename "$entry")
|
|
373
|
+
if [ -z "$best" ] || [ "$at" -gt "$best_at" ] || { [ "$at" -eq "$best_at" ] && [ "$name" \> "$best_name" ]; }; then
|
|
374
|
+
best="$entry"
|
|
375
|
+
best_at="$at"
|
|
376
|
+
best_name="$name"
|
|
377
|
+
fi
|
|
378
|
+
done
|
|
379
|
+
if [ -z "$best" ]; then
|
|
380
|
+
fail "$(fmt "$(t 'restore.snapshotNone' 'there is no snapshot with a manifest under {0}')" "$root")"
|
|
381
|
+
fi
|
|
382
|
+
printf '%s' "$best"
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
# ---------------------------------------------------------------------------
|
|
386
|
+
# Start
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
if [ ! -f "$STRINGS_FILE" ]; then
|
|
390
|
+
strings_error=$(fmt "$(t 'common.stringsMissingEn' 'the Chinese strings file is missing: {0} - falling back to English messages')" "$STRINGS_FILE")
|
|
391
|
+
printf 'WARNING: %s\n' "$strings_error"
|
|
392
|
+
fi
|
|
393
|
+
|
|
394
|
+
say "$(t 'restore.title' 'dsh-autorestart - profile snapshot restorer')"
|
|
395
|
+
|
|
396
|
+
explicit_profile=0
|
|
397
|
+
if [ -n "$PROFILE_DIR" ]; then
|
|
398
|
+
explicit_profile=1
|
|
399
|
+
target_profile=$(full_path "$PROFILE_DIR")
|
|
400
|
+
else
|
|
401
|
+
target_profile=$(default_profile_dir)
|
|
402
|
+
fi
|
|
403
|
+
if [ -z "$target_profile" ]; then
|
|
404
|
+
fail "$(t 'restore.noProfileDir' 'the profile directory to restore into cannot be found (use --profile to say which one).')"
|
|
405
|
+
fi
|
|
406
|
+
|
|
407
|
+
# --- Gate 1: is the path itself safe? ---
|
|
408
|
+
forbidden=$(forbidden_reason "$target_profile")
|
|
409
|
+
if [ -n "$forbidden" ]; then
|
|
410
|
+
fail "$forbidden$(fmt "$(t 'restore.targetSuffix' 'target: {0}')" "$target_profile")"
|
|
411
|
+
fi
|
|
412
|
+
|
|
413
|
+
if [ "$explicit_profile" -eq 0 ]; then
|
|
414
|
+
profiles=$(profiles_root)
|
|
415
|
+
if ! is_inside "$target_profile" "$profiles"; then
|
|
416
|
+
fail "$(fmt "$(t 'restore.onlyProfiles' 'only a profile under {0} may be restored (target: {1})')" "$profiles" "$target_profile")"
|
|
417
|
+
fi
|
|
418
|
+
fi
|
|
419
|
+
say "$(fmt "$(t 'restore.target' 'target profile: {0}')" "$target_profile")"
|
|
420
|
+
|
|
421
|
+
# --- Gate 2: the target profile directory has to exist ---
|
|
422
|
+
if [ ! -d "$target_profile" ]; then
|
|
423
|
+
fail "$(fmt "$(t 'restore.missingDir' 'the target profile directory does not exist: {0} (the restore needs that directory to be there; the script does not create a profile directory for it)')" "$target_profile")"
|
|
424
|
+
fi
|
|
425
|
+
|
|
426
|
+
# --- Gate 3: does this directory still look like a DSH profile? ---
|
|
427
|
+
pkg_path="$target_profile/package.json"
|
|
428
|
+
fingerprint='not-a-profile'
|
|
429
|
+
if [ -f "$pkg_path" ]; then
|
|
430
|
+
if has_profile_section "$pkg_path"; then
|
|
431
|
+
fingerprint='profile'
|
|
432
|
+
fingerprint_reason=$(t 'restore.fingerprintProfile' 'the profile package.json carries a dsh.profile section')
|
|
433
|
+
elif [ -f "$target_profile/cordis.patch.yml" ] || [ -d "$target_profile/node_modules" ]; then
|
|
434
|
+
fingerprint='broken-profile'
|
|
435
|
+
fingerprint_reason=$(t 'restore.fingerprintBrokenNoDsh' 'the profile is broken - its package.json does not identify a profile - but the profile files are still there')
|
|
436
|
+
else
|
|
437
|
+
fingerprint_reason=$(t 'restore.fingerprintNotDshProfile' 'the package.json does not carry a dsh.profile section, and nothing else marks this as a profile')
|
|
438
|
+
fi
|
|
439
|
+
else
|
|
440
|
+
if [ -f "$target_profile/cordis.patch.yml" ] || [ -d "$target_profile/node_modules" ]; then
|
|
441
|
+
fingerprint='broken-profile'
|
|
442
|
+
fingerprint_reason=$(t 'restore.fingerprintBrokenNoPkg' 'the profile is broken - its package.json is missing - but the profile files are still there')
|
|
443
|
+
else
|
|
444
|
+
fingerprint_reason=$(t 'restore.fingerprintNotProfile' 'there is no package.json here - this does not look like a DSH profile')
|
|
445
|
+
fi
|
|
446
|
+
fi
|
|
447
|
+
if [ "$fingerprint" = 'not-a-profile' ]; then
|
|
448
|
+
fail "$fingerprint_reason$(fmt "$(t 'restore.targetSuffix' 'target: {0}')" "$target_profile")$(t 'restore.notProfileSuffix' '. Refusing to act - a directory that is not a profile must not be damaged.')"
|
|
449
|
+
fi
|
|
450
|
+
if [ "$fingerprint" = 'broken-profile' ]; then
|
|
451
|
+
say "$(fmt "$(t 'restore.brokenNotice' 'this profile is already broken: {0}')" "$fingerprint_reason")"
|
|
452
|
+
fi
|
|
453
|
+
|
|
454
|
+
# --- Find the snapshot ---
|
|
455
|
+
if [ -n "$SNAPSHOT" ]; then
|
|
456
|
+
snapshot_dir=$(full_path "$SNAPSHOT")
|
|
457
|
+
if [ ! -d "$snapshot_dir" ]; then
|
|
458
|
+
fail "$(fmt "$(t 'restore.snapshotMissing' 'this snapshot cannot be found: {0}')" "$snapshot_dir")"
|
|
459
|
+
fi
|
|
460
|
+
if ! has_manifest "$snapshot_dir"; then
|
|
461
|
+
fail "$(fmt "$(t 'restore.snapshotNoManifest' 'this snapshot has no manifest.json, so it is not a snapshot: {0}')" "$snapshot_dir")"
|
|
462
|
+
fi
|
|
463
|
+
else
|
|
464
|
+
backups=$(backups_root)
|
|
465
|
+
say "$(fmt "$(t 'restore.backupsDir' 'snapshot directory: {0}')" "$backups")"
|
|
466
|
+
snapshot_dir=$(select_snapshot "$backups")
|
|
467
|
+
fi
|
|
468
|
+
snapshot_name=$(basename "$snapshot_dir")
|
|
469
|
+
manifest="$snapshot_dir/manifest.json"
|
|
470
|
+
|
|
471
|
+
# --- Gate 4: does the snapshot hold a real profile? ---
|
|
472
|
+
snapshot_pkg="$snapshot_dir/config/package.json"
|
|
473
|
+
if [ ! -f "$snapshot_pkg" ]; then
|
|
474
|
+
fail "$(t 'restore.snapshotNoConfigPkg' 'this snapshot has no config/package.json')$(fmt "$(t 'restore.snapshotSuffix' '{0} - snapshot: {1}')" '' "$snapshot_dir")"
|
|
475
|
+
fi
|
|
476
|
+
if ! json_parses "$snapshot_pkg"; then
|
|
477
|
+
fail "$(t 'restore.snapshotBadConfigPkg' 'the package.json of this snapshot is not valid JSON')$(fmt "$(t 'restore.snapshotSuffix' '{0} - snapshot: {1}')" '' "$snapshot_dir")"
|
|
478
|
+
fi
|
|
479
|
+
if ! has_profile_section "$snapshot_pkg"; then
|
|
480
|
+
fail "$(t 'restore.snapshotNoDshProfile' 'the package.json of this snapshot has no dsh.profile')$(fmt "$(t 'restore.snapshotSuffix' '{0} - snapshot: {1}')" '' "$snapshot_dir")"
|
|
481
|
+
fi
|
|
482
|
+
|
|
483
|
+
described=$(manifest_value "$manifest" 'atLocal')
|
|
484
|
+
if [ -z "$described" ]; then
|
|
485
|
+
described=$(t 'restore.snapshotUnknownTime' 'an unknown time')
|
|
486
|
+
else
|
|
487
|
+
described=$(printf '%s' "$described" | tr 'T' ' ' | cut -c1-16)
|
|
488
|
+
fi
|
|
489
|
+
include_plugins='false'
|
|
490
|
+
if grep -q '"includePlugins"[[:space:]]*:[[:space:]]*true' "$manifest" 2>/dev/null; then
|
|
491
|
+
include_plugins='true'
|
|
492
|
+
fi
|
|
493
|
+
|
|
494
|
+
say "$(fmt "$(t 'restore.usingSnapshot' 'snapshot to use: {0} ({1})')" "$snapshot_name" "$described")"
|
|
495
|
+
|
|
496
|
+
# --- The name of the evidence directory for the old profile ---
|
|
497
|
+
stamp=$(date -u +%Y%m%d-%H%M%S)
|
|
498
|
+
broken_dir="$target_profile.broken-$stamp"
|
|
499
|
+
counter=2
|
|
500
|
+
while [ -e "$broken_dir" ]; do
|
|
501
|
+
broken_dir="$target_profile.broken-$stamp-$counter"
|
|
502
|
+
counter=$((counter + 1))
|
|
503
|
+
done
|
|
504
|
+
|
|
505
|
+
# --- Dry run ---
|
|
506
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
507
|
+
say "$(fmt "$(t 'restore.dryRunMove' '[dry run] the current profile would be moved to: {0}')" "$broken_dir")"
|
|
508
|
+
say "$(fmt "$(t 'restore.dryRunCopy' '[dry run] the configuration and the plugins of {0} would be copied back to: {1}')" "$snapshot_name" "$target_profile")"
|
|
509
|
+
say "$(fmt "$(t 'restore.dryRunVerify' '[dry run] package.json / dsh.profile.bundles / the version of {0} would be read back.')" "$PACKAGE_NAME")"
|
|
510
|
+
say "$(t 'restore.dryRunNoChange' '[dry run] no file was changed.')"
|
|
511
|
+
exit 0
|
|
512
|
+
fi
|
|
513
|
+
|
|
514
|
+
# --- Move the current profile away (never overwrites, never deletes) ---
|
|
515
|
+
if [ -d "$target_profile" ]; then
|
|
516
|
+
if ! mv -- "$target_profile" "$broken_dir"; then
|
|
517
|
+
fail "$(fmt "$(t 'restore.moveFailed' 'moving the current profile away failed (nothing was changed): {0}')" "$broken_dir")"
|
|
518
|
+
fi
|
|
519
|
+
say "$(fmt "$(t 'restore.moved' 'the old one has been moved to: {0}')" "$broken_dir")"
|
|
520
|
+
else
|
|
521
|
+
say "$(t 'restore.noProfileBefore' 'the profile directory was not there, it is created.')"
|
|
522
|
+
fi
|
|
523
|
+
|
|
524
|
+
# --- Copy it back ---
|
|
525
|
+
mkdir -p -- "$target_profile"
|
|
526
|
+
if [ -d "$snapshot_dir/config" ]; then
|
|
527
|
+
for item in "$snapshot_dir"/config/*; do
|
|
528
|
+
[ -e "$item" ] || continue
|
|
529
|
+
if ! cp -R -- "$item" "$target_profile/"; then
|
|
530
|
+
fail "$(fmt "$(t 'restore.copyFailed' 'copying the snapshot back failed (the old one is still at {0}): {1}')" "$broken_dir" "$item")"
|
|
531
|
+
fi
|
|
532
|
+
done
|
|
533
|
+
fi
|
|
534
|
+
if [ -d "$snapshot_dir/node_modules" ]; then
|
|
535
|
+
if ! cp -R -- "$snapshot_dir/node_modules" "$target_profile/node_modules"; then
|
|
536
|
+
fail "$(fmt "$(t 'restore.copyFailed' 'copying the snapshot back failed (the old one is still at {0}): {1}')" "$broken_dir" 'node_modules')"
|
|
537
|
+
fi
|
|
538
|
+
fi
|
|
539
|
+
|
|
540
|
+
# --- Read the result back ---
|
|
541
|
+
failed=''
|
|
542
|
+
check_ok() {
|
|
543
|
+
say "$(fmt "$(t 'restore.checkOk' ' [ok] {0} - {1}')" "$1" "${2:-}")"
|
|
544
|
+
}
|
|
545
|
+
check_skipped() {
|
|
546
|
+
say "$(fmt "$(t 'restore.checkSkipped' ' [skipped] {0} - {1}')" "$1" "${2:-}")"
|
|
547
|
+
}
|
|
548
|
+
check_bad() {
|
|
549
|
+
say "$(fmt "$(t 'restore.checkBad' ' [bad] {0} - {1}')" "$1" "${2:-}")"
|
|
550
|
+
failed="$failed, $1"
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
pkg_label=$(t 'restore.verifyPkgLabel' 'package.json can be parsed')
|
|
554
|
+
if [ ! -f "$target_profile/package.json" ]; then
|
|
555
|
+
check_bad "$pkg_label" "$(t 'restore.verifyPkgMissing' 'package.json is missing')"
|
|
556
|
+
elif json_parses "$target_profile/package.json"; then
|
|
557
|
+
check_ok "$pkg_label" ''
|
|
558
|
+
else
|
|
559
|
+
check_bad "$pkg_label" "$(t 'restore.verifyPkgBad' 'not valid JSON')"
|
|
560
|
+
fi
|
|
561
|
+
|
|
562
|
+
bundles_label=$(t 'restore.verifyBundlesLabel' 'dsh.profile.bundles has content')
|
|
563
|
+
count=$(bundles_count "$target_profile/package.json" || printf '0')
|
|
564
|
+
if [ "$count" -gt 0 ] 2>/dev/null; then
|
|
565
|
+
check_ok "$bundles_label" "$(fmt "$(t 'restore.verifyBundlesCount' '{0} in total')" "$count")"
|
|
566
|
+
else
|
|
567
|
+
check_bad "$bundles_label" "$(t 'restore.verifyBundlesEmpty' 'there is no dsh.profile.bundles, or it is empty')"
|
|
568
|
+
fi
|
|
569
|
+
|
|
570
|
+
plugin_label=$(fmt "$(t 'restore.verifyPluginLabel' 'the version of {0} can be read')" "$PACKAGE_NAME")
|
|
571
|
+
if ! declares_plugin "$target_profile/package.json"; then
|
|
572
|
+
check_skipped "$plugin_label" "$(fmt "$(t 'restore.verifyPluginNotDeclared' 'this profile does not list {0} (there is nothing to read)')" "$PACKAGE_NAME")"
|
|
573
|
+
else
|
|
574
|
+
version=$(plugin_version "$target_profile/node_modules/$PACKAGE_NAME/package.json" || printf '')
|
|
575
|
+
if [ -n "$version" ]; then
|
|
576
|
+
check_ok "$plugin_label" "$version"
|
|
577
|
+
else
|
|
578
|
+
check_bad "$plugin_label" "$(t 'restore.verifyPluginNoVersion' 'the version number cannot be read')"
|
|
579
|
+
fi
|
|
580
|
+
fi
|
|
581
|
+
|
|
582
|
+
if [ -n "$failed" ]; then
|
|
583
|
+
fail "$(fmt "$(t 'restore.verifyFailed' 'the restored profile did not pass the check ({0}).')" "${failed#, }")"
|
|
584
|
+
fi
|
|
585
|
+
|
|
586
|
+
# --- One sentence a human can act on ---
|
|
587
|
+
plugin_note=$(t 'restore.noteWithPlugins' '(with the plugins)')
|
|
588
|
+
if [ "$include_plugins" != 'true' ]; then
|
|
589
|
+
plugin_note=$(t 'restore.noteNoPlugins' '(configuration files only, no plugins)')
|
|
590
|
+
fi
|
|
591
|
+
say "$(fmt "$(t 'restore.done' 'restored to the snapshot of {0}{1}; you can open DSH again now.')" "$described" "$plugin_note")"
|
|
592
|
+
say "$(fmt "$(t 'restore.brokenLeft' 'the old one was left at: {0} (delete it yourself once you are sure)')" "$broken_dir")"
|
|
593
|
+
exit 0
|