@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/src/paths.ts ADDED
@@ -0,0 +1,40 @@
1
+ // 平台路径与单元落盘位置(纯函数;home 可注入,便于测试)。
2
+
3
+ import os from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { ServiceKind, ServicePlatform, UnitSpec } from './units.js';
6
+ import { systemdUnitFileName } from './units.js';
7
+
8
+ /** 状态目录(manifest + 日志)。`MEBULAR_SERVICE_HOME` 可覆盖(测试/隔离环境)。 */
9
+ export const serviceStateDir = (home: string = os.homedir()): string =>
10
+ process.env.MEBULAR_SERVICE_HOME ?? join(home, '.mebular', 'services');
11
+ export const serviceLogsDir = (home: string = os.homedir()): string => join(serviceStateDir(home), 'logs');
12
+ export const launchAgentsDir = (home: string = os.homedir()): string => join(home, 'Library', 'LaunchAgents');
13
+ export const systemdUserDir = (home: string = os.homedir()): string => join(home, '.config', 'systemd', 'user');
14
+ export const windowsTaskDir = (home: string = os.homedir()): string => join(serviceStateDir(home), 'tasks');
15
+
16
+ /** 安装清单(记录 SHA/args/role,供 `service status`)。 */
17
+ export const manifestPath = (name: string, home: string = os.homedir()): string =>
18
+ join(serviceStateDir(home), `${name}.json`);
19
+
20
+ /** 单元文件落盘路径。 */
21
+ export function unitFilePath(
22
+ platform: ServicePlatform,
23
+ spec: Pick<UnitSpec, 'kind' | 'label'>,
24
+ home: string = os.homedir(),
25
+ ): string {
26
+ switch (platform) {
27
+ case 'darwin':
28
+ return join(launchAgentsDir(home), `${spec.label}.plist`);
29
+ case 'linux':
30
+ return join(systemdUserDir(home), systemdUnitFileName(spec.kind));
31
+ case 'win32':
32
+ return join(windowsTaskDir(home), `${spec.kind}.xml`);
33
+ }
34
+ }
35
+
36
+ /** 服务 stdout/stderr 日志路径。 */
37
+ export const serviceLogPaths = (kind: ServiceKind, home: string = os.homedir()): { stdoutLog: string; stderrLog: string } => {
38
+ const dir = serviceLogsDir(home);
39
+ return { stdoutLog: join(dir, `${kind}.out.log`), stderrLog: join(dir, `${kind}.err.log`) };
40
+ };
package/src/units.ts ADDED
@@ -0,0 +1,208 @@
1
+ // 平台服务单元生成(**纯函数**):输入 spec,输出单元文本。无 IO、无时钟、无平台副作用,
2
+ // 因此可对三种平台做 golden 测试,也可被 macOS/Linux/Windows 之外的环境安全调用。
3
+ //
4
+ // 角色命名(D2):fleet-node(任务板/发起端)· fleet-worker(执行端)· mebular-serve(记忆/同步/MCP 底座)。
5
+
6
+ import { createHash } from 'node:crypto';
7
+ import { resolve } from 'node:path';
8
+
9
+ export type ServicePlatform = 'darwin' | 'linux' | 'win32';
10
+
11
+ /** 三个常驻组件(服务单元名 = 角色名)。 */
12
+ export type ServiceKind = 'fleet-node' | 'fleet-worker' | 'mebular-serve';
13
+
14
+ export const SERVICE_KINDS: readonly ServiceKind[] = ['fleet-node', 'fleet-worker', 'mebular-serve'];
15
+
16
+ /** 默认服务名前缀(标签/任务名由它派生;测试可用 labelPrefix 覆盖)。 */
17
+ export const DEFAULT_LABEL_PREFIX = 'com.mebular';
18
+
19
+ export interface UnitSpec {
20
+ kind: ServiceKind;
21
+ /** launchd Label / systemd 单元名 / Windows 任务名(派生后的完整名) */
22
+ label: string;
23
+ /** 解释器(通常 process.execPath) */
24
+ execPath: string;
25
+ /** 解释器参数(通常 [cli.js, 'node'|'worker', ...]) */
26
+ args: readonly string[];
27
+ workingDir?: string;
28
+ env?: Readonly<Record<string, string>>;
29
+ /** 服务日志(stdout/stderr)绝对路径 */
30
+ stdoutLog: string;
31
+ stderrLog: string;
32
+ /** 当前构建 SHA(钉死版本,供 `service status` 输出) */
33
+ sha: string;
34
+ /** 是否登录/开机自启(false = 仅安装、不自启) */
35
+ autostart: boolean;
36
+ /** 崩溃自拉节流(秒),默认 10 */
37
+ throttleSeconds?: number;
38
+ }
39
+
40
+ const escapeXml = (value: string): string =>
41
+ value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
42
+
43
+ /** systemd ExecStart / Environment 的引号规则:含空白或特殊字符时用双引号包裹并转义。 */
44
+ export function systemdQuote(value: string): string {
45
+ if (value.length > 0 && !/[\s"'\\$]/.test(value)) return value;
46
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
47
+ }
48
+
49
+ /** 单元内的审计注释(`service status` 与人工排查都从这里/state manifest 读 SHA)。 */
50
+ export const auditComment = (spec: UnitSpec): string => `mebular-service: kind=${spec.kind} sha=${spec.sha}`;
51
+
52
+ /** macOS launchd 用户级 LaunchAgent plist。 */
53
+ export function launchdPlist(spec: UnitSpec): string {
54
+ const throttle = spec.throttleSeconds ?? 10;
55
+ const envKeys = Object.keys(spec.env ?? {});
56
+ const lines: string[] = [];
57
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
58
+ lines.push('<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">');
59
+ lines.push('<plist version="1.0">');
60
+ lines.push('<dict>');
61
+ lines.push(' <!-- ' + auditComment(spec) + ' -->');
62
+ lines.push(' <key>Label</key>');
63
+ lines.push(` <string>${escapeXml(spec.label)}</string>`);
64
+ lines.push(' <key>ProgramArguments</key>');
65
+ lines.push(' <array>');
66
+ lines.push(` <string>${escapeXml(spec.execPath)}</string>`);
67
+ for (const arg of spec.args) lines.push(` <string>${escapeXml(arg)}</string>`);
68
+ lines.push(' </array>');
69
+ lines.push(' <key>RunAtLoad</key>');
70
+ lines.push(spec.autostart ? ' <true/>' : ' <false/>');
71
+ lines.push(' <key>KeepAlive</key>');
72
+ lines.push(' <true/>');
73
+ lines.push(' <key>ThrottleInterval</key>');
74
+ lines.push(` <integer>${throttle}</integer>`);
75
+ lines.push(' <key>StandardOutPath</key>');
76
+ lines.push(` <string>${escapeXml(spec.stdoutLog)}</string>`);
77
+ lines.push(' <key>StandardErrorPath</key>');
78
+ lines.push(` <string>${escapeXml(spec.stderrLog)}</string>`);
79
+ if (spec.workingDir !== undefined) {
80
+ lines.push(' <key>WorkingDirectory</key>');
81
+ lines.push(` <string>${escapeXml(spec.workingDir)}</string>`);
82
+ }
83
+ if (envKeys.length > 0) {
84
+ lines.push(' <key>EnvironmentVariables</key>');
85
+ lines.push(' <dict>');
86
+ for (const key of envKeys) {
87
+ lines.push(` <key>${escapeXml(key)}</key>`);
88
+ lines.push(` <string>${escapeXml(spec.env![key]!)}</string>`);
89
+ }
90
+ lines.push(' </dict>');
91
+ }
92
+ lines.push('</dict>');
93
+ lines.push('</plist>');
94
+ return `${lines.join('\n')}\n`;
95
+ }
96
+
97
+ /** Linux systemd --user 单元。 */
98
+ export function systemdUnit(spec: UnitSpec): string {
99
+ const throttle = spec.throttleSeconds ?? 10;
100
+ const lines: string[] = [];
101
+ lines.push(`# ${auditComment(spec)}`);
102
+ lines.push('[Unit]');
103
+ lines.push(`Description=Mebular ${spec.kind}`);
104
+ lines.push('After=network.target');
105
+ lines.push('');
106
+ lines.push('[Service]');
107
+ lines.push('Type=simple');
108
+ lines.push(`ExecStart=${[spec.execPath, ...spec.args].map(systemdQuote).join(' ')}`);
109
+ lines.push('Restart=on-failure');
110
+ lines.push(`RestartSec=${throttle}`);
111
+ if (spec.workingDir !== undefined) lines.push(`WorkingDirectory=${systemdQuote(spec.workingDir)}`);
112
+ for (const [key, value] of Object.entries(spec.env ?? {})) lines.push(`Environment=${systemdQuote(`${key}=${value}`)}`);
113
+ lines.push(`StandardOutput=append:${systemdQuote(spec.stdoutLog)}`);
114
+ lines.push(`StandardError=append:${systemdQuote(spec.stderrLog)}`);
115
+ if (spec.autostart) {
116
+ lines.push('');
117
+ lines.push('[Install]');
118
+ lines.push('WantedBy=default.target');
119
+ }
120
+ return `${lines.join('\n')}\n`;
121
+ }
122
+
123
+ /** Windows Task Scheduler 任务 XML(onlogon,无需管理员;InteractiveToken + LeastPrivilege)。 */
124
+ export function windowsTaskXml(spec: UnitSpec): string {
125
+ const throttle = spec.throttleSeconds ?? 10;
126
+ const args = [spec.execPath, ...spec.args].map((a) => (a === '' || /[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(' ');
127
+ const lines: string[] = [];
128
+ lines.push('<?xml version="1.0" encoding="UTF-16"?>');
129
+ lines.push('<!-- ' + auditComment(spec) + ' -->');
130
+ lines.push('<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">');
131
+ lines.push(' <RegistrationInfo>');
132
+ lines.push(` <Description>Mebular ${spec.kind}</Description>`);
133
+ lines.push(' </RegistrationInfo>');
134
+ lines.push(' <Triggers>');
135
+ if (spec.autostart) {
136
+ lines.push(' <LogonTrigger>');
137
+ lines.push(' <Enabled>true</Enabled>');
138
+ lines.push(' </LogonTrigger>');
139
+ }
140
+ lines.push(' </Triggers>');
141
+ lines.push(' <Principals>');
142
+ lines.push(' <Principal id="Author">');
143
+ lines.push(' <LogonType>InteractiveToken</LogonType>');
144
+ lines.push(' <RunLevel>LeastPrivilege</RunLevel>');
145
+ lines.push(' </Principal>');
146
+ lines.push(' </Principals>');
147
+ lines.push(' <Settings>');
148
+ lines.push(' <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>');
149
+ lines.push(' <AllowHardTerminate>true</AllowHardTerminate>');
150
+ lines.push(' <StartWhenAvailable>true</StartWhenAvailable>');
151
+ lines.push(' <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>');
152
+ lines.push(' <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>');
153
+ lines.push(' <RestartOnFailure>');
154
+ lines.push(' <Interval>PT' + throttle + 'S</Interval>');
155
+ lines.push(' <Count>3</Count>');
156
+ lines.push(' </RestartOnFailure>');
157
+ lines.push(' </Settings>');
158
+ lines.push(' <Actions Context="Author">');
159
+ lines.push(' <Exec>');
160
+ lines.push(` <Command>${escapeXml(spec.execPath)}</Command>`);
161
+ lines.push(` <Arguments>${escapeXml(args.slice(spec.execPath.length + 1))}</Arguments>`);
162
+ if (spec.workingDir !== undefined) lines.push(` <WorkingDirectory>${escapeXml(spec.workingDir)}</WorkingDirectory>`);
163
+ lines.push(' </Exec>');
164
+ lines.push(' </Actions>');
165
+ lines.push('</Task>');
166
+ return `${lines.join('\n')}\n`;
167
+ }
168
+
169
+ /** 按平台生成单元文本。 */
170
+ export function unitText(platform: ServicePlatform, spec: UnitSpec): string {
171
+ switch (platform) {
172
+ case 'darwin':
173
+ return launchdPlist(spec);
174
+ case 'linux':
175
+ return systemdUnit(spec);
176
+ case 'win32':
177
+ return windowsTaskXml(spec);
178
+ default: {
179
+ const never: never = platform;
180
+ throw new Error(`unsupported platform: ${String(never)}`);
181
+ }
182
+ }
183
+ }
184
+
185
+ /** 旧标签/单元名(**无 home 维度**):`com.mebular.<kind>`。仅用于迁移期识别与清理。 */
186
+ export function legacyLabel(kind: ServiceKind, labelPrefix = DEFAULT_LABEL_PREFIX): string {
187
+ return `${labelPrefix}.${kind}`;
188
+ }
189
+
190
+ /** home 派生键(纯函数):同 home → 同键、不同 home → 不同键(12 位十六进制)。 */
191
+ export function homeKey(home: string): string {
192
+ const normalized = resolve(home).replace(/[/\\]+$/, '') || resolve(home);
193
+ return createHash('sha256').update(normalized).digest('hex').slice(0, 12);
194
+ }
195
+
196
+ /**
197
+ * 默认标签/单元名:`<prefix>.<kind>.<homeKey>` —— **含 home 维度**,使不同 home 的服务身份彻底隔离、
198
+ * 互不顶替(F-SVC-1)。省略 `home` 时退回旧的无维度标签(仅供显示/迁移识别;install 恒传 home)。
199
+ */
200
+ export function defaultLabel(kind: ServiceKind, labelPrefix = DEFAULT_LABEL_PREFIX, home?: string): string {
201
+ const base = `${labelPrefix}.${kind}`;
202
+ return home === undefined || home.length === 0 ? base : `${base}.${homeKey(home)}`;
203
+ }
204
+
205
+ /** systemd 单元文件名(`mebular-<kind>.service`)。 */
206
+ export function systemdUnitFileName(kind: ServiceKind): string {
207
+ return `mebular-${kind}.service`;
208
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.