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/java.js ADDED
@@ -0,0 +1,658 @@
1
+ /**
2
+ * 本机 Java 服务发现:给侧边栏「运行中的 Java 服务」列表供数。
3
+ *
4
+ * 做法(Windows / macOS / Linux 都走同一套,只是取端口的命令不同):
5
+ * 1. `jps -l -v` 拿 pid + 主类/jar + JVM 参数(IDEA 启动的服务带 jdwp 调试参数,据此可判定「来自 IDEA」);
6
+ * 2. 监听端口按 pid 归到进程上:Windows 用 `netstat -ano -p tcp`,
7
+ * macOS/Linux 用 `lsof -nP -iTCP -sTCP:LISTEN`(没有 lsof 时退回 Linux 的 `netstat -tlnp`);
8
+ * 3. 从 JVM 参数里抽 server.port / spring.profiles.active / jdwp 地址等关键信息。
9
+ *
10
+ * 不依赖第三方包,只用 node:child_process。
11
+ */
12
+ import { execFile } from 'node:child_process';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { request as httpRequest } from 'node:http';
16
+
17
+ /** 执行命令并返回 stdout(超时/失败返回空串,绝不抛) */
18
+ function run(file, args, timeoutMs = 8000) {
19
+ return new Promise((resolve) => {
20
+ try {
21
+ execFile(file, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }, (error, stdout) => {
22
+ if (error) return resolve('');
23
+ resolve(String(stdout || ''));
24
+ });
25
+ } catch {
26
+ resolve('');
27
+ }
28
+ });
29
+ }
30
+
31
+ /** 找 jps:JAVA_HOME 优先,其次 PATH 上的 jps(Windows 是 jps.exe) */
32
+ async function resolveJps() {
33
+ const javaHome = process.env.JAVA_HOME;
34
+ if (javaHome) {
35
+ const candidate = `${javaHome.replace(/[\\/]+$/, '')}${process.platform === 'win32' ? '\\bin\\jps.exe' : '/bin/jps'}`;
36
+ const probe = await run(candidate, ['-l'], 4000);
37
+ if (probe.trim() !== '') return candidate;
38
+ }
39
+ const probe = await run('jps', ['-l'], 4000);
40
+ return probe.trim() === '' ? '' : 'jps';
41
+ }
42
+
43
+ /** 解析 `jps -l -v` 输出 */
44
+ function parseJps(stdout) {
45
+ const out = [];
46
+ for (const line of String(stdout).split(/\r?\n/)) {
47
+ const match = /^\s*(\d+)\s+(\S+)\s*(.*)$/.exec(line);
48
+ if (!match) continue;
49
+ const pid = Number(match[1]);
50
+ const mainClass = match[2];
51
+ const rest = match[3] || '';
52
+ out.push({ pid, mainClass, jvmArgs: rest });
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** 往 Map<pid, number[]> 里塞一条(去重,保持顺序) */
58
+ function addPort(map, pid, port) {
59
+ if (!Number.isInteger(pid) || pid <= 0) return;
60
+ if (!Number.isInteger(port) || port <= 0) return;
61
+ const list = map.get(pid) || [];
62
+ if (!list.includes(port)) list.push(port);
63
+ map.set(pid, list);
64
+ }
65
+
66
+ /**
67
+ * 解析 Windows `netstat -ano -p tcp`,返回 Map<pid, number[]>(仅 LISTENING)。
68
+ * 典型行:` TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 23088`
69
+ */
70
+ function parseNetstat(stdout) {
71
+ const map = new Map();
72
+ for (const line of String(stdout).split(/\r?\n/)) {
73
+ const match = /^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/i.exec(line);
74
+ if (!match) continue;
75
+ addPort(map, Number(match[2]), Number(match[1]));
76
+ }
77
+ return map;
78
+ }
79
+
80
+ /**
81
+ * 解析 macOS/Linux 的 `lsof -nP -iTCP -sTCP:LISTEN`,返回 Map<pid, number[]>。
82
+ * 典型行:`java 23088 user 42u IPv6 0x1234 0t0 TCP *:8080 (LISTEN)`
83
+ *
84
+ * 注意别按「固定第几列」去切:DEVICE 列在 macOS 上可能是 `0x1234abcd`,NAME 也可能是
85
+ * `127.0.0.1:8080`、`[::1]:8080`、`*:8080` 几种形态。这里只认两件事——
86
+ * 第二列是 pid、行尾 `:<端口> (LISTEN)`,其余列一概不管。
87
+ */
88
+ function parseLsof(stdout) {
89
+ const map = new Map();
90
+ for (const line of String(stdout).split(/\r?\n/)) {
91
+ if (!/\(LISTEN\)/i.test(line)) continue;
92
+ const fields = line.trim().split(/\s+/);
93
+ if (fields.length < 2) continue;
94
+ const pid = Number(fields[1]);
95
+ const portMatch = /:(\d+)\s+\(LISTEN\)/i.exec(line);
96
+ if (!portMatch) continue;
97
+ addPort(map, pid, Number(portMatch[1]));
98
+ }
99
+ return map;
100
+ }
101
+
102
+ /**
103
+ * 解析 Linux `netstat -tlnp`(lsof 缺失时的兜底),返回 Map<pid, number[]>。
104
+ * 典型行:`tcp6 0 0 :::8080 :::* LISTEN 23088/java`
105
+ */
106
+ function parseNetstatLinux(stdout) {
107
+ const map = new Map();
108
+ for (const line of String(stdout).split(/\r?\n/)) {
109
+ const match = /^\s*tcp6?\s+\d+\s+\d+\s+\S+:(\d+)\s+\S+\s+LISTEN\s+(\d+)\//i.exec(line);
110
+ if (!match) continue;
111
+ addPort(map, Number(match[2]), Number(match[1]));
112
+ }
113
+ return map;
114
+ }
115
+
116
+ /**
117
+ * 枚举「监听端口 → pid」。
118
+ *
119
+ * 早期只在 Windows 上跑 netstat,其它平台直接返回空表,导致 macOS 上面板一个端口都没有
120
+ * (服务能列出来但 baseUrl 全是空)。这里按平台分流,拿不到就退化成空表,不影响进程列表。
121
+ */
122
+ async function listeningPortsByPid() {
123
+ if (process.platform === 'win32') {
124
+ return parseNetstat(await run('netstat', ['-ano', '-p', 'tcp']));
125
+ }
126
+ const lsof = await run('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], 10_000);
127
+ if (lsof.trim() !== '') return parseLsof(lsof);
128
+ return parseNetstatLinux(await run('netstat', ['-tlnp'], 10_000));
129
+ }
130
+
131
+ /** 从 JVM 参数里抽关键信息 */
132
+ function analyzeArgs(jvmArgs) {
133
+ const text = String(jvmArgs || '');
134
+ const pick = (pattern) => {
135
+ const match = pattern.exec(text);
136
+ return match ? match[1] : '';
137
+ };
138
+ const profileText = pick(/-Dspring\.profiles\.active=([^\s]+)/);
139
+ return {
140
+ /** Spring Boot 显式端口 */
141
+ serverPort: Number(pick(/-Dserver\.port=(\d+)/)) || 0,
142
+ /** 激活的 profile(可能是逗号分隔的多个,统一成数组给前端用) */
143
+ profiles: profileText ? profileText.split(',').map((item) => item.trim()).filter(Boolean) : [],
144
+ /** IDEA 调试端口 */
145
+ debugPort: Number(pick(/-agentlib:jdwp=[^\s]*address=[^:\s]*:(\d+)/)) || 0,
146
+ /** IDEA 打开的项目路径(IDEA 自身进程才有) */
147
+ ideaProject: pick(/-Dpreload\.project\.path=([^\s]+)/),
148
+ /** 是否由 IDEA 启动(带 JetBrains debugger agent 或 debug agentlib)。
149
+ * 注意:插件自己拉起的进程也会带 -agentlib:jdwp(为了能被 IDEA 远程挂载调试),
150
+ * 所以带 -DdshApiRunner.runId 标记的一律不算 IDEA 起的。 */
151
+ fromIdea: /-DdshApiRunner\.runId=/.test(text) ? false : /JetBrains[\\/]IntelliJIdea/i.test(text) || /-agentlib:jdwp=/.test(text),
152
+ };
153
+ }
154
+
155
+ /** 归类:IDEA 自身/构建进程、Spring Boot 服务、其它 java 程序 */
156
+ function classify(mainClass, analysis) {
157
+ if (/^(jdk\.jcmd|sun\.tools\.jps|org\.jetbrains\.|com\.intellij\.)/.test(mainClass)) return 'tool';
158
+ if (/Application$/.test(mainClass) || analysis.profiles.length > 0) return 'spring';
159
+ return 'app';
160
+ }
161
+
162
+ /** HTTP 探测结果缓存:面板 5 秒轮询一次,端口是否讲 HTTP 不会那么快变,缓存 30 秒 */
163
+ const httpProbeCache = new Map();
164
+ const HTTP_PROBE_TTL_MS = 30_000;
165
+
166
+ function probeHttpCached(port) {
167
+ const hit = httpProbeCache.get(port);
168
+ if (hit && Date.now() - hit.at < HTTP_PROBE_TTL_MS) return Promise.resolve(hit);
169
+ return probeHttp(port).then((info) => {
170
+ httpProbeCache.set(port, { ...info, at: Date.now() });
171
+ return info;
172
+ });
173
+ }
174
+
175
+ /**
176
+ * 探测某个端口上是不是「能当接口地址」的 HTTP 服务。
177
+ *
178
+ * 为什么不能只看 netstat:一个服务常同时监听好几个端口
179
+ * (业务端口、Sentinel 规则端口、管理/actuator 端口,甚至注册中心之类的私有协议端口)。
180
+ * Sentinel 的 command server 是典型例子:它对 `/getRules` 回 200,
181
+ * 但对任意其它路径回 `400 Invalid command`(没有 content-type)。
182
+ * 直接拿它当 baseUrl,所有业务接口都会 400,所以必须识别并排除。
183
+ *
184
+ * 所以打分而不是只看「有没有响应」:
185
+ * - 2xx/3xx → 就是它
186
+ * - 401/403 → 需要鉴权的接口端,也是它
187
+ * - 404/405 且带 content-type(Spring 的 JSON 错误页)→ 也是它
188
+ * - 400 且 body 是 `Invalid command`、没有 content-type → 判为私有协议端口(多为 Sentinel),跳过
189
+ */
190
+ function probeHttp(port, timeoutMs = 800) {
191
+ return new Promise((resolve) => {
192
+ if (!port) return resolve({ http: false, status: 0, reason: 'no-port' });
193
+ let settled = false;
194
+ const done = (value) => {
195
+ if (settled) return;
196
+ settled = true;
197
+ resolve(value);
198
+ };
199
+ try {
200
+ const req = httpRequest({ host: '127.0.0.1', port, path: '/', method: 'GET', timeout: timeoutMs }, (res) => {
201
+ let body = '';
202
+ res.setEncoding('utf8');
203
+ res.on('data', (chunk) => {
204
+ if (body.length < 400) body += chunk;
205
+ });
206
+ res.on('end', () => {
207
+ const status = res.statusCode || 0;
208
+ const contentType = String(res.headers['content-type'] || '');
209
+ const looksPrivate =
210
+ status === 400
211
+ && contentType === ''
212
+ && /invalid\s+command/i.test(body);
213
+ done({
214
+ http: !looksPrivate,
215
+ status,
216
+ contentType,
217
+ reason: looksPrivate ? 'not-http-protocol' : 'ok',
218
+ });
219
+ });
220
+ res.on('error', () => done({ http: false, status: 0, reason: 'read-error' }));
221
+ // 注意:这里不能提前 req.destroy(),否则 res 还没读完就断开,
222
+ // body 为空会导致下面的 looksPrivate 判断永远不成立(踩过)。
223
+ });
224
+ req.on('timeout', () => {
225
+ req.destroy();
226
+ done({ http: false, status: 0, reason: 'timeout' });
227
+ });
228
+ req.on('error', () => done({ http: false, status: 0, reason: 'connect-error' }));
229
+ req.end();
230
+ } catch {
231
+ done({ http: false, status: 0, reason: 'exception' });
232
+ }
233
+ });
234
+ }
235
+
236
+ /**
237
+ * 列出本机所有 Java 进程。
238
+ * @returns {Promise<{ok:boolean, jps:string, processes:Array, note?:string}>}
239
+ */
240
+ export async function listJavaProcesses() {
241
+ const jps = await resolveJps();
242
+ if (!jps) {
243
+ return { ok: false, jps: '', processes: [], note: '找不到 jps(设置 JAVA_HOME 或把 JDK/bin 加入 PATH),已无法枚举 Java 进程' };
244
+ }
245
+ const [jpsOut, portsByPid] = await Promise.all([
246
+ run(jps, ['-l', '-v']),
247
+ listeningPortsByPid(),
248
+ ]);
249
+ const processes = parseJps(jpsOut)
250
+ .map((item) => {
251
+ const analysis = analyzeArgs(item.jvmArgs);
252
+ const ports = portsByPid.get(item.pid) || [];
253
+ return {
254
+ pid: item.pid,
255
+ mainClass: item.mainClass,
256
+ /** 服务名:取主类最后一段,去掉 Application 后缀更好读 */
257
+ name: (item.mainClass.split('.').pop() || item.mainClass).replace(/Application$/, ''),
258
+ kind: classify(item.mainClass, analysis),
259
+ fromIdea: analysis.fromIdea,
260
+ profiles: analysis.profiles,
261
+ debugPort: analysis.debugPort,
262
+ ideaProject: analysis.ideaProject,
263
+ serverPort: analysis.serverPort,
264
+ ports,
265
+ /** 先按「显式 server.port > 第一个非调试端口」给个初值,下面再用 HTTP 探测修正 */
266
+ primaryPort: analysis.serverPort || ports.find((port) => port !== analysis.debugPort) || 0,
267
+ /** 只保留可读的 -D 参数,避免把整条命令行糊到界面上 */
268
+ systemProperties: extractProperties(item.jvmArgs),
269
+ };
270
+ })
271
+ .filter((item) => item.kind !== 'tool');
272
+
273
+ // 并发探测每个进程的候选端口,挑第一个真正讲业务 HTTP 的作为 primaryPort,
274
+ // 同时给每个端口标上角色:一个服务往往同时听业务端口和 Sentinel 规则端口,
275
+ // 只报一个数字用户看不懂「为什么两个」。
276
+ await Promise.all(processes.map(async (item) => {
277
+ const candidates = item.ports.filter((port) => port !== item.debugPort);
278
+ item.portProbed = true;
279
+ const probes = new Map();
280
+ for (const port of candidates) {
281
+ if (item.serverPort === port) {
282
+ probes.set(port, { http: true, status: 0, reason: 'server-port' });
283
+ continue;
284
+ }
285
+ // eslint-disable-next-line no-await-in-loop
286
+ const info = await probeHttpCached(port);
287
+ probes.set(port, info);
288
+ if (info.http && !item.httpPort) item.httpPort = port;
289
+ }
290
+ if (item.serverPort > 0 && !item.httpPort) item.httpPort = item.serverPort;
291
+ if (item.httpPort) item.primaryPort = item.httpPort;
292
+ item.probe = item.httpPort
293
+ ? { ...(probes.get(item.httpPort) || { http: true, status: 0, reason: 'server-port' }), port: item.httpPort }
294
+ : { http: false, status: 0, reason: 'no-listen' };
295
+ item.sentinelPort = 0;
296
+ item.portList = candidates.map((port) => {
297
+ const probe = probes.get(port) || {};
298
+ let role = 'unknown';
299
+ if (port === item.httpPort) role = 'http';
300
+ else if (probe.reason === 'not-http-protocol') role = 'sentinel';
301
+ if (role === 'sentinel' && !item.sentinelPort) item.sentinelPort = port;
302
+ return {
303
+ port,
304
+ role,
305
+ label: PORT_ROLE_LABEL[role] || PORT_ROLE_LABEL.unknown,
306
+ status: probe.status || 0,
307
+ reason: probe.reason || '',
308
+ };
309
+ });
310
+ }));
311
+
312
+ return { ok: true, jps, processes, total: processes.length };
313
+ }
314
+
315
+ /** 端口角色 → 给人看的说明 */
316
+ const PORT_ROLE_LABEL = {
317
+ http: 'HTTP 业务端口',
318
+ debug: '调试端口(jdwp)',
319
+ sentinel: '私有协议端口(Sentinel 等)',
320
+ unknown: '未识别端口',
321
+ };
322
+
323
+ // ---- 应用日志文件发现 ----
324
+ //
325
+ // IDEA 起的进程,stdout 只在 IDEA 控制台里,没有文件可读;但它自己的 logback 会把
326
+ // 业务日志写到 `<user.dir>/logs/<服务名>/info.log`。所以「查询详情」对这类进程
327
+ // 只能读应用日志——这正是用户要的「跟 IDEA 一样的日志信息」。
328
+
329
+ /** 进程工作目录缓存:取 user.dir 要跑一次 jcmd(约 200ms),5 分钟内不重复取 */
330
+ const userDirCache = new Map();
331
+ const USER_DIR_TTL_MS = 5 * 60_000;
332
+
333
+ async function userDirOf(pid) {
334
+ const target = Number(pid);
335
+ if (!Number.isInteger(target) || target <= 0) return '';
336
+ const hit = userDirCache.get(target);
337
+ if (hit && Date.now() - hit.at < USER_DIR_TTL_MS) return hit.value;
338
+ const jcmd = await resolveJcmd();
339
+ if (!jcmd) return '';
340
+ const result = await runCapture(jcmd, [String(target), 'VM.system_properties'], 6000);
341
+ let value = '';
342
+ if (result.ok) {
343
+ const props = parseSystemProperties(result.stdout);
344
+ value = props['user.dir'] || '';
345
+ }
346
+ userDirCache.set(target, { value, at: Date.now() });
347
+ return value;
348
+ }
349
+
350
+ /** 归一化名字用于匹配:UserService / user-service / user_service 都变成 userservice */
351
+ function normalizeName(text) {
352
+ return String(text || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
353
+ }
354
+
355
+ /**
356
+ * 找某个 Java 进程对应的应用日志文件。
357
+ * 规则:从 user.dir 起找 logs/<与进程名同构的子目录>/,优先 info.log,其次 error.log,
358
+ * 最后取最近修改的 *.log。找不到就返回空数组(前端会提示只能看进程信息)。
359
+ */
360
+ export async function discoverLogs(pid, name) {
361
+ const cwd = await userDirOf(pid);
362
+ if (!cwd) return { ok: false, cwd: '', files: [], note: '读不到进程工作目录(jcmd 不可用)' };
363
+ const wanted = normalizeName(name);
364
+ const roots = [path.join(cwd, 'logs'), path.join(cwd, '..', 'logs')];
365
+ const files = [];
366
+ for (const root of roots) {
367
+ let entries;
368
+ try {
369
+ entries = fs.readdirSync(root, { withFileTypes: true });
370
+ } catch {
371
+ continue;
372
+ }
373
+ const dirs = entries.filter((entry) => entry.isDirectory());
374
+ // 先精确匹配,匹配不到再退化成「互相包含」,避免多服务时抓错
375
+ const exact = dirs.filter((entry) => normalizeName(entry.name) === wanted);
376
+ const loose = exact.length > 0 ? exact : dirs.filter((entry) => {
377
+ const key = normalizeName(entry.name);
378
+ return key !== '' && (key.includes(wanted) || wanted.includes(key));
379
+ });
380
+ for (const dir of loose) {
381
+ const dirPath = path.join(root, dir.name);
382
+ files.push(...pickLogFiles(dirPath, dir.name));
383
+ }
384
+ if (files.length > 0) break;
385
+ }
386
+ return {
387
+ ok: true,
388
+ cwd,
389
+ files,
390
+ note: files.length === 0 ? `在 ${cwd}\\logs 下没找到与「${name}」匹配的日志目录` : '',
391
+ };
392
+ }
393
+
394
+ /** 从一个日志目录里挑出要展示的文件:当前在写的排前面,按天滚动的归入历史 */
395
+ function pickLogFiles(dirPath, label) {
396
+ let names;
397
+ try {
398
+ names = fs.readdirSync(dirPath);
399
+ } catch {
400
+ return [];
401
+ }
402
+ const stats = [];
403
+ for (const name of names) {
404
+ if (!/\.log$/i.test(name)) continue;
405
+ const file = path.join(dirPath, name);
406
+ try {
407
+ const stat = fs.statSync(file);
408
+ if (!stat.isFile()) continue;
409
+ stats.push({ label, name, path: file, size: stat.size, updatedAt: stat.mtime.toISOString(), at: stat.mtimeMs });
410
+ } catch {
411
+ // 文件刚被轮转删掉,跳过
412
+ }
413
+ }
414
+ // 「当前文件」= 正好叫 info.log / error.log 的(logback 正在往里写),
415
+ // 其余按天滚动的是历史。历史文件绝不能排到前面——否则点开详情看到的是几天前的日志。
416
+ const isCurrent = (item) => /^(info|error)\.log$/i.test(item.name);
417
+ const rank = (item) => {
418
+ if (/^info\.log$/i.test(item.name)) return 0;
419
+ if (/^error\.log$/i.test(item.name)) return 1;
420
+ return 2;
421
+ };
422
+ stats.sort((a, b) => rank(a) - rank(b) || b.at - a.at);
423
+ return stats.slice(0, 6).map(({ at, ...rest }) => ({ ...rest, current: isCurrent(rest) }));
424
+ }
425
+
426
+ /**
427
+ * 按行读文件末尾:默认只取最后 N 行,返回的 fromOffset 精确落在这一段的起点,
428
+ * 之后拿它做增量(follow)不会重复也不会漏。
429
+ */
430
+ export function readFileTail(file, options = {}) {
431
+ const wantedLines = Math.max(1, Math.min(5000, Number(options.lines) || 200));
432
+ let stat;
433
+ try {
434
+ stat = fs.statSync(file);
435
+ } catch {
436
+ return { ok: false, code: 'NOT_FOUND', logPath: file, size: 0, fromOffset: 0, nextOffset: 0, text: '', lines: 0, note: '日志文件不存在' };
437
+ }
438
+ // 读末尾一段再把多出来的行切掉,避免整读几百 MB 的历史日志
439
+ const window = Math.min(stat.size, Math.max(256 * 1024, wantedLines * 512));
440
+ let from = Math.max(0, stat.size - window);
441
+ let text = '';
442
+ if (window > 0) {
443
+ const buffer = Buffer.alloc(window);
444
+ const fd = fs.openSync(file, 'r');
445
+ try {
446
+ fs.readSync(fd, buffer, 0, window, from);
447
+ } finally {
448
+ fs.closeSync(fd);
449
+ }
450
+ text = buffer.toString('utf8');
451
+ }
452
+ const parts = text.split(/\r?\n/);
453
+ // 结尾换行会产生一个空元素,先去掉;正文里的空行要保留
454
+ if (parts.length > 0 && parts[parts.length - 1] === '') parts.pop();
455
+ let taken = parts;
456
+ let cutBytes = 0;
457
+ if (parts.length > wantedLines) {
458
+ const dropped = parts.slice(0, parts.length - wantedLines);
459
+ cutBytes = Buffer.byteLength(dropped.join('\n'), 'utf8') + 1;
460
+ taken = parts.slice(parts.length - wantedLines);
461
+ }
462
+ // 头部可能被截断到半个字符,去掉第一行残缺内容(只在真的从文件中间开始时)
463
+ if (from > 0 && cutBytes === 0) {
464
+ const firstBreak = taken[0] === undefined ? -1 : 0;
465
+ if (firstBreak === 0) taken = taken.slice(1);
466
+ }
467
+ const body = taken.join('\n');
468
+ const startOffset = from + cutBytes;
469
+ return {
470
+ ok: true,
471
+ logPath: file,
472
+ size: stat.size,
473
+ fromOffset: startOffset,
474
+ nextOffset: stat.size,
475
+ remainingBytes: 0,
476
+ updatedAt: stat.mtime.toISOString(),
477
+ text: body,
478
+ lines: taken.length,
479
+ };
480
+ }
481
+
482
+
483
+ /**
484
+ * 按字节偏移读任意文件的末尾/增量(面板详情页对应用日志也用这个)。
485
+ * 偏移超出文件大小(轮转/截断)时自动从头读。
486
+ */
487
+ export function readFileChunk(file, options = {}) {
488
+ const maxBytes = Math.max(1024, Number(options.maxBytes) || 64 * 1024);
489
+ let stat;
490
+ try {
491
+ stat = fs.statSync(file);
492
+ } catch {
493
+ return { ok: false, code: 'NOT_FOUND', logPath: file, size: 0, fromOffset: 0, nextOffset: 0, text: '', note: '日志文件不存在' };
494
+ }
495
+ let from = Number(options.fromOffset);
496
+ if (!Number.isFinite(from) || from < 0 || from > stat.size) from = Math.max(0, stat.size - maxBytes);
497
+ const length = Math.min(Math.max(0, stat.size - from), maxBytes);
498
+ let text = '';
499
+ if (length > 0) {
500
+ const buffer = Buffer.alloc(length);
501
+ const fd = fs.openSync(file, 'r');
502
+ try {
503
+ fs.readSync(fd, buffer, 0, length, from);
504
+ } finally {
505
+ fs.closeSync(fd);
506
+ }
507
+ text = buffer.toString('utf8');
508
+ }
509
+ return {
510
+ ok: true,
511
+ logPath: file,
512
+ size: stat.size,
513
+ fromOffset: from,
514
+ nextOffset: from + length,
515
+ remainingBytes: Math.max(0, stat.size - (from + length)),
516
+ updatedAt: stat.mtime.toISOString(),
517
+ text,
518
+ };
519
+ }
520
+
521
+ // ---- 重启服务用的进程快照 ----
522
+
523
+ /** 执行命令并按结果返回 stdout/stderr(与 run 不同,这里需要知道失败原因) */
524
+ function runCapture(file, args, timeoutMs = 8000) {
525
+ return new Promise((resolve) => {
526
+ try {
527
+ execFile(file, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
528
+ resolve({ ok: !error, stdout: String(stdout || ''), stderr: String(stderr || '') });
529
+ });
530
+ } catch (error) {
531
+ resolve({ ok: false, stdout: '', stderr: String(error?.message || error) });
532
+ }
533
+ });
534
+ }
535
+
536
+ /** 找 jcmd:JAVA_HOME 优先,其次 PATH(jps 与 jcmd 同目录,复用同一套解析) */
537
+ async function resolveJcmd() {
538
+ const javaHome = process.env.JAVA_HOME;
539
+ if (javaHome) {
540
+ const candidate = `${javaHome.replace(/[\\/]+$/, '')}${process.platform === 'win32' ? '\\bin\\jcmd.exe' : '/bin/jcmd'}`;
541
+ const probe = await runCapture(candidate, ['-l'], 4000);
542
+ if (probe.ok && probe.stdout.trim() !== '') return candidate;
543
+ }
544
+ const probe = await runCapture('jcmd', ['-l'], 4000);
545
+ return probe.ok && probe.stdout.trim() !== '' ? 'jcmd' : '';
546
+ }
547
+
548
+ /** 解 jcmd 系统属性里的转义:`C\:\\Java\\jdk` → `C:\Java\jdk` */
549
+ function unescapeProperty(value) {
550
+ let out = '';
551
+ for (let i = 0; i < value.length; i += 1) {
552
+ if (value[i] === '\\' && i + 1 < value.length) {
553
+ out += value[i + 1];
554
+ i += 1;
555
+ } else {
556
+ out += value[i];
557
+ }
558
+ }
559
+ return out;
560
+ }
561
+
562
+ /** 解析 `jcmd <pid> VM.command_line` 的分段输出 */
563
+ function parseCommandLine(stdout) {
564
+ const out = { jvmArgsText: '', javaCommand: '', classPath: '' };
565
+ for (const line of String(stdout).split(/\r?\n/)) {
566
+ if (line.startsWith('jvm_args:')) out.jvmArgsText = line.slice('jvm_args:'.length).trim();
567
+ else if (line.startsWith('java_command:')) out.javaCommand = line.slice('java_command:'.length).trim();
568
+ else if (line.startsWith('java_class_path (initial):')) out.classPath = line.slice('java_class_path (initial):'.length).trim();
569
+ }
570
+ return out;
571
+ }
572
+
573
+ /** 解析 `jcmd <pid> VM.system_properties` 的 key=value 行 */
574
+ function parseSystemProperties(stdout) {
575
+ const out = {};
576
+ for (const line of String(stdout).split(/\r?\n/)) {
577
+ const at = line.indexOf('=');
578
+ if (at <= 0) continue;
579
+ out[line.slice(0, at)] = unescapeProperty(line.slice(at + 1));
580
+ }
581
+ return out;
582
+ }
583
+
584
+ /** 按空白切分 JVM 参数(classpath 单独给出,这里不会出现带空格的参数) */
585
+ function splitArgs(text) {
586
+ return String(text || '')
587
+ .split(/\s+/)
588
+ .map((item) => item.trim())
589
+ .filter(Boolean);
590
+ }
591
+
592
+ /**
593
+ * 读取某个 Java 进程的「重启所需快照」:java 路径、工作目录、classpath、主类、JVM 参数。
594
+ * 用的是 JVM 自带的 jcmd,不依赖 WMI,跨平台一致。
595
+ * @param {number} pid
596
+ */
597
+ export async function getJavaSnapshot(pid) {
598
+ const target = Number(pid);
599
+ if (!Number.isInteger(target) || target <= 0) return { ok: false, message: 'pid 非法' };
600
+ const jcmd = await resolveJcmd();
601
+ if (!jcmd) return { ok: false, message: '找不到 jcmd(设置 JAVA_HOME 或把 JDK/bin 加入 PATH)' };
602
+
603
+ const [cmdResult, propResult] = await Promise.all([
604
+ runCapture(jcmd, [String(target), 'VM.command_line'], 10_000),
605
+ runCapture(jcmd, [String(target), 'VM.system_properties'], 10_000),
606
+ ]);
607
+ if (!cmdResult.ok) {
608
+ const reason = (cmdResult.stderr || cmdResult.stdout || '').trim() || 'jcmd 无输出';
609
+ return { ok: false, message: `读取进程 ${target} 的启动参数失败:${reason}` };
610
+ }
611
+
612
+ const parsed = parseCommandLine(cmdResult.stdout);
613
+ const props = propResult.ok ? parseSystemProperties(propResult.stdout) : {};
614
+ const javaHome = props['java.home'] || '';
615
+ const javaExe = javaHome
616
+ ? `${javaHome.replace(/[\\/]+$/, '')}${process.platform === 'win32' ? '\\bin\\java.exe' : '/bin/java'}`
617
+ : '';
618
+ if (!parsed.classPath) return { ok: false, message: `进程 ${target} 没有 -classpath(可能不是通过标准启动器起的),无法原样还原` };
619
+ if (!parsed.javaCommand) return { ok: false, message: `进程 ${target} 读不到主类,无法还原` };
620
+
621
+ // 坑:`jcmd VM.command_line` 的 classpath 会被截断(实测 21335 字符只给出 1998 字符,
622
+ // 218 条依赖只剩前 19 条),拿它重启必然缺 jar。`VM.system_properties` 里的
623
+ // java.class.path 是完整值,所以优先用它,并取两者中更完整的那个兜底。
624
+ const propClassPath = props['java.class.path'] || '';
625
+ const classPath = propClassPath.length >= parsed.classPath.length ? propClassPath : parsed.classPath;
626
+
627
+ // java_command 可能是「主类 + 程序参数」,第一段是主类
628
+ const commandParts = splitArgs(parsed.javaCommand);
629
+ return {
630
+ ok: true,
631
+ pid: target,
632
+ javaExe,
633
+ javaHome,
634
+ cwd: props['user.dir'] || '',
635
+ classPath,
636
+ mainClass: commandParts[0] || '',
637
+ programArgs: commandParts.slice(1),
638
+ jvmArgs: splitArgs(parsed.jvmArgsText),
639
+ systemProperties: {
640
+ 'file.encoding': props['file.encoding'] || '',
641
+ 'spring.profiles.active': props['spring.profiles.active'] || '',
642
+ },
643
+ };
644
+ }
645
+
646
+ /**
647
+ * 只挑几个对排查有用的 -D 系统属性。
648
+ * `dshApiRunner.runId` 是本插件拉起进程时打的标记:即使状态文件丢了(换机器/清过 DSH_HOME),
649
+ * 也能从 jps 参数上认出「这个进程是我起的」。
650
+ */
651
+ function extractProperties(jvmArgs) {
652
+ const wanted = ['server.port', 'spring.profiles.active', 'spring.application.name', 'file.encoding', 'spring.output.ansi.enabled', 'dshApiRunner.runId'];
653
+ const out = {};
654
+ for (const match of String(jvmArgs || '').matchAll(/-D([A-Za-z0-9_.-]+)=([^\s]*)/g)) {
655
+ if (wanted.includes(match[1])) out[match[1]] = match[2];
656
+ }
657
+ return out;
658
+ }