pi-web-ui 0.11.2 → 0.11.4
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 +1272 -1272
- package/package.json +1 -1
- package/web/dist/icon.ico +0 -0
- package/web/public/icon.ico +0 -0
package/bin/pi-web-ui.mjs
CHANGED
|
@@ -1,1272 +1,1272 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* pi-web-ui CLI.
|
|
4
|
-
*
|
|
5
|
-
* pi-web-ui 启动生产服务器(前台,Ctrl+C 停止,自动打开浏览器)
|
|
6
|
-
* pi-web-ui --port 9000 --cwd /path 同上,覆盖端口 / 工作目录 / 数据目录
|
|
7
|
-
* pi-web-ui --no-browser 启动但不自动打开浏览器
|
|
8
|
-
* pi-web-ui --version | --help
|
|
9
|
-
* pi-web-ui server install [选项] 安装系统服务(开机自启)并启动
|
|
10
|
-
* pi-web-ui server shortcut [选项] 在桌面创建「一键启动」图标(启动服务并打开浏览器)
|
|
11
|
-
* pi-web-ui server uninstall [选项] 卸载系统服务(同时移除桌面图标)
|
|
12
|
-
* pi-web-ui server start|stop|restart|status [选项]
|
|
13
|
-
*
|
|
14
|
-
* 系统服务:
|
|
15
|
-
* - macOS → launchd 用户代理,label 默认 com.xingshuyin.pi-web-ui
|
|
16
|
-
* (--name 自定义时 com.<name>.server),无需 sudo
|
|
17
|
-
* - Linux → systemd 单元 <name>.service(/etc/systemd/system/,自动 sudo)
|
|
18
|
-
* - Windows → 计划任务(Task Scheduler / schtasks,登录后自启,无需管理员),
|
|
19
|
-
* 隐藏窗口启动(无黑窗);PowerShell 启动脚本与任务 XML 生成在
|
|
20
|
-
* %APPDATA%\pi-web-ui\
|
|
21
|
-
*
|
|
22
|
-
* 环境变量(前台与系统服务均适用):PORT / PI_WEB_CWD / PI_WEB_DATA_DIR /
|
|
23
|
-
* PI_CODING_AGENT_DIR。
|
|
24
|
-
*/
|
|
25
|
-
import { spawnSync } from "node:child_process";
|
|
26
|
-
import { get as httpGet } from "node:http";
|
|
27
|
-
import {
|
|
28
|
-
chmodSync,
|
|
29
|
-
copyFileSync,
|
|
30
|
-
existsSync,
|
|
31
|
-
mkdirSync,
|
|
32
|
-
realpathSync,
|
|
33
|
-
readFileSync,
|
|
34
|
-
rmSync,
|
|
35
|
-
writeFileSync,
|
|
36
|
-
} from "node:fs";
|
|
37
|
-
import { homedir, userInfo } from "node:os";
|
|
38
|
-
import { dirname, join, resolve } from "node:path";
|
|
39
|
-
import { pathToFileURL } from "node:url";
|
|
40
|
-
import { fileURLToPath } from "node:url";
|
|
41
|
-
|
|
42
|
-
const BIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
43
|
-
/** <pkg>/dist/server/index.js — the actual server entry. */
|
|
44
|
-
const SERVER_ENTRY = join(BIN_DIR, "..", "dist", "server", "index.js");
|
|
45
|
-
const NODE = process.execPath;
|
|
46
|
-
let pkg = { version: "0.0.0" };
|
|
47
|
-
try {
|
|
48
|
-
pkg = JSON.parse(readFileSync(join(BIN_DIR, "..", "package.json"), "utf8"));
|
|
49
|
-
} catch {
|
|
50
|
-
// version is best-effort — the server itself doesn't need it
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const HELP = `pi-web-ui v${pkg.version} — web chat for the pi coding agent
|
|
54
|
-
|
|
55
|
-
用法:
|
|
56
|
-
pi-web-ui 启动服务器(前台,Ctrl+C 停止,自动打开浏览器)
|
|
57
|
-
pi-web-ui --port 9000 --cwd /path 启动并指定端口 / 工作目录 / 数据目录
|
|
58
|
-
pi-web-ui --no-browser 启动但不自动打开浏览器
|
|
59
|
-
pi-web-ui server install [选项] 安装系统服务(开机自启)并启动
|
|
60
|
-
pi-web-ui server shortcut [选项] 在桌面创建「一键启动」图标(启动服务并打开浏览器)
|
|
61
|
-
pi-web-ui server uninstall [选项] 卸载系统服务(同时移除桌面图标)
|
|
62
|
-
pi-web-ui server start|stop|restart|status [选项]
|
|
63
|
-
pi-web-ui --version / --help
|
|
64
|
-
|
|
65
|
-
server 选项:
|
|
66
|
-
--port <n> 端口(默认 8787,或 $PORT)
|
|
67
|
-
--cwd <dir> 工作目录(默认 $PI_WEB_CWD 或当前目录)
|
|
68
|
-
--data-dir <dir> 会话数据目录(默认 <cwd>/.pi-web)
|
|
69
|
-
--name <name> 服务名(默认 pi-web-ui;macOS 的 launchd label
|
|
70
|
-
为 com.xingshuyin.pi-web-ui,自定义名时为 com.<name>.server)
|
|
71
|
-
--print 只打印将生成的配置文件,不实际安装
|
|
72
|
-
|
|
73
|
-
平台: macOS → launchd 用户代理 · Linux → systemd · Windows → 计划任务(schtasks)
|
|
74
|
-
(Windows 任务登录后自启、无需管理员、隐藏窗口运行;stop 停止,uninstall 移除)
|
|
75
|
-
快捷方式: Windows → 桌面 .lnk · macOS → 桌面 .command 启动器 · Linux → 桌面 .desktop 图标
|
|
76
|
-
|
|
77
|
-
环境变量(前台与系统服务均适用):
|
|
78
|
-
PORT / PI_WEB_CWD / PI_WEB_DATA_DIR / PI_CODING_AGENT_DIR
|
|
79
|
-
`;
|
|
80
|
-
|
|
81
|
-
/** Minimum Node required by the pi SDK (its dist uses `import … with { type: "json" }`). */
|
|
82
|
-
const NODE_MIN = [22, 19, 0];
|
|
83
|
-
function checkNodeVersion() {
|
|
84
|
-
const v = process.versions.node.split(".").map(Number);
|
|
85
|
-
const tooOld =
|
|
86
|
-
v[0] < NODE_MIN[0] ||
|
|
87
|
-
(v[0] === NODE_MIN[0] && v[1] < NODE_MIN[1]) ||
|
|
88
|
-
(v[0] === NODE_MIN[0] && v[1] === NODE_MIN[1] && v[2] < NODE_MIN[2]);
|
|
89
|
-
if (tooOld) {
|
|
90
|
-
console.error(
|
|
91
|
-
`✖ pi-web-ui 需要 Node.js >= ${NODE_MIN.join(".")}(当前 ${process.versions.node})。\n` +
|
|
92
|
-
` pi SDK 的代码使用了 import attributes(with)语法,旧版 Node 无法解析。\n` +
|
|
93
|
-
` 请升级 Node:https://nodejs.org(或 nvm-windows / fnm)后重装:npm i -g pi-web-ui`,
|
|
94
|
-
);
|
|
95
|
-
process.exit(1);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function fail(msg) {
|
|
100
|
-
console.error(`✖ ${msg}`);
|
|
101
|
-
process.exit(1);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Run a command, inheriting stdio; exits on failure unless ignoreError. */
|
|
105
|
-
function run(cmd, args, { ignoreError = false, silent = false } = {}) {
|
|
106
|
-
const res = spawnSync(cmd, args, {
|
|
107
|
-
stdio: silent ? ["inherit", "ignore", "ignore"] : "inherit",
|
|
108
|
-
});
|
|
109
|
-
if (!ignoreError && res.status !== 0) process.exit(res.status ?? 1);
|
|
110
|
-
return res;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Parse --flag value / --flag=value options; returns { opts, positionals }. */
|
|
114
|
-
function parseFlags(argv) {
|
|
115
|
-
const opts = {
|
|
116
|
-
port: undefined,
|
|
117
|
-
cwd: undefined,
|
|
118
|
-
dataDir: undefined,
|
|
119
|
-
name: undefined,
|
|
120
|
-
print: false,
|
|
121
|
-
noBrowser: false,
|
|
122
|
-
help: false,
|
|
123
|
-
};
|
|
124
|
-
const positionals = [];
|
|
125
|
-
for (let i = 0; i < argv.length; i++) {
|
|
126
|
-
const a = argv[i];
|
|
127
|
-
const eq = a.indexOf("=");
|
|
128
|
-
const key = eq >= 0 ? a.slice(0, eq) : a;
|
|
129
|
-
const inline = eq >= 0 ? a.slice(eq + 1) : undefined;
|
|
130
|
-
const take = (flag) => {
|
|
131
|
-
if (inline !== undefined) return inline;
|
|
132
|
-
if (i + 1 < argv.length) {
|
|
133
|
-
i++;
|
|
134
|
-
return argv[i];
|
|
135
|
-
}
|
|
136
|
-
fail(`缺少选项 ${flag} 的值`);
|
|
137
|
-
};
|
|
138
|
-
switch (key) {
|
|
139
|
-
case "--port":
|
|
140
|
-
opts.port = take("--port");
|
|
141
|
-
break;
|
|
142
|
-
case "--cwd":
|
|
143
|
-
opts.cwd = take("--cwd");
|
|
144
|
-
break;
|
|
145
|
-
case "--data-dir":
|
|
146
|
-
opts.dataDir = take("--data-dir");
|
|
147
|
-
break;
|
|
148
|
-
case "--name":
|
|
149
|
-
opts.name = take("--name");
|
|
150
|
-
break;
|
|
151
|
-
case "--print":
|
|
152
|
-
opts.print = true;
|
|
153
|
-
break;
|
|
154
|
-
case "--no-browser":
|
|
155
|
-
opts.noBrowser = true;
|
|
156
|
-
break;
|
|
157
|
-
case "--help":
|
|
158
|
-
case "-h":
|
|
159
|
-
opts.help = true;
|
|
160
|
-
break;
|
|
161
|
-
default:
|
|
162
|
-
if (key.startsWith("-")) fail(`未知选项: ${key}`);
|
|
163
|
-
positionals.push(a);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
return { opts, positionals };
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// ---------------------------------------------------------------------------
|
|
170
|
-
// 前台启动
|
|
171
|
-
// ---------------------------------------------------------------------------
|
|
172
|
-
|
|
173
|
-
/** Open a URL in the OS default browser; failures are ignored (best-effort). */
|
|
174
|
-
function openBrowser(url) {
|
|
175
|
-
if (isMac) {
|
|
176
|
-
spawnSync("open", [url], { stdio: "ignore" });
|
|
177
|
-
} else if (isWin) {
|
|
178
|
-
spawnSync("cmd", ["/c", "start", "", url], { stdio: "ignore" });
|
|
179
|
-
} else {
|
|
180
|
-
spawnSync("xdg-open", [url], { stdio: "ignore" });
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Poll `url` until the server answers (or ~15s pass), then open it in the
|
|
186
|
-
* default browser. The foreground server runs in this process, so the first
|
|
187
|
-
* HTTP response is the "listening" signal; if the server crashes meanwhile
|
|
188
|
-
* (e.g. port already taken), the pending timers die with the process and
|
|
189
|
-
* nothing is opened.
|
|
190
|
-
*/
|
|
191
|
-
function openBrowserWhenUp(url) {
|
|
192
|
-
const deadline = Date.now() + 15_000;
|
|
193
|
-
const attempt = () => {
|
|
194
|
-
const req = httpGet(url, (res) => {
|
|
195
|
-
res.resume();
|
|
196
|
-
console.log(` 🌐 已自动打开浏览器:${url}(--no-browser 可关闭)`);
|
|
197
|
-
openBrowser(url);
|
|
198
|
-
});
|
|
199
|
-
req.setTimeout(1000, () => {
|
|
200
|
-
req.destroy();
|
|
201
|
-
if (Date.now() < deadline) setTimeout(attempt, 150);
|
|
202
|
-
});
|
|
203
|
-
req.on("error", () => {
|
|
204
|
-
if (Date.now() < deadline) setTimeout(attempt, 150);
|
|
205
|
-
});
|
|
206
|
-
};
|
|
207
|
-
attempt();
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
async function startForeground(opts) {
|
|
211
|
-
if (opts.port) process.env.PORT = opts.port;
|
|
212
|
-
if (opts.cwd) process.env.PI_WEB_CWD = resolve(opts.cwd);
|
|
213
|
-
if (opts.dataDir) process.env.PI_WEB_DATA_DIR = resolve(opts.dataDir);
|
|
214
|
-
const url = `http://localhost:${String(
|
|
215
|
-
opts.port ?? process.env.PORT ?? "8787",
|
|
216
|
-
)}`;
|
|
217
|
-
await import(pathToFileURL(SERVER_ENTRY).href);
|
|
218
|
-
if (!opts.noBrowser) openBrowserWhenUp(url);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// ---------------------------------------------------------------------------
|
|
222
|
-
// 系统服务管理
|
|
223
|
-
// ---------------------------------------------------------------------------
|
|
224
|
-
|
|
225
|
-
const isMac = process.platform === "darwin";
|
|
226
|
-
const isLinux = process.platform === "linux";
|
|
227
|
-
const isWin = process.platform === "win32";
|
|
228
|
-
|
|
229
|
-
function uid() {
|
|
230
|
-
try {
|
|
231
|
-
return userInfo().uid;
|
|
232
|
-
} catch {
|
|
233
|
-
return process.getuid?.() ?? 501;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** launchd label / systemd unit name / Windows task name for a service name. */
|
|
238
|
-
function serviceLabel(name) {
|
|
239
|
-
if (isMac) {
|
|
240
|
-
return name === "pi-web-ui"
|
|
241
|
-
? "com.xingshuyin.pi-web-ui"
|
|
242
|
-
: `com.${name}.server`;
|
|
243
|
-
}
|
|
244
|
-
return name;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function launchAgentPlist(name) {
|
|
248
|
-
return join(
|
|
249
|
-
homedir(),
|
|
250
|
-
"Library",
|
|
251
|
-
"LaunchAgents",
|
|
252
|
-
`${serviceLabel(name)}.plist`,
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function systemdUnitPath(name) {
|
|
257
|
-
return `/etc/systemd/system/${name}.service`;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/** Windows: per-user config dir (%APPDATA%\pi-web-ui) holding the .cmd wrapper + task XML. */
|
|
261
|
-
function winServiceDir() {
|
|
262
|
-
return join(
|
|
263
|
-
process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
|
|
264
|
-
"pi-web-ui",
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function winCmdPath(name) {
|
|
269
|
-
return join(winServiceDir(), `${name}.cmd`);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function winPs1Path(name) {
|
|
273
|
-
return join(winServiceDir(), `${name}.ps1`);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function winTaskXmlPath(name) {
|
|
277
|
-
return join(winServiceDir(), `${name}.xml`);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function winLogPath() {
|
|
281
|
-
return join(homedir(), "pi-web-ui.log");
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/** True when a scheduled task with this name exists (schtasks exits 0). */
|
|
285
|
-
function winTaskExists(name) {
|
|
286
|
-
return (
|
|
287
|
-
spawnSync("schtasks", ["/Query", "/TN", name], { stdio: "ignore" })
|
|
288
|
-
.status === 0
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// ---------------------------------------------------------------------------
|
|
293
|
-
// 桌面快捷方式(server shortcut)
|
|
294
|
-
// ---------------------------------------------------------------------------
|
|
295
|
-
|
|
296
|
-
const SHORTCUT_LNK_NAME = "pi-web-ui.lnk"; // Windows 桌面快捷方式
|
|
297
|
-
const SHORTCUT_MAC_NAME = "pi-web-ui.command"; // macOS 双击启动器
|
|
298
|
-
const SHORTCUT_LINUX_NAME = "pi-web-ui.desktop"; // Linux 桌面图标
|
|
299
|
-
|
|
300
|
-
/** 快捷方式图标(品牌 .ico,随包发布;.lnk / .desktop 指向它)。 */
|
|
301
|
-
const APP_ICO_NAME = "pi-web-ui.ico"; //
|
|
302
|
-
const APP_ICO_SOURCE = join(BIN_DIR, "..", "web", "public", "icon.ico"); //
|
|
303
|
-
/** Branded SVG logo (source of truth: web/public/favicon.svg) — used on Linux. */
|
|
304
|
-
const APP_SVG_PACKAGE = join(BIN_DIR, "..", "web", "public", "favicon.svg");
|
|
305
|
-
|
|
306
|
-
/** Windows: per-user copy of the branded .ico (stable path for the .lnk icon). */
|
|
307
|
-
function winIcoPath() {
|
|
308
|
-
return join(winServiceDir(), APP_ICO_NAME);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/** Full path to Windows PowerShell 5.1. */
|
|
312
|
-
function winPowershell() {
|
|
313
|
-
return join(
|
|
314
|
-
process.env.SystemRoot ?? "C:\\Windows",
|
|
315
|
-
"System32",
|
|
316
|
-
"WindowsPowerShell",
|
|
317
|
-
"v1.0",
|
|
318
|
-
"powershell.exe",
|
|
319
|
-
);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Resolve the real node binary. fnm/volta/nvm shims (e.g. fnm_multishells)
|
|
324
|
-
* point into temp dirs that vanish when the installing shell exits — the
|
|
325
|
-
* baked-in launcher scripts must use the stable real path instead.
|
|
326
|
-
*/
|
|
327
|
-
function realNode() {
|
|
328
|
-
try {
|
|
329
|
-
return realpathSync(process.execPath);
|
|
330
|
-
} catch {
|
|
331
|
-
return process.execPath;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** Windows: launcher ps1 the desktop .lnk runs (hidden). */
|
|
336
|
-
function winShortcutPs1Path(name) {
|
|
337
|
-
return join(winServiceDir(), `${name}-shortcut.ps1`);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/** Windows: PID file of a shortcut-started (no scheduled task) instance. */
|
|
341
|
-
function winPidFilePath(name) {
|
|
342
|
-
return join(winServiceDir(), `${name}.pid`);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/** Read the recorded PID of a shortcut-started Windows instance. */
|
|
346
|
-
function winReadPid(name) {
|
|
347
|
-
try {
|
|
348
|
-
const pid = Number(readFileSync(winPidFilePath(name), "utf8").trim());
|
|
349
|
-
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
350
|
-
} catch {
|
|
351
|
-
return undefined;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/** True when a PID exists (signal 0; EPERM means exists but not ours). */
|
|
356
|
-
function pidAlive(pid) {
|
|
357
|
-
if (!pid) return false;
|
|
358
|
-
try {
|
|
359
|
-
process.kill(pid, 0);
|
|
360
|
-
return true;
|
|
361
|
-
} catch (err) {
|
|
362
|
-
return err.code === "EPERM";
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/** Single-quote a string for embedding in a POSIX shell script. */
|
|
367
|
-
function shQuote(s) {
|
|
368
|
-
return "'" + s.replace(/'/g, `'\\''`) + "'";
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Windows shortcut launcher. Double-click the .lnk → this script runs hidden:
|
|
373
|
-
* server already up → just open the browser; scheduled task installed → start
|
|
374
|
-
* it (manageable via `server stop`); otherwise run the server in the foreground
|
|
375
|
-
* of this hidden window and record its PID so `server stop` / `server
|
|
376
|
-
* uninstall` can terminate it too.
|
|
377
|
-
*/
|
|
378
|
-
function buildWinShortcutPs1(env, cwd, taskName, url, logPath, pidPath) {
|
|
379
|
-
const sets = Object.entries(env)
|
|
380
|
-
.map(([k, v]) => `$env:${k} = ${psQuote(v)}`)
|
|
381
|
-
.join("\r\n");
|
|
382
|
-
const node = realNode();
|
|
383
|
-
return [
|
|
384
|
-
"# Generated by: pi-web-ui server shortcut (rerun to change)",
|
|
385
|
-
"# Runs hidden from the desktop shortcut: if the server is already up it",
|
|
386
|
-
"# opens the browser; a scheduled task (if installed) is used; otherwise",
|
|
387
|
-
"# the server runs in the foreground of this hidden window and its PID is",
|
|
388
|
-
"# recorded so `server stop` / `server uninstall` can terminate it.",
|
|
389
|
-
"$ErrorActionPreference = 'Continue'",
|
|
390
|
-
`$url = ${psQuote(url)}`,
|
|
391
|
-
`$health = ${psQuote(url + "/api/health")}`,
|
|
392
|
-
`$taskName = ${psQuote(taskName)}`,
|
|
393
|
-
`$pidFile = ${psQuote(pidPath)}`,
|
|
394
|
-
`$log = ${psQuote(logPath)}`,
|
|
395
|
-
"",
|
|
396
|
-
"function Test-Up {",
|
|
397
|
-
" try { return (Invoke-WebRequest -Uri $health -UseBasicParsing -TimeoutSec 2).StatusCode -eq 200 } catch { return $false }",
|
|
398
|
-
"}",
|
|
399
|
-
"function Open-Browser { Start-Process $url | Out-Null }",
|
|
400
|
-
"",
|
|
401
|
-
"# 已在运行 → 直接打开浏览器",
|
|
402
|
-
"if (Test-Up) { Open-Browser; exit 0 }",
|
|
403
|
-
"",
|
|
404
|
-
"# 已安装计划任务 → 走服务启动(server stop 可管理)",
|
|
405
|
-
"if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {",
|
|
406
|
-
" schtasks /Run /TN $taskName | Out-Null",
|
|
407
|
-
" for ($i = 0; $i -lt 120; $i++) {",
|
|
408
|
-
" Start-Sleep -Milliseconds 250",
|
|
409
|
-
" if (Test-Up) { Open-Browser; exit 0 }",
|
|
410
|
-
" }",
|
|
411
|
-
" Write-Host ('✖ pi-web-ui 服务未在 30 秒内就绪,请查看日志: ' + $log)",
|
|
412
|
-
" exit 1",
|
|
413
|
-
"}",
|
|
414
|
-
"",
|
|
415
|
-
"# 未安装服务 → 在本隐藏窗口中前台运行(记录 PID,server stop 可停止)",
|
|
416
|
-
`$PID | Out-File -Encoding ascii $pidFile`,
|
|
417
|
-
sets,
|
|
418
|
-
`Set-Location ${psQuote(cwd)}`,
|
|
419
|
-
"# 后台轮询,就绪后打开浏览器(与前台 node 并行)",
|
|
420
|
-
"$job = Start-Job -ScriptBlock { param($u)",
|
|
421
|
-
" $h = $u + '/api/health'",
|
|
422
|
-
" for ($i = 0; $i -lt 120; $i++) {",
|
|
423
|
-
" Start-Sleep -Milliseconds 250",
|
|
424
|
-
" try { if ((Invoke-WebRequest -Uri $h -UseBasicParsing -TimeoutSec 2).StatusCode -eq 200) { Start-Process $u | Out-Null; break } } catch {}",
|
|
425
|
-
" }",
|
|
426
|
-
"} -ArgumentList $url",
|
|
427
|
-
`& ${psQuote(node)} ${psQuote(SERVER_ENTRY)} *>> $log`,
|
|
428
|
-
"Remove-Item $pidFile -ErrorAction SilentlyContinue",
|
|
429
|
-
"",
|
|
430
|
-
].join("\r\n");
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Create the Windows desktop .lnk via WScript.Shell COM (correct Desktop path
|
|
435
|
-
* even with OneDrive redirection). Target is powershell.exe with
|
|
436
|
-
* -WindowStyle Hidden so nothing flashes on double-click.
|
|
437
|
-
*/
|
|
438
|
-
function installWinShortcut(opts) {
|
|
439
|
-
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
440
|
-
const env = serviceEnv(port, cwd, dataDir);
|
|
441
|
-
const url = `http://localhost:${port}`;
|
|
442
|
-
const ps1Path = winShortcutPs1Path(name);
|
|
443
|
-
const ps1 = buildWinShortcutPs1(
|
|
444
|
-
env,
|
|
445
|
-
cwd,
|
|
446
|
-
name,
|
|
447
|
-
url,
|
|
448
|
-
winLogPath(),
|
|
449
|
-
winPidFilePath(name),
|
|
450
|
-
);
|
|
451
|
-
if (opts.print) {
|
|
452
|
-
console.log(`# ${ps1Path}\n${ps1}`);
|
|
453
|
-
return;
|
|
454
|
-
}
|
|
455
|
-
mkdirSync(dirname(ps1Path), { recursive: true });
|
|
456
|
-
writeFileSync(ps1Path, "\uFEFF" + ps1, "utf8"); // PS 5.1 需要 BOM
|
|
457
|
-
// 把品牌图标准备好:复制到用户目录(.lnk 图标指向稳定路径)
|
|
458
|
-
if (existsSync(APP_ICO_SOURCE)) {
|
|
459
|
-
copyFileSync(APP_ICO_SOURCE, winIcoPath());
|
|
460
|
-
} else {
|
|
461
|
-
console.log(`⚠ 未找到品牌图标 ${APP_ICO_SOURCE},快捷方式将使用默认图标`);
|
|
462
|
-
}
|
|
463
|
-
const powershell = winPowershell();
|
|
464
|
-
const ps = [
|
|
465
|
-
"$ErrorActionPreference = 'Stop'",
|
|
466
|
-
"$ws = New-Object -ComObject WScript.Shell",
|
|
467
|
-
"$desktop = [Environment]::GetFolderPath('Desktop')",
|
|
468
|
-
`$lnk = $ws.CreateShortcut((Join-Path $desktop ${psQuote(SHORTCUT_LNK_NAME)}))`,
|
|
469
|
-
`$lnk.TargetPath = ${psQuote(powershell)}`,
|
|
470
|
-
`$lnk.Arguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File ' + ${psQuote('"' + ps1Path + '"')}`,
|
|
471
|
-
`$lnk.WorkingDirectory = ${psQuote(cwd)}`,
|
|
472
|
-
"$lnk.Description = 'pi-web-ui — 双击启动服务并打开浏览器'",
|
|
473
|
-
`$lnk.IconLocation = ${psQuote(winIcoPath())} + ',0'`,
|
|
474
|
-
"$lnk.Save()",
|
|
475
|
-
`Write-Output (Join-Path $desktop ${psQuote(SHORTCUT_LNK_NAME)})`,
|
|
476
|
-
].join("\r\n");
|
|
477
|
-
const res = spawnSync(
|
|
478
|
-
powershell,
|
|
479
|
-
[
|
|
480
|
-
"-NoProfile",
|
|
481
|
-
"-NonInteractive",
|
|
482
|
-
"-ExecutionPolicy",
|
|
483
|
-
"Bypass",
|
|
484
|
-
"-Command",
|
|
485
|
-
ps,
|
|
486
|
-
],
|
|
487
|
-
{ encoding: "utf8" },
|
|
488
|
-
);
|
|
489
|
-
if (res.status !== 0) {
|
|
490
|
-
fail(`创建桌面快捷方式失败: ${(res.stderr || res.stdout || "").trim()}`);
|
|
491
|
-
}
|
|
492
|
-
const lnk = (res.stdout ?? "").trim();
|
|
493
|
-
console.log(`✅ 已创建桌面快捷方式: ${lnk}`);
|
|
494
|
-
console.log(` 双击 : 服务未运行则启动(隐藏窗口,无黑窗),就绪后自动打开浏览器`);
|
|
495
|
-
console.log(` 停止 : pi-web-ui server stop(快捷方式启动的实例也会一并停止)`);
|
|
496
|
-
console.log(` 端口 : ${port}`);
|
|
497
|
-
console.log(` 目录 : ${cwd}`);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
/** macOS: double-clickable .command launcher (the .lnk equivalent). */
|
|
501
|
-
function buildMacShortcut(label, plist, url, env) {
|
|
502
|
-
const exports = Object.entries(env)
|
|
503
|
-
.map(([k, v]) => `export ${k}=${shQuote(v)}`)
|
|
504
|
-
.join("\n");
|
|
505
|
-
const node = realNode();
|
|
506
|
-
return `#!/bin/bash
|
|
507
|
-
# pi-web-ui 启动器 — generated by: pi-web-ui server shortcut
|
|
508
|
-
# 双击运行:确保服务在运行,然后打开浏览器。
|
|
509
|
-
# · 已安装 launchd 服务(登录自启)→ kickstart,图标主要用于「启动 + 打开」
|
|
510
|
-
# · 未安装服务 → 在本终端前台运行(关闭窗口即停止)
|
|
511
|
-
LABEL=${shQuote(label)}
|
|
512
|
-
PLIST=${shQuote(plist)}
|
|
513
|
-
URL=${shQuote(url)}
|
|
514
|
-
LOG=/tmp/pi-web-ui-shortcut.log
|
|
515
|
-
NODE=${shQuote(node)}
|
|
516
|
-
ENTRY=${shQuote(SERVER_ENTRY)}
|
|
517
|
-
${exports}
|
|
518
|
-
|
|
519
|
-
if launchctl print "gui/$(id -u)/$LABEL" >/dev/null 2>&1; then
|
|
520
|
-
launchctl kickstart "gui/$(id -u)/$LABEL"
|
|
521
|
-
elif [ -f "$PLIST" ]; then
|
|
522
|
-
launchctl bootstrap "gui/$(id -u)" "$PLIST"
|
|
523
|
-
else
|
|
524
|
-
# 未安装服务:在本终端前台运行服务器(关闭窗口即停止)
|
|
525
|
-
"$NODE" "$ENTRY" >>"$LOG" 2>&1 &
|
|
526
|
-
SERVER_PID=$!
|
|
527
|
-
trap 'kill "$SERVER_PID" 2>/dev/null' EXIT
|
|
528
|
-
fi
|
|
529
|
-
|
|
530
|
-
# 等服务就绪后打开浏览器(最多约 30 秒)
|
|
531
|
-
for i in $(seq 1 120); do
|
|
532
|
-
curl -sf "$URL/api/health" >/dev/null 2>&1 && break
|
|
533
|
-
sleep 0.25
|
|
534
|
-
done
|
|
535
|
-
open "$URL"
|
|
536
|
-
if [ -n "\${SERVER_PID:-}" ]; then wait "$SERVER_PID"; fi
|
|
537
|
-
`;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
function installMacShortcut(opts) {
|
|
541
|
-
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
542
|
-
const url = `http://localhost:${port}`;
|
|
543
|
-
const script = buildMacShortcut(
|
|
544
|
-
serviceLabel(name),
|
|
545
|
-
launchAgentPlist(name),
|
|
546
|
-
url,
|
|
547
|
-
serviceEnv(port, cwd, dataDir),
|
|
548
|
-
);
|
|
549
|
-
const path = join(homedir(), "Desktop", SHORTCUT_MAC_NAME);
|
|
550
|
-
if (opts.print) {
|
|
551
|
-
console.log(`# ${path}\n${script}`);
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
writeFileSync(path, script);
|
|
555
|
-
chmodSync(path, 0o755);
|
|
556
|
-
console.log(`✅ 已创建桌面启动器: ${path}`);
|
|
557
|
-
console.log(` 双击 : 确保服务运行并打开浏览器;未安装服务时在本终端前台运行`);
|
|
558
|
-
console.log(` 说明 : macOS 没有 Windows 式快捷方式,这是等价的 .command 启动器;`);
|
|
559
|
-
console.log(` launchd 服务登录自启,图标主要用于快速「启动 + 打开浏览器」`);
|
|
560
|
-
console.log(` 端口 : ${port}`);
|
|
561
|
-
console.log(` 目录 : ${cwd}`);
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
/** Linux: launcher script run by the .desktop icon. */
|
|
565
|
-
function buildLinuxStartScript(unitName, url) {
|
|
566
|
-
const log = join(homedir(), ".local", "share", "pi-web-ui", "pi-web-ui.log");
|
|
567
|
-
const node = realNode();
|
|
568
|
-
return `#!/bin/bash
|
|
569
|
-
# pi-web-ui 启动器 — generated by: pi-web-ui server shortcut
|
|
570
|
-
# 双击运行:确保服务在运行,然后打开浏览器。
|
|
571
|
-
# · systemd 单元已安装 → systemctl start(系统单元需要授权,失败则前台运行)
|
|
572
|
-
# · 未安装 → 在本进程前台运行(终端关闭即停止)
|
|
573
|
-
LOG=${shQuote(log)}
|
|
574
|
-
URL=${shQuote(url)}
|
|
575
|
-
NODE=${shQuote(node)}
|
|
576
|
-
ENTRY=${shQuote(SERVER_ENTRY)}
|
|
577
|
-
UNIT=${shQuote(unitName)}.service
|
|
578
|
-
|
|
579
|
-
if ! curl -sf "$URL/api/health" >/dev/null 2>&1; then
|
|
580
|
-
systemctl start "$UNIT" 2>/dev/null || true
|
|
581
|
-
if ! curl -sf "$URL/api/health" >/dev/null 2>&1; then
|
|
582
|
-
mkdir -p "$(dirname "$LOG")"
|
|
583
|
-
"$NODE" "$ENTRY" >>"$LOG" 2>&1 &
|
|
584
|
-
SERVER_PID=$!
|
|
585
|
-
trap 'kill "$SERVER_PID" 2>/dev/null' EXIT
|
|
586
|
-
fi
|
|
587
|
-
fi
|
|
588
|
-
|
|
589
|
-
for i in $(seq 1 120); do
|
|
590
|
-
curl -sf "$URL/api/health" >/dev/null 2>&1 && break
|
|
591
|
-
sleep 0.25
|
|
592
|
-
done
|
|
593
|
-
xdg-open "$URL" >/dev/null 2>&1 &
|
|
594
|
-
if [ -n "\${SERVER_PID:-}" ]; then wait "$SERVER_PID"; fi
|
|
595
|
-
`;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
function installLinuxShortcut(opts) {
|
|
599
|
-
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
600
|
-
const url = `http://localhost:${port}`;
|
|
601
|
-
const scriptDir = join(homedir(), ".local", "share", "pi-web-ui");
|
|
602
|
-
const scriptPath = join(scriptDir, `${name}-start.sh`);
|
|
603
|
-
const desktopPath = join(homedir(), "Desktop", SHORTCUT_LINUX_NAME);
|
|
604
|
-
const icoPath = join(scriptDir, APP_ICO_NAME); // 备用;优先 SVG
|
|
605
|
-
const svgPath = join(scriptDir, "pi-web-ui.svg");
|
|
606
|
-
const script = buildLinuxStartScript(name, url);
|
|
607
|
-
const desktopIcon = existsSync(APP_SVG_PACKAGE) ? svgPath : APP_ICO_NAME;
|
|
608
|
-
const desktop = `[Desktop Entry]
|
|
609
|
-
Version=1.0
|
|
610
|
-
Type=Application
|
|
611
|
-
Name=pi-web-ui
|
|
612
|
-
Comment=启动 pi-web-ui 服务并打开浏览器
|
|
613
|
-
Exec=${shQuote(scriptPath)}
|
|
614
|
-
Icon=${shQuote(desktopIcon)}
|
|
615
|
-
Terminal=false
|
|
616
|
-
Categories=Network;WebBrowser;
|
|
617
|
-
`;
|
|
618
|
-
if (opts.print) {
|
|
619
|
-
console.log(`# ${scriptPath}\n${script}`);
|
|
620
|
-
console.log(`# ${desktopPath}\n${desktop}`);
|
|
621
|
-
return;
|
|
622
|
-
}
|
|
623
|
-
mkdirSync(scriptDir, { recursive: true });
|
|
624
|
-
// 品牌图标(缺失时跳过,桌面自动回退默认图标)
|
|
625
|
-
if (existsSync(APP_SVG_PACKAGE)) copyFileSync(APP_SVG_PACKAGE, svgPath);
|
|
626
|
-
else if (existsSync(APP_ICO_SOURCE)) copyFileSync(APP_ICO_SOURCE, icoPath);
|
|
627
|
-
writeFileSync(scriptPath, script);
|
|
628
|
-
chmodSync(scriptPath, 0o755);
|
|
629
|
-
writeFileSync(desktopPath, desktop);
|
|
630
|
-
chmodSync(desktopPath, 0o755);
|
|
631
|
-
// GNOME 需要标记可信才能双击运行
|
|
632
|
-
run("gio", ["set", desktopPath, "metadata::trusted", "true"], {
|
|
633
|
-
ignoreError: true,
|
|
634
|
-
silent: true,
|
|
635
|
-
});
|
|
636
|
-
console.log(`✅ 已创建桌面图标: ${desktopPath}`);
|
|
637
|
-
console.log(` GNOME 若提示「不受信任的应用程序」,右键选择 Allow Launching`);
|
|
638
|
-
console.log(` 端口 : ${port}`);
|
|
639
|
-
console.log(` 目录 : ${cwd}`);
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
/** Remove desktop shortcut artifacts created by `server shortcut`. */
|
|
643
|
-
function removeShortcut(name) {
|
|
644
|
-
if (isWin) {
|
|
645
|
-
for (const f of [winShortcutPs1Path(name), winPidFilePath(name), winIcoPath()]) {
|
|
646
|
-
if (existsSync(f)) rmSync(f);
|
|
647
|
-
}
|
|
648
|
-
spawnSync(
|
|
649
|
-
winPowershell(),
|
|
650
|
-
[
|
|
651
|
-
"-NoProfile",
|
|
652
|
-
"-NonInteractive",
|
|
653
|
-
"-ExecutionPolicy",
|
|
654
|
-
"Bypass",
|
|
655
|
-
"-Command",
|
|
656
|
-
`$d=[Environment]::GetFolderPath('Desktop');$p=Join-Path $d ${psQuote(SHORTCUT_LNK_NAME)};if(Test-Path $p){Remove-Item $p -Force}`,
|
|
657
|
-
],
|
|
658
|
-
{ stdio: "ignore" },
|
|
659
|
-
);
|
|
660
|
-
} else if (isMac) {
|
|
661
|
-
const p = join(homedir(), "Desktop", SHORTCUT_MAC_NAME);
|
|
662
|
-
if (existsSync(p)) rmSync(p);
|
|
663
|
-
} else if (isLinux) {
|
|
664
|
-
const p = join(homedir(), "Desktop", SHORTCUT_LINUX_NAME);
|
|
665
|
-
if (existsSync(p)) rmSync(p);
|
|
666
|
-
rmSync(join(homedir(), ".local", "share", "pi-web-ui"), {
|
|
667
|
-
recursive: true,
|
|
668
|
-
force: true,
|
|
669
|
-
});
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
/** Single-quote a string for embedding in a generated PowerShell script. */
|
|
674
|
-
function psQuote(s) {
|
|
675
|
-
return "'" + s.replace(/'/g, "''") + "'";
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/**
|
|
679
|
-
* Build the PowerShell launcher the scheduled task runs. Task Scheduler
|
|
680
|
-
* launches it with -WindowStyle Hidden, so the console window is created
|
|
681
|
-
* hidden (SW_HIDE) — no black cmd window stays open while the server runs,
|
|
682
|
-
* and there is nothing to accidentally close/kill. The script sets the env,
|
|
683
|
-
* cd's to the workspace, then runs node in the foreground with output
|
|
684
|
-
* appended to the log, so the task instance IS the powershell process and
|
|
685
|
-
* `schtasks /End` still stops the whole tree.
|
|
686
|
-
*/
|
|
687
|
-
function buildWinStartPs1(env, cwd, logPath) {
|
|
688
|
-
const sets = Object.entries(env)
|
|
689
|
-
.map(([k, v]) => `$env:${k} = ${psQuote(v)}`)
|
|
690
|
-
.join("\r\n");
|
|
691
|
-
return [
|
|
692
|
-
"# Generated by: pi-web-ui server install (rerun to change)",
|
|
693
|
-
"# Starts the server with a hidden console window (no black cmd box).",
|
|
694
|
-
sets,
|
|
695
|
-
`Set-Location ${psQuote(cwd)}`,
|
|
696
|
-
`& ${psQuote(NODE)} ${psQuote(SERVER_ENTRY)} *>> ${psQuote(logPath)}`,
|
|
697
|
-
"",
|
|
698
|
-
].join("\r\n");
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
/**
|
|
702
|
-
* Build the Task Scheduler XML for a user task: LogonTrigger (starts at logon,
|
|
703
|
-
* like a launchd agent — no admin needed), InteractiveToken, auto-restart on
|
|
704
|
-
* failure. The task runs the PowerShell launcher via
|
|
705
|
-
* powershell.exe -WindowStyle Hidden, so no console window ever appears.
|
|
706
|
-
*/
|
|
707
|
-
function buildWinTaskXml(ps1Path, cwd) {
|
|
708
|
-
const powershell = winPowershell();
|
|
709
|
-
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
710
|
-
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
711
|
-
<RegistrationInfo>
|
|
712
|
-
<Description>pi-web-ui — web chat for the pi coding agent (auto-start at logon)</Description>
|
|
713
|
-
</RegistrationInfo>
|
|
714
|
-
<Triggers>
|
|
715
|
-
<LogonTrigger>
|
|
716
|
-
<Enabled>true</Enabled>
|
|
717
|
-
</LogonTrigger>
|
|
718
|
-
</Triggers>
|
|
719
|
-
<Principals>
|
|
720
|
-
<Principal id="Author">
|
|
721
|
-
<LogonType>InteractiveToken</LogonType>
|
|
722
|
-
<RunLevel>LeastPrivilege</RunLevel>
|
|
723
|
-
</Principal>
|
|
724
|
-
</Principals>
|
|
725
|
-
<Settings>
|
|
726
|
-
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
727
|
-
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
728
|
-
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
729
|
-
<AllowHardTerminate>true</AllowHardTerminate>
|
|
730
|
-
<StartWhenAvailable>false</StartWhenAvailable>
|
|
731
|
-
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
732
|
-
<IdleSettings>
|
|
733
|
-
<StopOnIdleEnd>false</StopOnIdleEnd>
|
|
734
|
-
<RestartOnIdle>false</RestartOnIdle>
|
|
735
|
-
</IdleSettings>
|
|
736
|
-
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
737
|
-
<Enabled>true</Enabled>
|
|
738
|
-
<Hidden>false</Hidden>
|
|
739
|
-
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
740
|
-
<WakeToRun>false</WakeToRun>
|
|
741
|
-
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
742
|
-
<Priority>7</Priority>
|
|
743
|
-
<RestartOnFailure>
|
|
744
|
-
<Interval>PT1M</Interval>
|
|
745
|
-
<Count>3</Count>
|
|
746
|
-
</RestartOnFailure>
|
|
747
|
-
</Settings>
|
|
748
|
-
<Actions Context="Author">
|
|
749
|
-
<Exec>
|
|
750
|
-
<Command>${esc(powershell)}</Command>
|
|
751
|
-
<Arguments>-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "${esc(ps1Path)}"</Arguments>
|
|
752
|
-
<WorkingDirectory>${esc(cwd)}</WorkingDirectory>
|
|
753
|
-
</Exec>
|
|
754
|
-
</Actions>
|
|
755
|
-
</Task>
|
|
756
|
-
`;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
function esc(s) {
|
|
760
|
-
return s
|
|
761
|
-
.replace(/&/g, "&")
|
|
762
|
-
.replace(/</g, "<")
|
|
763
|
-
.replace(/>/g, ">")
|
|
764
|
-
.replace(/"/g, """);
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
/** Build the launchd plist XML. */
|
|
768
|
-
function buildPlist(label, cwd, env) {
|
|
769
|
-
const entries = Object.entries(env)
|
|
770
|
-
.map(([k, v]) => ` <key>${esc(k)}</key>\n <string>${esc(v)}</string>`)
|
|
771
|
-
.join("\n");
|
|
772
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
773
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
774
|
-
<!-- Generated by: pi-web-ui server install (do not edit by hand — rerun to change) -->
|
|
775
|
-
<plist version="1.0">
|
|
776
|
-
<dict>
|
|
777
|
-
<key>Label</key>
|
|
778
|
-
<string>${esc(label)}</string>
|
|
779
|
-
|
|
780
|
-
<key>ProgramArguments</key>
|
|
781
|
-
<array>
|
|
782
|
-
<string>${esc(NODE)}</string>
|
|
783
|
-
<string>${esc(SERVER_ENTRY)}</string>
|
|
784
|
-
</array>
|
|
785
|
-
|
|
786
|
-
<key>RunAtLoad</key>
|
|
787
|
-
<true/>
|
|
788
|
-
|
|
789
|
-
<!-- Restart if it crashes -->
|
|
790
|
-
<key>KeepAlive</key>
|
|
791
|
-
<true/>
|
|
792
|
-
|
|
793
|
-
<key>WorkingDirectory</key>
|
|
794
|
-
<string>${esc(cwd)}</string>
|
|
795
|
-
|
|
796
|
-
<key>EnvironmentVariables</key>
|
|
797
|
-
<dict>
|
|
798
|
-
${entries}
|
|
799
|
-
</dict>
|
|
800
|
-
|
|
801
|
-
<key>StandardOutPath</key>
|
|
802
|
-
<string>/tmp/pi-web-ui.log</string>
|
|
803
|
-
<key>StandardErrorPath</key>
|
|
804
|
-
<string>/tmp/pi-web-ui.err</string>
|
|
805
|
-
</dict>
|
|
806
|
-
</plist>
|
|
807
|
-
`;
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
/** Build the systemd unit file. */
|
|
811
|
-
function buildUnit(cwd, env) {
|
|
812
|
-
const envLines = Object.entries(env)
|
|
813
|
-
.map(([k, v]) => `Environment=${k}=${v}`)
|
|
814
|
-
.join("\n");
|
|
815
|
-
return `# Generated by: pi-web-ui server install (do not edit by hand — rerun to change)
|
|
816
|
-
[Unit]
|
|
817
|
-
Description=pi-web-ui — web chat for the pi coding agent
|
|
818
|
-
After=network.target
|
|
819
|
-
|
|
820
|
-
[Service]
|
|
821
|
-
Type=simple
|
|
822
|
-
User=${process.env.SUDO_USER ?? userInfo().username}
|
|
823
|
-
WorkingDirectory=${cwd}
|
|
824
|
-
${envLines}
|
|
825
|
-
ExecStart=${JSON.stringify(NODE)} ${JSON.stringify(SERVER_ENTRY)}
|
|
826
|
-
Restart=always
|
|
827
|
-
RestartSec=5
|
|
828
|
-
|
|
829
|
-
[Install]
|
|
830
|
-
WantedBy=multi-user.target
|
|
831
|
-
`;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
/** If not root on Linux, re-exec the same server command through sudo. */
|
|
835
|
-
function ensureRootForSystemctl() {
|
|
836
|
-
if (typeof process.getuid === "function" && process.getuid() === 0) return;
|
|
837
|
-
// process.argv = [node, <bin>, "server", <action>, ...rest] — forward
|
|
838
|
-
// everything after "server" so flags like --port/--cwd survive.
|
|
839
|
-
const res = spawnSync(
|
|
840
|
-
"sudo",
|
|
841
|
-
[NODE, fileURLToPath(import.meta.url), "server", ...process.argv.slice(3)],
|
|
842
|
-
{ stdio: "inherit" },
|
|
843
|
-
);
|
|
844
|
-
process.exit(res.status ?? 1);
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
/** Shared option normalization for install. */
|
|
848
|
-
function serviceOptions(opts) {
|
|
849
|
-
const name = opts.name ?? "pi-web-ui";
|
|
850
|
-
const port = String(opts.port ?? process.env.PORT ?? "8787");
|
|
851
|
-
if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
|
|
852
|
-
fail(`无效端口: ${port}`);
|
|
853
|
-
}
|
|
854
|
-
const cwd = resolve(opts.cwd ?? process.env.PI_WEB_CWD ?? process.cwd());
|
|
855
|
-
if (!existsSync(cwd)) fail(`工作目录不存在: ${cwd}`);
|
|
856
|
-
let dataDir;
|
|
857
|
-
if (opts.dataDir) {
|
|
858
|
-
dataDir = resolve(opts.dataDir);
|
|
859
|
-
} else if (process.env.PI_WEB_DATA_DIR) {
|
|
860
|
-
dataDir = resolve(process.env.PI_WEB_DATA_DIR);
|
|
861
|
-
}
|
|
862
|
-
return { name, port, cwd, dataDir };
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
function serviceEnv(port, cwd, dataDir) {
|
|
866
|
-
const env = {
|
|
867
|
-
PORT: port,
|
|
868
|
-
PI_WEB_CWD: cwd,
|
|
869
|
-
};
|
|
870
|
-
// Interactive Windows tasks inherit the user's PATH; only systemd/launchd
|
|
871
|
-
// run with a minimal environment that needs an explicit PATH.
|
|
872
|
-
if (!isWin) env.PATH = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
873
|
-
// Same for the locale: launchd/systemd drop LANG/LC_ALL, and a C-locale
|
|
874
|
-
// shell garbles multibyte input in the terminal (UTF-8 continuation
|
|
875
|
-
// bytes 0x80–0x9F rendered as C1 control chars). Bake the installing
|
|
876
|
-
// shell's locale into the service env so spawned terminals are UTF-8.
|
|
877
|
-
if (!isWin && process.env.LANG) env.LANG = process.env.LANG;
|
|
878
|
-
if (!isWin && process.env.LC_ALL) env.LC_ALL = process.env.LC_ALL;
|
|
879
|
-
if (dataDir) env.PI_WEB_DATA_DIR = dataDir;
|
|
880
|
-
return env;
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
function installLaunchd(opts) {
|
|
884
|
-
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
885
|
-
const label = serviceLabel(name);
|
|
886
|
-
const plist = launchAgentPlist(name);
|
|
887
|
-
const content = buildPlist(label, cwd, serviceEnv(port, cwd, dataDir));
|
|
888
|
-
if (opts.print) {
|
|
889
|
-
console.log(`# ${plist}\n${content}`);
|
|
890
|
-
return;
|
|
891
|
-
}
|
|
892
|
-
// Unload any existing instance (ignore "not loaded"), then (re)install.
|
|
893
|
-
run("launchctl", ["bootout", `gui/${uid()}/${label}`], {
|
|
894
|
-
ignoreError: true,
|
|
895
|
-
silent: true,
|
|
896
|
-
});
|
|
897
|
-
mkdirSync(dirname(plist), { recursive: true });
|
|
898
|
-
writeFileSync(plist, content);
|
|
899
|
-
run("launchctl", ["bootstrap", `gui/${uid()}`, plist]);
|
|
900
|
-
console.log(`✅ 已安装并启动 launchd 服务 ${label}`);
|
|
901
|
-
console.log(` 端口 : ${port}`);
|
|
902
|
-
console.log(` 目录 : ${cwd}`);
|
|
903
|
-
console.log(` 访问 : http://localhost:${port}`);
|
|
904
|
-
console.log(` 日志 : /tmp/pi-web-ui.log /tmp/pi-web-ui.err`);
|
|
905
|
-
console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
|
|
906
|
-
console.log(` 提示 : pi-web-ui server shortcut 可在桌面创建「一键启动」图标`);
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
function installSystemd(opts) {
|
|
910
|
-
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
911
|
-
const content = buildUnit(cwd, serviceEnv(port, cwd, dataDir));
|
|
912
|
-
const unitPath = systemdUnitPath(name);
|
|
913
|
-
if (opts.print) {
|
|
914
|
-
console.log(`# ${unitPath}\n${content}`);
|
|
915
|
-
return;
|
|
916
|
-
}
|
|
917
|
-
ensureRootForSystemctl();
|
|
918
|
-
writeFileSync(unitPath, content);
|
|
919
|
-
run("systemctl", ["daemon-reload"]);
|
|
920
|
-
run("systemctl", ["enable", "--now", `${name}.service`]);
|
|
921
|
-
console.log(`✅ 已安装并启动 systemd 服务 ${name}.service`);
|
|
922
|
-
console.log(` 端口 : ${port}`);
|
|
923
|
-
console.log(` 目录 : ${cwd}`);
|
|
924
|
-
console.log(` 访问 : http://localhost:${port}`);
|
|
925
|
-
console.log(` 日志 : journalctl -u ${name}.service -f`);
|
|
926
|
-
console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
|
|
927
|
-
console.log(` 提示 : pi-web-ui server shortcut 可在桌面创建「一键启动」图标`);
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
function uninstallLaunchd(opts) {
|
|
931
|
-
const name = opts.name ?? "pi-web-ui";
|
|
932
|
-
const label = serviceLabel(name);
|
|
933
|
-
const plist = launchAgentPlist(name);
|
|
934
|
-
run("launchctl", ["bootout", `gui/${uid()}/${label}`], {
|
|
935
|
-
ignoreError: true,
|
|
936
|
-
silent: true,
|
|
937
|
-
});
|
|
938
|
-
if (existsSync(plist)) rmSync(plist);
|
|
939
|
-
removeShortcut(name);
|
|
940
|
-
console.log(`🗑 已卸载 ${label}(plist 已删除,不再开机自启)`);
|
|
941
|
-
console.log(`🗑 已移除桌面快捷方式`);
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
function uninstallSystemd(opts) {
|
|
945
|
-
const name = opts.name ?? "pi-web-ui";
|
|
946
|
-
ensureRootForSystemctl();
|
|
947
|
-
run("systemctl", ["disable", "--now", `${name}.service`], {
|
|
948
|
-
ignoreError: true,
|
|
949
|
-
});
|
|
950
|
-
const unitPath = systemdUnitPath(name);
|
|
951
|
-
if (existsSync(unitPath)) rmSync(unitPath);
|
|
952
|
-
run("systemctl", ["daemon-reload"]);
|
|
953
|
-
removeShortcut(name);
|
|
954
|
-
console.log(`🗑 已卸载 ${name}.service(不再开机自启)`);
|
|
955
|
-
console.log(`🗑 已移除桌面快捷方式`);
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
function installWindows(opts) {
|
|
959
|
-
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
960
|
-
const env = serviceEnv(port, cwd, dataDir);
|
|
961
|
-
const ps1Path = winPs1Path(name);
|
|
962
|
-
const xmlPath = winTaskXmlPath(name);
|
|
963
|
-
const ps1 = buildWinStartPs1(env, cwd, winLogPath());
|
|
964
|
-
const xml = buildWinTaskXml(ps1Path, cwd);
|
|
965
|
-
if (opts.print) {
|
|
966
|
-
console.log(`# ${ps1Path}\n${ps1}`);
|
|
967
|
-
console.log(`# ${xmlPath}\n${xml}`);
|
|
968
|
-
return;
|
|
969
|
-
}
|
|
970
|
-
mkdirSync(dirname(ps1Path), { recursive: true });
|
|
971
|
-
// UTF-8 with BOM: Windows PowerShell 5.1 misreads BOM-less UTF-8 as ANSI.
|
|
972
|
-
writeFileSync(ps1Path, "\uFEFF" + ps1, "utf8");
|
|
973
|
-
// Remove the old-style .cmd wrapper from previous installs.
|
|
974
|
-
if (existsSync(winCmdPath(name))) rmSync(winCmdPath(name));
|
|
975
|
-
// schtasks /Create /XML requires a UTF-16 file (with BOM).
|
|
976
|
-
writeFileSync(xmlPath, "\uFEFF" + xml, "utf16le");
|
|
977
|
-
if (winTaskExists(name)) {
|
|
978
|
-
run("schtasks", ["/Delete", "/TN", name, "/F"], {
|
|
979
|
-
ignoreError: true,
|
|
980
|
-
silent: true,
|
|
981
|
-
});
|
|
982
|
-
}
|
|
983
|
-
run("schtasks", ["/Create", "/TN", name, "/XML", xmlPath, "/F"]);
|
|
984
|
-
run("schtasks", ["/Run", "/TN", name], { ignoreError: true });
|
|
985
|
-
console.log(`✅ 已安装并启动计划任务 ${name}(隐藏窗口运行,无黑窗)`);
|
|
986
|
-
console.log(` 端口 : ${port}`);
|
|
987
|
-
console.log(` 目录 : ${cwd}`);
|
|
988
|
-
console.log(` 访问 : http://localhost:${port}`);
|
|
989
|
-
console.log(` 日志 : ${winLogPath()}`);
|
|
990
|
-
console.log(
|
|
991
|
-
` 说明 : 登录后自启(与 launchd 用户代理一致);stop 停止,uninstall 移除`,
|
|
992
|
-
);
|
|
993
|
-
console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
|
|
994
|
-
console.log(` 提示 : pi-web-ui server shortcut 可在桌面创建「一键启动」图标`);
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
function uninstallWindows(opts) {
|
|
998
|
-
const name = opts.name ?? "pi-web-ui";
|
|
999
|
-
if (winTaskExists(name)) {
|
|
1000
|
-
run("schtasks", ["/Delete", "/TN", name, "/F"], { ignoreError: true });
|
|
1001
|
-
}
|
|
1002
|
-
for (const f of [winCmdPath(name), winPs1Path(name), winTaskXmlPath(name)]) {
|
|
1003
|
-
if (existsSync(f)) rmSync(f);
|
|
1004
|
-
}
|
|
1005
|
-
// 快捷方式启动的隐藏实例
|
|
1006
|
-
const pid = winReadPid(name);
|
|
1007
|
-
if (pid && pidAlive(pid)) {
|
|
1008
|
-
run("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
1009
|
-
ignoreError: true,
|
|
1010
|
-
silent: true,
|
|
1011
|
-
});
|
|
1012
|
-
}
|
|
1013
|
-
removeShortcut(name);
|
|
1014
|
-
console.log(`🗑 已卸载 ${name}(计划任务已删除,不再自启)`);
|
|
1015
|
-
console.log(`🗑 已移除桌面快捷方式`);
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
function controlService(action, opts) {
|
|
1019
|
-
const name = opts.name ?? "pi-web-ui";
|
|
1020
|
-
|
|
1021
|
-
if (isMac) {
|
|
1022
|
-
const label = serviceLabel(name);
|
|
1023
|
-
const target = `gui/${uid()}/${label}`;
|
|
1024
|
-
const loaded = () =>
|
|
1025
|
-
spawnSync("launchctl", ["print", target], { stdio: "ignore" }).status ===
|
|
1026
|
-
0;
|
|
1027
|
-
|
|
1028
|
-
if (action === "status") {
|
|
1029
|
-
if (loaded()) {
|
|
1030
|
-
const res = spawnSync("launchctl", ["print", target], {
|
|
1031
|
-
encoding: "utf8",
|
|
1032
|
-
});
|
|
1033
|
-
const state = (res.stdout.match(/state = (\w+)/) ?? [])[1] ?? "loaded";
|
|
1034
|
-
console.log(`${label}: ${state}(已加载,开机自启中)`);
|
|
1035
|
-
} else {
|
|
1036
|
-
console.log(`${label}: 未安装(运行 pi-web-ui server install 安装)`);
|
|
1037
|
-
}
|
|
1038
|
-
return;
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
if (action === "start") {
|
|
1042
|
-
if (loaded()) {
|
|
1043
|
-
run("launchctl", ["kickstart", target]);
|
|
1044
|
-
} else {
|
|
1045
|
-
const plist = launchAgentPlist(name);
|
|
1046
|
-
if (!existsSync(plist)) {
|
|
1047
|
-
fail(`找不到 ${plist},请先运行 pi-web-ui server install`);
|
|
1048
|
-
}
|
|
1049
|
-
run("launchctl", ["bootstrap", `gui/${uid()}`, plist]);
|
|
1050
|
-
}
|
|
1051
|
-
console.log(`✅ 已启动 ${label}`);
|
|
1052
|
-
return;
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
if (action === "restart") {
|
|
1056
|
-
if (!loaded()) fail(`${label} 未加载,请先 pi-web-ui server start`);
|
|
1057
|
-
run("launchctl", ["kickstart", "-k", target]);
|
|
1058
|
-
console.log(`✅ 已重启 ${label}`);
|
|
1059
|
-
return;
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
if (action === "stop") {
|
|
1063
|
-
run("launchctl", ["bootout", target], {
|
|
1064
|
-
ignoreError: true,
|
|
1065
|
-
silent: true,
|
|
1066
|
-
});
|
|
1067
|
-
console.log(`⏹ 已停止 ${label}(已卸载,不再开机自启;start 恢复)`);
|
|
1068
|
-
return;
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
fail(`未知操作: ${action}`);
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
if (isLinux) {
|
|
1075
|
-
ensureRootForSystemctl();
|
|
1076
|
-
if (action === "status") {
|
|
1077
|
-
run("systemctl", ["status", `${name}.service`, "--no-pager"]);
|
|
1078
|
-
return;
|
|
1079
|
-
}
|
|
1080
|
-
run("systemctl", [action, `${name}.service`]);
|
|
1081
|
-
console.log(`✅ ${action} ${name}.service`);
|
|
1082
|
-
return;
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
if (isWin) {
|
|
1086
|
-
const exists = winTaskExists(name);
|
|
1087
|
-
|
|
1088
|
-
if (action === "status") {
|
|
1089
|
-
const pid = winReadPid(name);
|
|
1090
|
-
const instAlive = pid && pidAlive(pid);
|
|
1091
|
-
if (!exists) {
|
|
1092
|
-
console.log(`${name}: 未安装(运行 pi-web-ui server install 安装)`);
|
|
1093
|
-
if (instAlive) console.log(` 快捷方式实例 : 运行中 (PID ${pid})`);
|
|
1094
|
-
return;
|
|
1095
|
-
}
|
|
1096
|
-
// Get-ScheduledTask outputs English state enums — locale-independent,
|
|
1097
|
-
// unlike `schtasks /Query` tables on localized Windows.
|
|
1098
|
-
const ps = spawnSync(
|
|
1099
|
-
"powershell.exe",
|
|
1100
|
-
[
|
|
1101
|
-
"-NoProfile",
|
|
1102
|
-
"-NonInteractive",
|
|
1103
|
-
"-Command",
|
|
1104
|
-
"$t=Get-ScheduledTask -TaskName '" +
|
|
1105
|
-
name +
|
|
1106
|
-
"' -ErrorAction SilentlyContinue;" +
|
|
1107
|
-
"if(!$t){'NOT_INSTALLED';exit}" +
|
|
1108
|
-
"$i=$t|Get-ScheduledTaskInfo;" +
|
|
1109
|
-
"'State: '+$t.State;" +
|
|
1110
|
-
"'LastRunTime: '+$i.LastRunTime;" +
|
|
1111
|
-
"'LastTaskResult: '+$i.LastTaskResult",
|
|
1112
|
-
],
|
|
1113
|
-
{ encoding: "utf8" },
|
|
1114
|
-
);
|
|
1115
|
-
if (ps.status !== 0 || (ps.stdout ?? "").includes("NOT_INSTALLED")) {
|
|
1116
|
-
console.log(`${name}: 未安装(运行 pi-web-ui server install 安装)`);
|
|
1117
|
-
return;
|
|
1118
|
-
}
|
|
1119
|
-
console.log(`${name}: 计划任务\n${(ps.stdout ?? "").trim()}`);
|
|
1120
|
-
if (pid) {
|
|
1121
|
-
if (instAlive) console.log(` 快捷方式实例 : 运行中 (PID ${pid})`);
|
|
1122
|
-
else console.log(` 快捷方式实例 : 已退出 (PID ${pid})`);
|
|
1123
|
-
}
|
|
1124
|
-
return;
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
if (action === "start") {
|
|
1128
|
-
if (!exists) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
|
|
1129
|
-
run("schtasks", ["/Run", "/TN", name]);
|
|
1130
|
-
console.log(`✅ 已启动 ${name}`);
|
|
1131
|
-
return;
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
if (action === "restart") {
|
|
1135
|
-
if (!exists) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
|
|
1136
|
-
run("schtasks", ["/End", "/TN", name], {
|
|
1137
|
-
ignoreError: true,
|
|
1138
|
-
silent: true,
|
|
1139
|
-
});
|
|
1140
|
-
run("schtasks", ["/Run", "/TN", name]);
|
|
1141
|
-
console.log(`✅ 已重启 ${name}`);
|
|
1142
|
-
return;
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
if (action === "stop") {
|
|
1146
|
-
run("schtasks", ["/End", "/TN", name], {
|
|
1147
|
-
ignoreError: true,
|
|
1148
|
-
silent: true,
|
|
1149
|
-
});
|
|
1150
|
-
const pid = winReadPid(name);
|
|
1151
|
-
if (pid) {
|
|
1152
|
-
if (pidAlive(pid)) {
|
|
1153
|
-
run("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
1154
|
-
ignoreError: true,
|
|
1155
|
-
silent: true,
|
|
1156
|
-
});
|
|
1157
|
-
console.log(`⏹ 已停止快捷方式实例 (PID ${pid})`);
|
|
1158
|
-
}
|
|
1159
|
-
rmSync(winPidFilePath(name), { force: true });
|
|
1160
|
-
}
|
|
1161
|
-
console.log(`⏹ 已停止 ${name}(自启保留;uninstall 移除)`);
|
|
1162
|
-
return;
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
fail(`未知操作: ${action}`);
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
fail(
|
|
1169
|
-
`不支持的系统服务平台: ${process.platform}(仅 macOS / Linux / Windows)`,
|
|
1170
|
-
);
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
async function serverCmd(argv) {
|
|
1174
|
-
const { opts, positionals } = parseFlags(argv);
|
|
1175
|
-
if (opts.help) {
|
|
1176
|
-
console.log(HELP);
|
|
1177
|
-
return;
|
|
1178
|
-
}
|
|
1179
|
-
if (positionals.length === 0) {
|
|
1180
|
-
console.log(HELP);
|
|
1181
|
-
console.log("--- 当前服务状态 ---");
|
|
1182
|
-
controlService("status", opts);
|
|
1183
|
-
return;
|
|
1184
|
-
}
|
|
1185
|
-
const action = positionals[0];
|
|
1186
|
-
if (positionals.length > 1)
|
|
1187
|
-
fail(`多余的参数: ${positionals.slice(1).join(" ")}`);
|
|
1188
|
-
switch (action) {
|
|
1189
|
-
case "shortcut": {
|
|
1190
|
-
if (isWin) {
|
|
1191
|
-
installWinShortcut(opts);
|
|
1192
|
-
} else if (isMac) {
|
|
1193
|
-
installMacShortcut(opts);
|
|
1194
|
-
} else if (isLinux) {
|
|
1195
|
-
installLinuxShortcut(opts);
|
|
1196
|
-
} else {
|
|
1197
|
-
fail(`不支持的系统服务平台: ${process.platform}`);
|
|
1198
|
-
}
|
|
1199
|
-
break;
|
|
1200
|
-
}
|
|
1201
|
-
case "install": {
|
|
1202
|
-
if (isMac) {
|
|
1203
|
-
installLaunchd(opts);
|
|
1204
|
-
} else if (isLinux) {
|
|
1205
|
-
installSystemd(opts);
|
|
1206
|
-
} else if (isWin) {
|
|
1207
|
-
installWindows(opts);
|
|
1208
|
-
} else {
|
|
1209
|
-
fail(`不支持的系统服务平台: ${process.platform}`);
|
|
1210
|
-
}
|
|
1211
|
-
break;
|
|
1212
|
-
}
|
|
1213
|
-
case "uninstall": {
|
|
1214
|
-
if (isMac) {
|
|
1215
|
-
uninstallLaunchd(opts);
|
|
1216
|
-
} else if (isLinux) {
|
|
1217
|
-
uninstallSystemd(opts);
|
|
1218
|
-
} else if (isWin) {
|
|
1219
|
-
uninstallWindows(opts);
|
|
1220
|
-
} else {
|
|
1221
|
-
fail(`不支持的系统服务平台: ${process.platform}`);
|
|
1222
|
-
}
|
|
1223
|
-
break;
|
|
1224
|
-
}
|
|
1225
|
-
case "start":
|
|
1226
|
-
case "stop":
|
|
1227
|
-
case "restart":
|
|
1228
|
-
case "status":
|
|
1229
|
-
controlService(action, opts);
|
|
1230
|
-
break;
|
|
1231
|
-
default:
|
|
1232
|
-
fail(
|
|
1233
|
-
`未知操作: ${action}(install / shortcut / uninstall / start / stop / restart / status)`,
|
|
1234
|
-
);
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
async function main() {
|
|
1239
|
-
checkNodeVersion();
|
|
1240
|
-
const argv = process.argv.slice(2);
|
|
1241
|
-
if (argv.length === 0) {
|
|
1242
|
-
await startForeground({});
|
|
1243
|
-
return;
|
|
1244
|
-
}
|
|
1245
|
-
const first = argv[0];
|
|
1246
|
-
if (first === "--version" || first === "-v") {
|
|
1247
|
-
console.log(pkg.version);
|
|
1248
|
-
return;
|
|
1249
|
-
}
|
|
1250
|
-
if (first === "--help" || first === "-h") {
|
|
1251
|
-
console.log(HELP);
|
|
1252
|
-
return;
|
|
1253
|
-
}
|
|
1254
|
-
if (first === "server") {
|
|
1255
|
-
await serverCmd(argv.slice(1));
|
|
1256
|
-
return;
|
|
1257
|
-
}
|
|
1258
|
-
// One-shot server with optional --port/--cwd/--data-dir overrides.
|
|
1259
|
-
const { opts, positionals } = parseFlags(argv);
|
|
1260
|
-
if (opts.help) {
|
|
1261
|
-
console.log(HELP);
|
|
1262
|
-
return;
|
|
1263
|
-
}
|
|
1264
|
-
if (positionals.length > 0)
|
|
1265
|
-
fail(`未知命令: ${positionals[0]}(--help 查看用法)`);
|
|
1266
|
-
await startForeground(opts);
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
main().catch((err) => {
|
|
1270
|
-
console.error(`✖ ${err instanceof Error ? err.message : String(err)}`);
|
|
1271
|
-
process.exit(1);
|
|
1272
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pi-web-ui CLI.
|
|
4
|
+
*
|
|
5
|
+
* pi-web-ui 启动生产服务器(前台,Ctrl+C 停止,自动打开浏览器)
|
|
6
|
+
* pi-web-ui --port 9000 --cwd /path 同上,覆盖端口 / 工作目录 / 数据目录
|
|
7
|
+
* pi-web-ui --no-browser 启动但不自动打开浏览器
|
|
8
|
+
* pi-web-ui --version | --help
|
|
9
|
+
* pi-web-ui server install [选项] 安装系统服务(开机自启)并启动
|
|
10
|
+
* pi-web-ui server shortcut [选项] 在桌面创建「一键启动」图标(启动服务并打开浏览器)
|
|
11
|
+
* pi-web-ui server uninstall [选项] 卸载系统服务(同时移除桌面图标)
|
|
12
|
+
* pi-web-ui server start|stop|restart|status [选项]
|
|
13
|
+
*
|
|
14
|
+
* 系统服务:
|
|
15
|
+
* - macOS → launchd 用户代理,label 默认 com.xingshuyin.pi-web-ui
|
|
16
|
+
* (--name 自定义时 com.<name>.server),无需 sudo
|
|
17
|
+
* - Linux → systemd 单元 <name>.service(/etc/systemd/system/,自动 sudo)
|
|
18
|
+
* - Windows → 计划任务(Task Scheduler / schtasks,登录后自启,无需管理员),
|
|
19
|
+
* 隐藏窗口启动(无黑窗);PowerShell 启动脚本与任务 XML 生成在
|
|
20
|
+
* %APPDATA%\pi-web-ui\
|
|
21
|
+
*
|
|
22
|
+
* 环境变量(前台与系统服务均适用):PORT / PI_WEB_CWD / PI_WEB_DATA_DIR /
|
|
23
|
+
* PI_CODING_AGENT_DIR。
|
|
24
|
+
*/
|
|
25
|
+
import { spawnSync } from "node:child_process";
|
|
26
|
+
import { get as httpGet } from "node:http";
|
|
27
|
+
import {
|
|
28
|
+
chmodSync,
|
|
29
|
+
copyFileSync,
|
|
30
|
+
existsSync,
|
|
31
|
+
mkdirSync,
|
|
32
|
+
realpathSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
rmSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
} from "node:fs";
|
|
37
|
+
import { homedir, userInfo } from "node:os";
|
|
38
|
+
import { dirname, join, resolve } from "node:path";
|
|
39
|
+
import { pathToFileURL } from "node:url";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
41
|
+
|
|
42
|
+
const BIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
/** <pkg>/dist/server/index.js — the actual server entry. */
|
|
44
|
+
const SERVER_ENTRY = join(BIN_DIR, "..", "dist", "server", "index.js");
|
|
45
|
+
const NODE = process.execPath;
|
|
46
|
+
let pkg = { version: "0.0.0" };
|
|
47
|
+
try {
|
|
48
|
+
pkg = JSON.parse(readFileSync(join(BIN_DIR, "..", "package.json"), "utf8"));
|
|
49
|
+
} catch {
|
|
50
|
+
// version is best-effort — the server itself doesn't need it
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const HELP = `pi-web-ui v${pkg.version} — web chat for the pi coding agent
|
|
54
|
+
|
|
55
|
+
用法:
|
|
56
|
+
pi-web-ui 启动服务器(前台,Ctrl+C 停止,自动打开浏览器)
|
|
57
|
+
pi-web-ui --port 9000 --cwd /path 启动并指定端口 / 工作目录 / 数据目录
|
|
58
|
+
pi-web-ui --no-browser 启动但不自动打开浏览器
|
|
59
|
+
pi-web-ui server install [选项] 安装系统服务(开机自启)并启动
|
|
60
|
+
pi-web-ui server shortcut [选项] 在桌面创建「一键启动」图标(启动服务并打开浏览器)
|
|
61
|
+
pi-web-ui server uninstall [选项] 卸载系统服务(同时移除桌面图标)
|
|
62
|
+
pi-web-ui server start|stop|restart|status [选项]
|
|
63
|
+
pi-web-ui --version / --help
|
|
64
|
+
|
|
65
|
+
server 选项:
|
|
66
|
+
--port <n> 端口(默认 8787,或 $PORT)
|
|
67
|
+
--cwd <dir> 工作目录(默认 $PI_WEB_CWD 或当前目录)
|
|
68
|
+
--data-dir <dir> 会话数据目录(默认 <cwd>/.pi-web)
|
|
69
|
+
--name <name> 服务名(默认 pi-web-ui;macOS 的 launchd label
|
|
70
|
+
为 com.xingshuyin.pi-web-ui,自定义名时为 com.<name>.server)
|
|
71
|
+
--print 只打印将生成的配置文件,不实际安装
|
|
72
|
+
|
|
73
|
+
平台: macOS → launchd 用户代理 · Linux → systemd · Windows → 计划任务(schtasks)
|
|
74
|
+
(Windows 任务登录后自启、无需管理员、隐藏窗口运行;stop 停止,uninstall 移除)
|
|
75
|
+
快捷方式: Windows → 桌面 .lnk · macOS → 桌面 .command 启动器 · Linux → 桌面 .desktop 图标
|
|
76
|
+
|
|
77
|
+
环境变量(前台与系统服务均适用):
|
|
78
|
+
PORT / PI_WEB_CWD / PI_WEB_DATA_DIR / PI_CODING_AGENT_DIR
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
/** Minimum Node required by the pi SDK (its dist uses `import … with { type: "json" }`). */
|
|
82
|
+
const NODE_MIN = [22, 19, 0];
|
|
83
|
+
function checkNodeVersion() {
|
|
84
|
+
const v = process.versions.node.split(".").map(Number);
|
|
85
|
+
const tooOld =
|
|
86
|
+
v[0] < NODE_MIN[0] ||
|
|
87
|
+
(v[0] === NODE_MIN[0] && v[1] < NODE_MIN[1]) ||
|
|
88
|
+
(v[0] === NODE_MIN[0] && v[1] === NODE_MIN[1] && v[2] < NODE_MIN[2]);
|
|
89
|
+
if (tooOld) {
|
|
90
|
+
console.error(
|
|
91
|
+
`✖ pi-web-ui 需要 Node.js >= ${NODE_MIN.join(".")}(当前 ${process.versions.node})。\n` +
|
|
92
|
+
` pi SDK 的代码使用了 import attributes(with)语法,旧版 Node 无法解析。\n` +
|
|
93
|
+
` 请升级 Node:https://nodejs.org(或 nvm-windows / fnm)后重装:npm i -g pi-web-ui`,
|
|
94
|
+
);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function fail(msg) {
|
|
100
|
+
console.error(`✖ ${msg}`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Run a command, inheriting stdio; exits on failure unless ignoreError. */
|
|
105
|
+
function run(cmd, args, { ignoreError = false, silent = false } = {}) {
|
|
106
|
+
const res = spawnSync(cmd, args, {
|
|
107
|
+
stdio: silent ? ["inherit", "ignore", "ignore"] : "inherit",
|
|
108
|
+
});
|
|
109
|
+
if (!ignoreError && res.status !== 0) process.exit(res.status ?? 1);
|
|
110
|
+
return res;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Parse --flag value / --flag=value options; returns { opts, positionals }. */
|
|
114
|
+
function parseFlags(argv) {
|
|
115
|
+
const opts = {
|
|
116
|
+
port: undefined,
|
|
117
|
+
cwd: undefined,
|
|
118
|
+
dataDir: undefined,
|
|
119
|
+
name: undefined,
|
|
120
|
+
print: false,
|
|
121
|
+
noBrowser: false,
|
|
122
|
+
help: false,
|
|
123
|
+
};
|
|
124
|
+
const positionals = [];
|
|
125
|
+
for (let i = 0; i < argv.length; i++) {
|
|
126
|
+
const a = argv[i];
|
|
127
|
+
const eq = a.indexOf("=");
|
|
128
|
+
const key = eq >= 0 ? a.slice(0, eq) : a;
|
|
129
|
+
const inline = eq >= 0 ? a.slice(eq + 1) : undefined;
|
|
130
|
+
const take = (flag) => {
|
|
131
|
+
if (inline !== undefined) return inline;
|
|
132
|
+
if (i + 1 < argv.length) {
|
|
133
|
+
i++;
|
|
134
|
+
return argv[i];
|
|
135
|
+
}
|
|
136
|
+
fail(`缺少选项 ${flag} 的值`);
|
|
137
|
+
};
|
|
138
|
+
switch (key) {
|
|
139
|
+
case "--port":
|
|
140
|
+
opts.port = take("--port");
|
|
141
|
+
break;
|
|
142
|
+
case "--cwd":
|
|
143
|
+
opts.cwd = take("--cwd");
|
|
144
|
+
break;
|
|
145
|
+
case "--data-dir":
|
|
146
|
+
opts.dataDir = take("--data-dir");
|
|
147
|
+
break;
|
|
148
|
+
case "--name":
|
|
149
|
+
opts.name = take("--name");
|
|
150
|
+
break;
|
|
151
|
+
case "--print":
|
|
152
|
+
opts.print = true;
|
|
153
|
+
break;
|
|
154
|
+
case "--no-browser":
|
|
155
|
+
opts.noBrowser = true;
|
|
156
|
+
break;
|
|
157
|
+
case "--help":
|
|
158
|
+
case "-h":
|
|
159
|
+
opts.help = true;
|
|
160
|
+
break;
|
|
161
|
+
default:
|
|
162
|
+
if (key.startsWith("-")) fail(`未知选项: ${key}`);
|
|
163
|
+
positionals.push(a);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { opts, positionals };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// 前台启动
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/** Open a URL in the OS default browser; failures are ignored (best-effort). */
|
|
174
|
+
function openBrowser(url) {
|
|
175
|
+
if (isMac) {
|
|
176
|
+
spawnSync("open", [url], { stdio: "ignore" });
|
|
177
|
+
} else if (isWin) {
|
|
178
|
+
spawnSync("cmd", ["/c", "start", "", url], { stdio: "ignore" });
|
|
179
|
+
} else {
|
|
180
|
+
spawnSync("xdg-open", [url], { stdio: "ignore" });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Poll `url` until the server answers (or ~15s pass), then open it in the
|
|
186
|
+
* default browser. The foreground server runs in this process, so the first
|
|
187
|
+
* HTTP response is the "listening" signal; if the server crashes meanwhile
|
|
188
|
+
* (e.g. port already taken), the pending timers die with the process and
|
|
189
|
+
* nothing is opened.
|
|
190
|
+
*/
|
|
191
|
+
function openBrowserWhenUp(url) {
|
|
192
|
+
const deadline = Date.now() + 15_000;
|
|
193
|
+
const attempt = () => {
|
|
194
|
+
const req = httpGet(url, (res) => {
|
|
195
|
+
res.resume();
|
|
196
|
+
console.log(` 🌐 已自动打开浏览器:${url}(--no-browser 可关闭)`);
|
|
197
|
+
openBrowser(url);
|
|
198
|
+
});
|
|
199
|
+
req.setTimeout(1000, () => {
|
|
200
|
+
req.destroy();
|
|
201
|
+
if (Date.now() < deadline) setTimeout(attempt, 150);
|
|
202
|
+
});
|
|
203
|
+
req.on("error", () => {
|
|
204
|
+
if (Date.now() < deadline) setTimeout(attempt, 150);
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
attempt();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function startForeground(opts) {
|
|
211
|
+
if (opts.port) process.env.PORT = opts.port;
|
|
212
|
+
if (opts.cwd) process.env.PI_WEB_CWD = resolve(opts.cwd);
|
|
213
|
+
if (opts.dataDir) process.env.PI_WEB_DATA_DIR = resolve(opts.dataDir);
|
|
214
|
+
const url = `http://localhost:${String(
|
|
215
|
+
opts.port ?? process.env.PORT ?? "8787",
|
|
216
|
+
)}`;
|
|
217
|
+
await import(pathToFileURL(SERVER_ENTRY).href);
|
|
218
|
+
if (!opts.noBrowser) openBrowserWhenUp(url);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// 系统服务管理
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
const isMac = process.platform === "darwin";
|
|
226
|
+
const isLinux = process.platform === "linux";
|
|
227
|
+
const isWin = process.platform === "win32";
|
|
228
|
+
|
|
229
|
+
function uid() {
|
|
230
|
+
try {
|
|
231
|
+
return userInfo().uid;
|
|
232
|
+
} catch {
|
|
233
|
+
return process.getuid?.() ?? 501;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** launchd label / systemd unit name / Windows task name for a service name. */
|
|
238
|
+
function serviceLabel(name) {
|
|
239
|
+
if (isMac) {
|
|
240
|
+
return name === "pi-web-ui"
|
|
241
|
+
? "com.xingshuyin.pi-web-ui"
|
|
242
|
+
: `com.${name}.server`;
|
|
243
|
+
}
|
|
244
|
+
return name;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function launchAgentPlist(name) {
|
|
248
|
+
return join(
|
|
249
|
+
homedir(),
|
|
250
|
+
"Library",
|
|
251
|
+
"LaunchAgents",
|
|
252
|
+
`${serviceLabel(name)}.plist`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function systemdUnitPath(name) {
|
|
257
|
+
return `/etc/systemd/system/${name}.service`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Windows: per-user config dir (%APPDATA%\pi-web-ui) holding the .cmd wrapper + task XML. */
|
|
261
|
+
function winServiceDir() {
|
|
262
|
+
return join(
|
|
263
|
+
process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
|
|
264
|
+
"pi-web-ui",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function winCmdPath(name) {
|
|
269
|
+
return join(winServiceDir(), `${name}.cmd`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function winPs1Path(name) {
|
|
273
|
+
return join(winServiceDir(), `${name}.ps1`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function winTaskXmlPath(name) {
|
|
277
|
+
return join(winServiceDir(), `${name}.xml`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function winLogPath() {
|
|
281
|
+
return join(homedir(), "pi-web-ui.log");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** True when a scheduled task with this name exists (schtasks exits 0). */
|
|
285
|
+
function winTaskExists(name) {
|
|
286
|
+
return (
|
|
287
|
+
spawnSync("schtasks", ["/Query", "/TN", name], { stdio: "ignore" })
|
|
288
|
+
.status === 0
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// 桌面快捷方式(server shortcut)
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
const SHORTCUT_LNK_NAME = "pi-web-ui.lnk"; // Windows 桌面快捷方式
|
|
297
|
+
const SHORTCUT_MAC_NAME = "pi-web-ui.command"; // macOS 双击启动器
|
|
298
|
+
const SHORTCUT_LINUX_NAME = "pi-web-ui.desktop"; // Linux 桌面图标
|
|
299
|
+
|
|
300
|
+
/** 快捷方式图标(品牌 .ico,随包发布;.lnk / .desktop 指向它)。 */
|
|
301
|
+
const APP_ICO_NAME = "pi-web-ui-logo.ico"; // 复制到用户目录后的稳定文件名(避开 pi-web-ui.ico —— Windows 对该路径有损坏的图标缓存残留,见 issue #xxx)
|
|
302
|
+
const APP_ICO_SOURCE = join(BIN_DIR, "..", "web", "public", "icon.ico"); // 包内品牌图标源文件(10 帧多分辨率,DPI 密度帧保证桌面/任务栏各尺寸颜色不失真)
|
|
303
|
+
/** Branded SVG logo (source of truth: web/public/favicon.svg) — used on Linux. */
|
|
304
|
+
const APP_SVG_PACKAGE = join(BIN_DIR, "..", "web", "public", "favicon.svg");
|
|
305
|
+
|
|
306
|
+
/** Windows: per-user copy of the branded .ico (stable path for the .lnk icon). */
|
|
307
|
+
function winIcoPath() {
|
|
308
|
+
return join(winServiceDir(), APP_ICO_NAME);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Full path to Windows PowerShell 5.1. */
|
|
312
|
+
function winPowershell() {
|
|
313
|
+
return join(
|
|
314
|
+
process.env.SystemRoot ?? "C:\\Windows",
|
|
315
|
+
"System32",
|
|
316
|
+
"WindowsPowerShell",
|
|
317
|
+
"v1.0",
|
|
318
|
+
"powershell.exe",
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Resolve the real node binary. fnm/volta/nvm shims (e.g. fnm_multishells)
|
|
324
|
+
* point into temp dirs that vanish when the installing shell exits — the
|
|
325
|
+
* baked-in launcher scripts must use the stable real path instead.
|
|
326
|
+
*/
|
|
327
|
+
function realNode() {
|
|
328
|
+
try {
|
|
329
|
+
return realpathSync(process.execPath);
|
|
330
|
+
} catch {
|
|
331
|
+
return process.execPath;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Windows: launcher ps1 the desktop .lnk runs (hidden). */
|
|
336
|
+
function winShortcutPs1Path(name) {
|
|
337
|
+
return join(winServiceDir(), `${name}-shortcut.ps1`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Windows: PID file of a shortcut-started (no scheduled task) instance. */
|
|
341
|
+
function winPidFilePath(name) {
|
|
342
|
+
return join(winServiceDir(), `${name}.pid`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Read the recorded PID of a shortcut-started Windows instance. */
|
|
346
|
+
function winReadPid(name) {
|
|
347
|
+
try {
|
|
348
|
+
const pid = Number(readFileSync(winPidFilePath(name), "utf8").trim());
|
|
349
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
350
|
+
} catch {
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** True when a PID exists (signal 0; EPERM means exists but not ours). */
|
|
356
|
+
function pidAlive(pid) {
|
|
357
|
+
if (!pid) return false;
|
|
358
|
+
try {
|
|
359
|
+
process.kill(pid, 0);
|
|
360
|
+
return true;
|
|
361
|
+
} catch (err) {
|
|
362
|
+
return err.code === "EPERM";
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Single-quote a string for embedding in a POSIX shell script. */
|
|
367
|
+
function shQuote(s) {
|
|
368
|
+
return "'" + s.replace(/'/g, `'\\''`) + "'";
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Windows shortcut launcher. Double-click the .lnk → this script runs hidden:
|
|
373
|
+
* server already up → just open the browser; scheduled task installed → start
|
|
374
|
+
* it (manageable via `server stop`); otherwise run the server in the foreground
|
|
375
|
+
* of this hidden window and record its PID so `server stop` / `server
|
|
376
|
+
* uninstall` can terminate it too.
|
|
377
|
+
*/
|
|
378
|
+
function buildWinShortcutPs1(env, cwd, taskName, url, logPath, pidPath) {
|
|
379
|
+
const sets = Object.entries(env)
|
|
380
|
+
.map(([k, v]) => `$env:${k} = ${psQuote(v)}`)
|
|
381
|
+
.join("\r\n");
|
|
382
|
+
const node = realNode();
|
|
383
|
+
return [
|
|
384
|
+
"# Generated by: pi-web-ui server shortcut (rerun to change)",
|
|
385
|
+
"# Runs hidden from the desktop shortcut: if the server is already up it",
|
|
386
|
+
"# opens the browser; a scheduled task (if installed) is used; otherwise",
|
|
387
|
+
"# the server runs in the foreground of this hidden window and its PID is",
|
|
388
|
+
"# recorded so `server stop` / `server uninstall` can terminate it.",
|
|
389
|
+
"$ErrorActionPreference = 'Continue'",
|
|
390
|
+
`$url = ${psQuote(url)}`,
|
|
391
|
+
`$health = ${psQuote(url + "/api/health")}`,
|
|
392
|
+
`$taskName = ${psQuote(taskName)}`,
|
|
393
|
+
`$pidFile = ${psQuote(pidPath)}`,
|
|
394
|
+
`$log = ${psQuote(logPath)}`,
|
|
395
|
+
"",
|
|
396
|
+
"function Test-Up {",
|
|
397
|
+
" try { return (Invoke-WebRequest -Uri $health -UseBasicParsing -TimeoutSec 2).StatusCode -eq 200 } catch { return $false }",
|
|
398
|
+
"}",
|
|
399
|
+
"function Open-Browser { Start-Process $url | Out-Null }",
|
|
400
|
+
"",
|
|
401
|
+
"# 已在运行 → 直接打开浏览器",
|
|
402
|
+
"if (Test-Up) { Open-Browser; exit 0 }",
|
|
403
|
+
"",
|
|
404
|
+
"# 已安装计划任务 → 走服务启动(server stop 可管理)",
|
|
405
|
+
"if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {",
|
|
406
|
+
" schtasks /Run /TN $taskName | Out-Null",
|
|
407
|
+
" for ($i = 0; $i -lt 120; $i++) {",
|
|
408
|
+
" Start-Sleep -Milliseconds 250",
|
|
409
|
+
" if (Test-Up) { Open-Browser; exit 0 }",
|
|
410
|
+
" }",
|
|
411
|
+
" Write-Host ('✖ pi-web-ui 服务未在 30 秒内就绪,请查看日志: ' + $log)",
|
|
412
|
+
" exit 1",
|
|
413
|
+
"}",
|
|
414
|
+
"",
|
|
415
|
+
"# 未安装服务 → 在本隐藏窗口中前台运行(记录 PID,server stop 可停止)",
|
|
416
|
+
`$PID | Out-File -Encoding ascii $pidFile`,
|
|
417
|
+
sets,
|
|
418
|
+
`Set-Location ${psQuote(cwd)}`,
|
|
419
|
+
"# 后台轮询,就绪后打开浏览器(与前台 node 并行)",
|
|
420
|
+
"$job = Start-Job -ScriptBlock { param($u)",
|
|
421
|
+
" $h = $u + '/api/health'",
|
|
422
|
+
" for ($i = 0; $i -lt 120; $i++) {",
|
|
423
|
+
" Start-Sleep -Milliseconds 250",
|
|
424
|
+
" try { if ((Invoke-WebRequest -Uri $h -UseBasicParsing -TimeoutSec 2).StatusCode -eq 200) { Start-Process $u | Out-Null; break } } catch {}",
|
|
425
|
+
" }",
|
|
426
|
+
"} -ArgumentList $url",
|
|
427
|
+
`& ${psQuote(node)} ${psQuote(SERVER_ENTRY)} *>> $log`,
|
|
428
|
+
"Remove-Item $pidFile -ErrorAction SilentlyContinue",
|
|
429
|
+
"",
|
|
430
|
+
].join("\r\n");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Create the Windows desktop .lnk via WScript.Shell COM (correct Desktop path
|
|
435
|
+
* even with OneDrive redirection). Target is powershell.exe with
|
|
436
|
+
* -WindowStyle Hidden so nothing flashes on double-click.
|
|
437
|
+
*/
|
|
438
|
+
function installWinShortcut(opts) {
|
|
439
|
+
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
440
|
+
const env = serviceEnv(port, cwd, dataDir);
|
|
441
|
+
const url = `http://localhost:${port}`;
|
|
442
|
+
const ps1Path = winShortcutPs1Path(name);
|
|
443
|
+
const ps1 = buildWinShortcutPs1(
|
|
444
|
+
env,
|
|
445
|
+
cwd,
|
|
446
|
+
name,
|
|
447
|
+
url,
|
|
448
|
+
winLogPath(),
|
|
449
|
+
winPidFilePath(name),
|
|
450
|
+
);
|
|
451
|
+
if (opts.print) {
|
|
452
|
+
console.log(`# ${ps1Path}\n${ps1}`);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
mkdirSync(dirname(ps1Path), { recursive: true });
|
|
456
|
+
writeFileSync(ps1Path, "\uFEFF" + ps1, "utf8"); // PS 5.1 需要 BOM
|
|
457
|
+
// 把品牌图标准备好:复制到用户目录(.lnk 图标指向稳定路径)
|
|
458
|
+
if (existsSync(APP_ICO_SOURCE)) {
|
|
459
|
+
copyFileSync(APP_ICO_SOURCE, winIcoPath());
|
|
460
|
+
} else {
|
|
461
|
+
console.log(`⚠ 未找到品牌图标 ${APP_ICO_SOURCE},快捷方式将使用默认图标`);
|
|
462
|
+
}
|
|
463
|
+
const powershell = winPowershell();
|
|
464
|
+
const ps = [
|
|
465
|
+
"$ErrorActionPreference = 'Stop'",
|
|
466
|
+
"$ws = New-Object -ComObject WScript.Shell",
|
|
467
|
+
"$desktop = [Environment]::GetFolderPath('Desktop')",
|
|
468
|
+
`$lnk = $ws.CreateShortcut((Join-Path $desktop ${psQuote(SHORTCUT_LNK_NAME)}))`,
|
|
469
|
+
`$lnk.TargetPath = ${psQuote(powershell)}`,
|
|
470
|
+
`$lnk.Arguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File ' + ${psQuote('"' + ps1Path + '"')}`,
|
|
471
|
+
`$lnk.WorkingDirectory = ${psQuote(cwd)}`,
|
|
472
|
+
"$lnk.Description = 'pi-web-ui — 双击启动服务并打开浏览器'",
|
|
473
|
+
`$lnk.IconLocation = ${psQuote(winIcoPath())} + ',0'`,
|
|
474
|
+
"$lnk.Save()",
|
|
475
|
+
`Write-Output (Join-Path $desktop ${psQuote(SHORTCUT_LNK_NAME)})`,
|
|
476
|
+
].join("\r\n");
|
|
477
|
+
const res = spawnSync(
|
|
478
|
+
powershell,
|
|
479
|
+
[
|
|
480
|
+
"-NoProfile",
|
|
481
|
+
"-NonInteractive",
|
|
482
|
+
"-ExecutionPolicy",
|
|
483
|
+
"Bypass",
|
|
484
|
+
"-Command",
|
|
485
|
+
ps,
|
|
486
|
+
],
|
|
487
|
+
{ encoding: "utf8" },
|
|
488
|
+
);
|
|
489
|
+
if (res.status !== 0) {
|
|
490
|
+
fail(`创建桌面快捷方式失败: ${(res.stderr || res.stdout || "").trim()}`);
|
|
491
|
+
}
|
|
492
|
+
const lnk = (res.stdout ?? "").trim();
|
|
493
|
+
console.log(`✅ 已创建桌面快捷方式: ${lnk}`);
|
|
494
|
+
console.log(` 双击 : 服务未运行则启动(隐藏窗口,无黑窗),就绪后自动打开浏览器`);
|
|
495
|
+
console.log(` 停止 : pi-web-ui server stop(快捷方式启动的实例也会一并停止)`);
|
|
496
|
+
console.log(` 端口 : ${port}`);
|
|
497
|
+
console.log(` 目录 : ${cwd}`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** macOS: double-clickable .command launcher (the .lnk equivalent). */
|
|
501
|
+
function buildMacShortcut(label, plist, url, env) {
|
|
502
|
+
const exports = Object.entries(env)
|
|
503
|
+
.map(([k, v]) => `export ${k}=${shQuote(v)}`)
|
|
504
|
+
.join("\n");
|
|
505
|
+
const node = realNode();
|
|
506
|
+
return `#!/bin/bash
|
|
507
|
+
# pi-web-ui 启动器 — generated by: pi-web-ui server shortcut
|
|
508
|
+
# 双击运行:确保服务在运行,然后打开浏览器。
|
|
509
|
+
# · 已安装 launchd 服务(登录自启)→ kickstart,图标主要用于「启动 + 打开」
|
|
510
|
+
# · 未安装服务 → 在本终端前台运行(关闭窗口即停止)
|
|
511
|
+
LABEL=${shQuote(label)}
|
|
512
|
+
PLIST=${shQuote(plist)}
|
|
513
|
+
URL=${shQuote(url)}
|
|
514
|
+
LOG=/tmp/pi-web-ui-shortcut.log
|
|
515
|
+
NODE=${shQuote(node)}
|
|
516
|
+
ENTRY=${shQuote(SERVER_ENTRY)}
|
|
517
|
+
${exports}
|
|
518
|
+
|
|
519
|
+
if launchctl print "gui/$(id -u)/$LABEL" >/dev/null 2>&1; then
|
|
520
|
+
launchctl kickstart "gui/$(id -u)/$LABEL"
|
|
521
|
+
elif [ -f "$PLIST" ]; then
|
|
522
|
+
launchctl bootstrap "gui/$(id -u)" "$PLIST"
|
|
523
|
+
else
|
|
524
|
+
# 未安装服务:在本终端前台运行服务器(关闭窗口即停止)
|
|
525
|
+
"$NODE" "$ENTRY" >>"$LOG" 2>&1 &
|
|
526
|
+
SERVER_PID=$!
|
|
527
|
+
trap 'kill "$SERVER_PID" 2>/dev/null' EXIT
|
|
528
|
+
fi
|
|
529
|
+
|
|
530
|
+
# 等服务就绪后打开浏览器(最多约 30 秒)
|
|
531
|
+
for i in $(seq 1 120); do
|
|
532
|
+
curl -sf "$URL/api/health" >/dev/null 2>&1 && break
|
|
533
|
+
sleep 0.25
|
|
534
|
+
done
|
|
535
|
+
open "$URL"
|
|
536
|
+
if [ -n "\${SERVER_PID:-}" ]; then wait "$SERVER_PID"; fi
|
|
537
|
+
`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function installMacShortcut(opts) {
|
|
541
|
+
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
542
|
+
const url = `http://localhost:${port}`;
|
|
543
|
+
const script = buildMacShortcut(
|
|
544
|
+
serviceLabel(name),
|
|
545
|
+
launchAgentPlist(name),
|
|
546
|
+
url,
|
|
547
|
+
serviceEnv(port, cwd, dataDir),
|
|
548
|
+
);
|
|
549
|
+
const path = join(homedir(), "Desktop", SHORTCUT_MAC_NAME);
|
|
550
|
+
if (opts.print) {
|
|
551
|
+
console.log(`# ${path}\n${script}`);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
writeFileSync(path, script);
|
|
555
|
+
chmodSync(path, 0o755);
|
|
556
|
+
console.log(`✅ 已创建桌面启动器: ${path}`);
|
|
557
|
+
console.log(` 双击 : 确保服务运行并打开浏览器;未安装服务时在本终端前台运行`);
|
|
558
|
+
console.log(` 说明 : macOS 没有 Windows 式快捷方式,这是等价的 .command 启动器;`);
|
|
559
|
+
console.log(` launchd 服务登录自启,图标主要用于快速「启动 + 打开浏览器」`);
|
|
560
|
+
console.log(` 端口 : ${port}`);
|
|
561
|
+
console.log(` 目录 : ${cwd}`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Linux: launcher script run by the .desktop icon. */
|
|
565
|
+
function buildLinuxStartScript(unitName, url) {
|
|
566
|
+
const log = join(homedir(), ".local", "share", "pi-web-ui", "pi-web-ui.log");
|
|
567
|
+
const node = realNode();
|
|
568
|
+
return `#!/bin/bash
|
|
569
|
+
# pi-web-ui 启动器 — generated by: pi-web-ui server shortcut
|
|
570
|
+
# 双击运行:确保服务在运行,然后打开浏览器。
|
|
571
|
+
# · systemd 单元已安装 → systemctl start(系统单元需要授权,失败则前台运行)
|
|
572
|
+
# · 未安装 → 在本进程前台运行(终端关闭即停止)
|
|
573
|
+
LOG=${shQuote(log)}
|
|
574
|
+
URL=${shQuote(url)}
|
|
575
|
+
NODE=${shQuote(node)}
|
|
576
|
+
ENTRY=${shQuote(SERVER_ENTRY)}
|
|
577
|
+
UNIT=${shQuote(unitName)}.service
|
|
578
|
+
|
|
579
|
+
if ! curl -sf "$URL/api/health" >/dev/null 2>&1; then
|
|
580
|
+
systemctl start "$UNIT" 2>/dev/null || true
|
|
581
|
+
if ! curl -sf "$URL/api/health" >/dev/null 2>&1; then
|
|
582
|
+
mkdir -p "$(dirname "$LOG")"
|
|
583
|
+
"$NODE" "$ENTRY" >>"$LOG" 2>&1 &
|
|
584
|
+
SERVER_PID=$!
|
|
585
|
+
trap 'kill "$SERVER_PID" 2>/dev/null' EXIT
|
|
586
|
+
fi
|
|
587
|
+
fi
|
|
588
|
+
|
|
589
|
+
for i in $(seq 1 120); do
|
|
590
|
+
curl -sf "$URL/api/health" >/dev/null 2>&1 && break
|
|
591
|
+
sleep 0.25
|
|
592
|
+
done
|
|
593
|
+
xdg-open "$URL" >/dev/null 2>&1 &
|
|
594
|
+
if [ -n "\${SERVER_PID:-}" ]; then wait "$SERVER_PID"; fi
|
|
595
|
+
`;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function installLinuxShortcut(opts) {
|
|
599
|
+
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
600
|
+
const url = `http://localhost:${port}`;
|
|
601
|
+
const scriptDir = join(homedir(), ".local", "share", "pi-web-ui");
|
|
602
|
+
const scriptPath = join(scriptDir, `${name}-start.sh`);
|
|
603
|
+
const desktopPath = join(homedir(), "Desktop", SHORTCUT_LINUX_NAME);
|
|
604
|
+
const icoPath = join(scriptDir, APP_ICO_NAME); // 备用;优先 SVG
|
|
605
|
+
const svgPath = join(scriptDir, "pi-web-ui.svg");
|
|
606
|
+
const script = buildLinuxStartScript(name, url);
|
|
607
|
+
const desktopIcon = existsSync(APP_SVG_PACKAGE) ? svgPath : APP_ICO_NAME;
|
|
608
|
+
const desktop = `[Desktop Entry]
|
|
609
|
+
Version=1.0
|
|
610
|
+
Type=Application
|
|
611
|
+
Name=pi-web-ui
|
|
612
|
+
Comment=启动 pi-web-ui 服务并打开浏览器
|
|
613
|
+
Exec=${shQuote(scriptPath)}
|
|
614
|
+
Icon=${shQuote(desktopIcon)}
|
|
615
|
+
Terminal=false
|
|
616
|
+
Categories=Network;WebBrowser;
|
|
617
|
+
`;
|
|
618
|
+
if (opts.print) {
|
|
619
|
+
console.log(`# ${scriptPath}\n${script}`);
|
|
620
|
+
console.log(`# ${desktopPath}\n${desktop}`);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
mkdirSync(scriptDir, { recursive: true });
|
|
624
|
+
// 品牌图标(缺失时跳过,桌面自动回退默认图标)
|
|
625
|
+
if (existsSync(APP_SVG_PACKAGE)) copyFileSync(APP_SVG_PACKAGE, svgPath);
|
|
626
|
+
else if (existsSync(APP_ICO_SOURCE)) copyFileSync(APP_ICO_SOURCE, icoPath);
|
|
627
|
+
writeFileSync(scriptPath, script);
|
|
628
|
+
chmodSync(scriptPath, 0o755);
|
|
629
|
+
writeFileSync(desktopPath, desktop);
|
|
630
|
+
chmodSync(desktopPath, 0o755);
|
|
631
|
+
// GNOME 需要标记可信才能双击运行
|
|
632
|
+
run("gio", ["set", desktopPath, "metadata::trusted", "true"], {
|
|
633
|
+
ignoreError: true,
|
|
634
|
+
silent: true,
|
|
635
|
+
});
|
|
636
|
+
console.log(`✅ 已创建桌面图标: ${desktopPath}`);
|
|
637
|
+
console.log(` GNOME 若提示「不受信任的应用程序」,右键选择 Allow Launching`);
|
|
638
|
+
console.log(` 端口 : ${port}`);
|
|
639
|
+
console.log(` 目录 : ${cwd}`);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Remove desktop shortcut artifacts created by `server shortcut`. */
|
|
643
|
+
function removeShortcut(name) {
|
|
644
|
+
if (isWin) {
|
|
645
|
+
for (const f of [winShortcutPs1Path(name), winPidFilePath(name), winIcoPath()]) {
|
|
646
|
+
if (existsSync(f)) rmSync(f);
|
|
647
|
+
}
|
|
648
|
+
spawnSync(
|
|
649
|
+
winPowershell(),
|
|
650
|
+
[
|
|
651
|
+
"-NoProfile",
|
|
652
|
+
"-NonInteractive",
|
|
653
|
+
"-ExecutionPolicy",
|
|
654
|
+
"Bypass",
|
|
655
|
+
"-Command",
|
|
656
|
+
`$d=[Environment]::GetFolderPath('Desktop');$p=Join-Path $d ${psQuote(SHORTCUT_LNK_NAME)};if(Test-Path $p){Remove-Item $p -Force}`,
|
|
657
|
+
],
|
|
658
|
+
{ stdio: "ignore" },
|
|
659
|
+
);
|
|
660
|
+
} else if (isMac) {
|
|
661
|
+
const p = join(homedir(), "Desktop", SHORTCUT_MAC_NAME);
|
|
662
|
+
if (existsSync(p)) rmSync(p);
|
|
663
|
+
} else if (isLinux) {
|
|
664
|
+
const p = join(homedir(), "Desktop", SHORTCUT_LINUX_NAME);
|
|
665
|
+
if (existsSync(p)) rmSync(p);
|
|
666
|
+
rmSync(join(homedir(), ".local", "share", "pi-web-ui"), {
|
|
667
|
+
recursive: true,
|
|
668
|
+
force: true,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Single-quote a string for embedding in a generated PowerShell script. */
|
|
674
|
+
function psQuote(s) {
|
|
675
|
+
return "'" + s.replace(/'/g, "''") + "'";
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Build the PowerShell launcher the scheduled task runs. Task Scheduler
|
|
680
|
+
* launches it with -WindowStyle Hidden, so the console window is created
|
|
681
|
+
* hidden (SW_HIDE) — no black cmd window stays open while the server runs,
|
|
682
|
+
* and there is nothing to accidentally close/kill. The script sets the env,
|
|
683
|
+
* cd's to the workspace, then runs node in the foreground with output
|
|
684
|
+
* appended to the log, so the task instance IS the powershell process and
|
|
685
|
+
* `schtasks /End` still stops the whole tree.
|
|
686
|
+
*/
|
|
687
|
+
function buildWinStartPs1(env, cwd, logPath) {
|
|
688
|
+
const sets = Object.entries(env)
|
|
689
|
+
.map(([k, v]) => `$env:${k} = ${psQuote(v)}`)
|
|
690
|
+
.join("\r\n");
|
|
691
|
+
return [
|
|
692
|
+
"# Generated by: pi-web-ui server install (rerun to change)",
|
|
693
|
+
"# Starts the server with a hidden console window (no black cmd box).",
|
|
694
|
+
sets,
|
|
695
|
+
`Set-Location ${psQuote(cwd)}`,
|
|
696
|
+
`& ${psQuote(NODE)} ${psQuote(SERVER_ENTRY)} *>> ${psQuote(logPath)}`,
|
|
697
|
+
"",
|
|
698
|
+
].join("\r\n");
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Build the Task Scheduler XML for a user task: LogonTrigger (starts at logon,
|
|
703
|
+
* like a launchd agent — no admin needed), InteractiveToken, auto-restart on
|
|
704
|
+
* failure. The task runs the PowerShell launcher via
|
|
705
|
+
* powershell.exe -WindowStyle Hidden, so no console window ever appears.
|
|
706
|
+
*/
|
|
707
|
+
function buildWinTaskXml(ps1Path, cwd) {
|
|
708
|
+
const powershell = winPowershell();
|
|
709
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
710
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
711
|
+
<RegistrationInfo>
|
|
712
|
+
<Description>pi-web-ui — web chat for the pi coding agent (auto-start at logon)</Description>
|
|
713
|
+
</RegistrationInfo>
|
|
714
|
+
<Triggers>
|
|
715
|
+
<LogonTrigger>
|
|
716
|
+
<Enabled>true</Enabled>
|
|
717
|
+
</LogonTrigger>
|
|
718
|
+
</Triggers>
|
|
719
|
+
<Principals>
|
|
720
|
+
<Principal id="Author">
|
|
721
|
+
<LogonType>InteractiveToken</LogonType>
|
|
722
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
723
|
+
</Principal>
|
|
724
|
+
</Principals>
|
|
725
|
+
<Settings>
|
|
726
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
727
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
728
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
729
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
730
|
+
<StartWhenAvailable>false</StartWhenAvailable>
|
|
731
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
732
|
+
<IdleSettings>
|
|
733
|
+
<StopOnIdleEnd>false</StopOnIdleEnd>
|
|
734
|
+
<RestartOnIdle>false</RestartOnIdle>
|
|
735
|
+
</IdleSettings>
|
|
736
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
737
|
+
<Enabled>true</Enabled>
|
|
738
|
+
<Hidden>false</Hidden>
|
|
739
|
+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
740
|
+
<WakeToRun>false</WakeToRun>
|
|
741
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
742
|
+
<Priority>7</Priority>
|
|
743
|
+
<RestartOnFailure>
|
|
744
|
+
<Interval>PT1M</Interval>
|
|
745
|
+
<Count>3</Count>
|
|
746
|
+
</RestartOnFailure>
|
|
747
|
+
</Settings>
|
|
748
|
+
<Actions Context="Author">
|
|
749
|
+
<Exec>
|
|
750
|
+
<Command>${esc(powershell)}</Command>
|
|
751
|
+
<Arguments>-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "${esc(ps1Path)}"</Arguments>
|
|
752
|
+
<WorkingDirectory>${esc(cwd)}</WorkingDirectory>
|
|
753
|
+
</Exec>
|
|
754
|
+
</Actions>
|
|
755
|
+
</Task>
|
|
756
|
+
`;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function esc(s) {
|
|
760
|
+
return s
|
|
761
|
+
.replace(/&/g, "&")
|
|
762
|
+
.replace(/</g, "<")
|
|
763
|
+
.replace(/>/g, ">")
|
|
764
|
+
.replace(/"/g, """);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** Build the launchd plist XML. */
|
|
768
|
+
function buildPlist(label, cwd, env) {
|
|
769
|
+
const entries = Object.entries(env)
|
|
770
|
+
.map(([k, v]) => ` <key>${esc(k)}</key>\n <string>${esc(v)}</string>`)
|
|
771
|
+
.join("\n");
|
|
772
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
773
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
774
|
+
<!-- Generated by: pi-web-ui server install (do not edit by hand — rerun to change) -->
|
|
775
|
+
<plist version="1.0">
|
|
776
|
+
<dict>
|
|
777
|
+
<key>Label</key>
|
|
778
|
+
<string>${esc(label)}</string>
|
|
779
|
+
|
|
780
|
+
<key>ProgramArguments</key>
|
|
781
|
+
<array>
|
|
782
|
+
<string>${esc(NODE)}</string>
|
|
783
|
+
<string>${esc(SERVER_ENTRY)}</string>
|
|
784
|
+
</array>
|
|
785
|
+
|
|
786
|
+
<key>RunAtLoad</key>
|
|
787
|
+
<true/>
|
|
788
|
+
|
|
789
|
+
<!-- Restart if it crashes -->
|
|
790
|
+
<key>KeepAlive</key>
|
|
791
|
+
<true/>
|
|
792
|
+
|
|
793
|
+
<key>WorkingDirectory</key>
|
|
794
|
+
<string>${esc(cwd)}</string>
|
|
795
|
+
|
|
796
|
+
<key>EnvironmentVariables</key>
|
|
797
|
+
<dict>
|
|
798
|
+
${entries}
|
|
799
|
+
</dict>
|
|
800
|
+
|
|
801
|
+
<key>StandardOutPath</key>
|
|
802
|
+
<string>/tmp/pi-web-ui.log</string>
|
|
803
|
+
<key>StandardErrorPath</key>
|
|
804
|
+
<string>/tmp/pi-web-ui.err</string>
|
|
805
|
+
</dict>
|
|
806
|
+
</plist>
|
|
807
|
+
`;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** Build the systemd unit file. */
|
|
811
|
+
function buildUnit(cwd, env) {
|
|
812
|
+
const envLines = Object.entries(env)
|
|
813
|
+
.map(([k, v]) => `Environment=${k}=${v}`)
|
|
814
|
+
.join("\n");
|
|
815
|
+
return `# Generated by: pi-web-ui server install (do not edit by hand — rerun to change)
|
|
816
|
+
[Unit]
|
|
817
|
+
Description=pi-web-ui — web chat for the pi coding agent
|
|
818
|
+
After=network.target
|
|
819
|
+
|
|
820
|
+
[Service]
|
|
821
|
+
Type=simple
|
|
822
|
+
User=${process.env.SUDO_USER ?? userInfo().username}
|
|
823
|
+
WorkingDirectory=${cwd}
|
|
824
|
+
${envLines}
|
|
825
|
+
ExecStart=${JSON.stringify(NODE)} ${JSON.stringify(SERVER_ENTRY)}
|
|
826
|
+
Restart=always
|
|
827
|
+
RestartSec=5
|
|
828
|
+
|
|
829
|
+
[Install]
|
|
830
|
+
WantedBy=multi-user.target
|
|
831
|
+
`;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** If not root on Linux, re-exec the same server command through sudo. */
|
|
835
|
+
function ensureRootForSystemctl() {
|
|
836
|
+
if (typeof process.getuid === "function" && process.getuid() === 0) return;
|
|
837
|
+
// process.argv = [node, <bin>, "server", <action>, ...rest] — forward
|
|
838
|
+
// everything after "server" so flags like --port/--cwd survive.
|
|
839
|
+
const res = spawnSync(
|
|
840
|
+
"sudo",
|
|
841
|
+
[NODE, fileURLToPath(import.meta.url), "server", ...process.argv.slice(3)],
|
|
842
|
+
{ stdio: "inherit" },
|
|
843
|
+
);
|
|
844
|
+
process.exit(res.status ?? 1);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Shared option normalization for install. */
|
|
848
|
+
function serviceOptions(opts) {
|
|
849
|
+
const name = opts.name ?? "pi-web-ui";
|
|
850
|
+
const port = String(opts.port ?? process.env.PORT ?? "8787");
|
|
851
|
+
if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
|
|
852
|
+
fail(`无效端口: ${port}`);
|
|
853
|
+
}
|
|
854
|
+
const cwd = resolve(opts.cwd ?? process.env.PI_WEB_CWD ?? process.cwd());
|
|
855
|
+
if (!existsSync(cwd)) fail(`工作目录不存在: ${cwd}`);
|
|
856
|
+
let dataDir;
|
|
857
|
+
if (opts.dataDir) {
|
|
858
|
+
dataDir = resolve(opts.dataDir);
|
|
859
|
+
} else if (process.env.PI_WEB_DATA_DIR) {
|
|
860
|
+
dataDir = resolve(process.env.PI_WEB_DATA_DIR);
|
|
861
|
+
}
|
|
862
|
+
return { name, port, cwd, dataDir };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function serviceEnv(port, cwd, dataDir) {
|
|
866
|
+
const env = {
|
|
867
|
+
PORT: port,
|
|
868
|
+
PI_WEB_CWD: cwd,
|
|
869
|
+
};
|
|
870
|
+
// Interactive Windows tasks inherit the user's PATH; only systemd/launchd
|
|
871
|
+
// run with a minimal environment that needs an explicit PATH.
|
|
872
|
+
if (!isWin) env.PATH = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
873
|
+
// Same for the locale: launchd/systemd drop LANG/LC_ALL, and a C-locale
|
|
874
|
+
// shell garbles multibyte input in the terminal (UTF-8 continuation
|
|
875
|
+
// bytes 0x80–0x9F rendered as C1 control chars). Bake the installing
|
|
876
|
+
// shell's locale into the service env so spawned terminals are UTF-8.
|
|
877
|
+
if (!isWin && process.env.LANG) env.LANG = process.env.LANG;
|
|
878
|
+
if (!isWin && process.env.LC_ALL) env.LC_ALL = process.env.LC_ALL;
|
|
879
|
+
if (dataDir) env.PI_WEB_DATA_DIR = dataDir;
|
|
880
|
+
return env;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function installLaunchd(opts) {
|
|
884
|
+
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
885
|
+
const label = serviceLabel(name);
|
|
886
|
+
const plist = launchAgentPlist(name);
|
|
887
|
+
const content = buildPlist(label, cwd, serviceEnv(port, cwd, dataDir));
|
|
888
|
+
if (opts.print) {
|
|
889
|
+
console.log(`# ${plist}\n${content}`);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
// Unload any existing instance (ignore "not loaded"), then (re)install.
|
|
893
|
+
run("launchctl", ["bootout", `gui/${uid()}/${label}`], {
|
|
894
|
+
ignoreError: true,
|
|
895
|
+
silent: true,
|
|
896
|
+
});
|
|
897
|
+
mkdirSync(dirname(plist), { recursive: true });
|
|
898
|
+
writeFileSync(plist, content);
|
|
899
|
+
run("launchctl", ["bootstrap", `gui/${uid()}`, plist]);
|
|
900
|
+
console.log(`✅ 已安装并启动 launchd 服务 ${label}`);
|
|
901
|
+
console.log(` 端口 : ${port}`);
|
|
902
|
+
console.log(` 目录 : ${cwd}`);
|
|
903
|
+
console.log(` 访问 : http://localhost:${port}`);
|
|
904
|
+
console.log(` 日志 : /tmp/pi-web-ui.log /tmp/pi-web-ui.err`);
|
|
905
|
+
console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
|
|
906
|
+
console.log(` 提示 : pi-web-ui server shortcut 可在桌面创建「一键启动」图标`);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function installSystemd(opts) {
|
|
910
|
+
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
911
|
+
const content = buildUnit(cwd, serviceEnv(port, cwd, dataDir));
|
|
912
|
+
const unitPath = systemdUnitPath(name);
|
|
913
|
+
if (opts.print) {
|
|
914
|
+
console.log(`# ${unitPath}\n${content}`);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
ensureRootForSystemctl();
|
|
918
|
+
writeFileSync(unitPath, content);
|
|
919
|
+
run("systemctl", ["daemon-reload"]);
|
|
920
|
+
run("systemctl", ["enable", "--now", `${name}.service`]);
|
|
921
|
+
console.log(`✅ 已安装并启动 systemd 服务 ${name}.service`);
|
|
922
|
+
console.log(` 端口 : ${port}`);
|
|
923
|
+
console.log(` 目录 : ${cwd}`);
|
|
924
|
+
console.log(` 访问 : http://localhost:${port}`);
|
|
925
|
+
console.log(` 日志 : journalctl -u ${name}.service -f`);
|
|
926
|
+
console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
|
|
927
|
+
console.log(` 提示 : pi-web-ui server shortcut 可在桌面创建「一键启动」图标`);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function uninstallLaunchd(opts) {
|
|
931
|
+
const name = opts.name ?? "pi-web-ui";
|
|
932
|
+
const label = serviceLabel(name);
|
|
933
|
+
const plist = launchAgentPlist(name);
|
|
934
|
+
run("launchctl", ["bootout", `gui/${uid()}/${label}`], {
|
|
935
|
+
ignoreError: true,
|
|
936
|
+
silent: true,
|
|
937
|
+
});
|
|
938
|
+
if (existsSync(plist)) rmSync(plist);
|
|
939
|
+
removeShortcut(name);
|
|
940
|
+
console.log(`🗑 已卸载 ${label}(plist 已删除,不再开机自启)`);
|
|
941
|
+
console.log(`🗑 已移除桌面快捷方式`);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function uninstallSystemd(opts) {
|
|
945
|
+
const name = opts.name ?? "pi-web-ui";
|
|
946
|
+
ensureRootForSystemctl();
|
|
947
|
+
run("systemctl", ["disable", "--now", `${name}.service`], {
|
|
948
|
+
ignoreError: true,
|
|
949
|
+
});
|
|
950
|
+
const unitPath = systemdUnitPath(name);
|
|
951
|
+
if (existsSync(unitPath)) rmSync(unitPath);
|
|
952
|
+
run("systemctl", ["daemon-reload"]);
|
|
953
|
+
removeShortcut(name);
|
|
954
|
+
console.log(`🗑 已卸载 ${name}.service(不再开机自启)`);
|
|
955
|
+
console.log(`🗑 已移除桌面快捷方式`);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function installWindows(opts) {
|
|
959
|
+
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
960
|
+
const env = serviceEnv(port, cwd, dataDir);
|
|
961
|
+
const ps1Path = winPs1Path(name);
|
|
962
|
+
const xmlPath = winTaskXmlPath(name);
|
|
963
|
+
const ps1 = buildWinStartPs1(env, cwd, winLogPath());
|
|
964
|
+
const xml = buildWinTaskXml(ps1Path, cwd);
|
|
965
|
+
if (opts.print) {
|
|
966
|
+
console.log(`# ${ps1Path}\n${ps1}`);
|
|
967
|
+
console.log(`# ${xmlPath}\n${xml}`);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
mkdirSync(dirname(ps1Path), { recursive: true });
|
|
971
|
+
// UTF-8 with BOM: Windows PowerShell 5.1 misreads BOM-less UTF-8 as ANSI.
|
|
972
|
+
writeFileSync(ps1Path, "\uFEFF" + ps1, "utf8");
|
|
973
|
+
// Remove the old-style .cmd wrapper from previous installs.
|
|
974
|
+
if (existsSync(winCmdPath(name))) rmSync(winCmdPath(name));
|
|
975
|
+
// schtasks /Create /XML requires a UTF-16 file (with BOM).
|
|
976
|
+
writeFileSync(xmlPath, "\uFEFF" + xml, "utf16le");
|
|
977
|
+
if (winTaskExists(name)) {
|
|
978
|
+
run("schtasks", ["/Delete", "/TN", name, "/F"], {
|
|
979
|
+
ignoreError: true,
|
|
980
|
+
silent: true,
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
run("schtasks", ["/Create", "/TN", name, "/XML", xmlPath, "/F"]);
|
|
984
|
+
run("schtasks", ["/Run", "/TN", name], { ignoreError: true });
|
|
985
|
+
console.log(`✅ 已安装并启动计划任务 ${name}(隐藏窗口运行,无黑窗)`);
|
|
986
|
+
console.log(` 端口 : ${port}`);
|
|
987
|
+
console.log(` 目录 : ${cwd}`);
|
|
988
|
+
console.log(` 访问 : http://localhost:${port}`);
|
|
989
|
+
console.log(` 日志 : ${winLogPath()}`);
|
|
990
|
+
console.log(
|
|
991
|
+
` 说明 : 登录后自启(与 launchd 用户代理一致);stop 停止,uninstall 移除`,
|
|
992
|
+
);
|
|
993
|
+
console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
|
|
994
|
+
console.log(` 提示 : pi-web-ui server shortcut 可在桌面创建「一键启动」图标`);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function uninstallWindows(opts) {
|
|
998
|
+
const name = opts.name ?? "pi-web-ui";
|
|
999
|
+
if (winTaskExists(name)) {
|
|
1000
|
+
run("schtasks", ["/Delete", "/TN", name, "/F"], { ignoreError: true });
|
|
1001
|
+
}
|
|
1002
|
+
for (const f of [winCmdPath(name), winPs1Path(name), winTaskXmlPath(name)]) {
|
|
1003
|
+
if (existsSync(f)) rmSync(f);
|
|
1004
|
+
}
|
|
1005
|
+
// 快捷方式启动的隐藏实例
|
|
1006
|
+
const pid = winReadPid(name);
|
|
1007
|
+
if (pid && pidAlive(pid)) {
|
|
1008
|
+
run("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
1009
|
+
ignoreError: true,
|
|
1010
|
+
silent: true,
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
removeShortcut(name);
|
|
1014
|
+
console.log(`🗑 已卸载 ${name}(计划任务已删除,不再自启)`);
|
|
1015
|
+
console.log(`🗑 已移除桌面快捷方式`);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function controlService(action, opts) {
|
|
1019
|
+
const name = opts.name ?? "pi-web-ui";
|
|
1020
|
+
|
|
1021
|
+
if (isMac) {
|
|
1022
|
+
const label = serviceLabel(name);
|
|
1023
|
+
const target = `gui/${uid()}/${label}`;
|
|
1024
|
+
const loaded = () =>
|
|
1025
|
+
spawnSync("launchctl", ["print", target], { stdio: "ignore" }).status ===
|
|
1026
|
+
0;
|
|
1027
|
+
|
|
1028
|
+
if (action === "status") {
|
|
1029
|
+
if (loaded()) {
|
|
1030
|
+
const res = spawnSync("launchctl", ["print", target], {
|
|
1031
|
+
encoding: "utf8",
|
|
1032
|
+
});
|
|
1033
|
+
const state = (res.stdout.match(/state = (\w+)/) ?? [])[1] ?? "loaded";
|
|
1034
|
+
console.log(`${label}: ${state}(已加载,开机自启中)`);
|
|
1035
|
+
} else {
|
|
1036
|
+
console.log(`${label}: 未安装(运行 pi-web-ui server install 安装)`);
|
|
1037
|
+
}
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (action === "start") {
|
|
1042
|
+
if (loaded()) {
|
|
1043
|
+
run("launchctl", ["kickstart", target]);
|
|
1044
|
+
} else {
|
|
1045
|
+
const plist = launchAgentPlist(name);
|
|
1046
|
+
if (!existsSync(plist)) {
|
|
1047
|
+
fail(`找不到 ${plist},请先运行 pi-web-ui server install`);
|
|
1048
|
+
}
|
|
1049
|
+
run("launchctl", ["bootstrap", `gui/${uid()}`, plist]);
|
|
1050
|
+
}
|
|
1051
|
+
console.log(`✅ 已启动 ${label}`);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (action === "restart") {
|
|
1056
|
+
if (!loaded()) fail(`${label} 未加载,请先 pi-web-ui server start`);
|
|
1057
|
+
run("launchctl", ["kickstart", "-k", target]);
|
|
1058
|
+
console.log(`✅ 已重启 ${label}`);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
if (action === "stop") {
|
|
1063
|
+
run("launchctl", ["bootout", target], {
|
|
1064
|
+
ignoreError: true,
|
|
1065
|
+
silent: true,
|
|
1066
|
+
});
|
|
1067
|
+
console.log(`⏹ 已停止 ${label}(已卸载,不再开机自启;start 恢复)`);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
fail(`未知操作: ${action}`);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (isLinux) {
|
|
1075
|
+
ensureRootForSystemctl();
|
|
1076
|
+
if (action === "status") {
|
|
1077
|
+
run("systemctl", ["status", `${name}.service`, "--no-pager"]);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
run("systemctl", [action, `${name}.service`]);
|
|
1081
|
+
console.log(`✅ ${action} ${name}.service`);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (isWin) {
|
|
1086
|
+
const exists = winTaskExists(name);
|
|
1087
|
+
|
|
1088
|
+
if (action === "status") {
|
|
1089
|
+
const pid = winReadPid(name);
|
|
1090
|
+
const instAlive = pid && pidAlive(pid);
|
|
1091
|
+
if (!exists) {
|
|
1092
|
+
console.log(`${name}: 未安装(运行 pi-web-ui server install 安装)`);
|
|
1093
|
+
if (instAlive) console.log(` 快捷方式实例 : 运行中 (PID ${pid})`);
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
// Get-ScheduledTask outputs English state enums — locale-independent,
|
|
1097
|
+
// unlike `schtasks /Query` tables on localized Windows.
|
|
1098
|
+
const ps = spawnSync(
|
|
1099
|
+
"powershell.exe",
|
|
1100
|
+
[
|
|
1101
|
+
"-NoProfile",
|
|
1102
|
+
"-NonInteractive",
|
|
1103
|
+
"-Command",
|
|
1104
|
+
"$t=Get-ScheduledTask -TaskName '" +
|
|
1105
|
+
name +
|
|
1106
|
+
"' -ErrorAction SilentlyContinue;" +
|
|
1107
|
+
"if(!$t){'NOT_INSTALLED';exit}" +
|
|
1108
|
+
"$i=$t|Get-ScheduledTaskInfo;" +
|
|
1109
|
+
"'State: '+$t.State;" +
|
|
1110
|
+
"'LastRunTime: '+$i.LastRunTime;" +
|
|
1111
|
+
"'LastTaskResult: '+$i.LastTaskResult",
|
|
1112
|
+
],
|
|
1113
|
+
{ encoding: "utf8" },
|
|
1114
|
+
);
|
|
1115
|
+
if (ps.status !== 0 || (ps.stdout ?? "").includes("NOT_INSTALLED")) {
|
|
1116
|
+
console.log(`${name}: 未安装(运行 pi-web-ui server install 安装)`);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
console.log(`${name}: 计划任务\n${(ps.stdout ?? "").trim()}`);
|
|
1120
|
+
if (pid) {
|
|
1121
|
+
if (instAlive) console.log(` 快捷方式实例 : 运行中 (PID ${pid})`);
|
|
1122
|
+
else console.log(` 快捷方式实例 : 已退出 (PID ${pid})`);
|
|
1123
|
+
}
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if (action === "start") {
|
|
1128
|
+
if (!exists) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
|
|
1129
|
+
run("schtasks", ["/Run", "/TN", name]);
|
|
1130
|
+
console.log(`✅ 已启动 ${name}`);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (action === "restart") {
|
|
1135
|
+
if (!exists) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
|
|
1136
|
+
run("schtasks", ["/End", "/TN", name], {
|
|
1137
|
+
ignoreError: true,
|
|
1138
|
+
silent: true,
|
|
1139
|
+
});
|
|
1140
|
+
run("schtasks", ["/Run", "/TN", name]);
|
|
1141
|
+
console.log(`✅ 已重启 ${name}`);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
if (action === "stop") {
|
|
1146
|
+
run("schtasks", ["/End", "/TN", name], {
|
|
1147
|
+
ignoreError: true,
|
|
1148
|
+
silent: true,
|
|
1149
|
+
});
|
|
1150
|
+
const pid = winReadPid(name);
|
|
1151
|
+
if (pid) {
|
|
1152
|
+
if (pidAlive(pid)) {
|
|
1153
|
+
run("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
1154
|
+
ignoreError: true,
|
|
1155
|
+
silent: true,
|
|
1156
|
+
});
|
|
1157
|
+
console.log(`⏹ 已停止快捷方式实例 (PID ${pid})`);
|
|
1158
|
+
}
|
|
1159
|
+
rmSync(winPidFilePath(name), { force: true });
|
|
1160
|
+
}
|
|
1161
|
+
console.log(`⏹ 已停止 ${name}(自启保留;uninstall 移除)`);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
fail(`未知操作: ${action}`);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
fail(
|
|
1169
|
+
`不支持的系统服务平台: ${process.platform}(仅 macOS / Linux / Windows)`,
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
async function serverCmd(argv) {
|
|
1174
|
+
const { opts, positionals } = parseFlags(argv);
|
|
1175
|
+
if (opts.help) {
|
|
1176
|
+
console.log(HELP);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
if (positionals.length === 0) {
|
|
1180
|
+
console.log(HELP);
|
|
1181
|
+
console.log("--- 当前服务状态 ---");
|
|
1182
|
+
controlService("status", opts);
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
const action = positionals[0];
|
|
1186
|
+
if (positionals.length > 1)
|
|
1187
|
+
fail(`多余的参数: ${positionals.slice(1).join(" ")}`);
|
|
1188
|
+
switch (action) {
|
|
1189
|
+
case "shortcut": {
|
|
1190
|
+
if (isWin) {
|
|
1191
|
+
installWinShortcut(opts);
|
|
1192
|
+
} else if (isMac) {
|
|
1193
|
+
installMacShortcut(opts);
|
|
1194
|
+
} else if (isLinux) {
|
|
1195
|
+
installLinuxShortcut(opts);
|
|
1196
|
+
} else {
|
|
1197
|
+
fail(`不支持的系统服务平台: ${process.platform}`);
|
|
1198
|
+
}
|
|
1199
|
+
break;
|
|
1200
|
+
}
|
|
1201
|
+
case "install": {
|
|
1202
|
+
if (isMac) {
|
|
1203
|
+
installLaunchd(opts);
|
|
1204
|
+
} else if (isLinux) {
|
|
1205
|
+
installSystemd(opts);
|
|
1206
|
+
} else if (isWin) {
|
|
1207
|
+
installWindows(opts);
|
|
1208
|
+
} else {
|
|
1209
|
+
fail(`不支持的系统服务平台: ${process.platform}`);
|
|
1210
|
+
}
|
|
1211
|
+
break;
|
|
1212
|
+
}
|
|
1213
|
+
case "uninstall": {
|
|
1214
|
+
if (isMac) {
|
|
1215
|
+
uninstallLaunchd(opts);
|
|
1216
|
+
} else if (isLinux) {
|
|
1217
|
+
uninstallSystemd(opts);
|
|
1218
|
+
} else if (isWin) {
|
|
1219
|
+
uninstallWindows(opts);
|
|
1220
|
+
} else {
|
|
1221
|
+
fail(`不支持的系统服务平台: ${process.platform}`);
|
|
1222
|
+
}
|
|
1223
|
+
break;
|
|
1224
|
+
}
|
|
1225
|
+
case "start":
|
|
1226
|
+
case "stop":
|
|
1227
|
+
case "restart":
|
|
1228
|
+
case "status":
|
|
1229
|
+
controlService(action, opts);
|
|
1230
|
+
break;
|
|
1231
|
+
default:
|
|
1232
|
+
fail(
|
|
1233
|
+
`未知操作: ${action}(install / shortcut / uninstall / start / stop / restart / status)`,
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
async function main() {
|
|
1239
|
+
checkNodeVersion();
|
|
1240
|
+
const argv = process.argv.slice(2);
|
|
1241
|
+
if (argv.length === 0) {
|
|
1242
|
+
await startForeground({});
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
const first = argv[0];
|
|
1246
|
+
if (first === "--version" || first === "-v") {
|
|
1247
|
+
console.log(pkg.version);
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
if (first === "--help" || first === "-h") {
|
|
1251
|
+
console.log(HELP);
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
if (first === "server") {
|
|
1255
|
+
await serverCmd(argv.slice(1));
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
// One-shot server with optional --port/--cwd/--data-dir overrides.
|
|
1259
|
+
const { opts, positionals } = parseFlags(argv);
|
|
1260
|
+
if (opts.help) {
|
|
1261
|
+
console.log(HELP);
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
if (positionals.length > 0)
|
|
1265
|
+
fail(`未知命令: ${positionals[0]}(--help 查看用法)`);
|
|
1266
|
+
await startForeground(opts);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
main().catch((err) => {
|
|
1270
|
+
console.error(`✖ ${err instanceof Error ? err.message : String(err)}`);
|
|
1271
|
+
process.exit(1);
|
|
1272
|
+
});
|