@noob-stupid/dsh-plugin-console 0.5.29 → 0.5.31

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.
Files changed (49) hide show
  1. package/README.md +7 -7
  2. package/README.zh.md +8 -8
  3. package/SECURITY.md +2 -2
  4. package/lib/client.js +91 -5
  5. package/lib/index.js +141 -141
  6. package/lib/server/domain/ai-run.js +1 -1
  7. package/lib/server/domain/ai.js +39 -11
  8. package/lib/server/domain/compat.js +69 -3
  9. package/lib/server/domain/components.js +1 -1
  10. package/lib/server/domain/dep-source.js +1 -1
  11. package/lib/server/domain/framework-cleanup.js +225 -0
  12. package/lib/server/domain/framework-residuals.js +59 -0
  13. package/lib/server/domain/framework.js +65 -6
  14. package/lib/server/domain/install-job.js +1 -1
  15. package/lib/server/domain/install.js +1 -1
  16. package/lib/server/domain/jobs.js +1 -1
  17. package/lib/server/domain/market.js +1 -1
  18. package/lib/server/domain/patch-composition-audit.js +218 -0
  19. package/lib/server/domain/patch.js +69 -16
  20. package/lib/server/domain/preset-audit.js +144 -0
  21. package/lib/server/domain/presets.js +1 -1
  22. package/lib/server/domain/release-source.js +3 -3
  23. package/lib/server/domain/repoland.js +1 -1
  24. package/lib/server/domain/runtime.js +1 -1
  25. package/lib/server/domain/skills.js +1 -1
  26. package/lib/server/domain/sources.js +1 -1
  27. package/lib/server/domain/suite.js +1 -1
  28. package/lib/server/infra/exec.js +24 -3
  29. package/lib/server/infra/framework-root.js +99 -0
  30. package/lib/server/infra/fsx.js +3 -3
  31. package/lib/server/infra/http.js +1 -1
  32. package/lib/server/infra/httpd.js +1 -1
  33. package/lib/server/infra/mask.js +1 -1
  34. package/lib/server/infra/package-resolve.js +73 -0
  35. package/lib/server/infra/paths.js +1 -1
  36. package/lib/server/infra/registry-versions.js +64 -0
  37. package/lib/server/infra/semver.js +1 -1
  38. package/lib/server/routes/compat.js +41 -1
  39. package/lib/server/routes/components.js +1 -1
  40. package/lib/server/routes/framework-preflight.js +43 -2
  41. package/lib/server/routes/framework-upgrade.js +16 -17
  42. package/lib/server/routes/framework.js +18 -2
  43. package/lib/server/routes/github-login.js +2 -2
  44. package/lib/server/routes/index.js +17 -2
  45. package/lib/server/routes/patch-audit.js +59 -0
  46. package/lib/server/routes/plugins.js +20 -2
  47. package/lib/server/routes/preset-audit.js +24 -0
  48. package/lib/server/routes/state.js +9 -3
  49. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  // L1 · domain —— ai.js(AI 赋能的纯逻辑部分:任务表与视图、DeepSeek 密钥与配置、占位符/Python/VLM 解析、写入白名单与运行白名单、内置计划模板、计划 JSON 解析;分层 Step 7 从 lib/index.js 搬出,只搬移未改逻辑。注:aiEmpowerPlan/Execute、runAiStep、aiRepair 吃运行上下文,留到 Step 8)
2
- // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md 三
2
+ // 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
3
3
 
4
4
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
5
5
  import { dirname, join, basename, resolve } from 'node:path'
@@ -126,15 +126,41 @@ function resolvePlaceholders(value, jobOrEnv) {
126
126
  .replaceAll('${python}', python)
127
127
  .replaceAll('${scripts}', join(dirname(python), 'Scripts'))
128
128
  .replaceAll('${node}', process.execPath)
129
+ .replaceAll('${asciiData}', openVikingDataRoot())
129
130
  .replaceAll('${deepseekKey}', readDeepSeekKey())
130
131
  }
131
132
 
133
+ /** 解析 Python 解释器:候选按平台分开,**不含任何开发机专有路径**。
134
+ * win32:用户级安装目录(AppData\Local\Programs\Python)+ PATH 上的 python.exe;
135
+ * POSIX:/usr/bin/python3、/usr/local/bin/python3、PATH 上的 python3/python。
136
+ * 原实现第一条写死开发机的绝对路径(`<盘符>:\python314\python.exe`),且三个候选全是 Windows 形态 ——
137
+ * 非 Windows 上永远解析不到,`${python}` 会退化成 Windows 专有名字。
138
+ * 解析不到时返回**本平台的命令名**(由执行阶段如实报错),不在这里抛。 */
132
139
  function resolvePythonPath() {
133
- const candidates = [join('E:', 'python314', 'python.exe'), join(homedir(), 'AppData', 'Local', 'Programs', 'Python', 'Python314', 'python.exe'), 'python.exe']
140
+ const win = process.platform === 'win32'
141
+ const candidates = win
142
+ ? [
143
+ join(homedir(), 'AppData', 'Local', 'Programs', 'Python', 'Python314', 'python.exe'),
144
+ 'python.exe',
145
+ ]
146
+ : ['/usr/bin/python3', '/usr/local/bin/python3', 'python3', 'python']
134
147
  for (const c of candidates) {
135
148
  if (existsSync(c)) return c
136
149
  }
137
- return 'python.exe'
150
+ return win ? 'python.exe' : 'python3'
151
+ }
152
+
153
+ /** OpenViking 数据根:**必须纯 ASCII**(home 含非 ASCII 字符时,各语言的 Rust/原生向量库
154
+ * 写盘会报 UnicodeDecodeError)。三种来源,**不含任何写死的盘符**:
155
+ * ① 环境变量 DSH_OPENVIKING_DATA_ROOT(用户显式指定,最高优先)
156
+ * ② win32 历史默认:D:/OpenVikingData(保持既有实例的数据位置不变)
157
+ * ③ 其他平台:${home}/.openviking/ascii-data
158
+ * 原实现无条件返回 'D:/OpenVikingData':非 Windows 上它是个**相对路径**,会被写进 CWD
159
+ * (服务进程的工作目录),在别人的机器上根本不是"ASCII 数据根"。 */
160
+ function openVikingDataRoot() {
161
+ const override = process.env.DSH_OPENVIKING_DATA_ROOT
162
+ if (typeof override === 'string' && override.trim() !== '') return override.trim()
163
+ return process.platform === 'win32' ? 'D:/OpenVikingData' : join(homedir(), '.openviking', 'ascii-data')
138
164
  }
