@youweichen/pi-web-ui 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/bin/pi-web-ui.mjs +1818 -0
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
- package/deploy/nginx-subpath.conf +88 -0
- package/deploy/pi-web-ui-task.xml +71 -0
- package/deploy/pi-web-ui.service +31 -0
- package/dist/server/agent-service.js +2975 -0
- package/dist/server/attachments.js +672 -0
- package/dist/server/bg-servers.js +150 -0
- package/dist/server/client-state.js +234 -0
- package/dist/server/control-socket.js +177 -0
- package/dist/server/ensure-bash.js +80 -0
- package/dist/server/files-service.js +709 -0
- package/dist/server/goal-service.js +869 -0
- package/dist/server/index.js +879 -0
- package/dist/server/mcp-bridge.js +268 -0
- package/dist/server/model-admin.js +821 -0
- package/dist/server/patch-node-pty.js +80 -0
- package/dist/server/plugin-facilities.js +299 -0
- package/dist/server/plugin-updater.js +226 -0
- package/dist/server/plugins.js +893 -0
- package/dist/server/process-utils.js +112 -0
- package/dist/server/protocol-version.js +11 -0
- package/dist/server/protocol.js +6 -0
- package/dist/server/scm.js +298 -0
- package/dist/server/serialize.js +172 -0
- package/dist/server/settings-service.js +395 -0
- package/dist/server/slash-commands.js +263 -0
- package/dist/server/terminals.js +1372 -0
- package/dist/server/text-sniff.js +268 -0
- package/dist/server/uploads.js +107 -0
- package/dist/server/vision-bridge.js +131 -0
- package/dist/server/webui-context.js +222 -0
- package/extensions/webui.ts +192 -0
- package/package.json +99 -0
- package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
- package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
- package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
- package/web/dist/assets/arc-pkuxEN8A.js +1 -0
- package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
- package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
- package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
- package/web/dist/assets/channel-0Y__6sq2.js +1 -0
- package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
- package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
- package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
- package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
- package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
- package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
- package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
- package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
- package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
- package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
- package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
- package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
- package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
- package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
- package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
- package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
- package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
- package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
- package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
- package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
- package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
- package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
- package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
- package/web/dist/assets/index-CniHcFxT.js +22 -0
- package/web/dist/assets/index-DYz2iP_B.css +10 -0
- package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
- package/web/dist/assets/init-Gi6I4Gst.js +1 -0
- package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
- package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
- package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
- package/web/dist/assets/katex-C5jXJg4s.js +257 -0
- package/web/dist/assets/layout-zq_9DigZ.js +1 -0
- package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
- package/web/dist/assets/markdown-CUPbftfV.js +51 -0
- package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
- package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
- package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
- package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
- package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
- package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
- package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
- package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
- package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
- package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
- package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
- package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
- package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
- package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
- package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
- package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
- package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
- package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
- package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
- package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
- package/web/dist/favicon.svg +8 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/dist/icon.ico +0 -0
- package/web/dist/index.html +17 -0
- package/web/public/favicon.svg +8 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/public/icon.ico +0 -0
|
@@ -0,0 +1,1818 @@
|
|
|
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
|
+
* pi-web-ui install <源> [选项] 安装 GitHub 上的界面插件(见下方「界面插件」)
|
|
14
|
+
* pi-web-ui plugins / uninstall <id> 列出 / 卸载界面插件
|
|
15
|
+
*
|
|
16
|
+
* 系统服务:
|
|
17
|
+
* - macOS → launchd 用户代理,label 默认 com.xingshuyin.pi-web-ui
|
|
18
|
+
* (--name 自定义时 com.<name>.server),无需 sudo
|
|
19
|
+
* - Linux → systemd 单元 <name>.service(/etc/systemd/system/,自动 sudo)
|
|
20
|
+
* - Windows → 计划任务(Task Scheduler / schtasks,登录后自启,无需管理员),
|
|
21
|
+
* 隐藏窗口启动(无黑窗);PowerShell 启动脚本与任务 XML 生成在
|
|
22
|
+
* %APPDATA%\pi-web-ui\
|
|
23
|
+
*
|
|
24
|
+
* 环境变量(前台与系统服务均适用):PORT / PI_WEB_CWD / PI_WEB_DATA_DIR /
|
|
25
|
+
* PI_CODING_AGENT_DIR。
|
|
26
|
+
*/
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
import { createConnection } from "node:net";
|
|
29
|
+
import { get as httpGet } from "node:http";
|
|
30
|
+
import {
|
|
31
|
+
ensureBackup as ensurePluginBackup,
|
|
32
|
+
restoreBackup as restorePluginBackup,
|
|
33
|
+
checkPluginUpdates,
|
|
34
|
+
resolveRemoteSha,
|
|
35
|
+
} from "../dist/server/plugin-updater.js";
|
|
36
|
+
import {
|
|
37
|
+
chmodSync,
|
|
38
|
+
copyFileSync,
|
|
39
|
+
cpSync,
|
|
40
|
+
existsSync,
|
|
41
|
+
mkdirSync,
|
|
42
|
+
mkdtempSync,
|
|
43
|
+
realpathSync,
|
|
44
|
+
readFileSync,
|
|
45
|
+
readdirSync,
|
|
46
|
+
rmSync,
|
|
47
|
+
writeFileSync,
|
|
48
|
+
} from "node:fs";
|
|
49
|
+
import { homedir, tmpdir, userInfo } from "node:os";
|
|
50
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
51
|
+
import { pathToFileURL } from "node:url";
|
|
52
|
+
import { fileURLToPath } from "node:url";
|
|
53
|
+
|
|
54
|
+
const BIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
/** <pkg>/dist/server/index.js — the actual server entry. */
|
|
56
|
+
const SERVER_ENTRY = join(BIN_DIR, "..", "dist", "server", "index.js");
|
|
57
|
+
const NODE = process.execPath;
|
|
58
|
+
let pkg = { version: "0.0.0" };
|
|
59
|
+
try {
|
|
60
|
+
pkg = JSON.parse(readFileSync(join(BIN_DIR, "..", "package.json"), "utf8"));
|
|
61
|
+
} catch {
|
|
62
|
+
// version is best-effort — the server itself doesn't need it
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const HELP = `pi-web-ui v${pkg.version} — web chat for the pi coding agent
|
|
66
|
+
|
|
67
|
+
用法:
|
|
68
|
+
pi-web-ui 启动服务器(前台,Ctrl+C 停止,自动打开浏览器)
|
|
69
|
+
pi-web-ui --port 9000 --cwd /path 启动并指定端口 / 工作目录 / 数据目录
|
|
70
|
+
pi-web-ui --no-browser 启动但不自动打开浏览器
|
|
71
|
+
pi-web-ui server install [选项] 安装系统服务(开机自启)并启动
|
|
72
|
+
pi-web-ui server shortcut [选项] 在桌面创建「一键启动」图标(启动服务并打开浏览器)
|
|
73
|
+
pi-web-ui server uninstall [选项] 卸载系统服务(同时移除桌面图标)
|
|
74
|
+
pi-web-ui server start|stop|restart|status [选项]
|
|
75
|
+
pi-web-ui server quiesce [选项] 进入排空模式:拒绝新的对话/消息/编辑,存量运行继续跑完
|
|
76
|
+
pi-web-ui server unquiesce [选项] 解除排空模式,恢复接收新工作
|
|
77
|
+
pi-web-ui --version / --help
|
|
78
|
+
|
|
79
|
+
server 选项:
|
|
80
|
+
--port <n> 端口(默认 8787,或 $PORT)
|
|
81
|
+
--cwd <dir> 工作目录(默认 $PI_WEB_CWD 或当前目录)
|
|
82
|
+
--data-dir <dir> 会话数据目录(默认 <cwd>/.pi-web)
|
|
83
|
+
--name <name> 服务名(默认 pi-web-ui;macOS 的 launchd label
|
|
84
|
+
为 com.xingshuyin.pi-web-ui,自定义名时为 com.<name>.server)
|
|
85
|
+
--print 只打印将生成的配置文件,不实际安装
|
|
86
|
+
|
|
87
|
+
平台: macOS → launchd 用户代理 · Linux → systemd · Windows → 计划任务(schtasks)
|
|
88
|
+
(Windows 任务登录后自启、无需管理员、隐藏窗口运行;stop 停止,uninstall 移除)
|
|
89
|
+
快捷方式: Windows → 桌面 .lnk · macOS → 桌面 .command 启动器 · Linux → 桌面 .desktop 图标
|
|
90
|
+
|
|
91
|
+
界面插件(安装到 <data-dir>/plugins/,服务运行中刷新浏览器即生效):
|
|
92
|
+
pi-web-ui install <源> 从 GitHub 安装界面插件
|
|
93
|
+
pi-web-ui uninstall <id> 卸载已安装的界面插件
|
|
94
|
+
pi-web-ui plugins 列出已安装的界面插件
|
|
95
|
+
|
|
96
|
+
源写法: owner/repo · https://github.com/owner/repo · 本地目录路径
|
|
97
|
+
URL 带 /tree/<分支>/<子目录> 可指定分支与仓库内子目录;任意写法
|
|
98
|
+
末尾加 #<分支或tag> 也可指定分支(如 owner/repo#v1.2)
|
|
99
|
+
install 选项: --name <id> 自定义插件目录名(默认取仓库名)
|
|
100
|
+
--data-dir <dir> 数据目录(默认 ~/.pi-web)
|
|
101
|
+
--force 目标已存在时覆盖
|
|
102
|
+
|
|
103
|
+
环境变量(前台与系统服务均适用):
|
|
104
|
+
PORT / PI_WEB_CWD / PI_WEB_DATA_DIR / PI_CODING_AGENT_DIR
|
|
105
|
+
`;
|
|
106
|
+
|
|
107
|
+
/** Minimum Node required by the pi SDK (its dist uses `import … with { type: "json" }`). */
|
|
108
|
+
const NODE_MIN = [22, 19, 0];
|
|
109
|
+
function checkNodeVersion() {
|
|
110
|
+
const v = process.versions.node.split(".").map(Number);
|
|
111
|
+
const tooOld =
|
|
112
|
+
v[0] < NODE_MIN[0] ||
|
|
113
|
+
(v[0] === NODE_MIN[0] && v[1] < NODE_MIN[1]) ||
|
|
114
|
+
(v[0] === NODE_MIN[0] && v[1] === NODE_MIN[1] && v[2] < NODE_MIN[2]);
|
|
115
|
+
if (tooOld) {
|
|
116
|
+
console.error(
|
|
117
|
+
`✖ pi-web-ui 需要 Node.js >= ${NODE_MIN.join(".")}(当前 ${process.versions.node})。\n` +
|
|
118
|
+
` pi SDK 的代码使用了 import attributes(with)语法,旧版 Node 无法解析。\n` +
|
|
119
|
+
` 请升级 Node:https://nodejs.org(或 nvm-windows / fnm)后重装:npm i -g pi-web-ui`,
|
|
120
|
+
);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function fail(msg) {
|
|
126
|
+
console.error(`✖ ${msg}`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Run a command, inheriting stdio; exits on failure unless ignoreError. */
|
|
131
|
+
function run(cmd, args, { ignoreError = false, silent = false } = {}) {
|
|
132
|
+
const res = spawnSync(cmd, args, {
|
|
133
|
+
stdio: silent ? ["inherit", "ignore", "ignore"] : "inherit",
|
|
134
|
+
});
|
|
135
|
+
if (!ignoreError && res.status !== 0) process.exit(res.status ?? 1);
|
|
136
|
+
return res;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Parse --flag value / --flag=value options; returns { opts, positionals }. */
|
|
140
|
+
function parseFlags(argv) {
|
|
141
|
+
const opts = {
|
|
142
|
+
port: undefined,
|
|
143
|
+
cwd: undefined,
|
|
144
|
+
dataDir: undefined,
|
|
145
|
+
name: undefined,
|
|
146
|
+
print: false,
|
|
147
|
+
noBrowser: false,
|
|
148
|
+
force: false,
|
|
149
|
+
checkUpdates: false,
|
|
150
|
+
rollback: undefined,
|
|
151
|
+
help: false,
|
|
152
|
+
};
|
|
153
|
+
const positionals = [];
|
|
154
|
+
for (let i = 0; i < argv.length; i++) {
|
|
155
|
+
const a = argv[i];
|
|
156
|
+
const eq = a.indexOf("=");
|
|
157
|
+
const key = eq >= 0 ? a.slice(0, eq) : a;
|
|
158
|
+
const inline = eq >= 0 ? a.slice(eq + 1) : undefined;
|
|
159
|
+
const take = (flag) => {
|
|
160
|
+
if (inline !== undefined) return inline;
|
|
161
|
+
if (i + 1 < argv.length) {
|
|
162
|
+
i++;
|
|
163
|
+
return argv[i];
|
|
164
|
+
}
|
|
165
|
+
fail(`缺少选项 ${flag} 的值`);
|
|
166
|
+
};
|
|
167
|
+
switch (key) {
|
|
168
|
+
case "--port":
|
|
169
|
+
opts.port = take("--port");
|
|
170
|
+
break;
|
|
171
|
+
case "--cwd":
|
|
172
|
+
opts.cwd = take("--cwd");
|
|
173
|
+
break;
|
|
174
|
+
case "--data-dir":
|
|
175
|
+
opts.dataDir = take("--data-dir");
|
|
176
|
+
break;
|
|
177
|
+
case "--name":
|
|
178
|
+
opts.name = take("--name");
|
|
179
|
+
break;
|
|
180
|
+
case "--print":
|
|
181
|
+
opts.print = true;
|
|
182
|
+
break;
|
|
183
|
+
case "--no-browser":
|
|
184
|
+
opts.noBrowser = true;
|
|
185
|
+
break;
|
|
186
|
+
case "--force":
|
|
187
|
+
opts.force = true;
|
|
188
|
+
break;
|
|
189
|
+
case "--check-updates":
|
|
190
|
+
opts.checkUpdates = true;
|
|
191
|
+
break;
|
|
192
|
+
case "--rollback":
|
|
193
|
+
opts.rollback = take("--rollback");
|
|
194
|
+
break;
|
|
195
|
+
case "--help":
|
|
196
|
+
case "-h":
|
|
197
|
+
opts.help = true;
|
|
198
|
+
break;
|
|
199
|
+
default:
|
|
200
|
+
if (key.startsWith("-")) fail(`未知选项: ${key}`);
|
|
201
|
+
positionals.push(a);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { opts, positionals };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// 前台启动
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
/** Open a URL in the OS default browser; failures are ignored (best-effort). */
|
|
212
|
+
function openBrowser(url) {
|
|
213
|
+
let res;
|
|
214
|
+
if (isMac) {
|
|
215
|
+
res = spawnSync("open", [url], { stdio: "ignore" });
|
|
216
|
+
} else if (isWin) {
|
|
217
|
+
res = spawnSync("cmd", ["/c", "start", "", url], { stdio: "ignore" });
|
|
218
|
+
} else {
|
|
219
|
+
res = spawnSync("xdg-open", [url], { stdio: "ignore" });
|
|
220
|
+
}
|
|
221
|
+
// spawnSync 不抛异常:命令缺失(headless 服务器)时在返回对象里带 error 字段。
|
|
222
|
+
if (res?.error) {
|
|
223
|
+
if (res.error.code === "ENOENT") {
|
|
224
|
+
console.warn(
|
|
225
|
+
`[browser] 未找到打开器 (${res.error.path || "command not found"}),` +
|
|
226
|
+
"headless 服务器可用 --no-browser 关闭自动打开"
|
|
227
|
+
);
|
|
228
|
+
} else {
|
|
229
|
+
console.warn("[browser] 打开浏览器失败:", res.error.message);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Poll `url` until the server answers (or ~15s pass), then open it in the
|
|
236
|
+
* default browser. The foreground server runs in this process, so the first
|
|
237
|
+
* HTTP response is the "listening" signal; if the server crashes meanwhile
|
|
238
|
+
* (e.g. port already taken), the pending timers die with the process and
|
|
239
|
+
* nothing is opened.
|
|
240
|
+
*/
|
|
241
|
+
function openBrowserWhenUp(url) {
|
|
242
|
+
const deadline = Date.now() + 15_000;
|
|
243
|
+
const attempt = () => {
|
|
244
|
+
const req = httpGet(url, (res) => {
|
|
245
|
+
res.resume();
|
|
246
|
+
console.log(` 🌐 已自动打开浏览器:${url}(--no-browser 可关闭)`);
|
|
247
|
+
openBrowser(url);
|
|
248
|
+
});
|
|
249
|
+
req.setTimeout(1000, () => {
|
|
250
|
+
req.destroy();
|
|
251
|
+
if (Date.now() < deadline) setTimeout(attempt, 150);
|
|
252
|
+
});
|
|
253
|
+
req.on("error", () => {
|
|
254
|
+
if (Date.now() < deadline) setTimeout(attempt, 150);
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
attempt();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function startForeground(opts) {
|
|
261
|
+
if (opts.port) process.env.PORT = opts.port;
|
|
262
|
+
if (opts.cwd) process.env.PI_WEB_CWD = resolve(opts.cwd);
|
|
263
|
+
if (opts.dataDir) process.env.PI_WEB_DATA_DIR = resolve(opts.dataDir);
|
|
264
|
+
const url = `http://localhost:${String(
|
|
265
|
+
opts.port ?? process.env.PORT ?? "8787",
|
|
266
|
+
)}`;
|
|
267
|
+
await import(pathToFileURL(SERVER_ENTRY).href);
|
|
268
|
+
if (!opts.noBrowser) openBrowserWhenUp(url);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// 系统服务管理
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
const isMac = process.platform === "darwin";
|
|
276
|
+
const isLinux = process.platform === "linux";
|
|
277
|
+
const isWin = process.platform === "win32";
|
|
278
|
+
|
|
279
|
+
function uid() {
|
|
280
|
+
try {
|
|
281
|
+
return userInfo().uid;
|
|
282
|
+
} catch {
|
|
283
|
+
return process.getuid?.() ?? 501;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** launchd label / systemd unit name / Windows task name for a service name. */
|
|
288
|
+
function serviceLabel(name) {
|
|
289
|
+
if (isMac) {
|
|
290
|
+
return name === "pi-web-ui"
|
|
291
|
+
? "com.xingshuyin.pi-web-ui"
|
|
292
|
+
: `com.${name}.server`;
|
|
293
|
+
}
|
|
294
|
+
return name;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function launchAgentPlist(name) {
|
|
298
|
+
return join(
|
|
299
|
+
homedir(),
|
|
300
|
+
"Library",
|
|
301
|
+
"LaunchAgents",
|
|
302
|
+
`${serviceLabel(name)}.plist`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function systemdUnitPath(name) {
|
|
307
|
+
return `/etc/systemd/system/${name}.service`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Windows: per-user config dir (%APPDATA%\pi-web-ui) holding the .cmd wrapper + task XML. */
|
|
311
|
+
function winServiceDir() {
|
|
312
|
+
return join(
|
|
313
|
+
process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
|
|
314
|
+
"pi-web-ui",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function winCmdPath(name) {
|
|
319
|
+
return join(winServiceDir(), `${name}.cmd`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function winPs1Path(name) {
|
|
323
|
+
return join(winServiceDir(), `${name}.ps1`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function winTaskXmlPath(name) {
|
|
327
|
+
return join(winServiceDir(), `${name}.xml`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function winLogPath() {
|
|
331
|
+
return join(homedir(), "pi-web-ui.log");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** True when a scheduled task with this name exists (schtasks exits 0). */
|
|
335
|
+
function winTaskExists(name) {
|
|
336
|
+
return (
|
|
337
|
+
spawnSync("schtasks", ["/Query", "/TN", name], { stdio: "ignore" })
|
|
338
|
+
.status === 0
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// 桌面快捷方式(server shortcut)
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
const SHORTCUT_LNK_NAME = "pi-web-ui.lnk"; // Windows 桌面快捷方式
|
|
347
|
+
const SHORTCUT_MAC_NAME = "pi-web-ui.command"; // macOS 双击启动器
|
|
348
|
+
const SHORTCUT_LINUX_NAME = "pi-web-ui.desktop"; // Linux 桌面图标
|
|
349
|
+
|
|
350
|
+
/** 快捷方式图标(品牌 .ico,随包发布;.lnk / .desktop 指向它)。 */
|
|
351
|
+
const APP_ICO_NAME = "pi-web-ui-logo.ico"; // 复制到用户目录后的稳定文件名(避开 pi-web-ui.ico —— Windows 对该路径有损坏的图标缓存残留,见 issue #xxx)
|
|
352
|
+
const APP_ICO_SOURCE = join(BIN_DIR, "..", "web", "public", "icon.ico"); // 包内品牌图标源文件(10 帧多分辨率,DPI 密度帧保证桌面/任务栏各尺寸颜色不失真)
|
|
353
|
+
/** Branded SVG logo (source of truth: web/public/favicon.svg) — used on Linux. */
|
|
354
|
+
const APP_SVG_PACKAGE = join(BIN_DIR, "..", "web", "public", "favicon.svg");
|
|
355
|
+
|
|
356
|
+
/** Windows: per-user copy of the branded .ico (stable path for the .lnk icon). */
|
|
357
|
+
function winIcoPath() {
|
|
358
|
+
return join(winServiceDir(), APP_ICO_NAME);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Full path to Windows PowerShell 5.1. */
|
|
362
|
+
function winPowershell() {
|
|
363
|
+
return join(
|
|
364
|
+
process.env.SystemRoot ?? "C:\\Windows",
|
|
365
|
+
"System32",
|
|
366
|
+
"WindowsPowerShell",
|
|
367
|
+
"v1.0",
|
|
368
|
+
"powershell.exe",
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
/** Full path to wscript.exe — a console-free host (no conhost window, so no black
|
|
374
|
+
* console box ever appears in the taskbar on double-click). Used as the .lnk target;
|
|
375
|
+
* it launches the .ps1 hidden via a thin VBS launcher. */
|
|
376
|
+
function winWscript() {
|
|
377
|
+
return join(process.env.SystemRoot ?? "C:\\Windows", "System32", "wscript.exe");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Resolve the real node binary. fnm/volta/nvm shims (e.g. fnm_multishells)
|
|
382
|
+
* point into temp dirs that vanish when the installing shell exits — the
|
|
383
|
+
* baked-in launcher scripts must use the stable real path instead.
|
|
384
|
+
*/
|
|
385
|
+
function realNode() {
|
|
386
|
+
try {
|
|
387
|
+
return realpathSync(process.execPath);
|
|
388
|
+
} catch {
|
|
389
|
+
return process.execPath;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Windows: launcher ps1 the desktop .lnk runs (hidden). */
|
|
394
|
+
function winShortcutPs1Path(name) {
|
|
395
|
+
return join(winServiceDir(), `${name}-shortcut.ps1`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
/** Windows: launcher vbs the desktop .lnk actually runs (wscript.exe, console-free). */
|
|
400
|
+
function winShortcutVbsPath(name) {
|
|
401
|
+
return join(winServiceDir(), `${name}-shortcut.vbs`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Windows: PID file of a shortcut-started (no scheduled task) instance. */
|
|
405
|
+
function winPidFilePath(name) {
|
|
406
|
+
return join(winServiceDir(), `${name}.pid`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Read the recorded PID of a shortcut-started Windows instance. */
|
|
410
|
+
function winReadPid(name) {
|
|
411
|
+
try {
|
|
412
|
+
const pid = Number(readFileSync(winPidFilePath(name), "utf8").trim());
|
|
413
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
414
|
+
} catch {
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** True when a PID exists (signal 0; EPERM means exists but not ours). */
|
|
420
|
+
function pidAlive(pid) {
|
|
421
|
+
if (!pid) return false;
|
|
422
|
+
try {
|
|
423
|
+
process.kill(pid, 0);
|
|
424
|
+
return true;
|
|
425
|
+
} catch (err) {
|
|
426
|
+
return err.code === "EPERM";
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Single-quote a string for embedding in a POSIX shell script. */
|
|
431
|
+
function shQuote(s) {
|
|
432
|
+
return "'" + s.replace(/'/g, `'\\''`) + "'";
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Windows shortcut launcher. Double-click the .lnk → this script runs hidden:
|
|
437
|
+
* server already up → just open the browser; scheduled task installed → start
|
|
438
|
+
* it (manageable via `server stop`); otherwise run the server in the foreground
|
|
439
|
+
* of this hidden window and record its PID so `server stop` / `server
|
|
440
|
+
* uninstall` can terminate it too.
|
|
441
|
+
*/
|
|
442
|
+
function buildWinShortcutPs1(env, cwd, taskName, url, logPath, pidPath) {
|
|
443
|
+
const sets = Object.entries(env)
|
|
444
|
+
.map(([k, v]) => `$env:${k} = ${psQuote(v)}`)
|
|
445
|
+
.join("\r\n");
|
|
446
|
+
const node = realNode();
|
|
447
|
+
return [
|
|
448
|
+
"# Generated by: pi-web-ui server shortcut (rerun to change)",
|
|
449
|
+
"# Runs hidden from the desktop shortcut: if the server is already up it",
|
|
450
|
+
"# opens the browser; a scheduled task (if installed) is used; otherwise",
|
|
451
|
+
"# the server runs in the foreground of this hidden window and its PID is",
|
|
452
|
+
"# recorded so `server stop` / `server uninstall` can terminate it.",
|
|
453
|
+
"$ErrorActionPreference = 'Continue'",
|
|
454
|
+
`$url = ${psQuote(url)}`,
|
|
455
|
+
`$health = ${psQuote(url + "/api/health")}`,
|
|
456
|
+
`$taskName = ${psQuote(taskName)}`,
|
|
457
|
+
`$pidFile = ${psQuote(pidPath)}`,
|
|
458
|
+
`$log = ${psQuote(logPath)}`,
|
|
459
|
+
"",
|
|
460
|
+
"function Test-Up {",
|
|
461
|
+
" try { return (Invoke-WebRequest -Uri $health -UseBasicParsing -TimeoutSec 2).StatusCode -eq 200 } catch { return $false }",
|
|
462
|
+
"}",
|
|
463
|
+
"function Open-Browser { Start-Process $url | Out-Null }",
|
|
464
|
+
"",
|
|
465
|
+
"# 已在运行 → 直接打开浏览器",
|
|
466
|
+
"if (Test-Up) { Open-Browser; exit 0 }",
|
|
467
|
+
"",
|
|
468
|
+
"# 已安装计划任务 → 走服务启动(server stop 可管理)",
|
|
469
|
+
"if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {",
|
|
470
|
+
" schtasks /Run /TN $taskName | Out-Null",
|
|
471
|
+
" for ($i = 0; $i -lt 120; $i++) {",
|
|
472
|
+
" Start-Sleep -Milliseconds 250",
|
|
473
|
+
" if (Test-Up) { Open-Browser; exit 0 }",
|
|
474
|
+
" }",
|
|
475
|
+
" Write-Host ('✖ pi-web-ui 服务未在 30 秒内就绪,请查看日志: ' + $log)",
|
|
476
|
+
" exit 1",
|
|
477
|
+
"}",
|
|
478
|
+
"",
|
|
479
|
+
"# 未安装服务 → 在本隐藏窗口中前台运行(记录 PID,server stop 可停止)",
|
|
480
|
+
`$PID | Out-File -Encoding ascii $pidFile`,
|
|
481
|
+
sets,
|
|
482
|
+
`Set-Location ${psQuote(cwd)}`,
|
|
483
|
+
"# 后台轮询,就绪后打开浏览器(与前台 node 并行)",
|
|
484
|
+
"$job = Start-Job -ScriptBlock { param($u)",
|
|
485
|
+
" $h = $u + '/api/health'",
|
|
486
|
+
" for ($i = 0; $i -lt 120; $i++) {",
|
|
487
|
+
" Start-Sleep -Milliseconds 250",
|
|
488
|
+
" try { if ((Invoke-WebRequest -Uri $h -UseBasicParsing -TimeoutSec 2).StatusCode -eq 200) { Start-Process $u | Out-Null; break } } catch {}",
|
|
489
|
+
" }",
|
|
490
|
+
"} -ArgumentList $url",
|
|
491
|
+
`& ${psQuote(node)} ${psQuote(SERVER_ENTRY)} *>> $log`,
|
|
492
|
+
"Remove-Item $pidFile -ErrorAction SilentlyContinue",
|
|
493
|
+
"",
|
|
494
|
+
].join("\r\n");
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Build the tiny VBS launcher the desktop .lnk invokes. Its purpose is purely to
|
|
500
|
+
* start the .ps1 *without* creating any console host: wscript.exe has no console,
|
|
501
|
+
* and WScript.Shell.Run(…, 0, False) launches the child hidden and returns at once
|
|
502
|
+
* (0 = hidden window, False = don't wait). Result: double-clicking the icon never
|
|
503
|
+
* flashes a black box and no leftover console appears in the taskbar.
|
|
504
|
+
*/
|
|
505
|
+
function buildWinShortcutVbs(ps1Path) {
|
|
506
|
+
const cmd = `${winPowershell()} -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "${ps1Path}"`;
|
|
507
|
+
// VBScript 字符串没有 \" 转义(也没有 \uXXXX),内嵌引号必须写成 "";
|
|
508
|
+
// 不能用 JSON.stringify —— 它输出 \" 会在 VBScript 里提前结束字符串(语句未结束 800A0401),
|
|
509
|
+
// 且会把非 ASCII 路径转成 \uXXXX 字面量(wscript 不识别,中文用户名直接变成乱码路径)。
|
|
510
|
+
const vbsCmd = cmd.replace(/"/g, '""');
|
|
511
|
+
return [
|
|
512
|
+
"Option Explicit",
|
|
513
|
+
"Dim sh, cmd",
|
|
514
|
+
"Set sh = CreateObject(\"WScript.Shell\")",
|
|
515
|
+
`cmd = "${vbsCmd}"`,
|
|
516
|
+
"sh.Run cmd, 0, False",
|
|
517
|
+
"Set sh = Nothing",
|
|
518
|
+
"",
|
|
519
|
+
].join("\r\n");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Create the Windows desktop .lnk via WScript.Shell COM (correct Desktop path
|
|
524
|
+
* even with OneDrive redirection). Target is powershell.exe with
|
|
525
|
+
* -WindowStyle Hidden so nothing flashes on double-click.
|
|
526
|
+
*/
|
|
527
|
+
function installWinShortcut(opts) {
|
|
528
|
+
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
529
|
+
const env = serviceEnv(port, cwd, dataDir);
|
|
530
|
+
const url = `http://localhost:${port}`;
|
|
531
|
+
const ps1Path = winShortcutPs1Path(name);
|
|
532
|
+
const ps1 = buildWinShortcutPs1(
|
|
533
|
+
env,
|
|
534
|
+
cwd,
|
|
535
|
+
name,
|
|
536
|
+
url,
|
|
537
|
+
winLogPath(),
|
|
538
|
+
winPidFilePath(name),
|
|
539
|
+
);
|
|
540
|
+
if (opts.print) {
|
|
541
|
+
console.log(`# ${ps1Path}\n${ps1}`);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
mkdirSync(dirname(ps1Path), { recursive: true });
|
|
545
|
+
writeFileSync(ps1Path, "\uFEFF" + ps1, "utf8"); // PS 5.1 需要 BOM
|
|
546
|
+
// wscript host + VBS launcher: no console window / taskbar black box on double-click.
|
|
547
|
+
const vbsPath = winShortcutVbsPath(name);
|
|
548
|
+
writeFileSync(vbsPath, "\uFEFF" + buildWinShortcutVbs(ps1Path), "utf16le"); // wscript 只认 UTF-16/ANSI,UTF-8 BOM 会报“无效字符”,中文路径用 utf16le + BOM
|
|
549
|
+
// 把品牌图标准备好:复制到用户目录(.lnk 图标指向稳定路径)
|
|
550
|
+
if (existsSync(APP_ICO_SOURCE)) {
|
|
551
|
+
copyFileSync(APP_ICO_SOURCE, winIcoPath());
|
|
552
|
+
} else {
|
|
553
|
+
console.log(`⚠ 未找到品牌图标 ${APP_ICO_SOURCE},快捷方式将使用默认图标`);
|
|
554
|
+
}
|
|
555
|
+
const powershell = winPowershell();
|
|
556
|
+
const ps = [
|
|
557
|
+
"$ErrorActionPreference = 'Stop'",
|
|
558
|
+
"$ws = New-Object -ComObject WScript.Shell",
|
|
559
|
+
"$desktop = [Environment]::GetFolderPath('Desktop')",
|
|
560
|
+
`$lnk = $ws.CreateShortcut((Join-Path $desktop ${psQuote(SHORTCUT_LNK_NAME)}))`,
|
|
561
|
+
`$lnk.TargetPath = ${psQuote(winWscript())}`,
|
|
562
|
+
`$lnk.Arguments = ${psQuote(vbsPath)}`,
|
|
563
|
+
`$lnk.WorkingDirectory = ${psQuote(cwd)}`,
|
|
564
|
+
"$lnk.Description = 'pi-web-ui — 双击启动服务并打开浏览器'",
|
|
565
|
+
`$lnk.IconLocation = ${psQuote(winIcoPath())} + ',0'`,
|
|
566
|
+
"$lnk.Save()",
|
|
567
|
+
`Write-Output (Join-Path $desktop ${psQuote(SHORTCUT_LNK_NAME)})`,
|
|
568
|
+
].join("\r\n");
|
|
569
|
+
const res = spawnSync(
|
|
570
|
+
powershell,
|
|
571
|
+
[
|
|
572
|
+
"-NoProfile",
|
|
573
|
+
"-NonInteractive",
|
|
574
|
+
"-ExecutionPolicy",
|
|
575
|
+
"Bypass",
|
|
576
|
+
"-Command",
|
|
577
|
+
ps,
|
|
578
|
+
],
|
|
579
|
+
{ encoding: "utf8" },
|
|
580
|
+
);
|
|
581
|
+
if (res.status !== 0) {
|
|
582
|
+
fail(`创建桌面快捷方式失败: ${(res.stderr || res.stdout || "").trim()}`);
|
|
583
|
+
}
|
|
584
|
+
const lnk = (res.stdout ?? "").trim();
|
|
585
|
+
console.log(`✅ 已创建桌面快捷方式: ${lnk}`);
|
|
586
|
+
console.log(` 双击 : 服务未运行则启动(隐藏窗口,无黑窗),就绪后自动打开浏览器`);
|
|
587
|
+
console.log(` 停止 : pi-web-ui server stop(快捷方式启动的实例也会一并停止)`);
|
|
588
|
+
console.log(` 端口 : ${port}`);
|
|
589
|
+
console.log(` 目录 : ${cwd}`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** macOS: double-clickable .command launcher (the .lnk equivalent). */
|
|
593
|
+
function buildMacShortcut(label, plist, url, env) {
|
|
594
|
+
const exports = Object.entries(env)
|
|
595
|
+
.map(([k, v]) => `export ${k}=${shQuote(v)}`)
|
|
596
|
+
.join("\n");
|
|
597
|
+
const node = realNode();
|
|
598
|
+
return `#!/bin/bash
|
|
599
|
+
# pi-web-ui 启动器 — generated by: pi-web-ui server shortcut
|
|
600
|
+
# 双击运行:确保服务在运行,然后打开浏览器。
|
|
601
|
+
# · 已安装 launchd 服务(登录自启)→ kickstart,图标主要用于「启动 + 打开」
|
|
602
|
+
# · 未安装服务 → 在本终端前台运行(关闭窗口即停止)
|
|
603
|
+
LABEL=${shQuote(label)}
|
|
604
|
+
PLIST=${shQuote(plist)}
|
|
605
|
+
URL=${shQuote(url)}
|
|
606
|
+
LOG=/tmp/pi-web-ui-shortcut.log
|
|
607
|
+
NODE=${shQuote(node)}
|
|
608
|
+
ENTRY=${shQuote(SERVER_ENTRY)}
|
|
609
|
+
${exports}
|
|
610
|
+
|
|
611
|
+
if launchctl print "gui/$(id -u)/$LABEL" >/dev/null 2>&1; then
|
|
612
|
+
launchctl kickstart "gui/$(id -u)/$LABEL"
|
|
613
|
+
elif [ -f "$PLIST" ]; then
|
|
614
|
+
launchctl bootstrap "gui/$(id -u)" "$PLIST"
|
|
615
|
+
else
|
|
616
|
+
# 未安装服务:在本终端前台运行服务器(关闭窗口即停止)
|
|
617
|
+
"$NODE" "$ENTRY" >>"$LOG" 2>&1 &
|
|
618
|
+
SERVER_PID=$!
|
|
619
|
+
trap 'kill "$SERVER_PID" 2>/dev/null' EXIT
|
|
620
|
+
fi
|
|
621
|
+
|
|
622
|
+
# 等服务就绪后打开浏览器(最多约 30 秒)
|
|
623
|
+
for i in $(seq 1 120); do
|
|
624
|
+
curl -sf "$URL/api/health" >/dev/null 2>&1 && break
|
|
625
|
+
sleep 0.25
|
|
626
|
+
done
|
|
627
|
+
open "$URL"
|
|
628
|
+
if [ -n "\${SERVER_PID:-}" ]; then wait "$SERVER_PID"; fi
|
|
629
|
+
`;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function installMacShortcut(opts) {
|
|
633
|
+
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
634
|
+
const url = `http://localhost:${port}`;
|
|
635
|
+
const script = buildMacShortcut(
|
|
636
|
+
serviceLabel(name),
|
|
637
|
+
launchAgentPlist(name),
|
|
638
|
+
url,
|
|
639
|
+
serviceEnv(port, cwd, dataDir),
|
|
640
|
+
);
|
|
641
|
+
const path = join(homedir(), "Desktop", SHORTCUT_MAC_NAME);
|
|
642
|
+
if (opts.print) {
|
|
643
|
+
console.log(`# ${path}\n${script}`);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
writeFileSync(path, script);
|
|
647
|
+
chmodSync(path, 0o755);
|
|
648
|
+
console.log(`✅ 已创建桌面启动器: ${path}`);
|
|
649
|
+
console.log(` 双击 : 确保服务运行并打开浏览器;未安装服务时在本终端前台运行`);
|
|
650
|
+
console.log(` 说明 : macOS 没有 Windows 式快捷方式,这是等价的 .command 启动器;`);
|
|
651
|
+
console.log(` launchd 服务登录自启,图标主要用于快速「启动 + 打开浏览器」`);
|
|
652
|
+
console.log(` 端口 : ${port}`);
|
|
653
|
+
console.log(` 目录 : ${cwd}`);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Linux: launcher script run by the .desktop icon. */
|
|
657
|
+
function buildLinuxStartScript(unitName, url) {
|
|
658
|
+
const log = join(homedir(), ".local", "share", "pi-web-ui", "pi-web-ui.log");
|
|
659
|
+
const node = realNode();
|
|
660
|
+
return `#!/bin/bash
|
|
661
|
+
# pi-web-ui 启动器 — generated by: pi-web-ui server shortcut
|
|
662
|
+
# 双击运行:确保服务在运行,然后打开浏览器。
|
|
663
|
+
# · systemd 单元已安装 → systemctl start(系统单元需要授权,失败则前台运行)
|
|
664
|
+
# · 未安装 → 在本进程前台运行(终端关闭即停止)
|
|
665
|
+
LOG=${shQuote(log)}
|
|
666
|
+
URL=${shQuote(url)}
|
|
667
|
+
NODE=${shQuote(node)}
|
|
668
|
+
ENTRY=${shQuote(SERVER_ENTRY)}
|
|
669
|
+
UNIT=${shQuote(unitName)}.service
|
|
670
|
+
|
|
671
|
+
if ! curl -sf "$URL/api/health" >/dev/null 2>&1; then
|
|
672
|
+
systemctl start "$UNIT" 2>/dev/null || true
|
|
673
|
+
if ! curl -sf "$URL/api/health" >/dev/null 2>&1; then
|
|
674
|
+
mkdir -p "$(dirname "$LOG")"
|
|
675
|
+
"$NODE" "$ENTRY" >>"$LOG" 2>&1 &
|
|
676
|
+
SERVER_PID=$!
|
|
677
|
+
trap 'kill "$SERVER_PID" 2>/dev/null' EXIT
|
|
678
|
+
fi
|
|
679
|
+
fi
|
|
680
|
+
|
|
681
|
+
for i in $(seq 1 120); do
|
|
682
|
+
curl -sf "$URL/api/health" >/dev/null 2>&1 && break
|
|
683
|
+
sleep 0.25
|
|
684
|
+
done
|
|
685
|
+
xdg-open "$URL" >/dev/null 2>&1 &
|
|
686
|
+
if [ -n "\${SERVER_PID:-}" ]; then wait "$SERVER_PID"; fi
|
|
687
|
+
`;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function installLinuxShortcut(opts) {
|
|
691
|
+
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
692
|
+
const url = `http://localhost:${port}`;
|
|
693
|
+
const scriptDir = join(homedir(), ".local", "share", "pi-web-ui");
|
|
694
|
+
const scriptPath = join(scriptDir, `${name}-start.sh`);
|
|
695
|
+
const desktopPath = join(homedir(), "Desktop", SHORTCUT_LINUX_NAME);
|
|
696
|
+
const icoPath = join(scriptDir, APP_ICO_NAME); // 备用;优先 SVG
|
|
697
|
+
const svgPath = join(scriptDir, "pi-web-ui.svg");
|
|
698
|
+
const script = buildLinuxStartScript(name, url);
|
|
699
|
+
const desktopIcon = existsSync(APP_SVG_PACKAGE) ? svgPath : APP_ICO_NAME;
|
|
700
|
+
const desktop = `[Desktop Entry]
|
|
701
|
+
Version=1.0
|
|
702
|
+
Type=Application
|
|
703
|
+
Name=pi-web-ui
|
|
704
|
+
Comment=启动 pi-web-ui 服务并打开浏览器
|
|
705
|
+
Exec=${shQuote(scriptPath)}
|
|
706
|
+
Icon=${shQuote(desktopIcon)}
|
|
707
|
+
Terminal=false
|
|
708
|
+
Categories=Network;WebBrowser;
|
|
709
|
+
`;
|
|
710
|
+
if (opts.print) {
|
|
711
|
+
console.log(`# ${scriptPath}\n${script}`);
|
|
712
|
+
console.log(`# ${desktopPath}\n${desktop}`);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
mkdirSync(scriptDir, { recursive: true });
|
|
716
|
+
// 品牌图标(缺失时跳过,桌面自动回退默认图标)
|
|
717
|
+
if (existsSync(APP_SVG_PACKAGE)) copyFileSync(APP_SVG_PACKAGE, svgPath);
|
|
718
|
+
else if (existsSync(APP_ICO_SOURCE)) copyFileSync(APP_ICO_SOURCE, icoPath);
|
|
719
|
+
writeFileSync(scriptPath, script);
|
|
720
|
+
chmodSync(scriptPath, 0o755);
|
|
721
|
+
writeFileSync(desktopPath, desktop);
|
|
722
|
+
chmodSync(desktopPath, 0o755);
|
|
723
|
+
// GNOME 需要标记可信才能双击运行
|
|
724
|
+
run("gio", ["set", desktopPath, "metadata::trusted", "true"], {
|
|
725
|
+
ignoreError: true,
|
|
726
|
+
silent: true,
|
|
727
|
+
});
|
|
728
|
+
console.log(`✅ 已创建桌面图标: ${desktopPath}`);
|
|
729
|
+
console.log(` GNOME 若提示「不受信任的应用程序」,右键选择 Allow Launching`);
|
|
730
|
+
console.log(` 端口 : ${port}`);
|
|
731
|
+
console.log(` 目录 : ${cwd}`);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Remove desktop shortcut artifacts created by `server shortcut`. */
|
|
735
|
+
function removeShortcut(name) {
|
|
736
|
+
if (isWin) {
|
|
737
|
+
for (const f of [winShortcutPs1Path(name), winShortcutVbsPath(name), winPidFilePath(name), winIcoPath()]) {
|
|
738
|
+
if (existsSync(f)) rmSync(f);
|
|
739
|
+
}
|
|
740
|
+
spawnSync(
|
|
741
|
+
winPowershell(),
|
|
742
|
+
[
|
|
743
|
+
"-NoProfile",
|
|
744
|
+
"-NonInteractive",
|
|
745
|
+
"-ExecutionPolicy",
|
|
746
|
+
"Bypass",
|
|
747
|
+
"-Command",
|
|
748
|
+
`$d=[Environment]::GetFolderPath('Desktop');$p=Join-Path $d ${psQuote(SHORTCUT_LNK_NAME)};if(Test-Path $p){Remove-Item $p -Force}`,
|
|
749
|
+
],
|
|
750
|
+
{ stdio: "ignore" },
|
|
751
|
+
);
|
|
752
|
+
} else if (isMac) {
|
|
753
|
+
const p = join(homedir(), "Desktop", SHORTCUT_MAC_NAME);
|
|
754
|
+
if (existsSync(p)) rmSync(p);
|
|
755
|
+
} else if (isLinux) {
|
|
756
|
+
const p = join(homedir(), "Desktop", SHORTCUT_LINUX_NAME);
|
|
757
|
+
if (existsSync(p)) rmSync(p);
|
|
758
|
+
rmSync(join(homedir(), ".local", "share", "pi-web-ui"), {
|
|
759
|
+
recursive: true,
|
|
760
|
+
force: true,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** Single-quote a string for embedding in a generated PowerShell script. */
|
|
766
|
+
function psQuote(s) {
|
|
767
|
+
return "'" + s.replace(/'/g, "''") + "'";
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Build the PowerShell launcher the scheduled task runs. Task Scheduler
|
|
772
|
+
* launches it with -WindowStyle Hidden, so the console window is created
|
|
773
|
+
* hidden (SW_HIDE) — no black cmd window stays open while the server runs,
|
|
774
|
+
* and there is nothing to accidentally close/kill. The script sets the env,
|
|
775
|
+
* cd's to the workspace, then runs node in the foreground with output
|
|
776
|
+
* appended to the log, so the task instance IS the powershell process and
|
|
777
|
+
* `schtasks /End` still stops the whole tree.
|
|
778
|
+
*/
|
|
779
|
+
function buildWinStartPs1(env, cwd, logPath) {
|
|
780
|
+
const sets = Object.entries(env)
|
|
781
|
+
.map(([k, v]) => `$env:${k} = ${psQuote(v)}`)
|
|
782
|
+
.join("\r\n");
|
|
783
|
+
return [
|
|
784
|
+
"# Generated by: pi-web-ui server install (rerun to change)",
|
|
785
|
+
"# Starts the server with a hidden console window (no black cmd box).",
|
|
786
|
+
sets,
|
|
787
|
+
`Set-Location ${psQuote(cwd)}`,
|
|
788
|
+
`& ${psQuote(NODE)} ${psQuote(SERVER_ENTRY)} *>> ${psQuote(logPath)}`,
|
|
789
|
+
"",
|
|
790
|
+
].join("\r\n");
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Build the Task Scheduler XML for a user task: LogonTrigger (starts at logon,
|
|
795
|
+
* like a launchd agent — no admin needed), InteractiveToken, auto-restart on
|
|
796
|
+
* failure. The task runs the PowerShell launcher via
|
|
797
|
+
* powershell.exe -WindowStyle Hidden, so no console window ever appears.
|
|
798
|
+
*/
|
|
799
|
+
function buildWinTaskXml(ps1Path, cwd) {
|
|
800
|
+
const powershell = winPowershell();
|
|
801
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
802
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
803
|
+
<RegistrationInfo>
|
|
804
|
+
<Description>pi-web-ui — web chat for the pi coding agent (auto-start at logon)</Description>
|
|
805
|
+
</RegistrationInfo>
|
|
806
|
+
<Triggers>
|
|
807
|
+
<LogonTrigger>
|
|
808
|
+
<Enabled>true</Enabled>
|
|
809
|
+
</LogonTrigger>
|
|
810
|
+
</Triggers>
|
|
811
|
+
<Principals>
|
|
812
|
+
<Principal id="Author">
|
|
813
|
+
<LogonType>InteractiveToken</LogonType>
|
|
814
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
815
|
+
</Principal>
|
|
816
|
+
</Principals>
|
|
817
|
+
<Settings>
|
|
818
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
819
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
820
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
821
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
822
|
+
<StartWhenAvailable>false</StartWhenAvailable>
|
|
823
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
824
|
+
<IdleSettings>
|
|
825
|
+
<StopOnIdleEnd>false</StopOnIdleEnd>
|
|
826
|
+
<RestartOnIdle>false</RestartOnIdle>
|
|
827
|
+
</IdleSettings>
|
|
828
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
829
|
+
<Enabled>true</Enabled>
|
|
830
|
+
<Hidden>false</Hidden>
|
|
831
|
+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
832
|
+
<WakeToRun>false</WakeToRun>
|
|
833
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
834
|
+
<Priority>7</Priority>
|
|
835
|
+
<RestartOnFailure>
|
|
836
|
+
<Interval>PT1M</Interval>
|
|
837
|
+
<Count>3</Count>
|
|
838
|
+
</RestartOnFailure>
|
|
839
|
+
</Settings>
|
|
840
|
+
<Actions Context="Author">
|
|
841
|
+
<Exec>
|
|
842
|
+
<Command>${esc(powershell)}</Command>
|
|
843
|
+
<Arguments>-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "${esc(ps1Path)}"</Arguments>
|
|
844
|
+
<WorkingDirectory>${esc(cwd)}</WorkingDirectory>
|
|
845
|
+
</Exec>
|
|
846
|
+
</Actions>
|
|
847
|
+
</Task>
|
|
848
|
+
`;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function esc(s) {
|
|
852
|
+
return s
|
|
853
|
+
.replace(/&/g, "&")
|
|
854
|
+
.replace(/</g, "<")
|
|
855
|
+
.replace(/>/g, ">")
|
|
856
|
+
.replace(/"/g, """);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** Build the launchd plist XML. */
|
|
860
|
+
function buildPlist(label, cwd, env) {
|
|
861
|
+
const entries = Object.entries(env)
|
|
862
|
+
.map(([k, v]) => ` <key>${esc(k)}</key>\n <string>${esc(v)}</string>`)
|
|
863
|
+
.join("\n");
|
|
864
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
865
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
866
|
+
<!-- Generated by: pi-web-ui server install (do not edit by hand — rerun to change) -->
|
|
867
|
+
<plist version="1.0">
|
|
868
|
+
<dict>
|
|
869
|
+
<key>Label</key>
|
|
870
|
+
<string>${esc(label)}</string>
|
|
871
|
+
|
|
872
|
+
<key>ProgramArguments</key>
|
|
873
|
+
<array>
|
|
874
|
+
<string>${esc(NODE)}</string>
|
|
875
|
+
<string>${esc(SERVER_ENTRY)}</string>
|
|
876
|
+
</array>
|
|
877
|
+
|
|
878
|
+
<key>RunAtLoad</key>
|
|
879
|
+
<true/>
|
|
880
|
+
|
|
881
|
+
<!-- Restart if it crashes -->
|
|
882
|
+
<key>KeepAlive</key>
|
|
883
|
+
<true/>
|
|
884
|
+
|
|
885
|
+
<key>WorkingDirectory</key>
|
|
886
|
+
<string>${esc(cwd)}</string>
|
|
887
|
+
|
|
888
|
+
<key>EnvironmentVariables</key>
|
|
889
|
+
<dict>
|
|
890
|
+
${entries}
|
|
891
|
+
</dict>
|
|
892
|
+
|
|
893
|
+
<key>StandardOutPath</key>
|
|
894
|
+
<string>/tmp/pi-web-ui.log</string>
|
|
895
|
+
<key>StandardErrorPath</key>
|
|
896
|
+
<string>/tmp/pi-web-ui.err</string>
|
|
897
|
+
</dict>
|
|
898
|
+
</plist>
|
|
899
|
+
`;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Build the systemd unit file. */
|
|
903
|
+
function buildUnit(cwd, env) {
|
|
904
|
+
const envLines = Object.entries(env)
|
|
905
|
+
.map(([k, v]) => `Environment=${k}=${v}`)
|
|
906
|
+
.join("\n");
|
|
907
|
+
return `# Generated by: pi-web-ui server install (do not edit by hand — rerun to change)
|
|
908
|
+
[Unit]
|
|
909
|
+
Description=pi-web-ui — web chat for the pi coding agent
|
|
910
|
+
After=network.target
|
|
911
|
+
|
|
912
|
+
[Service]
|
|
913
|
+
Type=simple
|
|
914
|
+
User=${process.env.SUDO_USER ?? userInfo().username}
|
|
915
|
+
WorkingDirectory=${cwd}
|
|
916
|
+
${envLines}
|
|
917
|
+
ExecStart=${JSON.stringify(NODE)} ${JSON.stringify(SERVER_ENTRY)}
|
|
918
|
+
Restart=always
|
|
919
|
+
RestartSec=5
|
|
920
|
+
|
|
921
|
+
[Install]
|
|
922
|
+
WantedBy=multi-user.target
|
|
923
|
+
`;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** If not root on Linux, re-exec the same server command through sudo. */
|
|
927
|
+
function ensureRootForSystemctl() {
|
|
928
|
+
if (typeof process.getuid === "function" && process.getuid() === 0) return;
|
|
929
|
+
// process.argv = [node, <bin>, "server", <action>, ...rest] — forward
|
|
930
|
+
// everything after "server" so flags like --port/--cwd survive.
|
|
931
|
+
const res = spawnSync(
|
|
932
|
+
"sudo",
|
|
933
|
+
[NODE, fileURLToPath(import.meta.url), "server", ...process.argv.slice(3)],
|
|
934
|
+
{ stdio: "inherit" },
|
|
935
|
+
);
|
|
936
|
+
process.exit(res.status ?? 1);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** Shared option normalization for install. */
|
|
940
|
+
function serviceOptions(opts) {
|
|
941
|
+
const name = opts.name ?? "pi-web-ui";
|
|
942
|
+
const port = String(opts.port ?? process.env.PORT ?? "8787");
|
|
943
|
+
if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
|
|
944
|
+
fail(`无效端口: ${port}`);
|
|
945
|
+
}
|
|
946
|
+
const cwd = resolve(opts.cwd ?? process.env.PI_WEB_CWD ?? process.cwd());
|
|
947
|
+
if (!existsSync(cwd)) fail(`工作目录不存在: ${cwd}`);
|
|
948
|
+
let dataDir;
|
|
949
|
+
if (opts.dataDir) {
|
|
950
|
+
dataDir = resolve(opts.dataDir);
|
|
951
|
+
} else if (process.env.PI_WEB_DATA_DIR) {
|
|
952
|
+
dataDir = resolve(process.env.PI_WEB_DATA_DIR);
|
|
953
|
+
}
|
|
954
|
+
return { name, port, cwd, dataDir };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function serviceEnv(port, cwd, dataDir) {
|
|
958
|
+
const env = {
|
|
959
|
+
PORT: port,
|
|
960
|
+
PI_WEB_CWD: cwd,
|
|
961
|
+
};
|
|
962
|
+
// Interactive Windows tasks inherit the user's PATH; only systemd/launchd
|
|
963
|
+
// run with a minimal environment that needs an explicit PATH.
|
|
964
|
+
if (!isWin) env.PATH = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
965
|
+
// Same for the locale: launchd/systemd drop LANG/LC_ALL, and a C-locale
|
|
966
|
+
// shell garbles multibyte input in the terminal (UTF-8 continuation
|
|
967
|
+
// bytes 0x80–0x9F rendered as C1 control chars). Bake the installing
|
|
968
|
+
// shell's locale into the service env so spawned terminals are UTF-8.
|
|
969
|
+
if (!isWin && process.env.LANG) env.LANG = process.env.LANG;
|
|
970
|
+
if (!isWin && process.env.LC_ALL) env.LC_ALL = process.env.LC_ALL;
|
|
971
|
+
if (dataDir) env.PI_WEB_DATA_DIR = dataDir;
|
|
972
|
+
return env;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function installLaunchd(opts) {
|
|
976
|
+
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
977
|
+
const label = serviceLabel(name);
|
|
978
|
+
const plist = launchAgentPlist(name);
|
|
979
|
+
const content = buildPlist(label, cwd, serviceEnv(port, cwd, dataDir));
|
|
980
|
+
if (opts.print) {
|
|
981
|
+
console.log(`# ${plist}\n${content}`);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
// Unload any existing instance (ignore "not loaded"), then (re)install.
|
|
985
|
+
run("launchctl", ["bootout", `gui/${uid()}/${label}`], {
|
|
986
|
+
ignoreError: true,
|
|
987
|
+
silent: true,
|
|
988
|
+
});
|
|
989
|
+
mkdirSync(dirname(plist), { recursive: true });
|
|
990
|
+
writeFileSync(plist, content);
|
|
991
|
+
run("launchctl", ["bootstrap", `gui/${uid()}`, plist]);
|
|
992
|
+
console.log(`✅ 已安装并启动 launchd 服务 ${label}`);
|
|
993
|
+
console.log(` 端口 : ${port}`);
|
|
994
|
+
console.log(` 目录 : ${cwd}`);
|
|
995
|
+
console.log(` 访问 : http://localhost:${port}`);
|
|
996
|
+
console.log(` 日志 : /tmp/pi-web-ui.log /tmp/pi-web-ui.err`);
|
|
997
|
+
console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
|
|
998
|
+
console.log(` 提示 : pi-web-ui server shortcut 可在桌面创建「一键启动」图标`);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function installSystemd(opts) {
|
|
1002
|
+
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
1003
|
+
const content = buildUnit(cwd, serviceEnv(port, cwd, dataDir));
|
|
1004
|
+
const unitPath = systemdUnitPath(name);
|
|
1005
|
+
if (opts.print) {
|
|
1006
|
+
console.log(`# ${unitPath}\n${content}`);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
ensureRootForSystemctl();
|
|
1010
|
+
writeFileSync(unitPath, content);
|
|
1011
|
+
run("systemctl", ["daemon-reload"]);
|
|
1012
|
+
run("systemctl", ["enable", "--now", `${name}.service`]);
|
|
1013
|
+
console.log(`✅ 已安装并启动 systemd 服务 ${name}.service`);
|
|
1014
|
+
console.log(` 端口 : ${port}`);
|
|
1015
|
+
console.log(` 目录 : ${cwd}`);
|
|
1016
|
+
console.log(` 访问 : http://localhost:${port}`);
|
|
1017
|
+
console.log(` 日志 : journalctl -u ${name}.service -f`);
|
|
1018
|
+
console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
|
|
1019
|
+
console.log(` 提示 : pi-web-ui server shortcut 可在桌面创建「一键启动」图标`);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function uninstallLaunchd(opts) {
|
|
1023
|
+
const name = opts.name ?? "pi-web-ui";
|
|
1024
|
+
const label = serviceLabel(name);
|
|
1025
|
+
const plist = launchAgentPlist(name);
|
|
1026
|
+
run("launchctl", ["bootout", `gui/${uid()}/${label}`], {
|
|
1027
|
+
ignoreError: true,
|
|
1028
|
+
silent: true,
|
|
1029
|
+
});
|
|
1030
|
+
if (existsSync(plist)) rmSync(plist);
|
|
1031
|
+
removeShortcut(name);
|
|
1032
|
+
console.log(`🗑 已卸载 ${label}(plist 已删除,不再开机自启)`);
|
|
1033
|
+
console.log(`🗑 已移除桌面快捷方式`);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function uninstallSystemd(opts) {
|
|
1037
|
+
const name = opts.name ?? "pi-web-ui";
|
|
1038
|
+
ensureRootForSystemctl();
|
|
1039
|
+
run("systemctl", ["disable", "--now", `${name}.service`], {
|
|
1040
|
+
ignoreError: true,
|
|
1041
|
+
});
|
|
1042
|
+
const unitPath = systemdUnitPath(name);
|
|
1043
|
+
if (existsSync(unitPath)) rmSync(unitPath);
|
|
1044
|
+
run("systemctl", ["daemon-reload"]);
|
|
1045
|
+
removeShortcut(name);
|
|
1046
|
+
console.log(`🗑 已卸载 ${name}.service(不再开机自启)`);
|
|
1047
|
+
console.log(`🗑 已移除桌面快捷方式`);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function installWindows(opts) {
|
|
1051
|
+
const { name, port, cwd, dataDir } = serviceOptions(opts);
|
|
1052
|
+
const env = serviceEnv(port, cwd, dataDir);
|
|
1053
|
+
const ps1Path = winPs1Path(name);
|
|
1054
|
+
const xmlPath = winTaskXmlPath(name);
|
|
1055
|
+
const ps1 = buildWinStartPs1(env, cwd, winLogPath());
|
|
1056
|
+
const xml = buildWinTaskXml(ps1Path, cwd);
|
|
1057
|
+
if (opts.print) {
|
|
1058
|
+
console.log(`# ${ps1Path}\n${ps1}`);
|
|
1059
|
+
console.log(`# ${xmlPath}\n${xml}`);
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
mkdirSync(dirname(ps1Path), { recursive: true });
|
|
1063
|
+
// UTF-8 with BOM: Windows PowerShell 5.1 misreads BOM-less UTF-8 as ANSI.
|
|
1064
|
+
writeFileSync(ps1Path, "\uFEFF" + ps1, "utf8");
|
|
1065
|
+
// Remove the old-style .cmd wrapper from previous installs.
|
|
1066
|
+
if (existsSync(winCmdPath(name))) rmSync(winCmdPath(name));
|
|
1067
|
+
// schtasks /Create /XML requires a UTF-16 file (with BOM).
|
|
1068
|
+
writeFileSync(xmlPath, "\uFEFF" + xml, "utf16le");
|
|
1069
|
+
if (winTaskExists(name)) {
|
|
1070
|
+
run("schtasks", ["/Delete", "/TN", name, "/F"], {
|
|
1071
|
+
ignoreError: true,
|
|
1072
|
+
silent: true,
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
run("schtasks", ["/Create", "/TN", name, "/XML", xmlPath, "/F"]);
|
|
1076
|
+
run("schtasks", ["/Run", "/TN", name], { ignoreError: true });
|
|
1077
|
+
console.log(`✅ 已安装并启动计划任务 ${name}(隐藏窗口运行,无黑窗)`);
|
|
1078
|
+
console.log(` 端口 : ${port}`);
|
|
1079
|
+
console.log(` 目录 : ${cwd}`);
|
|
1080
|
+
console.log(` 访问 : http://localhost:${port}`);
|
|
1081
|
+
console.log(` 日志 : ${winLogPath()}`);
|
|
1082
|
+
console.log(
|
|
1083
|
+
` 说明 : 登录后自启(与 launchd 用户代理一致);stop 停止,uninstall 移除`,
|
|
1084
|
+
);
|
|
1085
|
+
console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
|
|
1086
|
+
console.log(` 提示 : pi-web-ui server shortcut 可在桌面创建「一键启动」图标`);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function uninstallWindows(opts) {
|
|
1090
|
+
const name = opts.name ?? "pi-web-ui";
|
|
1091
|
+
if (winTaskExists(name)) {
|
|
1092
|
+
run("schtasks", ["/Delete", "/TN", name, "/F"], { ignoreError: true });
|
|
1093
|
+
}
|
|
1094
|
+
for (const f of [winCmdPath(name), winPs1Path(name), winTaskXmlPath(name)]) {
|
|
1095
|
+
if (existsSync(f)) rmSync(f);
|
|
1096
|
+
}
|
|
1097
|
+
// 快捷方式启动的隐藏实例
|
|
1098
|
+
const pid = winReadPid(name);
|
|
1099
|
+
if (pid && pidAlive(pid)) {
|
|
1100
|
+
run("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
1101
|
+
ignoreError: true,
|
|
1102
|
+
silent: true,
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
removeShortcut(name);
|
|
1106
|
+
console.log(`🗑 已卸载 ${name}(计划任务已删除,不再自启)`);
|
|
1107
|
+
console.log(`🗑 已移除桌面快捷方式`);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// ---------------------------------------------------------------------------
|
|
1111
|
+
// ---------------------------------------------------------------------------
|
|
1112
|
+
// Local control socket (status / quiesce / unquiesce). The server listens on
|
|
1113
|
+
// a mode-0600 Unix socket (POSIX) or a named pipe (Windows) under its data
|
|
1114
|
+
// dir; same path rules as server/control-socket.ts so the CLI and server
|
|
1115
|
+
// always agree without sharing code.
|
|
1116
|
+
// ---------------------------------------------------------------------------
|
|
1117
|
+
|
|
1118
|
+
/** Resolve the control socket path for the given options. */
|
|
1119
|
+
function controlPath(opts) {
|
|
1120
|
+
const dir = opts.dataDir
|
|
1121
|
+
? resolve(opts.dataDir)
|
|
1122
|
+
: process.env.PI_WEB_DATA_DIR
|
|
1123
|
+
? resolve(process.env.PI_WEB_DATA_DIR)
|
|
1124
|
+
: join(homedir(), ".pi-web");
|
|
1125
|
+
return isWin
|
|
1126
|
+
? `\\\\.\\pipe\\pi-web-ui-${String(opts.port ?? process.env.PORT ?? "8787")}`
|
|
1127
|
+
: join(dir, "pi-web-ui.sock");
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** Send one control command to a RUNNING server; resolves null if unreachable. */
|
|
1131
|
+
function controlCommand(opts, cmd) {
|
|
1132
|
+
const path = controlPath(opts);
|
|
1133
|
+
return new Promise((resolvePromise) => {
|
|
1134
|
+
const sock = createConnection(path);
|
|
1135
|
+
let done = false;
|
|
1136
|
+
const finish = (v) => {
|
|
1137
|
+
if (done) return;
|
|
1138
|
+
done = true;
|
|
1139
|
+
clearTimeout(timer);
|
|
1140
|
+
sock.destroy();
|
|
1141
|
+
resolvePromise(v);
|
|
1142
|
+
};
|
|
1143
|
+
const timer = setTimeout(() => finish(null), 3000);
|
|
1144
|
+
let buf = "";
|
|
1145
|
+
sock.on("connect", () => sock.write(JSON.stringify({ cmd }) + "\n"));
|
|
1146
|
+
sock.on("data", (chunk) => {
|
|
1147
|
+
buf += chunk.toString("utf8");
|
|
1148
|
+
const nl = buf.indexOf("\n");
|
|
1149
|
+
if (nl >= 0) {
|
|
1150
|
+
try {
|
|
1151
|
+
finish(JSON.parse(buf.slice(0, nl)));
|
|
1152
|
+
} catch {
|
|
1153
|
+
finish(null);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
sock.on("error", () => finish(null));
|
|
1158
|
+
sock.on("close", () => finish(null));
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** Append the live server status (via the control socket) to `server status`. */
|
|
1163
|
+
async function printLiveStatus(opts) {
|
|
1164
|
+
const st = await controlCommand(opts, "status");
|
|
1165
|
+
if (!st || !st.ok) {
|
|
1166
|
+
console.log(" (服务器未运行或控制通道不可达 — 启动后可查 server status 实时信息)");
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
console.log(" --- 实时状态 (control socket) ---");
|
|
1170
|
+
console.log(` 版本 : ${st.version} · PID ${st.pid}`);
|
|
1171
|
+
console.log(` 目录 : ${st.cwd}`);
|
|
1172
|
+
console.log(
|
|
1173
|
+
` 排空 : ${st.quiesced ? `是(自 ${new Date(st.quiescedSince).toLocaleString()})` : "否"}`,
|
|
1174
|
+
);
|
|
1175
|
+
console.log(
|
|
1176
|
+
` 连接 : ${st.connectedClients} 个浏览器 · ${st.activeConversations} 个运行中对话 · ${st.pendingMessages} 条排队消息`,
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/** `server quiesce|unquiesce` — toggle the admission gate on a RUNNING server. */
|
|
1181
|
+
async function setQuiesce(opts, on) {
|
|
1182
|
+
const st = await controlCommand(opts, on ? "quiesce" : "unquiesce");
|
|
1183
|
+
if (!st || !st.ok) {
|
|
1184
|
+
fail(`服务器未运行或控制通道不可达(${controlPath(opts)})`);
|
|
1185
|
+
}
|
|
1186
|
+
console.log(
|
|
1187
|
+
on
|
|
1188
|
+
? "⏸ 已进入排空模式(quiesce):拒绝新的对话/消息/编辑,存量运行继续跑完。\n" +
|
|
1189
|
+
" 跑完后用 pi-web-ui server unquiesce 恢复。"
|
|
1190
|
+
: "▶ 已解除排空模式(unquiesce):恢复接收新的对话/消息/编辑。",
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function controlService(action, opts) {
|
|
1195
|
+
const name = opts.name ?? "pi-web-ui";
|
|
1196
|
+
|
|
1197
|
+
if (isMac) {
|
|
1198
|
+
const label = serviceLabel(name);
|
|
1199
|
+
const target = `gui/${uid()}/${label}`;
|
|
1200
|
+
const loaded = () =>
|
|
1201
|
+
spawnSync("launchctl", ["print", target], { stdio: "ignore" }).status ===
|
|
1202
|
+
0;
|
|
1203
|
+
|
|
1204
|
+
if (action === "status") {
|
|
1205
|
+
if (loaded()) {
|
|
1206
|
+
const res = spawnSync("launchctl", ["print", target], {
|
|
1207
|
+
encoding: "utf8",
|
|
1208
|
+
});
|
|
1209
|
+
const state = (res.stdout.match(/state = (\w+)/) ?? [])[1] ?? "loaded";
|
|
1210
|
+
console.log(`${label}: ${state}(已加载,开机自启中)`);
|
|
1211
|
+
} else {
|
|
1212
|
+
console.log(`${label}: 未安装(运行 pi-web-ui server install 安装)`);
|
|
1213
|
+
}
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (action === "start") {
|
|
1218
|
+
if (loaded()) {
|
|
1219
|
+
run("launchctl", ["kickstart", target]);
|
|
1220
|
+
} else {
|
|
1221
|
+
const plist = launchAgentPlist(name);
|
|
1222
|
+
if (!existsSync(plist)) {
|
|
1223
|
+
fail(`找不到 ${plist},请先运行 pi-web-ui server install`);
|
|
1224
|
+
}
|
|
1225
|
+
run("launchctl", ["bootstrap", `gui/${uid()}`, plist]);
|
|
1226
|
+
}
|
|
1227
|
+
console.log(`✅ 已启动 ${label}`);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
if (action === "restart") {
|
|
1232
|
+
if (!loaded()) fail(`${label} 未加载,请先 pi-web-ui server start`);
|
|
1233
|
+
run("launchctl", ["kickstart", "-k", target]);
|
|
1234
|
+
console.log(`✅ 已重启 ${label}`);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
if (action === "stop") {
|
|
1239
|
+
run("launchctl", ["bootout", target], {
|
|
1240
|
+
ignoreError: true,
|
|
1241
|
+
silent: true,
|
|
1242
|
+
});
|
|
1243
|
+
console.log(`⏹ 已停止 ${label}(已卸载,不再开机自启;start 恢复)`);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
fail(`未知操作: ${action}`);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (isLinux) {
|
|
1251
|
+
ensureRootForSystemctl();
|
|
1252
|
+
if (action === "status") {
|
|
1253
|
+
run("systemctl", ["status", `${name}.service`, "--no-pager"]);
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
run("systemctl", [action, `${name}.service`]);
|
|
1257
|
+
console.log(`✅ ${action} ${name}.service`);
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
if (isWin) {
|
|
1262
|
+
const exists = winTaskExists(name);
|
|
1263
|
+
|
|
1264
|
+
if (action === "status") {
|
|
1265
|
+
const pid = winReadPid(name);
|
|
1266
|
+
const instAlive = pid && pidAlive(pid);
|
|
1267
|
+
if (!exists) {
|
|
1268
|
+
console.log(`${name}: 未安装(运行 pi-web-ui server install 安装)`);
|
|
1269
|
+
if (instAlive) console.log(` 快捷方式实例 : 运行中 (PID ${pid})`);
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
// Get-ScheduledTask outputs English state enums — locale-independent,
|
|
1273
|
+
// unlike `schtasks /Query` tables on localized Windows.
|
|
1274
|
+
const ps = spawnSync(
|
|
1275
|
+
"powershell.exe",
|
|
1276
|
+
[
|
|
1277
|
+
"-NoProfile",
|
|
1278
|
+
"-NonInteractive",
|
|
1279
|
+
"-Command",
|
|
1280
|
+
"$t=Get-ScheduledTask -TaskName '" +
|
|
1281
|
+
name +
|
|
1282
|
+
"' -ErrorAction SilentlyContinue;" +
|
|
1283
|
+
"if(!$t){'NOT_INSTALLED';exit}" +
|
|
1284
|
+
"$i=$t|Get-ScheduledTaskInfo;" +
|
|
1285
|
+
"'State: '+$t.State;" +
|
|
1286
|
+
"'LastRunTime: '+$i.LastRunTime;" +
|
|
1287
|
+
"'LastTaskResult: '+$i.LastTaskResult",
|
|
1288
|
+
],
|
|
1289
|
+
{ encoding: "utf8" },
|
|
1290
|
+
);
|
|
1291
|
+
if (ps.status !== 0 || (ps.stdout ?? "").includes("NOT_INSTALLED")) {
|
|
1292
|
+
console.log(`${name}: 未安装(运行 pi-web-ui server install 安装)`);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
console.log(`${name}: 计划任务\n${(ps.stdout ?? "").trim()}`);
|
|
1296
|
+
if (pid) {
|
|
1297
|
+
if (instAlive) console.log(` 快捷方式实例 : 运行中 (PID ${pid})`);
|
|
1298
|
+
else console.log(` 快捷方式实例 : 已退出 (PID ${pid})`);
|
|
1299
|
+
}
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
if (action === "start") {
|
|
1304
|
+
if (!exists) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
|
|
1305
|
+
run("schtasks", ["/Run", "/TN", name]);
|
|
1306
|
+
console.log(`✅ 已启动 ${name}`);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
if (action === "restart") {
|
|
1311
|
+
if (!exists) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
|
|
1312
|
+
run("schtasks", ["/End", "/TN", name], {
|
|
1313
|
+
ignoreError: true,
|
|
1314
|
+
silent: true,
|
|
1315
|
+
});
|
|
1316
|
+
run("schtasks", ["/Run", "/TN", name]);
|
|
1317
|
+
console.log(`✅ 已重启 ${name}`);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
if (action === "stop") {
|
|
1322
|
+
run("schtasks", ["/End", "/TN", name], {
|
|
1323
|
+
ignoreError: true,
|
|
1324
|
+
silent: true,
|
|
1325
|
+
});
|
|
1326
|
+
const pid = winReadPid(name);
|
|
1327
|
+
if (pid) {
|
|
1328
|
+
if (pidAlive(pid)) {
|
|
1329
|
+
run("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
1330
|
+
ignoreError: true,
|
|
1331
|
+
silent: true,
|
|
1332
|
+
});
|
|
1333
|
+
console.log(`⏹ 已停止快捷方式实例 (PID ${pid})`);
|
|
1334
|
+
}
|
|
1335
|
+
rmSync(winPidFilePath(name), { force: true });
|
|
1336
|
+
}
|
|
1337
|
+
console.log(`⏹ 已停止 ${name}(自启保留;uninstall 移除)`);
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
fail(`未知操作: ${action}`);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
fail(
|
|
1345
|
+
`不支持的系统服务平台: ${process.platform}(仅 macOS / Linux / Windows)`,
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// ---------------------------------------------------------------------------
|
|
1350
|
+
// 界面插件管理(<dataDir>/plugins/,从 GitHub 安装)
|
|
1351
|
+
// ---------------------------------------------------------------------------
|
|
1352
|
+
|
|
1353
|
+
/** 合法插件 id(同 server/plugins.ts 的 ID_RE)。 */
|
|
1354
|
+
const PLUGIN_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
1355
|
+
|
|
1356
|
+
const PLUGIN_HELP = `用法:
|
|
1357
|
+
pi-web-ui install <源> [选项] 安装 GitHub 上的界面插件
|
|
1358
|
+
pi-web-ui uninstall <id> [选项] 卸载已安装的界面插件
|
|
1359
|
+
pi-web-ui plugins [选项] 列出已安装的界面插件
|
|
1360
|
+
|
|
1361
|
+
源写法(任选其一):
|
|
1362
|
+
owner/repo 简写
|
|
1363
|
+
https://github.com/owner/repo 完整 URL(.git 可省)
|
|
1364
|
+
https://github.com/o/r/tree/dev/sub/dir 指定分支 + 仓库内子目录
|
|
1365
|
+
以上任意写法末尾加 #分支或tag 指定分支/tag(如 owner/repo#v1.2)
|
|
1366
|
+
/path/to/plugin-dir 本地目录直接安装(开发调试用)
|
|
1367
|
+
|
|
1368
|
+
install 选项:
|
|
1369
|
+
--name <id> 插件目录名/id(默认取仓库名或 manifest.id,仅限字母数字-_)
|
|
1370
|
+
--data-dir <dir> 数据目录(默认 ~/.pi-web 或 $PI_WEB_DATA_DIR)
|
|
1371
|
+
--force 目标目录已存在时覆盖(覆盖前自动备份旧版本)
|
|
1372
|
+
|
|
1373
|
+
plugins 选项:
|
|
1374
|
+
--check-updates 逐个对比最近安装版本与远端 HEAD,列出可更新插件
|
|
1375
|
+
--rollback <id> 回滚到最近一份更新前备份(<dataDir>/plugin-backups/)
|
|
1376
|
+
`;
|
|
1377
|
+
|
|
1378
|
+
function pluginDataDir(opts) {
|
|
1379
|
+
return resolve(opts.dataDir ?? process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/** 解析安装源为 { owner, repo, ref, subpath, cloneUrl } 或本地路径;非法输入直接退出。 */
|
|
1383
|
+
function parsePluginSource(rawSpec) {
|
|
1384
|
+
let spec = rawSpec.trim();
|
|
1385
|
+
let ref;
|
|
1386
|
+
const hash = spec.indexOf("#");
|
|
1387
|
+
if (hash >= 0) {
|
|
1388
|
+
ref = spec.slice(hash + 1).trim();
|
|
1389
|
+
if (!ref) fail(`无效的源 "${rawSpec}":# 后缺少分支/tag 名`);
|
|
1390
|
+
spec = spec.slice(0, hash).replace(/\/+$/, "");
|
|
1391
|
+
}
|
|
1392
|
+
// ssh 形式转 https 拉取(不要求本机配 ssh key);URL 去掉协议前缀统一按路径段解析
|
|
1393
|
+
const ssh = spec.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
|
|
1394
|
+
if (ssh) [, , spec] = ssh;
|
|
1395
|
+
else {
|
|
1396
|
+
const url = spec.match(/^https?:\/\/(?:www\.)?github\.com\/(.+?)(?:\.git)?\/?$/i);
|
|
1397
|
+
if (url) [, spec] = url;
|
|
1398
|
+
}
|
|
1399
|
+
const segs = spec.split("/").filter(Boolean);
|
|
1400
|
+
if (segs.length < 2)
|
|
1401
|
+
fail(`无法识别的插件源 "${rawSpec}"\n${PLUGIN_HELP}`);
|
|
1402
|
+
for (const s of segs) {
|
|
1403
|
+
if (s === "." || s === "..") fail(`无效的源 "${rawSpec}":路径段不能是 . 或 ..`);
|
|
1404
|
+
}
|
|
1405
|
+
const [owner, repo] = segs;
|
|
1406
|
+
let subpath;
|
|
1407
|
+
if (segs[2] === "tree" || segs[2] === "blob") {
|
|
1408
|
+
if (!ref && segs.length > 3) ref = segs[3];
|
|
1409
|
+
subpath = segs.slice(4).join("/") || undefined;
|
|
1410
|
+
} else if (segs.length > 2) {
|
|
1411
|
+
subpath = segs.slice(2).join("/"); // owner/repo/sub/dir —— 子目录写法
|
|
1412
|
+
}
|
|
1413
|
+
return { owner, repo, ref, subpath, cloneUrl: `https://github.com/${owner}/${repo}.git` };
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/** 把仓库拉到 tmpDir 并返回检出根目录。优先 git clone --depth 1,失败回退 codeload tarball + 系统 tar。 */
|
|
1417
|
+
async function acquireRepo(src, tmpDir) {
|
|
1418
|
+
const dst = join(tmpDir, "src");
|
|
1419
|
+
const hasGit = spawnSync("git", ["--version"], { stdio: "ignore", timeout: 10_000 }).status === 0;
|
|
1420
|
+
if (hasGit) {
|
|
1421
|
+
const args = ["clone", "--depth", "1", "--single-branch"];
|
|
1422
|
+
if (src.ref) args.push("--branch", src.ref);
|
|
1423
|
+
args.push(src.cloneUrl, dst);
|
|
1424
|
+
console.log(`· git clone --depth 1 ${src.cloneUrl}${src.ref ? ` (${src.ref})` : ""}`);
|
|
1425
|
+
const res = spawnSync("git", args, {
|
|
1426
|
+
stdio: "inherit",
|
|
1427
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" },
|
|
1428
|
+
timeout: 300_000,
|
|
1429
|
+
});
|
|
1430
|
+
if (res.status === 0 && existsSync(dst)) return dst;
|
|
1431
|
+
console.log("· git clone 失败,回退到 tarball 直连下载…");
|
|
1432
|
+
}
|
|
1433
|
+
const url = `https://codeload.github.com/${src.owner}/${src.repo}/tar.gz/${src.ref || "HEAD"}`;
|
|
1434
|
+
console.log(`· 下载 ${url}`);
|
|
1435
|
+
// 注意:这里不用 fail()/process.exit —— async 上下文里还有未关闭的 socket 时
|
|
1436
|
+
// 直接退出会触发 Windows libuv "UV_HANDLE_CLOSING" 断言崩溃;改为 throw,
|
|
1437
|
+
// 由 pluginInstallCmd 捕获后设 exitCode 让事件循环自然排空。
|
|
1438
|
+
let res;
|
|
1439
|
+
try {
|
|
1440
|
+
res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) });
|
|
1441
|
+
} catch (err) {
|
|
1442
|
+
throw new Error(`下载失败:${err?.message ?? err}\n 请检查网络/代理后重试。`);
|
|
1443
|
+
}
|
|
1444
|
+
if (!res.ok)
|
|
1445
|
+
throw new Error(
|
|
1446
|
+
`下载失败 HTTP ${res.status}:${url}` +
|
|
1447
|
+
(res.status === 404
|
|
1448
|
+
? "\n 仓库/分支不存在,或为私有仓库(私有仓库请先在本机配置好 git 凭据再重试,会优先走 git clone)。"
|
|
1449
|
+
: ""),
|
|
1450
|
+
);
|
|
1451
|
+
writeFileSync(join(tmpDir, "src.tar.gz"), Buffer.from(await res.arrayBuffer()));
|
|
1452
|
+
const extractTo = join(tmpDir, "tar");
|
|
1453
|
+
mkdirSync(extractTo, { recursive: true });
|
|
1454
|
+
// 相对路径解压:win32 的 GNU tar 会把 "C:\..." 里的 C: 当远程主机(Cannot connect to C:)
|
|
1455
|
+
const tarRes = spawnSync("tar", ["-xzf", "src.tar.gz", "-C", "tar"], {
|
|
1456
|
+
cwd: tmpDir,
|
|
1457
|
+
stdio: "inherit",
|
|
1458
|
+
});
|
|
1459
|
+
if (tarRes.status !== 0) fail("tar 解压失败(可重试,或手动下载 release 包解压)");
|
|
1460
|
+
const entries = readdirSync(extractTo);
|
|
1461
|
+
if (entries.length !== 1) fail("tarball 解压结果异常(顶层应只有一个目录)");
|
|
1462
|
+
return join(extractTo, entries[0]);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/** 在检出树里找包含 manifest.json 的目录(深度 ≤3,跳过 .git/node_modules)。 */
|
|
1466
|
+
function findManifestDirs(root) {
|
|
1467
|
+
const hits = [];
|
|
1468
|
+
const walk = (dir, depth) => {
|
|
1469
|
+
if (existsSync(join(dir, "manifest.json"))) {
|
|
1470
|
+
hits.push(dir);
|
|
1471
|
+
return; // 目录本身是插件就不再往下搜嵌套插件
|
|
1472
|
+
}
|
|
1473
|
+
if (depth >= 3) return;
|
|
1474
|
+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
1475
|
+
if (!ent.isDirectory() || ent.name === ".git" || ent.name === "node_modules") continue;
|
|
1476
|
+
walk(join(dir, ent.name), depth + 1);
|
|
1477
|
+
}
|
|
1478
|
+
};
|
|
1479
|
+
walk(root, 0);
|
|
1480
|
+
return hits;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/** 定位插件根目录:显式子路径 > 根目录 manifest > 全树搜索(唯一命中才继续)。 */
|
|
1484
|
+
function locatePluginRoot(checkout, subpath, repoLabel) {
|
|
1485
|
+
if (subpath) {
|
|
1486
|
+
const dir = join(checkout, ...subpath.split("/"));
|
|
1487
|
+
if (!existsSync(join(dir, "manifest.json")))
|
|
1488
|
+
fail(`子目录 "${subpath}" 里没有 manifest.json`);
|
|
1489
|
+
return dir;
|
|
1490
|
+
}
|
|
1491
|
+
if (existsSync(join(checkout, "manifest.json"))) return checkout;
|
|
1492
|
+
const hits = findManifestDirs(checkout);
|
|
1493
|
+
if (hits.length === 0)
|
|
1494
|
+
fail(`"${repoLabel}" 里没找到 manifest.json —— 不是 pi-web-ui 界面插件`);
|
|
1495
|
+
if (hits.length > 1)
|
|
1496
|
+
fail(
|
|
1497
|
+
`${repoLabel} 里有多个插件(多个 manifest.json),请用子目录写法指定其中一个:\n ` +
|
|
1498
|
+
hits.map((h) => `${repoLabel}/${relative(checkout, h).split(/[\\/]/).join("/")}`).join("\n "),
|
|
1499
|
+
);
|
|
1500
|
+
console.log(`· 插件位于子目录: ${relative(checkout, hits[0]).split(/[\\/]/).join("/")}`);
|
|
1501
|
+
return hits[0];
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
async function pluginInstallCmd(argv) {
|
|
1505
|
+
const { opts, positionals } = parseFlags(argv);
|
|
1506
|
+
if (opts.help) {
|
|
1507
|
+
console.log(PLUGIN_HELP);
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
if (positionals.length !== 1)
|
|
1511
|
+
fail(`用法: pi-web-ui install <源> [--name <id>] [--data-dir <dir>] [--force]\n${PLUGIN_HELP}`);
|
|
1512
|
+
const rawSpec = positionals[0];
|
|
1513
|
+
const pluginsDir = join(pluginDataDir(opts), "plugins");
|
|
1514
|
+
// 本地目录直接装(离线开发调试),否则从 GitHub 拉取
|
|
1515
|
+
const localCandidate = resolve(rawSpec.replace(/^file:\/\//, ""));
|
|
1516
|
+
const isLocal = existsSync(localCandidate);
|
|
1517
|
+
const src = isLocal ? null : parsePluginSource(rawSpec);
|
|
1518
|
+
const tmp = mkdtempSync(join(tmpdir(), "pi-web-ui-plugin-"));
|
|
1519
|
+
let backupTs = null;
|
|
1520
|
+
try {
|
|
1521
|
+
let checkout;
|
|
1522
|
+
try {
|
|
1523
|
+
checkout = isLocal ? localCandidate : await acquireRepo(src, tmp);
|
|
1524
|
+
} catch (err) {
|
|
1525
|
+
console.error(`✖ ${err?.message ?? err}`);
|
|
1526
|
+
process.exitCode = 1;
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
const repoLabel = isLocal ? localCandidate : `${src.owner}/${src.repo}`;
|
|
1530
|
+
const pluginRoot = locatePluginRoot(checkout, src?.subpath, repoLabel);
|
|
1531
|
+
let manifest;
|
|
1532
|
+
try {
|
|
1533
|
+
manifest = JSON.parse(readFileSync(join(pluginRoot, "manifest.json"), "utf8"));
|
|
1534
|
+
} catch (err) {
|
|
1535
|
+
fail(`manifest.json 不是合法 JSON:${err?.message ?? err}`);
|
|
1536
|
+
}
|
|
1537
|
+
// 默认 id:子目录名 > 仓库名 > 本地目录名
|
|
1538
|
+
const sourceName = src?.subpath
|
|
1539
|
+
? src.subpath.split("/").pop()
|
|
1540
|
+
: (src?.repo ?? localCandidate.split(/[\\/]/).pop());
|
|
1541
|
+
const fallbackId =
|
|
1542
|
+
String(manifest.id ?? sourceName)
|
|
1543
|
+
.replace(/[^A-Za-z0-9_-]/g, "-")
|
|
1544
|
+
.replace(/^-+|-+$/g, "") || "plugin";
|
|
1545
|
+
const id = opts.name ?? fallbackId;
|
|
1546
|
+
if (!PLUGIN_ID_RE.test(id))
|
|
1547
|
+
fail(`非法插件 id "${id}"(仅限字母数字-_,可用 --name <id> 自定义)`);
|
|
1548
|
+
const target = join(pluginsDir, id);
|
|
1549
|
+
let prevConfig = null;
|
|
1550
|
+
const CONFIG_NAME = "config.json";
|
|
1551
|
+
if (existsSync(target)) {
|
|
1552
|
+
if (!opts.force)
|
|
1553
|
+
fail(`插件目录已存在:${target}\n 加 --force 覆盖,或用 --name <id> 换个名字。`);
|
|
1554
|
+
// 更新前备份旧版本(<dataDir>/plugin-backups/<id>-<ts>/,保留最近 3 份),
|
|
1555
|
+
// 失败时自动回滚。备份与安装同 filter:不带 .git/node_modules。
|
|
1556
|
+
backupTs = ensurePluginBackup(pluginDataDir(opts), id, { source: rawSpec });
|
|
1557
|
+
// 插件凭据/配置不因升级丢失:先取出旧 config.json,拷完新文件后原样放回
|
|
1558
|
+
try {
|
|
1559
|
+
prevConfig = readFileSync(join(target, CONFIG_NAME), "utf8");
|
|
1560
|
+
} catch {
|
|
1561
|
+
/* 无配置文件 */
|
|
1562
|
+
}
|
|
1563
|
+
rmSync(target, { recursive: true, force: true });
|
|
1564
|
+
}
|
|
1565
|
+
mkdirSync(target, { recursive: true });
|
|
1566
|
+
try {
|
|
1567
|
+
cpSync(pluginRoot, target, {
|
|
1568
|
+
recursive: true,
|
|
1569
|
+
filter: (s) => !/(^|[\\/])(\.git|node_modules)([\\/]|$)/.test(s),
|
|
1570
|
+
});
|
|
1571
|
+
} catch (err) {
|
|
1572
|
+
// 拷贝失败 → 有备份则自动回滚,保持旧版本可用
|
|
1573
|
+
if (backupTs && restorePluginBackup(pluginDataDir(opts), id)) {
|
|
1574
|
+
fail(`插件更新失败:${err?.message ?? err}\n 已自动回滚到更新前版本。`);
|
|
1575
|
+
}
|
|
1576
|
+
fail(`插件更新失败:${err?.message ?? err}\n (无可用备份,请重新 install --force)`);
|
|
1577
|
+
}
|
|
1578
|
+
if (prevConfig !== null && !existsSync(join(target, CONFIG_NAME))) {
|
|
1579
|
+
writeFileSync(join(target, CONFIG_NAME), prevConfig);
|
|
1580
|
+
}
|
|
1581
|
+
// 记录安装来源:设置面板「更新」按钮据此重跑同一条安装命令(--force 覆盖)。
|
|
1582
|
+
try {
|
|
1583
|
+
writeFileSync(
|
|
1584
|
+
join(target, ".pi-source.json"),
|
|
1585
|
+
JSON.stringify({ source: rawSpec }, null, 2) + "\n",
|
|
1586
|
+
);
|
|
1587
|
+
} catch {
|
|
1588
|
+
/* 尽力而为:没有来源信息只是不显示更新按钮 */
|
|
1589
|
+
}
|
|
1590
|
+
// 记录本次安装的远端 sha(git ls-remote HEAD,离线也支持本地 git 源):
|
|
1591
|
+
// 供 `pi-web-ui plugins --check-updates` 对比更新。失败静默(无 sha = 保守可更新)。
|
|
1592
|
+
try {
|
|
1593
|
+
const sha = await resolveRemoteSha(rawSpec);
|
|
1594
|
+
if (sha) writeFileSync(join(target, ".pi-git-sha"), sha + "\n");
|
|
1595
|
+
} catch {
|
|
1596
|
+
/* 尽力而为 */
|
|
1597
|
+
}
|
|
1598
|
+
console.log(
|
|
1599
|
+
`✔ 已安装插件 ${id}${manifest.name && manifest.name !== id ? `(${manifest.name})` : ""}${manifest.version ? ` v${manifest.version}` : ""}`,
|
|
1600
|
+
);
|
|
1601
|
+
if (manifest.description) console.log(` ${manifest.description}`);
|
|
1602
|
+
console.log(` 位置: ${target}`);
|
|
1603
|
+
console.log(` 生效: 服务运行中刷新浏览器即可加载;未运行则下次启动生效。卸载: pi-web-ui uninstall ${id}`);
|
|
1604
|
+
} finally {
|
|
1605
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function pluginUninstallCmd(argv) {
|
|
1610
|
+
const { opts, positionals } = parseFlags(argv);
|
|
1611
|
+
if (opts.help || positionals.length !== 1) {
|
|
1612
|
+
console.log(PLUGIN_HELP);
|
|
1613
|
+
if (!opts.help) process.exit(1);
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
const id = positionals[0];
|
|
1617
|
+
if (!PLUGIN_ID_RE.test(id)) fail(`非法插件 id: ${id}`);
|
|
1618
|
+
const target = join(pluginDataDir(opts), "plugins", id);
|
|
1619
|
+
if (!existsSync(target)) fail(`未安装插件 "${id}"(pi-web-ui plugins 查看已装列表)`);
|
|
1620
|
+
rmSync(target, { recursive: true, force: true });
|
|
1621
|
+
console.log(`✔ 已卸载插件 ${id} —— 运行中的服务刷新浏览器后消失。`);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
function pluginListCmd(argv) {
|
|
1625
|
+
const { opts, positionals } = parseFlags(argv);
|
|
1626
|
+
if (opts.help) {
|
|
1627
|
+
console.log(PLUGIN_HELP);
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
const dataDir = pluginDataDir(opts);
|
|
1631
|
+
// --rollback <id>:回滚到最近一份更新前备份
|
|
1632
|
+
if (opts.rollback) {
|
|
1633
|
+
const id = String(opts.rollback);
|
|
1634
|
+
if (!PLUGIN_ID_RE.test(id)) fail(`非法插件 id: ${id}`);
|
|
1635
|
+
const target = join(dataDir, "plugins", id);
|
|
1636
|
+
if (!existsSync(target)) fail(`未安装插件 "${id}"(pi-web-ui plugins 查看已装列表)`);
|
|
1637
|
+
const ts = restorePluginBackup(dataDir, id);
|
|
1638
|
+
if (!ts) fail(`插件 "${id}" 没有更新备份(从未覆盖安装 / 备份已用完)`);
|
|
1639
|
+
console.log(`✔ 已回滚插件 ${id} 到 ${ts} 的快照 —— 运行中的服务刷新浏览器后生效。`);
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
// --check-updates:对比各插件记录的最后安装 sha 与远端 HEAD(git ls-remote)
|
|
1643
|
+
if (opts.checkUpdates) {
|
|
1644
|
+
return checkUpdatesCmd(dataDir).then(() => {});
|
|
1645
|
+
}
|
|
1646
|
+
const pluginsDir = join(dataDir, "plugins");
|
|
1647
|
+
const rows = [];
|
|
1648
|
+
let names = [];
|
|
1649
|
+
try {
|
|
1650
|
+
names = readdirSync(pluginsDir).sort();
|
|
1651
|
+
} catch {
|
|
1652
|
+
/* 目录不存在 = 未安装任何插件 */
|
|
1653
|
+
}
|
|
1654
|
+
for (const n of names) {
|
|
1655
|
+
if (!PLUGIN_ID_RE.test(n)) continue;
|
|
1656
|
+
try {
|
|
1657
|
+
const m = JSON.parse(readFileSync(join(pluginsDir, n, "manifest.json"), "utf8"));
|
|
1658
|
+
rows.push(` ${n.padEnd(24)} ${[m.name, m.version ? `v${m.version}` : "", m.description].filter(Boolean).join(" ")}`);
|
|
1659
|
+
} catch {
|
|
1660
|
+
continue; // 坏目录跳过
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
if (rows.length === 0) {
|
|
1664
|
+
console.log(`尚未安装任何界面插件(目录: ${pluginsDir})\n安装示例: pi-web-ui install owner/repo`);
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
console.log(`已安装的界面插件(${pluginsDir}):\n${rows.join("\n")}`);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
async function checkUpdatesCmd(dataDir) {
|
|
1671
|
+
console.log("检查界面插件更新(git ls-remote 对比最近安装版本)…\n");
|
|
1672
|
+
let rows;
|
|
1673
|
+
try {
|
|
1674
|
+
rows = await checkPluginUpdates(dataDir);
|
|
1675
|
+
} catch (err) {
|
|
1676
|
+
fail(`更新检查失败:${err?.message ?? err}`);
|
|
1677
|
+
}
|
|
1678
|
+
if (rows.length === 0) {
|
|
1679
|
+
console.log(`尚未安装任何带来源记录的界面插件(目录: ${join(dataDir, "plugins")})`);
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
let any = false;
|
|
1683
|
+
for (const r of rows) {
|
|
1684
|
+
const label = r.name && r.name !== r.id ? `${r.id}(${r.name})` : r.id;
|
|
1685
|
+
if (r.updatable) {
|
|
1686
|
+
console.log(` 🔄 ${label}${r.version ? ` v${r.version}` : ""} 可更新(已装 ${r.localSha ?? "未知"} → 远端 ${r.remoteSha})`);
|
|
1687
|
+
console.log(` 更新: pi-web-ui install ${r.source} --name ${r.id} --force`);
|
|
1688
|
+
any = true;
|
|
1689
|
+
} else if (r.remoteSha) {
|
|
1690
|
+
console.log(` ✓ ${label}${r.version ? ` v${r.version}` : ""} 已是最新(${r.remoteSha})`);
|
|
1691
|
+
} else {
|
|
1692
|
+
console.log(` ? ${label} ${r.error ?? "无法检查"}(来源: ${r.source})`);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
if (!any) console.log("\n全部插件均为最新版本。");
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
async function serverCmd(argv) {
|
|
1699
|
+
const { opts, positionals } = parseFlags(argv);
|
|
1700
|
+
if (opts.help) {
|
|
1701
|
+
console.log(HELP);
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
if (positionals.length === 0) {
|
|
1705
|
+
console.log(HELP);
|
|
1706
|
+
console.log("--- 当前服务状态 ---");
|
|
1707
|
+
controlService("status", opts);
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
const action = positionals[0];
|
|
1711
|
+
if (positionals.length > 1)
|
|
1712
|
+
fail(`多余的参数: ${positionals.slice(1).join(" ")}`);
|
|
1713
|
+
switch (action) {
|
|
1714
|
+
case "shortcut": {
|
|
1715
|
+
if (isWin) {
|
|
1716
|
+
installWinShortcut(opts);
|
|
1717
|
+
} else if (isMac) {
|
|
1718
|
+
installMacShortcut(opts);
|
|
1719
|
+
} else if (isLinux) {
|
|
1720
|
+
installLinuxShortcut(opts);
|
|
1721
|
+
} else {
|
|
1722
|
+
fail(`不支持的系统服务平台: ${process.platform}`);
|
|
1723
|
+
}
|
|
1724
|
+
break;
|
|
1725
|
+
}
|
|
1726
|
+
case "install": {
|
|
1727
|
+
if (isMac) {
|
|
1728
|
+
installLaunchd(opts);
|
|
1729
|
+
} else if (isLinux) {
|
|
1730
|
+
installSystemd(opts);
|
|
1731
|
+
} else if (isWin) {
|
|
1732
|
+
installWindows(opts);
|
|
1733
|
+
} else {
|
|
1734
|
+
fail(`不支持的系统服务平台: ${process.platform}`);
|
|
1735
|
+
}
|
|
1736
|
+
break;
|
|
1737
|
+
}
|
|
1738
|
+
case "uninstall": {
|
|
1739
|
+
if (isMac) {
|
|
1740
|
+
uninstallLaunchd(opts);
|
|
1741
|
+
} else if (isLinux) {
|
|
1742
|
+
uninstallSystemd(opts);
|
|
1743
|
+
} else if (isWin) {
|
|
1744
|
+
uninstallWindows(opts);
|
|
1745
|
+
} else {
|
|
1746
|
+
fail(`不支持的系统服务平台: ${process.platform}`);
|
|
1747
|
+
}
|
|
1748
|
+
break;
|
|
1749
|
+
}
|
|
1750
|
+
case "start":
|
|
1751
|
+
case "stop":
|
|
1752
|
+
case "restart":
|
|
1753
|
+
controlService(action, opts);
|
|
1754
|
+
break;
|
|
1755
|
+
case "status":
|
|
1756
|
+
controlService("status", opts);
|
|
1757
|
+
await printLiveStatus(opts);
|
|
1758
|
+
break;
|
|
1759
|
+
case "quiesce":
|
|
1760
|
+
await setQuiesce(opts, true);
|
|
1761
|
+
break;
|
|
1762
|
+
case "unquiesce":
|
|
1763
|
+
await setQuiesce(opts, false);
|
|
1764
|
+
break;
|
|
1765
|
+
default:
|
|
1766
|
+
fail(
|
|
1767
|
+
`未知操作: ${action}(install / shortcut / uninstall / start / stop / restart / status / quiesce / unquiesce)`,
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
async function main() {
|
|
1773
|
+
checkNodeVersion();
|
|
1774
|
+
const argv = process.argv.slice(2);
|
|
1775
|
+
if (argv.length === 0) {
|
|
1776
|
+
await startForeground({});
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
const first = argv[0];
|
|
1780
|
+
if (first === "--version" || first === "-v") {
|
|
1781
|
+
console.log(pkg.version);
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
if (first === "--help" || first === "-h") {
|
|
1785
|
+
console.log(HELP);
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
if (first === "server") {
|
|
1789
|
+
await serverCmd(argv.slice(1));
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
if (first === "install") {
|
|
1793
|
+
await pluginInstallCmd(argv.slice(1));
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
if (first === "uninstall") {
|
|
1797
|
+
pluginUninstallCmd(argv.slice(1));
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
if (first === "plugins" || first === "plugin") {
|
|
1801
|
+
pluginListCmd(argv.slice(1));
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
// One-shot server with optional --port/--cwd/--data-dir overrides.
|
|
1805
|
+
const { opts, positionals } = parseFlags(argv);
|
|
1806
|
+
if (opts.help) {
|
|
1807
|
+
console.log(HELP);
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
if (positionals.length > 0)
|
|
1811
|
+
fail(`未知命令: ${positionals[0]}(--help 查看用法)`);
|
|
1812
|
+
await startForeground(opts);
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
main().catch((err) => {
|
|
1816
|
+
console.error(`✖ ${err instanceof Error ? err.message : String(err)}`);
|
|
1817
|
+
process.exit(1);
|
|
1818
|
+
});
|