pi-web-ui 0.1.1 → 0.2.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/README.md CHANGED
@@ -43,6 +43,105 @@ npm run build # compiles server (tsc) + frontend (vite)
43
43
  npm start # serves everything on http://localhost:8787
44
44
  ```
45
45
 
46
+ ## npm package (install / start / stop / update / uninstall)
47
+
48
+ The package is published on npm as [`pi-web-ui`](https://www.npmjs.com/package/pi-web-ui).
49
+
50
+ ### Install
51
+
52
+ ```bash
53
+ # install globally (recommended)
54
+ npm i -g pi-web-ui
55
+
56
+ # or run without installing (pulls the latest, starts on :8787)
57
+ npx pi-web-ui
58
+
59
+ # or install the local checkout (for testing changes before publishing)
60
+ npm i -g .
61
+ ```
62
+
63
+ > **pi-managed npm?** If your `npm` is the pi wrapper that blocks dependency
64
+ > install scripts, approve node-pty's native build once after installing:
65
+ > `npm approve-scripts node-pty@1.1.0` (standard npm does this automatically).
66
+
67
+ ### Start
68
+
69
+ ```bash
70
+ pi-web-ui # foreground, http://localhost:8787
71
+ PORT=9000 PI_WEB_CWD=/path/to/project pi-web-ui # custom port / workspace
72
+ ```
73
+
74
+ To run it in the background or auto-start on boot, use a system service —
75
+ see [Deploy & auto-start on boot](#deploy--auto-start-on-boot) (systemd /
76
+ launchd / Docker).
77
+
78
+ The `pi-web-ui` command serves the built frontend and the WebSocket API from
79
+ wherever the package is installed — no repo checkout needed. It uses **your**
80
+ `~/.pi/agent` config (auth/models/skills) and stores per-client sessions under
81
+ `<PI_WEB_CWD>/.pi-web`.
82
+
83
+ ### Stop
84
+
85
+ - **Foreground**: press `Ctrl+C` in the terminal running it.
86
+ - **systemd**: `sudo systemctl stop pi-web-ui`
87
+ - **launchd**: `launchctl bootout gui/$(id -u)/com.xingshuyin.pi-web-ui`
88
+ - **Docker**: `docker compose stop` (stop + remove the container: `docker compose down`)
89
+
90
+ (Background processes should be managed by a system service, not `nohup` —
91
+ service stop commands above also stop and disable auto-start.)
92
+
93
+ ### Verify / version
94
+
95
+ ```bash
96
+ pi-web-ui --version # CLI version
97
+ npm ls -g pi-web-ui # installed? which version?
98
+ which pi-web-ui # executable location
99
+ ```
100
+
101
+ ### Update
102
+
103
+ ```bash
104
+ npm i -g pi-web-ui@latest # upgrade to the latest published version
105
+ # restart the server afterwards for the new version to take effect
106
+ ```
107
+
108
+ ### Uninstall
109
+
110
+ ```bash
111
+ npm uninstall -g pi-web-ui
112
+ ```
113
+
114
+ Uninstalling does **not** delete your chats: session data lives in
115
+ `<PI_WEB_CWD>/.pi-web` (or `PI_WEB_DATA_DIR`) and survives uninstall/upgrade.
116
+
117
+ ### Manage as a system service (auto-start)
118
+
119
+ Install the server as a system service that starts on boot, with a custom
120
+ port and workspace:
121
+
122
+ ```bash
123
+ pi-web-ui server install --port 9000 --cwd /path/to/project # install + start
124
+ pi-web-ui server status # running? auto-start?
125
+ pi-web-ui server restart # restart (also applies config changes)
126
+ pi-web-ui server stop # stop + disable auto-start
127
+ pi-web-ui server start # start again
128
+ pi-web-ui server uninstall # remove the service entirely
129
+ ```
130
+
131
+ - **macOS** → launchd agent (no sudo): writes and loads
132
+ `~/Library/LaunchAgents/com.xingshuyin.pi-web-ui.plist`, restarts on crash
133
+ (`KeepAlive`), logs to `/tmp/pi-web-ui.log` / `/tmp/pi-web-ui.err`.
134
+ - **Linux** → systemd unit (auto-sudo): writes
135
+ `/etc/systemd/system/pi-web-ui.service` and runs `systemctl enable --now`,
136
+ logs via `journalctl -u pi-web-ui -f`.
137
+ - Options: `--port` (default 8787 or `$PORT`), `--cwd` (default `$PI_WEB_CWD`
138
+ or the current directory), `--data-dir` (sessions), `--name` (custom service
139
+ name; on macOS the label is `com.xingshuyin.pi-web-ui`, custom names become
140
+ `com.<name>.server`). `--print` previews the generated unit/plist without
141
+ applying it.
142
+ - Rerunning `install` with new options regenerates the config and restarts the
143
+ service — that's how you change the port/cwd of an installed service.
144
+
46
145
  ## Configuration
47
146
 
48
147
  | Env var | Default | Description |
@@ -83,6 +182,12 @@ Key design points:
83
182
  event it schedules a throttled (60 ms) full-state snapshot, and the browser
84
183
  renders purely from snapshots. Reconnects just re-request `get_state`. Large
85
184
  payloads (tool output, text) are capped during serialization (`server/serialize.ts`).
185
+ - **Live assistant streaming.** The in-progress message (SDK
186
+ `agent.state.streamingMessage`) is serialized into every snapshot, so thinking
187
+ blocks and answer text appear in the browser as they are generated — with a
188
+ blinking cursor — instead of only after the turn finishes. The partial message
189
+ gets a stable `stream-<ts>` id so it stays mounted (open thinking/tool blocks
190
+ keep their state) across snapshots.
86
191
  - **Size-aware attachments.** Clicking + on a file queues it as an attachment
87
192
  (shown as chips above the input). On send, the server attaches each file as an
88
193
  independent custom message (SDK `sendCustomMessage` + `nextTurn` asides) — the
@@ -158,6 +263,52 @@ Server → client: `ready`, `snapshot` (full `UiState`), `tool_delta`, `notice`,
158
263
  | `node terminal-smoke-test.mjs` | WS-level terminal/commands protocol test (build first) |
159
264
  | `node terminal-browser-test.mjs` | headless-browser E2E of the terminal view (build first) |
160
265
 
266
+ ## Deploy & auto-start on boot
267
+
268
+ Quickest path: `pi-web-ui server install --port 8787 --cwd /path` — installs
269
+ and starts the service on boot (see [Manage as a system service](#manage-as-a-system-service-auto-start)).
270
+ The manual alternatives below are kept for reference / non-standard setups.
271
+
272
+ ### Docker (one command)
273
+
274
+ ```bash
275
+ docker compose up -d # builds, starts on :8787, auto-restarts on boot
276
+ docker compose stop # stop (keeps the container)
277
+ docker compose down # stop and remove the container
278
+ ```
279
+
280
+ `restart: unless-stopped` in `docker-compose.yml` brings the server back up
281
+ whenever the Docker daemon starts (boot, crashes, reboots). Mount a volume for
282
+ `/app/.pi-web` (sessions persist) and, optionally, your `~/.pi/agent` config
283
+ and a workspace — see the comments in `docker-compose.yml`.
284
+
285
+ ### Linux — systemd
286
+
287
+ ```bash
288
+ sudo npm i -g pi-web-ui
289
+ sudo cp deploy/pi-web-ui.service /etc/systemd/system/
290
+ # edit User/WorkingDirectory/Environment in the unit first
291
+ sudo systemctl daemon-reload
292
+ sudo systemctl enable --now pi-web-ui # starts now + on every boot
293
+ sudo systemctl stop pi-web-ui # stop
294
+ sudo systemctl disable pi-web-ui # stop auto-start on boot
295
+ journalctl -u pi-web-ui -f # logs
296
+ ```
297
+
298
+ ### macOS — launchd
299
+
300
+ ```bash
301
+ npm i -g pi-web-ui
302
+ cp deploy/com.xingshuyin.pi-web-ui.plist ~/Library/LaunchAgents/
303
+ # edit ProgramArguments / WorkingDirectory / PI_WEB_CWD (which pi-web-ui)
304
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.xingshuyin.pi-web-ui.plist
305
+ launchctl bootout gui/$(id -u)/com.xingshuyin.pi-web-ui # stop + remove auto-start
306
+ # logs: /tmp/pi-web-ui.log, /tmp/pi-web-ui.err
307
+ ```
308
+
309
+ Both templates use `KeepAlive`/`Restart=on-failure` so the server survives
310
+ crashes, and start at login/boot automatically.
311
+
161
312
  ## License
162
313
 
163
314
  MIT
package/bin/pi-web-ui.mjs CHANGED
@@ -1,9 +1,518 @@
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
+ *
17
+ * 环境变量(前台与系统服务均适用):PORT / PI_WEB_CWD / PI_WEB_DATA_DIR /
18
+ * PI_CODING_AGENT_DIR。
8
19
  */
9
- import "../dist/server/index.js";
20
+ import { spawnSync } from "node:child_process";
21
+ import {
22
+ existsSync,
23
+ mkdirSync,
24
+ readFileSync,
25
+ rmSync,
26
+ writeFileSync,
27
+ } from "node:fs";
28
+ import { homedir, userInfo } from "node:os";
29
+ import { dirname, join, resolve } from "node:path";
30
+ import { pathToFileURL } from "node:url";
31
+ import { fileURLToPath } from "node:url";
32
+
33
+ const BIN_DIR = dirname(fileURLToPath(import.meta.url));
34
+ /** <pkg>/dist/server/index.js — the actual server entry. */
35
+ const SERVER_ENTRY = join(BIN_DIR, "..", "dist", "server", "index.js");
36
+ const NODE = process.execPath;
37
+ let pkg = { version: "0.0.0" };
38
+ try {
39
+ pkg = JSON.parse(readFileSync(join(BIN_DIR, "..", "package.json"), "utf8"));
40
+ } catch {
41
+ // version is best-effort — the server itself doesn't need it
42
+ }
43
+
44
+ const HELP = `pi-web-ui v${pkg.version} — web chat for the pi coding agent
45
+
46
+ 用法:
47
+ pi-web-ui 启动服务器(前台,Ctrl+C 停止)
48
+ pi-web-ui --port 9000 --cwd /path 启动并指定端口 / 工作目录 / 数据目录
49
+ pi-web-ui server install [选项] 安装系统服务(开机自启)并启动
50
+ pi-web-ui server uninstall [选项] 卸载系统服务
51
+ pi-web-ui server start|stop|restart|status [选项]
52
+ pi-web-ui --version / --help
53
+
54
+ server 选项:
55
+ --port <n> 端口(默认 8787,或 $PORT)
56
+ --cwd <dir> 工作目录(默认 $PI_WEB_CWD 或当前目录)
57
+ --data-dir <dir> 会话数据目录(默认 <cwd>/.pi-web)
58
+ --name <name> 服务名(默认 pi-web-ui;macOS 的 launchd label
59
+ 为 com.xingshuyin.pi-web-ui,自定义名时为 com.<name>.server)
60
+ --print 只打印将生成的配置文件,不实际安装
61
+
62
+ 环境变量(前台与系统服务均适用):
63
+ PORT / PI_WEB_CWD / PI_WEB_DATA_DIR / PI_CODING_AGENT_DIR
64
+ `;
65
+
66
+ function fail(msg) {
67
+ console.error(`✖ ${msg}`);
68
+ process.exit(1);
69
+ }
70
+
71
+ /** Run a command, inheriting stdio; exits on failure unless ignoreError. */
72
+ function run(cmd, args, { ignoreError = false, silent = false } = {}) {
73
+ const res = spawnSync(cmd, args, {
74
+ stdio: silent ? ["inherit", "ignore", "ignore"] : "inherit",
75
+ });
76
+ if (!ignoreError && res.status !== 0) process.exit(res.status ?? 1);
77
+ return res;
78
+ }
79
+
80
+ /** Parse --flag value / --flag=value options; returns { opts, positionals }. */
81
+ function parseFlags(argv) {
82
+ const opts = {
83
+ port: undefined,
84
+ cwd: undefined,
85
+ dataDir: undefined,
86
+ name: undefined,
87
+ print: false,
88
+ help: false,
89
+ };
90
+ const positionals = [];
91
+ for (let i = 0; i < argv.length; i++) {
92
+ const a = argv[i];
93
+ const eq = a.indexOf("=");
94
+ const key = eq >= 0 ? a.slice(0, eq) : a;
95
+ const inline = eq >= 0 ? a.slice(eq + 1) : undefined;
96
+ const take = (flag) => {
97
+ if (inline !== undefined) return inline;
98
+ if (i + 1 < argv.length) {
99
+ i++;
100
+ return argv[i];
101
+ }
102
+ fail(`缺少选项 ${flag} 的值`);
103
+ };
104
+ switch (key) {
105
+ case "--port":
106
+ opts.port = take("--port");
107
+ break;
108
+ case "--cwd":
109
+ opts.cwd = take("--cwd");
110
+ break;
111
+ case "--data-dir":
112
+ opts.dataDir = take("--data-dir");
113
+ break;
114
+ case "--name":
115
+ opts.name = take("--name");
116
+ break;
117
+ case "--print":
118
+ opts.print = true;
119
+ break;
120
+ case "--help":
121
+ case "-h":
122
+ opts.help = true;
123
+ break;
124
+ default:
125
+ if (key.startsWith("-")) fail(`未知选项: ${key}`);
126
+ positionals.push(a);
127
+ }
128
+ }
129
+ return { opts, positionals };
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // 前台启动
134
+ // ---------------------------------------------------------------------------
135
+
136
+ async function startForeground(opts) {
137
+ if (opts.port) process.env.PORT = opts.port;
138
+ if (opts.cwd) process.env.PI_WEB_CWD = resolve(opts.cwd);
139
+ if (opts.dataDir) process.env.PI_WEB_DATA_DIR = resolve(opts.dataDir);
140
+ await import(pathToFileURL(SERVER_ENTRY).href);
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // 系统服务管理
145
+ // ---------------------------------------------------------------------------
146
+
147
+ const isMac = process.platform === "darwin";
148
+ const isLinux = process.platform === "linux";
149
+
150
+ function uid() {
151
+ try {
152
+ return userInfo().uid;
153
+ } catch {
154
+ return process.getuid?.() ?? 501;
155
+ }
156
+ }
157
+
158
+ /** launchd label / systemd unit name for a service name. */
159
+ function serviceLabel(name) {
160
+ if (isMac) {
161
+ return name === "pi-web-ui"
162
+ ? "com.xingshuyin.pi-web-ui"
163
+ : `com.${name}.server`;
164
+ }
165
+ return name;
166
+ }
167
+
168
+ function launchAgentPlist(name) {
169
+ return join(
170
+ homedir(),
171
+ "Library",
172
+ "LaunchAgents",
173
+ `${serviceLabel(name)}.plist`,
174
+ );
175
+ }
176
+
177
+ function systemdUnitPath(name) {
178
+ return `/etc/systemd/system/${name}.service`;
179
+ }
180
+
181
+ function esc(s) {
182
+ return s
183
+ .replace(/&/g, "&amp;")
184
+ .replace(/</g, "&lt;")
185
+ .replace(/>/g, "&gt;")
186
+ .replace(/"/g, "&quot;");
187
+ }
188
+
189
+ /** Build the launchd plist XML. */
190
+ function buildPlist(label, cwd, env) {
191
+ const entries = Object.entries(env)
192
+ .map(([k, v]) => ` <key>${esc(k)}</key>\n <string>${esc(v)}</string>`)
193
+ .join("\n");
194
+ return `<?xml version="1.0" encoding="UTF-8"?>
195
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
196
+ <!-- Generated by: pi-web-ui server install (do not edit by hand — rerun to change) -->
197
+ <plist version="1.0">
198
+ <dict>
199
+ <key>Label</key>
200
+ <string>${esc(label)}</string>
201
+
202
+ <key>ProgramArguments</key>
203
+ <array>
204
+ <string>${esc(NODE)}</string>
205
+ <string>${esc(SERVER_ENTRY)}</string>
206
+ </array>
207
+
208
+ <key>RunAtLoad</key>
209
+ <true/>
210
+
211
+ <!-- Restart if it crashes -->
212
+ <key>KeepAlive</key>
213
+ <true/>
214
+
215
+ <key>WorkingDirectory</key>
216
+ <string>${esc(cwd)}</string>
217
+
218
+ <key>EnvironmentVariables</key>
219
+ <dict>
220
+ ${entries}
221
+ </dict>
222
+
223
+ <key>StandardOutPath</key>
224
+ <string>/tmp/pi-web-ui.log</string>
225
+ <key>StandardErrorPath</key>
226
+ <string>/tmp/pi-web-ui.err</string>
227
+ </dict>
228
+ </plist>
229
+ `;
230
+ }
231
+
232
+ /** Build the systemd unit file. */
233
+ function buildUnit(cwd, env) {
234
+ const envLines = Object.entries(env)
235
+ .map(([k, v]) => `Environment=${k}=${v}`)
236
+ .join("\n");
237
+ return `# Generated by: pi-web-ui server install (do not edit by hand — rerun to change)
238
+ [Unit]
239
+ Description=pi-web-ui — web chat for the pi coding agent
240
+ After=network.target
241
+
242
+ [Service]
243
+ Type=simple
244
+ User=${process.env.SUDO_USER ?? userInfo().username}
245
+ WorkingDirectory=${cwd}
246
+ ${envLines}
247
+ ExecStart=${JSON.stringify(NODE)} ${JSON.stringify(SERVER_ENTRY)}
248
+ Restart=on-failure
249
+ RestartSec=5
250
+
251
+ [Install]
252
+ WantedBy=multi-user.target
253
+ `;
254
+ }
255
+
256
+ /** If not root on Linux, re-exec the same server command through sudo. */
257
+ function ensureRootForSystemctl() {
258
+ if (typeof process.getuid === "function" && process.getuid() === 0) return;
259
+ // process.argv = [node, <bin>, "server", <action>, ...rest] — forward
260
+ // everything after "server" so flags like --port/--cwd survive.
261
+ const res = spawnSync(
262
+ "sudo",
263
+ [NODE, fileURLToPath(import.meta.url), "server", ...process.argv.slice(3)],
264
+ { stdio: "inherit" },
265
+ );
266
+ process.exit(res.status ?? 1);
267
+ }
268
+
269
+ /** Shared option normalization for install. */
270
+ function serviceOptions(opts) {
271
+ const name = opts.name ?? "pi-web-ui";
272
+ const port = String(opts.port ?? process.env.PORT ?? "8787");
273
+ if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
274
+ fail(`无效端口: ${port}`);
275
+ }
276
+ const cwd = resolve(opts.cwd ?? process.env.PI_WEB_CWD ?? process.cwd());
277
+ if (!existsSync(cwd)) fail(`工作目录不存在: ${cwd}`);
278
+ let dataDir;
279
+ if (opts.dataDir) {
280
+ dataDir = resolve(opts.dataDir);
281
+ } else if (process.env.PI_WEB_DATA_DIR) {
282
+ dataDir = resolve(process.env.PI_WEB_DATA_DIR);
283
+ }
284
+ return { name, port, cwd, dataDir };
285
+ }
286
+
287
+ function serviceEnv(port, cwd, dataDir) {
288
+ const env = {
289
+ PORT: port,
290
+ PI_WEB_CWD: cwd,
291
+ PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
292
+ };
293
+ if (dataDir) env.PI_WEB_DATA_DIR = dataDir;
294
+ return env;
295
+ }
296
+
297
+ function installLaunchd(opts) {
298
+ const { name, port, cwd, dataDir } = serviceOptions(opts);
299
+ const label = serviceLabel(name);
300
+ const plist = launchAgentPlist(name);
301
+ const content = buildPlist(label, cwd, serviceEnv(port, cwd, dataDir));
302
+ if (opts.print) {
303
+ console.log(`# ${plist}\n${content}`);
304
+ return;
305
+ }
306
+ // Unload any existing instance (ignore "not loaded"), then (re)install.
307
+ run("launchctl", ["bootout", `gui/${uid()}/${label}`], {
308
+ ignoreError: true,
309
+ silent: true,
310
+ });
311
+ mkdirSync(dirname(plist), { recursive: true });
312
+ writeFileSync(plist, content);
313
+ run("launchctl", ["bootstrap", `gui/${uid()}`, plist]);
314
+ console.log(`✅ 已安装并启动 launchd 服务 ${label}`);
315
+ console.log(` 端口 : ${port}`);
316
+ console.log(` 目录 : ${cwd}`);
317
+ console.log(` 访问 : http://localhost:${port}`);
318
+ console.log(` 日志 : /tmp/pi-web-ui.log /tmp/pi-web-ui.err`);
319
+ console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
320
+ }
321
+
322
+ function installSystemd(opts) {
323
+ const { name, port, cwd, dataDir } = serviceOptions(opts);
324
+ const content = buildUnit(cwd, serviceEnv(port, cwd, dataDir));
325
+ const unitPath = systemdUnitPath(name);
326
+ if (opts.print) {
327
+ console.log(`# ${unitPath}\n${content}`);
328
+ return;
329
+ }
330
+ ensureRootForSystemctl();
331
+ writeFileSync(unitPath, content);
332
+ run("systemctl", ["daemon-reload"]);
333
+ run("systemctl", ["enable", "--now", `${name}.service`]);
334
+ console.log(`✅ 已安装并启动 systemd 服务 ${name}.service`);
335
+ console.log(` 端口 : ${port}`);
336
+ console.log(` 目录 : ${cwd}`);
337
+ console.log(` 访问 : http://localhost:${port}`);
338
+ console.log(` 日志 : journalctl -u ${name}.service -f`);
339
+ console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
340
+ }
341
+
342
+ function uninstallLaunchd(opts) {
343
+ const name = opts.name ?? "pi-web-ui";
344
+ const label = serviceLabel(name);
345
+ const plist = launchAgentPlist(name);
346
+ run("launchctl", ["bootout", `gui/${uid()}/${label}`], {
347
+ ignoreError: true,
348
+ silent: true,
349
+ });
350
+ if (existsSync(plist)) rmSync(plist);
351
+ console.log(`🗑 已卸载 ${label}(plist 已删除,不再开机自启)`);
352
+ }
353
+
354
+ function uninstallSystemd(opts) {
355
+ const name = opts.name ?? "pi-web-ui";
356
+ ensureRootForSystemctl();
357
+ run("systemctl", ["disable", "--now", `${name}.service`], {
358
+ ignoreError: true,
359
+ });
360
+ const unitPath = systemdUnitPath(name);
361
+ if (existsSync(unitPath)) rmSync(unitPath);
362
+ run("systemctl", ["daemon-reload"]);
363
+ console.log(`🗑 已卸载 ${name}.service(不再开机自启)`);
364
+ }
365
+
366
+ function controlService(action, opts) {
367
+ const name = opts.name ?? "pi-web-ui";
368
+
369
+ if (isMac) {
370
+ const label = serviceLabel(name);
371
+ const target = `gui/${uid()}/${label}`;
372
+ const loaded = () =>
373
+ spawnSync("launchctl", ["print", target], { stdio: "ignore" }).status ===
374
+ 0;
375
+
376
+ if (action === "status") {
377
+ if (loaded()) {
378
+ const res = spawnSync("launchctl", ["print", target], {
379
+ encoding: "utf8",
380
+ });
381
+ const state = (res.stdout.match(/state = (\w+)/) ?? [])[1] ?? "loaded";
382
+ console.log(`${label}: ${state}(已加载,开机自启中)`);
383
+ } else {
384
+ console.log(`${label}: 未安装(运行 pi-web-ui server install 安装)`);
385
+ }
386
+ return;
387
+ }
388
+
389
+ if (action === "start") {
390
+ if (loaded()) {
391
+ run("launchctl", ["kickstart", target]);
392
+ } else {
393
+ const plist = launchAgentPlist(name);
394
+ if (!existsSync(plist)) {
395
+ fail(`找不到 ${plist},请先运行 pi-web-ui server install`);
396
+ }
397
+ run("launchctl", ["bootstrap", `gui/${uid()}`, plist]);
398
+ }
399
+ console.log(`✅ 已启动 ${label}`);
400
+ return;
401
+ }
402
+
403
+ if (action === "restart") {
404
+ if (!loaded()) fail(`${label} 未加载,请先 pi-web-ui server start`);
405
+ run("launchctl", ["kickstart", "-k", target]);
406
+ console.log(`✅ 已重启 ${label}`);
407
+ return;
408
+ }
409
+
410
+ if (action === "stop") {
411
+ run("launchctl", ["bootout", target], {
412
+ ignoreError: true,
413
+ silent: true,
414
+ });
415
+ console.log(`⏹ 已停止 ${label}(已卸载,不再开机自启;start 恢复)`);
416
+ return;
417
+ }
418
+
419
+ fail(`未知操作: ${action}`);
420
+ }
421
+
422
+ if (isLinux) {
423
+ ensureRootForSystemctl();
424
+ if (action === "status") {
425
+ run("systemctl", ["status", `${name}.service`, "--no-pager"]);
426
+ return;
427
+ }
428
+ run("systemctl", [action, `${name}.service`]);
429
+ console.log(`✅ ${action} ${name}.service`);
430
+ return;
431
+ }
432
+
433
+ fail(`不支持的系统服务平台: ${process.platform}(仅 macOS / Linux)`);
434
+ }
435
+
436
+ async function serverCmd(argv) {
437
+ const { opts, positionals } = parseFlags(argv);
438
+ if (opts.help) {
439
+ console.log(HELP);
440
+ return;
441
+ }
442
+ if (positionals.length === 0) {
443
+ console.log(HELP);
444
+ console.log("--- 当前服务状态 ---");
445
+ controlService("status", opts);
446
+ return;
447
+ }
448
+ const action = positionals[0];
449
+ if (positionals.length > 1)
450
+ fail(`多余的参数: ${positionals.slice(1).join(" ")}`);
451
+ switch (action) {
452
+ case "install": {
453
+ if (isMac) {
454
+ installLaunchd(opts);
455
+ } else if (isLinux) {
456
+ installSystemd(opts);
457
+ } else {
458
+ fail(`不支持的系统服务平台: ${process.platform}(仅 macOS / Linux)`);
459
+ }
460
+ break;
461
+ }
462
+ case "uninstall": {
463
+ if (isMac) {
464
+ uninstallLaunchd(opts);
465
+ } else if (isLinux) {
466
+ uninstallSystemd(opts);
467
+ } else {
468
+ fail(`不支持的系统服务平台: ${process.platform}(仅 macOS / Linux)`);
469
+ }
470
+ break;
471
+ }
472
+ case "start":
473
+ case "stop":
474
+ case "restart":
475
+ case "status":
476
+ controlService(action, opts);
477
+ break;
478
+ default:
479
+ fail(
480
+ `未知操作: ${action}(install / uninstall / start / stop / restart / status)`,
481
+ );
482
+ }
483
+ }
484
+
485
+ async function main() {
486
+ const argv = process.argv.slice(2);
487
+ if (argv.length === 0) {
488
+ await startForeground({});
489
+ return;
490
+ }
491
+ const first = argv[0];
492
+ if (first === "--version" || first === "-v") {
493
+ console.log(pkg.version);
494
+ return;
495
+ }
496
+ if (first === "--help" || first === "-h") {
497
+ console.log(HELP);
498
+ return;
499
+ }
500
+ if (first === "server") {
501
+ await serverCmd(argv.slice(1));
502
+ return;
503
+ }
504
+ // One-shot server with optional --port/--cwd/--data-dir overrides.
505
+ const { opts, positionals } = parseFlags(argv);
506
+ if (opts.help) {
507
+ console.log(HELP);
508
+ return;
509
+ }
510
+ if (positionals.length > 0)
511
+ fail(`未知命令: ${positionals[0]}(--help 查看用法)`);
512
+ await startForeground(opts);
513
+ }
514
+
515
+ main().catch((err) => {
516
+ console.error(`✖ ${err instanceof Error ? err.message : String(err)}`);
517
+ process.exit(1);
518
+ });