@dsh-plus/agent-preset-rea 0.1.0 → 0.1.1

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/src/manager.ts ADDED
@@ -0,0 +1,419 @@
1
+ /**
2
+ * REA 的受管安装与状态探测:把「REA 包」与「REA skill」都变成插件自己管理的本机资源,
3
+ * 从而摆脱启动期 `npx -y` 拉取(那会在 boot 里跑 npm 安装脚本:原生依赖
4
+ * `@lydell/node-pty` 的 prebuild 下载一旦卡住,预设激活就不返回,名册与插件页
5
+ * 一起被吊住;事故经过见 docs/README.md「启动期拉取的教训」)。
6
+ *
7
+ * 落点(属插件数据,不进 settings,见 @dsh-plus/shared/plugin-data;插件数据目录名
8
+ * 取插件 id `dsh-plus-agent-preset-rea`):
9
+ * - REA 包:`<DSH_HOME>/dsh-plus/dsh-plus-agent-preset-rea/rea-agent/`(npm --prefix
10
+ * 安装树;入口 `node_modules/rea-agents/scripts/rea.mjs`,用 `process.execPath` 直接
11
+ * spawn,跨平台不经 shell shim);
12
+ * - 手动更新的 skill:`<DSH_HOME>/dsh-plus/dsh-plus-agent-preset-rea/skills/`
13
+ * (根下是 `<技能名>/` + `manifest.json`,与随包 `skills/` 同构;
14
+ * 清单完整时替代随包 skill 根)。
15
+ *
16
+ * 作业都是「先落 staging 再换目录」:中断只留下 staging,不会让半截安装树被当成可用
17
+ * 安装;换目录后清理旧树,运行中的旧进程按 POSIX 语义继续持有已打开文件。
18
+ *
19
+ * 外部进程经 {@link CommandRunner} 注入(见 exec.ts),单测用假 runner 跑完整状态机。
20
+ * @module @dsh-plus/agent-preset-rea/manager
21
+ */
22
+ import { createHash } from 'node:crypto'
23
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
24
+ import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
25
+ import { join } from 'node:path'
26
+
27
+ import { pluginDataPath } from '@dsh-plus/shared'
28
+
29
+ import type { AgentPresetReaConfig } from './config.ts'
30
+ import {
31
+ type CommandRunner,
32
+ countFiles,
33
+ type JobOperation,
34
+ type JobTaskContext,
35
+ lineSink,
36
+ listFiles,
37
+ NPM,
38
+ npmEnv,
39
+ safeVersion,
40
+ spawnRunner,
41
+ } from './exec.ts'
42
+
43
+ /** npm 包名。 */
44
+ export const REA_PACKAGE = 'rea-agents'
45
+
46
+ /** 受管安装树目录名。 */
47
+ export const REA_INSTALL_DIR = 'rea-agent'
48
+
49
+ /** 手动更新 skill 的目录名。 */
50
+ export const REA_SKILL_DIR = 'skills'
51
+
52
+ /** REA 随包 skill 名(tar 包内路径与目录名同此)。 */
53
+ export const REA_SKILL_NAME = 'reverse-engineer-anything'
54
+
55
+ /** 插件数据目录标识(`<DSH_HOME>/dsh-plus/<id>`)。 */
56
+ const PLUGIN_ID = 'dsh-plus-agent-preset-rea'
57
+
58
+ /** 已安装的 REA 事实。 */
59
+ export interface ReaInstall {
60
+ version: string
61
+ root: string
62
+ /** spawn 用的可执行文件(`process.execPath`,即当前 node)。 */
63
+ command: string
64
+ /** 入口脚本绝对路径(`scripts/rea.mjs`)。 */
65
+ entry: string
66
+ }
67
+
68
+ /** skill 来源事实。 */
69
+ export interface SkillState {
70
+ /** `bundled` = 随包副本;`updated` = 插件数据目录里的手动更新副本。 */
71
+ source: 'bundled' | 'updated'
72
+ /** 来源版本(从清单 source 解析;解析不出为 null)。 */
73
+ version: string | null
74
+ root: string
75
+ files: number
76
+ updatedAt: string | null
77
+ }
78
+
79
+ /** 运行期事实快照(现探,不进配置;声明组装按它决定 mcp 行与 skill 根)。 */
80
+ export interface RuntimeSnapshot {
81
+ install: ReaInstall | null
82
+ skill: SkillState
83
+ }
84
+
85
+ /** skill 清单(与随包夹具同形;更新副本多写入 updatedAt)。 */
86
+ export interface SkillManifest {
87
+ source: string
88
+ files: { path: string; sha256: string; bytes: number }[]
89
+ updatedAt?: string
90
+ }
91
+
92
+ /** 受管路径。 */
93
+ export interface ManagerPaths {
94
+ installRoot: string
95
+ skillRoot: string
96
+ }
97
+
98
+ /** 作业可一次性覆盖的配置项(卡片未保存的代理端口即时生效用)。 */
99
+ export interface JobOverrides {
100
+ proxyPort?: number
101
+ }
102
+
103
+ /** REA 管理面。 */
104
+ export interface ReaManager {
105
+ paths(): ManagerPaths
106
+ /** 现探运行期事实(同步;声明组装在 `apply` 期就要读到)。 */
107
+ snapshot(): RuntimeSnapshot
108
+ /** 重新现探并更新快照(作业完成、volatile 变更后调用)。 */
109
+ refresh(): RuntimeSnapshot
110
+ /** 受管作业(安装/更新 REA 包、更新 skill)。 */
111
+ operations(overrides?: JobOverrides): { install: JobOperation; skill: JobOperation }
112
+ }
113
+
114
+ /** 安装作业阶段名。 */
115
+ export const INSTALL_PHASES: string[] = [
116
+ '解析版本信息',
117
+ `下载并安装 ${REA_PACKAGE}`,
118
+ '替换安装目录',
119
+ '校验安装',
120
+ ]
121
+
122
+ /** skill 作业阶段名。 */
123
+ export const SKILL_PHASES: string[] = [
124
+ '解析版本信息',
125
+ '下载 skill 包',
126
+ '解包 skills',
127
+ '校验并写入清单',
128
+ ]
129
+
130
+ /**
131
+ * 解析受管路径:`installRoot` 非空即运维指定位置(部署方平面),
132
+ * 否则落插件数据目录;skill 更新副本固定落插件数据目录。
133
+ */
134
+ export function managerPaths(config: AgentPresetReaConfig): ManagerPaths {
135
+ const root = config.installRoot.trim()
136
+ return {
137
+ installRoot: root === '' ? pluginDataPath(PLUGIN_ID, REA_INSTALL_DIR) : root,
138
+ skillRoot: pluginDataPath(PLUGIN_ID, REA_SKILL_DIR),
139
+ }
140
+ }
141
+
142
+ /** 读 JSON(不存在或坏内容返回 null)。 */
143
+ function readJson(file: string): unknown {
144
+ try {
145
+ return JSON.parse(readFileSync(file, 'utf8')) as unknown
146
+ } catch {
147
+ return null
148
+ }
149
+ }
150
+
151
+ /** 记录型守卫。 */
152
+ function asRecord(value: unknown): Record<string, unknown> | null {
153
+ return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
154
+ }
155
+
156
+ /** 安装树内固定入口:`node_modules/rea-agents/scripts/rea.mjs`。 */
157
+ function reaEntryFile(root: string): string {
158
+ return join(root, 'node_modules', REA_PACKAGE, 'scripts', 'rea.mjs')
159
+ }
160
+
161
+ /** 从清单 source(`rea-agents@1.2.3`)解析版本。 */
162
+ function versionOfSource(source: string): string | null {
163
+ const prefix = `${REA_PACKAGE}@`
164
+ return source.startsWith(prefix) ? source.slice(prefix.length) : null
165
+ }
166
+
167
+ /**
168
+ * 探测已安装 REA:包版本可读 **且** 入口脚本存在才算安装完整
169
+ * (只有一半的安装树、或换目录中断留下的 staging 都不算)。
170
+ */
171
+ export function probeInstall(paths: ManagerPaths): ReaInstall | null {
172
+ const pkg = asRecord(
173
+ readJson(join(paths.installRoot, 'node_modules', REA_PACKAGE, 'package.json')),
174
+ )
175
+ const version = typeof pkg?.['version'] === 'string' ? pkg['version'] : null
176
+ const entry = reaEntryFile(paths.installRoot)
177
+ if (version === null || !existsSync(entry)) return null
178
+ return { version, root: paths.installRoot, command: process.execPath, entry }
179
+ }
180
+
181
+ /** 探测 skill 来源:数据目录里清单完整且技能文件在位才认更新副本。 */
182
+ export function probeSkill(
183
+ paths: ManagerPaths,
184
+ bundledRoot: string,
185
+ bundledVersion: string,
186
+ ): SkillState {
187
+ const manifest = asRecord(readJson(join(paths.skillRoot, 'manifest.json')))
188
+ const source = typeof manifest?.['source'] === 'string' ? manifest['source'] : null
189
+ const files = Array.isArray(manifest?.['files']) ? manifest['files'].length : 0
190
+ if (
191
+ source !== null &&
192
+ files > 0 &&
193
+ existsSync(join(paths.skillRoot, REA_SKILL_NAME, 'SKILL.md'))
194
+ ) {
195
+ return {
196
+ source: 'updated',
197
+ version: versionOfSource(source),
198
+ root: paths.skillRoot,
199
+ files,
200
+ updatedAt: typeof manifest?.['updatedAt'] === 'string' ? manifest['updatedAt'] : null,
201
+ }
202
+ }
203
+ return {
204
+ source: 'bundled',
205
+ version: bundledVersion,
206
+ root: bundledRoot,
207
+ files: countFiles(bundledRoot),
208
+ updatedAt: null,
209
+ }
210
+ }
211
+
212
+ /** 现探运行期事实。 */
213
+ export function probeRuntime(
214
+ config: AgentPresetReaConfig,
215
+ bundledRoot: string,
216
+ bundledVersion: string,
217
+ ): RuntimeSnapshot {
218
+ const paths = managerPaths(config)
219
+ return {
220
+ install: probeInstall(paths),
221
+ skill: probeSkill(paths, bundledRoot, bundledVersion),
222
+ }
223
+ }
224
+
225
+ /** 作业依赖:生效配置、命令执行面、作业完成后的现探刷新。 */
226
+ export interface JobDeps {
227
+ config: AgentPresetReaConfig
228
+ runner: CommandRunner
229
+ refresh(): void
230
+ }
231
+
232
+ /**
233
+ * 解析钉版在 registry 上的实际版本:顺带做存在性校验(失败即报错,
234
+ * 而不是装出一棵空树让 MCP 行指向不存在的入口)。
235
+ */
236
+ async function resolveVersion(deps: JobDeps, ctx: JobTaskContext, wanted: string): Promise<void> {
237
+ ctx.phase(0)
238
+ const lines: string[] = []
239
+ const result = await deps.runner.run(
240
+ NPM,
241
+ ['view', `${REA_PACKAGE}@${wanted}`, 'version', '--json'],
242
+ {
243
+ env: npmEnv(deps.config),
244
+ signal: ctx.signal,
245
+ onLine: (line) => {
246
+ lines.push(line)
247
+ lineSink(ctx)(line)
248
+ },
249
+ },
250
+ )
251
+ if (result.code !== 0) throw new Error(`registry 上找不到 ${REA_PACKAGE}@${wanted}`)
252
+ const reported = lines.join('').replace(/["\s]/g, '')
253
+ if (reported !== '' && reported !== wanted) {
254
+ ctx.log(`registry 返回 ${reported}(配置钉的是 ${wanted})`)
255
+ }
256
+ }
257
+
258
+ /** 装到 staging 并清掉失败现场的公共包壳。 */
259
+ async function npmInstall(deps: JobDeps, ctx: JobTaskContext, staging: string): Promise<void> {
260
+ const version = safeVersion(deps.config.reaVersion)
261
+ await rm(staging, { recursive: true, force: true })
262
+ const result = await deps.runner.run(
263
+ NPM,
264
+ [
265
+ 'install',
266
+ '--prefix',
267
+ staging,
268
+ '--no-audit',
269
+ '--no-fund',
270
+ '--loglevel',
271
+ 'http',
272
+ `${REA_PACKAGE}@${version}`,
273
+ ],
274
+ { env: npmEnv(deps.config), signal: ctx.signal, onLine: lineSink(ctx) },
275
+ )
276
+ if (result.code !== 0) {
277
+ await rm(staging, { recursive: true, force: true })
278
+ throw new Error(`npm install 失败(退出码 ${result.code})`)
279
+ }
280
+ }
281
+
282
+ /** 安装/更新 REA 包:staging 安装 → 换目录 → 校验入口可读。 */
283
+ export async function runInstallJob(deps: JobDeps, ctx: JobTaskContext): Promise<string> {
284
+ const version = safeVersion(deps.config.reaVersion)
285
+ const root = managerPaths(deps.config).installRoot
286
+ const staging = `${root}.staging`
287
+ await resolveVersion(deps, ctx, version)
288
+
289
+ ctx.phase(1)
290
+ await npmInstall(deps, ctx, staging)
291
+
292
+ ctx.phase(2)
293
+ const backup = `${root}.old`
294
+ await rm(backup, { recursive: true, force: true })
295
+ if (existsSync(root)) await rename(root, backup)
296
+ await rename(staging, root)
297
+ await rm(backup, { recursive: true, force: true })
298
+
299
+ ctx.phase(3)
300
+ const probe = probeInstall(managerPaths(deps.config))
301
+ if (probe === null) throw new Error('安装校验失败:入口脚本不可用')
302
+ deps.refresh()
303
+ ctx.log(`已安装 ${REA_PACKAGE}@${probe.version} → ${probe.root}`)
304
+ return probe.version
305
+ }
306
+
307
+ /** 逐文件 sha256(相对路径 + 字节数)。 */
308
+ function hashFiles(dir: string): SkillManifest['files'] {
309
+ return listFiles(dir).map((path) => {
310
+ const bytes = readFileSync(join(dir, path))
311
+ return { path, sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length }
312
+ })
313
+ }
314
+
315
+ /** 从 tar 包取钉版 skill 目录(副本根与随包 `skills/` 同构:只 strip 两层)。 */
316
+ async function unpackSkill(
317
+ deps: JobDeps,
318
+ ctx: JobTaskContext,
319
+ staging: string,
320
+ tree: string,
321
+ ): Promise<void> {
322
+ const version = safeVersion(deps.config.reaVersion)
323
+ await rm(staging, { recursive: true, force: true })
324
+ await mkdir(staging, { recursive: true })
325
+ const packed = await deps.runner.run(
326
+ NPM,
327
+ ['pack', `${REA_PACKAGE}@${version}`, '--pack-destination', staging, '--loglevel', 'http'],
328
+ { env: npmEnv(deps.config), signal: ctx.signal, onLine: lineSink(ctx) },
329
+ )
330
+ if (packed.code !== 0) {
331
+ await rm(staging, { recursive: true, force: true })
332
+ throw new Error(`npm pack 失败(退出码 ${packed.code})`)
333
+ }
334
+ const tarball = readdirSync(staging).find((name) => name.endsWith('.tgz'))
335
+ if (tarball === undefined) throw new Error('npm pack 未产出 tarball')
336
+
337
+ ctx.phase(2)
338
+ await mkdir(tree, { recursive: true })
339
+ const untar = await deps.runner.run(
340
+ 'tar',
341
+ [
342
+ '-xzf',
343
+ join(staging, tarball),
344
+ '-C',
345
+ tree,
346
+ '--strip-components=2',
347
+ `package/skills/${REA_SKILL_NAME}`,
348
+ ],
349
+ { env: npmEnv(deps.config), signal: ctx.signal, onLine: lineSink(ctx) },
350
+ )
351
+ if (untar.code !== 0) {
352
+ await rm(staging, { recursive: true, force: true })
353
+ throw new Error(`tar 解包失败(退出码 ${untar.code})`)
354
+ }
355
+ }
356
+
357
+ /** 拉取钉版 tarball 并替换手动更新的 skill 副本(写 sha256 清单后换目录)。 */
358
+ export async function runSkillJob(deps: JobDeps, ctx: JobTaskContext): Promise<string> {
359
+ const version = safeVersion(deps.config.reaVersion)
360
+ const root = managerPaths(deps.config).skillRoot
361
+ const staging = `${root}.staging`
362
+ const tree = join(staging, 'tree')
363
+ await resolveVersion(deps, ctx, version)
364
+ ctx.phase(1)
365
+ await unpackSkill(deps, ctx, staging, tree)
366
+
367
+ ctx.phase(3)
368
+ const files = hashFiles(tree)
369
+ if (files.length === 0) throw new Error('解包结果为空:上游 skill 目录结构可能已变')
370
+ const manifest: SkillManifest = {
371
+ source: `${REA_PACKAGE}@${version}`,
372
+ files,
373
+ updatedAt: new Date().toISOString(),
374
+ }
375
+ await writeFile(join(tree, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
376
+ await rm(root, { recursive: true, force: true })
377
+ await rename(tree, root)
378
+ await rm(staging, { recursive: true, force: true })
379
+ deps.refresh()
380
+ ctx.log(`已更新 skill ${REA_SKILL_NAME}(${files.length} 文件)→ ${root}`)
381
+ return version
382
+ }
383
+
384
+ /** 生效配置:作业可带一次性覆盖(卡片未保存的代理端口即时可用)。 */
385
+ export function configWith(
386
+ base: AgentPresetReaConfig,
387
+ overrides?: JobOverrides,
388
+ ): AgentPresetReaConfig {
389
+ if (overrides?.proxyPort === undefined) return base
390
+ return { ...base, proxyPort: overrides.proxyPort }
391
+ }
392
+
393
+ /** 建一个 REA 管理器。 */
394
+ export function createManager(options: {
395
+ config: () => AgentPresetReaConfig
396
+ bundledRoot: string
397
+ bundledVersion: string
398
+ runner?: CommandRunner
399
+ }): ReaManager {
400
+ const runner = options.runner ?? spawnRunner
401
+ const paths = (): ManagerPaths => managerPaths(options.config())
402
+ let cache: RuntimeSnapshot | null = null
403
+ const refresh = (): RuntimeSnapshot => {
404
+ cache = probeRuntime(options.config(), options.bundledRoot, options.bundledVersion)
405
+ return cache
406
+ }
407
+ return {
408
+ paths,
409
+ refresh,
410
+ snapshot: () => cache ?? refresh(),
411
+ operations: (overrides) => {
412
+ const deps: JobDeps = { config: configWith(options.config(), overrides), runner, refresh }
413
+ return {
414
+ install: { phases: INSTALL_PHASES, run: (ctx) => runInstallJob(deps, ctx) },
415
+ skill: { phases: SKILL_PHASES, run: (ctx) => runSkillJob(deps, ctx) },
416
+ }
417
+ },
418
+ }
419
+ }
package/src/mcp.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  /**
2
- * REA MCP 行的取值派生:钉版版本、stdio 启动命令与参数、入参 schema 档位。
2
+ * REA MCP 行的取值派生:启动方式(受管安装入口 or 显式命令)、参数、入参/出参 schema 档位。
3
3
  *
4
4
  * REA 的 stdio 入口是 `rea-agents mcp`(`server.json` 的 positional 参数),
5
5
  * 包版本钉在 {@link REA_PINNED_VERSION}——升级只改这一个常量,漂移由测试拦住。
6
6
  * REA 的 Node 引擎要求为 `^22.19 || ^24.11 || >=26`(见 REA `docs/installation.md`)。
7
+ *
8
+ * **默认不 spawn npx**:启动期 `npx -y` 会跑 npm 安装脚本(原生依赖 prebuild 下载),
9
+ * 一旦卡住就把预设激活与整棵名册一起吊住;因此默认只认受管安装入口(见 manager.ts),
10
+ * 未安装时该行 `disabled`,安装入口在插件页卡片上。`mcpCommand` 显式配置时按用户
11
+ * 命令启动(经典 npx 配方仍可用,风险自负)。
7
12
  * @module @dsh-plus/agent-preset-rea/mcp
8
13
  */
9
14
  import type { AgentPresetReaConfig } from './config.ts'
@@ -11,7 +16,7 @@ import type { AgentPresetReaConfig } from './config.ts'
11
16
  /** 钉版基线:`rea-agents@6.5.0`(升级时同步测试断言、tests 清单与包文档)。 */
12
17
  export const REA_PINNED_VERSION = '6.5.0'
13
18
 
14
- /** stdio 启动命令默认值:POSIX 用 `npx`;Windows 需 `cmd.exe /c npx`(见包文档)。 */
19
+ /** 显式命令配 npx 时的参数配方基准值(`mcpArgs` 留空即按它派生)。 */
15
20
  export const DEFAULT_MCP_COMMAND = 'npx'
16
21
 
17
22
  /** REA 工具入参 schema 广告档位:`compact` 压请求前缀体积,`full` 与上游默认一致。 */
@@ -20,11 +25,47 @@ export const INPUT_SCHEMA_PROFILES = ['compact', 'full'] as const
20
25
  /** REA 工具出参 schema 广告档位:`off` 不下发(结果仍由 REA 侧校验),`full` 与上游默认一致。 */
21
26
  export const OUTPUT_SCHEMA_PROFILES = ['off', 'full'] as const
22
27
 
23
- /** stdio 参数:显式配置优先(Windows/全局安装配方),否则按钉版版本派生。 */
28
+ /** 受管安装的入口事实(结构上由 manager 的 ReaInstall 满足)。 */
29
+ export interface InstalledEntry {
30
+ /** spawn 用的可执行文件。 */
31
+ command: string
32
+ /** 入口脚本绝对路径。 */
33
+ entry: string
34
+ }
35
+
36
+ /** MCP 行的启动方式。 */
37
+ export interface McpLaunch {
38
+ command: string
39
+ args: string[]
40
+ }
41
+
42
+ /**
43
+ * 显式命令(`mcpCommand` 非空)时的参数:`mcpArgs` 优先;
44
+ * 留空时 npx 走 `-y rea-agents@<钉版> mcp`,其他可执行文件只补 `mcp`。
45
+ */
24
46
  export function reaMcpArgs(config: AgentPresetReaConfig): string[] {
25
- return config.mcpArgs.length > 0
26
- ? [...config.mcpArgs]
27
- : ['-y', `rea-agents@${config.reaVersion}`, 'mcp']
47
+ if (config.mcpArgs.length > 0) return [...config.mcpArgs]
48
+ return config.mcpCommand === DEFAULT_MCP_COMMAND
49
+ ? ['-y', `rea-agents@${config.reaVersion}`, 'mcp']
50
+ : ['mcp']
51
+ }
52
+
53
+ /**
54
+ * 本预设 MCP 行的启动方式:
55
+ * 1. `mcpCommand` 显式配置 → 用用户命令(Windows 配方、全局安装场景);
56
+ * 2. 受管安装存在 → 用当前 node 直接跑安装树里的入口脚本(跨平台、不经 shell shim);
57
+ * 3. 都没有 → `null`,调用方把该行置 `disabled`(未安装,等卡片上的安装动作)。
58
+ */
59
+ export function reaMcpLaunch(
60
+ config: AgentPresetReaConfig,
61
+ installed: InstalledEntry | null,
62
+ ): McpLaunch | null {
63
+ if (config.mcpCommand.trim() !== '') {
64
+ return { command: config.mcpCommand, args: reaMcpArgs(config) }
65
+ }
66
+ if (installed === null) return null
67
+ const extra = config.mcpArgs.length > 0 ? [...config.mcpArgs] : ['mcp']
68
+ return { command: installed.command, args: [installed.entry, ...extra] }
28
69
  }
29
70
 
30
71
  /**
package/src/routes.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * 受管安装/更新的 HTTP 通道:状态查询 + 三类动作(安装/更新 REA、更新 skill、取消)。
3
+ *
4
+ * 只挂在 dsh web 同源(webserver 默认 127.0.0.1,与 notify-email 的测试端点同等暴露面):
5
+ * 这些端点会 spawn npm 并写插件数据目录,不做鉴权以外的额外暴露面扩张。
6
+ * 配置读写仍走官方 settings(卡片直连 remote.settings),这里只补官方传输没有的动作。
7
+ *
8
+ * 请求体只接受一个可选字段 `proxyPort`(0..65535);其余一律来自插件配置。
9
+ * @module @dsh-plus/agent-preset-rea/routes
10
+ */
11
+ import type { IncomingMessage, ServerResponse } from 'node:http'
12
+
13
+ import type { Context } from '@deepseek-ai/cordis'
14
+ // 平台声明导入(无值消费):把 webServer 服务面合进 program,ctx.webServer 方可解析。
15
+ import type {} from '@deepseek-ai/dsh-host-webserver'
16
+
17
+ import type { AgentPresetReaConfig } from './config.ts'
18
+ import type { JobActionDto, StatusDto } from './dto.ts'
19
+ import type { JobBoard } from './jobs.ts'
20
+ import type { ReaManager } from './manager.ts'
21
+ import { type McpLaunch, reaMcpLaunch } from './mcp.ts'
22
+
23
+ /** 路由前缀。 */
24
+ export const ROUTE_BASE = '/dsh-plus/agent-preset-rea'
25
+
26
+ /** 请求体大小上限(只有一个数字字段)。 */
27
+ const BODY_LIMIT = 4096
28
+
29
+ /** 路由依赖。 */
30
+ export interface ReaRoutesOptions {
31
+ manager: ReaManager
32
+ board: JobBoard
33
+ config: () => AgentPresetReaConfig
34
+ }
35
+
36
+ /** 读请求体(超限即拒绝;空体按空对象)。 */
37
+ async function readBody(req: IncomingMessage): Promise<unknown> {
38
+ const chunks: Buffer[] = []
39
+ let size = 0
40
+ for await (const chunk of req) {
41
+ const buf = chunk as Buffer
42
+ size += buf.length
43
+ if (size > BODY_LIMIT) throw new Error('请求体过大')
44
+ chunks.push(buf)
45
+ }
46
+ const text = Buffer.concat(chunks).toString('utf8').trim()
47
+ return text === '' ? {} : (JSON.parse(text) as unknown)
48
+ }
49
+
50
+ /** 从请求体取可选的代理端口覆盖(形状不对即抛,由调用方回 400)。 */
51
+ function proxyPortOf(body: unknown): number | undefined {
52
+ if (typeof body !== 'object' || body === null) return undefined
53
+ const value = (body as Record<string, unknown>)['proxyPort']
54
+ if (value === undefined || value === null) return undefined
55
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 65535) {
56
+ throw new Error('proxyPort 必须是 0..65535 的整数')
57
+ }
58
+ return value
59
+ }
60
+
61
+ /** 组装状态响应(纯函数,可直接单测)。 */
62
+ export function statusPayload(options: ReaRoutesOptions): StatusDto {
63
+ const config = options.config()
64
+ const runtime = options.manager.snapshot()
65
+ const paths = options.manager.paths()
66
+ const launch: McpLaunch | null = reaMcpLaunch(config, runtime.install)
67
+ const mode =
68
+ config.mcpCommand.trim() !== '' ? 'custom' : launch === null ? 'missing' : 'installed'
69
+ return {
70
+ config: {
71
+ reaVersion: config.reaVersion,
72
+ proxyPort: config.proxyPort,
73
+ mcpEnabled: config.mcpEnabled,
74
+ installRoot: config.installRoot,
75
+ },
76
+ install: {
77
+ installed: runtime.install !== null,
78
+ version: runtime.install?.version ?? null,
79
+ root: paths.installRoot,
80
+ entry: runtime.install?.entry ?? null,
81
+ },
82
+ skill: { ...runtime.skill },
83
+ mcp: {
84
+ mode,
85
+ enabled: config.mcpEnabled && launch !== null,
86
+ command: launch?.command ?? null,
87
+ args: launch?.args ?? [],
88
+ },
89
+ paths,
90
+ job: options.board.snapshot(),
91
+ }
92
+ }
93
+
94
+ /** 注册全部路由。 */
95
+ export function registerReaRoutes(ctx: Context, options: ReaRoutesOptions): void {
96
+ const logger = ctx.logger('agent-preset-rea')
97
+ const send = (res: ServerResponse, status: number, body: unknown): void => {
98
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
99
+ res.end(JSON.stringify(body))
100
+ }
101
+ const guard = (
102
+ handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>,
103
+ ): ((req: IncomingMessage, res: ServerResponse) => Promise<void>) => {
104
+ return async (req, res) => {
105
+ try {
106
+ await handler(req, res)
107
+ } catch (error) {
108
+ const message = error instanceof Error ? error.message : String(error)
109
+ logger.warn(`${req.method ?? '?'} ${req.url ?? '?'} 失败:${message}`)
110
+ if (!res.headersSent) send(res, 400, { job: null, error: message } satisfies JobActionDto)
111
+ else res.end()
112
+ }
113
+ }
114
+ }
115
+ const startJob = async (
116
+ req: IncomingMessage,
117
+ res: ServerResponse,
118
+ kind: 'install' | 'skill',
119
+ ): Promise<void> => {
120
+ const overrides = proxyPortOf(await readBody(req))
121
+ const operations = options.manager.operations(
122
+ overrides === undefined ? undefined : { proxyPort: overrides },
123
+ )
124
+ const job = options.board.start(kind, operations[kind])
125
+ if (job === null) {
126
+ send(res, 409, { job: options.board.snapshot(), error: '已有作业在运行' })
127
+ return
128
+ }
129
+ logger.info(`已启动 ${kind} 作业`)
130
+ send(res, 200, { job } satisfies JobActionDto)
131
+ }
132
+
133
+ ctx.webServer.register({
134
+ kind: 'exact',
135
+ path: `${ROUTE_BASE}/status`,
136
+ handler: guard(async (req, res) => {
137
+ if (req.method !== 'GET') {
138
+ send(res, 405, { job: null, error: 'method not allowed' })
139
+ return
140
+ }
141
+ send(res, 200, statusPayload(options))
142
+ }),
143
+ })
144
+ ctx.webServer.register({
145
+ kind: 'exact',
146
+ path: `${ROUTE_BASE}/install`,
147
+ handler: guard(async (req, res) => {
148
+ if (req.method !== 'POST') {
149
+ send(res, 405, { job: null, error: 'method not allowed' })
150
+ return
151
+ }
152
+ await startJob(req, res, 'install')
153
+ }),
154
+ })
155
+ ctx.webServer.register({
156
+ kind: 'exact',
157
+ path: `${ROUTE_BASE}/skill`,
158
+ handler: guard(async (req, res) => {
159
+ if (req.method !== 'POST') {
160
+ send(res, 405, { job: null, error: 'method not allowed' })
161
+ return
162
+ }
163
+ await startJob(req, res, 'skill')
164
+ }),
165
+ })
166
+ ctx.webServer.register({
167
+ kind: 'exact',
168
+ path: `${ROUTE_BASE}/cancel`,
169
+ handler: guard(async (req, res) => {
170
+ if (req.method !== 'POST') {
171
+ send(res, 405, { job: null, error: 'method not allowed' })
172
+ return
173
+ }
174
+ const cancelled = options.board.cancel()
175
+ send(res, 200, {
176
+ job: options.board.snapshot(),
177
+ ...(cancelled ? {} : { error: '没有正在运行的作业' }),
178
+ } satisfies JobActionDto)
179
+ }),
180
+ })
181
+ }