@noob-stupid/dsh-plugin-console 0.3.24-beta.2 → 0.3.25

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
@@ -200,6 +200,123 @@ function entryPkgMeta(moduleName, baseUrl) {
200
200
  return meta
201
201
  }
202
202
 
203
+ /** 最小 semver:解析(含 prerelease/build)。 */
204
+ function parseSemverText(v) {
205
+ const m = String(v ?? '').trim().replace(/^v/u, '').match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u)
206
+ if (!m) return null
207
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] ?? null }
208
+ }
209
+ function compareSemverText(a, b) {
210
+ if (a.major !== b.major) return a.major - b.major
211
+ if (a.minor !== b.minor) return a.minor - b.minor
212
+ if (a.patch !== b.patch) return a.patch - b.patch
213
+ if (a.pre === null && b.pre === null) return 0
214
+ if (a.pre === null) return 1
215
+ if (b.pre === null) return -1
216
+ const pa = a.pre.split('.')
217
+ const pb = b.pre.split('.')
218
+ const len = Math.max(pa.length, pb.length)
219
+ for (let i = 0; i < len; i += 1) {
220
+ const xa = pa[i]
221
+ const xb = pb[i]
222
+ if (xa === undefined) return -1
223
+ if (xb === undefined) return 1
224
+ const na = /^\d+$/u.test(xa)
225
+ const nb = /^\d+$/u.test(xb)
226
+ if (na && nb) { const d = Number(xa) - Number(xb); if (d !== 0) return d; continue }
227
+ if (na) return -1
228
+ if (nb) return 1
229
+ const d = xa < xb ? -1 : xa > xb ? 1 : 0
230
+ if (d !== 0) return d
231
+ }
232
+ return 0
233
+ }
234
+ /** 单段范围匹配(^ ~ >= <= > < = 精确;返回 true = 满足)。 */
235
+ function semverCompareOne(v, raw) {
236
+ const rText = String(raw).trim()
237
+ const m = rText.match(/^(\^|~|>=|<=|>|<|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?/u)
238
+ if (!m) return true
239
+ const op = m[1] ?? ''
240
+ const base = { major: Number(m[2]), minor: m[3] === undefined ? 0 : Number(m[3]), patch: m[4] === undefined ? 0 : Number(m[4]), pre: m[5] ?? null }
241
+ const hasMinor = m[3] !== undefined
242
+ const hasPatch = m[4] !== undefined
243
+ // npm 语义:prerelease 版本只与同 [major,minor,patch] 且范围带 prerelease 的声明匹配
244
+ const preAllowed = v.pre === null || (v.major === base.major && v.minor === base.minor && v.patch === base.patch && base.pre !== null)
245
+ switch (op) {
246
+ case '':
247
+ case '=':
248
+ return preAllowed && compareSemverText(v, base) === 0
249
+ case '<':
250
+ return preAllowed && compareSemverText(v, base) < 0
251
+ case '<=':
252
+ return preAllowed && compareSemverText(v, base) <= 0
253
+ case '>':
254
+ return preAllowed && compareSemverText(v, base) > 0
255
+ case '>=':
256
+ return preAllowed && compareSemverText(v, base) >= 0
257
+ case '~': {
258
+ if (!preAllowed) return false
259
+ if (!hasMinor) return v.major === base.major
260
+ if (!hasPatch) return v.major === base.major && v.minor === base.minor
261
+ return compareSemverText(v, base) >= 0 && !(v.major === base.major && v.minor > base.minor) && v.major === base.major
262
+ }
263
+ case '^': {
264
+ if (!preAllowed) return false
265
+ const upper = base.major === 0
266
+ ? (base.minor === 0 ? { major: 0, minor: 0, patch: base.patch + 1, pre: null } : { major: 0, minor: base.minor + 1, patch: 0, pre: null })
267
+ : { major: base.major + 1, minor: 0, patch: 0, pre: null }
268
+ return compareSemverText(v, base) >= 0 && compareSemverText(v, upper) < 0
269
+ }
270
+ default:
271
+ return true
272
+ }
273
+ }
274
+ /** 范围匹配:支持多个段以逗号/空白分隔(AND)与 `||`(OR)。 */
275
+ function semverRangeMatch(versionText, rangeText) {
276
+ const v = parseSemverText(versionText)
277
+ if (!v) return false
278
+ const alternatives = String(rangeText ?? '').split(/\s*\|\|\s*/u).filter(Boolean)
279
+ if (alternatives.length === 0) return false
280
+ return alternatives.some((alt) => {
281
+ const parts = alt.split(/\s*[,\s]\s*/u).filter(Boolean)
282
+ return parts.length > 0 && parts.every((part) => semverCompareOne(v, part))
283
+ })
284
+ }
285
+ /**
286
+ * 宽松声明匹配(仅用于插件「显式声明兼容范围」):prerelease 版本按同线发布版判定——
287
+ * 作者声明 `>=0.1.2` 即代表支持 0.1.2 线,框架运行在 0.1.2-rc.1 应判定兼容。
288
+ */
289
+ function semverRangeMatchLoose(versionText, rangeText) {
290
+ if (semverRangeMatch(versionText, rangeText)) return true
291
+ const v = parseSemverText(versionText)
292
+ if (v === null || v.pre === null) return false
293
+ return semverRangeMatch(`${v.major}.${v.minor}.${v.patch}`, rangeText)
294
+ }
295
+
296
+ /**
297
+ * 插件-框架兼容校验(框架升级适配门):
298
+ * 1) 显式兼容声明(dsh.engines.framework / dsh.compat.framework / engines.dsh):以声明为准;
299
+ * 2) 否则扫描依赖/peerDeps 的 @deepseek-ai/*:任一范围明确不含框架版本 → fail;
300
+ * 3) 无声明且依赖全部满足 → unknown(新版变动不大;由调用方在「版本已变化」前提下降级为放行)。
301
+ * 返回 { decision: 'pass'|'fail'|'unknown', reason }。
302
+ */
303
+ function checkPluginFrameworkCompat(pkg, frameworkVersion) {
304
+ const declared = pkg?.dsh?.engines?.framework ?? pkg?.dsh?.compat?.framework ?? pkg?.engines?.dsh ?? null
305
+ if (typeof declared === 'string' && declared !== '') {
306
+ return semverRangeMatchLoose(frameworkVersion, declared)
307
+ ? { decision: 'pass', reason: `声明兼容范围 ${declared} 满足框架 ${frameworkVersion}` }
308
+ : { decision: 'fail', reason: `声明兼容范围 ${declared} 不满足当前框架 ${frameworkVersion}` }
309
+ }
310
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.peerDependencies ?? {}), ...(pkg?.optionalDependencies ?? {}) }
311
+ const hits = Object.entries(deps).filter(([name]) => /^@deepseek-ai\//u.test(name))
312
+ const failing = hits.filter(([, range]) => typeof range === 'string' && range !== '' && !semverRangeMatch(frameworkVersion, range))
313
+ if (failing.length > 0) {
314
+ return { decision: 'fail', reason: `依赖声明不满足:${failing.map(([n, r]) => `${n}@${r}`).join('、')}(当前框架 ${frameworkVersion})` }
315
+ }
316
+ if (hits.length === 0) return { decision: 'unknown', reason: '未声明对 @deepseek-ai/* 的依赖,无法从声明判定(版本已更新时放行)' }
317
+ return { decision: 'unknown', reason: '依赖范围包含当前框架版本但未显式声明兼容(版本已更新时放行)' }
318
+ }
319
+
203
320
  /** 读取用户额外 bundle(非官方模板)的补丁插入行 id 与包名,用于"额外插件"判定。 */
