mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
chore(scripts): seed daemon with pre-baked decks and web prototypes (#457)
* chore(scripts): seed daemon with pre-baked decks and web prototypes Adds `pnpm seed:test-projects`, a small HTTP client that talks to the running daemon and creates curated slide decks + web prototypes loaded from each skill's example.html. Each seeded project gets `index.html`, an active tab, and two fake chat messages so the UI renders fully without waiting for an LLM run. `--clear` removes everything prefixed with `seed-`. * fix(scripts): exit non-zero when any seed fixture fails Track per-fixture failures in seed:test-projects and exit 1 when at least one fixture errored, so CI/scripts no longer treat a partially failed seed run (e.g. unreachable daemon) as success. Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) * fix(scripts): wire ProjectFile contract through seed assistant message The seeded assistant message was passing producedFiles as a string array, but the UI consumes ProjectFile[] (name/size/mtime/kind/mime), so seeded projects rendered with a broken produced-file chip. Capture the upload response and forward uploaded.file as the chip payload. Also tighten --clear: only delete projects that match the seed- prefix AND carry the seeded/source metadata stamp this script writes, so a manually-created project sharing the prefix isn't removed. Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) * fix(scripts): auto-discover daemon URL from tools-dev status Previously the seed script defaulted to 127.0.0.1:7456 when neither OD_DAEMON_URL nor OD_PORT was set in the calling shell. `pnpm tools-dev` runs in a sibling shell with an ephemeral daemon port, so that default was almost never the right URL and broke the documented two-shell flow. Resolution now goes: --daemon flag, then OD_DAEMON_URL, then http://127.0.0.1:$OD_PORT (only when OD_PORT is a real port — OD_PORT=0 falls through), and finally a discovery step that parses the daemon URL out of `pnpm exec tools-dev status --json`. If everything fails the script aborts with a hint instead of silently fetching against the wrong port. Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)
This commit is contained in:
parent
3935aeb421
commit
05623cd743
2 changed files with 434 additions and 0 deletions
|
|
@ -17,6 +17,7 @@
|
|||
"check:residual-js": "node --experimental-strip-types scripts/check-residual-js.ts",
|
||||
"sync:community-pets": "node --experimental-strip-types scripts/sync-community-pets.ts",
|
||||
"bake:community-pets": "node --experimental-strip-types scripts/bake-community-pets.ts",
|
||||
"seed:test-projects": "node --experimental-strip-types scripts/seed-test-projects.ts",
|
||||
"test:e2e:live": "pnpm --filter @open-design/e2e test:e2e:live",
|
||||
"test": "pnpm -r --workspace-concurrency=1 --if-present run test",
|
||||
"test:ui:clean": "pnpm --filter @open-design/e2e test:ui:clean",
|
||||
|
|
|
|||
433
scripts/seed-test-projects.ts
Normal file
433
scripts/seed-test-projects.ts
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
#!/usr/bin/env node
|
||||
// Seed the running daemon 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's
|
||||
// `example.html`, drops it in as `index.html`, and adds a couple of
|
||||
// fake chat messages so the conversation panel isn't empty.
|
||||
//
|
||||
// Usage (daemon must be running — e.g. `pnpm tools-dev`):
|
||||
// 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 --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. 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 { readFile } 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 SEED_PREFIX = 'seed-';
|
||||
|
||||
type SeedKind = 'deck' | 'prototype';
|
||||
|
||||
interface SeedFixture {
|
||||
skillId: string;
|
||||
kind: SeedKind;
|
||||
name: string;
|
||||
pendingPrompt: string;
|
||||
// optional: path to the file inside skills/<skillId>/ 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;
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const DECKS: SeedFixture[] = [
|
||||
{
|
||||
skillId: 'html-ppt-pitch-deck',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
kind: 'prototype',
|
||||
name: 'Admin dashboard — analytics',
|
||||
pendingPrompt:
|
||||
'Admin dashboard with KPI cards, a revenue chart, and a recent activity table. Fixed left sidebar.',
|
||||
},
|
||||
];
|
||||
|
||||
interface Args {
|
||||
daemonUrl: string | null;
|
||||
decks: number;
|
||||
webs: number;
|
||||
clear: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const out: Args = {
|
||||
daemonUrl: null,
|
||||
decks: DECKS.length,
|
||||
webs: WEBS.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 === '--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 === '--clear') {
|
||||
out.clear = true;
|
||||
} else if (a === '-h' || a === '--help') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`unknown flag: ${a}`);
|
||||
printHelp();
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: pnpm seed:test-projects [opts]
|
||||
|
||||
Seeds the running daemon with pre-baked slide decks and web prototypes
|
||||
loaded from each skill's example.html. Useful for working on the UI
|
||||
without waiting for an LLM run.
|
||||
|
||||
Options:
|
||||
--daemon <url> 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\`.
|
||||
--decks <n> Number of slide decks to seed (default: ${DECKS.length}, max: ${DECKS.length})
|
||||
--webs <n> Number of web prototypes to seed (default: ${WEBS.length}, max: ${WEBS.length})
|
||||
--clear Delete every previously seeded project (id prefix '${SEED_PREFIX}')
|
||||
-h, --help Show this help
|
||||
|
||||
Daemon URL resolution (first match wins):
|
||||
1. \`--daemon <url>\` 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
|
||||
`);
|
||||
}
|
||||
|
||||
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(): Promise<string | null> {
|
||||
return await new Promise<string | null>((resolve) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn('pnpm', ['exec', 'tools-dev', 'status', '--json'], {
|
||||
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', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
apps?: { daemon?: { url?: string | null } };
|
||||
url?: string | null;
|
||||
};
|
||||
const url = parsed?.apps?.daemon?.url ?? parsed?.url ?? null;
|
||||
resolve(typeof url === 'string' && url.length > 0 ? url : null);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveDaemonUrl(args: Args): Promise<string> {
|
||||
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();
|
||||
if (discovered) return discovered;
|
||||
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`) or pass ' +
|
||||
'`--daemon http://127.0.0.1:<port>` explicitly.',
|
||||
);
|
||||
}
|
||||
|
||||
async function api<T = unknown>(
|
||||
daemonUrl: string,
|
||||
method: string,
|
||||
pathPart: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
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(skillId: string): 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 = skillId.replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 60);
|
||||
return `${SEED_PREFIX}${slug}-${ts}-${rand}`.slice(0, 128);
|
||||
}
|
||||
|
||||
async function loadExample(fix: SeedFixture): Promise<string> {
|
||||
const file = path.join(SKILLS_DIR, fix.skillId, fix.source ?? 'example.html');
|
||||
return readFile(file, 'utf8');
|
||||
}
|
||||
|
||||
async function seedOne(daemonUrl: string, fix: SeedFixture): Promise<void> {
|
||||
const html = await loadExample(fix);
|
||||
const id = makeSeedId(fix.skillId);
|
||||
process.stdout.write(` - ${fix.kind.padEnd(9)} ${id} (${fix.skillId})\n`);
|
||||
|
||||
const created = await api<{
|
||||
project: { id: string };
|
||||
conversationId: string;
|
||||
}>(daemonUrl, 'POST', '/api/projects', {
|
||||
id,
|
||||
name: fix.name,
|
||||
skillId: fix.skillId,
|
||||
pendingPrompt: fix.pendingPrompt,
|
||||
metadata: { kind: fix.kind, seeded: true, source: 'seed-test-projects' },
|
||||
});
|
||||
|
||||
const uploaded = await api<ProjectFileResponse>(
|
||||
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 \`skills/${fix.skillId}/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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function clearSeeded(daemonUrl: string): Promise<void> {
|
||||
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 main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const daemonUrl = await resolveDaemonUrl(args);
|
||||
|
||||
if (args.clear) {
|
||||
await clearSeeded(daemonUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const decks = DECKS.slice(0, args.decks);
|
||||
const webs = WEBS.slice(0, args.webs);
|
||||
if (decks.length === 0 && webs.length === 0) {
|
||||
console.error('--decks 0 and --webs 0 — nothing to do.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
console.log(`seeding ${decks.length} deck(s) + ${webs.length} web prototype(s) → ${daemonUrl}`);
|
||||
const failures: string[] = [];
|
||||
for (const fix of [...decks, ...webs]) {
|
||||
try {
|
||||
await seedOne(daemonUrl, fix);
|
||||
} catch (err) {
|
||||
failures.push(fix.skillId);
|
||||
console.error(` ! ${fix.skillId} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
`done with ${failures.length} failure(s): ${failures.join(', ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('done. 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);
|
||||
});
|
||||
Loading…
Reference in a new issue