ework-web 0.10.16 → 0.10.17

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.10.16",
3
+ "version": "0.10.17",
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",
package/src/db-admin.ts CHANGED
@@ -575,6 +575,75 @@ const DAEMON_DDL: Record<string, string> = {
575
575
  ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
576
576
  };
577
577
 
578
+ export interface DdlResult {
579
+ ok: true;
580
+ sql: string;
581
+ database: string;
582
+ webPrefix: string;
583
+ daemonPrefix: string;
584
+ }
585
+
586
+ /** Generate a runnable MySQL script for accounts whose DB user lacks CREATE
587
+ * (the wizard's auto-CREATE-DATABASE path is blocked). Reuses migrate()'s
588
+ * exact DDL so manual + auto paths stay in sync. Idempotent. */
589
+ export function generateMysqlDDL(
590
+ opts: MysqlTargetOpts,
591
+ daemonPrefixRaw: string,
592
+ ): DdlResult {
593
+ const webPrefix = validatePrefix(opts.prefix ?? "");
594
+ const daemonPrefix = validatePrefix(daemonPrefixRaw);
595
+ const lines: string[] = [];
596
+
597
+ lines.push(`-- ework-web + ework-daemon MySQL schema`);
598
+ lines.push(`-- Generated for restricted-privilege accounts (no CREATE).`);
599
+ lines.push(`-- Database: ${opts.database} | web prefix: "${webPrefix}" | daemon prefix: "${daemonPrefix}"`);
600
+ lines.push(`-- Idempotent — safe to re-run.`);
601
+ lines.push(``);
602
+ lines.push(`CREATE DATABASE IF NOT EXISTS ${ident(opts.database)} CHARACTER SET utf8mb4;`);
603
+ lines.push(`USE ${ident(opts.database)};`);
604
+ lines.push(``);
605
+ lines.push(`-- ─── ework-web tables (${webPrefix ? `prefix "${webPrefix}"` : "no prefix"}) ───`);
606
+
607
+ for (const stmt of readSchemaMysqlStatements(webPrefix)) {
608
+ lines.push(stmt + ";");
609
+ }
610
+
611
+ // config table — created in db.ts:SqliteDriver.create at boot, missing from
612
+ // schema-mysql.sql. Same shape as migrateSqliteToMysql L257-266. `key` is a
613
+ // MySQL reserved word → backticked.
614
+ lines.push(
615
+ applyTargetPrefix(
616
+ "CREATE TABLE IF NOT EXISTS {{config}} (" +
617
+ "`key` VARCHAR(255) PRIMARY KEY," +
618
+ "value TEXT NOT NULL," +
619
+ "updated_at VARCHAR(40) NOT NULL" +
620
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
621
+ webPrefix
622
+ )
623
+ );
624
+
625
+ lines.push(``);
626
+ lines.push(`-- ─── ework-daemon tables (${daemonPrefix ? `prefix "${daemonPrefix}"` : "no prefix"}) ───`);
627
+ lines.push(`-- Daemon columns match its SQLite schema; coordination columns`);
628
+ lines.push(`-- (owner_daemon_id etc.) are added by the daemon's ALTER TABLE on boot.`);
629
+ for (const table of DAEMON_TABLES) {
630
+ const ddl = applyTargetPrefix(
631
+ DAEMON_DDL[table]!.replace(/{{t}}/g, `{{${table}}}`),
632
+ daemonPrefix
633
+ );
634
+ lines.push(ddl + ";");
635
+ }
636
+
637
+ lines.push(``);
638
+ return {
639
+ ok: true,
640
+ sql: lines.join("\n"),
641
+ database: opts.database,
642
+ webPrefix,
643
+ daemonPrefix,
644
+ };
645
+ }
646
+
578
647
  export async function migrateDaemonSqliteToMysql(
579
648
  sqlitePath: string,
580
649
  opts: MysqlTargetOpts,
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@ import { loadConfig, DB_OVERRIDABLE, parseOverride, resolveTtsBackend } from "./
7
7
  import type { Config } from "./config";
8
8
  import { setConfig, initDB, getDB } from "./db";
9
9
  import { getActiveDaemons, listAllDaemons } from "./coordination";
10
- import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv, migrateDaemonSqliteToMysql } from "./db-admin";
10
+ import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv, migrateDaemonSqliteToMysql, generateMysqlDDL } from "./db-admin";
11
11
  import type { MysqlTargetOpts } from "./db-admin";
