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/service.js ADDED
@@ -0,0 +1,329 @@
1
+ /**
2
+ * 本地服务管理:两种模式。
3
+ *
4
+ * managed —— 由本插件 spawn 子进程(默认 detached,DSH 重启不影响已起服务),
5
+ * pid 持久化在状态文件里,重启 = 杀进程树 + 重新 spawn + 等健康检查;
6
+ * observe —— 插件不动进程(进程由 IDEA 起,idea 的 restart 按钮),只能轮询健康
7
+ * 检查/端口判断"是否起来了",restart 会明确报错让用户去 IDE 里重启。
8
+ *
9
+ * 健康检查三选一(按配置存在与否):
10
+ * health.url —— HTTP 请求,状态码等于 expectStatus(未配置则 2xx/3xx 视为通过)
11
+ * health.ports—— TCP 端口可连通
12
+ * 都没有 —— managed 模式以"子进程仍存活"为准
13
+ */
14
+ import fs from 'node:fs';
15
+ import net from 'node:net';
16
+ import path from 'node:path';
17
+ import { spawn } from 'node:child_process';
18
+ import { isPlainObject, isPidAlive, readStateNamespace, sleep, writeStateNamespace } from './util.js';
19
+
20
+ const isWindows = process.platform === 'win32';
21
+
22
+ /** 打开日志文件用于追加(失败返回 'ignore') */
23
+ function openAppend(logPath) {
24
+ if (!logPath) return 'ignore';
25
+ try {
26
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
27
+ return fs.openSync(logPath, 'a');
28
+ } catch {
29
+ return 'ignore';
30
+ }
31
+ }
32
+
33
+ /** TCP 连通性探测 */
34
+ function probePort(host, port, timeoutMs = 1500) {
35
+ return new Promise((resolve) => {
36
+ const socket = net.connect({ host, port: Number(port) });
37
+ let settled = false;
38
+ const finish = (ok) => {
39
+ if (settled) return;
40
+ settled = true;
41
+ socket.destroy();
42
+ resolve(ok);
43
+ };
44
+ socket.setTimeout(timeoutMs, () => finish(false));
45
+ socket.once('connect', () => finish(true));
46
+ socket.once('error', () => finish(false));
47
+ });
48
+ }
49
+
50
+ /** HTTP 健康探测 */
51
+ async function probeHttp(health) {
52
+ if (!health.url) return undefined;
53
+ try {
54
+ const response = await fetch(health.url, {
55
+ method: health.method || 'GET',
56
+ signal: AbortSignal.timeout(health.timeoutMs || 3000),
57
+ redirect: 'manual',
58
+ });
59
+ const expected = Number(health.expectStatus || 0);
60
+ const ok = expected ? response.status === expected : response.status < 400;
61
+ return { ok, detail: `HTTP ${response.status}` };
62
+ } catch (error) {
63
+ return { ok: false, detail: `请求失败:${error?.message || error}` };
64
+ }
65
+ }
66
+
67
+ export class ServiceManager {
68
+ /**
69
+ * @param {{statePath:string, homeDir:string}} options
70
+ */
71
+ constructor(options = {}) {
72
+ this.statePath = options.statePath || '';
73
+ this.children = new Map();
74
+ this._state = null;
75
+ }
76
+
77
+ _load() {
78
+ if (this._state) return this._state;
79
+ this._state = readStateNamespace(this.statePath, 'services');
80
+ return this._state;
81
+ }
82
+
83
+ _record(serviceId) {
84
+ const state = this._load();
85
+ return isPlainObject(state[serviceId]) ? state[serviceId] : undefined;
86
+ }
87
+
88
+ _setRecord(serviceId, record) {
89
+ const state = this._load();
90
+ if (record) state[serviceId] = record;
91
+ else delete state[serviceId];
92
+ this._flush();
93
+ }
94
+
95
+ _flush() {
96
+ if (!this.statePath || !this._state) return;
97
+ // 只更新 services 命名空间,保留日志游标
98
+ writeStateNamespace(this.statePath, 'services', this._state);
99
+ }
100
+
101
+ /** 当前托管的 pid(内存优先,回退状态文件,并确认存活) */
102
+ pidOf(service) {
103
+ const live = this.children.get(service.id);
104
+ if (live?.pid && isPidAlive(live.pid)) return live.pid;
105
+ const record = this._record(service.id);
106
+ if (record?.pid && isPidAlive(record.pid)) return record.pid;
107
+ return 0;
108
+ }
109
+
110
+ /** 健康检查:返回 {ok, checks:[{kind, ok, detail}]} */
111
+ async check(service) {
112
+ const checks = [];
113
+ const pid = this.pidOf(service);
114
+ if (service.mode === 'managed') {
115
+ checks.push({ kind: 'process', ok: pid > 0, detail: pid > 0 ? `pid ${pid} 存活` : '未发现托管进程' });
116
+ }
117
+ const http = await probeHttp(service.health);
118
+ if (http) checks.push({ kind: 'http', ...http, detail: `${service.health.url} → ${http.detail}` });
119
+
120
+ for (const port of service.health.ports || []) {
121
+ const ok = await probePort(service.health.host || '127.0.0.1', port, service.health.timeoutMs || 1500);
122
+ checks.push({ kind: 'tcp', ok, detail: `${service.health.host || '127.0.0.1'}:${port} ${ok ? '可连接' : '连接失败'}` });
123
+ }
124
+
125
+ if (checks.length === 0) {
126
+ return { ok: pid > 0, checks: [{ kind: 'process', ok: pid > 0, detail: pid > 0 ? `pid ${pid} 存活` : '无健康检查配置且无托管进程' }] };
127
+ }
128
+ // process 检查只作为辅助信息:observe 模式下没有 pid 是正常的
129
+ const effective = checks.filter((item) => item.kind !== 'process');
130
+ const ok = effective.length > 0 ? effective.every((item) => item.ok) : checks.every((item) => item.ok);
131
+ return { ok, checks };
132
+ }
133
+
134
+ /** 轮询等到健康;返回 {ok, waitedMs, checks} */
135
+ async waitHealthy(service, timeoutMs = service.startupTimeoutMs || 60_000) {
136
+ const started = Date.now();
137
+ const interval = Math.max(200, service.health.intervalMs || 1000);
138
+ let last = await this.check(service);
139
+ while (!last.ok && Date.now() - started < timeoutMs) {
140
+ await sleep(interval);
141
+ last = await this.check(service);
142
+ }
143
+ return { ok: last.ok, waitedMs: Date.now() - started, checks: last.checks };
144
+ }
145
+
146
+ /** 启动(managed);已健康则直接返回 */
147
+ async start(service, options = {}) {
148
+ if (service.mode !== 'managed') {
149
+ return {
150
+ ok: false,
151
+ reason: 'OBSERVE_MODE',
152
+ message: `服务 ${service.id} 是 observe 模式,插件不托管进程;请在 IDEA 里启动后调用 wait/status 等待就绪。`,
153
+ };
154
+ }
155
+ const existing = await this.check(service);
156
+ if (existing.ok && !options.force) {
157
+ return { ok: true, alreadyRunning: true, reason: 'ALREADY_RUNNING', pid: this.pidOf(service), checks: existing.checks };
158
+ }
159
+ if (this.pidOf(service) > 0) {
160
+ // 进程在但不健康:先杀掉再起,避免端口冲突
161
+ await this.stop(service);
162
+ }
163
+ if (!service.command) {
164
+ return { ok: false, reason: 'NO_COMMAND', message: `服务 ${service.id} 未配置 command` };
165
+ }
166
+
167
+ const logFd = openAppend(service.logPath);
168
+ const [file, ...args] = [service.command, ...service.args];
169
+ let child;
170
+ try {
171
+ child = spawn(file, args, {
172
+ cwd: service.cwd || process.cwd(),
173
+ env: { ...process.env, ...service.env },
174
+ detached: true,
175
+ stdio: ['ignore', logFd, logFd],
176
+ shell: isWindows && /\.(cmd|bat)$/i.test(file),
177
+ windowsHide: true,
178
+ });
179
+ } catch (error) {
180
+ if (typeof logFd === 'number') {
181
+ try {
182
+ fs.closeSync(logFd);
183
+ } catch {
184
+ // 关闭失败无影响
185
+ }
186
+ }
187
+ return { ok: false, reason: 'SPAWN_FAILED', message: `启动失败:${error.message}` };
188
+ }
189
+ // 子进程已继承该 fd,父进程必须关掉自己那份,否则文件句柄泄漏、日志文件被占用
190
+ if (typeof logFd === 'number') {
191
+ try {
192
+ fs.closeSync(logFd);
193
+ } catch {
194
+ // 关闭失败无影响
195
+ }
196
+ }
197
+ child.unref?.();
198
+ this.children.set(service.id, child);
199
+
200
+ let exitInfo;
201
+ child.once('exit', (code, signal) => {
202
+ exitInfo = { code, signal };
203
+ if (this.children.get(service.id) === child) this.children.delete(service.id);
204
+ });
205
+ this._setRecord(service.id, {
206
+ pid: child.pid,
207
+ startedAt: new Date().toISOString(),
208
+ command: [service.command, ...service.args].join(' '),
209
+ cwd: service.cwd,
210
+ });
211
+
212
+ const health = await this.waitHealthy(service, options.timeoutMs || service.startupTimeoutMs);
213
+ if (health.ok) {
214
+ return { ok: true, pid: child.pid, waitedMs: health.waitedMs, checks: health.checks };
215
+ }
216
+ return {
217
+ ok: false,
218
+ reason: 'START_TIMEOUT',
219
+ pid: child.pid,
220
+ waitedMs: health.waitedMs,
221
+ checks: health.checks,
222
+ exited: exitInfo,
223
+ message: exitInfo
224
+ ? `进程启动后立即退出(code=${exitInfo.code}, signal=${exitInfo.signal}),检查 command/cwd 与日志`
225
+ : `启动后 ${health.waitedMs}ms 内健康检查未通过`,
226
+ };
227
+ }
228
+
229
+ /** 停止(managed):Windows 用 taskkill /T /F 杀进程树,其它平台杀进程组 */
230
+ async stop(service) {
231
+ if (service.mode !== 'managed') {
232
+ return { ok: false, reason: 'OBSERVE_MODE', message: `服务 ${service.id} 是 observe 模式,请到 IDEA 里停止。` };
233
+ }
234
+ const pid = this.pidOf(service);
235
+ if (pid === 0) {
236
+ this._setRecord(service.id, undefined);
237
+ return { ok: true, alreadyStopped: true };
238
+ }
239
+ const killed = await this._killTree(pid, service.killTree !== false);
240
+ this.children.delete(service.id);
241
+ const deadline = Date.now() + (service.stopTimeoutMs || 10_000);
242
+ while (Date.now() < deadline && isPidAlive(pid)) await sleep(120);
243
+ const stillAlive = isPidAlive(pid);
244
+ if (!stillAlive) this._setRecord(service.id, undefined);
245
+ return {
246
+ ok: !stillAlive,
247
+ pid,
248
+ killedBy: killed,
249
+ stillAlive,
250
+ message: stillAlive ? `pid ${pid} 在 ${service.stopTimeoutMs}ms 内未退出` : `已停止 pid ${pid}`,
251
+ };
252
+ }
253
+
254
+ _killTree(pid, tree) {
255
+ return new Promise((resolve) => {
256
+ if (isWindows) {
257
+ const args = ['/PID', String(pid), '/T', '/F'];
258
+ const killer = spawn('taskkill', args, { windowsHide: true, stdio: 'ignore' });
259
+ killer.once('exit', (code) => {
260
+ if (code === 0) {
261
+ resolve('taskkill');
262
+ return;
263
+ }
264
+ try {
265
+ process.kill(pid);
266
+ resolve('kill');
267
+ } catch {
268
+ resolve('none');
269
+ }
270
+ });
271
+ killer.once('error', () => {
272
+ try {
273
+ process.kill(pid);
274
+ resolve('kill');
275
+ } catch {
276
+ resolve('none');
277
+ }
278
+ });
279
+ return;
280
+ }
281
+ try {
282
+ // detached 启动 => 子进程是进程组 leader
283
+ process.kill(tree ? -pid : pid, 'SIGTERM');
284
+ resolve('SIGTERM');
285
+ } catch {
286
+ resolve('none');
287
+ }
288
+ });
289
+ }
290
+
291
+ /** 重启(managed):stop + start,返回带耗时与阶段的结果 */
292
+ async restart(service, options = {}) {
293
+ if (service.mode !== 'managed') {
294
+ // observe 模式:不能替用户重启,但可以等待用户手动重启后恢复
295
+ return {
296
+ ok: false,
297
+ reason: 'OBSERVE_MODE',
298
+ message: `服务 ${service.id} 是 observe 模式,插件不托管进程。请在 IDEA 里重启,然后调用 action=wait 等待健康检查通过。`,
299
+ };
300
+ }
301
+ const stopped = await this.stop(service);
302
+ if (!stopped.ok) return { ok: false, stage: 'stop', ...stopped };
303
+ const started = await this.start(service, { ...options, force: true });
304
+ return { ok: started.ok, stage: started.ok ? 'done' : 'start', stop: stopped, start: started };
305
+ }
306
+
307
+ /** 概览:所有配置服务的实时状态 */
308
+ async statusAll(services) {
309
+ const out = [];
310
+ for (const service of services) {
311
+ const pid = this.pidOf(service);
312
+ const health = await this.check(service);
313
+ out.push({
314
+ id: service.id,
315
+ name: service.name,
316
+ mode: service.mode,
317
+ pid: pid || undefined,
318
+ healthy: health.ok,
319
+ checks: health.checks,
320
+ });
321
+ }
322
+ return out;
323
+ }
324
+
325
+ /** 插件卸载:只保存状态,不杀进程(服务应在 DSH 重启后继续可用) */
326
+ dispose() {
327
+ this._flush();
328
+ }
329
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 插件级设置(跨项目共用一份)。
3
+ *
4
+ * 存在 `${DSH_HOME}/api-runner-settings.json`,与项目配置分开:
5
+ * 项目配置(YAML / 面板保存的接口)是「这个项目怎么调接口」,
6
+ * 这里是「插件把运行数据写到哪」这类机器级偏好。
7
+ *
8
+ * 结构:
9
+ * {
10
+ * version: 1,
11
+ * dataDir: "/abs/path/.dsh-api-runner", // 空表示用 DSH_HOME;必须是绝对路径
12
+ * workspace: "/abs/path/your-project", // 面板默认项目根(可选)
13
+ * mavenHome: "/abs/path/apache-maven-3.6.3" // Maven 主路径(可选;空表示自动探测)
14
+ * }
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { isPlainObject } from './util.js';
19
+
20
+ export class SettingsStore {
21
+ constructor(options = {}) {
22
+ this.homeDir = options.homeDir || process.env.DSH_HOME || path.join(process.cwd(), '.dsh');
23
+ this.file = path.join(this.homeDir, 'api-runner-settings.json');
24
+ this._doc = undefined;
25
+ this._mtimeMs = -1;
26
+ }
27
+
28
+ _load() {
29
+ let stat;
30
+ try {
31
+ stat = fs.statSync(this.file);
32
+ } catch {
33
+ this._doc = { version: 1, dataDir: '', workspace: '', mavenHome: '' };
34
+ this._mtimeMs = -1;
35
+ return this._doc;
36
+ }
37
+ if (this._doc !== undefined && stat.mtimeMs === this._mtimeMs) return this._doc;
38
+ try {
39
+ const parsed = JSON.parse(fs.readFileSync(this.file, 'utf8'));
40
+ this._doc = isPlainObject(parsed) ? parsed : { version: 1 };
41
+ } catch {
42
+ this._doc = { version: 1 };
43
+ }
44
+ this._mtimeMs = stat.mtimeMs;
45
+ return this._doc;
46
+ }
47
+
48
+ _save(doc) {
49
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
50
+ const tmp = `${this.file}.tmp`;
51
+ fs.writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8');
52
+ fs.renameSync(tmp, this.file);
53
+ this._doc = doc;
54
+ this._mtimeMs = fs.statSync(this.file).mtimeMs;
55
+ }
56
+
57
+ /** 当前设置(对外展示用) */
58
+ read() {
59
+ const doc = this._load();
60
+ return {
61
+ dataDir: String(doc.dataDir || ''),
62
+ workspace: String(doc.workspace || ''),
63
+ mavenHome: String(doc.mavenHome || ''),
64
+ /** 生效的运行数据目录:没配就用 DSH_HOME */
65
+ effectiveDataDir: this.dataDir(),
66
+ homeDir: this.homeDir,
67
+ settingsFile: this.file,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * 运行数据落盘目录。
73
+ * 配了 dataDir 就用它(相对路径按 DSH_HOME 解析,避免用户填了个相对路径不知道落到哪);
74
+ * 否则退回 DSH_HOME,与老版本行为一致。
75
+ */
76
+ dataDir() {
77
+ const doc = this._load();
78
+ const raw = String(doc.dataDir || '').trim();
79
+ if (!raw) return this.homeDir;
80
+ return path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(this.homeDir, raw);
81
+ }
82
+
83
+ set(patch = {}) {
84
+ const doc = this._load();
85
+ if (patch.dataDir !== undefined) {
86
+ const raw = String(patch.dataDir || '').trim();
87
+ if (raw !== '' && !path.isAbsolute(raw)) {
88
+ const sample = process.platform === 'win32' ? 'D:\\dsh-api-runner-data' : '/Users/you/dsh-api-runner-data';
89
+ throw new Error(`数据目录必须是绝对路径,例如 ${sample}`);
90
+ }
91
+ doc.dataDir = raw;
92
+ }
93
+ if (patch.workspace !== undefined) {
94
+ const raw = String(patch.workspace || '').trim();
95
+ doc.workspace = raw;
96
+ }
97
+ if (patch.mavenHome !== undefined) {
98
+ const raw = String(patch.mavenHome || '').trim();
99
+ if (raw !== '' && !path.isAbsolute(raw)) {
100
+ const sample = process.platform === 'win32' ? 'D:\\apache-maven-3.9.6' : '/opt/apache-maven-3.9.6';
101
+ throw new Error(`Maven 主路径必须是绝对路径(到安装目录为止),例如 ${sample}`);
102
+ }
103
+ doc.mavenHome = raw;
104
+ }
105
+ doc.version = 1;
106
+ this._save(doc);
107
+ return this.read();
108
+ }
109
+ }
package/src/util.js ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * 通用小工具:配置发现、模板展开、文本裁剪、进程存活判断。
3
+ * 本文件不依赖 cordis,可在纯 node 下单独测试。
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ /** 是否普通对象(排除数组、null) */
9
+ export function isPlainObject(value) {
10
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
11
+ }
12
+
13
+ /** 按“字节上限”截断文本,并给出截断提示 */
14
+ export function truncateText(text, maxBytes = 32 * 1024) {
15
+ if (typeof text !== 'string') return '';
16
+ const buf = Buffer.from(text, 'utf8');
17
+ if (buf.length <= maxBytes) return text;
18
+ // 从末尾往前找换行,避免切碎多字节字符
19
+ let end = maxBytes;
20
+ const slice = buf.subarray(0, end).toString('utf8');
21
+ const cut = slice.lastIndexOf('\n');
22
+ const body = cut > 0 ? slice.slice(0, cut) : slice;
23
+ return `${body}\n…[已截断,原始 ${buf.length} 字节,仅显示前 ${maxBytes} 字节]`;
24
+ }
25
+
26
+ /** 展开 ${NAME}:先查 scope,再查 process.env;查不到保持原样 */
27
+ export function expandDollar(source, scope = {}) {
28
+ if (typeof source !== 'string') return source;
29
+ return source.replace(/\$\{([A-Za-z_][A-Za-z0-9_.-]*)\}/g, (whole, name) => {
30
+ if (Object.prototype.hasOwnProperty.call(scope, name) && scope[name] !== undefined && scope[name] !== null) {
31
+ return String(scope[name]);
32
+ }
33
+ const envValue = process.env[name];
34
+ return envValue === undefined ? whole : envValue;
35
+ });
36
+ }
37
+
38
+ /** 展开调用期占位符 {key}:只替换 vars 中存在的键,其余原样保留 */
39
+ export function expandBraces(source, vars = {}) {
40
+ if (typeof source !== 'string') return source;
41
+ if (!vars || Object.keys(vars).length === 0) return source;
42
+ return source.replace(/\{([A-Za-z_][A-Za-z0-9_.-]*)\}/g, (whole, name) => {
43
+ return Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : whole;
44
+ });
45
+ }
46
+
47
+ /** 递归展开对象/数组里的字符串模板 */
48
+ export function expandDeep(value, scope = {}, vars = {}) {
49
+ if (typeof value === 'string') return expandBraces(expandDollar(value, scope), vars);
50
+ if (Array.isArray(value)) return value.map((item) => expandDeep(item, scope, vars));
51
+ if (isPlainObject(value)) {
52
+ const out = {};
53
+ for (const [key, item] of Object.entries(value)) out[expandBraces(expandDollar(key, scope), vars)] = expandDeep(item, scope, vars);
54
+ return out;
55
+ }
56
+ return value;
57
+ }
58
+
59
+ /** 沿目录向上查找配置文件,返回绝对路径或 '' */
60
+ export function findUp(startDir, fileNames, maxDepth = 8) {
61
+ let dir = path.resolve(startDir || '.');
62
+ for (let depth = 0; depth <= maxDepth; depth += 1) {
63
+ for (const name of fileNames) {
64
+ const candidate = path.join(dir, name);
65
+ try {
66
+ if (fs.statSync(candidate).isFile()) return candidate;
67
+ } catch {
68
+ // 不存在,继续
69
+ }
70
+ }
71
+ const parent = path.dirname(dir);
72
+ if (parent === dir) break;
73
+ dir = parent;
74
+ }
75
+ return '';
76
+ }
77
+
78
+ /** 统一项目标识:盘符小写、正斜杠、去掉末尾斜杠 */
79
+ export function normalizeProjectKey(cwd) {
80
+ if (!cwd || typeof cwd !== 'string') return '';
81
+ let key = path.resolve(cwd).replace(/\\/g, '/');
82
+ if (/^[A-Za-z]:/.test(key)) key = key[0].toLowerCase() + key.slice(1);
83
+ return key.replace(/\/+$/, '');
84
+ }
85
+
86
+ /** 敏感请求头脱敏(只用于展示/返回给模型前的日志副本) */
87
+ export function redactHeaders(headers) {
88
+ // 兼顾写法差异:标准头(authorization/cookie)、自定义头(X-Token、X-Api-Key)、
89
+ // 以及名字里带 token/secret/password/key 的任意头,一律按敏感处理。
90
+ const exact = /^(authorization|cookie|set-cookie|proxy-authorization)$/i;
91
+ const fuzzy = /(^|[-_])(token|secret|password|passwd|pwd|key|auth|credential|signature)([-_]|$)/i;
92
+ const out = {};
93
+ for (const [key, value] of Object.entries(headers || {})) {
94
+ out[key] = exact.test(key) || fuzzy.test(key) ? maskSecret(String(value)) : value;
95
+ }
96
+ return out;
97
+ }
98
+
99
+ export function maskSecret(text) {
100
+ if (text.length <= 8) return '***';
101
+ return `${text.slice(0, 4)}***${text.slice(-4)}`;
102
+ }
103
+
104
+ /** 尝试 JSON.parse,失败返回 undefined */
105
+ export function tryJson(text) {
106
+ if (typeof text !== 'string' || text.trim() === '') return undefined;
107
+ try {
108
+ return JSON.parse(text);
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ }
113
+
114
+ export function sleep(ms) {
115
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
116
+ }
117
+
118
+ /** 进程是否存活(EPERM 视为存活:存在但无权限) */
119
+ export function isPidAlive(pid) {
120
+ const num = Number(pid);
121
+ if (!Number.isInteger(num) || num <= 0) return false;
122
+ try {
123
+ process.kill(num, 0);
124
+ return true;
125
+ } catch (error) {
126
+ return error?.code === 'EPERM';
127
+ }
128
+ }
129
+
130
+ /** 相对路径按 baseDir 展开;绝对路径原样 */
131
+ export function resolveFrom(baseDir, target) {
132
+ if (!target) return '';
133
+ return path.isAbsolute(target) ? path.normalize(target) : path.resolve(baseDir, target);
134
+ }
135
+
136
+ /**
137
+ * 读取状态文件里的某个命名空间。
138
+ * 日志游标与服务 pid 共用同一个状态文件,所以必须按命名空间分开读写,
139
+ * 否则一个组件写盘时会把另一个组件的记录整体抹掉。
140
+ */
141
+ export function readStateNamespace(statePath, key) {
142
+ if (!statePath) return {};
143
+ try {
144
+ const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
145
+ if (isPlainObject(parsed) && isPlainObject(parsed[key])) return parsed[key];
146
+ } catch {
147
+ // 首次运行或文件损坏,视为空
148
+ }
149
+ return {};
150
+ }
151
+
152
+ /** 只更新状态文件里的某个命名空间,保留其它命名空间(原子替换) */
153
+ export function writeStateNamespace(statePath, key, value) {
154
+ if (!statePath) return;
155
+ let doc = {};
156
+ try {
157
+ const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
158
+ if (isPlainObject(parsed)) doc = parsed;
159
+ } catch {
160
+ // 首次运行或文件损坏,从空文档开始
161
+ }
162
+ doc.version = 1;
163
+ doc[key] = value;
164
+ try {
165
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
166
+ const tmp = `${statePath}.tmp`;
167
+ fs.writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8');
168
+ fs.renameSync(tmp, statePath);
169
+ } catch {
170
+ // 状态写失败不影响本次操作
171
+ }
172
+ }
173
+
174
+ /** 对象深合并:后者覆盖前者,数组整体替换 */
175
+ export function deepMerge(base, patch) {
176
+ if (!isPlainObject(base) || !isPlainObject(patch)) return patch === undefined ? base : patch;
177
+ const out = { ...base };
178
+ for (const [key, value] of Object.entries(patch)) {
179
+ out[key] = isPlainObject(value) && isPlainObject(base[key]) ? deepMerge(base[key], value) : value;
180
+ }
181
+ return out;
182
+ }