dsh-autorestart 0.6.2 → 0.8.0

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.
Files changed (47) hide show
  1. package/README.md +460 -12
  2. package/assets/screenshot-1.png +0 -0
  3. package/assets/screenshot-2.png +0 -0
  4. package/assets/screenshot-3.png +0 -0
  5. package/assets/screenshot-4.png +0 -0
  6. package/assets/screenshot-5.png +0 -0
  7. package/client.js +6329 -3863
  8. package/cordis.patch.yml +44 -1
  9. package/docs/README.zh.md +297 -9
  10. package/docs/platform-matrix.md +92 -0
  11. package/docs/rescue-drill.md +606 -0
  12. package/index.js +2867 -199
  13. package/lib/access.js +212 -0
  14. package/lib/autostart-advice.js +198 -0
  15. package/lib/backup.js +131 -4
  16. package/lib/blockers.js +228 -0
  17. package/lib/checkpoint.js +147 -0
  18. package/lib/diagnostics.js +188 -0
  19. package/lib/health.js +264 -0
  20. package/lib/host-kind.js +145 -0
  21. package/lib/node-runtime.js +144 -0
  22. package/lib/rescue-paths.js +253 -0
  23. package/lib/rescue.js +596 -0
  24. package/lib/restart-verdict.js +327 -0
  25. package/lib/restart.js +823 -56
  26. package/lib/scheduler-capability.js +300 -0
  27. package/lib/sleep.js +46 -49
  28. package/lib/startup-mode.js +491 -0
  29. package/lib/supervisor-config.js +735 -0
  30. package/lib/supervisor-install.js +557 -0
  31. package/lib/supervisor.js +1276 -0
  32. package/lib/watchdog-arbiter.js +117 -0
  33. package/lib/watchdog-schedule.js +310 -0
  34. package/lib/watchdog-tier.js +182 -0
  35. package/lib/watchdog.js +479 -0
  36. package/locale/en.json +274 -64
  37. package/locale/zh.json +274 -64
  38. package/package.json +7 -2
  39. package/screenshots.json +6 -4
  40. package/scripts/README.md +65 -0
  41. package/scripts/drill-rescue.ps1 +508 -0
  42. package/scripts/emergency-disable.cmd +27 -0
  43. package/scripts/emergency-toggle.ps1 +373 -0
  44. package/scripts/restore-profile.cmd +44 -0
  45. package/scripts/restore-profile.ps1 +608 -0
  46. package/scripts/run-tests.ps1 +46 -0
  47. package/supervisor/supervisor.js +884 -0
