@lancewu602/mihomo-dash 0.0.0-stage → 0.6.1

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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -2
  3. package/bin/mihomo-dash.js +4 -0
  4. package/package.json +63 -4
  5. package/server/CHANGELOG.md +51 -0
  6. package/server/package.json +13 -0
  7. package/server/src/cli/index.js +75 -0
  8. package/server/src/cli/service.js +167 -0
  9. package/server/src/cli/status.js +245 -0
  10. package/server/src/http/config-files.js +148 -0
  11. package/server/src/http/config-order.js +186 -0
  12. package/server/src/http/config-sections.js +278 -0
  13. package/server/src/http/default-config.js +69 -0
  14. package/server/src/http/groups.js +386 -0
  15. package/server/src/http/index.js +260 -0
  16. package/server/src/http/kernel-admin.js +522 -0
  17. package/server/src/http/kernel-proxy.js +43 -0
  18. package/server/src/http/providers.js +251 -0
  19. package/server/src/http/rules.js +509 -0
  20. package/server/src/http/settings.js +409 -0
  21. package/server/src/http/system-proxy.js +86 -0
  22. package/server/src/index.js +26 -0
  23. package/server/src/kernel/archive.js +181 -0
  24. package/server/src/kernel/binary.js +33 -0
  25. package/server/src/kernel/client.js +138 -0
  26. package/server/src/kernel/install.js +150 -0
  27. package/server/src/kernel/lifecycle.js +339 -0
  28. package/server/src/kernel/run.js +11 -0
  29. package/server/src/shared/args.js +57 -0
  30. package/server/src/shared/atomic-file.js +41 -0
  31. package/server/src/shared/exec.js +44 -0
  32. package/server/src/shared/kernel-config.js +27 -0
  33. package/server/src/shared/log-file.js +92 -0
  34. package/server/src/shared/panel.js +58 -0
  35. package/server/src/shared/paths.js +72 -0
  36. package/server/src/shared/service.js +552 -0
  37. package/server/src/shared/system-proxy.js +375 -0
  38. package/server/src/shared/text.js +11 -0
  39. package/server/tests/cli-service.test.js +153 -0
  40. package/server/tests/config-dir.test.js +133 -0
  41. package/server/tests/config-order.test.js +148 -0
  42. package/server/tests/groups.test.js +558 -0
  43. package/server/tests/kernel-admin.test.js +647 -0
  44. package/server/tests/kernel.test.js +250 -0
  45. package/server/tests/log-file.test.js +110 -0
  46. package/server/tests/providers.test.js +259 -0
  47. package/server/tests/rules.test.js +463 -0
  48. package/server/tests/service.test.js +470 -0
  49. package/server/tests/settings.test.js +456 -0
  50. package/server/tests/status.test.js +269 -0
  51. package/server/tests/system-proxy.test.js +497 -0
  52. package/server/tsconfig.json +17 -0
  53. package/web/dist/assets/index-BpDzYhuI.css +1 -0
  54. package/web/dist/assets/index-WmujD2EM.js +515 -0
  55. package/web/dist/favicon.svg +5 -0
  56. package/web/dist/index.html +14 -0