204
321
  async function readExtraBundleRows(profileDir) {
205
322
  const rows = new Set()
@@ -683,6 +800,18 @@ async function detectCompat(baseUrl) {
683
800
 
684
801
  const FRAMEWORK_STATE_FILE = () => join(dshHome(), 'plugin-console', 'framework-state.json')
685
802
  const FRAMEWORK_BACKUP_ROOT = () => join(dshHome(), 'plugin-console', 'framework-backups')
803
+ const COMPAT_PENDING_FILE = () => join(dshHome(), 'plugin-console', 'compat-pending.json')
804
+
805
+ /** 读取框架升级适配门清单(compat-pending.json);损坏/缺失返回 null。 */
806
+ function readCompatPending() {
807
+ try { return JSON.parse(readFileSync(COMPAT_PENDING_FILE(), 'utf8')) } catch { return null }
808
+ }
809
+ function writeCompatPending(payload) {
810
+ try {
811
+ mkdirSync(dirname(COMPAT_PENDING_FILE()), { recursive: true })
812
+ writeFileSync(COMPAT_PENDING_FILE(), JSON.stringify(payload, null, 2), 'utf8')
813
+ } catch {}
814
+ }
686
815
 
687
816
  /** 定位 @deepseek-ai/dsh-app-boot(与 @deepseek-ai/dsh 同级)。 */
688
817
  function locateAppBootFile(baseUrl) {
@@ -1838,6 +1967,72 @@ const aiJobs = new Map()
1838
1967
  let aiJobSeq = 0
1839
1968
  /** 组件注册表(服务型组件:启动/停止/状态控制按钮的数据源)。 */
1840
1969
  const COMPONENTS_FILE = join(homedir(), '.dsh', 'plugin-console', 'components.json')
1970
+ /** AI 赋能任务持久化文件(DSH 重启不丢计划/结果;running 任务重启后标记中断)。 */
1971
+ const AI_JOBS_FILE = join(homedir(), '.dsh', 'plugin-console', 'ai-jobs.json')
1972
+ /** 仓库落地根目录(可配置,默认 ~/.dsh/repos)。 */
1973
+ let reposDirCache = null
1974
+ const REPO_LAND_CONF = join(homedir(), '.dsh', 'plugin-console', 'repo-land.json')
1975
+ function getReposDir() {
1976
+ if (reposDirCache !== null) return reposDirCache
1977
+ try {
1978
+ if (existsSync(REPO_LAND_CONF)) {
1979
+ const conf = JSON.parse(readFileSync(REPO_LAND_CONF, 'utf8'))
1980
+ if (typeof conf.dir === 'string' && conf.dir.trim() !== '') {
1981
+ reposDirCache = conf.dir.trim()
1982
+ return reposDirCache
1983
+ }
1984
+ }
1985
+ } catch {}
1986
+ reposDirCache = join(homedir(), '.dsh', 'repos')
1987
+ return reposDirCache
1988
+ }
1989
+ function setReposDir(dir) {
1990
+ reposDirCache = String(dir ?? '').trim()
1991
+ mkdirSync(dirname(REPO_LAND_CONF), { recursive: true })
1992
+ writeFileSync(REPO_LAND_CONF, JSON.stringify({ dir: reposDirCache }, null, 2), 'utf8')
1993
+ return reposDirCache
1994
+ }
1995
+ /** 已落地仓库列表:扫描 dir 下两级目录(owner/name 下含 .git)。 */
1996
+ function listLandedRepos() {
1997
+ const dir = getReposDir()
1998
+ const out = []
1999
+ try {
2000
+ if (!existsSync(dir)) return out
2001
+ for (const owner of readdirSync(dir, { withFileTypes: true })) {
2002
+ if (!owner.isDirectory() || owner.name.startsWith('.')) continue
2003
+ const ownerDir = join(dir, owner.name)
2004
+ for (const entry of readdirSync(ownerDir, { withFileTypes: true })) {
2005
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
2006
+ const repoDir = join(ownerDir, entry.name)
2007
+ if (existsSync(join(repoDir, '.git'))) {
2008
+ out.push({ owner: owner.name, name: entry.name, repo: `${owner.name}/${entry.name}`, path: repoDir })
2009
+ }
2010
+ }
2011
+ }
2012
+ } catch {}
2013
+ return out
2014
+ }
2015
+ function saveAiJobs() {
2016
+ try {
2017
+ mkdirSync(dirname(AI_JOBS_FILE), { recursive: true })
2018
+ const arr = [...aiJobs.values()].map((j) => ({ id: j.id, source: j.source, status: j.status, stage: j.stage, plan: j.plan ?? null, logText: j.logText ?? '', stepStates: j.stepStates ?? [], progress: j.progress ?? null, error: j.error ?? null, createdAt: j.createdAt, finishedAt: j.finishedAt ?? null }))
2019
+ writeFileSync(AI_JOBS_FILE, JSON.stringify(arr, null, 2), 'utf8')
2020
+ } catch {}
2021
+ }
2022
+ function loadAiJobs() {
2023
+ try {
2024
+ if (!existsSync(AI_JOBS_FILE)) return
2025
+ const arr = JSON.parse(readFileSync(AI_JOBS_FILE, 'utf8'))
2026
+ for (const j of arr) {
2027
+ if (!j || typeof j.id !== 'string') continue
2028
+ let job = j
2029
+ if (job.status === 'running') {
2030
+ job = { ...job, status: 'failed', error: 'DSH 重启,任务中断(可重新发起)', finishedAt: Date.now() }
2031
+ }
2032
+ aiJobs.set(job.id, job)
2033
+ }
2034
+ } catch {}
2035
+ }
1841
2036
  /** 静态插件索引内存缓存(/market-index 用)。 */
1842
2037
  let marketIndexCache = null
1843
2038
 
@@ -1859,6 +2054,7 @@ function installJobView(job) {
1859
2054
  source: job.source ?? 'github',
1860
2055
  curlNote: job.curlNote ?? null,
1861
2056
  bundleNote: job.bundleNote ?? null,
2057
+ compatNote: job.compatNote ?? null,
1862
2058
  kind: job.kind ?? 'plugin',
1863
2059
  skillName: job.skillName ?? null,
1864
2060
  skillDir: job.skillDir ?? null,
@@ -2453,6 +2649,8 @@ function aiJobView(job) {
2453
2649
  logText: (job.logText ?? '').slice(-6000),
2454
2650
  createdAt: job.createdAt,
2455
2651
  finishedAt: job.finishedAt ?? null,
2652
+ workspace: job.parentCwd ?? null,
2653
+ frameworkCheck: job.frameworkCheck ?? null,
2456
2654
  }
2457
2655
  }
2458
2656
 
@@ -2588,6 +2786,7 @@ function isAllowedWritePath(p, profileDir) {
2588
2786
  norm(homedir()) + '/.openviking',
2589
2787
  norm(homedir()) + '/.cache/openviking',
2590
2788
  norm(homedir()) + '/.local/share/openviking',
2789
+ norm(homedir()) + '/.dsh/repos',
2591
2790
  norm(profileDir),
2592
2791
  'D:/OpenVikingData',
2593
2792
  ]
@@ -2611,7 +2810,16 @@ function jobLog(job, line) {
2611
2810
 
2612
2811
  /** 已知组件内置模板:跳过 AI 调研,直接产出经过验证的部署计划(当前仅 OpenViking)。 */
2613
2812
  function builtinPlanFor(source) {
2614
- if (!/openviking/i.test(source)) return null
2813
+ // 精确匹配 OpenViking 服务器本体(npm/pip 包名 openviking 或 volcengine/OpenViking 仓库),
2814
+ // 防止宽泛子串匹配误命中 openclaw_openviking_skill 之类的第三方包(曾实际发生误判)。
2815
+ const norm = String(source).trim().toLowerCase()
2816
+ .replace(/^https?:\/\//u, '')
2817
+ .replace(/^www\./u, '')
2818
+ .replace(/^github\.com\//u, '')
2819
+ .replace(/^raw\.githubusercontent\.com\//u, '')
2820
+ .replace(/\.git$/u, '')
2821
+ .replace(/\/$/u, '')
2822
+ if (norm !== 'openviking' && norm !== 'volcengine/openviking') return null
2615
2823
  const home = homedir()
2616
2824
  const storage = /^[\x00-\x7F]+$/u.test(home) ? `${home}/.openviking/data` : 'D:/OpenVikingData'
2617
2825
  const vlm = resolveVlmForOpenViking()
@@ -2691,6 +2899,9 @@ async function aiEmpowerPlan(job, ctx, profileDir) {
2691
2899
  '你是 DSH 插件控制台挂载的「AI 赋能」部署规划器。用户想把下面的组件完整部署到本机。',
2692
2900
  `目标来源:${job.source}`,
2693
2901
  '',
2902
+ '## 框架适配检测(控制台权威结果,规划时必须引用并向用户解释)',
2903
+ frameworkCheckPromptText(job.frameworkCheck),
2904
+ '',
2694
2905
  '【任务】只做只读调研与规划,禁止执行任何安装/写文件/启动服务/杀进程命令(curl 的 GET 抓取、gh api 查询、读取文件是允许的)。',
2695
2906
  '调研材料:npm README / GitHub README / docs/ 目录 / package.json / install 说明。',
2696
2907
  '全部调研完成后,输出且只输出一个 ```json ... ``` 代码块,内容为部署计划;代码块之外不要输出任何文字。',
@@ -2732,7 +2943,14 @@ async function aiEmpowerPlan(job, ctx, profileDir) {
2732
2943
  try {
2733
2944
  const agents = ctx.get('agents')
2734
2945
  const list = typeof agents?.roots === 'function' ? agents.roots() : (typeof agents?.list === 'function' ? agents.list() : [])
2735
- parent = list[0] ?? null
2946
+ // 父代理选择:优先 cwd 与 DSH 服务工作区一致的会话(子代理 cwd 继承父会话),
2947
+ // 避免多工作区/多会话时借到别的 workspace 的会话导致调研错位
2948
+ const svcCwd = String(process.cwd() ?? '').replace(/[\\/]+$/u, '')
2949
+ const cwdOf = (a) => {
2950
+ try { return String(a?.session?.header?.cwd ?? a?.cwd ?? '').replace(/[\\/]+$/u, '') } catch { return '' }
2951
+ }
2952
+ parent = list.find((a) => cwdOf(a) !== '' && cwdOf(a) === svcCwd) ?? list[0] ?? null
2953
+ job.parentCwd = parent ? cwdOf(parent) : null
2736
2954
  } catch {}
2737
2955
  if (parent === null) {
2738
2956
  job.status = 'failed'
@@ -2787,6 +3005,7 @@ async function aiEmpowerPlan(job, ctx, profileDir) {
2787
3005
  job.error = `AI 赋能规划异常:${error instanceof Error ? error.message : String(error)}`
2788
3006
  }
2789
3007
  job.finishedAt = Date.now()
3008
+ saveAiJobs()
2790
3009
  }
2791
3010
 
2792
3011
  /** 从子代理输出中提取 ```json ... ``` 计划。 */
@@ -2846,6 +3065,7 @@ async function aiEmpowerExecute(job, ctx, profileDir, selected) {
2846
3065
  job.progress = { done, total: chosenList.length }
2847
3066
  job.finishedAt = Date.now()
2848
3067
  jobLog(job, job.status === 'done' ? '🎉 AI 赋能部署完成' : '⛔ AI 赋能部署中止')
3068
+ saveAiJobs()
2849
3069
  }
2850
3070
 
2851
3071
  /** 单步执行器:动作白名单 + 路径白名单 + 破坏性命令拦截。 */
@@ -3032,7 +3252,23 @@ async function compStatus(id) {
3032
3252
  }
3033
3253
  }
3034
3254
  const running = Boolean(record.pid && processAlive(record.pid)) || healthy === true
3035
- return { id, name: record.name, running, healthy, pid: record.pid ?? null, port: record.port ?? null }
3255
+ return { id, name: record.name, running, healthy, pid: record.pid ?? null, port: record.port ?? null, autoStart: record.autoStart === true }
3256
+ }
3257
+
3258
+ /** DSH 启动时自动拉起标记 autoStart 的组件(幂等:已健康/已运行则跳过)。 */
3259
+ async function autostartComponents() {
3260
+ for (const c of findComponents()) {
3261
+ if (c.autoStart !== true) continue
3262
+ if (c.healthUrl) {
3263
+ try {
3264
+ const res = await fetch(c.healthUrl, { signal: AbortSignal.timeout(2500) })
3265
+ if (res.ok) continue
3266
+ } catch {}
3267
+ } else if (c.pid && processAlive(c.pid)) {
3268
+ continue
3269
+ }
3270
+ try { await compStart(c.id) } catch {}
3271
+ }
3036
3272
  }
3037
3273
 
3038
3274
  function processAlive(pid) {
@@ -3072,6 +3308,188 @@ async function syncAggregateSubpackageVersions(profileDir, packageName, registri
3072
3308
  return updated
3073
3309
  }
3074
3310
 
3311
+ /** 当前运行框架版本(@deepseek-ai/dsh package.json)。 */
3312
+ function currentFrameworkVersion(ctx) {
3313
+ try {
3314
+ const require = createRequire(ctx?.baseUrl ?? 'file:///')
3315
+ const pkgPath = require.resolve('@deepseek-ai/dsh/package.json')
3316
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
3317
+ return typeof pkg.version === 'string' ? pkg.version : null
3318
+ } catch {
3319
+ return null
3320
+ }
3321
+ }
3322
+
3323
+ /** 定位框架安装根(顶层 node_modules):优先运行进程入口,其次从包目录上溯找含 .pnpm 的 node_modules。 */
3324
+ function resolveFrameworkRootNodeModules(fromDir) {
3325
+ try {
3326
+ const entry = process.argv[1]
3327
+ if (typeof entry === 'string' && /bin\.js$/u.test(entry)) {
3328
+ const nm = dirname(dirname(entry))
3329
+ if (existsSync(join(nm, '.pnpm')) && existsSync(join(nm, '@deepseek-ai'))) return nm
3330
+ }
3331
+ } catch {}
3332
+ let dir = typeof fromDir === 'string' && fromDir !== '' ? fromDir : null
3333
+ for (let i = 0; i < 24 && dir !== null; i += 1) {
3334
+ if (basename(dir) === 'node_modules' && existsSync(join(dir, '.pnpm'))) return dir
3335
+ const parent = dirname(dir)
3336
+ if (parent === dir) break
3337
+ dir = parent
3338
+ }
3339
+ return null
3340
+ }
3341
+
3342
+ /**
3343
+ * 框架全树 checkpoint(可靠回滚点):镜像 .pnpm 中所有 @deepseek-ai 条目「自包」内容 +
3344
+ * 顶层 @deepseek-ai scope + lock.yaml。只镜像自包、不跟随依赖 junction,避免重复拷贝;
3345
+ * 恢复时按同路径写回 .pnpm 条目即可让整个依赖世界回到升级前。
3346
+ */
3347
+ function checkpointFrameworkTree(fwRoot, destRoot) {
3348
+ const pnpmRoot = join(fwRoot, '.pnpm')
3349
+ const dest = join(destRoot, 'fw-tree', String(Date.now()))
3350
+ mkdirSync(dest, { recursive: true })
3351
+ let mirrored = 0
3352
+ const selfNameOf = (entryName) => entryName.slice('@deepseek-ai+'.length).split('@')[0]
3353
+ for (const entry of readdirSync(pnpmRoot, { withFileTypes: true })) {
3354
+ if (!entry.isDirectory() || !entry.name.startsWith('@deepseek-ai+')) continue
3355
+ const name = selfNameOf(entry.name)
3356
+ const selfDir = join(pnpmRoot, entry.name, 'node_modules', '@deepseek-ai', name)
3357
+ if (!existsSync(join(selfDir, 'package.json'))) continue
3358
+ copyTree(selfDir, join(dest, '.pnpm', entry.name, 'node_modules', '@deepseek-ai', name))
3359
+ mirrored += 1
3360
+ }
3361
+ const topScope = join(fwRoot, '@deepseek-ai')
3362
+ if (existsSync(topScope)) copyTree(topScope, join(dest, 'top-@deepseek-ai'))
3363
+ try { copyFileSync(join(pnpmRoot, 'lock.yaml'), join(dest, 'lock.yaml')) } catch {}
3364
+ try { copyFileSync(join(fwRoot, 'package.json'), join(dest, 'fw-package.json')) } catch {}
3365
+ return { dest, mirrored }
3366
+ }
3367
+
3368
+ /** 移除补丁中的单行 disabled/forced 覆盖块(兼容门解锁用)。 */
3369
+ function removeDisableBlock(patchPath, rowId) {
3370
+ return queuedWrite(async () => {
3371
+ const { text } = await readPatchState(patchPath)
3372
+ const overrideRe = new RegExp(`^- id: ${escapeRegExp(rowId)}\\s*\\r?\\n {2}disabled: (true|false)\\s*\\r?\\n`, 'mu')
3373
+ const next = text.replace(overrideRe, '')
3374
+ if (next !== text) await writeFile(patchPath, sanitizePatchText(next), 'utf8')
3375
+ })
3376
+ }
3377
+
3378
+ /** 从「子包版本对齐」记录解析纯包名(如 '@linxin666/dsh-pet@0.2.1(新装)')。 */
3379
+ function syncNameFromNote(note) {
3380
+ const m = String(note ?? '').match(/^((?:@[^/]+\/)?[^@]+)/u)
3381
+ return m ? m[1] : null
3382
+ }
3383
+
3384
+ /**
3385
+ * 框架升级适配门:安装/更新完成后自动校验兼容性,
3386
+ * 通过(版本已变化 + 未在声明/依赖层面明确不兼容)则移除补丁禁用块并解锁启用。
3387
+ */
3388
+ async function maybeAutoAdaptCompat({ profileDir, packageName, syncedNames, patchPath, ctx }) {
3389
+ const pending = readCompatPending()
3390
+ if (!pending || !Array.isArray(pending.pending)) return { ran: false }
3391
+ const interested = new Set([packageName, ...(syncedNames ?? [])].filter((n) => n !== null))
3392
+ const targets = pending.pending.filter((p) => (p.status ?? 'pending') === 'pending' && interested.has(p.moduleName))
3393
+ if (targets.length === 0) return { ran: false }
3394
+ const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : '?'
3395
+ const adopted = []
3396
+ const kept = []
3397
+ for (const p of targets) {
3398
+ let pkg = null
3399
+ try {
3400
+ const require = createRequire(join(profileDir, 'package.json'))
3401
+ const pkgPath = require.resolve(`${p.moduleName}/package.json`)
3402
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
3403
+ } catch {}
3404
+ const version = typeof pkg?.version === 'string' ? pkg.version : null
3405
+ const changed = version !== null && version !== p.version
3406
+ const check = pkg !== null ? checkPluginFrameworkCompat(pkg, fwVer) : { decision: 'unknown', reason: '无法读取包信息' }
3407
+ if (changed && check.decision !== 'fail') {
3408
+ try { await removeDisableBlock(patchPath, p.rowId) } catch {}
3409
+ p.status = 'adopted'
3410
+ p.adoptedAt = Date.now()
3411
+ p.adoptedVersion = version
3412
+ p.adoptedFramework = fwVer
3413
+ p.check = check.decision
3414
+ p.checkNote = check.reason ?? null
3415
+ adopted.push({ rowId: p.rowId, moduleName: p.moduleName, version, check: check.decision })
3416
+ } else {
3417
+ kept.push({ rowId: p.rowId, moduleName: p.moduleName, reason: !changed ? '版本未变化(无更新可适配)' : (check.reason ?? '兼容校验未通过') })
3418
+ }
3419
+ }
3420
+ if (adopted.length > 0 || kept.length > 0) writeCompatPending(pending)
3421
+ const note = adopted.length > 0
3422
+ ? `兼容适配:已解锁 ${adopted.length} 行(${adopted.map((a) => a.rowId).join('、')})`
3423
+ + (kept.length > 0 ? `;仍待适配 ${kept.length} 行(${kept.map((k) => `${k.rowId} ${k.reason}`).join('、')})` : '')
3424
+ : `兼容适配:暂未解锁(${kept.map((k) => `${k.rowId} ${k.reason}`).join('、')})`
3425
+ return { ran: true, adopted, kept, note }
3426
+ }
3427
+
3428
+ /** AI 赋能框架适配预检:查 registry 最新版声明 + 兼容门清单,给出权威说明。 */
3429
+ async function frameworkCompatReportFor(source, ctx, profileDir) {
3430
+ const fwVer = currentFrameworkVersion(ctx)
3431
+ const pending = readCompatPending()
3432
+ const norm = String(source ?? '').trim()
3433
+ const isNpm = norm.startsWith('@') || !norm.includes('/')
3434
+ const installed = (() => {
3435
+ try {
3436
+ const require = createRequire(join(profileDir, 'package.json'))
3437
+ const pkgPath = require.resolve(`${norm}/package.json`)
3438
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? null
3439
+ } catch { return null }
3440
+ })()
3441
+ const pendHits = (pending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending' && p.moduleName === norm)
3442
+ if (!isNpm) {
3443
+ return {
3444
+ kind: 'repo', frameworkVersion: fwVer, packageName: null, latest: null, check: null,
3445
+ pending: pendHits, installedVersion: null,
3446
+ summary: pendHits.length > 0
3447
+ ? `该来源命中兼容门清单(${pending.frameworkVersion}),GitHub 仓库无法在线预检——更新/部署完成后会由控制台自动校验解锁`
3448
+ : `GitHub 仓库来源无法预检 npm 声明,部署后以实际运行为准(框架当前 ${fwVer ?? '未知'})`,
3449
+ }
3450
+ }
3451
+ const name = norm.startsWith('@')
3452
+ ? '@' + norm.slice(1).split('@')[0]
3453
+ : norm.split('@')[0]
3454
+ let latest = null
3455
+ let versionMeta = null
3456
+ try {
3457
+ const data = await fetchJsonUrl(`https://registry.npmmirror.com/${name.replace('/', '%2f')}`)
3458
+ latest = data?.['dist-tags']?.latest ?? data?.['dist-tags']?.next ?? null
3459
+ if (latest !== null) versionMeta = data?.versions?.[latest] ?? null
3460
+ } catch (error) {
3461
+ return {
3462
+ kind: 'npm', frameworkVersion: fwVer, packageName: name, latest: null, check: null,
3463
+ pending: pendHits, installedVersion: installed,
3464
+ summary: `registry 预检失败(${error instanceof Error ? error.message : String(error)});本机已装 ${installed ?? '?'}${pendHits.length > 0 ? ',处于兼容门禁用中' : ''}`,
3465
+ }
3466
+ }
3467
+ const check = versionMeta !== null ? checkPluginFrameworkCompat(versionMeta, fwVer) : null
3468
+ const parts = []
3469
+ if (pendHits.length > 0) parts.push(`已在兼容门清单(${pending.upgradeFrom ?? '?'} → ${pending.frameworkVersion})中强制禁用,当前已装 ${installed ?? pendHits[0]?.version ?? '?'}`)
3470
+ if (latest !== null) {
3471
+ if (check?.decision === 'fail') parts.push(`最新版 ${latest} 不满足框架 ${fwVer}(${check.reason})——请勿启用,等待适配版本`)
3472
+ else if (check?.decision === 'pass') parts.push(`最新版 ${latest} 声明兼容框架 ${fwVer},可放心启用`)
3473
+ else parts.push(`最新版 ${latest} 未声明框架兼容(${check?.reason ?? '无声明'}),建议更新后启用并观察`)
3474
+ }
3475
+ return {
3476
+ kind: 'npm', frameworkVersion: fwVer, packageName: name, latest, check, pending: pendHits, installedVersion: installed,
3477
+ summary: parts.length > 0 ? parts.join(';') : `框架 ${fwVer},无待适配记录`,
3478
+ }
3479
+ }
3480
+
3481
+ /** 把框架适配检测结果格式化为给子代理的提示段。 */
3482
+ function frameworkCheckPromptText(fc) {
3483
+ if (fc === null || fc === undefined) return '(不可用)'
3484
+ return [
3485
+ `- 当前框架版本:${fc.frameworkVersion ?? '未知'}`,
3486
+ `- ${fc.kind === 'npm' ? `npm 包 ${fc.packageName ?? '?'}` : '来源类型'}:registry 最新 ${fc.latest ?? '未知'}${fc.installedVersion ? `;本机已装 ${fc.installedVersion}` : ''}`,
3487
+ fc.pending && fc.pending.length > 0 ? `- ⚠ 已命中兼容门:${fc.pending.map((p) => p.rowId).join('、')} 处于强制禁用(版本 ${fc.pending[0]?.version ?? '?'}),升级后仅更新并通过校验才解锁` : null,
3488
+ fc.check ? `- 声明/依赖校验:${fc.check.decision === 'fail' ? '不兼容' : fc.check.decision === 'pass' ? '兼容' : '未知'}(${fc.check.reason})` : null,
3489
+ '- 计划总结必须向用户说明上述适配结论;若校验为 fail,不要建议启用,应建议「更新到最新版后再启用」。',
3490
+ ].filter((s) => s !== null).join('\n')
3491
+ }
3492
+
3075
3493
  async function runInstallJob(job, ctx) {
3076
3494
  try {
3077
3495
  job.stage = 'preparing'
@@ -3302,9 +3720,11 @@ async function runInstallJob(job, ctx) {
3302
3720
  }
3303
3721
  job.packageName = installedName
3304
3722
  // 聚合包子包版本对齐(确保更新后所有子包也到新版声明版本)
3723
+ let syncedNames = []
3305
3724
  try {
3306
3725
  const synced = await syncAggregateSubpackageVersions(profileDir, installedName, registries)
3307
3726
  if (synced.length > 0) {
3727
+ syncedNames = synced.map(syncNameFromNote).filter((n) => n !== null)
3308
3728
  job.bundleNote = (job.bundleNote ? job.bundleNote + ';' : '') + `子包版本对齐:更新 ${synced.length} 个(${synced.join('、')})`
3309
3729
  }
3310
3730
  } catch {}
@@ -3318,6 +3738,11 @@ async function runInstallJob(job, ctx) {
3318
3738
  + (integrity.missing.length > 0 ? `,仍缺失 ${integrity.missing.join('、')}(网络恢复后建议重新更新)` : '')
3319
3739
  }
3320
3740
  } catch {}
3741
+ // 框架升级适配门:更新完成后自动校验兼容性,通过则移除禁用块解锁启用
3742
+ try {
3743
+ const adapt = await maybeAutoAdaptCompat({ profileDir, packageName: installedName, syncedNames, patchPath, ctx })
3744
+ if (adapt !== null && adapt.ran === true) job.compatNote = adapt.note
3745
+ } catch {}
3321
3746
  job.stage = 'configuring'
3322
3747
  if (await detectBundleOnly(profileDir, installedName)) {
3323
3748
  // 官方 dsh plugin add 行为:声明 dsh.bundle 的包追加为 profile bundle 层,
@@ -3364,6 +3789,10 @@ function listEntries(ctx) { const entries = []
3364
3789
 
3365
3790
  /** 应用插件:注册 /plugin-console 路由。 */
3366
3791
  export function apply(ctx) {
3792
+ // 恢复 AI 赋能任务(restart 后 running 置失败,plan-ready/结果保留)
3793
+ loadAiJobs()
3794
+ // DSH 启动时自动拉起标记了「自启动」的服务器组件(幂等,不阻塞启动)
3795
+ autostartComponents().catch(() => {})
3367
3796
  ctx.effect(() => {
3368
3797
  const route = {
3369
3798
  kind: 'prefix',
@@ -3405,6 +3834,15 @@ async function handle(ctx, req, res) {
3405
3834
  const patchPath = findPatchPath(ctx)
3406
3835
  const patch = await readPatchState(patchPath)
3407
3836
  const extraRows = await readExtraBundleRows(dirname(patchPath))
3837
+ const compatPending = readCompatPending()
3838
+ const compatPendingRows = new Set((compatPending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => p.rowId))
3839
+ let rollbackRec = null
3840
+ try {
3841
+ const rr = JSON.parse(readFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), 'utf8'))
3842
+ if (rr !== null && typeof rr.checkpointDir === 'string' && existsSync(join(rr.checkpointDir, '.pnpm'))) {
3843
+ rollbackRec = { from: rr.from ?? null, to: rr.to ?? null, at: rr.at ?? null }
3844
+ }
3845
+ } catch {}
3408
3846
  const entries = listEntries(ctx).map((entry) => {
3409
3847
  const meta = entryPkgMeta(entry.moduleName, ctx.baseUrl ?? 'file:///')
3410
3848
  return {
@@ -3415,6 +3853,7 @@ async function handle(ctx, req, res) {
3415
3853
  installDate: meta?.installDate ?? null,
3416
3854
  version: meta?.version ?? null,
3417
3855
  repository: meta?.repository ?? null,
3856
+ pendingCompat: compatPendingRows.has(entry.rowId),
3418
3857
  }
3419
3858
  })
3420
3859
  const compat = await detectCompat(ctx.baseUrl ?? 'file:///')
@@ -3429,7 +3868,7 @@ async function handle(ctx, req, res) {
3429
3868
  const selfPkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'))
3430
3869
  selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
3431
3870
  } catch {}
3432
- 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, 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) })) })
3871
+ 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 })) })
3433
3872
  return
3434
3873
  }
3435
3874
 
@@ -3626,6 +4065,15 @@ async function handle(ctx, req, res) {
3626
4065
  sendError(res, 400, '不能停用插件控制台自身')
3627
4066
  return
3628
4067
  }
4068
+ if (enabled) {
4069
+ // 框架升级适配门(事故教训):兼容门清单内的行保持强制禁用,直到更新并通过校验
4070
+ const compatPending = readCompatPending()
4071
+ const pend = (compatPending?.pending ?? []).find((p) => p.rowId === rowId && (p.status ?? 'pending') === 'pending')
4072
+ if (pend) {
4073
+ sendError(res, 409, `「${rowId}」仍在框架升级适配门中(${compatPending.frameworkVersion ?? '?'})被强制禁用:请先点击「更新并适配」,更新完成并通过兼容校验后系统自动解锁`)
4074
+ return
4075
+ }
4076
+ }
3629
4077
  const patchPath = findPatchPath(ctx)
3630
4078
  const result = enabled
3631
4079
  ? await enableEntry(patchPath, rowId)
@@ -3692,6 +4140,7 @@ async function handle(ctx, req, res) {
3692
4140
  const query = raw === '' ? DEFAULT_SEARCH : raw
3693
4141
  const page = Math.max(Math.min(Number.parseInt(String(body.page), 10) || 1, 5), 1)
3694
4142
  const source = typeof body.source === 'string' && body.source !== '' ? body.source : 'github'
4143
+ const all = body.all === true
3695
4144
  const auth = readGithubAuth()
3696
4145
  let items = []
3697
4146
  if (body.multi === true) {
@@ -3702,7 +4151,7 @@ async function handle(ctx, req, res) {
3702
4151
  (async () => {
3703
4152
  try {
3704
4153
  const data = await githubJson(
3705
- `${GITHUB_API}/search/repositories?q=${encodeURIComponent(query)}&sort=stars&order=desc&per_page=20&page=${page}`,
4154
+ `${GITHUB_API}/search/repositories?q=${encodeURIComponent(all ? query : `${query} topic:dsh-plugin`)}&sort=stars&order=desc&per_page=20&page=${page}`,
3706
4155
  req.signal,
3707
4156
  auth.token,
3708
4157
  )
@@ -3801,7 +4250,7 @@ async function handle(ctx, req, res) {
3801
4250
  items = normalizePlatformItems(data, 'main').map((item) => ({ ...item, source, sourceName: custom.name }))
3802
4251
  } else {
3803
4252
  const data = await githubJson(
3804
- `${GITHUB_API}/search/repositories?q=${encodeURIComponent(query)}&sort=stars&order=desc&per_page=20&page=${page}`,
4253
+ `${GITHUB_API}/search/repositories?q=${encodeURIComponent(all ? query : `${query} topic:dsh-plugin`)}&sort=stars&order=desc&per_page=20&page=${page}`,
3805
4254
  req.signal,
3806
4255
  auth.token,
3807
4256
  )
@@ -4308,6 +4757,27 @@ async function handle(ctx, req, res) {
4308
4757
  sendError(res, 500, `框架备份失败(回滚点${rollbackDir !== null ? `:${rollbackDir}` : '(无)'}缺失或无效),已取消升级——请检查磁盘空间/权限后重试`)
4309
4758
  return
4310
4759
  }
4760
+ // 框架安装根识别(事故教训 2026-09-04):require.resolve 返回 .pnpm 内部 realpath,
4761
+ // 旧逻辑 dirname(dirname(dshDir)) 指向 .pnpm/<entry>/node_modules——「重链跳过:.pnpm 目录不存在」、
4762
+ // 依赖修复 0 个,pnpm 还在错误 cwd 里把新 CLI 原位覆盖进旧 .pnpm 目录,旧树就此被毁。
4763
+ const fwRoot = resolveFrameworkRootNodeModules(dshDir)
4764
+ if (fwRoot === null || !existsSync(join(fwRoot, '.pnpm')) || !existsSync(join(fwRoot, '@deepseek-ai', 'dsh', 'lib', 'bin.js'))) {
4765
+ sendError(res, 500, `无法定位框架安装根(期望含 .pnpm 与 @deepseek-ai/dsh 的顶层 node_modules,实际:${fwRoot ?? '未找到'})——已取消升级,请修复框架安装后重试`)
4766
+ return
4767
+ }
4768
+ // 框架全树 checkpoint(可靠回滚点):升级前镜像全部 @deepseek-ai 版本包(自包内容)+ 顶层 scope + lock
4769
+ let fwCheckpoint = null
4770
+ try {
4771
+ const cpRoot = backupDir !== null ? backupDir : join(FRAMEWORK_BACKUP_ROOT(), current ?? 'unknown')
4772
+ fwCheckpoint = checkpointFrameworkTree(fwRoot, cpRoot)
4773
+ steps.push(`框架全树 checkpoint 完成:${fwCheckpoint.dest}(镜像 ${fwCheckpoint.mirrored} 个 @deepseek-ai 版本包;升级失败/拉起失败会自动全树回滚,也可用框架卡片的「回滚到上一版」一键回滚)`)
4774
+ } catch (error) {
4775
+ steps.push(`框架全树 checkpoint 失败:${error instanceof Error ? error.message : String(error)}(回滚点降级为 CLI 备份)`)
4776
+ }
4777
+ // 一键回滚记录:框架卡片「回滚到上一版」按钮读取
4778
+ try {
4779
+ writeFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), JSON.stringify({ from: current, to: target, checkpointDir: fwCheckpoint?.dest ?? null, cliBackupDir: rollbackDir, fwRoot, at: Date.now() }, null, 2), 'utf8')
4780
+ } catch {}
4311
4781
  const ps1 = join(tmpdir(), `fw-upgrade-${process.pid}.ps1`)
4312
4782
  const logFile = join(dshHome(), 'plugin-console', 'fw-upgrade.log')
4313
4783
  const stateFile = join(dshHome(), 'plugin-console', 'fw-upgrade-state.txt')
@@ -4337,7 +4807,7 @@ async function handle(ctx, req, res) {
4337
4807
  `Log '升级框架本体 ${current} -> ${target}…'`,
4338
4808
  // 关键:npm 的工作目录必须是缓存 node_modules(schtasks 默认 cwd 是 system32/用户目录,
4339
4809
  // 不指定会装错位置——缓存永远不更新的根因)
4340
- `Set-Location -Path ${ps(dirname(dshDir))}`,
4810
+ `Set-Location -Path ${ps(fwRoot)}`,
4341
4811
  // npm 安装函数:Start-Process 跑 npm-cli.js(唯一正确入口——lib/cli.js 是函数模块,
4342
4812
  // 直跑只加载不执行,假成功 exit 0)+ --force(显式指定版本但已在 package.json 的
4343
4813
  // semver 范围内时 npm 会跳过安装,假成功)+ 15 分钟总时长硬超时。注意:**不能加
@@ -4400,17 +4870,36 @@ async function handle(ctx, req, res) {
4400
4870
  ` }`,
4401
4871
  ` return $code`,
4402
4872
  `}`,
4403
- // 回滚函数:robocopy 支持长路径(Copy-Item 对 >260 字符路径静默失败,曾导致回滚后
4404
- // 框架目录缺失、服务无法重启);回滚前确保服务已停(进程 cwd 会锁住框架目录)
4873
+ // 回滚函数(2026-09-04 事故后升级为「全树优先」):robocopy 支持长路径(Copy-Item 对 >260
4874
+ // 字符路径静默失败,曾导致回滚后框架目录缺失、服务无法重启);回滚前确保服务已停
4875
+ // (进程 cwd 会锁住框架目录)。checkpoint 存在 → 恢复全部 @deepseek-ai 版本包 + 顶层 scope;
4876
+ // checkpoint 缺失 → CLI 兜底。
4405
4877
  `function Invoke-Rollback {`,
4406
- ` SetState 'rollback' '升级失败,回滚框架本体…'`,
4407
- ` Log '升级失败,回滚框架本体…'`,
4408
- ` # 备份缺失时绝不删除现有框架目录(曾因先删后拷失败导致框架目录为空、服务无法重启)`,
4409
- ` if (-not (Test-Path ${ps(join(rollbackDir, 'lib', 'bin.js'))})) { Log ('回滚失败:备份缺失(' + ${ps(rollbackDir)} + '),跳过删除框架目录'); return }`,
4410
- ` try { $svc = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($svc) { $svc | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 2 } } catch {}`,
4878
+ ` SetState 'rollback' '升级失败,回滚框架(全树/CLI 备份)…'`,
4879
+ ` Log '升级失败,回滚框架…'`,
4880
+ ` try { $svc = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($svc) { $svc | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 3 } } catch {}`,
4881
+ ` $cp = '${ps(fwCheckpoint?.dest ?? '')}'`,
4882
+ ` if ($cp -ne '' -and (Test-Path (Join-Path $cp '.pnpm'))) {`,
4883
+ ` $restored = 0`,
4884
+ ` $entries = Get-ChildItem -Path (Join-Path $cp '.pnpm') -Directory -ErrorAction SilentlyContinue`,
4885
+ ` foreach ($e in $entries) {`,
4886
+ ` $name = @($e.Name -split '\\+')[1].Split('@')[0]`,
4887
+ ` $src = Join-Path $e.FullName ('node_modules\\@deepseek-ai\\' + $name)`,
4888
+ ` $dst = Join-Path (Join-Path '${ps(fwRoot)}' ('.pnpm\\' + $e.Name)) ('node_modules\\@deepseek-ai\\' + $name)`,
4889
+ ` if (Test-Path (Join-Path $src 'package.json')) { New-Item -ItemType Directory -Path (Split-Path $dst -Parent) -Force | Out-Null; robocopy $src $dst /E /NFL /NDL /NJH /NJS /R:1 /W:1 | Out-Null; $restored++ }`,
4890
+ ` }`,
4891
+ ` $topSrc = Join-Path $cp 'top-@deepseek-ai'`,
4892
+ ` if (Test-Path $topSrc) { Remove-Item '${ps(join(fwRoot, '@deepseek-ai'))}' -Recurse -Force -ErrorAction SilentlyContinue; robocopy $topSrc '${ps(join(fwRoot, '@deepseek-ai'))}' /E /NFL /NDL /NJH /NJS /R:1 /W:1 | Out-Null }`,
4893
+ ` try { Copy-Item (Join-Path $cp 'lock.yaml') '${ps(join(fwRoot, '.pnpm', 'lock.yaml'))}' -Force -ErrorAction SilentlyContinue } catch {}`,
4894
+ ` Log ('全树回滚完成:恢复 ' + $restored + ' 个版本包 + 顶层 scope')`,
4895
+ ` $script:rolledBack = $true`,
4896
+ ` return`,
4897
+ ` }`,
4898
+ ` # CLI 兜底(checkpoint 缺失或损坏)`,
4899
+ ` if (-not (Test-Path ${ps(join(rollbackDir, 'lib', 'bin.js'))})) { Log ('回滚失败:CLI 备份也缺失(' + ${ps(rollbackDir)} + ')'); return }`,
4411
4900
  ` Remove-Item ${ps(dshDir)} -Recurse -Force -ErrorAction SilentlyContinue`,
4412
4901
  ` robocopy ${ps(rollbackDir)} ${ps(dshDir)} /E /NFL /NDL /NJH /NJS /R:1 /W:1 | Out-Null`,
4413
- ` if (Test-Path ${ps(join(dshDir, 'lib', 'bin.js'))}) { Log '回滚完成(框架目录已恢复)'; $script:rolledBack = $true } else { Log '回滚失败:复制失败,请手动修复框架安装' }`,
4902
+ ` if (Test-Path ${ps(join(dshDir, 'lib', 'bin.js'))}) { Log '回滚完成(CLI 恢复)'; $script:rolledBack = $true } else { Log '回滚失败:复制失败,请手动修复框架安装' }`,
4414
4903
  `}`,
4415
4904
  `$script:rolledBack = $false`,
4416
4905
  `$code = Install-Framework 'https://registry.npmmirror.com'`,
@@ -4424,6 +4913,8 @@ async function handle(ctx, req, res) {
4424
4913
  ` if ($code -ne 0) { Log ('(停服后)npmmirror 安装失败(exit=' + $code + '),切换 registry.npmjs.org 重试一次'); $code = Install-Framework 'https://registry.npmjs.org' }`,
4425
4914
  `}`,
4426
4915
  `if ($code -ne 0) { Invoke-Rollback } else { Log '框架本体升级完成' }`,
4916
+ // 回滚后跳过重链/依赖修复/版本对齐(它们按新树预期运行,会对已恢复的旧树造成二次破坏)
4917
+ `if (-not $script:rolledBack) {`,
4427
4918
  // 重新链接框架配套包(事故教训):pnpm 升级到新版本时 .pnpm 目录重建,
4428
4919
  // 但顶层 node_modules/@deepseek-ai/* 不会自动切换,仍指向旧版(如 0.1.0-rc.7)——
4429
4920
  // 导致框架混版本(如 dsh-llm-deepseek 旧版无 vision 模型)、部分功能异常。
@@ -4432,7 +4923,7 @@ async function handle(ctx, req, res) {
4432
4923
  `$relinked = 0`,
4433
4924
  `function Relink-FrameworkPackages($version) {`,
4434
4925
  ` # dshDir = .../node_modules/@deepseek-ai/dsh → nodeModules = .../node_modules`,
4435
- ` $nodeModules = ${ps(dirname(dirname(dshDir)))}`,
4926
+ ` $nodeModules = ${ps(fwRoot)}`,
4436
4927
  ` $pnpm = Join-Path $nodeModules '.pnpm'`,
4437
4928
  ` if (-not (Test-Path $pnpm)) { Log ('重链跳过:.pnpm 目录不存在'); return }`,
4438
4929
  ` $topPkgs = Get-ChildItem -Path (Join-Path $nodeModules '@deepseek-ai') -Directory | Where-Object { $_.Name -like 'dsh-*' }`,
@@ -4470,7 +4961,7 @@ async function handle(ctx, req, res) {
4470
4961
  ` $bak = $top.FullName + '.bak-' + $topVer`,
4471
4962
  ` if (Test-Path $bak) { Remove-Item $bak -Recurse -Force -ErrorAction SilentlyContinue }`,
4472
4963
  ` Rename-Item -LiteralPath $top.FullName -NewName ($name + '.bak-' + $topVer) -Force`,
4473
- ` cmd /c mklink /J "${top.FullName}" "${best}" 2>$null | Out-Null`,
4964
+ ` cmd /c mklink /J "$($top.FullName)" "$($best)" 2>$null | Out-Null`,
4474
4965
  ` if (Test-Path (Join-Path $top.FullName 'package.json')) { $script:relinked++; Log ('重链 ' + $name + ': ' + $topVer + ' -> ' + $bestVer) } else { Log ('重链失败:' + $name) }`,
4475
4966
  ` } catch { Log ('重链异常:' + $name + ' ' + $_.Exception.Message) }`,
4476
4967
  ` }`,
@@ -4484,7 +4975,7 @@ async function handle(ctx, req, res) {
4484
4975
  `$treeFixed = 0`,
4485
4976
  `function Verify-DependencyTree($refVersion) {`,
4486
4977
  ` # dshDir = .../node_modules/@deepseek-ai/dsh → nodeModules = .../node_modules`,
4487
- ` $nodeModules = ${ps(dirname(dirname(dshDir)))}`,
4978
+ ` $nodeModules = ${ps(fwRoot)}`,
4488
4979
  ` $webApp = Join-Path $nodeModules '@deepseek-ai\\dsh-web-app\\package.json'`,
4489
4980
  ` if (-not (Test-Path $webApp)) { Log ('依赖树验证跳过:dsh-web-app 未找到'); return }`,
4490
4981
  ` try { $deps = (Get-Content $webApp -Raw | ConvertFrom-Json).dependencies } catch { Log ('依赖树验证跳过:读取失败'); return }`,
@@ -4552,6 +5043,7 @@ async function handle(ctx, req, res) {
4552
5043
  `}`,
4553
5044
  `Verify-SelfNameConsistency '${selfName ?? '@noob-stupid/dsh-plugin-console'}'`,
4554
5045
  pkgArgs.length > 0 ? `SetState 'pkg' '更新官方配套包…'\n Log '更新官方配套包…'\n Set-Location -Path ${ps(profileDir2)}\n if (Test-Path ${ps(corepackJs)}) {\n $pp = Start-Process -FilePath ${ps(nodePath)} -ArgumentList @(${ps(corepackJs)}, 'pnpm', 'update', ${pkgArgs.join(', ')}, '--no-optional', '--registry', 'https://registry.npmmirror.com') -PassThru -WindowStyle Hidden\n if (-not $pp.WaitForExit(300000)) { Stop-Process -Id $pp.Id -Force -ErrorAction SilentlyContinue; Log '官方配套包更新超时(5 分钟),已跳过' } else { try { Log ('官方配套包更新结束(exit=' + $pp.ExitCode + ')') } catch { Log '官方配套包更新结束(退出码读取失败)' } }\n }` : '',
5046
+ "}",
4555
5047
  "SetState 'relaunching' '升级完成,重启 DSH 服务生效…'",
4556
5048
  "Log '重启 DSH 服务生效…'",
4557
5049
  // 升级全程服务保持在线(先下载后重启):这里停掉旧服务(内存仍是旧代码),拉起新版本生效
@@ -4563,7 +5055,19 @@ async function handle(ctx, req, res) {
4563
5055
  ` if (-not $ok -and -not $started) { $binNow = ''; try { $env:DSH_RESOLVE_ROOT = '${join(dirname(fileURLToPath(import.meta.url)), '..')}'; $binNow = (& ${ps(nodePath)} -e "const path=require('path');const p=require.resolve('@deepseek-ai/dsh/package.json',{paths:[process.env.DSH_RESOLVE_ROOT]});console.log(path.join(path.dirname(p),'lib','bin.js'))" 2>$null | Select-Object -Last 1) } catch {}; if ($binNow -ne '' -and (Test-Path $binNow)) { Start-Process -FilePath ${ps(nodePath)} -ArgumentList $binNow,'web' -WindowStyle Hidden; $started = $true; Log '已发起拉起服务' } else { Log 'bin.js 缺失,跳过拉起' } }`,
4564
5056
  ' Start-Sleep -Seconds 5',
4565
5057
  '}',
4566
- `if ($ok) { if ($script:rolledBack) { SetState 'failed' '升级失败,已回滚到原版本(服务已恢复)' ; Log '升级失败已回滚,服务已恢复' } else { SetState 'done' '升级完成,服务已监听 ${port}' ; Log '服务已监听 ${port},升级完成' } } else { SetState 'failed' '拉起失败:请手动运行 node ${binPath} web' ; Log '拉起失败:请手动运行 node ${binPath} web(参考本日志排查)' }`,
5058
+ // 拉起失败不再只留提示(2026-09-04 教训):自动全树回滚并再拉起一次
5059
+ `if (-not $ok -and -not $script:rolledBack) {`,
5060
+ ` Log '服务拉起失败,尝试自动回滚到升级前版本…'`,
5061
+ ` Invoke-Rollback`,
5062
+ ` $ok = $false`,
5063
+ ` $started = $false`,
5064
+ ` for ($i = 0; $i -lt 20; $i++) {`,
5065
+ ` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
5066
+ ` if (-not $ok -and -not $started) { $binNow = ''; try { $env:DSH_RESOLVE_ROOT = '${join(dirname(fileURLToPath(import.meta.url)), '..')}'; $binNow = (& ${ps(nodePath)} -e "const path=require('path');const p=require.resolve('@deepseek-ai/dsh/package.json',{paths:[process.env.DSH_RESOLVE_ROOT]});console.log(path.join(path.dirname(p),'lib','bin.js'))" 2>$null | Select-Object -Last 1) } catch {}; if ($binNow -ne '' -and (Test-Path $binNow)) { Start-Process -FilePath ${ps(nodePath)} -ArgumentList $binNow,'web' -WindowStyle Hidden; $started = $true; Log '已发起拉起服务(回滚后)' } else { Log 'bin.js 缺失,跳过拉起' } }`,
5067
+ ` Start-Sleep -Seconds 5`,
5068
+ ` }`,
5069
+ `}`,
5070
+ `if ($ok) { if ($script:rolledBack) { SetState 'failed' '升级失败,已自动回滚到升级前版本(服务已恢复)' ; Log '升级失败已自动回滚,服务已恢复' } else { SetState 'done' '升级完成,服务已监听 ${port}' ; Log '服务已监听 ${port},升级完成' } } else { SetState 'failed' '拉起失败且自动回滚未生效:请手动运行 node ${binPath} web' ; Log '拉起失败且自动回滚未生效:请手动运行 node ${binPath} web(参考本日志排查)' }`,
4567
5071
  `schtasks /delete /f /tn ${taskName} 2>$null`,
4568
5072
  ].filter((l) => l !== '').join('\r\n')
4569
5073
  // 初始状态(备份完成、脚本即将执行)
@@ -4626,6 +5130,87 @@ async function handle(ctx, req, res) {
4626
5130
  return
4627
5131
  }
4628
5132
 
5133
+ if (pathname === `${ROUTE_PREFIX}/framework-rollback`) {
5134
+ // 一键回滚(2026-09-04 事故后的新能力):读 framework-rollback.json(升级时写入),
5135
+ // 生成分离脚本:停服 → 全树恢复(.pnpm 自包镜像 + 顶层 scope + lock)→ 拉起 → 状态。
5136
+ let rec = null
5137
+ try { rec = JSON.parse(readFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), 'utf8')) } catch {}
5138
+ if (rec === null || typeof rec.checkpointDir !== 'string' || typeof rec.fwRoot !== 'string'
5139
+ || !existsSync(join(rec.checkpointDir, '.pnpm')) || !existsSync(join(rec.fwRoot, '.pnpm'))) {
5140
+ sendError(res, 409, '没有可用的框架全树回滚点(framework-rollback.json 缺失或 checkpoint 已清理)')
5141
+ return
5142
+ }
5143
+ const port = webPort(ctx)
5144
+ const nodePath = process.execPath
5145
+ const taskName = `DSH-FW-Rollback-${process.pid}`
5146
+ const ps1 = join(tmpdir(), `fw-rollback-${process.pid}.ps1`)
5147
+ const logFile = join(dshHome(), 'plugin-console', 'fw-upgrade.log')
5148
+ const stateFile = join(dshHome(), 'plugin-console', 'fw-upgrade-state.txt')
5149
+ const ps = (s) => JSON.stringify(s).replace(/\\\\/gu, '\\')
5150
+ const lines = [
5151
+ `$state = ${ps(stateFile)}`,
5152
+ `$log = ${ps(logFile)}`,
5153
+ "function Log($m) { try { Add-Content -Path $log -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $m) -Encoding UTF8 } catch {} }",
5154
+ "function SetState($s, $m) { try { Set-Content -Path $state -Value ($s + '|' + $m) -Encoding UTF8 } catch {} }",
5155
+ "trap {",
5156
+ " try { SetState 'failed' ('回滚脚本异常终止:' + $_.Exception.Message) } catch {}",
5157
+ ` schtasks /delete /f /tn ${taskName} 2>$null`,
5158
+ " exit 1",
5159
+ "}",
5160
+ "SetState 'rollback' '回滚到升级前版本…'",
5161
+ "Log '一键回滚脚本启动'",
5162
+ `try { $svc = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($svc) { $svc | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 3 } } catch {}`,
5163
+ "Log '服务已停止(回滚生效)'",
5164
+ `$cp = ${ps(rec.checkpointDir)}`,
5165
+ `$restored = 0`,
5166
+ `$entries = Get-ChildItem -Path (Join-Path $cp '.pnpm') -Directory -ErrorAction SilentlyContinue`,
5167
+ `foreach ($e in $entries) {`,
5168
+ ` $name = @($e.Name -split '\\+')[1].Split('@')[0]`,
5169
+ ` $src = Join-Path $e.FullName ('node_modules\\@deepseek-ai\\' + $name)`,
5170
+ ` $dst = Join-Path (Join-Path ${ps(rec.fwRoot)} ('.pnpm\\' + $e.Name)) ('node_modules\\@deepseek-ai\\' + $name)`,
5171
+ ` if (Test-Path (Join-Path $src 'package.json')) { New-Item -ItemType Directory -Path (Split-Path $dst -Parent) -Force | Out-Null; robocopy $src $dst /E /NFL /NDL /NJH /NJS /R:1 /W:1 | Out-Null; $restored++ }`,
5172
+ `}`,
5173
+ `$topSrc = Join-Path $cp 'top-@deepseek-ai'`,
5174
+ `if (Test-Path $topSrc) { Remove-Item ${ps(join(rec.fwRoot, '@deepseek-ai'))} -Recurse -Force -ErrorAction SilentlyContinue; robocopy $topSrc ${ps(join(rec.fwRoot, '@deepseek-ai'))} /E /NFL /NDL /NJH /NJS /R:1 /W:1 | Out-Null }`,
5175
+ `try { Copy-Item (Join-Path $cp 'lock.yaml') ${ps(join(rec.fwRoot, '.pnpm', 'lock.yaml'))} -Force -ErrorAction SilentlyContinue } catch {}`,
5176
+ `Log ('全树回滚完成:恢复 ' + $restored + ' 个版本包 + 顶层 scope,拉起验证中…')`,
5177
+ `$ok = $false`,
5178
+ `$started = $false`,
5179
+ `for ($i = 0; $i -lt 20; $i++) {`,
5180
+ ` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
5181
+ ` if (-not $ok -and -not $started) { $binNow = ''; try { $env:DSH_RESOLVE_ROOT = '${join(dirname(fileURLToPath(import.meta.url)), '..')}'; $binNow = (& ${ps(nodePath)} -e "const path=require('path');const p=require.resolve('@deepseek-ai/dsh/package.json',{paths:[process.env.DSH_RESOLVE_ROOT]});console.log(path.join(path.dirname(p),'lib','bin.js'))" 2>$null | Select-Object -Last 1) } catch {}; if ($binNow -ne '' -and (Test-Path $binNow)) { Start-Process -FilePath ${ps(nodePath)} -ArgumentList $binNow,'web' -WindowStyle Hidden; $started = $true; Log '已发起拉起服务(回滚后)' } else { Log 'bin.js 缺失,跳过拉起' } }`,
5182
+ ` Start-Sleep -Seconds 5`,
5183
+ `}`,
5184
+ `if ($ok) { SetState 'done' ('已回滚到升级前版本 ${rec.from ?? '?'},服务正常') ; Log '回滚完成,服务已恢复' } else { SetState 'failed' '回滚后服务拉起失败:请手动运行 node ${resolveDshBin() ?? '<bin>'} web' ; Log '回滚后拉起失败' }`,
5185
+ `schtasks /delete /f /tn ${taskName} 2>$null`,
5186
+ ].filter((l) => l !== '').join('\r\n')
5187
+ try { writeFileSync(stateFile, 'rollback|回滚脚本已启动…', 'utf8') } catch {}
5188
+ writeFile(ps1, `\uFEFF${lines}`, 'utf8').then(
5189
+ () => {
5190
+ const ps1Posix = ps1.replace(/\\/gu, '/')
5191
+ const tr = / /.test(ps1Posix)
5192
+ ? `"powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \\"${ps1Posix}\\""`
5193
+ : `C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ${ps1Posix}`
5194
+ execFile('schtasks.exe', ['/create', '/f', '/tn', taskName, '/tr', tr, '/sc', 'once', '/st', '00:00'], { windowsHide: true }, (error) => {
5195
+ if (error) {
5196
+ execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true, detached: true, stdio: 'ignore' }, () => {})
5197
+ return
5198
+ }
5199
+ setTimeout(() => {
5200
+ execFile('schtasks.exe', ['/run', '/tn', taskName], { windowsHide: true }, (runError) => {
5201
+ if (runError) {
5202
+ execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true, detached: true, stdio: 'ignore' }, () => {})
5203
+ }
5204
+ })
5205
+ }, 800)
5206
+ })
5207
+ },
5208
+ () => {},
5209
+ )
5210
+ sendJson(res, 200, { ok: true, from: rec.from ?? null, checkpointDir: rec.checkpointDir })
5211
+ return
5212
+ }
5213
+
4629
5214
  if (pathname === `${ROUTE_PREFIX}/clean-residuals`) {
4630
5215
  // 清理残余备份/旧子包:删除 .old-* 备份目录,以及聚合包未声明的 @linxin666 旧子包
4631
5216
  const profileDir = dirname(findPatchPath(ctx))
@@ -4858,6 +5443,8 @@ async function handle(ctx, req, res) {
4858
5443
  const profileDir = dirname(patchPath)
4859
5444
  const jobId = `ai-${Date.now()}-${++aiJobSeq}`
4860
5445
  const job = { id: jobId, source, status: 'running', stage: 'planning', createdAt: Date.now(), logText: '', stepStates: [] }
5446
+ // 框架适配预检(兼容门 + registry 声明):规划期即给出权威说明,供子代理引用与用户查看
5447
+ try { job.frameworkCheck = await frameworkCompatReportFor(source, ctx, profileDir) } catch { job.frameworkCheck = null }
4861
5448
  aiJobs.set(jobId, job)
4862
5449
  const builtin = builtinPlanFor(source)
4863
5450
  if (builtin !== null) {
@@ -4865,7 +5452,8 @@ async function handle(ctx, req, res) {
4865
5452
  job.status = 'plan-ready'
4866
5453
  job.stage = 'builtin'
4867
5454
  job.finishedAt = Date.now()
4868
- sendJson(res, 200, { ok: true, jobId })
5455
+ saveAiJobs()
5456
+ sendJson(res, 200, { ok: true, jobId, frameworkCheck: job.frameworkCheck })
4869
5457
  return
4870
5458
  }
4871
5459
  aiEmpowerPlan(job, ctx, profileDir).catch((error) => {
@@ -4873,7 +5461,7 @@ async function handle(ctx, req, res) {
4873
5461
  job.error = `规划任务异常:${error instanceof Error ? error.message : String(error)}`
4874
5462
  job.finishedAt = Date.now()
4875
5463
  })
4876
- sendJson(res, 200, { ok: true, jobId })
5464
+ sendJson(res, 200, { ok: true, jobId, frameworkCheck: job.frameworkCheck })
4877
5465
  return
4878
5466
  }
4879
5467
 
@@ -4887,9 +5475,30 @@ async function handle(ctx, req, res) {
4887
5475
  sendJson(res, 200, { ok: true, ...aiJobView(job) })
4888
5476
  return
4889
5477
  }
5478
+ if (pathname === `${ROUTE_PREFIX}/ai-empower/list`) {
5479
+ // 并发任务列表(轻量视图,不含日志全文):面板展示/切换多个并发 AI 赋能任务
5480
+ const list = [...aiJobs.values()].slice(-10).reverse().map((job) => ({
5481
+ jobId: job.id,
5482
+ source: job.source,
5483
+ status: job.status,
5484
+ stage: job.stage,
5485
+ type: job.plan?.type ?? null,
5486
+ displayName: job.plan?.displayName ?? null,
5487
+ progress: job.progress ?? { done: 0, total: (job.plan?.steps ?? []).length },
5488
+ error: job.error ?? null,
5489
+ createdAt: job.createdAt,
5490
+ }))
5491
+ sendJson(res, 200, { ok: true, tasks: list })
5492
+ return
5493
+ }
4890
5494
  if (pathname === `${ROUTE_PREFIX}/ai-empower/run`) {
4891
5495
  const jobId = typeof body.jobId === 'string' ? body.jobId : ''
4892
5496
  const selected = Array.isArray(body.steps) ? body.steps : null
5497
+ // 纵深防御:必须显式确认(前端「同意并部署」按钮携带 confirmed:true),防止任何绕过同意直接执行
5498
+ if (body.confirmed !== true) {
5499
+ sendError(res, 400, '未确认部署:请先在面板勾选步骤并点击「同意并部署」')
5500
+ return
5501
+ }
4893
5502
  const job = aiJobs.get(jobId)
4894
5503
  if (!job) {
4895
5504
  sendError(res, 404, '没有这个 AI 赋能任务')
@@ -4905,6 +5514,7 @@ async function handle(ctx, req, res) {
4905
5514
  job.status = 'failed'
4906
5515
  job.error = `执行任务异常:${error instanceof Error ? error.message : String(error)}`
4907
5516
  job.finishedAt = Date.now()
5517
+ saveAiJobs()
4908
5518
  })
4909
5519
  sendJson(res, 200, { ok: true, jobId })
4910
5520
  return
@@ -4922,6 +5532,7 @@ async function handle(ctx, req, res) {
4922
5532
  job.status = 'failed'
4923
5533
  job.error = '已取消执行(用户中断)'
4924
5534
  job.finishedAt = Date.now()
5535
+ saveAiJobs()
4925
5536
  }
4926
5537
  sendJson(res, 200, { ok: true, jobId })
4927
5538
  return
@@ -4934,13 +5545,124 @@ async function handle(ctx, req, res) {
4934
5545
  try {
4935
5546
  enriched.push(await compStatus(c.id))
4936
5547
  } catch {
4937
- enriched.push({ id: c.id, name: c.name, running: false, healthy: null, pid: null, port: c.port ?? null })
5548
+ enriched.push({ id: c.id, name: c.name, running: false, healthy: null, pid: null, port: c.port ?? null, autoStart: c.autoStart === true })
4938
5549
  }
4939
5550
  }
4940
5551
  sendJson(res, 200, { ok: true, components: enriched.map((x) => ({ ...x, uiUrl: compUiUrl(compFind(x.id) ?? {}) })) })
4941
5552
  return
4942
5553
  }
4943
5554
 
5555
+ if (pathname === `${ROUTE_PREFIX}/repo-clone`) {
5556
+ // 仓库落地:从 GitHub 克隆任意项目到 <配置路径>/<owner>/<name>(不限于 DSH 插件)
5557
+ const raw = typeof body.repo === 'string' ? body.repo.trim() : ''
5558
+ let repo = raw
5559
+ .replace(/^https?:\/\/github\.com\//u, '')
5560
+ .replace(/^git@github\.com:/u, '')
5561
+ .replace(/\.git$/u, '')
5562
+ .replace(/\/+$/u, '')
5563
+ const m = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/u.exec(repo)
5564
+ if (!m) {
5565
+ sendError(res, 400, '仓库格式不正确:请提供 owner/repo 或 GitHub 仓库链接')
5566
+ return
5567
+ }
5568
+ const [, owner, name] = m
5569
+ const root = getReposDir()
5570
+ const target = join(root, owner, name)
5571
+ if (existsSync(target)) {
5572
+ sendError(res, 400, `已存在:${target}(如需更新请先手动处理该目录)`)
5573
+ return
5574
+ }
5575
+ try {
5576
+ mkdirSync(root, { recursive: true })
5577
+ await execFileAsync('git.exe', ['clone', '--depth', '1', `https://github.com/${owner}/${name}.git`, target], { cwd: root, timeout: 600000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 })
5578
+ sendJson(res, 200, { ok: true, owner, name, path: target })
5579
+ } catch (error) {
5580
+ sendError(res, 500, `克隆失败:${error instanceof Error ? error.message : String(error)}`)
5581
+ }
5582
+ return
5583
+ }
5584
+
5585
+ if (pathname === `${ROUTE_PREFIX}/repo-list`) {
5586
+ try {
5587
+ sendJson(res, 200, { ok: true, dir: getReposDir(), repos: listLandedRepos() })
5588
+ } catch (error) {
5589
+ sendError(res, 500, error instanceof Error ? error.message : String(error))
5590
+ }
5591
+ return
5592
+ }
5593
+
5594
+ if (pathname === `${ROUTE_PREFIX}/repo-land-config`) {
5595
+ const dir = typeof body.dir === 'string' ? body.dir.trim() : ''
5596
+ if (dir === '' || !/^[A-Za-z]:[\\/]/.test(dir) && !/^\\\\/.test(dir)) {
5597
+ sendError(res, 400, '保存路径不合法:请提供绝对路径(如 D:\\dsh\\repos)')
5598
+ return
5599
+ }
5600
+ try {
5601
+ setReposDir(dir)
5602
+ sendJson(res, 200, { ok: true, dir: getReposDir() })
5603
+ } catch (error) {
5604
+ sendError(res, 500, error instanceof Error ? error.message : String(error))
5605
+ }
5606
+ return
5607
+ }
5608
+
5609
+ if (pathname === `${ROUTE_PREFIX}/repo-remove`) {
5610
+ const target = typeof body.path === 'string' ? body.path : ''
5611
+ const rootNorm = getReposDir().replace(/[\\/]+$/u, '')
5612
+ const tNorm = target.replace(/[\\/]+$/u, '')
5613
+ if (!tNorm.startsWith(rootNorm) || tNorm === rootNorm || !existsSync(target)) {
5614
+ sendError(res, 400, '路径不在仓库落地目录内或不存在')
5615
+ return
5616
+ }
5617
+ try {
5618
+ rmSync(target, { recursive: true, force: true })
5619
+ sendJson(res, 200, { ok: true })
5620
+ } catch (error) {
5621
+ sendError(res, 500, `删除失败:${error instanceof Error ? error.message : String(error)}`)
5622
+ }
5623
+ return
5624
+ }
5625
+
5626
+ if (pathname === `${ROUTE_PREFIX}/repo-open`) {
5627
+ const target = typeof body.path === 'string' ? body.path : ''
5628
+ const rootNorm = getReposDir().replace(/[\\/]+$/u, '')
5629
+ const tNorm = target.replace(/[\\/]+$/u, '')
5630
+ if (!tNorm.startsWith(rootNorm) || tNorm === rootNorm) {
5631
+ sendError(res, 400, '路径不在仓库落地目录内')
5632
+ return
5633
+ }
5634
+ if (!existsSync(target)) {
5635
+ sendError(res, 404, `目录不存在:${target}`)
5636
+ return
5637
+ }
5638
+ try {
5639
+ execFile('explorer.exe', [target], { windowsHide: true, detached: true }).unref()
5640
+ sendJson(res, 200, { ok: true })
5641
+ } catch (error) {
5642
+ sendError(res, 500, `打开失败:${error instanceof Error ? error.message : String(error)}`)
5643
+ }
5644
+ return
5645
+ }
5646
+
5647
+ if (pathname === `${ROUTE_PREFIX}/component/autostart`) {
5648
+ const id = typeof body.id === 'string' ? body.id : ''
5649
+ const enabled = body.enabled === true
5650
+ if (id === '') {
5651
+ sendError(res, 400, '缺少组件 id')
5652
+ return
5653
+ }
5654
+ if (!compFind(id)) {
5655
+ sendError(res, 404, '组件不存在')
5656
+ return
5657
+ }
5658
+ compUpsert({ id, autoStart: enabled })
5659
+ if (enabled) {
5660
+ compStart(id).catch(() => {})
5661
+ }
5662
+ sendJson(res, 200, { ok: true, id, autoStart: enabled })
5663
+ return
5664
+ }
5665
+
4944
5666
  if (pathname === `${ROUTE_PREFIX}/component/start`) {
4945
5667
  const id = typeof body.id === 'string' ? body.id : ''
4946
5668
  if (id === '') {