@@ -0,0 +1,606 @@
1
+ # 自救包隔离演练报告(restore-profile)
2
+
3
+ > 这是 **t2 演练与复核** 的留痕:把 profile 故意弄坏、再一键救回来,整个过程**只在临时目录里**发生,
4
+ > 用户正在用的 `C:\Users\Administrator\.dsh\profiles\desktop` **一个字节都没被碰过**。
5
+ > 报告里的每段输出都是那台机器上跑出来的真货,不是复述。
6
+
7
+ ---
8
+
9
+ ## 一、演练基本信息
10
+
11
+ | 项 | 值 |
12
+ | --- | --- |
13
+ | 演练时间 | 2026-10-29 16:11 – 16:19(Asia/Shanghai,UTC+8) |
14
+ | 机器 | Windows,**Windows PowerShell 5.1.19041.2673**(这台机器**没有装 pwsh**;`.cmd` 入口调的也是 `powershell.exe`) |
15
+ | 系统 ANSI 代码页 | **gb2312** ← 这条决定了下面第 9 节那个坑 |
16
+ | node | v24.22.1 |
17
+ | `DSH_HOME` | `C:\Users\Administrator\.dsh` |
18
+ | `DSH_PROFILE_DIR` | `C:\Users\Administrator\.dsh\profiles\desktop` ← **指向真实 profile**,所以演练全程必须显式传 `-ProfileDir` |
19
+ | 演练者 | gate(只跑不写业务代码) |
20
+
21
+ ### 被测文件的指纹(这一轮跑的就是这个版本)
22
+
23
+ ```
24
+ scripts\restore-profile.ps1 sha256=05b68b499f6c64f27ea4305312fe6d4d913676732220c442c36b574641704f8a (UTF-8 BOM) mtime 16:17:33
25
+ scripts\restore-profile.cmd sha256=4c902fd65efb4c7419bf33b9398cf0cf26b7b59286a9754dc83435dd36d7694a (纯 ASCII) mtime 16:00:45
26
+ scripts\drill-rescue.ps1 sha256=42a414695ba202555b9a4ac416bbc224e0f78d84c3e97edcea7922c4e6ea7392 (UTF-8 BOM) mtime 16:17:34
27
+ lib\rescue.js sha256=23c0f9324daacb4183e1c2545b4f85f5835834376dc3c50ec9594b10a94e81ea mtime 16:14:31
28
+ ```
29
+
30
+ > 说明:演练期间 `dev` 正在同一个仓库上改这两个脚本(任务 t3),所以被测版本变了两次。
31
+ > 本报告的最终数据来自 **16:18 那一轮**(上表这组指纹)。中途那一版出过事故,见第 9 节。
32
+
33
+ ---
34
+
35
+ ## 二、结论先说
36
+
37
+ **通过。** 6 个必测项全绿,另外补了 9 项更狠的检查也全绿:
38
+
39
+ | 必测项 | 结果 | 证据 |
40
+ | --- | --- | --- |
41
+ | 隔离目录里的假 profile 被故意弄坏后,**一次运行内**恢复 | ✅ | 第 4 节,一次 `restore-profile.ps1` 运行,退出码 0 |
42
+ | 恢复后 `package.json` 可解析 | ✅ | 第 4 节 |
43
+ | 恢复后 `dsh.profile.bundles` 非空 | ✅ | 第 4 节(2 个) |
44
+ | 恢复后 `node_modules/dsh-autorestart/package.json` 的 version 可读 | ✅ | 第 4 节(`0.7.0`,且就是快照里那一份) |
45
+ | `broken-*` 证据目录确实存在 | ✅ | 第 4 节,且里面保留的还是坏掉的那份 |
46
+ | `-DryRun` 只打印计划、不改磁盘 | ✅ | 第 6 节(对比执行前后的文件清单 + SHA256 + mtime ticks) |
47
+ | 安全拒绝:不含 `package.json` 的目录 → 非零退出码、且不动它 | ✅ | 第 5 节 |
48
+ | 全程不触碰用户真实 profile | ✅ | 第 3 节 + 第 8 节(哈希、mtime ticks、ctime、6565 个文件的目录树指纹全部一致) |
49
+
50
+ ---
51
+
52
+ ## 三、第 0 步:记录基线(用户真实 profile)
53
+
54
+ **命令**(在 `C:\Users\Administrator\.dsh\profiles\desktop` 上,只读):
55
+
56
+ ```powershell
57
+ $pkg = 'C:\Users\Administrator\.dsh\profiles\desktop\package.json'
58
+ Get-FileHash -LiteralPath $pkg -Algorithm SHA256
59
+ Get-Item -LiteralPath $pkg | Select-Object Length, LastWriteTimeUtc, CreationTimeUtc
60
+ # 外加整个 profile 目录的「轻量树指纹」:每个文件的 相对路径|字节数|修改时间(ticks),排序后取 SHA256
61
+ ```
62
+
63
+ **真实输出**:
64
+
65
+ ```
66
+ SHA256 : 754948513BBFBDA88CBED82982A80BAE9168844FCFB1A24C7823E615BC47A31D
67
+ Length : 2902
68
+ LastWriteTime : 2026-10-29T16:03:52.0611312+08:00
69
+ LastWriteUtc : 2026-10-29T08:03:52.0611312Z
70
+ CreationTime : 2026-10-29T08:45:10.8879423+08:00
71
+
72
+ profile 目录规模:文件数 6565,总字节 169252227
73
+ 目录树指纹 TreeHash = ED0330203CDE4FC47D2F6562F1A643E054092AFAC7280CD30860DDA6F164A7A8
74
+ ```
75
+
76
+ 基线另存了一份 JSON 到 `%TEMP%\dsh-t2-baseline.json`,收尾时用它做逐项对比。
77
+
78
+ ---
79
+
80
+ ## 四、第 1 步:跑插件自带的演练(`drill-rescue.ps1`)
81
+
82
+ **命令**:
83
+
84
+ ```
85
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File plugins\dsh-autorestart\scripts\drill-rescue.ps1
86
+ ```
87
+
88
+ **真实输出(节选,完整 22 条断言全过,退出码 0)**:
89
+
90
+ ```
91
+ 演练目录:C:\Users\Administrator\AppData\Local\Temp\dsh-rescue-drill-a8fbf6eb818b4368a9ec37ae4dab8bc2
92
+ 已经故意弄坏:package.json 写成非法 JSON,插件目录删掉了。
93
+ --- 还原脚本的输出(试运行)---
94
+ dsh-autorestart —— profile 快照还原器
95
+ 目标 profile:...\dsh-rescue-drill-a8fbf6eb818b4368a9ec37ae4dab8bc2\profile
96
+ 这个 profile 已经坏了:profile 坏了(package.json 不再是 profile),但 profile 的文件还在
97
+ 要用快照:2026-10-29T01-00-00-000Z(2026-10-29 09:00)
98
+ [试运行] 会先把现在的 profile 挪到:...\profile.broken-20261029-161806
99
+ [试运行] 没有改动任何文件。
100
+ --- 输出结束 ---
101
+ [通过] 试运行退出码是 0
102
+ [通过] 试运行没有改动 package.json
103
+ [通过] 试运行没有留下 broken 目录
104
+ --- 还原脚本的输出(真正的还原)---
105
+ 原来那份已经挪到:...\profile.broken-20261029-161808
106
+ [好] package.json 能解析
107
+ [好] dsh.profile.bundles 有内容 —— 1 个
108
+ [好] dsh-autorestart 的版本号读得到 —— 0.7.0
109
+ 已还原到 2026-10-29 09:00 的快照(含插件);现在可以重新打开 DSH。
110
+ --- 输出结束 ---
111
+ [通过] 还原退出码是 0
112
+ 断言:
113
+ [通过] 还原后 package.json 能解析
114
+ [通过] 用的是带 manifest 的那份快照(跳过了更新的诱饵)
115
+ [通过] 还原后 dsh.profile.bundles 有内容
116
+ [通过] 还原后插件版本读得到
117
+ [通过] 插件版本是快照里的那一个
118
+ [通过] broken 证据目录存在
119
+ [通过] broken 目录里保留的还是坏掉的那份
120
+ [通过] 不像 profile 的目录被拒绝(退出码不是 0)
121
+ [通过] 被拒绝的时候没有动那个目录
122
+ [通过] 盘符根目录被拒绝(退出码不是 0)
123
+ [通过] 目标 profile 目录不存在时被拒绝(退出码不是 0)
124
+ [通过] 目标目录不存在时没有把它创建出来
125
+ [通过] 声明了插件却没装 → 还原必须失败(退出码不是 0)
126
+ [通过] 声明了插件却没装 → 明确说 broken 目录还在
127
+ [通过] restore-profile.ps1 是 UTF-8 带 BOM
128
+ [通过] restore-profile.cmd 是纯 ASCII
129
+ [通过] 演练只在临时目录里动手(broken 目录在临时目录下面)
130
+ [通过] 演练目录里有锁文件
131
+ 清理临时目录:...\dsh-rescue-drill-a8fbf6eb818b4368a9ec37ae4dab8bc2
132
+ 临时目录已清理干净。
133
+ 演练全部通过:故意弄坏 → 还原 → 校验 → 清理,一整圈跑通了。
134
+ ```
135
+
136
+ **退出码 = 0。**
137
+
138
+ 其中「声明了插件却没装 → 还原必须失败」这一段值得单独看一眼——它证明脚本**不假装成功**:
139
+
140
+ ```
141
+ [好] package.json 能解析
142
+ [好] dsh.profile.bundles 有内容 —— 1 个
143
+ [坏] dsh-autorestart 的版本号读得到 —— profile 里写着要装 dsh-autorestart,但 node_modules\dsh-autorestart 不在 —— 配置说一套、磁盘是另一套
144
+ 还原失败:还原后的 profile 没通过校验(dsh-autorestart 的版本号读得到)。
145
+ 你原来的 profile 完整地留在这里:...\lying-profile.broken-20261029-161813
146
+ (这个目录没有被动过,里面还是你出事之前的样子。)
147
+ ```
148
+
149
+ ---
150
+
151
+ ## 五、第 2 步:我自己写的「更狠的坏法」独立演练
152
+
153
+ 自带演练只坏两样(非法 JSON + 删插件目录)。我另写了一个**独立**的演练台(放在 `%TEMP%`,
154
+ **没有写进仓库**),比它狠一档,一次坏四处:
155
+
156
+ | 坏法 | 自带演练 | 我的演练 |
157
+ | --- | --- | --- |
158
+ | `package.json` 写成非法 JSON | ✅ | ✅ |
159
+ | 删掉 `node_modules/dsh-autorestart` | ✅ | ✅ |
160
+ | `cordis.patch.yml` 也写成垃圾 | — | ✅ |
161
+ | 塞一个快照里没有的残留文件 `leftover-junk.txt` | — | ✅ |
162
+
163
+ **坏完之后的 profile 指纹**(`relative path|字节数|mtime ticks|SHA256` 逐文件比对):
164
+
165
+ ```
166
+ package.json -> 非法 JSON
167
+ cordis.patch.yml -> "this is not yaml at all: [[["
168
+ node_modules/dsh-autorestart\ -> 整个删掉
169
+ leftover-junk.txt -> 快照里没有的文件
170
+ ```
171
+
172
+ **命令**:
173
+
174
+ ```
175
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File %TEMP%\dsh-t2-my-drill-v4.ps1 -ScriptsRoot <插件 scripts 目录>
176
+ ```
177
+
178
+ **真实输出(节选)**:
179
+
180
+ ```
181
+ drill root: C:\Users\Administrator\AppData\Local\Temp\dsh-t2-drill-d3d60b5cca
182
+ started : 2026-10-29 16:18:14 +08:00
183
+ === STEP 2: 故意更狠地弄坏(比 t1 的演练更狠) ===
184
+ broken: package.json = invalid JSON / node_modules\dsh-autorestart = deleted / cordis.patch.yml = garbage / extra junk file
185
+
186
+ === STEP 3: -DryRun 必须一个字节都不改 ===
187
+ [PASS] DryRun exit code is 0
188
+ [PASS] DryRun did not change any byte on disk
189
+ [PASS] DryRun left no broken-* directory
190
+
191
+ === STEP 4: 一键救回(一次 restore-profile.ps1 运行) ===
192
+ --- restore-profile.ps1 output (real restore) ---
193
+ 这个 profile 已经坏了:profile 坏了(package.json 不再是 profile),但 profile 的文件还在
194
+ 要用快照:2026-10-28T12-00-00-000Z(2026-10-28 20:00)
195
+ 原来那份已经挪到:...\profile.broken-20261029-161818
196
+ [好] package.json 能解析
197
+ [好] dsh.profile.bundles 有内容 —— 2 个
198
+ [好] dsh-autorestart 的版本号读得到 —— 0.7.0
199
+ 已还原到 2026-10-28 20:00 的快照(含插件);现在可以重新打开 DSH。
200
+ --- end of output ---
201
+ [PASS] restore exit code is 0
202
+ [PASS] restored package.json parses
203
+ [PASS] used the snapshot that carries a manifest (skipped the newer decoy)
204
+ [PASS] dsh.profile.bundles is not empty
205
+ [PASS] node_modules/dsh-autorestart/package.json version is readable
206
+ [PASS] the plugin version is the one from the snapshot
207
+ [PASS] cordis.patch.yml restored from the snapshot
208
+ [PASS] restored package.json is byte-identical to the snapshot copy
209
+ [PASS] broken-* evidence directory exists
210
+ [PASS] broken dir keeps the BROKEN package.json (invalid JSON)
211
+ [PASS] broken dir keeps the BROKEN cordis.patch.yml (garbage)
212
+ [PASS] broken dir keeps the file that is not in the snapshot
213
+ [PASS] broken dir holds the state at the moment of the break (plugin dir was already gone)
214
+ [PASS] the junk file was NOT resurrected in the restored profile
215
+
216
+ === STEP 4c: explicit -Snapshot ===
217
+ [PASS] explicit -Snapshot exit code is 0
218
+ [PASS] explicit -Snapshot restored the plugin version
219
+ ```
220
+
221
+ **退出码 = 0(`ALL MY OWN ASSERTIONS PASSED.`)。**
222
+
223
+ 两条值得说明的:
224
+
225
+ - **「还原后 package.json 与快照里那份逐字节相同」** —— 还原不是「差不多」,是把快照原样放回去。
226
+ - **「快照里没有的残留文件不会被凭空造回来」** —— 还原只放回快照里的东西。
227
+ 那个残留文件在 `broken-*` 里(出事那一刻的样子),但不会出现在还原后的 profile 里。
228
+ - **`broken-*` 保存的是「出事那一刻」的状态**:我在移动之前就删掉了插件目录,所以证据目录里当然没有它。
229
+ 证据目录不是时光机,它保存的是**故障现场**,不是更早的样子。
230
+
231
+ ---
232
+
233
+ ## 六、第 3 步:`-DryRun` 不改磁盘
234
+
235
+ **做法**:在同一个坏掉的 profile 上先拍一份「逐文件 相对路径|字节数|mtime ticks|SHA256」清单,
236
+ 跑 `-DryRun`,再拍一份,**逐字节比**。
237
+
238
+ ```powershell
239
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\restore-profile.ps1 `
240
+ -DryRun -ProfileDir <隔离 profile> -StateDir <隔离 state>
241
+ ```
242
+
243
+ **真实输出**:
244
+
245
+ ```
246
+ [试运行] 会先把现在的 profile 挪到:...\profile.broken-20261029-161816
247
+ [试运行] 会把 2026-10-28T12-00-00-000Z 里的配置和插件复制回:...\profile
248
+ [试运行] 会回读校验 package.json / dsh.profile.bundles / dsh-autorestart 的版本号。
249
+ [试运行] 没有改动任何文件。
250
+ ---
251
+ [PASS] DryRun exit code is 0
252
+ [PASS] DryRun did not change any byte on disk
253
+ [PASS] DryRun left no broken-* directory
254
+ ```
255
+
256
+ 清单逐项一致,**没有留下任何 `broken-*` 目录**,退出码 0。
257
+
258
+ ---
259
+
260
+ ## 七、第 4 步:安全拒绝(拿随机目录冒充 profile)
261
+
262
+ **做法**:造一个只装 5 个随机二进制文件 + 1 个子目录的临时目录(**没有 package.json**),
263
+ 当 `-ProfileDir` 传进去,前后各拍一次清单。
264
+
265
+ **真实输出**:
266
+
267
+ ```
268
+ --- restore-profile.ps1 output (not a profile) ---
269
+ dsh-autorestart —— profile 快照还原器
270
+ 目标 profile:...\dsh-t2-drill-d3d60b5cca\not-a-profile
271
+ 还原失败:这里没有 package.json —— 不像 DSH profile(目标:...\not-a-profile)。拒绝动手 —— 免得把不是 profile 的目录弄坏。
272
+ --- end of output ---
273
+ [PASS] not-a-profile dir is refused (exit code != 0)
274
+ [PASS] the refused dir was NOT modified (same bytes + mtimes)
275
+ [PASS] the refused dir has exactly the same entries and mtimes
276
+ [PASS] no broken-* sibling was created for the refused dir
277
+ ```
278
+
279
+ **退出码非 0;那个目录里的 5 个随机文件的 SHA256 和 mtime ticks 一个都没变;旁边也没多出 `not-a-profile.broken-*`。**
280
+
281
+ ### 边界闸门顺带也验了一遍(全是只读探测,没碰那些目录)
282
+
283
+ | 目标 `-ProfileDir` | 脚本的回应 | 退出码 |
284
+ | --- | --- | --- |
285
+ | `C:\` | 还原失败:拒绝动盘符根目录(目标:C:\) | 非 0 |
286
+ | `C:\Windows` | 还原失败:拒绝动系统目录:C:\Windows | 非 0 |
287
+ | `C:\Program Files` | 还原失败:拒绝动系统目录:C:\Program Files | 非 0 |
288
+ | `C:\ProgramData` | 还原失败:拒绝动系统目录:C:\ProgramData | 非 0 |
289
+ | `C:\Users\Administrator` | 还原失败:拒绝动用户主目录本身 | 非 0 |
290
+ | `C:\Users\SomeoneElse` | 还原失败:拒绝动别人的用户目录 | 非 0 |
291
+
292
+ **显式 `-ProfileDir` 也不例外** —— 闸门在「显式指定」这条路上照样生效。
293
+
294
+ ---
295
+
296
+ ## 八、第 5 步:收尾对比基线(真实 profile 一个字节没动)
297
+
298
+ **命令**:重算第 3 步那一整套指纹,和基线 JSON 逐项比。
299
+
300
+ **真实输出**:
301
+
302
+ ```
303
+ === 用户真实 profile:演练前 vs 演练后 ===
304
+ [一致] package.json SHA256 before=754948513BBFBDA88CBED82982A80BAE9168844FCFB1A24C7823E615BC47A31D after=754948513BBFBDA88CBED82982A80BAE9168844FCFB1A24C7823E615BC47A31D
305
+ [一致] package.json 字节数 before=2902 after=2902
306
+ [一致] package.json 修改时间(UTC) before=2026-10-29T08:03:52.0611312Z after=2026-10-29T08:03:52.0611312Z
307
+ [一致] package.json 修改时间(ticks) before=639288578320611312 after=639288578320611312
308
+ [一致] package.json 创建时间(UTC) before=2026-10-29T00:45:10.8879423Z after=2026-10-29T00:45:10.8879423Z
309
+ [一致] profile 目录文件数 before=6565 after=6565
310
+ [一致] profile 目录总字节 before=169252227 after=169252227
311
+ [一致] profile 目录树指纹 before=ED0330203CDE4FC47D2F6562F1A643E054092AFAC7280CD30860DDA6F164A7A8 after=ED0330203CDE4FC47D2F6562F1A643E054092AFAC7280CD30860DDA6F164A7A8
312
+
313
+ === 结论:用户真实 profile 一字未动 ✅ ===
314
+ ```
315
+
316
+ 顺带查了 `C:\Users\Administrator\.dsh\profiles\` 下面,只剩 `desktop` / `headless` / `web` 三个目录,
317
+ **没有多出任何 `broken-*`**。
318
+
319
+ 补充:`node --test test/rescue.test.mjs test/scripts-encoding.test.mjs` → **36 项全过,退出码 0**。
320
+
321
+ ---
322
+
323
+ ## 九、演练过程中真的出过一次事故(已修,留档)
324
+
325
+ **16:14:58**,`dev` 为任务 t3 改 `scripts/restore-profile.ps1` 时,**把 UTF-8 BOM 弄丢了**。
326
+ 文件首字节从 `EF BB BF 3C`(`<#` 带 BOM)变成 `3C 23 0A 2E`(`<#` 不带 BOM)。
327
+
328
+ 后果不是「中文显示乱码」,而是**整个脚本死掉**:
329
+
330
+ - 这台机器只有 Windows PowerShell 5.1(**没装 pwsh**),`.cmd` 入口调的也是 `powershell.exe`;
331
+ - 系统 ANSI 代码页是 **gb2312**,PS 5.1 读「无 BOM 的 UTF-8 文件」按 ANSI 解码 →
332
+ 中文变成乱码 → 多字节字符被截断 → **ParserError**,脚本**一行都没执行**;
333
+ - 连带 `drill-rescue.ps1` 自己那 22 条断言里 **9 条挂掉**,演练整体跑不完。
334
+
335
+ 当时 `drill-rescue.ps1` 的真实输出(节选):
336
+
337
+ ```
338
+ --- 还原脚本的输出(真正的还原)---
339
+ At ...\scripts\restore-profile.ps1:55 char:18
340
+ + Write-Host ('浣犲師鏉ョ殑 profile 瀹屾暣鍦扮暀鍦ㄨ繖閲岋細' + $script:BrokenDir) -F ...
341
+ Unexpected token '浣犲師鏉ョ殑' in expression or statement.
342
+ Not all parse errors were reported. Correct the reported errors and try again.
343
+ + CategoryInfo : ParserError: (:) [], ParentContainsErrorRecordException
344
+ ---
345
+ [失败] 还原退出码是 0 —— 退出码 1
346
+ [失败] 还原后 package.json 能解析 —— Invalid object passed in, ':' or '}' expected. (32): { "name": "drill-profile", oops
347
+ ...
348
+ [失败] restore-profile.ps1 是 UTF-8 带 BOM
349
+ 演练失败:9 条断言没过。
350
+ ```
351
+
352
+ **16:17:33 / 16:17:34 已修好**(两个 `.ps1` 的 BOM 都补回去了),并且新增了
353
+ `test/scripts-encoding.test.mjs` 做硬闸:直接读字节,断言两个 `.ps1` 必须以 `EF BB BF` 开头、
354
+ `.cmd` 必须纯 ASCII、`.cmd` 必须调 `powershell.exe` 而不是 `pwsh`。
355
+ 本报告最终数据就是修好之后重跑的那一轮。
356
+
357
+ ### 顺带暴露的一条演练盲区 —— **已修复(t4,commit `7209c4a`)**
358
+
359
+ 「拒绝类」断言原本只看**退出码非 0**。脚本解析失败时退出码**也是**非 0 —— 于是
360
+ 「不像 profile 的目录被拒绝」「盘符根目录被拒绝」这几条会**假通过**:
361
+ 脚本坏成上面那样,它们照样显示 `[通过]`。
362
+
363
+ > 当时的建议:拒绝类断言除了退出码,还要断言输出里**出现了具体的拒绝原因**(例如包含「拒绝动盘符根目录」)。
364
+ > 这样「脚本自己坏了」和「脚本正确地拒绝了」才分得开。
365
+
366
+ **这条建议已经被采纳并实现了**(`dev` 的任务 t4,commit `7209c4a`):演练里新增了
367
+ `Assert-Refused`,拒绝类断言现在**同时**检查退出码和输出里的拒绝原因。实跑输出:
368
+
369
+ ```
370
+ [通过] 不像 profile 的目录被拒绝(退出码 + 拒绝原因) —— 退出码 1,输出里有拒绝原因「不像」
371
+ [通过] 盘符根目录被拒绝(退出码 + 拒绝原因) —— 退出码 1,输出里有拒绝原因「盘符」
372
+ [通过] 系统目录 C:\Windows 被拒绝(退出码 + 拒绝原因) —— 退出码 1,输出里有拒绝原因「系统目录」
373
+ [通过] 系统目录 C:\Program Files 被拒绝(退出码 + 拒绝原因) —— 退出码 1,输出里有拒绝原因「系统目录」
374
+ [通过] 系统目录 C:\ProgramData 被拒绝(退出码 + 拒绝原因) —— 退出码 1,输出里有拒绝原因「系统目录」
375
+ [通过] 目标 profile 目录不存在时被拒绝(退出码 + 拒绝原因) —— 退出码 1,输出里有拒绝原因「不存在」
376
+ [通过] 声明了插件却没装 → 还原必须失败(退出码 + 拒绝原因) —— 退出码 1,输出里有拒绝原因「没通过校验」
377
+ ```
378
+
379
+ **而且它被反向验证过**(`dev` 做了一次「人为再弄坏」):临时去掉 BOM 再跑演练,
380
+ 那些拒绝类断言现在会**如实失败**,而不是假通过:
381
+
382
+ ```
383
+ BROKEN first3=3C 23 0A -> 演练 exit 1、16 条断言没过,
384
+ 每条拒绝类断言都写着「退出码是 1,但输出里没有拒绝原因(期望其中之一:…)」
385
+ RESTORED first3=EF BB BF identical=True -> 再跑 exit 0
386
+ ```
387
+
388
+ 也就是说:**BOM 事故那天会产生假通过的那批断言,现在会如实失败。**
389
+ 这才是这条改动的意义 —— 演练的价值不在于「跑绿了」,而在于「它真的坏得能被看见」。
390
+
391
+ ### 同一个坑又踩了一次(16:24:46)—— 但这次被硬闸当场抓住
392
+
393
+ 修好 BOM 之后,**又有人在 16:24:46 改了一次 `scripts/drill-rescue.ps1`,BOM 又没了**
394
+ (首字节又回到 `3C 23 0A`)。根因很明确:**任何用 edit / 写文件工具重写这两个 `.ps1` 的动作,都会把 BOM 抹掉**。
395
+ 这不是谁不小心,是工具行为 —— 所以「记得补 BOM」这种约定一定会再失效。
396
+
397
+ 当前工作区的实测:
398
+
399
+ ```
400
+ powershell.exe -ExecutionPolicy Bypass -File scripts\drill-rescue.ps1
401
+ -> exit = 1,【0 条 [通过]、0 条 [失败]】(解析阶段就死了,一条断言都没跑到)
402
+ At scripts\drill-rescue.ps1:70 char:20
403
+ + Write-Host (' [澶辫触] ' + $Name + ' 鈥斺€?閫€鍑虹爜鏄?' + $Result.Code + ...
404
+ Unexpected token '[澶辫触]' in expression or statement.
405
+ ```
406
+
407
+ **但这次没有造成事故** —— t3 新加的编码硬闸当场就抓住了:
408
+
409
+ ```
410
+ node --test test\scripts-encoding.test.mjs
411
+ ✖ the PowerShell scripts keep their UTF-8 byte order mark (actual: 60, expected: 239 —— 60 是 '<',239 是 0xEF)
412
+ ✔ the batch entry point stays pure ASCII
413
+ ✔ the batch entry point calls powershell.exe, never pwsh
414
+ ✔ the drill script never sweeps %TEMP% with a wildcard
415
+ pass 3 / fail 1
416
+ ```
417
+
418
+ **已提交的历史是干净的**(`65a4c98` 那一版 BOM 在、drill 22 条断言全过、node --test 36/36 全过),
419
+ 坏掉的只是当时的工作区。所以这两次事故的净结论是:
420
+
421
+ > **编码硬闸是有用的,但它只在「有人想起来跑测试」时才生效。**
422
+ > 这套脚本平时根本没人跑 —— 坏了没人发现,正是它当初能被悄悄弄死、而整个自救包静默失效的原因。
423
+ > 建议再往前一步:把「`.ps1` 丢了 BOM 就不许提交」做成 pre-commit 检查或发布前的必跑项。
424
+ > 更彻底的做法是把 `.ps1` 里面向用户的中文字符串挪到旁边的 UTF-8 文本文件、运行时读出来,
425
+ > 让 `.ps1` 本身变成纯 ASCII —— BOM 这个坑从此不存在。
426
+
427
+ ---
428
+
429
+ ## 十、已知限制
430
+
431
+ 1. **这套自救包解决不了「完全起不来时自动回来」。**
432
+ 那要靠看门狗(`dsh-autorestart` 的 supervisor / 计划任务),不靠这套脚本。
433
+ 这套自救包只保证一件事:**你能一键回到上一次好状态**——
434
+ 前提是你还能双击 `scripts\restore-profile.cmd`(或者还记得手敲那条命令)。
435
+ 如果 DSH 起不来是「插件在启动阶段把宿主带崩了、连带连脚本都跑不起来」,
436
+ 这个自救包救不了你。
437
+
438
+ 2. **它不替你重启 DSH。** 脚本只管把文件放回去,最后一句是「现在可以重新打开 DSH」。
439
+ 故意不自动重启:救不回来还顺手再弄挂一次没有意义。
440
+
441
+ 3. **必须有「带 manifest 的快照」才救得回来。** 没有快照(或者快照目录被清空),
442
+ 脚本会直接报错退出。快照是插件平时运行时自动备的 —— **没备过就没有退路**。
443
+
444
+ 4. **目标 profile 目录必须已经存在。** 脚本不会为了还原去新建一个 profile 目录
445
+ (避免把一个打错字的目标变成一个空壳 profile)。这意味着**「profile 整个没了」这种情况救不回来**。
446
+
447
+ 5. **最坏情况会被拒绝。** 实测:如果 profile 坏到**只剩一个非法的 `package.json`**,
448
+ 既没有 `cordis.patch.yml` 也没有 `node_modules`,脚本判不出「这曾经是个 profile」,直接拒绝:
449
+
450
+ ```
451
+ 还原失败:package.json 里没有 dsh.profile,也没有别的东西能证明这是 profile(目标:...\worst-case-profile)。拒绝动手 —— 免得把不是 profile 的目录弄坏。
452
+ ```
453
+
454
+ 这是**故意保守**的取舍(宁可不动,也不误伤),但用户要知道:**坏得越彻底,越可能落到拒绝这一边**。
455
+
456
+ 6. **只还原快照里有的东西。** 还原只复制 `config/*` 和 `node_modules/`。
457
+ profile 里其它位置(如果还有别的东西)在还原后**不在**了 —— 它们留在 `broken-*` 目录里。
458
+ 还原前请确认 `broken-*` 目录在你能找到的地方。
459
+
460
+ 7. **`broken-*` 目录会一直堆着。** 脚本不会自动删它(证据要留)。手动确认没问题之后自己删。
461
+
462
+ 8. **中文 Windows 上的编码陷阱(真实踩过,见第九节)。**
463
+ PS 5.1 + gb2312 代码页下,`.ps1` **必须**是 UTF-8 带 BOM,否则整个脚本静默死掉。
464
+ 任何编辑器/脚本重写这两个 `.ps1` 之后,**必须**确认 BOM 还在 ——
465
+ `test/scripts-encoding.test.mjs` 现在会替你盯这件事,**但跑测试得有人跑**。
466
+
467
+ > **尚未落地的保险**:把「`.ps1` 丢了 BOM 就不许提交」做成 pre-commit 闸
468
+ > (`.githooks/pre-commit` + `git config core.hooksPath .githooks`)。
469
+ > 现在这条硬闸只在「有人想起来跑测试」时才生效,而这套脚本平时根本没人跑 ——
470
+ > **今天它已经漏掉两次**(16:14:58、16:24:46)。这条建议已报给 captain 等派活。
471
+
472
+ 9. **演练只在 Windows 上做过。** 这套脚本本来就只服务 Windows 桌面版;
473
+ macOS / Linux 没有对应的演练覆盖。
474
+
475
+ ---
476
+
477
+ ## 十一、复现方式
478
+
479
+ ```bash
480
+ # 1) 插件自带的演练(隔离目录,全绿退出码 0)
481
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File plugins\dsh-autorestart\scripts\drill-rescue.ps1
482
+
483
+ # 2) 规则层单测(含编码硬闸)
484
+ cd plugins\dsh-autorestart && node --test test\rescue.test.mjs test\scripts-encoding.test.mjs
485
+
486
+ # 3) 手动试跑(先看它要干什么,再真干)
487
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File plugins\dsh-autorestart\scripts\restore-profile.ps1 -DryRun
488
+ ```
489
+
490
+ 演练全程**不要**去掉 `-ProfileDir` / `-StateDir` 这两个隔离参数:
491
+ 这台机器的 `DSH_PROFILE_DIR` 环境变量指向的是**真实** profile。
492
+
493
+ ### 本轮遗留的两个临时目录(无害,但请知悉)
494
+
495
+ ```
496
+ C:\Users\Administrator\AppData\Local\Temp\dsh-t2-drill-c77f730a2c ← 中途一次演练中断,脚本自己没来得及清
497
+ C:\Users\Administrator\AppData\Local\Temp\dsh-t2-sut-278d686a ← 「BOM 修好后的脚本副本」,已用完
498
+ ```
499
+
500
+ 两个都是本轮演练自己造的、**只在 TEMP 里**、**不含任何真实 profile 数据**。
501
+ 清理它们的动作走了「开工前检查」的确认卡,但这个委派会话里没有可用的交互问答通道,
502
+ 确认卡没等到回答、动作**没有执行** —— 所以它们还在原处。可以手动删掉:
503
+
504
+ ```powershell
505
+ Remove-Item -LiteralPath "$env:TEMP\dsh-t2-drill-c77f730a2c" -Recurse -Force
506
+ Remove-Item -LiteralPath "$env:TEMP\dsh-t2-sut-278d686a" -Recurse -Force
507
+ ```
508
+
509
+ > 顺带一提:这两个残留正好是「演练自己清理自己」这条要求的反例 ——
510
+ > **脚本被中断时,它自己建的目录会留在原地**。`drill-rescue.ps1` 现在会写锁文件来防并发,
511
+ > 但中断场景下的残留还是得靠人扫一眼 `%TEMP%`。
512
+
513
+ ---
514
+
515
+ ## 十二、收尾复验(提交之后的干净工作区)
516
+
517
+ 演练期间 `dev` 在同一个仓库上改这两个脚本(任务 t3),所以最后**又跑了一遍**,
518
+ 这次是跑在**已经提交、工作区干净**的树上。
519
+
520
+ **交付版本**:
521
+
522
+ | 提交 | 内容 |
523
+ | --- | --- |
524
+ | `4c89846` | t1 的原始交付(自救包 6 个新文件) |
525
+ | `65a4c98` | t3:修演练并发缺陷 + 补回 BOM + 把编码规矩升级成单测硬闸 |
526
+ | `7209c4a` | t4:拒绝类断言升级为「退出码 + 输出里的拒绝原因」 |
527
+ | `09735bf` → `52df711` | 本报告(含历次收尾复验) |
528
+
529
+ **关键事实:`65a4c98` 提交的就是本报告 16:18 那一轮测的那几个字节。**
530
+ 逐个 sha256 对得上,没有「报告写的是旧版本、交付的是新版本」的问题:
531
+
532
+ ```
533
+ scripts\restore-profile.ps1 sha256=05b68b499f6c64f27ea4305312fe6d4d913676732220c442c36b574641704f8a ← 报告第一节那张表
534
+ scripts\restore-profile.cmd sha256=4c902fd65efb4c7419bf33b9398cf0cf26b7b59286a9754dc83435dd36d7694a ← 同上
535
+ scripts\drill-rescue.ps1 sha256=42a414695ba202555b9a4ac416bbc224e0f78d84c3e97edcea7922c4e6ea7392 ← 同上
536
+ ```
537
+
538
+ **提交后在干净工作区(`git status` 为空)上的复验结果,2026-10-29 16:23**:
539
+
540
+ | 检查 | 结果 |
541
+ | --- | --- |
542
+ | `drill-rescue.ps1` | 退出码 **0**,**22 条 `[通过]`、0 条 `[失败]`** |
543
+ | 我自写的更狠演练 | 退出码 **0**,**PASS=33、FAIL=0** |
544
+ | `node --test test\rescue.test.mjs test\scripts-encoding.test.mjs` | **tests 36 / pass 36 / fail 0** |
545
+ | 用户真实 profile 基线 | SHA256、mtime、ctime、6565 文件树指纹 **四项全部仍与基线一致** |
546
+ | `profiles\` 下有没有 `broken-*` | **没有** |
547
+
548
+ 这一轮演练的临时目录是
549
+ `%TEMP%\dsh-rescue-drill-9de04366792240c98a460c7486f5bba3`(32 位完整 GUID —— t3 的新规矩),
550
+ 跑完**自己清掉了**。
551
+
552
+ ### 16:28 再收一次尾(BOM 二次回归修好之后)
553
+
554
+ `dev` 修完第二次 BOM 回归后,我在**当时的工作区**上又整体跑了一遍,仍然全绿:
555
+
556
+ | 检查 | 结果 |
557
+ | --- | --- |
558
+ | `scripts\restore-profile.ps1` / `scripts\drill-rescue.ps1` 的 BOM | 都在(`EF BB BF`) |
559
+ | 编码硬闸 `node --test test\scripts-encoding.test.mjs` | **pass 6 / fail 0**(硬闸自己又长了两条) |
560
+ | `drill-rescue.ps1` | 退出码 **0**,**28 条 `[通过]`、0 条 `[失败]`** |
561
+ | 我自写的更狠演练 | 退出码 **0**,**PASS=33、FAIL=0** |
562
+ | `node --test test\rescue.test.mjs test\scripts-encoding.test.mjs` | **tests 38 / pass 38 / fail 0** |
563
+ | 用户真实 profile 基线 | SHA256、mtime、ctime **三项仍与基线一致**,无 `broken-*` |
564
+
565
+ 当时工作区的脚本指纹:
566
+
567
+ ```
568
+ scripts\restore-profile.ps1 sha256=05b68b499f6c64f27ea4305312fe6d4d913676732220c442c36b574641704f8a ← 与 65a4c98 一致,没再变
569
+ scripts\restore-profile.cmd sha256=4c902fd65efb4c7419bf33b9398cf0cf26b7b59286a9754dc83435dd36d7694a ← 同上
570
+ scripts\drill-rescue.ps1 sha256=db36bb6282f0cfaa2171277883c8233d268afd51f20698572388c0f994c77ef4 ← t3 后续又改过
571
+ test\scripts-encoding.test.mjs sha256=c981c816911c72bf0020146fa66c77ccd97cd7999dae20a5d8bf73a7e275c12d ← 同上
572
+ ```
573
+
574
+ > **关于断言条数**:`drill-rescue.ps1` 的条数随 t3 的推进一路在长 ——
575
+ > t1 原始交付 **17 条** → t3 补完并发与编码 **22 条** → 再补 **28 条**。
576
+ > 报告正文里所有引用的输出片段来自 **22 条那一版**(与提交 `65a4c98` 逐字节一致);
577
+ > 第十六节表格里的最终数字来自 **28 条那一版**。两版都全绿,结论不变。
578
+ >
579
+ > **`restore-profile.ps1` 和 `restore-profile.cmd` 自始至终没有再变过字节** ——
580
+ > 本次演练的核心结论(能救回来、会拒绝、-DryRun 不落盘)在这两个文件的每一个版本上都是一样的。
581
+
582
+ ### 16:33 t4 之后的最终状态(commit `7209c4a`)
583
+
584
+ t4 把拒绝类断言升级成了「退出码 + 输出里的拒绝原因」(见第九节)。
585
+ 我在**提交后的干净工作区**上又整体复验了一遍,全绿:
586
+
587
+ | 检查 | 结果 |
588
+ | --- | --- |
589
+ | `scripts\drill-rescue.ps1` 的 BOM | 在(`EF BB BF`) |
590
+ | `drill-rescue.ps1` | 退出码 **0**,**28 条 `[通过]`、0 条 `[失败]`**(含 7 条新的「退出码 + 拒绝原因」断言) |
591
+ | 我自写的更狠演练 | 退出码 **0**,**PASS=33、FAIL=0** |
592
+ | `node --test test\rescue.test.mjs test\scripts-encoding.test.mjs` | **tests 38 / pass 38 / fail 0** |
593
+ | 用户真实 profile 基线 | SHA256、mtime、ctime、6565 文件树指纹 **四项全部仍与基线一致** |
594
+ | `profiles\` 下有没有 `broken-*` | **没有** |
595
+
596
+ 当时的脚本指纹(= 已提交版本):
597
+
598
+ ```
599
+ scripts\restore-profile.ps1 sha256=05b68b499f6c64f27ea4305312fe6d4d913676732220c442c36b574641704f8a
600
+ scripts\restore-profile.cmd sha256=4c902fd65efb4c7419bf33b9398cf0cf26b7b59286a9754dc83435dd36d7694a
601
+ scripts\drill-rescue.ps1 sha256=db36bb6282f0cfaa2171277883c8233d268afd51f20698572388c0f994c77ef4
602
+ ```
603
+
604
+ **本报告到此覆盖了完整的一轮生命周期**:
605
+ 造坏 → 救回 → 断言 → 基线复核 → 编码事故 → 事故复现 → 硬闸生效 → 断言升级 → 复验。
606
+ 所有环节的最终状态都是绿的,而用户真实的 profile 从头到尾一个字节都没被碰过。