@noob-stupid/dsh-plugin-console 0.3.66 → 0.4.0

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 (50) hide show
  1. package/README.md +1 -1
  2. package/README.zh.md +1 -1
  3. package/lib/client.js +127 -32
  4. package/lib/index.js +60 -9687
  5. package/lib/server/domain/ai-run.js +479 -0
  6. package/lib/server/domain/ai.js +246 -0
  7. package/lib/server/domain/compat.js +474 -0
  8. package/lib/server/domain/components.js +108 -0
  9. package/lib/server/domain/dep-source.js +122 -0
  10. package/lib/server/domain/format-contract.js +265 -0
  11. package/lib/server/domain/format-scan.js +431 -0
  12. package/lib/server/domain/framework.js +393 -0
  13. package/lib/server/domain/install-job.js +561 -0
  14. package/lib/server/domain/install.js +599 -0
  15. package/lib/server/domain/jobs.js +28 -0
  16. package/lib/server/domain/market.js +409 -0
  17. package/lib/server/domain/patch.js +203 -0
  18. package/lib/server/domain/presets.js +93 -0
  19. package/lib/server/domain/quarantine.js +224 -0
  20. package/lib/server/domain/release-source.js +504 -0
  21. package/lib/server/domain/repoland.js +119 -0
  22. package/lib/server/domain/revoke.js +184 -0
  23. package/lib/server/domain/runtime.js +118 -0
  24. package/lib/server/domain/selfupdate.js +319 -0
  25. package/lib/server/domain/skills.js +234 -0
  26. package/lib/server/domain/sources.js +297 -0
  27. package/lib/server/domain/suite.js +220 -0
  28. package/lib/server/infra/exec.js +98 -0
  29. package/lib/server/infra/fsx.js +163 -0
  30. package/lib/server/infra/fw-integrity-check.js +37 -0
  31. package/lib/server/infra/http.js +373 -0
  32. package/lib/server/infra/httpd.js +51 -0
  33. package/lib/server/infra/mask.js +19 -0
  34. package/lib/server/infra/paths.js +177 -0
  35. package/lib/server/infra/semver.js +168 -0
  36. package/lib/server/routes/ai.js +172 -0
  37. package/lib/server/routes/components.js +254 -0
  38. package/lib/server/routes/framework-preflight.js +154 -0
  39. package/lib/server/routes/framework-upgrade.js +679 -0
  40. package/lib/server/routes/framework.js +544 -0
  41. package/lib/server/routes/github-login.js +198 -0
  42. package/lib/server/routes/index.js +128 -0
  43. package/lib/server/routes/install.js +116 -0
  44. package/lib/server/routes/market.js +415 -0
  45. package/lib/server/routes/plugins.js +562 -0
  46. package/lib/server/routes/skills.js +107 -0
  47. package/lib/server/routes/sources.js +437 -0
  48. package/lib/server/routes/state.js +125 -0
  49. package/lib/server/state.js +22 -0
  50. package/package.json +1 -1
