pi-web-ui 0.1.1 → 0.2.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.
package/bin/pi-web-ui.mjs CHANGED
@@ -1,9 +1,747 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * pi-web-ui CLI entry.
3
+ * pi-web-ui CLI.
4
4
  *
5
- * Starts the production server (serves the built frontend + WebSocket API).
6
- * Env vars: PORT (default 8787), PI_WEB_CWD, PI_WEB_DATA_DIR, PI_CODING_AGENT_DIR.
7
- * See README.md for details.
5
+ * pi-web-ui 启动生产服务器(前台,Ctrl+C 停止)
6
+ * pi-web-ui --port 9000 --cwd /path 同上,覆盖端口 / 工作目录 / 数据目录
7
+ * pi-web-ui --version | --help
8
+ * pi-web-ui server install [选项] 安装系统服务(开机自启)并启动
9
+ * pi-web-ui server uninstall [选项] 卸载系统服务
10
+ * pi-web-ui server start|stop|restart|status [选项]
11
+ *
12
+ * 系统服务:
13
+ * - macOS → launchd 用户代理,label 默认 com.xingshuyin.pi-web-ui
14
+ * (--name 自定义时 com.<name>.server),无需 sudo
15
+ * - Linux → systemd 单元 <name>.service(/etc/systemd/system/,自动 sudo)
16
+ * - Windows → 计划任务(Task Scheduler / schtasks,登录后自启,无需管理员),
17
+ * 包装脚本与任务 XML 生成在 %APPDATA%\pi-web-ui\
18
+ *
19
+ * 环境变量(前台与系统服务均适用):PORT / PI_WEB_CWD / PI_WEB_DATA_DIR /
20
+ * PI_CODING_AGENT_DIR。
21
+ */
22
+ import { spawnSync } from "node:child_process";
23
+ import {
24
+ existsSync,
25
+ mkdirSync,
26
+ readFileSync,
27
+ rmSync,
28
+ writeFileSync,
29
+ } from "node:fs";
30
+ import { homedir, userInfo } from "node:os";
31
+ import { dirname, join, resolve } from "node:path";
32
+ import { pathToFileURL } from "node:url";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ const BIN_DIR = dirname(fileURLToPath(import.meta.url));
36
+ /** <pkg>/dist/server/index.js — the actual server entry. */
37
+ const SERVER_ENTRY = join(BIN_DIR, "..", "dist", "server", "index.js");
38
+ const NODE = process.execPath;
39
+ let pkg = { version: "0.0.0" };
40
+ try {
41
+ pkg = JSON.parse(readFileSync(join(BIN_DIR, "..", "package.json"), "utf8"));
42
+ } catch {
43
+ // version is best-effort — the server itself doesn't need it
44
+ }
45
+
46
+ const HELP = `pi-web-ui v${pkg.version} — web chat for the pi coding agent
47
+
48
+ 用法:
49
+ pi-web-ui 启动服务器(前台,Ctrl+C 停止)
50
+ pi-web-ui --port 9000 --cwd /path 启动并指定端口 / 工作目录 / 数据目录
51
+ pi-web-ui server install [选项] 安装系统服务(开机自启)并启动
52
+ pi-web-ui server uninstall [选项] 卸载系统服务
53
+ pi-web-ui server start|stop|restart|status [选项]
54
+ pi-web-ui --version / --help
55
+
56
+ server 选项:
57
+ --port <n> 端口(默认 8787,或 $PORT)
58
+ --cwd <dir> 工作目录(默认 $PI_WEB_CWD 或当前目录)
59
+ --data-dir <dir> 会话数据目录(默认 <cwd>/.pi-web)
60
+ --name <name> 服务名(默认 pi-web-ui;macOS 的 launchd label
61
+ 为 com.xingshuyin.pi-web-ui,自定义名时为 com.<name>.server)
62
+ --print 只打印将生成的配置文件,不实际安装
63
+
64
+ 平台: macOS → launchd 用户代理 · Linux → systemd · Windows → 计划任务(schtasks)
65
+ (Windows 任务登录后自启、无需管理员;stop 停止,uninstall 移除)
66
+
67
+ 环境变量(前台与系统服务均适用):
68
+ PORT / PI_WEB_CWD / PI_WEB_DATA_DIR / PI_CODING_AGENT_DIR
69
+ `;
70
+
71
+ function fail(msg) {
72
+ console.error(`✖ ${msg}`);
73
+ process.exit(1);
74
+ }
75
+
76
+ /** Run a command, inheriting stdio; exits on failure unless ignoreError. */
77
+ function run(cmd, args, { ignoreError = false, silent = false } = {}) {
78
+ const res = spawnSync(cmd, args, {
79
+ stdio: silent ? ["inherit", "ignore", "ignore"] : "inherit",
80
+ });
81
+ if (!ignoreError && res.status !== 0) process.exit(res.status ?? 1);
82
+ return res;
83
+ }
84
+
85
+ /** Parse --flag value / --flag=value options; returns { opts, positionals }. */
86
+ function parseFlags(argv) {
87
+ const opts = {
88
+ port: undefined,
89
+ cwd: undefined,
90
+ dataDir: undefined,
91
+ name: undefined,
92
+ print: false,
93
+ help: false,
94
+ };
95
+ const positionals = [];
96
+ for (let i = 0; i < argv.length; i++) {
97
+ const a = argv[i];
98
+ const eq = a.indexOf("=");
99
+ const key = eq >= 0 ? a.slice(0, eq) : a;
100
+ const inline = eq >= 0 ? a.slice(eq + 1) : undefined;
101
+ const take = (flag) => {
102
+ if (inline !== undefined) return inline;
103
+ if (i + 1 < argv.length) {
104
+ i++;
105
+ return argv[i];
106
+ }
107
+ fail(`缺少选项 ${flag} 的值`);
108
+ };
109
+ switch (key) {
110
+ case "--port":
111
+ opts.port = take("--port");
112
+ break;
113
+ case "--cwd":
114
+ opts.cwd = take("--cwd");
115
+ break;
116
+ case "--data-dir":
117
+ opts.dataDir = take("--data-dir");
118
+ break;
119
+ case "--name":
120
+ opts.name = take("--name");
121
+ break;
122
+ case "--print":
123
+ opts.print = true;
124
+ break;
125
+ case "--help":
126
+ case "-h":
127
+ opts.help = true;
128
+ break;
129
+ default:
130
+ if (key.startsWith("-")) fail(`未知选项: ${key}`);
131
+ positionals.push(a);
132
+ }
133
+ }
134
+ return { opts, positionals };
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // 前台启动
139
+ // ---------------------------------------------------------------------------
140
+
141
+ async function startForeground(opts) {
142
+ if (opts.port) process.env.PORT = opts.port;
143
+ if (opts.cwd) process.env.PI_WEB_CWD = resolve(opts.cwd);
144
+ if (opts.dataDir) process.env.PI_WEB_DATA_DIR = resolve(opts.dataDir);
145
+ await import(pathToFileURL(SERVER_ENTRY).href);
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // 系统服务管理
150
+ // ---------------------------------------------------------------------------
151
+
152
+ const isMac = process.platform === "darwin";
153
+ const isLinux = process.platform === "linux";
154
+ const isWin = process.platform === "win32";
155
+
156
+ function uid() {
157
+ try {
158
+ return userInfo().uid;
159
+ } catch {
160
+ return process.getuid?.() ?? 501;
161
+ }
162
+ }
163
+
164
+ /** launchd label / systemd unit name / Windows task name for a service name. */
165
+ function serviceLabel(name) {
166
+ if (isMac) {
167
+ return name === "pi-web-ui"
168
+ ? "com.xingshuyin.pi-web-ui"
169
+ : `com.${name}.server`;
170
+ }
171
+ return name;
172
+ }
173
+
174
+ function launchAgentPlist(name) {
175
+ return join(
176
+ homedir(),
177
+ "Library",
178
+ "LaunchAgents",
179
+ `${serviceLabel(name)}.plist`,
180
+ );
181
+ }
182
+
183
+ function systemdUnitPath(name) {
184
+ return `/etc/systemd/system/${name}.service`;
185
+ }
186
+
187
+ /** Windows: per-user config dir (%APPDATA%\pi-web-ui) holding the .cmd wrapper + task XML. */
188
+ function winServiceDir() {
189
+ return join(
190
+ process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
191
+ "pi-web-ui",
192
+ );
193
+ }
194
+
195
+ function winCmdPath(name) {
196
+ return join(winServiceDir(), `${name}.cmd`);
197
+ }
198
+
199
+ function winTaskXmlPath(name) {
200
+ return join(winServiceDir(), `${name}.xml`);
201
+ }
202
+
203
+ function winLogPath() {
204
+ return join(homedir(), "pi-web-ui.log");
205
+ }
206
+
207
+ /** True when a scheduled task with this name exists (schtasks exits 0). */
208
+ function winTaskExists(name) {
209
+ return (
210
+ spawnSync("schtasks", ["/Query", "/TN", name], { stdio: "ignore" })
211
+ .status === 0
212
+ );
213
+ }
214
+
215
+ /** Build the .cmd wrapper the scheduled task runs (set env → cd → launch node → log). */
216
+ function buildWinCmd(cwd, env, logPath) {
217
+ const sets = Object.entries(env)
218
+ .map(([k, v]) => `set "${k}=${v}"`)
219
+ .join("\r\n");
220
+ return [
221
+ "@echo off",
222
+ "rem Generated by: pi-web-ui server install (rerun to change)",
223
+ sets,
224
+ `cd /d "${cwd}"`,
225
+ `"${NODE}" "${SERVER_ENTRY}" >> "${logPath}" 2>&1`,
226
+ "",
227
+ ].join("\r\n");
228
+ }
229
+
230
+ /**
231
+ * Build the Task Scheduler XML for a user task: LogonTrigger (starts at logon,
232
+ * like a launchd agent — no admin needed), InteractiveToken, auto-restart on
233
+ * failure. The task runs the .cmd wrapper via cmd.exe.
8
234
  */
9
- import "../dist/server/index.js";
235
+ function buildWinTaskXml(cmdPath, cwd) {
236
+ const cmdExe = join(
237
+ process.env.SystemRoot ?? "C:\\Windows",
238
+ "System32",
239
+ "cmd.exe",
240
+ );
241
+ return `<?xml version="1.0" encoding="UTF-16"?>
242
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
243
+ <RegistrationInfo>
244
+ <Description>pi-web-ui — web chat for the pi coding agent (auto-start at logon)</Description>
245
+ </RegistrationInfo>
246
+ <Triggers>
247
+ <LogonTrigger>
248
+ <Enabled>true</Enabled>
249
+ </LogonTrigger>
250
+ </Triggers>
251
+ <Principals>
252
+ <Principal id="Author">
253
+ <LogonType>InteractiveToken</LogonType>
254
+ <RunLevel>LeastPrivilege</RunLevel>
255
+ </Principal>
256
+ </Principals>
257
+ <Settings>
258
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
259
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
260
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
261
+ <AllowHardTerminate>true</AllowHardTerminate>
262
+ <StartWhenAvailable>false</StartWhenAvailable>
263
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
264
+ <IdleSettings>
265
+ <StopOnIdleEnd>false</StopOnIdleEnd>
266
+ <RestartOnIdle>false</RestartOnIdle>
267
+ </IdleSettings>
268
+ <AllowStartOnDemand>true</AllowStartOnDemand>
269
+ <Enabled>true</Enabled>
270
+ <Hidden>false</Hidden>
271
+ <RunOnlyIfIdle>false</RunOnlyIfIdle>
272
+ <WakeToRun>false</WakeToRun>
273
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
274
+ <Priority>7</Priority>
275
+ <RestartOnFailure>
276
+ <Interval>PT1M</Interval>
277
+ <Count>3</Count>
278
+ </RestartOnFailure>
279
+ </Settings>
280
+ <Actions Context="Author">
281
+ <Exec>
282
+ <Command>${esc(cmdExe)}</Command>
283
+ <Arguments>/c ""${esc(cmdPath)}""</Arguments>
284
+ <WorkingDirectory>${esc(cwd)}</WorkingDirectory>
285
+ </Exec>
286
+ </Actions>
287
+ </Task>
288
+ `;
289
+ }
290
+
291
+ function esc(s) {
292
+ return s
293
+ .replace(/&/g, "&amp;")
294
+ .replace(/</g, "&lt;")
295
+ .replace(/>/g, "&gt;")
296
+ .replace(/"/g, "&quot;");
297
+ }
298
+
299
+ /** Build the launchd plist XML. */
300
+ function buildPlist(label, cwd, env) {
301
+ const entries = Object.entries(env)
302
+ .map(([k, v]) => ` <key>${esc(k)}</key>\n <string>${esc(v)}</string>`)
303
+ .join("\n");
304
+ return `<?xml version="1.0" encoding="UTF-8"?>
305
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
306
+ <!-- Generated by: pi-web-ui server install (do not edit by hand — rerun to change) -->
307
+ <plist version="1.0">
308
+ <dict>
309
+ <key>Label</key>
310
+ <string>${esc(label)}</string>
311
+
312
+ <key>ProgramArguments</key>
313
+ <array>
314
+ <string>${esc(NODE)}</string>
315
+ <string>${esc(SERVER_ENTRY)}</string>
316
+ </array>
317
+
318
+ <key>RunAtLoad</key>
319
+ <true/>
320
+
321
+ <!-- Restart if it crashes -->
322
+ <key>KeepAlive</key>
323
+ <true/>
324
+
325
+ <key>WorkingDirectory</key>
326
+ <string>${esc(cwd)}</string>
327
+
328
+ <key>EnvironmentVariables</key>
329
+ <dict>
330
+ ${entries}
331
+ </dict>
332
+
333
+ <key>StandardOutPath</key>
334
+ <string>/tmp/pi-web-ui.log</string>
335
+ <key>StandardErrorPath</key>
336
+ <string>/tmp/pi-web-ui.err</string>
337
+ </dict>
338
+ </plist>
339
+ `;
340
+ }
341
+
342
+ /** Build the systemd unit file. */
343
+ function buildUnit(cwd, env) {
344
+ const envLines = Object.entries(env)
345
+ .map(([k, v]) => `Environment=${k}=${v}`)
346
+ .join("\n");
347
+ return `# Generated by: pi-web-ui server install (do not edit by hand — rerun to change)
348
+ [Unit]
349
+ Description=pi-web-ui — web chat for the pi coding agent
350
+ After=network.target
351
+
352
+ [Service]
353
+ Type=simple
354
+ User=${process.env.SUDO_USER ?? userInfo().username}
355
+ WorkingDirectory=${cwd}
356
+ ${envLines}
357
+ ExecStart=${JSON.stringify(NODE)} ${JSON.stringify(SERVER_ENTRY)}
358
+ Restart=on-failure
359
+ RestartSec=5
360
+
361
+ [Install]
362
+ WantedBy=multi-user.target
363
+ `;
364
+ }
365
+
366
+ /** If not root on Linux, re-exec the same server command through sudo. */
367
+ function ensureRootForSystemctl() {
368
+ if (typeof process.getuid === "function" && process.getuid() === 0) return;
369
+ // process.argv = [node, <bin>, "server", <action>, ...rest] — forward
370
+ // everything after "server" so flags like --port/--cwd survive.
371
+ const res = spawnSync(
372
+ "sudo",
373
+ [NODE, fileURLToPath(import.meta.url), "server", ...process.argv.slice(3)],
374
+ { stdio: "inherit" },
375
+ );
376
+ process.exit(res.status ?? 1);
377
+ }
378
+
379
+ /** Shared option normalization for install. */
380
+ function serviceOptions(opts) {
381
+ const name = opts.name ?? "pi-web-ui";
382
+ const port = String(opts.port ?? process.env.PORT ?? "8787");
383
+ if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
384
+ fail(`无效端口: ${port}`);
385
+ }
386
+ const cwd = resolve(opts.cwd ?? process.env.PI_WEB_CWD ?? process.cwd());
387
+ if (!existsSync(cwd)) fail(`工作目录不存在: ${cwd}`);
388
+ let dataDir;
389
+ if (opts.dataDir) {
390
+ dataDir = resolve(opts.dataDir);
391
+ } else if (process.env.PI_WEB_DATA_DIR) {
392
+ dataDir = resolve(process.env.PI_WEB_DATA_DIR);
393
+ }
394
+ return { name, port, cwd, dataDir };
395
+ }
396
+
397
+ function serviceEnv(port, cwd, dataDir) {
398
+ const env = {
399
+ PORT: port,
400
+ PI_WEB_CWD: cwd,
401
+ };
402
+ // Interactive Windows tasks inherit the user's PATH; only systemd/launchd
403
+ // run with a minimal environment that needs an explicit PATH.
404
+ if (!isWin) env.PATH = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
405
+ if (dataDir) env.PI_WEB_DATA_DIR = dataDir;
406
+ return env;
407
+ }
408
+
409
+ function installLaunchd(opts) {
410
+ const { name, port, cwd, dataDir } = serviceOptions(opts);
411
+ const label = serviceLabel(name);
412
+ const plist = launchAgentPlist(name);
413
+ const content = buildPlist(label, cwd, serviceEnv(port, cwd, dataDir));
414
+ if (opts.print) {
415
+ console.log(`# ${plist}\n${content}`);
416
+ return;
417
+ }
418
+ // Unload any existing instance (ignore "not loaded"), then (re)install.
419
+ run("launchctl", ["bootout", `gui/${uid()}/${label}`], {
420
+ ignoreError: true,
421
+ silent: true,
422
+ });
423
+ mkdirSync(dirname(plist), { recursive: true });
424
+ writeFileSync(plist, content);
425
+ run("launchctl", ["bootstrap", `gui/${uid()}`, plist]);
426
+ console.log(`✅ 已安装并启动 launchd 服务 ${label}`);
427
+ console.log(` 端口 : ${port}`);
428
+ console.log(` 目录 : ${cwd}`);
429
+ console.log(` 访问 : http://localhost:${port}`);
430
+ console.log(` 日志 : /tmp/pi-web-ui.log /tmp/pi-web-ui.err`);
431
+ console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
432
+ }
433
+
434
+ function installSystemd(opts) {
435
+ const { name, port, cwd, dataDir } = serviceOptions(opts);
436
+ const content = buildUnit(cwd, serviceEnv(port, cwd, dataDir));
437
+ const unitPath = systemdUnitPath(name);
438
+ if (opts.print) {
439
+ console.log(`# ${unitPath}\n${content}`);
440
+ return;
441
+ }
442
+ ensureRootForSystemctl();
443
+ writeFileSync(unitPath, content);
444
+ run("systemctl", ["daemon-reload"]);
445
+ run("systemctl", ["enable", "--now", `${name}.service`]);
446
+ console.log(`✅ 已安装并启动 systemd 服务 ${name}.service`);
447
+ console.log(` 端口 : ${port}`);
448
+ console.log(` 目录 : ${cwd}`);
449
+ console.log(` 访问 : http://localhost:${port}`);
450
+ console.log(` 日志 : journalctl -u ${name}.service -f`);
451
+ console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
452
+ }
453
+
454
+ function uninstallLaunchd(opts) {
455
+ const name = opts.name ?? "pi-web-ui";
456
+ const label = serviceLabel(name);
457
+ const plist = launchAgentPlist(name);
458
+ run("launchctl", ["bootout", `gui/${uid()}/${label}`], {
459
+ ignoreError: true,
460
+ silent: true,
461
+ });
462
+ if (existsSync(plist)) rmSync(plist);
463
+ console.log(`🗑 已卸载 ${label}(plist 已删除,不再开机自启)`);
464
+ }
465
+
466
+ function uninstallSystemd(opts) {
467
+ const name = opts.name ?? "pi-web-ui";
468
+ ensureRootForSystemctl();
469
+ run("systemctl", ["disable", "--now", `${name}.service`], {
470
+ ignoreError: true,
471
+ });
472
+ const unitPath = systemdUnitPath(name);
473
+ if (existsSync(unitPath)) rmSync(unitPath);
474
+ run("systemctl", ["daemon-reload"]);
475
+ console.log(`🗑 已卸载 ${name}.service(不再开机自启)`);
476
+ }
477
+
478
+ function installWindows(opts) {
479
+ const { name, port, cwd, dataDir } = serviceOptions(opts);
480
+ const env = serviceEnv(port, cwd, dataDir);
481
+ const cmdPath = winCmdPath(name);
482
+ const xmlPath = winTaskXmlPath(name);
483
+ const cmd = buildWinCmd(cwd, env, winLogPath());
484
+ const xml = buildWinTaskXml(cmdPath, cwd);
485
+ if (opts.print) {
486
+ console.log(`# ${cmdPath}\n${cmd}`);
487
+ console.log(`# ${xmlPath}\n${xml}`);
488
+ return;
489
+ }
490
+ mkdirSync(dirname(cmdPath), { recursive: true });
491
+ writeFileSync(cmdPath, cmd);
492
+ // schtasks /Create /XML requires a UTF-16 file (with BOM).
493
+ writeFileSync(xmlPath, "\uFEFF" + xml, "utf16le");
494
+ if (winTaskExists(name)) {
495
+ run("schtasks", ["/Delete", "/TN", name, "/F"], {
496
+ ignoreError: true,
497
+ silent: true,
498
+ });
499
+ }
500
+ run("schtasks", ["/Create", "/TN", name, "/XML", xmlPath, "/F"]);
501
+ run("schtasks", ["/Run", "/TN", name], { ignoreError: true });
502
+ console.log(`✅ 已安装并启动计划任务 ${name}`);
503
+ console.log(` 端口 : ${port}`);
504
+ console.log(` 目录 : ${cwd}`);
505
+ console.log(` 访问 : http://localhost:${port}`);
506
+ console.log(` 日志 : ${winLogPath()}`);
507
+ console.log(
508
+ ` 说明 : 登录后自启(与 launchd 用户代理一致);stop 停止,uninstall 移除`,
509
+ );
510
+ console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
511
+ }
512
+
513
+ function uninstallWindows(opts) {
514
+ const name = opts.name ?? "pi-web-ui";
515
+ if (winTaskExists(name)) {
516
+ run("schtasks", ["/Delete", "/TN", name, "/F"], { ignoreError: true });
517
+ }
518
+ for (const f of [winCmdPath(name), winTaskXmlPath(name)]) {
519
+ if (existsSync(f)) rmSync(f);
520
+ }
521
+ console.log(`🗑 已卸载 ${name}(计划任务已删除,不再自启)`);
522
+ }
523
+
524
+ function controlService(action, opts) {
525
+ const name = opts.name ?? "pi-web-ui";
526
+
527
+ if (isMac) {
528
+ const label = serviceLabel(name);
529
+ const target = `gui/${uid()}/${label}`;
530
+ const loaded = () =>
531
+ spawnSync("launchctl", ["print", target], { stdio: "ignore" }).status ===
532
+ 0;
533
+
534
+ if (action === "status") {
535
+ if (loaded()) {
536
+ const res = spawnSync("launchctl", ["print", target], {
537
+ encoding: "utf8",
538
+ });
539
+ const state = (res.stdout.match(/state = (\w+)/) ?? [])[1] ?? "loaded";
540
+ console.log(`${label}: ${state}(已加载,开机自启中)`);
541
+ } else {
542
+ console.log(`${label}: 未安装(运行 pi-web-ui server install 安装)`);
543
+ }
544
+ return;
545
+ }
546
+
547
+ if (action === "start") {
548
+ if (loaded()) {
549
+ run("launchctl", ["kickstart", target]);
550
+ } else {
551
+ const plist = launchAgentPlist(name);
552
+ if (!existsSync(plist)) {
553
+ fail(`找不到 ${plist},请先运行 pi-web-ui server install`);
554
+ }
555
+ run("launchctl", ["bootstrap", `gui/${uid()}`, plist]);
556
+ }
557
+ console.log(`✅ 已启动 ${label}`);
558
+ return;
559
+ }
560
+
561
+ if (action === "restart") {
562
+ if (!loaded()) fail(`${label} 未加载,请先 pi-web-ui server start`);
563
+ run("launchctl", ["kickstart", "-k", target]);
564
+ console.log(`✅ 已重启 ${label}`);
565
+ return;
566
+ }
567
+
568
+ if (action === "stop") {
569
+ run("launchctl", ["bootout", target], {
570
+ ignoreError: true,
571
+ silent: true,
572
+ });
573
+ console.log(`⏹ 已停止 ${label}(已卸载,不再开机自启;start 恢复)`);
574
+ return;
575
+ }
576
+
577
+ fail(`未知操作: ${action}`);
578
+ }
579
+
580
+ if (isLinux) {
581
+ ensureRootForSystemctl();
582
+ if (action === "status") {
583
+ run("systemctl", ["status", `${name}.service`, "--no-pager"]);
584
+ return;
585
+ }
586
+ run("systemctl", [action, `${name}.service`]);
587
+ console.log(`✅ ${action} ${name}.service`);
588
+ return;
589
+ }
590
+
591
+ if (isWin) {
592
+ const exists = winTaskExists(name);
593
+
594
+ if (action === "status") {
595
+ if (!exists) {
596
+ console.log(`${name}: 未安装(运行 pi-web-ui server install 安装)`);
597
+ return;
598
+ }
599
+ // Get-ScheduledTask outputs English state enums — locale-independent,
600
+ // unlike `schtasks /Query` tables on localized Windows.
601
+ const ps = spawnSync(
602
+ "powershell.exe",
603
+ [
604
+ "-NoProfile",
605
+ "-NonInteractive",
606
+ "-Command",
607
+ "$t=Get-ScheduledTask -TaskName '" +
608
+ name +
609
+ "' -ErrorAction SilentlyContinue;" +
610
+ "if(!$t){'NOT_INSTALLED';exit}" +
611
+ "$i=$t|Get-ScheduledTaskInfo;" +
612
+ "'State: '+$t.State;" +
613
+ "'LastRunTime: '+$i.LastRunTime;" +
614
+ "'LastTaskResult: '+$i.LastTaskResult",
615
+ ],
616
+ { encoding: "utf8" },
617
+ );
618
+ if (ps.status !== 0 || (ps.stdout ?? "").includes("NOT_INSTALLED")) {
619
+ console.log(`${name}: 未安装(运行 pi-web-ui server install 安装)`);
620
+ return;
621
+ }
622
+ console.log(`${name}: 计划任务\n${(ps.stdout ?? "").trim()}`);
623
+ return;
624
+ }
625
+
626
+ if (action === "start") {
627
+ if (!exists) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
628
+ run("schtasks", ["/Run", "/TN", name]);
629
+ console.log(`✅ 已启动 ${name}`);
630
+ return;
631
+ }
632
+
633
+ if (action === "restart") {
634
+ if (!exists) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
635
+ run("schtasks", ["/End", "/TN", name], {
636
+ ignoreError: true,
637
+ silent: true,
638
+ });
639
+ run("schtasks", ["/Run", "/TN", name]);
640
+ console.log(`✅ 已重启 ${name}`);
641
+ return;
642
+ }
643
+
644
+ if (action === "stop") {
645
+ run("schtasks", ["/End", "/TN", name], {
646
+ ignoreError: true,
647
+ silent: true,
648
+ });
649
+ console.log(`⏹ 已停止 ${name}(自启保留;uninstall 移除)`);
650
+ return;
651
+ }
652
+
653
+ fail(`未知操作: ${action}`);
654
+ }
655
+
656
+ fail(
657
+ `不支持的系统服务平台: ${process.platform}(仅 macOS / Linux / Windows)`,
658
+ );
659
+ }
660
+
661
+ async function serverCmd(argv) {
662
+ const { opts, positionals } = parseFlags(argv);
663
+ if (opts.help) {
664
+ console.log(HELP);
665
+ return;
666
+ }
667
+ if (positionals.length === 0) {
668
+ console.log(HELP);
669
+ console.log("--- 当前服务状态 ---");
670
+ controlService("status", opts);
671
+ return;
672
+ }
673
+ const action = positionals[0];
674
+ if (positionals.length > 1)
675
+ fail(`多余的参数: ${positionals.slice(1).join(" ")}`);
676
+ switch (action) {
677
+ case "install": {
678
+ if (isMac) {
679
+ installLaunchd(opts);
680
+ } else if (isLinux) {
681
+ installSystemd(opts);
682
+ } else if (isWin) {
683
+ installWindows(opts);
684
+ } else {
685
+ fail(`不支持的系统服务平台: ${process.platform}`);
686
+ }
687
+ break;
688
+ }
689
+ case "uninstall": {
690
+ if (isMac) {
691
+ uninstallLaunchd(opts);
692
+ } else if (isLinux) {
693
+ uninstallSystemd(opts);
694
+ } else if (isWin) {
695
+ uninstallWindows(opts);
696
+ } else {
697
+ fail(`不支持的系统服务平台: ${process.platform}`);
698
+ }
699
+ break;
700
+ }
701
+ case "start":
702
+ case "stop":
703
+ case "restart":
704
+ case "status":
705
+ controlService(action, opts);
706
+ break;
707
+ default:
708
+ fail(
709
+ `未知操作: ${action}(install / uninstall / start / stop / restart / status)`,
710
+ );
711
+ }
712
+ }
713
+
714
+ async function main() {
715
+ const argv = process.argv.slice(2);
716
+ if (argv.length === 0) {
717
+ await startForeground({});
718
+ return;
719
+ }
720
+ const first = argv[0];
721
+ if (first === "--version" || first === "-v") {
722
+ console.log(pkg.version);
723
+ return;
724
+ }
725
+ if (first === "--help" || first === "-h") {
726
+ console.log(HELP);
727
+ return;
728
+ }
729
+ if (first === "server") {
730
+ await serverCmd(argv.slice(1));
731
+ return;
732
+ }
733
+ // One-shot server with optional --port/--cwd/--data-dir overrides.
734
+ const { opts, positionals } = parseFlags(argv);
735
+ if (opts.help) {
736
+ console.log(HELP);
737
+ return;
738
+ }
739
+ if (positionals.length > 0)
740
+ fail(`未知命令: ${positionals[0]}(--help 查看用法)`);
741
+ await startForeground(opts);
742
+ }
743
+
744
+ main().catch((err) => {
745
+ console.error(`✖ ${err instanceof Error ? err.message : String(err)}`);
746
+ process.exit(1);
747
+ });