139
165
 
140
166
  /** 写入路径白名单:只允许 DSH profile、~/.dsh、~/.openviking、~/.cache/openviking、ASCII 数据根。 */
@@ -148,13 +174,13 @@ function isAllowedWritePath(p, profileDir) {
148
174
  norm(homedir()) + '/.local/share/openviking',
149
175
  norm(homedir()) + '/.dsh/repos',
150
176
  norm(profileDir),
151
- 'D:/OpenVikingData',
177
+ norm(openVikingDataRoot()),
152
178
  ]
153
179
  return roots.some((root) => target === root || target.startsWith(root + '/'))
154
180
  }
155
181
 
156
- /** run-cmd 可执行文件白名单(按 basename)。 */
157
- const ALLOWED_RUN_FILES = new Set(['curl.exe', 'curl', 'git.exe', 'git', 'node.exe', 'node', 'python.exe', 'python', 'py.exe', 'py', 'gh.exe', 'gh', 'npm.cmd', 'npm', 'ov.cmd', 'ov'])
182
+ /** run-cmd 可执行文件白名单(按 basename;含 POSIX 名 python3,否则 ${python}=python3 会被自己拒掉)。 */
183
+ const ALLOWED_RUN_FILES = new Set(['curl.exe', 'curl', 'git.exe', 'git', 'node.exe', 'node', 'python.exe', 'python3', 'python', 'py.exe', 'py', 'gh.exe', 'gh', 'npm.cmd', 'npm', 'ov.cmd', 'ov'])
158
184
 
159
185
  const DESTRUCTIVE_RE = /(^|\s)(rm\s+(-[a-zA-Z]*r)|rmdir\s+\/s|del\s+\/s|format\s+[a-zA-Z]:|Remove-Item\b|taskkill\s+\/im|reg\s+delete|schtasks\s+\/delete|shutdown|cipher\s+\/w)/iu
160
186
 
