@mebular/service 0.0.0-stage → 0.1.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/dist/cli.d.ts +17 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +120 -0
- package/dist/cli.js.map +1 -0
- package/dist/heartbeat.d.ts +30 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/heartbeat.js +69 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/launchd.d.ts +32 -0
- package/dist/launchd.d.ts.map +1 -0
- package/dist/launchd.js +102 -0
- package/dist/launchd.js.map +1 -0
- package/dist/manager.d.ts +169 -0
- package/dist/manager.d.ts.map +1 -0
- package/dist/manager.js +505 -0
- package/dist/manager.js.map +1 -0
- package/dist/paths.d.ts +17 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +29 -0
- package/dist/paths.js.map +1 -0
- package/dist/units.d.ts +50 -0
- package/dist/units.d.ts.map +1 -0
- package/dist/units.js +173 -0
- package/dist/units.js.map +1 -0
- package/dist/version.json +6 -0
- package/package.json +28 -4
- package/src/cli.ts +139 -0
- package/src/heartbeat.ts +89 -0
- package/src/index.ts +8 -0
- package/src/launchd.ts +111 -0
- package/src/manager.ts +686 -0
- package/src/paths.ts +40 -0
- package/src/units.ts +208 -0
- package/README.md +0 -3
package/src/manager.ts
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
// 服务管理(install/uninstall/status/logs)与共用 CLI。
|
|
2
|
+
//
|
|
3
|
+
// 平台机制(D3):
|
|
4
|
+
// - darwin:launchd 用户级 LaunchAgent(launchctl bootstrap/bootout,KeepAlive 自拉)
|
|
5
|
+
// - linux :systemd --user(systemctl --user enable/start,Restart=on-failure)
|
|
6
|
+
// - win32 :Task Scheduler onlogon(schtasks,无需管理员;真 Windows Service 为后续可选项)
|
|
7
|
+
//
|
|
8
|
+
// 所有外部命令经可注入的 `RunCommand`,使 install/uninstall 的幂等逻辑可被 hermetic 测试。
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_LABEL_PREFIX,
|
|
16
|
+
SERVICE_KINDS,
|
|
17
|
+
defaultLabel,
|
|
18
|
+
legacyLabel,
|
|
19
|
+
systemdUnitFileName,
|
|
20
|
+
unitText,
|
|
21
|
+
type ServiceKind,
|
|
22
|
+
type ServicePlatform,
|
|
23
|
+
type UnitSpec,
|
|
24
|
+
} from './units.js';
|
|
25
|
+
import { describeJobOwner, jobBelongsToHome, parseLaunchdPrint, type LaunchdJob } from './launchd.js';
|
|
26
|
+
import {
|
|
27
|
+
launchAgentsDir,
|
|
28
|
+
manifestPath,
|
|
29
|
+
serviceLogPaths,
|
|
30
|
+
serviceLogsDir,
|
|
31
|
+
serviceStateDir,
|
|
32
|
+
systemdUserDir,
|
|
33
|
+
unitFilePath,
|
|
34
|
+
windowsTaskDir,
|
|
35
|
+
} from './paths.js';
|
|
36
|
+
import { isHeartbeatFresh, readHeartbeat } from './heartbeat.js';
|
|
37
|
+
|
|
38
|
+
export interface RunResult {
|
|
39
|
+
code: number;
|
|
40
|
+
stdout: string;
|
|
41
|
+
stderr: string;
|
|
42
|
+
}
|
|
43
|
+
export type RunCommand = (cmd: string, args: readonly string[]) => RunResult;
|
|
44
|
+
|
|
45
|
+
export const defaultRun: RunCommand = (cmd, args) => {
|
|
46
|
+
const res = spawnSync(cmd, [...args], { encoding: 'utf-8' });
|
|
47
|
+
return { code: res.status ?? 1, stdout: (res.stdout ?? '').trim(), stderr: (res.stderr ?? '').trim() };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** install 仪器化留痕:每条外部命令的 argv/rc/stdout/stderr/耗时(供 launchd 竞态诊断)。 */
|
|
51
|
+
export interface RunTraceEntry {
|
|
52
|
+
at: string;
|
|
53
|
+
cmd: string;
|
|
54
|
+
args: string[];
|
|
55
|
+
code: number;
|
|
56
|
+
stdout: string;
|
|
57
|
+
stderr: string;
|
|
58
|
+
durationMs: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 单个常驻组件的宿主描述(由 fleet / mebular CLI 提供)。 */
|
|
62
|
+
export interface ServiceDescriptor {
|
|
63
|
+
kind: ServiceKind;
|
|
64
|
+
/** 解释器参数(不含 execPath),通常 `[cliJs, 'node'|'worker'|'serve', ...]` */
|
|
65
|
+
args: readonly string[];
|
|
66
|
+
/** 心跳/配置目录(`service.heartbeat` 落在这里) */
|
|
67
|
+
heartbeatDir: string;
|
|
68
|
+
execPath?: string;
|
|
69
|
+
workingDir?: string;
|
|
70
|
+
env?: Readonly<Record<string, string>>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ServiceOptions {
|
|
74
|
+
platform?: ServicePlatform;
|
|
75
|
+
home?: string;
|
|
76
|
+
run?: RunCommand;
|
|
77
|
+
now?: number;
|
|
78
|
+
sha?: string;
|
|
79
|
+
/** 仪器化:非空则对每条外部命令回调一条留痕(CLI 经 MEBULAR_SERVICE_TRACE 落 JSONL)。 */
|
|
80
|
+
trace?: (entry: RunTraceEntry) => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ServiceManifest {
|
|
84
|
+
kind: ServiceKind;
|
|
85
|
+
label: string;
|
|
86
|
+
sha: string;
|
|
87
|
+
autostart: boolean;
|
|
88
|
+
args: string[];
|
|
89
|
+
execPath: string;
|
|
90
|
+
unitPath: string;
|
|
91
|
+
installedAt: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const currentPlatform = (p?: ServicePlatform): ServicePlatform => p ?? (process.platform as ServicePlatform);
|
|
95
|
+
const uid = (): string => String(process.getuid?.() ?? 0);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 构建 SHA 兜底 helper:env → 显式 cwd 的 git HEAD → `unknown`。
|
|
99
|
+
* **本模块刻意不读 `import.meta` 与 `process.cwd()`**(F-INSTALL-1):前者 ts-jest 以 CJS 加载 src 会编译失败;
|
|
100
|
+
* 后者是「sha 随 cwd 漂移」的病灶。真正与 cwd 无关的解析(模块邻接 `version.json` → 模块所在仓库 git)
|
|
101
|
+
* 在 ESM 入口边界完成并注入:`mebular service` 经 `packages/mcp/src/build-sha.mjs`、`fleet` 经 `cli.ts buildSha()`,
|
|
102
|
+
* 再经 `runServiceCli({ sha })` / `installService({ sha })` 传入。
|
|
103
|
+
*/
|
|
104
|
+
export function resolveBuildSha(env: NodeJS.ProcessEnv = process.env, cwd?: string): string {
|
|
105
|
+
const fromEnv = env.MEBULAR_BUILD_SHA ?? env.MEBULAR_SERVICE_SHA;
|
|
106
|
+
if (fromEnv) return fromEnv;
|
|
107
|
+
if (cwd !== undefined) {
|
|
108
|
+
try {
|
|
109
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
110
|
+
} catch {
|
|
111
|
+
// 显式目录不在 git 仓库内 → unknown
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return 'unknown';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readManifest(kind: ServiceKind, home: string): ServiceManifest | null {
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(fs.readFileSync(manifestPath(kind, home), 'utf-8')) as ServiceManifest;
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 实例身份(**服务身份隔离的根**):优先取描述符里的守护 home(`env.MEBULAR_HOME`,其次
|
|
127
|
+
* `heartbeatDir`),最后退回服务包选项的 home。**必须与「plist/状态落盘位置」区分开**:
|
|
128
|
+
* mcp CLI 的 `service install` 不传 `--home`(落盘仍用用户 home),但其 `MEBULAR_HOME`
|
|
129
|
+
* 才是实例身份——用实例身份派生 label,沙箱 home 才不会与真实 home 撞标签。
|
|
130
|
+
*/
|
|
131
|
+
function instanceHomeOf(descriptor: ServiceDescriptor, home: string): string {
|
|
132
|
+
const envHome = descriptor.env?.MEBULAR_HOME;
|
|
133
|
+
if (typeof envHome === 'string' && envHome.length > 0) return envHome;
|
|
134
|
+
if (descriptor.heartbeatDir.length > 0) return descriptor.heartbeatDir;
|
|
135
|
+
return home;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildSpec(descriptor: ServiceDescriptor, opts: Required<Pick<ServiceOptions, 'home' | 'platform'>> & { label?: string; autostart: boolean; sha: string }): UnitSpec {
|
|
139
|
+
const { stdoutLog, stderrLog } = serviceLogPaths(descriptor.kind, opts.home);
|
|
140
|
+
return {
|
|
141
|
+
kind: descriptor.kind,
|
|
142
|
+
// F-SVC-1:标签**含实例 home 维度**(`com.mebular.<kind>.<homeKey>`),不同 home 互不顶替。
|
|
143
|
+
label: opts.label ?? defaultLabel(descriptor.kind, DEFAULT_LABEL_PREFIX, instanceHomeOf(descriptor, opts.home)),
|
|
144
|
+
execPath: descriptor.execPath ?? process.execPath,
|
|
145
|
+
args: descriptor.args,
|
|
146
|
+
...(descriptor.workingDir !== undefined ? { workingDir: descriptor.workingDir } : {}),
|
|
147
|
+
env: { MEBULAR_SERVICE_KIND: descriptor.kind, MEBULAR_SERVICE_SHA: opts.sha, ...(descriptor.env ?? {}) },
|
|
148
|
+
stdoutLog,
|
|
149
|
+
stderrLog,
|
|
150
|
+
sha: opts.sha,
|
|
151
|
+
autostart: opts.autostart,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface LoadedJob {
|
|
156
|
+
loaded: boolean;
|
|
157
|
+
job: LaunchdJob | null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 读取某 label 的加载态(`launchctl print` 失败 = 未加载)。 */
|
|
161
|
+
function readLoadedJob(label: string, run: RunCommand): LoadedJob {
|
|
162
|
+
const res = run('launchctl', ['print', `gui/${uid()}/${label}`]);
|
|
163
|
+
if (res.code !== 0) return { loaded: false, job: null };
|
|
164
|
+
return { loaded: true, job: parseLaunchdPrint(res.stdout) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface RunningState {
|
|
168
|
+
/** 加载中**且**归属本 home 才为 true。 */
|
|
169
|
+
running: boolean;
|
|
170
|
+
/** 有同 label 作业被加载但**不属于**本 home(跨 home 占用)。 */
|
|
171
|
+
foreign: boolean;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* F-SVC-1:running 判定**校验作业归属**——`launchctl print` 只要 label 存在就返回 0,
|
|
176
|
+
* 故必须解析其 ProgramArguments/MEBULAR_HOME/WorkingDirectory/plist 路径是否与本 home 匹配;
|
|
177
|
+
* 不匹配(他人作业)→ running=false 且 foreign=true,**绝不算 running**。
|
|
178
|
+
*/
|
|
179
|
+
function runningState(platform: ServicePlatform, spec: UnitSpec, run: RunCommand, home: string): RunningState {
|
|
180
|
+
if (platform === 'darwin') {
|
|
181
|
+
const loaded = readLoadedJob(spec.label, run);
|
|
182
|
+
const owned = loaded.loaded && jobBelongsToHome(loaded.job, home);
|
|
183
|
+
return { running: owned, foreign: loaded.loaded && !owned };
|
|
184
|
+
}
|
|
185
|
+
if (platform === 'linux') {
|
|
186
|
+
const active = run('systemctl', ['--user', 'is-active', systemdUnitFileName(spec.kind)]).stdout.trim() === 'active';
|
|
187
|
+
return { running: active, foreign: false };
|
|
188
|
+
}
|
|
189
|
+
const queried = run('schtasks', ['/Query', '/TN', spec.kind]).code === 0;
|
|
190
|
+
return { running: queried, foreign: false };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function ensureDirs(platform: ServicePlatform, home: string): void {
|
|
194
|
+
fs.mkdirSync(serviceStateDir(home), { recursive: true });
|
|
195
|
+
fs.mkdirSync(serviceLogsDir(home), { recursive: true });
|
|
196
|
+
const unitDir = platform === 'darwin' ? launchAgentsDir(home) : platform === 'linux' ? systemdUserDir(home) : windowsTaskDir(home);
|
|
197
|
+
fs.mkdirSync(unitDir, { recursive: true });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface InstallResult {
|
|
201
|
+
ok: boolean;
|
|
202
|
+
action: 'install';
|
|
203
|
+
kind: ServiceKind;
|
|
204
|
+
label: string;
|
|
205
|
+
sha: string;
|
|
206
|
+
unitPath: string;
|
|
207
|
+
manifestPath: string;
|
|
208
|
+
autostart: boolean;
|
|
209
|
+
started: boolean;
|
|
210
|
+
/** started=false 时的真实原因(bootstrap 重试耗尽 / 终态校验超时等);绝不谎报。 */
|
|
211
|
+
startError?: string;
|
|
212
|
+
/** 迁移:本次安装顺带卸下的旧(无 home 维度)作业 label。 */
|
|
213
|
+
migratedFrom?: string;
|
|
214
|
+
/** 同 label 被**他人**作业占用 → 已拒绝(绝不顶替/bootout)。 */
|
|
215
|
+
foreign?: boolean;
|
|
216
|
+
warning?: string;
|
|
217
|
+
error?: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** install 专属选项:时序旋钮全部有界且可注入(测试用小值;CLI 用默认)。 */
|
|
221
|
+
export interface InstallOptions extends ServiceOptions {
|
|
222
|
+
autostart?: boolean;
|
|
223
|
+
label?: string;
|
|
224
|
+
/** bootout 后等待「作业确实卸载」的预算(默认 3000ms;轮询 print 直至查无此作业)。 */
|
|
225
|
+
unloadTimeoutMs?: number;
|
|
226
|
+
/** bootstrap 失败(如 launchd 收尾竞态 error 5)时的尝试次数(默认 4,含首次)。 */
|
|
227
|
+
bootstrapAttempts?: number;
|
|
228
|
+
/** bootstrap 相邻两次尝试间的退避(默认 750ms)。 */
|
|
229
|
+
bootstrapBackoffMs?: number;
|
|
230
|
+
/** 装载后「确认真的起来了」的校验预算(默认 8000ms;轮询新鲜心跳)。 */
|
|
231
|
+
verifyTimeoutMs?: number;
|
|
232
|
+
/** 校验轮询间隔(默认 250ms)。 */
|
|
233
|
+
verifyIntervalMs?: number;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 同步睡眠(install 全程基于同步 RunCommand;Atomics.wait 不依赖事件循环)。 */
|
|
237
|
+
function sleepSync(ms: number): void {
|
|
238
|
+
if (ms <= 0) return;
|
|
239
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** bootout 后**有界**等待作业真正卸载(bootout→bootstrap 收尾竞态的根治:未卸完不 bootstrap)。 */
|
|
243
|
+
function waitForUnloaded(label: string, run: RunCommand, timeoutMs: number): boolean {
|
|
244
|
+
const deadline = Date.now() + timeoutMs;
|
|
245
|
+
for (;;) {
|
|
246
|
+
if (!readLoadedJob(label, run).loaded) return true;
|
|
247
|
+
if (Date.now() >= deadline) return false;
|
|
248
|
+
sleepSync(150);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** bootstrap 有界退避重试(launchd 偶发 `Bootstrap failed: 5: Input/output error` 等暂态失败)。 */
|
|
253
|
+
function bootstrapWithRetry(unitPath: string, run: RunCommand, attempts: number, backoffMs: number): RunResult {
|
|
254
|
+
let last: RunResult = { code: 1, stdout: '', stderr: 'not attempted' };
|
|
255
|
+
for (let i = 0; i < Math.max(1, attempts); i++) {
|
|
256
|
+
last = run('launchctl', ['bootstrap', `gui/${uid()}`, unitPath]);
|
|
257
|
+
if (last.code === 0) return last;
|
|
258
|
+
if (i + 1 < attempts) sleepSync(backoffMs);
|
|
259
|
+
}
|
|
260
|
+
return last;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 装载后终态校验:在预算内等待「**比基线更新**的新鲜心跳」。基线 = 本次安装前的心跳 ts——
|
|
265
|
+
* 没有它,上一次运行残留的陈旧心跳会让「装载即 SIGTERM」被误判成 started:true(谎报根因之一)。
|
|
266
|
+
*/
|
|
267
|
+
function verifyStartedFresh(
|
|
268
|
+
descriptor: ServiceDescriptor,
|
|
269
|
+
baselineTs: number,
|
|
270
|
+
timeoutMs: number,
|
|
271
|
+
intervalMs: number,
|
|
272
|
+
): { ok: boolean; reason?: string } {
|
|
273
|
+
const deadline = Date.now() + timeoutMs;
|
|
274
|
+
for (;;) {
|
|
275
|
+
const hb = readHeartbeat(descriptor.heartbeatDir);
|
|
276
|
+
if (hb !== null && hb.ts > baselineTs && isHeartbeatFresh(descriptor.heartbeatDir)) return { ok: true };
|
|
277
|
+
if (Date.now() >= deadline) {
|
|
278
|
+
const seen = hb === null ? '无心跳文件' : `心跳 ts=${hb.ts}(基线 ${baselineTs},未见更新)`;
|
|
279
|
+
return { ok: false, reason: `启动校验超时:${timeoutMs}ms 内未见新的新鲜心跳(${seen};heartbeatDir=${descriptor.heartbeatDir})` };
|
|
280
|
+
}
|
|
281
|
+
sleepSync(intervalMs);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const summarizeRun = (r: RunResult): string => (r.stderr || r.stdout || `rc=${r.code}`).slice(0, 300);
|
|
286
|
+
|
|
287
|
+
export function installService(
|
|
288
|
+
descriptor: ServiceDescriptor,
|
|
289
|
+
options: InstallOptions = {},
|
|
290
|
+
): InstallResult {
|
|
291
|
+
const platform = currentPlatform(options.platform);
|
|
292
|
+
const home = options.home ?? os.homedir();
|
|
293
|
+
const rawRun = options.run ?? defaultRun;
|
|
294
|
+
const trace = options.trace;
|
|
295
|
+
// 仪器化:每条外部命令(含重试/轮询)都留痕——launchd 竞态只能靠命令级时序定位。
|
|
296
|
+
const run: RunCommand = trace === undefined
|
|
297
|
+
? rawRun
|
|
298
|
+
: (cmd, args) => {
|
|
299
|
+
const t0 = Date.now();
|
|
300
|
+
const r = rawRun(cmd, args);
|
|
301
|
+
trace({
|
|
302
|
+
at: new Date(t0).toISOString(),
|
|
303
|
+
cmd,
|
|
304
|
+
args: [...args],
|
|
305
|
+
code: r.code,
|
|
306
|
+
stdout: r.stdout.slice(0, 4000),
|
|
307
|
+
stderr: r.stderr.slice(0, 4000),
|
|
308
|
+
durationMs: Date.now() - t0,
|
|
309
|
+
});
|
|
310
|
+
return r;
|
|
311
|
+
};
|
|
312
|
+
const sha = options.sha ?? resolveBuildSha();
|
|
313
|
+
const autostart = options.autostart ?? true;
|
|
314
|
+
const unloadTimeoutMs = options.unloadTimeoutMs ?? 3000;
|
|
315
|
+
const bootstrapAttempts = options.bootstrapAttempts ?? 4;
|
|
316
|
+
const bootstrapBackoffMs = options.bootstrapBackoffMs ?? 750;
|
|
317
|
+
const verifyTimeoutMs = options.verifyTimeoutMs ?? 8000;
|
|
318
|
+
const verifyIntervalMs = options.verifyIntervalMs ?? 250;
|
|
319
|
+
const instanceHome = instanceHomeOf(descriptor, home);
|
|
320
|
+
const spec = buildSpec(descriptor, { home, platform, autostart, sha, ...(options.label !== undefined ? { label: options.label } : {}) });
|
|
321
|
+
const unitPath = unitFilePath(platform, spec, home);
|
|
322
|
+
|
|
323
|
+
// F-SVC-1 护栏(darwin):安装前先判「同 label 是否被**他人**作业占用」。
|
|
324
|
+
// - 本 home 新标签被他人占用 → 拒绝(绝不 bootout 他人);
|
|
325
|
+
// - 旧全局标签被他人占用 → 保留不 bootout,只装本 home 专属标签并警告;
|
|
326
|
+
// - 旧全局标签属于本 home → 记录待迁移(bootout 旧 + 装新,保留 KeepAlive/RunAtLoad)。
|
|
327
|
+
let migratedFrom: string | undefined;
|
|
328
|
+
let foreignWarning: string | undefined;
|
|
329
|
+
if (platform === 'darwin') {
|
|
330
|
+
const own = readLoadedJob(spec.label, run);
|
|
331
|
+
if (own.loaded && !jobBelongsToHome(own.job, instanceHome)) {
|
|
332
|
+
return {
|
|
333
|
+
ok: false,
|
|
334
|
+
action: 'install',
|
|
335
|
+
kind: spec.kind,
|
|
336
|
+
label: spec.label,
|
|
337
|
+
sha,
|
|
338
|
+
unitPath,
|
|
339
|
+
manifestPath: manifestPath(spec.kind, home),
|
|
340
|
+
autostart,
|
|
341
|
+
started: false,
|
|
342
|
+
foreign: true,
|
|
343
|
+
error: 'foreign_job',
|
|
344
|
+
warning: `标签 ${spec.label} 已被另一 home 的作业占用(占用者:${describeJobOwner(own.job) ?? '未知'})→ 拒绝安装(绝不顶替他人)`,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
const legacy = legacyLabel(spec.kind);
|
|
348
|
+
if (legacy !== spec.label) {
|
|
349
|
+
const legacyJob = readLoadedJob(legacy, run);
|
|
350
|
+
if (legacyJob.loaded) {
|
|
351
|
+
if (jobBelongsToHome(legacyJob.job, instanceHome)) {
|
|
352
|
+
migratedFrom = legacy;
|
|
353
|
+
} else {
|
|
354
|
+
foreignWarning = `旧标签 ${legacy} 由另一 home 的作业占用(占用者:${describeJobOwner(legacyJob.job) ?? '未知'})→ 未 bootout 他人;仅安装本 home 专属标签 ${spec.label}`;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
ensureDirs(platform, home);
|
|
361
|
+
fs.writeFileSync(unitPath, unitText(platform, spec), { mode: 0o644 });
|
|
362
|
+
|
|
363
|
+
const manifest: ServiceManifest = {
|
|
364
|
+
kind: spec.kind,
|
|
365
|
+
label: spec.label,
|
|
366
|
+
sha,
|
|
367
|
+
autostart,
|
|
368
|
+
args: [...descriptor.args],
|
|
369
|
+
execPath: spec.execPath,
|
|
370
|
+
unitPath,
|
|
371
|
+
installedAt: new Date(options.now ?? Date.now()).toISOString(),
|
|
372
|
+
};
|
|
373
|
+
fs.writeFileSync(manifestPath(spec.kind, home), JSON.stringify(manifest, null, 2), { mode: 0o600 });
|
|
374
|
+
|
|
375
|
+
let started = false;
|
|
376
|
+
let startError: string | undefined;
|
|
377
|
+
if (platform === 'darwin') {
|
|
378
|
+
if (migratedFrom !== undefined) {
|
|
379
|
+
// 仅当旧标签确属本 home 才卸载(上面已校验);并清掉本 home 的旧 plist。
|
|
380
|
+
run('launchctl', ['bootout', `gui/${uid()}/${migratedFrom}`]);
|
|
381
|
+
waitForUnloaded(migratedFrom, run, unloadTimeoutMs);
|
|
382
|
+
const legacyUnit = unitFilePath('darwin', { kind: spec.kind, label: migratedFrom }, home);
|
|
383
|
+
try {
|
|
384
|
+
fs.rmSync(legacyUnit, { force: true });
|
|
385
|
+
} catch {
|
|
386
|
+
// ignore
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
run('launchctl', ['bootout', `gui/${uid()}/${spec.label}`]); // 幂等:先卸本 home 自个儿的旧作业(忽略失败)
|
|
390
|
+
// bootout 是异步生效的:不等待卸载完成就 bootstrap 会撞 `Bootstrap failed: 5`(收尾竞态)。
|
|
391
|
+
waitForUnloaded(spec.label, run, unloadTimeoutMs);
|
|
392
|
+
// 基线心跳:之后只认「比它新」的心跳,杜绝陈旧心跳造成的 started 假阳性。
|
|
393
|
+
const baselineHbTs = readHeartbeat(descriptor.heartbeatDir)?.ts ?? 0;
|
|
394
|
+
const boot = bootstrapWithRetry(unitPath, run, bootstrapAttempts, bootstrapBackoffMs);
|
|
395
|
+
let loadedUnit = boot.code === 0;
|
|
396
|
+
if (!loadedUnit) {
|
|
397
|
+
const legacy = run('launchctl', ['load', unitPath]); // 旧系统回退
|
|
398
|
+
loadedUnit = legacy.code === 0;
|
|
399
|
+
if (!loadedUnit) {
|
|
400
|
+
startError = `bootstrap 失败(${bootstrapAttempts} 次有界重试):${summarizeRun(boot)};load 回退亦失败:${summarizeRun(legacy)}`;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (loadedUnit) {
|
|
404
|
+
if (!autostart) {
|
|
405
|
+
// RunAtLoad=false:显式拉起一次,确保 install 后即在跑
|
|
406
|
+
run('launchctl', ['kickstart', `gui/${uid()}/${spec.label}`]);
|
|
407
|
+
}
|
|
408
|
+
// 终态校验:装载成功 ≠ 进程活着。只有新的新鲜心跳才允许报 started:true。
|
|
409
|
+
const verify = verifyStartedFresh(descriptor, baselineHbTs, verifyTimeoutMs, verifyIntervalMs);
|
|
410
|
+
started = verify.ok;
|
|
411
|
+
if (!verify.ok) startError = verify.reason;
|
|
412
|
+
}
|
|
413
|
+
} else if (platform === 'linux') {
|
|
414
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
415
|
+
started = run('systemctl', ['--user', autostart ? 'enable' : 'start', '--now', systemdUnitFileName(spec.kind)]).code === 0;
|
|
416
|
+
} else {
|
|
417
|
+
const xml = unitFilePath(platform, spec, home);
|
|
418
|
+
const created = run('schtasks', ['/Create', '/TN', spec.kind, '/XML', xml, '/F']);
|
|
419
|
+
started = created.code === 0 && run('schtasks', ['/Run', '/TN', spec.kind]).code === 0;
|
|
420
|
+
if (!autostart) run('schtasks', ['/End', '/TN', spec.kind]); // 仅安装不自启:注册后停止
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
ok: true,
|
|
425
|
+
action: 'install',
|
|
426
|
+
kind: spec.kind,
|
|
427
|
+
label: spec.label,
|
|
428
|
+
sha,
|
|
429
|
+
unitPath,
|
|
430
|
+
manifestPath: manifestPath(spec.kind, home),
|
|
431
|
+
autostart,
|
|
432
|
+
started,
|
|
433
|
+
...(startError !== undefined ? { startError } : {}),
|
|
434
|
+
...(migratedFrom !== undefined ? { migratedFrom } : {}),
|
|
435
|
+
...(foreignWarning !== undefined ? { warning: foreignWarning } : {}),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export interface UninstallResult {
|
|
440
|
+
ok: boolean;
|
|
441
|
+
action: 'uninstall';
|
|
442
|
+
kind: ServiceKind;
|
|
443
|
+
removed: boolean;
|
|
444
|
+
note?: string;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function uninstallService(descriptor: ServiceDescriptor, options: ServiceOptions = {}): UninstallResult {
|
|
448
|
+
const platform = currentPlatform(options.platform);
|
|
449
|
+
const home = options.home ?? os.homedir();
|
|
450
|
+
const run = options.run ?? defaultRun;
|
|
451
|
+
const instanceHome = instanceHomeOf(descriptor, home);
|
|
452
|
+
const manifest = readManifest(descriptor.kind, home);
|
|
453
|
+
const spec: UnitSpec = buildSpec(descriptor, {
|
|
454
|
+
home,
|
|
455
|
+
platform,
|
|
456
|
+
autostart: manifest?.autostart ?? true,
|
|
457
|
+
sha: manifest?.sha ?? 'unknown',
|
|
458
|
+
...(manifest?.label !== undefined ? { label: manifest.label } : {}),
|
|
459
|
+
});
|
|
460
|
+
const unitPath = unitFilePath(platform, spec, home);
|
|
461
|
+
const isInstalled = fs.existsSync(unitPath) || manifest !== null;
|
|
462
|
+
if (!isInstalled) {
|
|
463
|
+
return { ok: true, action: 'uninstall', kind: descriptor.kind, removed: false, note: `未安装(${unitPath} 不存在)` };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (platform === 'darwin') {
|
|
467
|
+
// F-SVC-1:只卸**属于本 home** 的作业(含迁移期遗留的旧全局标签);绝不 bootout 他人。
|
|
468
|
+
for (const candidate of [spec.label, legacyLabel(spec.kind)]) {
|
|
469
|
+
const loaded = readLoadedJob(candidate, run);
|
|
470
|
+
if (loaded.loaded && jobBelongsToHome(loaded.job, instanceHome)) {
|
|
471
|
+
run('launchctl', ['bootout', `gui/${uid()}/${candidate}`]);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
} else if (platform === 'linux') {
|
|
475
|
+
run('systemctl', ['--user', 'disable', '--now', systemdUnitFileName(descriptor.kind)]);
|
|
476
|
+
} else {
|
|
477
|
+
run('schtasks', ['/End', '/TN', descriptor.kind]);
|
|
478
|
+
run('schtasks', ['/Delete', '/TN', descriptor.kind, '/F']);
|
|
479
|
+
}
|
|
480
|
+
const legacyUnitPath = unitFilePath('darwin', { kind: descriptor.kind, label: legacyLabel(descriptor.kind) }, home);
|
|
481
|
+
const files = platform === 'darwin' ? [unitPath, legacyUnitPath, manifestPath(descriptor.kind, home)] : [unitPath, manifestPath(descriptor.kind, home)];
|
|
482
|
+
for (const file of files) {
|
|
483
|
+
try {
|
|
484
|
+
fs.rmSync(file, { force: true });
|
|
485
|
+
} catch {
|
|
486
|
+
// ignore
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return { ok: true, action: 'uninstall', kind: descriptor.kind, removed: true };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* 某配置目录是否已被常驻服务接管(读 manifest,`args` 含该目录)。dir-scoped:
|
|
494
|
+
* 在 temp 设备目录上恒为 `[]`(除非确有针对它的服务),使 doctor 可复现。
|
|
495
|
+
*/
|
|
496
|
+
export function registeredServicesForDir(dir: string, options: { home?: string } = {}): ServiceKind[] {
|
|
497
|
+
const home = options.home ?? os.homedir();
|
|
498
|
+
const found: ServiceKind[] = [];
|
|
499
|
+
for (const kind of SERVICE_KINDS) {
|
|
500
|
+
const manifest = readManifest(kind, home);
|
|
501
|
+
if (manifest && manifest.args.includes(dir)) found.push(kind);
|
|
502
|
+
}
|
|
503
|
+
return found;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export interface ServiceStatusEntry {
|
|
507
|
+
kind: ServiceKind;
|
|
508
|
+
label: string;
|
|
509
|
+
registered: boolean;
|
|
510
|
+
running: boolean;
|
|
511
|
+
/** F-SVC-1:存在同 label(或旧全局 label)的**他人**作业 → 跨 home 占用(running 必为 false)。 */
|
|
512
|
+
foreign: boolean;
|
|
513
|
+
/** 跨 home 占用者的 home/路径提示(无则 null)。 */
|
|
514
|
+
foreignOwner: string | null;
|
|
515
|
+
unitPath: string;
|
|
516
|
+
sha: string | null;
|
|
517
|
+
autostart: boolean | null;
|
|
518
|
+
heartbeat: { present: boolean; fresh: boolean; ageMs: number | null; pid: number | null; sha: string | null; role: string | null };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function serviceStatus(descriptor: ServiceDescriptor, options: ServiceOptions = {}): ServiceStatusEntry {
|
|
522
|
+
const platform = currentPlatform(options.platform);
|
|
523
|
+
const home = options.home ?? os.homedir();
|
|
524
|
+
const run = options.run ?? defaultRun;
|
|
525
|
+
const now = options.now ?? Date.now();
|
|
526
|
+
const instanceHome = instanceHomeOf(descriptor, home);
|
|
527
|
+
const manifest = readManifest(descriptor.kind, home);
|
|
528
|
+
const spec: UnitSpec = buildSpec(descriptor, {
|
|
529
|
+
home,
|
|
530
|
+
platform,
|
|
531
|
+
autostart: manifest?.autostart ?? true,
|
|
532
|
+
sha: manifest?.sha ?? options.sha ?? 'unknown',
|
|
533
|
+
...(manifest?.label !== undefined ? { label: manifest.label } : {}),
|
|
534
|
+
});
|
|
535
|
+
const unitPath = unitFilePath(platform, spec, home);
|
|
536
|
+
const hb = readHeartbeat(descriptor.heartbeatDir);
|
|
537
|
+
const fresh = isHeartbeatFresh(descriptor.heartbeatDir, undefined, now);
|
|
538
|
+
|
|
539
|
+
// F-SVC-1:running 必须校验**作业归属**;并额外揭露「旧全局 label 被他人占用」这一假阳性来源。
|
|
540
|
+
const state = runningState(platform, spec, run, instanceHome);
|
|
541
|
+
let foreign = state.foreign;
|
|
542
|
+
let foreignOwner: string | null = null;
|
|
543
|
+
if (platform === 'darwin') {
|
|
544
|
+
if (state.foreign) foreignOwner = describeJobOwner(readLoadedJob(spec.label, run).job);
|
|
545
|
+
const legacy = legacyLabel(spec.kind);
|
|
546
|
+
if (legacy !== spec.label) {
|
|
547
|
+
const legacyLoaded = readLoadedJob(legacy, run);
|
|
548
|
+
if (legacyLoaded.loaded && !jobBelongsToHome(legacyLoaded.job, instanceHome)) {
|
|
549
|
+
foreign = true;
|
|
550
|
+
foreignOwner = foreignOwner ?? describeJobOwner(legacyLoaded.job);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
kind: descriptor.kind,
|
|
557
|
+
label: spec.label,
|
|
558
|
+
registered: fs.existsSync(unitPath) || manifest !== null,
|
|
559
|
+
running: state.running,
|
|
560
|
+
foreign,
|
|
561
|
+
foreignOwner,
|
|
562
|
+
unitPath,
|
|
563
|
+
sha: manifest?.sha ?? null,
|
|
564
|
+
autostart: manifest?.autostart ?? null,
|
|
565
|
+
heartbeat: {
|
|
566
|
+
present: hb !== null,
|
|
567
|
+
fresh,
|
|
568
|
+
ageMs: hb === null ? null : Math.max(0, now - hb.ts),
|
|
569
|
+
pid: hb?.pid ?? null,
|
|
570
|
+
sha: hb?.sha ?? null,
|
|
571
|
+
role: hb?.role ?? null,
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** 一键重启:平台命令(**纯函数**,不执行;未注册为服务时给手动指引)。 */
|
|
577
|
+
export interface RestartPlan {
|
|
578
|
+
kind: ServiceKind;
|
|
579
|
+
label: string;
|
|
580
|
+
registered: boolean;
|
|
581
|
+
/** 需按序执行的命令(每项 = [cmd, ...args]);未注册时为空数组 */
|
|
582
|
+
commands: string[][];
|
|
583
|
+
/** 未注册/不可托管时的手动重启指引 */
|
|
584
|
+
manual?: string;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* 构造重启命令(不执行):
|
|
589
|
+
* - darwin:`launchctl kickstart -k gui/<uid>/<label>`(-k 先杀再拉起,KeepAlive 兜底)
|
|
590
|
+
* - linux :`systemctl --user restart <unit>`(Restart=on-failure 兜底)
|
|
591
|
+
* - win32 :`schtasks /End` + `schtasks /Run`(计划任务无 restart,退化为停+起)
|
|
592
|
+
* 未注册(单元文件与 manifest 都不存在)→ 只给手动指引,绝不盲发 kickstart。
|
|
593
|
+
*/
|
|
594
|
+
export function restartPlanFor(descriptor: ServiceDescriptor, options: ServiceOptions = {}): RestartPlan {
|
|
595
|
+
const platform = currentPlatform(options.platform);
|
|
596
|
+
const home = options.home ?? os.homedir();
|
|
597
|
+
const run = options.run ?? defaultRun;
|
|
598
|
+
const instanceHome = instanceHomeOf(descriptor, home);
|
|
599
|
+
const manifest = readManifest(descriptor.kind, home);
|
|
600
|
+
const spec: UnitSpec = buildSpec(descriptor, {
|
|
601
|
+
home,
|
|
602
|
+
platform,
|
|
603
|
+
autostart: manifest?.autostart ?? true,
|
|
604
|
+
sha: manifest?.sha ?? options.sha ?? 'unknown',
|
|
605
|
+
...(manifest?.label !== undefined ? { label: manifest.label } : {}),
|
|
606
|
+
});
|
|
607
|
+
const unitPath = unitFilePath(platform, spec, home);
|
|
608
|
+
const registered = fs.existsSync(unitPath) || manifest !== null;
|
|
609
|
+
const manual = '未注册为服务:请手动重启(nohup mebular serve > ~/.mebular/serve.log 2>&1 &)';
|
|
610
|
+
if (!registered) return { kind: descriptor.kind, label: spec.label, registered: false, commands: [], manual };
|
|
611
|
+
if (platform === 'darwin') {
|
|
612
|
+
// F-SVC-1:绝不 kickstart 他人作业——加载中的同 label 作业必须归属本 home。
|
|
613
|
+
const state = runningState(platform, spec, run, instanceHome);
|
|
614
|
+
if (state.foreign) {
|
|
615
|
+
const owner = describeJobOwner(readLoadedJob(spec.label, run).job);
|
|
616
|
+
return {
|
|
617
|
+
kind: descriptor.kind,
|
|
618
|
+
label: spec.label,
|
|
619
|
+
registered: false,
|
|
620
|
+
commands: [],
|
|
621
|
+
manual: `标签 ${spec.label} 被另一 home 的作业占用(占用者:${owner ?? '未知'})→ 不 kickstart 他人;请确认该作业归属后重试`,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
return { kind: descriptor.kind, label: spec.label, registered: true, commands: [['launchctl', 'kickstart', '-k', `gui/${uid()}/${spec.label}`]] };
|
|
625
|
+
}
|
|
626
|
+
if (platform === 'linux') {
|
|
627
|
+
return { kind: descriptor.kind, label: spec.label, registered: true, commands: [['systemctl', '--user', 'restart', systemdUnitFileName(spec.kind)]] };
|
|
628
|
+
}
|
|
629
|
+
return {
|
|
630
|
+
kind: descriptor.kind,
|
|
631
|
+
label: spec.label,
|
|
632
|
+
registered: true,
|
|
633
|
+
commands: [['schtasks', '/End', '/TN', spec.kind], ['schtasks', '/Run', '/TN', spec.kind]],
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export interface RestartResult {
|
|
638
|
+
ok: boolean;
|
|
639
|
+
action: 'restart';
|
|
640
|
+
kind: ServiceKind;
|
|
641
|
+
label: string;
|
|
642
|
+
registered: boolean;
|
|
643
|
+
commands: string[][];
|
|
644
|
+
executed: boolean;
|
|
645
|
+
manual?: string;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** 执行重启(命令可注入,便于 hermetic 测试)。未注册 → executed:false + 手动指引。 */
|
|
649
|
+
export function restartService(descriptor: ServiceDescriptor, options: ServiceOptions = {}): RestartResult {
|
|
650
|
+
const run = options.run ?? defaultRun;
|
|
651
|
+
const plan = restartPlanFor(descriptor, options);
|
|
652
|
+
if (!plan.registered) {
|
|
653
|
+
return { ok: false, action: 'restart', kind: plan.kind, label: plan.label, registered: false, commands: [], executed: false, ...(plan.manual !== undefined ? { manual: plan.manual } : {}) };
|
|
654
|
+
}
|
|
655
|
+
let ok = true;
|
|
656
|
+
for (const command of plan.commands) {
|
|
657
|
+
const cmd = command[0];
|
|
658
|
+
if (cmd === undefined) continue;
|
|
659
|
+
if (run(cmd, command.slice(1)).code !== 0) ok = false;
|
|
660
|
+
}
|
|
661
|
+
return { ok, action: 'restart', kind: plan.kind, label: plan.label, registered: true, commands: plan.commands, executed: true };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export interface LogsResult {
|
|
665
|
+
kind: ServiceKind;
|
|
666
|
+
stdoutLog: string;
|
|
667
|
+
stderrLog: string;
|
|
668
|
+
stdout: string[];
|
|
669
|
+
stderr: string[];
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export function serviceLogs(descriptor: ServiceDescriptor, options: ServiceOptions & { tail?: number } = {}): LogsResult {
|
|
673
|
+
const home = options.home ?? os.homedir();
|
|
674
|
+
const { stdoutLog, stderrLog } = serviceLogPaths(descriptor.kind, home);
|
|
675
|
+
const tail = Math.max(0, options.tail ?? 50);
|
|
676
|
+
const readTail = (file: string): string[] => {
|
|
677
|
+
try {
|
|
678
|
+
const lines = fs.readFileSync(file, 'utf-8').split('\n');
|
|
679
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
680
|
+
return tail === 0 ? [] : lines.slice(-tail);
|
|
681
|
+
} catch {
|
|
682
|
+
return [];
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
return { kind: descriptor.kind, stdoutLog, stderrLog, stdout: readTail(stdoutLog), stderr: readTail(stderrLog) };
|
|
686
|
+
}
|