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

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
@@ -1970,6 +1970,11 @@ function healthSignature(health) {
1970
1970
  const [repoInfo, setRepoInfo] = react.useState(null);
1971
1971
  const [subpackages, setSubpackages] = react.useState(null);
1972
1972
  const [installing, setInstalling] = react.useState(null);
1973
+ // ★ 0.5.70(用户 2026-10-18):**按目标并行**的在飞集合。installing 只留作展示,
1974
+ // 真正的并发闸门是 inFlight —— 同一目标才拦,不同插件可同时更新。
1975
+ const [inFlight, setInFlight] = react.useState(() => new Set());
1976
+ const markInFlight = (key) => { if (typeof key === "string" && key !== "") setInFlight((prev) => { const n = new Set(prev); n.add(key); return n; }); };
1977
+ const clearInFlight = (key) => { if (typeof key === "string" && key !== "") setInFlight((prev) => { const n = new Set(prev); n.delete(key); return n; }); };
1973
1978
  const [details, setDetails] = react.useState(null);
1974
1979
  // 已安装插件"检测更新":null=未检测 / {status:'checking'} / {status:'ready', latest, error}
1975
1980
  const [updateCheck, setUpdateCheck] = react.useState(null);
@@ -2928,7 +2933,7 @@ function healthSignature(health) {
2928
2933
  const pkgName = entry.moduleName;
2929
2934
  setUpdateCheck({ status: "checking" });
2930
2935
  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 }),
2936
+ (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
2937
  (error) => setUpdateCheck({ status: "ready", latest: null, source: "npm", error: friendlyGithubError(error).message }),
2933
2938
  );
2934
2939
  };
@@ -3411,6 +3416,8 @@ function healthSignature(health) {
3411
3416
  aiConsent(jobId, true);
3412
3417
  }
3413
3418
  if (data.status !== "installing") {
3419
+ // ★ 0.5.70:任务结束 ⇒ 释放该目标的并发锁(其它插件的更新不受影响)
3420
+ clearInFlight(data.packageName);
3414
3421
  stopJobPolling(jobId);
3415
3422
  if (data.status === "done") {
3416
3423
  if (data.kind === "skill") {
@@ -3792,11 +3799,14 @@ function healthSignature(health) {
3792
3799
  const startJob = (repo, packageName, source = "github", kind = "plugin", update = false, subdir = null) => {
3793
3800
  // 防并发(与 addLocal 一致):已有安装/更新进行中时忽略新点击(更新按钮此前可绕过,
3794
3801
  // 反复点击会产生几十个并发任务装同一插件)
3795
- if (installing !== null) {
3802
+ // 只拦**同一个目标**:不同插件可以并行更新(服务端是作业模型,天然支持)
3803
+ const jobKey = repo || packageName;
3804
+ if (inFlight.has(jobKey)) {
3796
3805
  setMessage(t("installBusy"));
3797
3806
  return;
3798
3807
  }
3799
- setInstalling(repo || packageName);
3808
+ markInFlight(jobKey);
3809
+ setInstalling(jobKey);
3800
3810
  setReloadHint(false);
3801
3811
  setRestartHint(false);
3802
3812
  setMessage(null);
@@ -3814,6 +3824,7 @@ function healthSignature(health) {
3814
3824
  },
3815
3825
  (error) => {
3816
3826
  setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message);
3827
+ clearInFlight(jobKey);
3817
3828
  setInstalling(null);
3818
3829
  },
3819
3830
  );
@@ -4026,7 +4037,7 @@ function healthSignature(health) {
4026
4037
  el("button", {
4027
4038
  type: "button",
4028
4039
  className: styles.toggle,
4029
- disabled: (updateCheck !== null && updateCheck.status === "checking") || installing === entry.moduleName,
4040
+ disabled: (updateCheck !== null && updateCheck.status === "checking") || inFlight.has(entry.moduleName),
4030
4041
  onClick: () => checkUpdate(entry),
4031
4042
  }, updateCheck !== null && updateCheck.status === "checking" ? t("checkingUpdate") : t("checkUpdate")),
4032
4043
  el("button", {
@@ -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 })
@@ -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
  }
@@ -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'
@@ -122,7 +123,23 @@ async function routeStateGet(req, res, rc) {
122
123
  // 已安装但尚未生效的任务(2026-09-20 真装真卸演练实测):bundle 型插件要重启才被加载,
123
124
  // 装完 /install 返回 entryId: null、/state 里新增 loader 条目 = 0 —— 面板过去完全看不出
124
125
  // 「装了但还没生效」,也无法撤销。前端据此渲染「已安装·重启后生效」徽标 + 按 jobId 删除。
125
- const pendingRestart = pendingRestartJobs([...installJobs.values()], listEntries(ctx))
126
+ // ★ 0.5.70(用户 2026-10-18):徽章要能活过重启 —— 内存判定的结果与**持久记录**合并;
127
+ // 已经在 entries 里出现过的包会自动从持久记录里清掉(不留陈旧项)。
128
+ const livePackageNames = (() => {
129
+ const set = new Set()
130
+ try {
131
+ for (const e of listEntries(ctx)) {
132
+ const mn = typeof e?.moduleName === 'string' ? e.moduleName : ''
133
+ if (mn === '' || mn.startsWith('cordis:')) continue
134
+ set.add(mn)
135
+ const m = /^(@[^/]+\/[^/]+)(?:\/.*)?$/u.exec(mn)
136
+ if (m) set.add(m[1])
137
+ }
138
+ } catch {}
139
+ return set
140
+ })()
141
+ let pendingRestart = []
142
+ try { pendingRestart = mergePendingRestart(pendingRestartJobs([...installJobs.values()], listEntries(ctx)), livePackageNames) } catch { pendingRestart = pendingRestartJobs([...installJobs.values()], listEntries(ctx)) }
126
143
  // 框架升级检测与适配(备份快照 + 重打框架补丁),try 包裹不阻塞 state 返回
127
144
  let framework = null
128
145
  try { framework = detectFrameworkUpgrade(ctx) } catch {}
@@ -205,12 +222,15 @@ async function routeStateGet(req, res, rc) {
205
222
 
206
223
  // ★ 0.5.55 加法:把已经算好的各块**折成一行健康摘要**(不新增 IO、不新增判据)。
207
224
  // 面板 176 个按钮、信息散在六处,用户没有一个地方能回答"整体健康吗、我该先处理哪件"。
225
+ const liveRowIds = new Set(entries.map((e) => e.rowId))
208
226
  let health = { issues: 0, items: [] }
209
227
  try {
210
228
  health = buildInstallHealth({
211
229
  entries,
212
230
  skippedBundles,
213
- pendingCompat: compatPending?.pending ?? [],
231
+ // ★ 0.5.68:与界面同口径 —— 只统计「仍待适配」且**目标行仍存在**的记录。
232
+ // 否则会把已采纳的历史记录、以及指向已删除行的陈旧记录都算成"待处理"(实测 24 vs 界面 0)。
233
+ pendingCompat: (compatPending?.pending ?? []).filter((p) => (p?.status ?? 'pending') === 'pending' && liveRowIds.has(p?.rowId)),
214
234
  patchUncertain: patchHeal === null ? [] : (patchHeal.uncertain ?? []),
215
235
  })
216
236
  } 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.71",
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",