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 +8 -0
- package/README.md +9 -6
- package/lib/api.js +29 -37
- package/lib/client.js +74 -1
- package/lib/dep-precheck.js +132 -0
- package/lib/dep-version.js +188 -0
- package/lib/mount.js +42 -4
- package/lib/parts/row.part.js +22 -1
- package/lib/parts/styles.part.js +13 -0
- package/lib/parts/util.part.js +18 -0
- package/package.json +1 -1
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
|
-
└─ 失败 →
|
|
18
|
+
└─ 失败 → 自动隔离(记录次数 + 失败类型/错误,连续 3 次冻结)
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
## 功能
|
|
22
22
|
|
|
23
23
|
- **两段式加载**:新装/更新插件先进候选区,启动不阻塞、坏插件不拖垮进程。
|
|
24
|
-
- **失败自动隔离**:挂载失败自动记录(尝试次数 + 错误摘要),连续失败 **3 次冻结**,需手动重试。
|
|
24
|
+
- **失败自动隔离**:挂载失败自动记录(尝试次数 + 失败类型 + 错误摘要),连续失败 **3 次冻结**,需手动重试。
|
|
25
|
+
- **挂载前依赖预检**:候选插件挂载前检查 `peerDependencies`——仓库内 `dsh-*` 依赖是否安装、官方依赖版本是否满足;缺失/不满足即标记「依赖缺失」并给出安装建议(如 `dsh plugin add <依赖>`),不进入挂载。
|
|
25
26
|
- **成功自动转正**:挂载成功的插件进入持久化清单(`$DSH_HOME/guardian/state.json`),后续每次启动自动恢复挂载。
|
|
26
27
|
- **安全模式**:一键跳过所有候选/已转正插件,快速恢复被插件搞坏的环境。
|
|
27
|
-
- **诊断面板**:dsh-better-sidebar 侧边栏"插件守护"页签——状态列表 / 重试 / 移除 / 错误详情 / 安全模式开关 / 最近事件。
|
|
28
|
+
- **诊断面板**:dsh-better-sidebar 侧边栏"插件守护"页签——状态列表 / 重试 / 移除 / 错误详情 / 失败分类徽标 / 安全模式开关 / 最近事件。
|
|
28
29
|
- **运行中热挂载**:DSH 运行期间往候选区加条目,自动挂载,无需重启。
|
|
29
30
|
|
|
30
31
|
## 安装
|
|
31
32
|
|
|
32
|
-
> 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-my-guardian`——无需克隆本仓库;以下 link 方式供本仓库开发者使用。依赖 `dsh-shared`(server 端共享工具包)随 npm 自动安装,无需手动处理。
|
|
33
|
+
> 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-my-guardian --trust-lockfile`——无需克隆本仓库;以下 link 方式供本仓库开发者使用。依赖 `dsh-shared`(server 端共享工具包)随 npm 自动安装,无需手动处理。
|
|
33
34
|
|
|
34
35
|
### 方式一:dsh plugin(推荐)
|
|
35
36
|
|
|
@@ -83,6 +84,8 @@ dsh plugin --profile web add link:<仓库路径>/plugins/dsh-my-guardian
|
|
|
83
84
|
| 失败 ×N | 挂载失败 N 次 | 重试 / 移除 |
|
|
84
85
|
| 冻结 | 连续失败 3 次 | 重试(解除冻结)/ 移除 |
|
|
85
86
|
|
|
87
|
+
失败条目额外带**失败类型徽标**(依赖缺失 / 代码错误 / 其他),依赖缺失时并展示安装建议命令(如 `dsh plugin add dsh-shared`)。
|
|
88
|
+
|
|
86
89
|
### 效果截图(真实 DSH 实例验证)
|
|
87
90
|
|
|
88
91
|
侧边栏"插件守护"诊断面板(独立 3081 端口隔离 DSH 实例实测):
|
|
@@ -91,7 +94,7 @@ dsh plugin --profile web add link:<仓库路径>/plugins/dsh-my-guardian
|
|
|
91
94
|
|
|
92
95
|

