ellm-proxy 0.0.3 → 0.0.6

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.
Files changed (53) hide show
  1. package/README.md +60 -60
  2. package/bin/commands/autostart.js +37 -213
  3. package/bin/commands/config.js +8 -8
  4. package/bin/commands/pm2.js +28 -28
  5. package/bin/commands/service.js +107 -107
  6. package/bin/ellm-proxy.js +61 -61
  7. package/dist/server.js +33 -2
  8. package/dist/web-ui/assets/DashboardView-BHG4xIdN.css +1 -0
  9. package/dist/web-ui/assets/{DashboardView-B71Dy3px.js → DashboardView-C3Hw-HpG.js} +1 -1
  10. package/dist/web-ui/assets/LayoutView-BUA8-IzK.css +1 -0
  11. package/dist/web-ui/assets/LayoutView-C9phf7in.js +1 -0
  12. package/dist/web-ui/assets/LoginView-DrhfslIY.css +1 -0
  13. package/dist/web-ui/assets/LoginView-es6pSPgt.js +1 -0
  14. package/dist/web-ui/assets/{LogsView-B2ctrpv4.js → LogsView-BlXITEJH.js} +1 -1
  15. package/dist/web-ui/assets/ProcessView-Bgf7-MLQ.js +1 -0
  16. package/dist/web-ui/assets/ProcessView-DK0TvDpR.css +1 -0
  17. package/dist/web-ui/assets/{ProvidersView-mb0KU16n.js → ProvidersView-f8aZkMdw.js} +1 -1
  18. package/dist/web-ui/assets/SettingsView-BkwyoOUb.css +1 -0
  19. package/dist/web-ui/assets/SettingsView-GvAA2vLp.js +1 -0
  20. package/dist/web-ui/assets/config-Cs3_z63C.js +1 -0
  21. package/dist/web-ui/assets/{index-dPeMJjrz.js → index-GohB4JDa.js} +2 -2
  22. package/dist/web-ui/assets/{providers-Dmz2p4Wd.js → providers-Djtdy2tv.js} +1 -1
  23. package/dist/web-ui/assets/{useApi-C1Ngt_o5.js → useApi-D_iJ2Mca.js} +1 -1
  24. package/dist/web-ui/assets/useManagerApi-C1_COxKg.js +1 -0
  25. package/dist/web-ui/assets/{useToast-B3quFZyv.js → useToast-C0UMTbkx.js} +1 -1
  26. package/dist/web-ui/config.js +10 -10
  27. package/dist/web-ui/index.html +13 -13
  28. package/docs/DESIGN.md +99 -99
  29. package/manager/api/harness/backup.js +20 -20
  30. package/manager/api/harness/claude-code.js +54 -54
  31. package/manager/api/harness/codex.js +123 -123
  32. package/manager/api/harness/dsh.js +175 -175
  33. package/manager/api/harness/index.js +118 -118
  34. package/manager/api/service-settings.js +98 -0
  35. package/manager/api/service.js +3 -13
  36. package/manager/index.js +182 -170
  37. package/package.json +4 -1
  38. package/utils/autostart.js +219 -0
  39. package/utils/interpreter.js +14 -14
  40. package/utils/paths.js +45 -44
  41. package/utils/pm2.js +130 -130
  42. package/utils/service.js +205 -189
  43. package/utils/updater.js +210 -0
  44. package/dist/web-ui/assets/DashboardView-qrQrkgLA.css +0 -1
  45. package/dist/web-ui/assets/LayoutView-C03GVB3X.js +0 -1
  46. package/dist/web-ui/assets/LoginView-Dg0mB6ok.css +0 -1
  47. package/dist/web-ui/assets/LoginView-XTF5dHvd.js +0 -1
  48. package/dist/web-ui/assets/ProcessView-DmbVABQV.js +0 -1
  49. package/dist/web-ui/assets/ProcessView-DzMqBJmd.css +0 -1
  50. package/dist/web-ui/assets/SettingsView-BDkymsrS.css +0 -1
  51. package/dist/web-ui/assets/SettingsView-BgLQVC2H.js +0 -1
  52. package/dist/web-ui/assets/config-BRaXq0LL.js +0 -1
  53. package/dist/web-ui/assets/useManagerApi-BDWJz5o6.js +0 -1
