dsh-recency-guard 0.0.0-stage → 0.10.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.
- package/LICENSE +21 -0
- package/README.md +375 -2
- package/bin/rescue.mjs +269 -0
- package/cordis.patch.yml +16 -0
- package/dist/client.js +3118 -0
- package/dist/index.js +5256 -0
- package/locale/en.json +270 -0
- package/locale/zh.json +270 -0
- package/package.json +125 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-recency-guard contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,376 @@
|
|
|
1
|
-
|
|
1
|
+
## v0.10 新增(可观测性数学增强 + 洞察卡 + 插件信息/反馈入口)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
- **转移矩阵卡(马尔可夫链)**:阶段行为的「去向分布」——monitoring 是健康值守
|
|
4
|
+
(80% 留在自己)还是高危巡逻(40% 掉进 crashed)一眼可见;幂法迭代求平稳分布
|
|
5
|
+
(ε=1e-9,L∞ 判敛),回答「长期来看守护停在哪个阶段」。样本不足时诚实显示
|
|
6
|
+
「不可判定」,白名单外迁移被丢弃且如实计数。
|
|
7
|
+
- **控制带(休哈特单值-MR)**:崩溃间隔的 x̄±3σ̂(σ̂=MR̄/1.128)控制带,
|
|
8
|
+
区分「偶然波动」与「特殊原因」——哪一次间隔越界(崩溃变密)被点名到序号;
|
|
9
|
+
与 CUSUM 变点、回归斜率、生存分析互为视角(趋势看方向,控制带看单点越界)。
|
|
10
|
+
- **数据流卡**:journal 原文 → 事件投影 → 语义流动 → 面板渲染的四环链路白盒,
|
|
11
|
+
每环带说明与计数 —— 「画面上的事实从哪来、经过什么变换」不再靠猜。
|
|
12
|
+
- **系统诊断卡 + 插件信息面板 + 反馈入口(R18)**:插件版本 / 宿主版本 / 运行环境 /
|
|
13
|
+
**分区键** / 数据面状态 / 快照耗时一卡尽览;仓库链接来自 package.json 的
|
|
14
|
+
`repository`/`bugs`,「问题反馈」按钮直达 GitHub Issues(未登记时隐藏按钮,
|
|
15
|
+
绝不放死链);工具 `status` 的命令窗口输出同步携带这两条。
|
|
16
|
+
- **命令窗口显示修复(R10)**:`rescue.mjs` 的 status/history 输出按 CJK 显示宽
|
|
17
|
+
(全角 2 列)对齐 —— 中文标签与英文值混排不再错位(rescue 零依赖纪律不变,
|
|
18
|
+
宽度判定内联实现)。
|
|
19
|
+
|
|
20
|
+
## v0.9.1 新增(判定可视化)
|
|
21
|
+
|
|
22
|
+
- **恢复判定条**:崩溃后的分级判定现在到达用户可见面 —— 面板生命周期卡片顶部
|
|
23
|
+
显示「观察中 / 已行动」标签 + 完整判定理由 + 进度(第 N/M 轮,act=false 虚线中性色,
|
|
24
|
+
「观察中」不是错误);工具 `status` 的 facts 同步携带。undefined(从未崩溃过)
|
|
25
|
+
不渲染假判定。
|
|
26
|
+
- **事件流高亮两阶段**:观察轮/恢复判定/两阶段对账的 journal note 在事件流卡片
|
|
27
|
+
用 warn/success 徽标区分(识别 detail 前缀派生语义,存储词汇不变)。
|
|
28
|
+
|
|
29
|
+
## v0.9 新增(恢复置信分级 + 两阶段提交 + CLI 逃生通道 + 卡片限高)
|
|
30
|
+
|
|
31
|
+
- **恢复置信分级**:唯一嫌疑是确定性证据(第 1 轮即关闭);多嫌疑是概率性证据,
|
|
32
|
+
同一头号嫌疑连续 N 轮(默认 2,可配 `confirmRounds`)复现才自动关闭。
|
|
33
|
+
偶发崩溃不再误伤无辜插件 —— 观察轮的崩溃证据链完整保留,供下次判定。
|
|
34
|
+
- **两阶段提交 + 重启对账**:关闭动作分 prepared(记录待执行)→ quarantined
|
|
35
|
+
(已生效)两阶段;崩溃打断时下次启动按磁盘现状对账(confirmed/reverted),
|
|
36
|
+
不留半成品状态。
|
|
37
|
+
- **CLI 逃生通道**:`node bin/rescue.mjs status|history|decision|archives
|
|
38
|
+
--profile-dir <dir>` —— 宿主起不来时离线读取守护产物(零依赖、只读、
|
|
39
|
+
语义化退出码 2 = 有未恢复崩溃,供脚本联动)。
|
|
40
|
+
- **面板备份策略卡片**:时间线备份开关/保留份数/备份根/配置编辑服务可用性
|
|
41
|
+
一目了然(只读投影;改配置走宿主设置页)。
|
|
42
|
+
- **卡片固定高度 + 扩大**:每张展开卡片内容区限高 240px 内部滚动(窄侧栏
|
|
43
|
+
180px),视口密度均匀;卡片头部 ⤢ 按钮临时解除限高摊开阅读,与折叠态独立。
|
|
44
|
+
|
|
45
|
+
## v0.8 新增(备份增强 + 官方实验插件观测 + 事件驱动)
|
|
46
|
+
|
|
47
|
+
- **时间线备份**:插件更新时自动把「更新前」的配置快照存为 `<版本>@<时刻>.json`,
|
|
48
|
+
每插件默认保留 5 份(可配),更新前后的配置可逐版对账 —— 回答「升级后行为变了,
|
|
49
|
+
是配置变了还是 bug」。工具 `action=backups` 同时报告最近备份与每插件时间线。
|
|
50
|
+
- **自定义备份目录**:配置 `backupRoot` 或环境变量 `RECENCY_GUARD_BACKUP_ROOT`;
|
|
51
|
+
自定义根下仍按分区键分层,多项目隔离不因集中存放而失效。
|
|
52
|
+
- **官方实验插件观测**:`@deepseek-ai/dsh-experimental-*` 只记录不关闭
|
|
53
|
+
(journal `official-observe` 事件 + 整组状态的时间线备份)。它们是官方的
|
|
54
|
+
实验场,崩溃概率最高,但自动关闭官方组件的风险不可接受 —— 现在崩溃归因时
|
|
55
|
+
能回答「当时装了哪些实验组件、什么版本、什么状态」。
|
|
56
|
+
- **事件驱动变更检测**:订阅宿主 `plugin-manager/changed` 事件(500ms 防抖),
|
|
57
|
+
GUI/工具路径的安装更新**立即**进时间线与嫌疑面,不用等重启。CLI/手工编辑
|
|
58
|
+
不发此事件,仍由每次启动的同步 diff 兑底 —— 事件是加速器,不是唯一真相。
|
|
59
|
+
- **嫌疑排名显示修复**:数据层不再截断(曾 slice(0,10) 且渲染又 limit 5,
|
|
60
|
+
「报 N 只列 5」);命令窗口列 15 条带省略计数,面板默认 15 条 + 「展开其余 N 个」。
|
|
61
|
+
|
|
62
|
+
# dsh-recency-guard
|
|
63
|
+
|
|
64
|
+
**插件崩溃恢复守护** —— 解决"添加或更新第三方/实验性插件导致 DSH 崩溃、必须禁用所有插件再逐个添加"的问题。
|
|
65
|
+
|
|
66
|
+
当某个插件把 DSH 拖垮时,本插件会在崩溃的那一刻记录下"谁最可疑",并在**下一次启动时、在崩溃插件的代码被加载之前**把它挡在门外。反复崩溃时,关闭的插件会逐个增加(第 1 次关最近更新的 1 个,仍崩溃则关最近更新的 2 个)。稳定之后,除首要嫌疑外,其余被关闭的配置会自动恢复。
|
|
67
|
+
|
|
68
|
+
## v0.7 新增(崩溃级修复 + 可交互图谱 + 可观测性白盒化)
|
|
69
|
+
|
|
70
|
+
**先说最要紧的一条:v0.6 会把 profile 写崩。**
|
|
71
|
+
|
|
72
|
+
- **崩溃级修复:守护行 YAML 注入**。v0.6 写入守护行时 `name:` 没有加引号,
|
|
73
|
+
而 YAML 把 `@` 列为保留指示符 —— 于是任意 scoped 包(`@linxin666/...`)
|
|
74
|
+
都会让**整份 `cordis.patch.yml` 不可解析**,DSH 起不来。真机桌面 profile
|
|
75
|
+
已中招(第 185/189/193 行)。v0.7 改为「永远引号 + 写前校验 + 原子回滚 +
|
|
76
|
+
pristine 备份」三层防线,并提供 `npm run repair-patch` 修复既有污染。
|
|
77
|
+
- **静默失效修复**:`apply` 里直接读未 `inject` 的 `ctx.configEditor` 会抛错,
|
|
78
|
+
表现为「端口在听、UI 能开,但插件根本没激活」(日志只有一行
|
|
79
|
+
`warning: 1 entry did not activate`)。改走 `tryReadService` 后消失。
|
|
80
|
+
- **可交互图谱**:hover 高亮 + tooltip、点击选中联动详情、搜索过滤、
|
|
81
|
+
布局切换、缩放平移钳制;**fit 只做一次**,数据轮询不重置用户缩放
|
|
82
|
+
(实测 7 秒轮询零变化)。滚轮改用原生非 passive 监听,
|
|
83
|
+
修掉「缩放时页面跟着滚」。
|
|
84
|
+
- **面板自己滚动(v0.7.1)**:宿主槽位是 `overflow:hidden` 且整页不滚,
|
|
85
|
+
面板内容约 4000px 被裁到一屏 —— 下方指标/能力清单/归档根本看不到。
|
|
86
|
+
面板改为自带滚动容器(`height:100%` + `min-height:0` + `overflow-y:auto`
|
|
87
|
+
+ `overscroll-behavior:contain`),实测可滚到 `scrollTop=3361` 并看到底部归档区。
|
|
88
|
+
滚轮分工同步调整:**普通滚轮滚动面板,Ctrl/⌘ + 滚轮缩放图谱**
|
|
89
|
+
(缩放入口仍在工具栏三个按钮上)。
|
|
90
|
+
- **可观测性白盒化**:每个指标都带 `formula` / `inputs` / `sampleSize` /
|
|
91
|
+
`confidence`;不可判定时 `defined=false` 且**给原因**("没有崩溃样本"),
|
|
92
|
+
绝不把无数据当 0。CUSUM 变点按**相邻崩溃间隔**回归(不是时间戳、不是累计计数)。
|
|
93
|
+
- **能力闭环**:能力清单与注册处双向比对;`consumes`/`consumedBy` 方向修正;
|
|
94
|
+
21 项能力、对外暴露 12、连通率 100%、孤立 0(面板可见)。
|
|
95
|
+
- **8 条数据面路由全部可用**(曾出现 `isolation` / `lifecycle` 500,
|
|
96
|
+
根因是 RouteContext 缺 provider)。
|
|
97
|
+
- **模块拆分降复杂度**:`patch.ts` 142 → `patch-scalar.ts`(87) + `patch-yaml.ts`
|
|
98
|
+
+ `patch.ts` 门面,band-detector `Status: OK`。
|
|
99
|
+
|
|
100
|
+
## v0.6 新增
|
|
101
|
+
|
|
102
|
+
- **崩溃压力(EWMA)**:新增 KPI「崩溃压力」——EWMA 平滑的近期崩溃密度
|
|
103
|
+
(半衰期 5 次启动)。同样 1/3 的崩溃率,崩溃集中在最近时压力更高,
|
|
104
|
+
这是整体崩溃率给不出的时间局部性。无观测样本时诚实显示「不可判定」。
|
|
105
|
+
- **归档摘要**:归档清单(工具 `archives` / 面板归档卡片)现在带 manifest
|
|
106
|
+
摘要——那次归档时的崩溃次数、事件数、被关插件。「举他对照」时能看到
|
|
107
|
+
基线事实,而不只是「归过档」。
|
|
108
|
+
- **隔离可见性**:工具 `status` 输出新增「工作区 · 分区」行,命令窗口可
|
|
109
|
+
对账「这次守护是哪个项目的事实」;面板 header 显示可读项目名 +
|
|
110
|
+
完整工作区路径(悬停全文),哈希前缀退居兜底。
|
|
111
|
+
- **图谱邻居导航**:节点详情侧栏新增上游(生产方)/下游(消费方)邻居
|
|
112
|
+
列表,点击即跳转定位——度数回答「有几条边」,邻居回答「连到谁」。
|
|
113
|
+
|
|
114
|
+
## v0.5 新增
|
|
115
|
+
|
|
116
|
+
- **图谱交互二次增强**:节点拖拽重排(布局偏移跨轮询保持)、搜索定位(id/标签/引用三级优先匹配,回车跳第一个)、节点详情侧栏(完整 id + 度数 + 定位按钮)、双击节点视口居中、贝塞尔曲线边(同一对端点的平行边奇偶交替弯曲,方向可辨)、高亮边带生产/消费语义标签。
|
|
117
|
+
- **面板现代化**:KPI 摘要条置顶(稳定性 SVG 圆环、当前阶段、崩溃率、趋势判定、CUSUM 变点一览);崩溃间隔统计(中位/CV)与 CUSUM 变点接入指标卡片;轮询暂停/恢复开关(调试截图时画面不再跳动);卡片可折叠(折叠态记入 localStorage,跨会话保持)。
|
|
118
|
+
- **性能债清理**:图谱 id→node 索引由「每条边重建一次」改为「每个节点数组建一次」(O(E·V) → O(V)),节点上百时轮询不再卡顿。
|
|
119
|
+
|
|
120
|
+
## v0.3 新增
|
|
121
|
+
|
|
122
|
+
- **能力闭环可证**:能力清单(注册/生产/消费)与实际注册面双向比对(契约测试 100% 层),三类缺口(消费断链/过度生产/孤立)可检出。
|
|
123
|
+
- **生命周期状态机**:六阶段白名单迁移(idle/monitoring/crashed/deciding/stabilizing/recovered),非法迁移被拒且不改状态,迁移历史带原因与时刻。
|
|
124
|
+
- **可量化可观测**:崩溃率/平均恢复耗时/检出率/隔离纯度/稳定性评分/风险评分/崩溃趋势七个读数,**不可判定时为 null 而非 0**。
|
|
125
|
+
- **管理面板 + 可交互图谱**:`main` 与侧栏双席位,滚轮缩放以指针为锚、拖拽平移、点击高亮追溯(BFS)、Esc 一次退出;响应式(窄屏单列)、主题变量驱动。
|
|
126
|
+
- **多项目隔离**:状态按**项目目录**分区(哈希键),A 项目崩溃不影响 B;守护行经指针文件搭桥。
|
|
127
|
+
- **归档重置 + 举他**:归档 = 搬走 + 留档(manifest 先读后搬),重置后不继承旧结论、按最新 node_modules 事实重推嫌疑;另一工作区可只读清单作对照。
|
|
128
|
+
- **质量评审体系**:Core/Host/Client/Contract 四层测试 + 类型/lint/构建/依赖审计全零门禁(npm audit high:0 critical:0)。
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 目录
|
|
133
|
+
|
|
134
|
+
- [它解决什么问题](#它解决什么问题)
|
|
135
|
+
- [工作原理](#工作原理)
|
|
136
|
+
- [安装](#安装)
|
|
137
|
+
- [配置](#配置)
|
|
138
|
+
- [工具](#工具recency_guard)
|
|
139
|
+
- [数据文件](#数据文件)
|
|
140
|
+
- [恢复流程](#恢复流程)
|
|
141
|
+
- [安全边界](#安全边界)
|
|
142
|
+
- [开发与测试](#开发与测试)
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## 它解决什么问题
|
|
147
|
+
|
|
148
|
+
在 DSH 里添加或更新一个第三方 / 实验性插件后,DSH 可能直接崩溃退出:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
dsh: fatal uncaught exception: Error: ...
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
崩溃发生在插件启动阶段,所以此时**插件本身已经无法运行**,也就无法自己写任何恢复逻辑。传统做法是:手动禁用所有插件 → 重启 → 逐个加回 → 每次重启验证。这既慢又容易再次踩坑。
|
|
155
|
+
|
|
156
|
+
本插件把这件事自动化:
|
|
157
|
+
|
|
158
|
+
| 步骤 | 传统做法 | 本插件 |
|
|
159
|
+
| --- | --- | --- |
|
|
160
|
+
| 记录安装/更新日期 | 靠记忆 | 自动写入日志(崩溃也不丢) |
|
|
161
|
+
| 备份插件配置 | 靠手动复制 | 自动备份到 `.recency-guard/backups/` |
|
|
162
|
+
| 崩溃后定位嫌疑 | 靠猜 | 按"最近更新"排序 |
|
|
163
|
+
| 禁用插件 | 手动改配置 | 自动写入决策文件,重启即生效 |
|
|
164
|
+
| 反复崩溃 | 重复手工操作 | 第 2 次崩溃自动多关一个 |
|
|
165
|
+
| 恢复配置 | 手动逐个加回 | 非首要嫌疑自动恢复 |
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## 工作原理
|
|
170
|
+
|
|
171
|
+
### 1. 崩溃是怎么发生的
|
|
172
|
+
|
|
173
|
+
DSH 的 Loader 会捕获插件 `apply` 中抛出的错误,让该插件失效但不影响其它部分。真正致命的是**未被捕获的 rejection / 未捕获的异常**:Harness 的 fail-loud 处理器会把它们变成一次 `exit(1)`,整个 DSH 随之退出。
|
|
174
|
+
|
|
175
|
+
这意味着恢复**必须发生在崩溃插件被加载之前**,而不是之后。
|
|
176
|
+
|
|
177
|
+
### 2. 崩溃检测:进程级哨兵
|
|
178
|
+
|
|
179
|
+
Harness 在**正常退出和崩溃时都会**释放插件的 fiber,所以插件的 `dispose` 回调无法区分二者。能区分的是**退出码**:正常退出是 0,崩溃是 1。
|
|
180
|
+
|
|
181
|
+
因此本插件在**进程级**注册 `process.on('exit')`(不是 fiber 级 —— cordis 的 disposer 逆序执行,fiber 级的监听器会在进程真正退出前被移除):
|
|
182
|
+
|
|
183
|
+
- 退出码非 0 → 写入 `.recency-guard/crashed` 哨兵文件
|
|
184
|
+
- 同时**同步**写出 `.recency-guard/decision.json` 恢复决策
|
|
185
|
+
|
|
186
|
+
### 3. 恢复决策:在插件被加载之前生效
|
|
187
|
+
|
|
188
|
+
Loader 支持给 patch 行写一个 `disabled: !!js "<表达式>"`。关键在于:**Loader 在导入该行的模块之前就会求值这个表达式**。
|
|
189
|
+
|
|
190
|
+
本插件为每个第三方插件在 profile 的 `cordis.patch.yml` 中写入这样一行(安装时自动完成):
|
|
191
|
+
|
|
192
|
+
```yaml
|
|
193
|
+
- id: some-third-party-plugin
|
|
194
|
+
name: dsh-some-third-party-plugin
|
|
195
|
+
disabled: !!js "(() => { try { const fs = process.getBuiltinModule('fs'); const profile = ctx.get('profileContext'); if (!profile || !profile.dir) return false; const file = profile.dir + '/.recency-guard/decision.json'; if (!fs.existsSync(file)) return false; const decision = JSON.parse(fs.readFileSync(file, 'utf8')); return Array.isArray(decision.disable) && decision.disable.includes('dsh-some-third-party-plugin'); } catch { return false } })()"
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
表达式读取上一轮崩溃时写下的决策文件,命中就把该插件挡在门外 —— **崩溃插件的代码根本不会被加载**,崩溃自然不会重演。
|
|
199
|
+
|
|
200
|
+
> 表达式运行在 Loader 的 `with (ctx) { eval(expr) }` 作用域里:有 `ctx` 和 `process`,没有 `require`。`process.getBuiltinModule('fs')` 在该作用域可用,因此表达式能同步读到决策文件。
|
|
201
|
+
|
|
202
|
+
### 4. 为什么要"崩溃时"而不是"下次启动时"写决策
|
|
203
|
+
|
|
204
|
+
Loader 并发创建各个行。如果等到下次启动、守护插件自己加载时才写决策,决策可能**晚于**嫌疑插件那一行的求值,竞态会让嫌疑插件照常加载。
|
|
205
|
+
|
|
206
|
+
所以决策在**崩溃退出的那一刻**就写好:那时决策文件已经在磁盘上,下一次启动的第一个被求值的就是它。
|
|
207
|
+
|
|
208
|
+
### 5. 嫌疑排序
|
|
209
|
+
|
|
210
|
+
嫌疑 = profile 里声明的、**非官方**(`@deepseek-ai/*` 除外)、且**不是本插件自己**的依赖。排序依据:
|
|
211
|
+
|
|
212
|
+
1. 日志中的安装/更新事件(权威)
|
|
213
|
+
2. `node_modules` 的 mtime(首次运行时兜底)
|
|
214
|
+
|
|
215
|
+
排除官方包是为了保证恢复机制永远不会误伤 harness 自身;排除本插件自己,是因为关掉它就等于关掉恢复机制。
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## 安装
|
|
220
|
+
|
|
221
|
+
### 在主力 profile 中安装
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
dsh plugin --profile <profile名> add dsh-recency-guard
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
安装后重启 DSH。本插件会在启动时自动:
|
|
228
|
+
|
|
229
|
+
- 在 profile 的 `cordis.patch.yml` 中为每个第三方插件写入守护行
|
|
230
|
+
- 开始记录安装 / 更新日志
|
|
231
|
+
|
|
232
|
+
> **安装顺序**:`dsh-recency-guard` 必须排在被守护插件**之前**,这样它才能在启动阶段完成守护行的安装。请在 profile 的 `package.json` 中确认:
|
|
233
|
+
>
|
|
234
|
+
> ```json
|
|
235
|
+
> "dsh": { "profile": { "bundles": [
|
|
236
|
+
> "@deepseek-ai/dsh-base",
|
|
237
|
+
> "dsh-recency-guard",
|
|
238
|
+
> "dsh-你的插件"
|
|
239
|
+
> ] } }
|
|
240
|
+
> ```
|
|
241
|
+
|
|
242
|
+
### 从本地源码安装(开发)
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
dsh plugin --profile <profile名> add file:/绝对路径/dsh-recency-guard
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### 插件市场
|
|
249
|
+
|
|
250
|
+
本插件已提交 [awesome-dsh-plugin](https://github.com/xuanyuanchumo/awesome-dsh-plugin) 收录(`security` 分类),
|
|
251
|
+
收录后即可在 dsh 0.2.1+ 的 **Plugins → Discover** 中搜索到(索引源
|
|
252
|
+
`https://cdn.jsdelivr.net/npm/dsh-plugin-catalog/index.json`),也可通过
|
|
253
|
+
[dsh-market](https://github.com/dsh-market/dsh-market#readme) 一键安装。
|
|
254
|
+
npm 发布后市场索引会自动携带 npm 版本;GitHub-only 期间用上面的命令安装。
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## 配置
|
|
259
|
+
|
|
260
|
+
在 profile 的 `cordis.patch.yml` 中:
|
|
261
|
+
|
|
262
|
+
```yaml
|
|
263
|
+
- id: recency-guard
|
|
264
|
+
name: dsh-recency-guard
|
|
265
|
+
config:
|
|
266
|
+
enabled: true # 总开关;false 时只记录,绝不自动关闭
|
|
267
|
+
includeExperimental: true # 是否把 experimental/beta 命名的包算作嫌疑
|
|
268
|
+
maxAutoDisable: 2 # 单次恢复最多关闭几个嫌疑插件
|
|
269
|
+
autoRestore: true # 稳定后自动恢复非首要嫌疑的配置
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
| 配置 | 默认 | 说明 |
|
|
273
|
+
| --- | --- | --- |
|
|
274
|
+
| `enabled` | `true` | 总开关。关闭后插件只记录日志,永不自动禁用任何插件 |
|
|
275
|
+
| `includeExperimental` | `true` | 实验性/beta 命名的包是否算嫌疑 |
|
|
276
|
+
| `maxAutoDisable` | `2` | 单次恢复最多关闭几个。崩溃次数递增,但封顶于此值 |
|
|
277
|
+
| `autoRestore` | `true` | 稳定启动后自动重新启用非首要嫌疑 |
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## 工具:`recency_guard`
|
|
282
|
+
|
|
283
|
+
| action | 作用 |
|
|
284
|
+
| --- | --- |
|
|
285
|
+
| `status` | 查看崩溃状态、嫌疑排名、被自动关闭的插件、备份列表;输出带「工作区 · 分区」行(多项目时对账归因) |
|
|
286
|
+
| `history` | 查看安装 / 更新 / 崩溃 / 关闭 / 恢复的完整日志 |
|
|
287
|
+
| `recover` | 立即按"最近更新"执行一次恢复关闭(无需等崩溃) |
|
|
288
|
+
| `restore` | 重新启用被自动关闭的非首要嫌疑插件 |
|
|
289
|
+
| `reenable` | 手动重新启用指定插件(`bundle` 参数) |
|
|
290
|
+
| `backups` | 列出配置备份 |
|
|
291
|
+
| `archive` | 归档当前产物并重置(按最新事实重新开始) |
|
|
292
|
+
| `archives` | 列出历史归档,带 manifest 摘要(崩溃次数 / 事件数 / 曾关插件) |
|
|
293
|
+
|
|
294
|
+
示例:
|
|
295
|
+
|
|
296
|
+
```json
|
|
297
|
+
{ "action": "status" }
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## 数据文件
|
|
303
|
+
|
|
304
|
+
全部位于 profile 目录下:
|
|
305
|
+
|
|
306
|
+
```
|
|
307
|
+
<profile>/.recency-guard/
|
|
308
|
+
├── journal.jsonl # 只追加的事件日志:install / update / crash-detected / disable / restore …
|
|
309
|
+
├── snapshot.json # 上次观测到的 { 插件名: 版本 },用于识别更新
|
|
310
|
+
├── state.json # 崩溃计数、被自动关闭的插件、已恢复的插件
|
|
311
|
+
├── decision.json # 本轮要拦截的插件(守护行的表达式读它)
|
|
312
|
+
├── crashed # 崩溃哨兵:非零退出码时写下
|
|
313
|
+
└── backups/ # 关闭前备份的配置
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## 恢复流程
|
|
319
|
+
|
|
320
|
+
```
|
|
321
|
+
第 1 次崩溃
|
|
322
|
+
└─ 退出码非 0 → 写哨兵 + 决策(关闭最近更新的 1 个嫌疑)
|
|
323
|
+
第 2 次启动
|
|
324
|
+
└─ 决策文件已就位 → 嫌疑插件的模块不被加载
|
|
325
|
+
├─ 仍然崩溃 → 关闭数 +1(最近更新的 2 个)
|
|
326
|
+
└─ 启动成功 → 视为恢复
|
|
327
|
+
第 3 次启动(稳定)
|
|
328
|
+
└─ 自动重新启用除首要嫌疑外的插件
|
|
329
|
+
└─ 首要嫌疑保持关闭,等你确认后用 reenable 手动开启
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
**首要嫌疑**(最后被关闭的那个)会一直保持关闭,直到你明确用 `reenable` 开启它 —— 避免刚恢复就再次崩溃。
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
## 安全边界
|
|
337
|
+
|
|
338
|
+
本插件在设计上刻意保守:
|
|
339
|
+
|
|
340
|
+
- **只碰第三方插件**:`@deepseek-ai/*` 官方包永远不是嫌疑,harness 核心不会被关掉。
|
|
341
|
+
- **不碰自己**:`dsh-recency-guard` 永远不在嫌疑名单里。
|
|
342
|
+
- **决策文件缺失 / 损坏时一律放行**:表达式在任何异常下都返回 `false`,插件照常加载。守护机制**永远不会**把 profile 锁死在插件之外。
|
|
343
|
+
- **无法读取时不影响启动**:所有文件操作都包在 `try/catch` 中,失败只记录日志。
|
|
344
|
+
- **官方包与 harness 组件不受影响**:崩溃检测只影响"用户自己加的插件"这一层。
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## 开发与测试
|
|
349
|
+
|
|
350
|
+
```bash
|
|
351
|
+
npm install
|
|
352
|
+
npm run build # esbuild 打包到 lib/
|
|
353
|
+
npm run typecheck # tsc --noEmit
|
|
354
|
+
npm test # 单元测试 + 工具行为测试
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
工具行为测试需要 DSH 运行时包(harness 由应用提供,不在 profile 的 node_modules 中):
|
|
358
|
+
|
|
359
|
+
```bash
|
|
360
|
+
npm run extract-runtime # 从 DSH 安装目录解出运行时包到 .tmp/runtime
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### 沙箱验证
|
|
364
|
+
|
|
365
|
+
开发时务必在独立沙箱 profile 中验证,不要动主力 profile:
|
|
366
|
+
|
|
367
|
+
```bash
|
|
368
|
+
dsh plugin --profile sb-recency-guard add file:/绝对路径/dsh-recency-guard
|
|
369
|
+
dsh --profile sb-recency-guard --help
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
## 许可证
|
|
375
|
+
|
|
376
|
+
MIT
|
package/bin/rescue.mjs
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* recency-guard rescue —— 宿主崩溃后的离线逃生通道(v0.9.0)。
|
|
4
|
+
*
|
|
5
|
+
* ## 为什么需要它
|
|
6
|
+
*
|
|
7
|
+
* 宿主起不来时,插件内的工具、路由、面板全部不可达 —— 而那正是最需要
|
|
8
|
+
* 看到「上次崩溃是谁、守护做了什么」的时刻(cookie-guard 的
|
|
9
|
+
* prune-cookies.mjs 实证了「插件只防不治,治靠宿主外脚本」的分工)。
|
|
10
|
+
*
|
|
11
|
+
* 本脚本**零依赖**(只用 node 内置模块),不启动宿主,直接读 profile
|
|
12
|
+
* 目录下的守护产物:
|
|
13
|
+
*
|
|
14
|
+
* node bin/rescue.mjs status --profile-dir <profile目录> [--project <key|label>]
|
|
15
|
+
* node bin/rescue.mjs history --profile-dir <profile目录> [--limit 40]
|
|
16
|
+
* node bin/rescue.mjs decision --profile-dir <profile目录>
|
|
17
|
+
* node bin/rescue.mjs archives --profile-dir <profile目录>
|
|
18
|
+
*
|
|
19
|
+
* 只读:本脚本绝不写任何文件 —— 逃生通道的第一纪律是「别把现场弄乱」。
|
|
20
|
+
* 写操作(归档/恢复)留给宿主活着时的工具与面板。
|
|
21
|
+
*
|
|
22
|
+
* 退出码:0 = 正常;1 = 参数错误;2 = 发现未恢复的崩溃状态(供脚本联动)。
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
25
|
+
import { join, resolve } from 'node:path'
|
|
26
|
+
|
|
27
|
+
const GUARD_DIR = '.recency-guard'
|
|
28
|
+
|
|
29
|
+
function fail(message, code = 1) {
|
|
30
|
+
console.error(`rescue: ${message}`)
|
|
31
|
+
process.exit(code)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseArgs(argv) {
|
|
35
|
+
const args = { _: [] }
|
|
36
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
37
|
+
const a = argv[i]
|
|
38
|
+
if (a === '--profile-dir') args.profileDir = argv[++i]
|
|
39
|
+
else if (a === '--project') args.project = argv[++i]
|
|
40
|
+
else if (a === '--limit') args.limit = Number(argv[++i])
|
|
41
|
+
else if (a === '--help' || a === '-h') args.help = true
|
|
42
|
+
else args._.push(a)
|
|
43
|
+
}
|
|
44
|
+
return args
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function usage() {
|
|
48
|
+
console.log(`
|
|
49
|
+
recency-guard rescue —— 宿主崩溃后的离线守护产物查询(只读)
|
|
50
|
+
|
|
51
|
+
用法:
|
|
52
|
+
node bin/rescue.mjs <command> --profile-dir <profile目录> [选项]
|
|
53
|
+
|
|
54
|
+
命令:
|
|
55
|
+
status 崩溃状态、守护决策、自动关闭名单
|
|
56
|
+
history journal 事件时间线(--limit N,默认 40)
|
|
57
|
+
decision 预启动决策文件全文(守护行正在执行什么)
|
|
58
|
+
archives 归档清单(带 manifest 摘要)
|
|
59
|
+
|
|
60
|
+
选项:
|
|
61
|
+
--profile-dir <dir> profile 根目录(含 .recency-guard/)
|
|
62
|
+
--project <key|label> 多项目 profile 时指定分区(默认取最近活跃的)
|
|
63
|
+
--limit <n> history 条数
|
|
64
|
+
|
|
65
|
+
退出码:0 正常;1 参数错误;2 发现未恢复的崩溃状态。
|
|
66
|
+
`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 读分区目录:显式指定优先,否则取 lastSeenAt 最新的分区。 */
|
|
70
|
+
function pickPartition(guardDir, project) {
|
|
71
|
+
const indexPath = join(guardDir, 'partitions.json')
|
|
72
|
+
let partitions = []
|
|
73
|
+
try {
|
|
74
|
+
partitions = JSON.parse(readFileSync(indexPath, 'utf8')).partitions ?? []
|
|
75
|
+
} catch {
|
|
76
|
+
/* 没有索引:单分区形态,直接找 projects 下的唯一目录 */
|
|
77
|
+
}
|
|
78
|
+
let key
|
|
79
|
+
if (project) {
|
|
80
|
+
const hit = partitions.find((p) => p.key === project || p.label === project)
|
|
81
|
+
if (hit === undefined) fail(`找不到分区 ${JSON.stringify(project)}(可用:${partitions.map((p) => p.label).join(', ') || '无索引'})`)
|
|
82
|
+
key = hit.key
|
|
83
|
+
} else if (partitions.length > 0) {
|
|
84
|
+
const sorted = [...partitions].sort((a, b) => String(b.lastSeenAt).localeCompare(String(a.lastSeenAt)))
|
|
85
|
+
key = sorted[0].key
|
|
86
|
+
} else {
|
|
87
|
+
const projectsDir = join(guardDir, 'projects')
|
|
88
|
+
if (!existsSync(projectsDir)) fail(`没有分区索引也没有 projects 目录:${projectsDir}`)
|
|
89
|
+
key = readdirSync(projectsDir)[0]
|
|
90
|
+
}
|
|
91
|
+
const dir = join(guardDir, 'projects', key)
|
|
92
|
+
if (!existsSync(dir)) fail(`分区目录不存在:${dir}`)
|
|
93
|
+
const meta = partitions.find((p) => p.key === key)
|
|
94
|
+
return { key, dir, label: meta?.label ?? key, workspace: meta?.workspace }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readJsonSafe(file) {
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
100
|
+
} catch {
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** journal 读取(容忍崩溃截断的尾行,与 store.ts 同一容错语义)。 */
|
|
106
|
+
function readJournal(partitionDir, limit) {
|
|
107
|
+
const file = join(partitionDir, 'journal.jsonl')
|
|
108
|
+
if (!existsSync(file)) return []
|
|
109
|
+
const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean)
|
|
110
|
+
const take = Math.max(0, Math.min(limit, lines.length))
|
|
111
|
+
const out = []
|
|
112
|
+
for (const line of lines.slice(lines.length - take)) {
|
|
113
|
+
try {
|
|
114
|
+
out.push(JSON.parse(line))
|
|
115
|
+
} catch {
|
|
116
|
+
/* torn tail line */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function fmtTime(iso) {
|
|
123
|
+
return typeof iso === 'string' ? iso.replace('T', ' ').slice(0, 19) : '?'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 一个码点占多少终端显示列(v0.10 R10 命令窗口显示修复)。
|
|
128
|
+
*
|
|
129
|
+
* 为什么 rescue.mjs 自带一份而不是 import src/tool-text:本脚本的纪律是
|
|
130
|
+
* **零依赖**(崩溃后 node_modules 可能正处于损坏状态,那正是要用逃生通道
|
|
131
|
+
* 的时刻)。这里只实现 status/history 用到的列对齐所需的宽度判定,
|
|
132
|
+
* 完整的折行/中和原语仍在 src/tool-text.ts(工具输出走那条路)。
|
|
133
|
+
*/
|
|
134
|
+
function codePointWidth(cp) {
|
|
135
|
+
const wide =
|
|
136
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
137
|
+
(cp >= 0x2e80 && cp <= 0x303e) ||
|
|
138
|
+
(cp >= 0x3041 && cp <= 0x33ff) ||
|
|
139
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
140
|
+
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
141
|
+
(cp >= 0xa000 && cp <= 0xa4cf) ||
|
|
142
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
143
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
144
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
145
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
146
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
147
|
+
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
148
|
+
(cp >= 0x20000 && cp <= 0x3fffd)
|
|
149
|
+
return wide ? 2 : 1
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 文本的显示列宽(CJK 全宽算 2 列;`length` 数的是码元,中文会错一半)。 */
|
|
153
|
+
function displayWidth(text) {
|
|
154
|
+
let width = 0
|
|
155
|
+
for (const ch of String(text)) width += codePointWidth(ch.codePointAt(0) ?? 0)
|
|
156
|
+
return width
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 按显示列右填充(`padEnd` 对 CJK 会少补空格 ⇒ 后续列错位,实测踩过)。 */
|
|
160
|
+
function padWidth(text, width) {
|
|
161
|
+
const pad = width - displayWidth(text)
|
|
162
|
+
return pad > 0 ? text + ' '.repeat(pad) : String(text)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function cmdStatus(partition) {
|
|
166
|
+
console.log(`分区:${partition.label}(${partition.key})`)
|
|
167
|
+
if (partition.workspace) console.log(`工作区:${partition.workspace}`)
|
|
168
|
+
const state = readJsonSafe(join(partition.dir, 'state.json')) ?? {}
|
|
169
|
+
const decision = readJsonSafe(join(partition.dir, 'decision.json'))
|
|
170
|
+
const sentinelFile = join(partition.dir, 'sentinel.json')
|
|
171
|
+
const sentinel = existsSync(sentinelFile) ? readJsonSafe(sentinelFile) : null
|
|
172
|
+
|
|
173
|
+
// 键值对齐表(v0.10 R10):键列按显示宽取齐,值列从同一列起 ——
|
|
174
|
+
// 中文键(「崩溃计数」4 字 8 列)与英文值混排时 padEnd 会错位。
|
|
175
|
+
const disabled = state.autoDisabled ?? []
|
|
176
|
+
const rows = [
|
|
177
|
+
['崩溃计数', String(state.crashSeq ?? 0)],
|
|
178
|
+
['上次运行', state.crashedRun ? `崩溃(退出码 ${state.lastExitCode ?? '?'})` : '干净退出'],
|
|
179
|
+
]
|
|
180
|
+
if (state.bootMarker) {
|
|
181
|
+
rows.push(['boot 标记', `${fmtTime(state.bootMarker.startedAt)} pid=${state.bootMarker.pid}(存在 = 进程没走干净退出)`])
|
|
182
|
+
}
|
|
183
|
+
rows.push(['保持关闭', disabled.length === 0 ? '无' : `${disabled.length} 个`])
|
|
184
|
+
const keyWidth = Math.max(...rows.map(([key]) => displayWidth(key)))
|
|
185
|
+
console.log('')
|
|
186
|
+
for (const [key, value] of rows) console.log(` ${padWidth(`${key}:`, keyWidth + 1)}${value}`)
|
|
187
|
+
for (const d of disabled) {
|
|
188
|
+
console.log(` - ${d.name}(第 ${d.crashSeq} 次崩溃后关闭 @ ${fmtTime(d.disabledAt)},原因 ${d.reason})`)
|
|
189
|
+
}
|
|
190
|
+
if (decision) {
|
|
191
|
+
console.log('')
|
|
192
|
+
console.log(`决策文件:${decision.reason} · phase=${decision.phase ?? '(旧格式,无 phase)'} · captured ${fmtTime(decision.capturedAt)}`)
|
|
193
|
+
console.log(` disable(本轮被拦截):${decision.disable.length === 0 ? '(空 = 全放行)' : decision.disable.join(', ')}`)
|
|
194
|
+
console.log(` 嫌疑名单:${(decision.suspects ?? []).map((s) => s.bundle).join(', ') || '无'}`)
|
|
195
|
+
}
|
|
196
|
+
if (sentinel) console.log(`\n哨兵残留:退出码 ${sentinel.exitCode} @ ${fmtTime(sentinel.at)}(宿主没走到清理就死了)`)
|
|
197
|
+
return state.crashedRun === true || state.crashSeq > 0 ? 2 : 0
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function cmdHistory(partition, limit) {
|
|
201
|
+
const events = readJournal(partition.dir, limit)
|
|
202
|
+
console.log(`分区:${partition.label} · 最近 ${events.length} 条事件\n`)
|
|
203
|
+
// kind 列固定显示宽:中文 subject 的宽度不再把时间/kind 列挤歪。
|
|
204
|
+
const KIND_COL = 18
|
|
205
|
+
for (const e of events) {
|
|
206
|
+
const subject = e.bundle ?? e.detail ?? ''
|
|
207
|
+
const kindCell = padWidth(String(e.kind), KIND_COL)
|
|
208
|
+
console.log(`${fmtTime(e.t)} ${kindCell} ${subject}${e.version ? ` v${e.version}` : ''}`)
|
|
209
|
+
}
|
|
210
|
+
return 0
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function cmdDecision(partition) {
|
|
214
|
+
const decision = readJsonSafe(join(partition.dir, 'decision.json'))
|
|
215
|
+
if (decision === null) {
|
|
216
|
+
console.log('没有决策文件 —— 守护行本轮全放行(或从未发布过决策)。')
|
|
217
|
+
return 0
|
|
218
|
+
}
|
|
219
|
+
console.log(JSON.stringify(decision, null, 2))
|
|
220
|
+
return 0
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function cmdArchives(partition) {
|
|
224
|
+
const root = join(partition.dir, 'archive')
|
|
225
|
+
if (!existsSync(root)) {
|
|
226
|
+
console.log('没有归档。')
|
|
227
|
+
return 0
|
|
228
|
+
}
|
|
229
|
+
const names = readdirSync(root).sort()
|
|
230
|
+
console.log(`分区:${partition.label} · ${names.length} 份归档\n`)
|
|
231
|
+
for (const name of names) {
|
|
232
|
+
const dir = join(root, name)
|
|
233
|
+
if (!statSync(dir).isDirectory()) continue
|
|
234
|
+
const manifest = readJsonSafe(join(dir, 'manifest.json'))
|
|
235
|
+
const summary = manifest?.summary
|
|
236
|
+
console.log(`- ${name}`)
|
|
237
|
+
if (summary) {
|
|
238
|
+
console.log(` 崩溃 ${summary.crashSeq ?? 0} 次 · 事件 ${summary.journalEvents ?? '?'} 条 · 关闭 ${(summary.autoDisabled ?? []).length || 0} 个`)
|
|
239
|
+
}
|
|
240
|
+
if (manifest?.reason) console.log(` 原因:${manifest.reason}`)
|
|
241
|
+
}
|
|
242
|
+
return 0
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function main() {
|
|
246
|
+
const args = parseArgs(process.argv.slice(2))
|
|
247
|
+
if (args.help || args._.length === 0) {
|
|
248
|
+
usage()
|
|
249
|
+
process.exit(args.help ? 0 : 1)
|
|
250
|
+
}
|
|
251
|
+
const command = args._[0]
|
|
252
|
+
if (!args.profileDir) fail('缺少 --profile-dir <profile 目录>')
|
|
253
|
+
const guardDir = resolve(args.profileDir, GUARD_DIR)
|
|
254
|
+
if (!existsSync(guardDir)) fail(`守护目录不存在:${guardDir}(这个 profile 装过 dsh-recency-guard 吗?)`)
|
|
255
|
+
const partition = pickPartition(guardDir, args.project)
|
|
256
|
+
const limit = Number.isFinite(args.limit) && args.limit > 0 ? args.limit : 40
|
|
257
|
+
let code
|
|
258
|
+
switch (command) {
|
|
259
|
+
case 'status': code = cmdStatus(partition); break
|
|
260
|
+
case 'history': code = cmdHistory(partition, limit); break
|
|
261
|
+
case 'decision': code = cmdDecision(partition); break
|
|
262
|
+
case 'archives': code = cmdArchives(partition); break
|
|
263
|
+
default:
|
|
264
|
+
fail(`未知命令 ${JSON.stringify(command)}(可用:status / history / decision / archives)`)
|
|
265
|
+
}
|
|
266
|
+
process.exit(code)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
main()
|