@noob-stupid/dsh-plugin-console 0.3.32 → 0.3.46
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/README.md +18 -0
- package/README.zh.md +18 -0
- package/SECURITY.md +113 -0
- package/lib/client.js +332 -46
- package/lib/index.js +1069 -100
- package/package.json +44 -43
package/lib/index.js
CHANGED
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
* 移除该块即恢复;HMR 监视器会自动重组合,无需重启。
|
|
14
14
|
*/
|
|
15
15
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
16
|
-
import { readFileSync, writeFileSync, existsSync, statSync, rmSync, readdirSync, mkdirSync, copyFileSync } from 'node:fs'
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, statSync, rmSync, readdirSync, mkdirSync, copyFileSync, realpathSync } from 'node:fs'
|
|
17
17
|
import { execFile, spawn } from 'node:child_process'
|
|
18
18
|
import { promisify } from 'node:util'
|
|
19
|
-
import { dirname, join, basename } from 'node:path'
|
|
19
|
+
import { dirname, join, basename, sep } from 'node:path'
|
|
20
20
|
import { tmpdir, homedir } from 'node:os'
|
|
21
21
|
import { fileURLToPath } from 'node:url'
|
|
22
22
|
import { createRequire } from 'node:module'
|
|
@@ -341,6 +341,26 @@ function semverRangeMatchLoose(versionText, rangeText) {
|
|
|
341
341
|
* 静态声明检查判 pass 是假通过;真实兼容性只有模块 import 时见分晓,而 loader 单行失败
|
|
342
342
|
* = 整个服务启动崩溃)。返回命中的符号列表,空数组 = 干净。
|
|
343
343
|
*/
|
|
344
|
+
const REMOVED_SETTINGS_SYMBOLS = ['settingsNamespace', 'installSettingsSection']
|
|
345
|
+
|
|
346
|
+
/** 源码是否**引用**了已删除符号——而不是「恰好包含同名前缀的其它标识符」或「自己定义的同名局部函数」。
|
|
347
|
+
* v0.3.35 修复(真机演练抓到的误伤):原先用 `text.includes('settingsNamespace')` 子串匹配,
|
|
348
|
+
* 而框架自带的 @deepseek-ai/dsh-api-settings-controller 里有个 `settingsNamespaceRequestSchema`,
|
|
349
|
+
* 于是框架自己的 settings 控制器被判「引用已删除 API」→ 升级预扫会把它自动禁用(砍掉框架功能)。
|
|
350
|
+
* 现在要求标识符边界,且排除「本地 const/let/var/function/class 定义且同行没有 dsh-settings 引用」。 */
|
|
351
|
+
function referencesRemovedSymbol(text, sym) {
|
|
352
|
+
const re = new RegExp(`(?<![A-Za-z0-9_$])${sym}(?![A-Za-z0-9_$])`, 'u')
|
|
353
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
354
|
+
const m = re.exec(line)
|
|
355
|
+
if (m === null) continue
|
|
356
|
+
const before = line.slice(0, m.index)
|
|
357
|
+
const localDefinition = /(?:^|[^\w$])(?:const|let|var|function|class|async\s+function)\s*$/u.test(before)
|
|
358
|
+
if (localDefinition && !/dsh-settings/u.test(line)) continue
|
|
359
|
+
return true
|
|
360
|
+
}
|
|
361
|
+
return false
|
|
362
|
+
}
|
|
363
|
+
|
|
344
364
|
function scanSettingsApiUsage(pkgDir) {
|
|
345
365
|
const found = []
|
|
346
366
|
const seen = new Set()
|
|
@@ -361,8 +381,9 @@ function scanSettingsApiUsage(pkgDir) {
|
|
|
361
381
|
if (seen.size > 120) return
|
|
362
382
|
try {
|
|
363
383
|
const text = readFileSync(full, 'utf8').slice(0, 400000)
|
|
364
|
-
for (const sym of
|
|
365
|
-
if (
|
|
384
|
+
for (const sym of REMOVED_SETTINGS_SYMBOLS) {
|
|
385
|
+
if (found.includes(sym)) continue
|
|
386
|
+
if (referencesRemovedSymbol(text, sym)) found.push(sym)
|
|
366
387
|
}
|
|
367
388
|
} catch {}
|
|
368
389
|
}
|
|
@@ -371,6 +392,18 @@ function scanSettingsApiUsage(pkgDir) {
|
|
|
371
392
|
return found
|
|
372
393
|
}
|
|
373
394
|
|
|
395
|
+
/** 包是否由框架自带(解析到 profile 目录之外,如 npx/pnpm 缓存里的 @deepseek-ai/*)。
|
|
396
|
+
* v0.3.35:这类行随框架一起发布,禁用**不是**正确处置(正确处置是回滚框架),
|
|
397
|
+
* 而且一旦判定有误就会把框架功能砍掉——所以升级预扫永不自动禁用它们。 */
|
|
398
|
+
function isFrameworkOwnedPackage(pkgDir, profileDir) {
|
|
399
|
+
if (typeof pkgDir !== 'string' || pkgDir === '' || typeof profileDir !== 'string' || profileDir === '') return false
|
|
400
|
+
const real = (p) => { try { return realpathSync(p) } catch { return p } }
|
|
401
|
+
const dir = real(pkgDir).toLowerCase()
|
|
402
|
+
let base = real(profileDir).toLowerCase()
|
|
403
|
+
if (!base.endsWith(sep)) base += sep
|
|
404
|
+
return !dir.startsWith(base)
|
|
405
|
+
}
|
|
406
|
+
|
|
374
407
|
/**
|
|
375
408
|
* 插件-框架兼容校验(框架升级适配门):
|
|
376
409
|
* 1) 显式兼容声明(dsh.engines.framework / dsh.compat.framework / engines.dsh):以声明为准;
|
|
@@ -1073,6 +1106,208 @@ function writeCompatPending(payload) {
|
|
|
1073
1106
|
} catch {}
|
|
1074
1107
|
}
|
|
1075
1108
|
|
|
1109
|
+
/** 兼容门总开关(用户定案 2026-09-11):用户可关掉自动行为,回到纯手动。
|
|
1110
|
+
* autoDisable —— 升级前是否自动禁用判定不适配的行(关掉 = 只提示不动开关)
|
|
1111
|
+
* autoDetect —— 打开控制台时是否自动检测「已适配」(关掉 = 不显示可解锁提示) */
|
|
1112
|
+
const COMPAT_GATE_FILE = () => join(dshHome(), 'plugin-console', 'compat-gate.json')
|
|
1113
|
+
const COMPAT_GATE_DEFAULTS = { autoDisable: true, autoDetect: true }
|
|
1114
|
+
function readCompatGate() {
|
|
1115
|
+
try {
|
|
1116
|
+
const raw = JSON.parse(readFileSync(COMPAT_GATE_FILE(), 'utf8'))
|
|
1117
|
+
return {
|
|
1118
|
+
autoDisable: raw?.autoDisable !== false,
|
|
1119
|
+
autoDetect: raw?.autoDetect !== false,
|
|
1120
|
+
}
|
|
1121
|
+
} catch { return { ...COMPAT_GATE_DEFAULTS } }
|
|
1122
|
+
}
|
|
1123
|
+
function writeCompatGate(patch) {
|
|
1124
|
+
const next = { ...readCompatGate(), ...patch }
|
|
1125
|
+
try {
|
|
1126
|
+
mkdirSync(dirname(COMPAT_GATE_FILE()), { recursive: true })
|
|
1127
|
+
writeFileSync(COMPAT_GATE_FILE(), JSON.stringify(next, null, 2), 'utf8')
|
|
1128
|
+
} catch {}
|
|
1129
|
+
return next
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** 待适配行的「现在是否已适配」检测(只提示,不自动解锁——用户定案:我点才开)。
|
|
1133
|
+
* 返回 Map<rowId, {version, check, note}>:仅当版本已变化且源码扫描不再 fail 时才算可解锁。 */
|
|
1134
|
+
function detectAdoptablePending(ctx) {
|
|
1135
|
+
const out = new Map()
|
|
1136
|
+
const pending = readCompatPending()
|
|
1137
|
+
if (pending === null) return out
|
|
1138
|
+
const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : null
|
|
1139
|
+
if (fwVer === null) return out
|
|
1140
|
+
let profileDir = null
|
|
1141
|
+
try { profileDir = dirname(findPatchPath(ctx)) } catch { return out }
|
|
1142
|
+
for (const p of (pending.pending ?? []).filter((x) => (x.status ?? 'pending') === 'pending')) {
|
|
1143
|
+
try {
|
|
1144
|
+
const pkgPath = resolvePackageJson(p.moduleName, profileDir)
|
|
1145
|
+
if (pkgPath === null) continue
|
|
1146
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
1147
|
+
const version = typeof pkg.version === 'string' ? pkg.version : null
|
|
1148
|
+
const changed = version !== null && version !== p.version
|
|
1149
|
+
const check = checkPluginFrameworkCompat(pkg, fwVer, dirname(pkgPath))
|
|
1150
|
+
if (changed && check.decision !== 'fail') {
|
|
1151
|
+
out.set(p.rowId, { version, check: check.decision, note: check.reason ?? null })
|
|
1152
|
+
}
|
|
1153
|
+
} catch {}
|
|
1154
|
+
}
|
|
1155
|
+
return out
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** 把升级脚本留下的「启动失败隔离」记录并入适配门清单(只处理一次:处理后改名 .applied)。
|
|
1159
|
+
* 作用:服务被隔离救回来之后,用户能在面板上看到「谁被自动关了、为什么」,并可逐个解锁。 */
|
|
1160
|
+
/** 读取升级脚本写下的隔离记录(含 BOM 兼容)。
|
|
1161
|
+
* ★ 2026-09-11 事故根因:升级脚本用 PowerShell `Set-Content -Encoding UTF8` 写这个文件,
|
|
1162
|
+
* PS5.1 会**带 UTF-8 BOM**;而合并逻辑先"复制 + 删除"再判断 JSON.parse 结果 → BOM 让 parse
|
|
1163
|
+
* 必失败 → 记录被销毁、却从未并进适配门清单 → 界面上那 20 行只剩一个**没有解释的【停用】**。
|
|
1164
|
+
* 这里统一剥 BOM 再解析;解析失败返回 null(调用方会保留文件、留错误日志,绝不销毁证据)。 */
|
|
1165
|
+
function readQuarantineRecord() {
|
|
1166
|
+
const file = join(dshHome(), 'plugin-console', 'fw-quarantine.json')
|
|
1167
|
+
if (!existsSync(file)) return null
|
|
1168
|
+
let raw = ''
|
|
1169
|
+
try { raw = readFileSync(file, 'utf8') } catch { return null }
|
|
1170
|
+
if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1)
|
|
1171
|
+
try {
|
|
1172
|
+
const rec = JSON.parse(raw)
|
|
1173
|
+
return rec !== null && typeof rec === 'object' ? rec : null
|
|
1174
|
+
} catch { return null }
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/** 合并隔离记录失败时留痕(v0.3.44):静默 catch 让 2026-09-11 那次 20 行隔离记录凭空消失,
|
|
1178
|
+
* 界面只剩一个没有解释的【停用】,而且事后完全查不到原因。错误落到 fw-merge-error.log。 */
|
|
1179
|
+
function logQuarantineMergeError(error) {
|
|
1180
|
+
try {
|
|
1181
|
+
const file = join(dshHome(), 'plugin-console', 'fw-merge-error.log')
|
|
1182
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
1183
|
+
writeFileSync(file, `${new Date().toISOString()} ${error?.stack ?? String(error)}\n`, { flag: 'a' })
|
|
1184
|
+
} catch {}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/** 把升级脚本留下的「启动失败隔离」记录并入适配门清单。
|
|
1188
|
+
* 顺序很关键(v0.3.44):**先写清单并校验成功,再归档移除记录**。反过来的话,
|
|
1189
|
+
* 任何解析/写入意外都会"记录没了、清单也没进",用户只看到一个没有理由的【停用】。
|
|
1190
|
+
* 导出仅供测试直接驱动(避免测试为了跑它而 apply 整个插件)。 */
|
|
1191
|
+
export function mergeQuarantineRecord(ctx) {
|
|
1192
|
+
const file = join(dshHome(), 'plugin-console', 'fw-quarantine.json')
|
|
1193
|
+
if (!existsSync(file)) return null
|
|
1194
|
+
const rec = readQuarantineRecord()
|
|
1195
|
+
if (rec === null) {
|
|
1196
|
+
logQuarantineMergeError(new Error('隔离记录无法解析(已按 BOM 兼容处理仍失败),保留文件待下次启动重试'))
|
|
1197
|
+
return null
|
|
1198
|
+
}
|
|
1199
|
+
const rows = (Array.isArray(rec.rows) ? rec.rows : []).filter((rowId) => typeof rowId === 'string' && rowId !== '')
|
|
1200
|
+
const pending = readCompatPending() ?? { frameworkVersion: null, upgradeFrom: null, pending: [] }
|
|
1201
|
+
if (!Array.isArray(pending.pending)) pending.pending = []
|
|
1202
|
+
const lines = Array.isArray(rec.lines) ? rec.lines : []
|
|
1203
|
+
// 隔离记录里只有 rowId(脚本写的),但清单里其它地方都按 moduleName 认身份:
|
|
1204
|
+
// 「全家桶一键启用已适配」按 moduleName 前缀匹配、`检测到已适配 vX` 也要 moduleName 才能算。
|
|
1205
|
+
// 所以这里按当前 loader 反查补上(v0.3.45:补不上就会永远匹配不到 —— 用户实测「点了说没有待适配行」)。
|
|
1206
|
+
const info = ctx === undefined || ctx === null ? new Map() : rowIdModuleMap(ctx)
|
|
1207
|
+
for (const rowId of rows) {
|
|
1208
|
+
const meta = info.get(rowId) ?? null
|
|
1209
|
+
const record = {
|
|
1210
|
+
rowId,
|
|
1211
|
+
moduleName: meta?.moduleName ?? null,
|
|
1212
|
+
version: meta?.version ?? null,
|
|
1213
|
+
status: 'pending',
|
|
1214
|
+
check: 'unknown',
|
|
1215
|
+
checkNote: `启动失败隔离(${rec.mode ?? 'targeted'}):${lines[0] ?? '启动日志命中,服务曾被它拖垮'}`,
|
|
1216
|
+
forcedAt: Date.now(),
|
|
1217
|
+
source: 'boot-quarantine',
|
|
1218
|
+
}
|
|
1219
|
+
const at = pending.pending.findIndex((p) => p.rowId === rowId)
|
|
1220
|
+
if (at >= 0) pending.pending[at] = { ...pending.pending[at], ...record }
|
|
1221
|
+
else pending.pending.push(record)
|
|
1222
|
+
// 该行现在就是启用的(比如用户在隔离之后已经手动启用过)→ 直接记成已适配,别留一个假 pending
|
|
1223
|
+
if (meta?.enabled === true) markPendingAdopted(pending, rowId, 'row-enabled', meta)
|
|
1224
|
+
}
|
|
1225
|
+
// 预设隔离单独记录(它不是插件行,没有「启用」语义;解锁 = 把 .broken 文件改回来)
|
|
1226
|
+
if (Array.isArray(rec.presets) && rec.presets.length > 0) {
|
|
1227
|
+
pending.presetsQuarantined = rec.presets.map((name) => ({ name, at: rec.at ?? null, note: 'agent.cordis.yml 已改名 .broken(启动失败隔离),确认修好后改回文件名即可恢复' }))
|
|
1228
|
+
}
|
|
1229
|
+
pending.quarantineAt = rec.at ?? null
|
|
1230
|
+
pending.quarantineLines = lines.slice(0, 5)
|
|
1231
|
+
writeCompatPending(pending)
|
|
1232
|
+
// 写后校验:清单里必须真的能看到这些行,才允许销毁原始记录
|
|
1233
|
+
const back = readCompatPending()
|
|
1234
|
+
const missing = rows.filter((rowId) => !(back?.pending ?? []).some((p) => p.rowId === rowId))
|
|
1235
|
+
if (back === null || missing.length > 0) {
|
|
1236
|
+
logQuarantineMergeError(new Error(`隔离记录写入清单后校验失败(缺失 ${missing.length}/${rows.length} 行),保留记录待下次启动重试`))
|
|
1237
|
+
return rec
|
|
1238
|
+
}
|
|
1239
|
+
try {
|
|
1240
|
+
copyFileSync(file, `${file}.applied-${Date.now()}`)
|
|
1241
|
+
rmSync(file, { force: true })
|
|
1242
|
+
} catch {}
|
|
1243
|
+
return rec
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/** 当前 loader 树里的 rowId → { moduleName, version, enabled }(补 moduleName、对账用)。 */
|
|
1247
|
+
export function rowIdModuleMap(ctx) {
|
|
1248
|
+
const out = new Map()
|
|
1249
|
+
let entries = []
|
|
1250
|
+
try { entries = listEntries(ctx) } catch { return out }
|
|
1251
|
+
const profileDir = profileDirOf(ctx)
|
|
1252
|
+
for (const entry of entries) {
|
|
1253
|
+
if (typeof entry.rowId !== 'string' || entry.rowId === '') continue
|
|
1254
|
+
let version = null
|
|
1255
|
+
try {
|
|
1256
|
+
const pkgPath = profileDir === null ? null : resolvePackageJson(entry.moduleName, profileDir)
|
|
1257
|
+
if (pkgPath !== null) version = JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? null
|
|
1258
|
+
} catch {}
|
|
1259
|
+
out.set(entry.rowId, { moduleName: entry.moduleName ?? null, version, enabled: entry.enabled === true })
|
|
1260
|
+
}
|
|
1261
|
+
return out
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/** 把某行的待适配记录标记为「已适配」(保留历史判定痕迹:check / checkNote / riskyApprovedAt)。
|
|
1265
|
+
* 语义(用户定案 2026-09-11):**启用即视为已适配**,但要留下"曾被判定/隔离"的痕迹供事后查。 */
|
|
1266
|
+
function markPendingAdopted(pending, rowId, by, meta) {
|
|
1267
|
+
const rec = (pending?.pending ?? []).find((p) => p.rowId === rowId)
|
|
1268
|
+
if (rec === undefined) return false
|
|
1269
|
+
rec.status = 'adopted'
|
|
1270
|
+
rec.adoptedAt = Date.now()
|
|
1271
|
+
rec.adoptedBy = by
|
|
1272
|
+
if (meta !== null && meta !== undefined) {
|
|
1273
|
+
if (typeof meta.moduleName === 'string' && meta.moduleName !== '') rec.moduleName = meta.moduleName
|
|
1274
|
+
if (typeof meta.version === 'string' && meta.version !== '') rec.version = meta.version
|
|
1275
|
+
}
|
|
1276
|
+
return true
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/** 启动时让清单与现实对账(v0.3.45):
|
|
1280
|
+
* ① 老记录缺 moduleName → 按当前 loader 补上(否则「全家桶一键启用已适配」永远匹配不到);
|
|
1281
|
+
* ② 记录还是 pending、但这一行**当前已经启用**(用户手动启用过 / 补丁被清过)→ 转 adopted。
|
|
1282
|
+
* 不这么做就会出现用户实测的那种矛盾:**已启用的行,重启后仍挂着【待适配】**。
|
|
1283
|
+
* 返回 { backfilled, adopted } 供日志/测试核对;无变化则不写盘。 */
|
|
1284
|
+
export function reconcileCompatPending(ctx) {
|
|
1285
|
+
const pending = readCompatPending()
|
|
1286
|
+
if (pending === null || !Array.isArray(pending.pending)) return { backfilled: 0, adopted: 0 }
|
|
1287
|
+
const info = rowIdModuleMap(ctx)
|
|
1288
|
+
let backfilled = 0
|
|
1289
|
+
let adopted = 0
|
|
1290
|
+
for (const rec of pending.pending) {
|
|
1291
|
+
if ((rec.status ?? 'pending') !== 'pending') continue
|
|
1292
|
+
const meta = info.get(rec.rowId) ?? null
|
|
1293
|
+
if (meta !== null) {
|
|
1294
|
+
if ((rec.moduleName === null || rec.moduleName === undefined || rec.moduleName === '') && typeof meta.moduleName === 'string' && meta.moduleName !== '') {
|
|
1295
|
+
rec.moduleName = meta.moduleName
|
|
1296
|
+
if (meta.version !== null && meta.version !== undefined) rec.version = meta.version
|
|
1297
|
+
backfilled += 1
|
|
1298
|
+
}
|
|
1299
|
+
if (meta.enabled === true) {
|
|
1300
|
+
if (markPendingAdopted(pending, rec.rowId, 'row-enabled', meta)) adopted += 1
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
if (backfilled > 0 || adopted > 0) {
|
|
1305
|
+
pending.updatedAt = new Date().toISOString()
|
|
1306
|
+
writeCompatPending(pending)
|
|
1307
|
+
}
|
|
1308
|
+
return { backfilled, adopted }
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1076
1311
|
/** 定位 @deepseek-ai/dsh-app-boot(与 @deepseek-ai/dsh 同级)。 */
|
|
1077
1312
|
function locateAppBootFile(baseUrl) {
|
|
1078
1313
|
try {
|
|
@@ -1181,6 +1416,27 @@ function profileDirOf(ctx) {
|
|
|
1181
1416
|
}
|
|
1182
1417
|
}
|
|
1183
1418
|
|
|
1419
|
+
/** 框架版本检查缓存(功能包 → 框架 面板用;5 分钟 TTL,避免每次打开面板都打 registry)。 */
|
|
1420
|
+
let fwCheckCache = null
|
|
1421
|
+
|
|
1422
|
+
/** 清理残留的框架升级/回滚/重启计划任务(脚本被强杀时它来不及自删)。
|
|
1423
|
+
* v0.3.43:把「重启」与「重启守护」任务也纳入——2026-09-11 现场残留了 5 个 Ready 僵尸任务
|
|
1424
|
+
* (DSH-Restart-13804 / -31688 / -3744 / RestartV2 / RestartV3),正是"重启后没人拉起"的证据。
|
|
1425
|
+
* 只在服务已经起来了的时候清理是安全的:服务在跑 ⇒ 守护任务无事可做(它自己也会立刻收工)。 */
|
|
1426
|
+
function cleanupStaleFwTasks() {
|
|
1427
|
+
try {
|
|
1428
|
+
execFile('schtasks', ['/query', '/fo', 'CSV', '/nh'], { windowsHide: true, timeout: 20000 }, (error, stdout) => {
|
|
1429
|
+
if (error) return
|
|
1430
|
+
const names = String(stdout).split(/\r?\n/u)
|
|
1431
|
+
.map((line) => (line.match(/^"([^"]*)"/u)?.[1] ?? '').trim())
|
|
1432
|
+
.filter((name) => /^\\?DSH-(?:FW-(?:Upgrade|Rollback)|Restart(?:V\d+)?|RestartGuard)-\d+$/u.test(name))
|
|
1433
|
+
for (const name of names) {
|
|
1434
|
+
execFile('schtasks', ['/delete', '/f', '/tn', name], { windowsHide: true, timeout: 20000 }, () => {})
|
|
1435
|
+
}
|
|
1436
|
+
})
|
|
1437
|
+
} catch {}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1184
1440
|
/** 读取补丁文件并扫描:停用块与 insert 行的 id。 */
|
|
1185
1441
|
async function readPatchState(patchPath) {
|
|
1186
1442
|
let text = ''
|
|
@@ -2346,17 +2602,17 @@ let installJobSeq = 0
|
|
|
2346
2602
|
const aiJobs = new Map()
|
|
2347
2603
|
let aiJobSeq = 0
|
|
2348
2604
|
/** 组件注册表(服务型组件:启动/停止/状态控制按钮的数据源)。 */
|
|
2349
|
-
const
|
|
2605
|
+
const componentsFile = () => join(dshHome(), 'plugin-console', 'components.json')
|
|
2350
2606
|
/** AI 赋能任务持久化文件(DSH 重启不丢计划/结果;running 任务重启后标记中断)。 */
|
|
2351
|
-
const
|
|
2607
|
+
const aiJobsFile = () => join(dshHome(), 'plugin-console', 'ai-jobs.json')
|
|
2352
2608
|
/** 仓库落地根目录(可配置,默认 ~/.dsh/repos)。 */
|
|
2353
2609
|
let reposDirCache = null
|
|
2354
|
-
const
|
|
2610
|
+
const repoLandConfFile = () => join(dshHome(), 'plugin-console', 'repo-land.json')
|
|
2355
2611
|
function getReposDir() {
|
|
2356
2612
|
if (reposDirCache !== null) return reposDirCache
|
|
2357
2613
|
try {
|
|
2358
|
-
if (existsSync(
|
|
2359
|
-
const conf = JSON.parse(readFileSync(
|
|
2614
|
+
if (existsSync(repoLandConfFile())) {
|
|
2615
|
+
const conf = JSON.parse(readFileSync(repoLandConfFile(), 'utf8'))
|
|
2360
2616
|
if (typeof conf.dir === 'string' && conf.dir.trim() !== '') {
|
|
2361
2617
|
reposDirCache = conf.dir.trim()
|
|
2362
2618
|
return reposDirCache
|
|
@@ -2368,8 +2624,8 @@ function getReposDir() {
|
|
|
2368
2624
|
}
|
|
2369
2625
|
function setReposDir(dir) {
|
|
2370
2626
|
reposDirCache = String(dir ?? '').trim()
|
|
2371
|
-
mkdirSync(dirname(
|
|
2372
|
-
writeFileSync(
|
|
2627
|
+
mkdirSync(dirname(repoLandConfFile()), { recursive: true })
|
|
2628
|
+
writeFileSync(repoLandConfFile(), JSON.stringify({ dir: reposDirCache }, null, 2), 'utf8')
|
|
2373
2629
|
return reposDirCache
|
|
2374
2630
|
}
|
|
2375
2631
|
/** 已落地仓库列表:扫描 dir 下两级目录(owner/name 下含 .git)。 */
|
|
@@ -2394,15 +2650,15 @@ function listLandedRepos() {
|
|
|
2394
2650
|
}
|
|
2395
2651
|
function saveAiJobs() {
|
|
2396
2652
|
try {
|
|
2397
|
-
mkdirSync(dirname(
|
|
2653
|
+
mkdirSync(dirname(aiJobsFile()), { recursive: true })
|
|
2398
2654
|
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 }))
|
|
2399
|
-
writeFileSync(
|
|
2655
|
+
writeFileSync(aiJobsFile(), JSON.stringify(arr, null, 2), 'utf8')
|
|
2400
2656
|
} catch {}
|
|
2401
2657
|
}
|
|
2402
2658
|
function loadAiJobs() {
|
|
2403
2659
|
try {
|
|
2404
|
-
if (!existsSync(
|
|
2405
|
-
const arr = JSON.parse(readFileSync(
|
|
2660
|
+
if (!existsSync(aiJobsFile())) return
|
|
2661
|
+
const arr = JSON.parse(readFileSync(aiJobsFile(), 'utf8'))
|
|
2406
2662
|
for (const j of arr) {
|
|
2407
2663
|
if (!j || typeof j.id !== 'string') continue
|
|
2408
2664
|
let job = j
|
|
@@ -2722,7 +2978,7 @@ async function runSuiteInstallJob(job, ctx) {
|
|
|
2722
2978
|
|
|
2723
2979
|
/** 技能安装:git clone 仓库 → 定位 SKILL.md(根或第一层子目录)→ 复制到 ~/.dsh/skills/<name>/。
|
|
2724
2980
|
* 技能由 dsh-skill-filesystem 插件扫描(发现根:<dshHome>/skills),与桌面版共享。 */
|
|
2725
|
-
async function runSkillInstallJob(job
|
|
2981
|
+
async function runSkillInstallJob(job) {
|
|
2726
2982
|
const tmpDir = join(tmpdir(), `dsh-skill-${job.id}-${Date.now()}`)
|
|
2727
2983
|
try {
|
|
2728
2984
|
job.stage = 'preparing'
|
|
@@ -3146,7 +3402,7 @@ function resolvePythonPath() {
|
|
|
3146
3402
|
|
|
3147
3403
|
function findComponents() {
|
|
3148
3404
|
try {
|
|
3149
|
-
const parsed = JSON.parse(readFileSync(
|
|
3405
|
+
const parsed = JSON.parse(readFileSync(componentsFile(), 'utf8'))
|
|
3150
3406
|
return Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.components) ? parsed.components : [])
|
|
3151
3407
|
} catch {
|
|
3152
3408
|
return []
|
|
@@ -3154,8 +3410,8 @@ function findComponents() {
|
|
|
3154
3410
|
}
|
|
3155
3411
|
|
|
3156
3412
|
function saveComponents(list) {
|
|
3157
|
-
mkdirSync(dirname(
|
|
3158
|
-
writeFileSync(
|
|
3413
|
+
mkdirSync(dirname(componentsFile()), { recursive: true })
|
|
3414
|
+
writeFileSync(componentsFile(), JSON.stringify(list, null, 2), 'utf8')
|
|
3159
3415
|
}
|
|
3160
3416
|
|
|
3161
3417
|
function compFind(id) {
|
|
@@ -3797,6 +4053,309 @@ function checkpointFrameworkTree(fwRoot, destRoot) {
|
|
|
3797
4053
|
return { dest, mirrored }
|
|
3798
4054
|
}
|
|
3799
4055
|
|
|
4056
|
+
/**
|
|
4057
|
+
* 预设/组合文件的配置迁移门禁(2026-09-10 事故):
|
|
4058
|
+
* 框架升级会改「插件配置 schema」——0.1.5 的 @deepseek-ai/dsh-persona 把 text 改名为 prefix(必填)。
|
|
4059
|
+
* 适配门只扫「已装插件包源码」,**预设不在其中**(~/.dsh/.agent-presets/<name>/agent.cordis.yml),
|
|
4060
|
+
* 于是升级后预设挂载失败 → 服务/会话起不来(这次就是这么挂的)。
|
|
4061
|
+
* 升级前按目标版本扫描并自动迁移(写 .bak 备份),返回迁移清单供步骤展示。
|
|
4062
|
+
*/
|
|
4063
|
+
const PRESET_CONFIG_MIGRATIONS = [
|
|
4064
|
+
{ pkg: '@deepseek-ai/dsh-persona', from: 'text', to: 'prefix', since: '0.1.5' },
|
|
4065
|
+
]
|
|
4066
|
+
|
|
4067
|
+
/**
|
|
4068
|
+
* 生成「拉起 DSH 服务」的 PowerShell 前导块(升级脚本 / 回滚脚本共用,v0.3.37 事故修复)。
|
|
4069
|
+
*
|
|
4070
|
+
* 2026-09-11 事故:脚本在「重启服务」这一步崩溃,服务没人拉起(框架其实已经升级成功,
|
|
4071
|
+
* 界面却显示全红)。根因是一条**静默的 null**:
|
|
4072
|
+
* $binNow = ''; try { $binNow = (& node -e "…require.resolve…" | Select-Object -Last 1) } catch {}
|
|
4073
|
+
* if ($binNow -ne '' -and (Test-Path $binNow)) { … }
|
|
4074
|
+
* 当 node 解析那一瞬间失败(新版链接尚未就绪等)时输出为空 → `Select-Object -Last 1` 让
|
|
4075
|
+
* `$binNow` 变成 **$null**,而 PowerShell 里 `$null -ne ''` 是 **true**(守卫失效)→
|
|
4076
|
+
* `Test-Path $null` 抛「无法将参数绑定到参数"Path",因为该参数是空值」→ 脚本当场终止。
|
|
4077
|
+
* 同一段代码原先被复制了 5 份,所以这个坑反复出现。
|
|
4078
|
+
*
|
|
4079
|
+
* 现在:只保留这一份实现,并且
|
|
4080
|
+
* 1) 返回值**永不为 $null**(非字符串一律归一成空串,再做 IsNullOrWhiteSpace 判断);
|
|
4081
|
+
* 2) 解析走**多级回退**(不再假设某一处路径一定可用):node resolve → 目标版本的 .pnpm 实体
|
|
4082
|
+
* 目录 → 顶层可见链接 → .pnpm 里最新的一个;
|
|
4083
|
+
* 3) 全路径参数一律 `Test-Path -LiteralPath`,失败只记录、不抛错。
|
|
4084
|
+
*/
|
|
4085
|
+
function relaunchPrelude({ nodePath, pluginDir, fwRoot, target, ps }) {
|
|
4086
|
+
const probe = "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'))"
|
|
4087
|
+
const pnpmBin = (dirExpr) => `Join-Path ${dirExpr} 'node_modules\\@deepseek-ai\\dsh\\lib\\bin.js'`
|
|
4088
|
+
return [
|
|
4089
|
+
`$launchLog = ''`,
|
|
4090
|
+
// 心跳(v0.3.39):脚本每推进一小步就更新一次心跳文件的时间戳。服务端据此区分
|
|
4091
|
+
// 「脚本还在干活」与「脚本进程被系统/启动器杀掉」。2026-09-11 真机事故:回滚脚本
|
|
4092
|
+
// 干完活之后被 Ctrl+C 类事件结束(计划任务 Last Result = 0xC000013A),终态没写成,
|
|
4093
|
+
// 界面就永远卡在「回滚中…」——有心跳就能判定「脚本已死 + 现实是什么」。
|
|
4094
|
+
`$hb = $state + '.hb'`,
|
|
4095
|
+
`function Beat { try { Set-Content -Path $hb -Value ([string](Get-Date).Ticks) -Encoding UTF8 } catch {} }`,
|
|
4096
|
+
`try { Beat } catch {}`,
|
|
4097
|
+
`try { if ($log) { $launchLog = Join-Path (Split-Path -LiteralPath $log) 'fw-relaunch.log' } } catch {}`,
|
|
4098
|
+
`if ([string]::IsNullOrWhiteSpace($launchLog)) { $launchLog = Join-Path $env:TEMP 'fw-relaunch.log' }`,
|
|
4099
|
+
`function Resolve-DshBin {`,
|
|
4100
|
+
` $cand = ''`,
|
|
4101
|
+
` try { $env:DSH_RESOLVE_ROOT = ${ps(pluginDir)}; $cand = (& ${ps(nodePath)} -e "${probe}" 2>$null | Select-Object -Last 1) } catch { $cand = '' }`,
|
|
4102
|
+
` if ($cand -isnot [string]) { $cand = '' }`,
|
|
4103
|
+
` $cand = ([string]$cand).Trim()`,
|
|
4104
|
+
` if ($cand -ne '' -and (Test-Path -LiteralPath $cand)) { return $cand }`,
|
|
4105
|
+
` try { foreach ($d in @(Get-ChildItem -Path (Join-Path ${ps(fwRoot)} '.pnpm') -Directory -Filter '@deepseek-ai+dsh@${target}*' -ErrorAction SilentlyContinue)) { $c = ${pnpmBin('$d.FullName')}; if (Test-Path -LiteralPath $c) { return $c } } } catch {}`,
|
|
4106
|
+
` try { $c = ${ps(join(fwRoot, '@deepseek-ai', 'dsh', 'lib', 'bin.js'))}; if (Test-Path -LiteralPath $c) { return $c } } catch {}`,
|
|
4107
|
+
` try { foreach ($d in @(Get-ChildItem -Path (Join-Path ${ps(fwRoot)} '.pnpm') -Directory -Filter '@deepseek-ai+dsh@*' -ErrorAction SilentlyContinue | Sort-Object Name -Descending)) { $c = ${pnpmBin('$d.FullName')}; if (Test-Path -LiteralPath $c) { return $c } } } catch {}`,
|
|
4108
|
+
` return ''`,
|
|
4109
|
+
`}`,
|
|
4110
|
+
`function Invoke-DshRelaunch($tag) {`,
|
|
4111
|
+
` $bin = Resolve-DshBin`,
|
|
4112
|
+
` if ([string]::IsNullOrWhiteSpace($bin)) { Log ('拉起失败(' + $tag + '):node resolve / .pnpm / 顶层链接 三种方式都找不到 dsh 的 bin.js,请手动启动 DSH'); return $false }`,
|
|
4113
|
+
` try { Add-Content -Path $launchLog -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' 拉起(' + $tag + '): ' + $bin) -Encoding UTF8 } catch {}`,
|
|
4114
|
+
` try { Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c', ('"' + ${ps(nodePath)} + '" "' + $bin + '" web >> "' + $launchLog + '" 2>&1')) -WindowStyle Hidden } catch { Log ('拉起进程启动失败(' + $tag + '):' + $_.Exception.Message); return $false }`,
|
|
4115
|
+
` Log ('已发起拉起服务(' + $tag + ',输出见 fw-relaunch.log)')`,
|
|
4116
|
+
` return $true`,
|
|
4117
|
+
`}`,
|
|
4118
|
+
].join('\r\n')
|
|
4119
|
+
}
|
|
4120
|
+
|
|
4121
|
+
/** 待扫描的 agent 配置文件:所有预设 + profile 的 host 组合。 */
|
|
4122
|
+
function collectAgentConfigFiles(profileDir) {
|
|
4123
|
+
const files = []
|
|
4124
|
+
const presetRoot = join(dshHome(), '.agent-presets')
|
|
4125
|
+
try {
|
|
4126
|
+
for (const entry of readdirSync(presetRoot, { withFileTypes: true })) {
|
|
4127
|
+
if (!entry.isDirectory()) continue
|
|
4128
|
+
for (const name of ['agent.cordis.yml', 'agent.cordis.yaml']) {
|
|
4129
|
+
const f = join(presetRoot, entry.name, name)
|
|
4130
|
+
if (existsSync(f)) files.push(f)
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
4133
|
+
} catch {}
|
|
4134
|
+
for (const name of ['cordis.yml', 'cordis.yaml', 'cordis.patch.yml']) {
|
|
4135
|
+
const f = join(profileDir, name)
|
|
4136
|
+
if (existsSync(f)) files.push(f)
|
|
4137
|
+
}
|
|
4138
|
+
return files
|
|
4139
|
+
}
|
|
4140
|
+
|
|
4141
|
+
/** 迁移一个文件里 persona 行 config 的旧键(仅当尚无新键时改写,先写 .bak)。 */
|
|
4142
|
+
function migratePresetPersona(file, mig) {
|
|
4143
|
+
let text = null
|
|
4144
|
+
try { text = readFileSync(file, 'utf8') } catch { return null }
|
|
4145
|
+
const lines = text.split(/\r?\n/u)
|
|
4146
|
+
let rowAt = -1
|
|
4147
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
4148
|
+
if (new RegExp(`name:\\s*['"]?${escapeRegExp(mig.pkg)}['"]?\\s*$`, 'u').test(lines[i])) { rowAt = i; break }
|
|
4149
|
+
}
|
|
4150
|
+
if (rowAt === -1) return null
|
|
4151
|
+
let cfgAt = -1
|
|
4152
|
+
for (let i = rowAt + 1; i < lines.length; i += 1) {
|
|
4153
|
+
if (/^\s*config:\s*$/u.test(lines[i])) { cfgAt = i; break }
|
|
4154
|
+
if (/^\s*-\s+id:/u.test(lines[i])) break
|
|
4155
|
+
}
|
|
4156
|
+
if (cfgAt === -1) return null
|
|
4157
|
+
const cfgIndent = lines[cfgAt].match(/^\s*/u)[0].length
|
|
4158
|
+
let endAt = lines.length
|
|
4159
|
+
for (let i = cfgAt + 1; i < lines.length; i += 1) {
|
|
4160
|
+
const line = lines[i]
|
|
4161
|
+
if (line.trim() === '' || /^\s*#/u.test(line)) continue
|
|
4162
|
+
if (line.match(/^\s*/u)[0].length <= cfgIndent) { endAt = i; break }
|
|
4163
|
+
}
|
|
4164
|
+
let hasNew = false
|
|
4165
|
+
let hit = -1
|
|
4166
|
+
for (let i = cfgAt + 1; i < endAt; i += 1) {
|
|
4167
|
+
if (new RegExp(`^\\s*${mig.to}:`, 'u').test(lines[i])) { hasNew = true; break }
|
|
4168
|
+
if (new RegExp(`^\\s*${mig.from}:`, 'u').test(lines[i])) hit = i
|
|
4169
|
+
}
|
|
4170
|
+
if (hasNew || hit === -1) return null
|
|
4171
|
+
lines[hit] = lines[hit].replace(new RegExp(`^(\\s*)${mig.from}:`, 'u'), `$1${mig.to}:`)
|
|
4172
|
+
try {
|
|
4173
|
+
copyFileSync(file, `${file}.bak-${Date.now()}`)
|
|
4174
|
+
writeFileSync(file, lines.join('\n'), 'utf8')
|
|
4175
|
+
} catch { return null }
|
|
4176
|
+
return { file, from: mig.from, to: mig.to, line: hit + 1, pkg: mig.pkg }
|
|
4177
|
+
}
|
|
4178
|
+
|
|
4179
|
+
/** 目标版本是否 ≥ since(只比 major.minor.patch,忽略 rc 段——0.1.5-rc.1 也算已达 0.1.5 变更)。 */
|
|
4180
|
+
function isVersionAtLeast(target, since) {
|
|
4181
|
+
const t = parseFrameworkVersion(target)
|
|
4182
|
+
const s = parseFrameworkVersion(since)
|
|
4183
|
+
if (t === -1 || s === -1) return false
|
|
4184
|
+
if (t.maj !== s.maj) return t.maj > s.maj
|
|
4185
|
+
if (t.min !== s.min) return t.min > s.min
|
|
4186
|
+
return t.pat >= s.pat
|
|
4187
|
+
}
|
|
4188
|
+
|
|
4189
|
+
/** 升级前扫描预设/组合文件并按目标版本迁移(返回迁移清单)。导出供测试直接调用。 */
|
|
4190
|
+
export function migrateAgentConfigsForUpgrade(profileDir, targetVersion) {
|
|
4191
|
+
const out = []
|
|
4192
|
+
if (typeof targetVersion !== 'string' || targetVersion === '') return out
|
|
4193
|
+
for (const mig of PRESET_CONFIG_MIGRATIONS) {
|
|
4194
|
+
if (!isVersionAtLeast(targetVersion, mig.since)) continue
|
|
4195
|
+
for (const file of collectAgentConfigFiles(profileDir)) {
|
|
4196
|
+
const done = migratePresetPersona(file, mig)
|
|
4197
|
+
if (done !== null) out.push(done)
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
return out
|
|
4201
|
+
}
|
|
4202
|
+
|
|
4203
|
+
/**
|
|
4204
|
+
* 启动失败日志分析器(纯函数,导出供测试):
|
|
4205
|
+
* 从服务/预设的启动失败日志里提取「谁把服务搞挂了」——用于**启动失败隔离**(升级后服务拉不起来时,
|
|
4206
|
+
* 先禁用/隔离肇事者并重试,而不是整包回滚)。三类信号:
|
|
4207
|
+
* ① 预设挂载失败:`preset "router-spec" failed to mount` 或路径 `.agent-presets/<name>/agent.cordis.yml`
|
|
4208
|
+
* ② loader 条目应用失败:`failed to apply loader entry <rowId> (<moduleName>)`
|
|
4209
|
+
* ③ 模块解析失败:`Cannot find module '<moduleName>'`
|
|
4210
|
+
* 返回 { presets:[name], modules:[{rowId,moduleName}], lines:[命中行] }
|
|
4211
|
+
*/
|
|
4212
|
+
export function analyzeBootFailure(logText) {
|
|
4213
|
+
const text = String(logText ?? '')
|
|
4214
|
+
const presets = new Set()
|
|
4215
|
+
const modules = new Map() // moduleName -> rowId|null
|
|
4216
|
+
const hits = []
|
|
4217
|
+
const pushHit = (line) => { if (hits.length < 40 && !hits.includes(line.trim())) hits.push(line.trim().slice(0, 300)) }
|
|
4218
|
+
|
|
4219
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
4220
|
+
if (line.trim() === '') continue
|
|
4221
|
+
let matched = false
|
|
4222
|
+
for (const m of line.matchAll(/preset\s+"([^"]+)"\s+failed to mount/gu)) { presets.add(m[1]); matched = true }
|
|
4223
|
+
for (const m of line.matchAll(/\.agent-presets[\\/]([^\\/\s"']+)[\\/]agent\.cordis\.ya?ml/gu)) { presets.add(m[1]); matched = true }
|
|
4224
|
+
for (const m of line.matchAll(/failed to apply loader entry\s+(\S+)\s+\(([^)]+)\)/gu)) { modules.set(m[2], m[1]); matched = true }
|
|
4225
|
+
for (const m of line.matchAll(/Cannot find module '([^']+)'/gu)) {
|
|
4226
|
+
if (!modules.has(m[1])) modules.set(m[1], null)
|
|
4227
|
+
matched = true
|
|
4228
|
+
}
|
|
4229
|
+
if (matched) pushHit(line)
|
|
4230
|
+
}
|
|
4231
|
+
return {
|
|
4232
|
+
presets: [...presets],
|
|
4233
|
+
modules: [...modules].map(([moduleName, rowId]) => ({ moduleName, rowId })),
|
|
4234
|
+
lines: hits,
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
|
|
4238
|
+
/** 启动失败隔离决策器(纯函数,导出供测试)。
|
|
4239
|
+
* 输入:启动失败日志 + 当前可开关行清单([{rowId,moduleName,toggleable}])。
|
|
4240
|
+
* 输出一份**可直接执行的隔离方案**(PowerShell 侧只负责照做,不掺判断逻辑):
|
|
4241
|
+
* presets 要隔离的预设文件(改名 .broken-<ts>,避免预设挂载失败拖垮整个服务)
|
|
4242
|
+
* rows 要写入 disabled:true 的行(已剔除核心行/受保护行/控制台自身)
|
|
4243
|
+
* safeMode 无明确肇事者时是否建议安全模式(禁用全部第三方行,先让服务起来)
|
|
4244
|
+
* coreHits 命中的行属于核心/受保护(禁它没用,正确动作是回滚框架)
|
|
4245
|
+
* 设计依据:loader 单行 import 失败 = 整个服务启动崩溃;静态扫描抓不到全部不兼容,
|
|
4246
|
+
* 所以必须有一条「起不来 → 定位肇事者 → 隔离 → 重试」的运行时兜底。 */
|
|
4247
|
+
export function planQuarantine({ logText, candidates, presetRoot = null, exists = existsSync }) {
|
|
4248
|
+
const analysis = analyzeBootFailure(logText)
|
|
4249
|
+
const byModule = new Map()
|
|
4250
|
+
const byRow = new Map()
|
|
4251
|
+
for (const c of candidates ?? []) {
|
|
4252
|
+
if (typeof c?.rowId !== 'string' || c.rowId === '') continue
|
|
4253
|
+
if (typeof c.moduleName === 'string' && c.moduleName !== '') byModule.set(c.moduleName, c)
|
|
4254
|
+
byRow.set(c.rowId, c)
|
|
4255
|
+
}
|
|
4256
|
+
const rows = []
|
|
4257
|
+
const coreHits = []
|
|
4258
|
+
const unknown = []
|
|
4259
|
+
for (const m of analysis.modules) {
|
|
4260
|
+
const hit = (m.rowId !== null && byRow.get(m.rowId)) || byModule.get(m.moduleName) || null
|
|
4261
|
+
if (hit === null) { unknown.push(m.moduleName); continue }
|
|
4262
|
+
const isCore = CORE_PATCH_ROW_IDS.has(hit.rowId) || hit.rowId === 'plugin-console'
|
|
4263
|
+
if (isCore || hit.toggleable === false) { coreHits.push({ rowId: hit.rowId, moduleName: hit.moduleName }); continue }
|
|
4264
|
+
if (!rows.includes(hit.rowId)) rows.push(hit.rowId)
|
|
4265
|
+
}
|
|
4266
|
+
const root = presetRoot ?? join(dshHome(), '.agent-presets')
|
|
4267
|
+
const presets = []
|
|
4268
|
+
for (const name of analysis.presets) {
|
|
4269
|
+
const file = join(root, name, 'agent.cordis.yml')
|
|
4270
|
+
presets.push({ name, file, exists: exists(file) })
|
|
4271
|
+
}
|
|
4272
|
+
return {
|
|
4273
|
+
presets,
|
|
4274
|
+
rows,
|
|
4275
|
+
unknown,
|
|
4276
|
+
coreHits,
|
|
4277
|
+
safeMode: presets.length === 0 && rows.length === 0,
|
|
4278
|
+
lines: analysis.lines,
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
|
|
4282
|
+
/** 框架升级前置门禁(用户硬要求:「升级后所有不适配的必须先禁用」)。
|
|
4283
|
+
* 在升级脚本执行**之前**扫描全部可开关行,对目标框架版本判定为 fail 的行就地写 disabled:true,
|
|
4284
|
+
* 并记入 compat-pending(UI 显示「待适配」,更新后一键解锁)。这样新框架 boot 时不会因为
|
|
4285
|
+
* 某行 import 失败而整树崩溃(loader 单行失败 = 服务起不来)。
|
|
4286
|
+
* 受保护行/核心行/自身永不禁用——禁它们本身就会让服务起不来。
|
|
4287
|
+
* 返回 { disabled:[{rowId,moduleName,version,reason}], skipped:[{rowId,reason}] } */
|
|
4288
|
+
export async function preflightDisableIncompatible({ ctx, profileDir, patchPath, targetVersion }) {
|
|
4289
|
+
const disabled = []
|
|
4290
|
+
const skipped = []
|
|
4291
|
+
const gate = readCompatGate()
|
|
4292
|
+
if (typeof targetVersion !== 'string' || targetVersion === '') return { disabled, skipped }
|
|
4293
|
+
let entries = []
|
|
4294
|
+
try { entries = listEntries(ctx) } catch { return { disabled, skipped } }
|
|
4295
|
+
let pending = readCompatPending()
|
|
4296
|
+
if (pending === null || !Array.isArray(pending.pending)) pending = { frameworkVersion: targetVersion, upgradeFrom: null, pending: [] }
|
|
4297
|
+
for (const entry of entries) {
|
|
4298
|
+
if (typeof entry.rowId !== 'string' || entry.rowId === '') continue
|
|
4299
|
+
if (!entry.enabled) continue
|
|
4300
|
+
if (entry.rowId === 'plugin-console') continue // 控制台自己永不禁用
|
|
4301
|
+
if (!entry.toggleable || CORE_PATCH_ROW_IDS.has(entry.rowId)) {
|
|
4302
|
+
skipped.push({ rowId: entry.rowId, reason: '受保护/核心行(禁用会让服务起不来,改由启动失败隔离兜底)' })
|
|
4303
|
+
continue
|
|
4304
|
+
}
|
|
4305
|
+
let pkg = null
|
|
4306
|
+
let pkgDir = null
|
|
4307
|
+
try {
|
|
4308
|
+
const pkgPath = resolvePackageJson(entry.moduleName, profileDir)
|
|
4309
|
+
if (pkgPath !== null) { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); pkgDir = dirname(pkgPath) }
|
|
4310
|
+
} catch {}
|
|
4311
|
+
if (pkg === null) {
|
|
4312
|
+
skipped.push({ rowId: entry.rowId, reason: '无法读取包信息(保持启用,改由启动失败隔离兜底)' })
|
|
4313
|
+
continue
|
|
4314
|
+
}
|
|
4315
|
+
// v0.3.35:框架自带包永不自动禁用(真机演练抓到:框架自己的 settings 控制器被误判成不适配)。
|
|
4316
|
+
// 它们与框架同源安装,禁用不是正确处置——正确处置是回滚;误判则直接砍掉框架功能。
|
|
4317
|
+
if (isFrameworkOwnedPackage(pkgDir, profileDir)) {
|
|
4318
|
+
skipped.push({ rowId: entry.rowId, reason: '框架自带包(与框架同源安装,禁用不是正确处置,改由回滚兜底)' })
|
|
4319
|
+
continue
|
|
4320
|
+
}
|
|
4321
|
+
let check = { decision: 'unknown', reason: '' }
|
|
4322
|
+
try { check = checkPluginFrameworkCompat(pkg, targetVersion, pkgDir) } catch (error) { check = { decision: 'unknown', reason: error instanceof Error ? error.message : String(error) } }
|
|
4323
|
+
if (check.decision !== 'fail') continue // pass / unknown 一律不禁用(避免过度禁用把功能砍掉)
|
|
4324
|
+
if (gate.autoDisable !== true) {
|
|
4325
|
+
// 总开关关闭:只报告不动开关(用户定案:自动行为必须可关)
|
|
4326
|
+
skipped.push({ rowId: entry.rowId, reason: `判定不适配(${check.reason ?? '不兼容'}),但「升级时自动禁用」已关闭——保持启用,请手动处理` })
|
|
4327
|
+
continue
|
|
4328
|
+
}
|
|
4329
|
+
try {
|
|
4330
|
+
await disableEntry(patchPath, entry.rowId)
|
|
4331
|
+
} catch (error) {
|
|
4332
|
+
skipped.push({ rowId: entry.rowId, reason: `禁用写入失败:${error instanceof Error ? error.message : String(error)}` })
|
|
4333
|
+
continue
|
|
4334
|
+
}
|
|
4335
|
+
const version = typeof pkg.version === 'string' ? pkg.version : null
|
|
4336
|
+
disabled.push({ rowId: entry.rowId, moduleName: entry.moduleName, version, reason: check.reason ?? null })
|
|
4337
|
+
const record = {
|
|
4338
|
+
rowId: entry.rowId,
|
|
4339
|
+
moduleName: entry.moduleName,
|
|
4340
|
+
version,
|
|
4341
|
+
status: 'pending',
|
|
4342
|
+
check: 'fail',
|
|
4343
|
+
checkNote: check.reason ?? null,
|
|
4344
|
+
forcedAt: Date.now(),
|
|
4345
|
+
source: 'preflight-disabled-before-upgrade',
|
|
4346
|
+
}
|
|
4347
|
+
const at = pending.pending.findIndex((p) => p.rowId === entry.rowId)
|
|
4348
|
+
if (at >= 0) pending.pending[at] = { ...pending.pending[at], ...record }
|
|
4349
|
+
else pending.pending.push(record)
|
|
4350
|
+
}
|
|
4351
|
+
if (disabled.length > 0) {
|
|
4352
|
+
pending.frameworkVersion = targetVersion
|
|
4353
|
+
pending.updatedAt = new Date().toISOString()
|
|
4354
|
+
writeCompatPending(pending)
|
|
4355
|
+
}
|
|
4356
|
+
return { disabled, skipped }
|
|
4357
|
+
}
|
|
4358
|
+
|
|
3800
4359
|
/** 移除补丁中的单行 disabled/forced 覆盖块(兼容门解锁用)。 */
|
|
3801
4360
|
function removeDisableBlock(patchPath, rowId) {
|
|
3802
4361
|
return queuedWrite(async () => {
|
|
@@ -3965,7 +4524,7 @@ async function runInstallJob(job, ctx) {
|
|
|
3965
4524
|
const skillProbe = await detectSkillRepo(job.repo, branch)
|
|
3966
4525
|
if (skillProbe.hasSkill) {
|
|
3967
4526
|
job.kind = 'skill'
|
|
3968
|
-
await runSkillInstallJob(job
|
|
4527
|
+
await runSkillInstallJob(job)
|
|
3969
4528
|
return
|
|
3970
4529
|
}
|
|
3971
4530
|
job.status = 'failed'
|
|
@@ -4272,6 +4831,20 @@ function listEntries(ctx) { const entries = []
|
|
|
4272
4831
|
export function apply(ctx) {
|
|
4273
4832
|
// 恢复 AI 赋能任务(restart 后 running 置失败,plan-ready/结果保留)
|
|
4274
4833
|
loadAiJobs()
|
|
4834
|
+
// 升级脚本的「启动失败隔离」记录并入适配门清单(让用户看到谁被自动关了)
|
|
4835
|
+
// v0.3.44:这里的 catch 不再静默 —— 2026-09-11 那次隔离记录(20 行)就是这样凭空消失的:
|
|
4836
|
+
// 合并函数在中途抛错、catch 吞掉,界面只剩一个没有解释的【停用】。失败必须留痕。
|
|
4837
|
+
try { mergeQuarantineRecord(ctx) } catch (error) { logQuarantineMergeError(error) }
|
|
4838
|
+
// v0.3.45:清单与现实对账 —— 补 moduleName(否则「全家桶一键启用已适配」永远匹配不到),
|
|
4839
|
+
// 以及把「已经启用、却还挂着 pending」的记录转成已适配(用户实测:启用后重启又变【待适配】)
|
|
4840
|
+
try {
|
|
4841
|
+
const fixed = reconcileCompatPending(ctx)
|
|
4842
|
+
if (fixed.backfilled > 0 || fixed.adopted > 0) {
|
|
4843
|
+
try { writeFileSync(join(dshHome(), 'plugin-console', 'fw-merge-error.log'), `${new Date().toISOString()} 清单对账:补 moduleName ${fixed.backfilled} 行,已启用转已适配 ${fixed.adopted} 行\n`, { flag: 'a' }) } catch {}
|
|
4844
|
+
}
|
|
4845
|
+
} catch (error) { logQuarantineMergeError(error) }
|
|
4846
|
+
// 启动时清掉上次升级/回滚/重启留下的僵尸计划任务(脚本被强杀时来不及自删)
|
|
4847
|
+
try { cleanupStaleFwTasks() } catch {}
|
|
4275
4848
|
// DSH 启动时自动拉起标记了「自启动」的服务器组件(幂等,不阻塞启动)
|
|
4276
4849
|
autostartComponents().catch(() => {})
|
|
4277
4850
|
ctx.effect(() => {
|
|
@@ -4328,11 +4901,14 @@ async function handle(ctx, req, res) {
|
|
|
4328
4901
|
const extraRows = await readExtraBundleRows(dirname(patchPath))
|
|
4329
4902
|
const compatPending = readCompatPending()
|
|
4330
4903
|
const compatPendingRows = new Set((compatPending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => p.rowId))
|
|
4904
|
+
// 兼容门总开关 + 自动检测(只提示不自动开)
|
|
4905
|
+
const compatGate = readCompatGate()
|
|
4906
|
+
const adoptable = compatGate.autoDetect ? detectAdoptablePending(ctx) : new Map()
|
|
4331
4907
|
let rollbackRec = null
|
|
4332
4908
|
try {
|
|
4333
4909
|
const rr = JSON.parse(readFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), 'utf8'))
|
|
4334
4910
|
if (rr !== null && typeof rr.checkpointDir === 'string' && existsSync(join(rr.checkpointDir, '.pnpm'))) {
|
|
4335
|
-
rollbackRec = { from: rr.from ?? null, to: rr.to ?? null, at: rr.at ?? null }
|
|
4911
|
+
rollbackRec = { from: rr.from ?? null, to: rr.to ?? null, at: rr.at ?? null, applicable: true }
|
|
4336
4912
|
}
|
|
4337
4913
|
} catch {}
|
|
4338
4914
|
const entries = listEntries(ctx).map((entry) => {
|
|
@@ -4345,7 +4921,11 @@ async function handle(ctx, req, res) {
|
|
|
4345
4921
|
installDate: meta?.installDate ?? null,
|
|
4346
4922
|
version: meta?.version ?? null,
|
|
4347
4923
|
repository: meta?.repository ?? null,
|
|
4348
|
-
|
|
4924
|
+
// v0.3.45:只有"补丁里此刻确实还禁着它"的行才显示【待适配】——
|
|
4925
|
+
// 记录与开关脱节时(用户手动启用过 / 补丁块被清掉)不能继续挂着「待适配」误导人
|
|
4926
|
+
pendingCompat: compatPendingRows.has(entry.rowId) && patch.disables.includes(entry.rowId),
|
|
4927
|
+
// 自动检测结果(只提示,不自动开):该待适配行现在是否已适配(插件已更新 + 扫描通过)
|
|
4928
|
+
adoptable: adoptable.get(entry.rowId) ?? null,
|
|
4349
4929
|
}
|
|
4350
4930
|
})
|
|
4351
4931
|
const compat = await detectCompat(ctx.baseUrl ?? 'file:///')
|
|
@@ -4355,12 +4935,18 @@ async function handle(ctx, req, res) {
|
|
|
4355
4935
|
// 框架升级检测与适配(备份快照 + 重打框架补丁),try 包裹不阻塞 state 返回
|
|
4356
4936
|
let framework = null
|
|
4357
4937
|
try { framework = detectFrameworkUpgrade(ctx) } catch {}
|
|
4938
|
+
// 回滚可用性(2026-09-11 用户困惑:版本已经回滚了,回滚按钮还能点)——
|
|
4939
|
+
// 当前版本已经等于记录里的 from 时,再点回滚等于"恢复到你现在这个版本",无意义。
|
|
4940
|
+
if (rollbackRec !== null) {
|
|
4941
|
+
const currentVer = typeof framework?.version === 'string' ? framework.version : null
|
|
4942
|
+
rollbackRec.applicable = !(currentVer !== null && rollbackRec.from !== null && currentVer === rollbackRec.from)
|
|
4943
|
+
}
|
|
4358
4944
|
let selfVersion = null
|
|
4359
4945
|
try {
|
|
4360
4946
|
const selfPkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'))
|
|
4361
4947
|
selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
|
|
4362
4948
|
} catch {}
|
|
4363
|
-
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 })) })
|
|
4949
|
+
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, riskyApprovedAt: p.riskyApprovedAt ?? null, adoptable: adoptable.get(p.rowId) ?? null })) }, compatGate, 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 })) })
|
|
4364
4950
|
return
|
|
4365
4951
|
}
|
|
4366
4952
|
|
|
@@ -4444,7 +5030,50 @@ async function handle(ctx, req, res) {
|
|
|
4444
5030
|
if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1)
|
|
4445
5031
|
const [st, ...rest] = raw.split('|')
|
|
4446
5032
|
const at = statSync(f).mtimeMs
|
|
4447
|
-
|
|
5033
|
+
// v0.3.37:失败记录里带 stage=<崩溃前最后阶段>,界面据此把已完成的步骤显示成 ✓(而不是整列 ✕)
|
|
5034
|
+
const stageRaw = rest.find((p) => p.startsWith('stage='))
|
|
5035
|
+
const stage = stageRaw === undefined ? null : (stageRaw.slice(6) || null)
|
|
5036
|
+
status = { status: st ?? 'idle', message: rest.filter((p) => !p.startsWith('stage=')).join('|') || null, stage, at }
|
|
5037
|
+
// ── v0.3.39 状态自愈 ──────────────────────────────────────────────────
|
|
5038
|
+
// 2026-09-11 真机:回滚脚本干完活之后**进程被 Ctrl+C 类事件结束**(计划任务 Last Result
|
|
5039
|
+
// = 0xC000013A),终态没写成 → 界面永远卡在「回滚中…」并每 3 秒轮询。心跳 + 现实核对
|
|
5040
|
+
// 能把它纠正回来:脚本不再心跳(>90 秒没动静)时,用「已装版本 vs 回滚记录的 from/to」
|
|
5041
|
+
// 判断真实结果;同时清掉残留的 DSH-FW-* 计划任务。
|
|
5042
|
+
const terminal = status.status === 'idle' || status.status === 'done' || status.status === 'failed'
|
|
5043
|
+
if (!terminal) {
|
|
5044
|
+
let hbAge = null
|
|
5045
|
+
try { hbAge = Date.now() - statSync(`${f}.hb`).mtimeMs } catch { hbAge = null }
|
|
5046
|
+
const scriptAlive = hbAge !== null && hbAge < 90000
|
|
5047
|
+
if (!scriptAlive) {
|
|
5048
|
+
let rec = null
|
|
5049
|
+
let current = null
|
|
5050
|
+
try { rec = JSON.parse(readFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), 'utf8')) } catch {}
|
|
5051
|
+
try {
|
|
5052
|
+
const localRequire = createRequire(ctx.baseUrl ?? 'file:///')
|
|
5053
|
+
current = JSON.parse(readFileSync(localRequire.resolve('@deepseek-ai/dsh/package.json'), 'utf8')).version ?? null
|
|
5054
|
+
} catch {}
|
|
5055
|
+
const upgradedTo = rec !== null && typeof rec.to === 'string' && current === rec.to
|
|
5056
|
+
const rolledBackTo = rec !== null && typeof rec.from === 'string' && current === rec.from
|
|
5057
|
+
// 「安装阶段」不能靠 from 判定(那时本来就还是旧版本),只认 to
|
|
5058
|
+
if (upgradedTo) {
|
|
5059
|
+
status = { ...status, status: 'done', reconciled: { from: st, note: `脚本进程已中断,但框架已是 ${current}、服务正常 —— 实际结果:升级成功` } }
|
|
5060
|
+
} else if (rolledBackTo && (st === 'rollback' || st === 'relaunching' || st === 'stopped')) {
|
|
5061
|
+
status = { ...status, status: 'done', reconciled: { from: st, note: `脚本进程已中断,但框架已回到 ${current}、服务正常 —— 实际结果:回滚成功` } }
|
|
5062
|
+
} else if (hbAge !== null) {
|
|
5063
|
+
status = { ...status, stalled: true, note: '升级/回滚脚本已超过 90 秒没有心跳,进程可能已被结束——请用「重新检查版本」核对,必要时重启服务' }
|
|
5064
|
+
}
|
|
5065
|
+
if (status.reconciled !== undefined) cleanupStaleFwTasks()
|
|
5066
|
+
}
|
|
5067
|
+
}
|
|
5068
|
+
// 失败但框架本体其实已经装到目标版本:明确告诉用户「升级本体成功、失败的是重启那一步」,
|
|
5069
|
+
// 免得整列红叉让人以为白干了(2026-09-11 真机事故就是这样)。
|
|
5070
|
+
if (status.status === 'failed') {
|
|
5071
|
+
try {
|
|
5072
|
+
const rr = JSON.parse(readFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), 'utf8'))
|
|
5073
|
+
const cur = JSON.parse(readFileSync(join(rr.fwRoot, '@deepseek-ai', 'dsh', 'package.json'), 'utf8')).version
|
|
5074
|
+
if (typeof rr.to === 'string' && rr.to !== '' && cur === rr.to) status.frameworkAtTarget = cur
|
|
5075
|
+
} catch {}
|
|
5076
|
+
}
|
|
4448
5077
|
// 残留清理:非终止状态(starting/stopped/installing/rollback/pkg/relaunching)超过 15 分钟
|
|
4449
5078
|
// 视为上次升级的残留——升级脚本要么成功(done)要么失败(failed),服务重启后不可能还在
|
|
4450
5079
|
// 中途;任务调度失败/脚本空跑时状态会永远停在 starting,重启后不应再自动恢复进度条。
|
|
@@ -4561,12 +5190,34 @@ async function handle(ctx, req, res) {
|
|
|
4561
5190
|
return
|
|
4562
5191
|
}
|
|
4563
5192
|
if (enabled) {
|
|
4564
|
-
//
|
|
5193
|
+
// 框架升级适配门(用户定案 2026-09-11:**软禁**)——自动禁用的目的是「保证新框架能起来」,
|
|
5194
|
+
// 不是「剥夺用户控制权」:默认自动禁用,但允许手动强行启用(首次请求返回 needsConfirm,
|
|
5195
|
+
// 前端弹风险提示确认框;带 confirmRisky:true 才放行)。
|
|
5196
|
+
// 注意下面还有一条**硬**门禁:启用前的 import 冒烟检查——那条是事实性崩溃(模块根本加载不了),
|
|
5197
|
+
// 不允许覆盖(否则下次启动必崩,与「服务永不崩」冲突)。
|
|
4565
5198
|
const compatPending = readCompatPending()
|
|
4566
5199
|
const pend = (compatPending?.pending ?? []).find((p) => p.rowId === rowId && (p.status ?? 'pending') === 'pending')
|
|
4567
5200
|
if (pend) {
|
|
4568
|
-
|
|
4569
|
-
|
|
5201
|
+
if (body.confirmRisky !== true) {
|
|
5202
|
+
sendError(
|
|
5203
|
+
res,
|
|
5204
|
+
409,
|
|
5205
|
+
`「${rowId}」在框架升级适配门清单中(框架 ${compatPending.frameworkVersion ?? '?'}${pend.checkNote ? `,判定:${pend.checkNote}` : ''})——已自动禁用。强行启用可能让 DSH 下次启动失败,需要你确认。`,
|
|
5206
|
+
{
|
|
5207
|
+
code: 'compat-confirm',
|
|
5208
|
+
rowId,
|
|
5209
|
+
moduleName: pend.moduleName ?? null,
|
|
5210
|
+
frameworkVersion: compatPending.frameworkVersion ?? null,
|
|
5211
|
+
checkNote: pend.checkNote ?? null,
|
|
5212
|
+
},
|
|
5213
|
+
)
|
|
5214
|
+
return
|
|
5215
|
+
}
|
|
5216
|
+
try {
|
|
5217
|
+
const next = readCompatPending()
|
|
5218
|
+
const rec = (next?.pending ?? []).find((p) => p.rowId === rowId)
|
|
5219
|
+
if (rec) { rec.riskyApprovedAt = Date.now(); writeCompatPending(next) }
|
|
5220
|
+
} catch {}
|
|
4570
5221
|
}
|
|
4571
5222
|
}
|
|
4572
5223
|
const patchPath = findPatchPath(ctx)
|
|
@@ -4587,6 +5238,19 @@ async function handle(ctx, req, res) {
|
|
|
4587
5238
|
const result = enabled
|
|
4588
5239
|
? await enableEntry(patchPath, rowId)
|
|
4589
5240
|
: await disableEntry(patchPath, rowId)
|
|
5241
|
+
// v0.3.45(用户定案:启用即视为已适配,但保留痕迹):手动启用一个待适配行后,
|
|
5242
|
+
// 清单里的 pending 记录要转成 adopted —— 否则重启后界面上会出现「已启用却还挂着【待适配】」,
|
|
5243
|
+
// 用户实测就是这样(5 行)。check / checkNote / riskyApprovedAt 一律保留供事后查。
|
|
5244
|
+
if (enabled) {
|
|
5245
|
+
try {
|
|
5246
|
+
const list = readCompatPending()
|
|
5247
|
+
const meta = rowIdModuleMap(ctx).get(rowId) ?? null
|
|
5248
|
+
if (list !== null && markPendingAdopted(list, rowId, 'manual-enable', meta)) {
|
|
5249
|
+
list.updatedAt = new Date().toISOString()
|
|
5250
|
+
writeCompatPending(list)
|
|
5251
|
+
}
|
|
5252
|
+
} catch {}
|
|
5253
|
+
}
|
|
4590
5254
|
sendJson(res, 200, { ok: true, entryId, rowId, enabled, changed: result.changed, patchPath })
|
|
4591
5255
|
return
|
|
4592
5256
|
}
|
|
@@ -5314,6 +5978,49 @@ async function handle(ctx, req, res) {
|
|
|
5314
5978
|
return
|
|
5315
5979
|
}
|
|
5316
5980
|
|
|
5981
|
+
if (pathname === `${ROUTE_PREFIX}/framework-check`) {
|
|
5982
|
+
// 框架版本检查(「功能包 → 框架」常驻面板用):当前版本 / latest / next / 升级目标。
|
|
5983
|
+
// 与升级路由同一套判定规则,但**只读**(不备份、不写状态文件);5 分钟内存缓存 +
|
|
5984
|
+
// body.refresh === true 强制重查——面板是常驻入口,不能每次打开都打 registry。
|
|
5985
|
+
const now = Date.now()
|
|
5986
|
+
if (body.refresh === true || fwCheckCache === null || now - fwCheckCache.at > 300000) {
|
|
5987
|
+
let latest = null
|
|
5988
|
+
let next = null
|
|
5989
|
+
let registryError = null
|
|
5990
|
+
try {
|
|
5991
|
+
const data = await fetchJsonUrl('https://registry.npmmirror.com/@deepseek-ai%2fdsh')
|
|
5992
|
+
latest = data?.['dist-tags']?.latest ?? null
|
|
5993
|
+
next = data?.['dist-tags']?.next ?? null
|
|
5994
|
+
} catch (error) {
|
|
5995
|
+
registryError = error instanceof Error ? error.message : String(error)
|
|
5996
|
+
}
|
|
5997
|
+
let current = null
|
|
5998
|
+
try {
|
|
5999
|
+
const localRequire = createRequire(ctx.baseUrl ?? 'file:///')
|
|
6000
|
+
const pkg = JSON.parse(readFileSync(localRequire.resolve('@deepseek-ai/dsh/package.json'), 'utf8'))
|
|
6001
|
+
current = typeof pkg.version === 'string' ? pkg.version : null
|
|
6002
|
+
} catch {}
|
|
6003
|
+
const target = latest !== null && current !== null && isFrameworkVersionNewer(latest, current)
|
|
6004
|
+
? latest
|
|
6005
|
+
: (next !== null && current !== null && isFrameworkVersionNewer(next, current) ? next : null)
|
|
6006
|
+
fwCheckCache = { at: now, data: { current, latest, next, target, registryError } }
|
|
6007
|
+
}
|
|
6008
|
+
sendJson(res, 200, { ok: true, ...fwCheckCache.data, checkedAt: fwCheckCache.at })
|
|
6009
|
+
return
|
|
6010
|
+
}
|
|
6011
|
+
|
|
6012
|
+
if (pathname === `${ROUTE_PREFIX}/compat-gate`) {
|
|
6013
|
+
// 兼容门总开关(用户定案 2026-09-11):自动行为必须可关,关掉即回到纯手动。
|
|
6014
|
+
// autoDisable —— 升级前是否自动禁用判定不适配的行
|
|
6015
|
+
// autoDetect —— 打开控制台时是否自动检测「已适配」(仅提示,绝不自动解锁)
|
|
6016
|
+
const patchBody = {}
|
|
6017
|
+
if (typeof body.autoDisable === 'boolean') patchBody.autoDisable = body.autoDisable
|
|
6018
|
+
if (typeof body.autoDetect === 'boolean') patchBody.autoDetect = body.autoDetect
|
|
6019
|
+
const gateNext = Object.keys(patchBody).length > 0 ? writeCompatGate(patchBody) : readCompatGate()
|
|
6020
|
+
sendJson(res, 200, { ok: true, compatGate: gateNext })
|
|
6021
|
+
return
|
|
6022
|
+
}
|
|
6023
|
+
|
|
5317
6024
|
if (pathname === `${ROUTE_PREFIX}/adapt-unlock`) {
|
|
5318
6025
|
// 待适配行「已适配,立即解锁」:无需等新版本——对已装模块重跑源码扫描,通过即移除禁用块。
|
|
5319
6026
|
const rowId = typeof body.rowId === 'string' ? body.rowId : ''
|
|
@@ -5369,7 +6076,20 @@ async function handle(ctx, req, res) {
|
|
|
5369
6076
|
const pending = readCompatPending()
|
|
5370
6077
|
const patchPath = findPatchPath(ctx)
|
|
5371
6078
|
const profileDir = dirname(patchPath)
|
|
5372
|
-
|
|
6079
|
+
// v0.3.45:匹配规则改成「moduleName 前缀 **或** 属主行集合」——
|
|
6080
|
+
// 隔离记录合并进来的老行 moduleName 为空(脚本只写 rowId),只按 moduleName 比会匹配不到,
|
|
6081
|
+
// 于是用户点「一键启用已适配」得到「该全家桶没有待适配行」(实测就是这个)。
|
|
6082
|
+
const info = rowIdModuleMap(ctx)
|
|
6083
|
+
const bundleRowIds = new Set()
|
|
6084
|
+
for (const [rowId, meta] of info) {
|
|
6085
|
+
const name = meta?.moduleName
|
|
6086
|
+
if (typeof name === 'string' && name !== '' && (name === root || name.startsWith(root + '/'))) bundleRowIds.add(rowId)
|
|
6087
|
+
}
|
|
6088
|
+
const targets = (pending?.pending ?? []).filter((p) => {
|
|
6089
|
+
if ((p.status ?? 'pending') !== 'pending') return false
|
|
6090
|
+
if (typeof p.moduleName === 'string' && p.moduleName !== '' && (p.moduleName === root || p.moduleName.startsWith(root + '/'))) return true
|
|
6091
|
+
return bundleRowIds.has(p.rowId) || info.get(p.rowId)?.moduleName === root
|
|
6092
|
+
})
|
|
5373
6093
|
if (targets.length === 0) {
|
|
5374
6094
|
// 无待适配行=全部已适配/已解锁,属正常状态:返回友好提示而非错误(点击「一键启用已适配」不应报"操作失败")
|
|
5375
6095
|
sendJson(res, 200, { ok: true, unlocked: [], kept: [], note: '该全家桶没有待适配行(已全部适配/解锁),无需操作' })
|
|
@@ -5544,7 +6264,7 @@ async function handle(ctx, req, res) {
|
|
|
5544
6264
|
// 面板自报名(自报名一致性校验用):从插件自身 package.json 读,与部署目录比对
|
|
5545
6265
|
let selfName = null
|
|
5546
6266
|
try {
|
|
5547
|
-
const selfPkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8'))
|
|
6267
|
+
const selfPkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'))
|
|
5548
6268
|
selfName = typeof selfPkg.name === 'string' ? selfPkg.name : null
|
|
5549
6269
|
} catch {}
|
|
5550
6270
|
try {
|
|
@@ -5588,6 +6308,45 @@ async function handle(ctx, req, res) {
|
|
|
5588
6308
|
const hasUpdate = target !== null
|
|
5589
6309
|
const steps = []
|
|
5590
6310
|
let upgraded = false
|
|
6311
|
+
// 1.5) 预设/config 迁移门禁(写在生成升级脚本之前):框架新版不再接受的旧字段
|
|
6312
|
+
//(如 0.1.5 的 persona.text → prefix)会让升级后**预设挂载失败、服务起不来**——
|
|
6313
|
+
// 适配门只扫插件包,扫不到预设;这里就地改名并留 .bak,避免整次升级被一个字段搞死。
|
|
6314
|
+
let configMigrations = []
|
|
6315
|
+
if (hasUpdate) {
|
|
6316
|
+
try {
|
|
6317
|
+
configMigrations = migrateAgentConfigsForUpgrade(profileDir, target)
|
|
6318
|
+
} catch (error) {
|
|
6319
|
+
steps.push(`预设配置迁移检查失败:${error instanceof Error ? error.message : String(error)}`)
|
|
6320
|
+
}
|
|
6321
|
+
for (const m of configMigrations) {
|
|
6322
|
+
steps.push(`预设字段迁移:${m.file} 第 ${m.line} 行 ${m.from} → ${m.to}(${m.pkg},已留 .bak 备份)`)
|
|
6323
|
+
}
|
|
6324
|
+
if (configMigrations.length > 0) {
|
|
6325
|
+
steps.push(`自动迁移了 ${configMigrations.length} 处框架新版不再接受的预设字段(不迁移会让升级后预设挂载失败、服务拉不起来)`)
|
|
6326
|
+
}
|
|
6327
|
+
}
|
|
6328
|
+
// 1.6) 不适配行预禁用(用户硬要求:「更新框架后所有不适配的必须先禁用」)。
|
|
6329
|
+
// 适配门此前只有「执行侧」(读清单→锁启用→更新后解锁),**检测侧从未实现**:
|
|
6330
|
+
// 那份 compat-pending.json 一直是人工/一次性脚本产出的(v0.3.25 起遗留至今)。
|
|
6331
|
+
// 这里补上检测侧:升级脚本执行**前**扫描全部可开关行,判定 fail 的就地禁用 + 记入清单,
|
|
6332
|
+
// 保证新框架 boot 时不会被某行 import 失败拖垮(loader 单行失败 = 整个服务起不来)。
|
|
6333
|
+
let preflight = { disabled: [], skipped: [] }
|
|
6334
|
+
if (hasUpdate) {
|
|
6335
|
+
try {
|
|
6336
|
+
preflight = await preflightDisableIncompatible({ ctx, profileDir, patchPath: findPatchPath(ctx), targetVersion: target })
|
|
6337
|
+
} catch (error) {
|
|
6338
|
+
steps.push(`不适配行预禁用失败(不阻塞升级,改由启动失败隔离兜底):${error instanceof Error ? error.message : String(error)}`)
|
|
6339
|
+
}
|
|
6340
|
+
for (const row of preflight.disabled) {
|
|
6341
|
+
steps.push(`已预先禁用不适配行:${row.rowId}(${row.moduleName}@${row.version ?? '?'})— ${row.reason ?? '判定不兼容目标框架'}`)
|
|
6342
|
+
}
|
|
6343
|
+
if (preflight.disabled.length > 0) {
|
|
6344
|
+
steps.push(`共预先禁用 ${preflight.disabled.length} 行:新框架启动不会再被它们拖垮;等它们发布适配版本后点「更新并适配」即可解锁`)
|
|
6345
|
+
}
|
|
6346
|
+
if (preflight.skipped.length > 0) {
|
|
6347
|
+
steps.push(`另有 ${preflight.skipped.length} 行无法预判(受保护行/包信息读不到),保持启用,由升级后的启动失败隔离兜底`)
|
|
6348
|
+
}
|
|
6349
|
+
}
|
|
5591
6350
|
// 2) 自动升级框架:运行中的服务锁着 dsh/lib 目录(EBUSY 无法原地替换),
|
|
5592
6351
|
// 因此生成独立升级脚本(kill 服务 → npm 升级 → 失败回滚 → 拉起服务),后台执行。
|
|
5593
6352
|
// 与 /restart 同款自守护模式:端口无监听会自动拉起,不会留下"服务起不来"。
|
|
@@ -5604,7 +6363,7 @@ async function handle(ctx, req, res) {
|
|
|
5604
6363
|
// 避免"kill 后升级/拉起双双失败,服务永久 down"
|
|
5605
6364
|
if (!existsSync(corepackJs) || binPath === null || !existsSync(binPath)) {
|
|
5606
6365
|
steps.push(`升级已取消:无法定位 ${!existsSync(corepackJs) ? 'corepack(pnpm 通道)' : 'dsh bin.js'}(框架安装可能不完整),服务保持运行;请先修复框架再升级`)
|
|
5607
|
-
sendJson(res, 200, { ok: true, current, latest, next, target, hasUpdate, upgraded: false, backupDir, steps, hints: steps })
|
|
6366
|
+
sendJson(res, 200, { ok: true, current, latest, next, target, hasUpdate, upgraded: false, backupDir, configMigrations, preflight, steps, hints: steps })
|
|
5608
6367
|
return
|
|
5609
6368
|
}
|
|
5610
6369
|
const profileDir2 = profileDir
|
|
@@ -5656,21 +6415,72 @@ async function handle(ctx, req, res) {
|
|
|
5656
6415
|
const ps1 = join(tmpdir(), `fw-upgrade-${process.pid}.ps1`)
|
|
5657
6416
|
const logFile = join(dshHome(), 'plugin-console', 'fw-upgrade.log')
|
|
5658
6417
|
const stateFile = join(dshHome(), 'plugin-console', 'fw-upgrade-state.txt')
|
|
6418
|
+
// 启动失败隔离所需的两个文件(升级脚本在服务起不来时调用):
|
|
6419
|
+
// ① 分析器:直接 import 本插件的 planQuarantine,避免把判断逻辑再写一份到 PowerShell;
|
|
6420
|
+
// ② 候选行快照:升级发起时的可开关行(决策器据此把日志里的模块名映射回行 id)。
|
|
6421
|
+
const qHelperPath = join(dshHome(), 'plugin-console', 'fw-analyze-boot.mjs')
|
|
6422
|
+
const qCandidatesPath = join(dshHome(), 'plugin-console', 'fw-quarantine-candidates.json')
|
|
6423
|
+
const qRecordPath = join(dshHome(), 'plugin-console', 'fw-quarantine.json')
|
|
6424
|
+
let thirdPartyRows = []
|
|
6425
|
+
try {
|
|
6426
|
+
thirdPartyRows = listEntries(ctx)
|
|
6427
|
+
.filter((e) => e.enabled && e.toggleable && e.rowId !== 'plugin-console'
|
|
6428
|
+
&& !CORE_PATCH_ROW_IDS.has(e.rowId)
|
|
6429
|
+
&& typeof e.moduleName === 'string' && !e.moduleName.startsWith('@deepseek-ai/'))
|
|
6430
|
+
.map((e) => e.rowId)
|
|
6431
|
+
} catch {}
|
|
6432
|
+
try {
|
|
6433
|
+
const selfLibUrl = new URL('./index.js', import.meta.url).href
|
|
6434
|
+
const helperSource = [
|
|
6435
|
+
'// 自动生成:启动失败隔离分析器(升级脚本在服务起不来时调用;逻辑在插件内,本文件只做转发)',
|
|
6436
|
+
`import { planQuarantine } from ${JSON.stringify(selfLibUrl)}`,
|
|
6437
|
+
"import { readFileSync } from 'node:fs'",
|
|
6438
|
+
'const [logPath, candPath] = process.argv.slice(2)',
|
|
6439
|
+
"let logText = ''",
|
|
6440
|
+
"try { logText = readFileSync(logPath, 'utf8').split(/\\r?\\n/u).slice(-400).join('\\n') } catch {}",
|
|
6441
|
+
'let candidates = []',
|
|
6442
|
+
'try { candidates = JSON.parse(readFileSync(candPath, "utf8")) } catch {}',
|
|
6443
|
+
'try { process.stdout.write(JSON.stringify(planQuarantine({ logText, candidates }))) } catch (error) { process.stdout.write(JSON.stringify({ error: String(error && error.message ? error.message : error) })) }',
|
|
6444
|
+
'',
|
|
6445
|
+
].join('\n')
|
|
6446
|
+
writeFileSync(qHelperPath, helperSource, 'utf8')
|
|
6447
|
+
const candidateSnapshot = listEntries(ctx)
|
|
6448
|
+
.filter((e) => typeof e.rowId === 'string' && e.rowId !== '')
|
|
6449
|
+
.map((e) => ({ rowId: e.rowId, moduleName: e.moduleName, toggleable: e.toggleable === true, enabled: e.enabled === true }))
|
|
6450
|
+
writeFileSync(qCandidatesPath, JSON.stringify(candidateSnapshot, null, 2), 'utf8')
|
|
6451
|
+
} catch {}
|
|
5659
6452
|
try { mkdirSync(dirname(logFile), { recursive: true }) } catch {}
|
|
5660
6453
|
// PowerShell 字符串里反斜杠是字面量(无 \\ 转义):JSON.stringify 产生的 \\ 必须还原为 \,
|
|
5661
6454
|
// 否则所有路径无效(npm 调不起来、回滚无效、拉起失败——曾导致升级脚本空跑)
|
|
5662
|
-
|
|
6455
|
+
// PowerShell 双引号字面量:反斜杠还原 + 空串产出 ''(JSON 的 "" 被单引号包裹会变成字面量 "" ,
|
|
6456
|
+
// 曾导致 $cp = '""' → Test-Path 恒假 → 全树回滚被静默跳过)+ $ / 反引号转义(路径含 $ 不被插值)
|
|
6457
|
+
const ps = (s) => {
|
|
6458
|
+
const j = JSON.stringify(String(s)).replace(/\\\\/gu, '\\')
|
|
6459
|
+
if (j === '""') return "''"
|
|
6460
|
+
return j.replace(/`/gu, '``').replace(/\$/gu, '`$')
|
|
6461
|
+
}
|
|
6462
|
+
const patchFilePath = join(profileDir, 'cordis.patch.yml')
|
|
6463
|
+
// 拉起服务的 PowerShell 片段(隔离重试用;与升级/回滚路径共用 relaunchPrelude 里的同一份实现)
|
|
6464
|
+
const launchSnippet = (tag) => `if (-not $ok -and -not $started) { if (Invoke-DshRelaunch '${tag}') { $started = $true } }; Beat`
|
|
5663
6465
|
const lines = [
|
|
5664
6466
|
`$state = ${ps(stateFile)}`,
|
|
5665
6467
|
`$log = ${ps(logFile)}`,
|
|
5666
6468
|
"function Log($m) { try { Add-Content -Path $log -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $m) -Encoding UTF8 } catch {} }",
|
|
5667
|
-
"function SetState($s, $m) {
|
|
6469
|
+
"function SetState($s, $m) {",
|
|
6470
|
+
// v0.3.37:失败时把**崩溃前最后到达的阶段**一起写进状态文件(stage=…)——
|
|
6471
|
+
// 否则界面只能看到 failed,把已经成功的步骤全打成 ✕(2026-09-11 用户实测就是这个观感:
|
|
6472
|
+
// 框架其实升好了,卡片却整列红叉)。$script:stage 未设置时 [string] 会安全地变成空串。
|
|
6473
|
+
" try { if ($s -eq 'failed') { Set-Content -Path $state -Value ($s + '|' + $m + '|stage=' + [string]$script:stage) -Encoding UTF8; return } } catch {}",
|
|
6474
|
+
" try { if ($s -ne 'done' -and $s -ne 'idle') { $script:stage = $s }; Set-Content -Path $state -Value ($s + '|' + $m) -Encoding UTF8; Beat } catch {}",
|
|
6475
|
+
"}",
|
|
6476
|
+
relaunchPrelude({ nodePath, pluginDir: join(dirname(fileURLToPath(import.meta.url)), '..'), fwRoot, target, ps }),
|
|
5668
6477
|
// 全局异常兜底(事故教训):脚本任何未捕获异常都会无声死亡、状态永远卡住、服务没人拉起。
|
|
5669
6478
|
// trap 捕获后:写 failed 状态 + 日志 + 尝试拉起服务 + 自删任务 + 退出
|
|
5670
6479
|
"trap {",
|
|
5671
6480
|
" try { SetState 'failed' ('升级脚本异常终止:' + $_.Exception.Message) } catch {}",
|
|
5672
|
-
|
|
5673
|
-
|
|
6481
|
+
// 事故教训(2026-09-10):只记消息无法定位是哪一行崩的 —— 连出错位置一起写日志
|
|
6482
|
+
" try { Log ('升级脚本异常终止:' + $_.Exception.Message + ' @ ' + $_.InvocationInfo.PositionMessage) } catch {}",
|
|
6483
|
+
` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if (-not $c) { [void](Invoke-DshRelaunch '异常兜底') } } catch {}`,
|
|
5674
6484
|
` schtasks /delete /f /tn ${taskName} 2>$null`,
|
|
5675
6485
|
" exit 1",
|
|
5676
6486
|
"}",
|
|
@@ -5703,7 +6513,7 @@ async function handle(ctx, req, res) {
|
|
|
5703
6513
|
` $cacheDir = ''`,
|
|
5704
6514
|
` try {`,
|
|
5705
6515
|
` $npmrcFile = Join-Path $env:USERPROFILE '.npmrc'`,
|
|
5706
|
-
` if (Test-Path $npmrcFile) { $m = Select-String -Path $npmrcFile -Pattern '^cache
|
|
6516
|
+
` if (Test-Path $npmrcFile) { $m = Select-String -Path $npmrcFile -Pattern '^cache\\s*=\\s*(.+)$' | Select-Object -Last 1; if ($m) { $cacheDir = $m.Matches[0].Groups[1].Value.Trim().Trim('"') } }`,
|
|
5707
6517
|
` } catch {}`,
|
|
5708
6518
|
` if ($cacheDir -eq '') { $fb = Join-Path $env:APPDATA 'npm-cache'; if (Test-Path (Join-Path $fb '_logs')) { $cacheDir = $fb } }`,
|
|
5709
6519
|
` $npmOut = $log + '.npm.out.txt'`,
|
|
@@ -5739,9 +6549,12 @@ async function handle(ctx, req, res) {
|
|
|
5739
6549
|
` }`,
|
|
5740
6550
|
` if ($code -eq 0) {`,
|
|
5741
6551
|
` try {`,
|
|
5742
|
-
|
|
5743
|
-
|
|
5744
|
-
|
|
6552
|
+
// 事故教训(2026-09-10):版本校验必须看「启动器可见路径」(顶层 @deepseek-ai\dsh),
|
|
6553
|
+
// 而不是 require.resolve 得到的 .pnpm 内部路径——旧逻辑查 .pnpm 里的旧副本,
|
|
6554
|
+
// 于是 pnpm 明明装好了却报「版本未更新,按失败处理」,白等两轮。
|
|
6555
|
+
` $v = (Get-Content ${ps(join(fwRoot, '@deepseek-ai', 'dsh', 'package.json'))} -Raw | ConvertFrom-Json).version`,
|
|
6556
|
+
` if ($v -ne '${target}') { Log ('pnpm 退出码 0 但顶层可见版本未更新(顶层 ' + $v + ',目标 ${target}),按失败处理'); $code = 1 }`,
|
|
6557
|
+
` } catch { Log 'pnpm 退出码 0 但无法读取顶层版本,按失败处理'; $code = 1 }`,
|
|
5745
6558
|
` }`,
|
|
5746
6559
|
` return $code`,
|
|
5747
6560
|
`}`,
|
|
@@ -5752,29 +6565,39 @@ async function handle(ctx, req, res) {
|
|
|
5752
6565
|
`function Invoke-Rollback {`,
|
|
5753
6566
|
` SetState 'rollback' '升级失败,回滚框架(全树/CLI 备份)…'`,
|
|
5754
6567
|
` Log '升级失败,回滚框架…'`,
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
`
|
|
5759
|
-
` $
|
|
5760
|
-
`
|
|
5761
|
-
`
|
|
5762
|
-
` $
|
|
5763
|
-
` $
|
|
5764
|
-
`
|
|
6568
|
+
// 事故教训(2026-09-10):整个回滚体包 try/catch —— 脚本崩在回滚里(曾报
|
|
6569
|
+
// 「无法将参数绑定到参数 Path」,回滚没跑完、旧树半新半旧),必须记录出错位置而不是
|
|
6570
|
+
// 只留一句 trap 消息;回滚失败也要明确写状态,别让人以为已回滚。
|
|
6571
|
+
` try {`,
|
|
6572
|
+
` 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 {}`,
|
|
6573
|
+
` $cp = ${ps(fwCheckpoint?.dest ?? '')}`,
|
|
6574
|
+
` if ($cp -and (Test-Path (Join-Path $cp '.pnpm'))) {`,
|
|
6575
|
+
` $restored = 0`,
|
|
6576
|
+
` $entries = Get-ChildItem -Path (Join-Path $cp '.pnpm') -Directory -ErrorAction SilentlyContinue`,
|
|
6577
|
+
` foreach ($e in $entries) {`,
|
|
6578
|
+
` if ($e.Name -notlike '*+*') { continue }`,
|
|
6579
|
+
` $name = @($e.Name -split '\\+')[1] -replace '@.*$', ''`,
|
|
6580
|
+
` if (-not $name) { continue }`,
|
|
6581
|
+
` $src = Join-Path $e.FullName ('node_modules\\@deepseek-ai\\' + $name)`,
|
|
6582
|
+
` $dst = Join-Path (Join-Path ${ps(fwRoot)} ('.pnpm\\' + $e.Name)) ('node_modules\\@deepseek-ai\\' + $name)`,
|
|
6583
|
+
` 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++ }`,
|
|
6584
|
+
` }`,
|
|
6585
|
+
` $topSrc = Join-Path $cp 'top-@deepseek-ai'`,
|
|
6586
|
+
` 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 }`,
|
|
6587
|
+
` try { Copy-Item (Join-Path $cp 'lock.yaml') ${ps(join(fwRoot, '.pnpm', 'lock.yaml'))} -Force -ErrorAction SilentlyContinue } catch {}`,
|
|
6588
|
+
` Log ('全树回滚完成:恢复 ' + $restored + ' 个版本包 + 顶层 scope')`,
|
|
6589
|
+
` $script:rolledBack = $true`,
|
|
6590
|
+
` return`,
|
|
5765
6591
|
` }`,
|
|
5766
|
-
`
|
|
5767
|
-
` if (Test-Path $
|
|
5768
|
-
`
|
|
5769
|
-
`
|
|
5770
|
-
` $script:rolledBack = $true`,
|
|
5771
|
-
`
|
|
6592
|
+
` # CLI 兜底(checkpoint 缺失或损坏)`,
|
|
6593
|
+
` if (-not (Test-Path ${ps(join(rollbackDir, 'lib', 'bin.js'))})) { Log ('回滚失败:CLI 备份也缺失(' + ${ps(rollbackDir)} + ')'); return }`,
|
|
6594
|
+
` Remove-Item ${ps(dshDir)} -Recurse -Force -ErrorAction SilentlyContinue`,
|
|
6595
|
+
` robocopy ${ps(rollbackDir)} ${ps(dshDir)} /E /NFL /NDL /NJH /NJS /R:1 /W:1 | Out-Null`,
|
|
6596
|
+
` if (Test-Path ${ps(join(dshDir, 'lib', 'bin.js'))}) { Log '回滚完成(CLI 恢复)'; $script:rolledBack = $true } else { Log '回滚失败:复制失败,请手动修复框架安装' }`,
|
|
6597
|
+
` } catch {`,
|
|
6598
|
+
` Log ('回滚过程异常:' + $_.Exception.Message + ' @ ' + $_.InvocationInfo.PositionMessage)`,
|
|
6599
|
+
` SetState 'failed' ('回滚过程异常,框架可能处于混合状态:' + $_.Exception.Message)`,
|
|
5772
6600
|
` }`,
|
|
5773
|
-
` # CLI 兜底(checkpoint 缺失或损坏)`,
|
|
5774
|
-
` if (-not (Test-Path ${ps(join(rollbackDir, 'lib', 'bin.js'))})) { Log ('回滚失败:CLI 备份也缺失(' + ${ps(rollbackDir)} + ')'); return }`,
|
|
5775
|
-
` Remove-Item ${ps(dshDir)} -Recurse -Force -ErrorAction SilentlyContinue`,
|
|
5776
|
-
` robocopy ${ps(rollbackDir)} ${ps(dshDir)} /E /NFL /NDL /NJH /NJS /R:1 /W:1 | Out-Null`,
|
|
5777
|
-
` if (Test-Path ${ps(join(dshDir, 'lib', 'bin.js'))}) { Log '回滚完成(CLI 恢复)'; $script:rolledBack = $true } else { Log '回滚失败:复制失败,请手动修复框架安装' }`,
|
|
5778
6601
|
`}`,
|
|
5779
6602
|
`$script:rolledBack = $false`,
|
|
5780
6603
|
`$code = Install-Framework 'https://registry.npmmirror.com'`,
|
|
@@ -5787,7 +6610,28 @@ async function handle(ctx, req, res) {
|
|
|
5787
6610
|
` $code = Install-Framework 'https://registry.npmmirror.com'`,
|
|
5788
6611
|
` if ($code -ne 0) { Log ('(停服后)npmmirror 安装失败(exit=' + $code + '),切换 registry.npmjs.org 重试一次'); $code = Install-Framework 'https://registry.npmjs.org' }`,
|
|
5789
6612
|
`}`,
|
|
5790
|
-
`if ($code -ne 0) { Invoke-Rollback } else {
|
|
6613
|
+
`if ($code -ne 0) { Invoke-Rollback } else {`,
|
|
6614
|
+
` Log '框架本体升级完成'`,
|
|
6615
|
+
// 事故教训(2026-09-10):启动器(桌面端/`npx dsh`)用的是**顶层** @deepseek-ai\dsh;
|
|
6616
|
+
// 若它是 npm 时代的真实目录,pnpm 无法把它换成链接 → 顶层永远停在旧版,
|
|
6617
|
+
// 桌面端下次自己拉服务就跑旧框架(版本错配 → 拉起失败 → 又提示升级,循环)。
|
|
6618
|
+
// 这里校验「启动器可见版本」,是实体目录就改名备份后重装一次,让 pnpm 重建链接。
|
|
6619
|
+
` $visible = ${ps(join(fwRoot, '@deepseek-ai', 'dsh'))}`,
|
|
6620
|
+
` $vv = ''`,
|
|
6621
|
+
` try { $vv = (Get-Content (Join-Path $visible 'package.json') -Raw | ConvertFrom-Json).version } catch {}`,
|
|
6622
|
+
` if ($vv -ne '${target}') {`,
|
|
6623
|
+
` $item = Get-Item $visible -Force -ErrorAction SilentlyContinue`,
|
|
6624
|
+
` if ($item -and -not $item.LinkType) {`,
|
|
6625
|
+
` $bakName = 'dsh.npm-backup-' + (Get-Date -Format 'yyyyMMdd-HHmmss')`,
|
|
6626
|
+
` try { Rename-Item -Path $visible -NewName $bakName -ErrorAction Stop; Log ('顶层为旧版实体目录(' + $vv + ')→ 已改名备份 ' + $bakName + ',重装以重建链接…') } catch { Log ('顶层实体目录改名失败:' + $_.Exception.Message) }`,
|
|
6627
|
+
` $code = Install-Framework 'https://registry.npmmirror.com'`,
|
|
6628
|
+
` if ($code -ne 0) { $code = Install-Framework 'https://registry.npmjs.org' }`,
|
|
6629
|
+
` $vv = ''`,
|
|
6630
|
+
` try { $vv = (Get-Content (Join-Path $visible 'package.json') -Raw | ConvertFrom-Json).version } catch {}`,
|
|
6631
|
+
` if ($vv -eq '${target}') { Log '顶层可见版本已修正为 ${target}' } else { Log ('顶层可见版本仍为 ' + $vv + '(拉起不受影响:Node 按链接真实路径解析依赖)') }`,
|
|
6632
|
+
` } else { Log ('顶层可见版本异常(' + $vv + ')且非实体目录,跳过改名(LinkType=' + $item.LinkType + ')') }`,
|
|
6633
|
+
` } else { Log '顶层可见版本校验通过' }`,
|
|
6634
|
+
`}`,
|
|
5791
6635
|
// 回滚后跳过重链/依赖修复/版本对齐(它们按新树预期运行,会对已恢复的旧树造成二次破坏)
|
|
5792
6636
|
`if (-not $script:rolledBack) {`,
|
|
5793
6637
|
// 重新链接框架配套包(事故教训):pnpm 升级到新版本时 .pnpm 目录重建,
|
|
@@ -5805,8 +6649,8 @@ async function handle(ctx, req, res) {
|
|
|
5805
6649
|
` # 版本数值比较(修复:字符串比较在版本号位数变化时出错,如 0.1.10 < 0.1.9;PS5.1 兼容,不用三元运算符)`,
|
|
5806
6650
|
` function Compare-Version($a, $b) {`,
|
|
5807
6651
|
` if ($a -eq $b) { return 0 }`,
|
|
5808
|
-
` $pa = [regex]::Match($a, '^(
|
|
5809
|
-
` $pb = [regex]::Match($b, '^(
|
|
6652
|
+
` $pa = [regex]::Match($a, '^(\\d+)\\.(\\d+)\\.(\\d+)(?:-(?:[a-z]+\\.)?(\\d+))?' )`,
|
|
6653
|
+
` $pb = [regex]::Match($b, '^(\\d+)\\.(\\d+)\\.(\\d+)(?:-(?:[a-z]+\\.)?(\\d+))?' )`,
|
|
5810
6654
|
` if (-not $pa.Success -or -not $pb.Success) { return ($a.CompareTo($b)) }`,
|
|
5811
6655
|
` for ($i = 1; $i -le 4; $i++) {`,
|
|
5812
6656
|
` if ($pa.Groups[$i].Success) { $va = [int]$pa.Groups[$i].Value } else { $va = 2147483647 }`,
|
|
@@ -5822,15 +6666,15 @@ async function handle(ctx, req, res) {
|
|
|
5822
6666
|
` try { $topVer = (Get-Content $topPkgJson -Raw | ConvertFrom-Json).version } catch { continue }`,
|
|
5823
6667
|
` # 找 .pnpm 里该包的所有主目录(@deepseek-ai+dsh-<name>@<ver>_<hash> 或 @deepseek-ai+dsh-<name>@<ver>-rc.x_<hash>)`,
|
|
5824
6668
|
` $cands = Get-ChildItem -Path $pnpm -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -match ('^@deepseek-ai\\\\+dsh-' + [regex]::Escape($name.Substring(4)) + '@') }`,
|
|
5825
|
-
` $best = $
|
|
6669
|
+
` $best = $null; $bestVer = ''`,
|
|
5826
6670
|
` foreach ($c in $cands) {`,
|
|
5827
6671
|
` $inner = Join-Path $c.FullName ('node_modules\\@deepseek-ai\\' + $name)`,
|
|
5828
6672
|
` $pj = Join-Path $inner 'package.json'`,
|
|
5829
6673
|
` if (-not (Test-Path $pj)) { continue }`,
|
|
5830
6674
|
` try { $v = (Get-Content $pj -Raw | ConvertFrom-Json).version } catch { continue }`,
|
|
5831
|
-
` if ($
|
|
6675
|
+
` if ($null -eq $best -or (Compare-Version $v $bestVer) -gt 0) { $bestVer = $v; $best = $inner }`,
|
|
5832
6676
|
` }`,
|
|
5833
|
-
` if ($
|
|
6677
|
+
` if ($null -eq $best) { continue }`,
|
|
5834
6678
|
` if ($bestVer -eq $topVer) { continue }`,
|
|
5835
6679
|
` try {`,
|
|
5836
6680
|
` $bak = $top.FullName + '.bak-' + $topVer`,
|
|
@@ -5927,10 +6771,63 @@ async function handle(ctx, req, res) {
|
|
|
5927
6771
|
'$started = $false',
|
|
5928
6772
|
`for ($i = 0; $i -lt 20; $i++) {`,
|
|
5929
6773
|
` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
|
|
5930
|
-
` if (-not $ok -and -not $started) {
|
|
6774
|
+
` if (-not $ok -and -not $started) { if (Invoke-DshRelaunch '升级后') { $started = $true } }; Beat`,
|
|
5931
6775
|
' Start-Sleep -Seconds 5',
|
|
5932
6776
|
'}',
|
|
5933
|
-
//
|
|
6777
|
+
// ── 启动失败隔离(用户硬要求):新框架起不来时先隔离肇事者再重试,不直接回滚 ──
|
|
6778
|
+
// 判定逻辑全在 Node 决策器(fw-analyze-boot.mjs → planQuarantine);这里只执行方案。
|
|
6779
|
+
`function Invoke-Quarantine {`,
|
|
6780
|
+
` $helper = ${ps(qHelperPath)}`,
|
|
6781
|
+
` if (-not (Test-Path $helper)) { Log '隔离分析器缺失,跳过隔离'; return $false }`,
|
|
6782
|
+
` $logPath = Join-Path (Split-Path $log) 'fw-relaunch.log'`,
|
|
6783
|
+
` if (-not (Test-Path $logPath)) { $logPath = $log }`,
|
|
6784
|
+
` $out = ''`,
|
|
6785
|
+
` try { $out = (& ${ps(nodePath)} $helper $logPath ${ps(qCandidatesPath)} 2>$null | Select-Object -Last 1) } catch { Log ('隔离分析调用失败:' + $_.Exception.Message); return $false }`,
|
|
6786
|
+
` if (-not $out) { Log '隔离分析无输出'; return $false }`,
|
|
6787
|
+
` $plan = $null`,
|
|
6788
|
+
` try { $plan = $out | ConvertFrom-Json } catch { Log '隔离分析输出无法解析'; return $false }`,
|
|
6789
|
+
` $acted = $false; $doneP = @(); $doneR = @(); $mode = 'targeted'`,
|
|
6790
|
+
` foreach ($p in @($plan.presets)) {`,
|
|
6791
|
+
` if (-not $p.exists) { continue }`,
|
|
6792
|
+
` try { Rename-Item -LiteralPath $p.file -NewName ((Split-Path $p.file -Leaf) + '.broken-' + (Get-Date -Format 'yyyyMMdd-HHmmss')) -ErrorAction Stop; $doneP += $p.name; $acted = $true; Log ('已隔离预设:' + $p.name + '(改名 .broken,不再参与挂载)') } catch { Log ('预设隔离失败:' + $p.name + ' ' + $_.Exception.Message) }`,
|
|
6793
|
+
` }`,
|
|
6794
|
+
` foreach ($r in @($plan.rows)) {`,
|
|
6795
|
+
` try {`,
|
|
6796
|
+
` $has = Select-String -Path ${ps(patchFilePath)} -Pattern ('^- id: ' + [regex]::Escape($r) + '\\s*$') -Quiet -ErrorAction SilentlyContinue`,
|
|
6797
|
+
` if (-not $has) { Add-Content -Path ${ps(patchFilePath)} -Value ('- id: ' + $r) -Encoding UTF8; Add-Content -Path ${ps(patchFilePath)} -Value ' disabled: true' -Encoding UTF8 }`,
|
|
6798
|
+
` $doneR += $r; $acted = $true; Log ('已禁用不适配行:' + $r)`,
|
|
6799
|
+
` } catch { Log ('禁用行失败:' + $r + ' ' + $_.Exception.Message) }`,
|
|
6800
|
+
` }`,
|
|
6801
|
+
` if (-not $acted -and $plan.safeMode) {`,
|
|
6802
|
+
` $mode = 'safe-mode'`,
|
|
6803
|
+
` foreach ($r in @(${thirdPartyRows.length > 0 ? thirdPartyRows.map((r) => ps(r)).join(', ') : "''"})) {`,
|
|
6804
|
+
` try { $has = Select-String -Path ${ps(patchFilePath)} -Pattern ('^- id: ' + [regex]::Escape($r) + '\\s*$') -Quiet -ErrorAction SilentlyContinue; if (-not $has) { Add-Content -Path ${ps(patchFilePath)} -Value ('- id: ' + $r) -Encoding UTF8; Add-Content -Path ${ps(patchFilePath)} -Value ' disabled: true' -Encoding UTF8 }; $doneR += $r; $acted = $true } catch {}`,
|
|
6805
|
+
` }`,
|
|
6806
|
+
` if ($acted) { Log ('安全模式:已禁用 ' + $doneR.Count + ' 个第三方行,先让服务起来(适配后逐个解锁)') }`,
|
|
6807
|
+
` }`,
|
|
6808
|
+
` if ($acted) {`,
|
|
6809
|
+
` try {`,
|
|
6810
|
+
` $rec = [pscustomobject]@{ at = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'); mode = $mode; presets = $doneP; rows = $doneR; lines = $plan.lines } | ConvertTo-Json -Depth 4`,
|
|
6811
|
+
` Set-Content -Path ${ps(qRecordPath)} -Value $rec -Encoding UTF8`,
|
|
6812
|
+
` } catch {}`,
|
|
6813
|
+
` } else { Log '隔离分析未定位到可隔离对象' }`,
|
|
6814
|
+
` return $acted`,
|
|
6815
|
+
`}`,
|
|
6816
|
+
`if (-not $ok -and -not $script:rolledBack) {`,
|
|
6817
|
+
` Log '服务拉起失败 → 先做启动失败隔离(隔离肇事者后重试,不直接回滚)'`,
|
|
6818
|
+
` for ($q = 1; $q -le 3; $q++) {`,
|
|
6819
|
+
` if (-not (Invoke-Quarantine)) { Log ('第 ' + $q + ' 轮:未定位到可隔离对象,停止隔离'); break }`,
|
|
6820
|
+
` $ok = $false; $started = $false`,
|
|
6821
|
+
` for ($i = 0; $i -lt 12; $i++) {`,
|
|
6822
|
+
` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
|
|
6823
|
+
` ${launchSnippet('隔离后重试')}`,
|
|
6824
|
+
` Start-Sleep -Seconds 5`,
|
|
6825
|
+
` }`,
|
|
6826
|
+
` if ($ok) { Log ('第 ' + $q + ' 轮隔离后服务已起来(被隔离项可在控制台解锁)'); break }`,
|
|
6827
|
+
` Log ('第 ' + $q + ' 轮隔离后仍失败')`,
|
|
6828
|
+
` }`,
|
|
6829
|
+
`}`,
|
|
6830
|
+
// 隔离也救不回才回滚(2026-09-04 教训:自动全树回滚并再拉起一次)
|
|
5934
6831
|
`if (-not $ok -and -not $script:rolledBack) {`,
|
|
5935
6832
|
` Log '服务拉起失败,尝试自动回滚到升级前版本…'`,
|
|
5936
6833
|
` Invoke-Rollback`,
|
|
@@ -5938,7 +6835,7 @@ async function handle(ctx, req, res) {
|
|
|
5938
6835
|
` $started = $false`,
|
|
5939
6836
|
` for ($i = 0; $i -lt 20; $i++) {`,
|
|
5940
6837
|
` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
|
|
5941
|
-
` if (-not $ok -and -not $started) {
|
|
6838
|
+
` if (-not $ok -and -not $started) { if (Invoke-DshRelaunch '回滚后') { $started = $true } }; Beat`,
|
|
5942
6839
|
` Start-Sleep -Seconds 5`,
|
|
5943
6840
|
` }`,
|
|
5944
6841
|
`}`,
|
|
@@ -5947,6 +6844,8 @@ async function handle(ctx, req, res) {
|
|
|
5947
6844
|
].filter((l) => l !== '').join('\r\n')
|
|
5948
6845
|
// 初始状态(备份完成、脚本即将执行)
|
|
5949
6846
|
try { writeFileSync(stateFile, 'starting|备份配置完成,启动升级脚本…', 'utf8') } catch {}
|
|
6847
|
+
// 版本检查缓存作废:升级后 [框架] 面板要立刻显示新版本(否则 5 分钟内还显示"可升级到…")
|
|
6848
|
+
fwCheckCache = null
|
|
5950
6849
|
// UTF-8 BOM:powershell 5.1 按 ANSI 读无 BOM 文件,中文会乱码——加 BOM 保证解析正确。
|
|
5951
6850
|
// 执行方式:schtasks 一次性计划任务(Task Scheduler 启动,独立于服务进程树/Job Object)——
|
|
5952
6851
|
// 服务进程被杀时,其子进程(execFile/detached 的 powershell)会被 Job/进程树连带杀死,
|
|
@@ -6001,7 +6900,7 @@ async function handle(ctx, req, res) {
|
|
|
6001
6900
|
'升级完成后请重启 DSH 服务生效——重启后 Hub 自动:备份新版本配置快照、重打框架补丁(issue #5 容错)、给出适配提示。',
|
|
6002
6901
|
'官方配套组件(@deepseek-ai/*)随框架一起升级,请勿在市场单独更新。',
|
|
6003
6902
|
]
|
|
6004
|
-
sendJson(res, 200, { ok: true, current, latest, next, target, hasUpdate, upgraded, backupDir, registryError, steps, hints })
|
|
6903
|
+
sendJson(res, 200, { ok: true, current, latest, next, target, hasUpdate, upgraded, backupDir, registryError, configMigrations, preflight, steps, hints })
|
|
6005
6904
|
return
|
|
6006
6905
|
}
|
|
6007
6906
|
|
|
@@ -6026,7 +6925,11 @@ async function handle(ctx, req, res) {
|
|
|
6026
6925
|
`$state = ${ps(stateFile)}`,
|
|
6027
6926
|
`$log = ${ps(logFile)}`,
|
|
6028
6927
|
"function Log($m) { try { Add-Content -Path $log -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $m) -Encoding UTF8 } catch {} }",
|
|
6029
|
-
"function SetState($s, $m) {
|
|
6928
|
+
"function SetState($s, $m) {",
|
|
6929
|
+
" try { if ($s -eq 'failed') { Set-Content -Path $state -Value ($s + '|' + $m + '|stage=' + [string]$script:stage) -Encoding UTF8; return } } catch {}",
|
|
6930
|
+
" try { if ($s -ne 'done' -and $s -ne 'idle') { $script:stage = $s }; Set-Content -Path $state -Value ($s + '|' + $m) -Encoding UTF8; Beat } catch {}",
|
|
6931
|
+
"}",
|
|
6932
|
+
relaunchPrelude({ nodePath, pluginDir: join(dirname(fileURLToPath(import.meta.url)), '..'), fwRoot: rec.fwRoot, target: rec.from ?? '', ps }),
|
|
6030
6933
|
"trap {",
|
|
6031
6934
|
" try { SetState 'failed' ('回滚脚本异常终止:' + $_.Exception.Message) } catch {}",
|
|
6032
6935
|
` schtasks /delete /f /tn ${taskName} 2>$null`,
|
|
@@ -6053,13 +6956,14 @@ async function handle(ctx, req, res) {
|
|
|
6053
6956
|
`$started = $false`,
|
|
6054
6957
|
`for ($i = 0; $i -lt 20; $i++) {`,
|
|
6055
6958
|
` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
|
|
6056
|
-
` if (-not $ok -and -not $started) {
|
|
6959
|
+
` if (-not $ok -and -not $started) { if (Invoke-DshRelaunch '回滚后') { $started = $true } }; Beat`,
|
|
6057
6960
|
` Start-Sleep -Seconds 5`,
|
|
6058
6961
|
`}`,
|
|
6059
6962
|
`if ($ok) { SetState 'done' ('已回滚到升级前版本 ${rec.from ?? '?'},服务正常') ; Log '回滚完成,服务已恢复' } else { SetState 'failed' '回滚后服务拉起失败:请手动运行 node ${resolveDshBin() ?? '<bin>'} web' ; Log '回滚后拉起失败' }`,
|
|
6060
6963
|
`schtasks /delete /f /tn ${taskName} 2>$null`,
|
|
6061
6964
|
].filter((l) => l !== '').join('\r\n')
|
|
6062
6965
|
try { writeFileSync(stateFile, 'rollback|回滚脚本已启动…', 'utf8') } catch {}
|
|
6966
|
+
fwCheckCache = null // 回滚后 [框架] 面板要立刻显示回滚到的版本
|
|
6063
6967
|
writeFile(ps1, `\uFEFF${lines}`, 'utf8').then(
|
|
6064
6968
|
() => {
|
|
6065
6969
|
const ps1Posix = ps1.replace(/\\/gu, '/')
|
|
@@ -6212,7 +7116,7 @@ async function handle(ctx, req, res) {
|
|
|
6212
7116
|
}
|
|
6213
7117
|
installJobs.set(job.id, job)
|
|
6214
7118
|
// 后台执行:请求立即返回,安装不受客户端断开/离开面板影响
|
|
6215
|
-
void (kind === 'skill' ? runSkillInstallJob(job
|
|
7119
|
+
void (kind === 'skill' ? runSkillInstallJob(job) : kind === 'suite' ? runSuiteInstallJob(job, ctx) : runInstallJob(job, ctx))
|
|
6216
7120
|
sendJson(res, 200, { ok: true, jobId: job.id, status: 'installing', kind })
|
|
6217
7121
|
return
|
|
6218
7122
|
}
|
|
@@ -6607,6 +7511,16 @@ async function handle(ctx, req, res) {
|
|
|
6607
7511
|
// 不再依赖桌面端监督器(它并不总是会重启服务,曾导致用户需要重启电脑)。
|
|
6608
7512
|
// 安全护栏(事故教训):无法定位 dsh bin 或 bin.js 不存在时**拒绝重启**——
|
|
6609
7513
|
// 避免"kill 后拉不起"(框架缓存损坏时常见),提示先修复框架安装。
|
|
7514
|
+
//
|
|
7515
|
+
// v0.3.43 事故修复(2026-09-11 用户实测「重启后服务没自己拉起来,只能手动重启」):
|
|
7516
|
+
// 现场证据=一堆 Ready 僵尸任务(DSH-Restart-13804 / -31688 / -3744 / V2 / V3),
|
|
7517
|
+
// 说明脚本杀完服务后**自己也被结束了**(与升级/回滚脚本同一个毛病:0xC000013A),
|
|
7518
|
+
// 于是"检查端口→拉起"那几行根本没跑到;而且原来只等 3 秒、只查一次端口。
|
|
7519
|
+
// 现在改成三层保险:
|
|
7520
|
+
// ① 主脚本:等到端口真正空出来(最多 20 秒轮询)→ 拉起 → 重试 3 次 → 写 console-restart.log
|
|
7521
|
+
// ② **守护任务**(关键):主脚本动手**之前**就注册一个每分钟跑一次的独立计划任务,
|
|
7522
|
+
// 服务被杀、主脚本被杀都不影响它;端口起来了它自删,起不来就继续拉(最多 5 次)
|
|
7523
|
+
// ③ bin 解析用与升级/回滚同一套多级回退(node resolve → .pnpm → 顶层链接)
|
|
6610
7524
|
const port = webPort(ctx)
|
|
6611
7525
|
const binPath = resolveDshBin()
|
|
6612
7526
|
const nodePath = process.execPath
|
|
@@ -6615,37 +7529,92 @@ async function handle(ctx, req, res) {
|
|
|
6615
7529
|
return
|
|
6616
7530
|
}
|
|
6617
7531
|
if (process.platform === 'win32') {
|
|
6618
|
-
const
|
|
7532
|
+
const consoleDir = join(dshHome(), 'plugin-console')
|
|
7533
|
+
try { mkdirSync(consoleDir, { recursive: true }) } catch {}
|
|
7534
|
+
const restartLog = join(consoleDir, 'console-restart.log')
|
|
7535
|
+
const fwRoot = resolveFrameworkRootNodeModules(dirname(dirname(binPath)))
|
|
7536
|
+
let installedVersion = ''
|
|
7537
|
+
try { installedVersion = JSON.parse(readFileSync(join(dirname(dirname(binPath)), 'package.json'), 'utf8')).version ?? '' } catch {}
|
|
7538
|
+
const ps = (s) => {
|
|
7539
|
+
const j = JSON.stringify(String(s)).replace(/\\\\/gu, '\\')
|
|
7540
|
+
if (j === '""') return "''"
|
|
7541
|
+
return j.replace(/`/gu, '``').replace(/\$/gu, '`$')
|
|
7542
|
+
}
|
|
7543
|
+
const prelude = relaunchPrelude({ nodePath, pluginDir: join(dirname(fileURLToPath(import.meta.url)), '..'), fwRoot: fwRoot ?? dirname(dirname(binPath)), target: installedVersion, ps })
|
|
6619
7544
|
const taskName = `DSH-Restart-${process.pid}`
|
|
6620
|
-
const
|
|
6621
|
-
|
|
6622
|
-
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
7545
|
+
const guardName = `DSH-RestartGuard-${process.pid}`
|
|
7546
|
+
const guardCount = join(consoleDir, `restart-guard-${process.pid}.count`)
|
|
7547
|
+
const killLine = `Stop-Process -Id ${process.pid} -Force -ErrorAction SilentlyContinue`
|
|
7548
|
+
// ① 主脚本:等端口空 → 拉起(重试 3 次)
|
|
7549
|
+
const mainLines = [
|
|
7550
|
+
`$log = ${ps(restartLog)}`,
|
|
7551
|
+
`$state = ${ps(join(consoleDir, 'fw-upgrade-state.txt'))}`,
|
|
7552
|
+
`function Log($m) { try { Add-Content -Path $log -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $m) -Encoding UTF8 } catch {} }`,
|
|
7553
|
+
prelude,
|
|
7554
|
+
`Log ('重启脚本启动:目标端口 ${port},bin=' + ${ps(binPath)})`,
|
|
7555
|
+
killLine,
|
|
7556
|
+
// 等到端口真正空出来(原实现只 sleep 3 秒、只查一次 —— 端口还占着就误判"已有人监听"而跳过拉起)
|
|
7557
|
+
`$free = $false`,
|
|
7558
|
+
`for ($i = 0; $i -lt 20; $i++) { Start-Sleep -Seconds 1; try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if (-not $c -or $c.Count -eq 0) { $free = $true; break } } catch { $free = $true; break } }`,
|
|
7559
|
+
`Log ('端口 ' + ${ps(String(port))} + ' 状态:' + $(if ($free) { '已释放' } else { '仍被占用(可能被其它实例占着)' }))`,
|
|
7560
|
+
`$up = $false`,
|
|
7561
|
+
`for ($a = 1; $a -le 3; $a++) {`,
|
|
7562
|
+
` [void](Invoke-DshRelaunch ('重启第 ' + $a + ' 次'))`,
|
|
7563
|
+
` for ($w = 0; $w -lt 8; $w++) { Start-Sleep -Seconds 2; try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c -and $c.Count -gt 0) { $up = $true; break } } catch {} }; if ($up) { break }`,
|
|
7564
|
+
`}`,
|
|
7565
|
+
`if ($up) { Log '服务已重新监听,重启完成' } else { Log '三次拉起后端口仍未监听:守护任务会继续尝试(输出见 fw-relaunch.log)' }`,
|
|
6627
7566
|
`schtasks /delete /f /tn ${taskName} 2>$null`,
|
|
6628
7567
|
]
|
|
6629
|
-
//
|
|
6630
|
-
|
|
6631
|
-
|
|
6632
|
-
|
|
6633
|
-
|
|
6634
|
-
|
|
6635
|
-
|
|
6636
|
-
|
|
6637
|
-
|
|
6638
|
-
|
|
6639
|
-
|
|
6640
|
-
|
|
6641
|
-
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
|
|
6645
|
-
|
|
6646
|
-
|
|
6647
|
-
|
|
6648
|
-
|
|
7568
|
+
// ② 守护任务:独立于主脚本与服务进程,端口不起来就一直拉(最多 5 次)
|
|
7569
|
+
const guardLines = [
|
|
7570
|
+
`$log = ${ps(restartLog)}`,
|
|
7571
|
+
`$state = ${ps(join(consoleDir, 'fw-upgrade-state.txt'))}`,
|
|
7572
|
+
`function Log($m) { try { Add-Content -Path $log -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' [guard] ' + $m) -Encoding UTF8 } catch {} }`,
|
|
7573
|
+
prelude,
|
|
7574
|
+
`$c = $null`,
|
|
7575
|
+
`try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue } catch {}`,
|
|
7576
|
+
`if ($c -and $c.Count -gt 0) { Log '服务已在监听,守护任务收工'; Remove-Item ${ps(guardCount)} -Force -ErrorAction SilentlyContinue; schtasks /delete /f /tn ${guardName} 2>$null; schtasks /delete /f /tn ${taskName} 2>$null; exit 0 }`,
|
|
7577
|
+
`$n = 0`,
|
|
7578
|
+
`try { $n = [int](Get-Content ${ps(guardCount)} -Raw -ErrorAction SilentlyContinue) } catch { $n = 0 }`,
|
|
7579
|
+
`$n = $n + 1`,
|
|
7580
|
+
`if ($n -gt 5) { Log ('已尝试 ' + ($n - 1) + ' 次仍拉不起来,放弃并自删(请手动启动,或看 fw-relaunch.log / console-restart.log)'); schtasks /delete /f /tn ${guardName} 2>$null; exit 0 }`,
|
|
7581
|
+
`try { Set-Content -Path ${ps(guardCount)} -Value ([string]$n) -Encoding UTF8 } catch {}`,
|
|
7582
|
+
`Log ('端口 ' + ${ps(String(port))} + ' 无监听,第 ' + $n + ' 次拉起')`,
|
|
7583
|
+
`[void](Invoke-DshRelaunch ('守护第 ' + $n + ' 次'))`,
|
|
7584
|
+
]
|
|
7585
|
+
const ps1 = join(tmpdir(), `console-restart-${process.pid}.ps1`)
|
|
7586
|
+
const guardPs1 = join(tmpdir(), `console-restart-guard-${process.pid}.ps1`)
|
|
7587
|
+
const scheduleFor = (file, name, scheduleArgs) => {
|
|
7588
|
+
const posix = String(file).replace(/\\/gu, '/')
|
|
7589
|
+
// 无引号 /tr(重要):Task Scheduler 对带引号命令的解析会把 Command 拆坏成
|
|
7590
|
+
// `"powershell ... -File \"`(非有效可执行文件)——任务显示 Ready、/run 报 SUCCESS
|
|
7591
|
+
// 但永不执行(曾导致升级/重启脚本反复"已启动"却不动作、服务不停止)。实测无引号
|
|
7592
|
+
// 格式(exe 与脚本路径均无空格时)任务正常执行、脚本完整跑通。仅当脚本路径含空格
|
|
7593
|
+
// 时才退回带引号格式(schtasks 引号解析在服务 execFile 上下文不可靠,此时宁可用它)。
|
|
7594
|
+
const tr = / /.test(posix)
|
|
7595
|
+
? `"powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \\"${posix}\\""`
|
|
7596
|
+
: `C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ${posix}`
|
|
7597
|
+
return new Promise((resolve) => {
|
|
7598
|
+
execFile('schtasks.exe', ['/create', '/f', '/tn', name, '/tr', tr, ...scheduleArgs], { windowsHide: true }, (error) => resolve(error ?? null))
|
|
7599
|
+
})
|
|
7600
|
+
}
|
|
7601
|
+
Promise.all([
|
|
7602
|
+
writeFile(ps1, `\uFEFF${mainLines.join('\r\n')}`, 'utf8'),
|
|
7603
|
+
writeFile(guardPs1, `\uFEFF${guardLines.join('\r\n')}`, 'utf8'),
|
|
7604
|
+
]).then(
|
|
7605
|
+
async () => {
|
|
7606
|
+
// 守护任务先注册(每分钟一次,独立于服务进程树)——主脚本被杀也有它兜底
|
|
7607
|
+
const guardError = await scheduleFor(guardPs1, guardName, ['/sc', 'minute', '/mo', '1'])
|
|
7608
|
+
const mainError = await scheduleFor(ps1, taskName, ['/sc', 'once', '/st', '00:00'])
|
|
7609
|
+
if (mainError !== null) {
|
|
7610
|
+
// schtasks 不可用:退回 detached 直接执行主脚本(守护任务仍可能已建)
|
|
7611
|
+
execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true, detached: true, stdio: 'ignore' }, () => {})
|
|
7612
|
+
return
|
|
7613
|
+
}
|
|
7614
|
+
execFile('schtasks.exe', ['/run', '/tn', taskName], { windowsHide: true }, () => {})
|
|
7615
|
+
if (guardError !== null) {
|
|
7616
|
+
try { writeFileSync(restartLog, `[warn] 守护任务注册失败(${guardError.message}),仅靠主脚本重启\n`, { flag: 'a' }) } catch {}
|
|
7617
|
+
}
|
|
6649
7618
|
},
|
|
6650
7619
|
() => {},
|
|
6651
7620
|
)
|