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/LICENSE +21 -0
- package/README.md +435 -2
- package/client/client.js +1824 -0
- package/cordis.patch.yml +9 -0
- package/docs/screenshot-service.png +0 -0
- package/docs/screenshot-settings.png +0 -0
- package/docs/verified-dsh-version.png +0 -0
- package/dsh-api-runner.example.yml +92 -0
- package/package.json +71 -6
- package/scripts/stage.mjs +78 -0
- package/src/api.js +194 -0
- package/src/config.js +265 -0
- package/src/java-runner.js +623 -0
- package/src/java.js +658 -0
- package/src/log.js +209 -0
- package/src/main.js +615 -0
- package/src/ops.js +1257 -0
- package/src/project-store.js +334 -0
- package/src/restart.js +549 -0
- package/src/service.js +329 -0
- package/src/settings.js +109 -0
- package/src/util.js +182 -0
- package/test/client.mjs +162 -0
- package/test/java.mjs +298 -0
- package/test/managed.mjs +137 -0
- package/test/plugin.mjs +467 -0
- package/test/smoke.mjs +528 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 由插件托管启动的 Java 服务(接管 IDEA 起的进程)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这一层:
|
|
5
|
+
* IDEA 没有对外提供「重启某个 run configuration」的接口,所以插件能做的只有
|
|
6
|
+
* 「按进程快照原样再起一个」。一旦这么做,进程就不归 IDEA 管了,必须自己记账:
|
|
7
|
+
* 谁起的、pid 多少、端口多少、日志写到哪、原来的 argv 是什么——
|
|
8
|
+
* 否则面板既分不清「哪些是 IDEA 起的、哪些是我起的」,也没法只停自己起的那个。
|
|
9
|
+
*
|
|
10
|
+
* 记录存在 ${DSH_HOME}/api-runner-java.json,按项目分桶,每条 run 以服务名(id)为键,
|
|
11
|
+
* 重启前后是同一个 id,面板上看起来就是「这个服务」而不是一堆历史进程。
|
|
12
|
+
*
|
|
13
|
+
* 日志:每个服务一个固定文件 ${DSH_HOME}/java-logs/<id>.log,追加写;
|
|
14
|
+
* 面板的「详情」直接 tail 这个文件,内容与 IDEA 控制台同源(都是 stdout/stderr)。
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import {
|
|
19
|
+
buildRestartArgv,
|
|
20
|
+
compileModule,
|
|
21
|
+
getJavaSnapshot,
|
|
22
|
+
inferMavenModule,
|
|
23
|
+
isAlive,
|
|
24
|
+
killProcessTree,
|
|
25
|
+
resolveMaven,
|
|
26
|
+
spawnService,
|
|
27
|
+
waitPort,
|
|
28
|
+
waitPortFree,
|
|
29
|
+
} from './restart.js';
|
|
30
|
+
import { normalizeProjectKey } from './util.js';
|
|
31
|
+
import { readFileChunk, readFileTail } from './java.js';
|
|
32
|
+
|
|
33
|
+
const EMPTY = () => ({ version: 1, runs: {} });
|
|
34
|
+
|
|
35
|
+
/** 服务名:取主类最后一段去掉 Application 后缀,作为稳定 id */
|
|
36
|
+
export function serviceIdOf(mainClass) {
|
|
37
|
+
const last = String(mainClass || '').split('.').pop() || 'service';
|
|
38
|
+
return (last.replace(/Application$/, '') || last).replace(/[^\w.-]+/g, '_');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class JavaRunner {
|
|
42
|
+
constructor(options = {}) {
|
|
43
|
+
this.homeDir = options.homeDir || process.env.DSH_HOME || path.join(process.cwd(), '.dsh');
|
|
44
|
+
/** 可选:插件级设置(数据目录可改到别的盘) */
|
|
45
|
+
this.settings = options.settingsStore || null;
|
|
46
|
+
this.setDataDir(options.dataDir || this._preferredDataDir());
|
|
47
|
+
this._doc = undefined;
|
|
48
|
+
this._mtimeMs = -1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_preferredDataDir() {
|
|
52
|
+
try {
|
|
53
|
+
return this.settings ? this.settings.dataDir() : this.homeDir;
|
|
54
|
+
} catch {
|
|
55
|
+
return this.homeDir;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 设置改了数据目录后调用:重新指向新目录(老目录里的记录不迁移,只换读写位置) */
|
|
60
|
+
refreshDataDir() {
|
|
61
|
+
const next = this._preferredDataDir();
|
|
62
|
+
if (next !== this.dataDir) this.setDataDir(next);
|
|
63
|
+
return this.dataDir;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 运行数据目录:状态文件与日志都跟着它走。
|
|
68
|
+
* 用户可以在面板「设置」里改成别的盘,不必都堆在默认的 DSH 目录下。
|
|
69
|
+
*/
|
|
70
|
+
setDataDir(dir) {
|
|
71
|
+
this.dataDir = dir || this.homeDir;
|
|
72
|
+
this.file = path.join(this.dataDir, 'api-runner-java.json');
|
|
73
|
+
this.logDir = path.join(this.dataDir, 'java-logs');
|
|
74
|
+
this._doc = undefined;
|
|
75
|
+
this._mtimeMs = -1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
keyOf(cwd) {
|
|
79
|
+
return normalizeProjectKey(cwd) || 'default';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 编译用的 mvn:设置里显式配的 Maven 主路径优先,
|
|
84
|
+
* 否则交给 resolveMaven 自动探测(MAVEN_HOME → PATH → IDEA 自带)。
|
|
85
|
+
*/
|
|
86
|
+
configuredMaven(explicit = '') {
|
|
87
|
+
if (explicit) return explicit;
|
|
88
|
+
try {
|
|
89
|
+
const home = this.settings ? String(this.settings.read().mavenHome || '') : '';
|
|
90
|
+
if (home) {
|
|
91
|
+
const candidate = path.join(home, 'bin', process.platform === 'win32' ? 'mvn.cmd' : 'mvn');
|
|
92
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
/* 设置读不到就走自动探测 */
|
|
96
|
+
}
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_load() {
|
|
101
|
+
let stat;
|
|
102
|
+
try {
|
|
103
|
+
stat = fs.statSync(this.file);
|
|
104
|
+
} catch {
|
|
105
|
+
this._doc = EMPTY();
|
|
106
|
+
this._mtimeMs = -1;
|
|
107
|
+
return this._doc;
|
|
108
|
+
}
|
|
109
|
+
if (this._doc !== undefined && stat.mtimeMs === this._mtimeMs) return this._doc;
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
112
|
+
this._doc = parsed && typeof parsed === 'object' && parsed.runs && typeof parsed.runs === 'object' ? parsed : EMPTY();
|
|
113
|
+
} catch {
|
|
114
|
+
this._doc = EMPTY();
|
|
115
|
+
}
|
|
116
|
+
this._mtimeMs = stat.mtimeMs;
|
|
117
|
+
return this._doc;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_save(doc) {
|
|
121
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
122
|
+
const tmp = `${this.file}.tmp`;
|
|
123
|
+
fs.writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8');
|
|
124
|
+
fs.renameSync(tmp, this.file);
|
|
125
|
+
this._doc = doc;
|
|
126
|
+
this._mtimeMs = fs.statSync(this.file).mtimeMs;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
_slot(cwd, id) {
|
|
130
|
+
return `${this.keyOf(cwd)}|${id}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 某个项目的所有托管记录(含 alive 状态,已退出的也保留,便于看历史日志/再次启动) */
|
|
134
|
+
runs(cwd) {
|
|
135
|
+
const doc = this._load();
|
|
136
|
+
const key = this.keyOf(cwd);
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const [slot, run] of Object.entries(doc.runs)) {
|
|
139
|
+
if (!slot.startsWith(`${key}|`)) continue;
|
|
140
|
+
out.push({ ...run, alive: isAlive(Number(run.pid)) });
|
|
141
|
+
}
|
|
142
|
+
out.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
runById(cwd, id) {
|
|
147
|
+
const doc = this._load();
|
|
148
|
+
return doc.runs[this._slot(cwd, String(id))] || undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
_put(cwd, run) {
|
|
152
|
+
const doc = this._load();
|
|
153
|
+
doc.runs[this._slot(cwd, run.id)] = run;
|
|
154
|
+
this._save(doc);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
_drop(cwd, id) {
|
|
158
|
+
const doc = this._load();
|
|
159
|
+
delete doc.runs[this._slot(cwd, id)];
|
|
160
|
+
this._save(doc);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 每次启动单独一个日志文件:`<dataDir>/java-logs/<id>/<启动时间>.log`。
|
|
165
|
+
*
|
|
166
|
+
* 以前是一个服务一个文件追加写,结果重启几次以后同一次启动的日志被
|
|
167
|
+
* 后面的启动淹没,翻起来很难受(用户明确反馈过)。分文件之后:
|
|
168
|
+
* - 面板详情默认打开最新那一段,与 IDEA 控制台「本次运行」对齐;
|
|
169
|
+
* - 想对比上次启动的报错,切到上一段就行。
|
|
170
|
+
*/
|
|
171
|
+
logPathOf(id, at = new Date()) {
|
|
172
|
+
const safeId = String(id).replace(/[^\w.-]+/g, '_');
|
|
173
|
+
const stamp = [
|
|
174
|
+
at.getFullYear(),
|
|
175
|
+
String(at.getMonth() + 1).padStart(2, '0'),
|
|
176
|
+
String(at.getDate()).padStart(2, '0'),
|
|
177
|
+
'-',
|
|
178
|
+
String(at.getHours()).padStart(2, '0'),
|
|
179
|
+
String(at.getMinutes()).padStart(2, '0'),
|
|
180
|
+
String(at.getSeconds()).padStart(2, '0'),
|
|
181
|
+
].join('');
|
|
182
|
+
return path.join(this.logDir, safeId, `${stamp}.log`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 某个服务历史上所有的启动日志段,最新的在前 */
|
|
186
|
+
logSegmentsOf(id) {
|
|
187
|
+
const safeId = String(id).replace(/[^\w.-]+/g, '_');
|
|
188
|
+
const dir = path.join(this.logDir, safeId);
|
|
189
|
+
let names;
|
|
190
|
+
try {
|
|
191
|
+
names = fs.readdirSync(dir);
|
|
192
|
+
} catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
const out = [];
|
|
196
|
+
for (const name of names) {
|
|
197
|
+
if (!/\.log$/i.test(name)) continue;
|
|
198
|
+
const file = path.join(dir, name);
|
|
199
|
+
try {
|
|
200
|
+
const stat = fs.statSync(file);
|
|
201
|
+
if (!stat.isFile()) continue;
|
|
202
|
+
out.push({ name, path: file, size: stat.size, at: stat.mtimeMs, updatedAt: stat.mtime.toISOString() });
|
|
203
|
+
} catch {
|
|
204
|
+
// 正在轮转/被删,跳过
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
out.sort((a, b) => b.at - a.at);
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------- 启动
|
|
212
|
+
/**
|
|
213
|
+
* 接管启动:按 pid 的进程快照,先编译,再杀掉原进程,最后由插件拉起新进程。
|
|
214
|
+
* 顺序是刻意的:编译失败时绝不动原进程,避免「代码没编过、服务也停了」。
|
|
215
|
+
*
|
|
216
|
+
* @param {{pid:number, id?:string, port?:number, compile?:boolean, debug?:'port'|'none'|'keep',
|
|
217
|
+
* mvn?:string, timeoutMs?:number}} options
|
|
218
|
+
*/
|
|
219
|
+
async start(cwd, options = {}) {
|
|
220
|
+
const sourcePid = Number(options.pid);
|
|
221
|
+
if (!Number.isInteger(sourcePid) || sourcePid <= 0) {
|
|
222
|
+
return { ok: false, code: 'MISSING_PID', message: '需要提供要接管的 Java 进程 pid(面板里「关闭并接管」会自动带上)' };
|
|
223
|
+
}
|
|
224
|
+
const snapshot = await getJavaSnapshot(sourcePid);
|
|
225
|
+
if (!snapshot.ok) return { ok: false, code: 'SNAPSHOT_FAILED', message: snapshot.message };
|
|
226
|
+
|
|
227
|
+
const id = String(options.id || serviceIdOf(snapshot.mainClass));
|
|
228
|
+
const module = inferMavenModule(snapshot.classPath);
|
|
229
|
+
const port = Number(options.port) || Number(snapshot.systemProperties?.['server.port']) || 0;
|
|
230
|
+
const logPath = this.logPathOf(id);
|
|
231
|
+
// 1) 编译(默认开启):改完代码直接接管,才叫「写完代码就能重启」
|
|
232
|
+
let compile;
|
|
233
|
+
if (options.compile === false) {
|
|
234
|
+
compile = { ok: true, skipped: true };
|
|
235
|
+
} else if (!module) {
|
|
236
|
+
compile = { ok: true, skipped: true, note: 'classpath 里没有 target/classes,无法推断 Maven 模块,跳过编译' };
|
|
237
|
+
} else {
|
|
238
|
+
const mvn = await resolveMaven(this.configuredMaven(String(options.mvn || '')));
|
|
239
|
+
compile = await compileModule({
|
|
240
|
+
mvn,
|
|
241
|
+
rootDir: module.rootDir,
|
|
242
|
+
artifactId: module.artifactId,
|
|
243
|
+
selector: module.selector,
|
|
244
|
+
javaHome: snapshot.javaHome,
|
|
245
|
+
timeoutMs: Number(options.compileTimeoutMs) || 300_000,
|
|
246
|
+
});
|
|
247
|
+
compile.module = module;
|
|
248
|
+
if (!compile.ok) {
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
code: compile.code || 'COMPILE_FAILED',
|
|
252
|
+
message: `编译失败,未动原进程:${compile.message}`,
|
|
253
|
+
compile,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 2) 结束原进程并等端口释放(端口没放就开始拉新进程,会误判「新进程已就绪」)
|
|
259
|
+
const killed = await killProcessTree(sourcePid);
|
|
260
|
+
if (!killed.ok) return { ok: false, code: 'KILL_FAILED', message: killed.message, compile };
|
|
261
|
+
const freed = port > 0 ? await waitPortFree('127.0.0.1', port, { timeoutMs: 20_000 }) : { ok: true, waitedMs: 0 };
|
|
262
|
+
|
|
263
|
+
// 3) 用快照参数拉起新进程,打上来源标记,便于下次扫描区分是谁起的
|
|
264
|
+
const built = buildRestartArgv(snapshot, {
|
|
265
|
+
debug: options.debug || 'port',
|
|
266
|
+
debugPort: Number(options.debugPort) || 0,
|
|
267
|
+
extraJvmArgs: [`-DdshApiRunner.runId=${id}`],
|
|
268
|
+
});
|
|
269
|
+
this._ensureLogHeader(logPath, `接管 pid ${sourcePid}(${id})`);
|
|
270
|
+
const spawned = await spawnService({ javaExe: snapshot.javaExe, argv: built.argv, cwd: snapshot.cwd, logPath });
|
|
271
|
+
if (!spawned.ok) return { ok: false, code: 'SPAWN_FAILED', message: spawned.message, compile };
|
|
272
|
+
|
|
273
|
+
const record = {
|
|
274
|
+
id,
|
|
275
|
+
name: id,
|
|
276
|
+
pid: spawned.pid,
|
|
277
|
+
port,
|
|
278
|
+
debugPort: built.debugPort || 0,
|
|
279
|
+
mainClass: snapshot.mainClass,
|
|
280
|
+
programArgs: snapshot.programArgs,
|
|
281
|
+
jvmArgs: snapshot.jvmArgs,
|
|
282
|
+
argv: built.argv,
|
|
283
|
+
javaExe: snapshot.javaExe,
|
|
284
|
+
javaHome: snapshot.javaHome,
|
|
285
|
+
cwd: snapshot.cwd,
|
|
286
|
+
logPath,
|
|
287
|
+
sourcePid,
|
|
288
|
+
projectKey: this.keyOf(cwd),
|
|
289
|
+
module: module || null,
|
|
290
|
+
startedAt: new Date().toISOString(),
|
|
291
|
+
lastError: '',
|
|
292
|
+
};
|
|
293
|
+
this._put(cwd, record);
|
|
294
|
+
|
|
295
|
+
// 4) 等端口就绪;起来了才算成功,起不来把日志尾巴带回去
|
|
296
|
+
const health = await this._awaitReady(record, Number(options.timeoutMs) || 120_000, cwd);
|
|
297
|
+
return {
|
|
298
|
+
ok: health.ok,
|
|
299
|
+
code: health.ok ? undefined : 'START_TIMEOUT',
|
|
300
|
+
id,
|
|
301
|
+
pid: spawned.pid,
|
|
302
|
+
port: record.port || port,
|
|
303
|
+
ports: record.ports || [],
|
|
304
|
+
logPath,
|
|
305
|
+
health,
|
|
306
|
+
compile,
|
|
307
|
+
portFreed: freed,
|
|
308
|
+
note: health.ok
|
|
309
|
+
? `已由插件启动 ${id}(pid ${spawned.pid}${record.port || port ? `,端口 ${record.port || port}` : ''}),日志:${logPath}`
|
|
310
|
+
: `进程已拉起(pid ${spawned.pid})但 ${health.waitedMs}ms 内端口没就绪,看日志:${logPath}`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
_ensureLogHeader(logPath, reason) {
|
|
315
|
+
try {
|
|
316
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
317
|
+
fs.appendFileSync(logPath, `\n==== ${new Date().toISOString()} ${reason} ====\n`, 'utf8');
|
|
318
|
+
} catch {
|
|
319
|
+
/* 目录建不了就让 spawn 去报错 */
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 等端口就绪。
|
|
325
|
+
* 配了端口就等 TCP 监听;没配(端口常写在配置中心或外部配置文件里,
|
|
326
|
+
* 进程参数里读不到)就只确认进程活着,同时补探一次真实监听端口,
|
|
327
|
+
* 否则接管后 run 记录里 ports/httpPort 是空的,面板上这个服务看起来像没起来。
|
|
328
|
+
*/
|
|
329
|
+
async _awaitReady(record, timeoutMs, cwd) {
|
|
330
|
+
const port = Number(record.port) || 0;
|
|
331
|
+
if (port > 0) {
|
|
332
|
+
const waited = await waitPort('127.0.0.1', port, { timeoutMs });
|
|
333
|
+
return { ok: waited.ok, waitedMs: waited.waitedMs, checks: [{ kind: 'tcp', ok: waited.ok, detail: `127.0.0.1:${port}` }] };
|
|
334
|
+
}
|
|
335
|
+
const startedAt = Date.now();
|
|
336
|
+
while (Date.now() - startedAt < Math.min(timeoutMs, 15_000)) {
|
|
337
|
+
if (!isAlive(Number(record.pid))) break;
|
|
338
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
339
|
+
}
|
|
340
|
+
const alive = isAlive(Number(record.pid));
|
|
341
|
+
// 启动成功就先把回填挂到后台:Spring 业务端口通常在进程起来后十几秒才监听,
|
|
342
|
+
// 此刻同步探一次多半只有 debug 端口,不能就此认定「没有端口」。
|
|
343
|
+
if (alive) {
|
|
344
|
+
await this._backfillPorts(record, cwd);
|
|
345
|
+
this._scheduleBackfill(record.pid, cwd);
|
|
346
|
+
}
|
|
347
|
+
return { ok: alive, waitedMs: Date.now() - startedAt, checks: [{ kind: 'process', ok: alive, detail: `pid ${record.pid}` }] };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 启动后在后台继续补探端口,直到探到 HTTP 端口或超时。
|
|
352
|
+
* 面板每 5 秒拉一次 java 列表,回填写进托管记录后下一次拉取就能看到端口。
|
|
353
|
+
* 不阻塞 restart/start 的返回(编译+启动已经够慢了)。
|
|
354
|
+
*/
|
|
355
|
+
_scheduleBackfill(pid, cwd, options = {}) {
|
|
356
|
+
const target = Number(pid);
|
|
357
|
+
if (!Number.isInteger(target) || target <= 0) return;
|
|
358
|
+
const deadline = Date.now() + (Number(options.timeoutMs) || 90_000);
|
|
359
|
+
const tick = async () => {
|
|
360
|
+
if (Date.now() > deadline || !isAlive(target)) return;
|
|
361
|
+
const record = Object.values(this._load().runs).find((run) => Number(run.pid) === target);
|
|
362
|
+
if (!record) return;
|
|
363
|
+
if (Number(record.httpPort) > 0) return;
|
|
364
|
+
await this._backfillPorts(record, cwd);
|
|
365
|
+
const fresh = Object.values(this._load().runs).find((run) => Number(run.pid) === target);
|
|
366
|
+
if (fresh && Number(fresh.httpPort) > 0) return;
|
|
367
|
+
const timer = setTimeout(tick, 3_000);
|
|
368
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
369
|
+
};
|
|
370
|
+
const first = setTimeout(tick, 8_000);
|
|
371
|
+
if (typeof first.unref === 'function') first.unref();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 从真实监听端口里认出业务端口,写回 run 记录。
|
|
376
|
+
* 复用 java.js 的扫描逻辑(netstat/lsof → probeHttp 判哪个讲 HTTP),
|
|
377
|
+
* 只补 record 上缺的字段,失败就当没探到——不影响「启动成功」这个结论。
|
|
378
|
+
*
|
|
379
|
+
* 注意:桶要用**项目 cwd**(调用方传进来),不能用 record.cwd ——
|
|
380
|
+
* record.cwd 是进程的工作目录,可能只是项目里的某个子模块,与项目根不同,
|
|
381
|
+
* 拿它算 projectKey 会把回填写进另一个桶,面板上依然看不到端口。
|
|
382
|
+
*/
|
|
383
|
+
async _backfillPorts(record, cwd) {
|
|
384
|
+
try {
|
|
385
|
+
const { listJavaProcesses } = await import('./java.js');
|
|
386
|
+
const all = await listJavaProcesses();
|
|
387
|
+
const item = (all.processes || []).find((p) => Number(p.pid) === Number(record.pid));
|
|
388
|
+
if (!item) return;
|
|
389
|
+
const httpPort = Number(item.httpPort) || 0;
|
|
390
|
+
const ports = Array.isArray(item.ports) ? item.ports : [];
|
|
391
|
+
if (!httpPort && ports.length === 0) return;
|
|
392
|
+
const next = {
|
|
393
|
+
...record,
|
|
394
|
+
port: httpPort || Number(record.port) || 0,
|
|
395
|
+
ports,
|
|
396
|
+
httpPort,
|
|
397
|
+
primaryPort: Number(item.primaryPort) || httpPort || 0,
|
|
398
|
+
sentinelPort: Number(item.sentinelPort) || 0,
|
|
399
|
+
};
|
|
400
|
+
this._put(cwd || record.projectKey || '', next);
|
|
401
|
+
// 调用方拿的是同一个对象引用,回填后返回体里也能看到端口
|
|
402
|
+
Object.assign(record, next);
|
|
403
|
+
} catch {
|
|
404
|
+
/* 探测失败不影响启动结论 */
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------- 停止 / 重启
|
|
409
|
+
/** 只允许停插件自己起的进程 */
|
|
410
|
+
async stop(cwd, idOrPid) {
|
|
411
|
+
const key = String(idOrPid || '');
|
|
412
|
+
const doc = this._load();
|
|
413
|
+
const slotKey = doc.runs[this._slot(cwd, key)] ? this._slot(cwd, key) : undefined;
|
|
414
|
+
const entry = slotKey
|
|
415
|
+
? doc.runs[slotKey]
|
|
416
|
+
: Object.entries(doc.runs).find(([slot, run]) => slot.startsWith(`${this.keyOf(cwd)}|`) && String(run.pid) === key)?.[1];
|
|
417
|
+
if (!entry) return { ok: false, code: 'RUN_NOT_FOUND', message: `没有由插件启动的 ${key};IDEA 起的进程请在 IDEA 里停止(或先接管)` };
|
|
418
|
+
|
|
419
|
+
const killed = await killProcessTree(Number(entry.pid));
|
|
420
|
+
if (killed.ok) {
|
|
421
|
+
// 保留记录(alive 会变 false),面板上还能看历史日志、再次启动
|
|
422
|
+
this._put(cwd, { ...entry, stoppedAt: new Date().toISOString() });
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
ok: killed.ok,
|
|
426
|
+
code: killed.ok ? undefined : 'KILL_FAILED',
|
|
427
|
+
id: entry.id,
|
|
428
|
+
pid: entry.pid,
|
|
429
|
+
message: killed.ok ? `已停止 ${entry.id}(pid ${entry.pid})` : killed.message,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** 重启插件自己起的进程:用记录里的 argv 原样再起(重新编译一遍) */
|
|
434
|
+
async restart(cwd, idOrPid, options = {}) {
|
|
435
|
+
const key = String(idOrPid || '');
|
|
436
|
+
const entry = this.runById(cwd, key)
|
|
437
|
+
|| Object.values(this._load().runs).find((run) => String(run.pid) === key && this.keyOf(cwd) === run.projectKey);
|
|
438
|
+
if (!entry) return { ok: false, code: 'RUN_NOT_FOUND', message: `没有由插件启动的 ${key}` };
|
|
439
|
+
|
|
440
|
+
let compile = { ok: true, skipped: true };
|
|
441
|
+
if (options.compile !== false && entry.module) {
|
|
442
|
+
const mvn = await resolveMaven(this.configuredMaven(String(options.mvn || '')));
|
|
443
|
+
compile = await compileModule({
|
|
444
|
+
mvn,
|
|
445
|
+
rootDir: entry.module.rootDir,
|
|
446
|
+
artifactId: entry.module.artifactId,
|
|
447
|
+
selector: entry.module.selector,
|
|
448
|
+
javaHome: entry.javaHome,
|
|
449
|
+
timeoutMs: Number(options.compileTimeoutMs) || 300_000,
|
|
450
|
+
});
|
|
451
|
+
if (!compile.ok) return { ok: false, code: compile.code || 'COMPILE_FAILED', message: `编译失败,未重启:${compile.message}`, compile };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (isAlive(Number(entry.pid))) {
|
|
455
|
+
const killed = await killProcessTree(Number(entry.pid));
|
|
456
|
+
if (!killed.ok) return { ok: false, code: 'KILL_FAILED', message: killed.message, compile };
|
|
457
|
+
if (entry.port) await waitPortFree('127.0.0.1', Number(entry.port), { timeoutMs: 20_000 });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// 每次重启开一个新日志段,历史启动记录不被覆盖
|
|
461
|
+
const logPath = this.logPathOf(entry.id);
|
|
462
|
+
const spawned = await spawnService({ javaExe: entry.javaExe, argv: entry.argv, cwd: entry.cwd, logPath });
|
|
463
|
+
if (!spawned.ok) return { ok: false, code: 'SPAWN_FAILED', message: spawned.message, compile };
|
|
464
|
+
|
|
465
|
+
const record = { ...entry, pid: spawned.pid, logPath, argv: entry.argv, startedAt: new Date().toISOString(), stoppedAt: '' };
|
|
466
|
+
this._put(cwd, record);
|
|
467
|
+
const health = await this._awaitReady(record, Number(options.timeoutMs) || 120_000, cwd);
|
|
468
|
+
return { ok: health.ok, code: health.ok ? undefined : 'START_TIMEOUT', id: entry.id, pid: spawned.pid, port: record.port || 0, ports: record.ports || [], health, compile, logPath };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ---------------------------------------------------------------- 从保存的配置启动
|
|
472
|
+
/**
|
|
473
|
+
* 不依赖 IDEA 进程启动:用面板里保存的服务定义(主类 / classpath / JVM 参数)直接拉起。
|
|
474
|
+
*
|
|
475
|
+
* 场景:用户不想每次都先在 IDEA 里点 Run 才能接管,于是把一次接管的快照存下来(「保存服务」),
|
|
476
|
+
* 以后插件可以自己起。要求保存时 classpath 完整(从 getJavaSnapshot 拿到的那份)。
|
|
477
|
+
*/
|
|
478
|
+
async startSaved(cwd, saved, options = {}) {
|
|
479
|
+
const id = String(saved.id || serviceIdOf(saved.mainClass));
|
|
480
|
+
if (!saved.mainClass || !saved.classPath) {
|
|
481
|
+
return { ok: false, code: 'SAVED_INCOMPLETE', message: `保存的 ${id} 缺少主类或 classpath,无法独立启动(请重新接管一次并保存)` };
|
|
482
|
+
}
|
|
483
|
+
// 老配方里存的 module 可能来自旧版本推断(reactor 根取错、没有 selector),
|
|
484
|
+
// 所以优先用 classpath 重新推断——classpath 一直在,推断逻辑是当前版本的。
|
|
485
|
+
// 推断不出来(例如 classpath 里没有 target/classes)才退回配方里存的。
|
|
486
|
+
const module = inferMavenModule(saved.classPath)
|
|
487
|
+
|| (saved.module && saved.module.artifactId
|
|
488
|
+
? { rootDir: saved.module.rootDir, artifactId: saved.module.artifactId, selector: saved.module.selector }
|
|
489
|
+
: undefined);
|
|
490
|
+
const port = Number(options.port) || Number(saved.port) || 0;
|
|
491
|
+
|
|
492
|
+
let compile = { ok: true, skipped: true };
|
|
493
|
+
if (options.compile !== false && module) {
|
|
494
|
+
const mvn = await resolveMaven(this.configuredMaven(String(options.mvn || '')));
|
|
495
|
+
compile = await compileModule({
|
|
496
|
+
mvn,
|
|
497
|
+
rootDir: module.rootDir,
|
|
498
|
+
artifactId: module.artifactId,
|
|
499
|
+
selector: module.selector,
|
|
500
|
+
javaHome: saved.javaHome,
|
|
501
|
+
timeoutMs: Number(options.compileTimeoutMs) || 300_000,
|
|
502
|
+
});
|
|
503
|
+
compile.module = module;
|
|
504
|
+
if (!compile.ok) return { ok: false, code: compile.code || 'COMPILE_FAILED', message: `编译失败,未启动:${compile.message}`, compile };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// 端口被占(比如 IDEA 里那个还开着)就先说清楚,别拉起来又撞端口
|
|
508
|
+
if (port > 0) {
|
|
509
|
+
const busy = await waitPort('127.0.0.1', port, { timeoutMs: 300 });
|
|
510
|
+
if (busy.ok) {
|
|
511
|
+
return {
|
|
512
|
+
ok: false,
|
|
513
|
+
code: 'PORT_IN_USE',
|
|
514
|
+
message: `端口 ${port} 已被占用(可能 IDEA 里的 ${id} 还在跑)。先在 IDEA 里停掉,或直接对它点「接管启动」。`,
|
|
515
|
+
compile,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const built = buildRestartArgv({
|
|
521
|
+
javaExe: saved.javaExe,
|
|
522
|
+
classPath: saved.classPath,
|
|
523
|
+
mainClass: saved.mainClass,
|
|
524
|
+
jvmArgs: saved.jvmArgs,
|
|
525
|
+
programArgs: saved.programArgs,
|
|
526
|
+
}, {
|
|
527
|
+
debug: options.debug || 'port',
|
|
528
|
+
debugPort: Number(options.debugPort) || 0,
|
|
529
|
+
extraJvmArgs: [`-DdshApiRunner.runId=${id}`],
|
|
530
|
+
});
|
|
531
|
+
const logPath = this.logPathOf(id);
|
|
532
|
+
this._ensureLogHeader(logPath, `按保存的配置启动 ${id}`);
|
|
533
|
+
const spawned = await spawnService({ javaExe: saved.javaExe, argv: built.argv, cwd: saved.cwd, logPath });
|
|
534
|
+
if (!spawned.ok) return { ok: false, code: 'SPAWN_FAILED', message: spawned.message, compile };
|
|
535
|
+
|
|
536
|
+
const record = {
|
|
537
|
+
id,
|
|
538
|
+
name: id,
|
|
539
|
+
pid: spawned.pid,
|
|
540
|
+
port,
|
|
541
|
+
debugPort: built.debugPort || 0,
|
|
542
|
+
mainClass: saved.mainClass,
|
|
543
|
+
programArgs: saved.programArgs || [],
|
|
544
|
+
jvmArgs: saved.jvmArgs || [],
|
|
545
|
+
argv: built.argv,
|
|
546
|
+
javaExe: saved.javaExe,
|
|
547
|
+
javaHome: saved.javaHome || '',
|
|
548
|
+
cwd: saved.cwd,
|
|
549
|
+
logPath,
|
|
550
|
+
sourcePid: 0,
|
|
551
|
+
savedStart: true,
|
|
552
|
+
projectKey: this.keyOf(cwd),
|
|
553
|
+
module: module || null,
|
|
554
|
+
startedAt: new Date().toISOString(),
|
|
555
|
+
lastError: '',
|
|
556
|
+
};
|
|
557
|
+
this._put(cwd, record);
|
|
558
|
+
const health = await this._awaitReady(record, Number(options.timeoutMs) || 120_000, cwd);
|
|
559
|
+
return {
|
|
560
|
+
ok: health.ok,
|
|
561
|
+
code: health.ok ? undefined : 'START_TIMEOUT',
|
|
562
|
+
id,
|
|
563
|
+
pid: spawned.pid,
|
|
564
|
+
port: record.port || port,
|
|
565
|
+
ports: record.ports || [],
|
|
566
|
+
logPath,
|
|
567
|
+
health,
|
|
568
|
+
compile,
|
|
569
|
+
note: health.ok
|
|
570
|
+
? `已按保存的配置启动 ${id}(pid ${spawned.pid}${record.port || port ? `,端口 ${record.port || port}` : ''})`
|
|
571
|
+
: `进程已拉起(pid ${spawned.pid})但 ${health.waitedMs}ms 内端口没就绪,看日志:${logPath}`,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ---------------------------------------------------------------- 日志
|
|
576
|
+
/**
|
|
577
|
+
* 读托管服务的日志(面板「详情」用)。
|
|
578
|
+
* 与 LogReader 的区别:这里不存游标,每次都按传入偏移取增量,
|
|
579
|
+
* 因为同一个文件可能同时被面板轮询和模型读取。
|
|
580
|
+
*
|
|
581
|
+
* 首屏(没给 fromOffset)默认只取最后 N 行——与 IDEA 控制台一致:
|
|
582
|
+
* 打开就看到最新的几行,而不是从头翻几百 KB。
|
|
583
|
+
*/
|
|
584
|
+
logs(cwd, id, options = {}) {
|
|
585
|
+
const key = String(id || '');
|
|
586
|
+
const entry = this.runById(cwd, key);
|
|
587
|
+
const segments = this.logSegmentsOf(entry?.id || key);
|
|
588
|
+
|
|
589
|
+
// 选了历史某一段就用它,否则用当前记录指向的那一段
|
|
590
|
+
let logPath = entry?.logPath || '';
|
|
591
|
+
const wanted = String(options.source || '');
|
|
592
|
+
if (wanted) {
|
|
593
|
+
const hit = segments.find((item) => item.path === wanted || item.name === wanted);
|
|
594
|
+
if (hit) logPath = hit.path;
|
|
595
|
+
}
|
|
596
|
+
if (!logPath) logPath = segments[0]?.path || this.logPathOf(entry?.id || key);
|
|
597
|
+
|
|
598
|
+
const lines = Number(options.lines) || 200;
|
|
599
|
+
const hasFrom = Number.isFinite(Number(options.fromOffset)) && Number(options.fromOffset) >= 0;
|
|
600
|
+
const chunk = hasFrom
|
|
601
|
+
? readFileChunk(logPath, { fromOffset: Number(options.fromOffset), maxBytes: options.maxBytes })
|
|
602
|
+
: readFileTail(logPath, { lines });
|
|
603
|
+
|
|
604
|
+
const alive = entry ? isAlive(Number(entry.pid)) : false;
|
|
605
|
+
return {
|
|
606
|
+
...chunk,
|
|
607
|
+
id: entry?.id || key,
|
|
608
|
+
segments: segments.map((item) => ({
|
|
609
|
+
key: item.path,
|
|
610
|
+
label: item.name.replace(/\.log$/i, ''),
|
|
611
|
+
path: item.path,
|
|
612
|
+
size: item.size,
|
|
613
|
+
updatedAt: item.updatedAt,
|
|
614
|
+
current: item.path === entry?.logPath,
|
|
615
|
+
})),
|
|
616
|
+
source: logPath,
|
|
617
|
+
alive,
|
|
618
|
+
pid: entry?.pid || 0,
|
|
619
|
+
port: entry?.port || 0,
|
|
620
|
+
note: chunk.ok ? '' : `日志文件还不存在(服务没启动过,或日志还没写):${logPath}`,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
}
|