@noob-stupid/dsh-plugin-console 0.5.29 → 0.5.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +89 -3
- package/lib/server/domain/compat.js +68 -2
- package/lib/server/domain/framework-cleanup.js +225 -0
- package/lib/server/domain/framework-residuals.js +59 -0
- package/lib/server/domain/patch-composition-audit.js +218 -0
- package/lib/server/domain/patch.js +68 -15
- package/lib/server/domain/preset-audit.js +144 -0
- package/lib/server/infra/exec.js +23 -2
- package/lib/server/infra/framework-root.js +99 -0
- package/lib/server/infra/package-resolve.js +73 -0
- package/lib/server/infra/registry-versions.js +64 -0
- package/lib/server/routes/compat.js +41 -1
- package/lib/server/routes/framework-preflight.js +43 -2
- package/lib/server/routes/index.js +13 -1
- package/lib/server/routes/patch-audit.js +59 -0
- package/lib/server/routes/plugins.js +20 -2
- package/lib/server/routes/preset-audit.js +24 -0
- package/lib/server/routes/state.js +9 -3
- package/package.json +1 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// L0 · infra —— package-resolve.js(Node 解析器封装:包名可解析性 + 可用包名枚举;0.5.30 加法)
|
|
2
|
+
//
|
|
3
|
+
// 为什么不用「store 目录名」判断包在不在(真机教训 2026-09-28):
|
|
4
|
+
// pnpm 会**截断超长目录名**(`@deepseek-ai+dsh-workflow-ptc` → `@deepseek-ai+dsh-workflow-p_<hash>`),
|
|
5
|
+
// 按目录名前缀匹配会把**存在的包**误判成不存在 —— 本机实测:`dsh-workflow-ptc`、`dsh-agent-preset`
|
|
6
|
+
// 两个真存在的包都被目录名法误报。所以体检一律走 **Node 自己的解析器**(与框架加载补丁行同一套解析),
|
|
7
|
+
// 并在多个基准上取并集:① profile 目录(第三方插件在这里)② 框架运行时根(`@deepseek-ai/*` 在这里)。
|
|
8
|
+
|
|
9
|
+
import { createRequire } from 'node:module'
|
|
10
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 基准目录 → `createRequire` 的探针位置。
|
|
15
|
+
* base 有两种合法写法,本函数把两者都收下(判据:base 自己是不是就叫 node_modules):
|
|
16
|
+
* · base 是**含 node_modules 的目录**(如 profile 目录 `<…>/profiles/web`)→ 探针 `<base>/__patch_audit_probe__.js`
|
|
17
|
+
* · base 本身就是 **node_modules 目录**(如 `…/.pnpm/@deepseek-ai+dsh@…/node_modules`)→ 探针放它内部
|
|
18
|
+
* 为什么必须兼容第二种:框架内置包的实体只存在于 `.pnpm/<pkg>/node_modules/` 这一层,
|
|
19
|
+
* 说"框架基准"时最自然的写法就是那层本身;若一律拼 `<base>/node_modules/…`,
|
|
20
|
+
* 就会去找 `<…>/node_modules/node_modules` —— **真存在的包被判成不存在**(本套 ① 段即是回归)。
|
|
21
|
+
*/
|
|
22
|
+
function probePathOf(base) {
|
|
23
|
+
return base.replace(/[\\/]+$/u, '').toLowerCase().endsWith('node_modules')
|
|
24
|
+
? join(base, '__patch_audit_probe__.js')
|
|
25
|
+
: join(base, 'node_modules', '__patch_audit_probe__.js')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 造多基准解析器:任一基准能解析即算命中(`from` 如实回带,便于报告里说明"从哪解析到的")。 */
|
|
29
|
+
function makePackageResolver(bases) {
|
|
30
|
+
const reqs = []
|
|
31
|
+
for (const base of (Array.isArray(bases) ? bases : [])) {
|
|
32
|
+
if (typeof base !== 'string' || base === '') continue
|
|
33
|
+
try { reqs.push(createRequire(probePathOf(base))) } catch {}
|
|
34
|
+
}
|
|
35
|
+
return (name) => {
|
|
36
|
+
for (const req of reqs) {
|
|
37
|
+
try { return { ok: true, from: req.resolve(name) } } catch {}
|
|
38
|
+
}
|
|
39
|
+
return { ok: false, reason: '所有基准都解析不到(包可能已被移除/改名,或该子路径不存在)' }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 枚举一个 node_modules 目录下可见的包名(含 scope 内一层)。 */
|
|
44
|
+
function listPackageNames(nodeModulesDir, limit = 4000) {
|
|
45
|
+
const out = []
|
|
46
|
+
try {
|
|
47
|
+
for (const entry of readdirSync(nodeModulesDir, { withFileTypes: true })) {
|
|
48
|
+
if (out.length >= limit) break
|
|
49
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
|
50
|
+
if (entry.name.startsWith('@')) {
|
|
51
|
+
for (const sub of readdirSync(join(nodeModulesDir, entry.name), { withFileTypes: true })) {
|
|
52
|
+
if (out.length >= limit) break
|
|
53
|
+
if (sub.isDirectory() && !sub.name.startsWith('.')) out.push(`${entry.name}/${sub.name}`)
|
|
54
|
+
}
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
out.push(entry.name)
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 合并多个 node_modules 目录下的包名(去重)—— 供「疑似改名」当候选集。 */
|
|
64
|
+
function listAvailablePackages(dirs) {
|
|
65
|
+
const set = new Set()
|
|
66
|
+
for (const dir of (Array.isArray(dirs) ? dirs : [])) {
|
|
67
|
+
if (typeof dir !== 'string' || dir === '' || !existsSync(dir)) continue
|
|
68
|
+
for (const name of listPackageNames(dir)) set.add(name)
|
|
69
|
+
}
|
|
70
|
+
return [...set]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { listAvailablePackages, listPackageNames, makePackageResolver }
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// L0 · infra —— registry-versions.js(registry 元数据探针:包名 → 某版本是否已发布;0.5.30 加法)
|
|
2
|
+
//
|
|
3
|
+
// 用途:**升级前**核对「profile 补丁里引用的框架包,在目标版本里还在不在」。
|
|
4
|
+
// 真机事故 2026-09-28:0.2.0-rc.1 移除了 `@deepseek-ai/dsh-workflow-worker-thread`(引擎改为 ptc),
|
|
5
|
+
// 补丁仍指向旧包 → 升级后 `workflowEngine` 无提供方 → 老会话 resume 失败。
|
|
6
|
+
// 这类「官方移除/改名」在升级前**只能用 registry 元数据看见**(目标版本还没装到本机)。
|
|
7
|
+
//
|
|
8
|
+
// 三个取舍:
|
|
9
|
+
// ① 同一次预检里**同一个包只查一次**(内存缓存)—— 补丁里同名包可能出现在多处;
|
|
10
|
+
// ② 网络失败/元数据缺失 → 返回 **null(未知)**,绝不当作「不存在」;
|
|
11
|
+
// ③ 小并发(默认 4)+ 单请求超时,避免预检被 30 个包串行拖死。
|
|
12
|
+
|
|
13
|
+
import { fetchJsonUrl } from './http.js'
|
|
14
|
+
|
|
15
|
+
/** 包名 → URL 片段(`@a/b` → `@a%2fb`)。 */
|
|
16
|
+
function encodePackageName(name) {
|
|
17
|
+
return String(name).replace('/', '%2f')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 造版本探针:`(name) => { ok, versions?, error? }`(带内存缓存)。 */
|
|
21
|
+
function makeVersionProbe({ registry, fetchJson, timeoutMs = 9000, cache = new Map() } = {}) {
|
|
22
|
+
const fetchImpl = typeof fetchJson === 'function' ? fetchJson : (url, t) => fetchJsonUrl(url, t)
|
|
23
|
+
return async (name) => {
|
|
24
|
+
if (cache.has(name)) return cache.get(name)
|
|
25
|
+
let result
|
|
26
|
+
try {
|
|
27
|
+
const meta = await fetchImpl(`${registry}/${encodePackageName(name)}`, timeoutMs)
|
|
28
|
+
const versions = meta && typeof meta === 'object' && meta.versions !== null && typeof meta.versions === 'object'
|
|
29
|
+
? Object.keys(meta.versions)
|
|
30
|
+
: []
|
|
31
|
+
result = { ok: true, versions }
|
|
32
|
+
} catch (error) {
|
|
33
|
+
result = { ok: false, error: String(error?.message ?? error) }
|
|
34
|
+
}
|
|
35
|
+
cache.set(name, result)
|
|
36
|
+
return result
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 批量核对:`[{name, lines}]` → `[{name, lines, published: true|false|null}]`(小并发,保序)。 */
|
|
41
|
+
async function checkPublishedVersions(entries, { probe, version, concurrency = 4 } = {}) {
|
|
42
|
+
const list = Array.isArray(entries) ? entries : []
|
|
43
|
+
const width = Math.max(1, Number.isFinite(concurrency) && concurrency > 0 ? Math.floor(concurrency) : 4)
|
|
44
|
+
const out = new Array(list.length)
|
|
45
|
+
let cursor = 0
|
|
46
|
+
const worker = async () => {
|
|
47
|
+
for (;;) {
|
|
48
|
+
const index = cursor
|
|
49
|
+
cursor += 1
|
|
50
|
+
if (index >= list.length) return
|
|
51
|
+
const entry = list[index]
|
|
52
|
+
let published = null
|
|
53
|
+
try {
|
|
54
|
+
const res = await probe(entry.name)
|
|
55
|
+
if (res?.ok === true) published = Array.isArray(res.versions) ? res.versions.includes(version) : null
|
|
56
|
+
} catch { published = null }
|
|
57
|
+
out[index] = { name: entry.name, lines: Array.isArray(entry.lines) ? entry.lines : [], published }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
await Promise.all(Array.from({ length: Math.min(width, list.length) }, () => worker()))
|
|
61
|
+
return out.filter((x) => x !== undefined)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { checkPublishedVersions, encodePackageName, makeVersionProbe }
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { dirname } from 'node:path'
|
|
15
15
|
import { compareFingerprint, computeFingerprint, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode } from '../domain/compat-state.js'
|
|
16
|
+
import { NOTICE_MODES, detectCompat, readCompatAck, readCompatGate, writeCompatAck, writeCompatGate } from '../domain/compat.js'
|
|
16
17
|
import { currentFrameworkVersion } from '../domain/framework.js'
|
|
17
18
|
import { applyDependencyBackfill, planDependencyBackfill } from '../domain/manifest.js'
|
|
18
19
|
import { preferredRegistries } from '../domain/sources.js'
|
|
@@ -127,4 +128,43 @@ async function routeDeclareInstalled(req, res, rc) {
|
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
|
|
131
|
+
/** 兼容提示:手动关闭 / 设定展示模式(2026-09-28 加法)。
|
|
132
|
+
*
|
|
133
|
+
* 背景(用户实测):面板顶部那句「当前 DSH web 包版本 X 不在受支持的 0.1.x 系列内…」是**披露**,
|
|
134
|
+
* 但它以前**关不掉** —— 纯 `<p>`、没有关闭入口、也没有持久化。用户诉求:可以手动关。
|
|
135
|
+
*
|
|
136
|
+
* 两种入参(互斥,按字段判断):
|
|
137
|
+
* · `{ webAppVersion }` → 记住"这个版本我看过了":同版本不再提示,**框架版本一变重新提示**
|
|
138
|
+
* · `{ mode }` → 展示模式 `always` / `once-per-version`(默认)/ `off`
|
|
139
|
+
*
|
|
140
|
+
* ⚠ 硬约束:**只影响展示,不改 `supported`** —— 门控判定与"该不该拦"一个字都不变,
|
|
141
|
+
* 披露不能被关成"看不见的风险"。版本还会校验:只接受**当前探测到**的那一个,
|
|
142
|
+
* 避免把 ack 记到别的版本上(否则升到新版本时会被误吞)。
|
|
143
|
+
*/
|
|
144
|
+
async function routeCompatAck(req, res, rc) {
|
|
145
|
+
const body = rc.body ?? {}
|
|
146
|
+
if (typeof body.mode === 'string') {
|
|
147
|
+
const mode = body.mode.trim().toLowerCase()
|
|
148
|
+
if (!NOTICE_MODES.includes(mode)) {
|
|
149
|
+
sendError(res, 400, `未知的提示模式:${body.mode}(可选 ${NOTICE_MODES.join(' / ')})`)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
const gate = writeCompatGate({ noticeMode: mode })
|
|
153
|
+
sendJson(res, 200, { ok: true, noticeMode: gate.noticeMode, ack: readCompatAck() })
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
const version = typeof body.webAppVersion === 'string' ? body.webAppVersion.trim() : ''
|
|
157
|
+
if (version === '' || !/^\d+\.\d+\.\d+/u.test(version)) {
|
|
158
|
+
sendError(res, 400, 'webAppVersion 缺失或格式不对(形如 0.2.0-rc.1)')
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
let detected = null
|
|
162
|
+
try { detected = (await detectCompat(rc?.ctx?.baseUrl ?? 'file:///')).webAppVersion } catch {}
|
|
163
|
+
if (detected !== null && detected !== version) {
|
|
164
|
+
sendError(res, 400, `版本不匹配:当前探测到 ${detected},请求里是 ${version}(请刷新后重试)`)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
const ack = writeCompatAck(version)
|
|
168
|
+
sendJson(res, 200, { ok: true, ack, noticeMode: readCompatGate().noticeMode })
|
|
169
|
+
}
|
|
170
|
+
export { routeCompatAck, routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled }
|
|
@@ -7,17 +7,20 @@
|
|
|
7
7
|
// 「一发消息就报错、会话整体不可用」。包级适配门(peerDependencies / 版本号)看不见这类
|
|
8
8
|
// 运行期契约破坏,所以必须在**升级前**单独预检、先把本地适配好再装。
|
|
9
9
|
|
|
10
|
-
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
11
11
|
import { dirname, join } from 'node:path'
|
|
12
12
|
import { isFrameworkOwnedPackage } from '../domain/compat.js'
|
|
13
|
+
import { classifyTargetAvailability, frameworkPackageRows, parsePatchRows } from '../domain/patch-composition-audit.js'
|
|
14
|
+
import { collectPresetAudit } from '../domain/preset-audit.js'
|
|
13
15
|
import { loadContractRules, summarizeContractRules } from '../domain/contract-rules.js'
|
|
14
16
|
import { discoverFormatContract, summarizeContract } from '../domain/format-contract.js'
|
|
15
17
|
import { applyFormatPatch, collectProducerTargets, scanProducerFiles } from '../domain/format-scan.js'
|
|
16
18
|
import { currentFrameworkVersion } from '../domain/framework.js'
|
|
17
19
|
import { listEntries } from '../domain/runtime.js'
|
|
18
20
|
import { curlText, fetchJsonUrl } from '../infra/http.js'
|
|
21
|
+
import { checkPublishedVersions, makeVersionProbe } from '../infra/registry-versions.js'
|
|
19
22
|
import { sendError, sendJson } from '../infra/httpd.js'
|
|
20
|
-
import { baseDirOf, dshHome, pluginRoot, profileDirOf, resolvePackageJson } from '../infra/paths.js'
|
|
23
|
+
import { baseDirOf, dshHome, findPatchPath, pluginRoot, profileDirOf, resolvePackageJson } from '../infra/paths.js'
|
|
21
24
|
|
|
22
25
|
const REGISTRY = 'https://registry.npmmirror.com'
|
|
23
26
|
|
|
@@ -109,6 +112,42 @@ async function routeFrameworkPreflight(req, res, rc) {
|
|
|
109
112
|
const rules = contract?.rules ?? { producerKindRequired: false, forbiddenKinds: ['plugin'], pluginPrefix: 'plugin:', renames: {} }
|
|
110
113
|
const scan = scanProducerFiles({ targets, rules, targetVersion })
|
|
111
114
|
|
|
115
|
+
// 补丁 ↔ 目标版本核对(2026-09-28 加法;真机事故:0.2.0-rc.1 移除了
|
|
116
|
+
// `@deepseek-ai/dsh-workflow-worker-thread`,补丁三处仍引用 → 升级后 `workflowEngine`
|
|
117
|
+
// 没有提供方 → `dsh-tool-workflow`/`dsh-tool-ralph` 永久 waiting → 老会话 resume 失败)。
|
|
118
|
+
// 只核对**随框架版本走**的包(`@deepseek-ai/*`):目标版本还没装到本机,只有 registry 元数据能提前看见。
|
|
119
|
+
// 网络不可用/查询失败一律记 `unknown`,**绝不判死**。
|
|
120
|
+
let patchTarget = { checked: 0, missing: [], unknown: [], skipped: null, patchPath: null }
|
|
121
|
+
try {
|
|
122
|
+
const patchPath = findPatchPath(rc.ctx)
|
|
123
|
+
patchTarget.patchPath = patchPath
|
|
124
|
+
if (patchPath === null || !existsSync(patchPath)) {
|
|
125
|
+
patchTarget.skipped = '找不到 profile 补丁文件(cordis.patch.yml)'
|
|
126
|
+
} else {
|
|
127
|
+
const rows = parsePatchRows(readFileSync(patchPath, 'utf8'))
|
|
128
|
+
const fwRows = frameworkPackageRows(rows)
|
|
129
|
+
if (fwRows.length === 0) patchTarget.skipped = '补丁里没有随框架版本走的包(@deepseek-ai/*)'
|
|
130
|
+
else if (!networkAllowed()) patchTarget.skipped = '已跳过网络(DSH_TEST_SKIP_NETWORK=1)'
|
|
131
|
+
else {
|
|
132
|
+
const probe = makeVersionProbe({ registry: REGISTRY, fetchJson: (url, timeoutMs) => fetchJsonUrl(url, timeoutMs) })
|
|
133
|
+
const results = await checkPublishedVersions(fwRows, { probe, version: targetVersion })
|
|
134
|
+
const publishedBy = new Map(results.map((row) => [row.name, row.published]))
|
|
135
|
+
const classified = classifyTargetAvailability(fwRows, { publishedBy, targetVersion })
|
|
136
|
+
patchTarget = { ...patchTarget, checked: results.length, missing: classified.missing, unknown: classified.unknown, skipped: null }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch (error) {
|
|
140
|
+
patchTarget.skipped = '核对失败:' + String(error?.message ?? error)
|
|
141
|
+
}
|
|
142
|
+
// 预设声明行体检(2026-09-28 加法):升级前就能看见"补丁里的声明行已陈旧/缺失"。
|
|
143
|
+
// 真机事故:预设自身被改成 v10、补丁行还指向 v1 → 老会话 resume 到错的模块,且没有任何地方会报。
|
|
144
|
+
// 只读、不阻塞:作为 presetAudit 字段随预检一起下发(blockers>0 时由面板提示先修再升)。
|
|
145
|
+
let presetAudit = null
|
|
146
|
+
try {
|
|
147
|
+
presetAudit = collectPresetAudit({ presetsRoot: join(dshHome(), '.agent-presets'), patchPath: findPatchPath(rc.ctx) })
|
|
148
|
+
} catch (error) {
|
|
149
|
+
presetAudit = { ok: false, error: String(error?.message ?? error), blockers: 0, stale: [], missing: [], orphan: [], targetMissing: [] }
|
|
150
|
+
}
|
|
112
151
|
sendJson(res, 200, {
|
|
113
152
|
ok: true,
|
|
114
153
|
targetVersion,
|
|
@@ -122,6 +161,8 @@ async function routeFrameworkPreflight(req, res, rc) {
|
|
|
122
161
|
rules: summarizeContractRules(loadContractRules()),
|
|
123
162
|
roots: roots.map((r) => ({ root: r.root, kind: r.kind, moduleName: r.moduleName ?? null })),
|
|
124
163
|
scan,
|
|
164
|
+
patchTarget,
|
|
165
|
+
presetAudit,
|
|
125
166
|
})
|
|
126
167
|
}
|
|
127
168
|
|
|
@@ -15,7 +15,9 @@ import { readBody, sendError } from '../infra/httpd.js'
|
|
|
15
15
|
import { routeAiConsent, routeAiEmpowerCancel, routeAiEmpowerList, routeAiEmpowerPlan, routeAiEmpowerRun, routeAiEmpowerStatus } from './ai.js'
|
|
16
16
|
import { routeRunSuggested } from './actions.js'
|
|
17
17
|
import { routeComponentAutostart, routeComponentStart, routeComponentStatus, routeComponentStop, routeComponents, routeRepoClone, routeRepoLandConfig, routeRepoList, routeRepoOpen, routeRepoRemove } from './components.js'
|
|
18
|
-
import { routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled } from './compat.js'
|
|
18
|
+
import { routeCompatAck, routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled } from './compat.js'
|
|
19
|
+
import { routePatchAuditGet } from './patch-audit.js'
|
|
20
|
+
import { routePresetAuditGet } from './preset-audit.js'
|
|
19
21
|
import { routeFrameworkUpgrade } from './framework-upgrade.js'
|
|
20
22
|
import { routeFrameworkPreflight, routeFrameworkPreflightPatch } from './framework-preflight.js'
|
|
21
23
|
import { routeCheckUpdate, routeCompatGate, routeFrameworkCheck, routeFrameworkRelaunch, routeFrameworkRollback, routeFrameworkStatusClear, routeFrameworkUpgradeStatusGet, routeRestart } from './framework.js'
|
|
@@ -37,6 +39,15 @@ const ROUTES_EARLY = [
|
|
|
37
39
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-callback`, handler: routeGiteeOauthCallbackGet },
|
|
38
40
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/skills-installed`, handler: routeSkillsInstalledGet }, { methods: ['GET'], path: `${ROUTE_PREFIX}/framework-upgrade-status`, handler: routeFrameworkUpgradeStatusGet },
|
|
39
41
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/compat-status`, handler: routeCompatStatusGet },
|
|
42
|
+
// 只读体检(2026-09-30 改错:这两条**必须**住在守卫之前)。
|
|
43
|
+
// 原实现把它们登记在守卫之后的 ROUTES 表里 → GET 在守卫处就被 405 掉,永远到不了 handler。
|
|
44
|
+
// 真机现场(0.5.29 活体实例):`GET /plugin-console/preset-audit` → 405「不支持的方法」,
|
|
45
|
+
// `POST` 同路径 → 404「未知接口」—— 两句都由 handle() 自己发出,说明**查表根本没参与**。
|
|
46
|
+
// 判据:405 守卫 `if (method !== 'POST' && !(method === 'GET' && GET_COMPAT.has(pathname)))`
|
|
47
|
+
// 跑在 ROUTES 查表之前,只放行 POST(外加 GET_COMPAT 白名单)→
|
|
48
|
+
// **任何声明 GET 的路由都必须登记在 ROUTES_EARLY**。该判据已固化进 tests/test-route-inventory.mjs。
|
|
49
|
+
{ methods: ['GET'], path: `${ROUTE_PREFIX}/patch-audit`, handler: routePatchAuditGet },
|
|
50
|
+
{ methods: ['GET'], path: `${ROUTE_PREFIX}/preset-audit`, handler: routePresetAuditGet },
|
|
40
51
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/framework-relaunch`, handler: routeFrameworkRelaunch },
|
|
41
52
|
|
|
42
53
|
]
|
|
@@ -98,6 +109,7 @@ const ROUTES = [
|
|
|
98
109
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/framework-status-clear`, handler: routeFrameworkStatusClear },
|
|
99
110
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/compat-mode`, handler: routeCompatMode },
|
|
100
111
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/compat-stamp`, handler: routeCompatStamp },
|
|
112
|
+
{ methods: ['POST'], path: `${ROUTE_PREFIX}/compat-ack`, handler: routeCompatAck },
|
|
101
113
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/declare-installed`, handler: routeDeclareInstalled },
|
|
102
114
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/framework-rollback`, handler: routeFrameworkRollback },
|
|
103
115
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/restart`, handler: routeRestart },
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// L2 · routes —— 补丁体检(GET /plugin-console/patch-audit;0.5.30 加法)
|
|
2
|
+
//
|
|
3
|
+
// 真机事故 2026-09-28:框架 0.2.0-rc.1 移除了 `@deepseek-ai/dsh-workflow-worker-thread`(引擎改为 ptc),
|
|
4
|
+
// 而 profile 补丁三处行仍指向旧包 → 该行解析失败 → 隔离组里 `workflowEngine` 没有提供方 →
|
|
5
|
+
// `dsh-tool-workflow` / `dsh-tool-ralph` 永久 waiting → 老会话 resume 失败
|
|
6
|
+
// (RemoteError: tool-workflow … waiting for workflowEngine)。
|
|
7
|
+
//
|
|
8
|
+
// 本路由把 domain/patch-composition-audit.js 的**纯判据**接到真实环境(真补丁文件 + 真 Node 解析器),
|
|
9
|
+
// 并且**只报告、不代改**:改名建议来自「真机确证表」(KNOWN_RENAMES),猜不到的只列候选 ——
|
|
10
|
+
// 绝不自动改写用户补丁(同 preset-rows.js 的判据文化:宁缺勿滥、不猜)。
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { auditPatchText } from '../domain/patch-composition-audit.js'
|
|
16
|
+
import { frameworkBases } from '../infra/framework-root.js'
|
|
17
|
+
import { sendError, sendJson } from '../infra/httpd.js'
|
|
18
|
+
import { listAvailablePackages, makePackageResolver } from '../infra/package-resolve.js'
|
|
19
|
+
import { baseDirOf, findPatchPath } from '../infra/paths.js'
|
|
20
|
+
|
|
21
|
+
// 框架运行时根的取法已收敛到 infra/framework-root.js(0.5.30 改错:**同一份判据**必须同时供
|
|
22
|
+
// 只读体检与本文件、以及 domain/patch.js 的自愈使用 —— 各写一份的话,框架下次再挪目录只会有一处被修好;
|
|
23
|
+
// 2026-09-29 真机事故正是"自愈那处没跟上")。
|
|
24
|
+
|
|
25
|
+
/** profile 补丁 → 体检报告(只读;不写任何文件)。 */
|
|
26
|
+
async function routePatchAuditGet(req, res, rc) {
|
|
27
|
+
const ctx = rc.ctx
|
|
28
|
+
let patchPath = null
|
|
29
|
+
try { patchPath = findPatchPath(ctx) } catch {}
|
|
30
|
+
if (patchPath === null || !existsSync(patchPath)) {
|
|
31
|
+
sendError(res, 404, '找不到 profile 补丁文件(cordis.patch.yml)—— 无法体检')
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
const profileDir = dirname(patchPath)
|
|
35
|
+
let baseDir = null
|
|
36
|
+
try { baseDir = baseDirOf(rc?.deps?.baseUrl ?? rc?.ctx?.baseUrl ?? 'file:///') } catch { baseDir = null }
|
|
37
|
+
const fwBases = frameworkBases(baseDir, profileDir)
|
|
38
|
+
const bases = [profileDir, ...fwBases]
|
|
39
|
+
const resolve = makePackageResolver(bases)
|
|
40
|
+
const candidates = listAvailablePackages([join(profileDir, 'node_modules'), ...fwBases])
|
|
41
|
+
const text = readFileSync(patchPath, 'utf8')
|
|
42
|
+
const report = auditPatchText(text, {
|
|
43
|
+
resolve,
|
|
44
|
+
existsFile: (url) => { try { return existsSync(fileURLToPath(url)) } catch { return false } },
|
|
45
|
+
candidates,
|
|
46
|
+
})
|
|
47
|
+
sendJson(res, 200, {
|
|
48
|
+
ok: true,
|
|
49
|
+
patchPath,
|
|
50
|
+
bases,
|
|
51
|
+
rows: report.rows,
|
|
52
|
+
blockerCount: report.blockers.length,
|
|
53
|
+
warningCount: report.warnings.length,
|
|
54
|
+
blockers: report.blockers,
|
|
55
|
+
warnings: report.warnings,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { routePatchAuditGet }
|
|
@@ -5,6 +5,8 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
|
5
5
|
import { dirname, join } from 'node:path'
|
|
6
6
|
import { createRequire } from 'node:module'
|
|
7
7
|
import { checkPluginFrameworkCompat, probePluginImport, readCompatPending, rowIdModuleMap, writeCompatPending } from '../domain/compat.js'
|
|
8
|
+
import { cleanFrameworkBackups, cleanFrameworkTopLevel, readRollbackReferenced, reportFrameworkEntities, resolveFrameworkLayout } from '../domain/framework-cleanup.js'
|
|
9
|
+
import { currentFrameworkVersion } from '../domain/framework.js'
|
|
8
10
|
import { pnpmRemove } from '../domain/install-job.js'
|
|
9
11
|
import { curlManualInstall, readExtraBundleOwners } from '../domain/install.js'
|
|
10
12
|
import { disableEntry, enableEntry, readPatchState, removeDisableBlock, removeInsertRow } from '../domain/patch.js'
|
|
@@ -17,7 +19,7 @@ import { orderedRegistries, readSources } from '../domain/sources.js'
|
|
|
17
19
|
import { fetchJsonUrl } from '../infra/http.js'
|
|
18
20
|
import { sendError, sendJson } from '../infra/httpd.js'
|
|
19
21
|
import { removeDirVerifiedAsync } from '../infra/fsx.js'
|
|
20
|
-
import { dshHome, findPatchPath, pluginRoot, resolvePackageJson, rowIdOf } from '../infra/paths.js'
|
|
22
|
+
import { baseDirOf, dshHome, findPatchPath, pluginRoot, resolvePackageJson, rowIdOf } from '../infra/paths.js'
|
|
21
23
|
import { installJobs } from '../state.js'
|
|
22
24
|
|
|
23
25
|
/** 插件控制台自身的包名(撤销分支禁止删自己;@deepseek-ai/* 由通用护栏拒绝)。 */
|
|
@@ -504,7 +506,23 @@ async function routeCleanResiduals(req, res, rc) {
|
|
|
504
506
|
}
|
|
505
507
|
}
|
|
506
508
|
} catch {}
|
|
507
|
-
|
|
509
|
+
// ── scope 扩展(2026-09-28 加法):framework / frameworkBackups / pnpmEntities ──
|
|
510
|
+
// 默认(不传 scope)与以前**完全一致**:只清插件侧残留。framework 两档必须显式 confirm:true,
|
|
511
|
+
// 否则只出报告(dryRun)—— 高风险动作不许"点一下全干"。
|
|
512
|
+
const scope = Array.isArray(body?.scope) ? body.scope.filter((s) => typeof s === 'string' && s !== '') : ['plugin']
|
|
513
|
+
const extra = {}
|
|
514
|
+
if (scope.some((s) => s !== 'plugin')) {
|
|
515
|
+
const dryRun = body?.confirm !== true
|
|
516
|
+
let runningVersion = null
|
|
517
|
+
try { runningVersion = currentFrameworkVersion(ctx) } catch {}
|
|
518
|
+
const layout = resolveFrameworkLayout({ baseDir: baseDirOf(ctx?.baseUrl ?? 'file:///'), profileDir })
|
|
519
|
+
if (scope.includes('framework')) extra.framework = await cleanFrameworkTopLevel({ topScopeDir: layout.topScopeDir, dshLinkPath: layout.dshLinkPath, runningVersion, relink: body?.relink !== false, dryRun, stamp: Date.now() })
|
|
520
|
+
if (scope.includes('frameworkBackups')) extra.frameworkBackups = await cleanFrameworkBackups({ backupsRoot: join(dshHome(), 'plugin-console', 'framework-backups'), referenced: readRollbackReferenced(), keep: Number.isFinite(body?.keep) ? body.keep : 1, dryRun })
|
|
521
|
+
if (scope.includes('pnpmEntities')) extra.pnpmEntities = reportFrameworkEntities({ pnpmDir: layout.pnpmDir, runningVersion })
|
|
522
|
+
extra.scope = scope
|
|
523
|
+
extra.dryRun = dryRun
|
|
524
|
+
}
|
|
525
|
+
const payload = { ok: failed.length === 0, removed, kept, failed, count: removed.length, ...extra }
|
|
508
526
|
if (failed.length > 0) {
|
|
509
527
|
const detail = failed.slice(0, 3).map((f) => `${f.name}(${f.error ?? '未知原因'})`).join(';')
|
|
510
528
|
const keptNote = kept.length > 0 ? `;已在用插件 ${kept.length} 个(保留不删)` : ''
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// L2 · routes —— 预设声明行体检(GET /plugin-console/preset-audit;0.5.30 加法)
|
|
2
|
+
//
|
|
3
|
+
// 真机背景:框架 0.1.7-rc.1 起预设靠 profile 补丁里的**声明行**发现;预设目录自身后来被改成 v10,
|
|
4
|
+
// 而补丁里的行还指向 v1 → 老会话 resume 到错的模块(而且没有任何地方会报)。本路由**只读**出报告:
|
|
5
|
+
// 陈旧(stale)/ 缺失(missing)/ 悬空(orphan)/ 目标不存在(targetMissing)。
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { collectPresetAudit } from '../domain/preset-audit.js'
|
|
9
|
+
import { sendError, sendJson } from '../infra/httpd.js'
|
|
10
|
+
import { dshHome, findPatchPath } from '../infra/paths.js'
|
|
11
|
+
|
|
12
|
+
async function routePresetAuditGet(req, res, rc) {
|
|
13
|
+
let patchPath = null
|
|
14
|
+
try { patchPath = findPatchPath(rc.ctx) } catch {}
|
|
15
|
+
if (patchPath === null || !existsSync(patchPath)) {
|
|
16
|
+
sendError(res, 404, '找不到 profile 补丁文件(cordis.patch.yml)—— 无法体检')
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
const presetsRoot = join(dshHome(), '.agent-presets')
|
|
20
|
+
const report = collectPresetAudit({ presetsRoot, patchPath })
|
|
21
|
+
sendJson(res, 200, { ok: true, patchPath, presetsRoot, ...report })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { routePresetAuditGet }
|
|
@@ -28,7 +28,9 @@ async function routeStateGet(req, res, rc) {
|
|
|
28
28
|
const readExtraBundleRows = rc.deps.readExtraBundleRows
|
|
29
29
|
const patchPath = findPatchPath(ctx)
|
|
30
30
|
const patch = await readPatchState(patchPath)
|
|
31
|
-
// 补丁安全自愈(核心行误禁用恢复 /
|
|
31
|
+
// 补丁安全自愈(核心行误禁用恢复 / **确证**缺失模块行自动禁用)——每 2 分钟最多跑一次。
|
|
32
|
+
// 2026-09-30 改错:① 无真正需要修复的内容时零写盘(避免每 2 分钟无谓重写用户补丁);
|
|
33
|
+
// ② 写盘失败 / 解析不确定 / 框架自带包 → 如实回报(skipped / uncertain / writeError),不静默。
|
|
32
34
|
let patchHeal = null
|
|
33
35
|
try {
|
|
34
36
|
if (patchHealAt === null || Date.now() - patchHealAt > 120000) {
|
|
@@ -38,7 +40,11 @@ async function routeStateGet(req, res, rc) {
|
|
|
38
40
|
patchHeal = patchHealReport
|
|
39
41
|
}
|
|
40
42
|
if (patchHeal !== null) setPatchHealReport(patchHeal)
|
|
41
|
-
} catch {
|
|
43
|
+
} catch (error) {
|
|
44
|
+
// 自愈本身抛异常也要如实进 patchHeal(过去这里 catch {} 静默吞掉,面板看不出任何异常)
|
|
45
|
+
patchHeal = { healed: [], autoDisabled: [], skipped: [], uncertain: [], healedAt: 0, written: false, writeError: String(error?.message ?? error) }
|
|
46
|
+
setPatchHealReport(patchHeal)
|
|
47
|
+
}
|
|
42
48
|
const extraRows = await readExtraBundleRows(dirname(patchPath))
|
|
43
49
|
const compatPending = readCompatPending()
|
|
44
50
|
const compatPendingRows = new Set((compatPending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => p.rowId))
|
|
@@ -91,7 +97,7 @@ async function routeStateGet(req, res, rc) {
|
|
|
91
97
|
const selfPkg = JSON.parse(readFileSync(join(pluginRoot(), 'package.json'), 'utf8'))
|
|
92
98
|
selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
|
|
93
99
|
} catch {}
|
|
94
|
-
sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, pendingRestart, 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 }, gating: gatingSummary(ctx, compatPending, entries), 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 })) })
|
|
100
|
+
sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, pendingRestart, 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, written: patchHeal.written === true, writeError: patchHeal.writeError ?? null, skippedFrameworkOwned: (patchHeal.skipped ?? []).map((s) => s.id ?? null), uncertain: (patchHeal.uncertain ?? []).map((u) => u.id ?? null) }, gating: gatingSummary(ctx, compatPending, entries), 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 })) })
|
|
95
101
|
return
|
|
96
102
|
}
|
|
97
103
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noob-stupid/dsh-plugin-console",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.30",
|
|
4
4
|
"description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|