@noob-stupid/dsh-plugin-console 0.3.26 → 0.3.28

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
@@ -132,8 +132,8 @@ const FIBER_PHASE = {
132
132
  * (有入口)都是这样安装的,当作插件条目 insert 会漏掉它们的 bundle 补丁。 */
133
133
  async function detectBundleOnly(profileDir, packageName) {
134
134
  try {
135
- const require = createRequire(join(profileDir, 'package.json'))
136
- const pkgPath = require.resolve(`${packageName}/package.json`)
135
+ const pkgPath = resolvePackageJson(packageName, profileDir)
136
+ if (pkgPath === null) throw new Error('not found')
137
137
  const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
138
138
  return typeof pkg.dsh?.bundle?.patch === 'string'
139
139
  } catch {
@@ -158,17 +158,59 @@ async function addBundleToManifest(profileDir, packageName) {
158
158
  /** 官方 profile 模板自带的 bundle(其余 bundle 视为用户额外添加)。 */
159
159
  const DEFAULT_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
160
160
 
161
+ /** 包名归一:支持子路径导出形式(@linxin666/dsh-web-all/settings → @linxin666/dsh-web-all)。 */
162
+ function packageNameOf(moduleName) {
163
+ if (typeof moduleName !== 'string') return moduleName
164
+ const m = moduleName.match(/^(@[^/]+\/[^/]+)(?:\/.*)?$/u)
165
+ return m ? m[1] : moduleName
166
+ }
167
+
168
+ /** 把 file:// URL(如 ctx.baseUrl)转成目录路径(resolve 用)。 */
169
+ function baseDirOf(baseUrl) {
170
+ try {
171
+ if (typeof baseUrl === 'string' && baseUrl.startsWith('file:')) return dirname(fileURLToPath(baseUrl))
172
+ } catch {}
173
+ return typeof baseUrl === 'string' && baseUrl !== '' ? baseUrl : '.'
174
+ }
175
+
176
+ /**
177
+ * 解析包 package.json 的绝对路径(exports 限制包 fallback):
178
+ * require.resolve('pkg/package.json') 对声明了 exports 且不含 './package.json' 的包会抛错
179
+ * (2026-09-04 教训:@openviking/dsh-memory-plugin 因此被 heal 误判模块缺失并自动禁用)——
180
+ * fallback 直接查 node_modules 物理路径。
181
+ */
182
+ function resolvePackageJson(pkgName, baseDir, fallbackBase) {
183
+ const name = packageNameOf(pkgName)
184
+ try {
185
+ return createRequire(join(baseDir, 'package.json')).resolve(`${name}/package.json`)
186
+ } catch {}
187
+ const parts = String(name).split('/')
188
+ const candidate = join(baseDir, 'node_modules', ...parts, 'package.json')
189
+ if (existsSync(candidate)) return candidate
190
+ // issue #15:npm 全局安装 dsh 时 ctx.baseUrl 落在框架安装树(而非 profile node_modules),
191
+ // 官方 @deepseek-ai/* 恰好可见、第三方插件全部解析失败 → 详情/版本/仓库全空。
192
+ // 回退到 profile 目录(fallbackBase)再试一次。
193
+ if (typeof fallbackBase === 'string' && fallbackBase !== '' && fallbackBase !== baseDir) {
194
+ try {
195
+ return createRequire(join(fallbackBase, 'package.json')).resolve(`${name}/package.json`)
196
+ } catch {}
197
+ const candidate2 = join(fallbackBase, 'node_modules', ...parts, 'package.json')
198
+ if (existsSync(candidate2)) return candidate2
199
+ }
200
+ return null
201
+ }
202
+
161
203
  /** 已加载插件的包元信息缓存(安装日期/版本/仓库),60 秒 TTL。 */
162
204
  const pkgMetaCache = new Map()
163
205
  const PKG_META_TTL = 60000
164
- function entryPkgMeta(moduleName, baseUrl) {
206
+ function entryPkgMeta(moduleName, baseUrl, profileDir) {
165
207
  if (typeof moduleName !== 'string' || moduleName.startsWith('cordis:')) return null
166
208
  const hit = pkgMetaCache.get(moduleName)
167
209
  if (hit !== undefined && Date.now() - hit.at < PKG_META_TTL) return hit
168
210
  const meta = { at: Date.now(), installDate: null, version: null, repository: null }
169
211
  try {
170
- const require = createRequire(baseUrl)
171
- const pkgPath = require.resolve(`${moduleName}/package.json`)
212
+ const pkgPath = resolvePackageJson(moduleName, baseDirOf(baseUrl), profileDir ?? null)
213
+ if (pkgPath === null) throw new Error('not found')
172
214
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
173
215
  // 安装日期:优先 curl 通道写入的 .dsh-installed-at 标记(真实安装时刻);
174
216
  // 否则用 package.json 的 mtime,但 npm tarball 会把文件时间固定为 1985-10-26
@@ -352,6 +394,8 @@ function checkPluginFrameworkCompat(pkg, frameworkVersion, pkgDir = null) {
352
394
  return { decision: 'fail', reason: `源码仍引用 0.1.2 起已删除的 dsh-settings API(${broken.join('、')})——实际不兼容,启用会让整个服务启动崩溃` }
353
395
  }
354
396
  if (typeof declared === 'string' && declared !== '') return { decision: 'pass', reason: `声明兼容范围 ${declared} 满足框架 ${frameworkVersion},且源码无已删除 API 引用` }
397
+ // 源码扫描干净 = 权威判据:依赖范围可能滞后旧版("假拒绝"),不作为 fail。
398
+ return { decision: 'unknown', reason: '源码扫描无已删除 API 引用(声明/依赖范围为参考,不作为不兼容判据)' }
355
399
  } else if (typeof declared === 'string' && declared !== '') {
356
400
  return { decision: 'pass', reason: `声明兼容范围 ${declared} 满足框架 ${frameworkVersion}` }
357
401
  }
@@ -374,8 +418,8 @@ async function readExtraBundleRows(profileDir) {
374
418
  for (const pkg of bundles) {
375
419
  if (DEFAULT_BUNDLES.includes(pkg)) continue
376
420
  try {
377
- const require = createRequire(join(profileDir, 'package.json'))
378
- const dir = dirname(require.resolve(`${pkg}/package.json`))
421
+ const pk = resolvePackageJson(pkg, profileDir)
422
+ const dir = dirname(pk ?? join(profileDir, 'node_modules', ...String(pkg).split('/'), 'package.json'))
379
423
  const text = await readFile(join(dir, 'cordis.patch.yml'), 'utf8')
380
424
  const lines = text.split(/\r?\n/u)
381
425
  let inInsert = false
@@ -463,8 +507,8 @@ async function readExtraBundleOwners(profileDir) {
463
507
  for (const pkg of bundles) {
464
508
  if (DEFAULT_BUNDLES.includes(pkg)) continue
465
509
  try {
466
- const require = createRequire(join(profileDir, 'package.json'))
467
- const dir = dirname(require.resolve(`${pkg}/package.json`))
510
+ const pk = resolvePackageJson(pkg, profileDir)
511
+ const dir = dirname(pk ?? join(profileDir, 'node_modules', ...String(pkg).split('/'), 'package.json'))
468
512
  const text = await readFile(join(dir, 'cordis.patch.yml'), 'utf8')
469
513
  const lines = text.split(/\r?\n/u)
470
514
  let inInsert = false
@@ -850,6 +894,119 @@ const FRAMEWORK_STATE_FILE = () => join(dshHome(), 'plugin-console', 'framework-
850
894
  const FRAMEWORK_BACKUP_ROOT = () => join(dshHome(), 'plugin-console', 'framework-backups')
851
895
  const COMPAT_PENDING_FILE = () => join(dshHome(), 'plugin-console', 'compat-pending.json')
852
896
 
897
+ /** 框架核心行:误禁用会导致启动失败(2026-09-04 session-persistence-jsonl 事故:6 行 pending、
898
+ * 启动断言失败)。任何补丁写入(适配门/脚本/工具)都禁止禁用这些行;自愈机制自动恢复误禁。 */
899
+ const CORE_PATCH_ROW_IDS = new Set([
900
+ 'session-persistence-jsonl', 'webserver', 'timer', 'hmr', 'session', 'session-checkpoint-policy',
901
+ 'message-feedback', 'workspace', 'storage', 'storage-json', 'storage-domain', 'api-gateway',
902
+ 'api-session-controller', 'api-workspace-controller', 'credentials', 'settings', 'attachment-local',
903
+ 'subprocess', 'sandbox', 'sandbox-policy', 'shell-env', 'agent', 'agent-loop', 'llm', 'web-runtime', 'web-startup',
904
+ ])
905
+
906
+ let patchHealAt = null
907
+ let patchHealReport = null
908
+
909
+ /** 解析用户补丁中 insert 块的 id → moduleName(name 字段)。 */
910
+ function parseInsertNames(text) {
911
+ const map = new Map()
912
+ const lines = text.split(/\r?\n/u)
913
+ let inInsert = false
914
+ let curId = null
915
+ for (const line of lines) {
916
+ if (/^- insert:\s*$/u.test(line)) { inInsert = true; curId = null; continue }
917
+ if (inInsert && /^- /u.test(line)) inInsert = false
918
+ if (inInsert) {
919
+ const idMatch = line.match(/^\s+- id: ([A-Za-z0-9_.-]+)/u)
920
+ if (idMatch) { curId = idMatch[1]; continue }
921
+ if (curId !== null) {
922
+ const nameMatch = line.match(/^\s+name: ['"]([^'"]+)['"]/u)
923
+ if (nameMatch) { map.set(curId, nameMatch[1]); curId = null }
924
+ }
925
+ }
926
+ }
927
+ return map
928
+ }
929
+
930
+ /**
931
+ * 补丁安全自愈(服务永不崩机制):
932
+ * ① 核心行被误禁用 → 自动移除禁用块恢复;
933
+ * ② 启用态用户 insert 行的模块缺失(如引用未安装包的行)→ 自动禁用(loader 对缺失模块会致命崩溃)。
934
+ * return { healed:[], autoDisabled:[], healedAt } —— healedAt=0 表示本次无修改。
935
+ */
936
+ async function healPatchSafety(patchPath) {
937
+ const profileDir = dirname(patchPath)
938
+ const { text } = await readPatchState(patchPath)
939
+ let next = text
940
+ const healed = []
941
+ const autoDisabled = []
942
+ for (const id of CORE_PATCH_ROW_IDS) {
943
+ const re = new RegExp(`^- id: ${escapeRegExp(id)}\\s*\\r?\\n {2}disabled: true\\s*\\r?\\n`, 'mu')
944
+ if (re.test(next)) {
945
+ next = next.replace(re, '')
946
+ healed.push(id)
947
+ }
948
+ }
949
+ if (healed.length === 0) {
950
+ const insertNames = parseInsertNames(next)
951
+ let require = null
952
+ for (const [id, moduleName] of insertNames) {
953
+ if (!moduleName || moduleName.startsWith('cordis:')) continue
954
+ let ok = true
955
+ try { ok = resolvePackageJson(moduleName, profileDir) !== null } catch { ok = false }
956
+ if (!ok && !new RegExp(`^- id: ${escapeRegExp(id)}\\s*\\r?\\n {2}disabled: true\\s*\\r?\\n`, 'mu').test(next)) {
957
+ next = `${next.trimEnd()}\n- id: ${id}\n disabled: true\n`
958
+ autoDisabled.push(id)
959
+ }
960
+ }
961
+ }
962
+ if (next !== text) {
963
+ await writeFile(patchPath, sanitizePatchText(next), 'utf8')
964
+ }
965
+ return { healed, autoDisabled, healedAt: next !== text ? Date.now() : 0 }
966
+ }
967
+
968
+ /**
969
+ * 启用前冒烟检查(服务永不崩机制):在独立子进程中动态 import 插件主模块,
970
+ * 捕获 loader 会遇到的 import/resolution 错误(SyntaxError、缺失导出、模块缺失)。
971
+ * 子进程失败不影响控制台;探测失败/超时按"不通过"处理(宁可拒绝,不冒险拖崩服务)。
972
+ */
973
+ function probePluginImport(moduleName, profileDir) {
974
+ return new Promise((resolve) => {
975
+ const script = [
976
+ 'import { createRequire } from "node:module";',
977
+ 'import path from "node:path";',
978
+ 'try {',
979
+ ' const req = createRequire(path.join(process.argv[1], "package.json"));',
980
+ ' const mainPath = req.resolve(process.argv[2]);',
981
+ ' const { pathToFileURL } = await import("node:url");',
982
+ ' await import(pathToFileURL(mainPath).href);',
983
+ ' console.log("PROBE_OK");',
984
+ '} catch (e) { console.log("PROBE_ERR:" + String(e && e.message ? e.message : e).slice(0, 400)); process.exitCode = 1; }',
985
+ ].join('\n')
986
+ let child = null
987
+ try {
988
+ child = spawn(process.execPath, ['--input-type=module', '-e', script, profileDir, moduleName], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
989
+ } catch {
990
+ resolve({ ok: true, detail: 'probe 无法启动(放行)' })
991
+ return
992
+ }
993
+ let out = ''
994
+ let timer = null
995
+ const finish = (ok, detail) => {
996
+ if (timer !== null) clearTimeout(timer)
997
+ resolve({ ok, detail })
998
+ }
999
+ timer = setTimeout(() => { try { child.kill() } catch {}; finish(false, '探测超时') }, 8000)
1000
+ child.stdout.on('data', (c) => { out += c })
1001
+ child.stderr.on('data', (c) => { out += c })
1002
+ child.on('error', () => finish(true, 'probe 进程错误(放行)'))
1003
+ child.on('close', (code) => {
1004
+ if (out.includes('PROBE_OK')) finish(true, 'import OK')
1005
+ else finish(false, (out.match(/PROBE_ERR:([^\n]*)/u)?.[1] ?? out.slice(0, 300)).trim())
1006
+ })
1007
+ })
1008
+ }
1009
+
853
1010
  /** 读取框架升级适配门清单(compat-pending.json);损坏/缺失返回 null。 */
854
1011
  function readCompatPending() {
855
1012
  try { return JSON.parse(readFileSync(COMPAT_PENDING_FILE(), 'utf8')) } catch { return null }
@@ -915,7 +1072,7 @@ function backupProfileSnapshot(profileDir, version, ctx) {
915
1072
  try { copyFileSync(join(profileDir, 'package.json'), join(dir, 'profile-package.json')) } catch {}
916
1073
  try {
917
1074
  const plugins = listEntries(ctx).map((e) => {
918
- const meta = entryPkgMeta(e.moduleName, ctx.baseUrl ?? 'file:///')
1075
+ const meta = entryPkgMeta(e.moduleName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx))
919
1076
  return { rowId: e.rowId, moduleName: e.moduleName, enabled: e.enabled, version: meta?.version ?? null, installDate: meta?.installDate ?? null }
920
1077
  })
921
1078
  writeFileSync(join(dir, 'plugins.json'), JSON.stringify(plugins, null, 2), 'utf8')
@@ -960,6 +1117,15 @@ function findPatchPath(ctx) {
960
1117
  return defaultPatchPath()
961
1118
  }
962
1119
 
1120
+ /** profile 根目录(resolvePackageJson 的 fallbackBase 用;失败返回 null)。 */
1121
+ function profileDirOf(ctx) {
1122
+ try {
1123
+ return dirname(findPatchPath(ctx))
1124
+ } catch {
1125
+ return null
1126
+ }
1127
+ }
1128
+
963
1129
  /** 读取补丁文件并扫描:停用块与 insert 行的 id。 */
964
1130
  async function readPatchState(patchPath) {
965
1131
  let text = ''
@@ -1041,14 +1207,14 @@ function summarizeReadme(text) {
1041
1207
  }
1042
1208
 
1043
1209
  /** 读取一个已加载插件的 package.json 元信息与 README 摘要。 */
1044
- async function readPluginDetails(moduleName, baseUrl) {
1210
+ async function readPluginDetails(moduleName, baseUrl, profileDir) {
1045
1211
  if (typeof moduleName !== 'string' || moduleName.startsWith('cordis:')) return { meta: null, readme: null }
1046
1212
  try {
1047
- const require = createRequire(baseUrl)
1048
- const pkgPath = require.resolve(`${moduleName}/package.json`)
1213
+ const pkgPath = resolvePackageJson(moduleName, baseDirOf(baseUrl), profileDir ?? null)
1214
+ if (pkgPath === null) throw new Error('not found')
1049
1215
  const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
1050
1216
  const meta = {
1051
- name: pkg.name ?? moduleName,
1217
+ name: moduleName,
1052
1218
  version: pkg.version ?? null,
1053
1219
  description: pkg.description ?? null,
1054
1220
  homepage: pkg.homepage ?? null,
@@ -3504,10 +3670,11 @@ async function maybeAutoAdaptCompat({ profileDir, packageName, syncedNames, patc
3504
3670
  let pkg = null
3505
3671
  let pkgDir = null
3506
3672
  try {
3507
- const require = createRequire(join(profileDir, 'package.json'))
3508
- const pkgPath = require.resolve(`${p.moduleName}/package.json`)
3509
- pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
3510
- pkgDir = dirname(pkgPath)
3673
+ const pkgPath = resolvePackageJson(p.moduleName, profileDir)
3674
+ if (pkgPath !== null) {
3675
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
3676
+ pkgDir = dirname(pkgPath)
3677
+ }
3511
3678
  } catch {}
3512
3679
  const version = typeof pkg?.version === 'string' ? pkg.version : null
3513
3680
  const changed = version !== null && version !== p.version
@@ -3541,8 +3708,8 @@ async function frameworkCompatReportFor(source, ctx, profileDir) {
3541
3708
  const isNpm = norm.startsWith('@') || !norm.includes('/')
3542
3709
  const installed = (() => {
3543
3710
  try {
3544
- const require = createRequire(join(profileDir, 'package.json'))
3545
- const pkgPath = require.resolve(`${norm}/package.json`)
3711
+ const pkgPath = resolvePackageJson(norm, profileDir)
3712
+ if (pkgPath === null) return null
3546
3713
  return JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? null
3547
3714
  } catch { return null }
3548
3715
  })()
@@ -3730,7 +3897,7 @@ async function runInstallJob(job, ctx) {
3730
3897
  let ghRepo = typeof job.repo === 'string' && job.repo !== '' && job.repo.includes('/') ? job.repo : null
3731
3898
  if (ghRepo === null) {
3732
3899
  try {
3733
- const meta = entryPkgMeta(name, ctx.baseUrl ?? 'file:///')
3900
+ const meta = entryPkgMeta(name, ctx.baseUrl ?? 'file:///', profileDirOf(ctx))
3734
3901
  const repoUrl = typeof meta?.repository === 'string' ? meta.repository : (meta?.repository && typeof meta.repository === 'object' ? meta.repository.url : null)
3735
3902
  const m = typeof repoUrl === 'string' ? repoUrl.replace(/^git\+/u, '').match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/u) : null
3736
3903
  if (m) ghRepo = m[1]
@@ -3941,6 +4108,17 @@ async function handle(ctx, req, res) {
3941
4108
  if (method === 'GET' && pathname === `${ROUTE_PREFIX}/state`) {
3942
4109
  const patchPath = findPatchPath(ctx)
3943
4110
  const patch = await readPatchState(patchPath)
4111
+ // 补丁安全自愈(核心行误禁用恢复 / 缺失模块行自动禁用)——每 2 分钟最多跑一次
4112
+ let patchHeal = null
4113
+ try {
4114
+ if (patchHealAt === null || Date.now() - patchHealAt > 120000) {
4115
+ patchHealAt = Date.now()
4116
+ patchHeal = await healPatchSafety(patchPath)
4117
+ } else {
4118
+ patchHeal = patchHealReport
4119
+ }
4120
+ if (patchHeal !== null) patchHealReport = patchHeal
4121
+ } catch {}
3944
4122
  const extraRows = await readExtraBundleRows(dirname(patchPath))
3945
4123
  const compatPending = readCompatPending()
3946
4124
  const compatPendingRows = new Set((compatPending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => p.rowId))
@@ -3952,7 +4130,7 @@ async function handle(ctx, req, res) {
3952
4130
  }
3953
4131
  } catch {}
3954
4132
  const entries = listEntries(ctx).map((entry) => {
3955
- const meta = entryPkgMeta(entry.moduleName, ctx.baseUrl ?? 'file:///')
4133
+ const meta = entryPkgMeta(entry.moduleName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx))
3956
4134
  return {
3957
4135
  ...entry,
3958
4136
  userDisabled: patch.disables.includes(entry.rowId),
@@ -3976,7 +4154,7 @@ async function handle(ctx, req, res) {
3976
4154
  const selfPkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'))
3977
4155
  selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
3978
4156
  } catch {}
3979
- sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null })) }, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
4157
+ sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0 }, compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null, checkNote: p.checkNote ?? null, check: p.check ?? null })) }, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
3980
4158
  return
3981
4159
  }
3982
4160
 
@@ -4143,7 +4321,7 @@ async function handle(ctx, req, res) {
4143
4321
  return
4144
4322
  }
4145
4323
  const moduleName = entry.options.name
4146
- const details = await readPluginDetails(moduleName, ctx.baseUrl ?? 'file:///')
4324
+ const details = await readPluginDetails(moduleName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx))
4147
4325
  sendJson(res, 200, { ok: true, entryId, rowId: rowIdOf(ctx, entryId), moduleName, ...details })
4148
4326
  return
4149
4327
  }
@@ -4178,11 +4356,25 @@ async function handle(ctx, req, res) {
4178
4356
  const compatPending = readCompatPending()
4179
4357
  const pend = (compatPending?.pending ?? []).find((p) => p.rowId === rowId && (p.status ?? 'pending') === 'pending')
4180
4358
  if (pend) {
4181
- sendError(res, 409, `「${rowId}」仍在框架升级适配门中(${compatPending.frameworkVersion ?? '?'})被强制禁用:请先点击「更新并适配」,更新完成并通过兼容校验后系统自动解锁`)
4359
+ sendError(res, 409, `「${rowId}」仍在框架升级适配门中(${compatPending.frameworkVersion ?? '?'})被强制禁用:请先点击「更新并适配 / 已适配,立即解锁」,通过兼容校验后系统自动解锁`)
4182
4360
  return
4183
4361
  }
4184
4362
  }
4185
4363
  const patchPath = findPatchPath(ctx)
4364
+ // 启用前冒烟检查(服务永不崩机制):第三方模块在独立子进程中试 import,
4365
+ // 失败(SyntaxError/缺失导出/模块缺失)即拒绝启用,杜绝「单行 import 失败→整个服务启动崩溃」
4366
+ if (enabled) {
4367
+ const moduleName = target?.options?.name
4368
+ if (typeof moduleName === 'string' && !moduleName.startsWith('cordis:') && !moduleName.startsWith('@deepseek-ai/')) {
4369
+ try {
4370
+ const probe = await probePluginImport(moduleName, dirname(patchPath))
4371
+ if (probe.ok !== true) {
4372
+ sendError(res, 409, `启用前冒烟检查未通过:模块加载失败(${probe.detail ?? '未知'})——该插件与当前框架不兼容或依赖缺失,已阻止启用(服务不会再被拖崩);请先「检测更新/更新并适配」其适配版`)
4373
+ return
4374
+ }
4375
+ } catch {}
4376
+ }
4377
+ }
4186
4378
  const result = enabled
4187
4379
  ? await enableEntry(patchPath, rowId)
4188
4380
  : await disableEntry(patchPath, rowId)
@@ -4218,14 +4410,18 @@ async function handle(ctx, req, res) {
4218
4410
  const owners = await readExtraBundleOwners(profileDir)
4219
4411
  const ownerBundle = owners.get(rowId) ?? owners.get(moduleName)
4220
4412
  if (ownerBundle !== undefined) {
4221
- await removeBundleFromManifest(profileDir, ownerBundle)
4222
- let uninstallError = null
4223
- try {
4224
- await pnpmRemove(profileDir, ownerBundle)
4225
- } catch (error) {
4226
- uninstallError = error instanceof Error ? error.message : String(error)
4227
- }
4228
- sendJson(res, 200, { ok: true, removed: 'bundle', packageName: ownerBundle, restart: true, uninstallError })
4413
+ // 2026-09-04 事故教训:删除聚合包子路径行(如 @linxin666/dsh-web-all/plugin-manager)时
4414
+ // 曾把整个 bundle 从清单移除,pnpm 卸载失败(corepack 报错)后重启,全家桶整体消失。
4415
+ // 现改为:任何 bundle 行的「删除」= 仅停用该行(patch disabled:true),bundle 清单不动,
4416
+ // 之后随时可「启用」恢复;整体卸载请走包管理器。
4417
+ await disableEntry(patchPath, rowId)
4418
+ sendJson(res, 200, {
4419
+ ok: true,
4420
+ removed: 'row',
4421
+ packageName: moduleName,
4422
+ restart: false,
4423
+ note: '该行来自聚合包 ' + ownerBundle + ',已仅停用本行(bundle 保留,可随时启用恢复);整体卸载请用包管理器执行 pnpm remove ' + ownerBundle,
4424
+ })
4229
4425
  return
4230
4426
  }
4231
4427
  if (!patch.inserts.includes(rowId)) {
@@ -4664,11 +4860,105 @@ async function handle(ctx, req, res) {
4664
4860
  return
4665
4861
  }
4666
4862
 
4863
+ if (pathname === `${ROUTE_PREFIX}/adapt-unlock`) {
4864
+ // 待适配行「已适配,立即解锁」:无需等新版本——对已装模块重跑源码扫描,通过即移除禁用块。
4865
+ const rowId = typeof body.rowId === 'string' ? body.rowId : ''
4866
+ if (!/^[A-Za-z0-9_:.-]{1,80}$/u.test(rowId)) {
4867
+ sendError(res, 400, 'rowId 无效')
4868
+ return
4869
+ }
4870
+ const pending = readCompatPending()
4871
+ const entry = (pending?.pending ?? []).find((p) => p.rowId === rowId && (p.status ?? 'pending') === 'pending')
4872
+ if (entry === undefined) {
4873
+ sendError(res, 404, '该行不在适配门待适配清单中(或已解锁)')
4874
+ return
4875
+ }
4876
+ const patchPath = findPatchPath(ctx)
4877
+ const profileDir = dirname(patchPath)
4878
+ let pkg = null
4879
+ let pkgDir = null
4880
+ let version = null
4881
+ try {
4882
+ const require = createRequire(join(profileDir, 'package.json'))
4883
+ const pkgPath = resolvePackageJson(entry.moduleName, profileDir)
4884
+ if (pkgPath !== null) {
4885
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
4886
+ version = typeof pkg?.version === 'string' ? pkg.version : null
4887
+ pkgDir = dirname(pkgPath)
4888
+ }
4889
+ } catch {}
4890
+ const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : '?'
4891
+ const check = pkg !== null ? checkPluginFrameworkCompat(pkg, fwVer, pkgDir) : { decision: 'unknown', reason: '无法读取包信息' }
4892
+ if (check.decision === 'fail') {
4893
+ sendError(res, 409, `适配校验未通过:${check.reason}`)
4894
+ return
4895
+ }
4896
+ await removeDisableBlock(patchPath, rowId)
4897
+ entry.status = 'adopted'
4898
+ entry.adoptedAt = Date.now()
4899
+ entry.adoptedVersion = version
4900
+ entry.adoptedFramework = fwVer
4901
+ entry.check = check.decision
4902
+ entry.checkNote = check.reason ?? null
4903
+ writeCompatPending(pending)
4904
+ sendJson(res, 200, { ok: true, rowId, adopted: true, check: check.decision, note: check.reason ?? null })
4905
+ return
4906
+ }
4907
+
4908
+ if (pathname === `${ROUTE_PREFIX}/adapt-unlock-all`) {
4909
+ // 全家桶「一键启用已适配」:对家庭内所有待适配行批量重跑源码扫描,通过的全部解锁,未通过保留禁用。
4910
+ const root = typeof body.root === 'string' ? body.root : ''
4911
+ if (!/^(@[a-z0-9-][a-z0-9-._~]*\/)?[a-z0-9-][a-z0-9-._~]*$/u.test(root) || root.length > 214) {
4912
+ sendError(res, 400, 'root 无效')
4913
+ return
4914
+ }
4915
+ const pending = readCompatPending()
4916
+ const patchPath = findPatchPath(ctx)
4917
+ const profileDir = dirname(patchPath)
4918
+ const targets = (pending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending' && (p.moduleName === root || String(p.moduleName).startsWith(root + '/')))
4919
+ if (targets.length === 0) {
4920
+ sendError(res, 404, '该全家桶没有待适配行(或已全部解锁)')
4921
+ return
4922
+ }
4923
+ const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : '?'
4924
+ const unlocked = []
4925
+ const kept = []
4926
+ for (const p of targets) {
4927
+ let pkg = null
4928
+ let pkgDir = null
4929
+ let version = null
4930
+ try {
4931
+ const pkgPath = resolvePackageJson(p.moduleName, profileDir)
4932
+ if (pkgPath !== null) {
4933
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
4934
+ version = typeof pkg?.version === 'string' ? pkg.version : null
4935
+ pkgDir = dirname(pkgPath)
4936
+ }
4937
+ } catch {}
4938
+ const check = pkg !== null ? checkPluginFrameworkCompat(pkg, fwVer, pkgDir) : { decision: 'fail', reason: '无法读取包信息' }
4939
+ if (check.decision === 'fail') {
4940
+ kept.push({ rowId: p.rowId, reason: check.reason })
4941
+ continue
4942
+ }
4943
+ await removeDisableBlock(patchPath, p.rowId)
4944
+ p.status = 'adopted'
4945
+ p.adoptedAt = Date.now()
4946
+ p.adoptedVersion = version
4947
+ p.adoptedFramework = fwVer
4948
+ p.check = check.decision
4949
+ p.checkNote = check.reason ?? null
4950
+ unlocked.push(p.rowId)
4951
+ }
4952
+ writeCompatPending(pending)
4953
+ sendJson(res, 200, { ok: true, root, unlocked, kept })
4954
+ return
4955
+ }
4956
+
4667
4957
  if (pathname === `${ROUTE_PREFIX}/check-update`) {
4668
4958
  // 检测已安装插件是否有新版本:curl registry 元数据取 dist-tags.latest(node 网络黑洞时 curl 可用)。
4669
4959
  // 聚合包(有 dependencies)额外对比子包版本:声明版本 vs 本地 node_modules 实际版本,
4670
4960
  // 返回 depsOutdated 提示"更新本包需同步子包",避免半更新混搭导致启动冲突。
4671
- const packageName = typeof body.packageName === 'string' ? body.packageName.trim() : ''
4961
+ const packageName = packageNameOf(typeof body.packageName === 'string' ? body.packageName.trim() : '')
4672
4962
  if (!packageName) {
4673
4963
  sendError(res, 400, 'packageName 不能为空')
4674
4964
  return
@@ -4715,7 +5005,7 @@ async function handle(ctx, req, res) {
4715
5005
  // 覆盖 dsh-plugin-console(本面板)这类"源码在 GitHub、npm 上不存在"的宿主插件。
4716
5006
  let fallbackError = err instanceof Error ? err.message : String(err)
4717
5007
  try {
4718
- const meta = entryPkgMeta(packageName, ctx.baseUrl ?? 'file:///')
5008
+ const meta = entryPkgMeta(packageName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx))
4719
5009
  const repo = typeof meta?.repository === 'string'
4720
5010
  ? meta.repository.replace(/^git\+/u, '').replace(/\.git$/u, '')
4721
5011
  : (meta?.repository && typeof meta.repository === 'object' ? meta.repository.url : null)
@@ -4764,7 +5054,29 @@ async function handle(ctx, req, res) {
4764
5054
  error = `npm registry 查询失败且 GitHub 回退不可用(${fallbackError};${fbErr instanceof Error ? fbErr.message : String(fbErr)})`
4765
5055
  }
4766
5056
  }
4767
- sendJson(res, 200, { ok: true, packageName, latest, next, beta, depsOutdated, error, source })
5057
+ // migrate 换名检测(2026-09-04 缺陷修复):本地包声明 dsh.migrate.to 时查目标包最新版/引擎声明,
5058
+ // 识别「项目已改名/迁移发布」的更新(如 @linxin666/dsh-web-ui-all → @linxin666/dsh-web-all 0.3.14)
5059
+ let migrate = null
5060
+ try {
5061
+ const localPath = resolvePackageJson(packageName, profileDir)
5062
+ if (localPath !== null) {
5063
+ const localPkg = JSON.parse(readFileSync(localPath, 'utf8'))
5064
+ const to = typeof localPkg.dsh?.migrate?.to === 'string' ? localPkg.dsh.migrate.to : null
5065
+ if (to !== null && to !== '' && to !== packageName) {
5066
+ const encTo = to.startsWith('@')
5067
+ ? `@${encodeURIComponent(to.slice(1).split('/')[0])}%2f${encodeURIComponent(to.split('/').slice(1).join('/'))}`
5068
+ : encodeURIComponent(to)
5069
+ const meta = await fetchJsonUrl(`https://registry.npmmirror.com/${encTo}`)
5070
+ const toLatest = meta?.['dist-tags']?.latest ?? meta?.['dist-tags']?.next ?? null
5071
+ const toPkg = toLatest !== null ? (meta?.versions?.[toLatest] ?? null) : null
5072
+ const engine = toPkg?.dsh?.engines?.dsh ?? toPkg?.engines?.dsh ?? null
5073
+ const fwVer = currentFrameworkVersion(ctx)
5074
+ const compatible = fwVer !== null && (engine === null || semverRangeMatchLoose(fwVer, engine))
5075
+ migrate = { to, latest: toLatest, engine, compatible }
5076
+ }
5077
+ }
5078
+ } catch {}
5079
+ sendJson(res, 200, { ok: true, packageName, latest, next, beta, depsOutdated, error, source, migrate })
4768
5080
  return
4769
5081
  }
4770
5082
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.3.26",
3
+ "version": "0.3.28",
4
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
5
  "repository": {
6
6
  "type": "git",