ework-web 0.2.1 → 0.4.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/src/index.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  import { join, dirname } from "path";
2
2
  import { fileURLToPath } from "url";
3
- import { readFileSync, appendFileSync } from "fs";
3
+ import { readFileSync, appendFileSync, existsSync } from "fs";
4
+ import { spawn } from "child_process";
5
+ import { homedir } from "os";
4
6
  import { loadConfig, DB_OVERRIDABLE, parseOverride, resolveTtsBackend } from "./config";
5
7
  import type { Config } from "./config";
6
- import { setConfig } from "./db";
8
+ import { setConfig, initDB } from "./db";
9
+ import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv } from "./db-admin";
10
+ import type { MysqlTargetOpts } from "./db-admin";
7
11
  import { checkAuth, makeAuthCookieHeader, clearAuthCookieHeader, loginHTML, sanitizeNext, ensureBootstrapAdmin, ensureBootstrapSystem, isReservedSystemLogin } from "./auth";
8
12
  import { OpencodeClient, OpencodeError } from "./opencode";
9
13
  import { renderMarkdown } from "./render/markdown";
@@ -89,10 +93,11 @@ import { handleGiteaApi } from "./giteaApi";
89
93
  const __dirname = dirname(fileURLToPath(import.meta.url));
90
94
  const STATIC_DIR = join(__dirname, "static");
91
95
 
92
- const cfg: Config = loadConfig();
96
+ await initDB();
97
+ const cfg: Config = await loadConfig();
93
98
  const opencode = new OpencodeClient(cfg);
94
99
 
