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 +151 -0
- package/bin/pi-web-ui.mjs +514 -5
- package/dist/server/agent-service.js +8 -1
- package/dist/server/serialize.js +15 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-C-MVz-pL.css → index-B882SGfI.css} +1 -1
- package/web/dist/assets/{index-xKwKMfic.js → index-DLMNi58O.js} +42 -42
- package/web/dist/index.html +2 -2
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
|
|
3
|
+
* pi-web-ui CLI.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 "
|
|
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, "&")
|
|
184
|
+
.replace(/</g, "<")
|
|
185
|
+
.replace(/>/g, ">")
|
|
186
|
+
.replace(/"/g, """);
|
|
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
|
+
});
|