@noob-stupid/dsh-plugin-console 0.5.30 → 0.5.32
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 +39 -7
- package/README.zh.md +40 -8
- package/SECURITY.md +2 -2
- package/lib/client.js +17 -3
- package/lib/index.js +141 -141
- package/lib/server/domain/ai-run.js +1 -1
- package/lib/server/domain/ai.js +39 -11
- package/lib/server/domain/auto-disable.js +226 -0
- package/lib/server/domain/auto-preflight-run.js +184 -0
- package/lib/server/domain/auto-preflight.js +345 -0
- package/lib/server/domain/compat-state.js +57 -8
- package/lib/server/domain/compat.js +7 -3
- package/lib/server/domain/components.js +1 -1
- package/lib/server/domain/dep-source.js +1 -1
- package/lib/server/domain/framework.js +65 -6
- package/lib/server/domain/install-job.js +1 -1
- package/lib/server/domain/install.js +1 -1
- package/lib/server/domain/jobs.js +1 -1
- package/lib/server/domain/market.js +1 -1
- package/lib/server/domain/patch-yaml-check.js +151 -0
- package/lib/server/domain/patch.js +1 -1
- package/lib/server/domain/presets.js +1 -1
- package/lib/server/domain/release-source.js +3 -3
- package/lib/server/domain/repoland.js +1 -1
- package/lib/server/domain/runtime.js +1 -1
- package/lib/server/domain/safe-boot.js +520 -0
- package/lib/server/domain/skills.js +1 -1
- package/lib/server/domain/sources.js +1 -1
- package/lib/server/domain/suite.js +1 -1
- package/lib/server/infra/exec.js +2 -2
- package/lib/server/infra/fsx.js +3 -3
- package/lib/server/infra/http.js +1 -1
- package/lib/server/infra/httpd.js +1 -1
- package/lib/server/infra/mask.js +1 -1
- package/lib/server/infra/paths.js +1 -1
- package/lib/server/infra/semver.js +1 -1
- package/lib/server/routes/compat.js +240 -11
- package/lib/server/routes/components.js +1 -1
- package/lib/server/routes/framework-preflight.js +14 -1
- package/lib/server/routes/framework-upgrade.js +16 -17
- package/lib/server/routes/framework.js +18 -2
- package/lib/server/routes/github-login.js +2 -2
- package/lib/server/routes/index.js +8 -1
- package/lib/server/routes/safe-boot.js +139 -0
- package/package.json +1 -1
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// L1 · domain —— auto-preflight.js(环境指纹变化 → 自动预检的**判定与记录层**;0.5.32 加法 D1)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有它(用户 2026-09-29 诉求,原话):
|
|
4
|
+
// 「走**别的**更新通道(官方桌面端更新器 / 手动 pnpm / npx 缓存变化)改了框架后,我们**仍要自动守门**」。
|
|
5
|
+
// 现状(0.5.31):`domain/compat-state.js` 的 `compareFingerprint` 已经能**发现**任何来源的框架变更
|
|
6
|
+
// (`routes/compat.js` 每次状态查询现场算指纹并与基线比对),但发现之后**只上报一行文案**,
|
|
7
|
+
// 不会自动跑预检;而「启动失败隔离」(`domain/quarantine.js`)的输入是我们自己的升级脚本留下的
|
|
8
|
+
// 记录 —— 别人升级时**没有这份记录**,兜底因此不触发。本模块补上中间那一段:
|
|
9
|
+
//
|
|
10
|
+
// 指纹变化(changed=true)→ **自动跑一次只读预检** → 落成三样东西:
|
|
11
|
+
// ① 兼容清单(受影响行 + 判据) ② 隔离计划(建议禁用哪些行、为什么) ③ 本次变更摘要(版本 from→to / 新增消失的包 / 指纹 reasons)
|
|
12
|
+
//
|
|
13
|
+
// 四条硬约束(全部可离线断言,见 tests/test-auto-preflight.mjs):
|
|
14
|
+
// · **只读**:不下载、不安装、不改补丁、不碰 loader —— 本模块连 ctx/ports 都不认识(只吃注入的窄接口);
|
|
15
|
+
// · **同一指纹只自动跑一次**:以指纹为 key 持久化「已扫过」标记;失败**如实记录原因**且**允许重试**;
|
|
16
|
+
// · **绝不阻塞状态查询**:超时/异常一律降级成「未完成,可手动重跑」,状态查询该返回什么还返回什么;
|
|
17
|
+
// · 纯函数为主:判定(shouldAutoRun)、超时包裹(withTimeout)、增量(summarizeDelta)、
|
|
18
|
+
// 记录合并(upsertRecord)都不做 IO,IO 只出现在 store 读写与 runAutoPreflight 的调用缝上。
|
|
19
|
+
//
|
|
20
|
+
// 分层:L1 domain —— 无 ctx、无 HTTP、不抛(异常在 runAutoPreflight 内被收成 state='failed')。
|
|
21
|
+
|
|
22
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
23
|
+
import { dirname, join } from 'node:path'
|
|
24
|
+
import { dshHome } from '../infra/paths.js'
|
|
25
|
+
|
|
26
|
+
/** 记录文件(与我们其余的常驻状态同住 `dshHome()/plugin-console/`,不碰用户 profile)。 */
|
|
27
|
+
const autoPreflightFile = () => join(dshHome(), 'plugin-console', 'auto-preflight.json')
|
|
28
|
+
|
|
29
|
+
/** 保留最近的扫描记录条数(防止指纹频繁抖动把状态文件撑大)。 */
|
|
30
|
+
const MAX_RECORDS = 12
|
|
31
|
+
|
|
32
|
+
/** 自动预检的默认超时(毫秒)。宁可降级成「未完成,可手动重跑」,也不让状态查询等它。 */
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 4000
|
|
34
|
+
|
|
35
|
+
const STATES = ['pending', 'done', 'failed', 'timeout']
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 指纹的**稳定 key**:只取参与变更判定的三个字段(`at` 每次都变,绝不能进 key)。
|
|
39
|
+
* 归一化:字符串去空白 + 小写(win32 路径大小写不敏感;两个指纹「实际相同」时不许各跑一次)。
|
|
40
|
+
*/
|
|
41
|
+
function fingerprintKey(fp) {
|
|
42
|
+
const norm = (value, lower = false) => {
|
|
43
|
+
if (value === null || value === undefined) return ''
|
|
44
|
+
const text = String(value).trim()
|
|
45
|
+
return lower ? text.toLowerCase() : text
|
|
46
|
+
}
|
|
47
|
+
// 空包数按 '?' 归一:null 与 undefined 是同一件事(数不出来),别当成"变了"
|
|
48
|
+
const entities = fp?.pnpmEntities === null || fp?.pnpmEntities === undefined ? '?' : String(fp.pnpmEntities)
|
|
49
|
+
// 路径:反斜杠归一成正斜杠(Windows 的 `C:\x` 与 `C:/x` 是同一棵树)+ 小写(win32 不区分大小写)
|
|
50
|
+
const root = norm(fp?.pnpmRoot, true).replace(/\\/gu, '/')
|
|
51
|
+
return [norm(fp?.frameworkVersion), root, entities].join('|')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 两份指纹是否「实际相同」(同一 key)。 */
|
|
55
|
+
function sameFingerprint(a, b) {
|
|
56
|
+
return fingerprintKey(a) === fingerprintKey(b)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readStore(file = autoPreflightFile()) {
|
|
60
|
+
try {
|
|
61
|
+
const rec = JSON.parse(readFileSync(file, 'utf8'))
|
|
62
|
+
if (rec === null || typeof rec !== 'object') return { schema: 1, records: [] }
|
|
63
|
+
return { schema: 1, records: Array.isArray(rec.records) ? rec.records : [] }
|
|
64
|
+
} catch {
|
|
65
|
+
return { schema: 1, records: [] }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 只增不删的合并:同 key 记录用新值覆盖,别的 key 原样保留;按 at 倒序裁剪到 MAX_RECORDS。 */
|
|
70
|
+
function upsertRecord(records, record, limit = MAX_RECORDS) {
|
|
71
|
+
const list = Array.isArray(records) ? records.filter((r) => r !== null && typeof r === 'object') : []
|
|
72
|
+
const key = typeof record?.key === 'string' ? record.key : fingerprintKey(record?.fingerprint)
|
|
73
|
+
const next = list.filter((r) => (typeof r.key === 'string' ? r.key : fingerprintKey(r.fingerprint)) !== key)
|
|
74
|
+
next.push({ ...record, key })
|
|
75
|
+
next.sort((a, b) => (Number(b?.at ?? 0) - Number(a?.at ?? 0)))
|
|
76
|
+
return next.slice(0, limit)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function writeStore(store, file = autoPreflightFile()) {
|
|
80
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
81
|
+
writeFileSync(file, `${JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), records: store.records }, null, 2)}\n`, 'utf8')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 从记录集里取某个指纹的那条记录(没有则 null)。 */
|
|
85
|
+
function findRecord(records, fp) {
|
|
86
|
+
const key = fingerprintKey(fp)
|
|
87
|
+
return (Array.isArray(records) ? records : []).find((r) => (typeof r?.key === 'string' ? r.key : fingerprintKey(r?.fingerprint)) === key) ?? null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 自动预检的**执行判定**(纯函数)。三种「不跑」:
|
|
92
|
+
* · state='done' → 同一指纹已经扫过(这就是"同指纹只自动跑一次"的闸门);
|
|
93
|
+
* · state='pending' → 上一次还挂在超时边缘(没有结论),交给手动重跑,不叠加并发;
|
|
94
|
+
* · state='failed' / 'timeout' → **允许重试一次**(失败要能重试),但同一指纹最多自动重试 limit 次。
|
|
95
|
+
* @returns {{ run: boolean, reason: string, attempt: number }}
|
|
96
|
+
*/
|
|
97
|
+
function shouldAutoRun(record, { retryLimit = 1 } = {}) {
|
|
98
|
+
if (record === null || record === undefined) return { run: true, reason: 'first-seen', attempt: 1 }
|
|
99
|
+
const state = typeof record.state === 'string' ? record.state : 'failed'
|
|
100
|
+
const attempts = Number.isFinite(Number(record.attempts)) ? Number(record.attempts) : 0
|
|
101
|
+
if (state === 'done') return { run: false, reason: 'already-scanned', attempt: attempts }
|
|
102
|
+
if (state === 'pending') return { run: false, reason: 'in-flight', attempt: attempts }
|
|
103
|
+
if (attempts >= retryLimit + 1) return { run: false, reason: 'retry-exhausted', attempt: attempts }
|
|
104
|
+
return { run: true, reason: state === 'timeout' ? 'retry-after-timeout' : 'retry-after-failure', attempt: attempts + 1 }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 超时包裹(纯函数,**不抛**):到点就返回 `{ timedOut: true }`,绝不把异常甩给调用方。
|
|
109
|
+
* 注意它**不会取消**底层 promise(JS 没有取消语义)—— 底层跑完后的写盘由 runAutoPreflight 收口,
|
|
110
|
+
* 但那不影响状态查询:调用方拿到 timedOut 就立刻回响应。
|
|
111
|
+
*/
|
|
112
|
+
function withTimeout(promise, ms, onTimeout = null) {
|
|
113
|
+
const limit = Number.isFinite(Number(ms)) && Number(ms) > 0 ? Number(ms) : DEFAULT_TIMEOUT_MS
|
|
114
|
+
let timer = null
|
|
115
|
+
const timeout = new Promise((resolve) => {
|
|
116
|
+
timer = setTimeout(() => {
|
|
117
|
+
if (typeof onTimeout === 'function') { try { onTimeout() } catch {} }
|
|
118
|
+
resolve({ timedOut: true, value: null })
|
|
119
|
+
}, limit)
|
|
120
|
+
if (typeof timer?.unref === 'function') timer.unref()
|
|
121
|
+
})
|
|
122
|
+
const wrapped = Promise.resolve(promise).then(
|
|
123
|
+
(value) => ({ timedOut: false, value }),
|
|
124
|
+
(error) => ({ timedOut: false, value: null, error: error instanceof Error ? error.message : String(error) }),
|
|
125
|
+
)
|
|
126
|
+
return Promise.race([wrapped, timeout]).finally(() => { if (timer !== null) clearTimeout(timer) })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 兼容清单:把补丁体检与源码扫描的结论收敛成**受影响行**(面板按行显示,一行一句)。
|
|
131
|
+
* 只做汇总与排序,不新增判据(判据留在 patch-composition-audit / format-scan 两个模块里)。
|
|
132
|
+
*/
|
|
133
|
+
function affectedRows({ patchReport = null, scan = null, rows = [], limit = 40 } = {}) {
|
|
134
|
+
const out = []
|
|
135
|
+
const nameById = new Map((Array.isArray(rows) ? rows : []).map((r) => [r?.id, r?.name ?? null]))
|
|
136
|
+
for (const item of (patchReport?.blockers ?? [])) {
|
|
137
|
+
out.push({
|
|
138
|
+
rowId: item.row ?? null, moduleName: item.name ?? nameById.get(item.row) ?? null, severity: 'blocker',
|
|
139
|
+
code: item.code ?? 'patch-row', line: item.line ?? null, detail: item.detail ?? null,
|
|
140
|
+
hint: item.hint ?? null, suggestions: item.suggestions ?? [],
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
for (const item of (patchReport?.warnings ?? [])) {
|
|
144
|
+
out.push({
|
|
145
|
+
rowId: item.row ?? null, moduleName: item.name ?? nameById.get(item.row) ?? null, severity: 'warn',
|
|
146
|
+
code: item.code ?? 'patch-row', line: item.line ?? null, detail: item.detail ?? null,
|
|
147
|
+
hint: item.hint ?? null, suggestions: item.suggestions ?? [],
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
for (const f of (scan?.findings ?? [])) {
|
|
151
|
+
out.push({
|
|
152
|
+
rowId: null, moduleName: f.moduleName ?? null, severity: f.severity ?? 'warn',
|
|
153
|
+
code: f.rule ?? 'source-scan', line: f.line ?? null,
|
|
154
|
+
detail: `${f.file ?? '?'}${f.line === null || f.line === undefined ? '' : `:${f.line}`}${f.before === undefined ? '' : ` → ${f.after ?? ''}`}`,
|
|
155
|
+
hint: f.reason ?? f.note ?? null, suggestions: [],
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
const rank = { blocker: 0, warn: 1 }
|
|
159
|
+
out.sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9) || String(a.rowId ?? '').localeCompare(String(b.rowId ?? '')))
|
|
160
|
+
return out.slice(0, limit)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 隔离计划:**建议**禁用哪些行 + 为什么(绝不自动写盘;真正落盘只走用户显式触发的
|
|
165
|
+
* `POST /plugin-console/safe-boot {action:'disable-suspects'}` 或离线脚本)。
|
|
166
|
+
* 只收「点名 + 可开关 + 非核心」的行:核心行禁掉只会让服务更起不来(正确动作是回滚框架)。
|
|
167
|
+
*/
|
|
168
|
+
function planSuspectRows(affected, { coreRowIds = new Set(), toggleableById = new Map(), limit = 20 } = {}) {
|
|
169
|
+
const suspects = []
|
|
170
|
+
const skipped = []
|
|
171
|
+
for (const item of (Array.isArray(affected) ? affected : [])) {
|
|
172
|
+
if (item.severity !== 'blocker' || typeof item.rowId !== 'string' || item.rowId === '') continue
|
|
173
|
+
if (coreRowIds.has(item.rowId)) { skipped.push({ rowId: item.rowId, reason: 'core-row' }); continue }
|
|
174
|
+
if (item.rowId === 'plugin-console') { skipped.push({ rowId: item.rowId, reason: 'self' }); continue }
|
|
175
|
+
if (toggleableById.has(item.rowId) && toggleableById.get(item.rowId) === false) { skipped.push({ rowId: item.rowId, reason: 'not-toggleable' }); continue }
|
|
176
|
+
if (suspects.some((s) => s.rowId === item.rowId)) continue
|
|
177
|
+
if (suspects.length >= limit) break
|
|
178
|
+
suspects.push({ rowId: item.rowId, moduleName: item.moduleName ?? null, reason: item.code, detail: item.detail ?? null, line: item.line ?? null })
|
|
179
|
+
}
|
|
180
|
+
return { suspects, skipped }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 本次变更摘要:框架版本 from→to + 新增/消失的包 + 指纹 reasons(面板一句话,详情放长文)。 */
|
|
184
|
+
function summarizeDelta({ current = null, previous = null, currentPackages = [], previousPackages = [] } = {}) {
|
|
185
|
+
const from = previous?.frameworkVersion ?? null
|
|
186
|
+
const to = current?.frameworkVersion ?? null
|
|
187
|
+
const before = new Set((Array.isArray(previousPackages) ? previousPackages : []).filter((n) => typeof n === 'string' && n !== ''))
|
|
188
|
+
const after = new Set((Array.isArray(currentPackages) ? currentPackages : []).filter((n) => typeof n === 'string' && n !== ''))
|
|
189
|
+
const added = [...after].filter((n) => !before.has(n)).sort()
|
|
190
|
+
const removed = [...before].filter((n) => !after.has(n)).sort()
|
|
191
|
+
return {
|
|
192
|
+
frameworkVersionFrom: from,
|
|
193
|
+
frameworkVersionTo: to,
|
|
194
|
+
versionChanged: from !== to,
|
|
195
|
+
packagesAdded: added.slice(0, 50),
|
|
196
|
+
packagesRemoved: removed.slice(0, 50),
|
|
197
|
+
packagesAddedCount: added.length,
|
|
198
|
+
packagesRemovedCount: removed.length,
|
|
199
|
+
rootChanged: (previous?.pnpmRoot ?? null) !== (current?.pnpmRoot ?? null),
|
|
200
|
+
entitiesChanged: (previous?.pnpmEntities ?? null) !== (current?.pnpmEntities ?? null),
|
|
201
|
+
rationaleAvailable: before.size > 0 || after.size > 0,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 面板/离线脚本共用的一句话短句(中英双语;面板只放这一句,长文进 detail)。 */
|
|
206
|
+
function autoPreflightNote(state, { blockers = 0, suspects = 0 } = {}) {
|
|
207
|
+
const zh = {
|
|
208
|
+
pending: '环境变了,正在自动预检…',
|
|
209
|
+
done: blockers > 0 ? `环境变了:预检发现 ${blockers} 行受影响${suspects > 0 ? `,建议禁用 ${suspects} 行` : ''}` : '环境变了:自动预检未发现受影响行',
|
|
210
|
+
failed: '环境变了,但自动预检失败(可手动重跑)',
|
|
211
|
+
timeout: '环境变了,自动预检超时(可手动重跑)',
|
|
212
|
+
idle: '环境未变化,无需自动预检',
|
|
213
|
+
}
|
|
214
|
+
const en = {
|
|
215
|
+
pending: 'Environment changed — auto-preflight running…',
|
|
216
|
+
done: blockers > 0 ? `Environment changed: ${blockers} affected row(s)${suspects > 0 ? `, ${suspects} suggested for disable` : ''}` : 'Environment changed: auto-preflight found no affected rows',
|
|
217
|
+
failed: 'Environment changed, but auto-preflight failed (you can re-run it)',
|
|
218
|
+
timeout: 'Environment changed; auto-preflight timed out (you can re-run it)',
|
|
219
|
+
idle: 'No environment change — auto-preflight not needed',
|
|
220
|
+
}
|
|
221
|
+
const key = Object.prototype.hasOwnProperty.call(zh, state) ? state : 'idle'
|
|
222
|
+
return { state: key, zh: zh[key], en: en[key] }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 自动预检收口(**这条路径不阻塞任何调用方**:调用方不 await 它)。
|
|
227
|
+
* 流程:判定是否该跑 → 写 state='pending' 占位(同指纹只跑一次的闸门立刻生效)→ 跑注入的扫描
|
|
228
|
+
* → 落 `{state:'done', preflight, affected, suspects, delta}`;失败/超时如实落 `failed`/`timeout` + reason。
|
|
229
|
+
* 写盘失败也不抛(返回 `{ok:false, error}`),因为它的调用方是只读状态查询。
|
|
230
|
+
*
|
|
231
|
+
* ★ 并发纪律(2026-09-29 实测踩到):**终态写入必须只落自己的那一条**。
|
|
232
|
+
* 早先的写法是"读整份 store → push → 整份写回",两个指纹的扫描同时收尾时会互相覆盖
|
|
233
|
+
* (谁后写谁赢,另一条的终态**永远停在 pending** —— 面板就永远显示"正在自动预检…")。
|
|
234
|
+
* 现在终态写盘只 upsert **当前 key 的那一条**,其余记录原样保留。
|
|
235
|
+
*/
|
|
236
|
+
async function runAutoPreflight({ current, previous, scan, timeoutMs = DEFAULT_TIMEOUT_MS, now = () => Date.now(), file = autoPreflightFile() } = {}) {
|
|
237
|
+
const key = fingerprintKey(current)
|
|
238
|
+
const startedAt = now()
|
|
239
|
+
try {
|
|
240
|
+
const store = readStore(file)
|
|
241
|
+
const existing = findRecord(store.records, current)
|
|
242
|
+
const verdict = shouldAutoRun(existing)
|
|
243
|
+
if (!verdict.run) return { ok: true, ran: false, reason: verdict.reason, key, state: existing?.state ?? 'idle' }
|
|
244
|
+
const pendingRecord = {
|
|
245
|
+
...(existing ?? {}), key, at: startedAt, startedAt, finishedAt: null, state: 'pending',
|
|
246
|
+
attempts: verdict.attempt, reason: verdict.reason,
|
|
247
|
+
fingerprint: current ?? null, previousFingerprint: previous ?? null,
|
|
248
|
+
}
|
|
249
|
+
writeStore({ records: upsertRecord(store.records, pendingRecord) }, file)
|
|
250
|
+
let result = null
|
|
251
|
+
const raced = await withTimeout(
|
|
252
|
+
Promise.resolve().then(() => scan({ current, previous, key })),
|
|
253
|
+
timeoutMs,
|
|
254
|
+
)
|
|
255
|
+
if (raced.timedOut) {
|
|
256
|
+
result = { ok: false, state: 'timeout', reason: `自动预检超过 ${timeoutMs}ms 未完成(已降级为「未完成,可手动重跑」,未阻塞状态查询)` }
|
|
257
|
+
} else if (raced.error !== undefined && raced.error !== null) {
|
|
258
|
+
result = { ok: false, state: 'failed', reason: `自动预检抛错:${raced.error}` }
|
|
259
|
+
} else {
|
|
260
|
+
const value = raced.value ?? {}
|
|
261
|
+
result = value.ok === false
|
|
262
|
+
? { ok: false, state: 'failed', reason: value.reason ?? '自动预检未产出结论' }
|
|
263
|
+
: { ok: true, state: 'done', preflight: value }
|
|
264
|
+
}
|
|
265
|
+
const finishedAt = now()
|
|
266
|
+
const record = {
|
|
267
|
+
...pendingRecord,
|
|
268
|
+
finishedAt,
|
|
269
|
+
state: result.state,
|
|
270
|
+
error: result.ok ? null : result.reason,
|
|
271
|
+
summary: result.ok ? (result.preflight?.summary ?? null) : null,
|
|
272
|
+
affected: result.ok ? (result.preflight?.affected ?? []) : [],
|
|
273
|
+
suspects: result.ok ? (result.preflight?.suspects ?? []) : [],
|
|
274
|
+
evidence: result.ok ? (result.preflight?.evidence ?? null) : null,
|
|
275
|
+
delta: result.ok ? (result.preflight?.delta ?? null) : null,
|
|
276
|
+
skipped: result.ok ? (result.preflight?.skipped ?? []) : [],
|
|
277
|
+
}
|
|
278
|
+
// 终态写盘:只 upsert 自己这条(读最新一份 + 只替换同 key),不覆盖别的指纹已经写好的结论
|
|
279
|
+
const after = readStore(file)
|
|
280
|
+
writeStore({ records: upsertRecord(after.records, record) }, file)
|
|
281
|
+
return { ok: result.ok, ran: true, key, state: result.state, record, error: result.ok ? null : result.reason, reason: verdict.reason }
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return { ok: false, ran: false, key, state: 'failed', error: `自动预检记录写入失败:${error instanceof Error ? error.message : String(error)}` }
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 状态查询下发的视图(**加法字段**,不改 `/compat-status` 既有字段):
|
|
289
|
+
* 面板只需要一句短句 + 是否有结论 + 有几行受影响;长文(reasons/affected/delta)放在详情里。
|
|
290
|
+
*/
|
|
291
|
+
function autoPreflightView({ changed, current, records = null, file = autoPreflightFile() } = {}) {
|
|
292
|
+
const list = Array.isArray(records) ? records : readStore(file).records
|
|
293
|
+
// 指纹没变 → 不下发任何「上次的扫描结论」:那是上一个环境的结论,混在一起会让面板说假话。
|
|
294
|
+
const record = changed === true ? findRecord(list, current) : null
|
|
295
|
+
const state = record === null ? 'idle' : (STATES.includes(record.state) ? record.state : 'idle')
|
|
296
|
+
const preflight = record === null ? null : {
|
|
297
|
+
state: record.state ?? null,
|
|
298
|
+
startedAt: record.startedAt ?? null,
|
|
299
|
+
finishedAt: record.finishedAt ?? null,
|
|
300
|
+
attempts: record.attempts ?? 0,
|
|
301
|
+
error: record.error ?? null,
|
|
302
|
+
summary: record.summary ?? null,
|
|
303
|
+
affected: record.affected ?? [],
|
|
304
|
+
suspects: record.suspects ?? [],
|
|
305
|
+
skipped: record.skipped ?? [],
|
|
306
|
+
evidence: record.evidence ?? null,
|
|
307
|
+
delta: record.delta ?? null,
|
|
308
|
+
}
|
|
309
|
+
const blockers = (preflight?.affected ?? []).filter((a) => a.severity === 'blocker').length
|
|
310
|
+
return {
|
|
311
|
+
changed: changed === true,
|
|
312
|
+
state,
|
|
313
|
+
key: fingerprintKey(current),
|
|
314
|
+
ranAt: record?.startedAt ?? null,
|
|
315
|
+
scannedAt: record?.finishedAt ?? null,
|
|
316
|
+
blockers,
|
|
317
|
+
affectedCount: (preflight?.affected ?? []).length,
|
|
318
|
+
suspects: (preflight?.suspects ?? []).map((s) => s.rowId),
|
|
319
|
+
note: autoPreflightNote(state, { blockers, suspects: (preflight?.suspects ?? []).length }),
|
|
320
|
+
error: record?.error ?? null,
|
|
321
|
+
retryable: state === 'failed' || state === 'timeout',
|
|
322
|
+
preflight,
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export {
|
|
327
|
+
DEFAULT_TIMEOUT_MS,
|
|
328
|
+
MAX_RECORDS,
|
|
329
|
+
STATES,
|
|
330
|
+
affectedRows,
|
|
331
|
+
autoPreflightFile,
|
|
332
|
+
autoPreflightNote,
|
|
333
|
+
autoPreflightView,
|
|
334
|
+
findRecord,
|
|
335
|
+
fingerprintKey,
|
|
336
|
+
planSuspectRows,
|
|
337
|
+
readStore,
|
|
338
|
+
runAutoPreflight,
|
|
339
|
+
sameFingerprint,
|
|
340
|
+
shouldAutoRun,
|
|
341
|
+
summarizeDelta,
|
|
342
|
+
upsertRecord,
|
|
343
|
+
withTimeout,
|
|
344
|
+
writeStore,
|
|
345
|
+
}
|
|
@@ -17,6 +17,44 @@ const COMPAT_MODES = ['managed', 'observer']
|
|
|
17
17
|
// 默认**观察者**(用户 2026-09-24 定):只守门、不接管框架升级;一旦用户走了本控制台的框架升级,服务端会自动切回托管
|
|
18
18
|
const DEFAULT_MODE = 'observer'
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 模式写入的**来源**(0.5.32 加法 D4;缺字段一律按 `manual` 兼容 —— 老记录等于"用户手动设定过",永不自动改):
|
|
22
|
+
* · `manual` 用户在本控制台手动拨过(`POST /compat-mode`)→ 之后**永不自动改**
|
|
23
|
+
* · `auto:console-upgrade` 走了本控制台的框架升级 → 自动切托管(可被桌面端启动拨回)
|
|
24
|
+
* · `auto:desktop-host` 检测到桌面端宿主形态 → 自动切观察者
|
|
25
|
+
*/
|
|
26
|
+
const MODE_SOURCES = ['manual', 'auto:console-upgrade', 'auto:desktop-host']
|
|
27
|
+
const DEFAULT_MODE_SOURCE = 'manual'
|
|
28
|
+
|
|
29
|
+
/** 自动切换的判定(纯函数):**只在"上次不是用户手动设定"时才自动改**。 */
|
|
30
|
+
const AUTO_MODE_SOURCES = ['auto:console-upgrade', 'auto:desktop-host']
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 「桌面端宿主启动 → 自动拨回观察者」的判定(纯函数,加法 D4)。
|
|
34
|
+
* 规则(三条,全部可离线断言):
|
|
35
|
+
* ① 不是桌面端宿主 → 不动(`standalone` 实例的既有行为一字不改);
|
|
36
|
+
* ② 当前已经是 observer → 不动(**零写盘**:连 source 都不刷新);
|
|
37
|
+
* ③ 当前模式的来源是 `manual`(含缺字段的老记录)→ **不动**(用户手动优先,绝不抢用户意图)。
|
|
38
|
+
* @returns {{ action: 'none'|'switch-to-observer', mode: string, from: string, reason: string|null, note: {zh,en}|null }}
|
|
39
|
+
*/
|
|
40
|
+
function planDesktopHostSwitch(modeRecord, { hosted = false } = {}) {
|
|
41
|
+
const mode = typeof modeRecord?.mode === 'string' && COMPAT_MODES.includes(modeRecord.mode) ? modeRecord.mode : DEFAULT_MODE
|
|
42
|
+
const source = typeof modeRecord?.source === 'string' && MODE_SOURCES.includes(modeRecord.source) ? modeRecord.source : DEFAULT_MODE_SOURCE
|
|
43
|
+
if (hosted !== true) return { action: 'none', mode, from: source, reason: 'not-desktop-host', note: null }
|
|
44
|
+
if (mode === 'observer') return { action: 'none', mode, from: source, reason: 'already-observer', note: null }
|
|
45
|
+
if (!AUTO_MODE_SOURCES.includes(source)) return { action: 'none', mode, from: source, reason: 'user-set-manual', note: null }
|
|
46
|
+
return {
|
|
47
|
+
action: 'switch-to-observer',
|
|
48
|
+
mode: 'observer',
|
|
49
|
+
from: source,
|
|
50
|
+
reason: 'desktop-host-detected',
|
|
51
|
+
note: {
|
|
52
|
+
zh: '检测到桌面端托管,已切观察者:不接管框架升级',
|
|
53
|
+
en: 'Desktop host detected — switched to Observer: framework upgrades are not taken over',
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
20
58
|
const stateDir = () => join(dshHome(), 'plugin-console')
|
|
21
59
|
const modeFile = () => join(stateDir(), 'compat-mode.json')
|
|
22
60
|
const fingerprintFile = () => join(stateDir(), 'env-fingerprint.json')
|
|
@@ -36,25 +74,36 @@ function writeJson(path, value) {
|
|
|
36
74
|
}
|
|
37
75
|
|
|
38
76
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
77
|
+
* 读运行模式。**默认观察者** —— 纯加法:没有设置过的机器行为与之前完全一致。
|
|
78
|
+
* `source` 缺字段(0.5.32 之前写下的记录)按 `manual` 兼容:视为用户手动设定过,绝不自动改。
|
|
79
|
+
* @returns {{ mode: string, at: number|null, source: 'manual'|'auto:console-upgrade'|'auto:desktop-host', reason: string|null, fileSource: 'file'|'default' }}
|
|
41
80
|
*/
|
|
42
81
|
function readCompatMode() {
|
|
43
82
|
const rec = readJson(modeFile())
|
|
44
83
|
const mode = typeof rec?.mode === 'string' && COMPAT_MODES.includes(rec.mode) ? rec.mode : DEFAULT_MODE
|
|
45
|
-
|
|
84
|
+
const source = typeof rec?.source === 'string' && MODE_SOURCES.includes(rec.source) ? rec.source : DEFAULT_MODE_SOURCE
|
|
85
|
+
return { mode, at: typeof rec?.at === 'number' ? rec.at : null, source, reason: typeof rec?.reason === 'string' ? rec.reason : null, fileSource: rec === null ? 'default' : 'file' }
|
|
46
86
|
}
|
|
47
87
|
|
|
48
88
|
/**
|
|
49
89
|
* 写运行模式(只接受白名单值,避免拼错导致「模式静默失效」)。
|
|
50
|
-
*
|
|
90
|
+
* **幂等零写盘**(0.5.32 加法 D4):模式与来源都没变时一个字节都不写 —— 否则"桌面端每次启动都重写一份
|
|
91
|
+
* 状态文件",而用户看不出任何区别(也破坏"已是 observer 时零写盘"的断言)。
|
|
92
|
+
* @returns {{ ok: boolean, mode: string, source: string, changed: boolean, error?: string }}
|
|
51
93
|
*/
|
|
52
94
|
function writeCompatMode(mode, meta = {}) {
|
|
53
95
|
if (typeof mode !== 'string' || !COMPAT_MODES.includes(mode)) {
|
|
54
|
-
|
|
96
|
+
const current = readCompatMode()
|
|
97
|
+
return { ok: false, mode: current.mode, source: current.source, changed: false, error: `未知模式 ${String(mode)}(可选:${COMPAT_MODES.join(' / ')})` }
|
|
98
|
+
}
|
|
99
|
+
const source = typeof meta.source === 'string' && MODE_SOURCES.includes(meta.source) ? meta.source : DEFAULT_MODE_SOURCE
|
|
100
|
+
const reason = typeof meta.reason === 'string' ? meta.reason : null
|
|
101
|
+
const current = readCompatMode()
|
|
102
|
+
if (current.mode === mode && current.source === source && current.reason === reason && current.at !== null) {
|
|
103
|
+
return { ok: true, mode, source, changed: false }
|
|
55
104
|
}
|
|
56
|
-
writeJson(modeFile(), { mode, at: Date.now(),
|
|
57
|
-
return { ok: true, mode }
|
|
105
|
+
writeJson(modeFile(), { mode, at: Date.now(), source, reason })
|
|
106
|
+
return { ok: true, mode, source, changed: true }
|
|
58
107
|
}
|
|
59
108
|
|
|
60
109
|
/**
|
|
@@ -124,4 +173,4 @@ function readRollbackRecord() {
|
|
|
124
173
|
}
|
|
125
174
|
}
|
|
126
175
|
|
|
127
|
-
export { COMPAT_MODES, DEFAULT_MODE, compareFingerprint, computeFingerprint, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode }
|
|
176
|
+
export { AUTO_MODE_SOURCES, COMPAT_MODES, DEFAULT_MODE, DEFAULT_MODE_SOURCE, MODE_SOURCES, compareFingerprint, computeFingerprint, planDesktopHostSwitch, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// L1 · domain —— compat.js(框架兼容判定与适配门:设置 API 扫描 / 兼容门读写 / 待适配记录 / 隔离记录合并的前置部分 / 启动失败分析;分层 Step 6 从 lib/index.js 搬出,只搬移未改逻辑。注:detectCompat 等吃运行上下文的留到 Step 8)
|
|
2
|
-
//
|
|
2
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
3
|
|
|
4
4
|
import { readFileSync, writeFileSync, readdirSync, mkdirSync, realpathSync } from 'node:fs'
|
|
5
5
|
import { readFile } from 'node:fs/promises'
|
|
@@ -194,10 +194,13 @@ function writeCompatPending(payload) {
|
|
|
194
194
|
|
|
195
195
|
/** 兼容门总开关(用户定案 2026-09-11):用户可关掉自动行为,回到纯手动。
|
|
196
196
|
* autoDisable —— 升级前是否自动禁用判定不适配的行(关掉 = 只提示不动开关)
|
|
197
|
-
* autoDetect —— 打开控制台时是否自动检测「已适配」(关掉 = 不显示可解锁提示)
|
|
197
|
+
* autoDetect —— 打开控制台时是否自动检测「已适配」(关掉 = 不显示可解锁提示)
|
|
198
|
+
* autoDisableOnEvidence —— 0.5.32 加法 D5:**指纹变化自动预检 / 启动失败记录**给出**确证证据**时
|
|
199
|
+
* 是否自动写 `disabled: true`(默认启用 = 按用户意图;关掉 = 只报告 + 等用户点「只禁可疑行」)。
|
|
200
|
+
* 它与 autoDisable 是两个独立开关:后者管"我们升级时",前者管"别人升级 / 启动失败时"。 */
|
|
198
201
|
const COMPAT_GATE_FILE = () => join(dshHome(), 'plugin-console', 'compat-gate.json')
|
|
199
202
|
|
|
200
|
-
const COMPAT_GATE_DEFAULTS = { autoDisable: true, autoDetect: true, noticeMode: 'once-per-version' }
|
|
203
|
+
const COMPAT_GATE_DEFAULTS = { autoDisable: true, autoDetect: true, autoDisableOnEvidence: true, noticeMode: 'once-per-version' }
|
|
201
204
|
|
|
202
205
|
function readCompatGate() {
|
|
203
206
|
try {
|
|
@@ -205,6 +208,7 @@ function readCompatGate() {
|
|
|
205
208
|
return {
|
|
206
209
|
autoDisable: raw?.autoDisable !== false,
|
|
207
210
|
autoDetect: raw?.autoDetect !== false,
|
|
211
|
+
autoDisableOnEvidence: raw?.autoDisableOnEvidence !== false,
|
|
208
212
|
// 兼容提示的展示模式(2026-09-28 加法):非法/缺失一律回落默认,**不影响 supported 判定**
|
|
209
213
|
noticeMode: NOTICE_MODES.includes(raw?.noticeMode) ? raw.noticeMode : DEFAULT_NOTICE_MODE,
|
|
210
214
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// L1 · domain —— components.js(服务器组件:注册表 / 增删改 / 进程启停 / 状态 / 自启动;分层 Step 4 从 lib/index.js 搬出,只搬移未改逻辑)
|
|
2
|
-
//
|
|
2
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
3
|
|
|
4
4
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
5
5
|
import { spawn } from 'node:child_process'
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// lock(删 lock / 清 node_modules / 换机 / CI)→ `ERR_PNPM_FETCH_404`,而报错指向 npm registry,
|
|
12
12
|
// 用户根本联想不到是几周前面板安装改写造成的。
|
|
13
13
|
//
|
|
14
|
-
//
|
|
14
|
+
// 两条硬约束(真 pnpm 10.34.5 实测矩阵见仓库外私有方案稿第 23 节):
|
|
15
15
|
// ① 写回前必须确认「这个包的**这个版本**」在 registry 可解析,否则**绝不**写裸版本号;
|
|
16
16
|
// ② 不可解析时**也不能**写 tarball URL:pnpm 10 对 direct-URL 依赖只在冷缓存真下载时记 integrity,
|
|
17
17
|
// 命中缓存重写 lock 时 resolution 里没有 integrity → `ERR_PNPM_MISSING_TARBALL_INTEGRITY`,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// L1 · domain —— framework.js(升级三件套的可搬部分:状态/备份路径、dsh 可执行文件定位、app boot 定位、容忍补丁、版本比较、重启前奏;分层 Step 6 从 lib/index.js 搬出,只搬移未改逻辑。注:detectFrameworkUpgrade / backupProfileSnapshot / currentFrameworkVersion 吃运行上下文,留到 Step 8)
|
|
2
|
-
//
|
|
2
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
3
|
|
|
4
4
|
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, copyFileSync } from 'node:fs'
|
|
5
5
|
import { execFile } from 'node:child_process'
|
|
@@ -27,6 +27,27 @@ const FRAMEWORK_STATE_FILE = () => join(dshHome(), 'plugin-console', 'framework-
|
|
|
27
27
|
|
|
28
28
|
const FRAMEWORK_BACKUP_ROOT = () => join(dshHome(), 'plugin-console', 'framework-backups')
|
|
29
29
|
|
|
30
|
+
/** npm 缓存下的 npx 缓存目录候选(**不写死盘符/用户名**)。
|
|
31
|
+
* npm 的 cache 位置本来就是用户配置(把 cache 指到别的盘是常见做法),所以按 npm 自己的
|
|
32
|
+
* 配置来源读取:环境变量 npm_config_cache / NPM_CONFIG_CACHE,以及用户级 ~/.npmrc 的 `cache=`。
|
|
33
|
+
* (原实现把开发机上某个盘符下的 `node_cache\_npx` 直接写进源码,随 npm 发给所有用户。) */
|
|
34
|
+
function npmNpxCacheRoots() {
|
|
35
|
+
const roots = []
|
|
36
|
+
const push = (value) => {
|
|
37
|
+
if (typeof value !== 'string') return
|
|
38
|
+
const dir = value.trim().replace(/^["']|["']$/gu, '').replace(/^~(?=[\\/]|$)/u, homedir())
|
|
39
|
+
if (dir !== '') roots.push(join(dir, '_npx'))
|
|
40
|
+
}
|
|
41
|
+
for (const key of ['npm_config_cache', 'NPM_CONFIG_CACHE']) push(process.env[key])
|
|
42
|
+
try {
|
|
43
|
+
for (const line of readFileSync(join(homedir(), '.npmrc'), 'utf8').split(/\r?\n/u)) {
|
|
44
|
+
const hit = /^\s*cache\s*=\s*(.*?)\s*$/iu.exec(line)
|
|
45
|
+
if (hit) push(hit[1])
|
|
46
|
+
}
|
|
47
|
+
} catch {}
|
|
48
|
+
return roots
|
|
49
|
+
}
|
|
50
|
+
|
|
30
51
|
/** 定位 @deepseek-ai/dsh-app-boot(与 @deepseek-ai/dsh 同级)。 */
|
|
31
52
|
function locateAppBootFile(baseUrl) {
|
|
32
53
|
try {
|
|
@@ -35,15 +56,26 @@ function locateAppBootFile(baseUrl) {
|
|
|
35
56
|
const candidate = join(dirname(dshPkg), 'dsh-app-boot', 'lib', 'index.js')
|
|
36
57
|
if (existsSync(candidate)) return candidate
|
|
37
58
|
} catch {}
|
|
59
|
+
// 缓存根一律由「环境变量 / 用户配置 / 用户目录」派生,**不含任何写死的盘符或开发机路径**:
|
|
60
|
+
// · NODE_CACHE:npx 缓存根的可选覆盖(直接就是 _npx 那一层)
|
|
61
|
+
// · npm_config_cache / ~/.npmrc 的 cache=:npm 自己配置的缓存根(下面挂 _npx)
|
|
62
|
+
// · ~/.npm/_npx:npm/npx 的默认用户级缓存(POSIX 与 Windows 同一处)
|
|
63
|
+
// · %LOCALAPPDATA%\node_cache\_npx:缓存被重定向到 LOCALAPPDATA 时的位置(仅 Windows 有意义)
|
|
64
|
+
// 原实现第一条写死开发机上的缓存绝对路径(某个盘符下的 node_cache\_npx),且 LOCALAPPDATA
|
|
65
|
+
// 缺失时会拼出**相对路径** 'node_cache/_npx'(相对 CWD)——两处都已去掉。
|
|
38
66
|
const cacheRoots = [
|
|
39
67
|
process.env.NODE_CACHE || '',
|
|
40
|
-
|
|
68
|
+
...npmNpxCacheRoots(),
|
|
41
69
|
join(homedir(), '.npm', '_npx'),
|
|
42
|
-
join(process.env.LOCALAPPDATA
|
|
70
|
+
process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'node_cache', '_npx') : '',
|
|
43
71
|
].filter(Boolean)
|
|
44
72
|
for (const root of cacheRoots) {
|
|
45
73
|
if (!existsSync(root)) continue
|
|
46
|
-
|
|
74
|
+
// 读目录失败(权限/竞态)只跳过这一条候选,绝不抛给调用方——本函数的契约是
|
|
75
|
+
// 「定位不到就返回 null」(调用方据此如实报「dsh-app-boot 未找到」)。
|
|
76
|
+
let entries = []
|
|
77
|
+
try { entries = readdirSync(root, { withFileTypes: true }) } catch { continue }
|
|
78
|
+
for (const entry of entries) {
|
|
47
79
|
if (!entry.isDirectory()) continue
|
|
48
80
|
const candidate = join(root, entry.name, 'node_modules', '@deepseek-ai', 'dsh-app-boot', 'lib', 'index.js')
|
|
49
81
|
if (existsSync(candidate)) return candidate
|
|
@@ -141,7 +173,7 @@ function checkpointFrameworkTree(fwRoot, destRoot) {
|
|
|
141
173
|
*
|
|
142
174
|
* 事故现场(官方桌面端实例,127.0.0.1:19387,20:28):
|
|
143
175
|
* 桌面端里 DSH host 是 **Electron 二进制**跑的 asar 内入口
|
|
144
|
-
*
|
|
176
|
+
* (`<桌面端安装目录>\DeepSeek Harness.exe --expose-internals …\app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\index.js …`),
|
|
145
177
|
* 不是 `node bin.js web`。我们的「重启服务 / 回滚 / 框架升级重启」那一套会
|
|
146
178
|
* `Stop-Process` 掉 host,然后 `Resolve-DshBin` 三种方式全找不到 bin.js
|
|
147
179
|
* (桌面端 profile 的 node_modules 里根本没有 `@deepseek-ai` 作用域)→ 拉不起来 →
|
|
@@ -212,6 +244,33 @@ function shellHostedRefusal(deps) {
|
|
|
212
244
|
}
|
|
213
245
|
}
|
|
214
246
|
|
|
247
|
+
/**
|
|
248
|
+
* 非 Windows 平台的标准拒绝载荷(**纯数据,不碰 HTTP**):路由层负责翻成响应。
|
|
249
|
+
* 返回 null = win32(放行,行为与旧实现一字不改)。
|
|
250
|
+
*
|
|
251
|
+
* 为什么必须拒绝(2026-09-29 改错):一键升级/回滚通道整体建立在 Windows 机制上 ——
|
|
252
|
+
* `schtasks.exe` 计划任务(脱离服务进程树)+ PowerShell 脚本 + `Get-NetTCPConnection` 探端口。
|
|
253
|
+
* 非 Windows 上 execFile('schtasks.exe') 必然 ENOENT,而旧实现先 writeFileSync 一个
|
|
254
|
+
* 「脚本已通过 detached 启动」的状态文件、再去 exec 不存在的 powershell.exe,
|
|
255
|
+
* 最后**照样** `upgraded = true` 并回 `ok: true, steps: ['框架升级脚本已启动…']` ——
|
|
256
|
+
* 用户看到"已启动",实际一个字节都没动(静默失败 + 假成功)。宁可明确说"不支持"。
|
|
257
|
+
*/
|
|
258
|
+
function platformUpgradeRefusal() {
|
|
259
|
+
if (process.platform === 'win32') return null
|
|
260
|
+
return {
|
|
261
|
+
error: '当前平台暂不支持「一键框架升级/回滚」:该通道依赖 Windows 计划任务(schtasks.exe)与 PowerShell',
|
|
262
|
+
details: {
|
|
263
|
+
code: 'unsupported-platform',
|
|
264
|
+
platform: process.platform,
|
|
265
|
+
manual: [
|
|
266
|
+
'手动升级框架:npm i -g @deepseek-ai/dsh@latest(用 npx 的实例:清掉 npx 缓存后重跑 npx @deepseek-ai/dsh web)',
|
|
267
|
+
'升级前请自行备份 profile 目录(~/.dsh/profiles/<profile>)与 cordis.patch.yml',
|
|
268
|
+
'升级完成后本控制台仍会自动:备份新版本配置快照、重打框架补丁(issue #5 容错)、给出适配提示',
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
215
274
|
/**
|
|
216
275
|
* 生成「拉起 DSH 服务」的 PowerShell 前导块(升级脚本 / 回滚脚本共用,v0.3.37 事故修复)。
|
|
217
276
|
*
|
|
@@ -477,4 +536,4 @@ async function preflightDisableIncompatible({ ports, profileDir, patchPath, targ
|
|
|
477
536
|
}
|
|
478
537
|
return { disabled, skipped }
|
|
479
538
|
}
|
|
480
|
-
export { FRAMEWORK_STATE_FILE, FRAMEWORK_BACKUP_ROOT, SHELL_HOSTED_NOTICE, detectHostShape, shellHostedRefusal, cleanupStaleFwTasks, resolveDshBin, locateAppBootFile, applyFrameworkTolerancePatchOnce, resolveFrameworkRootNodeModules, checkpointFrameworkTree, relaunchPrelude, isVersionAtLeast, pickFrameworkTarget, resolveFrameworkUpgradePlan, currentFrameworkVersion, backupProfileSnapshot, detectFrameworkUpgrade, preflightDisableIncompatible }
|
|
539
|
+
export { FRAMEWORK_STATE_FILE, FRAMEWORK_BACKUP_ROOT, SHELL_HOSTED_NOTICE, detectHostShape, shellHostedRefusal, platformUpgradeRefusal, cleanupStaleFwTasks, resolveDshBin, locateAppBootFile, applyFrameworkTolerancePatchOnce, resolveFrameworkRootNodeModules, checkpointFrameworkTree, relaunchPrelude, isVersionAtLeast, pickFrameworkTarget, resolveFrameworkUpgradePlan, currentFrameworkVersion, backupProfileSnapshot, detectFrameworkUpgrade, preflightDisableIncompatible }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// L1 · domain —— install-job.js(安装任务编排:多通道安装 + 套装补丁 + 适配门 + 收尾)。
|
|
2
2
|
// 分层 Step 8c-2 从 lib/index.js 搬出;形参由 ctx 收窄为 ports(调用方注入的窄接口),只搬移未改逻辑。
|
|
3
|
-
//
|
|
3
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
4
4
|
|
|
5
5
|
import { readFileSync, existsSync } from 'node:fs'
|
|
6
6
|
import { readFile } from 'node:fs/promises'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// L1 · domain —— install.js(插件安装/卸载全链路:pnpm / curl 手动 / GitHub Release 三通道 + 包盒校验 + 套装补丁完整性;分层 Step 5 从 lib/index.js 搬出,只搬移未改逻辑)
|
|
2
|
-
//
|
|
2
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
3
|
|
|
4
4
|
import { readFileSync, writeFileSync, existsSync, rmSync, readdirSync, mkdirSync } from 'node:fs'
|
|
5
5
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// L1 · domain —— jobs.js(任务辅助:日志追加 + 健康等待轮询)。方案原稿把 jobLog 归 ai.js、waitHealth 归 components.js,但这俩是 install / suite / ai 三个域共用的,埋在任何一个域里都会造成跨域反向依赖 —— 故单独成模块(偏离已记录)
|
|
2
|
-
//
|
|
2
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
function jobLog(job, line) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// L1 · domain —— market.js(分层 Step 从 lib/index.js 搬出,只搬移未改逻辑)
|
|
2
|
-
//
|
|
2
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
3
|
|
|
4
4
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
5
5
|
import { readFile } from 'node:fs/promises'
|