#!/usr/bin/env node // Seed Open Design with pre-baked test projects so the UI has real slide // decks and web prototypes to work with without waiting for an LLM run. // Pulls each project's content straight from a skill or plugin // `example.html`, drops it in as `index.html`, and adds a couple of fake // chat messages so the conversation panel isn't empty. // // Usage: // pnpm seed:test-projects # default bundle // pnpm seed:test-projects --decks 2 --webs 2 # cap counts // pnpm seed:test-projects --daemon http://127.0.0.1:17456 // pnpm seed:test-projects --namespace work-a # discover tools-dev namespace // pnpm seed:test-projects --offline # ingest into ./.od before boot // pnpm seed:test-projects --clear # remove previously seeded projects // // The daemon URL is resolved in this order: --daemon flag > $OD_DAEMON_URL > // http://127.0.0.1:$OD_PORT > whatever `pnpm tools-dev status --json` reports // for the daemon app. --namespace is only passed to that tools-dev discovery // step; it is not forwarded to the od CLI or stored in daemon data. The // discovery step is what makes the two-shell flow // (`pnpm tools-dev` then `pnpm seed:test-projects`) work without extra flags, // because tools-dev defaults to an ephemeral daemon port that isn't exported // to sibling shells. // // Seeded project ids start with `seed-` so `--clear` only touches the // fixtures this script created. import { spawn } from 'node:child_process'; import { createRequire } from 'node:module'; import os from 'node:os'; import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(HERE, '..'); const SKILLS_DIR = path.join(REPO_ROOT, 'skills'); const BUNDLED_PLUGIN_EXAMPLES_DIR = path.join(REPO_ROOT, 'plugins', '_official', 'examples'); const COMMUNITY_PLUGIN_EXAMPLES_DIR = path.join(REPO_ROOT, 'plugins', 'community', 'examples'); const SEED_PREFIX = 'seed-'; type SeedKind = 'deck' | 'prototype'; type SeedSourceKind = 'skill' | 'default-plugin' | 'community-plugin'; type SeedMode = 'auto' | 'online' | 'offline'; interface SeedFixture { skillId: string; sourceKind: SeedSourceKind; kind: SeedKind; name: string; pendingPrompt: string; pluginId?: string; // optional: path to the file inside skills// to load as index.html // (defaults to example.html) source?: string; } // Local mirror of the daemon `ProjectFile` shape. Kept in sync with // `packages/contracts/src/api/files.ts` — the assistant message stores // `producedFiles: ProjectFile[]`, so we type the upload response against // it instead of fabricating a string array. interface ProjectFile { name: string; path?: string; type?: 'file' | 'dir'; size: number; mtime: number; kind: string; mime: string; } interface ProjectFileResponse { file: ProjectFile; } interface SeedProjectSummary { id: string; metadata?: { seeded?: boolean; source?: string; sourceKind?: string; sourceId?: string; [k: string]: unknown; } | null; } interface OfflineDatabase { exec: (sql: string) => void; pragma: (sql: string) => void; prepare: (sql: string) => { all: (...args: unknown[]) => unknown[]; get: (...args: unknown[]) => unknown; run: (...args: unknown[]) => unknown; }; transaction: (fn: () => void) => () => void; close: () => void; } interface OfflineSeedContext { db: OfflineDatabase; dataDir: string; projectsDir: string; } const DECKS: SeedFixture[] = [ { skillId: 'html-ppt-pitch-deck', sourceKind: 'skill', kind: 'deck', name: 'Pitch deck — Series A', pendingPrompt: 'Make a 10-slide investor pitch deck for an AI design tool. Cover problem, solution, market, traction, ask.', }, { skillId: 'kami-deck', sourceKind: 'skill', kind: 'deck', name: 'Kami deck — quarterly review', pendingPrompt: 'Build a print-grade kami deck summarizing Q2 results: revenue, top wins, risks, next quarter.', }, { skillId: 'html-ppt-weekly-report', sourceKind: 'skill', kind: 'deck', name: 'Weekly report — eng team', pendingPrompt: 'Weekly report deck for an engineering team: shipped, in-progress, blockers, next-week plan.', }, { skillId: 'html-ppt-product-launch', sourceKind: 'skill', kind: 'deck', name: 'Product launch — v2.0', pendingPrompt: 'Product launch deck for v2.0: hero feature, before/after, pricing, rollout plan.', }, ]; const WEBS: SeedFixture[] = [ { skillId: 'open-design-landing', sourceKind: 'skill', kind: 'prototype', name: 'Editorial landing — Atelier Zero', pendingPrompt: 'Single-page editorial landing page for an AI design tool. Magazine collage hero, sticky nav, scroll reveal.', }, { skillId: 'kami-landing', sourceKind: 'skill', kind: 'prototype', name: 'Kami landing — white paper', pendingPrompt: 'Print-grade kami landing — parchment canvas, ink-blue accent. Treat it like a studio one-pager.', }, { skillId: 'dashboard', sourceKind: 'skill', kind: 'prototype', name: 'Admin dashboard — analytics', pendingPrompt: 'Admin dashboard with KPI cards, a revenue chart, and a recent activity table. Fixed left sidebar.', }, ]; const DEFAULT_PLUGINS: SeedFixture[] = [ { skillId: 'html-ppt-pitch-deck', sourceKind: 'default-plugin', pluginId: 'example-html-ppt-pitch-deck', kind: 'deck', name: 'Default plugin — pitch deck', pendingPrompt: 'Run the bundled pitch-deck plugin for a seed-stage AI design product and produce the HTML slide artifact.', }, { skillId: 'dashboard', sourceKind: 'default-plugin', pluginId: 'example-dashboard', kind: 'prototype', name: 'Default plugin — analytics dashboard', pendingPrompt: 'Run the bundled dashboard plugin for a product analytics control panel with KPIs and a weekly trend chart.', }, { skillId: 'social-carousel', sourceKind: 'default-plugin', pluginId: 'example-social-carousel', kind: 'prototype', name: 'Default plugin — social carousel', pendingPrompt: 'Run the bundled social carousel plugin for a three-card product launch announcement.', }, ]; const COMMUNITY_PLUGINS: SeedFixture[] = [ { skillId: 'create-prototype-dashboard', sourceKind: 'community-plugin', pluginId: 'create-prototype-dashboard', kind: 'prototype', name: 'Community plugin — ops dashboard', pendingPrompt: 'Run the community prototype dashboard plugin for a launch operations room.', }, { skillId: 'create-slides-pitch', sourceKind: 'community-plugin', pluginId: 'create-slides-pitch', kind: 'deck', name: 'Community plugin — founder pitch', pendingPrompt: 'Run the community pitch slides plugin for a founder fundraising narrative.', }, { skillId: 'create-live-artifact-ops', sourceKind: 'community-plugin', pluginId: 'create-live-artifact-ops', kind: 'prototype', name: 'Community plugin — live ops artifact', pendingPrompt: 'Run the community live artifact plugin for a refreshable customer success command center.', }, ]; interface Args { daemonUrl: string | null; dataDir: string | null; namespace: string | null; mode: SeedMode; decks: number; webs: number; defaultPlugins: number; communityPlugins: number; clear: boolean; } function parseArgs(argv: string[]): Args { const out: Args = { daemonUrl: null, dataDir: null, namespace: null, mode: 'auto', decks: DECKS.length, webs: WEBS.length, defaultPlugins: DEFAULT_PLUGINS.length, communityPlugins: COMMUNITY_PLUGINS.length, clear: false, }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--daemon' || a === '--daemon-url') { const value = argv[++i]; if (!value) { console.error(`${a} requires a URL argument`); process.exit(2); } out.daemonUrl = value; } else if (a === '--data-dir') { const value = argv[++i]; if (!value) { console.error('--data-dir requires a directory argument'); process.exit(2); } out.dataDir = value; } else if (a === '--namespace') { const value = argv[++i]; if (!value) { console.error('--namespace requires a name argument'); process.exit(2); } out.namespace = value; } else if (a === '--mode') { const value = argv[++i]; if (value !== 'auto' && value !== 'online' && value !== 'offline') { console.error('--mode must be one of: auto, online, offline'); process.exit(2); } out.mode = value; } else if (a === '--online') { out.mode = 'online'; } else if (a === '--offline') { out.mode = 'offline'; } else if (a === '--decks') { out.decks = Math.max(0, Number(argv[++i]) || 0); } else if (a === '--webs' || a === '--prototypes') { out.webs = Math.max(0, Number(argv[++i]) || 0); } else if (a === '--default-plugins') { out.defaultPlugins = Math.max(0, Number(argv[++i]) || 0); } else if (a === '--community-plugins') { out.communityPlugins = Math.max(0, Number(argv[++i]) || 0); } else if (a === '--clear') { out.clear = true; } else if (a === '-h' || a === '--help') { printHelp(); process.exit(0); } else { console.error(`unknown flag: ${a}`); printHelp(); process.exit(2); } } if (out.mode === 'offline' && out.daemonUrl) { console.error('--offline cannot be combined with --daemon/--daemon-url'); process.exit(2); } return out; } function printHelp() { console.log(`Usage: pnpm seed:test-projects [opts] Seeds Open Design with pre-baked, real HTML artifacts from: - Skills examples - Bundled default plugin examples - Community plugin examples In online mode it writes through the running daemon API. In offline mode it writes /app.sqlite plus /projects/* directly, so you can ingest fixtures before starting pnpm tools-dev. Options: --daemon Daemon base URL. When omitted, the script reads \$OD_DAEMON_URL, then \$OD_PORT, and finally falls back to discovering the URL from \`pnpm tools-dev status --json\`. --mode auto | online | offline (default: auto). Auto uses the daemon when one is discoverable; otherwise offline ingest. --online Alias for --mode online. --offline Alias for --mode offline. --data-dir Offline target data dir (default: \$OD_DATA_DIR or ./.od). --namespace Tools-dev namespace for online auto-discovery. This does not affect od CLI behavior. Offline mode requires --data-dir or OD_DATA_DIR when --namespace is set. --decks Number of slide decks to seed (default: ${DECKS.length}, max: ${DECKS.length}) --webs Number of web prototypes to seed (default: ${WEBS.length}, max: ${WEBS.length}) --default-plugins Number of bundled default plugin artifacts to seed (default: ${DEFAULT_PLUGINS.length}, max: ${DEFAULT_PLUGINS.length}) --community-plugins Number of community plugin artifacts to seed (default: ${COMMUNITY_PLUGINS.length}, max: ${COMMUNITY_PLUGINS.length}) --clear Delete every previously seeded project (id prefix '${SEED_PREFIX}') -h, --help Show this help Online daemon URL resolution (first match wins): 1. \`--daemon \` on the command line. 2. \`OD_DAEMON_URL\` env var. 3. \`http://127.0.0.1:\$OD_PORT\` when \`OD_PORT\` is set to a real port. 4. Auto-discovered from \`pnpm tools-dev status --json\`. \`tools-dev\` defaults to an ephemeral daemon port, so a typical two-shell flow works without extra flags: pnpm tools-dev # in one shell pnpm seed:test-projects # in another — discovers the running daemon For a non-default tools-dev namespace, pass \`--namespace \` so the status lookup reads that namespace. Offline ingest before boot: pnpm seed:test-projects --offline --data-dir ./.od pnpm tools-dev `); } function isDiscoverablePort(value: string | undefined): value is string { if (value == null || value.length === 0) return false; // tools-dev sets OD_PORT=0 to mean "ephemeral, look at runtime status", // which is unusable as a target. Treat it the same as unset so we fall // through to the discovery path. const n = Number(value); return Number.isInteger(n) && n > 0 && n < 65536; } async function discoverDaemonUrlFromToolsDev(namespace: string | null): Promise { return await new Promise((resolve) => { let child; try { // `--silent` suppresses pnpm's own warnings (notably "Unsupported // engine" when the local node version doesn't match the repo's // engines.node). Without it, those warnings land on stdout under a // nested pnpm context and break the JSON parse below. const statusArgs = ['--silent', 'exec', 'tools-dev', 'status', '--json']; if (namespace) statusArgs.push('--namespace', namespace); child = spawn('pnpm', statusArgs, { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], }); } catch { resolve(null); return; } let stdout = ''; child.stdout?.on('data', (chunk: Buffer | string) => { stdout += typeof chunk === 'string' ? chunk : chunk.toString('utf8'); }); child.stderr?.resume(); child.on('error', () => resolve(null)); child.on('exit', () => { const url = extractDaemonUrlFromStatusOutput(stdout); resolve(url); }); }); } // Robust against any leading non-JSON noise pnpm or a wrapper might still // print on stdout (engine warnings, recursive run banners, deprecation // notices). Find the first `{` and try to parse the JSON object that // starts there; if that fails, walk forward to the next `{`. This keeps // discovery working even if a future pnpm version regresses around // `--silent`. function extractDaemonUrlFromStatusOutput(stdout: string): string | null { for (let i = stdout.indexOf('{'); i !== -1; i = stdout.indexOf('{', i + 1)) { try { const parsed = JSON.parse(stdout.slice(i)) as { apps?: { daemon?: { url?: string | null } }; url?: string | null; }; const url = parsed?.apps?.daemon?.url ?? parsed?.url ?? null; if (typeof url === 'string' && url.length > 0) return url; } catch { // try the next `{` } } return null; } async function resolveDaemonUrl(args: Args, { required }: { required: boolean }): Promise { if (args.daemonUrl) return args.daemonUrl; if (process.env.OD_DAEMON_URL) return process.env.OD_DAEMON_URL; if (isDiscoverablePort(process.env.OD_PORT)) { return `http://127.0.0.1:${process.env.OD_PORT}`; } const discovered = await discoverDaemonUrlFromToolsDev(args.namespace); if (discovered) return discovered; if (!required) return null; throw new Error( 'cannot determine daemon URL: no --daemon flag, no OD_DAEMON_URL, ' + 'no usable OD_PORT, and `pnpm tools-dev status --json` did not report a ' + 'running daemon. Start the daemon (e.g. `pnpm tools-dev`), pass ' + '`--daemon http://127.0.0.1:`, or use `--offline` to ingest ' + 'fixtures before startup.', ); } async function api( daemonUrl: string, method: string, pathPart: string, body?: unknown, ): Promise { const url = `${daemonUrl.replace(/\/$/, '')}${pathPart}`; const init: RequestInit = { method }; if (body !== undefined) { init.headers = { 'content-type': 'application/json' }; init.body = JSON.stringify(body); } let resp: Response; try { resp = await fetch(url, init); } catch (err) { throw new Error( `cannot reach daemon at ${daemonUrl} — start it with \`pnpm tools-dev\` ` + `(underlying error: ${(err as Error).message || String(err)})`, ); } if (!resp.ok) { const text = await resp.text().catch(() => ''); throw new Error(`${method} ${pathPart} → ${resp.status}: ${text}`); } if (resp.status === 204) return undefined as T; return (await resp.json()) as T; } function makeSeedId(fix: SeedFixture): string { // unique-ish, sortable, easy to spot in the UI / db const ts = Date.now().toString(36); const rand = Math.random().toString(36).slice(2, 6); // Slug must match [A-Za-z0-9._-]{1,128}, see daemon validation. const slug = `${fix.sourceKind}-${fix.skillId}`.replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 60); return `${SEED_PREFIX}${slug}-${ts}-${rand}`.slice(0, 128); } function fixtureRoot(fix: SeedFixture): string { if (fix.sourceKind === 'default-plugin') { return path.join(BUNDLED_PLUGIN_EXAMPLES_DIR, fix.skillId); } if (fix.sourceKind === 'community-plugin') { return path.join(COMMUNITY_PLUGIN_EXAMPLES_DIR, fix.skillId); } return path.join(SKILLS_DIR, fix.skillId); } function sourceLabel(fix: SeedFixture): string { if (fix.sourceKind === 'default-plugin') return `plugins/_official/examples/${fix.skillId}`; if (fix.sourceKind === 'community-plugin') return `plugins/community/examples/${fix.skillId}`; return `skills/${fix.skillId}`; } function seedMetadata(fix: SeedFixture) { return { kind: fix.kind, seeded: true, source: 'seed-test-projects', sourceKind: fix.sourceKind, sourceId: fix.skillId, pluginId: fix.pluginId ?? null, entryFile: 'index.html', }; } async function loadExample(fix: SeedFixture): Promise { const file = path.join(fixtureRoot(fix), fix.source ?? 'example.html'); return readFile(file, 'utf8'); } async function seedOneOnline(daemonUrl: string, fix: SeedFixture): Promise { const html = await loadExample(fix); const id = makeSeedId(fix); process.stdout.write(` - ${fix.kind.padEnd(9)} ${id} (${sourceLabel(fix)})\n`); const created = await api<{ project: { id: string }; conversationId: string; }>(daemonUrl, 'POST', '/api/projects', { id, name: fix.name, skillId: fix.sourceKind === 'skill' ? fix.skillId : null, pendingPrompt: fix.pendingPrompt, metadata: seedMetadata(fix), }); const uploaded = await api( daemonUrl, 'POST', `/api/projects/${id}/files`, { name: 'index.html', content: html, encoding: 'utf8', }, ); await api(daemonUrl, 'PUT', `/api/projects/${id}/tabs`, { tabs: ['index.html'], active: 'index.html', }); // Fake chat history so the conversation panel isn't empty. Two messages // is enough for the recent-activity sort and for the assistant bubble // to render with a producedFiles chip. const cid = created.conversationId; const userMid = `seed-msg-user-${Date.now().toString(36)}`; const asstMid = `seed-msg-asst-${Date.now().toString(36)}`; const now = Date.now(); await api( daemonUrl, 'PUT', `/api/projects/${id}/conversations/${cid}/messages/${userMid}`, { role: 'user', content: fix.pendingPrompt, createdAt: now, }, ); await api( daemonUrl, 'PUT', `/api/projects/${id}/conversations/${cid}/messages/${asstMid}`, { role: 'assistant', content: `Seeded \`index.html\` from \`${sourceLabel(fix)}/${fix.source ?? 'example.html'}\` ` + `as a starting point. Open the preview tab to see the rendered ${fix.kind}.`, agentId: 'seed-script', agentName: 'seed-test-projects', runStatus: 'succeeded', startedAt: now, endedAt: now, producedFiles: [uploaded.file], createdAt: now, }, ); return id; } function expandHomePrefix(raw: string): string { if (raw === '~' || raw === '$HOME' || raw === '${HOME}') return os.homedir(); const match = /^(~|\$\{HOME\}|\$HOME)[/\\](.*)$/.exec(raw); if (match) return path.join(os.homedir(), match[2] ?? ''); return raw; } function resolveDataDir(raw: string | null): string { const value = raw ?? process.env.OD_DATA_DIR ?? path.join(REPO_ROOT, '.od'); const expanded = expandHomePrefix(value); return path.isAbsolute(expanded) ? expanded : path.resolve(REPO_ROOT, expanded); } function assertOfflineDataDirIsExplicit(args: Args): void { if (args.namespace && !args.dataDir && !process.env.OD_DATA_DIR) { throw new Error( '--namespace is only a tools-dev discovery selector. Offline mode with ' + '--namespace requires --data-dir or OD_DATA_DIR so the script does not ' + 'guess a namespace-scoped daemon data directory.', ); } } function loadBetterSqlite(): new (filename: string) => OfflineDatabase { const daemonRequire = createRequire(path.join(REPO_ROOT, 'apps', 'daemon', 'package.json')); return daemonRequire('better-sqlite3') as new (filename: string) => OfflineDatabase; } function ensureColumn(db: OfflineDatabase, table: string, column: string, definition: string): void { const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array>; if (!cols.some((c) => c['name'] === column)) { db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); } } function migrateOffline(db: OfflineDatabase): void { db.exec(` CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, skill_id TEXT, design_system_id TEXT, pending_prompt TEXT, metadata_json TEXT, applied_plugin_snapshot_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS conversations ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT, applied_plugin_snapshot_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_conv_project ON conversations(project_id, updated_at DESC); CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, agent_id TEXT, agent_name TEXT, run_id TEXT, run_status TEXT, last_run_event_id TEXT, events_json TEXT, attachments_json TEXT, comment_attachments_json TEXT, produced_files_json TEXT, started_at INTEGER, ended_at INTEGER, position INTEGER NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, position); CREATE TABLE IF NOT EXISTS tabs ( project_id TEXT NOT NULL, name TEXT NOT NULL, position INTEGER NOT NULL, is_active INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(project_id, name), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_tabs_project ON tabs(project_id, position); `); ensureColumn(db, 'projects', 'metadata_json', 'TEXT'); ensureColumn(db, 'projects', 'applied_plugin_snapshot_id', 'TEXT'); ensureColumn(db, 'conversations', 'applied_plugin_snapshot_id', 'TEXT'); ensureColumn(db, 'messages', 'agent_id', 'TEXT'); ensureColumn(db, 'messages', 'agent_name', 'TEXT'); ensureColumn(db, 'messages', 'run_id', 'TEXT'); ensureColumn(db, 'messages', 'run_status', 'TEXT'); ensureColumn(db, 'messages', 'last_run_event_id', 'TEXT'); ensureColumn(db, 'messages', 'comment_attachments_json', 'TEXT'); ensureColumn(db, 'messages', 'produced_files_json', 'TEXT'); } async function openOfflineSeedContext(args: Args): Promise { assertOfflineDataDirIsExplicit(args); const dataDir = resolveDataDir(args.dataDir); await mkdir(dataDir, { recursive: true }); const Database = loadBetterSqlite(); const db = new Database(path.join(dataDir, 'app.sqlite')); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); migrateOffline(db); const projectsDir = path.join(dataDir, 'projects'); await mkdir(projectsDir, { recursive: true }); return { db, dataDir, projectsDir }; } function projectFileMeta(filePath: string, size: number, mtime: number): ProjectFile { return { name: filePath, path: filePath, type: 'file', size, mtime, kind: filePath.endsWith('.html') || filePath.endsWith('.htm') ? 'html' : 'code', mime: filePath.endsWith('.html') || filePath.endsWith('.htm') ? 'text/html; charset=utf-8' : 'application/octet-stream', }; } async function seedOneOffline(ctx: OfflineSeedContext, fix: SeedFixture): Promise { const html = await loadExample(fix); const id = makeSeedId(fix); process.stdout.write(` - ${fix.kind.padEnd(9)} ${id} (${sourceLabel(fix)})\n`); const projectDir = path.join(ctx.projectsDir, id); await mkdir(projectDir, { recursive: true }); const entryFile = 'index.html'; const entryPath = path.join(projectDir, entryFile); await writeFile(entryPath, html, 'utf8'); const written = await stat(entryPath); const file = projectFileMeta(entryFile, written.size, written.mtimeMs); const conversationId = `seed-conv-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; const userMid = `seed-msg-user-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; const asstMid = `seed-msg-asst-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; const now = Date.now(); const metadata = seedMetadata(fix); const tx = ctx.db.transaction(() => { ctx.db.prepare( `INSERT INTO projects (id, name, skill_id, design_system_id, pending_prompt, metadata_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ).run( id, fix.name, fix.sourceKind === 'skill' ? fix.skillId : null, null, fix.pendingPrompt, JSON.stringify(metadata), now, now, ); ctx.db.prepare( `INSERT INTO conversations (id, project_id, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, ).run(conversationId, id, null, now, now); ctx.db.prepare( `INSERT INTO tabs (project_id, name, position, is_active) VALUES (?, ?, ?, ?)`, ).run(id, entryFile, 0, 1); ctx.db.prepare( `INSERT INTO messages (id, conversation_id, role, content, position, created_at) VALUES (?, ?, ?, ?, ?, ?)`, ).run(userMid, conversationId, 'user', fix.pendingPrompt, 0, now); ctx.db.prepare( `INSERT INTO messages (id, conversation_id, role, content, agent_id, agent_name, run_status, produced_files_json, started_at, ended_at, position, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( asstMid, conversationId, 'assistant', `Seeded \`index.html\` from \`${sourceLabel(fix)}/${fix.source ?? 'example.html'}\` as a starting point. Open the preview tab to see the rendered ${fix.kind}.`, 'seed-script', 'seed-test-projects', 'succeeded', JSON.stringify([file]), now, now, 1, now, ); }); tx(); return id; } async function clearSeededOnline(daemonUrl: string): Promise { const { projects } = await api<{ projects: SeedProjectSummary[] }>( daemonUrl, 'GET', '/api/projects', ); // Project ids are caller-supplied through the public daemon API, so // the `seed-` prefix alone is not a strong enough marker for a // destructive delete. Require both the prefix AND the metadata stamp // we wrote in `seedOne` so a manually-created project that happens to // share the prefix is left alone. const seeded = projects.filter( (p) => p.id.startsWith(SEED_PREFIX) && p.metadata?.seeded === true && p.metadata?.source === 'seed-test-projects', ); if (seeded.length === 0) { console.log('no seeded projects to remove.'); return; } console.log(`removing ${seeded.length} seeded project(s):`); for (const p of seeded) { process.stdout.write(` - ${p.id}\n`); await api(daemonUrl, 'DELETE', `/api/projects/${p.id}`); } } async function clearSeededOffline(ctx: OfflineSeedContext): Promise { const rows = ctx.db.prepare( `SELECT id, metadata_json AS metadataJson FROM projects`, ).all() as Array<{ id?: unknown; metadataJson?: unknown }>; const seeded = rows.filter((row) => { if (typeof row.id !== 'string' || !row.id.startsWith(SEED_PREFIX)) return false; if (typeof row.metadataJson !== 'string') return false; try { const metadata = JSON.parse(row.metadataJson) as SeedProjectSummary['metadata']; return metadata?.seeded === true && metadata.source === 'seed-test-projects'; } catch { return false; } }); if (seeded.length === 0) { console.log('no seeded projects to remove.'); return; } console.log(`removing ${seeded.length} seeded project(s):`); for (const row of seeded) { const id = String(row.id); process.stdout.write(` - ${id}\n`); ctx.db.prepare(`DELETE FROM projects WHERE id = ?`).run(id); await rm(path.join(ctx.projectsDir, id), { recursive: true, force: true }); } } async function main() { const args = parseArgs(process.argv.slice(2)); const daemonUrl = args.mode === 'offline' ? null : await resolveDaemonUrl(args, { required: args.mode === 'online' }); const mode: Exclude = args.mode === 'offline' || !daemonUrl ? 'offline' : 'online'; const onlineDaemonUrl = mode === 'online' ? daemonUrl : null; if (args.clear) { if (mode === 'online') { if (!onlineDaemonUrl) throw new Error('online mode requires a daemon URL'); await clearSeededOnline(onlineDaemonUrl); return; } const ctx = await openOfflineSeedContext(args); try { await clearSeededOffline(ctx); } finally { ctx.db.close(); } return; } const decks = DECKS.slice(0, args.decks); const webs = WEBS.slice(0, args.webs); const defaultPlugins = DEFAULT_PLUGINS.slice(0, args.defaultPlugins); const communityPlugins = COMMUNITY_PLUGINS.slice(0, args.communityPlugins); const fixtures = [...decks, ...webs, ...defaultPlugins, ...communityPlugins]; if (fixtures.length === 0) { console.error('--decks 0, --webs 0, --default-plugins 0, and --community-plugins 0 — nothing to do.'); process.exit(2); } const target = mode === 'online' ? onlineDaemonUrl : resolveDataDir(args.dataDir); console.log( `seeding ${decks.length} skill deck(s) + ${webs.length} skill web prototype(s) + ` + `${defaultPlugins.length} default plugin artifact(s) + ` + `${communityPlugins.length} community plugin artifact(s) → ${mode}:${target}`, ); const failures: string[] = []; const createdIds: string[] = []; if (mode === 'online') { if (!onlineDaemonUrl) throw new Error('online mode requires a daemon URL'); for (const fix of fixtures) { try { createdIds.push(await seedOneOnline(onlineDaemonUrl, fix)); } catch (err) { failures.push(fix.skillId); console.error(` ! ${sourceLabel(fix)} failed: ${(err as Error).message}`); } } } else { const ctx = await openOfflineSeedContext(args); try { for (const fix of fixtures) { try { createdIds.push(await seedOneOffline(ctx, fix)); } catch (err) { failures.push(fix.skillId); console.error(` ! ${sourceLabel(fix)} failed: ${(err as Error).message}`); } } } finally { ctx.db.close(); } } if (failures.length > 0) { console.error( `done with ${failures.length} failure(s): ${failures.join(', ')}`, ); process.exit(1); } console.log(`done. Seeded ${createdIds.length} project(s).`); if (mode === 'offline') { console.log('Start the daemon/web UI next; the seeded projects will show up in the project list.'); } else { console.log('Open the web UI — the seeded projects show up in the project list.'); } } main().catch((err) => { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); });