12
12
  import { checkAuth, makeAuthCookieHeader, clearAuthCookieHeader, loginHTML, sanitizeNext, ensureBootstrapAdmin, ensureBootstrapSystem, isReservedSystemLogin } from "./auth";
13
13
  import { OpencodeError, createOpencodeClient, MultiDaemonOpencodeClient, RemoteOpencodeClient, isLocalhost, type OpencodeClientInterface } from "./opencode";
@@ -201,7 +201,9 @@ function html(body: string, status = 200): Response {
201
201
  // The prefix regex below must match db.ts:WORK_DB_PREFIX exactly — if the API
202
202
  // accepts a prefix that boot rejects, the wizard writes a .env that restarts
203
203
  // into a crash. Any change here must be mirrored in db.ts + db-admin.ts.
204
- function parseDbTargetOpts(payload: unknown): MysqlTargetOpts | { error: string } {
204
+ type ParsedDbTarget = MysqlTargetOpts & { daemonPrefix?: string };
205
+
206
+ function parseDbTargetOpts(payload: unknown): ParsedDbTarget | { error: string } {
205
207
  if (typeof payload !== "object" || payload === null) return { error: "invalid body" };
206
208
  const p = payload as Record<string, unknown>;
207
209
  const host = typeof p.host === "string" ? p.host.trim() : "";
@@ -210,17 +212,23 @@ function parseDbTargetOpts(payload: unknown): MysqlTargetOpts | { error: string
210
212
  const password = typeof p.password === "string" ? p.password : "";
211
213
  const database = typeof p.database === "string" ? p.database.trim() : "";
212
214
  const prefix = typeof p.prefix === "string" ? p.prefix.trim() : "";
215
+ const daemonPrefix = typeof p.daemonPrefix === "string" ? p.daemonPrefix.trim() : "";
213
216
  if (!host) return { error: "host required" };
214
217
  if (!user) return { error: "user required" };
215
218
  if (!database) return { error: "database required" };
216
219
  if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
217
220
  return { error: "port must be an integer 1-65535" };
218
221
  }
219
- if (prefix && !/^[A-Za-z_][A-Za-z0-9_]{0,31}$/.test(prefix)) {
222
+ const prefixRe = /^[A-Za-z_][A-Za-z0-9_]{0,31}$/;
223
+ if (prefix && !prefixRe.test(prefix)) {
220
224
  return { error: "prefix must match ^[A-Za-z_][A-Za-z0-9_]{0,31}$" };
221
225
  }
222
- const opts: MysqlTargetOpts = { host, port: portNum, user, password, database };
226
+ if (daemonPrefix && !prefixRe.test(daemonPrefix)) {
227
+ return { error: "daemonPrefix must match ^[A-Za-z_][A-Za-z0-9_]{0,31}$" };
228
+ }
229
+ const opts: ParsedDbTarget = { host, port: portNum, user, password, database };
223
230
  if (prefix) opts.prefix = prefix;
231
+ if (daemonPrefix) opts.daemonPrefix = daemonPrefix;
224
232
  return opts;
225
233
  }
226
234
 
@@ -805,7 +813,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
805
813
  const rawPayload = await req.json().catch(() => ({}));
806
814
  const parsed = parseDbTargetOpts(rawPayload);
807
815
  if ("error" in parsed) return json(parsed, 400);
808
- const daemonOpts: MysqlTargetOpts = { ...parsed, prefix: daemonPrefix(parsed.prefix ?? "") };
816
+ const { daemonPrefix: _dp, ...mysqlFields } = parsed;
817
+ const dPrefix = parsed.daemonPrefix ?? daemonPrefix(parsed.prefix ?? "");
818
+ const daemonOpts: MysqlTargetOpts = { ...mysqlFields, prefix: dPrefix };
809
819
  const envPath = daemonEnvPath();
810
820
  if (!envPath) {
811
821
  return json({ ok: false, configured: false, manual: daemonManualInstructions(daemonOpts) });
@@ -825,6 +835,21 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
825
835
  }
826
836
  }
827
837
 
838
+ if (req.method === "POST" && url.pathname === "/api/db/ddl") {
839
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
840
+ if (!rateLimit(`db-ddl:${ip}`, 10, 10 / 60)) return json({ error: "rate limited" }, 429);
841
+ const payload = await req.json().catch(() => ({}));
842
+ const parsed = parseDbTargetOpts(payload);
843
+ if ("error" in parsed) return json(parsed, 400);
844
+ const dPrefix = parsed.daemonPrefix ?? daemonPrefix(parsed.prefix ?? "");
845
+ try {
846
+ const result = generateMysqlDDL(parsed, dPrefix);
847
+ return json(result);
848
+ } catch (e) {
849
+ return json({ ok: false, error: errMsg(e) }, 400);
850
+ }
851
+ }
852
+
828
853
  if (req.method === "POST" && url.pathname === "/api/db/revert") {
829
854
  if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
830
855
  if (!rateLimit(`db-revert:${ip}`, 2, 2 / 3600)) return json({ error: "rate limited" }, 429);
@@ -12,7 +12,8 @@
12
12
  user: get('db-user').trim(),
13
13
  password: get('db-password'),
14
14
  database: get('db-database').trim(),
15
- prefix: get('db-prefix').trim()
15
+ prefix: get('db-prefix').trim(),
16
+ daemonPrefix: get('db-daemon-prefix').trim()
16
17
  });
17
18
  }
