ework-web 0.8.0 → 0.9.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/package.json +1 -1
- package/src/daemon-deploy.ts +174 -0
- package/src/index.ts +24 -0
- package/src/static/daemon-mgr.js +67 -1
- package/src/views/settings.ts +17 -1
package/package.json
CHANGED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
|
|
5
|
+
export interface DeployOpts {
|
|
6
|
+
sshHost: string;
|
|
7
|
+
sshUser: string;
|
|
8
|
+
sshPort: number;
|
|
9
|
+
sshKeyFile?: string;
|
|
10
|
+
daemonPort?: number;
|
|
11
|
+
mysqlHost: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DeployResult {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
output: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Keys forwarded from the local daemon .env to the remote one. Secrets are
|
|
21
|
+
// never logged — only written to the remote .env over SSH.
|
|
22
|
+
const FORWARD_KEYS = [
|
|
23
|
+
"WORK_DB_DRIVER",
|
|
24
|
+
"WORK_DB_USER",
|
|
25
|
+
"WORK_DB_PASSWORD",
|
|
26
|
+
"WORK_DB_NAME",
|
|
27
|
+
"WORK_DB_PREFIX",
|
|
28
|
+
"GITEA_URL",
|
|
29
|
+
"GITEA_TOKEN",
|
|
30
|
+
"BOT_TOKEN",
|
|
31
|
+
"BOT_USERNAME",
|
|
32
|
+
"GITEA_WEBHOOK_SECRET",
|
|
33
|
+
"OPENCODE_BASE_WORKDIR",
|
|
34
|
+
"OPENCODE_BINARY",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
function resolveDaemonEnvPath(): string | null {
|
|
38
|
+
const dataDir = process.env.WORK_DAEMON_DATA_DIR;
|
|
39
|
+
if (dataDir) {
|
|
40
|
+
const p = join(dataDir, ".env");
|
|
41
|
+
return existsSync(p) ? p : null;
|
|
42
|
+
}
|
|
43
|
+
const candidates = [
|
|
44
|
+
join(homedir(), ".local", "share", "ework-aio", "ework-daemon", ".env"),
|
|
45
|
+
join(homedir(), ".local", "share", "ework-daemon", ".env"),
|
|
46
|
+
];
|
|
47
|
+
for (const c of candidates) {
|
|
48
|
+
if (existsSync(c)) return c;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseEnvFile(content: string): Map<string, string> {
|
|
54
|
+
const out = new Map<string, string>();
|
|
55
|
+
for (const line of content.split("\n")) {
|
|
56
|
+
const eq = line.indexOf("=");
|
|
57
|
+
if (eq <= 0) continue;
|
|
58
|
+
const key = line.slice(0, eq).trim();
|
|
59
|
+
if (!key || key.startsWith("#")) continue;
|
|
60
|
+
const val = line.slice(eq + 1).replace(/^["']|["']$/g, "");
|
|
61
|
+
out.set(key, val);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildEnvBlock(env: Map<string, string>, opts: DeployOpts): string {
|
|
67
|
+
const lines: string[] = [];
|
|
68
|
+
for (const k of FORWARD_KEYS) {
|
|
69
|
+
const v = env.get(k);
|
|
70
|
+
if (v === undefined) continue;
|
|
71
|
+
lines.push(`${k}=${v}`);
|
|
72
|
+
}
|
|
73
|
+
lines.push(`WORK_DB_HOST=${opts.mysqlHost}`);
|
|
74
|
+
lines.push(`DAEMON_PORT=${String(opts.daemonPort ?? 3101)}`);
|
|
75
|
+
lines.push(`DAEMON_HOST=0.0.0.0`);
|
|
76
|
+
lines.push(`DAEMON_ENV=production`);
|
|
77
|
+
return lines.join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildSetupScript(envBlock: string): string {
|
|
81
|
+
return [
|
|
82
|
+
"set -e",
|
|
83
|
+
`command -v npm >/dev/null 2>&1 || { curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -; sudo apt-get install -y nodejs; }`,
|
|
84
|
+
`command -v bun >/dev/null 2>&1 || { curl -fsSL https://bun.sh/install | bash; export BUN_INSTALL="$HOME/.bun"; export PATH="$BUN_INSTALL/bin:$PATH"; }`,
|
|
85
|
+
`npm install -g ework-aio`,
|
|
86
|
+
`mkdir -p ~/.local/share/ework-aio/ework-daemon`,
|
|
87
|
+
`cat > ~/.local/share/ework-aio/ework-daemon/.env <<'EWORK_DAEMON_ENV_EOF'`,
|
|
88
|
+
envBlock,
|
|
89
|
+
`EWORK_DAEMON_ENV_EOF`,
|
|
90
|
+
`ework-aio start daemon`,
|
|
91
|
+
`echo DAEMON_STARTED`,
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function deployRemoteDaemon(opts: DeployOpts): Promise<DeployResult> {
|
|
96
|
+
const envPath = resolveDaemonEnvPath();
|
|
97
|
+
if (!envPath) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
output: "",
|
|
101
|
+
error: "找不到本地 daemon .env(设置 WORK_DAEMON_DATA_DIR 或将 .env 放到 ~/.local/share/ework-aio/ework-daemon/.env)",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
let envContent: string;
|
|
105
|
+
try {
|
|
106
|
+
envContent = readFileSync(envPath, "utf8");
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return { ok: false, output: "", error: e instanceof Error ? e.message : String(e) };
|
|
109
|
+
}
|
|
110
|
+
const env = parseEnvFile(envContent);
|
|
111
|
+
const envBlock = buildEnvBlock(env, opts);
|
|
112
|
+
const script = buildSetupScript(envBlock);
|
|
113
|
+
|
|
114
|
+
const args = [
|
|
115
|
+
"ssh",
|
|
116
|
+
"-o", "StrictHostKeyChecking=accept-new",
|
|
117
|
+
"-p", String(opts.sshPort),
|
|
118
|
+
"-i", opts.sshKeyFile ?? "~/.ssh/id_rsa",
|
|
119
|
+
`${opts.sshUser}@${opts.sshHost}`,
|
|
120
|
+
"bash -s",
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
let proc;
|
|
124
|
+
try {
|
|
125
|
+
proc = Bun.spawn(args, {
|
|
126
|
+
stdin: new TextEncoder().encode(script),
|
|
127
|
+
stdout: "pipe",
|
|
128
|
+
stderr: "pipe",
|
|
129
|
+
});
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return { ok: false, output: "", error: e instanceof Error ? e.message : String(e) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const ac = new AbortController();
|
|
135
|
+
const timer = setTimeout(() => {
|
|
136
|
+
ac.abort();
|
|
137
|
+
try { proc.kill(); } catch { /* already dead */ }
|
|
138
|
+
}, 60_000);
|
|
139
|
+
|
|
140
|
+
let stdoutText = "";
|
|
141
|
+
let stderrText = "";
|
|
142
|
+
let exitCode: number | null = null;
|
|
143
|
+
let timedOut = false;
|
|
144
|
+
try {
|
|
145
|
+
ac.signal.addEventListener("abort", () => { timedOut = true; });
|
|
146
|
+
[stdoutText, stderrText, exitCode] = await Promise.all([
|
|
147
|
+
new Response(proc.stdout).text(),
|
|
148
|
+
new Response(proc.stderr).text(),
|
|
149
|
+
proc.exited,
|
|
150
|
+
]);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
output: stdoutText + stderrText,
|
|
155
|
+
error: e instanceof Error ? e.message : String(e),
|
|
156
|
+
};
|
|
157
|
+
} finally {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (timedOut) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
output: stdoutText + stderrText,
|
|
165
|
+
error: "SSH 部署超时(60s)",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const combined = stdoutText + (stderrText ? "\n--- stderr ---\n" + stderrText : "");
|
|
169
|
+
return {
|
|
170
|
+
ok: exitCode === 0,
|
|
171
|
+
output: combined,
|
|
172
|
+
error: exitCode === 0 ? undefined : `ssh 退出码 ${String(exitCode)}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -93,6 +93,7 @@ import { buildProjectMembersPage } from "./views/projectMembers";
|
|
|
93
93
|
import { buildProjectUpstreamsPage, trySetUpstreamUrls } from "./views/projectUpstreams";
|
|
94
94
|
import { buildProjectModelPage } from "./views/projectModel";
|
|
95
95
|
import { handleGiteaApi } from "./giteaApi";
|
|
96
|
+
import { deployRemoteDaemon } from "./daemon-deploy";
|
|
96
97
|
|
|
97
98
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
98
99
|
const STATIC_DIR = join(__dirname, "static");
|
|
@@ -899,6 +900,29 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
899
900
|
return json({ ok: true });
|
|
900
901
|
}
|
|
901
902
|
|
|
903
|
+
if (req.method === "POST" && url.pathname === "/api/daemons/deploy") {
|
|
904
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
905
|
+
if (!rateLimit(`daemons-deploy:${ip}`, 3, 3 / 3600)) return json({ error: "rate limited" }, 429);
|
|
906
|
+
const payload = await req.json().catch(() => ({} as unknown));
|
|
907
|
+
if (!payload || typeof payload !== "object") return json({ ok: false, error: "invalid body" }, 400);
|
|
908
|
+
const obj = payload as Record<string, unknown>;
|
|
909
|
+
const sshHost = typeof obj.sshHost === "string" ? obj.sshHost.trim() : "";
|
|
910
|
+
const sshUser = typeof obj.sshUser === "string" ? obj.sshUser.trim() : "";
|
|
911
|
+
const mysqlHost = typeof obj.mysqlHost === "string" ? obj.mysqlHost.trim() : "";
|
|
912
|
+
if (!sshHost || !sshUser || !mysqlHost) {
|
|
913
|
+
return json({ ok: false, error: "sshHost, sshUser, mysqlHost 必填" }, 400);
|
|
914
|
+
}
|
|
915
|
+
const sshPort = typeof obj.sshPort === "number" && Number.isFinite(obj.sshPort) && obj.sshPort > 0 && obj.sshPort < 65536
|
|
916
|
+
? Math.trunc(obj.sshPort)
|
|
917
|
+
: 22;
|
|
918
|
+
const sshKeyFile = typeof obj.sshKeyFile === "string" && obj.sshKeyFile.trim() ? obj.sshKeyFile.trim() : undefined;
|
|
919
|
+
const daemonPort = typeof obj.daemonPort === "number" && Number.isFinite(obj.daemonPort) && obj.daemonPort > 0 && obj.daemonPort < 65536
|
|
920
|
+
? Math.trunc(obj.daemonPort)
|
|
921
|
+
: undefined;
|
|
922
|
+
const result = await deployRemoteDaemon({ sshHost, sshUser, sshPort, sshKeyFile, daemonPort, mysqlHost });
|
|
923
|
+
return json(result, result.ok ? 200 : 422);
|
|
924
|
+
}
|
|
925
|
+
|
|
902
926
|
if (url.pathname === "/settings") {
|
|
903
927
|
if (req.method === "GET") {
|
|
904
928
|
return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, await listCachedModels()).html);
|
package/src/static/daemon-mgr.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
var resultEl = document.getElementById('daemon-result');
|
|
8
8
|
var addBtn = document.getElementById('daemon-add');
|
|
9
9
|
var portInput = document.getElementById('daemon-port');
|
|
10
|
+
var deployBtn = document.getElementById('daemon-deploy');
|
|
11
|
+
var deployResultEl = document.getElementById('deploy-result');
|
|
10
12
|
var refreshTimer = null;
|
|
11
13
|
var inFlight = false;
|
|
12
14
|
|
|
@@ -32,6 +34,18 @@
|
|
|
32
34
|
return '<span class="pill ' + cls + '">' + esc(raw) + '</span>';
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
function isLocalhostEndpoint(endpoint) {
|
|
38
|
+
var host = String(endpoint || '').replace(/^https?:\/\//, '').split(':')[0] || '';
|
|
39
|
+
return /^(127\.|localhost$|0\.0\.0\.0$|::1$|\[::1\]$)/.test(host);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function locationCell(endpoint) {
|
|
43
|
+
if (isLocalhostEndpoint(endpoint)) {
|
|
44
|
+
return '<span class="pill loc-local">本机</span>';
|
|
45
|
+
}
|
|
46
|
+
return '<span class="pill loc-remote">🌐 远程</span>';
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
function render(daemons) {
|
|
36
50
|
if (!Array.isArray(daemons) || daemons.length === 0) {
|
|
37
51
|
listEl.innerHTML = '<div class="daemon-empty">暂无 daemon 注册。可在下方启动新实例。</div>';
|
|
@@ -43,6 +57,7 @@
|
|
|
43
57
|
: '';
|
|
44
58
|
return '<tr>' +
|
|
45
59
|
'<td class="cap">' + d.id + '</td>' +
|
|
60
|
+
'<td class="loc">' + locationCell(d.endpoint) + '</td>' +
|
|
46
61
|
'<td class="endpoint">' + esc(d.endpoint || '') + '</td>' +
|
|
47
62
|
'<td class="cap">' + esc(d.capacity != null ? d.capacity : '') + '</td>' +
|
|
48
63
|
'<td class="heartbeat">' + fmtTime(d.lastHeartbeat) + '</td>' +
|
|
@@ -53,7 +68,7 @@
|
|
|
53
68
|
}).join('');
|
|
54
69
|
listEl.innerHTML =
|
|
55
70
|
'<table><thead><tr>' +
|
|
56
|
-
'<th>ID</th><th>Endpoint</th><th>容量</th><th>心跳</th><th>注册于</th><th>状态</th><th></th>' +
|
|
71
|
+
'<th>ID</th><th>位置</th><th>Endpoint</th><th>容量</th><th>心跳</th><th>注册于</th><th>状态</th><th></th>' +
|
|
57
72
|
'</tr></thead><tbody>' + rows + '</tbody></table>';
|
|
58
73
|
listEl.querySelectorAll('button.stop').forEach(function (b) {
|
|
59
74
|
b.addEventListener('click', function () { stopDaemon(Number(b.getAttribute('data-id'))); });
|
|
@@ -130,7 +145,58 @@
|
|
|
130
145
|
}
|
|
131
146
|
}
|
|
132
147
|
|
|
148
|
+
function setDeployResult(text, kind) {
|
|
149
|
+
if (!deployResultEl) return;
|
|
150
|
+
deployResultEl.className = 'db-result db-' + (kind === 'ok' ? 'ok' : kind === 'err' ? 'err' : 'loading');
|
|
151
|
+
deployResultEl.textContent = text;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function val(id) {
|
|
155
|
+
var el = document.getElementById(id);
|
|
156
|
+
return el && el.value != null ? el.value.trim() : '';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function deployRemote() {
|
|
160
|
+
if (deployBtn) deployBtn.disabled = true;
|
|
161
|
+
setDeployResult('正在通过 SSH 部署(最长 60s)…', 'loading');
|
|
162
|
+
var body = {
|
|
163
|
+
sshHost: val('deploy-host'),
|
|
164
|
+
sshUser: val('deploy-user'),
|
|
165
|
+
mysqlHost: val('deploy-mysql-host')
|
|
166
|
+
};
|
|
167
|
+
var sshPort = Number(val('deploy-ssh-port'));
|
|
168
|
+
if (Number.isFinite(sshPort) && sshPort > 0) body.sshPort = sshPort;
|
|
169
|
+
var dPort = Number(val('deploy-daemon-port'));
|
|
170
|
+
if (Number.isFinite(dPort) && dPort > 0) body.daemonPort = dPort;
|
|
171
|
+
var keyFile = val('deploy-key');
|
|
172
|
+
if (keyFile) body.sshKeyFile = keyFile;
|
|
173
|
+
if (!body.sshHost || !body.sshUser || !body.mysqlHost) {
|
|
174
|
+
setDeployResult('✗ SSH 主机、SSH 用户、MySQL 主机 必填', 'err');
|
|
175
|
+
if (deployBtn) deployBtn.disabled = false;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
var res = await fetch('/api/daemons/deploy', {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: { 'Content-Type': 'application/json' },
|
|
182
|
+
body: JSON.stringify(body)
|
|
183
|
+
});
|
|
184
|
+
var data = await res.json();
|
|
185
|
+
if (data.ok) {
|
|
186
|
+
setDeployResult('✓ 部署成功' + (data.output ? '\n' + data.output : ''), 'ok');
|
|
187
|
+
await loadDaemons();
|
|
188
|
+
} else {
|
|
189
|
+
setDeployResult('✗ ' + (data.error || '未知错误') + (data.output ? '\n' + data.output : ''), 'err');
|
|
190
|
+
}
|
|
191
|
+
} catch (e) {
|
|
192
|
+
setDeployResult('✗ ' + (e.message || String(e)), 'err');
|
|
193
|
+
} finally {
|
|
194
|
+
if (deployBtn) deployBtn.disabled = false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
133
198
|
if (addBtn) addBtn.addEventListener('click', addDaemon);
|
|
199
|
+
if (deployBtn) deployBtn.addEventListener('click', deployRemote);
|
|
134
200
|
document.addEventListener('visibilitychange', function () {
|
|
135
201
|
if (document.hidden) {
|
|
136
202
|
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
|
package/src/views/settings.ts
CHANGED
|
@@ -78,6 +78,19 @@ function buildDaemonSection(viewer: UserRow): string {
|
|
|
78
78
|
<button type="button" id="daemon-add">添加 Daemon</button>
|
|
79
79
|
</div>
|
|
80
80
|
<div id="daemon-result" class="db-result"></div>
|
|
81
|
+
<details style="margin-top:.7rem">
|
|
82
|
+
<summary style="cursor:pointer;font-size:13px;color:var(--text-muted)">📡 SSH 远程部署</summary>
|
|
83
|
+
<div style="margin-top:.5rem">
|
|
84
|
+
<label class="sf"><span>SSH 主机</span><input type="text" id="deploy-host" placeholder="192.168.1.100" autocomplete="off"></label>
|
|
85
|
+
<label class="sf"><span>SSH 用户</span><input type="text" id="deploy-user" placeholder="root" value="root" autocomplete="off"></label>
|
|
86
|
+
<label class="sf"><span>SSH 端口</span><input type="number" id="deploy-ssh-port" value="22" min="1" max="65535" autocomplete="off"></label>
|
|
87
|
+
<label class="sf"><span>SSH 密钥</span><input type="text" id="deploy-key" placeholder="~/.ssh/id_rsa" autocomplete="off"></label>
|
|
88
|
+
<label class="sf"><span>Daemon 端口</span><input type="number" id="deploy-daemon-port" placeholder="3101" min="1" max="65535" autocomplete="off"></label>
|
|
89
|
+
<label class="sf"><span>MySQL 主机(远程可见)</span><input type="text" id="deploy-mysql-host" placeholder="192.168.1.1" autocomplete="off"></label>
|
|
90
|
+
<div class="db-controls"><button type="button" id="daemon-deploy">部署到远程</button></div>
|
|
91
|
+
</div>
|
|
92
|
+
</details>
|
|
93
|
+
<div id="deploy-result" class="db-result"></div>
|
|
81
94
|
<script src="/static/daemon-mgr.js"></script>
|
|
82
95
|
</section>`;
|
|
83
96
|
}
|
|
@@ -135,6 +148,9 @@ button.secondary{background:transparent;color:var(--text-muted);border:1px solid
|
|
|
135
148
|
.daemon-list .pill.active{background:rgba(40,167,69,.18);color:#5eb88a}
|
|
136
149
|
.daemon-list .pill.drained{background:rgba(255,193,7,.18);color:#d4a942}
|
|
137
150
|
.daemon-list .pill.dead,.daemon-list .pill.unknown{background:rgba(220,53,69,.18);color:#e87c7c}
|
|
151
|
+
.daemon-list .pill.loc-local{background:rgba(40,167,69,.18);color:#5eb88a}
|
|
152
|
+
.daemon-list .pill.loc-remote{background:rgba(13,110,253,.18);color:#6ea8fe}
|
|
153
|
+
.daemon-list td.loc{white-space:nowrap}
|
|
138
154
|
.daemon-list button.stop{padding:.25rem .6rem;font-size:11px;background:transparent;color:#e87c7c;border:1px solid rgba(220,53,69,.4)}
|
|
139
155
|
.daemon-list button.stop:hover{background:rgba(220,53,69,.12)}
|
|
140
156
|
.daemon-empty{color:var(--text-muted);font-size:13px;padding:.5rem 0}
|
|
@@ -145,9 +161,9 @@ button.secondary{background:transparent;color:var(--text-muted);border:1px solid
|
|
|
145
161
|
<p class="hint">改完保存立即生效,无需重启。密钥/启动项(token、端口等)仍在 <code>.env</code>,不在此处。</p>
|
|
146
162
|
${banner}
|
|
147
163
|
<form method="POST" action="/settings">${groups}
|
|
164
|
+
${modelRefreshForm}
|
|
148
165
|
<div class="bar"><button type="submit">保存</button><a class="a-back" href="/">返回</a></div>
|
|
149
166
|
</form>
|
|
150
|
-
${modelRefreshForm}
|
|
151
167
|
${ttsLink}
|
|
152
168
|
${buildDbSection(viewer)}
|
|
153
169
|
${buildDaemonSection(viewer)}
|