dsh-api-runner 0.0.0-stage → 0.2.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.
package/src/main.js ADDED
@@ -0,0 +1,615 @@
1
+ /**
2
+ * dsh-api-runner 插件入口。
3
+ *
4
+ * 形态对齐 dsh-db-tool:`export const inject` + `export function apply(ctx)`,
5
+ * **不提供 default export**(cordis-plugin-loader 的 unwrapExports() 会取
6
+ * exports.default,裸函数形态会丢掉 inject,报
7
+ * "cannot get property \"webServer\" without inject")。
8
+ *
9
+ * 提供三件事:
10
+ * 1. 按配置调用后端 HTTP 接口({param} 占位、JSON/form body、超时、响应截断);
11
+ * 2. 本地服务启停/重启(managed 托管进程 / observe 只观测 IDEA 起的进程);
12
+ * 3. 日志增量读取与"等某个日志特征出现",让模型能改完自己验证。
13
+ *
14
+ * 配置默认读 ${DSH_HOME}/api-runner.yml,项目内可用 <cwd>/.dsh/api-runner.yml 覆盖;
15
+ * 每次调用重新解析(带 mtime 缓存),改配置不需要重启 DSH。
16
+ */
17
+ import path from 'node:path';
18
+ import { ApiRunnerService, asObject, toText } from './ops.js';
19
+ import { ProjectStore } from './project-store.js';
20
+ import { JavaRunner } from './java-runner.js';
21
+ import { SettingsStore } from './settings.js';
22
+
23
+ export const inject = ['webServer', 'sessions', 'tools', 'connection', 'systemPrompt'];
24
+
25
+ const TOOL_DESCRIPTION = `开发联调工具(dsh-api-runner):按配置调用后端 HTTP 接口、启停本地服务、增量读取日志。
26
+
27
+ 典型用法(改完后端代码后自测):
28
+ 1. action=describe —— 先看有哪些接口/服务/日志可用(含每个接口需要的参数名)。
29
+ 2. action=service + service_action=restart —— 重启本地服务(managed 模式由插件托管进程;
30
+ observe 模式下插件不托管,需你到 IDEA 里重启,然后用 service_action=wait 等就绪)。
31
+ 3. action=log + pattern —— 增量读日志;或 action=wait_log 等启动完成/报错特征出现。
32
+ 4. action=call —— 调用接口,params 里的键会替换 URL 里的 {name} 并拼进 query/body。
33
+ 5. action=verify —— 一条命令串起「重启 → 读日志 → 调接口」,适合改完后端后自检。
34
+
35
+ 注意:
36
+ - 日志游标按 logId 持久化,第一次读只取文件末尾一段;要全量请传 reset=true。
37
+ - 服务重启会 kill 整个进程树后重新拉起(managed);失败时返回 exit code 与健康检查明细。
38
+ - 接口响应体默认截断到 32KB,可用 max_bytes 调整;请求头在返回里已脱敏。`;
39
+
40
+ const PARAMETERS = {
41
+ type: 'object',
42
+ properties: {
43
+ action: {
44
+ type: 'string',
45
+ enum: ['describe', 'call', 'login', 'service', 'log', 'wait_log', 'reset_log', 'verify', 'java'],
46
+ description: '要执行的操作',
47
+ },
48
+ api_id: { type: 'string', description: '接口 id 或 name(call、login、verify 用)' },
49
+ params: {
50
+ type: 'object',
51
+ additionalProperties: true,
52
+ description: '接口参数:键会替换 URL/body 里的 {key} 占位,未用到的键拼进 query',
53
+ },
54
+ token_from: { type: 'string', description: 'call 时用哪个登录接口保存的 token(省略则自动判断)' },
55
+ service_id: { type: 'string', description: '服务 id 或 name(service、verify 用;省略时取第一个服务)' },
56
+ service_action: {
57
+ type: 'string',
58
+ enum: ['start', 'stop', 'restart', 'wait', 'status'],
59
+ description: '服务动作(action=service 用)',
60
+ },
61
+ log_id: { type: 'string', description: '日志 id 或 name(log、wait_log、reset_log 用;省略时取第一个日志)' },
62
+ java_action: {
63
+ type: 'string',
64
+ enum: ['list', 'start', 'stop', 'restart', 'log', 'status', 'detail', 'ignore', 'unignore', 'save', 'unsave', 'start_saved', 'start_all', 'settings'],
65
+ description: 'Java 服务动作(action=java 用):list 列出;detail 服务详情+日志;start 接管启动(先编译再杀旧进程);stop/restart 只对插件启动的生效;log 读日志;ignore/unignore 在面板列表中隐藏/恢复;save 把当前进程的启动配方向保存下来(之后不用 IDEA 也能起);unsave 删除保存;start_saved 按保存的配置启动一个;start_all 一键启动所有已保存的服务(已运行的跳过,单个失败不影响其余,返回每个的成败);settings 读写插件设置(数据目录/默认工作区)',
66
+ },
67
+ java_ids: {
68
+ type: 'array',
69
+ items: { type: 'string' },
70
+ description: 'java_action=start_all 时只启动这些服务(省略则启动全部已保存的)',
71
+ },
72
+ pid: { type: 'number', description: '要接管的 Java 进程 pid(java_action=start 用)' },
73
+ java_id: { type: 'string', description: '插件托管的服务名或 pid(java_action=stop/restart/log/save/unsave/start_saved 用)' },
74
+ port: { type: 'number', description: '服务端口(java_action=start 用;省略则从进程参数推断)' },
75
+ debug: { type: 'string', enum: ['port', 'none', 'keep'], description: '重启后的调试端口处理(默认 port:自己开 y 端口等连接)' },
76
+ from_offset: { type: 'number', description: 'java_action=log 时的起始字节偏移(增量读)' },
77
+ log_source: { type: 'string', description: 'java_action=detail 时选哪个日志文件(取 detail 返回的 sources[].key;省略用第一个)' },
78
+ compile: { type: 'boolean', description: 'java_action=start/restart/start_saved 前是否先 mvn 编译(默认 true)' },
79
+ settings: {
80
+ type: 'object',
81
+ description: 'java_action=settings 且要写入时传:{ dataDir?: "/abs/path(绝对路径,空字符串恢复默认)", workspace?: "默认项目根", mavenHome?: "Maven 主路径(安装目录,空表示自动探测)" }',
82
+ properties: {
83
+ dataDir: { type: 'string' },
84
+ workspace: { type: 'string' },
85
+ mavenHome: { type: 'string' },
86
+ },
87
+ },
88
+ pattern: { type: 'string', description: '正则过滤:只返回匹配行(log);wait_log 时等匹配行出现' },
89
+ ignore_case: { type: 'boolean', description: 'pattern 是否忽略大小写' },
90
+ max_bytes: { type: 'number', description: '单次最多读取的字节数(默认 65536)' },
91
+ max_lines: { type: 'number', description: '返回行数上限(默认 400,超出保留最后 N 行)' },
92
+ reset: { type: 'boolean', description: '日志从头读(忽略游标与历史)' },
93
+ timeout_ms: { type: 'number', description: '超时:接口请求 / 等待日志 / 等待健康检查(毫秒)' },
94
+ restart: { type: 'boolean', description: 'verify 时是否先重启服务(默认 true)' },
95
+ },
96
+ required: ['action'],
97
+ };
98
+
99
+ /** 服务/日志动作的统一入口 */
100
+ async function runTool(service, args, projectPath) {
101
+ const action = String(args.action || '');
102
+ const timeoutMs = args.timeout_ms ? Number(args.timeout_ms) : undefined;
103
+
104
+ switch (action) {
105
+ case 'describe':
106
+ return service.describe(projectPath);
107
+ case 'call': {
108
+ if (!args.api_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'call 需要 api_id' };
109
+ return service.call(projectPath, String(args.api_id), asObject(args.params), {
110
+ maxBodyBytes: args.max_bytes ? Number(args.max_bytes) : undefined,
111
+ timeoutMs,
112
+ tokenFrom: args.token_from ? String(args.token_from) : '',
113
+ });
114
+ }
115
+ case 'login': {
116
+ if (!args.api_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'login 需要 api_id(登录接口的 id)' };
117
+ return service.login(projectPath, String(args.api_id), asObject(args.params), {
118
+ maxBodyBytes: args.max_bytes ? Number(args.max_bytes) : undefined,
119
+ timeoutMs,
120
+ });
121
+ }
122
+ case 'java': {
123
+ const javaAction = String(args.java_action || 'list');
124
+ if (javaAction === 'list') return service.javaProcesses(projectPath);
125
+ if (javaAction === 'start') {
126
+ if (!args.pid) return { ok: false, code: 'MISSING_ARGUMENT', message: 'java_action=start 需要 pid(要接管的 Java 进程)' };
127
+ return service.startJava(projectPath, {
128
+ pid: Number(args.pid),
129
+ id: args.java_id ? String(args.java_id) : '',
130
+ port: args.port ? Number(args.port) : 0,
131
+ compile: args.compile !== false,
132
+ debug: args.debug ? String(args.debug) : 'port',
133
+ timeoutMs,
134
+ });
135
+ }
136
+ if (javaAction === 'stop') {
137
+ const target = args.java_id || args.pid;
138
+ if (!target) return { ok: false, code: 'MISSING_ARGUMENT', message: 'java_action=stop 需要 java_id 或 pid' };
139
+ return service.stopJava(projectPath, target);
140
+ }
141
+ if (javaAction === 'restart') {
142
+ const target = args.java_id || args.pid;
143
+ if (!target) return { ok: false, code: 'MISSING_ARGUMENT', message: 'java_action=restart 需要 java_id 或 pid' };
144
+ return service.restartJava(projectPath, target, { compile: args.compile !== false, timeoutMs });
145
+ }
146
+ if (javaAction === 'log') {
147
+ const target = args.java_id || args.pid;
148
+ if (!target) return { ok: false, code: 'MISSING_ARGUMENT', message: 'java_action=log 需要 java_id 或 pid' };
149
+ return service.javaLogs(projectPath, target, {
150
+ fromOffset: args.from_offset !== undefined ? Number(args.from_offset) : undefined,
151
+ maxBytes: args.max_bytes ? Number(args.max_bytes) : undefined,
152
+ });
153
+ }
154
+ if (javaAction === 'status') {
155
+ const list = await service.javaProcesses(projectPath);
156
+ return { ...list, javaAction: 'status' };
157
+ }
158
+ if (javaAction === 'detail') {
159
+ const target = args.java_id || args.pid;
160
+ if (!target) return { ok: false, code: 'MISSING_ARGUMENT', message: 'java_action=detail 需要 java_id 或 pid' };
161
+ return service.javaDetail(projectPath, target, {
162
+ fromOffset: args.from_offset !== undefined ? Number(args.from_offset) : undefined,
163
+ maxBytes: args.max_bytes ? Number(args.max_bytes) : undefined,
164
+ source: args.log_source ? String(args.log_source) : '',
165
+ });
166
+ }
167
+ if (javaAction === 'ignore' || javaAction === 'unignore') {
168
+ const target = args.java_id || args.pid;
169
+ if (!target) return { ok: false, code: 'MISSING_ARGUMENT', message: `java_action=${javaAction} 需要 java_id 或 pid` };
170
+ const list = await service.javaProcesses(projectPath);
171
+ const found = (list.processes || []).find((item) => String(item.runId) === String(target) || String(item.pid) === String(target) || String(item.name) === String(target));
172
+ if (!found) return { ok: false, code: 'NOT_FOUND', message: `找不到服务 ${target}` };
173
+ return javaAction === 'ignore' ? service.ignoreJava(projectPath, found) : service.unignoreJava(projectPath, found);
174
+ }
175
+ if (javaAction === 'save') {
176
+ const target = args.java_id || args.pid;
177
+ if (!target) return { ok: false, code: 'MISSING_ARGUMENT', message: 'java_action=save 需要 java_id 或 pid' };
178
+ return service.saveJava(projectPath, { id: String(target), pid: Number(args.pid) || 0 });
179
+ }
180
+ if (javaAction === 'unsave') {
181
+ const target = args.java_id || args.pid;
182
+ if (!target) return { ok: false, code: 'MISSING_ARGUMENT', message: 'java_action=unsave 需要 java_id' };
183
+ return service.deleteSavedJava(projectPath, String(target));
184
+ }
185
+ if (javaAction === 'start_saved') {
186
+ const target = args.java_id;
187
+ if (!target) return { ok: false, code: 'MISSING_ARGUMENT', message: 'java_action=start_saved 需要 java_id(已保存的服务名)' };
188
+ return service.startSavedJava(projectPath, String(target), {
189
+ compile: args.compile !== false,
190
+ port: args.port ? Number(args.port) : 0,
191
+ debug: args.debug ? String(args.debug) : 'port',
192
+ timeoutMs,
193
+ });
194
+ }
195
+ // 一键启动:把「已保存」里的服务全拉起来(或只拉 java_ids 列的那几个)
196
+ if (javaAction === 'start_all') {
197
+ const ids = Array.isArray(args.java_ids)
198
+ ? args.java_ids.map((id) => String(id))
199
+ : (args.java_id ? [String(args.java_id)] : []);
200
+ return service.startAllSavedJava(projectPath, ids, {
201
+ compile: args.compile !== false,
202
+ restart: args.restart === true,
203
+ timeoutMs,
204
+ compileTimeoutMs: args.timeout_ms ? Number(args.timeout_ms) : undefined,
205
+ });
206
+ }
207
+ if (javaAction === 'settings') {
208
+ if (args.settings && typeof args.settings === 'object') return service.saveSettings(asObject(args.settings));
209
+ return service.settings();
210
+ }
211
+ return { ok: false, code: 'BAD_ACTION', message: `不支持的 java_action: ${javaAction}` };
212
+ }
213
+ case 'service': {
214
+ const serviceAction = String(args.service_action || 'status');
215
+ if (!args.service_id && serviceAction !== 'status') {
216
+ return { ok: false, code: 'MISSING_ARGUMENT', message: 'service 动作需要 service_id' };
217
+ }
218
+ if (serviceAction === 'status') return service.serviceStatus(projectPath, args.service_id ? String(args.service_id) : '');
219
+ return service.serviceAction(projectPath, String(args.service_id), serviceAction, { timeoutMs });
220
+ }
221
+ case 'log':
222
+ return service.readLog(projectPath, args.log_id ? String(args.log_id) : '', {
223
+ pattern: args.pattern ? String(args.pattern) : '',
224
+ ignoreCase: Boolean(args.ignore_case),
225
+ maxBytes: args.max_bytes ? Number(args.max_bytes) : undefined,
226
+ maxLines: args.max_lines ? Number(args.max_lines) : undefined,
227
+ reset: Boolean(args.reset),
228
+ });
229
+ case 'wait_log':
230
+ return service.waitLog(projectPath, args.log_id ? String(args.log_id) : '', {
231
+ pattern: args.pattern ? String(args.pattern) : '',
232
+ ignoreCase: Boolean(args.ignore_case),
233
+ timeoutMs: timeoutMs || 60_000,
234
+ maxBytes: args.max_bytes ? Number(args.max_bytes) : undefined,
235
+ maxLines: args.max_lines ? Number(args.max_lines) : undefined,
236
+ reset: Boolean(args.reset),
237
+ });
238
+ case 'reset_log':
239
+ return service.resetLog(projectPath, args.log_id ? String(args.log_id) : '');
240
+ case 'verify':
241
+ return service.verify(projectPath, {
242
+ service: args.service_id ? String(args.service_id) : '',
243
+ apiId: args.api_id ? String(args.api_id) : '',
244
+ logId: args.log_id ? String(args.log_id) : '',
245
+ params: asObject(args.params),
246
+ pattern: args.pattern ? String(args.pattern) : '',
247
+ restart: args.restart !== false,
248
+ maxLines: args.max_lines ? Number(args.max_lines) : undefined,
249
+ startupTimeoutMs: timeoutMs,
250
+ });
251
+ default:
252
+ return { ok: false, code: 'BAD_ACTION', message: `不支持的 action: ${action}` };
253
+ }
254
+ }
255
+
256
+ export function apply(ctx) {
257
+ const log = ctx.logger ?? { info: () => {}, error: () => {} };
258
+ const homeDir = process.env.DSH_HOME || path.join(process.env.USERPROFILE || process.cwd(), '.dsh');
259
+ // 插件 Config 可选提供 configPath(相对 DSH_HOME);无 Config 时用默认约定
260
+ const configPath = (() => {
261
+ try {
262
+ return String(ctx.config?.configPath || '');
263
+ } catch {
264
+ return '';
265
+ }
266
+ })();
267
+ const projectStore = new ProjectStore({ homeDir });
268
+ const settingsStore = new SettingsStore({ homeDir });
269
+ const javaRunner = new JavaRunner({ homeDir, settingsStore });
270
+ const service = new ApiRunnerService({ homeDir, configPath, projectStore, javaRunner, settingsStore });
271
+
272
+ // 项目路径权威解析:会话 header.cwd 优先,绝不信任模型自报
273
+ const projectOf = (sessionId) => {
274
+ try {
275
+ return sessionId !== '' ? ctx.sessions?.get?.(sessionId)?.header?.cwd ?? '' : '';
276
+ } catch {
277
+ return '';
278
+ }
279
+ };
280
+
281
+ // ---- HTTP API:同源前端/脚本可读配置、调接口、读日志 ----
282
+ ctx.effect?.(
283
+ () =>
284
+ ctx.webServer?.register({
285
+ kind: 'prefix',
286
+ path: '/dsh-api-runner/api',
287
+ handler: async (req, res) => {
288
+ // 0.2.0-rc.1 起插件前缀路由不经宿主鉴权,这里显式应用请求栅栏
289
+ const rejection = ctx.connection?.requestRejection?.(req);
290
+ if (rejection !== undefined) {
291
+ res.statusCode = rejection;
292
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
293
+ res.end(
294
+ JSON.stringify({
295
+ ok: false,
296
+ code: rejection === 401 ? 'UNAUTHORIZED' : 'FORBIDDEN',
297
+ error: rejection === 401 ? 'browser authentication required' : 'untrusted request',
298
+ }),
299
+ );
300
+ return;
301
+ }
302
+ try {
303
+ await handleHttp(req, res, service, projectOf);
304
+ } catch (error) {
305
+ if (!res.headersSent) res.statusCode = 500;
306
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
307
+ res.end(JSON.stringify({ ok: false, code: 'INTERNAL', message: String(error?.message || error) }));
308
+ }
309
+ },
310
+ }) ?? (() => {}),
311
+ 'api-runner-http',
312
+ );
313
+
314
+ // ---- 系统提示:把可用接口清单注入,模型不用先 describe 也知道能调什么 ----
315
+ ctx.effect?.(() => {
316
+ // systemPrompt 已列入 inject:cordis 的 ctx 是 Proxy,访问未 inject 的服务
317
+ // 会直接抛 `cannot get property "xxx" without inject`,所以不能用可选链兜底。
318
+ const prompt = ctx.systemPrompt;
319
+ if (!prompt?.section) return () => {};
320
+ // section 的 order 用较大值,排在通用规范之后
321
+ return (
322
+ prompt.section({
323
+ name: 'api-runner',
324
+ order: 900,
325
+ text: () => {
326
+ try {
327
+ const info = service.describe('');
328
+ const apis = info.apis.map((api) => `- ${api.id}(${api.method} ${api.url})${api.params.length ? ` 参数: ${api.params.join(', ')}` : ''}`).join('\n');
329
+ const services = info.services.map((item) => `- ${item.id}(${item.mode})${item.name}`).join('\n');
330
+ const logs = info.logs.map((item) => `- ${item.id} → ${item.path}`).join('\n');
331
+ if (!apis && !services && !logs) return '';
332
+ return [
333
+ '# 本地开发联调(dsh-api-runner)',
334
+ '可用工具 ApiRunner 已配置以下资源;改完后端代码后可用它重启服务、读日志、直接调接口验证。',
335
+ apis ? `\n接口:\n${apis}` : '',
336
+ services ? `\n服务:\n${services}` : '',
337
+ logs ? `\n日志:\n${logs}` : '',
338
+ ].join('\n');
339
+ } catch {
340
+ return '';
341
+ }
342
+ },
343
+ }) ?? (() => {})
344
+ );
345
+ }, 'api-runner-prompt');
346
+
347
+ // ---- 模型工具 ----
348
+ ctx.tools?.register({
349
+ name: 'ApiRunner',
350
+ timeoutMs: 120_000,
351
+ description: TOOL_DESCRIPTION,
352
+ parameters: PARAMETERS,
353
+ output: {
354
+ schema: {
355
+ type: 'object',
356
+ properties: {
357
+ text: { type: 'string', description: 'JSON 结果文本' },
358
+ isError: { type: 'boolean', description: 'true 表示操作失败' },
359
+ },
360
+ required: ['text'],
361
+ },
362
+ render: (_args, value) => [{ type: 'text', text: value.text }],
363
+ },
364
+ execute: async (args, exec) => {
365
+ const sid = exec.agent?.session?.id ?? '';
366
+ // 会话 cwd 优先;拿不到时退回「设置」里的默认工作区,避免落到 default 桶里
367
+ const cwd = projectOf(sid) || defaultWorkspace(service);
368
+ let payload;
369
+ try {
370
+ payload = await runTool(service, args ?? {}, cwd);
371
+ } catch (error) {
372
+ payload = { ok: false, code: 'UNEXPECTED', message: String(error?.message || error), stack: error?.stack };
373
+ }
374
+ return { text: toText(payload), isError: payload?.ok === false };
375
+ },
376
+ });
377
+
378
+ // ---- 卸载收尾:保存日志游标与服务状态,但不杀托管进程 ----
379
+ ctx.effect?.(() => () => service.dispose(), 'api-runner-dispose');
380
+
381
+ log.info?.('[api-runner] 插件已加载(ApiRunner 工具 + /dsh-api-runner/api)');
382
+ }
383
+
384
+ /**
385
+ * 面板传来的项目路径:优先 session 头解析出的 cwd;
386
+ * 面板可能直传 cwd(浏览器里没有会话上下文时),但只作为兜底,
387
+ * 且必须是绝对路径,避免被随意指向别处。
388
+ */
389
+ function pickCwdRaw(cwd, url, body) {
390
+ if (cwd) return cwd;
391
+ const hint = (body && typeof body.cwd === 'string' && body.cwd) || url.searchParams.get('cwd') || '';
392
+ if (hint === '') return '';
393
+ return path.isAbsolute(hint) ? path.resolve(hint) : '';
394
+ }
395
+
396
+ /**
397
+ * 面板「接口」页二级页签里显式选中的项目。
398
+ *
399
+ * 值是 ProjectStore 的 key(归一化后的绝对路径)。它必须优先于会话 cwd:
400
+ * 面板在某个会话里打开时,会话 cwd 永远存在,否则切到别的项目会被它盖掉,
401
+ * 页签点了没反应。只接受两种值——store 里真实存在的 key,或绝对路径。
402
+ */
403
+ function pickProject(service, url, body) {
404
+ const hint = (body && typeof body.project === 'string' && body.project) || url.searchParams.get('project') || '';
405
+ if (!hint) return '';
406
+ try {
407
+ const store = service.projectStore;
408
+ if (store && typeof store.allProjects === 'function' && store.allProjects().some((item) => item.key === hint)) {
409
+ return hint;
410
+ }
411
+ } catch {
412
+ /* store 读失败就退回绝对路径判断 */
413
+ }
414
+ return path.isAbsolute(hint) ? path.resolve(hint) : '';
415
+ }
416
+
417
+ /** 没有会话上下文时,用「设置」里配的默认工作区(只接受绝对路径) */
418
+ function defaultWorkspace(service) {
419
+ try {
420
+ const current = service.settings?.();
421
+ const value = current && current.ok !== false ? String(current.workspace || '') : '';
422
+ return value && path.isAbsolute(value) ? path.resolve(value) : '';
423
+ } catch {
424
+ return '';
425
+ }
426
+ }
427
+
428
+ /** HTTP 路由分发(前缀 /dsh-api-runner/api) */
429
+ async function handleHttp(req, res, service, projectOf) {
430
+ const url = new URL(req.url || '/', 'http://127.0.0.1');
431
+ const route = url.pathname.replace(/^\/dsh-api-runner\/api\/?/, '');
432
+ const sessionId = req.headers['x-dsh-session-id'] ? String(req.headers['x-dsh-session-id']) : '';
433
+ const cwd = projectOf(sessionId);
434
+ /**
435
+ * 项目目录优先级:显式选中的项目 > 会话 cwd > 请求里显式给的 cwd(必须是绝对路径)> 设置里的默认工作区。
436
+ * 面板切到别的项目时靠第一档生效——否则会话 cwd 永远压着它。
437
+ */
438
+ const workspace = defaultWorkspace(service);
439
+ const pickCwd = (base, urlObj, body) => pickProject(service, urlObj, body) || pickCwdRaw(base, urlObj, body) || workspace;
440
+ const send = (status, payload) => {
441
+ res.statusCode = status;
442
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
443
+ res.end(JSON.stringify(payload));
444
+ };
445
+ const readBody = async () => {
446
+ if (req.method === 'GET' || req.method === 'HEAD') return {};
447
+ const chunks = [];
448
+ for await (const chunk of req) chunks.push(chunk);
449
+ const text = Buffer.concat(chunks).toString('utf8');
450
+ if (text.trim() === '') return {};
451
+ try {
452
+ return JSON.parse(text);
453
+ } catch {
454
+ return {};
455
+ }
456
+ };
457
+
458
+ if (route === 'config' || route === '') return send(200, service.describe(cwd));
459
+
460
+ // ---- 侧边栏面板:配置读写 ----
461
+ if (route === 'ui' && req.method === 'GET') {
462
+ // sessionCwd 单独传:切了项目之后 pickCwd 已是目标项目,「当前工作区」要靠会话 cwd 推
463
+ return send(200, await service.uiState(pickCwd(cwd, url, {}), { sessionCwd: cwd || workspace }));
464
+ }
465
+ if (route === 'ui/api' && req.method === 'POST') {
466
+ const body = await readBody();
467
+ return send(200, service.saveApiUi(pickCwd(cwd, url, body), asObject(body.api)));
468
+ }
469
+ if (route === 'ui/api/delete' && req.method === 'POST') {
470
+ const body = await readBody();
471
+ return send(200, service.deleteApiUi(pickCwd(cwd, url, body), String(body.id || '')));
472
+ }
473
+ if (route === 'ui/base-url' && req.method === 'POST') {
474
+ const body = await readBody();
475
+ const target = pickCwd(cwd, url, body);
476
+ service.projectStore?.setBaseUrl(target, String(body.baseUrl || ''));
477
+ return send(200, { ok: true });
478
+ }
479
+ if (route === 'ui/token/clear' && req.method === 'POST') {
480
+ const body = await readBody();
481
+ return send(200, service.clearTokenUi(pickCwd(cwd, url, body), String(body.login_id || body.id || '')));
482
+ }
483
+
484
+ // ---- 登录并保存 token ----
485
+ if (route === 'login' && req.method === 'POST') {
486
+ const body = await readBody();
487
+ return send(200, await service.login(pickCwd(cwd, url, body), String(body.api_id || ''), asObject(body.params), {
488
+ timeoutMs: body.timeout_ms ? Number(body.timeout_ms) : undefined,
489
+ }));
490
+ }
491
+
492
+ // ---- 本机 Java 服务 ----
493
+ if (route === 'java' && req.method === 'GET') {
494
+ return send(200, await service.javaProcesses(pickCwd(cwd, url, {})));
495
+ }
496
+ if (route === 'java/bind' && req.method === 'POST') {
497
+ const body = await readBody();
498
+ return send(200, service.bindJava(pickCwd(cwd, url, body), asObject(body.binding ?? body)));
499
+ }
500
+ if (route === 'java/unbind' && req.method === 'POST') {
501
+ const body = await readBody();
502
+ return send(200, service.unbindJava(pickCwd(cwd, url, body), Number(body.pid || 0)));
503
+ }
504
+ // 接管启动:先编译再杀旧进程(耗时可能几十秒,故不受前端短轮询限制)
505
+ if (route === 'java/start' && req.method === 'POST') {
506
+ const body = await readBody();
507
+ return send(200, await service.startJava(pickCwd(cwd, url, body), {
508
+ pid: Number(body.pid || 0),
509
+ id: body.id ? String(body.id) : '',
510
+ port: body.port ? Number(body.port) : 0,
511
+ compile: body.compile !== false,
512
+ debug: body.debug ? String(body.debug) : 'port',
513
+ timeoutMs: body.timeout_ms ? Number(body.timeout_ms) : undefined,
514
+ }));
515
+ }
516
+ if (route === 'java/stop' && req.method === 'POST') {
517
+ const body = await readBody();
518
+ return send(200, await service.stopJava(pickCwd(cwd, url, body), String(body.id || body.run_id || body.pid || '')));
519
+ }
520
+ if (route === 'java/restart' && req.method === 'POST') {
521
+ const body = await readBody();
522
+ return send(200, await service.restartJava(pickCwd(cwd, url, body), String(body.id || body.run_id || body.pid || ''), {
523
+ compile: body.compile !== false,
524
+ timeoutMs: body.timeout_ms ? Number(body.timeout_ms) : undefined,
525
+ }));
526
+ }
527
+ if (route === 'java/log' && req.method === 'GET') {
528
+ return send(200, service.javaLogs(pickCwd(cwd, url, {}), String(url.searchParams.get('id') || url.searchParams.get('pid') || ''), {
529
+ fromOffset: url.searchParams.has('from') ? Number(url.searchParams.get('from')) : undefined,
530
+ maxBytes: url.searchParams.has('max_bytes') ? Number(url.searchParams.get('max_bytes')) : undefined,
531
+ }));
532
+ }
533
+ // 服务详情:进程信息 + 日志文件清单 + 首屏日志(IDEA 起的进程读它自己的 logback 文件)
534
+ if (route === 'java/detail' && req.method === 'GET') {
535
+ return send(200, await service.javaDetail(pickCwd(cwd, url, {}), String(url.searchParams.get('id') || url.searchParams.get('pid') || ''), {
536
+ fromOffset: url.searchParams.has('from') ? Number(url.searchParams.get('from')) : undefined,
537
+ maxBytes: url.searchParams.has('max_bytes') ? Number(url.searchParams.get('max_bytes')) : undefined,
538
+ lines: url.searchParams.has('lines') ? Number(url.searchParams.get('lines')) : undefined,
539
+ source: url.searchParams.get('source') || '',
540
+ }));
541
+ }
542
+ if (route === 'java/ignore' && req.method === 'POST') {
543
+ const body = await readBody();
544
+ return send(200, service.ignoreJava(pickCwd(cwd, url, body), asObject(body.item || body)));
545
+ }
546
+ if (route === 'java/unignore' && req.method === 'POST') {
547
+ const body = await readBody();
548
+ return send(200, service.unignoreJava(pickCwd(cwd, url, body), asObject(body.item || body)));
549
+ }
550
+ // 保存服务配方:之后不需要先在 IDEA 里跑一个进程也能启动
551
+ if (route === 'java/save' && req.method === 'POST') {
552
+ const body = await readBody();
553
+ return send(200, await service.saveJava(pickCwd(cwd, url, body), { id: String(body.id || body.pid || ''), pid: Number(body.pid) || 0 }));
554
+ }
555
+ if (route === 'java/unsave' && req.method === 'POST') {
556
+ const body = await readBody();
557
+ return send(200, service.deleteSavedJava(pickCwd(cwd, url, body), String(body.id || '')));
558
+ }
559
+ if (route === 'java/start-saved' && req.method === 'POST') {
560
+ const body = await readBody();
561
+ return send(200, await service.startSavedJava(pickCwd(cwd, url, body), String(body.id || ''), {
562
+ compile: body.compile !== false,
563
+ port: Number(body.port) || 0,
564
+ timeoutMs: body.timeout_ms ? Number(body.timeout_ms) : undefined,
565
+ }));
566
+ }
567
+ // 一键启动:后台跑,立刻返回批次 id;面板轮询 java/start-all/status 看进度
568
+ if (route === 'java/start-all' && req.method === 'POST') {
569
+ const body = await readBody();
570
+ return send(200, service.startSavedJavaBatch(pickCwd(cwd, url, body), Array.isArray(body.ids) ? body.ids : [], {
571
+ compile: body.compile !== false,
572
+ restart: body.restart === true,
573
+ timeoutMs: body.timeout_ms ? Number(body.timeout_ms) : undefined,
574
+ }));
575
+ }
576
+ if (route === 'java/start-all/status' && req.method === 'GET') {
577
+ return send(200, service.startBatchStatus(url.searchParams.get('batch') || ''));
578
+ }
579
+ if (route === 'settings' && req.method === 'GET') {
580
+ return send(200, service.settings());
581
+ }
582
+ if (route === 'settings' && req.method === 'POST') {
583
+ const body = await readBody();
584
+ return send(200, service.saveSettings(asObject(body.settings || body)));
585
+ }
586
+
587
+ if (route === 'call' && req.method === 'POST') {
588
+ const body = await readBody();
589
+ // 用 pickCwd:面板切到别的项目后,「测试」也要调那个项目的接口
590
+ return send(200, await service.call(pickCwd(cwd, url, body), String(body.api_id || ''), asObject(body.params), {}));
591
+ }
592
+
593
+ if (route === 'service' && req.method === 'POST') {
594
+ const body = await readBody();
595
+ const action = String(body.action || 'status');
596
+ const result = action === 'status'
597
+ ? await service.serviceStatus(cwd, body.service_id ? String(body.service_id) : '')
598
+ : await service.serviceAction(cwd, String(body.service_id || ''), action, { timeoutMs: body.timeout_ms });
599
+ return send(200, result);
600
+ }
601
+
602
+ if (route === 'log' && req.method === 'GET') {
603
+ const query = Object.fromEntries(url.searchParams.entries());
604
+ return send(200, service.readLog(cwd, query.log_id || '', {
605
+ pattern: query.pattern || '',
606
+ maxLines: query.max_lines ? Number(query.max_lines) : undefined,
607
+ maxBytes: query.max_bytes ? Number(query.max_bytes) : undefined,
608
+ reset: query.reset === 'true' || query.reset === '1',
609
+ }));
610
+ }
611
+
612
+ return send(404, { ok: false, code: 'NOT_FOUND', message: `未知路由 ${route}` });
613
+ }
614
+
615
+ // 注意:不要提供 default export(原因见文件头注释)。