Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

admin.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

admin.tsxBlame795 lines · 3 contributors
8f50ed0Claude1/**
2 * Block F3 — Site admin panel.
3 *
4 * GET /admin — dashboard (counts + recent users)
5 * GET /admin/users — user list + search
6 * POST /admin/users/:id/admin — toggle site-admin flag
7 * GET /admin/repos — repo list (including private)
8 * POST /admin/repos/:id/delete — nuclear delete (audit-logged)
9 * GET /admin/flags — site flags CRUD
10 * POST /admin/flags — set flag
11 *
12 * All routes gated by `isSiteAdmin`. First registered user is the bootstrap
13 * admin. Site banner + registration lock are surfaced to the rest of the app
14 * via `getFlag`.
15 */
16
17import { Hono } from "hono";
18import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
19import { db } from "../db";
20import { repositories, users } from "../db/schema";
21import { Layout } from "../views/layout";
5618f9aClaude22import { softAuth } from "../middleware/auth";
8f50ed0Claude23import type { AuthEnv } from "../middleware/auth";
24import {
25 grantSiteAdmin,
26 isSiteAdmin,
27 KNOWN_FLAGS,
28 listFlags,
29 listSiteAdmins,
30 revokeSiteAdmin,
31 setFlag,
32} from "../lib/admin";
33import { audit } from "../lib/notify";
08420cdClaude34import { sendDigestsToAll, sendDigestForUser } from "../lib/email-digest";
8e9f1d9Claude35import {
36 getLastTick,
37 getTickCount,
38 runAutopilotTick,
39} from "../lib/autopilot";
988380aClaude40import { ensureDemoContent, DEMO_USERNAME } from "../lib/demo-seed";
8f50ed0Claude41
42const admin = new Hono<AuthEnv>();
43admin.use("*", softAuth);
44
45async function gate(c: any): Promise<{ user: any } | Response> {
46 const user = c.get("user");
47 if (!user) return c.redirect("/login?next=/admin");
48 if (!(await isSiteAdmin(user.id))) {
49 return c.html(
50 <Layout title="Forbidden" user={user}>
51 <div class="empty-state">
52 <h2>403 — Not a site admin</h2>
53 <p>You don't have permission to view this page.</p>
54 </div>
55 </Layout>,
56 403
57 );
58 }
59 return { user };
60}
61
62admin.get("/admin", async (c) => {
63 const g = await gate(c);
64 if (g instanceof Response) return g;
65 const { user } = g;
66
67 const [uc] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
68 const [rc] = await db
69 .select({ n: sql<number>`count(*)::int` })
70 .from(repositories);
71
72 const recent = await db
73 .select({
74 id: users.id,
75 username: users.username,
76 createdAt: users.createdAt,
77 })
78 .from(users)
79 .orderBy(desc(users.createdAt))
80 .limit(10);
81
82 const admins = await listSiteAdmins();
83
988380aClaude84 const msg = c.req.query("result") || c.req.query("error");
85 const isErr = !!c.req.query("error");
86
8f50ed0Claude87 return c.html(
88 <Layout title="Admin — Gluecron" user={user}>
89 <h2>Site admin</h2>
90
988380aClaude91 {msg && (
92 <div
93 class={isErr ? "auth-error" : "banner"}
94 style="margin-bottom:16px"
95 >
96 {decodeURIComponent(msg)}
97 </div>
98 )}
99
bba3681Test User100 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-3);margin-bottom:var(--space-5)">
101 <div class="panel" style="padding:var(--space-3);text-align:center">
8f50ed0Claude102 <div style="font-size:22px;font-weight:700">{Number(uc?.n || 0)}</div>
103 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
104 Users
105 </div>
106 </div>
bba3681Test User107 <div class="panel" style="padding:var(--space-3);text-align:center">
8f50ed0Claude108 <div style="font-size:22px;font-weight:700">{Number(rc?.n || 0)}</div>
109 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
110 Repos
111 </div>
112 </div>
bba3681Test User113 <div class="panel" style="padding:var(--space-3);text-align:center">
8f50ed0Claude114 <div style="font-size:22px;font-weight:700">{admins.length}</div>
115 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
116 Site admins
117 </div>
118 </div>
119 </div>
120
bba3681Test User121 <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-2);margin-bottom:var(--space-5)">
9dd96b9Test User122 <a href="/admin/ops" class="btn btn-primary">
123 Operations
124 </a>
8f50ed0Claude125 <a href="/admin/users" class="btn">
126 Manage users
127 </a>
128 <a href="/admin/repos" class="btn">
129 Manage repos
130 </a>
131 <a href="/admin/flags" class="btn">
132 Site flags
133 </a>
08420cdClaude134 <a href="/admin/digests" class="btn">
135 Email digests
136 </a>
edf7c36Claude137 <a href="/admin/sso" class="btn">
138 Enterprise SSO
139 </a>
988380aClaude140 <a href="/admin/autopilot" class="btn">
141 Autopilot
142 </a>
143 <form
144 method="post"
145 action="/admin/demo/reseed"
146 style="display:contents"
147 >
148 <button class="btn" type="submit" title="Idempotently (re)create demo user + 3 sample repos">
149 Reseed demo
150 </button>
151 </form>
8f50ed0Claude152 </div>
153
154 <h3>Recent signups</h3>
155 <div class="panel" style="margin-bottom:20px">
156 {recent.map((u) => (
157 <div class="panel-item" style="justify-content:space-between">
158 <a href={`/${u.username}`}>{u.username}</a>
159 <span style="font-size:12px;color:var(--text-muted)">
160 {u.createdAt
161 ? new Date(u.createdAt as unknown as string).toLocaleString()
162 : ""}
163 </span>
164 </div>
165 ))}
166 </div>
167
168 <h3>Site admins</h3>
169 <div class="panel">
170 {admins.length === 0 ? (
171 <div class="panel-empty">
172 No admins (bootstrap mode — oldest user is admin).
173 </div>
174 ) : (
175 admins.map((a) => (
176 <div class="panel-item" style="justify-content:space-between">
177 <a href={`/${a.username}`}>{a.username}</a>
178 <span style="font-size:12px;color:var(--text-muted)">
179 Granted{" "}
180 {a.grantedAt
181 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
182 : ""}
183 </span>
184 </div>
185 ))
186 )}
187 </div>
188 </Layout>
189 );
190});
191
192// ----- Users -----
193
194admin.get("/admin/users", async (c) => {
195 const g = await gate(c);
196 if (g instanceof Response) return g;
197 const { user } = g;
198 const q = c.req.query("q") || "";
199 const rows = await db
200 .select({
201 id: users.id,
202 username: users.username,
203 email: users.email,
204 createdAt: users.createdAt,
205 })
206 .from(users)
207 .where(
208 q
209 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
210 : sql`1=1`
211 )
212 .orderBy(desc(users.createdAt))
213 .limit(200);
214
215 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
216
217 return c.html(
218 <Layout title="Admin — Users" user={user}>
219 <h2>Users</h2>
b9968e3Claude220 <form method="get" action="/admin/users" style="margin-bottom:16px">
8f50ed0Claude221 <input
222 type="text"
223 name="q"
224 value={q}
225 placeholder="Search username or email"
2c3ba6ecopilot-swe-agent[bot]226 aria-label="Search username or email"
8f50ed0Claude227 style="width:320px"
228 />{" "}
229 <button type="submit" class="btn">
230 Search
231 </button>
232 <a href="/admin" class="btn" style="margin-left:6px">
233 Back
234 </a>
235 </form>
236 <div class="panel">
237 {rows.length === 0 ? (
238 <div class="panel-empty">No users found.</div>
239 ) : (
240 rows.map((u) => {
241 const isAdmin = adminIds.has(u.id);
242 return (
243 <div class="panel-item" style="justify-content:space-between">
244 <div>
245 <a href={`/${u.username}`} style="font-weight:600">
246 {u.username}
247 </a>{" "}
248 <span style="color:var(--text-muted)">{u.email}</span>
249 {isAdmin && (
250 <span
251 style="margin-left:6px;font-size:11px;background:#8957e5;color:white;padding:2px 6px;border-radius:3px"
252 >
253 ADMIN
254 </span>
255 )}
256 </div>
257 <form
b9968e3Claude258 method="post"
8f50ed0Claude259 action={`/admin/users/${u.id}/admin`}
260 onsubmit={
261 isAdmin
262 ? "return confirm('Revoke site admin?')"
263 : "return confirm('Grant site admin?')"
264 }
265 >
266 <button type="submit" class="btn btn-sm">
267 {isAdmin ? "Revoke admin" : "Grant admin"}
268 </button>
269 </form>
270 </div>
271 );
272 })
273 )}
274 </div>
275 </Layout>
276 );
277});
278
279admin.post("/admin/users/:id/admin", async (c) => {
280 const g = await gate(c);
281 if (g instanceof Response) return g;
282 const { user } = g;
283 const id = c.req.param("id");
284 const admins = await listSiteAdmins();
285 const isAlready = admins.some((a) => a.userId === id);
286 if (isAlready) {
287 await revokeSiteAdmin(id);
288 await audit({
289 userId: user.id,
290 action: "site_admin.revoke",
291 targetType: "user",
292 targetId: id,
293 });
294 } else {
295 await grantSiteAdmin(id, user.id);
296 await audit({
297 userId: user.id,
298 action: "site_admin.grant",
299 targetType: "user",
300 targetId: id,
301 });
302 }
303 return c.redirect("/admin/users");
304});
305
306// ----- Repos -----
307
308admin.get("/admin/repos", async (c) => {
309 const g = await gate(c);
310 if (g instanceof Response) return g;
311 const { user } = g;
312 const rows = await db
313 .select({
314 id: repositories.id,
315 name: repositories.name,
316 ownerUsername: users.username,
0316dbbClaude317 isPrivate: repositories.isPrivate,
8f50ed0Claude318 createdAt: repositories.createdAt,
319 starCount: repositories.starCount,
320 })
321 .from(repositories)
322 .innerJoin(users, eq(repositories.ownerId, users.id))
323 .orderBy(desc(repositories.createdAt))
324 .limit(200);
325
326 return c.html(
327 <Layout title="Admin — Repos" user={user}>
328 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
329 <h2>Repositories</h2>
330 <a href="/admin" class="btn btn-sm">
331 Back
332 </a>
333 </div>
334 <div class="panel">
335 {rows.length === 0 ? (
336 <div class="panel-empty">No repositories.</div>
337 ) : (
338 rows.map((r) => (
339 <div class="panel-item" style="justify-content:space-between">
340 <div>
341 <a
342 href={`/${r.ownerUsername}/${r.name}`}
343 style="font-weight:600"
344 >
345 {r.ownerUsername}/{r.name}
346 </a>
347 <span
348 style="margin-left:6px;font-size:11px;color:var(--text-muted);text-transform:uppercase"
349 >
0316dbbClaude350 {r.isPrivate ? "private" : "public"}
8f50ed0Claude351 </span>
352 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
353 {r.starCount} stars ·{" "}
354 {r.createdAt
355 ? new Date(r.createdAt as unknown as string).toLocaleDateString()
356 : ""}
357 </div>
358 </div>
359 <form
b9968e3Claude360 method="post"
8f50ed0Claude361 action={`/admin/repos/${r.id}/delete`}
362 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
363 >
364 <button type="submit" class="btn btn-sm btn-danger">
365 Delete
366 </button>
367 </form>
368 </div>
369 ))
370 )}
371 </div>
372 </Layout>
373 );
374});
375
376admin.post("/admin/repos/:id/delete", async (c) => {
377 const g = await gate(c);
378 if (g instanceof Response) return g;
379 const { user } = g;
380 const id = c.req.param("id");
381 try {
382 await db.delete(repositories).where(eq(repositories.id, id));
383 } catch (err) {
384 console.error("[admin] repo delete:", err);
385 }
386 await audit({
387 userId: user.id,
388 action: "admin.repo.delete",
389 targetType: "repository",
390 targetId: id,
391 });
392 return c.redirect("/admin/repos");
393});
394
395// ----- Flags -----
396
397admin.get("/admin/flags", async (c) => {
398 const g = await gate(c);
399 if (g instanceof Response) return g;
400 const { user } = g;
401
402 const existing = await listFlags();
403 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
404 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
405
406 return c.html(
407 <Layout title="Admin — Flags" user={user}>
408 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
409 <h2>Site flags</h2>
410 <a href="/admin" class="btn btn-sm">
411 Back
412 </a>
413 </div>
414 <form
b9968e3Claude415 method="post"
8f50ed0Claude416 action="/admin/flags"
417 class="panel"
bba3681Test User418 style="padding:var(--space-4)"
8f50ed0Claude419 >
420 {keys.map((k) => {
421 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
422 return (
423 <div class="form-group">
424 <label>{k}</label>
425 <input
426 type="text"
427 name={k}
428 value={current}
2c3ba6ecopilot-swe-agent[bot]429 aria-label={k}
8f50ed0Claude430 style="font-family:var(--font-mono)"
431 />
432 <div
433 style="font-size:11px;color:var(--text-muted);margin-top:2px"
434 >
435 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
436 </div>
437 </div>
438 );
439 })}
440 <button type="submit" class="btn btn-primary">
441 Save
442 </button>
443 </form>
444 </Layout>
445 );
446});
447
448admin.post("/admin/flags", async (c) => {
449 const g = await gate(c);
450 if (g instanceof Response) return g;
451 const { user } = g;
452 const body = await c.req.parseBody();
453 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
454 for (const k of keys) {
455 const v = String(body[k] ?? "");
456 await setFlag(k, v, user.id);
457 }
458 await audit({ userId: user.id, action: "admin.flags.save" });
459 return c.redirect("/admin/flags");
460});
461
08420cdClaude462// ----- Email digests (Block I7) -----
463
464admin.get("/admin/digests", async (c) => {
465 const g = await gate(c);
466 if (g instanceof Response) return g;
467 const { user } = g;
468
469 const [optedRow] = await db
470 .select({ n: sql<number>`count(*)::int` })
471 .from(users)
472 .where(eq(users.notifyEmailDigestWeekly, true));
473 const opted = Number(optedRow?.n || 0);
474
475 const recentlySent = await db
476 .select({
477 id: users.id,
478 username: users.username,
479 lastDigestSentAt: users.lastDigestSentAt,
480 })
481 .from(users)
482 .where(sql`${users.lastDigestSentAt} is not null`)
483 .orderBy(desc(users.lastDigestSentAt))
484 .limit(20);
485
486 const result = c.req.query("result");
487 const error = c.req.query("error");
488
489 return c.html(
490 <Layout title="Admin — Digests" user={user}>
491 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
492 <h2>Email digests</h2>
493 <a href="/admin" class="btn btn-sm">
494 Back
495 </a>
496 </div>
497
498 {result && (
499 <div class="auth-success">{decodeURIComponent(result)}</div>
500 )}
501 {error && (
502 <div class="auth-error">{decodeURIComponent(error)}</div>
503 )}
504
bba3681Test User505 <div class="panel" style="padding:var(--space-4);margin-bottom:var(--space-5)">
08420cdClaude506 <div style="font-size:13px;color:var(--text-muted);margin-bottom:8px">
507 {opted} user{opted === 1 ? "" : "s"} opted into the weekly digest.
508 </div>
b9968e3Claude509 <form method="post" action="/admin/digests/run" style="margin-bottom:8px">
08420cdClaude510 <button
511 type="submit"
512 class="btn btn-primary"
513 onclick="return confirm('Send weekly digest to all opted-in users now?')"
514 >
515 Send digests now
516 </button>
517 </form>
b9968e3Claude518 <form method="post" action="/admin/digests/preview" style="display:flex;gap:6px;align-items:center">
08420cdClaude519 <input
520 type="text"
521 name="username"
522 placeholder="username"
523 required
2c3ba6ecopilot-swe-agent[bot]524 aria-label="Username"
08420cdClaude525 style="width:240px"
526 />
527 <button type="submit" class="btn btn-sm">
528 Send to one user
529 </button>
530 </form>
531 </div>
532
533 <h3>Recently sent</h3>
534 <div class="panel">
535 {recentlySent.length === 0 ? (
536 <div class="panel-empty">No digests have been sent yet.</div>
537 ) : (
538 recentlySent.map((u) => (
539 <div class="panel-item" style="justify-content:space-between">
540 <a href={`/${u.username}`}>{u.username}</a>
541 <span style="font-size:12px;color:var(--text-muted)">
542 {u.lastDigestSentAt
543 ? new Date(
544 u.lastDigestSentAt as unknown as string
545 ).toLocaleString()
546 : ""}
547 </span>
548 </div>
549 ))
550 )}
551 </div>
552 </Layout>
553 );
554});
555
556admin.post("/admin/digests/run", async (c) => {
557 const g = await gate(c);
558 if (g instanceof Response) return g;
559 const { user } = g;
560 const results = await sendDigestsToAll();
561 const sent = results.filter((r) => r.ok).length;
562 const skipped = results.length - sent;
563 await audit({
564 userId: user.id,
565 action: "admin.digests.run",
566 metadata: { sent, skipped, total: results.length },
567 });
568 return c.redirect(
569 `/admin/digests?result=${encodeURIComponent(
570 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
571 )}`
572 );
573});
574
575admin.post("/admin/digests/preview", async (c) => {
576 const g = await gate(c);
577 if (g instanceof Response) return g;
578 const { user } = g;
579 const body = await c.req.parseBody();
580 const username = String(body.username || "").trim();
581 if (!username) {
582 return c.redirect("/admin/digests?error=Username+required");
583 }
584 const [target] = await db
585 .select({ id: users.id, username: users.username })
586 .from(users)
587 .where(eq(users.username, username))
588 .limit(1);
589 if (!target) {
590 return c.redirect("/admin/digests?error=User+not+found");
591 }
592 const result = await sendDigestForUser(target.id);
593 await audit({
594 userId: user.id,
595 action: "admin.digests.preview",
596 targetType: "user",
597 targetId: target.id,
598 metadata: {
599 ok: result.ok,
600 skipped: "skipped" in result ? result.skipped : null,
601 },
602 });
603 if (result.ok) {
604 return c.redirect(
605 `/admin/digests?result=${encodeURIComponent(
606 `Digest sent to ${target.username}.`
607 )}`
608 );
609 }
610 return c.redirect(
611 `/admin/digests?error=${encodeURIComponent(
612 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
613 )}`
614 );
615});
616
8e9f1d9Claude617admin.get("/admin/autopilot", async (c) => {
618 const g = await gate(c);
619 if (g instanceof Response) return g;
620 const { user } = g;
621 const tick = getLastTick();
622 const total = getTickCount();
623 const disabled = process.env.AUTOPILOT_DISABLED === "1";
624 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
625 const intervalMs =
626 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
627 ? Number(intervalRaw)
628 : 5 * 60 * 1000;
629 const msg = c.req.query("result") || c.req.query("error");
630 const isErr = !!c.req.query("error");
631 return c.html(
632 <Layout title="Autopilot — admin" user={user}>
bba3681Test User633 <div style="max-width: 960px; margin: 0 auto; padding: var(--space-6) var(--space-4)">
8e9f1d9Claude634 <h1 style="margin-bottom: 8px">Autopilot</h1>
635 <p style="color: var(--text-muted); margin-bottom: 24px">
636 Periodic platform-maintenance loop — mirror sync, merge-queue
87edc73Claude637 progress, weekly digests, advisory rescans, environment
638 wait-timer release, scheduled workflow triggers (cron).
8e9f1d9Claude639 </p>
640 {msg && (
641 <div
642 class={isErr ? "auth-error" : "banner"}
643 style="margin-bottom: 16px"
644 >
645 {decodeURIComponent(msg)}
646 </div>
647 )}
648 <div
bba3681Test User649 style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-6)"
8e9f1d9Claude650 >
651 <div class="stat-card">
652 <div class="stat-label">Status</div>
653 <div class="stat-value">
654 {disabled ? "disabled" : "running"}
655 </div>
656 </div>
657 <div class="stat-card">
658 <div class="stat-label">Interval</div>
659 <div class="stat-value">{Math.round(intervalMs / 1000)}s</div>
660 </div>
661 <div class="stat-card">
662 <div class="stat-label">Ticks this process</div>
663 <div class="stat-value">{total}</div>
664 </div>
665 <div class="stat-card">
666 <div class="stat-label">Last tick</div>
667 <div class="stat-value" style="font-size: 14px">
668 {tick ? tick.finishedAt : "never"}
669 </div>
670 </div>
671 </div>
672 <form
673 method="post"
674 action="/admin/autopilot/run"
675 style="margin-bottom: 24px"
676 >
677 <button class="btn btn-primary" type="submit">
678 Run tick now
679 </button>
680 <span style="color: var(--text-muted); margin-left: 12px; font-size: 13px">
681 Executes all sub-tasks synchronously and records the result.
682 </span>
683 </form>
684 <h2 style="margin-bottom: 12px">Last tick tasks</h2>
685 {tick ? (
686 <table class="table" style="width: 100%">
687 <thead>
688 <tr>
689 <th style="text-align: left">Task</th>
690 <th style="text-align: left">Status</th>
691 <th style="text-align: right">Duration</th>
692 <th style="text-align: left">Error</th>
693 </tr>
694 </thead>
695 <tbody>
696 {tick.tasks.map((t) => (
697 <tr>
698 <td>
699 <code>{t.name}</code>
700 </td>
701 <td
702 style={
703 t.ok
704 ? "color: var(--green)"
705 : "color: var(--red)"
706 }
707 >
708 {t.ok ? "ok" : "failed"}
709 </td>
710 <td style="text-align: right">{t.durationMs}ms</td>
711 <td style="color: var(--text-muted); font-size: 13px">
712 {t.error || ""}
713 </td>
714 </tr>
715 ))}
716 </tbody>
717 </table>
718 ) : (
719 <p style="color: var(--text-muted)">
720 No ticks have run yet. The first tick fires after the interval
721 elapses. Click "Run tick now" to fire one immediately.
722 </p>
723 )}
724 <p style="margin-top: 32px; color: var(--text-muted); font-size: 13px">
725 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
726 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
727 </p>
728 </div>
729 </Layout>
730 );
731});
732
988380aClaude733admin.post("/admin/demo/reseed", async (c) => {
734 const g = await gate(c);
735 if (g instanceof Response) return g;
736 const { user } = g;
737 try {
738 const result = await ensureDemoContent({ force: true });
739 const summary = `Demo reseed: user=${result.created.user ? "created" : "existed"}, repos=${result.created.repos.length}, issues=${result.created.issues}, prs=${result.created.prs}${result.errors.length ? `, errors=${result.errors.length}` : ""}`;
740 await audit({
741 userId: user.id,
742 action: "admin.demo.reseed",
743 targetType: "user",
744 targetId: result.demoUser?.id ?? "demo",
745 metadata: {
746 createdUser: result.created.user,
747 createdRepos: result.created.repos,
748 createdIssues: result.created.issues,
749 createdPrs: result.created.prs,
750 errors: result.errors.slice(0, 5),
751 },
752 });
753 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
754 } catch (err) {
755 const message = err instanceof Error ? err.message : String(err);
756 return c.redirect(
757 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
758 );
759 }
760});
761
762// Public jump-to-demo — redirects to the first demo repo if present,
763// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
764admin.get("/demo", (c) => {
765 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
766});
767
8e9f1d9Claude768admin.post("/admin/autopilot/run", async (c) => {
769 const g = await gate(c);
770 if (g instanceof Response) return g;
771 const { user } = g;
772 let summary = "";
773 try {
774 const result = await runAutopilotTick();
775 const ok = result.tasks.filter((t) => t.ok).length;
776 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
777 await audit({
778 userId: user.id,
779 action: "admin.autopilot.run",
780 targetType: "system",
781 targetId: "autopilot",
782 metadata: { ok, total: result.tasks.length },
783 });
784 return c.redirect(
785 `/admin/autopilot?result=${encodeURIComponent(summary)}`
786 );
787 } catch (err) {
788 const message = err instanceof Error ? err.message : String(err);
789 return c.redirect(
790 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
791 );
792 }
793});
794
8f50ed0Claude795export default admin;