ework-web 0.9.0 → 0.10.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
@@ -2,7 +2,7 @@ import { join } from "path";
2
2
  import { existsSync, readFileSync } from "fs";
3
3
  import { homedir } from "os";
4
4
 
5
- export interface DeployOpts {
5
+ export interface DeployTarget {
6
6
  sshHost: string;
7
7
  sshUser: string;
8
8
  sshPort: number;
@@ -11,14 +11,22 @@ export interface DeployOpts {
11
11
  mysqlHost: string;
12
12
  }
13
13
 
14
+ export interface DeployOpts extends DeployTarget {
15
+ timeoutMs: number;
16
+ onOutput?: (chunk: string) => void;
17
+ }
18
+
14
19
  export interface DeployResult {
15
20
  ok: boolean;
16
21
  output: string;
17
22
  error?: string;
18
23
  }
19
24
 
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.
25
+ export interface BatchTarget {
26
+ host: string;
27
+ daemonPort?: number;
28
+ }
29
+
22
30
  const FORWARD_KEYS = [
23
31
  "WORK_DB_DRIVER",
24
32
  "WORK_DB_USER",
@@ -63,15 +71,15 @@ function parseEnvFile(content: string): Map<string, string> {
63
71
  return out;
64
72
  }
65
73
 
66
- function buildEnvBlock(env: Map<string, string>, opts: DeployOpts): string {
74
+ function buildEnvBlock(env: Map<string, string>, target: DeployTarget): string {
67
75
  const lines: string[] = [];
68
76
  for (const k of FORWARD_KEYS) {
69
77
  const v = env.get(k);
70
78
  if (v === undefined) continue;
71
79
  lines.push(`${k}=${v}`);
72
80
  }
73
- lines.push(`WORK_DB_HOST=${opts.mysqlHost}`);
74
- lines.push(`DAEMON_PORT=${String(opts.daemonPort ?? 3101)}`);
81
+ lines.push(`WORK_DB_HOST=${target.mysqlHost}`);
82
+ lines.push(`DAEMON_PORT=${String(target.daemonPort ?? 3101)}`);
75
83
  lines.push(`DAEMON_HOST=0.0.0.0`);
76
84
  lines.push(`DAEMON_ENV=production`);
77
85
  return lines.join("\n");
@@ -81,13 +89,22 @@ function buildSetupScript(envBlock: string): string {
81
89
  return [
82
90
  "set -e",
83
91
  `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"; }`,
92
+ `command -v bun >/dev/null 2>&1 || { curl -fsSL https://bun.sh/install | bash; }`,
93
+ `export BUN_INSTALL="$HOME/.bun"`,
94
+ `export PATH="$HOME/.local/lib/bin:$BUN_INSTALL/bin:$PATH"`,
95
+ `mkdir -p "$HOME/.local/lib"`,
96
+ `npm config set prefix "$HOME/.local/lib"`,
97
+ `grep -q '.local/lib/bin' "$HOME/.bashrc" 2>/dev/null || echo 'export PATH="$HOME/.local/lib/bin:$HOME/.bun/bin:$PATH"' >> "$HOME/.bashrc"`,
98
+ `echo "[1/4] installing ework-aio..."`,
85
99
  `npm install -g ework-aio`,
100
+ `echo "[2/4] writing daemon config..."`,
86
101
  `mkdir -p ~/.local/share/ework-aio/ework-daemon`,
87
102
  `cat > ~/.local/share/ework-aio/ework-daemon/.env <<'EWORK_DAEMON_ENV_EOF'`,
88
103
  envBlock,
89
104
  `EWORK_DAEMON_ENV_EOF`,
105
+ `echo "[3/4] starting daemon..."`,
90
106
  `ework-aio start daemon`,
107
+ `echo "[4/4] done"`,
91
108
  `echo DAEMON_STARTED`,
92
109
  ].join("\n");
93
110
  }
@@ -98,7 +115,7 @@ export async function deployRemoteDaemon(opts: DeployOpts): Promise<DeployResult
98
115
  return {
99
116
  ok: false,
100
117
  output: "",
101
- error: "找不到本地 daemon .env(设置 WORK_DAEMON_DATA_DIR 或将 .env 放到 ~/.local/share/ework-aio/ework-daemon/.env)",
118
+ error: "找不到本地 daemon .env",
102
119
  };
103
120
  }
104
121
  let envContent: string;
@@ -135,23 +152,37 @@ export async function deployRemoteDaemon(opts: DeployOpts): Promise<DeployResult
135
152
  const timer = setTimeout(() => {
136
153
  ac.abort();
137
154
  try { proc.kill(); } catch { /* already dead */ }
138
- }, 60_000);
155
+ }, opts.timeoutMs);
139
156
 
140
- let stdoutText = "";
141
- let stderrText = "";
157
+ let output = "";
142
158
  let exitCode: number | null = null;
143
159
  let timedOut = false;
160
+
161
+ const decoder = new TextDecoder();
162
+ const readStream = async (stream: ReadableStream<Uint8Array>) => {
163
+ const reader = stream.getReader();
164
+ try {
165
+ while (true) {
166
+ const { done, value } = await reader.read();
167
+ if (done) break;
168
+ const chunk = decoder.decode(value, { stream: true });
169
+ output += chunk;
170
+ opts.onOutput?.(chunk);
171
+ }
172
+ } catch { /* stream closed */ }
173
+ };
174
+
144
175
  try {
145
176
  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,
177
+ await Promise.all([
178
+ readStream(proc.stdout as ReadableStream<Uint8Array>),
179
+ readStream(proc.stderr as ReadableStream<Uint8Array>),
150
180
  ]);
181
+ exitCode = await proc.exited;
151
182
  } catch (e) {
152
183
  return {
153
184
  ok: false,
154
- output: stdoutText + stderrText,
185
+ output,
155
186
  error: e instanceof Error ? e.message : String(e),
156
187
  };
157
188
  } finally {
@@ -161,14 +192,35 @@ export async function deployRemoteDaemon(opts: DeployOpts): Promise<DeployResult
161
192
  if (timedOut) {
162
193
  return {
163
194
  ok: false,
164
- output: stdoutText + stderrText,
165
- error: "SSH 部署超时(60s)",
195
+ output,
196
+ error: `SSH 部署超时(${String(Math.trunc(opts.timeoutMs / 1000))}s)`,
166
197
  };
167
198
  }
168
- const combined = stdoutText + (stderrText ? "\n--- stderr ---\n" + stderrText : "");
169
199
  return {
170
200
  ok: exitCode === 0,
171
- output: combined,
201
+ output,
172
202
  error: exitCode === 0 ? undefined : `ssh 退出码 ${String(exitCode)}`,
173
203
  };
174
204
  }
205
+
206
+ export async function deployBatch(
207
+ targets: DeployTarget[],
208
+ timeoutMs: number,
209
+ onOutput: (target: string, chunk: string) => void,
210
+ ): Promise<Map<string, DeployResult>> {
211
+ const results = new Map<string, DeployResult>();
212
+ await Promise.all(
213
+ targets.map(async (t) => {
214
+ const label = `${t.sshUser}@${t.sshHost}:${String(t.sshPort)}`;
215
+ onOutput(label, `▶ 开始部署到 ${label}\n`);
216
+ const r = await deployRemoteDaemon({
217
+ ...t,
218
+ timeoutMs,
219
+ onOutput: (chunk) => onOutput(label, chunk),
220
+ });
221
+ results.set(label, r);
222
+ onOutput(label, r.ok ? `✓ ${label} 部署成功\n` : `✗ ${label} ${r.error ?? "失败"}\n`);
223
+ }),
224
+ );
225
+ return results;
226
+ }
package/src/index.ts CHANGED
@@ -93,7 +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
+ import { deployRemoteDaemon, deployBatch, type DeployTarget } from "./daemon-deploy";
97
97
 
98
98
  const __dirname = dirname(fileURLToPath(import.meta.url));
99
99
  const STATIC_DIR = join(__dirname, "static");
@@ -902,25 +902,68 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
902
902
 
903
903
  if (req.method === "POST" && url.pathname === "/api/daemons/deploy") {
904
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);
905
+ if (!rateLimit(`daemons-deploy:${ip}`, 10, 10 / 3600)) return json({ error: "rate limited" }, 429);
906
906
  const payload = await req.json().catch(() => ({} as unknown));
907
907
  if (!payload || typeof payload !== "object") return json({ ok: false, error: "invalid body" }, 400);
908
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() : "";
909
+ const sshUser = typeof obj.sshUser === "string" ? obj.sshUser.trim() : "root";
911
910
  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
911
  const sshPort = typeof obj.sshPort === "number" && Number.isFinite(obj.sshPort) && obj.sshPort > 0 && obj.sshPort < 65536
916
912
  ? Math.trunc(obj.sshPort)
917
913
  : 22;
918
914
  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);
915
+ const timeoutMs = typeof obj.timeoutMs === "number" && Number.isFinite(obj.timeoutMs) && obj.timeoutMs >= 30_000 && obj.timeoutMs <= 600_000
916
+ ? Math.trunc(obj.timeoutMs)
917
+ : 180_000;
918
+
919
+ const targets: DeployTarget[] = [];
920
+ if (Array.isArray(obj.targets)) {
921
+ for (const t of obj.targets) {
922
+ if (!t || typeof t !== "object") continue;
923
+ const r = t as Record<string, unknown>;
924
+ const host = typeof r.host === "string" ? r.host.trim() : "";
925
+ if (!host) continue;
926
+ targets.push({
927
+ sshHost: host, sshUser, sshPort, sshKeyFile, mysqlHost,
928
+ daemonPort: typeof r.daemonPort === "number" ? Math.trunc(r.daemonPort) : undefined,
929
+ });
930
+ }
931
+ } else {
932
+ const sshHost = typeof obj.sshHost === "string" ? obj.sshHost.trim() : "";
933
+ if (sshHost) {
934
+ targets.push({
935
+ sshHost, sshUser, sshPort, sshKeyFile, mysqlHost,
936
+ daemonPort: typeof obj.daemonPort === "number" ? Math.trunc(obj.daemonPort) : undefined,
937
+ });
938
+ }
939
+ }
940
+
941
+ if (targets.length === 0 || !mysqlHost) {
942
+ return json({ ok: false, error: "至少需要一个目标 + mysqlHost" }, 400);
943
+ }
944
+ if (targets.length === 1) {
945
+ const [single] = targets;
946
+ if (!single) return json({ ok: false, error: "no target" }, 400);
947
+ const result = await deployRemoteDaemon({ ...single, timeoutMs });
948
+ return json(result, result.ok ? 200 : 422);
949
+ }
950
+
951
+ const { readable, writable } = new TransformStream();
952
+ const writer = writable.getWriter();
953
+ const enc = new TextEncoder();
954
+ const send = (s: string) => writer.write(enc.encode(`data: ${JSON.stringify(s)}\n\n`));
955
+
956
+ deployBatch(targets, timeoutMs, (label, chunk) => send(`[${label}] ${chunk}`))
957
+ .then((results) => {
958
+ const ok = [...results.values()].every((r) => r.ok);
959
+ send(`\n${ok ? "ALL OK" : "SOME FAILED"}: ${[...results.entries()].map(([k, v]) => `${k}=${v.ok ? "✓" : "✗"}`).join(", ")}`);
960
+ })
961
+ .catch((e) => send(`ERROR: ${e instanceof Error ? e.message : String(e)}`))
962
+ .finally(() => writer.close());
963
+
964
+ return new Response(readable, {
965
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
966
+ });
924
967
  }
