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 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.3.5] - 2026-09-04
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
- const [expanded, setExpanded] = useState(false)
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
- /** Recent guardian event log: badge + key info + time per entry. */
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
- if (events.length === 0) return null
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
- events.map((event, index) =>
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',
@@ -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
- function findModuleDir(nmRoot, packageName) {
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?.options?.id ?? '?'} initialized`)
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?.options?.id ?? '?'} disposed`)
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
- initialScan(shared).catch((error) => {
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(
@@ -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
- const [expanded, setExpanded] = useState(false)
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',
@@ -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
  `
@@ -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
  }
@@ -123,14 +123,19 @@ function EntryList({ rows, onAction }) {
123
123
  )
124
124
  }
125
125
 
126
- /** Recent guardian event log: badge + key info + time per entry. */
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
- if (events.length === 0) return null
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
- events.map((event, index) =>
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.5",
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": {