95
- function autoWireDaemon(projectId: number, origin: string): void {
100
+ async function autoWireDaemon(projectId: number, origin: string): Promise<void> {
96
101
  if (!cfg.autowireActive) {
97
102
  log.info("autoWireDaemon: skipped (WORK_AUTOWIRE_ACTIVE=false)", { projectId });
98
103
  return;
@@ -101,7 +106,7 @@ function autoWireDaemon(projectId: number, origin: string): void {
101
106
  const hookUrl = cfg.daemonWebhookUrl.trim();
102
107
  if (botLogin) {
103
108
  try {
104
- addProjectMember(projectId, botLogin, "writer");
109
+ await addProjectMember(projectId, botLogin, "writer");
105
110
  } catch (e) {
106
111
  log.warn("autoWireDaemon: addProjectMember failed", { botLogin, err: e as Error });
107
112
  }
@@ -109,15 +114,15 @@ function autoWireDaemon(projectId: number, origin: string): void {
109
114
  if (hookUrl) {
110
115
  try {
111
116
  const target = hookUrl.replace(/\/$/, "") + "/webhook/gitea";
112
- const exists = listWebhooks(projectId).some((w) => w.url === target);
117
+ const exists = (await listWebhooks(projectId)).some((w) => w.url === target);
113
118
  if (!exists) {
114
- createWebhook({
119
+ await createWebhook({
115
120
  project_id: projectId,
116
121
  url: target,
117
122
  secret: cfg.daemonWebhookSecret,
118
123
  events: ["issues", "issue_comment"],
119
124
  });
120
- emitPingEvent(projectId, origin);
125
+ void emitPingEvent(projectId, origin);
121
126
  }
122
127
  } catch (e) {
123
128
  log.warn("autoWireDaemon: createWebhook failed", { hookUrl, err: e as Error });
@@ -171,6 +176,98 @@ function html(body: string, status = 200): Response {
171
176
  });
172
177
  }
173
178
 
179
+ // The prefix regex below must match db.ts:WORK_DB_PREFIX exactly — if the API
180
+ // accepts a prefix that boot rejects, the wizard writes a .env that restarts
181
+ // into a crash. Any change here must be mirrored in db.ts + db-admin.ts.
182
+ function parseDbTargetOpts(payload: unknown): MysqlTargetOpts | { error: string } {
183
+ if (typeof payload !== "object" || payload === null) return { error: "invalid body" };
184
+ const p = payload as Record<string, unknown>;
185
+ const host = typeof p.host === "string" ? p.host.trim() : "";
186
+ const portNum = Number(p.port);
187
+ const user = typeof p.user === "string" ? p.user.trim() : "";
188
+ const password = typeof p.password === "string" ? p.password : "";
189
+ const database = typeof p.database === "string" ? p.database.trim() : "";
190
+ const prefix = typeof p.prefix === "string" ? p.prefix.trim() : "";
191
+ if (!host) return { error: "host required" };
192
+ if (!user) return { error: "user required" };
193
+ if (!database) return { error: "database required" };
194
+ if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
195
+ return { error: "port must be an integer 1-65535" };
196
+ }
197
+ if (prefix && !/^[A-Za-z_][A-Za-z0-9_]{0,31}$/.test(prefix)) {
198
+ return { error: "prefix must match ^[A-Za-z_][A-Za-z0-9_]{0,31}$" };
199
+ }
200
+ const opts: MysqlTargetOpts = { host, port: portNum, user, password, database };
201
+ if (prefix) opts.prefix = prefix;
202
+ return opts;
203
+ }
204
+
205
+ // Two restart paths depending on how this process was launched. Both run
206
+ // AFTER the HTTP response is sent (caller schedules this last):
207
+ // systemd (INVOCATION_ID set): exit → the unit's Restart=always brings us back
208
+ // PID-file mode (ework-aio start): `ework-aio restart web` SIGTERMs us via the
209
+ // pidfile and spawns a fresh process that re-reads the updated .env.
210
+ // The ework-aio spawn is UNSAFE under systemd — it would start a second process
211
+ // on the same port — so the INVOCATION_ID gate is load-bearing.
212
+ function scheduleRestart(): void {
213
+ if (process.env.INVOCATION_ID) {
214
+ setTimeout(() => process.exit(0), 750);
215
+ return;
216
+ }
217
+ const child = spawn("ework-aio", ["restart", "web"], {
218
+ detached: true,
219
+ stdio: "ignore",
220
+ });
221
+ child.on("error", () => {
222
+ // ework-aio not on PATH (dev mode) — best-effort exit; if no supervisor
223
+ // is watching, the admin restarts manually.
224
+ setTimeout(() => process.exit(0), 750);
225
+ });
226
+ child.unref();
227
+ }
228
+
229
+ // The daemon's `issues` table (thin ref) collides with the web's `issues`
230
+ // table (full content) if they share a database. The daemon MUST use a
231
+ // different prefix. We append "d_" to whatever the web uses so both can
232
+ // coexist in one database without the operator having to plan prefixes.
233
+ function daemonPrefix(webPrefix: string): string {
234
+ return webPrefix + "d_";
235
+ }
236
+
237
+ function daemonEnvPath(): string | null {
238
+ const dataDir = process.env.WORK_DAEMON_DATA_DIR;
239
+ if (dataDir) {
240
+ const p = join(dataDir, ".env");
241
+ return existsSync(p) ? p : null;
242
+ }
243
+ const conventional = join(homedir(), ".local", "share", "ework-daemon", ".env");
244
+ return existsSync(conventional) ? conventional : null;
245
+ }
246
+
247
+ function spawnDaemonRestart(): void {
248
+ try {
249
+ const child = spawn("ework-aio", ["restart", "daemon"], { detached: true, stdio: "ignore" });
250
+ child.on("error", () => {});
251
+ child.unref();
252
+ } catch {
253
+ // best-effort — the manual fallback path covers this
254
+ }
255
+ }
256
+
257
+ function daemonManualInstructions(opts: MysqlTargetOpts): string {
258
+ return [
259
+ "将以下内容追加到 daemon 的 .env,然后运行 ework-aio restart daemon:",
260
+ "",
261
+ `WORK_DB_DRIVER=mysql`,
262
+ `WORK_DB_HOST=${opts.host}`,
263
+ `WORK_DB_PORT=${opts.port}`,
264
+ `WORK_DB_USER=${opts.user}`,
265
+ "WORK_DB_PASSWORD=<daemon密码>",
266
+ `WORK_DB_NAME=${opts.database}`,
267
+ `WORK_DB_PREFIX=${opts.prefix ?? ""}`,
268
+ ].join("\n");
269
+ }
270
+
174
271
  const REPO_ISSUE_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)$/;
175
272
  const REPO_LIST_RE = /^\/([^/]+)\/([^/]+)\/issues$/;
176
273
  const REPO_NEW_RE = /^\/([^/]+)\/([^/]+)\/issues\/new$/;
@@ -367,7 +464,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
367
464
  if (att) {
368
465
  const [, uuid] = att;
369
466
  if (!uuid) return new Response(null, { status: 400, headers: SEC_HEADERS });
370
- const row = getAttachment(uuid);
467
+ const row = await getAttachment(uuid);
371
468
  if (!row) return new Response(null, { status: 404, headers: SEC_HEADERS });
372
469
  const stream = readAttachmentStream(uuid);
373
470
  if (!stream) return new Response(null, { status: 404, headers: SEC_HEADERS });
@@ -398,10 +495,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
398
495
  const form = await req.formData().catch(() => new FormData());
399
496
  const f: Record<string, string | undefined> = {};
400
497
  for (const [k, v] of form.entries()) f[k] = typeof v === "string" ? v : undefined;
401
- const r = handleCreateProject(f);
498
+ const r = await handleCreateProject(f);
402
499
  if (r.projectId) {
403
- ensureProjectBootstrapAdmin(r.projectId, ctx.user!.login);
404
- autoWireDaemon(r.projectId, url.origin);
500
+ await ensureProjectBootstrapAdmin(r.projectId, ctx.user!.login);
501
+ await autoWireDaemon(r.projectId, url.origin);
405
502
  const q = new URLSearchParams({ created: "1" });
406
503
  return Response.redirect(`${url.origin}${r.location}?${q}`, 303);
407
504
  }
@@ -410,14 +507,14 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
410
507
  }
411
508
  const flashErr = url.searchParams.get("err");
412
509
  const flash = flashErr ? { kind: "err" as const, msg: flashErr } : null;
413
- return html(buildHome(ctx.user, flash));
510
+ return html(await buildHome(ctx.user, flash));
414
511
  }
415
512
 
416
513
  if (url.pathname === "/issues") {
417
514
  const state = parseState(url.searchParams.get("state"));
418
515
  const q = url.searchParams.get("q")?.trim() ?? "";
419
516
  try {
420
- return html(buildIssuesFeed(state, q));
517
+ return html(await buildIssuesFeed(state, q));
421
518
  } catch (e) {
422
519
  return html(errorPage("加载失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
423
520
  }
@@ -604,9 +701,71 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
604
701
  return new Response(stream, { status: 200, headers });
605
702
  }
606
703
 
704
+ if (req.method === "POST" && url.pathname === "/api/db/test") {
705
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
706
+ if (!rateLimit(`db-test:${ip}`, 5, 5 / 60)) return json({ error: "rate limited" }, 429);
707
+ const payload = await req.json().catch(() => ({}));
708
+ const parsed = parseDbTargetOpts(payload);
709
+ if ("error" in parsed) return json(parsed, 400);
710
+ const result = await testMysqlConnection(parsed);
711
+ return json(result, result.ok ? 200 : 422);
712
+ }
713
+
714
+ if (req.method === "POST" && url.pathname === "/api/db/migrate") {
715
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
716
+ if (!rateLimit(`db-migrate:${ip}`, 2, 2 / 3600)) return json({ error: "rate limited" }, 429);
717
+ const payload = await req.json().catch(() => ({}));
718
+ const parsed = parseDbTargetOpts(payload);
719
+ if ("error" in parsed) return json(parsed, 400);
720
+ const result = await migrateSqliteToMysql(parsed);
721
+ return json(result, result.ok ? 200 : 422);
722
+ }
723
+
724
+ if (req.method === "POST" && url.pathname === "/api/db/enable") {
725
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
726
+ if (!rateLimit(`db-enable:${ip}`, 3, 3 / 3600)) return json({ error: "rate limited" }, 429);
727
+ const payload = await req.json().catch(() => ({}));
728
+ const parsed = parseDbTargetOpts(payload);
729
+ if ("error" in parsed) return json(parsed, 400);
730
+ const envResult = await writeMysqlEnv(join(process.cwd(), ".env"), parsed);
731
+ scheduleRestart();
732
+ return json({ ok: true, ...envResult, restarting: true });
733
+ }
734
+
735
+ if (req.method === "POST" && url.pathname === "/api/db/daemon-config") {
736
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
737
+ if (!rateLimit(`db-daemon:${ip}`, 3, 3 / 3600)) return json({ error: "rate limited" }, 429);
738
+ const rawPayload = await req.json().catch(() => ({}));
739
+ const parsed = parseDbTargetOpts(rawPayload);
740
+ if ("error" in parsed) return json(parsed, 400);
741
+ const daemonOpts: MysqlTargetOpts = { ...parsed, prefix: daemonPrefix(parsed.prefix ?? "") };
742
+ const envPath = daemonEnvPath();
743
+ if (!envPath) {
744
+ return json({ ok: false, configured: false, manual: daemonManualInstructions(daemonOpts) });
745
+ }
746
+ try {
747
+ const written = await writeMysqlEnv(envPath, daemonOpts);
748
+ spawnDaemonRestart();
749
+ return json({ ok: true, configured: true, envPath, written: written.written });
750
+ } catch (e) {
751
+ return json({ ok: false, configured: false, error: errMsg(e), manual: daemonManualInstructions(daemonOpts) });
752
+ }
753
+ }
754
+
755
+ if (req.method === "POST" && url.pathname === "/api/db/revert") {
756
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
757
+ if (!rateLimit(`db-revert:${ip}`, 2, 2 / 3600)) return json({ error: "rate limited" }, 429);
758
+ const targetPath = join(process.cwd(), "ework.sqlite");
759
+ const result = await migrateMysqlToSqlite(targetPath);
760
+ if (!result.ok) return json(result, 422);
761
+ await writeSqliteEnv(join(process.cwd(), ".env"), targetPath);
762
+ scheduleRestart();
763
+ return json({ ...result, targetPath, restarting: true });
764
+ }
765
+
607
766
  if (url.pathname === "/settings") {
608
767
  if (req.method === "GET") {
609
- return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, listCachedModels()).html);
768
+ return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, await listCachedModels()).html);
610
769
  }
611
770
  if (req.method === "POST") {
612
771
  if (!rateLimit(`settings:${ip}`, 10, 10 / 60)) return html(errorPage("太快了", "请稍后再试"), 429);
@@ -616,10 +775,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
616
775
  const v = form.get(String(key));
617
776
  if (typeof v !== "string") continue;
618
777
  if (parseOverride(key, v) === null) { bad = String(key); break; }
619
- setConfig(String(key), v);
778
+ await setConfig(String(key), v);
620
779
  }
621
780
  if (bad) return html(errorPage(`无效的字段: ${bad}`, "请检查输入后重试"), 400);
622
- Object.assign(cfg, loadConfig());
781
+ Object.assign(cfg, await loadConfig());
623
782
  const rh = new Headers(SEC_HEADERS);
624
783
  rh.set("location", "/settings?saved=1");
625
784
  return new Response(null, { status: 303, headers: rh });
@@ -629,7 +788,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
629
788
  if (url.pathname === "/settings/models/refresh" && req.method === "POST") {
630
789
  if (!ctx.user || ctx.user.is_admin !== 1) return html(errorPage("403", "需要管理员"), 403);
631
790
  const ids = await opencode.listModels();
632
- replaceCachedModels(ids);
791
+ await replaceCachedModels(ids);
633
792
  // M-1: pin a default model so opencode never falls back to env vars. The
634
793
  // previous empty default meant the daemon omitted --model and opencode
635
794
  // picked the model from leaked env (e.g. OPENCODE_MODEL / a provider
@@ -638,8 +797,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
638
797
  if (!cfg.defaultModel) {
639
798
  const picked = ids.find((id) => typeof id === "string" && id.length > 0);
640
799
  if (picked) {
641
- setConfig("defaultModel", picked);
642
- Object.assign(cfg, loadConfig());
800
+ await setConfig("defaultModel", picked);
801
+ Object.assign(cfg, await loadConfig());
643
802
  }
644
803
  }
645
804
  const rh = new Headers(SEC_HEADERS);
@@ -684,7 +843,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
684
843
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
685
844
  const flashMsg = url.searchParams.get("ok") === "1" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
686
845
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
687
- return html(buildTokensPage(me, listPatsForUser(me.login), flash));
846
+ return html(buildTokensPage(me, await listPatsForUser(me.login), flash));
688
847
  }
689
848
 
690
849
  if (url.pathname === "/me/tokens/create" && req.method === "POST") {
@@ -725,7 +884,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
725
884
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
726
885
  const flashMsg = url.searchParams.get("ok") === "1" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
727
886
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
728
- return html(buildAdminUsersPage(ctx.user!, listUsers(), flash));
887
+ return html(buildAdminUsersPage(ctx.user!, await listUsers(), flash));
729
888
  }
730
889
  }
731
890
 
@@ -733,7 +892,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
733
892
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
734
893
  const flashMsg = url.searchParams.get("ok") === "1" ? url.searchParams.get("ok_msg")! : url.searchParams.get("err");
735
894
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg ?? "" } : null;
736
- return html(buildAdminTokensPage(ctx.user!, listAllPatsWithUsers(), flash));
895
+ return html(buildAdminTokensPage(ctx.user!, await listAllPatsWithUsers(), flash));
737
896
  }
738
897
 
739
898
  const adminPatRevoke = url.pathname.match(/^\/admin\/tokens\/(\d+)\/revoke$/);
@@ -769,8 +928,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
769
928
  if (!label) return ttsRedir("err", "label 必填");
770
929
  if (cfg.ttsBackends.some((b) => b.id === id)) return ttsRedir("err", `ID '${id}' 已存在`);
771
930
  const list = [...cfg.ttsBackends, { id, label, url: beUrl, voice }];
772
- setConfig("ttsBackends", JSON.stringify(list));
773
- Object.assign(cfg, loadConfig());
931
+ await setConfig("ttsBackends", JSON.stringify(list));
932
+ Object.assign(cfg, await loadConfig());
774
933
  return ttsRedir("ok", `已添加 ${id}`);
775
934
  }
