dsh-my-guardian 0.3.4 → 0.3.5

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.3.5] - 2026-09-04
9
+
10
+ ### 变更
11
+
12
+ - chore(release): dsh-my-guardian v0.3.5(#86 依赖预检 + 验证清单)
13
+ - feat(guardian): #86 候选区插件依赖预检 + 失败分类 (#124)
14
+ - docs: #106 安装命令统一加 --trust-lockfile (#113)
15
+
8
16
  ## [0.3.4] - 2026-09-02
9
17
 
10
18
  ### 变更
package/README.md CHANGED
@@ -13,23 +13,24 @@ cordis.patch.yml(核心区:只放稳定插件,守护插件自身)
13
13
  ↓ 新插件写进
14
14
  cordis.staged.json(候选区)
15
15
  ↓ DSH 启动完成后
16
- 守护插件逐个热挂载
16
+ 守护插件逐个依赖预检 + 热挂载
17
17
  ├─ 成功 → 自动转正(进入持久化清单,每次启动自动恢复)
18
- └─ 失败 → 自动隔离(记录次数+错误,连续 3 次冻结)
18
+ └─ 失败 → 自动隔离(记录次数 + 失败类型/错误,连续 3 次冻结)
19
19
  ```
20
20
 
21
21
  ## 功能
22
22
 
23
23
  - **两段式加载**:新装/更新插件先进候选区,启动不阻塞、坏插件不拖垮进程。
24
- - **失败自动隔离**:挂载失败自动记录(尝试次数 + 错误摘要),连续失败 **3 次冻结**,需手动重试。
24
+ - **失败自动隔离**:挂载失败自动记录(尝试次数 + 失败类型 + 错误摘要),连续失败 **3 次冻结**,需手动重试。
25
+ - **挂载前依赖预检**:候选插件挂载前检查 `peerDependencies`——仓库内 `dsh-*` 依赖是否安装、官方依赖版本是否满足;缺失/不满足即标记「依赖缺失」并给出安装建议(如 `dsh plugin add <依赖>`),不进入挂载。
25
26
  - **成功自动转正**:挂载成功的插件进入持久化清单(`$DSH_HOME/guardian/state.json`),后续每次启动自动恢复挂载。
26
27
  - **安全模式**:一键跳过所有候选/已转正插件,快速恢复被插件搞坏的环境。
27
- - **诊断面板**:dsh-better-sidebar 侧边栏"插件守护"页签——状态列表 / 重试 / 移除 / 错误详情 / 安全模式开关 / 最近事件。
28
+ - **诊断面板**:dsh-better-sidebar 侧边栏"插件守护"页签——状态列表 / 重试 / 移除 / 错误详情 / 失败分类徽标 / 安全模式开关 / 最近事件。
28
29
  - **运行中热挂载**:DSH 运行期间往候选区加条目,自动挂载,无需重启。
29
30
 
30
31
  ## 安装
31
32
 
32
- > 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-my-guardian`——无需克隆本仓库;以下 link 方式供本仓库开发者使用。依赖 `dsh-shared`(server 端共享工具包)随 npm 自动安装,无需手动处理。
33
+ > 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-my-guardian --trust-lockfile`——无需克隆本仓库;以下 link 方式供本仓库开发者使用。依赖 `dsh-shared`(server 端共享工具包)随 npm 自动安装,无需手动处理。
33
34
 
34
35
  ### 方式一:dsh plugin(推荐)
35
36
 
@@ -83,6 +84,8 @@ dsh plugin --profile web add link:<仓库路径>/plugins/dsh-my-guardian
83
84
  | 失败 ×N | 挂载失败 N 次 | 重试 / 移除 |
84
85
  | 冻结 | 连续失败 3 次 | 重试(解除冻结)/ 移除 |
85
86
 
87
+ 失败条目额外带**失败类型徽标**(依赖缺失 / 代码错误 / 其他),依赖缺失时并展示安装建议命令(如 `dsh plugin add dsh-shared`)。
88
+
86
89
  ### 效果截图(真实 DSH 实例验证)
87
90
 
88
91
  侧边栏"插件守护"诊断面板(独立 3081 端口隔离 DSH 实例实测):
@@ -91,7 +94,7 @@ dsh plugin --profile web add link:<仓库路径>/plugins/dsh-my-guardian
91
94
 
92
95
  ![失败自动隔离:错误详情可查](https://unpkg.com/dsh-my-guardian/assets/panel-error-detail.png)
93
96
 
94
- > 截图环境:隔离 DSH 验证实例(`/tmp/dsh-3081`,端口 3081)。候选区同时写入 `demo-plugin`(挂载成功 → 自动转正"运行中")与 `dsh-no-such-plugin-xyz`(包不存在 → 挂载失败自动隔离 ×1,错误详情保留可查)。
97
+ > 截图环境:隔离 DSH 验证实例(`/tmp/dsh-3085`,端口 3085)。候选区写入 `fake-needy`(peer 依赖 `fake-never-installed-dep` 缺失 → 依赖预检拦截,分类徽标「依赖缺失」+ 安装建议 `dsh plugin add fake-never-installed-dep` + 自动隔离冻结)与 `fake-simple`(无依赖 → 热挂载成功自动转正「运行中」)。
95
98
 
96
99
  ## 配置
97
100
 
package/lib/api.js CHANGED
@@ -155,45 +155,37 @@ async function handleSafemodePost(ctx, shared, request, response) {
155
155
  writeJson(response, 200, { ok: true, value: shared.snapshot() })
156
156
  }
157
157
 
158
+ /** One row for the panel: status + failure-classification fields (issue #86). */
159
+ function entrySnapshot(shared, id, record, isStaged) {
160
+ const status = shared.mounted.has(id)
161
+ ? 'running'
162
+ : record.frozen
163
+ ? 'frozen'
164
+ : record.attempts > 0
165
+ ? 'failed'
166
+ : 'pending'
167
+ const item = {
168
+ id,
169
+ name: record.name,
170
+ attempts: record.attempts,
171
+ frozen: record.frozen,
172
+ lastError: record.lastError,
173
+ lastFailedAt: record.lastFailedAt,
174
+ failureType: record.failureType ?? null,
175
+ missingDeps: record.missingDeps ?? [],
176
+ installHint: record.installHint ?? null,
177
+ status,
178
+ }
179
+ if (!isStaged) item.promotedAt = record.promotedAt
180
+ return item
181
+ }
182
+
158
183
  /** Snapshot for the panel (leaf values only). */
159
184
  function snapshot(shared) {
160
- const stagedList = []
161
- for (const [id, record] of Object.entries(shared.state.staged)) {
162
- stagedList.push({
163
- id,
164
- name: record.name,
165
- attempts: record.attempts,
166
- frozen: record.frozen,
167
- lastError: record.lastError,
168
- lastFailedAt: record.lastFailedAt,
169
- status: shared.mounted.has(id)
170
- ? 'running'
171
- : record.frozen
172
- ? 'frozen'
173
- : record.attempts > 0
174
- ? 'failed'
175
- : 'pending',
176
- })
177
- }
178
- const promotedList = []
179
- for (const [id, record] of Object.entries(shared.state.promoted)) {
180
- promotedList.push({
181
- id,
182
- name: record.name,
183
- attempts: record.attempts,
184
- frozen: record.frozen,
185
- lastError: record.lastError,
186
- lastFailedAt: record.lastFailedAt,
187
- promotedAt: record.promotedAt,
188
- status: shared.mounted.has(id)
189
- ? 'running'
190
- : record.frozen
191
- ? 'frozen'
192
- : record.attempts > 0
193
- ? 'failed'
194
- : 'pending',
195
- })
196
- }
185
+ const stagedList = Object.entries(shared.state.staged).map(([id, record]) => entrySnapshot(shared, id, record, true))
186
+ const promotedList = Object.entries(shared.state.promoted).map(([id, record]) =>
187
+ entrySnapshot(shared, id, record, false),
188
+ )
197
189
  return {
198
190
  safeMode: shared.state.safeMode,
199
191
  staged: stagedList,
package/lib/client.js CHANGED
@@ -107,6 +107,19 @@ const STYLES = `
107
107
  .dsh-my-guardian-badge-pending { color:var(--dsw-alias-accent); background:color-mix(in srgb, var(--dsw-alias-accent) 12%, transparent); }
108
108
  .dsh-my-guardian-badge-failed { color:var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent); }
