@kira905/dsh-ecosystem-panel 0.0.0-stage → 0.4.4

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/lib/upgrade.js ADDED
@@ -0,0 +1,487 @@
1
+ // dsh-ecosystem-panel / lib/upgrade.js
2
+ // ⑤ 升级体检(只读聚合):本地实体版本 vs npm latest + 兼容风险 + 补丁关联。
3
+ // 设计要点:
4
+ // · 本地版本优先读「实体 package.json」——profile 里的声明值会漂移(声明 ≠ 实体是常态)。
5
+ // · npm 查询走文件缓存(TTL 24h),面板打开不卡;force=true 才真查(并发 + 单包超时)。
6
+ // · 规则表(getUpgradeRules)只出**文案**;断裂符号表(getBreakingSymbols)只出**参考底表**。
7
+ // 两张表**默认都为空**:发布集不内嵌任何环境的禁升/冻结结论,由使用者用配置提供。
8
+ // · 红黄绿档位**不自算**:交给外部**只读 CLI**(DSH_ECOSYSTEM_PANEL_TRIAGE_CLI)判定,
9
+ // 本文件只做「采本地事实 + npm 缓存 → 交给 CLI → 渲染」。
10
+ // CLI 不可用/超时/输入缺口 ⇒ 渲染「档位不可用(原因)」,**绝不猜颜色**。
11
+ // 主包行**不参与**档位判定:分级授权只覆盖插件;主包行的 status 语义仍是「版本落后度」。
12
+ // · 只读:唯一写操作是自己的缓存文件(默认 <数据根>/ecosystem-panel-upgrade-cache.json),
13
+ // 不碰任何其它文件。**绝不执行升级**(升级永远由人决定)。
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { spawnSync } from 'node:child_process';
17
+ import { createHash } from 'node:crypto';
18
+ import { getConfig } from './config.js';
19
+
20
+ // 缓存文件落在配置层解析出的数据根(见 lib/config.js;可用 DSH_ECOSYSTEM_PANEL_CACHE_FILE 覆盖)。
21
+ // ⚠️ 路径必须在**调用时**解析:模块加载期固化成默认用户目录,会让隔离实例把缓存写进别的数据根。
22
+ export const UPGRADE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
23
+ const FETCH_TIMEOUT_MS = 6000;
24
+ const MAIN_PKG = '@deepseek-ai/dsh';
25
+
26
+ /** 当前配置下的缓存文件路径 */
27
+ export function upgradeCacheFile(cfg = getConfig()) {
28
+ return cfg.cacheFile;
29
+ }
30
+ /** 兼容导出(默认配置下的值;运行期请用 upgradeCacheFile(cfg)) */
31
+ export const UPGRADE_CACHE_FILE = upgradeCacheFile();
32
+
33
+ /** 升级决策规则表(**外置**):包名 → { verdict, label, note }
34
+ * verdict 只是**文案分类**(本插件不据此变色,颜色一律来自外部只读 CLI):
35
+ * blocked 需先升主包才能升
36
+ * frozen 已转自建/自维护 fork,升级 = 降级(不升)
37
+ * upgradable 可升(note 里写清升后要做什么,如重打补丁)
38
+ * internal 随宿主主包发行的内置包(忽略远端版本提示)
39
+ * self 本地包 / 无 npm 来源(不涉"升版")
40
+ * 默认**空**:发布集不内嵌任何环境的禁升结论。配置法:DSH_ECOSYSTEM_PANEL_UPGRADE_RULES_FILE
41
+ * (形状见 examples/upgrade-rules.example.json)。 */
42
+ export function getUpgradeRules(cfg = getConfig()) {
43
+ const r = cfg?.upgradeRules;
44
+ return r && typeof r === 'object' && !Array.isArray(r) ? r : {};
45
+ }
46
+ /** 兼容导出(默认配置下的值;运行期请用 getUpgradeRules(cfg)) */
47
+ export const UPGRADE_RULES = getUpgradeRules();
48
+
49
+ /** 已知断裂符号(**外置**):包名 → { removed: [...], since, replacement, affected: [...] }
50
+ * 含义:目标主包线已移除这些导出;插件若仍静态 import 则升级后必崩。
51
+ * 默认**空**:发布集不内嵌任何环境的预检结论。配置法:DSH_ECOSYSTEM_PANEL_BREAKING_SYMBOLS_FILE
52
+ * (形状见 examples/breaking-symbols.example.json)。 */
53
+ export function getBreakingSymbols(cfg = getConfig()) {
54
+ const r = cfg?.breakingSymbols;
55
+ return r && typeof r === 'object' && !Array.isArray(r) ? r : {};
56
+ }
57
+ /** 兼容导出(默认配置下的值;运行期请用 getBreakingSymbols(cfg)) */
58
+ export const BREAKING_SYMBOLS = getBreakingSymbols();
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // 小工具
62
+ // ---------------------------------------------------------------------------
63
+ function readJson(p) {
64
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
65
+ }
66
+ function readText(p) {
67
+ try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
68
+ }
69
+ /** 去掉规则文案里的状态 emoji——状态由列表圆点表示,避免"🟢 圆点 + 🔴 文案"的矛盾 */
70
+ function stripMark(s) {
71
+ return String(s ?? '').replace(/^(?:🟢|🟡|🔴|⬜|✅)\s*/u, '').trim();
72
+ }
73
+ function cmpVersion(a, b) {
74
+ const pa = String(a).replace(/^[^\d]*/, '').split(/[.\-+]/);
75
+ const pb = String(b).replace(/^[^\d]*/, '').split(/[.\-+]/);
76
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
77
+ const na = parseInt(pa[i] ?? '0', 10), nb = parseInt(pb[i] ?? '0', 10);
78
+ if (Number.isNaN(na) || Number.isNaN(nb)) return String(a).localeCompare(String(b));
79
+ if (na !== nb) return na - nb;
80
+ }
81
+ return 0;
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // npm latest(带文件缓存)
86
+ // ---------------------------------------------------------------------------
87
+ async function fetchLatestOne(name) {
88
+ const url = 'https://registry.npmjs.org/' + name.replace('/', '%2F');
89
+ const res = await fetch(url, {
90
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
91
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
92
+ cache: 'no-store'
93
+ });
94
+ if (!res.ok) throw new Error('HTTP ' + res.status);
95
+ const j = await res.json();
96
+ const latest = j?.['dist-tags']?.latest ?? null;
97
+ // 官方 latest 标签不一定是最高版本(实测主包 latest=0.1.2-rc.1,最高=0.1.5-alpha.1)
98
+ let highest = latest;
99
+ for (const v of Object.keys(j?.versions ?? {})) {
100
+ if (highest === null || cmpVersion(v, highest) > 0) highest = v;
101
+ }
102
+ if (!latest && !highest) throw new Error('no versions');
103
+ return { latest: latest ?? highest, highest: highest ?? latest };
104
+ }
105
+
106
+ /**
107
+ * 解析 latest 版本表(缓存优先)。
108
+ * @returns {{ latest: Record<string,string>, at: number|null, source: 'cache'|'network'|'mixed', errors: Record<string,string> }}
109
+ */
110
+ export async function resolveLatest(names, { force = false, cacheFile = upgradeCacheFile(), ttlMs = UPGRADE_CACHE_TTL_MS } = {}) {
111
+ const cache = readJson(cacheFile) ?? {};
112
+ const cached = cache.latest ?? {};
113
+ const cachedHigh = cache.highest ?? {};
114
+ const at = typeof cache.at === 'number' ? cache.at : null;
115
+ const fresh = at !== null && (Date.now() - at) < ttlMs && Object.keys(cachedHigh).length > 0;
116
+ if (!force && fresh) {
117
+ return { latest: cached, highest: cachedHigh, at, source: 'cache', errors: {} };
118
+ }
119
+ const errors = {};
120
+ const latest = force ? {} : { ...cached };
121
+ const highest = force ? {} : { ...cachedHigh };
122
+ await Promise.all(names.map(async (n) => {
123
+ try {
124
+ const r = await fetchLatestOne(n);
125
+ latest[n] = r.latest;
126
+ highest[n] = r.highest;
127
+ } catch (e) {
128
+ errors[n] = String(e?.message ?? e);
129
+ if (!force && cached[n]) latest[n] = cached[n];
130
+ if (!force && cachedHigh[n]) highest[n] = cachedHigh[n];
131
+ }
132
+ }));
133
+ // 写缓存(唯一写操作;失败静默——缓存不可写不应影响面板)
134
+ try {
135
+ fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
136
+ fs.writeFileSync(cacheFile, JSON.stringify({ at: Date.now(), latest, highest }, null, 1), 'utf8');
137
+ } catch { /* ignore */ }
138
+ return { latest, highest, at: Date.now(), source: 'network', errors };
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // 本地插件事实(实体版本优先 + 补丁关联)
143
+ // ---------------------------------------------------------------------------
144
+ function collectLocalFacts(profilePkg, profileNm, patchManifest) {
145
+ const deps = profilePkg?.dependencies ?? {};
146
+ const patchesByPkg = {};
147
+ for (const p of patchManifest ?? []) {
148
+ const pkgs = new Set();
149
+ for (const c of p.checks ?? []) pkgs.add(c.pkg);
150
+ if (p.special?.pkg) pkgs.add(p.special.pkg);
151
+ for (const pk of pkgs) {
152
+ (patchesByPkg[pk] ??= []).push({ id: p.id, title: p.title, script: p.script });
153
+ }
154
+ }
155
+ const facts = [];
156
+ for (const [name, declared] of Object.entries(deps)) {
157
+ const seg = name.startsWith('@') ? name.split('/') : [name];
158
+ const entDir = path.join(profileNm, ...seg);
159
+ const entPkg = readJson(path.join(entDir, 'package.json'));
160
+ const isLink = typeof declared === 'string' && declared.startsWith('link:');
161
+ facts.push({
162
+ name,
163
+ declared,
164
+ isLink,
165
+ entityVersion: entPkg?.version ?? null,
166
+ // 实体优先:声明值会漂移
167
+ localVersion: entPkg?.version ?? (isLink ? null : declared),
168
+ versionDrift: !!(entPkg?.version && typeof declared === 'string' && /^\d/.test(declared) && declared !== entPkg.version),
169
+ patches: patchesByPkg[name] ?? []
170
+ });
171
+ }
172
+ return facts;
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // 档位判定:交给外部**只读 CLI**(配置项 DSH_ECOSYSTEM_PANEL_TRIAGE_CLI)
177
+ //
178
+ // 链路:面板采「本地事实 + npm 缓存」→(stdin,同一份缓存输入)→ 外部 CLI
179
+ // → 返回每包档位(唯一判定源)→ 面板只渲染。
180
+ // 为什么走**进程边界**而不是 import:① 判定实现只许有一份,经只读 CLI 才不会长出第二套口径;
181
+ // ② CLI 不可用/超时/坏 JSON 在进程边界上是**可观测的一等事件**,fail-safe(显"档位不可用")才有落点。
182
+ // ---------------------------------------------------------------------------
183
+ export const TRIAGE_CLI_TIMEOUT_MS = 20_000;
184
+ /** 档位只读 CLI 路径(配置层:显式传入 > 环境变量 > 配置文件 > 工具目录派生 > 未配置) */
185
+ export function triageCliPath(cfg = getConfig()) {
186
+ return cfg?.scripts?.triageCli?.path ?? null;
187
+ }
188
+ /** 兼容导出(默认配置下的值;运行期请用 triageCliPath(cfg)) */
189
+ export const TRIAGE_CLI = triageCliPath();
190
+ /** 档位结果短 TTL 缓存:**键 = payload 指纹 + CLI 文件 mtime**(同输入同结果 ⇒ 缓存天然幂等)。
191
+ * 动机:spawn 一个 node 子进程本身要 200ms 量级,而 CLI 内部通常只跑十几毫秒;
192
+ * 面板每次打开都付这笔开销。命中缓存后档位取数 0ms。 */
193
+ export const TRIAGE_CACHE_TTL_MS = 30_000;
194
+ let _triageCache = null;
195
+
196
+ /**
197
+ * 把面板已知事实打包成 CLI 输入(**"同一份缓存输入"就是这一份**,判据 4 的比对基准)。
198
+ * @param {object} o
199
+ * @param {Array} o.facts collectLocalFacts() 的输出
200
+ * @param {object} o.latest resolveLatest() 的 latest
201
+ * @param {object} o.highest resolveLatest() 的 highest
202
+ * @param {number|null} o.cacheAt
203
+ * @param {string} o.cacheSource
204
+ * @param {object} o.errors npm 查询错误表(可缺)
205
+ */
206
+ export function buildTriagePayload({ facts, latest, highest, cacheAt, cacheSource, errors, decouplingInputs = null, rules = getUpgradeRules() }) {
207
+ const hostUpgradeRequiredByPkg = {};
208
+ for (const f of facts) {
209
+ // 「要升主包才能升」= 规则表里 blocked 类;frozen(已转自建 / 自维护 fork)不属此类。
210
+ if (rules[f.name]?.verdict === 'blocked') hostUpgradeRequiredByPkg[f.name] = true;
211
+ }
212
+ return {
213
+ source: { panel: 'dsh-ecosystem-panel', cacheSource: cacheSource ?? null },
214
+ packages: facts.map((f) => ({
215
+ name: f.name,
216
+ localVersion: f.localVersion,
217
+ declared: typeof f.declared === 'string' ? f.declared : null,
218
+ entityVersion: f.entityVersion ?? null,
219
+ // 无 npm 源(link 包 / 本地包 / 规则表 self)⇒ 不涉"升版",CLI 侧不当缺口
220
+ npmSource: !f.isLink && rules[f.name]?.verdict !== 'self',
221
+ targetVersion: (highest ?? {})[f.name] ?? (latest ?? {})[f.name] ?? null,
222
+ })),
223
+ npmCache: { at: cacheAt ?? null, latest: latest ?? {}, highest: highest ?? {} },
224
+ npmErrors: errors ?? {},
225
+ hostUpgradeRequiredByPkg,
226
+ // ★解耦结论由面板注入(同一份判定源的聚合 + R5 唯一源判定):
227
+ // CLI 因此不再自扫一遍全部包(首屏省 ~443ms)。形状不对/缺失时由 triage fail-closed 消费。
228
+ inputs: decouplingInputs ? { decoupling: decouplingInputs } : {},
229
+ };
230
+ }
231
+
232
+ /**
233
+ * 调只读 CLI 取档位。**任何失败都不抛**(降级成 {ok:false, reason},由调用方渲染"档位不可用")。
234
+ * @returns {{ok:true, result:object, elapsedMs:number}|{ok:false, reason:string, elapsedMs:number}}
235
+ */
236
+ export function evaluateTriageViaCli(payload, { cliPath = triageCliPath(), timeoutMs = TRIAGE_CLI_TIMEOUT_MS, nodePath = process.execPath, cacheTtlMs = TRIAGE_CACHE_TTL_MS } = {}) {
237
+ const t0 = Date.now();
238
+ if (cliPath === null) {
239
+ return { ok: false, reason: '未配置档位判定 CLI(DSH_ECOSYSTEM_PANEL_TRIAGE_CLI 或 DSH_ECOSYSTEM_PANEL_TOOLS_DIR)⇒ 档位不可用(未渲染任何颜色)', elapsedMs: Date.now() - t0 };
240
+ }
241
+ if (!fs.existsSync(cliPath)) return { ok: false, reason: `档位 CLI 不存在:${cliPath}`, elapsedMs: Date.now() - t0 };
242
+ let cliMtime = 0;
243
+ try { cliMtime = fs.statSync(cliPath).mtimeMs; } catch { /* 保持 0 */ }
244
+ const cacheKey = createHash('sha1').update(cliPath + '\u0000' + JSON.stringify(payload)).digest('hex') + '|' + cliMtime;
245
+ if (cacheTtlMs > 0 && _triageCache && _triageCache.key === cacheKey && (Date.now() - _triageCache.at) < cacheTtlMs) {
246
+ return { ..._triageCache.value, cached: true, elapsedMs: Date.now() - t0 };
247
+ }
248
+ const r = spawnSync(nodePath, [cliPath, '--json', '--input', '-'], {
249
+ input: JSON.stringify(payload),
250
+ encoding: 'utf8', windowsHide: true, timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024,
251
+ });
252
+ const elapsedMs = Date.now() - t0;
253
+ if (r.error) return { ok: false, reason: `档位 CLI 执行失败:${r.error.message}`, elapsedMs };
254
+ if (r.status !== 0) return { ok: false, reason: `档位 CLI 退出码 ${r.status}:${String(r.stderr ?? '').trim().slice(-300)}`, elapsedMs };
255
+ try {
256
+ const result = JSON.parse(r.stdout);
257
+ if (result?.ok !== true) return { ok: false, reason: `档位 CLI 返回 ok=false:${result?.error ?? '(无原因)'}`, elapsedMs };
258
+ const value = { ok: true, result, elapsedMs, cached: false };
259
+ _triageCache = { key: cacheKey, at: Date.now(), value };
260
+ return value;
261
+ } catch (e) {
262
+ return { ok: false, reason: `档位 CLI 输出不是 JSON:${String(e?.message ?? e)}`, elapsedMs };
263
+ }
264
+ }
265
+
266
+ /**
267
+ * 渲染口径的唯一落点(**纯函数**:把 CLI 结果映射到面板行;夹具直接断言它 == CLI 的档位)。
268
+ * 规则:
269
+ * · CLI 不可用 → 每行 status='unknown' + 原因(绝不猜颜色);
270
+ * · 行 available=false(输入缺口 / 目标版本不可判)→ status='unknown' + 原因;
271
+ * · 行 available=true → status = tier(green/yellow/red),标签取 CLI 的 tierLabel。
272
+ * @returns {{items:Array, meta:object}}
273
+ */
274
+ export function applyTriage(items, triage) {
275
+ const meta = {
276
+ ok: false, tierSource: 'unavailable', cliVersion: null, triageVersion: null,
277
+ cacheOnly: null, elapsedMs: triage?.elapsedMs ?? null, inputGaps: [], byName: {},
278
+ reason: null, cached: triage?.cached === true,
279
+ };
280
+ const out = items.map((it) => ({ ...it }));
281
+ const byName = new Map();
282
+ if (triage?.ok === true) {
283
+ for (const row of triage.result.items ?? []) byName.set(row.name, row);
284
+ meta.ok = true;
285
+ meta.tierSource = triage.result.tierSource ?? 'upgrade-triage';
286
+ meta.cliVersion = triage.result.cliVersion ?? null;
287
+ meta.triageVersion = triage.result.triageVersion ?? null;
288
+ meta.cacheOnly = triage.result.cacheOnly ?? null;
289
+ meta.inputGaps = triage.result.inputGaps ?? [];
290
+ meta.elapsedMs = triage.result.elapsedMs ?? meta.elapsedMs;
291
+ } else {
292
+ meta.reason = triage?.reason ?? '未调用档位 CLI';
293
+ }
294
+
295
+ for (const it of out) {
296
+ if (triage?.ok !== true) {
297
+ it.status = 'unknown';
298
+ it.tier = null;
299
+ it.tierAvailable = false;
300
+ it.tierSource = 'unavailable';
301
+ it.unavailableReason = meta.reason;
302
+ it.problems = [...(it.problems ?? []), `档位不可用(${meta.reason})—— 未渲染颜色`];
303
+ continue;
304
+ }
305
+ const row = byName.get(it.name);
306
+ if (!row) {
307
+ it.status = 'unknown';
308
+ it.tier = null;
309
+ it.tierAvailable = false;
310
+ it.tierSource = 'unavailable';
311
+ it.unavailableReason = '档位 CLI 未返回该包的行';
312
+ it.problems = [...(it.problems ?? []), '档位不可用(CLI 未返回该包)—— 未渲染颜色'];
313
+ continue;
314
+ }
315
+ it.tier = row.tier ?? null;
316
+ it.tierAvailable = row.available === true;
317
+ it.tierSource = 'upgrade-triage-cli';
318
+ it.tierReasons = row.reasons ?? [];
319
+ it.evidenceGaps = row.evidenceGaps ?? [];
320
+ it.triageLocal = row.local ?? null;
321
+ it.triageTarget = row.target ?? null;
322
+ it.triageTargetSource = row.targetSource ?? null;
323
+ if (row.available === true) {
324
+ it.status = row.tier; // ← 档位唯一源,面板不自算
325
+ it.unavailableReason = null;
326
+ it.verdictLabel = row.tierLabel ?? it.verdictLabel;
327
+ for (const r of (row.reasons ?? []).slice(0, 3)) it.problems = [...it.problems, `档位判据:${r}`];
328
+ } else {
329
+ it.status = 'unknown'; // ← 绝不猜颜色(表 3 逃生舱)
330
+ it.unavailableReason = row.unavailableReason ?? '输入不可用';
331
+ it.problems = [...(it.problems ?? []), `档位不可用(${it.unavailableReason})—— 未渲染颜色`];
332
+ }
333
+ // 原规则文案("需主包 ≥0.1.2" 之类)降级为注解,信息不丢;与档位冲突的旧标签不再上屏
334
+ if (it.ruleNote) it.problems = [...it.problems, `规则表:${it.ruleNote}`];
335
+ }
336
+ meta.byName = Object.fromEntries([...byName.entries()].map(([k, v]) => [k, { tier: v.tier, available: v.available === true, unavailableReason: v.unavailableReason ?? null, tierLabel: v.tierLabel ?? null }]));
337
+ return { items: out, meta };
338
+ }
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // 汇总
342
+ // ---------------------------------------------------------------------------
343
+ /**
344
+ * 构建「升级体检」分组数据(只读)。
345
+ * @param {object} opts
346
+ * @param {object} opts.profilePkg profile package.json 内容
347
+ * @param {string} opts.profileNm profile node_modules 路径
348
+ * @param {string} opts.mainPkgDir 主包目录(.../npm/node_modules/@deepseek-ai/dsh)
349
+ * @param {Array} opts.patchManifest 补丁清单(用于反查该包挂了哪些补丁)
350
+ * @param {boolean} opts.force 是否强制刷新 npm 查询
351
+ */
352
+ export async function buildUpgradeState(opts) {
353
+ const { profilePkg, profileNm, mainPkgDir, patchManifest, force = false, decouplingInputs = null } = opts;
354
+ const cfg = opts.config ?? getConfig();
355
+ const rules = getUpgradeRules(cfg);
356
+ const facts = collectLocalFacts(profilePkg, profileNm, patchManifest);
357
+
358
+ // 主包(mainPkgDir = 全局 npm 下的 @deepseek-ai/dsh 目录)
359
+ const mainPkg = readJson(path.join(mainPkgDir, 'package.json'));
360
+ const mainLocal = mainPkg?.version ?? null;
361
+
362
+ // 需要查远端的包:非 link、有 npm 来源的
363
+ const queryNames = facts
364
+ .filter((f) => !f.isLink && !(rules[f.name]?.verdict === 'self'))
365
+ .map((f) => f.name);
366
+ const mainNames = [MAIN_PKG];
367
+ const { latest, highest, at: cacheAt, source, errors } = await resolveLatest([...mainNames, ...queryNames], { force, cacheFile: cfg.cacheFile });
368
+
369
+ const items = [];
370
+
371
+ // 主包行(用「最高版本」判定是否落后;npm latest 标签另记)
372
+ // ⚠️ 主包行**不走档位判定**:分级授权只覆盖插件、不覆盖主包 ——
373
+ // 它的 status 语义是「版本落后度」(绿=最新 / 黄=有新版),不是「升级授权档」。
374
+ const mainTag = latest[MAIN_PKG] ?? null;
375
+ const mainHighest = highest[MAIN_PKG] ?? mainTag;
376
+ const mainItems = [];
377
+ if (mainLocal) {
378
+ const behind = mainHighest && cmpVersion(mainHighest, mainLocal) > 0;
379
+ const isPrerelease = /-(alpha|beta|rc)/.test(String(mainHighest ?? ''));
380
+ const tagDiffers = !!(mainTag && mainHighest && mainTag !== mainHighest);
381
+ mainItems.push({
382
+ kind: 'upgrade',
383
+ name: MAIN_PKG,
384
+ status: behind ? 'yellow' : 'green',
385
+ description: 'DSH 主包(全局 npm 安装)',
386
+ local: mainLocal,
387
+ latest: mainHighest,
388
+ latestTag: mainTag,
389
+ verdict: behind ? 'main-behind' : 'latest',
390
+ verdictLabel: behind
391
+ ? (isPrerelease ? '有新 prerelease(默认不升)' : '有新正式版')
392
+ : '最新',
393
+ patchCount: 0,
394
+ problems: behind
395
+ ? [
396
+ tagDiffers
397
+ ? `npm latest 标签=${mainTag},最高版本=${mainHighest}(官方全 prerelease)`
398
+ : '官方当前全为 prerelease',
399
+ '预发布线默认不升;真要升,先做兼容审计(老插件用到的 SDK 符号)+ 全量补丁重打 + 回归。'
400
+ ]
401
+ : []
402
+ });
403
+ }
404
+
405
+ for (const f of facts) {
406
+ const rule = rules[f.name];
407
+ const lat = highest[f.name] ?? latest[f.name] ?? null;
408
+ const err = errors[f.name] ?? null;
409
+ const behind = lat && f.localVersion && cmpVersion(lat, f.localVersion) > 0;
410
+ const patchCount = f.patches.length;
411
+
412
+ // ⚠️ 这里只算**文案**(verdict / verdictLabel / problems);**status 不自算**——
413
+ // 红黄绿档位一律来自只读 CLI(applyTriage 填),见本文件头说明。
414
+ let verdict, verdictLabel;
415
+ const problems = [];
416
+ if (rule?.verdict === 'self' || rule?.verdict === 'internal' || f.isLink) {
417
+ verdict = 'local-only';
418
+ verdictLabel = stripMark(rule?.label) || '本地包(无 npm 源)';
419
+ } else if (rule?.verdict === 'blocked' || rule?.verdict === 'frozen') {
420
+ verdict = rule.verdict;
421
+ verdictLabel = stripMark(rule.label);
422
+ } else if (!lat) {
423
+ verdict = 'unknown';
424
+ verdictLabel = err ? '远端查询失败' : '无 npm 源';
425
+ if (err) problems.push('npm registry: ' + err);
426
+ } else if (!behind) {
427
+ verdict = 'latest';
428
+ verdictLabel = '最新';
429
+ } else {
430
+ verdict = 'upgradable';
431
+ verdictLabel = stripMark(rule?.label) || '可升';
432
+ }
433
+
434
+ if (f.versionDrift) problems.push(`⚠ 版本漂移:声明 ${f.declared} ≠ 实体 ${f.entityVersion}`);
435
+ if (patchCount > 0) problems.push(`本地补丁 ${patchCount} 个:${f.patches.map((p) => p.script).join(', ')}`);
436
+ // 防再犯:规则文案声称「无补丁」但补丁清单里其实挂着该包的补丁 ⇒ 文案自相矛盾
437
+ // (面板一边报「无补丁,直接升」一边在「补丁健康」里列该包的补丁)。以补丁清单为事实源纠正。
438
+ if (patchCount > 0 && /无(本地)?(定制)?补丁/.test(String(verdictLabel ?? ''))) {
439
+ verdictLabel = `升版需重打 ${patchCount} 个补丁`;
440
+ problems.push('规则文案与补丁清单不一致(原声称"无补丁")—— 已按事实源纠正,请同步你的升级规则表配置');
441
+ }
442
+
443
+ items.push({
444
+ kind: 'upgrade',
445
+ name: f.name,
446
+ status: 'unknown', // 占位:紧接着被 applyTriage 用 CLI 档位覆盖(拿不到就保持 unknown)
447
+ description: null, // 由 state.js 注入 CN_INTRO
448
+ local: f.localVersion,
449
+ declared: f.declared,
450
+ entityVersion: f.entityVersion,
451
+ latest: lat,
452
+ verdict,
453
+ verdictLabel,
454
+ ruleNote: rule?.note ?? null, // 规则表注解(供 applyTriage 降级为 problems 注释,不再决定档位)
455
+ patchCount,
456
+ patchScripts: f.patches.map((p) => p.script),
457
+ problems
458
+ });
459
+ }
460
+
461
+ // 档位单源:把「本地事实 + npm 缓存」交给只读 CLI,取回每包档位
462
+ const payload = buildTriagePayload({ facts, latest, highest, cacheAt, cacheSource: source, errors, decouplingInputs, rules });
463
+ // ⚠️ 必须显式传当前配置推导出的 CLI 路径:否则会用"进程默认配置"的路径,
464
+ // 在有配置覆盖(隔离实例 / 夹具 / 使用者自定义)时静默失效。
465
+ const triage = evaluateTriageViaCli(payload, { cliPath: triageCliPath(cfg) });
466
+ const applied = applyTriage(items, triage);
467
+ const triagedItems = applied.items;
468
+
469
+ // 排序:红 → 档位不可用 → 黄 → 绿;同类按名字("不可用"靠前,避免被满屏绿掩盖)
470
+ const order = { red: 0, unknown: 1, yellow: 2, green: 3 };
471
+ triagedItems.sort((a, b) => (order[a.status] ?? 9) - (order[b.status] ?? 9) || String(a.name).localeCompare(String(b.name)));
472
+
473
+ const allItems = [...mainItems, ...triagedItems];
474
+ const summary = { total: allItems.length, green: 0, yellow: 0, red: 0, unknown: 0 };
475
+ for (const it of allItems) summary[it.status] = (summary[it.status] ?? 0) + 1;
476
+
477
+ return {
478
+ items: allItems,
479
+ summary,
480
+ cacheAt,
481
+ cacheSource: source,
482
+ errors,
483
+ breaking: getBreakingSymbols(cfg),
484
+ fetchedAt: new Date().toISOString(),
485
+ tier: applied.meta // 档位来源自证(CLI 版本/耗时/输入缺口/逐包档位)
486
+ };
487
+ }
package/package.json CHANGED
@@ -1,6 +1,73 @@
1
1
  {
2
2
  "name": "@kira905/dsh-ecosystem-panel",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "description": "Read-only ecosystem overview panel for a DSH instance: plugin bundles, cordis.patch inserts, patch health, skills, upgrade health and decoupling health. One tab per category. Zero runtime npm dependencies.",
4
+ "version": "0.4.4",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./lib/index.js"
10
+ },
11
+ "./client": {
12
+ "default": "./lib/client.js"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "dsh": {
17
+ "engines": {
18
+ "dsh": ">=0.2.0-rc.2 <0.3.0"
19
+ },
20
+ "bundle": {
21
+ "patch": "./cordis.patch.yml"
22
+ },
23
+ "client": {
24
+ "inject": [],
25
+ "platform": "web"
26
+ }
27
+ },
28
+ "engines": {
29
+ "node": ">=18.17"
30
+ },
31
+ "scripts": {
32
+ "verify": "node scripts/verify-source.mjs .",
33
+ "test": "node scripts/test-fixture.mjs",
34
+ "state": "node scripts/verify-state.mjs"
35
+ },
36
+ "peerDependencies": {
37
+ "@deepseek-ai/dsh": ">=0.2.0-rc.2 <0.3.0"
38
+ },
39
+ "dependencies": {},
40
+ "keywords": [
41
+ "dsh",
42
+ "plugin",
43
+ "panel",
44
+ "diagnostics",
45
+ "read-only",
46
+ "health-check",
47
+ "deepseek-harness",
48
+ "dsh-plugin"
49
+ ],
50
+ "files": [
51
+ "lib/**/*.js",
52
+ "cordis.patch.yml",
53
+ "scripts/**/*.mjs",
54
+ "examples/**",
55
+ "docs/**",
56
+ "README.md",
57
+ "README.en.md",
58
+ "CHANGELOG.md",
59
+ "LICENSE"
60
+ ],
61
+ "license": "AGPL-3.0-only",
62
+ "publishConfig": {
63
+ "access": "public"
64
+ },
65
+ "repository": {
66
+ "type": "git",
67
+ "url": "git+https://github.com/kira905/dsh-ecosystem-panel.git"
68
+ },
69
+ "homepage": "https://github.com/kira905/dsh-ecosystem-panel#readme",
70
+ "bugs": {
71
+ "url": "https://github.com/kira905/dsh-ecosystem-panel/issues"
72
+ }
73
+ }