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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.8.1 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
**修复打包遗漏**:0.8.0 的 npm 包按 `package.json` 的 `files` 白名单打包,而那份白名单只收了 `scripts/*.ps1` 与 `scripts/*.cmd`——**三个 POSIX 自救脚本(`restore-profile.sh` / `emergency-disable.sh` / `drill-rescue.sh`)、`scripts/README.md`、`scripts/rescue-strings.zh.json` 全都没进包**。Linux / WSL 用户装完 0.8.0 后,文档让他去双击/运行 `scripts/restore-profile.sh`,那个文件却不存在。
|
|
6
|
+
|
|
7
|
+
现在把白名单补齐(`.sh`、`.mjs`、`scripts/README.md`、`scripts/rescue-strings.zh.json`、`CHANGELOG.md`),并用 `npm pack --dry-run` 逐项确认:**54 → 61 个文件**,三个 `.sh` 都在。单元测试 740 项仍全绿。
|
|
8
|
+
|
|
9
|
+
0.8.0 的内容与行为没有其它变化;只是那个包缺文件。
|
|
10
|
+
|
|
11
|
+
## 0.8.0 — 2026-10-29
|
|
12
|
+
|
|
13
|
+
这一版把"出事之后怎么办"做成真正的闭环:**起不来就自动还原、再拉起、留下报告**,并且桌面端与各种 Web 托管端(Windows / Linux / WSL)都覆盖。
|
|
14
|
+
|
|
15
|
+
### 新增
|
|
16
|
+
|
|
17
|
+
- **看门狗(watchdog v2)**:重启前登记一个一次性监护者;新实例按约定报到就算过关,否则**先复查一次**(防误判),确认真的没起来才**自动还原重启前的快照 → 再自动拉起一次 → 把结果写进报告**。最多还原一次、拉起一次,绝不死循环;回滚前先探活,绝不双开。
|
|
18
|
+
- **三档调度,跨环境都可用**:Windows 一次性计划任务 → Linux/WSL 的 systemd-run → 自建后台进程(setsid nohup)。都没有时**明确告知"没有自动回滚"**,绝不假装有保护。
|
|
19
|
+
- **同一时刻只有一个监护者**:v2 可用时不启动旧的 v1;v2 不可用时才回落 v1;报告里记 arbiter 字段,出事能一眼看出当时是谁在监护。
|
|
20
|
+
- **POSIX 版自救包**:restore-profile.sh / emergency-disable.sh / drill-rescue.sh(与 Windows 的 .cmd/.ps1 同一套规则:先把坏的那份挪走留证、还原后回读校验、路径闸门、不自动重启)。
|
|
21
|
+
- **设置页「出事了怎么办」**:按平台显示对应的救命脚本绝对路径,可复制、可打开文件夹;Windows 还能一键在桌面放一个快捷方式(只有你点才创建)。
|
|
22
|
+
- **装上就告诉你有没有兜底**:第一次启用时真的建一个空计划任务验证能力(建 → 回读校验 → 删 → 再确认删掉),不能用就直接说清"会丢失:重启后起不来时不会自动恢复"。
|
|
23
|
+
- **平台能力矩阵**:docs/platform-matrix.md 列出 13 条能力 × 5 个平台,逐格标清能用、有前提(并写明用户会看到什么)与做不到。
|
|
24
|
+
|
|
25
|
+
### 修复
|
|
26
|
+
|
|
27
|
+
- **同一次重启出现两个看门狗会互相废掉**:旧的 PowerShell 看门狗只还原不拉起,若它先抢到锁,新的看门狗会放弃 → 结果是"配置回滚了却没有任何东西在运行"。已改成仲裁者,同一时刻只允许一个。
|
|
28
|
+
- **集成测试会往真实机器注册计划任务**(留下空壳任务)。测试改为注入,不再碰系统。
|
|
29
|
+
- 计划任务的触发时间曾写成 UTC,导致**任务会晚 8 小时才触发**;改为本地时间。
|
|
30
|
+
- 看门狗里一个忘记 await 的探活判断让 Promise 恒为真,看门狗每次都"放弃";已修并加回归用例。
|
|
31
|
+
|
|
32
|
+
### 文档
|
|
33
|
+
|
|
34
|
+
- 讲清**关机倒数会关掉哪台机器**:Windows 桌面 / Windows web → 关这台 Windows;**WSL 里的 web → 关掉整台 Windows**(不是关 WSL);容器里没有可靠的关机方式(界面会这么说)。
|
|
35
|
+
- 自救包补齐"Windows 用哪个、Linux/WSL 用哪个",并写明 restore-profile 与 emergency-disable 的分工与选择口诀。
|
|
36
|
+
- 记录写 POSIX 脚本踩过的三个坑(dash 数单引号里的括号、双引号里的 sed 转义被吃、嵌套 JSON 平铺 grep 找不到)。
|
|
37
|
+
|
|
38
|
+
### 验证
|
|
39
|
+
|
|
40
|
+
- 单元测试 **740 项全绿**(node --test plugins/dsh-autorestart/test/*.test.mjs)。
|
|
41
|
+
- 看门狗演练(三档都真触发)+ Windows 自救演练 + **WSL 里的 POSIX 自救演练** 全部 exit 0。
|
|
42
|
+
- 全部脚本字节级纯 ASCII(rescue-strings.zh.json 无 BOM),机器上无残留计划任务。
|
|
43
|
+
- 未真关电:POSIX 的关机链路只验证到"计划层"(用户决定不为一条常识证据断开会话),矩阵里如实标注。
|
|
44
|
+
|
|
45
|
+
## 0.7.0 — 2026-10-29
|
|
46
|
+
|
|
47
|
+
这一版的主题是**认得出自己在什么环境里、并且永远不双开**,外加一个用户真能点到的问题反馈入口。
|
package/README.md
CHANGED
package/docs/README.zh.md
CHANGED
package/index.js
CHANGED
|
@@ -374,7 +374,7 @@ function snapGraceChoice(value, fallback = DEFAULT_RESTART_GRACE_MS) {
|
|
|
374
374
|
* `pluginVersion()`), but a bundled installation may not ship that file — so
|
|
375
375
|
* the constant below is the last resort. A test keeps the two in sync.
|
|
376
376
|
*/
|
|
377
|
-
const PLUGIN_VERSION_FALLBACK = '0.8.
|
|
377
|
+
const PLUGIN_VERSION_FALLBACK = '0.8.1'
|
|
378
378
|
|
|
379
379
|
/**
|
|
380
380
|
* The version of the graceful-shutdown FEATURE, not the version of the package.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-autorestart",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DeepSeek Harness plugin: restart without losing work. Waits until every conversation is idle, restarts the host, then automatically resumes the interrupted sessions. AI-generated, not human-reviewed: use with caution.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,12 +34,17 @@
|
|
|
34
34
|
"supervisor/supervisor.js",
|
|
35
35
|
"scripts/*.ps1",
|
|
36
36
|
"scripts/*.cmd",
|
|
37
|
+
"scripts/*.sh",
|
|
38
|
+
"scripts/*.mjs",
|
|
39
|
+
"scripts/README.md",
|
|
40
|
+
"scripts/rescue-strings.zh.json",
|
|
37
41
|
"cordis.patch.yml",
|
|
38
42
|
"locale/*.json",
|
|
39
43
|
"icon.svg",
|
|
40
44
|
"assets/*.png",
|
|
41
45
|
"screenshots.json",
|
|
42
46
|
"README.md",
|
|
47
|
+
"CHANGELOG.md",
|
|
43
48
|
"docs/*.md"
|
|
44
49
|
],
|
|
45
50
|
"dsh": {
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# The rescue drill (POSIX): break a profile on purpose, put it back, prove it.
|
|
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 assertions as drill-rescue.ps1, in a directory of its own under the
|
|
13
|
+
# temp directory. Nothing here touches the profile of a running DSH.
|
|
14
|
+
#
|
|
15
|
+
# The wording comes from rescue-strings.zh.json; the English fallbacks keep
|
|
16
|
+
# the script working without it.
|
|
17
|
+
set -eu
|
|
18
|
+
|
|
19
|
+
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
20
|
+
RESTORE="$SCRIPT_DIR/restore-profile.sh"
|
|
21
|
+
DISABLE="$SCRIPT_DIR/emergency-disable.sh"
|
|
22
|
+
STRINGS_FILE="$SCRIPT_DIR/rescue-strings.zh.json"
|
|
23
|
+
PACKAGE_NAME='dsh-autorestart'
|
|
24
|
+
|
|
25
|
+
DRILL_DIR="${TMPDIR:-/tmp}/dsh-rescue-drill-$$"
|
|
26
|
+
FAILED=0
|
|
27
|
+
|
|
28
|
+
usage() {
|
|
29
|
+
printf 'Usage: %s [drill-directory]\n' "$0"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
while [ $# -gt 0 ]; do
|
|
33
|
+
case "$1" in
|
|
34
|
+
--help|-h) usage; exit 0 ;;
|
|
35
|
+
*) DRILL_DIR="$1" ;;
|
|
36
|
+
esac
|
|
37
|
+
shift
|
|
38
|
+
done
|
|
39
|
+
|
|
40
|
+
NODE_BIN=''
|
|
41
|
+
if command -v node >/dev/null 2>&1; then
|
|
42
|
+
NODE_BIN=$(command -v node)
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# t <key> <english fallback>
|
|
46
|
+
#
|
|
47
|
+
# The key is a PATH into the strings file ("drill.passed"), because that file
|
|
48
|
+
# is nested. Node reads it (it is on every machine that runs DSH); without
|
|
49
|
+
# node every message falls back to English.
|
|
50
|
+
t() {
|
|
51
|
+
key="$1"
|
|
52
|
+
fallback="$2"
|
|
53
|
+
if [ -n "$NODE_BIN" ] && [ -f "$STRINGS_FILE" ]; then
|
|
54
|
+
value=$("$NODE_BIN" -e '
|
|
55
|
+
const fs = require("fs");
|
|
56
|
+
try {
|
|
57
|
+
const doc = JSON.parse(fs.readFileSync(process.argv[1], "utf8").replace(/^\uFEFF/, ""));
|
|
58
|
+
let value = doc;
|
|
59
|
+
for (const part of process.argv[2].split(".")) value = value == null ? undefined : value[part];
|
|
60
|
+
if (typeof value !== "string" || value === "") process.exit(1);
|
|
61
|
+
process.stdout.write(value);
|
|
62
|
+
} catch (error) { process.exit(1); }
|
|
63
|
+
' "$STRINGS_FILE" "$key" 2>/dev/null) || value=''
|
|
64
|
+
if [ -n "$value" ]; then
|
|
65
|
+
printf '%s' "$value"
|
|
66
|
+
return 0
|
|
67
|
+
fi
|
|
68
|
+
fi
|
|
69
|
+
printf '%s' "$fallback"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# fmt <text> <value> ...
|
|
73
|
+
#
|
|
74
|
+
# Node does the replacement when it is there: the Chinese wording is UTF-8 and
|
|
75
|
+
# a byte-wise sed would mangle it. Without node only the English fallbacks are
|
|
76
|
+
# used, and those are plain ASCII.
|
|
77
|
+
fmt() {
|
|
78
|
+
text="$1"
|
|
79
|
+
shift
|
|
80
|
+
if [ -n "$NODE_BIN" ]; then
|
|
81
|
+
if "$NODE_BIN" -e '
|
|
82
|
+
let text = process.argv[1];
|
|
83
|
+
process.argv.slice(2).forEach((value, index) => { text = text.split("{" + index + "}").join(value); });
|
|
84
|
+
process.stdout.write(text);
|
|
85
|
+
' "$text" "$@" 2>/dev/null; then
|
|
86
|
+
return 0
|
|
87
|
+
fi
|
|
88
|
+
fi
|
|
89
|
+
index=0
|
|
90
|
+
for value in "$@"; do
|
|
91
|
+
safe=$(printf '%s' "$value" | sed 's/[&|\\]/\\&/g')
|
|
92
|
+
text=$(printf '%s' "$text" | sed "s|{$index}|$safe|g")
|
|
93
|
+
index=$((index + 1))
|
|
94
|
+
done
|
|
95
|
+
printf '%s' "$text"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
say() {
|
|
99
|
+
printf '%s\n' "$1"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
assert_pass() {
|
|
103
|
+
say "$(fmt "$(t 'drill.assertPass' ' [pass] {0}')" "$1")"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
assert_fail() {
|
|
107
|
+
FAILED=$((FAILED + 1))
|
|
108
|
+
say "$(fmt "$(t 'drill.assertFail' ' [FAIL] {0} -- {1}')" "$1" "$2")"
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# The keywords a refusal has to carry (the same file the Windows drill uses).
|
|
112
|
+
keyword_list() {
|
|
113
|
+
key="$1"
|
|
114
|
+
if [ -n "$NODE_BIN" ] && [ -f "$STRINGS_FILE" ]; then
|
|
115
|
+
"$NODE_BIN" -e '
|
|
116
|
+
const fs = require("fs");
|
|
117
|
+
try {
|
|
118
|
+
const doc = JSON.parse(fs.readFileSync(process.argv[1], "utf8").replace(/^\uFEFF/, ""));
|
|
119
|
+
let value = doc;
|
|
120
|
+
for (const part of process.argv[2].split(".")) value = value == null ? undefined : value[part];
|
|
121
|
+
if (!Array.isArray(value)) process.exit(1);
|
|
122
|
+
process.stdout.write(value.join("\n"));
|
|
123
|
+
} catch (error) { process.exit(1); }
|
|
124
|
+
' "$STRINGS_FILE" "$key" 2>/dev/null
|
|
125
|
+
return $?
|
|
126
|
+
fi
|
|
127
|
+
printf ''
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# refused <name> <output> <exit code> <keyword key>
|
|
131
|
+
refused() {
|
|
132
|
+
name="$1"
|
|
133
|
+
output="$2"
|
|
134
|
+
code="$3"
|
|
135
|
+
key="$4"
|
|
136
|
+
if [ "$code" -eq 0 ]; then
|
|
137
|
+
assert_fail "$name" "$(fmt "$(t 'drill.refusedNotRefused' ' [FAIL] {0} -- the exit code is 0, the script did not refuse')" "$name")"
|
|
138
|
+
return
|
|
139
|
+
fi
|
|
140
|
+
found=''
|
|
141
|
+
for word in $(keyword_list "$key"); do
|
|
142
|
+
[ -n "$word" ] || continue
|
|
143
|
+
if printf '%s' "$output" | grep -q "$word"; then
|
|
144
|
+
found="$word"
|
|
145
|
+
break
|
|
146
|
+
fi
|
|
147
|
+
done
|
|
148
|
+
if [ -z "$found" ]; then
|
|
149
|
+
assert_fail "$name" "$(fmt "$(t 'drill.refusedNoReason' ' [FAIL] {0} -- the exit code is {1}, but the output has no reason')" "$name" "$code")"
|
|
150
|
+
return
|
|
151
|
+
fi
|
|
152
|
+
detail=$(fmt "$(t 'drill.refusedOk' ' [pass] {0} -- exit code {1}, the output says why')" "$name" "$code")
|
|
153
|
+
assert_pass "$name ($detail)"
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if [ ! -x "$RESTORE" ] && [ ! -f "$RESTORE" ]; then
|
|
157
|
+
say "$(fmt "$(t 'drill.noScript' 'the drill failed: {0} not found')" "$RESTORE")"
|
|
158
|
+
exit 1
|
|
159
|
+
fi
|
|
160
|
+
if [ -e "$DRILL_DIR" ]; then
|
|
161
|
+
say "$(fmt "$(t 'drill.dirOccupied' 'the drill failed: this drill directory is already there: {0}')" "$DRILL_DIR")"
|
|
162
|
+
exit 1
|
|
163
|
+
fi
|
|
164
|
+
|
|
165
|
+
say "$(fmt "$(t 'drill.dir' 'drill directory: {0}')" "$DRILL_DIR")"
|
|
166
|
+
mkdir -p -- "$DRILL_DIR"
|
|
167
|
+
|
|
168
|
+
PROFILE="$DRILL_DIR/profile"
|
|
169
|
+
STATE="$DRILL_DIR/state"
|
|
170
|
+
BACKUPS="$STATE/backups"
|
|
171
|
+
SNAPSHOT="$BACKUPS/20260101-000000"
|
|
172
|
+
mkdir -p -- "$PROFILE/node_modules/$PACKAGE_NAME" "$SNAPSHOT/config" "$SNAPSHOT/node_modules/$PACKAGE_NAME"
|
|
173
|
+
|
|
174
|
+
# --- the profile and the snapshot ---
|
|
175
|
+
cat > "$PROFILE/package.json" <<'JSON'
|
|
176
|
+
{
|
|
177
|
+
"name": "dsh-profile",
|
|
178
|
+
"dsh": {
|
|
179
|
+
"profile": {
|
|
180
|
+
"bundles": [
|
|
181
|
+
"dsh-autorestart"
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
JSON
|
|
187
|
+
cat > "$PROFILE/cordis.patch.yml" <<'YML'
|
|
188
|
+
# the profile configuration
|
|
189
|
+
YML
|
|
190
|
+
cat > "$PROFILE/node_modules/$PACKAGE_NAME/package.json" <<'JSON'
|
|
191
|
+
{
|
|
192
|
+
"name": "dsh-autorestart",
|
|
193
|
+
"version": "1.2.3"
|
|
194
|
+
}
|
|
195
|
+
JSON
|
|
196
|
+
cp "$PROFILE/package.json" "$SNAPSHOT/config/package.json"
|
|
197
|
+
cp "$PROFILE/cordis.patch.yml" "$SNAPSHOT/config/cordis.patch.yml"
|
|
198
|
+
cp "$PROFILE/node_modules/$PACKAGE_NAME/package.json" "$SNAPSHOT/node_modules/$PACKAGE_NAME/package.json"
|
|
199
|
+
cat > "$SNAPSHOT/manifest.json" <<'JSON'
|
|
200
|
+
{
|
|
201
|
+
"at": 1767225600000,
|
|
202
|
+
"atLocal": "2026-01-01T00:00:00",
|
|
203
|
+
"includePlugins": true
|
|
204
|
+
}
|
|
205
|
+
JSON
|
|
206
|
+
|
|
207
|
+
# A NEWER decoy without a manifest: it must be skipped.
|
|
208
|
+
DECOY="$BACKUPS/20260202-000000"
|
|
209
|
+
mkdir -p -- "$DECOY/config"
|
|
210
|
+
cp "$PROFILE/package.json" "$DECOY/config/package.json"
|
|
211
|
+
|
|
212
|
+
# --- break it on purpose ---
|
|
213
|
+
printf '{ this is not json' > "$PROFILE/package.json"
|
|
214
|
+
rm -rf -- "$PROFILE/node_modules/$PACKAGE_NAME"
|
|
215
|
+
|
|
216
|
+
say "$(t 'drill.broke' 'broken on purpose: package.json is invalid JSON, the plugin directory is gone.')"
|
|
217
|
+
|
|
218
|
+
# --- 1) the dry run changes nothing ---
|
|
219
|
+
set +e
|
|
220
|
+
DRY_OUT=$("$RESTORE" --dry-run --profile "$PROFILE" --state-dir "$STATE" 2>&1)
|
|
221
|
+
DRY_CODE=$?
|
|
222
|
+
set -e
|
|
223
|
+
DRY_MARK=$(keyword_list 'drill.keywords.dryRun' | head -n 1)
|
|
224
|
+
if [ "$DRY_CODE" -eq 0 ] && { [ -z "$DRY_MARK" ] || printf '%s' "$DRY_OUT" | grep -q "$DRY_MARK"; }; then
|
|
225
|
+
assert_pass "$(t 'drill.assertDryRunExit' 'the dry run exits with 0')"
|
|
226
|
+
else
|
|
227
|
+
assert_fail "$(t 'drill.assertDryRunExit' 'the dry run exits with 0')" "$DRY_OUT"
|
|
228
|
+
fi
|
|
229
|
+
if [ -z "$(find "$DRILL_DIR" -maxdepth 1 -name 'profile.broken-*' 2>/dev/null)" ]; then
|
|
230
|
+
assert_pass "$(t 'drill.assertDryRunNoBroken' 'the dry run leaves no broken directory')"
|
|
231
|
+
else
|
|
232
|
+
assert_fail "$(t 'drill.assertDryRunNoBroken' 'the dry run leaves no broken directory')" 'there is one'
|
|
233
|
+
fi
|
|
234
|
+
|
|
235
|
+
# --- 2) the real restore ---
|
|
236
|
+
set +e
|
|
237
|
+
RESTORE_OUT=$("$RESTORE" --profile "$PROFILE" --state-dir "$STATE" 2>&1)
|
|
238
|
+
RESTORE_CODE=$?
|
|
239
|
+
set -e
|
|
240
|
+
if [ "$RESTORE_CODE" -eq 0 ]; then
|
|
241
|
+
assert_pass "$(t 'drill.assertRestoreExit' 'the restore exits with 0')"
|
|
242
|
+
else
|
|
243
|
+
assert_fail "$(t 'drill.assertRestoreExit' 'the restore exits with 0')" "$RESTORE_OUT"
|
|
244
|
+
fi
|
|
245
|
+
|
|
246
|
+
# --- the assertions about the result ---
|
|
247
|
+
if printf '%s' "$RESTORE_OUT" | LC_ALL=C grep -q '[^ -~]'; then
|
|
248
|
+
assert_pass "$(t 'drill.assertChineseOutput' 'the output of the restore script carries Chinese (the wording comes from the JSON)')"
|
|
249
|
+
else
|
|
250
|
+
assert_fail "$(t 'drill.assertChineseOutput' 'the output of the restore script carries Chinese (the wording comes from the JSON)')" 'no non-ASCII byte in the output'
|
|
251
|
+
fi
|
|
252
|
+
|
|
253
|
+
if [ -n "$NODE_BIN" ] && "$NODE_BIN" -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$PROFILE/package.json" >/dev/null 2>&1; then
|
|
254
|
+
assert_pass "$(t 'drill.assertParses' 'after the restore package.json can be parsed')"
|
|
255
|
+
else
|
|
256
|
+
assert_fail "$(t 'drill.assertParses' 'after the restore package.json can be parsed')" 'it does not parse'
|
|
257
|
+
fi
|
|
258
|
+
|
|
259
|
+
if grep -q "$PACKAGE_NAME" "$PROFILE/package.json" 2>/dev/null; then
|
|
260
|
+
assert_pass "$(t 'drill.assertUsedSnapshot' 'the snapshot with the manifest was used (the newer decoy was skipped)')"
|
|
261
|
+
else
|
|
262
|
+
assert_fail "$(t 'drill.assertUsedSnapshot' 'the snapshot with the manifest was used (the newer decoy was skipped)')" 'the plugin is not in the bundles'
|
|
263
|
+
fi
|
|
264
|
+
|
|
265
|
+
if grep -q '"bundles"' "$PROFILE/package.json" 2>/dev/null; then
|
|
266
|
+
assert_pass "$(t 'drill.assertBundles' 'after the restore dsh.profile.bundles has content')"
|
|
267
|
+
else
|
|
268
|
+
assert_fail "$(t 'drill.assertBundles' 'after the restore dsh.profile.bundles has content')" 'there is no bundles'
|
|
269
|
+
fi
|
|
270
|
+
|
|
271
|
+
VERSION=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PROFILE/node_modules/$PACKAGE_NAME/package.json" 2>/dev/null | head -n 1)
|
|
272
|
+
if [ -n "$VERSION" ]; then
|
|
273
|
+
assert_pass "$(t 'drill.assertVersionReadable' 'after the restore the version of the plugin can be read')"
|
|
274
|
+
else
|
|
275
|
+
assert_fail "$(t 'drill.assertVersionReadable' 'after the restore the version of the plugin can be read')" 'there is no version'
|
|
276
|
+
fi
|
|
277
|
+
if [ "$VERSION" = '1.2.3' ]; then
|
|
278
|
+
assert_pass "$(t 'drill.assertVersionSame' 'the version is the one of the snapshot')"
|
|
279
|
+
else
|
|
280
|
+
assert_fail "$(t 'drill.assertVersionSame' 'the version is the one of the snapshot')" "got '$VERSION'"
|
|
281
|
+
fi
|
|
282
|
+
|
|
283
|
+
BROKEN=$(find "$DRILL_DIR" -maxdepth 1 -name 'profile.broken-*' 2>/dev/null | head -n 1)
|
|
284
|
+
if [ -n "$BROKEN" ]; then
|
|
285
|
+
assert_pass "$(t 'drill.assertBrokenExists' 'the broken evidence directory is there')"
|
|
286
|
+
if grep -q 'this is not json' "$BROKEN/package.json" 2>/dev/null; then
|
|
287
|
+
assert_pass "$(t 'drill.assertBrokenKeeps' 'the broken directory keeps the broken version')"
|
|
288
|
+
else
|
|
289
|
+
assert_fail "$(t 'drill.assertBrokenKeeps' 'the broken directory keeps the broken version')" 'it does not'
|
|
290
|
+
fi
|
|
291
|
+
else
|
|
292
|
+
assert_fail "$(t 'drill.assertBrokenExists' 'the broken evidence directory is there')" 'there is none'
|
|
293
|
+
fi
|
|
294
|
+
|
|
295
|
+
# --- the refusals: the exit code AND the reason in the output ---
|
|
296
|
+
NOT_A_PROFILE="$DRILL_DIR/not-a-profile"
|
|
297
|
+
mkdir -p -- "$NOT_A_PROFILE"
|
|
298
|
+
printf 'hello' > "$NOT_A_PROFILE/readme.txt"
|
|
299
|
+
set +e
|
|
300
|
+
OUT=$("$RESTORE" --profile "$NOT_A_PROFILE" --state-dir "$STATE" 2>&1)
|
|
301
|
+
CODE=$?
|
|
302
|
+
set -e
|
|
303
|
+
refused "$(t 'drill.assertNotProfileRefused' 'a directory that is not a profile is refused')" "$OUT" "$CODE" 'drill.keywords.notProfile'
|
|
304
|
+
if [ "$(cat "$NOT_A_PROFILE/readme.txt")" = 'hello' ]; then
|
|
305
|
+
assert_pass "$(t 'drill.assertNotProfileUntouched' 'the refused directory was not touched')"
|
|
306
|
+
else
|
|
307
|
+
assert_fail "$(t 'drill.assertNotProfileUntouched' 'the refused directory was not touched')" 'it changed'
|
|
308
|
+
fi
|
|
309
|
+
|
|
310
|
+
set +e
|
|
311
|
+
OUT=$("$RESTORE" --profile / --state-dir "$STATE" 2>&1)
|
|
312
|
+
CODE=$?
|
|
313
|
+
set -e
|
|
314
|
+
refused "$(t 'drill.assertDriveRootRefused' 'the filesystem root is refused')" "$OUT" "$CODE" 'drill.keywords.driveRoot'
|
|
315
|
+
|
|
316
|
+
SYSTEM_DIR=/etc
|
|
317
|
+
set +e
|
|
318
|
+
OUT=$("$RESTORE" --profile "$SYSTEM_DIR" --state-dir "$STATE" 2>&1)
|
|
319
|
+
CODE=$?
|
|
320
|
+
set -e
|
|
321
|
+
refused "$(fmt "$(t 'drill.assertSystemDirRefused' 'the system directory {0} is refused')" "$SYSTEM_DIR")" "$OUT" "$CODE" 'drill.keywords.systemDir'
|
|
322
|
+
if [ -d "$SYSTEM_DIR" ]; then
|
|
323
|
+
assert_pass "$(fmt "$(t 'drill.assertSystemDirIntact' 'the system directory {0} is untouched')" "$SYSTEM_DIR")"
|
|
324
|
+
else
|
|
325
|
+
assert_fail "$(fmt "$(t 'drill.assertSystemDirIntact' 'the system directory {0} is untouched')" "$SYSTEM_DIR")" 'it is gone'
|
|
326
|
+
fi
|
|
327
|
+
|
|
328
|
+
MISSING="$DRILL_DIR/does-not-exist"
|
|
329
|
+
set +e
|
|
330
|
+
OUT=$("$RESTORE" --profile "$MISSING" --state-dir "$STATE" 2>&1)
|
|
331
|
+
CODE=$?
|
|
332
|
+
set -e
|
|
333
|
+
refused "$(t 'drill.assertMissingDirRefused' 'a target profile directory that does not exist is refused')" "$OUT" "$CODE" 'drill.keywords.missingDir'
|
|
334
|
+
if [ ! -e "$MISSING" ]; then
|
|
335
|
+
assert_pass "$(t 'drill.assertMissingDirNotCreated' 'the missing directory was not created')"
|
|
336
|
+
else
|
|
337
|
+
assert_fail "$(t 'drill.assertMissingDirNotCreated' 'the missing directory was not created')" 'it is there'
|
|
338
|
+
fi
|
|
339
|
+
|
|
340
|
+
# --- "the profile says one thing, the disk another": the restore must fail ---
|
|
341
|
+
LYING_PROFILE="$DRILL_DIR/lying"
|
|
342
|
+
LYING_SNAPSHOT="$BACKUPS/20260303-000000"
|
|
343
|
+
mkdir -p -- "$LYING_PROFILE" "$LYING_SNAPSHOT/config"
|
|
344
|
+
cat > "$LYING_PROFILE/package.json" <<'JSON'
|
|
345
|
+
{
|
|
346
|
+
"name": "dsh-profile",
|
|
347
|
+
"dsh": {
|
|
348
|
+
"profile": {
|
|
349
|
+
"bundles": [
|
|
350
|
+
"dsh-autorestart"
|
|
351
|
+
]
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
JSON
|
|
356
|
+
cp "$LYING_PROFILE/package.json" "$LYING_SNAPSHOT/config/package.json"
|
|
357
|
+
cat > "$LYING_SNAPSHOT/manifest.json" <<'JSON'
|
|
358
|
+
{
|
|
359
|
+
"at": 1772668800000,
|
|
360
|
+
"atLocal": "2026-03-03T00:00:00",
|
|
361
|
+
"includePlugins": false
|
|
362
|
+
}
|
|
363
|
+
JSON
|
|
364
|
+
set +e
|
|
365
|
+
OUT=$("$RESTORE" --profile "$LYING_PROFILE" --snapshot "$LYING_SNAPSHOT" --state-dir "$STATE" 2>&1)
|
|
366
|
+
CODE=$?
|
|
367
|
+
set -e
|
|
368
|
+
if [ "$CODE" -ne 0 ]; then
|
|
369
|
+
assert_pass "$(t 'drill.assertLyingRefused' 'the profile declares the plugin but does not have it -> the restore must fail')"
|
|
370
|
+
else
|
|
371
|
+
assert_fail "$(t 'drill.assertLyingRefused' 'the profile declares the plugin but does not have it -> the restore must fail')" 'it exited with 0'
|
|
372
|
+
fi
|
|
373
|
+
LYING_BROKEN=$(find "$DRILL_DIR" -maxdepth 1 -name 'lying.broken-*' 2>/dev/null | head -n 1)
|
|
374
|
+
if [ -n "$LYING_BROKEN" ]; then
|
|
375
|
+
assert_pass "$(t 'drill.assertLyingBrokenHint' 'the failing restore says the broken directory is still there')"
|
|
376
|
+
else
|
|
377
|
+
assert_fail "$(t 'drill.assertLyingBrokenHint' 'the failing restore says the broken directory is still there')" 'there is none'
|
|
378
|
+
fi
|
|
379
|
+
|
|
380
|
+
# --- the scripts are pure ASCII ---
|
|
381
|
+
for script in "$RESTORE" "$DISABLE"; do
|
|
382
|
+
if [ ! -f "$script" ]; then
|
|
383
|
+
continue
|
|
384
|
+
fi
|
|
385
|
+
if LC_ALL=C grep -q '[^ -~]' "$script"; then
|
|
386
|
+
assert_fail "$(fmt 'the script {0} is pure ASCII' "$(basename "$script")")" 'it holds a non-ASCII byte'
|
|
387
|
+
else
|
|
388
|
+
assert_pass "$(fmt 'the script {0} is pure ASCII' "$(basename "$script")")"
|
|
389
|
+
fi
|
|
390
|
+
done
|
|
391
|
+
|
|
392
|
+
# --- the emergency switch ---
|
|
393
|
+
if [ -f "$DISABLE" ]; then
|
|
394
|
+
EM_OUT=$("$DISABLE" --package "$PROFILE/package.json" --action disable 2>&1) || true
|
|
395
|
+
if grep -q "$PACKAGE_NAME" "$PROFILE/package.json" 2>/dev/null; then
|
|
396
|
+
assert_fail 'the emergency switch takes the plugin out of the bundles' 'it is still there'
|
|
397
|
+
else
|
|
398
|
+
assert_pass 'the emergency switch takes the plugin out of the bundles'
|
|
399
|
+
fi
|
|
400
|
+
if ls "$PROFILE"/package.json.bak-emergency-* >/dev/null 2>&1; then
|
|
401
|
+
assert_pass 'the emergency switch leaves a backup behind'
|
|
402
|
+
else
|
|
403
|
+
assert_fail 'the emergency switch leaves a backup behind' 'there is none'
|
|
404
|
+
fi
|
|
405
|
+
fi
|
|
406
|
+
|
|
407
|
+
# --- only the temp directory was touched ---
|
|
408
|
+
if [ -n "$(find "$DRILL_DIR" -maxdepth 1 -name 'profile.broken-*' 2>/dev/null)" ]; then
|
|
409
|
+
assert_pass "$(t 'drill.assertOnlyTemp' 'the drill only touched its own temporary directory')"
|
|
410
|
+
else
|
|
411
|
+
assert_fail "$(t 'drill.assertOnlyTemp' 'the drill only touched its own temporary directory')" 'nothing was touched'
|
|
412
|
+
fi
|
|
413
|
+
|
|
414
|
+
# --- cleanup ---
|
|
415
|
+
rm -rf -- "$DRILL_DIR"
|
|
416
|
+
if [ -e "$DRILL_DIR" ]; then
|
|
417
|
+
assert_fail "$(t 'drill.notCleaned' 'the temporary directory was not cleaned up, please delete it yourself: {0}')" "$DRILL_DIR"
|
|
418
|
+
else
|
|
419
|
+
assert_pass "$(t 'drill.cleaned' 'the temporary directory is clean.')"
|
|
420
|
+
fi
|
|
421
|
+
|
|
422
|
+
if [ "$FAILED" -eq 0 ]; then
|
|
423
|
+
say "$(t 'drill.passed' 'the drill passed: broke it on purpose -> restored -> checked -> cleaned up.')"
|
|
424
|
+
exit 0
|
|
425
|
+
fi
|
|
426
|
+
say "$(fmt "$(t 'drill.failed' 'the drill failed: {0} assertions did not pass.')" "$FAILED")"
|
|
427
|
+
exit 1
|