925
968
 
926
969
  if (url.pathname === "/settings") {
@@ -156,40 +156,111 @@
156
156
  return el && el.value != null ? el.value.trim() : '';
157
157
  }
158
158
 
159
+ function appendDeployLog(text) {
160
+ if (!deployResultEl) return;
161
+ deployResultEl.className = 'db-result db-loading';
162
+ deployResultEl.textContent += text;
163
+ deployResultEl.scrollTop = deployResultEl.scrollHeight;
164
+ }
165
+
166
+ function parseTargets(raw) {
167
+ var targets = [];
168
+ var lines = raw.split('\n');
169
+ for (var i = 0; i < lines.length; i++) {
170
+ var line = lines[i].trim();
171
+ if (!line || line.startsWith('#')) continue;
172
+ var parts = line.split(':');
173
+ var host = parts[0].trim();
174
+ var port = parts.length > 1 ? parseInt(parts[1].trim(), 10) : NaN;
175
+ if (host) {
176
+ var t = { host: host };
177
+ if (Number.isFinite(port) && port > 0) t.daemonPort = port;
178
+ targets.push(t);
179
+ }
180
+ }
181
+ return targets;
182
+ }
183
+
159
184
  async function deployRemote() {
160
185
  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;
186
+ var targets = parseTargets(val('deploy-targets'));
187
+ var mysqlHost = val('deploy-mysql-host');
188
+ var sshUser = val('deploy-user') || 'root';
189
+ var sshPort = Number(val('deploy-ssh-port')) || 22;
190
+ var timeoutMs = (Number(val('deploy-timeout')) || 180) * 1000;
171
191
  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');
192
+
193
+ if (targets.length === 0) {
194
+ setDeployResult('✗ 请填写至少一个目标机器', 'err');
195
+ if (deployBtn) deployBtn.disabled = false;
196
+ return;
197
+ }
198
+ if (!mysqlHost) {
199
+ setDeployResult('✗ MySQL 主机必填', 'err');
175
200
  if (deployBtn) deployBtn.disabled = false;
176
201
  return;
177
202
  }
203
+
204
+ deployResultEl.textContent = '';
205
+ appendDeployLog('▶ 部署 ' + targets.length + ' 个目标(超时 ' + (timeoutMs / 1000) + 's/个)...\n\n');
206
+
207
+ var body = {
208
+ targets: targets,
209
+ sshUser: sshUser,
210
+ sshPort: sshPort,
211
+ mysqlHost: mysqlHost,
212
+ timeoutMs: timeoutMs
213
+ };
214
+ if (keyFile) body.sshKeyFile = keyFile;
215
+
178
216
  try {
179
217
  var res = await fetch('/api/daemons/deploy', {
180
218
  method: 'POST',
181
219
  headers: { 'Content-Type': 'application/json' },
182
220
  body: JSON.stringify(body)
183
221
  });
184
- var data = await res.json();
185
- if (data.ok) {
186
- setDeployResult('✓ 部署成功' + (data.output ? '\n' + data.output : ''), 'ok');
187
- await loadDaemons();
222
+
223
+ if (res.headers.get('Content-Type') && res.headers.get('Content-Type').includes('text/event-stream')) {
224
+ var reader = res.body.getReader();
225
+ var decoder = new TextDecoder();
226
+ var allOk = true;
227
+ while (true) {
228
+ var chunk = await reader.read();
229
+ if (chunk.done) break;
230
+ var text = decoder.decode(chunk.value, { stream: true });
231
+ var lines = text.split('\n');
232
+ for (var i = 0; i < lines.length; i++) {
233
+ var line = lines[i].trim();
234
+ if (!line.startsWith('data: ')) continue;
235
+ try {
236
+ var msg = JSON.parse(line.slice(6));
237
+ appendDeployLog(msg);
238
+ if (msg.indexOf('✗') >= 0) allOk = false;
239
+ } catch (e) { /* skip non-JSON */ }
240
+ }
241
+ }
242
+ if (allOk) {
243
+ deployResultEl.className = 'db-result db-ok';
244
+ await loadDaemons();
245
+ } else {
246
+ deployResultEl.className = 'db-result db-err';
247
+ }
188
248
  } else {
189
- setDeployResult('✗ ' + (data.error || '未知错误') + (data.output ? '\n' + data.output : ''), 'err');
249
+ var data = await res.json();
250
+ if (data.ok) {
251
+ appendDeployLog('✓ 部署成功\n');
252
+ if (data.output) appendDeployLog(data.output);
253
+ deployResultEl.className = 'db-result db-ok';
254
+ await loadDaemons();
255
+ } else {
256
+ appendDeployLog('✗ ' + (data.error || '未知错误') + '\n');
257
+ if (data.output) appendDeployLog(data.output);
258
+ deployResultEl.className = 'db-result db-err';
259
+ }
190
260
  }
191
261
  } catch (e) {
192
- setDeployResult('✗ ' + (e.message || String(e)), 'err');
262
+ appendDeployLog('✗ ' + (e.message || String(e)) + '\n');
263
+ deployResultEl.className = 'db-result db-err';
193
264
  } finally {
194
265
  if (deployBtn) deployBtn.disabled = false;
195
266
  }
@@ -81,16 +81,16 @@ function buildDaemonSection(viewer: UserRow): string {
81
81
  <details style="margin-top:.7rem">
82
82
  <summary style="cursor:pointer;font-size:13px;color:var(--text-muted)">📡 SSH 远程部署</summary>
83
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
84
  <label class="sf"><span>SSH 用户</span><input type="text" id="deploy-user" placeholder="root" value="root" autocomplete="off"></label>
86
85
  <label class="sf"><span>SSH 端口</span><input type="number" id="deploy-ssh-port" value="22" min="1" max="65535" autocomplete="off"></label>
87
86
  <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
87
  <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>
88
+ <label class="sf"><span>超时(秒)</span><input type="number" id="deploy-timeout" value="180" min="30" max="600" autocomplete="off"></label>
89
+ <label class="sf" style="align-items:flex-start"><span>目标机器</span><textarea id="deploy-targets" rows="4" style="flex:1;padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:12px;font-family:ui-monospace,monospace" placeholder="每行一个,格式:&#10;192.168.1.100&#10;192.168.1.101:3201&#10;10.0.0.5:3202&#10;(不带端口则用默认 3101)"></textarea></label>
90
+ <div class="db-controls"><button type="button" id="daemon-deploy">部署(支持批量)</button></div>
91
91
  </div>
92
92
  </details>
93
- <div id="deploy-result" class="db-result"></div>
93
+ <div id="deploy-result" class="db-result" style="max-height:400px;overflow-y:auto;font-family:ui-monospace,monospace;font-size:11px"></div>
94
94
  <script src="/static/daemon-mgr.js"></script>
95
95
  </section>`;
96
96
  }