ellm-proxy 0.0.3 → 0.0.7

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 (58) 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/credentials.js +100 -0
  5. package/bin/commands/pm2.js +28 -28
  6. package/bin/commands/service.js +107 -107
  7. package/bin/ellm-proxy.js +64 -61
  8. package/dist/server.js +141 -4
  9. package/dist/web-ui/assets/DashboardView-BCwrFrE2.js +1 -0
  10. package/dist/web-ui/assets/DashboardView-BHG4xIdN.css +1 -0
  11. package/dist/web-ui/assets/LayoutView-BUA8-IzK.css +1 -0
  12. package/dist/web-ui/assets/LayoutView-BePJfgia.js +1 -0
  13. package/dist/web-ui/assets/LoginView-Dp7NmXtf.js +1 -0
  14. package/dist/web-ui/assets/LoginView-DrhfslIY.css +1 -0
  15. package/dist/web-ui/assets/{LogsView-B2ctrpv4.js → LogsView-Dyy_zkgg.js} +2 -2
  16. package/dist/web-ui/assets/ProcessView-CpPpMtFg.js +1 -0
  17. package/dist/web-ui/assets/ProcessView-DK0TvDpR.css +1 -0
  18. package/dist/web-ui/assets/{ProvidersView-mb0KU16n.js → ProvidersView-CYFs76wJ.js} +2 -2
  19. package/dist/web-ui/assets/SettingsView-BkwyoOUb.css +1 -0
  20. package/dist/web-ui/assets/SettingsView-mPcaVAD2.js +1 -0
  21. package/dist/web-ui/assets/SetupView-DX6S_KpK.js +1 -0
  22. package/dist/web-ui/assets/SetupView-kPJXJ7dX.css +1 -0
  23. package/dist/web-ui/assets/config-CRWaUFQc.js +1 -0
  24. package/dist/web-ui/assets/{index-dPeMJjrz.js → index-X5PdkJlA.js} +28 -28
  25. package/dist/web-ui/assets/{providers-Dmz2p4Wd.js → providers-H_9x778D.js} +1 -1
  26. package/dist/web-ui/assets/useManagerApi-C1_COxKg.js +1 -0
  27. package/dist/web-ui/assets/{useToast-B3quFZyv.js → useToast-MtbkGJiM.js} +1 -1
  28. package/dist/web-ui/config.js +10 -10
  29. package/dist/web-ui/index.html +13 -13
  30. package/docs/DESIGN.md +99 -99
  31. package/ellm.config.example.json +2 -0
  32. package/manager/api/harness/backup.js +20 -20
  33. package/manager/api/harness/claude-code.js +54 -54
  34. package/manager/api/harness/codex.js +123 -123
  35. package/manager/api/harness/dsh.js +175 -175
  36. package/manager/api/harness/index.js +118 -118
  37. package/manager/api/service-settings.js +98 -0
  38. package/manager/api/service.js +3 -13
  39. package/manager/index.js +182 -170
  40. package/package.json +4 -1
  41. package/utils/autostart.js +219 -0
  42. package/utils/interpreter.js +14 -14
  43. package/utils/paths.js +45 -44
  44. package/utils/pm2.js +130 -130
  45. package/utils/service.js +205 -189
  46. package/utils/updater.js +233 -0
  47. package/dist/web-ui/assets/DashboardView-B71Dy3px.js +0 -1
  48. package/dist/web-ui/assets/DashboardView-qrQrkgLA.css +0 -1
  49. package/dist/web-ui/assets/LayoutView-C03GVB3X.js +0 -1
  50. package/dist/web-ui/assets/LoginView-Dg0mB6ok.css +0 -1
  51. package/dist/web-ui/assets/LoginView-XTF5dHvd.js +0 -1
  52. package/dist/web-ui/assets/ProcessView-DmbVABQV.js +0 -1
  53. package/dist/web-ui/assets/ProcessView-DzMqBJmd.css +0 -1
  54. package/dist/web-ui/assets/SettingsView-BDkymsrS.css +0 -1
  55. package/dist/web-ui/assets/SettingsView-BgLQVC2H.js +0 -1
  56. package/dist/web-ui/assets/config-BRaXq0LL.js +0 -1
  57. package/dist/web-ui/assets/useApi-C1Ngt_o5.js +0 -1
  58. package/dist/web-ui/assets/useManagerApi-BDWJz5o6.js +0 -1