776
935
 
@@ -790,9 +949,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
790
949
  if (idx < 0) return ttsRedir("err", `后端 '${oldId}' 不存在`);
791
950
  if (newId !== oldId && list.some((b) => b.id === newId)) return ttsRedir("err", `ID '${newId}' 已被占用`);
792
951
  list[idx] = { id: newId, label, url: beUrl, voice };
793
- setConfig("ttsBackends", JSON.stringify(list));
794
- if (cfg.ttsDefaultBackend === oldId && newId !== oldId) setConfig("ttsDefaultBackend", newId);
795
- Object.assign(cfg, loadConfig());
952
+ await setConfig("ttsBackends", JSON.stringify(list));
953
+ if (cfg.ttsDefaultBackend === oldId && newId !== oldId) await setConfig("ttsDefaultBackend", newId);
954
+ Object.assign(cfg, await loadConfig());
796
955
  return ttsRedir("ok", `已更新 ${newId}`);
797
956
  }
798
957
 
@@ -805,8 +964,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
805
964
  const idx = list.findIndex((b) => b.id === id);
806
965
  if (idx < 0) return ttsRedir("err", `后端 '${id}' 不存在`);
807
966
  list.splice(idx, 1);
808
- setConfig("ttsBackends", JSON.stringify(list));
809
- Object.assign(cfg, loadConfig());
967
+ await setConfig("ttsBackends", JSON.stringify(list));
968
+ Object.assign(cfg, await loadConfig());
810
969
  return ttsRedir("ok", `已删除 ${id}`);
