dsh-my-guardian 0.3.4 → 0.3.6
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 +21 -0
- package/README.md +9 -6
- package/lib/api.js +29 -37
- package/lib/client.js +103 -7
- package/lib/dep-precheck.js +132 -0
- package/lib/dep-version.js +188 -0
- package/lib/events.js +21 -2
- package/lib/mount.js +42 -4
- package/lib/parts/row.part.js +36 -2
- package/lib/parts/styles.part.js +14 -0
- package/lib/parts/util.part.js +24 -2
- package/lib/parts/view.part.js +8 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.6] - 2026-09-04
|
|
9
|
+
|
|
10
|
+
### 变更
|
|
11
|
+
|
|
12
|
+
- fix(guardian): 事件日志不再访问 entry.id getter——构造期 parent.tree 未就绪会抛错炸掉 DSH 启动(隔离实例复现)
|
|
13
|
+
- fix(guardian): 诊断面板可读性 + entry 标识修复——插件失败时不再"看不懂/兜不住"
|
|
14
|
+
- fix(guardian): #86 发版一致性修复——package.json 0.3.5 + CHANGELOG 0.3.5 段(rebase 冲突还原恢复)
|
|
15
|
+
- chore(release): dsh-my-guardian v0.3.5(#86 依赖预检 + 验证清单)
|
|
16
|
+
- feat(guardian): #86 候选区插件依赖预检 + 失败分类 (#124)
|
|
17
|
+
- docs: #106 安装命令统一加 --trust-lockfile (#113)
|
|
18
|
+
|
|
19
|
+
## [0.3.5] - 2026-09-04
|
|
20
|
+
|
|
21
|
+
### 新增
|
|
22
|
+
|
|
23
|
+
- [#86](https://github.com/baosfeng/my-dsh-plugins/issues/86) 候选区插件依赖预检 + 失败分类:热挂载前校验依赖就绪,失败按类别诊断(缺失依赖/锁定冲突/加载错误),诊断面板展示分类详情
|
|
24
|
+
|
|
25
|
+
### 变更
|
|
26
|
+
|
|
27
|
+
- 验证清单与效果图同步(发版前功能级验证归档)
|
|
28
|
+
|
|
8
29
|
## [0.3.4] - 2026-09-02
|
|
9
30
|
|
|
10
31
|
### 变更
|
package/README.md
CHANGED
|
@@ -13,23 +13,24 @@ cordis.patch.yml(核心区:只放稳定插件,守护插件自身)
|
|
|
13
13
|
↓ 新插件写进
|
|
14
14
|
cordis.staged.json(候选区)
|
|
15
15
|
↓ DSH 启动完成后
|
|
16
|
-
|
|
16
|
+
守护插件逐个依赖预检 + 热挂载
|
|
17
17
|
├─ 成功 → 自动转正(进入持久化清单,每次启动自动恢复)
|
|
18
|
-
└─ 失败 →
|
|
18
|
+
└─ 失败 → 自动隔离(记录次数 + 失败类型/错误,连续 3 次冻结)
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
## 功能
|
|
22
22
|
|
|
23
23
|
- **两段式加载**:新装/更新插件先进候选区,启动不阻塞、坏插件不拖垮进程。
|
|
24
|
-
- **失败自动隔离**:挂载失败自动记录(尝试次数 + 错误摘要),连续失败 **3 次冻结**,需手动重试。
|
|
24
|
+
- **失败自动隔离**:挂载失败自动记录(尝试次数 + 失败类型 + 错误摘要),连续失败 **3 次冻结**,需手动重试。
|
|
25
|
+
- **挂载前依赖预检**:候选插件挂载前检查 `peerDependencies`——仓库内 `dsh-*` 依赖是否安装、官方依赖版本是否满足;缺失/不满足即标记「依赖缺失」并给出安装建议(如 `dsh plugin add <依赖>`),不进入挂载。
|
|
25
26
|
- **成功自动转正**:挂载成功的插件进入持久化清单(`$DSH_HOME/guardian/state.json`),后续每次启动自动恢复挂载。
|
|
26
27
|
- **安全模式**:一键跳过所有候选/已转正插件,快速恢复被插件搞坏的环境。
|
|
27
|
-
- **诊断面板**:dsh-better-sidebar 侧边栏"插件守护"页签——状态列表 / 重试 / 移除 / 错误详情 / 安全模式开关 / 最近事件。
|
|
28
|
+
- **诊断面板**:dsh-better-sidebar 侧边栏"插件守护"页签——状态列表 / 重试 / 移除 / 错误详情 / 失败分类徽标 / 安全模式开关 / 最近事件。
|
|
28
29
|
- **运行中热挂载**:DSH 运行期间往候选区加条目,自动挂载,无需重启。
|
|
29
30
|
|
|
30
31
|
## 安装
|
|
31
32
|
|
|
32
|
-
> 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-my-guardian`——无需克隆本仓库;以下 link 方式供本仓库开发者使用。依赖 `dsh-shared`(server 端共享工具包)随 npm 自动安装,无需手动处理。
|
|
33
|
+
> 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-my-guardian --trust-lockfile`——无需克隆本仓库;以下 link 方式供本仓库开发者使用。依赖 `dsh-shared`(server 端共享工具包)随 npm 自动安装,无需手动处理。
|
|
33
34
|
|
|
34
35
|
### 方式一:dsh plugin(推荐)
|
|
35
36
|
|
|
@@ -83,6 +84,8 @@ dsh plugin --profile web add link:<仓库路径>/plugins/dsh-my-guardian
|
|
|
83
84
|
| 失败 ×N | 挂载失败 N 次 | 重试 / 移除 |
|
|
84
85
|
| 冻结 | 连续失败 3 次 | 重试(解除冻结)/ 移除 |
|
|
85
86
|
|
|
87
|
+
失败条目额外带**失败类型徽标**(依赖缺失 / 代码错误 / 其他),依赖缺失时并展示安装建议命令(如 `dsh plugin add dsh-shared`)。
|
|
88
|
+
|
|
86
89
|
### 效果截图(真实 DSH 实例验证)
|
|
87
90
|
|
|
88
91
|
侧边栏"插件守护"诊断面板(独立 3081 端口隔离 DSH 实例实测):
|
|
@@ -91,7 +94,7 @@ dsh plugin --profile web add link:<仓库路径>/plugins/dsh-my-guardian
|
|
|
91
94
|
|
|
92
95
|

|
|
93
96
|
|
|
94
|
-
> 截图环境:隔离 DSH 验证实例(`/tmp/dsh-
|
|
97
|
+
> 截图环境:隔离 DSH 验证实例(`/tmp/dsh-3085`,端口 3085)。候选区写入 `fake-needy`(peer 依赖 `fake-never-installed-dep` 缺失 → 依赖预检拦截,分类徽标「依赖缺失」+ 安装建议 `dsh plugin add fake-never-installed-dep` + 自动隔离冻结)与 `fake-simple`(无依赖 → 热挂载成功自动转正「运行中」)。
|
|
95
98
|
|
|
96
99
|
## 配置
|
|
97
100
|
|
package/lib/api.js
CHANGED
|
@@ -155,45 +155,37 @@ async function handleSafemodePost(ctx, shared, request, response) {
|
|
|
155
155
|
writeJson(response, 200, { ok: true, value: shared.snapshot() })
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/** One row for the panel: status + failure-classification fields (issue #86). */
|
|
159
|
+
function entrySnapshot(shared, id, record, isStaged) {
|
|
160
|
+
const status = shared.mounted.has(id)
|
|
161
|
+
? 'running'
|
|
162
|
+
: record.frozen
|
|
163
|
+
? 'frozen'
|
|
164
|
+
: record.attempts > 0
|
|
165
|
+
? 'failed'
|
|
166
|
+
: 'pending'
|
|
167
|
+
const item = {
|
|
168
|
+
id,
|
|
169
|
+
name: record.name,
|
|
170
|
+
attempts: record.attempts,
|
|
171
|
+
frozen: record.frozen,
|
|
172
|
+
lastError: record.lastError,
|
|
173
|
+
lastFailedAt: record.lastFailedAt,
|
|
174
|
+
failureType: record.failureType ?? null,
|
|
175
|
+
missingDeps: record.missingDeps ?? [],
|
|
176
|
+
installHint: record.installHint ?? null,
|
|
177
|
+
status,
|
|
178
|
+
}
|
|
179
|
+
if (!isStaged) item.promotedAt = record.promotedAt
|
|
180
|
+
return item
|
|
181
|
+
}
|
|
182
|
+
|
|
158
183
|
/** Snapshot for the panel (leaf values only). */
|
|
159
184
|
function snapshot(shared) {
|
|
160
|
-
const stagedList = []
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
name: record.name,
|
|
165
|
-
attempts: record.attempts,
|
|
166
|
-
frozen: record.frozen,
|
|
167
|
-
lastError: record.lastError,
|
|
168
|
-
lastFailedAt: record.lastFailedAt,
|
|
169
|
-
status: shared.mounted.has(id)
|
|
170
|
-
? 'running'
|
|
171
|
-
: record.frozen
|
|
172
|
-
? 'frozen'
|
|
173
|
-
: record.attempts > 0
|
|
174
|
-
? 'failed'
|
|
175
|
-
: 'pending',
|
|
176
|
-
})
|
|
177
|
-
}
|
|
178
|
-
const promotedList = []
|
|
179
|
-
for (const [id, record] of Object.entries(shared.state.promoted)) {
|
|
180
|
-
promotedList.push({
|
|
181
|
-
id,
|
|
182
|
-
name: record.name,
|
|
183
|
-
attempts: record.attempts,
|
|
184
|
-
frozen: record.frozen,
|
|
185
|
-
lastError: record.lastError,
|
|
186
|
-
lastFailedAt: record.lastFailedAt,
|
|
187
|
-
promotedAt: record.promotedAt,
|
|
188
|
-
status: shared.mounted.has(id)
|
|
189
|
-
? 'running'
|
|
190
|
-
: record.frozen
|
|
191
|
-
? 'frozen'
|
|
192
|
-
: record.attempts > 0
|
|
193
|
-
? 'failed'
|
|
194
|
-
: 'pending',
|
|
195
|
-
})
|
|
196
|
-
}
|
|
185
|
+
const stagedList = Object.entries(shared.state.staged).map(([id, record]) => entrySnapshot(shared, id, record, true))
|
|
186
|
+
const promotedList = Object.entries(shared.state.promoted).map(([id, record]) =>
|
|
187
|
+
entrySnapshot(shared, id, record, false),
|
|
188
|
+
)
|
|
197
189
|
return {
|
|
198
190
|
safeMode: shared.state.safeMode,
|
|
199
191
|
staged: stagedList,
|
package/lib/client.js
CHANGED
|
@@ -107,8 +107,22 @@ const STYLES = `
|
|
|
107
107
|
.dsh-my-guardian-badge-pending { color:var(--dsw-alias-accent); background:color-mix(in srgb, var(--dsw-alias-accent) 12%, transparent); }
|
|
108
108
|
.dsh-my-guardian-badge-failed { color:var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent); }
|
|
109
109
|
.dsh-my-guardian-badge-frozen { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
|
|
110
|
+
/* failure-classification badge chips (issue #86): dependency / code / other */
|
|
111
|
+
.dsh-my-guardian-category { flex:none; display:inline-flex; align-items:center; justify-content:center; height:17px; padding:0 5px; border-radius:4px;
|
|
112
|
+
font:var(--dsw-font-xxxs-strong-11); }
|
|
113
|
+
.dsh-my-guardian-category-dependency { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
|
|
114
|
+
.dsh-my-guardian-category-code { color:var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); }
|
|
115
|
+
.dsh-my-guardian-category-other { color:var(--dsw-alias-label-tertiary); background:var(--dsw-alias-interactive-bg-hover); }
|
|
116
|
+
/* install-suggestion line for dependency failures */
|
|
117
|
+
.dsh-my-guardian-install-hint { display:flex; align-items:center; gap:5px; padding:3px 6px; border-radius:6px;
|
|
118
|
+
background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 6%, transparent);
|
|
119
|
+
font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); }
|
|
120
|
+
.dsh-my-guardian-install-hint-label { flex:none; color:var(--dsw-alias-label-tertiary); }
|
|
121
|
+
.dsh-my-guardian-install-hint code { font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:var(--dsw-font-xxxs-11);
|
|
122
|
+
color:var(--dsw-alias-state-success-primary); word-break:break-all; }
|
|
110
123
|
.dsh-my-guardian-row-meta { display:flex; align-items:center; gap:6px; font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); }
|
|
111
124
|
.dsh-my-guardian-attempts { color:var(--dsw-alias-state-error-primary); }
|
|
125
|
+
.dsh-my-guardian-freeze-hint { font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-state-warn-primary); line-height:1.6; }
|
|
112
126
|
.dsh-my-guardian-link { display:inline-flex; align-items:center; gap:3px; padding:0; border:none; background:transparent; cursor:pointer;
|
|
113
127
|
font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary);
|
|
114
128
|
transition:color var(--ds-transition-duration-slow) var(--ds-ease-in-out); }
|
|
@@ -186,8 +200,8 @@ const strings = {
|
|
|
186
200
|
safeMode: () => (isZh() ? '安全模式' : 'Safe mode'),
|
|
187
201
|
safeModeDesc: () =>
|
|
188
202
|
isZh()
|
|
189
|
-
? '
|
|
190
|
-
: 'Skips every staged/promoted plugin mount — fast recovery',
|
|
203
|
+
? '开启后所有候选/已转正插件(候选区 cordis.staged.json 中的条目)都不再加载,用于快速恢复环境。注意:安全模式只作用于启动后挂载的候选插件;若插件直接写进启动名册(cordis.patch.yml / profile),DSH 启动时仍是 all-or-nothing——请把新插件先放进候选区。'
|
|
204
|
+
: 'Skips every staged/promoted plugin mount — fast recovery. Note: this only covers entries mounted after boot (cordis.staged.json); plugins in the boot roster (cordis.patch.yml / profile) are still all-or-nothing — put new plugins in the staged file first.',
|
|
191
205
|
staged: () => (isZh() ? '候选' : 'staged'),
|
|
192
206
|
promoted: () => (isZh() ? '转正' : 'promoted'),
|
|
193
207
|
entries: () => (isZh() ? '插件条目' : 'Plugin entries'),
|
|
@@ -215,6 +229,14 @@ const strings = {
|
|
|
215
229
|
loading: () => (isZh() ? '加载中…' : 'Loading…'),
|
|
216
230
|
events: () => (isZh() ? '最近事件' : 'Recent events'),
|
|
217
231
|
attempts: (n) => (isZh() ? `失败 ${n} 次` : `failed ×${n}`),
|
|
232
|
+
frozenHint: () =>
|
|
233
|
+
isZh()
|
|
234
|
+
? '已冻结:连续失败停止自动重试——点击刷新按钮手动重试,或移除该条目'
|
|
235
|
+
: 'Frozen: auto-retry stopped after repeated failures — retry manually or remove the entry',
|
|
236
|
+
failureDependency: () => (isZh() ? '依赖缺失' : 'Dependency'),
|
|
237
|
+
failureCode: () => (isZh() ? '代码错误' : 'Code error'),
|
|
238
|
+
failureOther: () => (isZh() ? '其他' : 'Other'),
|
|
239
|
+
installHint: () => (isZh() ? '安装建议' : 'Install'),
|
|
218
240
|
}
|
|
219
241
|
|
|
220
242
|
// ── api ───────────────────────────────────────────────────────────────
|
|
@@ -256,6 +278,20 @@ function statusLabel(status) {
|
|
|
256
278
|
}
|
|
257
279
|
}
|
|
258
280
|
|
|
281
|
+
/** Failure-classification badge label (issue #86): dependency / code / other. */
|
|
282
|
+
function failureTypeLabel(type) {
|
|
283
|
+
switch (type) {
|
|
284
|
+
case 'dependency':
|
|
285
|
+
return strings.failureDependency()
|
|
286
|
+
case 'code':
|
|
287
|
+
return strings.failureCode()
|
|
288
|
+
case 'other':
|
|
289
|
+
return strings.failureOther()
|
|
290
|
+
default:
|
|
291
|
+
return type
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
259
295
|
// ── event log ─────────────────────────────────────────────────────────
|
|
260
296
|
// Event type → badge label + color variant (mirrors the dfa-op chip style).
|
|
261
297
|
const EVENT_LABELS = {
|
|
@@ -442,6 +478,27 @@ const icon = {
|
|
|
442
478
|
],
|
|
443
479
|
size,
|
|
444
480
|
),
|
|
481
|
+
// 下载(issue #85 新增):箭头入托盘,图表导出按钮(dsh-mermaid-render
|
|
482
|
+
// 卡片下载 PNG/SVG),stroke=currentColor 风格与其余图标一致。
|
|
483
|
+
download: (size = 16) =>
|
|
484
|
+
iconSvg(
|
|
485
|
+
[
|
|
486
|
+
createElement('path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' }),
|
|
487
|
+
createElement('polyline', { points: '7 10 12 15 17 10' }),
|
|
488
|
+
createElement('line', { x1: 12, y1: 15, x2: 12, y2: 3 }),
|
|
489
|
+
],
|
|
490
|
+
size,
|
|
491
|
+
),
|
|
492
|
+
// 复制(issue #85 新增):双层矩形,复制源码按钮(dsh-mermaid-render
|
|
493
|
+
// 卡片复制代码),stroke=currentColor 风格与其余图标一致。
|
|
494
|
+
copy: (size = 16) =>
|
|
495
|
+
iconSvg(
|
|
496
|
+
[
|
|
497
|
+
createElement('rect', { x: 9, y: 9, width: 13, height: 13, rx: 2 }),
|
|
498
|
+
createElement('path', { d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' }),
|
|
499
|
+
],
|
|
500
|
+
size,
|
|
501
|
+
),
|
|
445
502
|
}
|
|
446
503
|
|
|
447
504
|
// Common-language / file-type badges (issue #24): brand fill + contrast
|
|
@@ -599,7 +656,7 @@ const fileIconByExt = (ext, size = 14) => {
|
|
|
599
656
|
}
|
|
600
657
|
|
|
601
658
|
// ── row ────────────────────────────────────────────────────────────────
|
|
602
|
-
/** Row head: source chip + name + status badge. */
|
|
659
|
+
/** Row head: source chip + name + status badge + failure-category badge. */
|
|
603
660
|
function RowHead({ entry, source }) {
|
|
604
661
|
return createElement(
|
|
605
662
|
'div',
|
|
@@ -615,6 +672,16 @@ function RowHead({ entry, source }) {
|
|
|
615
672
|
{ className: `dsh-my-guardian-badge dsh-my-guardian-badge-${entry.status}` },
|
|
616
673
|
statusLabel(entry.status),
|
|
617
674
|
),
|
|
675
|
+
typeof entry.failureType === 'string' && entry.failureType !== ''
|
|
676
|
+
? createElement(
|
|
677
|
+
'span',
|
|
678
|
+
{
|
|
679
|
+
className: `dsh-my-guardian-category dsh-my-guardian-category-${entry.failureType}`,
|
|
680
|
+
title: entry.failureType,
|
|
681
|
+
},
|
|
682
|
+
failureTypeLabel(entry.failureType),
|
|
683
|
+
)
|
|
684
|
+
: null,
|
|
618
685
|
)
|
|
619
686
|
}
|
|
620
687
|
|
|
@@ -722,11 +789,26 @@ function RowActions({ entry, busy, onRetry, onRemove }) {
|
|
|
722
789
|
)
|
|
723
790
|
}
|
|
724
791
|
|
|
792
|
+
/** 冻结行提示(连败停止自动重试,需手动操作)。 */
|
|
793
|
+
function FrozenHint({ status }) {
|
|
794
|
+
if (status !== 'frozen') return null
|
|
795
|
+
return createElement('div', { className: 'dsh-my-guardian-freeze-hint' }, strings.frozenHint())
|
|
796
|
+
}
|
|
797
|
+
|
|
725
798
|
function EntryRow({ entry, source, onAction }) {
|
|
726
|
-
|
|
799
|
+
// 失败/冻结行默认展开错误详情(用户之前必须手动点开才看得到真正报错)。
|
|
800
|
+
const [expanded, setExpanded] = useState(
|
|
801
|
+
() =>
|
|
802
|
+
typeof entry.lastError === 'string' &&
|
|
803
|
+
entry.lastError !== '' &&
|
|
804
|
+
(entry.status === 'failed' || entry.status === 'frozen'),
|
|
805
|
+
)
|
|
727
806
|
const [confirming, setConfirming] = useState(false)
|
|
728
807
|
const [busy, setBusy] = useState(false)
|
|
729
808
|
const hasError = typeof entry.lastError === 'string' && entry.lastError !== ''
|
|
809
|
+
const isDepFailure = entry.failureType === 'dependency'
|
|
810
|
+
const installHint =
|
|
811
|
+
isDepFailure && typeof entry.installHint === 'string' && entry.installHint !== '' ? entry.installHint : null
|
|
730
812
|
|
|
731
813
|
const run = (kind) => {
|
|
732
814
|
setBusy(true)
|
|
@@ -738,6 +820,15 @@ function EntryRow({ entry, source, onAction }) {
|
|
|
738
820
|
{ className: 'dsh-my-guardian-row' },
|
|
739
821
|
createElement(RowHead, { entry, source }),
|
|
740
822
|
createElement(RowMeta, { entry }),
|
|
823
|
+
createElement(FrozenHint, { status: entry.status }),
|
|
824
|
+
installHint
|
|
825
|
+
? createElement(
|
|
826
|
+
'div',
|
|
827
|
+
{ className: 'dsh-my-guardian-install-hint' },
|
|
828
|
+
createElement('span', { className: 'dsh-my-guardian-install-hint-label' }, strings.installHint()),
|
|
829
|
+
createElement('code', null, installHint),
|
|
830
|
+
)
|
|
831
|
+
: null,
|
|
741
832
|
hasError ? createElement(ErrorToggle, { expanded, onToggle: () => setExpanded(!expanded) }) : null,
|
|
742
833
|
expanded && hasError ? createElement('pre', { className: 'dsh-my-guardian-error-detail' }, entry.lastError) : null,
|
|
743
834
|
confirming
|
|
@@ -884,14 +975,19 @@ function EntryList({ rows, onAction }) {
|
|
|
884
975
|
)
|
|
885
976
|
}
|
|
886
977
|
|
|
887
|
-
/**
|
|
978
|
+
/** 高频噪音事件:每次启动/热重载都会大量产生,挤掉真正重要的诊断信息。 */
|
|
979
|
+
const EVENT_NOISE = new Set(['entry-init', 'entry-dispose'])
|
|
980
|
+
|
|
981
|
+
/** Recent guardian event log: badge + key info + time per entry.
|
|
982
|
+
* 过滤 entry-init/entry-dispose 噪音,优先展示隔离/冻结/更新失败等关键事件。 */
|
|
888
983
|
function EventList({ events }) {
|
|
889
|
-
|
|
984
|
+
const important = events.filter((event) => !EVENT_NOISE.has(event.type))
|
|
985
|
+
if (important.length === 0) return null
|
|
890
986
|
return createElement(
|
|
891
987
|
'div',
|
|
892
988
|
{ className: 'dsh-my-guardian-events' },
|
|
893
989
|
createElement('div', { className: 'dsh-my-guardian-events-title' }, icon.clock(14), strings.events()),
|
|
894
|
-
|
|
990
|
+
important.map((event, index) =>
|
|
895
991
|
createElement(
|
|
896
992
|
'div',
|
|
897
993
|
{
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-my-guardian — dependency pre-check for the candidate mount pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Reads a candidate plugin's package.json peerDependencies (from the profile
|
|
5
|
+
* node_modules) and verifies each dependency is installed and version-satisfying
|
|
6
|
+
* BEFORE the plugin is mounted. A hard-missing dependency is reported as a
|
|
7
|
+
* pre-check failure (failureType 'dependency') with an install suggestion, and
|
|
8
|
+
* the mount is skipped — the plugin never enters the runtime load path with a
|
|
9
|
+
* hole in its dependency graph (issue #72: dsh-shared was not published).
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { satisfies } from './dep-version.js'
|
|
14
|
+
|
|
15
|
+
// Locate a package directory below a node_modules root, following symlinks
|
|
16
|
+
// (pnpm store / npm link both expose package.json through the mirrored dir).
|
|
17
|
+
function findModuleDir(nmRoot, packageName) {
|
|
18
|
+
const dir = join(nmRoot, packageName)
|
|
19
|
+
return existsSync(join(dir, 'package.json')) ? dir : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Resolve a dependency from the plugin's nested node_modules or the profile
|
|
23
|
+
// node_modules (hoisted installs). Returns the dir or null when absent.
|
|
24
|
+
function resolveDependencyDir(profileDir, pluginDir, dep) {
|
|
25
|
+
const nested = pluginDir === null ? null : findModuleDir(join(pluginDir, 'node_modules'), dep)
|
|
26
|
+
if (nested !== null) return nested
|
|
27
|
+
return findModuleDir(join(profileDir, 'node_modules'), dep)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readPackageJson(dir) {
|
|
31
|
+
if (dir === null) return null
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
34
|
+
} catch {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function installedVersion(dir) {
|
|
40
|
+
const pkg = readPackageJson(dir)
|
|
41
|
+
return pkg !== null && typeof pkg.version === 'string' ? pkg.version : null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Inspect a single peer dependency and classify the outcome.
|
|
45
|
+
function examinePeer(dep, range, optional, pluginDir, profileDir) {
|
|
46
|
+
const depDir = resolveDependencyDir(profileDir, pluginDir, dep)
|
|
47
|
+
if (depDir === null) {
|
|
48
|
+
if (optional) return { kind: 'warn', message: `可选依赖 ${dep} 缺失(未安装)` }
|
|
49
|
+
return { kind: 'missing', name: dep }
|
|
50
|
+
}
|
|
51
|
+
const version = installedVersion(depDir)
|
|
52
|
+
if (version !== null && typeof range === 'string' && range.trim() !== '' && !satisfies(version, range)) {
|
|
53
|
+
const issue = { name: dep, expected: range, found: version }
|
|
54
|
+
if (optional) return { kind: 'warn', message: `可选依赖 ${dep} 版本不满足:${range}(当前 ${version})` }
|
|
55
|
+
return { kind: 'mismatch', issue }
|
|
56
|
+
}
|
|
57
|
+
return { kind: 'ok' }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildSuggestions(missing, mismatched) {
|
|
61
|
+
const suggestions = missing.map((dep) => `dsh plugin add ${dep}`)
|
|
62
|
+
for (const item of mismatched) suggestions.push(`dsh plugin add ${item.name}@${item.expected}`)
|
|
63
|
+
return suggestions
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function objectOrEmpty(value) {
|
|
67
|
+
return value ?? {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Group every peer into missing / mismatched / warning buckets.
|
|
71
|
+
function classifyPeers(peers, meta, pluginDir, profileDir) {
|
|
72
|
+
const missing = []
|
|
73
|
+
const mismatched = []
|
|
74
|
+
const warnings = []
|
|
75
|
+
for (const [dep, range] of Object.entries(peers)) {
|
|
76
|
+
const result = examinePeer(dep, range, meta[dep]?.optional === true, pluginDir, profileDir)
|
|
77
|
+
if (result.kind === 'missing') missing.push(result.name)
|
|
78
|
+
else if (result.kind === 'mismatch') mismatched.push(result.issue)
|
|
79
|
+
else if (result.kind === 'warn') warnings.push(result.message)
|
|
80
|
+
}
|
|
81
|
+
return { missing, mismatched, warnings }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function skippedResult(reason) {
|
|
85
|
+
return { ok: true, missing: [], mismatched: [], suggestions: [], warnings: [`跳过依赖预检:${reason}`] }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Pre-check the peer dependencies of a candidate plugin. Returns:
|
|
90
|
+
* { ok, missing, mismatched, suggestions, warnings }
|
|
91
|
+
* - missing: deps required (not optional) but absent from node_modules
|
|
92
|
+
* - mismatched: deps present at a version outside the declared range
|
|
93
|
+
* - suggestions: `dsh plugin add ...` repair commands
|
|
94
|
+
* - warnings: non-blocking notes (plugin unreadable / optional peers missing)
|
|
95
|
+
* When the plugin or its package.json cannot be located the check is skipped
|
|
96
|
+
* (ok: true) so an unusual install layout is never a false block.
|
|
97
|
+
*/
|
|
98
|
+
export function checkPeerDependencies({ profileDir, pluginName }) {
|
|
99
|
+
const pluginDir = findModuleDir(join(profileDir, 'node_modules'), pluginName)
|
|
100
|
+
if (pluginDir === null)
|
|
101
|
+
return skippedResult(`无法定位插件 ${pluginName}(未在 profile node_modules 找到 package.json)`)
|
|
102
|
+
const pkg = readPackageJson(pluginDir)
|
|
103
|
+
if (pkg === null) return skippedResult(`无法解析 ${pluginName} 的 package.json`)
|
|
104
|
+
const { missing, mismatched, warnings } = classifyPeers(
|
|
105
|
+
objectOrEmpty(pkg.peerDependencies),
|
|
106
|
+
objectOrEmpty(pkg.peerDependenciesMeta),
|
|
107
|
+
pluginDir,
|
|
108
|
+
profileDir,
|
|
109
|
+
)
|
|
110
|
+
return {
|
|
111
|
+
ok: missing.length === 0 && mismatched.length === 0,
|
|
112
|
+
missing,
|
|
113
|
+
mismatched,
|
|
114
|
+
suggestions: buildSuggestions(missing, mismatched),
|
|
115
|
+
warnings,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Build the "缺少依赖 X(请先安装)" message recorded for a failed pre-check. */
|
|
120
|
+
export function buildDependencyMessage(result) {
|
|
121
|
+
const names = [...result.missing, ...result.mismatched.map((item) => item.name)]
|
|
122
|
+
if (names.length === 0) return '依赖预检失败'
|
|
123
|
+
return names.map((name) => `缺少依赖 ${name}(请先安装)`).join(';')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Classify a mount failure for the isolation record (issue #86). */
|
|
127
|
+
export function classifyFailure(error) {
|
|
128
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
129
|
+
if (/Cannot find module|MODULE_NOT_FOUND|Cannot resolve/i.test(message)) return 'dependency'
|
|
130
|
+
if (/already exists|already in use|conflict/i.test(message)) return 'other'
|
|
131
|
+
return 'code'
|
|
132
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-my-guardian — minimal semver helpers for the dependency pre-check.
|
|
3
|
+
*
|
|
4
|
+
* Only the subset needed to validate peerDependency ranges is implemented:
|
|
5
|
+
* parse a version, compare two versions (with prerelease precedence), and test
|
|
6
|
+
* whether a version satisfies a range (`^`, `~`, comparator operators,
|
|
7
|
+
* wildcards, `||` and whitespace-AND). Build metadata (`+build`) is ignored for
|
|
8
|
+
* precedence. Everything is pure and dependency-free.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Parse a version string into { major, minor, patch, prerelease }.
|
|
12
|
+
// Returns null for input that is not a usable version.
|
|
13
|
+
function parseVersion(input) {
|
|
14
|
+
if (typeof input !== 'string') return null
|
|
15
|
+
const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(input.trim())
|
|
16
|
+
if (match === null) return null
|
|
17
|
+
return {
|
|
18
|
+
major: Number(match[1]),
|
|
19
|
+
minor: Number(match[2] ?? 0),
|
|
20
|
+
patch: Number(match[3] ?? 0),
|
|
21
|
+
prerelease: match[4] === undefined ? [] : match[4].split('.'),
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Numeric identifiers have lower precedence than alphanumeric ones.
|
|
26
|
+
function compareIdentifiers(a, b) {
|
|
27
|
+
const aNum = /^\d+$/.test(a)
|
|
28
|
+
const bNum = /^\d+$/.test(b)
|
|
29
|
+
if (aNum && bNum) return Number(a) - Number(b)
|
|
30
|
+
if (aNum !== bNum) return aNum ? -1 : 1
|
|
31
|
+
return a < b ? -1 : a > b ? 1 : 0
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Compare two prerelease arrays (a shorter list has lower precedence).
|
|
35
|
+
function comparePrerelease(ap, bp) {
|
|
36
|
+
if (ap.length === 0 && bp.length === 0) return 0
|
|
37
|
+
if (ap.length === 0) return 1
|
|
38
|
+
if (bp.length === 0) return -1
|
|
39
|
+
const len = Math.max(ap.length, bp.length)
|
|
40
|
+
for (let i = 0; i < len; i++) {
|
|
41
|
+
const ai = ap[i]
|
|
42
|
+
const bi = bp[i]
|
|
43
|
+
if (ai === undefined) return -1
|
|
44
|
+
if (bi === undefined) return 1
|
|
45
|
+
const diff = compareIdentifiers(ai, bi)
|
|
46
|
+
if (diff !== 0) return diff
|
|
47
|
+
}
|
|
48
|
+
return 0
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Compare two parsed version tuples (major/minor/patch/prerelease).
|
|
52
|
+
// Returns negative/zero/positive like Array#sort.
|
|
53
|
+
function compareParsed(a, b) {
|
|
54
|
+
for (const key of ['major', 'minor', 'patch']) {
|
|
55
|
+
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
|
|
56
|
+
}
|
|
57
|
+
return comparePrerelease(a.prerelease, b.prerelease)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Parse a range version spec that may contain wildcards (`x`/`*`) and a
|
|
61
|
+
// trailing prerelease (`1.2.3-rc.8`). Missing/padded components are null.
|
|
62
|
+
function parseRangeVersion(spec) {
|
|
63
|
+
const match = /^v?(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?$/.exec(spec.trim())
|
|
64
|
+
if (match === null) return null
|
|
65
|
+
const toNum = (value) => (value !== undefined && /^\d+$/.test(value) ? Number(value) : null)
|
|
66
|
+
return {
|
|
67
|
+
major: toNum(match[1]),
|
|
68
|
+
minor: match[2] === undefined ? null : toNum(match[2]),
|
|
69
|
+
patch: match[3] === undefined ? null : toNum(match[3]),
|
|
70
|
+
prerelease: match[4] === undefined ? [] : match[4].split('.'),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Lower bound tuple for a caret/tilde range base (wildcards → 0).
|
|
75
|
+
function lowerBound(base) {
|
|
76
|
+
return {
|
|
77
|
+
major: base.major ?? 0,
|
|
78
|
+
minor: base.minor ?? 0,
|
|
79
|
+
patch: base.patch ?? 0,
|
|
80
|
+
prerelease: base.prerelease,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Upper bound for a caret range: bump the first non-zero component.
|
|
85
|
+
function caretUpper(lo) {
|
|
86
|
+
if (lo.major > 0) return { major: lo.major + 1, minor: 0, patch: 0, prerelease: ['0'] }
|
|
87
|
+
if (lo.minor > 0) return { major: 0, minor: lo.minor + 1, patch: 0, prerelease: ['0'] }
|
|
88
|
+
if (lo.patch > 0) return { major: 0, minor: 0, patch: lo.patch + 1, prerelease: ['0'] }
|
|
89
|
+
return { major: 0, minor: 1, patch: 0, prerelease: ['0'] }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function satisfiesCaret(version, spec) {
|
|
93
|
+
const base = parseRangeVersion(spec)
|
|
94
|
+
const parsed = parseVersion(version)
|
|
95
|
+
if (base === null || parsed === null) return false
|
|
96
|
+
const lo = lowerBound(base)
|
|
97
|
+
if (compareParsed(parsed, lo) < 0) return false
|
|
98
|
+
return compareParsed(parsed, caretUpper(lo)) < 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function satisfiesTilde(version, spec) {
|
|
102
|
+
const base = parseRangeVersion(spec)
|
|
103
|
+
const parsed = parseVersion(version)
|
|
104
|
+
if (base === null || parsed === null) return false
|
|
105
|
+
const lo = lowerBound(base)
|
|
106
|
+
if (compareParsed(parsed, lo) < 0) return false
|
|
107
|
+
const upper =
|
|
108
|
+
base.minor === null
|
|
109
|
+
? { major: lo.major + 1, minor: 0, patch: 0, prerelease: ['0'] }
|
|
110
|
+
: { major: lo.major, minor: lo.minor + 1, patch: 0, prerelease: ['0'] }
|
|
111
|
+
return compareParsed(parsed, upper) < 0
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Compare a single range component against the version; wildcard (null) skips.
|
|
115
|
+
function compareComponent(parsed, base, key) {
|
|
116
|
+
if (base[key] === null) return 0
|
|
117
|
+
if (parsed[key] === base[key]) return 0
|
|
118
|
+
return parsed[key] < base[key] ? -1 : 1
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Compare a version against a range spec, only over the components that are
|
|
122
|
+
// concrete numbers. Returns null when either side is not a usable version.
|
|
123
|
+
function compareRangeToVersion(version, spec) {
|
|
124
|
+
const base = parseRangeVersion(spec)
|
|
125
|
+
const parsed = parseVersion(version)
|
|
126
|
+
if (base === null || parsed === null) return null
|
|
127
|
+
for (const key of ['major', 'minor', 'patch']) {
|
|
128
|
+
const diff = compareComponent(parsed, base, key)
|
|
129
|
+
if (diff !== 0) return diff
|
|
130
|
+
}
|
|
131
|
+
return 0
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function satisfiesComparator(version, token) {
|
|
135
|
+
const match = /^(>=|<=|>|<|=|==)?\s*(.+)$/.exec(token.trim())
|
|
136
|
+
if (match === null) return false
|
|
137
|
+
const op = match[1] ?? '='
|
|
138
|
+
const diff = compareRangeToVersion(version, match[2].trim())
|
|
139
|
+
if (diff === null) return false
|
|
140
|
+
if (op === '>') return diff > 0
|
|
141
|
+
if (op === '<') return diff < 0
|
|
142
|
+
if (op === '>=') return diff >= 0
|
|
143
|
+
if (op === '<=') return diff <= 0
|
|
144
|
+
return diff === 0
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function satisfiesExactOrWildcard(version, token) {
|
|
148
|
+
const base = parseRangeVersion(token)
|
|
149
|
+
const parsed = parseVersion(version)
|
|
150
|
+
if (base === null || parsed === null) return false
|
|
151
|
+
return compareRangeToVersion(version, token) === 0
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function satisfiesToken(version, token) {
|
|
155
|
+
if (token === '' || token === '*' || token === 'x' || token === 'X') return true
|
|
156
|
+
if (token.startsWith('^')) return satisfiesCaret(version, token.slice(1))
|
|
157
|
+
if (token.startsWith('~')) return satisfiesTilde(version, token.slice(1))
|
|
158
|
+
if (/^(>=|<=|>|<|=|==)/.test(token)) return satisfiesComparator(version, token)
|
|
159
|
+
return satisfiesExactOrWildcard(version, token)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A single alternative group: whitespace-AND of tokens, minus hyphen ranges.
|
|
163
|
+
function satisfiesAll(version, alt) {
|
|
164
|
+
if (alt === '') return true
|
|
165
|
+
const hyphen = alt.split(/\s+-\s+/)
|
|
166
|
+
if (hyphen.length === 2) {
|
|
167
|
+
return satisfies(version, `>=${hyphen[0].trim()}`) && satisfies(version, `<=${hyphen[1].trim()}`)
|
|
168
|
+
}
|
|
169
|
+
return alt
|
|
170
|
+
.split(/\s+/)
|
|
171
|
+
.filter(Boolean)
|
|
172
|
+
.every((token) => satisfiesToken(version, token))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Test whether `version` satisfies `range` (supports `||`, whitespace-AND and
|
|
177
|
+
* the common comparators). An empty or `*` range matches anything. A prerelease
|
|
178
|
+
* version only matches when the range itself mentions a prerelease — mirroring
|
|
179
|
+
* the semver rule that prereleases are opt-in.
|
|
180
|
+
*/
|
|
181
|
+
export function satisfies(version, range) {
|
|
182
|
+
if (typeof version !== 'string' || typeof range !== 'string') return false
|
|
183
|
+
const trimmed = range.trim()
|
|
184
|
+
if (trimmed === '' || trimmed === '*') return true
|
|
185
|
+
const parsed = parseVersion(version)
|
|
186
|
+
if (parsed !== null && parsed.prerelease.length > 0 && !range.includes('-')) return false
|
|
187
|
+
return trimmed.split('||').some((alt) => satisfiesAll(version, alt.trim()))
|
|
188
|
+
}
|
package/lib/events.js
CHANGED
|
@@ -17,10 +17,10 @@ export function logEvent(shared, type, message) {
|
|
|
17
17
|
/** Register the loader/HMR diagnostic listeners (R9/R10). */
|
|
18
18
|
export function attachEventListeners(ctx, shared) {
|
|
19
19
|
ctx.on('loader/entry-init', (entry) => {
|
|
20
|
-
logEvent(shared, 'entry-init', `entry ${entry
|
|
20
|
+
logEvent(shared, 'entry-init', `entry ${entryLabelOf(entry)} initialized`)
|
|
21
21
|
})
|
|
22
22
|
ctx.on('loader/partial-dispose', (entry) => {
|
|
23
|
-
logEvent(shared, 'entry-dispose', `entry ${entry
|
|
23
|
+
logEvent(shared, 'entry-dispose', `entry ${entryLabelOf(entry)} disposed`)
|
|
24
24
|
})
|
|
25
25
|
ctx.on('hmr/config-update-failed', (filename, error) => {
|
|
26
26
|
logEvent(shared, 'update-failed', `${filename}: ${error instanceof Error ? error.message : String(error)}`)
|
|
@@ -28,3 +28,22 @@ export function attachEventListeners(ctx, shared) {
|
|
|
28
28
|
shared.persistSoon()
|
|
29
29
|
})
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* entry 可读标识——⚠️ 只读 options 字段,绝不访问 `entry.id` getter:
|
|
34
|
+
* loader 在 Entry **构造函数中** emit `loader/entry-init`,此时
|
|
35
|
+
* `parent.tree` 尚未就绪,访问 getter 会抛 "Cannot read properties of
|
|
36
|
+
* undefined (reading 'tree')" —— 这个异常发生在启动阶段,会让整个
|
|
37
|
+
* DSH 服务起不来(守护插件自己炸启动,实锤隔离实例复现)。
|
|
38
|
+
* (此前读 `entry?.options?.id` 安全但恒为空显示 '?';本次改为
|
|
39
|
+
* options.id → options.name → '?' 的回退,全部属性访问都可安全兜底。)
|
|
40
|
+
*/
|
|
41
|
+
function entryLabelOf(entry) {
|
|
42
|
+
if (entry === null || typeof entry !== 'object') return '?'
|
|
43
|
+
const options = entry.options
|
|
44
|
+
if (options !== null && typeof options === 'object') {
|
|
45
|
+
if (options.id !== undefined && options.id !== null) return String(options.id)
|
|
46
|
+
if (typeof options.name === 'string' && options.name !== '') return options.name
|
|
47
|
+
}
|
|
48
|
+
return '?'
|
|
49
|
+
}
|
package/lib/mount.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { dirname } from 'node:path'
|
|
10
10
|
import { FREEZE_LIMIT, errorSnip, loadState, readStagedFile, writeStagedFile } from './state.js'
|
|
11
11
|
import { logEvent } from './events.js'
|
|
12
|
+
import { checkPeerDependencies, buildDependencyMessage, classifyFailure } from './dep-precheck.js'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Find the root Include tree of the profile. The loader tree's entries carry
|
|
@@ -91,16 +92,40 @@ async function mountWithState(shared, kind, id, entry) {
|
|
|
91
92
|
const record = shared.state[recordKey][id] ?? {}
|
|
92
93
|
if (record.frozen) return 'skipped'
|
|
93
94
|
const name = typeof entry?.name === 'string' ? entry.name : id
|
|
95
|
+
// Dependency pre-check (issue #86): a plugin whose peer deps are missing or
|
|
96
|
+
// out of range must not enter the runtime load path with a hole in its
|
|
97
|
+
// dependency graph. A failed pre-check is recorded as a 'dependency' failure
|
|
98
|
+
// with the missing deps + an install suggestion, and the mount is skipped.
|
|
99
|
+
const precheck = precheckEntry(shared, name)
|
|
100
|
+
if (precheck !== null) {
|
|
101
|
+
recordFailure(shared, recordKey, id, name, entry, record, {
|
|
102
|
+
failureType: 'dependency',
|
|
103
|
+
message: buildDependencyMessage(precheck),
|
|
104
|
+
missingDeps: [...precheck.missing, ...precheck.mismatched.map((item) => item.name)],
|
|
105
|
+
installHint: precheck.suggestions[0] ?? null,
|
|
106
|
+
})
|
|
107
|
+
return 'failed'
|
|
108
|
+
}
|
|
94
109
|
try {
|
|
95
110
|
await mount(shared, id, entry)
|
|
96
111
|
await promote(shared, recordKey, id, name, entry, record)
|
|
97
112
|
return 'mounted'
|
|
98
113
|
} catch (error) {
|
|
99
|
-
recordFailure(shared, recordKey, id, name, entry, record,
|
|
114
|
+
recordFailure(shared, recordKey, id, name, entry, record, {
|
|
115
|
+
failureType: classifyFailure(error),
|
|
116
|
+
message: error instanceof Error ? error.message : String(error),
|
|
117
|
+
})
|
|
100
118
|
return 'failed'
|
|
101
119
|
}
|
|
102
120
|
}
|
|
103
121
|
|
|
122
|
+
/** Run the dependency pre-check; null means it passed (or was skipped). */
|
|
123
|
+
function precheckEntry(shared, name) {
|
|
124
|
+
if (typeof name !== 'string' || name === '') return null
|
|
125
|
+
const result = checkPeerDependencies({ profileDir: shared.profileDir, pluginName: name })
|
|
126
|
+
return result.ok ? null : result
|
|
127
|
+
}
|
|
128
|
+
|
|
104
129
|
/** Success path: staged moves into the persisted promoted list (R2). */
|
|
105
130
|
async function promote(shared, recordKey, id, name, entry, record) {
|
|
106
131
|
if (recordKey === 'staged') {
|
|
@@ -111,6 +136,9 @@ async function promote(shared, recordKey, id, name, entry, record) {
|
|
|
111
136
|
lastError: null,
|
|
112
137
|
lastFailedAt: null,
|
|
113
138
|
frozen: false,
|
|
139
|
+
failureType: null,
|
|
140
|
+
missingDeps: [],
|
|
141
|
+
installHint: null,
|
|
114
142
|
promotedAt: Date.now(),
|
|
115
143
|
}
|
|
116
144
|
delete shared.state.staged[id]
|
|
@@ -125,6 +153,9 @@ async function promote(shared, recordKey, id, name, entry, record) {
|
|
|
125
153
|
lastError: null,
|
|
126
154
|
lastFailedAt: null,
|
|
127
155
|
frozen: false,
|
|
156
|
+
failureType: null,
|
|
157
|
+
missingDeps: [],
|
|
158
|
+
installHint: null,
|
|
128
159
|
}
|
|
129
160
|
}
|
|
130
161
|
logEvent(shared, 'promote', `mounted ${name} (${id})`)
|
|
@@ -132,19 +163,23 @@ async function promote(shared, recordKey, id, name, entry, record) {
|
|
|
132
163
|
}
|
|
133
164
|
|
|
134
165
|
/** Failure path: attempts counter + error recorded; freeze at the limit. */
|
|
135
|
-
function recordFailure(shared, recordKey, id, name, entry, record,
|
|
166
|
+
function recordFailure(shared, recordKey, id, name, entry, record, info) {
|
|
136
167
|
const attempts = (record.attempts ?? 0) + 1
|
|
137
168
|
const frozen = attempts >= FREEZE_LIMIT
|
|
169
|
+
const message = typeof info.message === 'string' ? info.message : String(info.message ?? info)
|
|
138
170
|
shared.state[recordKey][id] = {
|
|
139
171
|
name,
|
|
140
172
|
config: entry.config ?? undefined,
|
|
141
173
|
attempts,
|
|
142
|
-
lastError: errorSnip(
|
|
174
|
+
lastError: errorSnip(message),
|
|
143
175
|
lastFailedAt: Date.now(),
|
|
144
176
|
frozen,
|
|
177
|
+
failureType: info.failureType ?? 'code',
|
|
178
|
+
missingDeps: info.missingDeps ?? [],
|
|
179
|
+
installHint: info.installHint ?? null,
|
|
145
180
|
...(recordKey === 'promoted' ? { promotedAt: record.promotedAt } : {}),
|
|
146
181
|
}
|
|
147
|
-
logEvent(shared, frozen ? 'freeze' : 'quarantine', `${name} (${id}) failed ${attempts}x: ${
|
|
182
|
+
logEvent(shared, frozen ? 'freeze' : 'quarantine', `${name} (${id}) failed ${attempts}x: ${message}`)
|
|
148
183
|
shared.persistSoon()
|
|
149
184
|
}
|
|
150
185
|
|
|
@@ -211,6 +246,9 @@ async function retryEntry(shared, id) {
|
|
|
211
246
|
lastError: null,
|
|
212
247
|
lastFailedAt: null,
|
|
213
248
|
frozen: false,
|
|
249
|
+
failureType: null,
|
|
250
|
+
missingDeps: [],
|
|
251
|
+
installHint: null,
|
|
214
252
|
}
|
|
215
253
|
shared.persistSoon()
|
|
216
254
|
if (shared.state.safeMode) return 'safe'
|
package/lib/parts/row.part.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ── row ────────────────────────────────────────────────────────────────
|
|
2
|
-
/** Row head: source chip + name + status badge. */
|
|
2
|
+
/** Row head: source chip + name + status badge + failure-category badge. */
|
|
3
3
|
function RowHead({ entry, source }) {
|
|
4
4
|
return createElement(
|
|
5
5
|
'div',
|
|
@@ -15,6 +15,16 @@ function RowHead({ entry, source }) {
|
|
|
15
15
|
{ className: `dsh-my-guardian-badge dsh-my-guardian-badge-${entry.status}` },
|
|
16
16
|
statusLabel(entry.status),
|
|
17
17
|
),
|
|
18
|
+
typeof entry.failureType === 'string' && entry.failureType !== ''
|
|
19
|
+
? createElement(
|
|
20
|
+
'span',
|
|
21
|
+
{
|
|
22
|
+
className: `dsh-my-guardian-category dsh-my-guardian-category-${entry.failureType}`,
|
|
23
|
+
title: entry.failureType,
|
|
24
|
+
},
|
|
25
|
+
failureTypeLabel(entry.failureType),
|
|
26
|
+
)
|
|
27
|
+
: null,
|
|
18
28
|
)
|
|
19
29
|
}
|
|
20
30
|
|
|
@@ -122,11 +132,26 @@ function RowActions({ entry, busy, onRetry, onRemove }) {
|
|
|
122
132
|
)
|
|
123
133
|
}
|
|
124
134
|
|
|
135
|
+
/** 冻结行提示(连败停止自动重试,需手动操作)。 */
|
|
136
|
+
function FrozenHint({ status }) {
|
|
137
|
+
if (status !== 'frozen') return null
|
|
138
|
+
return createElement('div', { className: 'dsh-my-guardian-freeze-hint' }, strings.frozenHint())
|
|
139
|
+
}
|
|
140
|
+
|
|
125
141
|
function EntryRow({ entry, source, onAction }) {
|
|
126
|
-
|
|
142
|
+
// 失败/冻结行默认展开错误详情(用户之前必须手动点开才看得到真正报错)。
|
|
143
|
+
const [expanded, setExpanded] = useState(
|
|
144
|
+
() =>
|
|
145
|
+
typeof entry.lastError === 'string' &&
|
|
146
|
+
entry.lastError !== '' &&
|
|
147
|
+
(entry.status === 'failed' || entry.status === 'frozen'),
|
|
148
|
+
)
|
|
127
149
|
const [confirming, setConfirming] = useState(false)
|
|
128
150
|
const [busy, setBusy] = useState(false)
|
|
129
151
|
const hasError = typeof entry.lastError === 'string' && entry.lastError !== ''
|
|
152
|
+
const isDepFailure = entry.failureType === 'dependency'
|
|
153
|
+
const installHint =
|
|
154
|
+
isDepFailure && typeof entry.installHint === 'string' && entry.installHint !== '' ? entry.installHint : null
|
|
130
155
|
|
|
131
156
|
const run = (kind) => {
|
|
132
157
|
setBusy(true)
|
|
@@ -138,6 +163,15 @@ function EntryRow({ entry, source, onAction }) {
|
|
|
138
163
|
{ className: 'dsh-my-guardian-row' },
|
|
139
164
|
createElement(RowHead, { entry, source }),
|
|
140
165
|
createElement(RowMeta, { entry }),
|
|
166
|
+
createElement(FrozenHint, { status: entry.status }),
|
|
167
|
+
installHint
|
|
168
|
+
? createElement(
|
|
169
|
+
'div',
|
|
170
|
+
{ className: 'dsh-my-guardian-install-hint' },
|
|
171
|
+
createElement('span', { className: 'dsh-my-guardian-install-hint-label' }, strings.installHint()),
|
|
172
|
+
createElement('code', null, installHint),
|
|
173
|
+
)
|
|
174
|
+
: null,
|
|
141
175
|
hasError ? createElement(ErrorToggle, { expanded, onToggle: () => setExpanded(!expanded) }) : null,
|
|
142
176
|
expanded && hasError ? createElement('pre', { className: 'dsh-my-guardian-error-detail' }, entry.lastError) : null,
|
|
143
177
|
confirming
|
package/lib/parts/styles.part.js
CHANGED
|
@@ -73,8 +73,22 @@ const STYLES = `
|
|
|
73
73
|
.dsh-my-guardian-badge-pending { color:var(--dsw-alias-accent); background:color-mix(in srgb, var(--dsw-alias-accent) 12%, transparent); }
|
|
74
74
|
.dsh-my-guardian-badge-failed { color:var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent); }
|
|
75
75
|
.dsh-my-guardian-badge-frozen { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
|
|
76
|
+
/* failure-classification badge chips (issue #86): dependency / code / other */
|
|
77
|
+
.dsh-my-guardian-category { flex:none; display:inline-flex; align-items:center; justify-content:center; height:17px; padding:0 5px; border-radius:4px;
|
|
78
|
+
font:var(--dsw-font-xxxs-strong-11); }
|
|
79
|
+
.dsh-my-guardian-category-dependency { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
|
|
80
|
+
.dsh-my-guardian-category-code { color:var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); }
|
|
81
|
+
.dsh-my-guardian-category-other { color:var(--dsw-alias-label-tertiary); background:var(--dsw-alias-interactive-bg-hover); }
|
|
82
|
+
/* install-suggestion line for dependency failures */
|
|
83
|
+
.dsh-my-guardian-install-hint { display:flex; align-items:center; gap:5px; padding:3px 6px; border-radius:6px;
|
|
84
|
+
background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 6%, transparent);
|
|
85
|
+
font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); }
|
|
86
|
+
.dsh-my-guardian-install-hint-label { flex:none; color:var(--dsw-alias-label-tertiary); }
|
|
87
|
+
.dsh-my-guardian-install-hint code { font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:var(--dsw-font-xxxs-11);
|
|
88
|
+
color:var(--dsw-alias-state-success-primary); word-break:break-all; }
|
|
76
89
|
.dsh-my-guardian-row-meta { display:flex; align-items:center; gap:6px; font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); }
|
|
77
90
|
.dsh-my-guardian-attempts { color:var(--dsw-alias-state-error-primary); }
|
|
91
|
+
.dsh-my-guardian-freeze-hint { font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-state-warn-primary); line-height:1.6; }
|
|
78
92
|
.dsh-my-guardian-link { display:inline-flex; align-items:center; gap:3px; padding:0; border:none; background:transparent; cursor:pointer;
|
|
79
93
|
font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary);
|
|
80
94
|
transition:color var(--ds-transition-duration-slow) var(--ds-ease-in-out); }
|
package/lib/parts/util.part.js
CHANGED
|
@@ -12,8 +12,8 @@ const strings = {
|
|
|
12
12
|
safeMode: () => (isZh() ? '安全模式' : 'Safe mode'),
|
|
13
13
|
safeModeDesc: () =>
|
|
14
14
|
isZh()
|
|
15
|
-
? '
|
|
16
|
-
: 'Skips every staged/promoted plugin mount — fast recovery',
|
|
15
|
+
? '开启后所有候选/已转正插件(候选区 cordis.staged.json 中的条目)都不再加载,用于快速恢复环境。注意:安全模式只作用于启动后挂载的候选插件;若插件直接写进启动名册(cordis.patch.yml / profile),DSH 启动时仍是 all-or-nothing——请把新插件先放进候选区。'
|
|
16
|
+
: 'Skips every staged/promoted plugin mount — fast recovery. Note: this only covers entries mounted after boot (cordis.staged.json); plugins in the boot roster (cordis.patch.yml / profile) are still all-or-nothing — put new plugins in the staged file first.',
|
|
17
17
|
staged: () => (isZh() ? '候选' : 'staged'),
|
|
18
18
|
promoted: () => (isZh() ? '转正' : 'promoted'),
|
|
19
19
|
entries: () => (isZh() ? '插件条目' : 'Plugin entries'),
|
|
@@ -41,6 +41,14 @@ const strings = {
|
|
|
41
41
|
loading: () => (isZh() ? '加载中…' : 'Loading…'),
|
|
42
42
|
events: () => (isZh() ? '最近事件' : 'Recent events'),
|
|
43
43
|
attempts: (n) => (isZh() ? `失败 ${n} 次` : `failed ×${n}`),
|
|
44
|
+
frozenHint: () =>
|
|
45
|
+
isZh()
|
|
46
|
+
? '已冻结:连续失败停止自动重试——点击刷新按钮手动重试,或移除该条目'
|
|
47
|
+
: 'Frozen: auto-retry stopped after repeated failures — retry manually or remove the entry',
|
|
48
|
+
failureDependency: () => (isZh() ? '依赖缺失' : 'Dependency'),
|
|
49
|
+
failureCode: () => (isZh() ? '代码错误' : 'Code error'),
|
|
50
|
+
failureOther: () => (isZh() ? '其他' : 'Other'),
|
|
51
|
+
installHint: () => (isZh() ? '安装建议' : 'Install'),
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
// ── api ───────────────────────────────────────────────────────────────
|
|
@@ -82,6 +90,20 @@ function statusLabel(status) {
|
|
|
82
90
|
}
|
|
83
91
|
}
|
|
84
92
|
|
|
93
|
+
/** Failure-classification badge label (issue #86): dependency / code / other. */
|
|
94
|
+
function failureTypeLabel(type) {
|
|
95
|
+
switch (type) {
|
|
96
|
+
case 'dependency':
|
|
97
|
+
return strings.failureDependency()
|
|
98
|
+
case 'code':
|
|
99
|
+
return strings.failureCode()
|
|
100
|
+
case 'other':
|
|
101
|
+
return strings.failureOther()
|
|
102
|
+
default:
|
|
103
|
+
return type
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
85
107
|
// ── event log ─────────────────────────────────────────────────────────
|
|
86
108
|
// Event type → badge label + color variant (mirrors the dfa-op chip style).
|
|
87
109
|
const EVENT_LABELS = {
|
package/lib/parts/view.part.js
CHANGED
|
@@ -123,14 +123,19 @@ function EntryList({ rows, onAction }) {
|
|
|
123
123
|
)
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
/**
|
|
126
|
+
/** 高频噪音事件:每次启动/热重载都会大量产生,挤掉真正重要的诊断信息。 */
|
|
127
|
+
const EVENT_NOISE = new Set(['entry-init', 'entry-dispose'])
|
|
128
|
+
|
|
129
|
+
/** Recent guardian event log: badge + key info + time per entry.
|
|
130
|
+
* 过滤 entry-init/entry-dispose 噪音,优先展示隔离/冻结/更新失败等关键事件。 */
|
|
127
131
|
function EventList({ events }) {
|
|
128
|
-
|
|
132
|
+
const important = events.filter((event) => !EVENT_NOISE.has(event.type))
|
|
133
|
+
if (important.length === 0) return null
|
|
129
134
|
return createElement(
|
|
130
135
|
'div',
|
|
131
136
|
{ className: 'dsh-my-guardian-events' },
|
|
132
137
|
createElement('div', { className: 'dsh-my-guardian-events-title' }, icon.clock(14), strings.events()),
|
|
133
|
-
|
|
138
|
+
important.map((event, index) =>
|
|
134
139
|
createElement(
|
|
135
140
|
'div',
|
|
136
141
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-my-guardian",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "DSH 插件治理守护:新装/更新插件先进候选区,启动后热挂载——成功转正、失败自动禁用、连续失败冻结,一键安全模式,侧边栏诊断面板。DSH web plugin: staged plugin loading with auto-disable on failure, freeze, safe mode and a sidebar panel.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|