package/manager/index.js CHANGED
@@ -1,170 +1,182 @@
1
- // ellm-proxy 管理服务入口(独立常驻脚本):随 `ellm-proxy start` 一并由 pm2 托管(name=ellm-proxy-manager);
2
- // 也可手动 `node manager/index.js` 运行。本文件 = 创建 app(中间件 + api 聚合挂载)+ 启动 HTTP 服务;
3
- // 各 api 域实现在 api/ 下:service.js(进程控制,命名对齐 backend/src/api/service.ts)、harness/(代理开关域)。
4
- //
5
- // 为什么独立于 backend:ellm-proxy 与 backend 同进程,进程自杀后管理台与转发层同时失联,
6
- // 无法返回操作结果——启停必须由活在外面的本服务执行。
7
- //
8
- // 管理界面托管:本服务同进程托管 web-ui 构建产物(根路径 /,hash 路由),并反代 /api/* 到主服务——
9
- // web-ui 全部接口(/api/* 管理台 + /status /restart /harness 等进程/开关域)同源可达,config.js 留空零配置。
10
- //
11
- // 接口(CORS 全放开,均返回 {ok, ...} 或 {ok:false, error}):
12
- // GET / web-ui 管理界面(静态托管,产物缺失时仅告警跳过)
13
- // GET /api/* 反代主服务管理台 API(端口 = ELLM_PORT env > 配置文件 port,每请求解析)
14
- // GET /status ellm-proxy 运行状态(running/pid/status/uptime/网关端口)
15
- // GET /processes 全进程列表(pm2 守护进程/管理服务/主服务:状态/PID/端口/内存/CPU/运行时长)
16
- // POST /start | /stop | /restart pm2 启动/停止/重启 ellm-proxy(复用 utils/service.js,含探活)
17
- // GET /harness Claude Code / Codex / DeepSeek Harness 代理开关状态
18
- // POST /harness/claude-code body {enabled};开 = 写 ~/.claude/settings.json(备份),关 = 还原备份
19
- // POST /harness/codex body {enabled};开 = 写 ~/.codex/config.toml(备份),关 = 还原备份
20
- // POST /harness/dsh body {enabled};开 = 写 ~/.dsh/settings.yaml + .credentials.yaml(备份),关 = 还原
21
- //
22
- // 配置来源(env 优先,argv 可覆盖,缺省兜底):
23
- // 监听端口 ELLM_MANAGER_PORT(doStart 统一预算 = 主服务端口+1)> argv --port > 29771
24
- // web-ui 产物 ELLM_WEBUI_DIR(可选覆盖)> 按本文件位置推导:包模式 <pkgRoot>/dist/web-ui 仓库模式 <repoRoot>/web-ui/dist
25
- // 主服务参数 ellm-proxy start 注入的 env 同源:ELLM_CONFIG / ELLM_DATA_DIR / ELLM_APP_DIR / ELLM_PORT /
26
- // ELLM_SERVER_JS(主服务 dist/server.js 路径,restart 重启主服务时使用,保证参数不漂移)
27
- import { existsSync } from "node:fs";
28
- import { readFile } from "node:fs/promises";
29
- import { Hono } from "hono";
30
- import { cors } from "hono/cors";
31
- import { proxy } from "hono/proxy";
32
- import { serve } from "@hono/node-server";
33
- import { serveStatic } from "@hono/node-server/serve-static";
34
- import { dirname, join, relative, resolve } from "node:path";
35
- import { fileURLToPath } from "node:url";
36
- import mri from "mri";
37
- import { absolutize } from "../utils/paths.js";
38
- import { resolveGatewayBase } from "../utils/service.js";
39
- import { ICONS, say } from "../utils/ui.js";
40
- import { createServiceApp } from "./api/service.js";
41
- import { createHarnessApp } from "./api/harness/index.js";
42
-
43
- const DEFAULT_SERVER_PORT = 29771; // 手动运行且无 env 时的兜底(默认网关 29770 + 1)
44
- // 本文件在 manager/ 下,包根为上一级
45
- const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
46
-
47
- // web-ui 构建产物目录:ELLM_WEBUI_DIR 优先;缺省按本文件位置推导(包模式 dist/web-ui 与 server.js 同级,
48
- // 仓库模式落到 monorepo web-ui/dist)。启动时判定一次,缺失则跳过静态托管(接口不受影响)。
49
- const WEBUI_DIST_DIR = process.env.ELLM_WEBUI_DIR
50
- ? resolve(process.cwd(), process.env.ELLM_WEBUI_DIR)
51
- : ([join(pkgRoot, "dist", "web-ui"), resolve(pkgRoot, "..", "web-ui", "dist")].find((p) =>
52
- existsSync(join(p, "index.html")),
53
- ) ?? join(pkgRoot, "dist", "web-ui"));
54
- const HAS_WEBUI = existsSync(join(WEBUI_DIST_DIR, "index.html"));
55
-
56
- /** web-ui 管理界面静态托管:/ 出 index.html(hash 路由唯一服务端入口),assets/ 补长缓存头。
57
- * 须在 api 聚合与 /api 反代之后注册——serve-static 未命中才放行,不会遮蔽接口。 */
58
- function registerWebUi(app) {
59
- if (!HAS_WEBUI) return;
60
- // serve-static root 只接受相对 cwd 的路径:把绝对定位换算成当前 cwd 表达(仓库/发布包/任意 cwd 均成立)
61
- const root = relative(process.cwd(), WEBUI_DIST_DIR);
62
- app.get("/", async (c) => {
63
- try {
64
- const buf = await readFile(join(WEBUI_DIST_DIR, "index.html"));
65
- return c.body(new Uint8Array(buf), 200, {
66
- "Content-Type": "text/html; charset=utf-8",
67
- "Cache-Control": "no-cache",
68
- });
69
- } catch {
70
- return c.text("Not Found", 404);
71
- }
72
- });
73
- app.use("/*", (c, next) => {
74
- if (c.req.path === "/") return next(); // 根路径由上面的 serveIndex 处理
75
- return serveStatic({
76
- root,
77
- onFound: (_p, ctx) => {
78
- if (ctx.req.path.startsWith("/assets/")) ctx.header("Cache-Control", "public, max-age=31536000, immutable");
79
- },
80
- })(c, next);
81
- });
82
- }
83
-
84
- /** 创建管理服务 app:CORS 全放开 + api 聚合挂载 + /api 反代主服务 + web-ui 静态托管 + 统一 404/500 */
85
- function createApp({ startOpts }) {
86
- const app = new Hono();
87
-
88
- // CORS 全放开(OPTIONS 预检由中间件自动处理)
89
- app.use("*", cors());
90
-
91
- // api 聚合:进程控制(GET /status · POST /start|stop|restart)与 harness 开关(GET /harness · POST /harness/claude-code|codex|dsh)
92
- app.route("/", createServiceApp({ startOpts }));
93
- app.route("/harness", createHarnessApp({ startOpts }));
94
-
95
- // 反代主服务管理台 API(web-ui 同源访问的 /api/*):端口与 start 口径一致(ELLM_PORT env > 配置文件),
96
- // 每请求解析——主服务端口顺延漂移无感知
97
- app.all("/api/*", (c) => {
98
- const { base } = resolveGatewayBase();
99
- const port = Number(new URL(base).port);
100
- if (!port) {
101
- return c.json(
102
- { ok: false, error: "主服务端口无法解析(ELLM_PORT env 与配置文件 port 均缺失),无法转发 /api/*" },
103
- 502,
104
- );
105
- }
106
- // 目标 URL 必须带上原始查询串:c.req.path 只有 pathname,漏拼 search 会把
107
- // ?id=... / ?page=... 等查询参数全部丢掉(主服务按缺参返回 400 或默认分页)
108
- const incoming = new URL(c.req.raw.url);
109
- return proxy(new URL(incoming.pathname + incoming.search, base), {
110
- method: c.req.method,
111
- headers: c.req.raw.headers,
112
- body: c.req.method === "GET" || c.req.method === "HEAD" ? undefined : c.req.raw.body,
113
- duplex: "half", // body 为流时 undici 必需
114
- });
115
- });
116
-
117
- registerWebUi(app);
118
-
119
- app.notFound((c) =>
120
- c.json(
121
- {
122
- ok: false,
123
- error: `未知接口: ${c.req.method} ${c.req.path}(可用: GET /(管理界面), GET /api/*(反代主服务), GET /status, GET /processes, POST /start|stop|restart, GET /harness, POST /harness/claude-code|codex|dsh)`,
124
- },
125
- 404,
126
- ),
127
- );
128
- app.onError((err, c) => {
129
- console.error(`[ellm-proxy-manager] ${c.req.method} ${c.req.path} 失败:`, err?.message ?? err);
130
- return c.json({ ok: false, error: err?.message ?? String(err) }, 500);
131
- });
132
-
133
- return app;
134
- }
135
-
136
- async function main() {
137
- // argv 解析(mri):仅 --port(其余主服务参数一律走 env,与 start 注入链同源)
138
- const argv = mri(process.argv.slice(2));
139
- const listenPort = Number(argv.port) || Number(process.env.ELLM_MANAGER_PORT) || DEFAULT_SERVER_PORT;
140
- // 主服务启动参数(start/restart 时透传给 doStart):ELLM_* env 与 CLI start 注入链同源
141
- const startOpts = absolutize({
142
- config: process.env.ELLM_CONFIG,
143
- dataDir: process.env.ELLM_DATA_DIR,
144
- workingDir: process.env.ELLM_APP_DIR,
145
- serverJs: process.env.ELLM_SERVER_JS || join(pkgRoot, "dist", "server.js"),
146
- });
147
-
148
- const app = createApp({ startOpts });
149
-
150
- const server = serve({ fetch: app.fetch, port: listenPort, hostname: "127.0.0.1" });
151
- server.on("error", (err) => {
152
- console.error(
153
- `错误:管理服务监听 ${listenPort} 失败(${err.code ?? err.message})${err.code === "EADDRINUSE" ? ",端口被占用" : ""}`,
154
- );
155
- process.exit(1);
156
- });
157
-
158
- say(`ellm-proxy 管理服务已启动: http://127.0.0.1:${listenPort}`, ICONS.rocket);
159
- say(
160
- HAS_WEBUI
161
- ? ` 管理界面: http://127.0.0.1:${listenPort}/(web-ui,/api/* 反代主服务)`
162
- : ` 管理界面未托管:web-ui 构建产物缺失(${WEBUI_DIST_DIR}),仓库根执行 pnpm build:web 后重启本服务`,
163
- );
164
- say(
165
- " 接口: GET /status /processes · POST /start /stop /restart · GET /harness · POST /harness/claude-code /harness/codex /harness/dsh",
166
- );
167
- say(" CORS 已全放开(仅监听 127.0.0.1,不对外)");
168
- }
169
-
170
- main();
1
+ // ellm-proxy 管理服务入口(独立常驻脚本):随 `ellm-proxy start` 一并由 pm2 托管(name=ellm-proxy-manager);
2
+ // 也可手动 `node manager/index.js` 运行。本文件 = 创建 app(中间件 + api 聚合挂载)+ 启动 HTTP 服务;
3
+ // 各 api 域实现在 api/ 下:service.js(进程控制,命名对齐 backend/src/api/service.ts)、harness/(代理开关域)。
4
+ //
5
+ // 为什么独立于 backend:ellm-proxy 与 backend 同进程,进程自杀后管理台与转发层同时失联,
6
+ // 无法返回操作结果——启停必须由活在外面的本服务执行。
7
+ //
8
+ // 管理界面托管:本服务同进程托管 web-ui 构建产物(根路径 /,hash 路由),并反代 /api/* 到主服务——
9
+ // web-ui 全部接口(/api/* 管理台 + /status /restart /harness 等进程/开关域)同源可达,config.js 留空零配置。
10
+ //
11
+ // 接口(CORS 全放开,均返回 {ok, ...} 或 {ok:false, error}):
12
+ // GET / web-ui 管理界面(静态托管,产物缺失时仅告警跳过)
13
+ // GET /api/* 反代主服务管理台 API(端口 = ELLM_PORT env > 配置文件 port,每请求解析)
14
+ // GET /status ellm-proxy 运行状态(running/pid/status/uptime/网关端口)
15
+ // GET /processes 全进程列表(pm2 守护进程/管理服务/主服务:状态/PID/端口/内存/CPU/运行时长)
16
+ // POST /start | /stop | /restart pm2 启动/停止/重启 ellm-proxy(复用 utils/service.js,含探活)
17
+ // GET|POST /autostart 开机自启状态 / 开关(body {enabled};utils/autostart.js,opt-out 尊重用户选择)
18
+ // GET /update 自动更新状态(开关/上次检查/上次更新/supported)
19
+ // POST /update/enabled body {enabled};自动更新开关(ellm.config.json 顶层 autoUpdate)
20
+ // POST /update/check 手动检查更新(npm view 私有源)
21
+ // POST /update/apply 手动执行更新(停主服务 → npm i -g → 重启主服务 → manager 延迟自重启)
22
+ // GET /harness Claude Code / Codex / DeepSeek Harness 代理开关状态
23
+ // POST /harness/claude-code body {enabled};开 = ~/.claude/settings.json(备份),关 = 还原备份
24
+ // POST /harness/codex body {enabled};开 = 写 ~/.codex/config.toml(备份),关 = 还原备份
25
+ // POST /harness/dsh body {enabled};开 = ~/.dsh/settings.yaml + .credentials.yaml(备份),关 = 还原
26
+ //
27
+ // 配置来源(env 优先,argv 可覆盖,缺省兜底):
28
+ // 监听端口 ELLM_MANAGER_PORT(doStart 统一预算 = 主服务端口+1)> argv --port > 29771
29
+ // web-ui 产物 ELLM_WEBUI_DIR(可选覆盖)> 按本文件位置推导:包模式 <pkgRoot>/dist/web-ui → 仓库模式 <repoRoot>/web-ui/dist
30
+ // 主服务参数 ellm-proxy start 注入的 env 同源:ELLM_CONFIG / ELLM_DATA_DIR / ELLM_APP_DIR / ELLM_PORT /
31
+ // ELLM_SERVER_JS(主服务 dist/server.js 路径,restart 重启主服务时使用,保证参数不漂移)
32
+ import { existsSync } from "node:fs";
33
+ import { readFile } from "node:fs/promises";
34
+ import { Hono } from "hono";
35
+ import { cors } from "hono/cors";
36
+ import { proxy } from "hono/proxy";
37
+ import { serve } from "@hono/node-server";
38
+ import { serveStatic } from "@hono/node-server/serve-static";
39
+ import { dirname, join, relative, resolve } from "node:path";
40
+ import { fileURLToPath } from "node:url";
41
+ import mri from "mri";
42
+ import { absolutize } from "../utils/paths.js";
43
+ import { resolveGatewayBase } from "../utils/service.js";
44
+ import { startUpdateWatchdog } from "../utils/updater.js";
45
+ import { ICONS, say } from "../utils/ui.js";
46
+ import { createServiceApp } from "./api/service.js";
47
+ import { createServiceSettingsApp } from "./api/service-settings.js";
48
+ import { createHarnessApp } from "./api/harness/index.js";
49
+
50
+ const DEFAULT_SERVER_PORT = 29771; // 手动运行且无 env 时的兜底(默认网关 29770 + 1)
51
+ // 本文件在 manager/ 下,包根为上一级
52
+ const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
53
+
54
+ // web-ui 构建产物目录:ELLM_WEBUI_DIR 优先;缺省按本文件位置推导(包模式 dist/web-ui 与 server.js 同级,
55
+ // 仓库模式落到 monorepo 的 web-ui/dist)。启动时判定一次,缺失则跳过静态托管(接口不受影响)。
56
+ const WEBUI_DIST_DIR = process.env.ELLM_WEBUI_DIR
57
+ ? resolve(process.cwd(), process.env.ELLM_WEBUI_DIR)
58
+ : ([join(pkgRoot, "dist", "web-ui"), resolve(pkgRoot, "..", "web-ui", "dist")].find((p) =>
59
+ existsSync(join(p, "index.html")),
60
+ ) ?? join(pkgRoot, "dist", "web-ui"));
61
+ const HAS_WEBUI = existsSync(join(WEBUI_DIST_DIR, "index.html"));
62
+
63
+ /** web-ui 管理界面静态托管:/ 出 index.html(hash 路由唯一服务端入口),assets/ 补长缓存头。
64
+ * 须在 api 聚合与 /api 反代之后注册——serve-static 未命中才放行,不会遮蔽接口。 */
65
+ function registerWebUi(app) {
66
+ if (!HAS_WEBUI) return;
67
+ // serve-static 的 root 只接受相对 cwd 的路径:把绝对定位换算成当前 cwd 表达(仓库/发布包/任意 cwd 均成立)
68
+ const root = relative(process.cwd(), WEBUI_DIST_DIR);
69
+ app.get("/", async (c) => {
70
+ try {
71
+ const buf = await readFile(join(WEBUI_DIST_DIR, "index.html"));
72
+ return c.body(new Uint8Array(buf), 200, {
73
+ "Content-Type": "text/html; charset=utf-8",
74
+ "Cache-Control": "no-cache",
75
+ });
76
+ } catch {
77
+ return c.text("Not Found", 404);
78
+ }
79
+ });
80
+ app.use("/*", (c, next) => {
81
+ if (c.req.path === "/") return next(); // 根路径由上面的 serveIndex 处理
82
+ return serveStatic({
83
+ root,
84
+ onFound: (_p, ctx) => {
85
+ if (ctx.req.path.startsWith("/assets/")) ctx.header("Cache-Control", "public, max-age=31536000, immutable");
86
+ },
87
+ })(c, next);
88
+ });
89
+ }
90
+
91
+ /** 创建管理服务 app:CORS 全放开 + api 聚合挂载 + /api 反代主服务 + web-ui 静态托管 + 统一 404/500 */
92
+ function createApp({ startOpts }) {
93
+ const app = new Hono();
94
+
95
+ // CORS 全放开(OPTIONS 预检由中间件自动处理)
96
+ app.use("*", cors());
97
+
98
+ // api 聚合:进程控制(GET /status · POST /start|stop|restart)、服务设置(autostart / update 域)
99
+ // harness 开关(GET /harness · POST /harness/claude-code|codex|dsh)
100
+ app.route("/", createServiceApp({ startOpts }));
101
+ app.route("/", createServiceSettingsApp({ startOpts }));
102
+ app.route("/harness", createHarnessApp({ startOpts }));
103
+
104
+ // 反代主服务管理台 API(web-ui 同源访问的 /api/*):端口与 start 口径一致(ELLM_PORT env > 配置文件),
105
+ // 每请求解析——主服务端口顺延漂移无感知
106
+ app.all("/api/*", (c) => {
107
+ const { base } = resolveGatewayBase();
108
+ const port = Number(new URL(base).port);
109
+ if (!port) {
110
+ return c.json(
111
+ { ok: false, error: "主服务端口无法解析(ELLM_PORT env 与配置文件 port 均缺失),无法转发 /api/*" },
112
+ 502,
113
+ );
114
+ }
115
+ // 目标 URL 必须带上原始查询串:c.req.path 只有 pathname,漏拼 search 会把
116
+ // ?id=... / ?page=... 等查询参数全部丢掉(主服务按缺参返回 400 或默认分页)
117
+ const incoming = new URL(c.req.raw.url);
118
+ return proxy(new URL(incoming.pathname + incoming.search, base), {
119
+ method: c.req.method,
120
+ headers: c.req.raw.headers,
121
+ body: c.req.method === "GET" || c.req.method === "HEAD" ? undefined : c.req.raw.body,
122
+ duplex: "half", // body 为流时 undici 必需
123
+ });
124
+ });
125
+
126
+ registerWebUi(app);
127
+
128
+ app.notFound((c) =>
129
+ c.json(
130
+ {
131
+ ok: false,
132
+ error: `未知接口: ${c.req.method} ${c.req.path}(可用: GET /(管理界面), GET /api/*(反代主服务), GET /status, GET /processes, POST /start|stop|restart, GET|POST /autostart, GET /update, POST /update/enabled|check|apply, GET /harness, POST /harness/claude-code|codex|dsh)`,
133
+ },
134
+ 404,
135
+ ),
136
+ );
137
+ app.onError((err, c) => {
138
+ console.error(`[ellm-proxy-manager] ${c.req.method} ${c.req.path} 失败:`, err?.message ?? err);
139
+ return c.json({ ok: false, error: err?.message ?? String(err) }, 500);
140
+ });
141
+
142
+ return app;
143
+ }
144
+
145
+ async function main() {
146
+ // argv 解析(mri):仅 --port(其余主服务参数一律走 env,与 start 注入链同源)
147
+ const argv = mri(process.argv.slice(2));
148
+ const listenPort = Number(argv.port) || Number(process.env.ELLM_MANAGER_PORT) || DEFAULT_SERVER_PORT;
149
+ // 主服务启动参数(start/restart 时透传给 doStart):ELLM_* env 与 CLI start 注入链同源
150
+ const startOpts = absolutize({
151
+ config: process.env.ELLM_CONFIG,
152
+ dataDir: process.env.ELLM_DATA_DIR,
153
+ workingDir: process.env.ELLM_APP_DIR,
154
+ serverJs: process.env.ELLM_SERVER_JS || join(pkgRoot, "dist", "server.js"),
155
+ });
156
+
157
+ const app = createApp({ startOpts });
158
+
159
+ const server = serve({ fetch: app.fetch, port: listenPort, hostname: "127.0.0.1" });
160
+ server.on("error", (err) => {
161
+ console.error(
162
+ `错误:管理服务监听 ${listenPort} 失败(${err.code ?? err.message})${err.code === "EADDRINUSE" ? ",端口被占用" : ""}`,
163
+ );
164
+ process.exit(1);
165
+ });
166
+
167
+ say(`ellm-proxy 管理服务已启动: http://127.0.0.1:${listenPort}`, ICONS.rocket);
168
+ say(
169
+ HAS_WEBUI
170
+ ? ` 管理界面: http://127.0.0.1:${listenPort}/(web-ui,/api/* 反代主服务)`
171
+ : ` 管理界面未托管:web-ui 构建产物缺失(${WEBUI_DIST_DIR}),仓库根执行 pnpm build:web 后重启本服务`,
172
+ );
173
+ say(
174
+ " 接口: GET /status /processes · POST /start /stop /restart · GET|POST /autostart · GET /update · POST /update/enabled|check|apply · GET /harness · POST /harness/claude-code /harness/codex /harness/dsh",
175
+ );
176
+ say(" CORS 已全放开(仅监听 127.0.0.1,不对外)");
177
+
178
+ // 自动更新看门狗:源码运行时内部直接返回(不轮询);开关默认开启,每小时检查私有源新版本
179
+ startUpdateWatchdog(startOpts);
180
+ }
181
+
182
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ellm-proxy",
3
- "version": "0.0.3",
3
+ "version": "0.0.7",
4
4
  "description": "Self-hosted LLM gateway with admin web UI (pm2-managed)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,5 +37,8 @@
37
37
  "pm2": "^7.0.4",
38
38
  "smol-toml": "^1.8.0",
39
39
  "yaml": "^2.9.0"
40
+ },
41
+ "publishConfig": {
42
+ "registry": "https://registry.npmjs.org/"
40
43
  }
41
44
  }
@@ -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
+ }