dsh-my-guardian 0.3.6 → 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,6 +5,14 @@ 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.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
+
8
16
  ## [0.3.6] - 2026-09-04
9
17
 
10
18
  ### 变更
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
@@ -182,6 +182,33 @@ const STYLES = `
182
182
  .dsh-my-guardian-event-message { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
183
183
  font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-secondary); }
184
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; }
185
212
  @keyframes dsh-my-guardian-row-in { from { opacity:0; transform:translateY(1px); } to { opacity:1; transform:none; } }
186
213
  @keyframes dsh-my-guardian-spin { to { transform:rotate(360deg); } }
187
214
  `
@@ -237,6 +264,13 @@ const strings = {
237
264
  failureCode: () => (isZh() ? '代码错误' : 'Code error'),
238
265
  failureOther: () => (isZh() ? '其他' : 'Other'),
239
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'),
240
274
  }
241
275
 
242
276
  // ── api ───────────────────────────────────────────────────────────────
@@ -304,6 +338,7 @@ const EVENT_LABELS = {
304
338
  safe: () => (isZh() ? '安全模式' : 'Safe mode'),
305
339
  'safe-mode': () => (isZh() ? '安全模式' : 'Safe mode'),
306
340
  skip: () => (isZh() ? '跳过' : 'Skipped'),
341
+ 'startup-issue': () => (isZh() ? '启动区问题' : 'Startup issue'),
307
342
  }
308
343
 
309
344
  /** Badge color variant for an event type; unknown types fall back to the
@@ -316,6 +351,7 @@ function eventVariant(type) {
316
351
  return 'accent'
317
352
  case 'quarantine':
318
353
  case 'update-failed':
354
+ case 'startup-issue':
319
355
  return 'danger'
320
356
  case 'freeze':
321
357
  case 'safe':
@@ -326,6 +362,20 @@ function eventVariant(type) {
326
362
  }
327
363
  }
328
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
+
329
379
  function eventLabel(type) {
330
380
  return (EVENT_LABELS[type] ?? (() => type))()
331
381
  }
@@ -1007,6 +1057,62 @@ function EventList({ events }) {
1007
1057
  )
1008
1058
  }
1009
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
+
1010
1116
  function GuardianView({ visible }) {
1011
1117
  const { state, loadFailed, reload, onAction, onSafeMode } = useGuardianState(visible)
1012
1118
 
@@ -1028,6 +1134,10 @@ function GuardianView({ visible }) {
1028
1134
  'div',
1029
1135
  { className: 'dsh-my-guardian-root' },
1030
1136
  createElement(SafeModeBar, { safeMode: state.safeMode, onSafeMode }),
1137
+ createElement(StartupIssuesBlock, {
1138
+ issues: state.startupIssues,
1139
+ checkedAt: state.startupCheckedAt,
1140
+ }),
1031
1141
  loadFailed
1032
1142
  ? createElement(
1033
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/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(
@@ -148,6 +148,33 @@ const STYLES = `
148
148
  .dsh-my-guardian-event-message { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
149
149
  font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-secondary); }
150
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; }
151
178
  @keyframes dsh-my-guardian-row-in { from { opacity:0; transform:translateY(1px); } to { opacity:1; transform:none; } }
152
179
  @keyframes dsh-my-guardian-spin { to { transform:rotate(360deg); } }
153
180
  `
@@ -49,6 +49,13 @@ const strings = {
49
49
  failureCode: () => (isZh() ? '代码错误' : 'Code error'),
50
50
  failureOther: () => (isZh() ? '其他' : 'Other'),
51
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'),
52
59
  }
53
60
 
54
61
  // ── api ───────────────────────────────────────────────────────────────
@@ -116,6 +123,7 @@ const EVENT_LABELS = {
116
123
  safe: () => (isZh() ? '安全模式' : 'Safe mode'),
117
124
  'safe-mode': () => (isZh() ? '安全模式' : 'Safe mode'),
118
125
  skip: () => (isZh() ? '跳过' : 'Skipped'),
126
+ 'startup-issue': () => (isZh() ? '启动区问题' : 'Startup issue'),
119
127
  }
120
128
 
121
129
  /** Badge color variant for an event type; unknown types fall back to the
@@ -128,6 +136,7 @@ function eventVariant(type) {
128
136
  return 'accent'
129
137
  case 'quarantine':
130
138
  case 'update-failed':
139
+ case 'startup-issue':
131
140
  return 'danger'
132
141
  case 'freeze':
133
142
  case 'safe':
@@ -138,6 +147,20 @@ function eventVariant(type) {
138
147
  }
139
148
  }
140
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
+
141
164
  function eventLabel(type) {
142
165
  return (EVENT_LABELS[type] ?? (() => type))()
143
166
  }
@@ -155,6 +155,62 @@ function EventList({ events }) {
155
155
  )
156
156
  }
157
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
+
158
214
  function GuardianView({ visible }) {
159
215
  const { state, loadFailed, reload, onAction, onSafeMode } = useGuardianState(visible)
160
216
 
@@ -176,6 +232,10 @@ function GuardianView({ visible }) {
176
232
  'div',
177
233
  { className: 'dsh-my-guardian-root' },
178
234
  createElement(SafeModeBar, { safeMode: state.safeMode, onSafeMode }),
235
+ createElement(StartupIssuesBlock, {
236
+ issues: state.startupIssues,
237
+ checkedAt: state.startupCheckedAt,
238
+ }),
179
239
  loadFailed
180
240
  ? createElement(
181
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.6",
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": {