@noob-stupid/dsh-plugin-console 0.5.36 → 0.5.37

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/client.js CHANGED
@@ -1403,6 +1403,77 @@ window.__ModuleLoader__.load({
1403
1403
  return { shown: n, total: known, page: p, perPage: size, hasMore, truncated, reason }
1404
1404
  }
1405
1405
  // <<<market-rank:end>>>
1406
+
1407
+ // ★★ 「更新候选」的**唯一**判据区(0.5.37)★★
1408
+ // 真机缺陷(2026-10-03 用户实测):卡片上已装 `@deepseek-ai/dsh-time-context` **0.2.0-rc.2**,
1409
+ // 却提示「发现新版本 0.2.0-rc.2 → **0.1.1-rc.1**」并给出「更新」按钮 —— 按下去是**降级**。
1410
+ // 根因就在本文件:普通插件行只判 `data.latest` 与 `entry.version` **字符串不相等**,没有大小比较;
1411
+ // 镜像源 `registry.npmmirror.com` 的 `latest` 陈旧到低于已装版本时,"不相等"被当成"有新版"。
1412
+ // 这里是服务端 lib/server/infra/semver.js 里同一标记块(// <<<update-pick:begin/end>>>)的
1413
+ // **逐字移植**(本文件不能 import),两端共用一份**文本**,由 tests/test-update-version-pick.mjs
1414
+ // 与 tests/test-architecture-guard.mjs ⑪ 断言"去缩进后逐字相等"(改一边不改另一边 = 直接红灯)。
1415
+ // 语义只有这一份判据,任何"要不要提示更新"的地方都只许调它。
1416
+ // <<<update-pick:begin>>>
1417
+ /** 宽松解析(容忍 `v` 前缀与首尾空白);不是三段数字的版本(''、'latest'、'1.2')一律 null。 */
1418
+ function updateParseVersion(value) {
1419
+ const text = String(value ?? '').trim().replace(/^v/u, '')
1420
+ const m = text.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u)
1421
+ if (!m) return null
1422
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] ?? null }
1423
+ }
1424
+
1425
+ /** 语义版本比较(含 prerelease 段;无 prerelease 段视为更高)。只在两份解析都成功时调用。 */
1426
+ function updateCompareVersion(a, b) {
1427
+ if (a.major !== b.major) return a.major - b.major
1428
+ if (a.minor !== b.minor) return a.minor - b.minor
1429
+ if (a.patch !== b.patch) return a.patch - b.patch
1430
+ if (a.pre === null && b.pre === null) return 0
1431
+ if (a.pre === null) return 1
1432
+ if (b.pre === null) return -1
1433
+ const pa = a.pre.split('.')
1434
+ const pb = b.pre.split('.')
1435
+ const len = Math.max(pa.length, pb.length)
1436
+ for (let i = 0; i < len; i += 1) {
1437
+ const xa = pa[i]
1438
+ const xb = pb[i]
1439
+ if (xa === undefined) return -1
1440
+ if (xb === undefined) return 1
1441
+ const na = /^\d+$/u.test(xa)
1442
+ const nb = /^\d+$/u.test(xb)
1443
+ if (na && nb) { const d = Number(xa) - Number(xb); if (d !== 0) return d; continue }
1444
+ if (na) return -1
1445
+ if (nb) return 1
1446
+ const d = xa < xb ? -1 : xa > xb ? 1 : 0
1447
+ if (d !== 0) return d
1448
+ }
1449
+ return 0
1450
+ }
1451
+
1452
+ /**
1453
+ * 「更新候选」的唯一判据:从 candidates 里挑出**严格高于 current** 的最高者,没有则 null。
1454
+ * 边界(全部钉死在 tests/test-update-version-pick.mjs):相同版本 / 更低版本 / 任一无法解析 ⇒ null。
1455
+ * @param {string} current 已装版本
1456
+ * @param {Array<string|null|undefined>} candidates 候选(registry 的 latest / next / beta 等)
1457
+ * @returns {string|null}
1458
+ */
1459
+ function pickNewerSemver(current, candidates) {
1460
+ const cur = updateParseVersion(current)
1461
+ if (cur === null) return null
1462
+ const list = Array.isArray(candidates) ? candidates : []
1463
+ let best = null
1464
+ let bestParsed = null
1465
+ for (const candidate of list) {
1466
+ const parsed = updateParseVersion(candidate)
1467
+ if (parsed === null) continue
1468
+ if (updateCompareVersion(parsed, cur) <= 0) continue
1469
+ if (bestParsed === null || updateCompareVersion(parsed, bestParsed) > 0) {
1470
+ best = String(candidate).trim().replace(/^v/u, '')
1471
+ bestParsed = parsed
1472
+ }
1473
+ }
1474
+ return best
1475
+ }
1476
+ // <<<update-pick:end>>>
1406
1477
  const el = react.createElement;
