@noob-stupid/dsh-plugin-console 0.3.67 → 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 (48) hide show
  1. package/lib/client.js +126 -31
  2. package/lib/index.js +60 -9687
  3. package/lib/server/domain/ai-run.js +479 -0
  4. package/lib/server/domain/ai.js +246 -0
  5. package/lib/server/domain/compat.js +474 -0
  6. package/lib/server/domain/components.js +108 -0
  7. package/lib/server/domain/dep-source.js +122 -0
  8. package/lib/server/domain/format-contract.js +265 -0
  9. package/lib/server/domain/format-scan.js +431 -0
  10. package/lib/server/domain/framework.js +393 -0
  11. package/lib/server/domain/install-job.js +561 -0
  12. package/lib/server/domain/install.js +599 -0
  13. package/lib/server/domain/jobs.js +28 -0
  14. package/lib/server/domain/market.js +409 -0
  15. package/lib/server/domain/patch.js +203 -0
  16. package/lib/server/domain/presets.js +93 -0
  17. package/lib/server/domain/quarantine.js +224 -0
  18. package/lib/server/domain/release-source.js +504 -0
  19. package/lib/server/domain/repoland.js +119 -0
  20. package/lib/server/domain/revoke.js +184 -0
  21. package/lib/server/domain/runtime.js +118 -0
  22. package/lib/server/domain/selfupdate.js +319 -0
  23. package/lib/server/domain/skills.js +234 -0
  24. package/lib/server/domain/sources.js +297 -0
  25. package/lib/server/domain/suite.js +220 -0
  26. package/lib/server/infra/exec.js +98 -0
  27. package/lib/server/infra/fsx.js +163 -0
  28. package/lib/server/infra/fw-integrity-check.js +37 -0
  29. package/lib/server/infra/http.js +373 -0
  30. package/lib/server/infra/httpd.js +51 -0
  31. package/lib/server/infra/mask.js +19 -0
  32. package/lib/server/infra/paths.js +177 -0
  33. package/lib/server/infra/semver.js +168 -0
  34. package/lib/server/routes/ai.js +172 -0
  35. package/lib/server/routes/components.js +254 -0
  36. package/lib/server/routes/framework-preflight.js +154 -0
  37. package/lib/server/routes/framework-upgrade.js +679 -0
  38. package/lib/server/routes/framework.js +544 -0
  39. package/lib/server/routes/github-login.js +198 -0
  40. package/lib/server/routes/index.js +128 -0
  41. package/lib/server/routes/install.js +116 -0
  42. package/lib/server/routes/market.js +415 -0
  43. package/lib/server/routes/plugins.js +562 -0
  44. package/lib/server/routes/skills.js +107 -0
  45. package/lib/server/routes/sources.js +437 -0
  46. package/lib/server/routes/state.js +125 -0
  47. package/lib/server/state.js +22 -0
  48. package/package.json +1 -1
