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

demo-seed.ts

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

demo-seed.tsBlame656 lines · 1 contributor
988380aClaude1/**
2 * Demo seed — idempotently creates a `demo` user plus three public demo repos
3 * (`hello-python`, `todo-api`, `design-docs`) each with an initial commit,
4 * one open issue, and (for `todo-api`) a closed pull request.
5 *
6 * Requirements:
7 * - Never throws. Every DB insert + subprocess call is wrapped in try/catch
8 * and errors are pushed into the `errors` array on the result.
9 * - Idempotent. A second call returns `created.user = false`, `repos = []`.
10 * - Fast-path: when the demo user already exists and all three repos are
11 * already recorded in the DB, returns immediately without side effects
12 * (unless `opts.force === true`).
13 * - Imports (never modifies) locked helpers: `hashPassword`, `initBareRepo`,
14 * `bootstrapRepository`.
15 *
16 * Content builders (`buildHelloPythonFiles`, `buildTodoApiFiles`,
17 * `buildDesignDocsFiles`) are pure — they return file-path → file-contents
18 * records and are unit-tested directly. They're re-exported via `__test`
19 * for convenience.
20 */
21
22import { and, eq } from "drizzle-orm";
23import { db } from "../db";
24import {
25 users,
26 repositories,
27 issues,
28 pullRequests,
29} from "../db/schema";
30import { hashPassword } from "./auth";
31import { initBareRepo, getRepoPath } from "../git/repository";
32import { bootstrapRepository } from "./repo-bootstrap";
33
34export const DEMO_USERNAME = "demo" as const;
35const DEMO_EMAIL = "demo@gluecron.local";
36const DEMO_DISPLAY_NAME = "Demo Account";
37const DEMO_AUTHOR_NAME = "Demo";
38const DEMO_AUTHOR_EMAIL = "demo@gluecron.local";
39
40export interface DemoSeedResult {
41 demoUser: { id: string; username: string } | null;
42 repos: Array<{ name: string; url: string }>;
43 created: {
44 user: boolean;
45 repos: string[];
46 issues: number;
47 prs: number;
48 };
49 errors: string[];
50}
51
52/* ------------------------------------------------------------------------ */
53/* Content builders (pure) */
54/* ------------------------------------------------------------------------ */
55
56export function buildHelloPythonFiles(): Record<string, string> {
57 return {
58 "README.md": `# hello-python
59
60A tiny demo Python app, seeded by GlueCron to showcase the UI.
61
62## Run
63
64\`\`\`bash
65pip install -r requirements.txt
66python main.py
67\`\`\`
68
69This repo belongs to the \`demo\` account. Feel free to browse — it's
70regenerated on demand from the demo seeder.
71`,
72 "main.py": `"""hello-python — demo entrypoint."""
73
74
75def greet(name: str) -> str:
76 return f"Hello, {name}!"
77
78
79def main() -> None:
80 print(greet("GlueCron"))
81
82
83if __name__ == "__main__":
84 main()
85`,
86 "requirements.txt": `# No third-party deps yet — kept intentionally minimal.
87# Add packages below as "name==version" once needed.
88`,
89 };
90}
91
92export function buildTodoApiFiles(): Record<string, string> {
93 const pkg = {
94 name: "todo-api",
95 version: "0.1.0",
96 private: true,
97 description: "Demo todo API — GlueCron seeded sample",
98 main: "src/index.ts",
99 scripts: {
100 dev: "bun run src/index.ts",
101 start: "bun run src/index.ts",
102 },
103 dependencies: {
104 hono: "^4.6.0",
105 },
106 devDependencies: {
107 typescript: "^5.4.0",
108 },
109 };
110
111 return {
112 "README.md": `# todo-api
113
114A minimal Hono-based todo API, seeded by GlueCron as a demo.
115
116## Endpoints
117
118- \`GET /todos\` — list todos
119- \`POST /todos\` — create todo
120- \`GET /health\` — health probe
121
122## Run
123
124\`\`\`bash
125bun install
126bun run dev
127\`\`\`
128`,
129 "package.json": JSON.stringify(pkg, null, 2) + "\n",
130 "src/index.ts": `import { Hono } from "hono";
131
132type Todo = { id: number; title: string; done: boolean };
133
134const app = new Hono();
135const todos: Todo[] = [
136 { id: 1, title: "Try GlueCron", done: false },
137 { id: 2, title: "Push a commit", done: false },
138];
139
140app.get("/health", (c) => c.json({ ok: true }));
141app.get("/todos", (c) => c.json(todos));
142
143app.post("/todos", async (c) => {
144 const body = await c.req.json<{ title?: string }>();
145 if (!body?.title) return c.json({ error: "title required" }, 400);
146 const todo: Todo = { id: todos.length + 1, title: body.title, done: false };
147 todos.push(todo);
148 return c.json(todo, 201);
149});
150
151export default app;
152`,
153 };
154}
155
156export function buildDesignDocsFiles(): Record<string, string> {
157 return {
158 "README.md": `# design-docs
159
160Architecture notes and ADRs for the \`demo\` org's sample project.
161
162Browse:
163
164- [Architecture overview](docs/architecture.md)
165- [ADR-001 — Choose Hono for the HTTP layer](docs/adr-001.md)
166`,
167 "docs/architecture.md": `# Architecture overview
168
169## Goals
170
171- Keep the surface small.
172- Prefer boring, well-understood primitives.
173- Fast cold-start on Bun.
174
175## Components
176
177- **HTTP layer:** Hono.
178- **Data:** PostgreSQL via Drizzle.
179- **Jobs:** in-process, cron-driven.
180
181## Non-goals
182
183- Multi-tenant isolation at the data layer.
184- Horizontal scale-out (v1 is single-node).
185`,
186 "docs/adr-001.md": `# ADR-001 — Choose Hono for the HTTP layer
187
188- **Status:** accepted
189- **Date:** 2026-01-15
190
191## Context
192
193We need an HTTP framework that runs on Bun natively, has JSX server-side
194rendering, and minimal dependencies.
195
196## Decision
197
198Adopt Hono as the HTTP layer.
199
200## Consequences
201
202- Tiny runtime footprint.
203- Ecosystem is smaller than Express; we'll write middleware ourselves
204 where nothing exists.
205
206## Rollout
207
208Migrate the existing Express routes to Hono over two sprints. Keep the
209legacy handler importable until all routes are ported.
210`,
211 };
212}
213
214/* ------------------------------------------------------------------------ */
215/* Git plumbing — write an initial commit from a file map */
216/* ------------------------------------------------------------------------ */
217
218interface SpawnResult {
219 stdout: string;
220 stderr: string;
221 exitCode: number;
222}
223
224async function spawnSafe(
225 cmd: string[],
226 cwd: string,
227 stdin?: string | Uint8Array,
228 env?: Record<string, string>
229): Promise<SpawnResult> {
230 try {
231 const proc = Bun.spawn(cmd, {
232 cwd,
233 stdout: "pipe",
234 stderr: "pipe",
235 stdin: stdin !== undefined ? "pipe" : undefined,
236 env: { ...process.env, ...(env || {}) },
237 });
238 if (stdin !== undefined && proc.stdin) {
239 const bytes =
240 typeof stdin === "string" ? new TextEncoder().encode(stdin) : stdin;
241 (proc.stdin as any).write(bytes);
242 (proc.stdin as any).end();
243 }
244 const [stdout, stderr] = await Promise.all([
245 new Response(proc.stdout).text(),
246 new Response(proc.stderr).text(),
247 ]);
248 const exitCode = await proc.exited;
249 return { stdout: stdout.trim(), stderr, exitCode };
250 } catch (err: any) {
251 return { stdout: "", stderr: String(err?.message || err), exitCode: -1 };
252 }
253}
254
255/**
256 * Write an initial commit to the bare repo at `repoDir` on branch `main`
257 * containing the given file map. Uses git plumbing (hash-object + update-index
258 * via a transient index + write-tree + commit-tree + update-ref). Mirrors the
259 * pattern in `dep-updater.ts` / `createOrUpdateFileOnBranch`. Returns the
260 * new commit sha on success.
261 */
262async function writeInitialCommit(
263 repoDir: string,
264 files: Record<string, string>,
265 message: string,
266 authorName: string,
267 authorEmail: string
268): Promise<{ commitSha: string } | { error: string }> {
269 const tmpIndex = `${repoDir}/index.demo-seed.${process.pid}.${Date.now()}.${Math.random()
270 .toString(36)
271 .slice(2)}`;
272 const baseEnv = {
273 GIT_INDEX_FILE: tmpIndex,
274 GIT_AUTHOR_NAME: authorName,
275 GIT_AUTHOR_EMAIL: authorEmail,
276 GIT_COMMITTER_NAME: authorName,
277 GIT_COMMITTER_EMAIL: authorEmail,
278 };
279
280 const cleanup = async () => {
281 try {
282 const { unlink } = await import("fs/promises");
283 await unlink(tmpIndex);
284 } catch {
285 /* ignore */
286 }
287 };
288
289 try {
290 // 1. Hash each file → blob sha, then stage via update-index --cacheinfo.
291 for (const [path, contents] of Object.entries(files)) {
292 const hashed = await spawnSafe(
293 ["git", "hash-object", "-w", "--stdin"],
294 repoDir,
295 contents
296 );
297 if (hashed.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(hashed.stdout)) {
298 await cleanup();
299 return { error: `hash-object failed for ${path}: ${hashed.stderr}` };
300 }
301 const blobSha = hashed.stdout;
302
303 const upd = await spawnSafe(
304 [
305 "git",
306 "update-index",
307 "--add",
308 "--cacheinfo",
309 `100644,${blobSha},${path}`,
310 ],
311 repoDir,
312 undefined,
313 baseEnv
314 );
315 if (upd.exitCode !== 0) {
316 await cleanup();
317 return { error: `update-index failed for ${path}: ${upd.stderr}` };
318 }
319 }
320
321 // 2. write-tree → tree sha.
322 const wt = await spawnSafe(
323 ["git", "write-tree"],
324 repoDir,
325 undefined,
326 baseEnv
327 );
328 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(wt.stdout)) {
329 await cleanup();
330 return { error: `write-tree failed: ${wt.stderr}` };
331 }
332 const treeSha = wt.stdout;
333
334 // 3. commit-tree (no parent — initial commit).
335 const commit = await spawnSafe(
336 ["git", "commit-tree", treeSha, "-m", message],
337 repoDir,
338 undefined,
339 baseEnv
340 );
341 if (commit.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commit.stdout)) {
342 await cleanup();
343 return { error: `commit-tree failed: ${commit.stderr}` };
344 }
345 const commitSha = commit.stdout;
346
347 // 4. update-ref refs/heads/main.
348 const upd = await spawnSafe(
349 ["git", "update-ref", "refs/heads/main", commitSha],
350 repoDir
351 );
352 if (upd.exitCode !== 0) {
353 await cleanup();
354 return { error: `update-ref failed: ${upd.stderr}` };
355 }
356
357 await cleanup();
358 return { commitSha };
359 } catch (err: any) {
360 await cleanup();
361 return { error: String(err?.message || err) };
362 }
363}
364
365/* ------------------------------------------------------------------------ */
366/* Seed orchestration */
367/* ------------------------------------------------------------------------ */
368
369interface DemoRepoSpec {
370 name: string;
371 description: string;
372 files: Record<string, string>;
373 issueTitle: string;
374 issueBody?: string;
375 seedClosedPr?: { title: string; body?: string };
376}
377
378function demoRepoSpecs(): DemoRepoSpec[] {
379 return [
380 {
381 name: "hello-python",
382 description: "Tiny Python demo app seeded by GlueCron.",
383 files: buildHelloPythonFiles(),
384 issueTitle: "Add rate limiting",
385 issueBody:
386 "The `/greet` endpoint has no rate limiting. We should add a simple token-bucket.",
387 },
388 {
389 name: "todo-api",
390 description: "Minimal Hono todo API, seeded as a demo.",
391 files: buildTodoApiFiles(),
392 issueTitle: "Dark mode broken on mobile",
393 issueBody:
394 "On iOS Safari, the dark-mode toggle flickers to light for ~200ms on first paint.",
395 seedClosedPr: {
396 title: "feat: add /health endpoint",
397 body: "Adds a trivial liveness probe at `GET /health` returning `{ ok: true }`.",
398 },
399 },
400 {
401 name: "design-docs",
402 description: "Architecture notes + ADRs for the demo project.",
403 files: buildDesignDocsFiles(),
404 issueTitle: "Clarify ADR-001 rollout section",
405 issueBody:
406 "The rollout section of ADR-001 mentions 'two sprints' — we should pin a concrete date range.",
407 },
408 ];
409}
410
411async function findDemoUser(): Promise<{ id: string; username: string } | null> {
412 try {
413 const [row] = await db
414 .select({ id: users.id, username: users.username })
415 .from(users)
416 .where(eq(users.username, DEMO_USERNAME))
417 .limit(1);
418 return row ?? null;
419 } catch {
420 return null;
421 }
422}
423
424async function findDemoRepo(
425 ownerId: string,
426 name: string
427): Promise<{ id: string } | null> {
428 try {
429 const [row] = await db
430 .select({ id: repositories.id })
431 .from(repositories)
432 .where(
433 and(eq(repositories.ownerId, ownerId), eq(repositories.name, name))
434 )
435 .limit(1);
436 return row ?? null;
437 } catch {
438 return null;
439 }
440}
441
442/**
443 * Idempotently create the demo user + three demo repos. Never throws.
444 */
445export async function ensureDemoContent(opts?: {
446 force?: boolean;
447}): Promise<DemoSeedResult> {
448 const force = !!opts?.force;
449 const result: DemoSeedResult = {
450 demoUser: null,
451 repos: [],
452 created: { user: false, repos: [], issues: 0, prs: 0 },
453 errors: [],
454 };
455
456 const specs = demoRepoSpecs();
457
458 // 1. Fast-path — demo user + all three repos already exist and !force.
459 if (!force) {
460 const existing = await findDemoUser();
461 if (existing) {
462 let allPresent = true;
463 for (const spec of specs) {
464 const repo = await findDemoRepo(existing.id, spec.name);
465 if (!repo) {
466 allPresent = false;
467 break;
468 }
469 }
470 if (allPresent) {
471 result.demoUser = existing;
472 result.repos = specs.map((s) => ({
473 name: s.name,
474 url: `/${DEMO_USERNAME}/${s.name}`,
475 }));
476 return result;
477 }
478 }
479 }
480
481 // 2. Resolve or create the demo user.
482 let demoUser = await findDemoUser();
483 if (!demoUser) {
484 try {
485 // Random password → login effectively disabled.
486 const randBytes = crypto.getRandomValues(new Uint8Array(32));
487 const randomPassword = Array.from(randBytes)
488 .map((b) => b.toString(16).padStart(2, "0"))
489 .join("");
490 const passwordHash = await hashPassword(randomPassword);
491 const [inserted] = await db
492 .insert(users)
493 .values({
494 username: DEMO_USERNAME,
495 email: DEMO_EMAIL,
496 displayName: DEMO_DISPLAY_NAME,
497 passwordHash,
498 })
499 .returning({ id: users.id, username: users.username });
500 if (inserted) {
501 demoUser = inserted;
502 result.created.user = true;
503 }
504 } catch (err: any) {
505 result.errors.push(
506 `create demo user: ${String(err?.message || err)}`
507 );
508 }
509 }
510
511 if (!demoUser) {
512 // Can't proceed without a user row.
513 return result;
514 }
515 result.demoUser = demoUser;
516
517 // 3. For each spec: ensure bare repo + initial commit + DB row + bootstrap
518 // + one open issue (+ closed PR on todo-api).
519 for (const spec of specs) {
520 result.repos.push({
521 name: spec.name,
522 url: `/${DEMO_USERNAME}/${spec.name}`,
523 });
524
525 const existingRepo = await findDemoRepo(demoUser.id, spec.name);
526 if (existingRepo && !force) {
527 continue;
528 }
529
530 // Create bare repo on disk (ok if already present — initBareRepo is
531 // git init --bare which is idempotent).
532 let diskPath: string;
533 try {
534 diskPath = await initBareRepo(DEMO_USERNAME, spec.name);
535 } catch (err: any) {
536 result.errors.push(
537 `initBareRepo(${spec.name}): ${String(err?.message || err)}`
538 );
539 continue;
540 }
541
542 // Write initial commit only if HEAD doesn't already resolve.
543 const repoDir = getRepoPath(DEMO_USERNAME, spec.name);
544 const headCheck = await spawnSafe(
545 ["git", "rev-parse", "--verify", "refs/heads/main"],
546 repoDir
547 );
548 if (headCheck.exitCode !== 0) {
549 const wrote = await writeInitialCommit(
550 repoDir,
551 spec.files,
552 "Initial commit",
553 DEMO_AUTHOR_NAME,
554 DEMO_AUTHOR_EMAIL
555 );
556 if ("error" in wrote) {
557 result.errors.push(
558 `writeInitialCommit(${spec.name}): ${wrote.error}`
559 );
560 // Continue — we still want the DB row so the UI can show it.
561 }
562 }
563
564 // Insert DB row (skip if it already exists — e.g. partial prior run).
565 let repoId: string | null = existingRepo?.id ?? null;
566 if (!repoId) {
567 try {
568 const [inserted] = await db
569 .insert(repositories)
570 .values({
571 name: spec.name,
572 ownerId: demoUser.id,
573 description: spec.description,
574 isPrivate: false,
575 defaultBranch: "main",
576 diskPath,
577 })
578 .returning({ id: repositories.id });
579 if (inserted) {
580 repoId = inserted.id;
581 result.created.repos.push(spec.name);
582 }
583 } catch (err: any) {
584 result.errors.push(
585 `insert repo(${spec.name}): ${String(err?.message || err)}`
586 );
587 }
588 }
589
590 if (!repoId) continue;
591
592 // Green-ecosystem bootstrap (labels, settings, branch protection, welcome
593 // issue). Wrapped — bootstrap internally tolerates duplicates but we
594 // don't want any surprise throw to poison the seeder.
595 try {
596 await bootstrapRepository({
597 repositoryId: repoId,
598 ownerUserId: demoUser.id,
599 });
600 } catch (err: any) {
601 result.errors.push(
602 `bootstrapRepository(${spec.name}): ${String(err?.message || err)}`
603 );
604 }
605
606 // One open issue per repo.
607 try {
608 await db.insert(issues).values({
609 repositoryId: repoId,
610 authorId: demoUser.id,
611 title: spec.issueTitle,
612 body: spec.issueBody ?? null,
613 state: "open",
614 });
615 result.created.issues += 1;
616 } catch (err: any) {
617 result.errors.push(
618 `insert issue(${spec.name}): ${String(err?.message || err)}`
619 );
620 }
621
622 // Closed PR on todo-api.
623 if (spec.seedClosedPr) {
624 try {
625 await db.insert(pullRequests).values({
626 repositoryId: repoId,
627 authorId: demoUser.id,
628 title: spec.seedClosedPr.title,
629 body: spec.seedClosedPr.body ?? null,
630 state: "closed",
631 baseBranch: "main",
632 headBranch: "demo/health-endpoint",
633 closedAt: new Date(),
634 });
635 result.created.prs += 1;
636 } catch (err: any) {
637 result.errors.push(
638 `insert PR(${spec.name}): ${String(err?.message || err)}`
639 );
640 }
641 }
642 }
643
644 return result;
645}
646
647/* ------------------------------------------------------------------------ */
648/* Test-only exports */
649/* ------------------------------------------------------------------------ */
650
651export const __test = {
652 buildHelloPythonFiles,
653 buildTodoApiFiles,
654 buildDesignDocsFiles,
655 demoRepoSpecs,
656};