@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.
@@ -0,0 +1,50 @@
1
+ export type ServicePlatform = 'darwin' | 'linux' | 'win32';
2
+ /** 三个常驻组件(服务单元名 = 角色名)。 */
3
+ export type ServiceKind = 'fleet-node' | 'fleet-worker' | 'mebular-serve';
4
+ export declare const SERVICE_KINDS: readonly ServiceKind[];
5
+ /** 默认服务名前缀(标签/任务名由它派生;测试可用 labelPrefix 覆盖)。 */
6
+ export declare const DEFAULT_LABEL_PREFIX = "com.mebular";
7
+ export interface UnitSpec {
8
+ kind: ServiceKind;
9
+ /** launchd Label / systemd 单元名 / Windows 任务名(派生后的完整名) */
10
+ label: string;
11
+ /** 解释器(通常 process.execPath) */
12
+ execPath: string;
13
+ /** 解释器参数(通常 [cli.js, 'node'|'worker', ...]) */
14
+ args: readonly string[];
15
+ workingDir?: string;
16
+ env?: Readonly<Record<string, string>>;
17
+ /** 服务日志(stdout/stderr)绝对路径 */
18
+ stdoutLog: string;
19
+ stderrLog: string;
20
+ /** 当前构建 SHA(钉死版本,供 `service status` 输出) */
21
+ sha: string;
22
+ /** 是否登录/开机自启(false = 仅安装、不自启) */
23
+ autostart: boolean;
24
+ /** 崩溃自拉节流(秒),默认 10 */
25
+ throttleSeconds?: number;
26
+ }
27
+ /** systemd ExecStart / Environment 的引号规则:含空白或特殊字符时用双引号包裹并转义。 */
28
+ export declare function systemdQuote(value: string): string;
29
+ /** 单元内的审计注释(`service status` 与人工排查都从这里/state manifest 读 SHA)。 */
30
+ export declare const auditComment: (spec: UnitSpec) => string;
31
+ /** macOS launchd 用户级 LaunchAgent plist。 */
32
+ export declare function launchdPlist(spec: UnitSpec): string;
33
+ /** Linux systemd --user 单元。 */
34
+ export declare function systemdUnit(spec: UnitSpec): string;
35
+ /** Windows Task Scheduler 任务 XML(onlogon,无需管理员;InteractiveToken + LeastPrivilege)。 */
36
+ export declare function windowsTaskXml(spec: UnitSpec): string;
37
+ /** 按平台生成单元文本。 */
38
+ export declare function unitText(platform: ServicePlatform, spec: UnitSpec): string;
39
+ /** 旧标签/单元名(**无 home 维度**):`com.mebular.<kind>`。仅用于迁移期识别与清理。 */
40
+ export declare function legacyLabel(kind: ServiceKind, labelPrefix?: string): string;
41
+ /** home 派生键(纯函数):同 home → 同键、不同 home → 不同键(12 位十六进制)。 */
42
+ export declare function homeKey(home: string): string;
43
+ /**
44
+ * 默认标签/单元名:`<prefix>.<kind>.<homeKey>` —— **含 home 维度**,使不同 home 的服务身份彻底隔离、
45
+ * 互不顶替(F-SVC-1)。省略 `home` 时退回旧的无维度标签(仅供显示/迁移识别;install 恒传 home)。
46
+ */
47
+ export declare function defaultLabel(kind: ServiceKind, labelPrefix?: string, home?: string): string;
48
+ /** systemd 单元文件名(`mebular-<kind>.service`)。 */
49
+ export declare function systemdUnitFileName(kind: ServiceKind): string;
50
+ //# sourceMappingURL=units.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"units.d.ts","sourceRoot":"","sources":["../src/units.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,CAAC;AAE3D,2BAA2B;AAC3B,MAAM,MAAM,WAAW,GAAG,YAAY,GAAG,cAAc,GAAG,eAAe,CAAC;AAE1E,eAAO,MAAM,aAAa,EAAE,SAAS,WAAW,EAAoD,CAAC;AAErG,+CAA+C;AAC/C,eAAO,MAAM,oBAAoB,gBAAgB,CAAC;AAElD,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,WAAW,CAAC;IAClB,yDAAyD;IACzD,KAAK,EAAE,MAAM,CAAC;IACd,+BAA+B;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,+CAA+C;IAC/C,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACvC,8BAA8B;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,iCAAiC;IACjC,SAAS,EAAE,OAAO,CAAC;IACnB,sBAAsB;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAKD,gEAAgE;AAChE,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAGlD;AAED,iEAAiE;AACjE,eAAO,MAAM,YAAY,GAAI,MAAM,QAAQ,KAAG,MAA8D,CAAC;AAE7G,2CAA2C;AAC3C,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CA0CnD;AAED,+BAA+B;AAC/B,wBAAgB,WAAW,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAuBlD;AAED,sFAAsF;AACtF,wBAAgB,cAAc,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CA2CrD;AAED,iBAAiB;AACjB,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,eAAe,EAAE,IAAI,EAAE,QAAQ,GAAG,MAAM,CAa1E;AAED,+DAA+D;AAC/D,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,EAAE,WAAW,SAAuB,GAAG,MAAM,CAEzF;AAED,yDAAyD;AACzD,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAG5C;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,WAAW,EAAE,WAAW,SAAuB,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAGzG;AAED,+CAA+C;AAC/C,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,CAE7D"}
package/dist/units.js ADDED
@@ -0,0 +1,173 @@
1
+ // 平台服务单元生成(**纯函数**):输入 spec,输出单元文本。无 IO、无时钟、无平台副作用,
2
+ // 因此可对三种平台做 golden 测试,也可被 macOS/Linux/Windows 之外的环境安全调用。
3
+ //
4
+ // 角色命名(D2):fleet-node(任务板/发起端)· fleet-worker(执行端)· mebular-serve(记忆/同步/MCP 底座)。
5
+ import { createHash } from 'node:crypto';
6
+ import { resolve } from 'node:path';
7
+ export const SERVICE_KINDS = ['fleet-node', 'fleet-worker', 'mebular-serve'];
8
+ /** 默认服务名前缀(标签/任务名由它派生;测试可用 labelPrefix 覆盖)。 */
9
+ export const DEFAULT_LABEL_PREFIX = 'com.mebular';
10
+ const escapeXml = (value) => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
11
+ /** systemd ExecStart / Environment 的引号规则:含空白或特殊字符时用双引号包裹并转义。 */
12
+ export function systemdQuote(value) {
13
+ if (value.length > 0 && !/[\s"'\\$]/.test(value))
14
+ return value;
15
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
16
+ }
17
+ /** 单元内的审计注释(`service status` 与人工排查都从这里/state manifest 读 SHA)。 */
18
+ export const auditComment = (spec) => `mebular-service: kind=${spec.kind} sha=${spec.sha}`;
19
+ /** macOS launchd 用户级 LaunchAgent plist。 */
20
+ export function launchdPlist(spec) {
21
+ const throttle = spec.throttleSeconds ?? 10;
22
+ const envKeys = Object.keys(spec.env ?? {});
23
+ const lines = [];
24
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
25
+ lines.push('<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">');
26
+ lines.push('<plist version="1.0">');
27
+ lines.push('<dict>');
28
+ lines.push(' <!-- ' + auditComment(spec) + ' -->');
29
+ lines.push(' <key>Label</key>');
30
+ lines.push(` <string>${escapeXml(spec.label)}</string>`);
31
+ lines.push(' <key>ProgramArguments</key>');
32
+ lines.push(' <array>');
33
+ lines.push(` <string>${escapeXml(spec.execPath)}</string>`);
34
+ for (const arg of spec.args)
35
+ lines.push(` <string>${escapeXml(arg)}</string>`);
36
+ lines.push(' </array>');
37
+ lines.push(' <key>RunAtLoad</key>');
38
+ lines.push(spec.autostart ? ' <true/>' : ' <false/>');
39
+ lines.push(' <key>KeepAlive</key>');
40
+ lines.push(' <true/>');
41
+ lines.push(' <key>ThrottleInterval</key>');
42
+ lines.push(` <integer>${throttle}</integer>`);
43
+ lines.push(' <key>StandardOutPath</key>');
44
+ lines.push(` <string>${escapeXml(spec.stdoutLog)}</string>`);
45
+ lines.push(' <key>StandardErrorPath</key>');
46
+ lines.push(` <string>${escapeXml(spec.stderrLog)}</string>`);
47
+ if (spec.workingDir !== undefined) {
48
+ lines.push(' <key>WorkingDirectory</key>');
49
+ lines.push(` <string>${escapeXml(spec.workingDir)}</string>`);
50
+ }
51
+ if (envKeys.length > 0) {
52
+ lines.push(' <key>EnvironmentVariables</key>');
53
+ lines.push(' <dict>');
54
+ for (const key of envKeys) {
55
+ lines.push(` <key>${escapeXml(key)}</key>`);
56
+ lines.push(` <string>${escapeXml(spec.env[key])}</string>`);
57
+ }
58
+ lines.push(' </dict>');
59
+ }
60
+ lines.push('</dict>');
61
+ lines.push('</plist>');
62
+ return `${lines.join('\n')}\n`;
63
+ }
64
+ /** Linux systemd --user 单元。 */
65
+ export function systemdUnit(spec) {
66
+ const throttle = spec.throttleSeconds ?? 10;
67
+ const lines = [];
68
+ lines.push(`# ${auditComment(spec)}`);
69
+ lines.push('[Unit]');
70
+ lines.push(`Description=Mebular ${spec.kind}`);
71
+ lines.push('After=network.target');
72
+ lines.push('');
73
+ lines.push('[Service]');
74
+ lines.push('Type=simple');
75
+ lines.push(`ExecStart=${[spec.execPath, ...spec.args].map(systemdQuote).join(' ')}`);
76
+ lines.push('Restart=on-failure');
77
+ lines.push(`RestartSec=${throttle}`);
78
+ if (spec.workingDir !== undefined)
79
+ lines.push(`WorkingDirectory=${systemdQuote(spec.workingDir)}`);
80
+ for (const [key, value] of Object.entries(spec.env ?? {}))
81
+ lines.push(`Environment=${systemdQuote(`${key}=${value}`)}`);
82
+ lines.push(`StandardOutput=append:${systemdQuote(spec.stdoutLog)}`);
83
+ lines.push(`StandardError=append:${systemdQuote(spec.stderrLog)}`);
84
+ if (spec.autostart) {
85
+ lines.push('');
86
+ lines.push('[Install]');
87
+ lines.push('WantedBy=default.target');
88
+ }
89
+ return `${lines.join('\n')}\n`;
90
+ }
91
+ /** Windows Task Scheduler 任务 XML(onlogon,无需管理员;InteractiveToken + LeastPrivilege)。 */
92
+ export function windowsTaskXml(spec) {
93
+ const throttle = spec.throttleSeconds ?? 10;
94
+ const args = [spec.execPath, ...spec.args].map((a) => (a === '' || /[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(' ');
95
+ const lines = [];
96
+ lines.push('<?xml version="1.0" encoding="UTF-16"?>');
97
+ lines.push('<!-- ' + auditComment(spec) + ' -->');
98
+ lines.push('<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">');
99
+ lines.push(' <RegistrationInfo>');
100
+ lines.push(` <Description>Mebular ${spec.kind}</Description>`);
101
+ lines.push(' </RegistrationInfo>');
102
+ lines.push(' <Triggers>');
103
+ if (spec.autostart) {
104
+ lines.push(' <LogonTrigger>');
105
+ lines.push(' <Enabled>true</Enabled>');
106
+ lines.push(' </LogonTrigger>');
107
+ }
108
+ lines.push(' </Triggers>');
109
+ lines.push(' <Principals>');
110
+ lines.push(' <Principal id="Author">');
111
+ lines.push(' <LogonType>InteractiveToken</LogonType>');
112
+ lines.push(' <RunLevel>LeastPrivilege</RunLevel>');
113
+ lines.push(' </Principal>');
114
+ lines.push(' </Principals>');
115
+ lines.push(' <Settings>');
116
+ lines.push(' <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>');
117
+ lines.push(' <AllowHardTerminate>true</AllowHardTerminate>');
118
+ lines.push(' <StartWhenAvailable>true</StartWhenAvailable>');
119
+ lines.push(' <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>');
120
+ lines.push(' <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>');
121
+ lines.push(' <RestartOnFailure>');
122
+ lines.push(' <Interval>PT' + throttle + 'S</Interval>');
123
+ lines.push(' <Count>3</Count>');
124
+ lines.push(' </RestartOnFailure>');
125
+ lines.push(' </Settings>');
126
+ lines.push(' <Actions Context="Author">');
127
+ lines.push(' <Exec>');
128
+ lines.push(` <Command>${escapeXml(spec.execPath)}</Command>`);
129
+ lines.push(` <Arguments>${escapeXml(args.slice(spec.execPath.length + 1))}</Arguments>`);
130
+ if (spec.workingDir !== undefined)
131
+ lines.push(` <WorkingDirectory>${escapeXml(spec.workingDir)}</WorkingDirectory>`);
132
+ lines.push(' </Exec>');
133
+ lines.push(' </Actions>');
134
+ lines.push('</Task>');
135
+ return `${lines.join('\n')}\n`;
136
+ }
137
+ /** 按平台生成单元文本。 */
138
+ export function unitText(platform, spec) {
139
+ switch (platform) {
140
+ case 'darwin':
141
+ return launchdPlist(spec);
142
+ case 'linux':
143
+ return systemdUnit(spec);
144
+ case 'win32':
145
+ return windowsTaskXml(spec);
146
+ default: {
147
+ const never = platform;
148
+ throw new Error(`unsupported platform: ${String(never)}`);
149
+ }
150
+ }
151
+ }
152
+ /** 旧标签/单元名(**无 home 维度**):`com.mebular.<kind>`。仅用于迁移期识别与清理。 */
153
+ export function legacyLabel(kind, labelPrefix = DEFAULT_LABEL_PREFIX) {
154
+ return `${labelPrefix}.${kind}`;
155
+ }
156
+ /** home 派生键(纯函数):同 home → 同键、不同 home → 不同键(12 位十六进制)。 */
157
+ export function homeKey(home) {
158
+ const normalized = resolve(home).replace(/[/\\]+$/, '') || resolve(home);
159
+ return createHash('sha256').update(normalized).digest('hex').slice(0, 12);
160
+ }
161
+ /**
162
+ * 默认标签/单元名:`<prefix>.<kind>.<homeKey>` —— **含 home 维度**,使不同 home 的服务身份彻底隔离、
163
+ * 互不顶替(F-SVC-1)。省略 `home` 时退回旧的无维度标签(仅供显示/迁移识别;install 恒传 home)。
164
+ */
165
+ export function defaultLabel(kind, labelPrefix = DEFAULT_LABEL_PREFIX, home) {
166
+ const base = `${labelPrefix}.${kind}`;
167
+ return home === undefined || home.length === 0 ? base : `${base}.${homeKey(home)}`;
168
+ }
169
+ /** systemd 单元文件名(`mebular-<kind>.service`)。 */
170
+ export function systemdUnitFileName(kind) {
171
+ return `mebular-${kind}.service`;
172
+ }
173
+ //# sourceMappingURL=units.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"units.js","sourceRoot":"","sources":["../src/units.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "@mebular/service",
3
+ "version": "0.1.0",
4
+ "sha": "38b7e436f79fe93d429d5fccc9a966c41b0a1f3d",
5
+ "builtAt": "2026-10-24T10:53:17.083Z"
6
+ }
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@mebular/service",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Mebular 常驻组件服务化:平台单元生成(launchd/systemd/Task Scheduler)+ install/uninstall/status/logs + 进程心跳",
5
+ "license": "MIT",
6
+ "private": false,
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "src",
18
+ "dist"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "prepack": "node ../../scripts/publish-manifest.mjs set",
28
+ "postpack": "node ../../scripts/publish-manifest.mjs restore"
29
+ }
30
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,139 @@
1
+ // 共用 `service` 子命令(fleet / mebular 两个 CLI 复用同一 UX 与语义)。
2
+ //
3
+ // install <name> [--no-autostart] [--label L] [--sha S]
4
+ // uninstall <name>
5
+ // status [name]
6
+ // logs <name> [--tail N]
7
+
8
+ import type { RunCommand, ServiceDescriptor, ServiceOptions } from './manager.js';
9
+ import { installService, resolveBuildSha, restartService, serviceLogs, serviceStatus, uninstallService } from './manager.js';
10
+ import type { ServiceKind, ServicePlatform } from './units.js';
11
+ import { appendFileSync } from 'node:fs';
12
+
13
+ /**
14
+ * 仪器化(F-INSTALL-1):`MEBULAR_SERVICE_TRACE=<path>` 时,install 的每条外部命令
15
+ * (launchctl/systemctl/…)以 JSONL 追加落盘(at/cmd/args/rc/stdout/stderr/durationMs),
16
+ * 供 launchd 收尾竞态等「命令级时序」诊断。
17
+ */
18
+ function fileTracerFromEnv(): ServiceOptions['trace'] {
19
+ const target = process.env.MEBULAR_SERVICE_TRACE;
20
+ if (target === undefined || target.length === 0) return undefined;
21
+ return (entry) => {
22
+ try {
23
+ appendFileSync(target, `${JSON.stringify(entry)}\n`, 'utf-8');
24
+ } catch {
25
+ // 留痕失败绝不阻塞安装主路径
26
+ }
27
+ };
28
+ }
29
+
30
+ export interface ServiceCliOptions {
31
+ descriptors: readonly ServiceDescriptor[];
32
+ argv: readonly string[];
33
+ home?: string;
34
+ platform?: ServicePlatform;
35
+ run?: RunCommand;
36
+ out?: (line: string) => void;
37
+ /** 入口边界解析好的构建 SHA(F-INSTALL-1:与 cwd 解耦);缺省回落 resolveBuildSha()。 */
38
+ sha?: string;
39
+ }
40
+
41
+ function parseFlags(list: readonly string[]): { positional: string[]; flags: Record<string, string | boolean> } {
42
+ const positional: string[] = [];
43
+ const flags: Record<string, string | boolean> = {};
44
+ for (let i = 0; i < list.length; i++) {
45
+ const token = list[i]!;
46
+ if (token.startsWith('--')) {
47
+ const key = token.slice(2);
48
+ const next = list[i + 1];
49
+ // `--extra` 的值本身以 `--` 开头(透传给服务的 CLI 参数),必须按值消费。
50
+ if (key === 'extra' && next !== undefined) {
51
+ flags[key] = next;
52
+ i++;
53
+ } else if (next === undefined || next.startsWith('--')) {
54
+ flags[key] = true;
55
+ } else {
56
+ flags[key] = next;
57
+ i++;
58
+ }
59
+ } else {
60
+ positional.push(token);
61
+ }
62
+ }
63
+ return { positional, flags };
64
+ }
65
+
66
+ const findDescriptor = (descriptors: readonly ServiceDescriptor[], name: string): ServiceDescriptor | undefined =>
67
+ descriptors.find((d) => d.kind === name);
68
+
69
+ export const SERVICE_USAGE =
70
+ '用法:<cli> service install <name> [--no-autostart] [--label L] [--sha S] | uninstall <name> | restart <name> | status [name] | logs <name> [--tail N]';
71
+
72
+ /** 返回退出码;输出为单行 JSON。 */
73
+ export function runServiceCli(opts: ServiceCliOptions): number {
74
+ const out = opts.out ?? ((line: string) => process.stdout.write(`${line}\n`));
75
+ const [sub, ...rest] = [...opts.argv];
76
+ const { positional, flags } = parseFlags(rest);
77
+ const base: ServiceOptions = {
78
+ ...(opts.home !== undefined ? { home: opts.home } : {}),
79
+ ...(opts.platform !== undefined ? { platform: opts.platform } : {}),
80
+ ...(opts.run !== undefined ? { run: opts.run } : {}),
81
+ };
82
+ const emit = (value: unknown, code = 0): number => {
83
+ out(JSON.stringify(value, null, 2));
84
+ return code;
85
+ };
86
+
87
+ if (sub === 'status') {
88
+ const names = positional.length > 0 ? positional : opts.descriptors.map((d) => d.kind);
89
+ const services = names.map((name) => {
90
+ const descriptor = findDescriptor(opts.descriptors, name);
91
+ if (!descriptor) throw new Error(`未知服务:${name}`);
92
+ return serviceStatus(descriptor, base);
93
+ });
94
+ return emit({ ok: true, action: 'status', services });
95
+ }
96
+
97
+ const name = positional[0] ?? (opts.descriptors.length === 1 ? opts.descriptors[0]!.kind : undefined);
98
+ if (name === undefined) {
99
+ out(SERVICE_USAGE);
100
+ return 2;
101
+ }
102
+ const found = findDescriptor(opts.descriptors, name);
103
+ if (!found) {
104
+ out(SERVICE_USAGE);
105
+ return 2;
106
+ }
107
+ // `--extra "a b c"`:把宿主透传的额外 CLI 参数追加到服务启动命令(如 `--submit 3`)。
108
+ const extra = typeof flags.extra === 'string' ? flags.extra.split(/\s+/).filter((s) => s.length > 0) : [];
109
+ const descriptor: ServiceDescriptor = extra.length > 0 ? { ...found, args: [...found.args, ...extra] } : found;
110
+
111
+ if (sub === 'install') {
112
+ const autostart = flags['no-autostart'] !== true;
113
+ const sha = typeof flags.sha === 'string' ? flags.sha : (opts.sha ?? resolveBuildSha());
114
+ const trace = fileTracerFromEnv();
115
+ const result = installService(descriptor, {
116
+ ...base,
117
+ autostart,
118
+ sha,
119
+ ...(typeof flags.label === 'string' ? { label: flags.label } : {}),
120
+ ...(trace !== undefined ? { trace } : {}),
121
+ });
122
+ return emit(result);
123
+ }
124
+ if (sub === 'uninstall') {
125
+ return emit(uninstallService(descriptor, base));
126
+ }
127
+ if (sub === 'restart') {
128
+ const result = restartService(descriptor, base);
129
+ return emit(result, result.ok ? 0 : 1);
130
+ }
131
+ if (sub === 'logs') {
132
+ const tail = typeof flags.tail === 'string' ? Number(flags.tail) : undefined;
133
+ return emit(serviceLogs(descriptor, { ...base, ...(tail !== undefined && Number.isFinite(tail) ? { tail } : {}) }));
134
+ }
135
+ out(SERVICE_USAGE);
136
+ return 2;
137
+ }
138
+
139
+ export type { ServiceKind };
@@ -0,0 +1,89 @@
1
+ // 进程心跳(D4):常驻组件周期性写 `<configDir>/service.heartbeat`,供 `service status` 与 doctor 判定
2
+ // 「是否在跑且新鲜」。原子写、0600(权限语义同现约定;Windows 走 ACL,由 doctor 说明差异)。
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ export interface Heartbeat {
8
+ pid: number;
9
+ /** epoch ms */
10
+ ts: number;
11
+ role: string;
12
+ sha: string;
13
+ }
14
+
15
+ export const HEARTBEAT_FILE = 'service.heartbeat';
16
+ export const HEARTBEAT_DEFAULT_MAX_AGE_MS = 15_000;
17
+
18
+ export const heartbeatPath = (dir: string): string => path.join(dir, HEARTBEAT_FILE);
19
+
20
+ export function writeHeartbeat(
21
+ dir: string,
22
+ input: { role: string; sha: string; pid?: number; now?: number },
23
+ ): Heartbeat {
24
+ const record: Heartbeat = {
25
+ pid: input.pid ?? process.pid,
26
+ ts: input.now ?? Date.now(),
27
+ role: input.role,
28
+ sha: input.sha,
29
+ };
30
+ fs.mkdirSync(dir, { recursive: true });
31
+ const target = heartbeatPath(dir);
32
+ const tmp = `${target}.tmp-${process.pid}`;
33
+ fs.writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
34
+ try {
35
+ fs.chmodSync(tmp, 0o600);
36
+ } catch {
37
+ // Windows 无 POSIX mode;由 ACL 兜底。
38
+ }
39
+ fs.renameSync(tmp, target);
40
+ return record;
41
+ }
42
+
43
+ export function readHeartbeat(dir: string): Heartbeat | null {
44
+ try {
45
+ const parsed = JSON.parse(fs.readFileSync(heartbeatPath(dir), 'utf-8')) as unknown;
46
+ if (typeof parsed !== 'object' || parsed === null) return null;
47
+ const rec = parsed as Record<string, unknown>;
48
+ if (typeof rec.pid !== 'number' || typeof rec.ts !== 'number') return null;
49
+ return { pid: rec.pid, ts: rec.ts, role: String(rec.role ?? ''), sha: String(rec.sha ?? '') };
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ export function heartbeatAgeMs(dir: string, now: number = Date.now()): number | null {
56
+ const hb = readHeartbeat(dir);
57
+ return hb === null ? null : Math.max(0, now - hb.ts);
58
+ }
59
+
60
+ export function isHeartbeatFresh(
61
+ dir: string,
62
+ maxAgeMs: number = HEARTBEAT_DEFAULT_MAX_AGE_MS,
63
+ now: number = Date.now(),
64
+ ): boolean {
65
+ const age = heartbeatAgeMs(dir, now);
66
+ return age !== null && age <= maxAgeMs;
67
+ }
68
+
69
+ /**
70
+ * 常驻进程心跳:立即写一次,之后每 `intervalMs` 刷新;返回停止函数(只停刷新,不删文件——
71
+ * 进程死亡后文件停留在最后 ts,`status`/doctor 据此报告「陈旧」)。
72
+ */
73
+ export function startHeartbeat(
74
+ dir: string,
75
+ input: { role: string; sha: string; intervalMs?: number; pid?: number },
76
+ ): () => void {
77
+ const intervalMs = input.intervalMs ?? 5_000;
78
+ const beat = (): void => {
79
+ try {
80
+ writeHeartbeat(dir, { role: input.role, sha: input.sha, ...(input.pid !== undefined ? { pid: input.pid } : {}) });
81
+ } catch {
82
+ // 心跳失败不应让服务崩溃。
83
+ }
84
+ };
85
+ beat();
86
+ const timer = setInterval(beat, intervalMs);
87
+ timer.unref?.();
88
+ return () => clearInterval(timer);
89
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ // @mebular/service —— 常驻组件服务化(平台单元生成 + 生命周期 + 心跳)。零 core 依赖。
2
+
3
+ export * from './units.js';
4
+ export * from './paths.js';
5
+ export * from './launchd.js';
6
+ export * from './heartbeat.js';
7
+ export * from './manager.js';
8
+ export * from './cli.js';
package/src/launchd.ts ADDED
@@ -0,0 +1,111 @@
1
+ // macOS launchd 作业的**纯解析与归属判定**(无 IO、无平台副作用),供 `service status/install`
2
+ // 校验「加载中的作业是否属于本 home」——这是 F-SVC-1 的核心不变量:
3
+ // **跨 home 的作业绝不算 running,也绝不 bootout 他人**。
4
+ //
5
+ // 背景(真机事故):旧方案 label 无 home 维度(`com.mebular.<kind>`),任意 home 的 install 都会
6
+ // 顶掉同 label 的既有作业;`launchctl print gui/<uid>/<label>` 只要 label 存在就返回 0 → 别的 home
7
+ // 的作业被本 home 误报 running(假阳性)。故 running 判定必须回到「作业内容」:
8
+ // ProgramArguments / MEBULAR_HOME / WorkingDirectory / plist 路径与本 home 是否一致。
9
+
10
+ import { resolve, sep } from 'node:path';
11
+
12
+ /** `launchctl print gui/<uid>/<label>` 里我们关心的事实。 */
13
+ export interface LaunchdJob {
14
+ label: string | null;
15
+ /** 作业来源 plist 路径(`path = …`) */
16
+ path: string | null;
17
+ /** `program = …` */
18
+ program: string | null;
19
+ /** `arguments = { … }`(去掉可执行文件名之外的逐项) */
20
+ arguments: string[];
21
+ /** `environment = { K => V }`(`=>` 与 `=` 两种写法都接受) */
22
+ environment: Record<string, string>;
23
+ /** `working directory = …` */
24
+ workingDirectory: string | null;
25
+ }
26
+
27
+ const strip = (value: string): string => value.trim().replace(/^["']|["']$/g, '').trim();
28
+
29
+ /** 归属比较用的路径规范化(绝对化 + 去尾部分隔符)。纯字符串运算,不做 realpath(免 IO)。 */
30
+ export function normalizeServicePath(value: string): string {
31
+ const absolute = resolve(value);
32
+ return absolute.length > 1 ? absolute.replace(/[/\\]+$/, '') : absolute;
33
+ }
34
+
35
+ function readScalar(lines: readonly string[], key: string): string | null {
36
+ const re = new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`);
37
+ for (const line of lines) {
38
+ const match = re.exec(line);
39
+ if (match) return strip(match[1]!);
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /** 读 `key = { … }` 或 `key = ( … )` 块内容(去缩进、去行尾逗号)。 */
45
+ function readBlock(lines: readonly string[], key: string): string[] {
46
+ const open = new RegExp(`^\\s*${key}\\s*=\\s*[({]\\s*$`);
47
+ const start = lines.findIndex((line) => open.test(line));
48
+ if (start < 0) return [];
49
+ const out: string[] = [];
50
+ for (let i = start + 1; i < lines.length; i++) {
51
+ const line = lines[i]!;
52
+ if (/^\s*[)}]\s*$/.test(line)) break;
53
+ const trimmed = line.replace(/^\s+/, '').replace(/,\s*$/, '');
54
+ if (trimmed.length > 0) out.push(trimmed);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * 解析 `launchctl print gui/<uid>/<label>` 输出。**容错**:缺块/空输出返回全 null,
61
+ * 调用方据此判定「不归属」(保守 → 绝不当成 running)。
62
+ */
63
+ export function parseLaunchdPrint(output: string): LaunchdJob {
64
+ const lines = String(output ?? '').split('\n');
65
+ const argumentsList = readBlock(lines, 'arguments').map(strip).filter((s) => s.length > 0);
66
+ const environment: Record<string, string> = {};
67
+ for (const entry of readBlock(lines, 'environment')) {
68
+ const match = /^(.+?)\s*(?:=>|=)\s*(.*)$/.exec(entry);
69
+ if (match && match[1] !== undefined) environment[strip(match[1])] = strip(match[2] ?? '');
70
+ }
71
+ return {
72
+ label: readScalar(lines, 'label'),
73
+ path: readScalar(lines, 'path'),
74
+ program: readScalar(lines, 'program'),
75
+ arguments: argumentsList,
76
+ environment,
77
+ workingDirectory: readScalar(lines, 'working directory'),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * 加载中的作业是否属于本 home。**任一**事实匹配即视为本 home:
83
+ * - 环境 `MEBULAR_HOME` / `MEBULAR_SERVICE_HOME` == home;
84
+ * - `working directory` == home;
85
+ * - plist 路径位于 `<home>/` 之下。
86
+ * 无法判定(解析不到任何事实)→ **false**(保守:宁可说不是本 home,也不误报 running)。
87
+ */
88
+ export function jobBelongsToHome(job: LaunchdJob | null, home: string): boolean {
89
+ if (!job) return false;
90
+ const target = normalizeServicePath(home);
91
+ for (const key of ['MEBULAR_HOME', 'MEBULAR_SERVICE_HOME'] as const) {
92
+ const value = job.environment[key];
93
+ if (typeof value === 'string' && value.length > 0 && normalizeServicePath(value) === target) return true;
94
+ }
95
+ if (job.workingDirectory && normalizeServicePath(job.workingDirectory) === target) return true;
96
+ if (job.path) {
97
+ const p = normalizeServicePath(job.path);
98
+ if (p === target || p.startsWith(target + sep)) return true;
99
+ }
100
+ return false;
101
+ }
102
+
103
+ /** 人类可读的「占用者」描述(用于警告文案;无法识别时给 null)。 */
104
+ export function describeJobOwner(job: LaunchdJob | null): string | null {
105
+ if (!job) return null;
106
+ const fromEnv = job.environment.MEBULAR_HOME ?? job.environment.MEBULAR_SERVICE_HOME;
107
+ if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
108
+ if (job.workingDirectory) return job.workingDirectory;
109
+ if (job.path) return job.path;
110
+ return null;
111
+ }