@noob-stupid/dsh-plugin-console 0.5.67 → 0.5.77

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
@@ -280,6 +280,11 @@ window.__ModuleLoader__.load({
280
280
  migrateBtn: "迁移并适配",
281
281
  aiEmpowerFrameworkCheck: "框架适配检测",
282
282
  userForcedTag: "补丁强制启用",
283
+ // ★ 0.5.76(用户 2026-10-18):禁用**出处**要写明,别只给一句"已停用"
284
+ disabledByBundleTag: "插件默认禁用",
285
+ forcedNotLoadedTag: "强制启用未生效",
286
+ revokeForceLabel: "撤销该强制启用",
287
+ revokeForceHint: "这条 disabled: false 覆盖没有让插件加载起来 —— 撤掉它,界面不再显示「强制启用」的假象(不会改动清单)",
283
288
  protectedTag: "受保护",
284
289
  unobserved: "未挂载",
285
290
  pending: "等待依赖",
@@ -891,6 +896,10 @@ window.__ModuleLoader__.load({
891
896
  migrateBtn: "Migrate & adapt",
892
897
  aiEmpowerFrameworkCheck: "Framework compat check",
893
898
  userForcedTag: "Forced on by patch",
899
+ disabledByBundleTag: "Disabled by plugin default",
900
+ forcedNotLoadedTag: "Force-enable had no effect",
901
+ revokeForceLabel: "Revoke this force-enable",
902
+ revokeForceHint: "This disabled: false override did not make the plugin load; revoking it stops the false forced-on badge (the manifest is untouched)",
894
903
  protectedTag: "Protected",
895
904
  unobserved: "Not mounted",
896
905
  pending: "Waiting for dependencies",
@@ -1970,6 +1979,11 @@ function healthSignature(health) {
1970
1979
  const [repoInfo, setRepoInfo] = react.useState(null);
1971
1980
  const [subpackages, setSubpackages] = react.useState(null);
1972
1981
  const [installing, setInstalling] = react.useState(null);
1982
+ // ★ 0.5.70(用户 2026-10-18):**按目标并行**的在飞集合。installing 只留作展示,
1983
+ // 真正的并发闸门是 inFlight —— 同一目标才拦,不同插件可同时更新。
1984
+ const [inFlight, setInFlight] = react.useState(() => new Set());
1985
+ const markInFlight = (key) => { if (typeof key === "string" && key !== "") setInFlight((prev) => { const n = new Set(prev); n.add(key); return n; }); };
1986
+ const clearInFlight = (key) => { if (typeof key === "string" && key !== "") setInFlight((prev) => { const n = new Set(prev); n.delete(key); return n; }); };
1973
1987
  const [details, setDetails] = react.useState(null);
1974
1988
  // 已安装插件"检测更新":null=未检测 / {status:'checking'} / {status:'ready', latest, error}
1975
1989
  const [updateCheck, setUpdateCheck] = react.useState(null);
@@ -2301,7 +2315,14 @@ function healthSignature(health) {
2301
2315
  const [toolbarPos, setToolbarPos] = react.useState(() => {
2302
2316
  try {
2303
2317
  const v = JSON.parse(localStorage.getItem("pc-toolbar-pos") || "null");
2304
- if (v && Number.isFinite(v.left) && Number.isFinite(v.top)) return { left: v.left, top: v.top };
2318
+ if (v && Number.isFinite(v.left) && Number.isFinite(v.top)) {
2319
+ // ★ 用户 2026-10-18:存下来的位置**必须钳进可视区**,否则拖到标题栏/屏幕外就再也拿不回来。
2320
+ // 上边界 64px 是标题栏与窗口按钮的安全区(原来只钳到 8px,正好落在标题栏里)。
2321
+ const SAFE_TOP = 64;
2322
+ const maxLeft = Math.max(8, (window.innerWidth || 1280) - 120);
2323
+ const maxTop = Math.max(SAFE_TOP, (window.innerHeight || 800) - 80);
2324
+ return { left: Math.min(Math.max(8, v.left), maxLeft), top: Math.min(Math.max(SAFE_TOP, v.top), maxTop) };
2325
+ }
2305
2326
  } catch {}
2306
2327
  return null;
2307
2328
  });
@@ -2924,11 +2945,20 @@ function healthSignature(health) {
2924
2945
  (error) => setDetails({ entryId: entry.entryId, status: "error", error }),
2925
2946
  );
2926
2947
  };
2948
+ // ★ 0.5.76:撤销"强制启用"覆盖(补丁里的 disabled: false)。只对确实 forced 的行生效,服务端会校验。
2949
+ const revokeForce = (entry) => {
2950
+ if (busy !== null) return;
2951
+ setBusy(entry.entryId);
2952
+ call("/plugin-console/revoke-force", { entryId: entry.entryId }).then(
2953
+ (data) => { setMessage(data?.note ?? t("revokeForceLabel")); refresh(); },
2954
+ (error) => { setMessage(friendlyGithubError(error).message); },
2955
+ ).finally(() => setBusy(null));
2956
+ };
2927
2957
  const checkUpdate = (entry) => {
2928
2958
  const pkgName = entry.moduleName;
2929
2959
  setUpdateCheck({ status: "checking" });
2930
2960
  call("/plugin-console/check-update", { packageName: pkgName }).then(
2931
- (data) => setUpdateCheck({ status: "ready", latest: data.latest ?? null, source: data.source ?? "npm", error: data.error ?? null }),
2961
+ (data) => setUpdateCheck({ status: "ready", latest: data.latest ?? null, next: data.next ?? null, beta: data.beta ?? null, target: data.target ?? null, source: data.source ?? "npm", error: data.error ?? null }),
2932
2962
  (error) => setUpdateCheck({ status: "ready", latest: null, source: "npm", error: friendlyGithubError(error).message }),
2933
2963
  );
2934
2964
  };
@@ -3411,6 +3441,8 @@ function healthSignature(health) {
3411
3441
  aiConsent(jobId, true);
3412
3442
  }
3413
3443
  if (data.status !== "installing") {
3444
+ // ★ 0.5.70:任务结束 ⇒ 释放该目标的并发锁(其它插件的更新不受影响)
3445
+ clearInFlight(data.packageName);
3414
3446
  stopJobPolling(jobId);
3415
3447
  if (data.status === "done") {
3416
3448
  if (data.kind === "skill") {
@@ -3792,11 +3824,14 @@ function healthSignature(health) {
3792
3824
  const startJob = (repo, packageName, source = "github", kind = "plugin", update = false, subdir = null) => {
3793
3825
  // 防并发(与 addLocal 一致):已有安装/更新进行中时忽略新点击(更新按钮此前可绕过,
3794
3826
  // 反复点击会产生几十个并发任务装同一插件)
3795
- if (installing !== null) {
3827
+ // 只拦**同一个目标**:不同插件可以并行更新(服务端是作业模型,天然支持)
3828
+ const jobKey = repo || packageName;
3829
+ if (inFlight.has(jobKey)) {
3796
3830
  setMessage(t("installBusy"));
3797
3831
  return;
3798
3832
  }
3799
- setInstalling(repo || packageName);
3833
+ markInFlight(jobKey);
3834
+ setInstalling(jobKey);
3800
3835
  setReloadHint(false);
3801
3836
  setRestartHint(false);
3802
3837
  setMessage(null);
@@ -3814,6 +3849,7 @@ function healthSignature(health) {
3814
3849
  },
3815
3850
  (error) => {
3816
3851
  setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message);
3852
+ clearInFlight(jobKey);
3817
3853
  setInstalling(null);
3818
3854
  },
3819
3855
  );
@@ -4026,7 +4062,7 @@ function healthSignature(health) {
4026
4062
  el("button", {
4027
4063
  type: "button",
4028
4064
  className: styles.toggle,
4029
- disabled: (updateCheck !== null && updateCheck.status === "checking") || installing === entry.moduleName,
4065
+ disabled: (updateCheck !== null && updateCheck.status === "checking") || inFlight.has(entry.moduleName),
4030
4066
  onClick: () => checkUpdate(entry),
4031
4067
  }, updateCheck !== null && updateCheck.status === "checking" ? t("checkingUpdate") : t("checkUpdate")),
4032
4068
  el("button", {
@@ -4124,6 +4160,10 @@ function healthSignature(health) {
4124
4160
  entry.protected ? el("span", { className: styles.tag, "data-user": "true" }, t("protectedTag")) : null,
4125
4161
  entry.userDisabled ? el("span", { className: styles.tag, "data-user": "true" }, t("userDisabledTag")) : null,
4126
4162
  entry.userForced ? el("span", { className: styles.tag, "data-user": "true" }, t("userForcedTag")) : null,
4163
+ // ★ 0.5.76:被**别处**(插件自身补丁/框架默认)禁用的行 —— 这就是"补丁强制启用"的来由
4164
+ entry.disableSource === "bundle" ? el("span", { className: styles.tag, "data-user": "true", "data-source": "bundle" }, t("disabledByBundleTag")) : null,
4165
+ // ★ 0.5.76:补丁写了强制开启、运行时却仍没起来 ⇒ 这条覆盖是**无效的**,必须说出来
4166
+ entry.forcedNotLoaded === true ? el("span", { className: styles.tag, "data-user": "true", "data-source": "ineffective" }, t("forcedNotLoadedTag")) : null,
4127
4167
  ),
4128
4168
  el("div", { className: styles.meta },
4129
4169
  el("span", { className: styles.phase, "data-phase": entry.fiberPhase ?? "unobserved" },
@@ -4153,11 +4193,18 @@ function healthSignature(health) {
4153
4193
  el("button", {
4154
4194
  type: "button",
4155
4195
  className: styles.toggle,
4156
- disabled: !entry.toggleable || busy === entry.entryId || entry.pendingCompat === true,
4196
+ disabled: !entry.toggleable || busy === entry.entryId, // ★ 待适配不再禁用启用(用户 2026-10-18):启用=确认启用,服务端会把它标为已采纳
4157
4197
  // 0.5.34:结论已被当前判据撤回的行不再显示「待适配」(旧判据的误报不许当事实展示)
4158
4198
  title: entry.pendingCompat === true ? (entry.compatRescinded === true ? t("compatRescindedHint") : t("pendingCompatHint")) : undefined,
4159
4199
  onClick: () => toggle(entry, !entry.enabled),
4160
4200
  }, busy === entry.entryId ? t("loadingPhase") : (entry.pendingCompat === true ? (entry.compatRescinded === true ? t("compatRescindedTag") : t("pendingCompatTag")) : t(entry.enabled ? "off" : "on"))),
4201
+ // ★ 0.5.76:补丁写了强制开启但运行时没起来 ⇒ 给一个撤销覆盖的入口
4202
+ entry.forcedNotLoaded === true ? el("button", {
4203
+ type: "button",
4204
+ className: styles.toggle,
4205
+ title: t("revokeForceHint"),
4206
+ onClick: () => revokeForce(entry),
4207
+ }, t("revokeForceLabel")) : null,
4161
4208
  el("button", {
4162
4209
  type: "button",
4163
4210
  className: styles.toggle,
@@ -4911,9 +4958,7 @@ function healthSignature(health) {
4911
4958
  el("p", { className: styles.message }, t("compatGateHint")),
4912
4959
  el("p", { className: styles.status, "data-pending": gatePendingCount > 0 ? "true" : undefined },
4913
4960
  gatePendingCount > 0 ? t("gatePendingInfo").replace("{n}", String(gatePendingCount)) : t("gatePendingNone")),
4914
- el("p", { className: styles.status, "data-pending": gatePendingCount > 0 ? "true" : undefined },
4915
- gatePendingCount > 0 ? t("gatePendingInfo").replace("{n}", String(gatePendingCount)) : t("gatePendingNone")),
4916
- // 明细清单(2026-09-24 用户要求:面板要能看到是哪些插件,有列表数据)
4961
+ // 明细清单(2026-09-24 用户要求:面板要能看到是哪些插件,有列表数据)
4917
4962
  gateRows !== null && gateRows.pendingCount + gateRows.adoptedCount > 0
4918
4963
  ? el("div", { style: { flexDirection: "column", gap: 6, display: "flex" } },
4919
4964
  el("strong", { className: styles.name }, t("gateListPending").replace("{n}", String(gateRows.pendingCount))),
@@ -5328,7 +5373,7 @@ function healthSignature(health) {
5328
5373
  const bw = e.currentTarget.offsetWidth || 96;
5329
5374
  const bh = e.currentTarget.offsetHeight || 30;
5330
5375
  const left = Math.max(8, Math.min(w - bw - 8, e.clientX - dragRef.current.grabX));
5331
- const top = Math.max(8, Math.min(h - bh - 8, e.clientY - dragRef.current.grabY));
5376
+ const top = Math.max(64, Math.min(h - bh - 8, e.clientY - dragRef.current.grabY)); // ★ 64 = 标题栏安全区
5332
5377
  setDragPos({ left, top });
5333
5378
  },
5334
5379
  onPointerUp: () => {
@@ -19,7 +19,7 @@
19
19
  // 改用 `link:<DSH_HOME>/plugin-src/<包名>`:pnpm 的 link 协议只建符号链接,不经 registry 解析、
20
20
  // 不经 tarball 完整性校验,lock 删掉重建、node_modules 清空重装都稳定通过。
21
21
 
22
- import { existsSync, rmSync, mkdirSync, realpathSync } from 'node:fs'
22
+ import { existsSync, readFileSync, rmSync, mkdirSync, realpathSync } from 'node:fs'
23
23
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
24
24
  import { copyTree } from '../infra/fsx.js'
25
25
  import { fetchJsonUrl } from '../infra/http.js'
@@ -213,7 +213,50 @@ const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u
213
213
  * · `command` **只供展示/复制**,服务端执行时**只认 payload.action 这个白名单类型**,argv 自己拼;
214
214
  * · payload 里只有动作类型 + 包名 + 版本 + profile 名(回显用),**没有任何命令字符串**位置。
215
215
  */
216
+ /**
217
+ * 读 profile 清单里某个包的**当前来源形态**(只读;读不到返回 null)。
218
+ * 用途:决定"缺口该怎么补" —— 路径型来源才该按 link: 钉,registry 来源该对齐 lock。
219
+ */
220
+ function profileDepSpec(profileDir, packageName) {
221
+ if (typeof profileDir !== 'string' || profileDir === '') return null
222
+ if (typeof packageName !== 'string' || packageName === '') return null
223
+ try {
224
+ const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
225
+ const spec = manifest?.dependencies?.[packageName]
226
+ return typeof spec === 'string' && spec !== '' ? spec : null
227
+ } catch { return null }
228
+ }
229
+
230
+ /** 路径型来源(link:/file:)—— 只有这一族"按 link: 钉住"才说得通。 */
231
+ function isPathSourceSpec(spec) {
232
+ return typeof spec === 'string' && /^(?:link:|file:)/u.test(spec)
233
+ }
234
+
235
+ /**
236
+ * **纯 registry 形态**的版本/范围(只有这一族"对齐 lock"才是正解)。
237
+ * 刻意收窄:git+https / https tarball / link: / file: / workspace: / portal: 一律**不算** ——
238
+ * 对它们推进 reconcile-lock 是错的(lock 对账解决不了来源问题),实测被 route-inventory 缺陷②抓到。
239
+ */
240
+ function isRegistryRangeSpec(spec) {
241
+ if (typeof spec !== 'string') return false
242
+ const s = spec.trim()
243
+ if (s === '' || s === '*' || s === 'x') return true
244
+ if (/^(?:\^|~|>=|<=|>|<|=)\s*v?\d/u.test(s)) return true
245
+ if (/^v?\d+(?:\.\d+)?\.x$/u.test(s)) return true
246
+ return /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(s)
247
+ }
248
+
216
249
  function suggestedPinAction({ packageName, version = null, profileDir = null, profile = null, home = undefined, reason = null } = {}) {
250
+ // ★ 0.5.69(用户 2026-10-18):**registry 来源的包不该被劝成 link:**
251
+ // 它的问题是 lock 与清单不一致,正确出路是「重建 lock」;把 registry 包改成 link: 会绕过
252
+ // registry 解析与 tarball 完整性校验,还会让依赖(如 zod)被当成插件钉进 plugin-src。
253
+ const currentSpec = profileDepSpec(profileDir, packageName)
254
+ if (currentSpec !== null && isRegistryRangeSpec(currentSpec)) {
255
+ const why = reason !== null && reason !== undefined && reason !== ''
256
+ ? `${reason};该包是 registry 来源(${currentSpec}),应对齐 lock 而不是改成 link:`
257
+ : `${packageName} 是 registry 来源(${currentSpec}),应对齐 lock 而不是改成 link:`
258
+ return suggestedReconcileAction({ profileDir, profile, reason: why })
259
+ }
217
260
  const dir = join(home ?? dshHome(), PLUGIN_SRC_DIR, ...String(packageName).split('/'))
218
261
  const spec = linkSpecFor(dir)
219
262
  const name = profile ?? profileNameOf(profileDir)
@@ -144,7 +144,7 @@ async function planDependencySpec({ profileDir, packageName, version, registries
144
144
  return {
145
145
  form: 'link',
146
146
  spec: link,
147
- note: `${packageName}:${why},清单已按 link: 形式记录(${link})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`,
147
+ note: `${packageName}:${why};已按**源码安装(本地物化)**记录(${link})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`,
148
148
  suggestion: null,
149
149
  }
150
150
  }
@@ -214,7 +214,7 @@ async function declareProfileDependency(profileDir, packageName, version = null,
214
214
  ? {
215
215
  form: 'link',
216
216
  spec: canonicalSourceSpec(realSpec),
217
- note: `${packageName}:按**显式开发式安装**记录(${realSpec})—— 不经 npm registry 解析,依赖与框架 peer 由 link 自足性补齐`,
217
+ note: `${packageName}:**源码安装(本地物化)** —— 该包在 npm registry 上找不到,只能拿到源码;已按路径型来源记录(${realSpec})。不经 registry 解析、不经 tarball 完整性校验,缺失依赖与框架 peer 由 link 自足性补齐`,
218
218
  suggestion: null,
219
219
  }
220
220
  : await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile, sourceSpec: installSpec })
@@ -226,7 +226,21 @@ async function declareProfileDependency(profileDir, packageName, version = null,
226
226
  sourceInstall = await installSourceDependency({ profileDir, spec: installSpec, registries, install: installSource })
227
227
  recordSourceSpec({ packageName, spec: installSpec, kind: sourceKind, install: sourceInstall, ...homeOpt })
228
228
  if (sourceInstall.ok !== true) {
229
- plan = await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile, sourceSpec: null })
229
+ // ★ 0.5.72(用户 2026-10-18 批准):**不再回落成 link:**。
230
+ // 有真实来源规格却装不上 ⇒ 如实报错、**不写清单、不建 link**;
231
+ // 偷偷挂一份本地副本会让人以为"装上了",而依赖与 peer 其实没有被包管理器保证过。
232
+ // 仍保留「一键真装」动作:给的是"重试真实通道"的出路,不是假装成功。
233
+ const why = typeof sourceInstall.error === 'string' && sourceInstall.error !== '' ? sourceInstall.error : '(原因未记录)'
234
+ return {
235
+ ...empty,
236
+ version: resolved,
237
+ form: plan.form,
238
+ reason: `真装失败:${why}`,
239
+ sourceInstall,
240
+ depNote: `${packageName}:已获取源码但**真装失败**(${why})—— 未改动清单,也未按 link: 记录。` +
241
+ `可点「一键真装」重试;或改用「从本地目录安装」显式走开发式链接。`,
242
+ suggested: suggestedRealInstallAction({ packageName, spec: installSpec, profile, profileDir }),
243
+ }
230
244
  }
231
245
  }
232
246
  const installNote = composeSourceInstallNote({ packageName, install: sourceInstall, plan })
@@ -0,0 +1,80 @@
1
+ // L1 · domain —— 「已安装·待重启」的**持久化**(0.5.70 加法)
2
+ //
3
+ // 用户 2026-10-18:「那个重启后生效徽章你不是有吗…memory 插件不就是这样的吗之前」
4
+ // —— 徽章确实早就有(revoke.js#pendingRestartJobs),但它的数据源是**内存里的 installJobs**,
5
+ // 宿主一重启就清空 ⇒ 更新完插件、重启一次之后,徽章连同那条记录一起消失,插件看起来"没了"。
6
+ // 实测:installJobs=0 / pendingRestart=0 / recentFailures=0,而包其实已经更新成功(0.7.2 → 0.7.8)。
7
+ //
8
+ // 本模块把"待重启"落成一份**小文件**(按包名),于是它能活过重启:
9
+ // · 写入:/state 计算时,若某条 done 作业的包**不在运行中的 entries**,就记下来(仅在集合变化时写盘);
10
+ // · 读取:重启后从文件里读回来,仍然显示徽章;
11
+ // · 清除:一旦该包出现在 entries 里(说明已经加载生效),自动从文件里删掉 —— 不留陈旧记录。
12
+ //
13
+ // 严格只读原则的例外说明:这里确实写盘,但写的是**控制台自己的状态文件**(不是 profile、不是 lock),
14
+ // 且只在集合变化时写一次;任何异常都不抛出,降级为"没有持久记录"(徽章退化到旧行为,不比现在更差)。
15
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
16
+ import { dirname, join } from 'node:path'
17
+ import { dshHome } from '../infra/paths.js'
18
+
19
+ const PENDING_RESTART_FILE = 'pending-restart.json'
20
+
21
+ function pendingRestartPath(home = undefined) {
22
+ return join(home ?? dshHome(), 'plugin-console', PENDING_RESTART_FILE)
23
+ }
24
+
25
+ const readJsonSafe = (file) => {
26
+ try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null }
27
+ }
28
+
29
+ /** 读持久记录 → [{ packageName, version, at }](读不到/坏文件 = 空数组)。 */
30
+ function readPendingRestart({ home = undefined } = {}) {
31
+ const value = readJsonSafe(pendingRestartPath(home))
32
+ const list = value !== null && typeof value === 'object' && Array.isArray(value.items) ? value.items : []
33
+ return list
34
+ .filter((x) => x !== null && x !== undefined && typeof x.packageName === 'string' && x.packageName !== '')
35
+ .map((x) => ({ packageName: x.packageName, version: typeof x.version === 'string' ? x.version : null, at: Number.isFinite(Number(x.at)) ? Number(x.at) : null }))
36
+ }
37
+
38
+ /**
39
+ * 把当前"待重启"集合写穿到文件(**集合不变就不写盘**)。
40
+ * @returns {{ changed: boolean, items: Array }}
41
+ */
42
+ function syncPendingRestart(items, { home = undefined } = {}) {
43
+ const next = (Array.isArray(items) ? items : [])
44
+ .filter((x) => x !== null && x !== undefined && typeof x.packageName === 'string' && x.packageName !== '')
45
+ .map((x) => ({ packageName: x.packageName, version: typeof x.version === 'string' ? x.version : null, at: Number.isFinite(Number(x.at)) ? Number(x.at) : Date.now() }))
46
+ const prev = readPendingRestart({ home })
47
+ const key = (list) => list.map((x) => `${x.packageName}@${x.version ?? '?'}`).sort().join('|')
48
+ if (key(prev) === key(next)) return { changed: false, items: prev }
49
+ try {
50
+ const file = pendingRestartPath(home)
51
+ mkdirSync(dirname(file), { recursive: true })
52
+ writeFileSync(file, `${JSON.stringify({ items: next }, null, 2)}\n`, 'utf8')
53
+ return { changed: true, items: next }
54
+ } catch {
55
+ // 写不进去就如实退化:本次仍返回新集合(界面照常显示),只是活不过重启
56
+ return { changed: false, items: next, writeError: true }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * 合并:内存里刚判定的待重启 + 文件里历史遗留的(**已经在 entries 里出现过的一律丢弃**)。
62
+ * @param {Array} fresh 内存判定结果(pendingRestartJobs 的输出)
63
+ * @param {Set<string>} liveNames 当前运行中的包名集合
64
+ */
65
+ function mergePendingRestart(fresh, liveNames, { home = undefined } = {}) {
66
+ const live = liveNames instanceof Set ? liveNames : new Set()
67
+ const persisted = readPendingRestart({ home }).filter((x) => !live.has(x.packageName))
68
+ const merged = new Map()
69
+ for (const x of persisted) merged.set(x.packageName, { packageName: x.packageName, version: x.version ?? null, at: x.at ?? null, persisted: true })
70
+ for (const x of Array.isArray(fresh) ? fresh : []) {
71
+ if (live.has(x.packageName)) continue
72
+ merged.set(x.packageName, { ...x, at: Date.now(), persisted: false })
73
+ }
74
+ const items = [...merged.values()]
75
+ // 写穿:把"合并后的待重启集合"作为新基线(集合不变则不写盘)
76
+ syncPendingRestart(items.map((x) => ({ packageName: x.packageName, version: x.version ?? null, at: x.at ?? null })), { home })
77
+ return items
78
+ }
79
+
80
+ export { PENDING_RESTART_FILE, pendingRestartPath, readPendingRestart, syncPendingRestart, mergePendingRestart }
@@ -386,7 +386,7 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
386
386
 
387
387
  const depNotes = []
388
388
  // 物化 + link: 计划(registry 查无此包时唯一安全的写回形式)。
389
- // ★ 2026-09-27 改错:这里**只产出"打算写什么"和"为什么"**,不再抢先断言"已按 link: 形式记录依赖"——
389
+ // ★ 2026-09-27 改错:这里**只产出"打算写什么"和"为什么"**,不再抢先断言"已按源码安装(本地物化)记录依赖"——
390
390
  // 旧版在 pnpm add 之前就把这句写进 depNote,于是"写回失败/被后一步覆盖"时面板仍然报"已钉住",
391
391
  // 而磁盘上留着的是裸版本号(真机 desktop profile 的实际状态)。最终文案一律在写回之后**读回真实值**再生成。
392
392
  const linkPlan = (row, why) => {
@@ -496,7 +496,7 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
496
496
  const wrote = writeOf.get(item.name) ?? null
497
497
  if (wrote !== null && (wrote.changed === true || wrote.unchanged === true)) {
498
498
  if (item.reason !== null) {
499
- depNotes.push(`${item.name}:${item.reason},已按 link: 形式记录依赖(${actual})—— lock 的 importer 条目已**定点写入**(${wrote.version ?? ''}),不经 npm registry 解析、不经 tarball 完整性校验`)
499
+ depNotes.push(`${item.name}:${item.reason},已按源码安装(本地物化)记录依赖(${actual})—— lock 的 importer 条目已**定点写入**(${wrote.version ?? ''}),不经 npm registry 解析、不经 tarball 完整性校验`)
500
500
  }
501
501
  continue
502
502
  }
@@ -513,7 +513,7 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
513
513
  }
514
514
  if (actual === item.spec) {
515
515
  if (item.spec.startsWith('link:') && item.reason !== null) {
516
- depNotes.push(`${item.name}:${item.reason},已按 link: 形式记录依赖(${actual})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`)
516
+ depNotes.push(`${item.name}:${item.reason},已按源码安装(本地物化)记录依赖(${actual})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`)
517
517
  }
518
518
  continue
519
519
  }
@@ -60,13 +60,33 @@ function dropStaleUpdateCandidates(candidates, packageName, baseUrl, profileDir)
60
60
  * @param {{ latest?: string|null, next?: string|null, beta?: string|null }} candidates
61
61
  * @returns {string|null} 目标版本;全空 ⇒ null(真的没有更高版本)
62
62
  */
63
- function pickUpdateTarget(candidates) {
63
+ /**
64
+ * 从 { latest, next, beta } 里选出**该更新的目标版本**。
65
+ * ★ 0.5.74(用户 2026-10-18):按**版本高低**选,不再按通道顺序取第一个非空。
66
+ * 真机证据:官方包只往 next 发版而 latest 停在远古 alpha ——
67
+ * dsh-experimental-speech-to-text: latest 0.1.7-alpha.1 / next 0.2.1-rc.2
68
+ * 旧实现返回 latest ⇒ 界面永远"已是最新版",next 里的新版永远看不见。
69
+ * 比较**不做第二份实现**:共用 infra/semver.js#pickNewerSemver(本模块既有的唯一判据)。
70
+ *
71
+ * @param {{latest?:string,next?:string,beta?:string}} candidates
72
+ * @param {{installedVersion?:string|null}} [options] 给了就优先选**严格高于它**的;没有更高的则退回全体最高
73
+ */
74
+ function pickUpdateTarget(candidates, options = {}) {
64
75
  const src = candidates !== null && typeof candidates === 'object' ? candidates : {}
65
- for (const key of ['latest', 'next', 'beta']) {
66
- const value = src[key]
67
- if (typeof value === 'string' && value !== '') return value
76
+ const list = ['latest', 'next', 'beta']
77
+ .map((key) => src[key])
78
+ .filter((value) => typeof value === 'string' && value !== '')
79
+ if (list.length === 0) return null
80
+ const installed = typeof options?.installedVersion === 'string' && options.installedVersion !== ''
81
+ ? options.installedVersion
82
+ : null
83
+ // ① 有已装版本:取严格高于它的最高者
84
+ if (installed !== null) {
85
+ const newer = pickNewerSemver(installed, list)
86
+ if (newer !== null) return newer
68
87
  }
69
- return null
88
+ // ② 没有更高的(或根本没给已装版本):取全体最高的 —— 以 0.0.0 为基准即可(唯一判据仍是同一个函数)
89
+ return pickNewerSemver('0.0.0', list)
70
90
  }
71
91
 
72
92
  export { dropStaleUpdateCandidates, installedVersionOf, pickUpdateTarget }
@@ -27,6 +27,7 @@ import { routeGithubLogin, routeGithubOpenLogin } from './github-login.js'
27
27
  import { routeInstall, routeInstallStatus } from './install.js'
28
28
  import { routeLockfileCheck, routeLockfileRepair } from './lockfile.js'
29
29
  import { routeEnrich, routeMarketIndex, routeRepo, routeSearch, routeSubpackages } from './market.js'
30
+ import { routeRevokeForce } from './revoke-force.js'
30
31
  import { routeAdaptUnlock, routeAdaptUnlockAll, routeCleanResiduals, routeSelfUpdate, routeToggle, routeUninstall } from './plugins.js'
31
32
  import { routeSkillRemove, routeSkillToggle, routeSkillsInstalledGet } from './skills.js'
32
33
  import { routeGiteeOauthCallbackGet, routeGiteeOauthUrlGet, routeRegistryScan, routeSources, routeSourcesGet } from './sources.js'
@@ -130,6 +131,7 @@ const ROUTES = [
130
131
  { methods: ['POST'], path: `${ROUTE_PREFIX}/toggle`, handler: routeToggle },
131
132
  { methods: ['POST'], path: `${ROUTE_PREFIX}/uninstall`, handler: routeUninstall },
132
133
  { methods: ['POST'], path: `${ROUTE_PREFIX}/adapt-unlock`, handler: routeAdaptUnlock },
134
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/revoke-force`, handler: routeRevokeForce },
133
135
  { methods: ['POST'], path: `${ROUTE_PREFIX}/adapt-unlock-all`, handler: routeAdaptUnlockAll },
134
136
  { methods: ['POST'], path: `${ROUTE_PREFIX}/clean-residuals`, handler: routeCleanResiduals },
135
137
  { methods: ['POST'], path: `${ROUTE_PREFIX}/self-update`, handler: routeSelfUpdate },
@@ -0,0 +1,35 @@
1
+ // L2 · routes —— 撤销「无效的强制启用覆盖」(0.5.76,用户 2026-10-18)
2
+ //
3
+ // 场景:补丁里写了 `disabled: false`(强制开启),但该行运行时根本没加载、也不是 peer 否决造成的
4
+ // —— 这条覆盖是**死的**,留着只会让界面继续显示「强制启用未生效」而毫无作用。
5
+ //
6
+ // 语义(写死):
7
+ // · 只对**确实在 forced 列表**里的行生效 —— 不凭空改别人的补丁;
8
+ // · 复用 patch.js#removeDisableBlock(与 adapt-unlock 同一实现,不写第二份);
9
+ // · 移除后**不承诺"已启用"**:撤掉覆盖只代表不再强压,实际是否加载由框架决定。
10
+ import { existsSync } from 'node:fs'
11
+ import { findPatchPath } from '../infra/paths.js'
12
+ import { removeDisableBlock, readPatchState } from '../domain/patch.js'
13
+ import { sendError, sendJson } from '../infra/httpd.js'
14
+
15
+ async function routeRevokeForce(req, res, rc) {
16
+ const body = rc.body !== null && typeof rc.body === 'object' ? rc.body : {}
17
+ const entryId = typeof body.entryId === 'string' ? body.entryId : ''
18
+ if (entryId === '') { sendError(res, 400, '缺少 entryId'); return }
19
+ const rowId = entryId.includes(':') ? entryId.split(':').slice(1).join(':') : entryId
20
+ let patchPath = null
21
+ try { patchPath = findPatchPath(rc.ctx) } catch { patchPath = null }
22
+ if (typeof patchPath !== 'string' || patchPath === '' || !existsSync(patchPath)) {
23
+ sendError(res, 400, '当前 profile 没有可写的补丁文件'); return
24
+ }
25
+ let forced = []
26
+ try { const state = await readPatchState(patchPath); forced = Array.isArray(state?.forced) ? state.forced : [] } catch { forced = [] }
27
+ if (!forced.includes(rowId)) {
28
+ sendError(res, 400, `${rowId} 当前不是「补丁强制启用」状态(没有 disabled: false 覆盖块),无需撤销`); return
29
+ }
30
+ try { await removeDisableBlock(patchPath, rowId) }
31
+ catch (error) { sendError(res, 500, `撤销失败:${error instanceof Error ? error.message : String(error)}`); return }
32
+ sendJson(res, 200, { ok: true, rowId, revoked: true, note: `已移除 ${rowId} 的强制启用覆盖(disabled: false)。该行是否加载由框架决定,本次未改清单。` })
33
+ }
34
+
35
+ export { routeRevokeForce }
@@ -22,6 +22,7 @@ import { installJobView, readGithubAuth } from '../domain/install.js'
22
22
  import { readPluginDetails } from '../domain/market.js'
23
23
  import { healPatchSafety, readPatchState } from '../domain/patch.js'
24
24
  import { pendingRestartJobs } from '../domain/revoke.js'
25
+ import { mergePendingRestart } from '../domain/pending-restart.js'
25
26
  import { listEntries } from '../domain/runtime.js'
26
27
  import { sendError, sendJson } from '../infra/httpd.js'
27
28
  import { dshHome, entryPkgMeta, findPatchPath, pluginRoot, profileDirOf, rowIdOf } from '../infra/paths.js'
@@ -97,7 +98,7 @@ async function routeStateGet(req, res, rc) {
97
98
  repository: meta?.repository ?? null,
98
99
  // v0.3.45:只有"补丁里此刻确实还禁着它"的行才显示【待适配】——
99
100
  // 记录与开关脱节时(用户手动启用过 / 补丁块被清掉)不能继续挂着「待适配」误导人
100
- pendingCompat: compatPendingRows.has(entry.rowId) && disabledByPatch,
101
+ pendingCompat: (compatPendingRows.has(entry.rowId) && disabledByPatch) || veto !== null,
101
102
  // 0.5.34 加法:记录的 `fail` 若已被**当前判据**撤回(旧误报),面板据此不再说「待适配」,
102
103
  // 改说「判据已撤回·可解锁」并给一键解锁入口(判据本身一个字都没放宽:真 fail 仍 fail)
103
104
  compatRescinded: compatPendingRows.has(entry.rowId) && verdict?.rescinded === true,
@@ -108,6 +109,14 @@ async function routeStateGet(req, res, rc) {
108
109
  adoptable: adoptable.get(entry.rowId) ?? null,
109
110
  // 框架 peer 预检的本地复算结果(null = 无话可说;只在这类行上下发,不新增常驻面板行)
110
111
  veto,
112
+ // ★ 0.5.76(用户 2026-10-18):**禁用出处** + 无效强制覆盖的可见性(只加字段,不改既有判定)
113
+ // 'patch' = 本控制台补丁禁的(删块即可正常启用)
114
+ // 'bundle' = 插件自身/框架默认禁的(我们只能写 disabled:false 覆盖 ⇒ 补丁强制启用)
115
+ disableSource: !entry.enabled && entry.fiberPhase === null
116
+ ? (disabledByPatch ? 'patch' : 'bundle')
117
+ : null,
118
+ // 强制开启写了、但运行时仍没起来,且不是 peer 否决造成的 ⇒ 这条覆盖是**无效的**,要说出来
119
+ forcedNotLoaded: patch.forced.includes(entry.rowId) && entry.enabled !== true && veto === null,
111
120
  }
112
121
  })
113
122
  const compat = await detectCompat(ctx.baseUrl ?? 'file:///')
@@ -122,7 +131,23 @@ async function routeStateGet(req, res, rc) {
122
131
  // 已安装但尚未生效的任务(2026-09-20 真装真卸演练实测):bundle 型插件要重启才被加载,
123
132
  // 装完 /install 返回 entryId: null、/state 里新增 loader 条目 = 0 —— 面板过去完全看不出
124
133
  // 「装了但还没生效」,也无法撤销。前端据此渲染「已安装·重启后生效」徽标 + 按 jobId 删除。
125
- const pendingRestart = pendingRestartJobs([...installJobs.values()], listEntries(ctx))
134
+ // ★ 0.5.70(用户 2026-10-18):徽章要能活过重启 —— 内存判定的结果与**持久记录**合并;
135
+ // 已经在 entries 里出现过的包会自动从持久记录里清掉(不留陈旧项)。
136
+ const livePackageNames = (() => {
137
+ const set = new Set()
138
+ try {
139
+ for (const e of listEntries(ctx)) {
140
+ const mn = typeof e?.moduleName === 'string' ? e.moduleName : ''
141
+ if (mn === '' || mn.startsWith('cordis:')) continue
142
+ set.add(mn)
143
+ const m = /^(@[^/]+\/[^/]+)(?:\/.*)?$/u.exec(mn)
144
+ if (m) set.add(m[1])
145
+ }
146
+ } catch {}
147
+ return set
148
+ })()
149
+ let pendingRestart = []
150
+ try { pendingRestart = mergePendingRestart(pendingRestartJobs([...installJobs.values()], listEntries(ctx)), livePackageNames) } catch { pendingRestart = pendingRestartJobs([...installJobs.values()], listEntries(ctx)) }
126
151
  // 框架升级检测与适配(备份快照 + 重打框架补丁),try 包裹不阻塞 state 返回
127
152
  let framework = null
128
153
  try { framework = detectFrameworkUpgrade(ctx) } catch {}
@@ -205,12 +230,15 @@ async function routeStateGet(req, res, rc) {
205
230
 
206
231
  // ★ 0.5.55 加法:把已经算好的各块**折成一行健康摘要**(不新增 IO、不新增判据)。
207
232
  // 面板 176 个按钮、信息散在六处,用户没有一个地方能回答"整体健康吗、我该先处理哪件"。
233
+ const liveRowIds = new Set(entries.map((e) => e.rowId))
208
234
  let health = { issues: 0, items: [] }
209
235
  try {
210
236
  health = buildInstallHealth({
211
237
  entries,
212
238
  skippedBundles,
213
- pendingCompat: compatPending?.pending ?? [],
239
+ // ★ 0.5.68:与界面同口径 —— 只统计「仍待适配」且**目标行仍存在**的记录。
240
+ // 否则会把已采纳的历史记录、以及指向已删除行的陈旧记录都算成"待处理"(实测 24 vs 界面 0)。
241
+ pendingCompat: (compatPending?.pending ?? []).filter((p) => (p?.status ?? 'pending') === 'pending' && liveRowIds.has(p?.rowId)),
214
242
  patchUncertain: patchHeal === null ? [] : (patchHeal.uncertain ?? []),
215
243
  })
216
244
  } catch {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.67",
3
+ "version": "0.5.77",
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",