811
970
  }
812
971
 
@@ -846,24 +1005,24 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
846
1005
  if (targetLogin === ctx.user!.login) {
847
1006
  return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("不能改自己的 admin 状态")}`, 303);
848
1007
  }
849
- const u = getUserByLogin(targetLogin);
1008
+ const u = await getUserByLogin(targetLogin);
850
1009
  if (!u) throw new StoreError(404, "用户不存在");
851
- if (u.is_admin === 1 && countAdmins() <= 1) {
1010
+ if (u.is_admin === 1 && (await countAdmins()) <= 1) {
852
1011
  return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("系统至少要保留一个 admin")}`, 303);
853
1012
  }
854
- const updated = updateUser(targetLogin, { is_admin: u.is_admin !== 1 });
1013
+ const updated = await updateUser(targetLogin, { is_admin: u.is_admin !== 1 });
855
1014
  return Response.redirect(`${url.origin}/admin/users?ok=1&ok_msg=${encodeURIComponent(`${updated.login} ${updated.is_admin ? "已设为 admin" : "已取消 admin"}`)}`, 303);
856
1015
  }
857
1016
  if (action === "toggle-active") {
858
1017
  if (targetLogin === ctx.user!.login) {
859
1018
  return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("不能禁用自己的账户")}`, 303);
860
1019
  }
861
- const u = getUserByLogin(targetLogin);
1020
+ const u = await getUserByLogin(targetLogin);
862
1021
  if (!u) throw new StoreError(404, "用户不存在");
863
- if (u.is_active === 1 && u.is_admin === 1 && countAdmins() <= 1) {
1022
+ if (u.is_active === 1 && u.is_admin === 1 && (await countAdmins()) <= 1) {
864
1023
  return Response.redirect(`${url.origin}/admin/users?err=${encodeURIComponent("不能禁用最后一个 admin")}`, 303);
865
1024
  }
866
- const updated = updateUser(targetLogin, { is_active: u.is_active !== 1 });
1025
+ const updated = await updateUser(targetLogin, { is_active: u.is_active !== 1 });
867
1026
  return Response.redirect(`${url.origin}/admin/users?ok=1&ok_msg=${encodeURIComponent(`${updated.login} ${updated.is_active ? "已启用" : "已禁用"}`)}`, 303);
868
1027
  }
869
1028
  return html(errorPage("bad action", ""), 400);
@@ -913,10 +1072,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
913
1072
  if (cl > MAX_ATTACHMENT_BYTES) return json({ error: "file too large (max 20MB)" }, 413);
914
1073
  const number = Number(numStr);
915
1074
  try {
916
- const project = getProject(owner, repo);
1075
+ const project = await getProject(owner, repo);
917
1076
  if (!project) return json({ error: "project not found" }, 404);
918
- if (!canWriteProject(project.id, ctx.user)) return json({ error: "forbidden: needs writer role on project" }, 403);
919
- const issue = getIssueWithMeta(project.id, number);
1077
+ if (!(await canWriteProject(project.id, ctx.user))) return json({ error: "forbidden: needs writer role on project" }, 403);
1078
+ const issue = await getIssueWithMeta(project.id, number);
920
1079
  if (!issue) return json({ error: "issue not found" }, 404);
921
1080
  const form = await req.formData().catch(() => null);
922
1081
  const file = form?.get("attachment");
@@ -927,7 +1086,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
927
1086
  const contentType = file.type || sniffImageContentType(filename);
928
1087
  const uuid = newAttachmentUUID();
929
1088
  const blobPath = saveAttachmentBlob(uuid, bytes);
930
- createAttachment({
1089
+ await createAttachment({
931
1090
  uuid,
932
1091
  issue_id: issue.id,
933
1092
  filename,
@@ -958,14 +1117,14 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
958
1117
  const wantsStateChange = payload.close === true || payload.reopen === true;
959
1118
  if (!hasBody && !wantsStateChange) return json({ error: "body required" }, 400);
960
1119
  if (body.length > 65536) return json({ error: "body too long" }, 413);
961
- const project = getProject(owner, repo);
1120
+ const project = await getProject(owner, repo);
962
1121
  if (!project) return json({ error: "project not found" }, 404);
963
- if (!canWriteProject(project.id, ctx.user)) return json({ error: "forbidden: needs writer role on project" }, 403);
964
- const issue = getIssueWithMeta(project.id, number);
1122
+ if (!(await canWriteProject(project.id, ctx.user))) return json({ error: "forbidden: needs writer role on project" }, 403);
1123
+ const issue = await getIssueWithMeta(project.id, number);
965
1124
  if (!issue) return json({ error: "issue not found" }, 404);
966
1125
  let view: CommentView | null = null;
967
1126
  if (hasBody) {
968
- const c = postComment(issue.id, body, ctx.user!.login);
1127
+ const c = await postComment(issue.id, body, ctx.user!.login);
969
1128
  view = {
970
1129
  id: c.id,
971
1130
  tag: classifyActor(c.body, c.author_kind),
@@ -978,11 +1137,11 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
978
1137
  let closed = false;
979
1138
  let reopened = false;
980
1139
  if (payload.close === true) {
981
- setIssueState(issue.id, "closed");
1140
+ await setIssueState(issue.id, "closed");
982
1141
  closed = true;
983
1142
  void emitIssueEvent(project.id, issue.id, "closed", url.origin);
984
1143
  } else if (payload.reopen === true) {
985
- setIssueState(issue.id, "open");
1144
+ await setIssueState(issue.id, "open");
986
1145
  reopened = true;
987
1146
  void emitIssueEvent(project.id, issue.id, "reopened", url.origin);
988
1147
  }
@@ -1004,20 +1163,20 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1004
1163
  const title = String(form.get("title") ?? "").trim();
1005
1164
  const body = String(form.get("body") ?? "");
1006
1165
  if (!title) return html(errorPage("标题不能为空", "回到上一页填写标题后重试"), 400);
1007
- let project = getProject(owner, repo);
1166
+ let project = await getProject(owner, repo);
1008
1167
  let createdProject = false;
1009
1168
  if (!project) {
1010
- project = createProjectSafe(owner, repo);
1169
+ project = await createProjectSafe(owner, repo);
1011
1170
  createdProject = true;
1012
1171
  }
1013
- if (!canWriteProject(project.id, ctx.user)) {
1172
+ if (!(await canWriteProject(project.id, ctx.user))) {
1014
1173
  return html(errorPage("无权限", "需要该项目 writer 及以上角色才能创建 issue"), 403);
1015
1174
  }
1016
1175
  if (createdProject) {
1017
- ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
1018
- autoWireDaemon(project.id, url.origin);
1176
+ await ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
1177
+ await autoWireDaemon(project.id, url.origin);
1019
1178
  }
1020
- const issue = createIssue(project.id, title, body, ctx.user!.login);
1179
+ const issue = await createIssue(project.id, title, body, ctx.user!.login);
1021
1180
  void emitIssueEvent(project.id, issue.id, "opened", url.origin);
1022
1181
  return Response.redirect(
1023
1182
  `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issue.number}`,
@@ -1033,9 +1192,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1033
1192
  const [, owner, repo] = whCreate;
1034
1193
  if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1035
1194
  try {
1036
- const project = getProject(owner, repo);
1195
+ const project = await getProject(owner, repo);
1037
1196
  if (!project) return html(errorPage("项目不存在", ""), 404);
1038
- if (!canAdminProject(project.id, ctx.user)) {
1197
+ if (!(await canAdminProject(project.id, ctx.user))) {
1039
1198
  return html(errorPage("无权限", "需要该项目 admin 角色才能管理 webhook"), 403);
1040
1199
  }
1041
1200
  const form = await req.formData().catch(() => new FormData());
@@ -1044,7 +1203,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1044
1203
  const events = form.getAll("events") as string[];
1045
1204
  const validEvents = (events.length > 0 ? events : ["issues", "issue_comment"])
1046
1205
  .filter((e): e is WebhookEventName => e === "issues" || e === "issue_comment");
1047
- const wh = createWebhook({
1206
+ const wh = await createWebhook({
1048
1207
  project_id: project.id,
1049
1208
  url: url_,
1050
1209
  secret,
@@ -1063,10 +1222,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1063
1222
  if (memberAdd) {
1064
1223
  const [, owner, repo] = memberAdd;
1065
1224
  if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1066
- const project = getProject(owner, repo);
1225
+ const project = await getProject(owner, repo);
1067
1226
  if (!project) return html(errorPage("项目不存在", ""), 404);
1068
1227
  const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/members`;