@@ -0,0 +1,219 @@
1
+ // 开机自启核心(跨平台,登录时触发,均无需管理员权限):状态探测 / 注册 / 移除 / 首启自愈。
2
+ // 被 CLI(bin/commands/autostart.js)与管理服务(manager/api/service-settings.js)复用。
3
+ // 本模块只做平台操作并返回数据,人读文案由调用方输出;错误以 throw 抛出(文案是契约的一部分)。
4
+ // 自启项内容三平台一致:登录时执行 <nodePath> <包内 pm2 bin> resurrect(恢复最后一次 pm2 dump):
5
+ // Windows: 启动文件夹 ellm-proxy-autostart.cmd(shell:startup——schtasks onlogon 需管理员,实测被拒)
6
+ // macOS: LaunchAgent ~/Library/LaunchAgents/com.ellm-proxy.autostart.plist(launchctl load)
7
+ // Linux: systemd user unit ~/.config/systemd/user/ellm-proxy.service(systemctl --user enable)
8
+ // opt-out 标记(<appDir>/autostart.opt-out):用户显式关闭过自启后,首启自愈不再自动重开
9
+ // (尊重用户选择);enableAutostart 成功时清除,disableAutostart 成功时写入。
10
+ import { writeFileSync, rmSync, existsSync, mkdirSync } from "node:fs";
11
+ import { homedir, platform } from "node:os";
12
+ import { spawn } from "node:child_process";
13
+ import { join } from "node:path";
14
+ import { defaultAppDir } from "./paths.js";
15
+ import { resolveInterpreter } from "./interpreter.js";
16
+ import { pm2BinJs } from "./pm2.js";
17
+
18
+ const AUTOSTART_CMD = "ellm-proxy-autostart.cmd"; // Windows
19
+ const PLIST_LABEL = "com.ellm-proxy.autostart"; // macOS
20
+ const LINUX_UNIT = "ellm-proxy.service"; // Linux
21
+ const OPT_OUT_FILE = "autostart.opt-out"; // appDir 下的 opt-out 标记文件名
22
+
23
+ /** opt-out 标记路径(用户显式关闭过自启的持久标记,首启自愈据此跳过) */
24
+ function optOutPath() {
25
+ return join(defaultAppDir, OPT_OUT_FILE);
26
+ }
27
+
28
+ /** 用户启动文件夹(Windows shell:startup):登录时自动执行其中的脚本 */
29
+ function windowsStartupDir() {
30
+ const dir = process.env["APPDATA"];
31
+ if (!dir) throw new Error("无法定位启动文件夹(APPDATA 环境变量未设置)。");
32
+ return join(dir, "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
33
+ }
34
+
35
+ /** spawn 执行外部命令,返回退出码;进程无法启动(ENOENT 等)返回 127 */
36
+ function runExit(cmd, args) {
37
+ return new Promise((resolvePromise) => {
38
+ const child = spawn(cmd, args, { stdio: "ignore", shell: false });
39
+ child.on("error", () => resolvePromise(127));
40
+ child.on("exit", (code) => resolvePromise(code ?? 1));
41
+ });
42
+ }
43
+
44
+ /** 各平台自启项路径(探测用;未知平台为 null) */
45
+ function platformItemPath() {
46
+ switch (platform()) {
47
+ case "win32":
48
+ return join(windowsStartupDir(), AUTOSTART_CMD);
49
+ case "darwin":
50
+ return join(homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
51
+ case "linux":
52
+ return join(homedir(), ".config", "systemd", "user", LINUX_UNIT);
53
+ default:
54
+ return null;
55
+ }
56
+ }
57
+
58
+ // —— 注册(各平台) ————————————————————————————————————————————————
59
+
60
+ async function windowsOn(nodePath, pm2Js) {
61
+ // cmd 内容全 ASCII(含中文的注释交给外部帮助输出,避免 cmd 在 GBK 代码页下乱码)
62
+ const body = [
63
+ "@echo off",
64
+ `rem Auto-generated by "ellm-proxy autostart on". Disable with "ellm-proxy autostart off".`,
65
+ `"${nodePath}" "${pm2Js}" resurrect >nul 2>&1`,
66
+ "",
67
+ ].join("\r\n");
68
+ const itemPath = join(windowsStartupDir(), AUTOSTART_CMD);
69
+ writeFileSync(itemPath, body, "utf-8");
70
+ return itemPath;
71
+ }
72
+
73
+ async function darwinOn(nodePath, pm2Js) {
74
+ const itemPath = join(homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
75
+ const logPath = join(defaultAppDir, "logs", "autostart.log");
76
+ mkdirSync(join(defaultAppDir, "logs"), { recursive: true });
77
+ const body = [
78
+ '<?xml version="1.0" encoding="UTF-8"?>',
79
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
80
+ '<plist version="1.0">',
81
+ "<dict>",
82
+ ` <key>Label</key><string>${PLIST_LABEL}</string>`,
83
+ " <key>ProgramArguments</key>",
84
+ " <array>",
85
+ ` <string>${nodePath}</string>`,
86
+ ` <string>${pm2Js}</string>`,
87
+ " <string>resurrect</string>",
88
+ " </array>",
89
+ " <key>RunAtLoad</key><true/>",
90
+ ` <key>StandardOutPath</key><string>${logPath}</string>`,
91
+ ` <key>StandardErrorPath</key><string>${logPath}</string>`,
92
+ "</dict>",
93
+ "</plist>",
94
+ "",
95
+ ].join("\n");
96
+ writeFileSync(itemPath, body, "utf-8");
97
+ // 先卸载旧定义再加载(幂等;未加载过时 unload 报错属正常)
98
+ await runExit("launchctl", ["unload", itemPath]);
99
+ const code = await runExit("launchctl", ["load", itemPath]);
100
+ if (code !== 0) {
101
+ throw new Error(`launchctl load 失败(退出码 ${code})。请手动执行: launchctl load "${itemPath}"`);
102
+ }
103
+ return itemPath;
104
+ }
105
+
106
+ async function linuxOn(nodePath, pm2Js) {
107
+ const itemPath = join(homedir(), ".config", "systemd", "user", LINUX_UNIT);
108
+ mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
109
+ const body = [
110
+ "[Unit]",
111
+ "Description=ellm-proxy autostart (pm2 resurrect)",
112
+ "After=default.target",
113
+ "",
114
+ "[Service]",
115
+ "Type=oneshot",
116
+ `ExecStart="${nodePath}" "${pm2Js}" resurrect`,
117
+ "",
118
+ "[Install]",
119
+ "WantedBy=default.target",
120
+ "",
121
+ ].join("\n");
122
+ writeFileSync(itemPath, body, "utf-8");
123
+ const reload = await runExit("systemctl", ["--user", "daemon-reload"]);
124
+ if (reload !== 0) {
125
+ throw new Error(
126
+ `systemctl --user daemon-reload 失败(退出码 ${reload})。当前环境可能无 systemd(WSL/容器),请手动启用或删除: ${itemPath}`,
127
+ );
128
+ }
129
+ const enable = await runExit("systemctl", ["--user", "enable", LINUX_UNIT]);
130
+ if (enable !== 0) {
131
+ throw new Error(
132
+ `systemctl --user enable 失败(退出码 ${enable})。请手动执行: systemctl --user enable ${LINUX_UNIT}`,
133
+ );
134
+ }
135
+ return itemPath;
136
+ }
137
+
138
+ // —— 移除(各平台) ————————————————————————————————————————————————
139
+
140
+ async function windowsOff() {
141
+ const itemPath = join(windowsStartupDir(), AUTOSTART_CMD);
142
+ if (!existsSync(itemPath)) return false;
143
+ rmSync(itemPath, { force: true });
144
+ return true;
145
+ }
146
+
147
+ async function darwinOff() {
148
+ const itemPath = join(homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
149
+ if (!existsSync(itemPath)) return false;
150
+ await runExit("launchctl", ["unload", itemPath]);
151
+ rmSync(itemPath, { force: true });
152
+ return true;
153
+ }
154
+
155
+ async function linuxOff() {
156
+ const itemPath = join(homedir(), ".config", "systemd", "user", LINUX_UNIT);
157
+ if (!existsSync(itemPath)) return false;
158
+ await runExit("systemctl", ["--user", "disable", LINUX_UNIT]); // 容错:未启用过时报错不影响删除
159
+ rmSync(itemPath, { force: true });
160
+ await runExit("systemctl", ["--user", "daemon-reload"]);
161
+ return true;
162
+ }
163
+
164
+ // —— 对外 API ——————————————————————————————————————————————————————
165
+
166
+ /** 自启状态:enabled = 平台自启项是否存在;itemPath 未知平台为 null */
167
+ export async function autostartStatus() {
168
+ const itemPath = platformItemPath();
169
+ return {
170
+ platform: platform(),
171
+ enabled: itemPath ? existsSync(itemPath) : false,
172
+ itemPath,
173
+ /** 用户是否显式关闭过(首启自愈据此跳过) */
174
+ optOut: existsSync(optOutPath()),
175
+ };
176
+ }
177
+
178
+ /** 注册自启项(幂等:重复注册覆盖旧文件;macOS 先 unload 再 load)。成功返回 { itemPath } */
179
+ export async function enableAutostart() {
180
+ const pf = platform();
181
+ if (pf !== "win32" && pf !== "darwin" && pf !== "linux") {
182
+ throw new Error(`暂不支持的平台: ${pf}。可改用 pm2 自带的 pm2 startup。`);
183
+ }
184
+ const nodePath = await resolveInterpreter();
185
+ const pm2Js = pm2BinJs();
186
+ const itemPath = await (pf === "win32" ? windowsOn : pf === "darwin" ? darwinOn : linuxOn)(nodePath, pm2Js);
187
+ rmSync(optOutPath(), { force: true }); // 显式开启 = 清除 opt-out,首启自愈不再被视为已关闭
188
+ return { itemPath };
189
+ }
190
+
191
+ /** 移除自启项。返回 removed(此前是否存在);成功后写 opt-out 标记(首启自愈不再自动重开) */
192
+ export async function disableAutostart() {
193
+ const pf = platform();
194
+ if (pf !== "win32" && pf !== "darwin" && pf !== "linux") {
195
+ throw new Error(`暂不支持的平台: ${pf}。可改用 pm2 自带的 pm2 startup。`);
196
+ }
197
+ const removed = await (pf === "win32" ? windowsOff : pf === "darwin" ? darwinOff : linuxOff)();
198
+ mkdirSync(defaultAppDir, { recursive: true });
199
+ writeFileSync(optOutPath(), new Date().toISOString(), "utf-8");
200
+ return { removed };
201
+ }
202
+
203
+ /** 首启自愈(doStart 末尾静默调用):自启项已存在或用户 opt-out 过则跳过,否则注册并提示一句。
204
+ * 失败不抛出(不阻塞启动,console.warn 提示手动路径)。 */
205
+ export async function ensureAutostartOnStart() {
206
+ try {
207
+ const status = await autostartStatus();
208
+ if (!status.itemPath || status.enabled) return; // 未知平台 / 已注册
209
+ if (status.optOut) {
210
+ console.log("已按此前设置保持开机自启关闭(如需开启:ellm-proxy autostart on 或管理台设置页)。");
211
+ return;
212
+ }
213
+ const { itemPath } = await enableAutostart();
214
+ console.log(`已自动注册开机自启(登录时 pm2 resurrect): ${itemPath}`);
215
+ console.log("禁用方式: ellm-proxy autostart off 或管理台设置页。");
216
+ } catch (err) {
217
+ console.warn(`开机自启注册失败(不影响本次启动): ${err?.message ?? err}`);
218
+ }
219
+ }
@@ -1,14 +1,14 @@
1
- // 服务解释器解析:保证 dist/server.js(node:sqlite)运行在 Node >= MIN_NODE 上。
2
- // CLI 自身 node 满足 → process.execPath;否则 get-node 解析(缺失时下载一次并缓存),
3
- // pm2 daemon 的 node 版本无关紧要(interpreter 由本模块保证)。
4
- import getNode from "get-node";
5
- import { MIN_NODE } from "./paths.js";
6
-
7
- export async function resolveInterpreter() {
8
- const [maj, min] = process.version.replace(/^v/, "").split(".").map(Number);
9
- if (maj > 22 || (maj === 22 && min >= 13)) return process.execPath;
10
- console.log(`当前 node ${process.version} 不满足 ${MIN_NODE},正在解析可用版本(缺失时自动下载一次并缓存)...`);
11
- const { path, version } = await getNode(MIN_NODE, { progress: true });
12
- console.log(`服务将使用 node ${version}: ${path}`);
13
- return path;
14
- }
1
+ // 服务解释器解析:保证 dist/server.js(node:sqlite)运行在 Node >= MIN_NODE 上。
2
+ // CLI 自身 node 满足 → process.execPath;否则 get-node 解析(缺失时下载一次并缓存),
3
+ // pm2 daemon 的 node 版本无关紧要(interpreter 由本模块保证)。
4
+ import getNode from "get-node";
5
+ import { MIN_NODE, FALLBACK_NODE } from "./paths.js";
6
+
7
+ export async function resolveInterpreter() {
8
+ const [maj, min] = process.version.replace(/^v/, "").split(".").map(Number);
9
+ if (maj > 22 || (maj === 22 && min >= 13)) return process.execPath;
10
+ console.log(`当前 node ${process.version} 不满足 ${MIN_NODE},正在解析可用版本(缺失时自动下载一次并缓存)...`);
11
+ const { path, version } = await getNode(FALLBACK_NODE, { progress: true });
12
+ console.log(`服务将使用 node ${version}: ${path}`);
13
+ return path;
14
+ }
package/utils/paths.js CHANGED
@@ -1,44 +1,45 @@
1
- // 共享常量与路径工具:包根/应用目录常量、配置查找链、首次配置自愈、选项绝对化。
2
- // 供 CLI(bin/)与管理服务(manager/)两侧复用;工具一律返回数据,人读文案由调用方输出。
3
- import { readFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
4
- import { homedir } from "node:os";
5
- import { dirname, join, resolve } from "node:path";
6
- import { fileURLToPath } from "node:url";
7
-
8
- /** 本文件在 utils/ 下,包根为上一级 */
9
- export const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
10
- export const defaultAppDir = join(homedir(), ".ellm-proxy");
11
- export const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf-8"));
12
- export const APP_NAME = "ellm-proxy"; // 主服务(dist/server.js)的 pm2 进程名
13
- export const MANAGER_APP_NAME = "ellm-proxy-manager"; // 管理服务(manager/index.js)的 pm2 进程名
14
- export const MIN_NODE = ">=22.13.0"; // node:sqlite 最低版(与包 engines 一致)
15
-
16
- /** 配置文件路径:--config > ELLM_CONFIG 环境变量 > ~/.ellm-proxy/ellm.config.json(与 server 端查找链一致) */
17
- export function configPath(override) {
18
- if (override) return resolve(override);
19
- return process.env["ELLM_CONFIG"] ? resolve(process.env["ELLM_CONFIG"]) : join(defaultAppDir, "ellm.config.json");
20
- }
21
-
22
- /** 首次配置自愈:不存在则从包内示例复制创建,并提示两种修改方式 */
23
- export function ensureConfig(override) {
24
- const config = configPath(override);
25
- if (existsSync(config)) return config;
26
- const example = join(pkgRoot, "ellm.config.example.json");
27
- if (!existsSync(example)) {
28
- throw new Error(`缺少示例配置 ${example},无法自动创建配置文件`);
29
- }
30
- mkdirSync(dirname(config), { recursive: true });
31
- copyFileSync(example, config);
32
- console.log(`已创建配置文件: ${config}`);
33
- console.log("可直接编辑该文件(填入供应商 apiKey),或经管理台在线修改(实时热更新)。");
34
- return config;
35
- }
36
-
37
- /** 路径类选项统一转绝对路径(相对按调用方当前目录展开;CLI cwd 与 node 进程工作目录基准不同,透传相对串会错位) */
38
- export function absolutize(opts) {
39
- const out = { ...opts };
40
- for (const key of ["config", "dataDir", "workingDir", "serverJs"]) {
41
- if (out[key]) out[key] = resolve(out[key]);
42
- }
43
- return out;
44
- }
1
+ // 共享常量与路径工具:包根/应用目录常量、配置查找链、首次配置自愈、选项绝对化。
2
+ // 供 CLI(bin/)与管理服务(manager/)两侧复用;工具一律返回数据,人读文案由调用方输出。
3
+ import { readFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ /** 本文件在 utils/ 下,包根为上一级 */
9
+ export const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
10
+ export const defaultAppDir = join(homedir(), ".ellm-proxy");
11
+ export const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf-8"));
12
+ export const APP_NAME = "ellm-proxy"; // 主服务(dist/server.js)的 pm2 进程名
13
+ export const MANAGER_APP_NAME = "ellm-proxy-manager"; // 管理服务(manager/index.js)的 pm2 进程名
14
+ export const MIN_NODE = ">=22.13.0"; // node:sqlite 最低版(与包 engines 一致)
15
+ export const FALLBACK_NODE = "22"; // 兜底解释器锁定的主版本(满足 MIN_NODE,与 nve 22 口径一致)
16
+
17
+ /** 配置文件路径:--config > ELLM_CONFIG 环境变量 > ~/.ellm-proxy/ellm.config.json(与 server 端查找链一致) */
18
+ export function configPath(override) {
19
+ if (override) return resolve(override);
20
+ return process.env["ELLM_CONFIG"] ? resolve(process.env["ELLM_CONFIG"]) : join(defaultAppDir, "ellm.config.json");
21
+ }
22
+
23
+ /** 首次配置自愈:不存在则从包内示例复制创建,并提示两种修改方式 */
24
+ export function ensureConfig(override) {
25
+ const config = configPath(override);
26
+ if (existsSync(config)) return config;
27
+ const example = join(pkgRoot, "ellm.config.example.json");
28
+ if (!existsSync(example)) {
29
+ throw new Error(`缺少示例配置 ${example},无法自动创建配置文件`);
30
+ }
31
+ mkdirSync(dirname(config), { recursive: true });
32
+ copyFileSync(example, config);
33
+ console.log(`已创建配置文件: ${config}`);
34
+ console.log("可直接编辑该文件(填入供应商 apiKey),或经管理台在线修改(实时热更新)。");
35
+ return config;
36
+ }
37
+
38
+ /** 路径类选项统一转绝对路径(相对按调用方当前目录展开;CLI cwd 与 node 进程工作目录基准不同,透传相对串会错位) */
39
+ export function absolutize(opts) {
40
+ const out = { ...opts };
41
+ for (const key of ["config", "dataDir", "workingDir", "serverJs"]) {
42
+ if (out[key]) out[key] = resolve(out[key]);
43
+ }
44
+ return out;
45
+ }
package/utils/pm2.js CHANGED
@@ -1,130 +1,130 @@
1
- // pm2 JS API 封装:连接包裹/回调转 promise/应用查询/探活轮询/安全 dump/包内 pm2 CLI 定位/env 白名单。
2
- import { rmSync } from "node:fs";
3
- import { homedir } from "node:os";
4
- import { dirname, join } from "node:path";
5
- import { createRequire } from "node:module";
6
- import pm2 from "pm2";
7
- import { APP_NAME, MANAGER_APP_NAME } from "./paths.js";
8
- import { dot, endOk, endFail } from "./ui.js";
9
-
10
- /** pm2.connect 包裹:fn 内可调 pm2.xxx(回调式),结束后自动 disconnect */
11
- export function withPm2(fn) {
12
- return new Promise((resolvePromise, reject) => {
13
- pm2.connect((connectErr) => {
14
- if (connectErr) return reject(connectErr);
15
- Promise.resolve(fn(pm2)).then(
16
- (value) => pm2.disconnect(() => resolvePromise(value)),
17
- (err) => {
18
- try {
19
- pm2.disconnect();
20
- } catch {
21
- /* 已断开 */
22
- }
23
- reject(err);
24
- },
25
- );
26
- });
27
- });
28
- }
29
-
30
- /** pm2 回调式方法转 promise */
31
- export function pm2cb(method, ...args) {
32
- return new Promise((resolvePromise, reject) => {
33
- pm2[method](...args, (err, ...out) => (err ? reject(err) : resolvePromise(...out)));
34
- });
35
- }
36
-
37
- /** 查询应用当前进程记录(list 中按 name 匹配;不存在返回 undefined)。
38
- * name 缺省 = 主服务 ellm-proxy;管理服务传 MANAGER_APP_NAME */
39
- export async function findApp(name = APP_NAME) {
40
- const list = (await pm2cb("list")) || [];
41
- return list.find((p) => p.name === name);
42
- }
43
-
44
- /** 启动探活:轮询 status 至 online(每 pollMs 一次,全程打进度点)——比固定等待更快成功
45
- * (online 即返回),也能容忍慢启动(默认 15s 上限,固定 1.5s 在慢机器上会误报);errored/stopped
46
- * 提前失败(脚本秒崩不必等满超时)。failHint 是失败文案的线索部分(文案是契约的一部分)。
47
- * quiet = 静默轮询不打点(多服务并行等待时输出由调用方合并,避免多组进度点交错)。 */
48
- export async function waitOnline(name, failHint, { timeoutMs = 15000, pollMs = 300, quiet = false } = {}) {
49
- const deadline = Date.now() + timeoutMs;
50
- let last = null;
51
- for (;;) {
52
- last = await findApp(name);
53
- const status = last?.pm2_env?.status;
54
- if (status === "online") {
55
- if (!quiet) endOk();
56
- return last;
57
- }
58
- if (status === "errored" || status === "stopped" || Date.now() >= deadline) {
59
- if (!quiet) endFail();
60
- throw new Error(`${name} 启动后状态异常(${status ?? "已退出"})。${failHint}`);
61
- }
62
- if (!quiet) dot();
63
- await new Promise((r) => setTimeout(r, pollMs));
64
- }
65
- }
66
-
67
- /** 安全 dump(start/stop 成功后调用):pm2 不允许 dump 空列表会报错——此时说明全部进程已删,
68
- * 清掉旧 dump.pm2 与滚动备份 dump.pm2.bak(resurrect 无 dump.pm2 时会回落 .bak),防止开机
69
- * resurrect 把已停止的服务拉起来 */
70
- export async function safeDump() {
71
- try {
72
- await pm2cb("dump");
73
- } catch {
74
- try {
75
- rmSync(join(homedir(), ".pm2", "dump.pm2"), { force: true });
76
- rmSync(join(homedir(), ".pm2", "dump.pm2.bak"), { force: true });
77
- } catch {
78
- /* 清理失败不影响 stop 语义 */
79
- }
80
- }
81
- }
82
-
83
- /** 当前包内 pm2 CLI 入口绝对路径(autostart cmd 用;resolve 兼容 npm 全局平铺与 pnpm 布局) */
84
- export function pm2BinJs() {
85
- const pm2PkgRoot = dirname(createRequire(import.meta.url).resolve("pm2/package.json"));
86
- return join(pm2PkgRoot, "bin", "pm2");
87
- }
88
-
89
- /** 清洗 pm2 子进程透传的 env:pm2 托管的进程自身 env 带 pm_id/pm2_env/unique_id 等
90
- * 内部变量与 bash 注入的 `_`,原样传回 pm2.start 会污染新进程注册——实测主服务
91
- * (name=ellm-proxy)被注册成管理服务的名字,导致探活误判「已退出」。
92
- * 黑名单清洗实测仍有漏网(多键组合触发),故改用白名单:
93
- * 只透传主服务必需的业务键(ELLM_* 前缀,含 ELLM_PORT / ELLM_MANAGER_PORT)与 Windows node 运行所需的系统键。
94
- * PM2_HOME 保留(pm2 API 连接 daemon 需要,主服务自身不用)。 */
95
- const PM2_ENV_WHITELIST = new Set([
96
- "PATH",
97
- "SYSTEMROOT",
98
- "WINDIR",
99
- "SYSTEMDRIVE",
100
- "COMSPEC",
101
- "PATHEXT",
102
- "OS",
103
- "TEMP",
104
- "TMP",
105
- "USERPROFILE",
106
- "HOMEDRIVE",
107
- "HOMEPATH",
108
- "APPDATA",
109
- "LOCALAPPDATA",
110
- "PROGRAMDATA",
111
- "PROGRAMFILES",
112
- "ALLUSERSPROFILE",
113
- "PUBLIC",
114
- "USERNAME",
115
- "USERDOMAIN",
116
- "COMPUTERNAME",
117
- "NUMBER_OF_PROCESSORS",
118
- "HTTP_PROXY",
119
- "HTTPS_PROXY",
120
- "NO_PROXY",
121
- ]);
122
-
123
- export function sanitizePm2Env(env) {
124
- const out = {};
125
- for (const [key, value] of Object.entries(env)) {
126
- if (key === "PM2_HOME" || key.startsWith("ELLM_")) out[key] = value;
127
- else if (PM2_ENV_WHITELIST.has(key.toUpperCase())) out[key] = value;
128
- }
129
- return out;
130
- }
1
+ // pm2 JS API 封装:连接包裹/回调转 promise/应用查询/探活轮询/安全 dump/包内 pm2 CLI 定位/env 白名单。
2
+ import { rmSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { createRequire } from "node:module";
6
+ import pm2 from "pm2";
7
+ import { APP_NAME, MANAGER_APP_NAME } from "./paths.js";
8
+ import { dot, endOk, endFail } from "./ui.js";
9
+
10
+ /** pm2.connect 包裹:fn 内可调 pm2.xxx(回调式),结束后自动 disconnect */
11
+ export function withPm2(fn) {
12
+ return new Promise((resolvePromise, reject) => {
13
+ pm2.connect((connectErr) => {
14
+ if (connectErr) return reject(connectErr);
15
+ Promise.resolve(fn(pm2)).then(
16
+ (value) => pm2.disconnect(() => resolvePromise(value)),
17
+ (err) => {
18
+ try {
19
+ pm2.disconnect();
20
+ } catch {
21
+ /* 已断开 */
22
+ }
23
+ reject(err);
24
+ },
25
+ );
26
+ });
27
+ });
28
+ }
29
+
30
+ /** pm2 回调式方法转 promise */
31
+ export function pm2cb(method, ...args) {
32
+ return new Promise((resolvePromise, reject) => {
33
+ pm2[method](...args, (err, ...out) => (err ? reject(err) : resolvePromise(...out)));
34
+ });
35
+ }
36
+
37
+ /** 查询应用当前进程记录(list 中按 name 匹配;不存在返回 undefined)。
38
+ * name 缺省 = 主服务 ellm-proxy;管理服务传 MANAGER_APP_NAME */
39
+ export async function findApp(name = APP_NAME) {
40
+ const list = (await pm2cb("list")) || [];
41
+ return list.find((p) => p.name === name);
42
+ }
43
+
44
+ /** 启动探活:轮询 status 至 online(每 pollMs 一次,全程打进度点)——比固定等待更快成功
45
+ * (online 即返回),也能容忍慢启动(默认 15s 上限,固定 1.5s 在慢机器上会误报);errored/stopped
46
+ * 提前失败(脚本秒崩不必等满超时)。failHint 是失败文案的线索部分(文案是契约的一部分)。
47
+ * quiet = 静默轮询不打点(多服务并行等待时输出由调用方合并,避免多组进度点交错)。 */
48
+ export async function waitOnline(name, failHint, { timeoutMs = 15000, pollMs = 300, quiet = false } = {}) {
49
+ const deadline = Date.now() + timeoutMs;
50
+ let last = null;
51
+ for (;;) {
52
+ last = await findApp(name);
53
+ const status = last?.pm2_env?.status;
54
+ if (status === "online") {
55
+ if (!quiet) endOk();
56
+ return last;
57
+ }
58
+ if (status === "errored" || status === "stopped" || Date.now() >= deadline) {
59
+ if (!quiet) endFail();
60
+ throw new Error(`${name} 启动后状态异常(${status ?? "已退出"})。${failHint}`);
61
+ }
62
+ if (!quiet) dot();
63
+ await new Promise((r) => setTimeout(r, pollMs));
64
+ }
65
+ }
66
+
67
+ /** 安全 dump(start/stop 成功后调用):pm2 不允许 dump 空列表会报错——此时说明全部进程已删,
68
+ * 清掉旧 dump.pm2 与滚动备份 dump.pm2.bak(resurrect 无 dump.pm2 时会回落 .bak),防止开机
69
+ * resurrect 把已停止的服务拉起来 */
70
+ export async function safeDump() {
71
+ try {
72
+ await pm2cb("dump");
73
+ } catch {
74
+ try {
75
+ rmSync(join(homedir(), ".pm2", "dump.pm2"), { force: true });
76
+ rmSync(join(homedir(), ".pm2", "dump.pm2.bak"), { force: true });
77
+ } catch {
78
+ /* 清理失败不影响 stop 语义 */
79
+ }
80
+ }
81
+ }
82
+
83
+ /** 当前包内 pm2 CLI 入口绝对路径(autostart cmd 用;resolve 兼容 npm 全局平铺与 pnpm 布局) */
84
+ export function pm2BinJs() {
85
+ const pm2PkgRoot = dirname(createRequire(import.meta.url).resolve("pm2/package.json"));
86
+ return join(pm2PkgRoot, "bin", "pm2");
87
+ }
88
+
89
+ /** 清洗 pm2 子进程透传的 env:pm2 托管的进程自身 env 带 pm_id/pm2_env/unique_id 等
90
+ * 内部变量与 bash 注入的 `_`,原样传回 pm2.start 会污染新进程注册——实测主服务
91
+ * (name=ellm-proxy)被注册成管理服务的名字,导致探活误判「已退出」。
92
+ * 黑名单清洗实测仍有漏网(多键组合触发),故改用白名单:
93
+ * 只透传主服务必需的业务键(ELLM_* 前缀,含 ELLM_PORT / ELLM_MANAGER_PORT)与 Windows node 运行所需的系统键。
94
+ * PM2_HOME 保留(pm2 API 连接 daemon 需要,主服务自身不用)。 */
95
+ const PM2_ENV_WHITELIST = new Set([
96
+ "PATH",
97
+ "SYSTEMROOT",
98
+ "WINDIR",
99
+ "SYSTEMDRIVE",
100
+ "COMSPEC",
101
+ "PATHEXT",
102
+ "OS",
103
+ "TEMP",
104
+ "TMP",
105
+ "USERPROFILE",
106
+ "HOMEDRIVE",
107
+ "HOMEPATH",
108
+ "APPDATA",
109
+ "LOCALAPPDATA",
110
+ "PROGRAMDATA",
111
+ "PROGRAMFILES",
112
+ "ALLUSERSPROFILE",
113
+ "PUBLIC",
114
+ "USERNAME",
115
+ "USERDOMAIN",
116
+ "COMPUTERNAME",
117
+ "NUMBER_OF_PROCESSORS",
118
+ "HTTP_PROXY",
119
+ "HTTPS_PROXY",
120
+ "NO_PROXY",
121
+ ]);
122
+
123
+ export function sanitizePm2Env(env) {
124
+ const out = {};
125
+ for (const [key, value] of Object.entries(env)) {
126
+ if (key === "PM2_HOME" || key.startsWith("ELLM_")) out[key] = value;
127
+ else if (PM2_ENV_WHITELIST.has(key.toUpperCase())) out[key] = value;
128
+ }
129
+ return out;
130
+ }