1407
1478
  function moduleShortName(moduleName) {
1408
1479
  return (moduleName.startsWith("@") ? moduleName.slice(moduleName.indexOf("/") + 1) : moduleName)
@@ -2495,26 +2566,18 @@ window.__ModuleLoader__.load({
2495
2566
  (data) => {
2496
2567
  if (data && data.error === null) {
2497
2568
  const current = state.data.compat?.dshVersion ?? "";
2498
- // 通用 semver 比较(支持 0.1.0-rc.8 → 0.1.1-rc.2 跨 minor 升级;0.1.1-rc.N 不再写死 0.1.0)
2499
- const verNum = (v) => {
2500
- const m = String(v).match(/^(\d+)\.(\d+)\.(\d+)(?:-(?:[a-z]+\.)?(\d+))?$/iu);
2501
- if (!m) return -1;
2502
- const [ , maj, min, pat, rc ] = m;
2503
- // 正式版(无 rc)视为 rc.∞:0.1.1 > 0.1.1-rc.2
2504
- return { maj: parseInt(maj, 10), min: parseInt(min, 10), pat: parseInt(pat, 10), rc: rc === undefined ? Number.POSITIVE_INFINITY : parseInt(rc, 10) };
2505
- };
2506
- const isNewer = (a, b) => {
2507
- if (a === -1 || b === -1) return false;
2508
- if (a.maj !== b.maj) return a.maj > b.maj;
2509
- if (a.min !== b.min) return a.min > b.min;
2510
- if (a.pat !== b.pat) return a.pat > b.pat;
2511
- return a.rc > b.rc;
2512
- };
2513
- // 稳定版 latest 优先;latest 不高于当前而 next(预发布渠道)更高时,目标取 next
2514
- const target = data.latest && isNewer(verNum(data.latest), verNum(current))
2515
- ? data.latest
2516
- : (data.next && isNewer(verNum(data.next), verNum(current)) ? data.next : null);
2517
- if (target) setUpdateMap((prev) => ({ ...prev, [fwItem.fullName]: target }));
2569
+ // 判据只有一份(本文件上方 <<<update-pick>>> 区,与服务端 lib/server/infra/semver.js 逐字同一份文本):
2570
+ // 支持 0.1.0-rc.8 → 0.1.1-rc.2 跨 minor 升级,正式版高于同号预发布(0.1.1 > 0.1.1-rc.2)。
2571
+ // 稳定版 latest 优先;latest 不高于当前而 next(预发布渠道)更高时,目标取 next。
2572
+ const target = pickNewerSemver(current, [data.latest, data.next]);
2573
+ if (target !== null) {
2574
+ setUpdateMap((prev) => {
2575
+ // 两条路径写同一个 map:已有目标更高时保持不动(绝不许把结论换低)
2576
+ const existing = prev[fwItem.fullName];
2577
+ if (typeof existing === "string" && pickNewerSemver(target, [existing]) !== null) return prev;
2578
+ return { ...prev, [fwItem.fullName]: target };
2579
+ });
2580
+ }
2518
2581
  }
2519
2582
  },
2520
2583
  () => {},
@@ -2528,8 +2591,17 @@ window.__ModuleLoader__.load({
2528
2591
  done += 1;
2529
2592
  call("/plugin-console/check-update", { packageName: entry.moduleName }).then(
2530
2593
  (data) => {
2531
- if (data && data.latest && data.error === null && data.latest !== entry.version) {
2532
- setUpdateMap((prev) => ({ ...prev, [item.fullName]: data.latest }));
2594
+ // ★ 只在**严格更高**时才提示更新(唯一判据 = 本文件上方 <<<update-pick>>> 区)。
2595
+ // 0.5.37 改错:这里原本只判 `data.latest` 与 `entry.version` **字符串不相等**,
2596
+ // 没有大小比较 ⇒ 镜像源陈旧的 latest 低于已装版本时也提示「更新」= 引导降级。
2597
+ const better = data && data.error === null ? pickNewerSemver(entry.version, [data.latest]) : null;
2598
+ if (better !== null) {
2599
+ setUpdateMap((prev) => {
2600
+ // 两条路径写同一个 map(框架特判可能已算出正确目标):已有目标更高时保持不动
2601
+ const existing = prev[item.fullName];
2602
+ if (typeof existing === "string" && pickNewerSemver(better, [existing]) !== null) return prev;
2603
+ return { ...prev, [item.fullName]: better };
2604
+ });
2533
2605
  }
2534
2606
  },
2535
2607
  () => {},
@@ -0,0 +1,46 @@
1
+ // L1 · domain —— 更新候选净化(0.5.37 改错)
2
+ //
3
+ // 真机缺陷(2026-10-03 用户实测):卡片上已装 `@deepseek-ai/dsh-time-context` **0.2.0-rc.2**,
4
+ // 主源 `registry.npmmirror.com` 的 `latest` 却是 **0.1.1-rc.1**(镜像陈旧,低于已装版本),
5
+ // 面板据此提示「发现新版本 0.2.0-rc.2 → 0.1.1-rc.1」并给出「更新」按钮 ⇒ 按下去是**降级**。
6
+ //
7
+ // 客户端已修(只在"严格更高"时提示);这里再兜一道:**服务端绝不把"低于已装版本"的候选
8
+ // 当新版递出去**。语义与既有返回**字段名完全不变**(latest / next / beta 仍是这三个字段),
9
+ // 只是把这种候选清成 null —— 面板因此既不提示降级,也不会拿到错数据。
10
+ //
11
+ // 判据不做第二份实现:与客户端、路由共用 lib/server/infra/semver.js 的 pickNewerSemver
12
+ // (`// <<<update-pick:begin/end>>>` 标记块,两端逐字一致由架构守卫 ⑪ 钉死)。
13
+ // 本模块只收**已解开的** baseUrl / profileDir(domain 层不认识 cordis ctx,见架构守卫 ⑤)。
14
+ import { entryPkgMeta } from '../infra/paths.js'
15
+ import { pickNewerSemver } from '../infra/semver.js'
16
+
17
+ /** 已装版本(profile 里真实的 package.json,走既有缓存 60s);读不到返回 null。 */
18
+ function installedVersionOf(packageName, baseUrl, profileDir) {
19
+ try {
20
+ const version = entryPkgMeta(packageName, baseUrl ?? 'file:///', profileDir ?? null)?.version
21
+ return typeof version === 'string' && version !== '' ? version : null
22
+ } catch {
23
+ return null
24
+ }
25
+ }
26
+
27
+ /**
28
+ * 把"严格低于已装版本"的候选清成 null(字段名不变,其余候选原样返回)。
29
+ * 已装版本读不到(未安装的插件 / 解析失败)⇒ 不做任何过滤:既有行为一字不动。
30
+ *
31
+ * @param {{ latest?: string|null, next?: string|null, beta?: string|null }} candidates registry 的 dist-tag 候选
32
+ * @param {string} packageName 包名
33
+ * @param {string} baseUrl 解析起点(route 传 baseUrl / file:///)
34
+ * @param {string|null} profileDir profile 根目录(route 传 profileDirOf)
35
+ * @returns {{ latest: string|null, next: string|null, beta: string|null }}
36
+ */
37
+ function dropStaleUpdateCandidates(candidates, packageName, baseUrl, profileDir) {
38
+ const src = candidates !== null && typeof candidates === 'object' ? candidates : {}
39
+ const installed = installedVersionOf(packageName, baseUrl, profileDir)
40
+ if (installed === null) return { latest: src.latest ?? null, next: src.next ?? null, beta: src.beta ?? null }
41
+ // pickNewerSemver(候选, [已装]) !== null ⇔ 已装版本**严格高于**该候选 ⇒ 该候选不是"新版"
42
+ const keep = (value) => (typeof value === 'string' && pickNewerSemver(value, [installed]) !== null ? null : value)
43
+ return { latest: keep(src.latest), next: keep(src.next), beta: keep(src.beta) }
44
+ }
45
+
46
+ export { dropStaleUpdateCandidates, installedVersionOf }
@@ -165,4 +165,85 @@ function frameworkUpgradeCandidates(meta, current) {
165
165
  return { versions, tagDefault }
166
166
  }
167
167
 
168
- export { parseSemverText, compareSemverText, semverCompareOne, semverRangeMatch, semverRangeMatchLoose, parseFrameworkVersion, isFrameworkVersionNewer, frameworkUpgradeCandidates }
168
+ // ★★ 「更新候选」的**唯一**判据区(0.5.37)★★
169
+ // 真机缺陷(2026-10-03 用户实测):插件卡片上已装 `@deepseek-ai/dsh-time-context` **0.2.0-rc.2**,
170
+ // 却提示「发现新版本 0.2.0-rc.2 → **0.1.1-rc.1**」(更低!)并给出「更新」按钮 —— 按下去是**降级**。
171
+ // 根因(客户端 lib/client.js):普通插件行只判了 `data.latest` 与 `entry.version` **字符串不相等**,
172
+ // 完全没有版本大小比较;镜像源 `registry.npmmirror.com` 的 `latest` 陈旧到低于本机已装版本时,
173
+ // 任何"不相等"都会被当成"有新版"。框架特判路径另有一份正确的局部实现,两条路径写同一个 map,
174
+ // 后执行的普通行把正确结论覆盖成错的 —— 所以判据必须收敛成**这一份**。
175
+ //
176
+ // 「一处定义、处处复用」的落地方式(**双份实现必须逐字一致,由架构守卫钉死**):
177
+ // · 服务端:本文件导出 `pickNewerSemver`,路由 `lib/server/routes/framework.js` 直接 import;
178
+ // · 浏览器:`lib/client.js` 是**单文件打包产物**(`window.__ModuleLoader__.load`),不能用 import,
179
+ // 所以那里是**逐字移植**的同一份代码,两端都用下面的提取标记圈住:
180
+ // // <<<update-pick:begin>>> … // <<<update-pick:end>>>
181
+ // 架构守卫(tests/test-architecture-guard.mjs ⑪)断言两份**去缩进后逐字相等**(改一边必须改另一边)。
182
+ //
183
+ // 语义(纯函数:无网络、无落盘、无 ctx):
184
+ // · 只认**严格高于** current 的候选,返回其中最高者;等于 / 低于 current 一律不算"新版"(降级另有回滚通道);
185
+ // · prerelease 按 semver 序:`0.2.0-rc.2 > 0.1.1-rc.1`;正式版高于同号预发布:`0.1.1 > 0.1.1-rc.2`;
186
+ // · current 解析不出、或候选解析不出一律 null —— "不可比"就是"不提示更新",绝不猜。
187
+ // <<<update-pick:begin>>>
188
+ /** 宽松解析(容忍 `v` 前缀与首尾空白);不是三段数字的版本(''、'latest'、'1.2')一律 null。 */
189
+ function updateParseVersion(value) {
190
+ const text = String(value ?? '').trim().replace(/^v/u, '')
191
+ const m = text.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u)
192
+ if (!m) return null
193
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] ?? null }
194
+ }
195
+
196
+ /** 语义版本比较(含 prerelease 段;无 prerelease 段视为更高)。只在两份解析都成功时调用。 */
197
+ function updateCompareVersion(a, b) {
198
+ if (a.major !== b.major) return a.major - b.major
199
+ if (a.minor !== b.minor) return a.minor - b.minor
200
+ if (a.patch !== b.patch) return a.patch - b.patch
201
+ if (a.pre === null && b.pre === null) return 0
202
+ if (a.pre === null) return 1
203
+ if (b.pre === null) return -1
204
+ const pa = a.pre.split('.')
205
+ const pb = b.pre.split('.')
206
+ const len = Math.max(pa.length, pb.length)
207
+ for (let i = 0; i < len; i += 1) {
208
+ const xa = pa[i]
209
+ const xb = pb[i]
210
+ if (xa === undefined) return -1
211
+ if (xb === undefined) return 1
212
+ const na = /^\d+$/u.test(xa)
213
+ const nb = /^\d+$/u.test(xb)
214
+ if (na && nb) { const d = Number(xa) - Number(xb); if (d !== 0) return d; continue }
215
+ if (na) return -1
216
+ if (nb) return 1
217
+ const d = xa < xb ? -1 : xa > xb ? 1 : 0
218
+ if (d !== 0) return d
219
+ }
220
+ return 0
221
+ }
222
+
223
+ /**
224
+ * 「更新候选」的唯一判据:从 candidates 里挑出**严格高于 current** 的最高者,没有则 null。
225
+ * 边界(全部钉死在 tests/test-update-version-pick.mjs):相同版本 / 更低版本 / 任一无法解析 ⇒ null。
226
+ * @param {string} current 已装版本
227
+ * @param {Array<string|null|undefined>} candidates 候选(registry 的 latest / next / beta 等)
228
+ * @returns {string|null}
229
+ */
230
+ function pickNewerSemver(current, candidates) {
231
+ const cur = updateParseVersion(current)
232
+ if (cur === null) return null
233
+ const list = Array.isArray(candidates) ? candidates : []
234
+ let best = null
235
+ let bestParsed = null
236
+ for (const candidate of list) {
237
+ const parsed = updateParseVersion(candidate)
238
+ if (parsed === null) continue
239
+ if (updateCompareVersion(parsed, cur) <= 0) continue
240
+ if (bestParsed === null || updateCompareVersion(parsed, bestParsed) > 0) {
241
+ best = String(candidate).trim().replace(/^v/u, '')
242
+ bestParsed = parsed
243
+ }
244
+ }
245
+ return best
246
+ }
247
+ // <<<update-pick:end>>>
248
+
249
+ export { parseSemverText, compareSemverText, semverCompareOne, semverRangeMatch, semverRangeMatchLoose, parseFrameworkVersion, isFrameworkVersionNewer, frameworkUpgradeCandidates, pickNewerSemver }
@@ -15,6 +15,7 @@ import { fetchJsonUrl } from '../infra/http.js'
15
15
  import { sendError, sendJson } from '../infra/httpd.js'
16
16
  import { dshHome, entryPkgMeta, findPatchPath, packageNameOf, pluginRoot, profileDirOf, resolvePackageJson } from '../infra/paths.js'
17
17
  import { isFrameworkVersionNewer, semverRangeMatchLoose, frameworkUpgradeCandidates } from '../infra/semver.js'
18
+ import { dropStaleUpdateCandidates } from '../domain/update-candidates.js'
18
19
  import { fwCheckCache, setFwCheckCache } from '../state.js'
19
20
 
20
21
  /**
@@ -346,6 +347,8 @@ async function routeCheckUpdate(req, res, rc) {
346
347
  }
347
348
  }
348
349
  } catch {}
350
+ // 兜底(0.5.37):低于已装版本的候选不是"新版"(真机:镜像 latest=0.1.1-rc.1 < 已装 0.2.0-rc.2)
351
+ ;({ latest, next, beta } = dropStaleUpdateCandidates({ latest, next, beta }, packageName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx)))
349
352
  sendJson(res, 200, { ok: true, packageName, latest, next, beta, depsOutdated, error, source, migrate })
350
353
  return
351
354
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.36",
3
+ "version": "0.5.37",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",