@@ -0,0 +1,220 @@
1
+ // L1 · domain —— suite.js(聚合套装:.gitmodules 解析 / 预设目录发现 / 包入口存在性;分层 Step 5 从 lib/index.js 搬出,只搬移未改逻辑。注:runSuiteInstallJob 含 ctx,留到 Step 8)
2
+ // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md 三
3
+
4
+ import { readFileSync, existsSync, rmSync, readdirSync, mkdirSync } from 'node:fs'
5
+ import { dirname, join, basename } from 'node:path'
6
+ import { tmpdir } from 'node:os'
7
+ import { appendInsert } from './patch.js'
8
+ import { gitCloneRepo } from './repoland.js'
9
+ import { deriveEntryId, listEntries } from './runtime.js'
10
+ import { copyTree } from '../infra/fsx.js'
11
+ import { looksLikeGitmodules, rawTextWithFallback } from '../infra/http.js'
12
+ import { dshHome, findPatchPath } from '../infra/paths.js'
13
+
14
+ /** 解析 .gitmodules:返回 [{name, path, url}](submodule 套装识别用)。 */
15
+ function readGitmodules(dir) {
16
+ const file = join(dir, '.gitmodules')
17
+ if (!existsSync(file)) return []
18
+ const text = readFileSync(file, 'utf8')
19
+ const subs = []
20
+ let cur = null
21
+ for (const line of text.split(/\r?\n/u)) {
22
+ const m = line.match(/^\[submodule\s+"([^"]+)"\]/u)
23
+ if (m) {
24
+ cur = { name: m[1], path: '', url: '' }
25
+ subs.push(cur)
26
+ continue
27
+ }
28
+ if (!cur) continue
29
+ const pm = line.match(/^\s*path\s*=\s*(.+)$/u)
30
+ if (pm) { cur.path = pm[1].trim(); continue }
31
+ const um = line.match(/^\s*url\s*=\s*(.+)$/u)
32
+ if (um) cur.url = um[1].trim()
33
+ }
34
+ return subs.filter((s) => s.path !== '' && s.url !== '')
35
+ }
36
+
37
+ /** 递归找含 preset.yml + agent.cordis.yml 的 agent 预设目录(深度 ≤ maxDepth)。 */
38
+ function findPresetDirs(root, maxDepth = 2) {
39
+ const out = []
40
+ const walk = (dir, depth) => {
41
+ if (depth > maxDepth) return
42
+ let entries = []
43
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
44
+ if (existsSync(join(dir, 'preset.yml')) && existsSync(join(dir, 'agent.cordis.yml'))) {
45
+ out.push(dir)
46
+ return
47
+ }
48
+ for (const e of entries) {
49
+ if (!e.isDirectory() || e.name.startsWith('.')) continue
50
+ walk(join(dir, e.name), depth + 1)
51
+ }
52
+ }
53
+ walk(root, 0)
54
+ return out
55
+ }
56
+
57
+ /** 包是否有构建产物(main/module/exports/bin 或 lib/index.js 任一存在)。
58
+ * 排除类型声明(.d.ts)与 package.json 自身——exports 的 "./package.json" 是合法导出但不是运行时入口。 */
59
+ function packageEntryExists(dir, pkg) {
60
+ const isEntry = (p) => typeof p === 'string' && p !== '' && !p.endsWith('.d.ts') && p !== './package.json' && p !== 'package.json'
61
+ const candidates = []
62
+ if (isEntry(pkg.main)) candidates.push(pkg.main)
63
+ if (isEntry(pkg.module)) candidates.push(pkg.module)
64
+ if (isEntry(pkg.bin)) candidates.push(pkg.bin)
65
+ if (pkg.bin && typeof pkg.bin === 'object') Object.values(pkg.bin).forEach((v) => { if (isEntry(v)) candidates.push(v) })
66
+ if (pkg.exports && typeof pkg.exports === 'object') {
67
+ const collect = (v) => {
68
+ if (typeof v === 'string') { if (isEntry(v)) candidates.push(v) }
69
+ else if (v && typeof v === 'object') Object.values(v).forEach(collect)
70
+ }
71
+ collect(pkg.exports)
72
+ }
73
+ candidates.push('lib/index.js', 'dist/index.js')
74
+ return candidates.some((c) => existsSync(join(dir, c)))
75
+ }
76
+
77
+ /** 套装探测(唯一入口):main → master,且**必须内容像 .gitmodules**(含 [submodule "x"] 段)才算套装。
78
+ * 判据是内容、不是"探测非 null":2026-09-19 用户反馈装 dsh-whale-widget 被判成 submodule 套装置仓库、
79
+ * clone 后报「未找到 .gitmodules」——根因就是代理/CDN 对**不存在的文件**回 2xx(空 body 也算"读到")。 */
80
+ async function probeGitmodules(repo) {
81
+ const main = await rawTextWithFallback(repo, 'main', '.gitmodules')
82
+ if (looksLikeGitmodules(main)) return main
83
+ const master = await rawTextWithFallback(repo, 'master', '.gitmodules')
84
+ return looksLikeGitmodules(master) ? master : null
85
+ }
86
+
87
+ /** 安装类型决策(纯函数,单测覆盖):`.gitmodules` 的**内容**说了算——
88
+ * 显式 suite 请求若探测内容不像 .gitmodules 也回落普通插件安装(前端标记可能来自 24h 缓存的误判,
89
+ * 不能当判据);技能请求不受影响。 */
90
+ function resolveInstallKind(requestKind, probeText) {
91
+ if (requestKind === 'skill') return 'skill'
92
+ return looksLikeGitmodules(probeText) ? 'suite' : 'plugin'
93
+ }
94
+
95
+ /** 套装安装(submodule 聚合仓库):照仓库 install.ps1/README 语义——
96
+ * clone 套装 → 手动镜像拉取子模块 → 按类型装配(bundle 插件含 Release tgz 兜底 / 普通插件 / 技能 / agent 预设)。
97
+ * 不执行第三方脚本本体(安全护栏:脚本型只读语义不运行)。 */
98
+ async function runSuiteInstallJob(job, ports) {
99
+ const tmpDir = join(tmpdir(), `dsh-suite-${job.id}-${Date.now()}`)
100
+ const report = []
101
+ try {
102
+ job.stage = 'preparing'
103
+ mkdirSync(tmpDir, { recursive: true })
104
+ await gitCloneRepo(job.repo, tmpDir, job.source)
105
+ const subs = readGitmodules(tmpDir)
106
+ if (subs.length === 0) {
107
+ // 探测与仓库实际内容不符(假阳性 / 仓库已重构):**不报失败**,把决定权交回调用方
108
+ // 回落普通插件安装(npm → Release → git 规格),用户不该因为一次误判装不上插件。
109
+ job.stage = 'detecting'
110
+ return { notASuite: true }
111
+ }
112
+ job.stage = 'detecting'
113
+ const patchPath = findPatchPath(ports)
114
+ const profileDir = dirname(patchPath)
115
+ // 手动拉取子模块(git 的 insteadOf 重写对 submodule 不生效,按镜像 URL 逐个 clone)
116
+ // 套装级进度:套装动辄几分钟(clone 每个子模块 + 逐个装配),面板要能显示"第 i/n 个子模块"
117
+ for (let si = 0; si < subs.length; si += 1) {
118
+ const sub = subs[si]
119
+ job.suiteProgress = { phase: 'clone', index: si + 1, total: subs.length, name: sub.name, done: false }
120
+ const subRepo = sub.url.replace(/^https?:\/\/[^/]+\//u, '').replace(/\.git$/u, '')
121
+ const dest = join(tmpDir, sub.path)
122
+ try {
123
+ mkdirSync(dirname(dest), { recursive: true })
124
+ await gitCloneRepo(subRepo, dest, sub.url.includes('gitee.com') ? 'gitee' : 'github', 120000)
125
+ } catch (error) {
126
+ report.push({ component: sub.name, type: 'clone', ok: false, note: `子模块拉取失败:${error.message}` })
127
+ }
128
+ }
129
+ for (let pi = 0; pi < subs.length; pi += 1) {
130
+ const sub = subs[pi]
131
+ job.suiteProgress = { phase: 'assemble', index: pi + 1, total: subs.length, name: sub.name, done: false }
132
+ const subDir = join(tmpDir, sub.path)
133
+ if (!existsSync(subDir)) continue
134
+ const subRepo = sub.url.replace(/^https?:\/\/[^/]+\//u, '').replace(/\.git$/u, '')
135
+ let pkg = null
136
+ try {
137
+ const pkgPath = join(subDir, 'package.json')
138
+ if (existsSync(pkgPath)) pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
139
+ } catch {}
140
+ let handled = false
141
+ // a. bundle 型插件(如 injector):自动装配默认**跳过**并给出官方装配指引——
142
+ // 第三方 bundle 需与当前 DSH 版本严格兼容(peer 依赖、client inject 模块、patch 语义),
143
+ // 自动写入 bundles 曾导致启动崩溃;预设/技能/普通插件不受影响。
144
+ if (pkg && typeof pkg.name === 'string' && pkg.dsh?.bundle) {
145
+ report.push({ component: sub.name, type: 'bundle', ok: false, note: `${pkg.name} 是 bundle 型插件,自动装配已跳过(避免 bundle 不兼容导致启动失败);请按详情面板「官方安装方式」命令手动装配(clone 套装后运行 install.ps1,或构建后加入 profile 的 dsh.profile.bundles)` })
146
+ handled = true
147
+ }
148
+ // b. 技能(根或第一层子目录 SKILL.md)
149
+ if (!handled) {
150
+ let skillDir0 = ''
151
+ if (existsSync(join(subDir, 'SKILL.md'))) {
152
+ skillDir0 = ''
153
+ } else {
154
+ let found = null
155
+ try {
156
+ found = readdirSync(subDir, { withFileTypes: true })
157
+ .find((d) => d.isDirectory() && existsSync(join(subDir, d.name, 'SKILL.md')))
158
+ } catch {}
159
+ skillDir0 = found ? found.name : null
160
+ }
161
+ if (skillDir0 !== null) {
162
+ const skillsRoot = join(dshHome(), 'skills')
163
+ mkdirSync(skillsRoot, { recursive: true })
164
+ const dest = join(skillsRoot, sub.name)
165
+ if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
166
+ copyTree(join(subDir, skillDir0), dest)
167
+ report.push({ component: sub.name, type: 'skill', ok: true, note: `已安装技能 ~/.dsh/skills/${sub.name}` })
168
+ handled = true
169
+ }
170
+ }
171
+ // c. agent 预设(含 preset.yml + agent.cordis.yml 的目录;预设优先于普通 npm 包——
172
+ // 如 dsh-router-standard 既是 npm 包又带预设目录,install.ps1 意图是复制预设)
173
+ const presets = findPresetDirs(subDir, 2)
174
+ for (const p of presets) {
175
+ const pname = basename(p)
176
+ const presetsRoot = join(dshHome(), '.agent-presets')
177
+ mkdirSync(presetsRoot, { recursive: true })
178
+ const dest = join(presetsRoot, pname)
179
+ if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
180
+ copyTree(p, dest)
181
+ report.push({ component: sub.name, type: 'preset', ok: true, note: `已安装预设 ${pname}(新建会话可选)` })
182
+ handled = true
183
+ }
184
+ // d. 普通 npm 插件(无 bundle 且无预设/技能)
185
+ if (!handled && pkg && typeof pkg.name === 'string') {
186
+ const target = join(profileDir, 'node_modules', pkg.name)
187
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true })
188
+ mkdirSync(dirname(target), { recursive: true })
189
+ copyTree(subDir, target)
190
+ const taken = new Set(listEntries(ports).map((e) => e.rowId))
191
+ const entryId = deriveEntryId(pkg.name, taken)
192
+ await appendInsert(patchPath, entryId, pkg.name)
193
+ // 记下套装装配出的包名:它们是 copyTree 铺进去的、不在 pnpm-lock.yaml 里,交给调用方统一对账
194
+ if (!Array.isArray(job.suiteInstalled)) job.suiteInstalled = []
195
+ if (!job.suiteInstalled.includes(pkg.name)) job.suiteInstalled.push(pkg.name)
196
+ report.push({ component: sub.name, type: 'plugin', ok: true, note: `已安装 ${pkg.name}(HMR 生效)` })
197
+ handled = true
198
+ }
199
+ if (!handled) {
200
+ report.push({ component: sub.name, type: 'unknown', ok: false, note: '未识别组件类型(无 package.json / SKILL.md / 预设)' })
201
+ }
202
+ }
203
+ job.status = 'done'
204
+ job.stage = 'done'
205
+ job.suiteReport = report
206
+ // 装配全部走完:进度标记完成(保留最后一轮的 i/n 与名字,前端显示"已完成 n/n 个子模块")
207
+ if (job.suiteProgress) job.suiteProgress = { ...job.suiteProgress, done: true }
208
+ const okCount = report.filter((r) => r.ok).length
209
+ const failCount = report.filter((r) => !r.ok).length
210
+ const bundleCount = report.filter((r) => r.type === 'bundle' && r.ok).length
211
+ job.suiteNote = `套装安装完成:${okCount} 个组件成功${failCount > 0 ? `,${failCount} 个失败(详见报告)` : ''}${bundleCount > 0 ? '。bundle 组件需重启服务生效' : ''};预设需新建会话时选择。`
212
+ } catch (error) {
213
+ job.status = 'failed'
214
+ job.error = error instanceof Error ? error.message : String(error)
215
+ } finally {
216
+ try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}
217
+ job.finishedAt = Date.now()
218
+ }
219
+ }
220
+ export { readGitmodules, findPresetDirs, packageEntryExists, runSuiteInstallJob, probeGitmodules, resolveInstallKind }
@@ -0,0 +1,98 @@
1
+ // 由 Step 1 搬运工具从 lib/index.js 原样切出(只移动、未改逻辑)
2
+ // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md §三 L0 · infra
3
+
4
+ import { execFile } from 'node:child_process'
5
+ import { promisify } from 'node:util'
6
+ import { existsSync } from 'node:fs'
7
+ import { dirname, join } from 'node:path'
8
+ import { homedir } from 'node:os'
9
+
10
+ /** git 可执行名(跨平台)。事故(2026-09-20,另一位用户:Android + proot Ubuntu):
11
+ * 「仓库落地」硬编码 `git.exe` → 非 Windows 环境 spawn git.exe ENOENT,克隆必然失败。 */
12
+ function gitBin() {
13
+ return process.platform === 'win32' ? 'git.exe' : 'git'
14
+ }
15
+
16
+ /** pnpm 执行方式定位(跨平台,纯函数便于单测 → 按优先级返回列表,逐个尝试)。
17
+ * 事故(2026-09-20,同一位用户,node v24 + Linux):只按 Windows 布局找 corepack.js
18
+ * (`<node bin>/node_modules/corepack/dist/corepack.js`),而 Linux 的 npm 全局布局在
19
+ * `<prefix>/lib/node_modules/corepack/...` → AI 赋能的 install-npm 生成
20
+ * `node /usr/local/bin/node_modules/corepack/dist/corepack.js pnpm add …` →
21
+ * `Error: Cannot find module …`(MODULE_NOT_FOUND)。这里把三种布局 + PATH 兜底都列出来。 */
22
+ function resolvePnpmRunners({ platform = process.platform, execPath = process.execPath, comspec = process.env.ComSpec ?? 'cmd.exe', exists = existsSync } = {}) {
23
+ const binDir = dirname(execPath)
24
+ const corepackCandidates = [
25
+ join(binDir, 'node_modules', 'corepack', 'dist', 'corepack.js'), // Windows 官方安装器 / nvm-windows
26
+ join(binDir, '..', 'lib', 'node_modules', 'corepack', 'dist', 'corepack.js'), // Linux/macOS npm 全局
27
+ join(binDir, '..', 'libexec', 'lib', 'node_modules', 'corepack', 'dist', 'corepack.js'), // brew / 自编译布局
28
+ ]
29
+ const runners = []
30
+ for (const js of corepackCandidates) {
31
+ if (exists(js)) {
32
+ runners.push({ kind: 'node-corepack', note: `node ${js} pnpm`, run: (args) => ({ bin: execPath, argv: [js, 'pnpm', ...args] }) })
33
+ }
34
+ }
35
+ if (platform === 'win32') {
36
+ // .cmd 批处理不能直接 execFile(EINVAL)→ 经 cmd.exe 调用(整条命令作为一个参数)
37
+ runners.push({
38
+ kind: 'cmd-corepack',
39
+ note: 'cmd /c corepack pnpm',
40
+ run: (args) => ({ bin: comspec, argv: ['/d', '/s', '/c', ['corepack', 'pnpm', ...args].map((a) => JSON.stringify(a)).join(' ')] }),
41
+ })
42
+ } else {
43
+ runners.push({ kind: 'corepack', note: 'corepack pnpm', run: (args) => ({ bin: 'corepack', argv: ['pnpm', ...args] }) })
44
+ runners.push({ kind: 'pnpm', note: 'pnpm', run: (args) => ({ bin: 'pnpm', argv: args }) })
45
+ }
46
+ return runners
47
+ }
48
+
49
+ /** 依次尝试各执行方式;只有"执行方式本身不可用"(ENOENT / MODULE_NOT_FOUND)才换下一个,
50
+ * 真正的安装失败(网络、依赖冲突等)立即抛出,并附上已尝试的清单便于排查。 */
51
+ async function runPnpmWithFallback(args, { execOpts = {}, runners = resolvePnpmRunners() } = {}) {
52
+ let lastError = null
53
+ for (const runner of runners) {
54
+ const { bin, argv } = runner.run(args)
55
+ try {
56
+ // eslint-disable-next-line no-await-in-loop
57
+ await execFileAsync(bin, argv, execOpts)
58
+ return { runner }
59
+ } catch (error) {
60
+ lastError = error
61
+ const message = String(error?.message ?? '')
62
+ if (!/ENOENT|Cannot find module/u.test(message)) throw error
63
+ }
64
+ }
65
+ const tried = runners.map((r) => r.note).join(' → ')
66
+ throw new Error(`${lastError?.message ?? 'pnpm 执行失败'}(已尝试:${tried})`)
67
+ }
68
+
69
+ /** git 非交互环境:禁止任何登录/凭据窗口弹出(私有仓库或不可达源直接失败,不做交互式重试)。 */
70
+
71
+ function gitEnv() {
72
+ return {
73
+ ...process.env,
74
+ GIT_TERMINAL_PROMPT: '0',
75
+ GCM_INTERACTIVE: 'never',
76
+ GIT_ASKPASS: 'echo',
77
+ SSH_ASKPASS: 'echo',
78
+ }
79
+ }
80
+ function processAlive(pid) {
81
+ try {
82
+ process.kill(Number(pid), 0)
83
+ return true
84
+ } catch {
85
+ return false
86
+ }
87
+ }
88
+ const execFileAsync = promisify(execFile)
89
+ /** gh CLI 通道:api.github.com 黑洞期(node:https 全部超时)时的最后兜底。
90
+ * 服务进程 PATH 可能不含 gh(桌面壳环境):依次尝试 gh、常见安装路径。 */
91
+ const GH_BIN_CANDIDATES = [
92
+ 'gh',
93
+ 'C:\\Program Files\\GitHub CLI\\gh.exe',
94
+ join(homedir(), 'AppData', 'Local', 'Microsoft', 'WinGet', 'Links', 'gh.exe'),
95
+ join(homedir(), 'scoop', 'shims', 'gh.exe'),
96
+ ]
97
+
98
+ export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, runPnpmWithFallback }
@@ -0,0 +1,163 @@
1
+ // 由 Step 1 搬运工具从 lib/index.js 原样切出(只移动、未改逻辑)
2
+ // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md §三 L0 · infra
3
+
4
+ import { existsSync, rmSync, readdirSync, mkdirSync, copyFileSync, chmodSync, lstatSync } from 'node:fs'
5
+ import { dirname, join, basename } from 'node:path'
6
+ import { execFile } from 'node:child_process'
7
+ import { promisify } from 'node:util'
8
+
9
+ const execFileAsync = promisify(execFile)
10
+
11
+ /**
12
+ * 递归复制目录树(绕开 fs.cpSync 在本环境的目录复制 EIO bug:
13
+ * cpSync 复制含子目录的树必报 `EIO, Access is denied`,而逐文件 copyFileSync 正常)。
14
+ * 跳过 .git(技能/包副本不需要版本库元数据)。
15
+ */
16
+
17
+ /** 清理陈旧包目录与 pnpm _tmp_ 残留(Windows 原子替换 EPERM 的根因),返回清理数量。 */
18
+
19
+ /** 串行化补丁文件写入,避免并发 toggle 的读改写竞争。 */
20
+
21
+ function copyTree(src, dest) {
22
+ mkdirSync(dest, { recursive: true })
23
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
24
+ if (entry.name === '.git') continue
25
+ const from = join(src, entry.name)
26
+ const to = join(dest, entry.name)
27
+ if (entry.isDirectory()) {
28
+ copyTree(from, to)
29
+ } else if (entry.isFile()) {
30
+ copyFileSync(from, to)
31
+ }
32
+ }
33
+ }
34
+ function queuedWrite(fn) {
35
+ const run = writeQueue.then(fn, fn)
36
+ writeQueue = run.then(() => undefined, () => undefined)
37
+ return run
38
+ }
39
+ function cleanupStalePackageDir(profileDir, packageName) {
40
+ const segments = packageName.startsWith('@') ? packageName.split('/') : [packageName]
41
+ const dir = join(profileDir, 'node_modules', ...segments)
42
+ const base = basename(dir)
43
+ const parent = dirname(dir)
44
+ let removed = 0
45
+ try {
46
+ if (existsSync(dir)) {
47
+ rmSync(dir, { recursive: true, force: true })
48
+ removed += 1
49
+ }
50
+ } catch {}
51
+ try {
52
+ for (const entry of readdirSync(parent)) {
53
+ if (entry.startsWith(`${base}_tmp_`)) {
54
+ try {
55
+ rmSync(join(parent, entry), { recursive: true, force: true })
56
+ removed += 1
57
+ } catch {}
58
+ }
59
+ }
60
+ } catch {}
61
+ return removed
62
+ }
63
+ let writeQueue = Promise.resolve()
64
+
65
+ /**
66
+ * 删除目录树并**核实删除结果**。
67
+ * 为什么要核实:本机某些环境(受限令牌/沙箱/杀软占用)下 `rmSync` 会**静默落空**——不抛错、目录仍在。
68
+ * 路由若删完直接 `{ok:true}` 就是对用户撒谎(2026-09-20 多类型演练实测:同一个 `rmSync` 在
69
+ * `D:\dsh\repos` 删得掉,在 `C:\Users\<user>\.dsh\...` 下返回成功但目录原封不动)。
70
+ * 返回 `{ok, attempts, error}`;`ok:false` 时调用方必须如实报错,不能吞。
71
+ */
72
+ function removeDirVerified(dir) {
73
+ let lastError = null
74
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
75
+ try {
76
+ rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 120 })
77
+ } catch (error) {
78
+ lastError = error
79
+ }
80
+ if (!existsSync(dir)) return { ok: true, attempts: attempt }
81
+ }
82
+ return { ok: false, attempts: 2, error: lastError instanceof Error ? lastError.message : null }
83
+ }
84
+
85
+ /**
86
+ * 清掉树里的只读位(Windows 上 `rmSync` 遇只读文件直接 EPERM,`force` **不会**替你清属性)。
87
+ * 返回清掉的文件数,供日志核对。
88
+ */
89
+ function clearReadonly(dir) {
90
+ let cleared = 0
91
+ const walk = (p) => {
92
+ let st = null
93
+ try { st = lstatSync(p) } catch { return }
94
+ try {
95
+ if (st.isDirectory()) {
96
+ for (const name of readdirSync(p)) walk(join(p, name))
97
+ } else if ((st.mode & 0o200) === 0) {
98
+ chmodSync(p, 0o666)
99
+ cleared += 1
100
+ }
101
+ } catch {}
102
+ }
103
+ walk(dir)
104
+ return cleared
105
+ }
106
+
107
+ /** 轮询等待目录真的消失(Windows「删除挂起」期间名字仍可见,立刻 existsSync 会误判失败)。 */
108
+ function waitGone(dir, timeoutMs) {
109
+ return new Promise((resolve) => {
110
+ const startedAt = Date.now()
111
+ const tick = () => {
112
+ if (!existsSync(dir)) { resolve(true); return }
113
+ if (Date.now() - startedAt >= timeoutMs) { resolve(false); return }
114
+ setTimeout(tick, 100)
115
+ }
116
+ tick()
117
+ })
118
+ }
119
+
120
+ /** 外部删除兜底:实测本机 PowerShell/.NET 能删掉 Node `rmSync` 静默删不掉的树。 */
121
+ async function removeViaShell(dir) {
122
+ try {
123
+ if (process.platform === 'win32') {
124
+ await execFileAsync('cmd.exe', ['/c', 'rmdir', '/s', '/q', dir], { windowsHide: true, timeout: 120000 })
125
+ return { ok: true, method: 'rmdir' }
126
+ }
127
+ await execFileAsync('rm', ['-rf', '--', dir], { timeout: 120000 })
128
+ return { ok: true, method: 'rm' }
129
+ } catch (error) {
130
+ return { ok: false, method: null, error: error instanceof Error ? error.message : String(error) }
131
+ }
132
+ }
133
+
134
+ /**
135
+ * 强化版目录删除(清理残余专用,2026-09-24 真机诊断后新增):
136
+ * ① 清只读位 → ② `rmSync` → ③ **轮询核实**(容忍删除挂起)→ ④ 失败则外部 `rmdir /s /q` 兜底 → ⑤ 再核实。
137
+ * 为什么不能只用 `rmSync` + 立刻 `existsSync`:真机上出现过「`rmSync` 不抛错、目录仍在」,
138
+ * 面板于是报「有 N 项没能删除(目录仍存在)——当前环境可能禁止删除」,把用户引向并不存在的权限问题;
139
+ * 实测同一棵树用 .NET/PowerShell 能删掉,所以这里补上兜底与轮询,并把真实错误码带回去。
140
+ */
141
+ async function removeDirVerifiedAsync(dir, { attempts = 2, pollMs = 600 } = {}) {
142
+ let lastError = null
143
+ let method = null
144
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
145
+ if (!existsSync(dir)) return { ok: true, attempts: attempt, method: method ?? 'already-gone', error: null }
146
+ try { clearReadonly(dir) } catch {}
147
+ try {
148
+ rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 120 })
149
+ } catch (error) {
150
+ lastError = error
151
+ }
152
+ if (await waitGone(dir, pollMs)) return { ok: true, attempts: attempt, method: 'rmSync', error: null }
153
+ const shell = await removeViaShell(dir)
154
+ if (shell.ok && await waitGone(dir, pollMs * 3)) return { ok: true, attempts: attempt, method: shell.method, error: null }
155
+ if (shell.error !== undefined && shell.error !== null) lastError = shell.error
156
+ }
157
+ const detail = lastError === null
158
+ ? '删除后目录仍存在(未抛出错误:Windows 删除挂起或占用)'
159
+ : `${lastError.code ?? ''} ${lastError.message ?? lastError}`.trim()
160
+ return { ok: false, attempts, method: null, error: detail }
161
+ }
162
+
163
+ export { copyTree, queuedWrite, cleanupStalePackageDir, removeDirVerified, removeDirVerifiedAsync, clearReadonly, waitGone, removeViaShell, writeQueue }
@@ -0,0 +1,37 @@
1
+ // L0 · infra —— 框架升级脚本里的「安装后结构完整性校验」生成器(2026-09-24)
2
+ //
3
+ // 为什么单独放一个模块:这段是升级脚本里唯一需要**逐项验结构**的地方,而 routes/framework-upgrade.js
4
+ // 已经贴着架构守卫的行数上限(那一段 600+ 行的 PowerShell 模板是历史遗留)。抽出来既守住上限,
5
+ // 也让这段逻辑能被单独读、单独审 —— 它是"升级完却没装全"这类事故的最后一道闸。
6
+ //
7
+ // 自包含:只用 target / nodePath / fwRoot 三个值(路径由调用方用 ps() 算好传进来),不依赖任何宿主上下文。
8
+
9
+ /** 生成"安装后结构校验"的 PowerShell 片段(返回单个字符串,供升级脚本数组作为一个元素展开)。 */
10
+ function fwIntegrityCheck(params) {
11
+ const { target, nodePath, fwRoot } = params
12
+ return [
13
+ ` // 安装后结构完整性校验:版本号对 ≠ 装完整。2026-09-24 事故:一次升级的 pnpm 安装被中断,`,
14
+ ` // 顶层 @deepseek-ai\\dsh 目录整个消失、.pnpm 实体只剩 lib 里几个硬链接 —— 只对版本号的校验`,
15
+ ` // 有可能被"package.json 写成功但 lib 没落全"骗过,等下次重启才发现服务起不来。`,
16
+ ` if ($code -eq 0) {`,
17
+ ` try {`,
18
+ ` $fwDsh = Join-Path '${fwRoot}' '@deepseek-ai\\dsh'`,
19
+ ` $binA = Join-Path $fwDsh 'lib\\bin.js'`,
20
+ ` $binB = $null`,
21
+ ` $ent = Get-ChildItem -Path (Join-Path '${fwRoot}' '.pnpm') -Directory -Filter '@deepseek-ai+dsh@${target}*' -ErrorAction SilentlyContinue | Select-Object -First 1`,
22
+ ` if ($ent) { $binB = Join-Path $ent.FullName 'node_modules\\@deepseek-ai\\dsh\\lib\\bin.js' }`,
23
+ ` $bin = $null`,
24
+ ` if (Test-Path -LiteralPath $binA) { $bin = $binA } elseif ($binB -and (Test-Path -LiteralPath $binB)) { $bin = $binB }`,
25
+ ` if (-not (Test-Path -LiteralPath (Join-Path $fwDsh 'package.json'))) { Log '结构校验失败:顶层 @deepseek-ai\\dsh\\package.json 不存在(安装不完整)'; $code = 1 }`,
26
+ ` elseif (-not $bin) { Log '结构校验失败:找不到 dsh 的 lib/bin.js(顶层与 .pnpm 都没有)——装完也拉不起来,按失败处理'; $code = 1 }`,
27
+ ` else {`,
28
+ ` $probe = (& '${nodePath}' $bin --version 2>&1 | Out-String).Trim()`,
29
+ ` if ($probe -ne '${target}') { Log ('结构校验失败:CLI 自报版本 ' + $probe + ',目标 ${target}(安装不完整或被其它副本顶替),按失败处理'); $code = 1 }`,
30
+ ` else { Log ('结构校验通过:package.json + bin.js 就位,CLI 自报 ' + $probe) }`,
31
+ ` }`,
32
+ ` } catch { Log ('结构校验异常,按失败处理:' + $_.Exception.Message); $code = 1 }`,
33
+ ` }`,
34
+ ].join('\r\n')
35
+ }
36
+
37
+ export { fwIntegrityCheck }