@@ -178,7 +204,7 @@ function builtinPlanFor(source) {
178
204
  .replace(/\/$/u, '')
179
205
  if (norm !== 'openviking' && norm !== 'volcengine/openviking') return null
180
206
  const home = homedir()
181
- const storage = /^[\x00-\x7F]+$/u.test(home) ? `${home}/.openviking/data` : 'D:/OpenVikingData'
207
+ const storage = /^[\x00-\x7F]+$/u.test(home) ? `${home}/.openviking/data` : openVikingDataRoot()
182
208
  const vlm = resolveVlmForOpenViking()
183
209
  // 注意:路径必须在此处(JSON.stringify 之前)使用真实值,由 stringify 统一转义;
184
210
  // 不能在 content 中保留 ${home} 占位符——写文件时替换会把反斜杠路径变成非法 JSON 转义。
@@ -209,10 +235,12 @@ function aiEmpowerPresetFor(source) {
209
235
  const common = [
210
236
  '## 本机环境事实(务必遵守)',
211
237
  '- 网络:pypi.org 不通、清华镜像 403;pip 必须用 `--index-url https://mirrors.aliyun.com/pypi/simple/`。npm registry.npmjs.org 可能黑洞,pnpm 用 `--registry https://registry.npmmirror.com`。huggingface.co 被墙,模型文件一律用 `https://hf-mirror.com/<同一路径>`(执行器会自动把 huggingface.co 换成 hf-mirror.com,计划里也可直接写 hf-mirror)。',
212
- // 0.5.28 改错:这条原本把它写成"本机用户名含中文(花火)"的**个案**,读起来像只在
238
+ // 0.5.28 改错:这条原本把它写成"本机用户名含非 ASCII 字符"的**个案**,读起来像只在
213
239
  // 那台机器上成立。真正的规则是通用的:只要**写盘路径**里出现非 ASCII 字符,各语言的
214
240
  // Rust/原生向量库就可能报 UnicodeDecodeError —— 与用户名具体是不是中文无关。
215
- '- 用户名/路径含非 ASCII 字符时:任何写盘路径若含非 ASCII 字符,各语言的 Rust/原生向量库会报 UnicodeDecodeError——服务数据目录必须纯 ASCII(如 `D:/OpenVikingData`)。',
241
+ // 2026-09-29 改错:ASCII 数据根不再写死某个盘符,改用 `${asciiData}` 占位符
242
+ // (由 resolvePlaceholders 解析成"本平台/本用户"的 ASCII 数据根,见 openVikingDataRoot)。
243
+ '- 用户名/路径含非 ASCII 字符时:任何写盘路径若含非 ASCII 字符,各语言的 Rust/原生向量库会报 UnicodeDecodeError——服务数据目录必须纯 ASCII,用 `${asciiData}` 引用控制台给出的 ASCII 数据根(可用环境变量 DSH_OPENVIKING_DATA_ROOT 覆盖)。',
216
244
  '- 凭据复用:DSH 的 DeepSeek 密钥在 `${home}/.dsh/.credentials.yaml`(YAML 行 `DEEPSEEK_API_KEY: sk-...`);模型选择在 `${home}/.dsh/settings.yaml` 的 `agent-default-model`(provider deepseek-official)。对外 OpenAI 兼容接口:api_base `https://api.deepseek.com`,模型名 `deepseek-v4-flash-vision-exp`。',
217
245
  '- 步骤解释用中文;每个动作一个步骤;步骤数尽量少;不得包含任何破坏性命令;服务类组件必须配 start-service + wait-health。',
218
246
  ]
@@ -222,8 +250,8 @@ function aiEmpowerPresetFor(source) {
222
250
  '## OpenViking 已验证的部署事实(v0.4.17,Python 3.14)',
223
251
  '- 安装:`pip install openviking[local-embed]`(含 llama-cpp-python 0.3.35,cp310-abi3 轮子可用;无需加 --force-reinstall)。',
224
252
  '- 嵌入模型:`bge-small-zh-v1.5-f16.gguf`(47MB,512 维中文)下载到 `${home}/.cache/openviking/models/`(写 download 步骤;URL 写 huggingface.co 会被执行器自动换 hf-mirror.com)。',
225
- '- ov.conf 路径 `${home}/.openviking/ov.conf`,内容:vlm = { provider: openai, api_base: https://api.deepseek.com, model: deepseek-v4-flash-vision-exp, api_key: 从 `${home}/.dsh/.credentials.yaml` 读取并内联 };embedding.dense = { provider: local, model: bge-small-zh-v1.5-f16, model_path: <上面的 gguf 路径> };server = { host: 127.0.0.1, port: 1933 }(不要写 root_api_key,dev 免鉴权模式自动启用);storage.workspace = `${home}` 含非 ASCII 时写 `D:/OpenVikingData`,否则 `${home}/.openviking/data`。',
226
- '- 启动:start-service,file=`${python}`,args=`["-m","openviking_cli.server_bootstrap"]`,cwd=`${home}/.openviking`,healthUrl=`http://127.0.0.1:1933/health`,port=1933。服务器启动首次会建 `D:/OpenVikingData` 数据目录。',
253
+ '- ov.conf 路径 `${home}/.openviking/ov.conf`,内容:vlm = { provider: openai, api_base: https://api.deepseek.com, model: deepseek-v4-flash-vision-exp, api_key: 从 `${home}/.dsh/.credentials.yaml` 读取并内联 };embedding.dense = { provider: local, model: bge-small-zh-v1.5-f16, model_path: <上面的 gguf 路径> };server = { host: 127.0.0.1, port: 1933 }(不要写 root_api_key,dev 免鉴权模式自动启用);storage.workspace = `${home}` 含非 ASCII 时写 `${asciiData}`,否则 `${home}/.openviking/data`。',
254
+ '- 启动:start-service,file=`${python}`,args=`["-m","openviking_cli.server_bootstrap"]`,cwd=`${home}/.openviking`,healthUrl=`http://127.0.0.1:1933/health`,port=1933。服务器启动首次会建 `${asciiData}` 数据目录。',
227
255
  '- 部署完成后惯例:重启 DSH 会话后 mcp__openviking__* 工具出现,`${home}/.openviking/pending` 的积压消息会在新会话启动时自动回放——把这写进总结。',
228
256
  ].join('\n')
229
257
  }
@@ -1,5 +1,5 @@
1
1
  // L1 · domain —— compat.js(框架兼容判定与适配门:设置 API 扫描 / 兼容门读写 / 待适配记录 / 隔离记录合并的前置部分 / 启动失败分析;分层 Step 6 从 lib/index.js 搬出,只搬移未改逻辑。注:detectCompat 等吃运行上下文的留到 Step 8)
2
- // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md 三
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'
@@ -197,7 +197,7 @@ function writeCompatPending(payload) {
197
197
  * autoDetect —— 打开控制台时是否自动检测「已适配」(关掉 = 不显示可解锁提示) */
198
198
  const COMPAT_GATE_FILE = () => join(dshHome(), 'plugin-console', 'compat-gate.json')
199
199
 
200
- const COMPAT_GATE_DEFAULTS = { autoDisable: true, autoDetect: true }
200
+ const COMPAT_GATE_DEFAULTS = { autoDisable: true, autoDetect: true, noticeMode: 'once-per-version' }
201
201
 
202
202
  function readCompatGate() {
203
203
  try {
@@ -205,6 +205,8 @@ function readCompatGate() {
205
205
  return {
206
206
  autoDisable: raw?.autoDisable !== false,
207
207
  autoDetect: raw?.autoDetect !== false,
208
+ // 兼容提示的展示模式(2026-09-28 加法):非法/缺失一律回落默认,**不影响 supported 判定**
209
+ noticeMode: NOTICE_MODES.includes(raw?.noticeMode) ? raw.noticeMode : DEFAULT_NOTICE_MODE,
208
210
  }
209
211
  } catch { return { ...COMPAT_GATE_DEFAULTS } }
210
212
  }
@@ -218,6 +220,49 @@ function writeCompatGate(patch) {
218
220
  return next
219
221
  }
220
222
 
223
+ /** 兼容提示的展示模式(2026-09-28 加法):
224
+ * · `always` 每次都提示
225
+ * · `once-per-version` 同一框架版本只提示一次(默认;用户点「我知道了」后不再出现,**版本一变重新提示**)
226
+ * · `off` 不提示(需显式设置)
227
+ * ⚠ 它只影响**展示** —— `supported` 的判定与门控语义一个字都不变(披露不能被"关掉"成看不见的风险)。 */
228
+ const NOTICE_MODES = ['always', 'once-per-version', 'off']
229
+ const DEFAULT_NOTICE_MODE = 'once-per-version'
230
+
231
+ const COMPAT_ACK_FILE = () => join(dshHome(), 'plugin-console', 'compat-ack.json')
232
+
233
+ /** 读「已确认看过」的框架版本(按版本记:换版本自动重新提示)。 */
234
+ function readCompatAck() {
235
+ try {
236
+ const raw = JSON.parse(readFileSync(COMPAT_ACK_FILE(), 'utf8'))
237
+ return {
238
+ version: typeof raw?.version === 'string' && raw.version !== '' ? raw.version : null,
239
+ at: Number.isFinite(raw?.at) ? raw.at : null,
240
+ }
241
+ } catch { return { version: null, at: null } }
242
+ }
243
+
244
+ /** 写 ack(整文件覆盖:只记最后一个确认过的版本,语义就是"这个版本我看过了")。 */
245
+ function writeCompatAck(version) {
246
+ const next = { version: String(version), at: Date.now() }
247
+ try {
248
+ mkdirSync(dirname(COMPAT_ACK_FILE()), { recursive: true })
249
+ writeFileSync(COMPAT_ACK_FILE(), JSON.stringify(next, null, 2), 'utf8')
250
+ } catch {}
251
+ return next
252
+ }
253
+
254
+ /** 提示该不该显示(纯函数,离线可断言)。**绝不修改 supported**。 */
255
+ function resolveNoticeState({ supported, notice, webAppVersion, noticeMode, ackVersion } = {}) {
256
+ if (notice === null || notice === undefined || notice === '') return { visible: false, reason: 'no-notice' }
257
+ if (supported === true) return { visible: false, reason: 'supported' }
258
+ const mode = NOTICE_MODES.includes(noticeMode) ? noticeMode : DEFAULT_NOTICE_MODE
259
+ if (mode === 'off') return { visible: false, reason: 'mode-off' }
260
+ if (mode === 'always') return { visible: true, reason: 'mode-always' }
261
+ const acked = typeof ackVersion === 'string' && ackVersion !== ''
262
+ && typeof webAppVersion === 'string' && webAppVersion !== ''
263
+ && ackVersion === webAppVersion
264
+ return acked ? { visible: false, reason: 'version-acked' } : { visible: true, reason: 'first-sight' }
265
+ }
221
266
  /** 把框架适配检测结果格式化为给子代理的提示段。 */
222
267
  function frameworkCheckPromptText(fc) {
223
268
  if (fc === null || fc === undefined) return '(不可用)'
@@ -263,6 +308,27 @@ async function detectCompat(baseUrl) {
263
308
  result.supported = false
264
309
  result.notice = `当前 DSH web 包版本 ${result.webAppVersion} 不在受支持的 0.1.x 系列内,插件控制台的部分功能可能因官方破坏性更新而失效;请到 https://github.com/Noob-stupid/dsh-plugin-gating-hub 获取匹配的更新`
265
310
  }
311
+ // 展示层(2026-09-28 加法):`supported` 与 `notice` 保持原样,这里只额外给出"该不该显示 + 为什么"。
312
+ try {
313
+ const gate = readCompatGate()
314
+ const ack = readCompatAck()
315
+ const resolved = resolveNoticeState({
316
+ supported: result.supported,
317
+ notice: result.notice,
318
+ webAppVersion: result.webAppVersion,
319
+ noticeMode: gate.noticeMode,
320
+ ackVersion: ack.version,
321
+ })
322
+ result.noticeMode = gate.noticeMode
323
+ result.noticeVisible = resolved.visible
324
+ result.noticeReason = resolved.reason
325
+ result.noticeAckedAt = ack.version !== null && ack.version === result.webAppVersion ? ack.at : null
326
+ } catch {
327
+ result.noticeMode = DEFAULT_NOTICE_MODE
328
+ result.noticeVisible = result.supported === false
329
+ result.noticeReason = 'fallback'
330
+ result.noticeAckedAt = null
331
+ }
266
332
  return result
267
333
  }
268
334
 
@@ -471,4 +537,4 @@ async function frameworkCompatReportFor(source, ports, profileDir) {
471
537
  summary: parts.length > 0 ? parts.join(';') : `框架 ${fwVer},无待适配记录`,
472
538
  }
473
539
  }
474
- export { gatingSummary, COMPAT_PENDING_FILE, COMPAT_GATE_FILE, COMPAT_GATE_DEFAULTS, REMOVED_SETTINGS_SYMBOLS, FIBER_STATE, FIBER_PHASE, SUPPORTED_WEB_APP_PATTERN, referencesRemovedSymbol, scanSettingsApiUsage, isFrameworkOwnedPackage, checkPluginFrameworkCompat, probePluginImport, readCompatPending, writeCompatPending, readCompatGate, writeCompatGate, frameworkCheckPromptText, CONSOLE_VERSION, detectCompat, detectAdoptablePending, rowIdModuleMap, frameworkCompatReportFor, maybeAutoAdaptCompat }
540
+ export { gatingSummary, COMPAT_PENDING_FILE, COMPAT_GATE_FILE, COMPAT_ACK_FILE, COMPAT_GATE_DEFAULTS, NOTICE_MODES, DEFAULT_NOTICE_MODE, REMOVED_SETTINGS_SYMBOLS, FIBER_STATE, FIBER_PHASE, SUPPORTED_WEB_APP_PATTERN, referencesRemovedSymbol, scanSettingsApiUsage, isFrameworkOwnedPackage, checkPluginFrameworkCompat, probePluginImport, readCompatPending, writeCompatPending, readCompatGate, writeCompatGate, readCompatAck, writeCompatAck, resolveNoticeState, frameworkCheckPromptText, CONSOLE_VERSION, detectCompat, detectAdoptablePending, rowIdModuleMap, frameworkCompatReportFor, maybeAutoAdaptCompat }
@@ -1,5 +1,5 @@
1
1
  // L1 · domain —— components.js(服务器组件:注册表 / 增删改 / 进程启停 / 状态 / 自启动;分层 Step 4 从 lib/index.js 搬出,只搬移未改逻辑)
2
- // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md 三
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
- // 两条硬约束(本机真 pnpm 10.34.5 实测矩阵见 D:\dsh\dsh-plugin-hub-plan\refactor-bugs.zh.md 第 23 节):
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`,
@@ -0,0 +1,225 @@
1
+ // L1 · domain —— framework-cleanup.js(框架树残留的**动作**层:顶层版本漂移 / 框架备份保留;0.5.30 加法)
2
+ //
3
+ // 判据在 domain/framework-residuals.js(纯函数);这里只负责「读现场 → 执行 → 如实回报」。
4
+ // 真机背景见该模块头注释(顶层 239 个 0.1.5-rc.2 真实目录;framework-backups 12 条快照约 5.1 GB)。
5
+ //
6
+ // 三条安全约定(对齐仓库既有纪律):
7
+ // ① **一律先改名备份**:顶层旧目录 → `<name>.stale-<ver>-<ts>`,绝不直接删(可回滚)。
8
+ // ② 重建 junction 只在**目标确实存在**时做:目标从 `dsh` junction 的同级目录推(运行树的真实位置);
9
+ // 推不到或目标不存在就只改名,并如实记进 `relinkSkipped`(不假装成功)。
10
+ // ③ 框架备份只删 `fw-tree/<ts>` 目录,**绝不碰** `dsh-package-backup` 与目录下的快照文件。
11
+
12
+ import { existsSync, readdirSync, readFileSync, readlinkSync, renameSync, statSync, symlinkSync } from 'node:fs'
13
+ import { dirname, join } from 'node:path'
14
+ import { removeDirVerifiedAsync } from '../infra/fsx.js'
15
+ import { dshHome, resolvePackageJson } from '../infra/paths.js'
16
+ import { classifyTopLevel, planBackupRetention } from './framework-residuals.js'
17
+
18
+ /** 目录体积(失败返回 0;只用于报告「能释放多少」)。 */
19
+ function dirSizeBytes(dir) {
20
+ let total = 0
21
+ const walk = (d) => {
22
+ let entries = []
23
+ try { entries = readdirSync(d, { withFileTypes: true }) } catch { return }
24
+ for (const entry of entries) {
25
+ const full = join(d, entry.name)
26
+ try {
27
+ if (entry.isDirectory()) walk(full)
28
+ else if (entry.isFile()) total += statSync(full).size
29
+ } catch {}
30
+ }
31
+ }
32
+ walk(dir)
33
+ return total
34
+ }
35
+
36
+ /** 是不是链接(Windows 的 junction 在 dirent 上不一定报 symbolic link → readlink 兜底)。 */
37
+ function isLinkEntry(full, dirent, readLink = readlinkSync) {
38
+ try { if (dirent.isSymbolicLink()) return true } catch {}
39
+ try { readLink(full); return true } catch { return false }
40
+ }
41
+
42
+ /**
43
+ * A 档:顶层投影里的旧版本真实目录 → 改名备份(+ 可选重建 junction)。
44
+ * `topScopeDir` = `<frameworkRoot>/node_modules/@deepseek-ai`;`dshLinkPath` = 同目录下的 `dsh`(运行树入口)。
45
+ */
46
+ async function cleanFrameworkTopLevel({ topScopeDir, dshLinkPath = null, runningVersion = null, relink = true, dryRun = false, stamp = null, deps = {} } = {}) {
47
+ const {
48
+ exists = existsSync, readdir = readdirSync, readText = (f) => readFileSync(f, 'utf8'),
49
+ rename = renameSync, link = symlinkSync, readLink = readlinkSync,
50
+ } = deps
51
+ if (topScopeDir === null || topScopeDir === undefined || !exists(topScopeDir)) {
52
+ return { ok: false, error: '框架顶层目录不存在(拿不到 @deepseek-ai/* 的真实位置)', renamed: [], relinked: [], relinkSkipped: [], failed: [] }
53
+ }
54
+ const entries = []
55
+ for (const dirent of readdir(topScopeDir, { withFileTypes: true })) {
56
+ const full = join(topScopeDir, dirent.name)
57
+ let version = null
58
+ try { version = JSON.parse(readText(join(full, 'package.json'))).version ?? null } catch { version = null }
59
+ entries.push({
60
+ name: dirent.name,
61
+ version,
62
+ isLink: isLinkEntry(full, dirent, readLink),
63
+ sizeBytes: dirSizeBytes(full),
64
+ })
65
+ }
66
+ const classified = classifyTopLevel(entries, { runningVersion })
67
+ const runningScopeDir = (() => {
68
+ if (dshLinkPath === null) return null
69
+ try { return dirname(readLink(dshLinkPath)) } catch { return null }
70
+ })()
71
+ const ts = stamp === null ? String(Date.now()) : String(stamp)
72
+ const renamed = []
73
+ const relinked = []
74
+ const relinkSkipped = []
75
+ const failed = []
76
+ for (const item of (dryRun === true ? [] : classified.stale)) {
77
+ const backupName = item.name + '.stale-' + item.version + '-' + ts
78
+ try {
79
+ rename(join(topScopeDir, item.name), join(topScopeDir, backupName))
80
+ } catch (error) {
81
+ failed.push({ name: item.name, error: String(error?.message ?? error) })
82
+ continue
83
+ }
84
+ renamed.push({ name: item.name, version: item.version, backup: backupName, sizeBytes: item.sizeBytes ?? null })
85
+ if (relink !== true) { relinkSkipped.push({ name: item.name, reason: 'relink-off' }); continue }
86
+ if (runningScopeDir === null) { relinkSkipped.push({ name: item.name, reason: 'no-running-scope' }); continue }
87
+ const target = join(runningScopeDir, item.name)
88
+ let targetExists = false
89
+ try { targetExists = exists(target) } catch { targetExists = false }
90
+ if (!targetExists) { relinkSkipped.push({ name: item.name, reason: 'target-missing' }); continue }
91
+ try {
92
+ link(target, join(topScopeDir, item.name), 'junction')
93
+ relinked.push({ name: item.name, target })
94
+ } catch (error) {
95
+ failed.push({ name: item.name, error: '已改名但重建链接失败:' + String(error?.message ?? error) })
96
+ }
97
+ }
98
+ return {
99
+ ok: failed.length === 0,
100
+ renamed,
101
+ relinked,
102
+ relinkSkipped,
103
+ failed,
104
+ staleCount: classified.stale.length,
105
+ sameVersionCount: classified.sameVersion.length,
106
+ missingVersion: classified.missingVersion,
107
+ dryRun,
108
+ planned: classified.stale.map((s) => ({ name: s.name, version: s.version, sizeBytes: s.sizeBytes ?? null })),
109
+ }
110
+ }
111
+
112
+ /**
113
+ * C 档:`framework-backups/<版本>/fw-tree/<ts>` 快照按「回滚点那条 ∪ 最近 N 条」保留,其余删除。
114
+ * 只删 fw-tree 快照目录;`dsh-package-backup` 与目录下的快照文件一律不碰。
115
+ */
116
+ async function cleanFrameworkBackups({ backupsRoot, referenced = [], keep = 1, dryRun = false, deps = {} } = {}) {
117
+ const { exists = existsSync, readdir = readdirSync, stat = statSync, remove = removeDirVerifiedAsync, sizeOf = dirSizeBytes } = deps
118
+ if (backupsRoot === null || backupsRoot === undefined || !exists(backupsRoot)) {
119
+ return { ok: false, error: '没有框架备份目录(无需清理)', snapshots: 0, kept: [], removed: [], failed: [], freedBytes: 0 }
120
+ }
121
+ const snapshots = []
122
+ for (const versionDir of readdir(backupsRoot, { withFileTypes: true })) {
123
+ if (!versionDir.isDirectory()) continue
124
+ const fwTree = join(backupsRoot, versionDir.name, 'fw-tree')
125
+ if (!exists(fwTree)) continue
126
+ for (const snap of readdir(fwTree, { withFileTypes: true })) {
127
+ if (!snap.isDirectory()) continue
128
+ const full = join(fwTree, snap.name)
129
+ let mtimeMs = 0
130
+ try { mtimeMs = stat(full).mtimeMs } catch { mtimeMs = 0 }
131
+ let sizeBytes = 0
132
+ try { sizeBytes = sizeOf(full) } catch { sizeBytes = 0 }
133
+ snapshots.push({ path: full, version: versionDir.name, id: snap.name, mtimeMs, sizeBytes })
134
+ }
135
+ }
136
+ const plan = planBackupRetention(snapshots, { referenced, keep })
137
+ const removed = []
138
+ const failed = []
139
+ for (const snap of (dryRun === true ? [] : plan.remove)) {
140
+ try {
141
+ const res = await remove(snap.path)
142
+ if (res !== null && res !== undefined && res.ok === false) failed.push({ path: snap.path, error: res.error ?? '删除失败' })
143
+ else removed.push({ path: snap.path, sizeBytes: snap.sizeBytes ?? 0 })
144
+ } catch (error) {
145
+ failed.push({ path: snap.path, error: String(error?.message ?? error) })
146
+ }
147
+ }
148
+ const freedBytes = removed.reduce((sum, item) => sum + (Number.isFinite(item.sizeBytes) ? item.sizeBytes : 0), 0)
149
+ return {
150
+ ok: failed.length === 0,
151
+ snapshots: snapshots.length,
152
+ kept: plan.kept.map((s) => ({ path: s.path, reason: s.reason, sizeBytes: s.sizeBytes ?? 0 })),
153
+ removed,
154
+ failed,
155
+ freedBytes,
156
+ dryRun,
157
+ plannedRemove: plan.remove.map((s) => ({ path: s.path, version: s.version, sizeBytes: s.sizeBytes ?? 0 })),
158
+ }
159
+ }
160
+
161
+ /**
162
+ * B 档(**只报告,不删**):`.pnpm` 里版本 ≠ 运行版本的框架包实体。
163
+ * 手删 `.pnpm` 会让 lockfile 与实际不一致(真机教训:9/24 一个 package.json 缺失 → framework.version=null),
164
+ * 所以这里只点名 + 给建议(用 pnpm 自己同步),不提供删除。
165
+ */
166
+ function reportFrameworkEntities({ pnpmDir, runningVersion = null } = {}) {
167
+ if (pnpmDir === null || pnpmDir === undefined || !existsSync(pnpmDir)) {
168
+ return { ok: false, error: '拿不到 .pnpm 目录', groups: [], entityCount: 0 }
169
+ }
170
+ const groups = new Map()
171
+ for (const dirent of readdirSync(pnpmDir, { withFileTypes: true })) {
172
+ if (!dirent.isDirectory()) continue
173
+ const m = dirent.name.match(/^@deepseek-ai\+dsh(?:-[a-z0-9]+)*@(\d+\.\d+\.\d+[^_]*)_/u)
174
+ if (m === null) continue
175
+ const version = m[1]
176
+ if (runningVersion !== null && version === String(runningVersion)) continue
177
+ if (!groups.has(version)) groups.set(version, [])
178
+ groups.get(version).push(dirent.name)
179
+ }
180
+ const out = [...groups.entries()]
181
+ .map(([version, names]) => ({ version, count: names.length, sample: names.slice(0, 3) }))
182
+ .sort((a, b) => a.version.localeCompare(b.version))
183
+ return {
184
+ ok: true,
185
+ groups: out,
186
+ entityCount: out.reduce((sum, g) => sum + g.count, 0),
187
+ hint: '这些是历史版本的虚拟 store 实体;建议用 pnpm 自己同步(在该框架根跑 pnpm install),不要手删目录',
188
+ }
189
+ }
190
+
191
+
192
+ /** 框架布局推导(compat.js 同款取法):找「含 .pnpm 的那层 node_modules」= 顶层投影目录。
193
+ * 返回 { fwRoot, topScopeDir, dshLinkPath, pnpmDir } —— 顶层 239 个旧真实目录就在 topScopeDir 下。 */
194
+ function resolveFrameworkLayout({ baseDir, profileDir, deps = {} } = {}) {
195
+ const { exists = existsSync, resolvePkg = resolvePackageJson } = deps
196
+ let fwRoot = null
197
+ try {
198
+ const pkgPath = resolvePkg('@deepseek-ai/dsh', baseDir, profileDir)
199
+ if (pkgPath !== null && pkgPath !== undefined) {
200
+ let dir = dirname(dirname(pkgPath))
201
+ for (let i = 0; i < 6; i += 1) {
202
+ if (exists(join(dir, '.pnpm'))) { fwRoot = dir; break }
203
+ const up = dirname(dir)
204
+ if (up === dir) break
205
+ dir = up
206
+ }
207
+ }
208
+ } catch {}
209
+ return {
210
+ fwRoot,
211
+ topScopeDir: fwRoot === null ? null : join(fwRoot, '@deepseek-ai'),
212
+ dshLinkPath: fwRoot === null ? null : join(fwRoot, '@deepseek-ai', 'dsh'),
213
+ pnpmDir: fwRoot === null ? null : join(fwRoot, '.pnpm'),
214
+ }
215
+ }
216
+
217
+ /** 回滚点引用的路径(framework-rollback.json 的 checkpointDir / cliBackupDir)—— 这些永远不删。 */
218
+ function readRollbackReferenced({ home = undefined, deps = {} } = {}) {
219
+ const { readText = (p) => readFileSync(p, 'utf8'), base = dshHome() } = deps
220
+ try {
221
+ const raw = JSON.parse(readText(join(home ?? base, 'plugin-console', 'framework-rollback.json')))
222
+ return [raw?.checkpointDir, raw?.cliBackupDir].filter((p) => typeof p === 'string' && p !== '')
223
+ } catch { return [] }
224
+ }
225
+ export { cleanFrameworkBackups, cleanFrameworkTopLevel, dirSizeBytes, isLinkEntry, readRollbackReferenced, reportFrameworkEntities, resolveFrameworkLayout }
@@ -0,0 +1,59 @@
1
+ // L1 · domain —— framework-residuals.js(框架树残留判据 + 备份保留策略;0.5.30 加法)
2
+ //
3
+ // 真机背景(2026-09-28):
4
+ // ① 框架树顶层 `@deepseek-ai/` 有 **239 个非 junction 的真实目录**,其中 230 个还停在
5
+ // 0.1.5-rc.2(npm 时代残留):`dsh` 一个被 junction 到 0.2.0-rc.1 → 于是"从顶层解析"与
6
+ // "从运行树解析"会拿到**不同版本**(9/10 桌面端扫描错配、9/24 schemastery 3.18.1 漂移同族)。
7
+ // ② `~/.dsh/plugin-console/framework-backups` 堆了 12 条 fw-tree 快照(约 5.1 GB),
8
+ // 而回滚只需要**回滚点引用的那一条** + **最近 N 条**。
9
+ //
10
+ // 本模块只做纯判据(IO 全由调用方注入),动作在 routes/plugins.js 的 clean-residuals scope 里:
11
+ // · `classifyTopLevel` → 哪些顶层真实目录属于"旧版本残留"(同版本/链接一律不动 —— 宁可少动)
12
+ // · `planBackupRetention` → 保留「回滚点那一条 ∪ 最近 N 条」,其余进删除清单 + 可释放字节
13
+ //
14
+ // 行数预算:独立模块,留在 600 行架构硬顶内。
15
+
16
+ /**
17
+ * 顶层条目分类(纯函数)。
18
+ * `entries` 形如 `[{ name, version, isLink, sizeBytes }]`;`isLink` 为真表示已被 junction/符号链接接管。
19
+ * **保守取舍**:同版本保留;缺版本(读不到 package.json)保留并单列 —— 判不准就不动。
20
+ */
21
+ function classifyTopLevel(entries, { runningVersion = null } = {}) {
22
+ const stale = []
23
+ const sameVersion = []
24
+ const missingVersion = []
25
+ for (const entry of (Array.isArray(entries) ? entries : [])) {
26
+ if (entry === null || typeof entry !== 'object' || typeof entry.name !== 'string' || entry.name === '') continue
27
+ if (entry.name.startsWith('.')) continue
28
+ if (entry.isLink === true) continue
29
+ const version = entry.version === undefined || entry.version === null ? '' : String(entry.version)
30
+ if (version === '') { missingVersion.push({ name: entry.name, sizeBytes: entry.sizeBytes ?? null }); continue }
31
+ if (runningVersion !== null && version === String(runningVersion)) { sameVersion.push({ name: entry.name, version }); continue }
32
+ stale.push({ name: entry.name, version, sizeBytes: entry.sizeBytes ?? null })
33
+ }
34
+ return { stale, sameVersion, missingVersion }
35
+ }
36
+
37
+ /**
38
+ * 备份保留策略(纯函数):保留集合 = 「回滚点引用的路径」∪「按 mtime 最近的 N 条」。
39
+ * `keep` 非法/缺失时按 1 处理(最小保留)。返回 `{ kept, remove, freedBytes }`,
40
+ * `kept` 每条带 `reason: 'rollback-point' | 'recent'`(面板要说清为什么留)。
41
+ */
42
+ function planBackupRetention(snapshots, { referenced = [], keep = 1 } = {}) {
43
+ const keepCount = Number.isFinite(keep) && keep >= 0 ? Math.floor(keep) : 1
44
+ const pinned = new Set((Array.isArray(referenced) ? referenced : []).filter((p) => typeof p === 'string' && p !== ''))
45
+ const list = (Array.isArray(snapshots) ? snapshots : [])
46
+ .filter((s) => s !== null && typeof s === 'object' && typeof s.path === 'string' && s.path !== '')
47
+ .sort((a, b) => (Number.isFinite(b.mtimeMs) ? b.mtimeMs : 0) - (Number.isFinite(a.mtimeMs) ? a.mtimeMs : 0))
48
+ const kept = []
49
+ const remove = []
50
+ list.forEach((snap, index) => {
51
+ if (pinned.has(snap.path)) { kept.push({ ...snap, reason: 'rollback-point' }); return }
52
+ if (index < keepCount) { kept.push({ ...snap, reason: 'recent' }); return }
53
+ remove.push(snap)
54
+ })
55
+ const freedBytes = remove.reduce((sum, snap) => sum + (Number.isFinite(snap.sizeBytes) ? snap.sizeBytes : 0), 0)
56
+ return { kept, remove, freedBytes }
57
+ }
58
+
59
+ export { classifyTopLevel, planBackupRetention }
@@ -1,5 +1,5 @@
1
1
  // L1 · domain —— framework.js(升级三件套的可搬部分:状态/备份路径、dsh 可执行文件定位、app boot 定位、容忍补丁、版本比较、重启前奏;分层 Step 6 从 lib/index.js 搬出,只搬移未改逻辑。注:detectFrameworkUpgrade / backupProfileSnapshot / currentFrameworkVersion 吃运行上下文,留到 Step 8)
2
- // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md 三
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
- 'D:\\node_cache\\_npx',
68
+ ...npmNpxCacheRoots(),
41
69
  join(homedir(), '.npm', '_npx'),
42
- join(process.env.LOCALAPPDATA || '', 'node_cache', '_npx'),
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
- for (const entry of readdirSync(root, { withFileTypes: true })) {
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
- * (`D:\dsh-desktop\DeepSeek Harness.exe --expose-internals …\app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\index.js …`),
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
- // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md 三
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'