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.tsBlame654 lines · 2 contributors
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 }> {
2c3ba6ecopilot-swe-agent[bot]269 const tmpIndex = `${repoDir}/index.demo-seed.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`;
988380aClaude270 const baseEnv = {
271 GIT_INDEX_FILE: tmpIndex,
272 GIT_AUTHOR_NAME: authorName,
273 GIT_AUTHOR_EMAIL: authorEmail,
274 GIT_COMMITTER_NAME: authorName,
275 GIT_COMMITTER_EMAIL: authorEmail,
276 };
277
278 const cleanup = async () => {
279 try {
280 const { unlink } = await import("fs/promises");
281 await unlink(tmpIndex);
282 } catch {
283 /* ignore */
284 }
285 };
286
287 try {
288 // 1. Hash each file → blob sha, then stage via update-index --cacheinfo.
289 for (const [path, contents] of Object.entries(files)) {
290 const hashed = await spawnSafe(
291 ["git", "hash-object", "-w", "--stdin"],
292 repoDir,
293 contents
294 );
295 if (hashed.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(hashed.stdout)) {
296 await cleanup();
297 return { error: `hash-object failed for ${path}: ${hashed.stderr}` };
298 }
299 const blobSha = hashed.stdout;
300
301 const upd = await spawnSafe(
302 [
303 "git",
304 "update-index",
305 "--add",
306 "--cacheinfo",
307 `100644,${blobSha},${path}`,
308 ],
309 repoDir,
310 undefined,
311 baseEnv
312 );
313 if (upd.exitCode !== 0) {
314 await cleanup();
315 return { error: `update-index failed for ${path}: ${upd.stderr}` };
316 }
317 }
318
319 // 2. write-tree → tree sha.
320 const wt = await spawnSafe(
321 ["git", "write-tree"],
322 repoDir,
323 undefined,
324 baseEnv
325 );
326 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(wt.stdout)) {
327 await cleanup();
328 return { error: `write-tree failed: ${wt.stderr}` };
329 }
330 const treeSha = wt.stdout;
331
332 // 3. commit-tree (no parent — initial commit).
333 const commit = await spawnSafe(
334 ["git", "commit-tree", treeSha, "-m", message],
335 repoDir,
336 undefined,
337 baseEnv
338 );
339 if (commit.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commit.stdout)) {
340 await cleanup();
341 return { error: `commit-tree failed: ${commit.stderr}` };
342 }
343 const commitSha = commit.stdout;
344
345 // 4. update-ref refs/heads/main.
346 const upd = await spawnSafe(
347 ["git", "update-ref", "refs/heads/main", commitSha],
348 repoDir
349 );
350 if (upd.exitCode !== 0) {
351 await cleanup();
352 return { error: `update-ref failed: ${upd.stderr}` };
353 }
354
355 await cleanup();
356 return { commitSha };
357 } catch (err: any) {
358 await cleanup();
359 return { error: String(err?.message || err) };
360 }
361}
362
363/* ------------------------------------------------------------------------ */
364/* Seed orchestration */
365/* ------------------------------------------------------------------------ */
366
367interface DemoRepoSpec {
368 name: string;
369 description: string;
370 files: Record<string, string>;
371 issueTitle: string;
372 issueBody?: string;
373 seedClosedPr?: { title: string; body?: string };
374}
375
376function demoRepoSpecs(): DemoRepoSpec[] {
377 return [
378 {
379 name: "hello-python",
380 description: "Tiny Python demo app seeded by GlueCron.",
381 files: buildHelloPythonFiles(),
382 issueTitle: "Add rate limiting",
383 issueBody:
384 "The `/greet` endpoint has no rate limiting. We should add a simple token-bucket.",
385 },
386 {
387 name: "todo-api",
388 description: "Minimal Hono todo API, seeded as a demo.",
389 files: buildTodoApiFiles(),
390 issueTitle: "Dark mode broken on mobile",
391 issueBody:
392 "On iOS Safari, the dark-mode toggle flickers to light for ~200ms on first paint.",
393 seedClosedPr: {
394 title: "feat: add /health endpoint",
395 body: "Adds a trivial liveness probe at `GET /health` returning `{ ok: true }`.",
396 },
397 },
398 {
399 name: "design-docs",
400 description: "Architecture notes + ADRs for the demo project.",
401 files: buildDesignDocsFiles(),
402 issueTitle: "Clarify ADR-001 rollout section",
403 issueBody:
404 "The rollout section of ADR-001 mentions 'two sprints' — we should pin a concrete date range.",
405 },
406 ];
407}
408
409async function findDemoUser(): Promise<{ id: string; username: string } | null> {
410 try {
411 const [row] = await db
412 .select({ id: users.id, username: users.username })
413 .from(users)
414 .where(eq(users.username, DEMO_USERNAME))
415 .limit(1);
416 return row ?? null;
417 } catch {
418 return null;
419 }
420}
421
422async function findDemoRepo(
423 ownerId: string,
424 name: string
425): Promise<{ id: string } | null> {
426 try {
427 const [row] = await db
428 .select({ id: repositories.id })
429 .from(repositories)
430 .where(
431 and(eq(repositories.ownerId, ownerId), eq(repositories.name, name))
432 )
433 .limit(1);
434 return row ?? null;
435 } catch {
436 return null;
437 }
438}
439
440/**
441 * Idempotently create the demo user + three demo repos. Never throws.
442 */
443export async function ensureDemoContent(opts?: {
444 force?: boolean;
445}): Promise<DemoSeedResult> {
446 const force = !!opts?.force;
447 const result: DemoSeedResult = {
448 demoUser: null,
449 repos: [],
450 created: { user: false, repos: [], issues: 0, prs: 0 },
451 errors: [],
452 };
453
454 const specs = demoRepoSpecs();
455
456 // 1. Fast-path — demo user + all three repos already exist and !force.
457 if (!force) {
458 const existing = await findDemoUser();
459 if (existing) {
460 let allPresent = true;
461 for (const spec of specs) {
462 const repo = await findDemoRepo(existing.id, spec.name);
463 if (!repo) {
464 allPresent = false;
465 break;
466 }
467 }
468 if (allPresent) {
469 result.demoUser = existing;
470 result.repos = specs.map((s) => ({
471 name: s.name,
472 url: `/${DEMO_USERNAME}/${s.name}`,
473 }));
474 return result;
475 }
476 }
477 }
478
479 // 2. Resolve or create the demo user.
480 let demoUser = await findDemoUser();
481 if (!demoUser) {
482 try {
483 // Random password → login effectively disabled.
484 const randBytes = crypto.getRandomValues(new Uint8Array(32));
485 const randomPassword = Array.from(randBytes)
486 .map((b) => b.toString(16).padStart(2, "0"))
487 .join("");
488 const passwordHash = await hashPassword(randomPassword);
489 const [inserted] = await db
490 .insert(users)
491 .values({
492 username: DEMO_USERNAME,
493 email: DEMO_EMAIL,
494 displayName: DEMO_DISPLAY_NAME,
495 passwordHash,
496 })
497 .returning({ id: users.id, username: users.username });
498 if (inserted) {
499 demoUser = inserted;
500 result.created.user = true;
501 }
502 } catch (err: any) {
503 result.errors.push(
504 `create demo user: ${String(err?.message || err)}`
505 );
506 }
507 }
508
509 if (!demoUser) {
510 // Can't proceed without a user row.
511 return result;
512 }
513 result.demoUser = demoUser;
514
515 // 3. For each spec: ensure bare repo + initial commit + DB row + bootstrap
516 // + one open issue (+ closed PR on todo-api).
517 for (const spec of specs) {
518 result.repos.push({
519 name: spec.name,
520 url: `/${DEMO_USERNAME}/${spec.name}`,
521 });
522
523 const existingRepo = await findDemoRepo(demoUser.id, spec.name);
524 if (existingRepo && !force) {
525 continue;
526 }
527
528 // Create bare repo on disk (ok if already present — initBareRepo is
529 // git init --bare which is idempotent).
530 let diskPath: string;
531 try {
532 diskPath = await initBareRepo(DEMO_USERNAME, spec.name);
533 } catch (err: any) {
534 result.errors.push(
535 `initBareRepo(${spec.name}): ${String(err?.message || err)}`
536 );
537 continue;
538 }
539
540 // Write initial commit only if HEAD doesn't already resolve.
541 const repoDir = getRepoPath(DEMO_USERNAME, spec.name);
542 const headCheck = await spawnSafe(
543 ["git", "rev-parse", "--verify", "refs/heads/main"],
544 repoDir
545 );
546 if (headCheck.exitCode !== 0) {
547 const wrote = await writeInitialCommit(
548 repoDir,
549 spec.files,
550 "Initial commit",
551 DEMO_AUTHOR_NAME,
552 DEMO_AUTHOR_EMAIL
553 );
554 if ("error" in wrote) {
555 result.errors.push(
556 `writeInitialCommit(${spec.name}): ${wrote.error}`
557 );
558 // Continue — we still want the DB row so the UI can show it.
559 }
560 }
561
562 // Insert DB row (skip if it already exists — e.g. partial prior run).
563 let repoId: string | null = existingRepo?.id ?? null;
564 if (!repoId) {
565 try {
566 const [inserted] = await db
567 .insert(repositories)
568 .values({
569 name: spec.name,
570 ownerId: demoUser.id,
571 description: spec.description,
572 isPrivate: false,
573 defaultBranch: "main",
574 diskPath,
575 })
576 .returning({ id: repositories.id });
577 if (inserted) {
578 repoId = inserted.id;
579 result.created.repos.push(spec.name);
580 }
581 } catch (err: any) {
582 result.errors.push(
583 `insert repo(${spec.name}): ${String(err?.message || err)}`
584 );
585 }
586 }
587
588 if (!repoId) continue;
589
590 // Green-ecosystem bootstrap (labels, settings, branch protection, welcome
591 // issue). Wrapped — bootstrap internally tolerates duplicates but we
592 // don't want any surprise throw to poison the seeder.
593 try {
594 await bootstrapRepository({
595 repositoryId: repoId,
596 ownerUserId: demoUser.id,
597 });
598 } catch (err: any) {
599 result.errors.push(
600 `bootstrapRepository(${spec.name}): ${String(err?.message || err)}`
601 );
602 }
603
604 // One open issue per repo.
605 try {
606 await db.insert(issues).values({
607 repositoryId: repoId,
608 authorId: demoUser.id,
609 title: spec.issueTitle,
610 body: spec.issueBody ?? null,
611 state: "open",
612 });
613 result.created.issues += 1;
614 } catch (err: any) {
615 result.errors.push(
616 `insert issue(${spec.name}): ${String(err?.message || err)}`
617 );
618 }
619
620 // Closed PR on todo-api.
621 if (spec.seedClosedPr) {
622 try {
623 await db.insert(pullRequests).values({
624 repositoryId: repoId,
625 authorId: demoUser.id,
626 title: spec.seedClosedPr.title,
627 body: spec.seedClosedPr.body ?? null,
628 state: "closed",
629 baseBranch: "main",
630 headBranch: "demo/health-endpoint",
631 closedAt: new Date(),
632 });
633 result.created.prs += 1;
634 } catch (err: any) {
635 result.errors.push(
636 `insert PR(${spec.name}): ${String(err?.message || err)}`
637 );
638 }
639 }
640 }
641
642 return result;
643}
644
645/* ------------------------------------------------------------------------ */
646/* Test-only exports */
647/* ------------------------------------------------------------------------ */
648
649export const __test = {
650 buildHelloPythonFiles,
651 buildTodoApiFiles,
652 buildDesignDocsFiles,
653 demoRepoSpecs,
654};