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,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