109
109
  .dsh-my-guardian-badge-frozen { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
110
+ /* failure-classification badge chips (issue #86): dependency / code / other */
111
+ .dsh-my-guardian-category { flex:none; display:inline-flex; align-items:center; justify-content:center; height:17px; padding:0 5px; border-radius:4px;
112
+ font:var(--dsw-font-xxxs-strong-11); }
113
+ .dsh-my-guardian-category-dependency { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
114
+ .dsh-my-guardian-category-code { color:var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); }
115
+ .dsh-my-guardian-category-other { color:var(--dsw-alias-label-tertiary); background:var(--dsw-alias-interactive-bg-hover); }
116
+ /* install-suggestion line for dependency failures */
117
+ .dsh-my-guardian-install-hint { display:flex; align-items:center; gap:5px; padding:3px 6px; border-radius:6px;
118
+ background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 6%, transparent);
119
+ font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); }
120
+ .dsh-my-guardian-install-hint-label { flex:none; color:var(--dsw-alias-label-tertiary); }
121
+ .dsh-my-guardian-install-hint code { font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:var(--dsw-font-xxxs-11);
122
+ color:var(--dsw-alias-state-success-primary); word-break:break-all; }
110
123
  .dsh-my-guardian-row-meta { display:flex; align-items:center; gap:6px; font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); }
111
124
  .dsh-my-guardian-attempts { color:var(--dsw-alias-state-error-primary); }
112
125
  .dsh-my-guardian-link { display:inline-flex; align-items:center; gap:3px; padding:0; border:none; background:transparent; cursor:pointer;
@@ -215,6 +228,10 @@ const strings = {
215
228
  loading: () => (isZh() ? '加载中…' : 'Loading…'),
216
229
  events: () => (isZh() ? '最近事件' : 'Recent events'),
217
230
  attempts: (n) => (isZh() ? `失败 ${n} 次` : `failed ×${n}`),
231
+ failureDependency: () => (isZh() ? '依赖缺失' : 'Dependency'),
232
+ failureCode: () => (isZh() ? '代码错误' : 'Code error'),
233
+ failureOther: () => (isZh() ? '其他' : 'Other'),
234
+ installHint: () => (isZh() ? '安装建议' : 'Install'),
218
235
  }
219
236
 
220
237
  // ── api ───────────────────────────────────────────────────────────────
@@ -256,6 +273,20 @@ function statusLabel(status) {
256
273
  }
257
274
  }
