dsh-my-guardian 0.3.5 → 0.4.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/CHANGELOG.md +22 -1
- package/README.md +10 -0
- package/lib/api.js +3 -0
- package/lib/client.js +139 -6
- package/lib/dep-precheck.js +3 -1
- package/lib/events.js +21 -2
- package/lib/index.js +35 -1
- package/lib/parts/row.part.js +14 -1
- package/lib/parts/styles.part.js +28 -0
- package/lib/parts/util.part.js +29 -2
- package/lib/parts/view.part.js +68 -3
- package/lib/startup-check.js +228 -0
- package/lib/state.js +20 -0
- package/package.json +2 -5
package/CHANGELOG.md
CHANGED
|
@@ -5,14 +5,35 @@ 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.
|
|
8
|
+
## [0.4.0] - 2026-09-07
|
|
9
|
+
|
|
10
|
+
### 变更
|
|
11
|
+
|
|
12
|
+
- feat(observability): #155 插件状态查询聚合——统一 status-query 事件 + /plugin-status API (#170)
|
|
13
|
+
- chore(plugins): #165 清理失效的 dsh.client.inject 声明(13 插件) (#167)
|
|
14
|
+
- feat(guard): #144 启动名册静态预检——startup-issues.json + 面板置顶 + 不阻断启动 (#151)
|
|
15
|
+
|
|
16
|
+
## [0.3.6] - 2026-09-04
|
|
9
17
|
|
|
10
18
|
### 变更
|
|
11
19
|
|
|
20
|
+
- fix(guardian): 事件日志不再访问 entry.id getter——构造期 parent.tree 未就绪会抛错炸掉 DSH 启动(隔离实例复现)
|
|
21
|
+
- fix(guardian): 诊断面板可读性 + entry 标识修复——插件失败时不再"看不懂/兜不住"
|
|
22
|
+
- fix(guardian): #86 发版一致性修复——package.json 0.3.5 + CHANGELOG 0.3.5 段(rebase 冲突还原恢复)
|
|
12
23
|
- chore(release): dsh-my-guardian v0.3.5(#86 依赖预检 + 验证清单)
|
|
13
24
|
- feat(guardian): #86 候选区插件依赖预检 + 失败分类 (#124)
|
|
14
25
|
- docs: #106 安装命令统一加 --trust-lockfile (#113)
|
|
15
26
|
|
|
27
|
+
## [0.3.5] - 2026-09-04
|
|
28
|
+
|
|
29
|
+
### 新增
|
|
30
|
+
|
|
31
|
+
- [#86](https://github.com/baosfeng/my-dsh-plugins/issues/86) 候选区插件依赖预检 + 失败分类:热挂载前校验依赖就绪,失败按类别诊断(缺失依赖/锁定冲突/加载错误),诊断面板展示分类详情
|
|
32
|
+
|
|
33
|
+
### 变更
|
|
34
|
+
|
|
35
|
+
- 验证清单与效果图同步(发版前功能级验证归档)
|
|
36
|
+
|
|
16
37
|
## [0.3.4] - 2026-09-02
|
|
17
38
|
|
|
18
39
|
### 变更
|
package/README.md
CHANGED
|
@@ -86,6 +86,16 @@ dsh plugin --profile web add link:<仓库路径>/plugins/dsh-my-guardian
|
|
|
86
86
|
|
|
87
87
|
失败条目额外带**失败类型徽标**(依赖缺失 / 代码错误 / 其他),依赖缺失时并展示安装建议命令(如 `dsh plugin add dsh-shared`)。
|
|
88
88
|
|
|
89
|
+
### 启动名册静态预检(issue #144)
|
|
90
|
+
|
|
91
|
+
候选区只兜住"走候选区"的插件;**直接写进启动名册(`cordis.patch.yml` / profile / bundles)的插件仍由 DSH 启动时 all-or-nothing 加载**。守护在每次启动时对名册做静态预检(不实际加载插件):
|
|
92
|
+
|
|
93
|
+
- **包可解析**:名册中 `dsh-*` 插件的包必须在 profile node_modules 中存在
|
|
94
|
+
- **peerDependencies 满足**:复用候选挂载的依赖预检
|
|
95
|
+
- **无重复 entry id**:名册中同一 id 出现多次(加载时 `EntryGroup.update` 会抛 `duplicate loader entry id`)
|
|
96
|
+
|
|
97
|
+
发现的问题写入 `$DSH_HOME/guardian/startup-issues.json`(原子写,名册健康也写空报告 + 检查时间),面板「最近事件」顶部**置顶**展示启动区问题,每条给出修复命令(`dsh plugin add ...`)与移除提示。**预检失败不阻断启动**——只记录、只告警,最坏情况是为启动失败留下排查记录。
|
|
98
|
+
|
|
89
99
|
### 效果截图(真实 DSH 实例验证)
|
|
90
100
|
|
|
91
101
|
侧边栏"插件守护"诊断面板(独立 3081 端口隔离 DSH 实例实测):
|
package/lib/api.js
CHANGED
|
@@ -191,5 +191,8 @@ function snapshot(shared) {
|
|
|
191
191
|
staged: stagedList,
|
|
192
192
|
promoted: promotedList,
|
|
193
193
|
events: shared.state.events.slice(-10),
|
|
194
|
+
// startup-roster pre-check report (issue #144): empty array = healthy
|
|
195
|
+
startupIssues: Array.isArray(shared.startupIssues) ? shared.startupIssues : [],
|
|
196
|
+
startupCheckedAt: typeof shared.startupCheckedAt === 'number' ? shared.startupCheckedAt : null,
|
|
194
197
|
}
|
|
195
198
|
}
|
package/lib/client.js
CHANGED
|
@@ -122,6 +122,7 @@ const STYLES = `
|
|
|
122
122
|
color:var(--dsw-alias-state-success-primary); word-break:break-all; }
|
|
123
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); }
|
|
124
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; }
|
|
125
126
|
.dsh-my-guardian-link { display:inline-flex; align-items:center; gap:3px; padding:0; border:none; background:transparent; cursor:pointer;
|
|
126
127
|
font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary);
|
|
127
128
|
transition:color var(--ds-transition-duration-slow) var(--ds-ease-in-out); }
|
|
@@ -181,6 +182,33 @@ const STYLES = `
|
|
|
181
182
|
.dsh-my-guardian-event-message { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
|
182
183
|
font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-secondary); }
|
|
183
184
|
.dsh-my-guardian-event-time { flex:none; font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); white-space:nowrap; }
|
|
185
|
+
/* ── startup-roster issues (issue #144): pinned block above the list ────── */
|
|
186
|
+
.dsh-my-guardian-startup-issues { display:flex; flex-direction:column; gap:2px; margin-top:4px; padding:6px 8px; border-radius:8px;
|
|
187
|
+
border:1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
|
|
188
|
+
background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 6%, transparent);
|
|
189
|
+
animation:dsh-my-guardian-row-in 150ms var(--ds-ease-in-out); }
|
|
190
|
+
.dsh-my-guardian-startup-issues-title { display:flex; align-items:center; gap:5px; padding:0 2px 3px;
|
|
191
|
+
font:var(--dsw-font-xxs-strong-12); color:var(--dsw-alias-state-error-primary); }
|
|
192
|
+
.dsh-my-guardian-startup-issues-title svg { display:block; flex:none; }
|
|
193
|
+
.dsh-my-guardian-startup-issues-count { display:inline-flex; align-items:center; justify-content:center; min-width:17px; height:17px; padding:0 5px; border-radius:4px;
|
|
194
|
+
font:var(--dsw-font-xxxs-strong-11); color:var(--dsw-alias-label-primary-foreground); background:var(--dsw-alias-state-error-primary); }
|
|
195
|
+
.dsh-my-guardian-startup-issues-time { flex:1; text-align:right; font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); }
|
|
196
|
+
.dsh-my-guardian-startup-issue { display:flex; flex-direction:column; gap:2px; padding:5px 6px; border-radius:6px;
|
|
197
|
+
border:1px solid var(--dsw-alias-border-l1); background:transparent; }
|
|
198
|
+
.dsh-my-guardian-startup-issue:hover { background:var(--dsw-alias-interactive-bg-hover); }
|
|
199
|
+
.dsh-my-guardian-startup-issue-head { display:flex; align-items:center; gap:6px; min-width:0; }
|
|
200
|
+
.dsh-my-guardian-startup-issue-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
|
201
|
+
font:var(--dsw-font-s-strong-14); color:var(--dsw-alias-label-primary); }
|
|
202
|
+
.dsh-my-guardian-startup-issue-badge-unresolvable { color:var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent); }
|
|
203
|
+
.dsh-my-guardian-startup-issue-badge-duplicate-id { color:var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent); }
|
|
204
|
+
.dsh-my-guardian-startup-issue-badge-dependency { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
|
|
205
|
+
.dsh-my-guardian-startup-issue-message { font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-secondary); line-height:1.6; }
|
|
206
|
+
.dsh-my-guardian-startup-issue-line { display:flex; align-items:flex-start; gap:5px; padding:2px 0;
|
|
207
|
+
font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); line-height:1.6; min-width:0; }
|
|
208
|
+
.dsh-my-guardian-startup-issue-label { flex:none; color:var(--dsw-alias-label-tertiary); }
|
|
209
|
+
.dsh-my-guardian-startup-issue-line code { font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:var(--dsw-font-xxxs-11);
|
|
210
|
+
color:var(--dsw-alias-state-success-primary); word-break:break-all; }
|
|
211
|
+
.dsh-my-guardian-startup-issue-remove { min-width:0; word-break:break-all; }
|
|
184
212
|
@keyframes dsh-my-guardian-row-in { from { opacity:0; transform:translateY(1px); } to { opacity:1; transform:none; } }
|
|
185
213
|
@keyframes dsh-my-guardian-spin { to { transform:rotate(360deg); } }
|
|
186
214
|
`
|
|
@@ -199,8 +227,8 @@ const strings = {
|
|
|
199
227
|
safeMode: () => (isZh() ? '安全模式' : 'Safe mode'),
|
|
200
228
|
safeModeDesc: () =>
|
|
201
229
|
isZh()
|
|
202
|
-
? '
|
|
203
|
-
: 'Skips every staged/promoted plugin mount — fast recovery',
|
|
230
|
+
? '开启后所有候选/已转正插件(候选区 cordis.staged.json 中的条目)都不再加载,用于快速恢复环境。注意:安全模式只作用于启动后挂载的候选插件;若插件直接写进启动名册(cordis.patch.yml / profile),DSH 启动时仍是 all-or-nothing——请把新插件先放进候选区。'
|
|
231
|
+
: '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.',
|
|
204
232
|
staged: () => (isZh() ? '候选' : 'staged'),
|
|
205
233
|
promoted: () => (isZh() ? '转正' : 'promoted'),
|
|
206
234
|
entries: () => (isZh() ? '插件条目' : 'Plugin entries'),
|
|
@@ -228,10 +256,21 @@ const strings = {
|
|
|
228
256
|
loading: () => (isZh() ? '加载中…' : 'Loading…'),
|
|
229
257
|
events: () => (isZh() ? '最近事件' : 'Recent events'),
|
|
230
258
|
attempts: (n) => (isZh() ? `失败 ${n} 次` : `failed ×${n}`),
|
|
259
|
+
frozenHint: () =>
|
|
260
|
+
isZh()
|
|
261
|
+
? '已冻结:连续失败停止自动重试——点击刷新按钮手动重试,或移除该条目'
|
|
262
|
+
: 'Frozen: auto-retry stopped after repeated failures — retry manually or remove the entry',
|
|
231
263
|
failureDependency: () => (isZh() ? '依赖缺失' : 'Dependency'),
|
|
232
264
|
failureCode: () => (isZh() ? '代码错误' : 'Code error'),
|
|
233
265
|
failureOther: () => (isZh() ? '其他' : 'Other'),
|
|
234
266
|
installHint: () => (isZh() ? '安装建议' : 'Install'),
|
|
267
|
+
// ── startup-roster issues (issue #144) ─────────────────────────────────
|
|
268
|
+
startupIssues: () => (isZh() ? '启动区问题' : 'Startup roster issues'),
|
|
269
|
+
startupIssueFix: () => (isZh() ? '修复' : 'Fix'),
|
|
270
|
+
startupIssueRemove: () => (isZh() ? '移除' : 'Remove'),
|
|
271
|
+
startupIssueUnresolvable: () => (isZh() ? '包不可解析' : 'Unresolvable'),
|
|
272
|
+
startupIssueDependency: () => (isZh() ? '依赖缺失' : 'Dependency'),
|
|
273
|
+
startupIssueDuplicate: () => (isZh() ? '重复 id' : 'Duplicate id'),
|
|
235
274
|
}
|
|
236
275
|
|
|
237
276
|
// ── api ───────────────────────────────────────────────────────────────
|
|
@@ -299,6 +338,7 @@ const EVENT_LABELS = {
|
|
|
299
338
|
safe: () => (isZh() ? '安全模式' : 'Safe mode'),
|
|
300
339
|
'safe-mode': () => (isZh() ? '安全模式' : 'Safe mode'),
|
|
301
340
|
skip: () => (isZh() ? '跳过' : 'Skipped'),
|
|
341
|
+
'startup-issue': () => (isZh() ? '启动区问题' : 'Startup issue'),
|
|
302
342
|
}
|
|
303
343
|
|
|
304
344
|
/** Badge color variant for an event type; unknown types fall back to the
|
|
@@ -311,6 +351,7 @@ function eventVariant(type) {
|
|
|
311
351
|
return 'accent'
|
|
312
352
|
case 'quarantine':
|
|
313
353
|
case 'update-failed':
|
|
354
|
+
case 'startup-issue':
|
|
314
355
|
return 'danger'
|
|
315
356
|
case 'freeze':
|
|
316
357
|
case 'safe':
|
|
@@ -321,6 +362,20 @@ function eventVariant(type) {
|
|
|
321
362
|
}
|
|
322
363
|
}
|
|
323
364
|
|
|
365
|
+
/** Startup-issue badge label (issue #144): unresolvable / dependency / dup. */
|
|
366
|
+
function startupIssueLabel(type) {
|
|
367
|
+
switch (type) {
|
|
368
|
+
case 'unresolvable':
|
|
369
|
+
return strings.startupIssueUnresolvable()
|
|
370
|
+
case 'dependency':
|
|
371
|
+
return strings.startupIssueDependency()
|
|
372
|
+
case 'duplicate-id':
|
|
373
|
+
return strings.startupIssueDuplicate()
|
|
374
|
+
default:
|
|
375
|
+
return type
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
324
379
|
function eventLabel(type) {
|
|
325
380
|
return (EVENT_LABELS[type] ?? (() => type))()
|
|
326
381
|
}
|
|
@@ -784,8 +839,20 @@ function RowActions({ entry, busy, onRetry, onRemove }) {
|
|
|
784
839
|
)
|
|
785
840
|
}
|
|
786
841
|
|
|
842
|
+
/** 冻结行提示(连败停止自动重试,需手动操作)。 */
|
|
843
|
+
function FrozenHint({ status }) {
|
|
844
|
+
if (status !== 'frozen') return null
|
|
845
|
+
return createElement('div', { className: 'dsh-my-guardian-freeze-hint' }, strings.frozenHint())
|
|
846
|
+
}
|
|
847
|
+
|
|
787
848
|
function EntryRow({ entry, source, onAction }) {
|
|
788
|
-
|
|
849
|
+
// 失败/冻结行默认展开错误详情(用户之前必须手动点开才看得到真正报错)。
|
|
850
|
+
const [expanded, setExpanded] = useState(
|
|
851
|
+
() =>
|
|
852
|
+
typeof entry.lastError === 'string' &&
|
|
853
|
+
entry.lastError !== '' &&
|
|
854
|
+
(entry.status === 'failed' || entry.status === 'frozen'),
|
|
855
|
+
)
|
|
789
856
|
const [confirming, setConfirming] = useState(false)
|
|
790
857
|
const [busy, setBusy] = useState(false)
|
|
791
858
|
const hasError = typeof entry.lastError === 'string' && entry.lastError !== ''
|
|
@@ -803,6 +870,7 @@ function EntryRow({ entry, source, onAction }) {
|
|
|
803
870
|
{ className: 'dsh-my-guardian-row' },
|
|
804
871
|
createElement(RowHead, { entry, source }),
|
|
805
872
|
createElement(RowMeta, { entry }),
|
|
873
|
+
createElement(FrozenHint, { status: entry.status }),
|
|
806
874
|
installHint
|
|
807
875
|
? createElement(
|
|
808
876
|
'div',
|
|
@@ -957,14 +1025,19 @@ function EntryList({ rows, onAction }) {
|
|
|
957
1025
|
)
|
|
958
1026
|
}
|
|
959
1027
|
|
|
960
|
-
/**
|
|
1028
|
+
/** 高频噪音事件:每次启动/热重载都会大量产生,挤掉真正重要的诊断信息。 */
|
|
1029
|
+
const EVENT_NOISE = new Set(['entry-init', 'entry-dispose'])
|
|
1030
|
+
|
|
1031
|
+
/** Recent guardian event log: badge + key info + time per entry.
|
|
1032
|
+
* 过滤 entry-init/entry-dispose 噪音,优先展示隔离/冻结/更新失败等关键事件。 */
|
|
961
1033
|
function EventList({ events }) {
|
|
962
|
-
|
|
1034
|
+
const important = events.filter((event) => !EVENT_NOISE.has(event.type))
|
|
1035
|
+
if (important.length === 0) return null
|
|
963
1036
|
return createElement(
|
|
964
1037
|
'div',
|
|
965
1038
|
{ className: 'dsh-my-guardian-events' },
|
|
966
1039
|
createElement('div', { className: 'dsh-my-guardian-events-title' }, icon.clock(14), strings.events()),
|
|
967
|
-
|
|
1040
|
+
important.map((event, index) =>
|
|
968
1041
|
createElement(
|
|
969
1042
|
'div',
|
|
970
1043
|
{
|
|
@@ -984,6 +1057,62 @@ function EventList({ events }) {
|
|
|
984
1057
|
)
|
|
985
1058
|
}
|
|
986
1059
|
|
|
1060
|
+
/** 启动区问题(issue #144):启动名册静态预检发现的问题条目,置顶展示
|
|
1061
|
+
* 修复命令与移除提示——名册中的坏条目是 all-or-nothing 启动失败的源头。 */
|
|
1062
|
+
function StartupIssuesBlock({ issues, checkedAt }) {
|
|
1063
|
+
if (!Array.isArray(issues) || issues.length === 0) return null
|
|
1064
|
+
return createElement(
|
|
1065
|
+
'div',
|
|
1066
|
+
{ className: 'dsh-my-guardian-startup-issues' },
|
|
1067
|
+
createElement(
|
|
1068
|
+
'div',
|
|
1069
|
+
{ className: 'dsh-my-guardian-startup-issues-title' },
|
|
1070
|
+
icon.alert(14),
|
|
1071
|
+
strings.startupIssues(),
|
|
1072
|
+
createElement('span', { className: 'dsh-my-guardian-startup-issues-count' }, String(issues.length)),
|
|
1073
|
+
typeof checkedAt === 'number' && Number.isFinite(checkedAt)
|
|
1074
|
+
? createElement('span', { className: 'dsh-my-guardian-startup-issues-time' }, formatTime(checkedAt))
|
|
1075
|
+
: null,
|
|
1076
|
+
),
|
|
1077
|
+
issues.map((issue, index) =>
|
|
1078
|
+
createElement(
|
|
1079
|
+
'div',
|
|
1080
|
+
{ className: 'dsh-my-guardian-startup-issue', key: index },
|
|
1081
|
+
createElement(
|
|
1082
|
+
'div',
|
|
1083
|
+
{ className: 'dsh-my-guardian-startup-issue-head' },
|
|
1084
|
+
createElement(
|
|
1085
|
+
'span',
|
|
1086
|
+
{
|
|
1087
|
+
className: `dsh-my-guardian-event-badge dsh-my-guardian-startup-issue-badge-${issue.type}`,
|
|
1088
|
+
title: issue.entryId,
|
|
1089
|
+
},
|
|
1090
|
+
startupIssueLabel(issue.type),
|
|
1091
|
+
),
|
|
1092
|
+
createElement('span', { className: 'dsh-my-guardian-startup-issue-name' }, issue.name),
|
|
1093
|
+
),
|
|
1094
|
+
createElement('div', { className: 'dsh-my-guardian-startup-issue-message' }, issue.message),
|
|
1095
|
+
typeof issue.fix === 'string' && issue.fix !== ''
|
|
1096
|
+
? createElement(
|
|
1097
|
+
'div',
|
|
1098
|
+
{ className: 'dsh-my-guardian-startup-issue-line' },
|
|
1099
|
+
createElement('span', { className: 'dsh-my-guardian-startup-issue-label' }, strings.startupIssueFix()),
|
|
1100
|
+
createElement('code', null, issue.fix),
|
|
1101
|
+
)
|
|
1102
|
+
: null,
|
|
1103
|
+
typeof issue.remove === 'string' && issue.remove !== ''
|
|
1104
|
+
? createElement(
|
|
1105
|
+
'div',
|
|
1106
|
+
{ className: 'dsh-my-guardian-startup-issue-line' },
|
|
1107
|
+
createElement('span', { className: 'dsh-my-guardian-startup-issue-label' }, strings.startupIssueRemove()),
|
|
1108
|
+
createElement('span', { className: 'dsh-my-guardian-startup-issue-remove' }, issue.remove),
|
|
1109
|
+
)
|
|
1110
|
+
: null,
|
|
1111
|
+
),
|
|
1112
|
+
),
|
|
1113
|
+
)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
987
1116
|
function GuardianView({ visible }) {
|
|
988
1117
|
const { state, loadFailed, reload, onAction, onSafeMode } = useGuardianState(visible)
|
|
989
1118
|
|
|
@@ -1005,6 +1134,10 @@ function GuardianView({ visible }) {
|
|
|
1005
1134
|
'div',
|
|
1006
1135
|
{ className: 'dsh-my-guardian-root' },
|
|
1007
1136
|
createElement(SafeModeBar, { safeMode: state.safeMode, onSafeMode }),
|
|
1137
|
+
createElement(StartupIssuesBlock, {
|
|
1138
|
+
issues: state.startupIssues,
|
|
1139
|
+
checkedAt: state.startupCheckedAt,
|
|
1140
|
+
}),
|
|
1008
1141
|
loadFailed
|
|
1009
1142
|
? createElement(
|
|
1010
1143
|
'div',
|
package/lib/dep-precheck.js
CHANGED
|
@@ -14,7 +14,9 @@ import { satisfies } from './dep-version.js'
|
|
|
14
14
|
|
|
15
15
|
// Locate a package directory below a node_modules root, following symlinks
|
|
16
16
|
// (pnpm store / npm link both expose package.json through the mirrored dir).
|
|
17
|
-
|
|
17
|
+
// Exported for the startup-roster pre-check (issue #144): resolvability of a
|
|
18
|
+
// roster plugin's own package is verified before peer dependencies.
|
|
19
|
+
export function findModuleDir(nmRoot, packageName) {
|
|
18
20
|
const dir = join(nmRoot, packageName)
|
|
19
21
|
return existsSync(join(dir, 'package.json')) ? dir : null
|
|
20
22
|
}
|
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/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import { createPersister, createState } from './state.js'
|
|
|
37
37
|
import { findRootTree, createMountOps, initialScan } from './mount.js'
|
|
38
38
|
import { createApi } from './api.js'
|
|
39
39
|
import { attachEventListeners, logEvent } from './events.js'
|
|
40
|
+
import { runStartupCheck } from './startup-check.js'
|
|
40
41
|
|
|
41
42
|
export const name = 'dsh-my-guardian'
|
|
42
43
|
|
|
@@ -69,6 +70,7 @@ function applyInner(ctx) {
|
|
|
69
70
|
scheduleInitialScan(ctx, shared)
|
|
70
71
|
startWatchers(ctx, shared)
|
|
71
72
|
registerTeardown(ctx, shared)
|
|
73
|
+
registerStatusQuery(ctx, shared)
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
/** Mutable per-instance runtime context shared by every sub-module. */
|
|
@@ -84,6 +86,9 @@ function createShared({ tree, profileDir }) {
|
|
|
84
86
|
tree,
|
|
85
87
|
profileDir,
|
|
86
88
|
stagedFile: join(profileDir, 'cordis.staged.json'),
|
|
89
|
+
// startup-roster pre-check (issue #144): filled by runStartupCheck
|
|
90
|
+
startupIssues: [],
|
|
91
|
+
startupCheckedAt: null,
|
|
87
92
|
}
|
|
88
93
|
}
|
|
89
94
|
|
|
@@ -99,7 +104,14 @@ function wireServices(ctx, shared) {
|
|
|
99
104
|
// ── run after boot settles ──────────────────────────────────────────────
|
|
100
105
|
function scheduleInitialScan(ctx, shared) {
|
|
101
106
|
void Promise.resolve().then(() => {
|
|
102
|
-
|
|
107
|
+
// Startup-roster static pre-check (issue #144): best-effort, MUST never
|
|
108
|
+
// block or break the boot — a pre-check failure only records a report.
|
|
109
|
+
void runStartupCheck(ctx, shared).catch((error) => {
|
|
110
|
+
ctx.logger?.warn(
|
|
111
|
+
`[dsh-my-guardian] startup pre-check failed (recorded only): ${error instanceof Error ? error.message : String(error)}`,
|
|
112
|
+
)
|
|
113
|
+
})
|
|
114
|
+
void initialScan(shared).catch((error) => {
|
|
103
115
|
// the scan must never take the process down
|
|
104
116
|
ctx.logger?.warn(
|
|
105
117
|
`[dsh-my-guardian] initial scan failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
@@ -126,6 +138,28 @@ function startWatchers(ctx, shared) {
|
|
|
126
138
|
}, POLL_MS)
|
|
127
139
|
}
|
|
128
140
|
|
|
141
|
+
/** 插件状态查询(#155 聚合层):返回 guardian 加载状态 + 最近事件。 */
|
|
142
|
+
function registerStatusQuery(ctx, shared) {
|
|
143
|
+
ctx.on('plugin:status-query', ({ plugin }) => {
|
|
144
|
+
if (plugin !== 'dsh-my-guardian') return undefined
|
|
145
|
+
const lastActions = (shared.state.events ?? []).slice(-5).map((e) => ({
|
|
146
|
+
time: e.time ?? e.timestamp ?? Date.now(),
|
|
147
|
+
type: e.type ?? 'event',
|
|
148
|
+
detail: e.message ?? e.detail ?? '',
|
|
149
|
+
}))
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
value: {
|
|
153
|
+
plugin: 'dsh-my-guardian',
|
|
154
|
+
config: { keys: ['safeMode', 'stagedFile'] },
|
|
155
|
+
running: shared.ready,
|
|
156
|
+
stats: { mounted: shared.mounted.size, safeMode: shared.state.safeMode === true },
|
|
157
|
+
lastActions,
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
129
163
|
/** teardown: unmount everything the guardian mounted, then persist. */
|
|
130
164
|
function registerTeardown(ctx, shared) {
|
|
131
165
|
ctx.effect(
|
package/lib/parts/row.part.js
CHANGED
|
@@ -132,8 +132,20 @@ function RowActions({ entry, busy, onRetry, onRemove }) {
|
|
|
132
132
|
)
|
|
133
133
|
}
|
|
134
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
|
+
|
|
135
141
|
function EntryRow({ entry, source, onAction }) {
|
|
136
|
-
|
|
142
|
+
// 失败/冻结行默认展开错误详情(用户之前必须手动点开才看得到真正报错)。
|
|
143
|
+
const [expanded, setExpanded] = useState(
|
|
144
|
+
() =>
|
|
145
|
+
typeof entry.lastError === 'string' &&
|
|
146
|
+
entry.lastError !== '' &&
|
|
147
|
+
(entry.status === 'failed' || entry.status === 'frozen'),
|
|
148
|
+
)
|
|
137
149
|
const [confirming, setConfirming] = useState(false)
|
|
138
150
|
const [busy, setBusy] = useState(false)
|
|
139
151
|
const hasError = typeof entry.lastError === 'string' && entry.lastError !== ''
|
|
@@ -151,6 +163,7 @@ function EntryRow({ entry, source, onAction }) {
|
|
|
151
163
|
{ className: 'dsh-my-guardian-row' },
|
|
152
164
|
createElement(RowHead, { entry, source }),
|
|
153
165
|
createElement(RowMeta, { entry }),
|
|
166
|
+
createElement(FrozenHint, { status: entry.status }),
|
|
154
167
|
installHint
|
|
155
168
|
? createElement(
|
|
156
169
|
'div',
|
package/lib/parts/styles.part.js
CHANGED
|
@@ -88,6 +88,7 @@ const STYLES = `
|
|
|
88
88
|
color:var(--dsw-alias-state-success-primary); word-break:break-all; }
|
|
89
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); }
|
|
90
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; }
|
|
91
92
|
.dsh-my-guardian-link { display:inline-flex; align-items:center; gap:3px; padding:0; border:none; background:transparent; cursor:pointer;
|
|
92
93
|
font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary);
|
|
93
94
|
transition:color var(--ds-transition-duration-slow) var(--ds-ease-in-out); }
|
|
@@ -147,6 +148,33 @@ const STYLES = `
|
|
|
147
148
|
.dsh-my-guardian-event-message { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
|
148
149
|
font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-secondary); }
|
|
149
150
|
.dsh-my-guardian-event-time { flex:none; font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); white-space:nowrap; }
|
|
151
|
+
/* ── startup-roster issues (issue #144): pinned block above the list ────── */
|
|
152
|
+
.dsh-my-guardian-startup-issues { display:flex; flex-direction:column; gap:2px; margin-top:4px; padding:6px 8px; border-radius:8px;
|
|
153
|
+
border:1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
|
|
154
|
+
background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 6%, transparent);
|
|
155
|
+
animation:dsh-my-guardian-row-in 150ms var(--ds-ease-in-out); }
|
|
156
|
+
.dsh-my-guardian-startup-issues-title { display:flex; align-items:center; gap:5px; padding:0 2px 3px;
|
|
157
|
+
font:var(--dsw-font-xxs-strong-12); color:var(--dsw-alias-state-error-primary); }
|
|
158
|
+
.dsh-my-guardian-startup-issues-title svg { display:block; flex:none; }
|
|
159
|
+
.dsh-my-guardian-startup-issues-count { display:inline-flex; align-items:center; justify-content:center; min-width:17px; height:17px; padding:0 5px; border-radius:4px;
|
|
160
|
+
font:var(--dsw-font-xxxs-strong-11); color:var(--dsw-alias-label-primary-foreground); background:var(--dsw-alias-state-error-primary); }
|
|
161
|
+
.dsh-my-guardian-startup-issues-time { flex:1; text-align:right; font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); }
|
|
162
|
+
.dsh-my-guardian-startup-issue { display:flex; flex-direction:column; gap:2px; padding:5px 6px; border-radius:6px;
|
|
163
|
+
border:1px solid var(--dsw-alias-border-l1); background:transparent; }
|
|
164
|
+
.dsh-my-guardian-startup-issue:hover { background:var(--dsw-alias-interactive-bg-hover); }
|
|
165
|
+
.dsh-my-guardian-startup-issue-head { display:flex; align-items:center; gap:6px; min-width:0; }
|
|
166
|
+
.dsh-my-guardian-startup-issue-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
|
167
|
+
font:var(--dsw-font-s-strong-14); color:var(--dsw-alias-label-primary); }
|
|
168
|
+
.dsh-my-guardian-startup-issue-badge-unresolvable { color:var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent); }
|
|
169
|
+
.dsh-my-guardian-startup-issue-badge-duplicate-id { color:var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent); }
|
|
170
|
+
.dsh-my-guardian-startup-issue-badge-dependency { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
|
|
171
|
+
.dsh-my-guardian-startup-issue-message { font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-secondary); line-height:1.6; }
|
|
172
|
+
.dsh-my-guardian-startup-issue-line { display:flex; align-items:flex-start; gap:5px; padding:2px 0;
|
|
173
|
+
font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); line-height:1.6; min-width:0; }
|
|
174
|
+
.dsh-my-guardian-startup-issue-label { flex:none; color:var(--dsw-alias-label-tertiary); }
|
|
175
|
+
.dsh-my-guardian-startup-issue-line code { font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:var(--dsw-font-xxxs-11);
|
|
176
|
+
color:var(--dsw-alias-state-success-primary); word-break:break-all; }
|
|
177
|
+
.dsh-my-guardian-startup-issue-remove { min-width:0; word-break:break-all; }
|
|
150
178
|
@keyframes dsh-my-guardian-row-in { from { opacity:0; transform:translateY(1px); } to { opacity:1; transform:none; } }
|
|
151
179
|
@keyframes dsh-my-guardian-spin { to { transform:rotate(360deg); } }
|
|
152
180
|
`
|
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,10 +41,21 @@ 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',
|
|
44
48
|
failureDependency: () => (isZh() ? '依赖缺失' : 'Dependency'),
|
|
45
49
|
failureCode: () => (isZh() ? '代码错误' : 'Code error'),
|
|
46
50
|
failureOther: () => (isZh() ? '其他' : 'Other'),
|
|
47
51
|
installHint: () => (isZh() ? '安装建议' : 'Install'),
|
|
52
|
+
// ── startup-roster issues (issue #144) ─────────────────────────────────
|
|
53
|
+
startupIssues: () => (isZh() ? '启动区问题' : 'Startup roster issues'),
|
|
54
|
+
startupIssueFix: () => (isZh() ? '修复' : 'Fix'),
|
|
55
|
+
startupIssueRemove: () => (isZh() ? '移除' : 'Remove'),
|
|
56
|
+
startupIssueUnresolvable: () => (isZh() ? '包不可解析' : 'Unresolvable'),
|
|
57
|
+
startupIssueDependency: () => (isZh() ? '依赖缺失' : 'Dependency'),
|
|
58
|
+
startupIssueDuplicate: () => (isZh() ? '重复 id' : 'Duplicate id'),
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
// ── api ───────────────────────────────────────────────────────────────
|
|
@@ -112,6 +123,7 @@ const EVENT_LABELS = {
|
|
|
112
123
|
safe: () => (isZh() ? '安全模式' : 'Safe mode'),
|
|
113
124
|
'safe-mode': () => (isZh() ? '安全模式' : 'Safe mode'),
|
|
114
125
|
skip: () => (isZh() ? '跳过' : 'Skipped'),
|
|
126
|
+
'startup-issue': () => (isZh() ? '启动区问题' : 'Startup issue'),
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
/** Badge color variant for an event type; unknown types fall back to the
|
|
@@ -124,6 +136,7 @@ function eventVariant(type) {
|
|
|
124
136
|
return 'accent'
|
|
125
137
|
case 'quarantine':
|
|
126
138
|
case 'update-failed':
|
|
139
|
+
case 'startup-issue':
|
|
127
140
|
return 'danger'
|
|
128
141
|
case 'freeze':
|
|
129
142
|
case 'safe':
|
|
@@ -134,6 +147,20 @@ function eventVariant(type) {
|
|
|
134
147
|
}
|
|
135
148
|
}
|
|
136
149
|
|
|
150
|
+
/** Startup-issue badge label (issue #144): unresolvable / dependency / dup. */
|
|
151
|
+
function startupIssueLabel(type) {
|
|
152
|
+
switch (type) {
|
|
153
|
+
case 'unresolvable':
|
|
154
|
+
return strings.startupIssueUnresolvable()
|
|
155
|
+
case 'dependency':
|
|
156
|
+
return strings.startupIssueDependency()
|
|
157
|
+
case 'duplicate-id':
|
|
158
|
+
return strings.startupIssueDuplicate()
|
|
159
|
+
default:
|
|
160
|
+
return type
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
137
164
|
function eventLabel(type) {
|
|
138
165
|
return (EVENT_LABELS[type] ?? (() => type))()
|
|
139
166
|
}
|
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
|
{
|
|
@@ -150,6 +155,62 @@ function EventList({ events }) {
|
|
|
150
155
|
)
|
|
151
156
|
}
|
|
152
157
|
|
|
158
|
+
/** 启动区问题(issue #144):启动名册静态预检发现的问题条目,置顶展示
|
|
159
|
+
* 修复命令与移除提示——名册中的坏条目是 all-or-nothing 启动失败的源头。 */
|
|
160
|
+
function StartupIssuesBlock({ issues, checkedAt }) {
|
|
161
|
+
if (!Array.isArray(issues) || issues.length === 0) return null
|
|
162
|
+
return createElement(
|
|
163
|
+
'div',
|
|
164
|
+
{ className: 'dsh-my-guardian-startup-issues' },
|
|
165
|
+
createElement(
|
|
166
|
+
'div',
|
|
167
|
+
{ className: 'dsh-my-guardian-startup-issues-title' },
|
|
168
|
+
icon.alert(14),
|
|
169
|
+
strings.startupIssues(),
|
|
170
|
+
createElement('span', { className: 'dsh-my-guardian-startup-issues-count' }, String(issues.length)),
|
|
171
|
+
typeof checkedAt === 'number' && Number.isFinite(checkedAt)
|
|
172
|
+
? createElement('span', { className: 'dsh-my-guardian-startup-issues-time' }, formatTime(checkedAt))
|
|
173
|
+
: null,
|
|
174
|
+
),
|
|
175
|
+
issues.map((issue, index) =>
|
|
176
|
+
createElement(
|
|
177
|
+
'div',
|
|
178
|
+
{ className: 'dsh-my-guardian-startup-issue', key: index },
|
|
179
|
+
createElement(
|
|
180
|
+
'div',
|
|
181
|
+
{ className: 'dsh-my-guardian-startup-issue-head' },
|
|
182
|
+
createElement(
|
|
183
|
+
'span',
|
|
184
|
+
{
|
|
185
|
+
className: `dsh-my-guardian-event-badge dsh-my-guardian-startup-issue-badge-${issue.type}`,
|
|
186
|
+
title: issue.entryId,
|
|
187
|
+
},
|
|
188
|
+
startupIssueLabel(issue.type),
|
|
189
|
+
),
|
|
190
|
+
createElement('span', { className: 'dsh-my-guardian-startup-issue-name' }, issue.name),
|
|
191
|
+
),
|
|
192
|
+
createElement('div', { className: 'dsh-my-guardian-startup-issue-message' }, issue.message),
|
|
193
|
+
typeof issue.fix === 'string' && issue.fix !== ''
|
|
194
|
+
? createElement(
|
|
195
|
+
'div',
|
|
196
|
+
{ className: 'dsh-my-guardian-startup-issue-line' },
|
|
197
|
+
createElement('span', { className: 'dsh-my-guardian-startup-issue-label' }, strings.startupIssueFix()),
|
|
198
|
+
createElement('code', null, issue.fix),
|
|
199
|
+
)
|
|
200
|
+
: null,
|
|
201
|
+
typeof issue.remove === 'string' && issue.remove !== ''
|
|
202
|
+
? createElement(
|
|
203
|
+
'div',
|
|
204
|
+
{ className: 'dsh-my-guardian-startup-issue-line' },
|
|
205
|
+
createElement('span', { className: 'dsh-my-guardian-startup-issue-label' }, strings.startupIssueRemove()),
|
|
206
|
+
createElement('span', { className: 'dsh-my-guardian-startup-issue-remove' }, issue.remove),
|
|
207
|
+
)
|
|
208
|
+
: null,
|
|
209
|
+
),
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
153
214
|
function GuardianView({ visible }) {
|
|
154
215
|
const { state, loadFailed, reload, onAction, onSafeMode } = useGuardianState(visible)
|
|
155
216
|
|
|
@@ -171,6 +232,10 @@ function GuardianView({ visible }) {
|
|
|
171
232
|
'div',
|
|
172
233
|
{ className: 'dsh-my-guardian-root' },
|
|
173
234
|
createElement(SafeModeBar, { safeMode: state.safeMode, onSafeMode }),
|
|
235
|
+
createElement(StartupIssuesBlock, {
|
|
236
|
+
issues: state.startupIssues,
|
|
237
|
+
checkedAt: state.startupCheckedAt,
|
|
238
|
+
}),
|
|
174
239
|
loadFailed
|
|
175
240
|
? createElement(
|
|
176
241
|
'div',
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-my-guardian — startup-roster static pre-check (issue #144).
|
|
3
|
+
*
|
|
4
|
+
* DSH boots its plugin roster all-or-nothing: any roster row (cordis.patch.yml /
|
|
5
|
+
* profile / bundles — everything merged into the loader's include tree) that
|
|
6
|
+
* fails to import, throws during apply, or stays pending takes the whole
|
|
7
|
+
* `dsh web` process down, and the guardian (itself a roster row) has no
|
|
8
|
+
* chance to quarantine anything after the fact. This module closes that gap
|
|
9
|
+
* with a STATIC, best-effort pre-flight that runs right after the guardian's
|
|
10
|
+
* own apply:
|
|
11
|
+
*
|
|
12
|
+
* - package resolvability: every dsh-* roster entry's package must exist
|
|
13
|
+
* under the profile node_modules — an unresolvable package always fails
|
|
14
|
+
* the import stage (assertEntriesLoaded throws in dsh-app-boot)
|
|
15
|
+
* - peer dependencies: reuse the staged-mount pre-check (dep-precheck.js)
|
|
16
|
+
* — missing/out-of-range peers surface at boot as module-resolution
|
|
17
|
+
* failures, so they are flagged before the next restart hits them
|
|
18
|
+
* - duplicate entry ids: EntryGroup.update in cordis-plugin-loader throws
|
|
19
|
+
* "duplicate loader entry id" when a group config lists the same id
|
|
20
|
+
* twice — a guaranteed boot failure worth flagging up front
|
|
21
|
+
*
|
|
22
|
+
* The report is written to $DSH_HOME/guardian/startup-issues.json so it
|
|
23
|
+
* survives a broken boot: applies run in parallel with roster imports, so the
|
|
24
|
+
* guardian can usually record the report before assertEntriesActivated
|
|
25
|
+
* rejects the boot.
|
|
26
|
+
*
|
|
27
|
+
* Guardrails (watchdog self-protection, R15): the pre-check itself NEVER
|
|
28
|
+
* fails the boot — every step is wrapped, failures only log; a broken loader
|
|
29
|
+
* tree simply yields no roster and an empty report.
|
|
30
|
+
*/
|
|
31
|
+
import { join } from 'node:path'
|
|
32
|
+
import { findModuleDir, checkPeerDependencies, buildDependencyMessage } from './dep-precheck.js'
|
|
33
|
+
import { writeStartupIssuesFile } from './state.js'
|
|
34
|
+
import { logEvent } from './events.js'
|
|
35
|
+
|
|
36
|
+
/** Profile-node_modules root used for roster resolvability checks. */
|
|
37
|
+
function profileNodeModules(profileDir) {
|
|
38
|
+
return join(profileDir, 'node_modules')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Collect every entry of a tree, tolerantly: entries() when present (real
|
|
42
|
+
* IncludeTree includes nested subtrees), otherwise a plain store walk
|
|
43
|
+
* (mocked trees in tests). Never throws — a broken tree yields []. */
|
|
44
|
+
export function collectTreeEntries(tree) {
|
|
45
|
+
if (tree === null || typeof tree !== 'object') return []
|
|
46
|
+
if (typeof tree.entries === 'function') {
|
|
47
|
+
try {
|
|
48
|
+
const out = []
|
|
49
|
+
for (const entry of tree.entries()) {
|
|
50
|
+
if (entry !== null && typeof entry === 'object') out.push(entry)
|
|
51
|
+
}
|
|
52
|
+
return out
|
|
53
|
+
} catch {
|
|
54
|
+
// fall through to the store walk
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const store = tree.store
|
|
58
|
+
if (store === null || typeof store !== 'object') return []
|
|
59
|
+
return Object.values(store).filter((entry) => entry !== null && typeof entry === 'object')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Read one string field from an entry's options (tolerant of odd shapes). */
|
|
63
|
+
function readOptionString(options, key) {
|
|
64
|
+
return options !== null && typeof options === 'object' && typeof options[key] === 'string' && options[key] !== ''
|
|
65
|
+
? options[key]
|
|
66
|
+
: ''
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Extract { id, name } from an entry, with safe fallbacks (entry.id getter
|
|
70
|
+
* may throw on partially-constructed entries — never let it break the boot). */
|
|
71
|
+
export function entryLabels(entry) {
|
|
72
|
+
const options = entry !== null && typeof entry === 'object' ? entry.options : null
|
|
73
|
+
let id = readOptionString(options, 'id')
|
|
74
|
+
if (id === '') id = entryIdGetterOf(entry)
|
|
75
|
+
const name = readOptionString(options, 'name')
|
|
76
|
+
return { id, name: name !== '' ? name : id !== '' ? id : '?' }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** entry.id getter fallback (wrapped: it may throw on partial entries). */
|
|
80
|
+
function entryIdGetterOf(entry) {
|
|
81
|
+
try {
|
|
82
|
+
const entryId = entry?.id
|
|
83
|
+
return typeof entryId === 'string' && entryId !== '' ? entryId : ''
|
|
84
|
+
} catch {
|
|
85
|
+
return ''
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True when an entry is disabled (explicit row or via options) — disabled
|
|
90
|
+
* rows are exempt from the boot audits and from this pre-check. */
|
|
91
|
+
export function isDisabledEntry(entry) {
|
|
92
|
+
if (entry === null || typeof entry !== 'object') return false
|
|
93
|
+
if (entry.disabled === true) return true
|
|
94
|
+
const options = entry.options
|
|
95
|
+
return options !== null && typeof options === 'object' && options.disabled === true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Removal hint shared by every issue kind (guardian never rewrites YAML). */
|
|
99
|
+
const REMOVE_HINT = '从启动名册(cordis.patch.yml / profile)中删除该条目行,或标记 disabled: true 暂缓加载'
|
|
100
|
+
|
|
101
|
+
/** Build an unresolvable-package issue (import stage would fail). */
|
|
102
|
+
function unresolvedIssue(id, name) {
|
|
103
|
+
return {
|
|
104
|
+
type: 'unresolvable',
|
|
105
|
+
entryId: id,
|
|
106
|
+
name,
|
|
107
|
+
message: `插件包 ${name} 无法解析(profile node_modules 中不存在),启动 import 将失败`,
|
|
108
|
+
fix: `dsh plugin add ${name}`,
|
|
109
|
+
remove: REMOVE_HINT,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Build a dependency issue reusing the staged-mount pre-check result. */
|
|
114
|
+
function dependencyIssue(id, name, precheck) {
|
|
115
|
+
return {
|
|
116
|
+
type: 'dependency',
|
|
117
|
+
entryId: id,
|
|
118
|
+
name,
|
|
119
|
+
message: buildDependencyMessage(precheck),
|
|
120
|
+
missingDeps: [...precheck.missing, ...precheck.mismatched.map((item) => item.name)],
|
|
121
|
+
installHint: precheck.suggestions[0] ?? null,
|
|
122
|
+
fix: precheck.suggestions[0] ?? `dsh plugin add ${name}`,
|
|
123
|
+
remove: REMOVE_HINT,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Build a duplicate-id issue for one id seen more than once. */
|
|
128
|
+
function duplicateIssue(id, names) {
|
|
129
|
+
return {
|
|
130
|
+
type: 'duplicate-id',
|
|
131
|
+
entryId: id,
|
|
132
|
+
name: names[0] ?? id,
|
|
133
|
+
message: `名册存在重复条目 id "${id}"(${names.length} 处:${names.join('、')}),加载时将抛 duplicate loader entry id`,
|
|
134
|
+
fix: null,
|
|
135
|
+
remove: '从名册中删除重复的条目行(每个 id 保留一条)',
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Record an entry under its id for the duplicate-id sweep. */
|
|
140
|
+
function addSeen(map, id, item) {
|
|
141
|
+
const group = map.get(id)
|
|
142
|
+
if (group === undefined) map.set(id, [item])
|
|
143
|
+
else group.push(item)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Static checks for one roster row: resolvability + peers (dsh-* only). */
|
|
147
|
+
function checkPluginItem(issues, item, nmRoot, profileDir) {
|
|
148
|
+
const id = typeof item.id === 'string' ? item.id : ''
|
|
149
|
+
const name = typeof item.name === 'string' ? item.name : ''
|
|
150
|
+
const label = name !== '' ? name : id
|
|
151
|
+
if (label === '' || !label.startsWith('dsh-')) return
|
|
152
|
+
const pluginDir = findModuleDir(nmRoot, label)
|
|
153
|
+
if (pluginDir === null) {
|
|
154
|
+
issues.push(unresolvedIssue(id, label))
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
const precheck = checkPeerDependencies({ profileDir, pluginName: label })
|
|
158
|
+
if (!precheck.ok) issues.push(dependencyIssue(id, label, precheck))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Append one duplicate-id issue per id seen in more than one roster row. */
|
|
162
|
+
function collectDuplicateIssues(issues, byId) {
|
|
163
|
+
for (const [id, group] of byId) {
|
|
164
|
+
if (group.length < 2) continue
|
|
165
|
+
issues.push(
|
|
166
|
+
duplicateIssue(
|
|
167
|
+
id,
|
|
168
|
+
group.map((item) => item.name ?? item.id ?? '?'),
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Core static pre-check over one roster's normalized entries.
|
|
176
|
+
* entries: [{ id, name, disabled }] (see normalizeRoster). Checks dsh-*
|
|
177
|
+
* plugins for package resolvability + peer deps, and the whole roster for
|
|
178
|
+
* duplicate ids. Never throws — returns { issues }.
|
|
179
|
+
*/
|
|
180
|
+
export function checkStartupRoster({ entries, profileDir }) {
|
|
181
|
+
const issues = []
|
|
182
|
+
const roster = Array.isArray(entries) ? entries : []
|
|
183
|
+
const nmRoot = profileNodeModules(profileDir)
|
|
184
|
+
const byId = new Map()
|
|
185
|
+
for (const item of roster) {
|
|
186
|
+
if (item === null || typeof item !== 'object') continue
|
|
187
|
+
const id = typeof item.id === 'string' ? item.id : ''
|
|
188
|
+
if (id !== '') addSeen(byId, id, item)
|
|
189
|
+
checkPluginItem(issues, item, nmRoot, profileDir)
|
|
190
|
+
}
|
|
191
|
+
collectDuplicateIssues(issues, byId)
|
|
192
|
+
return { issues }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Normalize raw tree entries into { id, name, disabled } roster rows. */
|
|
196
|
+
export function normalizeRoster(entries) {
|
|
197
|
+
return entries.map((entry) => {
|
|
198
|
+
const labels = entryLabels(entry)
|
|
199
|
+
return { id: labels.id, name: labels.name, disabled: isDisabledEntry(entry) }
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Full startup pre-flight for one guardian instance: collect the roster from
|
|
205
|
+
* the include tree, statically check it, persist the report and log an event.
|
|
206
|
+
* Never throws — every failure degrades to a warn log (boot must continue).
|
|
207
|
+
*/
|
|
208
|
+
export async function runStartupCheck(ctx, shared) {
|
|
209
|
+
const entries = collectTreeEntries(shared.tree)
|
|
210
|
+
const roster = normalizeRoster(entries).filter((item) => !item.disabled)
|
|
211
|
+
const { issues } = checkStartupRoster({ entries: roster, profileDir: shared.profileDir })
|
|
212
|
+
const checkedAt = Date.now()
|
|
213
|
+
await writeStartupIssuesFile({ version: 1, checkedAt, profileDir: shared.profileDir, issues })
|
|
214
|
+
shared.startupIssues = issues
|
|
215
|
+
shared.startupCheckedAt = checkedAt
|
|
216
|
+
if (issues.length > 0) {
|
|
217
|
+
const names = [...new Set(issues.map((issue) => issue.name))].slice(0, 3).join('、')
|
|
218
|
+
logEvent(
|
|
219
|
+
shared,
|
|
220
|
+
'startup-issue',
|
|
221
|
+
`启动名册静态预检发现 ${issues.length} 个问题(${names}…)— 详见 guardian/startup-issues.json`,
|
|
222
|
+
)
|
|
223
|
+
shared.persistSoon()
|
|
224
|
+
ctx.logger?.warn(
|
|
225
|
+
`[dsh-my-guardian] startup roster pre-check: ${issues.length} issue(s) — see $DSH_HOME/guardian/startup-issues.json`,
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
}
|
package/lib/state.js
CHANGED
|
@@ -32,6 +32,26 @@ function guardianDir() {
|
|
|
32
32
|
return join(homedir(), '.dsh', 'guardian')
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Persist the startup-roster pre-check report (issue #144) atomically at
|
|
37
|
+
* $DSH_HOME/guardian/startup-issues.json. The report is written even when
|
|
38
|
+
* the roster is healthy (empty issues + checkedAt) so the file doubles as a
|
|
39
|
+
* "last checked" marker; missing/corrupt file only means "never written".
|
|
40
|
+
* Never throws to callers — the guardian must not take the process down.
|
|
41
|
+
*/
|
|
42
|
+
export async function writeStartupIssuesFile(payload) {
|
|
43
|
+
const dir = guardianDir()
|
|
44
|
+
const file = join(dir, 'startup-issues.json')
|
|
45
|
+
const tmp = `${file}.tmp-${uniqueSuffix()}`
|
|
46
|
+
try {
|
|
47
|
+
await mkdir(dir, { recursive: true })
|
|
48
|
+
await writeFile(tmp, JSON.stringify(payload), 'utf8')
|
|
49
|
+
await rename(tmp, file)
|
|
50
|
+
} catch {
|
|
51
|
+
// the report is diagnostic-only: a write failure must not break boot
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
/** Empty state document. */
|
|
36
56
|
export function createState() {
|
|
37
57
|
return { version: 1, safeMode: false, staged: {}, promoted: {}, events: [] }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-my-guardian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
@@ -36,10 +36,7 @@
|
|
|
36
36
|
"patch": "./cordis.patch.yml"
|
|
37
37
|
},
|
|
38
38
|
"client": {
|
|
39
|
-
"platform": "web"
|
|
40
|
-
"inject": [
|
|
41
|
-
"@deepseek-ai/dsh-client-runtime"
|
|
42
|
-
]
|
|
39
|
+
"platform": "web"
|
|
43
40
|
}
|
|
44
41
|
},
|
|
45
42
|
"peerDependencies": {
|