@@ -0,0 +1,470 @@
1
+ /**
2
+ * 开机自启(server/src/shared/service.js)单元测试。
3
+ *
4
+ * 全部用桩执行器 + 临时 HOME / 状态目录,**不碰本机的 launchd / systemd**:
5
+ * 只断言生成的服务定义内容与真实 argv(固定 argv、不经 shell)。
6
+ * 运行:npm test
7
+ */
8
+ import assert from 'node:assert/strict';
9
+ import { after, before, test } from 'node:test';
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import {
14
+ createServiceManager,
15
+ parseLaunchctlPrint,
16
+ parseSystemctlState,
17
+ renderPlist,
18
+ renderUnit,
19
+ } from '../src/shared/service.js';
20
+
21
+ /** @type {string} */ let tmpDir;
22
+ /** @type {string} */ let home;
23
+ /** @type {string} */ let cliDir;
24
+
25
+ const ENTRIES = {
26
+ panel: '/opt/mihomo-dash/server/src/index.js',
27
+ kernel: '/opt/mihomo-dash/server/src/kernel/run.js',
28
+ };
29
+
30
+ const OPTS = () => ({
31
+ node: '/usr/local/bin/node',
32
+ entries: ENTRIES,
33
+ cliDir,
34
+ port: 8981,
35
+ host: '127.0.0.1',
36
+ });
37
+
38
+ /**
39
+ * 桩执行器:记录每次真实 argv,按 `cmd 子命令` 或 `cmd` 取预设返回值。
40
+ * @param {Record<string, any>} [handlers]
41
+ */
42
+ function stubRun(handlers = {}) {
43
+ const calls = [];
44
+ const run = (cmd, args) => {
45
+ calls.push([cmd, ...args]);
46
+ const h = handlers[`${cmd} ${args[0]}`] ?? handlers[cmd];
47
+ if (typeof h === 'function') return h(args);
48
+ if (h) return h;
49
+ return { ok: true, status: 0, stdout: '', stderr: '' };
50
+ };
51
+ return { run, calls };
52
+ }
53
+
54
+ const ok = (stdout = '') => ({ ok: true, status: 0, stdout, stderr: '' });
55
+ const fail = (stderr = 'boom') => ({ ok: false, status: 5, stdout: '', stderr });
56
+ const plistFile = (label) => path.join(home, 'Library', 'LaunchAgents', `${label}.plist`);
57
+ const unitFile = (unit) => path.join(home, '.config', 'systemd', 'user', unit);
58
+
59
+ before(() => {
60
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mihomo-dash-service-'));
61
+ home = path.join(tmpDir, 'home');
62
+ cliDir = path.join(tmpDir, 'cli');
63
+ fs.mkdirSync(home, { recursive: true });
64
+ fs.mkdirSync(cliDir, { recursive: true });
65
+ });
66
+
67
+ after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
68
+
69
+ test('plist:固定 argv 指向 job 自己的入口脚本,RunAtLoad 有、KeepAlive 没有(只开机起一次)', () => {
70
+ const panel = renderPlist('panel', OPTS());
71
+ assert.match(panel, /<key>Label<\/key>\s*<string>com\.mihomo-dash\.panel<\/string>/);
72
+ assert.match(
73
+ panel,
74
+ /<string>\/usr\/local\/bin\/node<\/string>\s*<string>\/opt\/mihomo-dash\/server\/src\/index\.js<\/string>\s*<string>--port<\/string>\s*<string>8981<\/string>\s*<string>--host<\/string>\s*<string>127\.0\.0\.1<\/string>/,
75
+ );
76
+ assert.match(panel, /<key>RunAtLoad<\/key>\s*<true\/>/);
77
+ // 只开机起一次:不能出现 KeepAlive 键(注释里提到这个词不算)
78
+ assert.doesNotMatch(panel, /<key>KeepAlive<\/key>/);
79
+ assert.doesNotMatch(renderUnit('panel', OPTS()), /Restart=(always|on-failure)/);
80
+ assert.match(panel, new RegExp(`daemon\\.log`));
81
+ // 内核那个 job 只带 kernel,不带端口
82
+ const kernel = renderPlist('kernel', OPTS());
83
+ assert.doesNotMatch(kernel, /--port/);
84
+ assert.match(kernel, /kernel\.log/);
85
+ // kernel job 没有额外参数:argv 就是 node + 内核入口脚本
86
+ assert.match(
87
+ kernel,
88
+ /<string>\/usr\/local\/bin\/node<\/string>\s*<string>\/opt\/mihomo-dash\/server\/src\/kernel\/run\.js<\/string>\s*<\/array>/,
89
+ );
90
+ // CLI 里已经没 service 子命令了,服务定义只能指向 runner 脚本
91
+ assert.doesNotMatch(panel + kernel, /<string>service<\/string>/);
92
+ // 「登录项 → 允许在后台」靠这个键认领归属(不写就显示成 node 的 Node.js Foundation)
93
+ for (const text of [panel, kernel]) {
94
+ assert.match(
95
+ text,
96
+ /<key>AssociatedBundleIdentifiers<\/key>\s*<array>\s*<string>com\.mihomo-dash<\/string>/,
97
+ );
98
+ }
99
+ });
100
+
101
+ test('plist:路径里的 XML 特殊字符会被转义', () => {
102
+ const text = renderPlist('panel', { ...OPTS(), cliDir: '/tmp/a & b<c>d' });
103
+ assert.match(text, /\/tmp\/a &amp; b&lt;c&gt;d/);
104
+ assert.doesNotMatch(text, /& b<c>/);
105
+ });
106
+
107
+ test('systemd 单元:ExecStart 固定 argv、Restart=no、日志 append', () => {
108
+ const unit = renderUnit('panel', OPTS());
109
+ assert.match(unit, /^# 由 `mihomo-dash service install` 生成/m);
110
+ assert.match(unit, /Description=mihomo-dash 面板/);
111
+ assert.match(
112
+ unit,
113
+ /ExecStart=\/usr\/local\/bin\/node \/opt\/mihomo-dash\/server\/src\/index\.js --port 8981 --host 127\.0\.0\.1/,
114
+ );
115
+ assert.match(unit, /Restart=no/);
116
+ assert.match(unit, /WantedBy=default\.target/);
117
+ assert.match(unit, new RegExp(`StandardOutput=append:${cliDir}/daemon\\.log`));
118
+ // 含空格的路径要加引号,否则 systemd 会把参数拆开
119
+ const quoted = renderUnit('kernel', {
120
+ ...OPTS(),
121
+ entries: { ...ENTRIES, kernel: '/opt/my dash/server/src/kernel/run.js' },
122
+ });
123
+ assert.match(
124
+ quoted,
125
+ /ExecStart=\/usr\/local\/bin\/node "\/opt\/my dash\/server\/src\/kernel\/run\.js"/,
126
+ );
127
+ });
128
+
129
+ test('macOS 安装:写两个 plist,launchctl bootstrap 走 gui 域,全程不经 shell', async () => {
130
+ // 全新安装:两个 job 都还没装载
131
+ const { run, calls } = stubRun({ 'launchctl print': () => fail('Could not find service') });
132
+ const svc = createServiceManager({
133
+ run,
134
+ platform: 'darwin',
135
+ home,
136
+ cliDir,
137
+ uid: 501,
138
+ node: OPTS().node,
139
+ entries: ENTRIES,
140
+ });
141
+ const res = await svc.install({ port: 8981, host: '127.0.0.1' });
142
+
143
+ assert.deepEqual(res.errors, []);
144
+ assert.ok(fs.existsSync(plistFile('com.mihomo-dash.panel')));
145
+ assert.ok(fs.existsSync(plistFile('com.mihomo-dash.kernel')));
146
+ assert.ok(res.files.length === 2);
147
+ assert.deepEqual(
148
+ calls.find((c) => c[0] === 'launchctl' && c[1] === 'bootstrap'),
149
+ ['launchctl', 'bootstrap', 'gui/501', plistFile('com.mihomo-dash.panel')],
150
+ );
151
+ // 安装不 bootout:那会当场杀掉正在服务这个请求的面板
152
+ assert.ok(!calls.some((c) => c[1] === 'bootout'));
153
+ // 固定 argv:命令只有 launchctl,没有 sh -c 这类拼接
154
+ assert.deepEqual([...new Set(calls.map((c) => c[0]))], ['launchctl']);
155
+ assert.ok(calls.every((c) => c.slice(1).every((a) => typeof a === 'string')));
156
+ assert.equal(res.loadedIn.panel, 'gui/501');
157
+ assert.match(res.hints.join('\n'), /Node 固定为 \/usr\/local\/bin\/node/);
158
+ });
159
+
160
+ test('macOS 安装:已装载的 job 不 bootout,只更新定义并说明下次登录生效', async () => {
161
+ const { run, calls } = stubRun({ 'launchctl print': () => ok('state = running\n') });
162
+ const svc = createServiceManager({ run, platform: 'darwin', home, cliDir, uid: 501 });
163
+ const res = await svc.install();
164
+ assert.ok(!calls.some((c) => c[1] === 'bootout'));
165
+ assert.ok(!calls.some((c) => c[1] === 'bootstrap'));
166
+ assert.match(res.notes.join('\n'), /只更新了定义/);
167
+ assert.equal(res.loadedIn.panel, 'gui/501');
168
+ });
169
+
170
+ test('macOS 安装:manualRunning 不影响装载(darwin 一律 bootstrap,「已装载」才是真的)', async () => {
171
+ const { run, calls } = stubRun({ 'launchctl print': () => fail('Could not find service') });
172
+ const svc = createServiceManager({ run, platform: 'darwin', home, cliDir, uid: 501 });
173
+ const res = await svc.install({ manualRunning: ['panel', 'kernel'] });
174
+ assert.ok(fs.existsSync(plistFile('com.mihomo-dash.panel')));
175
+ assert.deepEqual(res.errors, []);
176
+ // manualRunning 只影响 Linux:darwin 两个 job 都 bootstrap(进程会因端口被占自己退出)
177
+ assert.ok(calls.some((c) => c[1] === 'bootstrap' && /panel/.test(c[3] || '')));
178
+ assert.ok(calls.some((c) => c[1] === 'bootstrap' && /kernel/.test(c[3] || '')));
179
+ assert.equal(res.loadedIn.panel, 'gui/501');
180
+ });
181
+
182
+ test('macOS 安装:gui 域 bootstrap 失败时退到 user 域;失败时只报错不抛异常', async () => {
183
+ const { run } = stubRun({
184
+ 'launchctl print': () => fail('Could not find service'),
185
+ 'launchctl bootstrap': (args) =>
186
+ args[1] === 'user/501' ? ok() : fail('Bootstrap failed: 5: Input/output error'),
187
+ });
188
+ const svc = createServiceManager({ run, platform: 'darwin', home, cliDir, uid: 501 });
189
+ const res = await svc.install();
190
+ assert.equal(res.loadedIn.panel, 'user/501');
191
+ assert.deepEqual(res.errors, []);
192
+
193
+ const broken = createServiceManager({
194
+ run: stubRun({
195
+ 'launchctl print': () => fail('Could not find service'),
196
+ 'launchctl bootstrap': () => fail('nope'),
197
+ }).run,
198
+ platform: 'darwin',
199
+ home,
200
+ cliDir,
201
+ uid: 501,
202
+ });
203
+ const res2 = await broken.install();
204
+ assert.match(res2.errors.join('\n'), /bootstrap 失败/);
205
+ assert.ok(fs.existsSync(plistFile('com.mihomo-dash.panel')), 'plist 仍然写好,下次登录照样启动');
206
+ });
207
+
208
+ test('macOS 卸载:只删 plist,不 bootout(不动正在跑的进程)', async () => {
209
+ const { run, calls } = stubRun({ 'launchctl print': () => fail('Could not find service') });
210
+ const svc = createServiceManager({ run, platform: 'darwin', home, cliDir, uid: 501 });
211
+ await svc.install();
212
+ const res = await svc.uninstall();
213
+ assert.ok(!calls.some((c) => c[1] === 'bootout'), '卸载不 bootout:会连当前面板的响应一起带走');
214
+ assert.equal(res.removed.length, 2);
215
+ assert.ok(!fs.existsSync(plistFile('com.mihomo-dash.panel')));
216
+ assert.ok(!fs.existsSync(plistFile('com.mihomo-dash.kernel')));
217
+ assert.match(res.notes.join('\n'), /正在跑的进程没动/);
218
+ assert.ok(!res.notes.join('\n').includes('还挂着'), '没装载就不该提残留');
219
+ });
220
+
221
+ test('macOS 卸载:定义删了但 launchd 里仍挂着时,如实说明(不 bootout)', async () => {
222
+ const { run, calls } = stubRun({ 'launchctl print': () => ok('state = not running\n') });
223
+ const svc = createServiceManager({ run, platform: 'darwin', home, cliDir, uid: 501 });
224
+ await svc.install(); // 已装载 → 只更新定义
225
+ const res = await svc.uninstall();
226
+ assert.ok(!calls.some((c) => c[1] === 'bootout'), '卸载不 bootout:会连当前面板的响应一起带走');
227
+ assert.equal(res.removed.length, 2);
228
+ assert.match(res.notes.join('\n'), /panel: plist 已删,但 launchd 里这个 job 还挂着/);
229
+ assert.match(res.notes.join('\n'), /重新登录后它就没了/);
230
+ });
231
+
232
+ test('Linux 安装/卸载:daemon-reload + enable --now / disable(不 --now)', async () => {
233
+ const { run, calls } = stubRun();
234
+ const svc = createServiceManager({ run, platform: 'linux', home, cliDir });
235
+ const res = await svc.install({ port: 8982, host: '0.0.0.0' });
236
+ assert.deepEqual(res.errors, []);
237
+ assert.ok(fs.existsSync(unitFile('mihomo-dash-panel.service')));
238
+ assert.ok(fs.existsSync(unitFile('mihomo-dash-kernel.service')));
239
+ assert.ok(calls.some((c) => c.join(' ') === 'systemctl --user daemon-reload'));
240
+ assert.ok(
241
+ calls.some((c) => c.join(' ') === 'systemctl --user enable --now mihomo-dash-panel.service'),
242
+ );
243
+ assert.match(fs.readFileSync(unitFile('mihomo-dash-panel.service'), 'utf8'), /--port 8982/);
244
+ assert.deepEqual([...new Set(calls.map((c) => c[0]))], ['systemctl']);
245
+
246
+ const res2 = await svc.uninstall();
247
+ assert.equal(res2.removed.length, 2);
248
+ // disable 不能带 --now:unit 文件与 enable 链接是两回事,disable 才管「下次登录不再拉起」
249
+ assert.ok(
250
+ calls.some((c) => c.join(' ') === 'systemctl --user disable mihomo-dash-kernel.service'),
251
+ );
252
+ assert.ok(!calls.some((c) => c.includes('--now') && c.includes('disable')));
253
+ });
254
+
255
+ test('Linux 安装:manualRunning 的 job 只 enable(不 --now),不拉第二个实例', async () => {
256
+ const { run, calls } = stubRun();
257
+ const svc = createServiceManager({ run, platform: 'linux', home, cliDir });
258
+ const res = await svc.install({ manualRunning: ['panel'] });
259
+ assert.deepEqual(res.errors, []);
260
+ assert.ok(calls.some((c) => c.join(' ') === 'systemctl --user enable mihomo-dash-panel.service'));
261
+ assert.ok(
262
+ !calls.some((c) => c.join(' ') === 'systemctl --user enable --now mihomo-dash-panel.service'),
263
+ );
264
+ assert.ok(
265
+ calls.some((c) => c.join(' ') === 'systemctl --user enable --now mihomo-dash-kernel.service'),
266
+ );
267
+ assert.match(res.notes.join('\n'), /正在手动运行/);
268
+ });
269
+
270
+ test('Linux 状态:is-enabled / is-active 组合出「已装 / 已启用 / 运行中」,并报 linger', async () => {
271
+ const { run } = stubRun({
272
+ 'systemctl --user': (args) => {
273
+ const cmd = args[1];
274
+ if (cmd === 'is-enabled') return ok('enabled');
275
+ if (cmd === 'is-active') return ok('active');
276
+ return ok();
277
+ },
278
+ loginctl: () => ok('Linger=yes\n'),
279
+ });
280
+ const svc = createServiceManager({ run, platform: 'linux', home, cliDir, user: 'didi' });
281
+ await svc.install();
282
+ const res = await svc.status();
283
+ assert.equal(res.linger, true, 'Linger=yes → 开机即起');
284
+ assert.equal(res.jobs.length, 2);
285
+ for (const j of res.jobs) {
286
+ assert.equal(j.installed, true);
287
+ assert.equal(j.running, true);
288
+ assert.equal(j.domain, 'systemd --user');
289
+ }
290
+
291
+ const off = createServiceManager({
292
+ run: stubRun({
293
+ 'systemctl --user': (args) => (args[1] === 'is-enabled' ? ok('disabled') : ok('inactive')),
294
+ loginctl: () => ok('Linger=no\n'),
295
+ }).run,
296
+ platform: 'linux',
297
+ home,
298
+ cliDir,
299
+ user: 'didi',
300
+ });
301
+ const res2 = await off.status();
302
+ assert.equal(res2.jobs[0].installed, true);
303
+ assert.equal(res2.jobs[0].running, false);
304
+ assert.equal(res2.linger, false, 'Linger=no → 只会在登录后启动');
305
+ });
306
+
307
+ test('Linux 没有可用 systemd:如实说不支持,不去装一个注定跑不起来的服务', async () => {
308
+ const { run } = stubRun({
309
+ 'systemctl --user': () => fail('spawnSync systemctl ENOENT'),
310
+ });
311
+ const svc = createServiceManager({ run, platform: 'linux', home, cliDir });
312
+
313
+ const st = await svc.status();
314
+ assert.equal(st.supported, false);
315
+ assert.deepEqual(st.jobs, []);
316
+ assert.match(st.hint, /没有可用的 systemd 用户实例/);
317
+ assert.match(st.hint, /ENOENT/);
318
+
319
+ for (const res of [await svc.install(), await svc.uninstall(), await svc.start()]) {
320
+ assert.equal(res.supported, false, JSON.stringify(res));
321
+ assert.match(res.hint, /没有可用的 systemd 用户实例/);
322
+ }
323
+ });
324
+
325
+ test('Linux 连不上用户实例(容器 / 没有会话):也算不可用,不静默失败', async () => {
326
+ const { run } = stubRun({
327
+ 'systemctl --user': () => fail('Failed to connect to bus: No such file or directory'),
328
+ });
329
+ const svc = createServiceManager({ run, platform: 'linux', home, cliDir });
330
+ const st = await svc.status();
331
+ assert.equal(st.supported, false);
332
+ assert.match(st.hint, /Failed to connect to bus/);
333
+ assert.match(st.hint, /先在有用户会话的环境里跑/);
334
+ });
335
+
336
+ test('macOS 状态:从 launchctl print 读 state / pid;未装载时如实说', async () => {
337
+ const printPanel = `gui/501/com.mihomo-dash.panel = {
338
+ active count = 1
339
+ path = /Users/x/Library/LaunchAgents/com.mihomo-dash.panel.plist
340
+ state = running
341
+ program = /usr/local/bin/node
342
+ arguments = {
343
+ pid = 4321
344
+ }
345
+ last exit code = (never exited)
346
+ }
347
+ `;
348
+ const { run } = stubRun({
349
+ 'launchctl print': (args) =>
350
+ args[1].endsWith('com.mihomo-dash.panel') ? ok(printPanel) : fail('Could not find service'),
351
+ });
352
+ const svc = createServiceManager({ run, platform: 'darwin', home, cliDir, uid: 501 });
353
+ await svc.install();
354
+ const res = await svc.status();
355
+ const panel = res.jobs.find((j) => j.job === 'panel');
356
+ const kernel = res.jobs.find((j) => j.job === 'kernel');
357
+ assert.deepEqual(
358
+ { loaded: panel.loaded, running: panel.running, pid: panel.pid, domain: panel.domain },
359
+ { loaded: true, running: true, pid: 4321, domain: 'gui/501' },
360
+ );
361
+ assert.deepEqual(
362
+ { loaded: kernel.loaded, running: kernel.running },
363
+ { loaded: false, running: false },
364
+ );
365
+ });
366
+
367
+ test('service start:未安装时报错提示;已装载时按平台 kickstart / start', async () => {
368
+ const missing = createServiceManager({
369
+ run: stubRun().run,
370
+ platform: 'darwin',
371
+ home: path.join(tmpDir, 'empty-home'),
372
+ cliDir,
373
+ uid: 501,
374
+ });
375
+ const res0 = await missing.start('kernel');
376
+ assert.match(res0.errors.join('\n'), /还没安装开机自启/);
377
+
378
+ const { run, calls } = stubRun({ 'launchctl print': () => ok('state = not running\n') });
379
+ const svc = createServiceManager({ run, platform: 'darwin', home, cliDir, uid: 501 });
380
+ await svc.install();
381
+ const res = await svc.start('kernel');
382
+ assert.deepEqual(res.errors, []);
383
+ assert.deepEqual(
384
+ calls.find((c) => c[1] === 'kickstart'),
385
+ ['launchctl', 'kickstart', 'gui/501/com.mihomo-dash.kernel'],
386
+ );
387
+ });
388
+
389
+ test('Windows / 未知平台:不支持,给可执行的提示而不是空白', async () => {
390
+ const svc = createServiceManager({ run: stubRun().run, platform: 'win32', home, cliDir });
391
+ assert.equal(svc.supported, false);
392
+ assert.match(svc.hint, /启动/);
393
+ const res = await svc.install();
394
+ assert.equal(res.supported, false);
395
+ assert.deepEqual(res.files, []);
396
+ const start = await svc.start();
397
+ assert.equal(start.supported, false);
398
+ });
399
+
400
+ test('未知 job 名直接报错,不静默生成一个错文件', () => {
401
+ assert.throws(() => renderPlist('nope', OPTS()), /未知服务/);
402
+ assert.throws(() => renderUnit('nope', OPTS()), /未知服务/);
403
+ });
404
+
405
+ test('解析工具:launchctl print 取第一处 state / pid,systemd 两个查询归一化', () => {
406
+ const parsed = parseLaunchctlPrint(
407
+ 'gui/501/x = {\n\tstate = running\n\tpid = 99\n\t\tstate = active\n}\n',
408
+ );
409
+ assert.deepEqual(parsed, { state: 'running', pid: 99 });
410
+ assert.deepEqual(parseSystemctlState('enabled', 'active'), {
411
+ installed: true,
412
+ enabled: true,
413
+ active: true,
414
+ state: 'active',
415
+ });
416
+ assert.deepEqual(parseSystemctlState('not-found', 'inactive'), {
417
+ installed: false,
418
+ enabled: false,
419
+ active: false,
420
+ state: 'inactive',
421
+ });
422
+ });
423
+
424
+ test('system 单元(Debian 默认):写 /etc/systemd/system、不带 --user、multi-user.target', async () => {
425
+ const { createServiceManager } = await import('../src/shared/service.js');
426
+ const calls = [];
427
+ const svc = createServiceManager({
428
+ platform: 'linux',
429
+ uid: 0,
430
+ home,
431
+ cliDir,
432
+ systemDir: path.join(tmpDir, 'etc-systemd'),
433
+ entries: ENTRIES,
434
+ run: async (cmd, args) => {
435
+ calls.push([cmd, ...args]);
436
+ return { ok: true, stdout: '', stderr: '' };
437
+ },
438
+ });
439
+ assert.equal(svc.scope, 'system', 'root 时默认 system');
440
+
441
+ const res = await svc.install({ port: 8981, host: '127.0.0.1' });
442
+
443
+ assert.deepEqual(res.errors, []);
444
+ assert.equal(res.scope, 'system');
445
+ assert.ok(res.files.every((f) => f.startsWith(path.join(tmpDir, 'etc-systemd'))));
446
+ const unit = fs.readFileSync(res.files[0], 'utf8');
447
+ assert.match(unit, /WantedBy=multi-user\.target/);
448
+ assert.match(unit, /作用域:system/);
449
+ // system 作用域不能出现 --user
450
+ const systemctl = calls.filter((c) => c[0] === 'systemctl').map((c) => c.slice(1));
451
+ assert.ok(systemctl.length > 0);
452
+ assert.ok(
453
+ systemctl.every((args) => !args.includes('--user')),
454
+ JSON.stringify(systemctl),
455
+ );
456
+ assert.ok(systemctl.some((args) => args.includes('enable') && args.includes('--now')));
457
+ });
458
+
459
+ test('非 root 且没指定作用域:默认用用户级(不写 /etc)', async () => {
460
+ const { createServiceManager } = await import('../src/shared/service.js');
461
+ const svc = createServiceManager({
462
+ platform: 'linux',
463
+ uid: 1000,
464
+ home,
465
+ cliDir,
466
+ entries: ENTRIES,
467
+ run: async () => ({ ok: true, stdout: '', stderr: '' }),
468
+ });
469
+ assert.equal(svc.scope, 'user');
470
+ });