258
275
 
276
+ /** Failure-classification badge label (issue #86): dependency / code / other. */
277
+ function failureTypeLabel(type) {
278
+ switch (type) {
279
+ case 'dependency':
280
+ return strings.failureDependency()
281
+ case 'code':
282
+ return strings.failureCode()
283
+ case 'other':
284
+ return strings.failureOther()
285
+ default:
286
+ return type
287
+ }
288
+ }
289
+
259
290
  // ── event log ─────────────────────────────────────────────────────────
260
291
  // Event type → badge label + color variant (mirrors the dfa-op chip style).
261
292
  const EVENT_LABELS = {
@@ -442,6 +473,27 @@ const icon = {
442
473
  ],
443
474
  size,
444
475
  ),
476
+ // 下载(issue #85 新增):箭头入托盘,图表导出按钮(dsh-mermaid-render
477
+ // 卡片下载 PNG/SVG),stroke=currentColor 风格与其余图标一致。
478
+ download: (size = 16) =>
479
+ iconSvg(
480
+ [
481
+ createElement('path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' }),
482
+ createElement('polyline', { points: '7 10 12 15 17 10' }),
483
+ createElement('line', { x1: 12, y1: 15, x2: 12, y2: 3 }),
484
+ ],
485
+ size,
486
+ ),
487
+ // 复制(issue #85 新增):双层矩形,复制源码按钮(dsh-mermaid-render
488
+ // 卡片复制代码),stroke=currentColor 风格与其余图标一致。
489
+ copy: (size = 16) =>
490
+ iconSvg(
491
+ [
492
+ createElement('rect', { x: 9, y: 9, width: 13, height: 13, rx: 2 }),
493
+ createElement('path', { d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' }),
494
+ ],
495
+ size,
496
+ ),
445
497
  }
446
498
 
447
499
  // Common-language / file-type badges (issue #24): brand fill + contrast
@@ -599,7 +651,7 @@ const fileIconByExt = (ext, size = 14) => {
599
651
  }
600
652
 
601
653
  // ── row ────────────────────────────────────────────────────────────────
602
- /** Row head: source chip + name + status badge. */
654
+ /** Row head: source chip + name + status badge + failure-category badge. */
603
655
  function RowHead({ entry, source }) {
604
656
  return createElement(
605
657
  'div',
@@ -615,6 +667,16 @@ function RowHead({ entry, source }) {
615
667
  { className: `dsh-my-guardian-badge dsh-my-guardian-badge-${entry.status}` },
616
668
  statusLabel(entry.status),
617
669
  ),
670
+ typeof entry.failureType === 'string' && entry.failureType !== ''
671
+ ? createElement(
672
+ 'span',
673
+ {
674
+ className: `dsh-my-guardian-category dsh-my-guardian-category-${entry.failureType}`,
675
+ title: entry.failureType,
676
+ },
677
+ failureTypeLabel(entry.failureType),
678
+ )
679
+ : null,
618
680
  )
619
681
  }
620
682
 
@@ -727,6 +789,9 @@ function EntryRow({ entry, source, onAction }) {
727
789
  const [confirming, setConfirming] = useState(false)
728
790
  const [busy, setBusy] = useState(false)
729
791
  const hasError = typeof entry.lastError === 'string' && entry.lastError !== ''
792
+ const isDepFailure = entry.failureType === 'dependency'
793
+ const installHint =
794
+ isDepFailure && typeof entry.installHint === 'string' && entry.installHint !== '' ? entry.installHint : null
730
795
 
731
796
  const run = (kind) => {
732
797
  setBusy(true)
@@ -738,6 +803,14 @@ function EntryRow({ entry, source, onAction }) {
738
803
  { className: 'dsh-my-guardian-row' },
739
804
  createElement(RowHead, { entry, source }),
740
805
  createElement(RowMeta, { entry }),
806
+ installHint
807
+ ? createElement(
808
+ 'div',
809
+ { className: 'dsh-my-guardian-install-hint' },
810
+ createElement('span', { className: 'dsh-my-guardian-install-hint-label' }, strings.installHint()),
811
+ createElement('code', null, installHint),
812
+ )
813
+ : null,
741
814
  hasError ? createElement(ErrorToggle, { expanded, onToggle: () => setExpanded(!expanded) }) : null,
742
815
  expanded && hasError ? createElement('pre', { className: 'dsh-my-guardian-error-detail' }, entry.lastError) : null,
743
816
  confirming
@@ -0,0 +1,132 @@
1
+ /**
2
+ * dsh-my-guardian — dependency pre-check for the candidate mount pipeline.
3
+ *
4
+ * Reads a candidate plugin's package.json peerDependencies (from the profile
5
+ * node_modules) and verifies each dependency is installed and version-satisfying
6
+ * BEFORE the plugin is mounted. A hard-missing dependency is reported as a
7
+ * pre-check failure (failureType 'dependency') with an install suggestion, and
8
+ * the mount is skipped — the plugin never enters the runtime load path with a
9
+ * hole in its dependency graph (issue #72: dsh-shared was not published).
10
+ */
11
+ import { existsSync, readFileSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import { satisfies } from './dep-version.js'
14
+
15
+ // Locate a package directory below a node_modules root, following symlinks
16
+ // (pnpm store / npm link both expose package.json through the mirrored dir).
17
+ function findModuleDir(nmRoot, packageName) {
18
+ const dir = join(nmRoot, packageName)
19
+ return existsSync(join(dir, 'package.json')) ? dir : null
20
+ }
21
+
22
+ // Resolve a dependency from the plugin's nested node_modules or the profile
23
+ // node_modules (hoisted installs). Returns the dir or null when absent.
24
+ function resolveDependencyDir(profileDir, pluginDir, dep) {
25
+ const nested = pluginDir === null ? null : findModuleDir(join(pluginDir, 'node_modules'), dep)
26
+ if (nested !== null) return nested
27
+ return findModuleDir(join(profileDir, 'node_modules'), dep)
28
+ }
29
+
30
+ function readPackageJson(dir) {
31
+ if (dir === null) return null
32
+ try {
33
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
34
+ } catch {
35
+ return null
36
+ }
37
+ }
38
+
39
+ function installedVersion(dir) {
40
+ const pkg = readPackageJson(dir)
41
+ return pkg !== null && typeof pkg.version === 'string' ? pkg.version : null
42
+ }
43
+
44
+ // Inspect a single peer dependency and classify the outcome.
45
+ function examinePeer(dep, range, optional, pluginDir, profileDir) {
46
+ const depDir = resolveDependencyDir(profileDir, pluginDir, dep)
47
+ if (depDir === null) {
48
+ if (optional) return { kind: 'warn', message: `可选依赖 ${dep} 缺失(未安装)` }
49
+ return { kind: 'missing', name: dep }
50
+ }
51
+ const version = installedVersion(depDir)
52
+ if (version !== null && typeof range === 'string' && range.trim() !== '' && !satisfies(version, range)) {
53
+ const issue = { name: dep, expected: range, found: version }
54
+ if (optional) return { kind: 'warn', message: `可选依赖 ${dep} 版本不满足:${range}(当前 ${version})` }
55
+ return { kind: 'mismatch', issue }
56
+ }
57
+ return { kind: 'ok' }
58
+ }
59
+
60
+ function buildSuggestions(missing, mismatched) {
61
+ const suggestions = missing.map((dep) => `dsh plugin add ${dep}`)
62
+ for (const item of mismatched) suggestions.push(`dsh plugin add ${item.name}@${item.expected}`)
63
+ return suggestions
64
+ }
65
+
66
+ function objectOrEmpty(value) {
67
+ return value ?? {}
68
+ }
69
+
70
+ // Group every peer into missing / mismatched / warning buckets.
71
+ function classifyPeers(peers, meta, pluginDir, profileDir) {
72
+ const missing = []
73
+ const mismatched = []
74
+ const warnings = []
75
+ for (const [dep, range] of Object.entries(peers)) {
76
+ const result = examinePeer(dep, range, meta[dep]?.optional === true, pluginDir, profileDir)
77
+ if (result.kind === 'missing') missing.push(result.name)
78
+ else if (result.kind === 'mismatch') mismatched.push(result.issue)
79
+ else if (result.kind === 'warn') warnings.push(result.message)
80
+ }
81
+ return { missing, mismatched, warnings }
82
+ }
83
+
84
+ function skippedResult(reason) {
85
+ return { ok: true, missing: [], mismatched: [], suggestions: [], warnings: [`跳过依赖预检:${reason}`] }
86
+ }
87
+
88
+ /**
89
+ * Pre-check the peer dependencies of a candidate plugin. Returns:
90
+ * { ok, missing, mismatched, suggestions, warnings }
91
+ * - missing: deps required (not optional) but absent from node_modules
92
+ * - mismatched: deps present at a version outside the declared range
93
+ * - suggestions: `dsh plugin add ...` repair commands
94
+ * - warnings: non-blocking notes (plugin unreadable / optional peers missing)
95
+ * When the plugin or its package.json cannot be located the check is skipped
96
+ * (ok: true) so an unusual install layout is never a false block.
97
+ */
98
+ export function checkPeerDependencies({ profileDir, pluginName }) {
99
+ const pluginDir = findModuleDir(join(profileDir, 'node_modules'), pluginName)
100
+ if (pluginDir === null)
101
+ return skippedResult(`无法定位插件 ${pluginName}(未在 profile node_modules 找到 package.json)`)
102
+ const pkg = readPackageJson(pluginDir)
103
+ if (pkg === null) return skippedResult(`无法解析 ${pluginName} 的 package.json`)
104
+ const { missing, mismatched, warnings } = classifyPeers(
105
+ objectOrEmpty(pkg.peerDependencies),
106
+ objectOrEmpty(pkg.peerDependenciesMeta),
107
+ pluginDir,
108
+ profileDir,
109
+ )
110
+ return {
111
+ ok: missing.length === 0 && mismatched.length === 0,
112
+ missing,
113
+ mismatched,
114
+ suggestions: buildSuggestions(missing, mismatched),
115
+ warnings,
116
+ }
117
+ }
118
+
119
+ /** Build the "缺少依赖 X(请先安装)" message recorded for a failed pre-check. */
120
+ export function buildDependencyMessage(result) {
121
+ const names = [...result.missing, ...result.mismatched.map((item) => item.name)]
122
+ if (names.length === 0) return '依赖预检失败'
123
+ return names.map((name) => `缺少依赖 ${name}(请先安装)`).join(';')
124
+ }
125
+
126
+ /** Classify a mount failure for the isolation record (issue #86). */
127
+ export function classifyFailure(error) {
128
+ const message = error instanceof Error ? error.message : String(error)
129
+ if (/Cannot find module|MODULE_NOT_FOUND|Cannot resolve/i.test(message)) return 'dependency'
130
+ if (/already exists|already in use|conflict/i.test(message)) return 'other'
131
+ return 'code'
132
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * dsh-my-guardian — minimal semver helpers for the dependency pre-check.
3
+ *
4
+ * Only the subset needed to validate peerDependency ranges is implemented:
5
+ * parse a version, compare two versions (with prerelease precedence), and test
6
+ * whether a version satisfies a range (`^`, `~`, comparator operators,
7
+ * wildcards, `||` and whitespace-AND). Build metadata (`+build`) is ignored for
8
+ * precedence. Everything is pure and dependency-free.
9
+ */
10
+
11
+ // Parse a version string into { major, minor, patch, prerelease }.
12
+ // Returns null for input that is not a usable version.
13
+ function parseVersion(input) {
14
+ if (typeof input !== 'string') return null
15
+ const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(input.trim())
16
+ if (match === null) return null
17
+ return {
18
+ major: Number(match[1]),
19
+ minor: Number(match[2] ?? 0),
20
+ patch: Number(match[3] ?? 0),
21
+ prerelease: match[4] === undefined ? [] : match[4].split('.'),
22
+ }
23
+ }
24
+
25
+ // Numeric identifiers have lower precedence than alphanumeric ones.
26
+ function compareIdentifiers(a, b) {
27
+ const aNum = /^\d+$/.test(a)
28
+ const bNum = /^\d+$/.test(b)
29
+ if (aNum && bNum) return Number(a) - Number(b)
30
+ if (aNum !== bNum) return aNum ? -1 : 1
31
+ return a < b ? -1 : a > b ? 1 : 0
32
+ }
33
+
34
+ // Compare two prerelease arrays (a shorter list has lower precedence).
35
+ function comparePrerelease(ap, bp) {
36
+ if (ap.length === 0 && bp.length === 0) return 0
37
+ if (ap.length === 0) return 1
38
+ if (bp.length === 0) return -1
39
+ const len = Math.max(ap.length, bp.length)
40
+ for (let i = 0; i < len; i++) {
41
+ const ai = ap[i]
42
+ const bi = bp[i]
43
+ if (ai === undefined) return -1
44
+ if (bi === undefined) return 1
45
+ const diff = compareIdentifiers(ai, bi)
46
+ if (diff !== 0) return diff
47
+ }
48
+ return 0
49
+ }
50
+
51
+ // Compare two parsed version tuples (major/minor/patch/prerelease).
52
+ // Returns negative/zero/positive like Array#sort.
53
+ function compareParsed(a, b) {
54
+ for (const key of ['major', 'minor', 'patch']) {
55
+ if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
56
+ }
57
+ return comparePrerelease(a.prerelease, b.prerelease)
58
+ }
59
+
60
+ // Parse a range version spec that may contain wildcards (`x`/`*`) and a
61
+ // trailing prerelease (`1.2.3-rc.8`). Missing/padded components are null.
62
+ function parseRangeVersion(spec) {
63
+ const match = /^v?(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?$/.exec(spec.trim())
64
+ if (match === null) return null
65
+ const toNum = (value) => (value !== undefined && /^\d+$/.test(value) ? Number(value) : null)
66
+ return {
67
+ major: toNum(match[1]),
68
+ minor: match[2] === undefined ? null : toNum(match[2]),
69
+ patch: match[3] === undefined ? null : toNum(match[3]),
70
+ prerelease: match[4] === undefined ? [] : match[4].split('.'),
71
+ }
72
+ }
73
+
74
+ // Lower bound tuple for a caret/tilde range base (wildcards → 0).
75
+ function lowerBound(base) {
76
+ return {
77
+ major: base.major ?? 0,
78
+ minor: base.minor ?? 0,
79
+ patch: base.patch ?? 0,
80
+ prerelease: base.prerelease,
81
+ }
82
+ }
83
+
84
+ // Upper bound for a caret range: bump the first non-zero component.
85
+ function caretUpper(lo) {
86
+ if (lo.major > 0) return { major: lo.major + 1, minor: 0, patch: 0, prerelease: ['0'] }
87
+ if (lo.minor > 0) return { major: 0, minor: lo.minor + 1, patch: 0, prerelease: ['0'] }
88
+ if (lo.patch > 0) return { major: 0, minor: 0, patch: lo.patch + 1, prerelease: ['0'] }
89
+ return { major: 0, minor: 1, patch: 0, prerelease: ['0'] }
90
+ }
91
+
92
+ function satisfiesCaret(version, spec) {
93
+ const base = parseRangeVersion(spec)
94
+ const parsed = parseVersion(version)
95
+ if (base === null || parsed === null) return false
96
+ const lo = lowerBound(base)
97
+ if (compareParsed(parsed, lo) < 0) return false
98
+ return compareParsed(parsed, caretUpper(lo)) < 0
99
+ }
100
+
101
+ function satisfiesTilde(version, spec) {
102
+ const base = parseRangeVersion(spec)
103
+ const parsed = parseVersion(version)
104
+ if (base === null || parsed === null) return false
105
+ const lo = lowerBound(base)
106
+ if (compareParsed(parsed, lo) < 0) return false
107
+ const upper =
108
+ base.minor === null
109
+ ? { major: lo.major + 1, minor: 0, patch: 0, prerelease: ['0'] }
110
+ : { major: lo.major, minor: lo.minor + 1, patch: 0, prerelease: ['0'] }
111
+ return compareParsed(parsed, upper) < 0
112
+ }
113
+
114
+ // Compare a single range component against the version; wildcard (null) skips.
115
+ function compareComponent(parsed, base, key) {
116
+ if (base[key] === null) return 0
117
+ if (parsed[key] === base[key]) return 0
118
+ return parsed[key] < base[key] ? -1 : 1
119
+ }
120
+
121
+ // Compare a version against a range spec, only over the components that are
122
+ // concrete numbers. Returns null when either side is not a usable version.
123
+ function compareRangeToVersion(version, spec) {
124
+ const base = parseRangeVersion(spec)
125
+ const parsed = parseVersion(version)
126
+ if (base === null || parsed === null) return null
127
+ for (const key of ['major', 'minor', 'patch']) {
128
+ const diff = compareComponent(parsed, base, key)
129
+ if (diff !== 0) return diff
130
+ }
131
+ return 0
132
+ }
133
+
134
+ function satisfiesComparator(version, token) {
135
+ const match = /^(>=|<=|>|<|=|==)?\s*(.+)$/.exec(token.trim())
136
+ if (match === null) return false
137
+ const op = match[1] ?? '='
138
+ const diff = compareRangeToVersion(version, match[2].trim())
139
+ if (diff === null) return false
140
+ if (op === '>') return diff > 0
141
+ if (op === '<') return diff < 0
142
+ if (op === '>=') return diff >= 0
143
+ if (op === '<=') return diff <= 0
144
+ return diff === 0
145
+ }
146
+
147
+ function satisfiesExactOrWildcard(version, token) {
148
+ const base = parseRangeVersion(token)
149
+ const parsed = parseVersion(version)
150
+ if (base === null || parsed === null) return false
151
+ return compareRangeToVersion(version, token) === 0
152
+ }
153
+
154
+ function satisfiesToken(version, token) {
155
+ if (token === '' || token === '*' || token === 'x' || token === 'X') return true
156
+ if (token.startsWith('^')) return satisfiesCaret(version, token.slice(1))
157
+ if (token.startsWith('~')) return satisfiesTilde(version, token.slice(1))
158
+ if (/^(>=|<=|>|<|=|==)/.test(token)) return satisfiesComparator(version, token)
159
+ return satisfiesExactOrWildcard(version, token)
160
+ }
161
+
162
+ // A single alternative group: whitespace-AND of tokens, minus hyphen ranges.
163
+ function satisfiesAll(version, alt) {
164
+ if (alt === '') return true
165
+ const hyphen = alt.split(/\s+-\s+/)
166
+ if (hyphen.length === 2) {
167
+ return satisfies(version, `>=${hyphen[0].trim()}`) && satisfies(version, `<=${hyphen[1].trim()}`)
168
+ }
169
+ return alt
170
+ .split(/\s+/)
171
+ .filter(Boolean)
172
+ .every((token) => satisfiesToken(version, token))
173
+ }
174
+
175
+ /**
176
+ * Test whether `version` satisfies `range` (supports `||`, whitespace-AND and
177
+ * the common comparators). An empty or `*` range matches anything. A prerelease
178
+ * version only matches when the range itself mentions a prerelease — mirroring
179
+ * the semver rule that prereleases are opt-in.
180
+ */
181
+ export function satisfies(version, range) {
182
+ if (typeof version !== 'string' || typeof range !== 'string') return false
183
+ const trimmed = range.trim()
184
+ if (trimmed === '' || trimmed === '*') return true
185
+ const parsed = parseVersion(version)
186
+ if (parsed !== null && parsed.prerelease.length > 0 && !range.includes('-')) return false
187
+ return trimmed.split('||').some((alt) => satisfiesAll(version, alt.trim()))
188
+ }
package/lib/mount.js CHANGED
@@ -9,6 +9,7 @@
9
9
  import { dirname } from 'node:path'
10
10
  import { FREEZE_LIMIT, errorSnip, loadState, readStagedFile, writeStagedFile } from './state.js'
11
11
  import { logEvent } from './events.js'
12
+ import { checkPeerDependencies, buildDependencyMessage, classifyFailure } from './dep-precheck.js'
12
13
 
13
14
  /**
14
15
  * Find the root Include tree of the profile. The loader tree's entries carry
@@ -91,16 +92,40 @@ async function mountWithState(shared, kind, id, entry) {
91
92
  const record = shared.state[recordKey][id] ?? {}
92
93
  if (record.frozen) return 'skipped'
93
94
  const name = typeof entry?.name === 'string' ? entry.name : id
95
+ // Dependency pre-check (issue #86): a plugin whose peer deps are missing or
96
+ // out of range must not enter the runtime load path with a hole in its
97
+ // dependency graph. A failed pre-check is recorded as a 'dependency' failure
98
+ // with the missing deps + an install suggestion, and the mount is skipped.
99
+ const precheck = precheckEntry(shared, name)
100
+ if (precheck !== null) {
101
+ recordFailure(shared, recordKey, id, name, entry, record, {
102
+ failureType: 'dependency',
103
+ message: buildDependencyMessage(precheck),
104
+ missingDeps: [...precheck.missing, ...precheck.mismatched.map((item) => item.name)],
105
+ installHint: precheck.suggestions[0] ?? null,
106
+ })
107
+ return 'failed'
108
+ }
94
109
  try {
95
110
  await mount(shared, id, entry)
96
111
  await promote(shared, recordKey, id, name, entry, record)
97
112
  return 'mounted'
98
113
  } catch (error) {
99
- recordFailure(shared, recordKey, id, name, entry, record, error)
114
+ recordFailure(shared, recordKey, id, name, entry, record, {
115
+ failureType: classifyFailure(error),
116
+ message: error instanceof Error ? error.message : String(error),
117
+ })
100
118
  return 'failed'
101
119
  }
102
120
  }
103
121
 
122
+ /** Run the dependency pre-check; null means it passed (or was skipped). */
123
+ function precheckEntry(shared, name) {
124
+ if (typeof name !== 'string' || name === '') return null
125
+ const result = checkPeerDependencies({ profileDir: shared.profileDir, pluginName: name })
126
+ return result.ok ? null : result
127
+ }
128
+
104
129
  /** Success path: staged moves into the persisted promoted list (R2). */
105
130
  async function promote(shared, recordKey, id, name, entry, record) {
106
131
  if (recordKey === 'staged') {
@@ -111,6 +136,9 @@ async function promote(shared, recordKey, id, name, entry, record) {
111
136
  lastError: null,
112
137
  lastFailedAt: null,
113
138
  frozen: false,
139
+ failureType: null,
140
+ missingDeps: [],
141
+ installHint: null,
114
142
  promotedAt: Date.now(),
115
143
  }
116
144
  delete shared.state.staged[id]
@@ -125,6 +153,9 @@ async function promote(shared, recordKey, id, name, entry, record) {
125
153
  lastError: null,
126
154
  lastFailedAt: null,
127
155
  frozen: false,
156
+ failureType: null,
157
+ missingDeps: [],
158
+ installHint: null,
128
159
  }
129
160
  }
130
161
  logEvent(shared, 'promote', `mounted ${name} (${id})`)
@@ -132,19 +163,23 @@ async function promote(shared, recordKey, id, name, entry, record) {
132
163
  }
133
164
 
134
165
  /** Failure path: attempts counter + error recorded; freeze at the limit. */
135
- function recordFailure(shared, recordKey, id, name, entry, record, error) {
166
+ function recordFailure(shared, recordKey, id, name, entry, record, info) {
136
167
  const attempts = (record.attempts ?? 0) + 1
137
168
  const frozen = attempts >= FREEZE_LIMIT
169
+ const message = typeof info.message === 'string' ? info.message : String(info.message ?? info)
138
170
  shared.state[recordKey][id] = {
139
171
  name,
140
172
  config: entry.config ?? undefined,
141
173
  attempts,
142
- lastError: errorSnip(error),
174
+ lastError: errorSnip(message),
143
175
  lastFailedAt: Date.now(),
144
176
  frozen,
177
+ failureType: info.failureType ?? 'code',
178
+ missingDeps: info.missingDeps ?? [],
179
+ installHint: info.installHint ?? null,
145
180
  ...(recordKey === 'promoted' ? { promotedAt: record.promotedAt } : {}),
146
181
  }
147
- logEvent(shared, frozen ? 'freeze' : 'quarantine', `${name} (${id}) failed ${attempts}x: ${error.message ?? error}`)
182
+ logEvent(shared, frozen ? 'freeze' : 'quarantine', `${name} (${id}) failed ${attempts}x: ${message}`)
148
183
  shared.persistSoon()
149
184
  }
150
185
 
@@ -211,6 +246,9 @@ async function retryEntry(shared, id) {
211
246
  lastError: null,
212
247
  lastFailedAt: null,
213
248
  frozen: false,
249
+ failureType: null,
250
+ missingDeps: [],
251
+ installHint: null,
214
252
  }
215
253
  shared.persistSoon()
216
254
  if (shared.state.safeMode) return 'safe'
@@ -1,5 +1,5 @@
1
1
  // ── row ────────────────────────────────────────────────────────────────
2
- /** Row head: source chip + name + status badge. */
2
+ /** Row head: source chip + name + status badge + failure-category badge. */
3
3
  function RowHead({ entry, source }) {
4
4
  return createElement(
5
5
  'div',
@@ -15,6 +15,16 @@ function RowHead({ entry, source }) {
15
15
  { className: `dsh-my-guardian-badge dsh-my-guardian-badge-${entry.status}` },
16
16
  statusLabel(entry.status),
17
17
  ),
18
+ typeof entry.failureType === 'string' && entry.failureType !== ''
19
+ ? createElement(
20
+ 'span',
21
+ {
22
+ className: `dsh-my-guardian-category dsh-my-guardian-category-${entry.failureType}`,
23
+ title: entry.failureType,
24
+ },
25
+ failureTypeLabel(entry.failureType),
26
+ )
27
+ : null,
18
28
  )
19
29
  }
20
30
 
@@ -127,6 +137,9 @@ function EntryRow({ entry, source, onAction }) {
127
137
  const [confirming, setConfirming] = useState(false)
128
138
  const [busy, setBusy] = useState(false)
129
139
  const hasError = typeof entry.lastError === 'string' && entry.lastError !== ''
140
+ const isDepFailure = entry.failureType === 'dependency'
141
+ const installHint =
142
+ isDepFailure && typeof entry.installHint === 'string' && entry.installHint !== '' ? entry.installHint : null
130
143
 
131
144
  const run = (kind) => {
132
145
  setBusy(true)
@@ -138,6 +151,14 @@ function EntryRow({ entry, source, onAction }) {
138
151
  { className: 'dsh-my-guardian-row' },
139
152
  createElement(RowHead, { entry, source }),
140
153
  createElement(RowMeta, { entry }),
154
+ installHint
155
+ ? createElement(
156
+ 'div',
157
+ { className: 'dsh-my-guardian-install-hint' },
158
+ createElement('span', { className: 'dsh-my-guardian-install-hint-label' }, strings.installHint()),
159
+ createElement('code', null, installHint),
160
+ )
161
+ : null,
141
162
  hasError ? createElement(ErrorToggle, { expanded, onToggle: () => setExpanded(!expanded) }) : null,
142
163
  expanded && hasError ? createElement('pre', { className: 'dsh-my-guardian-error-detail' }, entry.lastError) : null,
143
164
  confirming
@@ -73,6 +73,19 @@ const STYLES = `
73
73
  .dsh-my-guardian-badge-pending { color:var(--dsw-alias-accent); background:color-mix(in srgb, var(--dsw-alias-accent) 12%, transparent); }
74
74
  .dsh-my-guardian-badge-failed { color:var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent); }
75
75
  .dsh-my-guardian-badge-frozen { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
76
+ /* failure-classification badge chips (issue #86): dependency / code / other */
77
+ .dsh-my-guardian-category { flex:none; display:inline-flex; align-items:center; justify-content:center; height:17px; padding:0 5px; border-radius:4px;
78
+ font:var(--dsw-font-xxxs-strong-11); }
79
+ .dsh-my-guardian-category-dependency { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
80
+ .dsh-my-guardian-category-code { color:var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); }
81
+ .dsh-my-guardian-category-other { color:var(--dsw-alias-label-tertiary); background:var(--dsw-alias-interactive-bg-hover); }
82
+ /* install-suggestion line for dependency failures */
83
+ .dsh-my-guardian-install-hint { display:flex; align-items:center; gap:5px; padding:3px 6px; border-radius:6px;
84
+ background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 6%, transparent);
85
+ font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); }
86
+ .dsh-my-guardian-install-hint-label { flex:none; color:var(--dsw-alias-label-tertiary); }
87
+ .dsh-my-guardian-install-hint code { font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:var(--dsw-font-xxxs-11);
88
+ color:var(--dsw-alias-state-success-primary); word-break:break-all; }
76
89
  .dsh-my-guardian-row-meta { display:flex; align-items:center; gap:6px; font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); }
77
90
  .dsh-my-guardian-attempts { color:var(--dsw-alias-state-error-primary); }
78
91
  .dsh-my-guardian-link { display:inline-flex; align-items:center; gap:3px; padding:0; border:none; background:transparent; cursor:pointer;
@@ -41,6 +41,10 @@ const strings = {
41
41
  loading: () => (isZh() ? '加载中…' : 'Loading…'),
42
42
  events: () => (isZh() ? '最近事件' : 'Recent events'),
43
43
  attempts: (n) => (isZh() ? `失败 ${n} 次` : `failed ×${n}`),
44
+ failureDependency: () => (isZh() ? '依赖缺失' : 'Dependency'),
45
+ failureCode: () => (isZh() ? '代码错误' : 'Code error'),
46
+ failureOther: () => (isZh() ? '其他' : 'Other'),
47
+ installHint: () => (isZh() ? '安装建议' : 'Install'),
44
48
  }
45
49
 
46
50
  // ── api ───────────────────────────────────────────────────────────────
@@ -82,6 +86,20 @@ function statusLabel(status) {
82
86
  }
83
87
  }
84
88
 
89
+ /** Failure-classification badge label (issue #86): dependency / code / other. */
90
+ function failureTypeLabel(type) {
91
+ switch (type) {
92
+ case 'dependency':
93
+ return strings.failureDependency()
94
+ case 'code':
95
+ return strings.failureCode()
96
+ case 'other':
97
+ return strings.failureOther()
98
+ default:
99
+ return type
100
+ }
101
+ }
102
+
85
103
  // ── event log ─────────────────────────────────────────────────────────
86
104
  // Event type → badge label + color variant (mirrors the dfa-op chip style).
87
105
  const EVENT_LABELS = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-my-guardian",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
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",