|
|
93
96
|
|
|
94
|
-
> 截图环境:隔离 DSH 验证实例(`/tmp/dsh-
|
|
97
|
+
> 截图环境:隔离 DSH 验证实例(`/tmp/dsh-3085`,端口 3085)。候选区写入 `fake-needy`(peer 依赖 `fake-never-installed-dep` 缺失 → 依赖预检拦截,分类徽标「依赖缺失」+ 安装建议 `dsh plugin add fake-never-installed-dep` + 自动隔离冻结)与 `fake-simple`(无依赖 → 热挂载成功自动转正「运行中」)。
|
|
95
98
|
|
|
96
99
|
## 配置
|
|
97
100
|
|
package/lib/api.js
CHANGED
|
@@ -155,45 +155,37 @@ async function handleSafemodePost(ctx, shared, request, response) {
|
|
|
155
155
|
writeJson(response, 200, { ok: true, value: shared.snapshot() })
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/** One row for the panel: status + failure-classification fields (issue #86). */
|
|
159
|
+
function entrySnapshot(shared, id, record, isStaged) {
|
|
160
|
+
const status = shared.mounted.has(id)
|
|
161
|
+
? 'running'
|
|
162
|
+
: record.frozen
|
|
163
|
+
? 'frozen'
|
|
164
|
+
: record.attempts > 0
|
|
165
|
+
? 'failed'
|
|
166
|
+
: 'pending'
|
|
167
|
+
const item = {
|
|
168
|
+
id,
|
|
169
|
+
name: record.name,
|
|
170
|
+
attempts: record.attempts,
|
|
171
|
+
frozen: record.frozen,
|
|
172
|
+
lastError: record.lastError,
|
|
173
|
+
lastFailedAt: record.lastFailedAt,
|
|
174
|
+
failureType: record.failureType ?? null,
|
|
175
|
+
missingDeps: record.missingDeps ?? [],
|
|
176
|
+
installHint: record.installHint ?? null,
|
|
177
|
+
status,
|
|
178
|
+
}
|
|
179
|
+
if (!isStaged) item.promotedAt = record.promotedAt
|
|
180
|
+
return item
|
|
181
|
+
}
|
|
182
|
+
|
|
158
183
|
/** Snapshot for the panel (leaf values only). */
|
|
159
184
|
function snapshot(shared) {
|
|
160
|
-
const stagedList = []
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
name: record.name,
|
|
165
|
-
attempts: record.attempts,
|
|
166
|
-
frozen: record.frozen,
|
|
167
|
-
lastError: record.lastError,
|
|
168
|
-
lastFailedAt: record.lastFailedAt,
|
|
169
|
-
status: shared.mounted.has(id)
|
|
170
|
-
? 'running'
|
|
171
|
-
: record.frozen
|
|
172
|
-
? 'frozen'
|
|
173
|
-
: record.attempts > 0
|
|
174
|
-
? 'failed'
|
|
175
|
-
: 'pending',
|
|
176
|
-
})
|
|
177
|
-
}
|
|
178
|
-
const promotedList = []
|
|
179
|
-
for (const [id, record] of Object.entries(shared.state.promoted)) {
|
|
180
|
-
promotedList.push({
|
|
181
|
-
id,
|
|
182
|
-
name: record.name,
|
|
183
|
-
attempts: record.attempts,
|
|
184
|
-
frozen: record.frozen,
|
|
185
|
-
lastError: record.lastError,
|
|
186
|
-
lastFailedAt: record.lastFailedAt,
|
|
187
|
-
promotedAt: record.promotedAt,
|
|
188
|
-
status: shared.mounted.has(id)
|
|
189
|
-
? 'running'
|
|
190
|
-
: record.frozen
|
|
191
|
-
? 'frozen'
|
|
192
|
-
: record.attempts > 0
|
|
193
|
-
? 'failed'
|
|
194
|
-
: 'pending',
|
|
195
|
-
})
|
|
196
|
-
}
|
|
185
|
+
const stagedList = Object.entries(shared.state.staged).map(([id, record]) => entrySnapshot(shared, id, record, true))
|
|
186
|
+
const promotedList = Object.entries(shared.state.promoted).map(([id, record]) =>
|
|
187
|
+
entrySnapshot(shared, id, record, false),
|
|
188
|
+
)
|
|
197
189
|
return {
|
|
198
190
|
safeMode: shared.state.safeMode,
|
|
199
191
|
staged: stagedList,
|
package/lib/client.js
CHANGED
|
@@ -107,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,
|
|
114
|
+
recordFailure(shared, recordKey, id, name, entry, record, {
|
|
115
|
+
failureType: classifyFailure(error),
|
|
116
|
+
message: error instanceof Error ? error.message : String(error),
|
|
117
|
+
})
|
|
100
118
|
return 'failed'
|
|
101
119
|
}
|
|
102
120
|
}
|
|
103
121
|
|
|
122
|
+
/** Run the dependency pre-check; null means it passed (or was skipped). */
|
|
123
|
+
function precheckEntry(shared, name) {
|
|
124
|
+
if (typeof name !== 'string' || name === '') return null
|
|
125
|
+
const result = checkPeerDependencies({ profileDir: shared.profileDir, pluginName: name })
|
|
126
|
+
return result.ok ? null : result
|
|
127
|
+
}
|
|
128
|
+
|
|
104
129
|
/** Success path: staged moves into the persisted promoted list (R2). */
|
|
105
130
|
async function promote(shared, recordKey, id, name, entry, record) {
|
|
106
131
|
if (recordKey === 'staged') {
|
|
@@ -111,6 +136,9 @@ async function promote(shared, recordKey, id, name, entry, record) {
|
|
|
111
136
|
lastError: null,
|
|
112
137
|
lastFailedAt: null,
|
|
113
138
|
frozen: false,
|
|
139
|
+
failureType: null,
|
|
140
|
+
missingDeps: [],
|
|
141
|
+
installHint: null,
|
|
114
142
|
promotedAt: Date.now(),
|
|
115
143
|
}
|
|
116
144
|
delete shared.state.staged[id]
|
|
@@ -125,6 +153,9 @@ async function promote(shared, recordKey, id, name, entry, record) {
|
|
|
125
153
|
lastError: null,
|
|
126
154
|
lastFailedAt: null,
|
|
127
155
|
frozen: false,
|
|
156
|
+
failureType: null,
|
|
157
|
+
missingDeps: [],
|
|
158
|
+
installHint: null,
|
|
128
159
|
}
|
|
129
160
|
}
|
|
130
161
|
logEvent(shared, 'promote', `mounted ${name} (${id})`)
|
|
@@ -132,19 +163,23 @@ async function promote(shared, recordKey, id, name, entry, record) {
|
|
|
132
163
|
}
|
|
133
164
|
|
|
134
165
|
/** Failure path: attempts counter + error recorded; freeze at the limit. */
|
|
135
|
-
function recordFailure(shared, recordKey, id, name, entry, record,
|
|
166
|
+
function recordFailure(shared, recordKey, id, name, entry, record, info) {
|
|
136
167
|
const attempts = (record.attempts ?? 0) + 1
|
|
137
168
|
const frozen = attempts >= FREEZE_LIMIT
|
|
169
|
+
const message = typeof info.message === 'string' ? info.message : String(info.message ?? info)
|
|
138
170
|
shared.state[recordKey][id] = {
|
|
139
171
|
name,
|
|
140
172
|
config: entry.config ?? undefined,
|
|
141
173
|
attempts,
|
|
142
|
-
lastError: errorSnip(
|
|
174
|
+
lastError: errorSnip(message),
|
|
143
175
|
lastFailedAt: Date.now(),
|
|
144
176
|
frozen,
|
|
177
|
+
failureType: info.failureType ?? 'code',
|
|
178
|
+
missingDeps: info.missingDeps ?? [],
|
|
179
|
+
installHint: info.installHint ?? null,
|
|
145
180
|
...(recordKey === 'promoted' ? { promotedAt: record.promotedAt } : {}),
|
|
146
181
|
}
|
|
147
|
-
logEvent(shared, frozen ? 'freeze' : 'quarantine', `${name} (${id}) failed ${attempts}x: ${
|
|
182
|
+
logEvent(shared, frozen ? 'freeze' : 'quarantine', `${name} (${id}) failed ${attempts}x: ${message}`)
|
|
148
183
|
shared.persistSoon()
|
|
149
184
|
}
|
|
150
185
|
|
|
@@ -211,6 +246,9 @@ async function retryEntry(shared, id) {
|
|
|
211
246
|
lastError: null,
|
|
212
247
|
lastFailedAt: null,
|
|
213
248
|
frozen: false,
|
|
249
|
+
failureType: null,
|
|
250
|
+
missingDeps: [],
|
|
251
|
+
installHint: null,
|
|
214
252
|
}
|
|
215
253
|
shared.persistSoon()
|
|
216
254
|
if (shared.state.safeMode) return 'safe'
|
package/lib/parts/row.part.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ── row ────────────────────────────────────────────────────────────────
|
|
2
|
-
/** Row head: source chip + name + status badge. */
|
|
2
|
+
/** Row head: source chip + name + status badge + failure-category badge. */
|
|
3
3
|
function RowHead({ entry, source }) {
|
|
4
4
|
return createElement(
|
|
5
5
|
'div',
|
|
@@ -15,6 +15,16 @@ function RowHead({ entry, source }) {
|
|
|
15
15
|
{ className: `dsh-my-guardian-badge dsh-my-guardian-badge-${entry.status}` },
|
|
16
16
|
statusLabel(entry.status),
|
|
17
17
|
),
|
|
18
|
+
typeof entry.failureType === 'string' && entry.failureType !== ''
|
|
19
|
+
? createElement(
|
|
20
|
+
'span',
|
|
21
|
+
{
|
|
22
|
+
className: `dsh-my-guardian-category dsh-my-guardian-category-${entry.failureType}`,
|
|
23
|
+
title: entry.failureType,
|
|
24
|
+
},
|
|
25
|
+
failureTypeLabel(entry.failureType),
|
|
26
|
+
)
|
|
27
|
+
: null,
|
|
18
28
|
)
|
|
19
29
|
}
|
|
20
30
|
|
|
@@ -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
|
package/lib/parts/styles.part.js
CHANGED
|
@@ -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;
|
package/lib/parts/util.part.js
CHANGED
|
@@ -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.
|
|
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",
|