1069
- if (!canAdminProject(project.id, ctx.user)) {
1228
+ if (!(await canAdminProject(project.id, ctx.user))) {
1070
1229
  return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1071
1230
  }
1072
1231
  const form = await req.formData().catch(() => new FormData());
@@ -1076,7 +1235,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1076
1235
  return Response.redirect(`${back}?err=${encodeURIComponent("非法角色")}`, 303);
1077
1236
  }
1078
1237
  try {
1079
- addProjectMember(project.id, login, role);
1238
+ await addProjectMember(project.id, login, role);
1080
1239
  return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`已添加 ${login} 为 ${role}`)}`, 303);
1081
1240
  } catch (e) {
1082
1241
  return Response.redirect(`${back}?err=${encodeURIComponent(errMsg(e))}`, 303);
@@ -1087,10 +1246,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1087
1246
  if (memberAction) {
1088
1247
  const [, owner, repo, targetLogin, action] = memberAction;
1089
1248
  if (!(owner && repo && targetLogin && action)) return html(errorPage("bad path", ""), 400);
1090
- const project = getProject(owner, repo);
1249
+ const project = await getProject(owner, repo);
1091
1250
  if (!project) return html(errorPage("项目不存在", ""), 404);
1092
1251
  const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/members`;
1093
- if (!canAdminProject(project.id, ctx.user)) {
1252
+ if (!(await canAdminProject(project.id, ctx.user))) {
1094
1253
  return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1095
1254
  }
1096
1255
  try {
@@ -1101,23 +1260,23 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1101
1260
  return Response.redirect(`${back}?err=${encodeURIComponent("非法角色")}`, 303);
1102
1261
  }
1103
1262
  // Guard last admin: don't let the only project admin demote themselves.
1104
- const current = getProjectMembership(project.id, targetLogin);
1263
+ const current = await getProjectMembership(project.id, targetLogin);
1105
1264
  if (
1106
1265
  current?.role === "admin" &&
1107
1266
  role !== "admin" &&
1108
- countProjectAdmins(project.id) <= 1
1267
+ (await countProjectAdmins(project.id)) <= 1
1109
1268
  ) {
1110
1269
  return Response.redirect(`${back}?err=${encodeURIComponent("最后一个 admin 不能降级")}`, 303);
1111
1270
  }
1112
- setProjectMemberRole(project.id, targetLogin, role);
1271
+ await setProjectMemberRole(project.id, targetLogin, role);
1113
1272
  return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`${targetLogin} → ${role}`)}`, 303);
1114
1273
  }
1115
1274
  if (action === "remove") {
1116
- const current = getProjectMembership(project.id, targetLogin);
1117
- if (current?.role === "admin" && countProjectAdmins(project.id) <= 1) {
1275
+ const current = await getProjectMembership(project.id, targetLogin);
1276
+ if (current?.role === "admin" && (await countProjectAdmins(project.id)) <= 1) {
1118
1277
  return Response.redirect(`${back}?err=${encodeURIComponent("最后一个 admin 不能移除")}`, 303);
1119
1278
  }
1120
- removeProjectMember(project.id, targetLogin);
1279
+ await removeProjectMember(project.id, targetLogin);
1121
1280
  return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`已移除 ${targetLogin}`)}`, 303);
