@noob-stupid/dsh-plugin-console 0.3.60 → 0.3.63

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/index.js CHANGED
@@ -2966,6 +2966,116 @@ async function backfillMissingDeps(profileDir, deps, registries) {
2966
2966
  return stillMissing
2967
2967
  }
2968
2968
 
2969
+ // ── 依赖来源写回(缺陷②修复:0.3.63)────────────────────────────────────────────
2970
+ // 缺陷背景(用户 issue 草案「缺陷②」,2026-09-22 实测):release 通道装的包只存在于 GitHub
2971
+ // release,npm registry 里查无此包;而 0.3.57 起的 lock 对账一律 `pnpm add <name>@<installed>` ——
2972
+ // pnpm 看到「已装版本满足新 spec」就**静默**把 profile package.json 的 dependencies 改写为裸版本号
2973
+ // (输出 `Already up to date`、EXIT=0,面板显示成功),同时把 lock 的 specifier 也改成版本号、
2974
+ // 却保留旧的 tarball 解析。装完一切正常,直到有人重建 lock(删 lock / 清 node_modules / 换机 / CI)
2975
+ // → `ERR_PNPM_FETCH_404`,而报错指向 npm registry,用户根本联想不到是几周前面板安装改写造成的。
2976
+ //
2977
+ // 两条硬约束(本机真 pnpm 10.34.5 实测矩阵见 D:\dsh\dsh-plugin-hub-plan\refactor-bugs.zh.md 第 23 节):
2978
+ // ① 写回前必须确认「这个包的**这个版本**」在 registry 可解析,否则**绝不**写裸版本号;
2979
+ // ② 不可解析时**也不能**写 tarball URL:pnpm 10 对 direct-URL 依赖只在冷缓存真下载时记 integrity,
2980
+ // 命中缓存重写 lock 时 resolution 里没有 integrity → `ERR_PNPM_MISSING_TARBALL_INTEGRITY`,
2981
+ // 而且 pnpm 会把 lock 文件直接删掉,形成「删 lock 修不好、不删 lock 装不动」的死循环。
2982
+ // 改用 `link:<DSH_HOME>/plugin-src/<包名>`:pnpm 的 link 协议只建符号链接,不经 registry 解析、
2983
+ // 不经 tarball 完整性校验,lock 删掉重建、node_modules 清空重装都稳定通过。
2984
+
2985
+ /** 物化目录的根(与用户 issue 里手工规避用的 `/root/.dsh/plugin-src/...` 同一位置)。 */
2986
+ const PLUGIN_SRC_DIR = 'plugin-src'
2987
+
2988
+ /**
2989
+ * registry 能否解析该包的指定版本(按顺序多源尝试,任一源可解析即通过)。
2990
+ * 404 / 网络失败都归为「不可解析」——调用方据此决定**绝不写裸版本号**。
2991
+ * `version` 为 null 时只判包是否存在;`hasVersion` 表示指定版本是否在 versions 里
2992
+ * (release 通道装的版本可能比 registry 上的 latest 还新,只判包名存在是不够的)。
2993
+ */
2994
+ async function probeRegistryPackage(packageName, registries = [], options = {}) {
2995
+ const fetchJson = typeof options.fetchJson === 'function' ? options.fetchJson : fetchJsonUrl
2996
+ const version = typeof options.version === 'string' && options.version !== '' ? options.version : null
2997
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 8000
2998
+ const list = (Array.isArray(registries) ? registries : []).filter((r) => typeof r === 'string' && r.trim() !== '')
2999
+ const tries = list.length > 0 ? list : ['https://registry.npmmirror.com']
3000
+ const encoded = packageName.startsWith('@')
3001
+ ? `@${encodeURIComponent(packageName.slice(1).split('/')[0])}%2f${encodeURIComponent(packageName.split('/').slice(1).join('/'))}`
3002
+ : encodeURIComponent(packageName)
3003
+ const tried = []
3004
+ for (const reg of tries) {
3005
+ const base = String(reg).replace(/\/+$/u, '')
3006
+ try {
3007
+ const meta = await fetchJson(`${base}/${encoded}`, timeoutMs)
3008
+ const versions = meta && typeof meta === 'object' && meta.versions && typeof meta.versions === 'object' ? meta.versions : null
3009
+ if (versions === null) {
3010
+ tried.push(`${base}:返回体没有 versions 字段`)
3011
+ continue
3012
+ }
3013
+ const latest = typeof meta['dist-tags']?.latest === 'string' ? meta['dist-tags'].latest : null
3014
+ return {
3015
+ resolvable: true,
3016
+ hasVersion: version === null || Object.prototype.hasOwnProperty.call(versions, version),
3017
+ latest,
3018
+ registry: base,
3019
+ tried,
3020
+ }
3021
+ } catch (error) {
3022
+ tried.push(`${base}:${error instanceof Error ? error.message : String(error)}`)
3023
+ }
3024
+ }
3025
+ return { resolvable: false, hasVersion: false, latest: null, registry: null, tried }
3026
+ }
3027
+
3028
+ /**
3029
+ * 把 profile 里**已装好的**包物化一份到 `<DSH_HOME>/plugin-src/<包名>`,返回该绝对路径。
3030
+ * 为什么必须另存一份而不是直接 link node_modules 里的目录:pnpm 重建 node_modules 时会先删掉
3031
+ * 整个目录,link 目标随即消失;plugin-src 是 pnpm 不管理的独立目录,跨 lock 重建、
3032
+ * 跨 node_modules 清空都稳定存在(这也是用户手工规避时选的位置)。
3033
+ * 返回 null 表示源目录不存在或复制失败 —— 调用方必须**跳过对齐**并如实记 note,绝不改 package.json。
3034
+ */
3035
+ function materializePackageForLink(profileDir, packageName, options = {}) {
3036
+ const home = typeof options.home === 'string' && options.home !== '' ? options.home : dshHome()
3037
+ const src = join(profileDir, 'node_modules', ...packageName.split('/'))
3038
+ if (!existsSync(join(src, 'package.json'))) return null
3039
+ const dest = join(home, PLUGIN_SRC_DIR, ...packageName.split('/'))
3040
+ // 已经是指向 plugin-src 的链接(重复对账)→ 不能先删再复制:那样源就成了悬空链接
3041
+ try {
3042
+ if (existsSync(dest) && realpathSync(src) === realpathSync(dest)) return dest
3043
+ } catch {}
3044
+ try {
3045
+ mkdirSync(dirname(dest), { recursive: true })
3046
+ if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
3047
+ copyTree(src, dest)
3048
+ } catch {
3049
+ return null
3050
+ }
3051
+ return existsSync(join(dest, 'package.json')) ? dest : null
3052
+ }
3053
+
3054
+ /** pnpm 的 `link:` 规格:写绝对路径(反斜杠转正斜杠,跨平台且 lock 可读)。 */
3055
+ function linkSpecFor(dir) {
3056
+ return `link:${String(dir).replace(/\\/gu, '/')}`
3057
+ }
3058
+
3059
+ /**
3060
+ * manifest 里的 `link:` 规格当前是否**真的**还生效(`node_modules/<包名>` 就是指向它的那个链接)。
3061
+ * 为什么必须查:release / curl 通道更新包时是「先 rmSync 再 copyTree」,会把 `node_modules/<包名>`
3062
+ * 从"链接"换成"真实目录",而 manifest 与 lock 里仍写着 `link:<plugin-src/…>` —— 光看版本号看不出来
3063
+ * (lock 里本来就是 `link:`),但**之后任何一次 pnpm 操作都会按 lock 重建链接**,把刚更新上去的版本
3064
+ * 还原成 `plugin-src` 里的旧副本(与 0.3.56 修过的「自更新被 lock 还原」同族)。
3065
+ * 返回 false 时调用方会重新物化(把新副本刷进 plugin-src)并重放 `link:`,实测能把链接与版本一起恢复。
3066
+ */
3067
+ function linkSpecIsIntact(profileDir, packageName, spec) {
3068
+ if (typeof spec !== 'string' || !spec.startsWith('link:')) return false
3069
+ const target = spec.slice('link:'.length)
3070
+ const src = join(profileDir, 'node_modules', ...packageName.split('/'))
3071
+ try {
3072
+ if (!existsSync(src) || !existsSync(target)) return false
3073
+ return realpathSync(src) === realpathSync(target)
3074
+ } catch {
3075
+ return false
3076
+ }
3077
+ }
3078
+
2969
3079
  // ── GitHub Release 源解析(issue #3:按包名反查发布仓库 → 遍历 release 的 assets → 按包名挑产物)──