18
19
  function setBtns(d) {
@@ -74,6 +75,58 @@
74
75
  document.getElementById('db-daemon').onclick = function () { act('daemon-config'); };
75
76
  document.getElementById('db-enable').onclick = function () { act('enable'); };
76
77
 
78
+ var ddlBtn = document.getElementById('db-ddl');
79
+ if (ddlBtn) {
80
+ ddlBtn.onclick = async function () {
81
+ setBtns(true);
82
+ R.className = 'db-result db-loading';
83
+ R.textContent = '生成建表 SQL…';
84
+ try {
85
+ var res = await fetch('/api/db/ddl', {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body: gather()
89
+ });
90
+ var data = await res.json();
91
+ if (data.ok) {
92
+ R.className = 'db-result db-ok';
93
+ R.textContent = '✓ SQL 已生成(' + data.database + ' · web 前缀 "' + (data.webPrefix || '(无)') + '" · daemon 前缀 "' + (data.daemonPrefix || '(无)') + '")。复制下方 SQL,用有 CREATE 权限的账号在 MySQL 里跑一遍,再回来点 ② 迁移。';
94
+ var out = document.getElementById('db-ddl-out');
95
+ var ta = document.getElementById('db-ddl-text');
96
+ var meta = document.getElementById('db-ddl-meta');
97
+ ta.value = data.sql;
98
+ meta.textContent = data.sql.length + ' 字符';
99
+ out.style.display = '';
100
+ } else {
101
+ R.className = 'db-result db-err';
102
+ R.textContent = '✗ ' + (data.error || '未知错误');
103
+ }
104
+ } catch (e) {
105
+ R.className = 'db-result db-err';
106
+ R.textContent = '✗ ' + (e.message || String(e));
107
+ } finally {
108
+ setBtns(false);
109
+ }
110
+ };
111
+ }
112
+
113
+ var ddlCopy = document.getElementById('db-ddl-copy');
114
+ if (ddlCopy) {
115
+ ddlCopy.onclick = async function () {
116
+ var ta = document.getElementById('db-ddl-text');
117
+ try {
118
+ await navigator.clipboard.writeText(ta.value);
119
+ ddlCopy.textContent = '✓ 已复制';
120
+ setTimeout(function () { ddlCopy.textContent = '📋 复制 SQL'; }, 1500);
121
+ } catch (e) {
122
+ ta.select();
123
+ document.execCommand('copy');
124
+ ddlCopy.textContent = '✓ 已复制';
125
+ setTimeout(function () { ddlCopy.textContent = '📋 复制 SQL'; }, 1500);
126
+ }
127
+ };
128
+ }
129
+
77
130
  var revertBtn = document.getElementById('db-revert');
78
131
  if (revertBtn) {
79
132
  revertBtn.onclick = async function () {
@@ -49,20 +49,23 @@ function buildDbSection(viewer: UserRow): string {
49
49
  return `<section class="sg db-section">
50
50
  <h2>数据库后端</h2>
51
51
  <p class="db-badge">当前后端: <strong>${escapeHtml(driver.toUpperCase())}</strong> · ${escapeHtml(currentTarget)}</p>
52
- <div class="db-warn">⚠ 切换到 MySQL 后 Web 进程会重启并以 MySQL 为存储。若 MySQL 不可达,进程无法启动——需手动编辑 <code>.env</code> 将 <code>WORK_DB_DRIVER</code> 改回 <code>sqlite</code> 才能恢复。流程:先 ① 测试连接、再 ② 迁移数据、最后 ③ 启用。</div>
52
+ <div class="db-warn">⚠ 切换到 MySQL 后 Web 进程会重启并以 MySQL 为存储。若 MySQL 不可达,进程无法启动——需手动编辑 <code>.env</code> 将 <code>WORK_DB_DRIVER</code> 改回 <code>sqlite</code> 才能恢复。流程:先 ① 测试连接、再 ② 迁移数据、最后 ③ 启用。若你的 MySQL 账号无 CREATE 权限,先点 ⑤ 生成建表 SQL,用有权限的账号跑一遍,再回来 ② 迁移。</div>
53
53
  <label class="sf"><span>MySQL 主机</span><input type="text" id="db-host" placeholder="127.0.0.1" autocomplete="off"></label>
54
54
  <label class="sf"><span>端口</span><input type="number" id="db-port" value="3306" min="1" max="65535" autocomplete="off"></label>
55
55
  <label class="sf"><span>用户名</span><input type="text" id="db-user" placeholder="ework" autocomplete="off"></label>
56
56
  <label class="sf"><span>密码</span><input type="password" id="db-password" placeholder="••••••" autocomplete="new-password"></label>
57
57
  <label class="sf"><span>数据库名</span><input type="text" id="db-database" placeholder="ework" autocomplete="off"></label>
58
58
  <label class="sf"><span>表前缀(可选)</span><input type="text" id="db-prefix" placeholder="留空 = 无前缀" autocomplete="off"></label>
59
+ <label class="sf"><span>daemon 前缀(可选)</span><input type="text" id="db-daemon-prefix" placeholder="留空 = 自动用「表前缀」+d_" autocomplete="off"></label>
59
60
  <div class="db-controls">
60
61
  <button type="button" id="db-test">① 测试连接</button>
61
62
  <button type="button" id="db-migrate" class="secondary">② 迁移数据</button>
62
63
  <button type="button" id="db-daemon" class="secondary">③ 配置 daemon</button>
63
64
  <button type="button" id="db-enable" class="secondary">④ 启用并重启</button>
65
+ <button type="button" id="db-ddl" class="secondary">⑤ 生成建表 SQL(手动建库)</button>
64
66
  </div>
65
67
  <div id="db-result" class="db-result"></div>
68
+ <div id="db-ddl-out" style="display:none;margin-top:.6rem"><div class="db-controls" style="align-items:center"><button type="button" id="db-ddl-copy" class="secondary">📋 复制 SQL</button><span id="db-ddl-meta" style="font-size:12px;color:var(--text-muted)"></span></div><textarea id="db-ddl-text" readonly style="width:100%;min-height:240px;padding:.5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" wrap="off"></textarea></div>
66
69
  ${isMysql ? `<hr style="border:0;border-top:1px solid var(--border);margin:1rem 0"><div class="db-controls"><button type="button" id="db-revert" class="secondary">⚠ 切回 SQLite(安全网)</button></div><div id="db-revert-result" class="db-result"></div>` : ""}
67
70
  <script src="/static/db-wizard.js"></script>
68
71
  </section>`;