@@ -0,0 +1,479 @@
1
+ // L1 · domain —— ai-run.js(AI 赋能的执行侧:规划 / 执行 / 修复 / 单步运行)。
2
+ // 分层 Step 8c-2 从 lib/index.js 搬出;形参由 ctx 收窄为 ports,只搬移未改逻辑。
3
+ // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md 三
4
+
5
+ import { writeFileSync, existsSync, statSync, rmSync, mkdirSync } from 'node:fs'
6
+ import { readFile } from 'node:fs/promises'
7
+ import { spawn } from 'node:child_process'
8
+ import { dirname, join, resolve } from 'node:path'
9
+ import { tmpdir, homedir } from 'node:os'
10
+ import { createRequire } from 'node:module'
11
+ import { aiEmpowerPresetFor, isAllowedWritePath, isSafeRunCmd, parsePlanJson, resolvePlaceholders, resolvePythonPath, saveAiJobs } from './ai.js'
12
+ import { frameworkCheckPromptText } from './compat.js'
13
+ import { compFind, compUpsert } from './components.js'
14
+ import { jobLog, waitHealth } from './jobs.js'
15
+ import { gitCloneUrls } from './sources.js'
16
+ import { execFileAsync, gitEnv, resolvePnpmRunners, runPnpmWithFallback } from '../infra/exec.js'
17
+ import { maskUrl } from '../infra/mask.js'
18
+
19
+ /**
20
+ * 本地 AI 修复:拉起无父上下文的 in-process 子代理接管安装。
21
+ * 子代理与本会话使用同一套工具(终端/文件),能真实修复安装。
22
+ */
23
+ async function aiRepair(job, ports, profileDir, candidates, lastError) {
24
+ job.stage = 'repairing'
25
+ job.ai = true
26
+ let subagents = null
27
+ try { subagents = ports.get('subagents') } catch {}
28
+ const startFn = subagents?.start
29
+ if (typeof startFn !== 'function') {
30
+ job.status = 'failed'
31
+ job.error = `确定性安装通道全部失败,且本地 AI 修复通道(subagents 服务)不可用。原始错误:${lastError ?? '未知'};请手动执行:dsh plugin --profile web add <包名>`
32
+ return
33
+ }
34
+ let provider = 'spawn'
35
+ try {
36
+ const list = subagents.list?.() ?? []
37
+ if (!list.includes(provider)) provider = list[0]
38
+ if (provider === undefined) throw new Error('no provider')
39
+ } catch (error) {
40
+ job.status = 'failed'
41
+ job.error = `本地 AI 修复通道没有可用的子代理提供方:${String(error)};请手动执行:dsh plugin --profile web add <包名>`
42
+ return
43
+ }
44
+ // 跨平台提示:corepack 的位置随安装方式/系统而异(Windows 官方安装器在 <node bin>/node_modules,
45
+ // Linux 的 npm 全局在 <prefix>/lib/node_modules),所以给子代理的提示不再写死路径
46
+ const corepackRunner = resolvePnpmRunners()[0]
47
+ const prompt = [
48
+ '你是 DeepSeek Harness 的插件安装专家。用户通过插件管理面板安装插件失败,现在由你主动接管安装(不是被动"修复",而是像人工一样把它装好)。',
49
+ `目标仓库:${job.repo}`,
50
+ `候选包名:${candidates.join('、')}`,
51
+ `profile 目录:${profileDir}(pnpm 管理,绝不能用 npm 写入,会破坏链接)`,
52
+ `此前确定性通道的错误:${lastError ?? '未知'}`,
53
+ '接管步骤:',
54
+ `1) 先弄清楚装什么:若根包是 private 的聚合仓库(monorepo),用 gh api repos/${job.repo}/git/trees/main?recursive=1 或读 packages 目录下的 package.json 找出子包名;优先选聚合包(名字含 all,如 dsh-web-ui-all,一个包装全部);`,
55
+ `2) 用 corepack/pnpm 安装(本机可用的执行方式:${corepackRunner?.note ?? 'corepack pnpm 或 pnpm'},即在该方式后接 add <包名> --registry https://registry.npmmirror.com,工作目录 ${profileDir};Windows 上是 node <corepack.js> pnpm,Linux 上通常是 corepack pnpm 或直接 pnpm);镜像 404 时改 --registry https://registry.npmjs.org;都不行再用 git 通道;`,
56
+ '3) **node 网络黑洞判定**:pnpm 长时间无下载进度(Progress 停在 downloaded 0)、或报 socket hang up / EPERM / TIMEOUT、或 node 脚本连 127.0.0.1 都超时,说明本机拦截 node 进程网络——此时 curl 与系统 git 通常仍可用,立即改用 curl 手动安装:先 `curl -s https://registry.npmmirror.com/<包名>` 取最新版本号,再 `curl -sL -o <临时目录>/pkg.tgz https://registry.npmmirror.com/<包名>/-/<包名>-<版本>.tgz`,解压 tar -xzf 后把 package 内容放入 profile/node_modules/<包名>(先备份旧目录);包有 dependencies 时用同样方式逐个 curl 拉取补齐到 node_modules;零依赖包(如 dsh-skin)一条龙即可完成;',
57
+ `4) 安装成功后按官方 dsh plugin add 规则落配置:包声明 dsh.bundle 时把包名追加进 profile 目录 package.json 的 dsh.profile.bundles 数组;普通插件在 cordis.patch.yml 追加 insert 行(id 由包名去 scope、非字母数字转连字符生成,name 填包名);`,
58
+ '5) 装完自查:确认包已出现在 node_modules、配置已写入;必要时用 gh api 核对仓库信息;',
59
+ '6) 执行 git/pnpm 前先设置环境变量 GIT_TERMINAL_PROMPT=0 和 GCM_INTERACTIVE=never,禁止弹出任何登录/凭据窗口;',
60
+ '7) 不要杀进程、不要重启服务、不要改动与本次安装无关的文件。',
61
+ '完成后用一两句话报告结果;确实无法安装也请说明原因。',
62
+ ].join('\n')
63
+ try {
64
+ // in-process 驱动要求 parent 是真实的活动 Agent(parent.ctx.agents.create)。
65
+ // 控制台在根上下文运行,从 Agent 注册表借一个顶级会话作为结构性父代理。
66
+ let parent = null
67
+ try {
68
+ const agents = ports.get('agents')
69
+ const candidates = typeof agents?.roots === 'function' ? agents.roots() : (typeof agents?.list === 'function' ? agents.list() : [])
70
+ parent = candidates[0] ?? null
71
+ } catch {}
72
+ if (parent === null) {
73
+ job.status = 'failed'
74
+ job.error = `本地 AI 修复无法借用活动会话(agents 注册表为空)。原始错误:${lastError ?? '未知'};请手动执行:dsh plugin --profile web add <包名>`
75
+ return
76
+ }
77
+ const controller = new AbortController()
78
+ const run = await startFn.call(subagents, provider, {
79
+ label: `install-repair-${String(job.repo).split('/')[1] ?? 'plugin'}`,
80
+ prompt: [{ type: 'text', text: prompt }],
81
+ // 父代理深度 0,修复子代理至少深度 1(0 会触发 depth 校验失败)
82
+ maxDepth: 1,
83
+ signal: controller.signal,
84
+ parent,
85
+ })
86
+ let settleFn = null
87
+ try {
88
+ const requireLocal = createRequire(join(profileDir, 'package.json'))
89
+ ;({ settleRun: settleFn } = requireLocal('@deepseek-ai/dsh-subagent'))
90
+ } catch {}
91
+ const settle = settleFn ?? (async (runHandle) => {
92
+ try {
93
+ const result = await runHandle.result
94
+ return { status: result?.stopReason === 'completed' ? 'completed' : 'failed', detail: String(result?.stopReason ?? 'unknown') }
95
+ } catch (error) {
96
+ return { status: 'failed', detail: String(error) }
97
+ }
98
+ })
99
+ const outcome = await Promise.race([
100
+ settle(run),
101
+ new Promise((resolve) => setTimeout(() => {
102
+ controller.abort()
103
+ resolve({ status: 'failed', detail: '本地 AI 修复超时(10 分钟)' })
104
+ }, 600000)),
105
+ ])
106
+ if (outcome.status === 'completed') {
107
+ // 校验修复结果:任一候选包现在可解析(或已进 bundle 层)才算成功,
108
+ // 避免子代理"跑完流程但没装上"被误报为成功
109
+ let verified = false
110
+ try {
111
+ const requireLocal = createRequire(join(profileDir, 'package.json'))
112
+ for (const name of candidates) {
113
+ try {
114
+ requireLocal.resolve(`${name}/package.json`)
115
+ verified = true
116
+ break
117
+ } catch {}
118
+ }
119
+ if (!verified) {
120
+ const manifest = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'))
121
+ const bundles = manifest.dsh?.profile?.bundles ?? []
122
+ if (candidates.some((name) => bundles.includes(name))) verified = true
123
+ }
124
+ } catch {}
125
+ if (verified) {
126
+ job.status = 'done'
127
+ job.aiNote = '本地 AI 已接管并完成修复,请刷新页面查看'
128
+ } else {
129
+ job.status = 'failed'
130
+ job.error = `本地 AI 完成了修复流程,但未能确认安装成功(候选包 ${candidates.join('、')} 均不可解析);请手动执行:dsh plugin --profile web add <包名>`
131
+ }
132
+ } else {
133
+ job.status = 'failed'
134
+ job.error = `本地 AI 修复未成功(${outcome.detail ?? outcome.status})。请手动执行:dsh plugin --profile web add <包名>`
135
+ }
136
+ } catch (error) {
137
+ job.status = 'failed'
138
+ job.error = `本地 AI 修复通道异常:${error instanceof Error ? error.message : String(error)};请手动执行:dsh plugin --profile web add <包名>`
139
+ }
140
+ }
141
+
142
+ /** AI 赋能:规划阶段——子代理只读调研文档,产出结构化部署计划(JSON)。 */
143
+ async function aiEmpowerPlan(job, ports, profileDir) {
144
+ job.profileDir = profileDir
145
+ job.status = 'running'
146
+ job.stage = 'planning'
147
+ let subagents = null
148
+ try { subagents = ports.get('subagents') } catch {}
149
+ const startFn = subagents?.start
150
+ if (typeof startFn !== 'function') {
151
+ job.status = 'failed'
152
+ job.error = 'AI 赋能需要 DSH 子代理服务(subagents),当前不可用'
153
+ job.finishedAt = Date.now()
154
+ return
155
+ }
156
+ let provider = 'spawn'
157
+ try {
158
+ const list = subagents.list?.() ?? []
159
+ if (!list.includes(provider)) provider = list[0]
160
+ if (provider === undefined) throw new Error('no provider')
161
+ } catch (error) {
162
+ job.status = 'failed'
163
+ job.error = `没有可用的子代理提供方:${String(error)}`
164
+ job.finishedAt = Date.now()
165
+ return
166
+ }
167
+ // 优先用「Git 源」把目标仓库克隆到本地,让子代理读本地材料:
168
+ // 内网/离线环境下 api.github.com 与 raw 通道不可达时,这是唯一可行的调研通道。
169
+ let materialDir = null
170
+ if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(String(job.source ?? '').trim())) {
171
+ const srcDir = join(tmpdir(), `dsh-ai-src-${job.id}-${Date.now().toString(36)}`)
172
+ try {
173
+ mkdirSync(srcDir, { recursive: true })
174
+ for (const url of gitCloneUrls(job.source)) {
175
+ try {
176
+ await execFileAsync('git', ['clone', '--depth', '1', '--quiet', url, srcDir], { timeout: 120000, windowsHide: true, env: gitEnv() })
177
+ materialDir = srcDir
178
+ break
179
+ } catch {}
180
+ }
181
+ } catch {}
182
+ if (materialDir === null) {
183
+ try { rmSync(srcDir, { recursive: true, force: true }) } catch {}
184
+ } else {
185
+ job.materialDir = srcDir
186
+ // 30 分钟后自动清理(规划+执行通常早已结束)
187
+ const timer = setTimeout(() => { try { rmSync(srcDir, { recursive: true, force: true }) } catch {} }, 30 * 60 * 1000)
188
+ if (typeof timer?.unref === 'function') timer.unref()
189
+ }
190
+ }
191
+ const prompt = [
192
+ '你是 DSH 插件控制台挂载的「AI 赋能」部署规划器。用户想把下面的组件完整部署到本机。',
193
+ `目标来源:${job.source}`,
194
+ '',
195
+ '## 框架适配检测(控制台权威结果,规划时必须引用并向用户解释)',
196
+ frameworkCheckPromptText(job.frameworkCheck),
197
+ '',
198
+ '【任务】只做只读调研与规划,禁止执行任何安装/写文件/启动服务/杀进程命令(curl 的 GET 抓取、gh api 查询、读取文件是允许的)。',
199
+ materialDir !== null
200
+ ? `调研材料:目标仓库已由控制台通过「Git 源」克隆到本地目录 ${materialDir} —— 直接读取该目录下的 README / package.json / docs/ / install 说明即可(无需联网,内网/离线环境同样可用)。`
201
+ : '调研材料:npm README / GitHub README / docs/ 目录 / package.json / install 说明。',
202
+ '全部调研完成后,输出且只输出一个 ```json ... ``` 代码块,内容为部署计划;代码块之外不要输出任何文字。',
203
+ '',
204
+ '计划 schema(严格遵循)',
205
+ '{',
206
+ ' "type": "pure-plugin" | "service" | "config-only",',
207
+ ' "displayName": "简短中文名",',
208
+ ' "summary": "一句话:这是什么、为何这样部署",',
209
+ ' "servers": [ { "name": "进程名", "file": "启动文件/命令", "args": ["参数..."], "cwd": "工作目录", "healthUrl": "http://127.0.0.1:端口/health", "port": 1933 } ],',
210
+ ' "steps": [ { "action": "...", "description": "中文说明", "...": "动作参数" } ]',
211
+ '}',
212
+ '',
213
+ '动作与参数(每个动作一个步骤,按依赖顺序)',
214
+ '- install-pip: {"package":"<包名[extras]>"}',
215
+ '- install-npm: {"package":"<npm 包名>","directory":"${profile}"}',
216
+ '- write-file: {"path":"<目标文件>","content":"<完整文件内容原文>"}',
217
+ '- download: {"url":"https://...","path":"<保存路径>"}',
218
+ '- run-cmd: {"file":"可执行文件","args":["..."]}(file 仅限 curl/git/node/python/py/gh/npm/ov)',
219
+ '- start-service: {"name":"<唯一名>","file":"...","args":["..."],"cwd":"...","healthUrl":"...","port":数字}',
220
+ '- wait-health: {"url":"http://127.0.0.1:端口/health","timeoutMs":60000}',
221
+ '',
222
+ '约束',
223
+ '- 占位符只能用 ${home} ${profile} ${python} ${scripts} ${node};write-file 的 content 会被原样落盘',
224
+ '- 纯 npm 插件:type=pure-plugin,只写 install-npm 步骤,不要 servers',
225
+ '- 若用户提供的来源带 @tag(如 pkg@beta),install-npm 的 package 必须原样保留该 @tag,不得丢弃',
226
+ '- 危险命令(rm -rf、del /s、format、Remove-Item 等)出现在计划里会被拒绝执行',
227
+ '',
228
+ '## 调研纪律(必须遵守)',
229
+ '- 禁止递归扫描目录(Get-ChildItem -Recurse、find /、rg 全盘搜索);只允许:读 ~/.openviking/、~/.dsh 顶层文件(.credentials.yaml、settings.yaml)、文档缓存目录,以及 curl 单次直连抓取',
230
+ '- 每条命令必须带超时:curl 用 --max-time 30;PowerShell 单命令控制在 15 秒内;抓取失败就跳过,不要反复重试同一 URL',
231
+ '- 总调研时间预算 8 分钟;剩余时间必须全部用于生成 JSON 计划,禁止重复验证已经确定的事实',
232
+ '',
233
+ aiEmpowerPresetFor(job.source),
234
+ ].join('\n')
235
+
236
+ try {
237
+ let parent = null
238
+ try {
239
+ const agents = ports.get('agents')
240
+ const list = typeof agents?.roots === 'function' ? agents.roots() : (typeof agents?.list === 'function' ? agents.list() : [])
241
+ // 父代理选择:优先 cwd 与 DSH 服务工作区一致的会话(子代理 cwd 继承父会话),
242
+ // 避免多工作区/多会话时借到别的 workspace 的会话导致调研错位
243
+ const svcCwd = String(process.cwd() ?? '').replace(/[\\/]+$/u, '')
244
+ const cwdOf = (a) => {
245
+ try { return String(a?.session?.header?.cwd ?? a?.cwd ?? '').replace(/[\\/]+$/u, '') } catch { return '' }
246
+ }
247
+ parent = list.find((a) => cwdOf(a) !== '' && cwdOf(a) === svcCwd) ?? list[0] ?? null
248
+ job.parentCwd = parent ? cwdOf(parent) : null
249
+ } catch {}
250
+ if (parent === null) {
251
+ job.status = 'failed'
252
+ job.error = 'AI 赋能无法借用活动会话(agents 注册表为空),请先开启一个会话再试'
253
+ job.finishedAt = Date.now()
254
+ return
255
+ }
256
+ const controller = new AbortController()
257
+ job.abort = controller
258
+ const run = await startFn.call(subagents, provider, {
259
+ label: `ai-empower-plan-${String(job.source).split('/')[0]?.slice(0, 20) ?? 'component'}`,
260
+ prompt: [{ type: 'text', text: prompt }],
261
+ maxDepth: 1,
262
+ signal: controller.signal,
263
+ parent,
264
+ })
265
+ let settleFn = null
266
+ try {
267
+ const requireLocal = createRequire(join(profileDir, 'package.json'))
268
+ ;({ settleRun: settleFn } = requireLocal('@deepseek-ai/dsh-subagent'))
269
+ } catch {}
270
+ const settle = settleFn ?? (async (runHandle) => {
271
+ try {
272
+ const result = await runHandle.result
273
+ return { status: result?.stopReason === 'completed' ? 'completed' : 'failed', detail: String(result?.stopReason ?? 'unknown') }
274
+ } catch (error) {
275
+ return { status: 'failed', detail: String(error) }
276
+ }
277
+ })
278
+ const outcome = await Promise.race([
279
+ settle(run),
280
+ new Promise((resolve) => setTimeout(() => {
281
+ controller.abort()
282
+ resolve({ status: 'failed', detail: 'AI 赋能规划超时(15 分钟)' })
283
+ }, 900000)),
284
+ ])
285
+ if (outcome.status === 'completed') {
286
+ const plan = parsePlanJson(String(outcome.output ?? outcome.detail ?? ''))
287
+ if (plan === null) {
288
+ job.status = 'failed'
289
+ job.error = '子代理完成调研但未产出可解析的部署计划 JSON,请重试或手动部署'
290
+ } else {
291
+ job.plan = plan
292
+ job.status = 'plan-ready'
293
+ }
294
+ } else {
295
+ job.status = 'failed'
296
+ job.error = `AI 赋能规划未成功(${outcome.detail ?? outcome.status})`
297
+ }
298
+ } catch (error) {
299
+ job.status = 'failed'
300
+ job.error = `AI 赋能规划异常:${error instanceof Error ? error.message : String(error)}`
301
+ }
302
+ job.finishedAt = Date.now()
303
+ saveAiJobs()
304
+ }
305
+
306
+ /** AI 赋能:执行阶段——按用户勾选的步骤顺序执行,逐步回显日志。 */
307
+ async function aiEmpowerExecute(job, ports, profileDir, selected) {
308
+ job.status = 'running'
309
+ job.stage = 'executing'
310
+ job.stepStates = (job.plan?.steps ?? []).map((_, i) => ({ index: i, status: 'skipped' }))
311
+ if (job.abort) job.abort = null
312
+ const controller = new AbortController()
313
+ job.abort = controller
314
+ const steps = job.plan?.steps ?? []
315
+ const chosen = new Set((selected ?? steps.map((_, i) => i)).map((n) => Number(n)).filter((n) => Number.isInteger(n) && n >= 0 && n < steps.length))
316
+ const chosenList = steps.map((s, i) => ({ s, i })).filter((x) => chosen.has(x.i))
317
+ let done = 0
318
+ for (const { s, i } of chosenList) {
319
+ if (controller.signal.aborted) {
320
+ job.status = 'failed'
321
+ job.error = '已取消执行(用户中断)'
322
+ break
323
+ }
324
+ job.stepStates[i].status = 'running'
325
+ job.progress = { done, total: chosenList.length }
326
+ jobLog(job, `▶ 步骤 ${i + 1}/${steps.length} [${s.action}] ${s.description ?? ''}`)
327
+ try {
328
+ await runAiStep(job, s, ports, profileDir, controller.signal)
329
+ job.stepStates[i].status = 'ok'
330
+ done += 1
331
+ jobLog(job, `✔ 步骤 ${i + 1} 完成`)
332
+ } catch (error) {
333
+ job.stepStates[i].status = 'fail'
334
+ job.status = 'failed'
335
+ job.error = `步骤 ${i + 1}(${s.action})失败:${error instanceof Error ? error.message : String(error)}`
336
+ jobLog(job, `✘ 步骤 ${i + 1} 失败:${job.error}`)
337
+ break
338
+ }
339
+ }
340
+ if (job.status !== 'failed') {
341
+ job.status = 'done'
342
+ job.stage = 'done'
343
+ }
344
+ job.progress = { done, total: chosenList.length }
345
+ job.finishedAt = Date.now()
346
+ jobLog(job, job.status === 'done' ? '🎉 AI 赋能部署完成' : '⛔ AI 赋能部署中止')
347
+ saveAiJobs()
348
+ }
349
+
350
+ /** 单步执行器:动作白名单 + 路径白名单 + 破坏性命令拦截。 */
351
+ async function runAiStep(job, step, ports, profileDir, signal) {
352
+ const profile = profileDir
353
+ switch (step.action) {
354
+ case 'install-pip': {
355
+ const pkg = String(step.package ?? '')
356
+ if (!/^[A-Za-z0-9_\-\.\[\]=\s]+$/u.test(pkg) || pkg === '') throw new Error(`包名不合法:${pkg}`)
357
+ const python = resolvePythonPath()
358
+ const params = ['-m', 'pip', 'install', pkg, '--index-url', 'https://mirrors.aliyun.com/pypi/simple/', '--disable-pip-version-check']
359
+ jobLog(job, ` 运行: ${python} ${params.join(' ')}`)
360
+ await execFileAsync(python, params, { timeout: 900000, windowsHide: true, maxBuffer: 4 * 1024 * 1024, signal })
361
+ return
362
+ }
363
+ case 'install-npm': {
364
+ const pkg = String(step.package ?? '')
365
+ if (!/^(@[A-Za-z0-9_\-\.]+\/)?[A-Za-z0-9_\-\.]+(@[A-Za-z0-9_\-\.]+)?$/u.test(pkg)) throw new Error(`包名不合法:${pkg}`)
366
+ const dir = resolvePlaceholders(String(step.directory ?? profile), { profileDir: profile })
367
+ // 跨平台:不再拼 `node <node bin>/node_modules/corepack/dist/corepack.js`(Linux 上必 MODULE_NOT_FOUND,
368
+ // 2026-09-20 另一位用户的 install-npm 失败现场就是这一行),改为按优先级尝试可用的执行方式
369
+ const args = ['add', pkg, '--dir', dir, '--registry', 'https://registry.npmmirror.com']
370
+ const { runner } = await runPnpmWithFallback(args, {
371
+ execOpts: { timeout: 900000, windowsHide: true, maxBuffer: 4 * 1024 * 1024, signal },
372
+ })
373
+ jobLog(job, ` 运行: ${runner.note} ${args.join(' ')}`)
374
+ return
375
+ }
376
+ case 'write-file': {
377
+ const target = resolvePlaceholders(String(step.path ?? ''), { profileDir: profile })
378
+ if (!isAllowedWritePath(target, profile)) throw new Error(`写入路径不在白名单内:${target}`)
379
+ mkdirSync(dirname(target), { recursive: true })
380
+ let content = resolvePlaceholders(String(step.content ?? ''), { profileDir: profile })
381
+ // JSON 内容防御:若计划生成方用了占位符导致非法转义(路径反斜杠未转义),在这里拦住并给出明确报错
382
+ const trimmed = content.trim()
383
+ if ((trimmed.startsWith('{') || trimmed.startsWith('[')) && content.length < 1024 * 1024) {
384
+ try {
385
+ JSON.parse(trimmed)
386
+ } catch {
387
+ throw new Error('write-file 的内容不是合法 JSON(占位符替换后可能残留未转义的反斜杠路径):请让计划以 JSON.stringify 后的原文提供 content')
388
+ }
389
+ }
390
+ writeFileSync(target, content, 'utf8')
391
+ jobLog(job, ` 已写入 ${target}(${content.length} 字节)`)
392
+ return
393
+ }
394
+ case 'download': {
395
+ const url = maskUrl(String(step.url ?? ''))
396
+ if (!/^https?:\/\//u.test(url)) throw new Error(`下载地址不合法:${url}`)
397
+ const target = resolvePlaceholders(String(step.path ?? ''), { profileDir: profile })
398
+ if (!isAllowedWritePath(target, profile)) throw new Error(`下载路径不在白名单内:${target}`)
399
+ mkdirSync(dirname(target), { recursive: true })
400
+ if (existsSync(target) && statSync(target).size > 1024 * 1024) {
401
+ jobLog(job, ` 已存在,跳过下载:${target}(${statSync(target).size} 字节)`)
402
+ return
403
+ }
404
+ const realUrl = String(step.url).replaceAll('https://huggingface.co', 'https://hf-mirror.com')
405
+ jobLog(job, ` 下载 ${maskUrl(realUrl)} → ${target}`)
406
+ await execFileAsync('curl.exe', ['-fSL', '-o', target, realUrl], { timeout: 900000, windowsHide: true, maxBuffer: 1024 * 1024, signal })
407
+ jobLog(job, ` 下载完成(${statSync(target).size} 字节)`)
408
+ return
409
+ }
410
+ case 'run-cmd': {
411
+ const file = resolvePlaceholders(String(step.file ?? ''), { profileDir: profile })
412
+ const args = (step.args ?? []).map((a) => resolvePlaceholders(String(a), { profileDir: profile }))
413
+ if (!isSafeRunCmd(file, args)) throw new Error('run-cmd 未通过安全白名单校验(不允许的可执行文件或破坏性参数)')
414
+ jobLog(job, ` 运行: ${[file, ...args].join(' ')}`)
415
+ const { stdout, stderr } = await execFileAsync(file, args, { timeout: 600000, windowsHide: true, maxBuffer: 4 * 1024 * 1024, signal })
416
+ jobLog(job, ` 输出: ${String(stdout ?? '').slice(-1500)}${stderr ? `(stderr: ${String(stderr).slice(-500)})` : ''}`)
417
+ return
418
+ }
419
+ case 'start-service': {
420
+ const name = String(step.name ?? 'service')
421
+ const file = resolvePlaceholders(String(step.file ?? ''), { profileDir: profile })
422
+ const args = (step.args ?? []).map((a) => resolvePlaceholders(String(a), { profileDir: profile }))
423
+ const cwd = resolvePlaceholders(String(step.cwd ?? homedir()), { profileDir: profile })
424
+ const healthUrl = typeof step.healthUrl === 'string' ? resolvePlaceholders(step.healthUrl, { profileDir: profile }) : null
425
+ const uiUrl = typeof step.uiUrl === 'string' ? resolvePlaceholders(step.uiUrl, { profileDir: profile }) : null
426
+ const port = Number(step.port ?? 0)
427
+ if (!isSafeRunCmd(file, args)) throw new Error('start-service 未通过安全白名单校验')
428
+ if (healthUrl !== null) {
429
+ try {
430
+ const probe = await fetch(healthUrl, { signal: AbortSignal.timeout(2500) })
431
+ if (probe.ok) {
432
+ const slugPre = name.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '')
433
+ compUpsert({ id: slugPre || `svc-${Date.now()}`, kind: 'server', pid: null, file, args, cwd, healthUrl, uiUrl, port, repo: job.source, installedAt: Date.now() })
434
+ jobLog(job, ` 服务健康已就绪,跳过启动(复用现有进程):${healthUrl}`)
435
+ return
436
+ }
437
+ } catch {}
438
+ }
439
+ const child = spawn(file, args, { detached: true, stdio: 'ignore', windowsHide: true, cwd, shell: false })
440
+ child.unref()
441
+ const pid = child.pid
442
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '')
443
+ const id = slug || `svc-${Date.now()}`
444
+ if (healthUrl) {
445
+ const alive = await waitHealth(healthUrl, 60000, job, signal).catch(() => false)
446
+ if (!alive) jobLog(job, ` ⚠ healthUrl ${healthUrl} 在 60s 内未就绪(进程 pid=${pid} 已启动,待手工排查)`)
447
+ }
448
+ compUpsert({ id, name, kind: 'server', pid, file, args, cwd, healthUrl, uiUrl, port, repo: job.source, installedAt: Date.now() })
449
+ jobLog(job, ` 服务已启动:${name}(pid=${pid}${healthUrl ? `,healthUrl=${healthUrl}` : ''})`)
450
+ return
451
+ }
452
+ case 'stop-service': {
453
+ const id = String(step.id ?? '')
454
+ const record = compFind(id)
455
+ if (!record || !record.pid) throw new Error(`组件不存在或未记录 pid:${id || '(空)'}`)
456
+ await execFileAsync('taskkill.exe', ['/PID', String(record.pid), '/F'], { timeout: 30000, windowsHide: true })
457
+ compUpsert({ id, pid: null })
458
+ jobLog(job, ` 服务已停止:${record.name}`)
459
+ return
460
+ }
461
+ case 'wait-health': {
462
+ const url = resolvePlaceholders(String(step.url ?? ''), { profileDir: profile })
463
+ const timeoutMs = Number(step.timeoutMs ?? 60000)
464
+ const ok = await waitHealth(url, timeoutMs, job, signal)
465
+ if (!ok) throw new Error(`健康检查未通过:${url}(${timeoutMs}ms)`)
466
+ return
467
+ }
468
+ case 'register-component': {
469
+ const id = String(step.id ?? '')
470
+ if (!id) throw new Error('register-component 缺少 id')
471
+ compUpsert({ id, name: String(step.name ?? id), kind: String(step.kind ?? 'server'), pid: null, file: step.file ? resolvePlaceholders(String(step.file), { profileDir: profile }) : undefined, args: (step.args ?? []).map((a) => resolvePlaceholders(String(a), { profileDir: profile })), healthUrl: step.healthUrl ? resolvePlaceholders(String(step.healthUrl), { profileDir: profile }) : undefined, uiUrl: step.uiUrl ? resolvePlaceholders(String(step.uiUrl), { profileDir: profile }) : undefined, port: Number(step.port ?? 0), repo: job.source, installedAt: Date.now() })
472
+ return
473
+ }
474
+ default:
475
+ throw new Error(`未知动作:${step.action}`)
476
+ }
477
+ }
478
+
479
+ export { aiEmpowerPlan, runAiStep, aiRepair, aiEmpowerExecute }