2970
3080
  //
2971
3081
  // 背景(用户 issue,附逐条实测):安装 yjh051108/dsh-routing-suite(根包 @dsh-external/dsh-super-injector,
@@ -3693,10 +3803,11 @@ function installJobView(job) {
3693
3803
  subpackages: job.subpackages ?? null,
3694
3804
  source: job.source ?? 'github',
3695
3805
  curlNote: job.curlNote ?? null,
3696
- bundleNote: job.bundleNote ?? null,
3697
- lockUpdated: job.lockUpdated ?? null,
3698
- lockVersion: job.lockVersion ?? null,
3806
+ bundleNote: job.bundleNote ?? null,
3807
+ lockUpdated: job.lockUpdated ?? null,
3808
+ lockVersion: job.lockVersion ?? null,
3699
3809
  lockNote: job.lockNote ?? null,
3810
+ depNote: job.depNote ?? null, // 依赖来源写回说明(缺陷②:release 专属包按 link: 记录时给出可见解释,绝不静默)
3700
3811
  compatNote: job.compatNote ?? null,
3701
3812
  kind: job.kind ?? 'plugin',
3702
3813
  skillName: job.skillName ?? null,
@@ -4121,9 +4232,9 @@ export async function runSuiteInstallJob(job, ctx) {
4121
4232
  copyTree(subDir, target)
4122
4233
  const taken = new Set(listEntries(ctx).map((e) => e.rowId))
4123
4234
  const entryId = deriveEntryId(pkg.name, taken)
4124
- await appendInsert(patchPath, entryId, pkg.name)
4125
- // 记下套装装配出的包名:它们是 copyTree 铺进去的、不在 pnpm-lock.yaml 里,交给调用方统一对账
4126
- if (!Array.isArray(job.suiteInstalled)) job.suiteInstalled = []
4235
+ await appendInsert(patchPath, entryId, pkg.name)
4236
+ // 记下套装装配出的包名:它们是 copyTree 铺进去的、不在 pnpm-lock.yaml 里,交给调用方统一对账
4237
+ if (!Array.isArray(job.suiteInstalled)) job.suiteInstalled = []
4127
4238
  if (!job.suiteInstalled.includes(pkg.name)) job.suiteInstalled.push(pkg.name)
4128
4239
  report.push({ component: sub.name, type: 'plugin', ok: true, note: `已安装 ${pkg.name}(HMR 生效)` })
4129
4240
  handled = true
@@ -5673,16 +5784,25 @@ function frameworkCheckPromptText(fc) {
5673
5784
  * 只给前 N 个候选扫 release;排在后面的候选仍照走 curl/并行竞速(按包名施工,代价小)。 */
5674
5785
  const RELEASE_CHANNEL_BUDGET = 3
5675
5786
 
5676
- /** 安装通道实现集合(默认真实实现;ctx.installChannels 可覆盖)。
5787
+ /** 安装通道实现集合(默认真实实现;`ctx.get('installChannels')` 可覆盖 —— 只走可选读取,不走属性访问)。
5677
5788
  * 为什么留这个缝:通道守卫(哪些通道在懒惰展开之后仍应被尝试)正是 issue #3 的核心语义,
5678
5789
  * 用真实通道无法离线断言"谁被调用了"——单测注入桩函数即可把语义钉死(见 test-suite-detect.mjs ⑪)。
5679
- * 也给离线 e2e(test-suite-install.mjs)用:那条用例不该为了验证通道派发去真装一个包。 */
5680
- function channelImpls(ports) {
5790
+ * 也给离线 e2e(test-suite-install.mjs)用:那条用例不该为了验证通道派发去真装一个包。
5791
+ * 注:注入桩在测试里必须以「provide 但未 inject」的方式喂进来(见 strict-ctx.mjs 的严格替身),
5792
+ * 否则替身与 cordis 语义不一致,这类缺陷还会再溜过去一次。导出只为单测能直接断言这个缝。 */
5793
+ export function channelImpls(ports) {
5681
5794
  const real = { pnpmInstall, curlManualInstall, raceInstallChannels, githubReleaseInstall, backfillMissingDeps }
5682
- // 测试注入缝:ports 在生产路径上可能是 cordis 的 ctx 代理,直接访问未 inject 的属性会**同步抛错**
5683
- // (cannot get property ... without inject),导致每一次安装都失败 → 必须兜住。
5684
- let override = null
5685
- try { override = ports?.installChannels ?? null } catch { override = null }
5795
+ // 测试注入缝(2026-09-22 事故的修法,**别改回属性访问**):
5796
+ // 生产路径上 ports 就是 cordis 的 ctx 代理,属性式读取未 inject 的名字会**同步抛**
5797
+ // `cannot get property "installChannels" without inject` —— 0.3.59 因此每一次安装都在这里失败。
5798
+ // 正规写法是 ctx.get('installChannels')(Cordis 的可选读取,未声明也不抛,与本文件其它
5799
+ // ctx.get('subagents') / ctx.get('agents') / ctx.get('skills') 一致);普通对象(测试替身/窄接口)
5800
+ // 才回退属性访问。try/catch 保留 0.3.60/0.3.61 的兜底语义:万一还有别的 ctx 形状,
5801
+ // 宁可回落真实实现也不要炸掉安装。
5802
+ let override = null
5803
+ try {
5804
+ override = typeof ports?.get === 'function' ? (ports.get('installChannels') ?? null) : (ports?.installChannels ?? null)
5805
+ } catch { override = null }
5686
5806
  return override !== null && typeof override === 'object' ? { ...real, ...override } : real
5687
5807
  }
5688
5808
 
@@ -5833,17 +5953,18 @@ async function runInstallJob(job, ctx) {
5833
5953
  if (resolveInstallKind(job.kind, await probeGitmodules(job.repo)) === 'suite') {
5834
5954
  job.kind = 'suite'
5835
5955
  const suiteResult = await runSuiteInstallJob(job, ctx)
5836
- // 套装装配出的普通插件同样对账,避免之后被 pnpm 还原/清理
5837
- if (Array.isArray(job.suiteInstalled) && job.suiteInstalled.length > 0) {
5838
- try {
5839
- const lock = await reconcileLockfile({ profileDir, packages: job.suiteInstalled.map((name) => ({ name })), registries })
5840
- job.lockUpdated = lock.lockUpdated
5841
- job.lockVersion = lock.lockVersion
5842
- job.lockMethod = lock.method
5843
- job.lockPackages = lock.packages
5844
- if (lock.lockNote !== null) job.lockNote = lock.lockNote
5845
- } catch {}
5846
- }
5956
+ // 套装装配出的普通插件同样对账,避免之后被 pnpm 还原/清理
5957
+ if (Array.isArray(job.suiteInstalled) && job.suiteInstalled.length > 0) {
5958
+ try {
5959
+ const lock = await reconcileLockfile({ profileDir, packages: job.suiteInstalled.map((name) => ({ name })), registries })
5960
+ job.lockUpdated = lock.lockUpdated
5961
+ job.lockVersion = lock.lockVersion
5962
+ job.lockMethod = lock.method
5963
+ job.lockPackages = lock.packages
5964
+ if (lock.lockNote !== null) job.lockNote = lock.lockNote
5965
+ if (lock.depNote !== null) job.depNote = lock.depNote
5966
+ } catch {}
5967
+ }
5847
5968
  // clone 后才发现没有 .gitmodules(探测假阳性 / 仓库已重构)→ 回落普通安装,不给用户一个失败
5848
5969
  if (suiteResult?.notASuite !== true) return
5849
5970
  job.kind = 'plugin'
@@ -6037,20 +6158,22 @@ async function runInstallJob(job, ctx) {
6037
6158
  const adapt = await maybeAutoAdaptCompat({ profileDir, packageName: installedName, syncedNames, patchPath, ctx })
6038
6159
  if (adapt !== null && adapt.ran === true) job.compatNote = adapt.note
6039
6160
  } catch {}
6040
- // lock 对账(2026-09-21,用户报告的自更新缺陷同源):主包 + 本次被补装/对齐的聚合子包一起核对,
6041
- // 一次 pnpm add 把漂移的包全写进 lock;对不上就留 lockNote,让面板如实告知(不假装成功)。
6042
- try {
6043
- const lock = await reconcileLockfile({
6044
- profileDir,
6045
- packages: [installedName, ...syncedNames].map((name) => ({ name })),
6046
- registries,
6047
- })
6048
- job.lockUpdated = lock.lockUpdated
6049
- job.lockVersion = lock.lockVersion
6050
- job.lockMethod = lock.method
6051
- job.lockPackages = lock.packages
6052
- if (lock.lockNote !== null) job.lockNote = lock.lockNote
6053
- } catch {}
6161
+ // lock 对账(2026-09-21,用户报告的自更新缺陷同源):主包 + 本次被补装/对齐的聚合子包一起核对,
6162
+ // 一次 pnpm add 把漂移的包全写进 lock;对不上就留 lockNote,让面板如实告知(不假装成功)。
6163
+ try {
6164
+ const lock = await reconcileLockfile({
6165
+ profileDir,
6166
+ packages: [installedName, ...syncedNames].map((name) => ({ name })),
6167
+ registries,
6168
+ })
6169
+ job.lockUpdated = lock.lockUpdated
6170
+ job.lockVersion = lock.lockVersion
6171
+ job.lockMethod = lock.method
6172
+ job.lockPackages = lock.packages
6173
+ if (lock.lockNote !== null) job.lockNote = lock.lockNote
6174
+ // 非常规来源(link:)写回说明:必须让用户看见(缺陷②最阴的地方就是"装完完全看不出问题")
6175
+ if (lock.depNote !== null) job.depNote = lock.depNote
6176
+ } catch {}
6054
6177
  job.stage = 'configuring'
6055
6178
  // 2026-09-06 事故(i18n 更新变重复行):@linxin666/dsh-i18n 声明了 dsh.bundle.patch,
6056
6179
  // 更新流程按「bundle 安装规则」把它追加进 bundles → 与全家桶内的 web-ui-i18n 行(同包)重复。
@@ -6168,7 +6291,13 @@ export function readInstalledVersion(profileDir, name) {
6168
6291
 
6169
6292
  /** 从 pnpm-lock.yaml 读出该包被**钉住**的版本。解析只认两种确定性位置,避免子串误命中:
6170
6293
  * ① importers 段里恰为 `'<name>':` 的行,随后几行内的 `version: X`;② packages 段 `<name>@<版本>:` 的键名。
6171
- * 读不到返回 null(调用方据此判定"没能核实",绝不谎报成功)。 */
6294
+ * 读不到返回 null(调用方据此判定"没能核实",绝不谎报成功)。
6295
+ *
6296
+ * ★ 2026-09-22 修复(缺陷②连带):旧版 ① 在遇到 `specifier: …` 行时**直接 break**,而 importers 段
6297
+ * 恒为 `specifier:` 在前、`version:` 在后 → ① 从来没生效过,全靠 ② 兜底;而 ② 的正则用 `[^':\s]+`
6298
+ * 取值,遇到 `name@https://…tgz` / `link:…` 会在第一个冒号处截断(读出 `http`)或读不到(link 依赖
6299
+ * 在 lock 里没有 packages 条目 → 返回 null)。后果:来源钉住的包永远被判为「漂移」→ 每次安装都白跑
6300
+ * 一次 pnpm add,并给用户一条假的「没写进 lock」警告。现在 ① 跳过 specifier 行、② 取到行尾。 */
6172
6301
  export function lockVersionOf(profileDir, name) {
6173
6302
  const file = join(profileDir, 'pnpm-lock.yaml')
6174
6303
  if (!existsSync(file)) return null
@@ -6180,14 +6309,16 @@ export function lockVersionOf(profileDir, name) {
6180
6309
  }
6181
6310
  for (let i = 0; i < lines.length; i += 1) {
6182
6311
  if (lines[i].trim() !== `'${name}':`) continue
6183
- for (let k = i + 1; k < Math.min(i + 5, lines.length); k += 1) {
6312
+ for (let k = i + 1; k < Math.min(i + 8, lines.length); k += 1) {
6184
6313
  const m = /^\s*version:\s*(\S+)\s*$/u.exec(lines[k])
6185
6314
  if (m) return m[1]
6186
- if (/^\s*\S+:/u.test(lines[k]) && !/^\s*version:/u.test(lines[k])) break
6315
+ // 只在新包的键行(`'<name>':` 形态)处停止;`specifier: X` 有值,不是键行,必须继续往下看
6316
+ if (/^\s*'?[^\s:]+'?:\s*$/u.test(lines[k])) break
6187
6317
  }
6188
6318
  }
6189
6319
  const escaped = name.replace(/[/\\^$*+?.()|[\]{}]/gu, '\\$&')
6190
- const keyRe = new RegExp(`^\\s*'?${escaped}@([^':\\s]+)'?:`, 'u')
6320
+ // 取到行尾再剥尾部的引号/冒号:`'name@https://…tgz':` / `name@git+https://…#sha:` / `name@1.2.3:`
6321
+ const keyRe = new RegExp(`^\\s*'?${escaped}@(.+?)'?:\\s*$`, 'u')
6191
6322
  for (const line of lines) {
6192
6323
  const m = keyRe.exec(line)
6193
6324
  if (m) return m[1]
@@ -6253,63 +6384,166 @@ export async function selfUpdateToLatest({ profileDir, latest, registries, curlM
6253
6384
  return { method, spec, installedVersion: installed, lockVersion: lock, lockUpdated, lockNote, command, note: noteParts.join(';'), errors }
6254
6385
  }
6255
6386
 
6256
-
6257
- /**
6258
- * 通用 lock 对账(**任何**安装通道装完都该调用,支持一次对账多个包)。
6259
- * 走非 pnpm 通道(并行 curl / curl tarball / GitHub Release / git 装配 / 套装 copyTree / 聚合子包补装)装的包,
6260
- * node_modules 里的版本与 `pnpm-lock.yaml` 记的版本可能不一致——之后任何 pnpm 操作(开关插件改
6261
- * `dsh.profile.bundles`、`dsh plugin add/remove`)都可能按 lock 把它还原或当外来物处理。这里:
6262
- * ① 全部一致 → 直接返回(不跑 pnpm,零成本);
6263
- * ② 有漂移 → **一次** `pnpm add <pkg1>@<v1> <pkg2>@<v2> …` 把漂移包精确写进 lock;
6264
- * ③ 仍对不上 → 返回 lockNote(逐包列出)+ 可复制命令,由调用方如实展示,绝不假装成功。
6265
- */
6266
- export async function reconcileLockfile({ profileDir, packageName = null, packages = null, registries = [], pnpmAdd = pnpmInstall, execOpts = {} }) {
6267
- const targets = Array.isArray(packages)
6268
- ? packages.filter((p) => p && typeof p.name === 'string' && p.name !== '')
6269
- : (packageName === null ? [] : [{ name: packageName, version: null }])
6270
- const unique = []
6271
- for (const t of targets) if (!unique.some((u) => u.name === t.name)) unique.push(t)
6272
- const command = (name, version) => `dsh plugin --profile <你的profile> add ${name}@${version ?? '<版本>'}`
6273
- const snap = () => unique.map((t) => ({
6274
- name: t.name,
6275
- spec: profileSpecOf(profileDir, t.name),
6276
- installed: readInstalledVersion(profileDir, t.name),
6277
- lock: lockVersionOf(profileDir, t.name),
6278
- }))
6279
- const drift = (rows) => rows.filter((r) => r.installed === null || r.installed !== r.lock)
6280
- let rows = snap()
6281
- let drifted = drift(rows)
6282
- const errors = []
6283
- let method = null
6284
- if (drifted.length > 0) {
6285
- const specs = drifted.filter((r) => r.installed !== null).map((r) => `${r.name}@${r.installed}`)
6286
- if (specs.length > 0) {
6287
- try {
6288
- await pnpmAdd(profileDir, specs.length === 1 ? specs[0] : specs, registries?.[0])
6289
- method = 'pnpm-add'
6290
- } catch (error) {
6291
- errors.push(`pnpm add 对齐失败:${error instanceof Error ? error.message : String(error)}`)
6292
- }
6293
- }
6294
- rows = snap()
6295
- drifted = drift(rows)
6296
- }
6297
- const lockUpdated = drifted.length === 0
6298
- return {
6299
- method,
6300
- packages: rows.map((r) => ({ name: r.name, installedVersion: r.installed, lockVersion: r.lock, aligned: r.installed !== null && r.installed === r.lock })),
6301
- spec: rows.length === 1 ? rows[0].spec : null,
6302
- installedVersion: rows.length === 1 ? rows[0].installed : null,
6303
- lockVersion: rows.length === 1 ? rows[0].lock : null,
6304
- lockUpdated,
6305
- lockNote: lockUpdated
6306
- ? null
6307
- : `${drifted.length} 个包没写进 pnpm-lock.yaml(${drifted.map((r) => `${r.name}:装了 ${r.installed ?? '未知'}/lock 里是 ${r.lock ?? '未记录'}`).join(';')}):之后任何 pnpm 操作(开关插件、dsh plugin add/remove)都可能把它们还原。要钉住请执行:${command(drifted[0].name, drifted[0].installed)}`,
6308
- command: drifted.length > 0 ? command(drifted[0].name, drifted[0].installed) : command(rows[0]?.name ?? '', rows[0]?.installed ?? null),
6309
- errors,
6310
- }
6311
- }
6312
-
6387
+
6388
+ /**
6389
+ * 依赖**来源规格**的协议前缀:manifest 里出现这些,说明来源不是 npm registry(link:/file:/URL/git/别名)。
6390
+ * 这类 spec 必须原样保留,**绝不能**换成版本号(换成 `<name>@<版本>` 就等于把来源丢掉 —— 缺陷②)。
6391
+ */
6392
+ const SOURCE_SPEC_RE = /^(link:|file:|https?:|git\+|git:|github:|gitlab:|bitbucket:|workspace:|portal:|npm:|jsr:)/u
6393
+
6394
+ /**
6395
+ * lock 里被**来源**(而不是版本号)钉住的解析:`link:../x`、`https://…tgz`、`git+https://…#sha`。
6396
+ * 这类解析的 `version` 字段不是语义化版本,不能拿它跟已装版本做相等比较(见下面 aligned)。
6397
+ */
6398
+ const SOURCE_PINNED_RE = SOURCE_SPEC_RE
6399
+
6400
+ /** 裸精确版本号(面板写回留下的形态):`0.3.3`。`^0.3.3` / `~0.3.3` / `>=1` 是用户手写的范围。 */
6401
+ const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u
6402
+
6403
+ /**
6404
+ * dist-tag 形式的 spec(`latest` / `next` / `beta`):也是 registry 来源,但要**保留标签**而不是钉成版本号。
6405
+ * 判据:不含版本号或协议前缀的裸标识符。注意不能把它当成"非 registry 来源"原样丢给 pnpm ——
6406
+ * `pnpm add latest` 会去装一个**名叫 latest 的包**,那是灾难性的误装。
6407
+ */
6408
+ const DIST_TAG_RE = /^[A-Za-z][A-Za-z0-9._-]*$/u
6409
+
6410
+ /**
6411
+ * 通用 lock 对账(**任何**安装通道装完都该调用,支持一次对账多个包)。
6412
+ * 走非 pnpm 通道(并行 curl / curl tarball / GitHub Release / git 装配 / 套装 copyTree / 聚合子包补装)装的包,
6413
+ * node_modules 里的版本与 `pnpm-lock.yaml` 记的版本可能不一致——之后任何 pnpm 操作(开关插件改
6414
+ * `dsh.profile.bundles`、`dsh plugin add/remove`)都可能按 lock 把它还原或当外来物处理。这里:
6415
+ * ① 全部一致 → 直接返回(不跑 pnpm,零成本);
6416
+ * ② 有漂移 → **一次** `pnpm add <spec1> <spec2> …` 把所有漂移包精确写进 lock;
6417
+ * ③ 仍对不上 → 返回 lockNote(逐包列出)+ 可复制命令,由调用方如实展示,绝不假装成功。
6418
+ *
6419
+ * ★ 缺陷②修复(0.3.63):写回 spec 前必须确认来源,**绝不能把 release 来源的包写成裸版本号**——
6420
+ * 0.3.57 起的旧实现一律 `pnpm add <name>@<installed>`,对 npm 上不存在的包会被 pnpm 静默改写成
6421
+ * `<name>: "<版本>"`(EXIT=0,装完看不出问题),lock 一重建就 ERR_PNPM_FETCH_404。现在的规则:
6422
+ * · manifest 已是真实来源(link: / git+ / file:)→ 原样重放,不降级成版本号;
6423
+ * · 已是 tarball URL(或在 lock 里被 URL 钉住)→ 转成 link: 形式
6424
+ * (pnpm 10 对 direct-URL 依赖重写 lock 会丢 integrity,实测 ERR_PNPM_MISSING_TARBALL_INTEGRITY);
6425
+ * · 版本号 / 没有条目 → **先探 registry**:这个包的**这个版本**可解析才写 `<name>@<版本>`;
6426
+ * 查无此包(404)→ 物化到 `<DSH_HOME>/plugin-src/<包名>` 并写 `link:<绝对路径>`,同时回 depNote;
6427
+ * 连物化都做不到 → **跳过对齐且不碰 package.json**,只记 note。
6428
+ */
6429
+ export async function reconcileLockfile({ profileDir, packageName = null, packages = null, registries = [], pnpmAdd = pnpmInstall, execOpts = {}, fetchJson = fetchJsonUrl, home = null } = {}) {
6430
+ const targets = Array.isArray(packages)
6431
+ ? packages.filter((p) => p && typeof p.name === 'string' && p.name !== '')
6432
+ : (packageName === null ? [] : [{ name: packageName, version: null }])
6433
+ const unique = []
6434
+ for (const t of targets) if (!unique.some((u) => u.name === t.name)) unique.push(t)
6435
+ const command = (name, version) => `dsh plugin --profile <你的profile> add ${name}@${version ?? '<版本>'}`
6436
+
6437
+ const snap = () => unique.map((t) => ({
6438
+ name: t.name,
6439
+ spec: profileSpecOf(profileDir, t.name),
6440
+ installed: readInstalledVersion(profileDir, t.name),
6441
+ lock: lockVersionOf(profileDir, t.name),
6442
+ }))
6443
+ const sourcePinned = (v) => typeof v === 'string' && SOURCE_PINNED_RE.test(v.trim())
6444
+ const urlPinned = (v) => typeof v === 'string' && /^https?:/u.test(v.trim())
6445
+ /** 缺陷②指纹:manifest 声明裸版本号、lock 却把该包解析到一个 URL —— pnpm 静默改写留下的状态。 */
6446
+ const misrecorded = (r) => typeof r.spec === 'string' && EXACT_VERSION_RE.test(r.spec.trim()) && urlPinned(r.lock)
6447
+ /** manifest 里仍是 tarball URL:虽然 lock 能解析,但 pnpm 10 重写 lock 会丢 integrity(实测),
6448
+ * 必须**主动**规整成 link: 形式 —— 否则「删 lock / 清 node_modules / 换机」就装不回来。 */
6449
+ const urlSpec = (r) => typeof r.spec === 'string' && /^https?:/u.test(r.spec.trim())
6450
+ /** 来源钉住的包没有「版本号相等」可比:link 依赖要看链接是否**真的**还在(release 通道更新会把
6451
+ * node_modules/<包名> 换成真实目录,此时必须重新物化 + 重放 link:,否则下次 pnpm 操作会还原版本);
6452
+ * 其余来源(git+/file:/URL)只要有解析且包装着即视为对齐。 */
6453
+ const aligned = (r) => {
6454
+ if (r.installed === null) return false
6455
+ if (typeof r.spec === 'string' && r.spec.startsWith('link:')) return linkSpecIsIntact(profileDir, r.name, r.spec)
6456
+ return r.installed === r.lock || sourcePinned(r.lock)
6457
+ }
6458
+ const driftedOf = (rows) => rows.filter((r) => r.installed === null || !aligned(r) || misrecorded(r) || urlSpec(r))
6459
+
6460
+ const depNotes = []
6461
+ // 物化 + link: 计划(registry 查无此包时唯一安全的写回形式)
6462
+ const linkPlan = (row, why) => {
6463
+ const dir = materializePackageForLink(profileDir, row.name, home === null ? {} : { home })
6464
+ if (dir === null) {
6465
+ return {
6466
+ spec: null,
6467
+ note: `${row.name}:${why},但 node_modules 里找不到可物化的已装副本 —— 已跳过 lock 对齐(**未改动 package.json**,避免留下指向不存在 npm 版本的裸版本号)`,
6468
+ }
6469
+ }
6470
+ const link = linkSpecFor(dir)
6471
+ return {
6472
+ spec: link,
6473
+ note: `${row.name}:${why},已按 link: 形式记录依赖(${link})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`,
6474
+ }
6475
+ }
6476
+ /** 决定单个漂移包该以什么 spec 写回。spec=null 表示跳过(不碰 manifest)。 */
6477
+ const planWriteback = async (row) => {
6478
+ const { name, spec, installed } = row
6479
+ // ① manifest 里已是真实来源(协议前缀)→ 保持来源,绝不降级成裸版本号
6480
+ if (typeof spec === 'string' && SOURCE_SPEC_RE.test(spec.trim())) {
6481
+ if (/^https?:/u.test(spec.trim()) || misrecorded(row)) {
6482
+ return linkPlan(row, '该包原先以 tarball URL 记录(pnpm 10 重写 lock 会丢 integrity,实测 ERR_PNPM_MISSING_TARBALL_INTEGRITY)')
6483
+ }
6484
+ // link: 规格:链接可能已被 release/curl 通道的"先删再铺"打断 → 先把新副本刷进 plugin-src 再重放
6485
+ if (spec.trim().startsWith('link:')) {
6486
+ const dir = materializePackageForLink(profileDir, name, home === null ? {} : { home })
6487
+ return { spec: dir === null ? spec.trim() : linkSpecFor(dir), note: null }
6488
+ }
6489
+ return { spec: spec.trim(), note: null } // git+ / file: / 别名 原样重放(幂等,来源不变)
6490
+ }
6491
+ // ② dist-tag(latest/next…):registry 来源,但**保留标签**(钉成版本号会悄悄失去升级语义)
6492
+ if (typeof spec === 'string' && DIST_TAG_RE.test(spec.trim())) {
6493
+ const probeTag = await probeRegistryPackage(name, registries, { fetchJson })
6494
+ if (probeTag.resolvable) return { spec: `${name}@${spec.trim()}`, note: null }
6495
+ return linkPlan(row, 'npm registry 查无此包(该包只存在于 GitHub release)')
6496
+ }
6497
+ // ③ 版本号或没有条目 → 先探 registry:**这个版本**可解析才允许写版本号
6498
+ const probe = await probeRegistryPackage(name, registries, { version: installed, fetchJson })
6499
+ if (probe.resolvable && probe.hasVersion) return { spec: `${name}@${installed}`, note: null }
6500
+ // ④ registry 查无此包(或查无此版本)→ 只存在于 GitHub release → 物化 + link:
6501
+ return linkPlan(row, probe.resolvable ? `registry 上没有 ${installed} 这个版本` : 'npm registry 查无此包(该包只存在于 GitHub release)')
6502
+ }
6503
+
6504
+ let rows = snap()
6505
+ let drifted = driftedOf(rows)
6506
+ const errors = []
6507
+ let method = null
6508
+ if (drifted.length > 0) {
6509
+ // 只对"能读到实际版本"的包做精确对齐;读不到的(目录都没有)无法对账,留给 lockNote
6510
+ const specs = []
6511
+ for (const row of drifted) {
6512
+ if (row.installed === null) continue
6513
+ const plan = await planWriteback(row)
6514
+ if (plan.note !== null) depNotes.push(plan.note)
6515
+ if (plan.spec !== null) specs.push(plan.spec)
6516
+ }
6517
+ if (specs.length > 0) {
6518
+ try {
6519
+ await pnpmAdd(profileDir, specs.length === 1 ? specs[0] : specs, registries?.[0])
6520
+ method = 'pnpm-add'
6521
+ } catch (error) {
6522
+ errors.push(`pnpm add 对齐失败:${error instanceof Error ? error.message : String(error)}`)
6523
+ }
6524
+ }
6525
+ rows = snap()
6526
+ drifted = driftedOf(rows)
6527
+ }
6528
+ const lockUpdated = drifted.length === 0
6529
+ return {
6530
+ method,
6531
+ packages: rows.map((r) => ({ name: r.name, installedVersion: r.installed, lockVersion: r.lock, spec: r.spec, aligned: r.installed !== null && (r.installed === r.lock || sourcePinned(r.lock)) })),
6532
+ spec: rows.length === 1 ? rows[0].spec : null,
6533
+ installedVersion: rows.length === 1 ? rows[0].installed : null,
6534
+ lockVersion: rows.length === 1 ? rows[0].lock : null,
6535
+ lockUpdated,
6536
+ lockNote: lockUpdated
6537
+ ? null
6538
+ : `${drifted.length} 个包没写进 pnpm-lock.yaml(${drifted.map((r) => `${r.name}:装了 ${r.installed ?? '未知'}/lock 里是 ${r.lock ?? '未记录'}`).join(';')}):之后任何 pnpm 操作(开关插件、dsh plugin add/remove)都可能把它们还原。要钉住请执行:${command(drifted[0].name, drifted[0].installed)}`,
6539
+ // 非常规来源(link:)写回时的**用户可见**说明:绝不静默(缺陷②的隐蔽性正是"装完看不出问题")
6540
+ depNote: depNotes.length > 0 ? depNotes.join(';') : null,
6541
+ command: drifted.length > 0 ? command(drifted[0].name, drifted[0].installed) : command(rows[0]?.name ?? '', rows[0]?.installed ?? null),
6542
+ errors,
6543
+ }
6544
+ }
6545
+
6546
+
6313
6547
  export function apply(ctx) {
6314
6548
  // 恢复 AI 赋能任务(restart 后 running 置失败,plan-ready/结果保留)
6315
6549
  loadAiJobs()
package/package.json CHANGED
@@ -1,44 +1,44 @@
1
- {
2
- "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.3.60",
4
- "description": "DSH plugin management panel & marketplace: one-click enable/disable, multi-source market (GitHub/Gitee/custom), static-index market (500+ plugins / 300 skills), skills, suites, and one-click framework upgrade.",
5
- "repository": {
6
- "type": "git",
7
- "url": "https://github.com/Noob-stupid/dsh-plugin-hub"
8
- },
9
- "type": "module",
10
- "main": "./lib/index.js",
11
- "exports": {
12
- ".": "./lib/index.js",
13
- "./client": "./lib/client.js",
14
- "./package.json": "./package.json"
15
- },
16
- "keywords": [
17
- "dsh-plugin",
18
- "deepseek-harness",
19
- "plugin-manager"
20
- ],
21
- "dsh": {
22
- "bundle": {
23
- "patch": "./cordis.patch.yml"
24
- },
25
- "client": {
26
- "platform": "web",
27
- "inject": [
28
- "@deepseek-ai/dsh-client-runtime",
29
- "@deepseek-ai/dsh-client-locale",
30
- "@deepseek-ai/dsh-client-ui-settings"
31
- ]
32
- }
33
- },
34
- "files": [
35
- "lib",
36
- "cordis.patch.yml",
37
- "SECURITY.md"
38
- ],
39
- "license": "MIT",
40
- "scripts": {
41
- "checkBom": "node -e \"const fs=require('fs');const b=fs.readFileSync('package.json');if(b[0]===0xEF){console.error('BOM detected in package.json — strip it before publish');process.exit(1)};console.log('package.json BOM check OK')\"",
42
- "prepublishOnly": "npm run checkBom"
43
- }
44
- }
1
+ {
2
+ "name": "@noob-stupid/dsh-plugin-console",
3
+ "version": "0.3.63",
4
+ "description": "DSH plugin management panel & marketplace: one-click enable/disable, multi-source market (GitHub/Gitee/custom), static-index market (500+ plugins / 300 skills), skills, suites, and one-click framework upgrade.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/Noob-stupid/dsh-plugin-hub"
8
+ },
9
+ "type": "module",
10
+ "main": "./lib/index.js",
11
+ "exports": {
12
+ ".": "./lib/index.js",
13
+ "./client": "./lib/client.js",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "keywords": [
17
+ "dsh-plugin",
18
+ "deepseek-harness",
19
+ "plugin-manager"
20
+ ],
21
+ "dsh": {
22
+ "bundle": {
23
+ "patch": "./cordis.patch.yml"
24
+ },
25
+ "client": {
26
+ "platform": "web",
27
+ "inject": [
28
+ "@deepseek-ai/dsh-client-runtime",
29
+ "@deepseek-ai/dsh-client-locale",
30
+ "@deepseek-ai/dsh-client-ui-settings"
31
+ ]
32
+ }
33
+ },
34
+ "files": [
35
+ "lib",
36
+ "cordis.patch.yml",
37
+ "SECURITY.md"
38
+ ],
39
+ "license": "MIT",
40
+ "scripts": {
41
+ "checkBom": "node -e \"const fs=require('fs');const b=fs.readFileSync('package.json');if(b[0]===0xEF){console.error('BOM detected in package.json — strip it before publish');process.exit(1)};console.log('package.json BOM check OK')\"",
42
+ "prepublishOnly": "npm run checkBom"
43
+ }
44
+ }