1122
1281
  }
1123
1282
  return html(errorPage("bad action", ""), 400);
@@ -1131,20 +1290,20 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1131
1290
  const [, idStr, action] = whAction;
1132
1291
  const id = Number(idStr || "0");
1133
1292
  if (!id) return html(errorPage("bad webhook id", ""), 400);
1134
- const wh = getWebhook(id);
1293
+ const wh = await getWebhook(id);
1135
1294
  if (!wh) return html(errorPage("webhook 不存在", ""), 404);
1136
- const project = getProjectById(wh.project_id);
1295
+ const project = await getProjectById(wh.project_id);
1137
1296
  if (!project) return html(errorPage("项目不存在", ""), 404);
1138
- if (!canAdminProject(project.id, ctx.user)) {
1297
+ if (!(await canAdminProject(project.id, ctx.user))) {
1139
1298
  return html(errorPage("无权限", "需要该项目 admin 角色才能管理 webhook"), 403);
1140
1299
  }
1141
1300
  const back = `${url.origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/webhooks`;
1142
1301
  if (action === "delete") {
1143
- deleteWebhook(id);
1302
+ await deleteWebhook(id);
1144
1303
  } else if (action === "toggle") {
1145
1304
  const form = await req.formData().catch(() => new FormData());
1146
1305
  const wantActive = form.get("active") === "1";
1147
- setWebhookActive(id, wantActive);
1306
+ await setWebhookActive(id, wantActive);
1148
1307
  } else if (action === "test") {
1149
1308
  void emitPingEvent(wh.project_id, url.origin);
1150
1309
  }
@@ -1155,15 +1314,15 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1155
1314
  if (upstreamSave) {
1156
1315
  const [, owner, repo] = upstreamSave;
1157
1316
  if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1158
- const project = getProject(owner, repo);
1317
+ const project = await getProject(owner, repo);
1159
1318
  if (!project) return html(errorPage("项目不存在", ""), 404);
1160
1319
  const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/upstreams`;
1161
- if (!canAdminProject(project.id, ctx.user)) {
1320
+ if (!(await canAdminProject(project.id, ctx.user))) {
1162
1321
  return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1163
1322
  }
1164
1323
  const form = await req.formData().catch(() => new FormData());
1165
1324
  const raw = String(form.get("urls") ?? "");
1166
- const result = trySetUpstreamUrls(project.id, raw);
1325
+ const result = await trySetUpstreamUrls(project.id, raw);
1167
1326
  if (result.ok) {
1168
1327
  return Response.redirect(
1169
1328
  `${back}?ok=1&ok_msg=${encodeURIComponent(`已保存 ${result.urls.length} 个上游 URL`)}`,
@@ -1177,16 +1336,16 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1177
1336
  if (modelSave) {
1178
1337
  const [, owner, repo] = modelSave;
1179
1338
  if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1180
- const project = getProject(owner, repo);
1339
+ const project = await getProject(owner, repo);
1181
1340
  if (!project) return html(errorPage("项目不存在", ""), 404);
1182
1341
  const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/model`;
1183
- if (!canAdminProject(project.id, ctx.user)) {
1342
+ if (!(await canAdminProject(project.id, ctx.user))) {
1184
1343
  return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1185
1344
  }
1186
1345
  const form = await req.formData().catch(() => new FormData());
1187
1346
  const raw = String(form.get("model") ?? "").trim();
1188
1347
  try {
1189
- setProjectModel(project.id, raw);
1348
+ await setProjectModel(project.id, raw);
1190
1349
  return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent("模型已保存")}`, 303);
1191
1350
  } catch (e) {
1192
1351
  const msg = e instanceof StoreError ? e.message : (e instanceof Error ? e.message : "保存失败");
@@ -1205,7 +1364,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1205
1364
  try {
1206
1365
  if (kind === "page") {
1207
1366
  const page = Math.max(1, Number(url.searchParams.get("page") ?? "1") || 1);
1208
- const { issue, views, currentPage, hasOlder } = fetchIssuePage(owner, repo, number, page);
1367
+ const { issue, views, currentPage, hasOlder } = await fetchIssuePage(owner, repo, number, page);
1209
1368
  const payload = {
1210
1369
  owner,
1211
1370
  repo,
@@ -1220,7 +1379,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1220
1379
  return json(payload);
1221
1380
  }
1222
1381
  const since = url.searchParams.get("since") ?? new Date(0).toISOString();
1223
- const views = fetchIssueSince(owner, repo, number, since);
1382
+ const views = await fetchIssueSince(owner, repo, number, since);
1224
1383
  return json({ comments: views });
1225
1384
  } catch (e) {
1226
1385
  return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
@@ -1240,7 +1399,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1240
1399
  if (!(owner && repo && numStr)) return html(errorPage("404", "bad path"), 404);
1241
1400
  const number = Number(numStr);
1242
1401
  try {
1243
- const { html: body } = buildIssueThread(cfg, owner, repo, number, ctx.user?.login);
1402
+ const { html: body } = await buildIssueThread(cfg, owner, repo, number, ctx.user?.login);
1244
1403
  return html(body);
1245
1404
  } catch (e) {
1246
1405
  const status = e instanceof StoreError ? e.status : 500;
@@ -1255,7 +1414,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1255
1414
  const state = parseState(url.searchParams.get("state"));
1256
1415
  const q = url.searchParams.get("q")?.trim() ?? "";
1257
1416
  try {
1258
- return html(buildIssueList(owner, repo, state, cfg.writesEnabled, q));
1417
+ return html(await buildIssueList(owner, repo, state, cfg.writesEnabled, q));
1259
1418
  } catch (e) {
1260
1419
  return html(errorPage("加载失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
1261
1420
  }
@@ -1265,13 +1424,13 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1265
1424
  if (whPage) {
1266
1425
  const [, owner, repo] = whPage;
1267
1426
  if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
1268
- const project = getProject(owner, repo);
1427
+ const project = await getProject(owner, repo);
1269
1428
  if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
1270
- const isAdmin = canAdminProject(project.id, ctx.user);
1429
+ const isAdmin = await canAdminProject(project.id, ctx.user);
1271
1430
  if (!isAdmin) return html(errorPage("无权限", "需要该项目 admin 角色才能管理 webhook"), 403);
1272
- const hooks = listWebhooks(project.id);
1431
+ const hooks = await listWebhooks(project.id);
1273
1432
  const deliveriesByWebhook = new Map(
1274
- hooks.map((h) => [h.id, listDeliveries(h.id, 10)] as const)
1433
+ await Promise.all(hooks.map(async (h) => [h.id, await listDeliveries(h.id, 10)] as const))
1275
1434
  );
1276
1435
  return html(
1277
1436
  buildWebhooksPage({ project, webhooks: hooks, deliveriesByWebhook })
@@ -1282,26 +1441,26 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1282
1441
  if (membersPage) {
1283
1442
  const [, owner, repo] = membersPage;
1284
1443
  if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
1285
- const project = getProject(owner, repo);
1444
+ const project = await getProject(owner, repo);
1286
1445
  if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
1287
1446
  // Lazy bootstrap: site-admin opening a pre-RBAC project auto-inserts as admin.
1288
- if (ctx.user!.is_admin === 1) ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
1289
- if (!canAdminProject(project.id, ctx.user)) {
1447
+ if (ctx.user!.is_admin === 1) await ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
1448
+ if (!(await canAdminProject(project.id, ctx.user))) {
1290
1449
  return html(errorPage("无权限", "需要该项目 admin 角色才能管理成员"), 403);
1291
1450
  }
1292
1451
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
1293
1452
  const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
1294
1453
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
1295
- return html(buildProjectMembersPage(ctx.user!, project, flash));
1454
+ return html(await buildProjectMembersPage(ctx.user!, project, flash));
1296
1455
  }
1297
1456
 
1298
1457
  const upstreamsPage = url.pathname.match(REPO_UPSTREAMS_RE);
1299
1458
  if (upstreamsPage) {
1300
1459
  const [, owner, repo] = upstreamsPage;
1301
1460
  if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
1302
- const project = getProject(owner, repo);
1461
+ const project = await getProject(owner, repo);
1303
1462
  if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
1304
- if (!canAdminProject(project.id, ctx.user)) {
1463
+ if (!(await canAdminProject(project.id, ctx.user))) {
1305
1464
  return html(errorPage("无权限", "需要该项目 admin 角色才能管理上游"), 403);
1306
1465
  }
1307
1466
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
@@ -1314,15 +1473,15 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1314
1473
  if (modelPage) {
1315
1474
  const [, owner, repo] = modelPage;
1316
1475
  if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
1317
- const project = getProject(owner, repo);
1476
+ const project = await getProject(owner, repo);
1318
1477
  if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
1319
- if (!canAdminProject(project.id, ctx.user)) {
1478
+ if (!(await canAdminProject(project.id, ctx.user))) {
1320
1479
  return html(errorPage("无权限", "需要该项目 admin 角色才能管理模型"), 403);
1321
1480
  }
1322
1481
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
1323
1482
  const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
1324
1483
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
1325
- return html(buildProjectModelPage(project, cfg.defaultModel, listCachedModels(), flash).html);
1484
+ return html(buildProjectModelPage(project, cfg.defaultModel, await listCachedModels(), flash).html);
1326
1485
  }
1327
1486
 
1328
1487
  const repoMatch = url.pathname.match(REPO_RE);
@@ -1342,13 +1501,13 @@ function parseState(s: string | null): "open" | "closed" | "all" {
1342
1501
  return s === "closed" ? "closed" : s === "all" ? "all" : "open";
1343
1502
  }
1344
1503
 
1345
- function createProjectSafe(owner: string, name: string) {
1346
- let project = getProject(owner, name);
1504
+ async function createProjectSafe(owner: string, name: string) {
1505
+ let project = await getProject(owner, name);
1347
1506
  if (project) return project;
1348
1507
  // Auto-create project on first issue POST. Owner/name were already validated by the
1349
1508
  // URL regex shape; allow creation here so `/<owner>/<new-repo>/issues` (POST) bootstraps
1350
1509
  // a project in one step. Use createIssue's tx for atomicity.
1351
- project = createProject(owner, name, "");
1510
+ project = await createProject(owner, name, "");
1352
1511
  return project;
1353
1512
  }
1354
1513
 
@@ -1361,13 +1520,13 @@ function errMsg(e: unknown): string {
1361
1520
  // migration path (legacy cookies resolve to this user).
1362
1521
  // Also ensure the reserved system user exists (kind=system, used to attribute
1363
1522
  // automated actions like future cron / import jobs; not login-able).
1364
- (() => {
1365
- const op = ensureBootstrapAdmin(cfg.operatorLogin);
1366
- if (op.is_admin === 0 && countAdmins() === 0) {
1367
- updateUser(op.login, { is_admin: true });
1523
+ await (async () => {
1524
+ const op = await ensureBootstrapAdmin(cfg.operatorLogin);
1525
+ if (op.is_admin === 0 && (await countAdmins()) === 0) {
1526
+ await updateUser(op.login, { is_admin: true });
1368
1527
  log.info("bootstrap: operator promoted to admin", { login: op.login });
1369
1528
  }
1370
- const sys = ensureBootstrapSystem(cfg.systemLogin);
1529
+ const sys = await ensureBootstrapSystem(cfg.systemLogin);
1371
1530
  void sys;
1372
1531
  })();
1373
1532