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
package/src/restart.js
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 重启「IDEA 启动的 Java 服务」。
|
|
3
|
+
*
|
|
4
|
+
* 背景:IDEA 没有对外暴露「重启某个 run configuration」的能力,所以这里走
|
|
5
|
+
* 「命令行快照回放」:从运行中的进程里读出它真实的 classpath / 主类 / 工作目录
|
|
6
|
+
* (jcmd 提供),kill 掉旧进程,再用同一套参数重新拉起一个新进程。
|
|
7
|
+
*
|
|
8
|
+
* 两个必须处理的差异:
|
|
9
|
+
* 1. IDEA 调试启动的参数是 `-agentlib:jdwp=...,suspend=y,server=n` —— JVM 反向
|
|
10
|
+
* 连 IDEA 的监听端口,IDEA 那边一停止监听,原样重放会让新 JVM 卡在
|
|
11
|
+
* suspend=y 等调试器。所以默认改成 `server=y,suspend=n`(自己开一个端口),
|
|
12
|
+
* 可以在 IDEA 里用 Remote JVM Debug 再挂上去;也可以整体去掉。
|
|
13
|
+
* 2. IDEA 注入的 `-javaagent:...captureAgent/debugger-agent.jar=file://...props`
|
|
14
|
+
* 依赖那一次调试会话的临时文件,必须去掉。
|
|
15
|
+
*
|
|
16
|
+
* 日志:新进程的 stdout/stderr 写到文件(追加),不再受 IDEA 控制台约束。
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { execFile, spawn } from 'node:child_process';
|
|
21
|
+
import { getJavaSnapshot } from './java.js';
|
|
22
|
+
|
|
23
|
+
const isWindows = process.platform === 'win32';
|
|
24
|
+
|
|
25
|
+
/** 判断某个参数是否是调试相关(需要被剥离/替换) */
|
|
26
|
+
function isDebugArg(arg) {
|
|
27
|
+
if (/^-agentlib:jdwp=/.test(arg)) return true;
|
|
28
|
+
if (/^-Xrunjdwp/.test(arg)) return true;
|
|
29
|
+
if (/^-Xdebug/.test(arg)) return true;
|
|
30
|
+
// IDEA 的捕获 agent,file:// 指向上一次调试会话的临时 props
|
|
31
|
+
if (/^-javaagent:.*captureAgent[\\/]debugger-agent\.jar/.test(arg)) return true;
|
|
32
|
+
// IDEA 协程调试相关的 -D,重放无害但没意义,去掉更干净
|
|
33
|
+
if (/^-Dkotlinx\.coroutines\.debug\./.test(arg)) return true;
|
|
34
|
+
if (/^-Ddebugger\.(agent|async)\./.test(arg)) return true;
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 从 jdwp 参数里取端口 */
|
|
39
|
+
function jdwpPort(arg) {
|
|
40
|
+
const match = /address=([^,\s]*?):(\d+)/.exec(arg);
|
|
41
|
+
return match ? Number(match[2]) : 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 选一个空闲的调试端口(默认避开原端口,+1 试到 +20) */
|
|
45
|
+
function pickDebugPort(base) {
|
|
46
|
+
const start = base > 0 ? base : 5005;
|
|
47
|
+
return start;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 生成重启用的 argv。
|
|
52
|
+
* @param {{javaExe:string, classPath:string, mainClass:string, programArgs:string[], jvmArgs:string[]}} snapshot
|
|
53
|
+
* @param {{debug?:'port'|'none'|'keep', debugPort?:number, extraJvmArgs?:string[]}} options
|
|
54
|
+
*/
|
|
55
|
+
export function buildRestartArgv(snapshot, options = {}) {
|
|
56
|
+
const mode = options.debug || 'port';
|
|
57
|
+
const kept = [];
|
|
58
|
+
let originalDebugPort = 0;
|
|
59
|
+
for (const arg of snapshot.jvmArgs || []) {
|
|
60
|
+
if (/^-agentlib:jdwp=/.test(arg) || /^-Xrunjdwp/.test(arg)) {
|
|
61
|
+
originalDebugPort = originalDebugPort || jdwpPort(arg);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (isDebugArg(arg)) continue;
|
|
65
|
+
kept.push(arg);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const argv = [];
|
|
69
|
+
if (mode === 'port') {
|
|
70
|
+
const port = Number(options.debugPort) || pickDebugPort(originalDebugPort);
|
|
71
|
+
argv.push('-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:' + port);
|
|
72
|
+
} else if (mode === 'keep' && originalDebugPort) {
|
|
73
|
+
// 保留端口但改成等待连接方向,避免 suspend=y 卡死
|
|
74
|
+
argv.push('-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:' + originalDebugPort);
|
|
75
|
+
}
|
|
76
|
+
argv.push(...kept);
|
|
77
|
+
argv.push(...(options.extraJvmArgs || []));
|
|
78
|
+
argv.push('-classpath', snapshot.classPath);
|
|
79
|
+
argv.push(snapshot.mainClass);
|
|
80
|
+
argv.push(...(snapshot.programArgs || []));
|
|
81
|
+
return { argv, debugPort: mode === 'none' ? 0 : Number(options.debugPort) || originalDebugPort };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- Maven 探测与编译 ----
|
|
85
|
+
|
|
86
|
+
/** 从 IDEA 进程路径推断它自带的 Maven(用户环境里常用这套,没装独立 Maven 时唯一选择) */
|
|
87
|
+
async function ideaBundledMaven() {
|
|
88
|
+
const ideaPath = await detectIdeaApp();
|
|
89
|
+
if (!ideaPath) return '';
|
|
90
|
+
// 候选安装根:
|
|
91
|
+
// Windows / Linux 传进来的是 .../bin/idea64.exe 或 .../bin/idea.sh,上两级就是安装根;
|
|
92
|
+
// macOS 传进来的是 /Applications/IntelliJ IDEA.app,Maven 在 Contents/ 下面。
|
|
93
|
+
const bases = [
|
|
94
|
+
path.resolve(ideaPath, '..'),
|
|
95
|
+
path.resolve(ideaPath, '..', '..'),
|
|
96
|
+
path.join(ideaPath, 'Contents'),
|
|
97
|
+
];
|
|
98
|
+
for (const base of bases) {
|
|
99
|
+
const candidate = path.join(base, 'plugins', 'maven', 'lib', 'maven3', 'bin', isWindows ? 'mvn.cmd' : 'mvn');
|
|
100
|
+
if (fileExists(candidate)) return candidate;
|
|
101
|
+
}
|
|
102
|
+
return '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 找本机 IDEA 的可执行文件路径(各平台分别试) */
|
|
106
|
+
async function detectIdeaApp() {
|
|
107
|
+
const commands = isWindows
|
|
108
|
+
? [['powershell', ['-NoProfile', '-Command', "(Get-Process idea64 -ErrorAction SilentlyContinue | Select-Object -First 1).Path"]]]
|
|
109
|
+
: process.platform === 'darwin'
|
|
110
|
+
? [
|
|
111
|
+
['ps', ['-Ao', 'comm']],
|
|
112
|
+
['mdfind', ['kMDItemCFBundleIdentifier == "com.jetbrains.intellij"']],
|
|
113
|
+
]
|
|
114
|
+
: [['ps', ['-Ao', 'args']]];
|
|
115
|
+
for (const [cmd, args] of commands) {
|
|
116
|
+
const out = await new Promise((resolve) => {
|
|
117
|
+
execFile(cmd, args, { timeout: 6000, windowsHide: true }, (error, stdout) => resolve(error ? '' : String(stdout || '')));
|
|
118
|
+
});
|
|
119
|
+
const found = pickIdeaPath(out);
|
|
120
|
+
if (found) return found;
|
|
121
|
+
}
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 从 ps/mdfind/powershell 的输出里挑出 IDEA 路径 */
|
|
126
|
+
function pickIdeaPath(text) {
|
|
127
|
+
const lines = String(text || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
// macOS 的 ps comm 只给可执行文件路径,形如 /Applications/IntelliJ IDEA.app/Contents/MacOS/idea
|
|
130
|
+
const mac = /^(.*?IntelliJ IDEA[^/]*\.app)\/Contents\//.exec(line);
|
|
131
|
+
if (mac) return mac[1];
|
|
132
|
+
// Windows 的 powershell 直接给整行路径(可能含空格),先按整行试
|
|
133
|
+
if (path.isAbsolute(line) && /[\\/]bin[\\/]idea64\.exe$/i.test(line)) return line;
|
|
134
|
+
// Linux 的 ps args 一行里有多个字段:第一个可执行文件就是 idea 启动脚本
|
|
135
|
+
const first = line.split(/\s+/)[0] || '';
|
|
136
|
+
if (path.isAbsolute(first) && /[\\/]bin[\\/](idea|idea\.sh|idea64\.exe)$/i.test(first)) return first;
|
|
137
|
+
}
|
|
138
|
+
return '';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function fileExists(file) {
|
|
142
|
+
try {
|
|
143
|
+
return fs.statSync(file).isFile();
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 按优先级找 mvn:显式配置 > MAVEN_HOME > M2_HOME > PATH > IDEA 自带 */
|
|
150
|
+
export async function resolveMaven(explicit = '') {
|
|
151
|
+
if (explicit) return fileExists(explicit) ? explicit : '';
|
|
152
|
+
for (const key of ['MAVEN_HOME', 'M2_HOME']) {
|
|
153
|
+
const home = process.env[key];
|
|
154
|
+
if (!home) continue;
|
|
155
|
+
const candidate = path.join(home, 'bin', isWindows ? 'mvn.cmd' : 'mvn');
|
|
156
|
+
if (fileExists(candidate)) return candidate;
|
|
157
|
+
}
|
|
158
|
+
const onPath = await new Promise((resolve) => {
|
|
159
|
+
execFile(isWindows ? 'where' : 'which', ['mvn'], { timeout: 5000, windowsHide: true }, (error, stdout) => {
|
|
160
|
+
if (error) return resolve('');
|
|
161
|
+
const first = String(stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)[0];
|
|
162
|
+
resolve(first || '');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
if (onPath && fileExists(onPath)) return onPath;
|
|
166
|
+
return ideaBundledMaven();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 从 classpath 反推 Maven 模块:第一条 `.../<模块>/target/classes` 就是模块目录;
|
|
171
|
+
* 再向上找含 `<module>` 的 pom 作为 reactor 根。
|
|
172
|
+
*/
|
|
173
|
+
export function inferMavenModule(classPath) {
|
|
174
|
+
const entries = String(classPath || '').split(isWindows ? ';' : ':').filter(Boolean);
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
const normalized = entry.replace(/[\\/]+/g, '/');
|
|
177
|
+
const match = /^(.*)\/target\/(classes|test-classes)(\/|$)/.exec(normalized);
|
|
178
|
+
if (!match) continue;
|
|
179
|
+
const moduleDir = match[1];
|
|
180
|
+
const pomFile = path.join(moduleDir, 'pom.xml');
|
|
181
|
+
if (!fileExists(pomFile)) continue;
|
|
182
|
+
const artifactId = readArtifactId(pomFile) || path.basename(moduleDir);
|
|
183
|
+
// 向上找 reactor 根:要取**最外层**的聚合 pom,不能取第一个带 <module> 的。
|
|
184
|
+
// 反例:classpath 指向 <项目>/<聚合模块>/<子模块> 时,
|
|
185
|
+
// 若停在聚合模块,-pl ... -am 就编不到同级的兄弟模块,
|
|
186
|
+
// 那些模块只能从本地仓库拿旧 jar,于是报「找不到符号」。
|
|
187
|
+
let rootDir = moduleDir;
|
|
188
|
+
let cursor = path.dirname(moduleDir);
|
|
189
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
190
|
+
const pom = path.join(cursor, 'pom.xml');
|
|
191
|
+
if (!fileExists(pom)) break;
|
|
192
|
+
const text = readText(pom);
|
|
193
|
+
if (/<module>/.test(text)) rootDir = cursor;
|
|
194
|
+
const parent = path.dirname(cursor);
|
|
195
|
+
if (parent === cursor) break;
|
|
196
|
+
cursor = parent;
|
|
197
|
+
}
|
|
198
|
+
// -pl 的取值:Maven 会把裸字符串当**相对 reactor 根的路径**,所以不能直接给 artifactId。
|
|
199
|
+
// 模块可能嵌在多层聚合里(root → modules → modules-xxx),
|
|
200
|
+
// 必须给「相对根目录的路径」,否则报 Could not find the selected project in the reactor。
|
|
201
|
+
const relative = path.relative(rootDir, moduleDir).split(path.sep).join('/');
|
|
202
|
+
return { moduleDir, artifactId, rootDir, pomFile, selector: relative || artifactId };
|
|
203
|
+
}
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function readText(file) {
|
|
208
|
+
try {
|
|
209
|
+
return fs.readFileSync(file, 'utf8');
|
|
210
|
+
} catch {
|
|
211
|
+
return '';
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function readArtifactId(pomFile) {
|
|
216
|
+
const text = readText(pomFile);
|
|
217
|
+
// 只看当前 project 直接子层的 artifactId(parent 里的不取)
|
|
218
|
+
const withoutParent = text.replace(/<parent>[\s\S]*?<\/parent>/, '');
|
|
219
|
+
const match = /<artifactId>([^<]+)<\/artifactId>/.exec(withoutParent);
|
|
220
|
+
return match ? match[1].trim() : '';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 探测编译该用的 Maven settings 与本地仓库。
|
|
225
|
+
*
|
|
226
|
+
* 背景:企业内部依赖通常只存在于用户自定义的 localRepository(IDEA 里配的那份),
|
|
227
|
+
* 而插件解析到的 mvn——尤其是 IDEA 自带的——不会去读它,于是报
|
|
228
|
+
* 「Could not find artifact xxx」。这里按优先级把 settings.xml / localRepository 找出来,
|
|
229
|
+
* 转成 `-s` / `-Dmaven.repo.local` 传给 mvn:
|
|
230
|
+
* 1. 项目(及 reactor 根上一级)的 .idea/workspace.xml 里 MavenGeneralSettings
|
|
231
|
+
* 的 userSettingsFile / localRepository——IDEA 用哪份,编译就用哪份;
|
|
232
|
+
* 2. mvn 自己的 conf/settings.xml(MAVEN_HOME/conf/settings.xml)。
|
|
233
|
+
*/
|
|
234
|
+
export function detectMavenSettings(rootDir, mvn = '') {
|
|
235
|
+
const result = { settingsFile: '', localRepository: '', mavenHome: '', source: '' };
|
|
236
|
+
// IDEA 项目可能开在任意一层(开在仓库根,也可能开在某个子目录);
|
|
237
|
+
// reactor 可能深 4-5 层,往上找 6 层,与 inferMavenModule 找 reactor 根的深度一致
|
|
238
|
+
const ideaFiles = [];
|
|
239
|
+
let cursor = rootDir;
|
|
240
|
+
for (let depth = 0; depth < 6 && cursor; depth += 1) {
|
|
241
|
+
ideaFiles.push(path.join(cursor, '.idea', 'workspace.xml'));
|
|
242
|
+
const parent = path.dirname(cursor);
|
|
243
|
+
if (parent === cursor) break;
|
|
244
|
+
cursor = parent;
|
|
245
|
+
}
|
|
246
|
+
for (const file of ideaFiles) {
|
|
247
|
+
if (!fileExists(file)) continue;
|
|
248
|
+
const text = readText(file);
|
|
249
|
+
const pick = (name) => {
|
|
250
|
+
const match = new RegExp(`<option name="${name}"\\s+value="([^"]+)"`).exec(text);
|
|
251
|
+
return match ? match[1].trim() : '';
|
|
252
|
+
};
|
|
253
|
+
const settings = pick('userSettingsFile');
|
|
254
|
+
const repo = pick('localRepository');
|
|
255
|
+
const mavenHome = pick('customMavenHome');
|
|
256
|
+
if (!settings && !repo && !mavenHome) continue;
|
|
257
|
+
if (!/<MavenGeneralSettings>/.test(text)) continue;
|
|
258
|
+
result.settingsFile = settings && fileExists(settings) ? settings : '';
|
|
259
|
+
result.localRepository = repo;
|
|
260
|
+
// IDEA 配了 customMavenHome 时,用它来编译——这就是 IDE 里 Build 用的那份 mvn
|
|
261
|
+
if (mavenHome) {
|
|
262
|
+
const bin = path.join(mavenHome, 'bin', isWindows ? 'mvn.cmd' : 'mvn');
|
|
263
|
+
if (fileExists(bin)) result.mavenHome = mavenHome;
|
|
264
|
+
}
|
|
265
|
+
result.source = file;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
// settings.xml 里通常也写了 <localRepository>(IDEA 只填 userSettingsFile 时兜底)
|
|
269
|
+
if (result.settingsFile && !result.localRepository) {
|
|
270
|
+
// 去掉 XML 注释再匹配,避免吃到文档里的示例 `<localRepository>/path/to/local/repo</localRepository>`
|
|
271
|
+
const text = readText(result.settingsFile).replace(/<!--[\s\S]*?-->/g, '');
|
|
272
|
+
const match = /<localRepository>([^<]+)<\/localRepository>/.exec(text);
|
|
273
|
+
if (match) result.localRepository = match[1].trim();
|
|
274
|
+
}
|
|
275
|
+
if (!result.settingsFile && mvn) {
|
|
276
|
+
// mvn 安装目录的 conf/settings.xml:<mvn home>/bin/mvn.cmd → <mvn home>/conf/settings.xml
|
|
277
|
+
const conf = path.resolve(path.dirname(path.dirname(mvn)), 'conf', 'settings.xml');
|
|
278
|
+
if (fileExists(conf)) {
|
|
279
|
+
result.settingsFile = conf;
|
|
280
|
+
result.source = conf;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 把探测结果转成 mvn 命令行参数 */
|
|
287
|
+
export function mavenSettingsArgs(detect) {
|
|
288
|
+
const args = [];
|
|
289
|
+
if (detect.settingsFile) args.push('-s', detect.settingsFile);
|
|
290
|
+
if (detect.localRepository) args.push(`-Dmaven.repo.local=${detect.localRepository}`);
|
|
291
|
+
return args;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 解码子进程输出。
|
|
296
|
+
* Maven 在中文 Windows 上按 GBK(平台默认编码)输出,Node 默认按 UTF-8 解码会变成乱码,
|
|
297
|
+
* 于是「找不到符号」这类关键信息全是问号。先按 UTF-8 严格解码,失败再退 GBK。
|
|
298
|
+
*/
|
|
299
|
+
function decodeOutput(buffer) {
|
|
300
|
+
if (!buffer) return '';
|
|
301
|
+
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(String(buffer), 'utf8');
|
|
302
|
+
if (buf.length === 0) return '';
|
|
303
|
+
try {
|
|
304
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
|
305
|
+
} catch {
|
|
306
|
+
try {
|
|
307
|
+
return new TextDecoder('gbk').decode(buf);
|
|
308
|
+
} catch {
|
|
309
|
+
return buf.toString('utf8');
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 执行 Maven 增量编译 */
|
|
315
|
+
export async function compileModule({ mvn, rootDir, artifactId, selector = '', timeoutMs = 300_000, extraArgs = [], javaHome = '' }) {
|
|
316
|
+
if (!mvn) return { ok: false, code: 'MVN_NOT_FOUND', message: '找不到 mvn,请在面板里指定 Maven 路径(或设置 MAVEN_HOME)' };
|
|
317
|
+
if (!rootDir || !artifactId) return { ok: false, code: 'MODULE_UNKNOWN', message: '无法从 classpath 推断 Maven 模块,请手动指定' };
|
|
318
|
+
// settings 先探测再拼参数:私有依赖只在用户自定义仓库里时,不传这个 mvn 必失败
|
|
319
|
+
const settings = detectMavenSettings(rootDir, mvn);
|
|
320
|
+
const settingsArgs = mavenSettingsArgs(settings);
|
|
321
|
+
// -pl 优先用相对 reactor 根的路径(模块嵌在多层聚合里时,裸 artifactId 会找不到)
|
|
322
|
+
const args = ['-B', '-q', '-pl', selector || artifactId, '-am', 'compile', '-DskipTests', ...settingsArgs, ...extraArgs];
|
|
323
|
+
const startedAt = Date.now();
|
|
324
|
+
// 编译 JDK 跟着运行进程走,避免「编译用 17、运行用 11」这类错配
|
|
325
|
+
const env = { ...process.env };
|
|
326
|
+
if (javaHome) env.JAVA_HOME = javaHome;
|
|
327
|
+
return new Promise((resolve) => {
|
|
328
|
+
const timer = setTimeout(() => {
|
|
329
|
+
child.kill();
|
|
330
|
+
resolve({ ok: false, code: 'COMPILE_TIMEOUT', message: `编译超时(>${timeoutMs}ms)`, durationMs: Date.now() - startedAt });
|
|
331
|
+
}, timeoutMs);
|
|
332
|
+
// Windows 上 .cmd 不是可执行文件,只能交给 cmd.exe。两条硬约束凑在一起很别扭:
|
|
333
|
+
// 1) 开 shell:true 会触发 Node 的 DEP0190 警告,且 mvn 路径含空格会被截断;
|
|
334
|
+
// 2) execFile 默认会给每个参数加引号,cmd /c 又会自己剥一层,结果引号翻倍。
|
|
335
|
+
// 所以整条命令行自己拼好,配 windowsVerbatimArguments 让 Node 原样传给 cmd.exe。
|
|
336
|
+
// 双引号包裹是 cmd /c 的固定要求:cmd /c ""C:\a b\x.cmd" -B ..."
|
|
337
|
+
const quote = (value) => (/[\s"]/.test(value) ? `"${value}"` : value);
|
|
338
|
+
const cmdLine = `""${mvn}" ${args.map(quote).join(' ')}"`;
|
|
339
|
+
const [file, fileArgs, verbatim] = isWindows && /\.cmd$/i.test(mvn)
|
|
340
|
+
? [process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', cmdLine], true]
|
|
341
|
+
: [mvn, args, false];
|
|
342
|
+
const child = execFile(
|
|
343
|
+
file,
|
|
344
|
+
fileArgs,
|
|
345
|
+
{ cwd: rootDir, env, timeout: timeoutMs, windowsHide: true, maxBuffer: 16 * 1024 * 1024, windowsVerbatimArguments: verbatim, encoding: 'buffer' },
|
|
346
|
+
(error, stdout, stderr) => {
|
|
347
|
+
clearTimeout(timer);
|
|
348
|
+
const output = `${decodeOutput(stdout)}${decodeOutput(stderr)}`.trim();
|
|
349
|
+
if (error) {
|
|
350
|
+
const tail = output.split(/\r?\n/).filter(Boolean).slice(-12).join('\n');
|
|
351
|
+
resolve({
|
|
352
|
+
ok: false,
|
|
353
|
+
code: 'COMPILE_FAILED',
|
|
354
|
+
message: `编译失败:${tail || error.message}`,
|
|
355
|
+
command: `${mvn} ${args.join(' ')}`,
|
|
356
|
+
cwd: rootDir,
|
|
357
|
+
settings,
|
|
358
|
+
durationMs: Date.now() - startedAt,
|
|
359
|
+
});
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
resolve({ ok: true, command: `${mvn} ${args.join(' ')}`, cwd: rootDir, durationMs: Date.now() - startedAt, output: output.slice(-2000), settings });
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
void child;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---- 进程控制 ----
|
|
370
|
+
|
|
371
|
+
/** 判断 pid 还活着 */
|
|
372
|
+
function pidAlive(pid) {
|
|
373
|
+
try {
|
|
374
|
+
process.kill(pid, 0);
|
|
375
|
+
return true;
|
|
376
|
+
} catch (error) {
|
|
377
|
+
return error?.code === 'EPERM';
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** 杀掉进程树(Windows 用 taskkill /T /F,其它平台杀进程组) */
|
|
382
|
+
export async function killProcessTree(pid, { timeoutMs = 15_000 } = {}) {
|
|
383
|
+
const target = Number(pid);
|
|
384
|
+
if (!Number.isInteger(target) || target <= 0) return { ok: false, message: 'pid 非法' };
|
|
385
|
+
if (!pidAlive(target)) return { ok: true, alreadyStopped: true, message: `pid ${target} 已不在运行` };
|
|
386
|
+
|
|
387
|
+
if (isWindows) {
|
|
388
|
+
const result = await new Promise((resolve) => {
|
|
389
|
+
execFile('taskkill', ['/PID', String(target), '/T', '/F'], { timeout: timeoutMs, windowsHide: true }, (error, stdout, stderr) =>
|
|
390
|
+
resolve({ error, stdout: String(stdout || ''), stderr: String(stderr || '') }),
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
// taskkill 对已退出的进程会报「没有找到」,不算失败
|
|
394
|
+
if (result.error && !/not found|没有找到|不存在/i.test(`${result.stdout}${result.stderr}`)) {
|
|
395
|
+
try {
|
|
396
|
+
process.kill(target, 'SIGKILL');
|
|
397
|
+
} catch {
|
|
398
|
+
/* 已经没了 */
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
try {
|
|
403
|
+
process.kill(-target, 'SIGKILL');
|
|
404
|
+
} catch {
|
|
405
|
+
try {
|
|
406
|
+
process.kill(target, 'SIGKILL');
|
|
407
|
+
} catch {
|
|
408
|
+
/* 已经没了 */
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const deadline = Date.now() + timeoutMs;
|
|
414
|
+
while (Date.now() < deadline) {
|
|
415
|
+
if (!pidAlive(target)) return { ok: true, message: `已结束 pid ${target}` };
|
|
416
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
417
|
+
}
|
|
418
|
+
return { ok: false, message: `等待 pid ${target} 退出超时(${timeoutMs}ms)` };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* 用快照参数拉起新进程。
|
|
423
|
+
* @returns {{ok:boolean, pid?:number, logPath:string, message?:string}}
|
|
424
|
+
*/
|
|
425
|
+
export function spawnService({ javaExe, argv, cwd, logPath }) {
|
|
426
|
+
const exe = javaExe || (isWindows ? 'java.exe' : 'java');
|
|
427
|
+
if (logPath) {
|
|
428
|
+
try {
|
|
429
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
430
|
+
} catch {
|
|
431
|
+
/* 建不了目录就让 spawn 报错 */
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
let out;
|
|
435
|
+
try {
|
|
436
|
+
out = logPath ? fs.openSync(logPath, 'a') : 'ignore';
|
|
437
|
+
} catch {
|
|
438
|
+
out = 'ignore';
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const closeOut = () => {
|
|
442
|
+
if (typeof out === 'number') {
|
|
443
|
+
try {
|
|
444
|
+
fs.closeSync(out);
|
|
445
|
+
} catch {
|
|
446
|
+
/* 已交给子进程 */
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* 返回 Promise:等到 'spawn' 或 'error' 再决定成败。
|
|
453
|
+
*
|
|
454
|
+
* 为什么不能直接返回 `{ok:true}`:可执行文件不存在时 spawn **不抛异常**,
|
|
455
|
+
* 而是异步发 'error';没有监听器时 Node 把它当未捕获异常,会把 DSH 整个进程崩掉。
|
|
456
|
+
* 一键启动要连续拉起多个服务,其中一个 javaExe 配错(换机器、JDK 路径变了)
|
|
457
|
+
* 就足以让宿主挂掉,所以这里必须挂监听器,并且如实报告失败原因。
|
|
458
|
+
*/
|
|
459
|
+
return new Promise((resolve) => {
|
|
460
|
+
let child;
|
|
461
|
+
try {
|
|
462
|
+
child = spawn(exe, argv, {
|
|
463
|
+
cwd: cwd || undefined,
|
|
464
|
+
detached: true,
|
|
465
|
+
stdio: ['ignore', out, out],
|
|
466
|
+
windowsHide: true,
|
|
467
|
+
});
|
|
468
|
+
} catch (error) {
|
|
469
|
+
closeOut();
|
|
470
|
+
resolve({ ok: false, message: `拉起进程失败:${error?.message || error}`, logPath: logPath || '' });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
let settled = false;
|
|
475
|
+
const settle = (value) => {
|
|
476
|
+
if (settled) return;
|
|
477
|
+
settled = true;
|
|
478
|
+
closeOut();
|
|
479
|
+
resolve(value);
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
child.once('error', (error) => {
|
|
483
|
+
settle({ ok: false, message: `拉起进程失败:${error?.message || error}`, logPath: logPath || '' });
|
|
484
|
+
});
|
|
485
|
+
child.once('spawn', () => {
|
|
486
|
+
child.unref();
|
|
487
|
+
settle({ ok: true, pid: child.pid, logPath: logPath || '' });
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** 等端口开始 LISTENING(用 TCP 连接探测,不依赖 netstat) */
|
|
493
|
+
export async function waitPort(host, port, { timeoutMs = 120_000, intervalMs = 500 } = {}) {
|
|
494
|
+
const net = await import('node:net');
|
|
495
|
+
const startedAt = Date.now();
|
|
496
|
+
const deadline = startedAt + timeoutMs;
|
|
497
|
+
const probe = () =>
|
|
498
|
+
new Promise((resolve) => {
|
|
499
|
+
const socket = net.connect({ host: host || '127.0.0.1', port: Number(port) });
|
|
500
|
+
let settled = false;
|
|
501
|
+
const finish = (ok) => {
|
|
502
|
+
if (settled) return;
|
|
503
|
+
settled = true;
|
|
504
|
+
socket.destroy();
|
|
505
|
+
resolve(ok);
|
|
506
|
+
};
|
|
507
|
+
socket.setTimeout(1500, () => finish(false));
|
|
508
|
+
socket.once('connect', () => finish(true));
|
|
509
|
+
socket.once('error', () => finish(false));
|
|
510
|
+
});
|
|
511
|
+
while (Date.now() < deadline) {
|
|
512
|
+
if (await probe()) return { ok: true, waitedMs: Date.now() - startedAt };
|
|
513
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
514
|
+
}
|
|
515
|
+
return { ok: false, waitedMs: Date.now() - startedAt };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** 等端口释放(重启前必须先确认旧进程真的放掉了端口,否则会误判「新进程已就绪」) */
|
|
519
|
+
export async function waitPortFree(host, port, { timeoutMs = 20_000, intervalMs = 200 } = {}) {
|
|
520
|
+
const net = await import('node:net');
|
|
521
|
+
const startedAt = Date.now();
|
|
522
|
+
const deadline = startedAt + timeoutMs;
|
|
523
|
+
const probe = () =>
|
|
524
|
+
new Promise((resolve) => {
|
|
525
|
+
const socket = net.connect({ host: host || '127.0.0.1', port: Number(port) });
|
|
526
|
+
let settled = false;
|
|
527
|
+
const finish = (connected) => {
|
|
528
|
+
if (settled) return;
|
|
529
|
+
settled = true;
|
|
530
|
+
socket.destroy();
|
|
531
|
+
resolve(connected);
|
|
532
|
+
};
|
|
533
|
+
socket.setTimeout(1200, () => finish(false));
|
|
534
|
+
socket.once('connect', () => finish(true));
|
|
535
|
+
socket.once('error', () => finish(false));
|
|
536
|
+
});
|
|
537
|
+
while (Date.now() < deadline) {
|
|
538
|
+
if (!(await probe())) return { ok: true, waitedMs: Date.now() - startedAt };
|
|
539
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
540
|
+
}
|
|
541
|
+
return { ok: false, waitedMs: Date.now() - startedAt };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** 进程是否还在 */
|
|
545
|
+
export function isAlive(pid) {
|
|
546
|
+
return pidAlive(pid);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export { getJavaSnapshot };
|