diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index f91a57aba..0e3681fd7 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -247,7 +247,7 @@ function printRootHelp() { od artifacts create --name --input [--project ] Create a normal project artifact through the local daemon. - od tools connectors [options] + od tools connectors [options] Discover and execute configured connectors. od mcp live-artifacts diff --git a/apps/daemon/src/design-system-generation-jobs.ts b/apps/daemon/src/design-system-generation-jobs.ts new file mode 100644 index 000000000..e78d2ea84 --- /dev/null +++ b/apps/daemon/src/design-system-generation-jobs.ts @@ -0,0 +1,352 @@ +import { randomUUID } from 'node:crypto'; + +import { + createUserDesignSystemRevision, + createUserDesignSystem, + listUserDesignSystemFiles, + readDesignSystem, + type DesignSystemRevision, + type DesignSystemSummary, + type UserDesignSystemInput, + type UserDesignSystemRevisionInput, +} from './design-systems.js'; +import { + collectDesignSystemSourceContext, + mergeSourceContextIntoInput, + type DesignSystemSourceContext, +} from './design-system-source-context.js'; + +export type DesignSystemGenerationJobStatus = + | 'queued' + | 'running' + | 'succeeded' + | 'failed'; + +export type DesignSystemGenerationStepStatus = + | 'pending' + | 'running' + | 'succeeded' + | 'failed'; + +export type DesignSystemGenerationStep = { + id: string; + title: string; + status: DesignSystemGenerationStepStatus; + message?: string; + startedAt?: string; + completedAt?: string; +}; + +export type DesignSystemGenerationJob = { + id: string; + kind?: 'generation' | 'revision'; + status: DesignSystemGenerationJobStatus; + progress: number; + steps: DesignSystemGenerationStep[]; + createdAt: string; + updatedAt: string; + completedAt?: string; + designSystemId?: string; + revisionId?: string; + error?: string; + message?: string; +}; + +type MutableJob = DesignSystemGenerationJob; + +type StoreOptions = { + root: string; + createDesignSystem?: ( + root: string, + input: UserDesignSystemInput, + ) => Promise; + readDesignSystem?: (root: string, id: string, options?: { idPrefix?: string }) => Promise; + createRevision?: ( + root: string, + id: string, + input: UserDesignSystemRevisionInput, + ) => Promise; + collectSourceContext?: (input: UserDesignSystemInput) => Promise; + listFiles?: (root: string, id: string) => Promise | null>; + delayMs?: number; + idFactory?: () => string; +}; + +const STEP_DEFS = [ + { id: 'explore-resources', title: 'Explore provided resources' }, + { id: 'create-draft', title: 'Create design system draft' }, + { id: 'generate-files', title: 'Generate preview cards and files' }, + { id: 'register-files', title: 'Register files for review' }, + { id: 'prepare-review', title: 'Prepare review workspace' }, +] as const; + +const REVISION_STEP_DEFS = [ + { id: 'read-draft', title: 'Read current draft' }, + { id: 'apply-feedback', title: 'Apply requested changes' }, + { id: 'create-revision', title: 'Create pending revision' }, + { id: 'prepare-review', title: 'Prepare updated review' }, +] as const; + +export type DesignSystemRevisionInput = { + designSystemId: string; + feedback: string; + sectionTitle?: string; + body?: string; +}; + +export function createDesignSystemGenerationJobStore(options: StoreOptions) { + const jobs = new Map(); + const createDesignSystem = options.createDesignSystem ?? createUserDesignSystem; + const readExistingDesignSystem = options.readDesignSystem ?? readDesignSystem; + const createRevision = options.createRevision ?? createUserDesignSystemRevision; + const collectSourceContext = options.collectSourceContext ?? collectDesignSystemSourceContext; + const listFiles = options.listFiles ?? listUserDesignSystemFiles; + const delayMs = options.delayMs ?? 280; + const idFactory = options.idFactory ?? randomUUID; + + function start(input: UserDesignSystemInput): DesignSystemGenerationJob { + const now = new Date().toISOString(); + const job: MutableJob = { + id: idFactory(), + kind: 'generation', + status: 'queued', + progress: 0, + steps: STEP_DEFS.map((step) => ({ ...step, status: 'pending' })), + createdAt: now, + updatedAt: now, + message: 'Queued', + }; + jobs.set(job.id, job); + void run(job, input); + return snapshot(job); + } + + function revise(input: DesignSystemRevisionInput): DesignSystemGenerationJob { + const now = new Date().toISOString(); + const job: MutableJob = { + id: idFactory(), + kind: 'revision', + status: 'queued', + progress: 0, + steps: REVISION_STEP_DEFS.map((step) => ({ ...step, status: 'pending' })), + createdAt: now, + updatedAt: now, + designSystemId: input.designSystemId, + message: 'Queued revision', + }; + jobs.set(job.id, job); + void runRevision(job, input); + return snapshot(job); + } + + function get(id: string): DesignSystemGenerationJob | null { + const job = jobs.get(id); + return job ? snapshot(job) : null; + } + + async function run(job: MutableJob, input: UserDesignSystemInput): Promise { + try { + markJob(job, 'running', 'Starting generation'); + let created: DesignSystemSummary | null = null; + let enrichedInput = input; + await runStep(job, 'explore-resources', async () => { + await sleep(delayMs); + const sourceContext = await safeCollectSourceContext(collectSourceContext, input); + enrichedInput = mergeSourceContextIntoInput(input, sourceContext); + setStepMessage(job, 'explore-resources', sourceSummary(input, sourceContext)); + }); + await runStep(job, 'create-draft', async () => { + created = await createDesignSystem(options.root, enrichedInput); + job.designSystemId = created.id; + setStepMessage(job, 'create-draft', `Created ${created.title}`); + }); + await runStep(job, 'generate-files', async () => { + await sleep(delayMs); + setStepMessage(job, 'generate-files', 'Generated DESIGN.md, README.md, SKILL.md, tokens, previews, and context files'); + }); + await runStep(job, 'register-files', async () => { + const designSystemId = created?.id; + const files = designSystemId ? await listFiles(options.root, designSystemId) : []; + setStepMessage(job, 'register-files', `Registered ${files?.length ?? 0} files`); + }); + await runStep(job, 'prepare-review', async () => { + await sleep(delayMs); + setStepMessage(job, 'prepare-review', 'Review workspace is ready'); + }); + completeJob(job, 'succeeded', 'Design system ready for review'); + } catch (err) { + failJob(job, err instanceof Error ? err.message : String(err)); + } + } + + async function runRevision(job: MutableJob, input: DesignSystemRevisionInput): Promise { + try { + markJob(job, 'running', 'Starting revision'); + const feedback = cleanFeedback(input.feedback); + if (!feedback) throw new Error('Revision feedback is required'); + let body = input.body; + let proposedBody = ''; + await runStep(job, 'read-draft', async () => { + if (!body) { + body = await readExistingDesignSystem(options.root, input.designSystemId, { + idPrefix: 'user:', + }) ?? undefined; + } + if (!body) throw new Error('Editable design system not found'); + setStepMessage(job, 'read-draft', `Loaded ${input.designSystemId}`); + }); + await runStep(job, 'apply-feedback', async () => { + await sleep(delayMs); + proposedBody = applyRevisionToBody(body ?? '', { + feedback, + ...(input.sectionTitle ? { sectionTitle: input.sectionTitle } : {}), + }); + setStepMessage(job, 'apply-feedback', input.sectionTitle ? `Updated ${input.sectionTitle}` : 'Updated DESIGN.md'); + }); + await runStep(job, 'create-revision', async () => { + if (!body) throw new Error('Editable design system not found'); + const revision = await createRevision(options.root, input.designSystemId, { + feedback, + baseBody: body, + proposedBody, + ...(input.sectionTitle ? { sectionTitle: input.sectionTitle } : {}), + jobId: job.id, + }); + if (!revision) throw new Error('Could not create revision'); + job.revisionId = revision.id; + setStepMessage(job, 'create-revision', `Created pending revision ${revision.id}`); + }); + await runStep(job, 'prepare-review', async () => { + await sleep(delayMs); + setStepMessage(job, 'prepare-review', 'Updated review is ready'); + }); + completeJob(job, 'succeeded', 'Revision ready for review'); + } catch (err) { + failJob(job, err instanceof Error ? err.message : String(err)); + } + } + + return { start, revise, get }; +} + +async function runStep( + job: MutableJob, + stepId: string, + task: () => Promise, +): Promise { + const step = job.steps.find((candidate) => candidate.id === stepId); + if (!step) throw new Error(`Unknown generation step: ${stepId}`); + step.status = 'running'; + step.startedAt = new Date().toISOString(); + touch(job); + try { + await task(); + step.status = 'succeeded'; + step.completedAt = new Date().toISOString(); + job.progress = Math.round( + (job.steps.filter((candidate) => candidate.status === 'succeeded').length / job.steps.length) * 100, + ); + touch(job); + } catch (err) { + step.status = 'failed'; + step.completedAt = new Date().toISOString(); + step.message = err instanceof Error ? err.message : String(err); + touch(job); + throw err; + } +} + +function markJob( + job: MutableJob, + status: DesignSystemGenerationJobStatus, + message: string, +): void { + job.status = status; + job.message = message; + touch(job); +} + +function completeJob( + job: MutableJob, + status: Extract, + message: string, +): void { + job.status = status; + job.progress = 100; + job.message = message; + job.completedAt = new Date().toISOString(); + touch(job); +} + +function failJob(job: MutableJob, message: string): void { + job.status = 'failed'; + job.error = message; + job.message = 'Generation failed'; + job.completedAt = new Date().toISOString(); + touch(job); +} + +function setStepMessage(job: MutableJob, stepId: string, message: string): void { + const step = job.steps.find((candidate) => candidate.id === stepId); + if (step) step.message = message; + touch(job); +} + +function touch(job: MutableJob): void { + job.updatedAt = new Date().toISOString(); +} + +function snapshot(job: MutableJob): DesignSystemGenerationJob { + return { + ...job, + steps: job.steps.map((step) => ({ ...step })), + }; +} + +async function safeCollectSourceContext( + collectSourceContext: (input: UserDesignSystemInput) => Promise, + input: UserDesignSystemInput, +): Promise { + try { + return await collectSourceContext(input); + } catch (err) { + return { + github: [], + notes: `Source context fetch failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +function sourceSummary(input: UserDesignSystemInput, context?: DesignSystemSourceContext): string { + const provenance = input.provenance; + const counts = [ + provenance?.githubUrls?.length ? `${provenance.githubUrls.length} GitHub link(s)` : '', + provenance?.localCodeFiles?.length ? `${provenance.localCodeFiles.length} local code reference(s)` : '', + provenance?.figFiles?.length ? `${provenance.figFiles.length} Figma file(s)` : '', + provenance?.assetFiles?.length ? `${provenance.assetFiles.length} asset(s)` : '', + ].filter(Boolean); + const base = counts.length > 0 ? counts.join(', ') : 'Using company context and notes'; + if (!context?.github.length) return base; + const readable = context.github.filter((repo) => !repo.error).length; + if (readable === 0) return `${base}; GitHub context unavailable`; + return `${base}; read ${readable} GitHub repo(s)`; +} + +function cleanFeedback(value: string): string { + return value.trim().replace(/\n{3,}/g, '\n\n'); +} + +function applyRevisionToBody( + body: string, + input: { feedback: string; sectionTitle?: string }, +): string { + const section = input.sectionTitle?.trim(); + const title = section ? `Revision Request: ${section}` : 'Revision Request'; + const stamp = new Date().toISOString(); + return `${body.trim()}\n\n## ${title}\n\n${input.feedback}\n\n_Revision job applied at ${stamp}._\n`; +} + +function sleep(ms: number): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/apps/daemon/src/design-system-source-context.ts b/apps/daemon/src/design-system-source-context.ts new file mode 100644 index 000000000..cf2d9e728 --- /dev/null +++ b/apps/daemon/src/design-system-source-context.ts @@ -0,0 +1,375 @@ +import type { UserDesignSystemInput } from './design-systems.js'; + +export type DesignSystemSourceContext = { + github: GitHubRepositoryContext[]; + notes: string; +}; + +export type GitHubRepositoryContext = { + url: string; + owner: string; + repo: string; + description?: string; + homepage?: string; + defaultBranch?: string; + language?: string; + stars?: number; + topics?: string[]; + readmeExcerpt?: string; + packageName?: string; + packageDescription?: string; + error?: string; +}; + +export type FetchLike = ( + url: string, + init?: { headers?: Record; signal?: AbortSignal }, +) => Promise<{ + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}>; + +export type SourceContextOptions = { + fetch?: FetchLike; + maxRepos?: number; + maxReadmeChars?: number; + timeoutMs?: number; +}; + +type ParsedGitHubRepo = { + owner: string; + repo: string; + url: string; +}; + +const DEFAULT_MAX_REPOS = 3; +const DEFAULT_MAX_README_CHARS = 720; +const DEFAULT_FETCH_TIMEOUT_MS = 3500; + +export async function collectDesignSystemSourceContext( + input: UserDesignSystemInput, + options: SourceContextOptions = {}, +): Promise { + const githubUrls = input.provenance?.githubUrls ?? []; + const repos = uniqueRepositories(githubUrls).slice(0, options.maxRepos ?? DEFAULT_MAX_REPOS); + if (repos.length === 0) return { github: [], notes: '' }; + + const fetchFn = options.fetch ?? defaultFetch; + const github = await Promise.all( + repos.map((repo) => readGitHubRepositoryContext(repo, { + fetch: fetchFn, + maxReadmeChars: options.maxReadmeChars ?? DEFAULT_MAX_README_CHARS, + timeoutMs: options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS, + })), + ); + + return { + github, + notes: formatGithubContextNotes(github), + }; +} + +export function mergeSourceContextIntoInput( + input: UserDesignSystemInput, + context: DesignSystemSourceContext, +): UserDesignSystemInput { + const contextNotes = cleanMultiline(context.notes); + if (!contextNotes) return input; + + const topLevelSourceNotes = joinUniqueBlocks([ + input.sourceNotes, + contextNotes, + ]); + const provenanceSourceNotes = joinUniqueBlocks([ + provenanceOnlySourceNotes(input), + contextNotes, + ]); + const provenance = { + ...(input.provenance ?? {}), + sourceNotes: provenanceSourceNotes, + }; + + return { + ...input, + sourceNotes: topLevelSourceNotes, + provenance, + }; +} + +function provenanceOnlySourceNotes(input: UserDesignSystemInput): string { + const provenanceSourceNotes = cleanMultiline(input.provenance?.sourceNotes); + const topLevelSourceNotes = cleanMultiline(input.sourceNotes); + if (!provenanceSourceNotes || provenanceSourceNotes === topLevelSourceNotes) return ''; + return provenanceSourceNotes; +} + +async function readGitHubRepositoryContext( + repo: ParsedGitHubRepo, + options: { fetch: FetchLike; maxReadmeChars: number; timeoutMs: number }, +): Promise { + const apiUrl = `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}`; + const api = await fetchJson(options.fetch, apiUrl, options.timeoutMs); + if (!api.ok) { + return { + ...repo, + error: `GitHub repository metadata unavailable (${api.error})`, + }; + } + + const payload = asRecord(api.value); + const description = readOptionalString(payload.description); + const homepage = readOptionalString(payload.homepage); + const defaultBranch = readOptionalString(payload.default_branch) ?? 'main'; + const language = readOptionalString(payload.language); + const stars = readOptionalNumber(payload.stargazers_count); + const topics = parseTopics(payload.topics); + const [readme, packageJson] = await Promise.all([ + readRawFile(options.fetch, repo, branchCandidates(defaultBranch), ['README.md', 'readme.md'], options.timeoutMs), + readRawFile(options.fetch, repo, branchCandidates(defaultBranch), ['package.json'], options.timeoutMs), + ]); + const packageInfo = parsePackageInfo(packageJson); + + return { + ...repo, + ...(description ? { description } : {}), + ...(homepage ? { homepage } : {}), + ...(defaultBranch ? { defaultBranch } : {}), + ...(language ? { language } : {}), + ...(typeof stars === 'number' ? { stars } : {}), + ...(topics.length > 0 ? { topics } : {}), + ...(readme ? { readmeExcerpt: excerptMarkdown(readme, options.maxReadmeChars) } : {}), + ...(packageInfo.name ? { packageName: packageInfo.name } : {}), + ...(packageInfo.description ? { packageDescription: packageInfo.description } : {}), + }; +} + +function uniqueRepositories(urls: string[]): ParsedGitHubRepo[] { + const seen = new Set(); + const repos: ParsedGitHubRepo[] = []; + for (const url of urls) { + const parsed = parseGitHubRepositoryUrl(url); + if (!parsed) continue; + const key = `${parsed.owner.toLowerCase()}/${parsed.repo.toLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); + repos.push(parsed); + } + return repos; +} + +function parseGitHubRepositoryUrl(raw: string): ParsedGitHubRepo | null { + const clean = raw.trim(); + const ssh = /^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?(?:[#?].*)?$/.exec(clean); + if (ssh?.[1] && ssh[2]) { + return { + owner: ssh[1], + repo: stripGitSuffix(ssh[2]), + url: `https://github.com/${ssh[1]}/${stripGitSuffix(ssh[2])}`, + }; + } + + let parsed: URL; + try { + parsed = new URL(clean); + } catch { + return null; + } + if (parsed.hostname !== 'github.com' && parsed.hostname !== 'www.github.com') return null; + const [owner, repo] = parsed.pathname.split('/').filter(Boolean); + if (!owner || !repo) return null; + return { + owner, + repo: stripGitSuffix(repo), + url: `https://github.com/${owner}/${stripGitSuffix(repo)}`, + }; +} + +async function fetchJson( + fetchFn: FetchLike, + url: string, + timeoutMs: number, +): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> { + try { + const response = await fetchWithTimeout(fetchFn, url, { + headers: { + accept: 'application/vnd.github+json', + 'user-agent': 'open-design-local', + }, + }, timeoutMs); + if (!response.ok) return { ok: false, error: `HTTP ${response.status}` }; + return { ok: true, value: await response.json() }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +async function readRawFile( + fetchFn: FetchLike, + repo: ParsedGitHubRepo, + branches: string[], + filePaths: string[], + timeoutMs: number, +): Promise { + for (const branch of branches) { + for (const filePath of filePaths) { + const url = rawGithubUrl(repo, branch, filePath); + try { + const response = await fetchWithTimeout(fetchFn, url, { + headers: { + accept: 'text/plain', + 'user-agent': 'open-design-local', + }, + }, timeoutMs); + if (response.ok) return response.text(); + } catch { + // Try the next candidate. + } + } + } + return ''; +} + +async function fetchWithTimeout( + fetchFn: FetchLike, + url: string, + init: { headers?: Record }, + timeoutMs: number, +): ReturnType { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetchFn(url, { + ...init, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + +function rawGithubUrl(repo: ParsedGitHubRepo, branch: string, filePath: string): string { + const parts = [ + encodeURIComponent(repo.owner), + encodeURIComponent(repo.repo), + encodeURIComponent(branch), + ...filePath.split('/').map(encodeURIComponent), + ]; + return `https://raw.githubusercontent.com/${parts.join('/')}`; +} + +function branchCandidates(defaultBranch: string): string[] { + const out: string[] = []; + for (const branch of [defaultBranch, 'main', 'master']) { + const clean = branch.trim(); + if (clean && !out.includes(clean)) out.push(clean); + } + return out; +} + +function parsePackageInfo(raw: string): { name?: string; description?: string } { + if (!raw) return {}; + try { + const value = JSON.parse(raw) as unknown; + const record = asRecord(value); + const name = readOptionalString(record.name); + const description = readOptionalString(record.description); + return { + ...(name ? { name } : {}), + ...(description ? { description } : {}), + }; + } catch { + return {}; + } +} + +function formatGithubContextNotes(repos: GitHubRepositoryContext[]): string { + if (repos.length === 0) return ''; + const lines = ['Fetched GitHub context:']; + for (const repo of repos) { + const headline = repo.description || repo.error || 'No repository description found.'; + lines.push(`- ${repo.owner}/${repo.repo}: ${headline}`); + const metadata = [ + repo.language ? `language ${repo.language}` : '', + typeof repo.stars === 'number' ? `${repo.stars} stars` : '', + repo.defaultBranch ? `default branch ${repo.defaultBranch}` : '', + ].filter(Boolean).join(', '); + if (metadata) lines.push(` Metadata: ${metadata}.`); + if (repo.homepage) lines.push(` Homepage: ${repo.homepage}`); + if (repo.topics?.length) lines.push(` Topics: ${repo.topics.join(', ')}`); + if (repo.packageName || repo.packageDescription) { + lines.push(` package.json: ${[repo.packageName, repo.packageDescription].filter(Boolean).join(' - ')}`); + } + if (repo.readmeExcerpt) lines.push(` README excerpt: ${repo.readmeExcerpt}`); + } + return lines.join('\n'); +} + +function excerptMarkdown(raw: string, maxChars: number): string { + const cleaned = raw + .replace(/!\[[^\]]*]\([^)]*\)/g, '') + .replace(/\[([^\]]+)]\([^)]*\)/g, '$1') + .replace(/^#{1,6}\s+/gm, '') + .replace(/`{1,3}/g, '') + .replace(/\r\n/g, '\n') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + return cleaned.length > maxChars ? `${cleaned.slice(0, Math.max(0, maxChars - 3)).trim()}...` : cleaned; +} + +function cleanMultiline(raw: string | undefined): string { + if (typeof raw !== 'string') return ''; + return raw + .replace(/\r\n/g, '\n') + .split('\n') + .map((line) => line.trim().replace(/[ \t]+/g, ' ')) + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function joinUniqueBlocks(blocks: Array): string { + const seen = new Set(); + const out: string[] = []; + for (const block of blocks) { + const clean = cleanMultiline(block); + if (!clean || seen.has(clean)) continue; + seen.add(clean); + out.push(clean); + } + return out.join('\n\n'); +} + +function stripGitSuffix(value: string): string { + return value.replace(/\.git$/i, ''); +} + +function parseTopics(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return raw + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) + .filter(Boolean) + .slice(0, 12); +} + +function readOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function readOptionalNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function defaultFetch(url: string, init?: { headers?: Record; signal?: AbortSignal }) { + return fetch(url, init); +} diff --git a/apps/daemon/src/design-systems.ts b/apps/daemon/src/design-systems.ts index 4c2f96834..d006c271d 100644 --- a/apps/daemon/src/design-systems.ts +++ b/apps/daemon/src/design-systems.ts @@ -9,7 +9,8 @@ // otherwise Markdown wins. Other fields (`name`/`description`/`category`/ // `surface`) fall back to frontmatter when the body has none. -import { readdir, readFile, stat } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { @@ -21,6 +22,10 @@ import { parseFrontmatter } from './frontmatter.js'; import type { FrontmatterObject, FrontmatterValue } from './frontmatter.js'; export type DesignSystemSurface = 'web' | 'image' | 'video' | 'audio'; +export type DesignSystemSource = 'built-in' | 'installed' | 'user'; +export type DesignSystemStatus = 'draft' | 'published'; +export type DesignSystemRevisionStatus = 'pending' | 'accepted' | 'rejected'; +export type DesignSystemArtifactMode = 'generated' | 'agent-managed'; export type DesignSystemSummary = { id: string; @@ -30,6 +35,47 @@ export type DesignSystemSummary = { swatches: string[]; surface: DesignSystemSurface; body: string; + source: DesignSystemSource; + status: DesignSystemStatus; + isEditable: boolean; + createdAt?: string; + updatedAt?: string; + provenance?: DesignSystemProvenance; + projectId?: string; +}; + +export type DesignSystemFileKind = + | 'folder' + | 'page' + | 'stylesheet' + | 'document' + | 'image' + | 'data' + | 'asset'; + +export type DesignSystemFileSummary = { + path: string; + name: string; + kind: DesignSystemFileKind; + size?: number; + updatedAt?: string; +}; + +export type DesignSystemFileDetail = DesignSystemFileSummary & { + content: string; +}; + +export type DesignSystemRevision = { + id: string; + designSystemId: string; + status: DesignSystemRevisionStatus; + feedback: string; + baseBody: string; + proposedBody: string; + createdAt: string; + updatedAt: string; + sectionTitle?: string; + jobId?: string; }; type ColorToken = { name: string; value: string }; @@ -47,7 +93,87 @@ type DesignSystemProjectManifest = { }; }; -export async function listDesignSystems(root: string): Promise { +export type DesignSystemProvenance = { + companyBlurb?: string; + githubUrls?: string[]; + localCodeFiles?: string[]; + figFiles?: string[]; + assetFiles?: string[]; + notes?: string; + sourceNotes?: string; +}; + +type UserDesignSystemMetadata = { + title?: string; + category?: string; + surface?: DesignSystemSurface; + status?: DesignSystemStatus; + artifactMode?: DesignSystemArtifactMode; + createdAt?: string; + updatedAt?: string; + provenance?: DesignSystemProvenance; + projectId?: string; +}; + +export const LEGACY_DESIGN_SYSTEM_ARTIFACTS = [ + { + legacyPath: 'preview/colors-ui-palette.html', + replacementPaths: ['preview/colors-primary.html'], + }, + { + legacyPath: 'preview/colors-node-types.html', + replacementPaths: ['preview/colors-theme-light.html', 'preview/colors-theme-dark.html'], + }, + { + legacyPath: 'preview/typography-scale.html', + replacementPaths: ['preview/typography-specimens.html'], + }, + { + legacyPath: 'preview/spacing-system.html', + replacementPaths: ['preview/spacing-tokens.html', 'preview/spacing-radius.html', 'preview/spacing-shadows.html'], + }, + { + legacyPath: 'preview/logo-variants.html', + replacementPaths: ['preview/brand-assets.html'], + }, + { + legacyPath: 'ui_kits/generated_interface', + replacementPaths: ['ui_kits/app/index.html'], + removeDirectory: true, + }, +] as const; + +export type UserDesignSystemInput = { + title?: string; + summary?: string; + category?: string; + surface?: DesignSystemSurface; + status?: DesignSystemStatus; + artifactMode?: DesignSystemArtifactMode; + body?: string; + sourceNotes?: string; + provenance?: DesignSystemProvenance; +}; + +export type UserDesignSystemRevisionInput = { + feedback: string; + baseBody: string; + proposedBody: string; + sectionTitle?: string; + jobId?: string; +}; + +export type DesignSystemListOptions = { + idPrefix?: string; + source?: DesignSystemSource; + isEditable?: boolean; + defaultStatus?: DesignSystemStatus; +}; + +export async function listDesignSystems( + root: string, + options: DesignSystemListOptions = {}, +): Promise { const out: DesignSystemSummary[] = []; let entries = []; try { @@ -64,35 +190,50 @@ export async function listDesignSystems(root: string): Promise { - const brandRoot = path.join(root, id); - const manifest = await readProjectManifest(brandRoot, id); +export async function readDesignSystem( + root: string, + id: string, + options: { idPrefix?: string } = {}, +): Promise { + const dirId = stripPrefixAndValidateId(id, options.idPrefix); + if (!dirId) return null; + const brandRoot = path.join(root, dirId); + const manifest = await readProjectManifest(brandRoot, dirId); const file = path.join(brandRoot, manifest?.files.design ?? 'DESIGN.md'); try { return await readFile(file, 'utf8'); @@ -175,8 +322,10 @@ export async function readDesignSystemAssets( root: string, id: string, ): Promise { - const brandRoot = path.join(root, id); - const manifest = await readProjectManifest(brandRoot, id); + const dirId = stripPrefixAndValidateId(id, id.startsWith('user:') ? 'user:' : ''); + if (!dirId) return {}; + const brandRoot = path.join(root, dirId); + const manifest = await readProjectManifest(brandRoot, dirId); const [tokensCss, fixtureHtml] = await Promise.all([ readFileOptional(path.join(brandRoot, manifest?.files.tokens ?? 'tokens.css')), manifest?.files.components === undefined && manifest !== null @@ -186,54 +335,12 @@ export async function readDesignSystemAssets( return withComponentsManifest(id, { tokensCss, fixtureHtml }); } -/** - * Returns true when the daemon should inject the structured design-system - * channel (tokens.css + components.html) into the system prompt for the - * active brand. Default-on as of PR-D — the only value that disables - * the channel is the literal string `'0'` on `OD_DESIGN_TOKEN_CHANNEL`, - * which acts as the kill switch. Unset, `'1'`, `'true'`, empty string, - * or any other value all keep the new default. - * - * Extracted from `server.ts` so the env-flag semantics (the single - * line PR-D actually flipped) can be unit-tested independently of the - * full daemon boot path. A regression that, say, restored the old - * `=== '1'` semantics or read the wrong env name would change the - * return value here and fail the unit test, even before any - * downstream prompt-assembly behaviour drifts. - */ export function isDesignTokenChannelEnabled( env: NodeJS.ProcessEnv = process.env, ): boolean { return env.OD_DESIGN_TOKEN_CHANNEL !== '0'; } -/** - * Resolves the structured design-system assets the daemon will hand to - * `composeSystemPrompt` for a given brand, applying both the - * `OD_DESIGN_TOKEN_CHANNEL` kill-switch and the built-in → - * user-installed root fallback chain. - * - * This is the function `server.ts` calls at the prompt-assembly seam. - * Extracted so the *whole* server-side asset-resolution path — - * env gate + per-file fallback + result shape — is unit-testable - * end-to-end from real disk fixtures, not just the boolean predicate. - * - * Behaviour (pinned by `tests/design-system-assets.test.ts`): - * - * - **`OD_DESIGN_TOKEN_CHANNEL=0`** (kill switch) — returns - * `{ tokensCss: undefined, fixtureHtml: undefined }` regardless of - * what's on disk. The composer skips both blocks, falling back to - * the pre-PR-C DESIGN.md-only prompt. - * - **Any other env state** (unset, `'1'`, `'true'`, …) — reads - * `tokens.css` and `components.html` from `builtInRoot//`. Any - * file missing there falls back to `userInstalledRoot//` - * independently per file, so a brand can ship one half built-in and - * the other half from user-installed without losing either. - * - * Real fs errors that are not "file not found" still propagate (see - * `readFileOptional`), so a misconfigured brand surfaces loudly - * instead of silently degrading to the prose-only prompt. - */ export async function resolveDesignSystemAssets( designSystemId: string, builtInRoot: string, @@ -288,21 +395,1911 @@ function buildComponentsManifestSummary( } } +export async function createUserDesignSystem( + root: string, + input: UserDesignSystemInput, +): Promise { + const title = normalizeTitle(input.title); + const dirId = await uniqueSlug(root, slugify(title)); + const now = new Date().toISOString(); + const provenance = normalizeProvenance(input.provenance, { + ...(input.summary ? { companyBlurb: input.summary } : {}), + ...(input.sourceNotes ? { sourceNotes: input.sourceNotes } : {}), + }); + const sourceNotes = provenanceToNotes(provenance) || cleanMultiline(input.sourceNotes); + const body = normalizeBody(input.body) ?? buildDraftDesignSystemBody({ + ...input, + title, + sourceNotes, + }); + const surface = input.surface ?? extractSurface(body) ?? 'web'; + await mkdir(path.join(root, dirId), { recursive: true }); + await writeFile(path.join(root, dirId, 'DESIGN.md'), body, 'utf8'); + const artifactMode = normalizeArtifactMode(input.artifactMode); + await writeUserMetadata(root, dirId, { + title, + category: cleanText(input.category) || extractCategory(body) || 'Custom', + surface, + status: input.status ?? 'draft', + ...(artifactMode ? { artifactMode } : {}), + createdAt: now, + updatedAt: now, + ...(provenance ? { provenance } : {}), + }); + if (artifactMode !== 'agent-managed') { + await writeGeneratedDesignSystemFiles(root, dirId, { + title, + category: cleanText(input.category) || extractCategory(body) || 'Custom', + surface, + summary: summarize(body), + ...(provenance ? { provenance } : {}), + ...(sourceNotes ? { sourceNotes } : {}), + body, + }); + } + const listed = await listDesignSystems(root, { + idPrefix: 'user:', + source: 'user', + isEditable: true, + defaultStatus: 'draft', + }); + return listed.find((s) => s.id === `user:${dirId}`)!; +} + +export async function updateUserDesignSystem( + root: string, + id: string, + input: UserDesignSystemInput, +): Promise { + const dirId = stripPrefixAndValidateId(id, 'user:'); + if (!dirId) return null; + const dir = path.join(root, dirId); + const designPath = path.join(dir, 'DESIGN.md'); + let existingBody: string; + try { + existingBody = await readFile(designPath, 'utf8'); + } catch { + return null; + } + const existingMeta = await readUserMetadata(root, dirId); + const now = new Date().toISOString(); + const title = normalizeTitle(input.title ?? existingMeta.title ?? firstHeading(existingBody) ?? dirId); + const category = cleanText(input.category) || existingMeta.category || extractCategory(existingBody) || 'Custom'; + const surface = input.surface ?? existingMeta.surface ?? extractSurface(existingBody) ?? 'web'; + const nextProvenance = normalizeProvenance(input.provenance, { + ...(input.sourceNotes ? { sourceNotes: input.sourceNotes } : {}), + }); + const provenance = nextProvenance ?? existingMeta.provenance; + const artifactMode = normalizeArtifactMode(input.artifactMode) ?? existingMeta.artifactMode; + const body = + normalizeBody(input.body) + ?? withDesignSystemHeader(existingBody, { title, category, surface }); + await writeFile(designPath, body, 'utf8'); + await writeUserMetadata(root, dirId, { + ...existingMeta, + title, + category, + surface, + status: input.status ?? existingMeta.status ?? 'draft', + ...(artifactMode ? { artifactMode } : {}), + createdAt: existingMeta.createdAt ?? now, + updatedAt: now, + ...(provenance ? { provenance } : {}), + }); + const sourceNotes = provenanceToNotes(provenance) || cleanMultiline(input.sourceNotes); + if (artifactMode !== 'agent-managed') { + await writeGeneratedDesignSystemFiles(root, dirId, { + title, + category, + surface, + summary: summarize(body), + ...(provenance ? { provenance } : {}), + ...(sourceNotes ? { sourceNotes } : {}), + body, + }); + } + const listed = await listDesignSystems(root, { + idPrefix: 'user:', + source: 'user', + isEditable: true, + defaultStatus: 'draft', + }); + return listed.find((s) => s.id === `user:${dirId}`) ?? null; +} + +export async function linkUserDesignSystemProject( + root: string, + id: string, + projectId: string, +): Promise { + const dirId = stripPrefixAndValidateId(id, 'user:'); + const cleanProjectId = cleanProjectIdForMetadata(projectId); + if (!dirId || !cleanProjectId) return null; + try { + const stats = await stat(path.join(root, dirId, 'DESIGN.md')); + if (!stats.isFile()) return null; + } catch { + return null; + } + const existingMeta = await readUserMetadata(root, dirId); + await writeUserMetadata(root, dirId, { + ...existingMeta, + projectId: cleanProjectId, + }); + const listed = await listDesignSystems(root, { + idPrefix: 'user:', + source: 'user', + isEditable: true, + defaultStatus: 'draft', + }); + return listed.find((s) => s.id === `user:${dirId}`) ?? null; +} + +export async function createUserDesignSystemRevision( + root: string, + id: string, + input: UserDesignSystemRevisionInput, +): Promise { + const dirId = stripPrefixAndValidateId(id, 'user:'); + if (!dirId) return null; + const dir = path.join(root, dirId); + try { + const stats = await stat(path.join(dir, 'DESIGN.md')); + if (!stats.isFile()) return null; + } catch { + return null; + } + const feedback = cleanMultiline(input.feedback); + const baseBody = normalizeBody(input.baseBody); + const proposedBody = normalizeBody(input.proposedBody); + if (!feedback || !baseBody || !proposedBody) return null; + const now = new Date().toISOString(); + const revision: DesignSystemRevision = { + id: randomUUID(), + designSystemId: `user:${dirId}`, + status: 'pending', + feedback, + baseBody, + proposedBody, + createdAt: now, + updatedAt: now, + ...(cleanText(input.sectionTitle) ? { sectionTitle: cleanText(input.sectionTitle) } : {}), + ...(input.jobId ? { jobId: input.jobId } : {}), + }; + await writeUserDesignSystemRevision(root, dirId, revision); + return revision; +} + +export async function listUserDesignSystemRevisions( + root: string, + id: string, +): Promise { + const dirId = stripPrefixAndValidateId(id, 'user:'); + if (!dirId) return null; + try { + const stats = await stat(path.join(root, dirId, 'DESIGN.md')); + if (!stats.isFile()) return null; + } catch { + return null; + } + let entries = []; + try { + entries = await readdir(path.join(root, dirId, 'revisions'), { withFileTypes: true }); + } catch { + return []; + } + const revisions: DesignSystemRevision[] = []; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) continue; + const revisionId = entry.name.slice(0, -'.json'.length); + const revision = await readUserDesignSystemRevision(root, id, revisionId); + if (revision) revisions.push(revision); + } + return revisions.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +} + +export async function readUserDesignSystemRevision( + root: string, + id: string, + revisionId: string, +): Promise { + const dirId = stripPrefixAndValidateId(id, 'user:'); + const cleanRevisionId = sanitizeRevisionId(revisionId); + if (!dirId || !cleanRevisionId) return null; + try { + const raw = await readFile( + path.join(root, dirId, 'revisions', `${cleanRevisionId}.json`), + 'utf8', + ); + return parseDesignSystemRevision(JSON.parse(raw), `user:${dirId}`); + } catch { + return null; + } +} + +export async function updateUserDesignSystemRevisionStatus( + root: string, + id: string, + revisionId: string, + status: Extract, +): Promise { + const dirId = stripPrefixAndValidateId(id, 'user:'); + if (!dirId) return null; + const revision = await readUserDesignSystemRevision(root, id, revisionId); + if (!revision) return null; + if (status === 'accepted') { + const updated = await updateUserDesignSystem(root, id, { + body: revision.proposedBody, + }); + if (!updated) return null; + } + const next: DesignSystemRevision = { + ...revision, + status, + updatedAt: new Date().toISOString(), + }; + await writeUserDesignSystemRevision(root, dirId, next); + return next; +} + +export async function deleteUserDesignSystem(root: string, id: string): Promise { + const dirId = stripPrefixAndValidateId(id, 'user:'); + if (!dirId) return false; + try { + await rm(path.join(root, dirId), { recursive: true, force: false }); + return true; + } catch { + return false; + } +} + +export async function listUserDesignSystemFiles( + root: string, + id: string, +): Promise { + const dirId = stripPrefixAndValidateId(id, 'user:'); + if (!dirId) return null; + const base = path.join(root, dirId); + try { + const baseStats = await stat(base); + if (!baseStats.isDirectory()) return null; + } catch { + return null; + } + await ensureGeneratedDesignSystemFiles(root, dirId); + const files: DesignSystemFileSummary[] = []; + await collectDesignSystemFiles(base, '', files); + return files.sort((a, b) => { + if (a.kind === 'folder' && b.kind !== 'folder') return -1; + if (a.kind !== 'folder' && b.kind === 'folder') return 1; + return a.path.localeCompare(b.path); + }); +} + +export async function readUserDesignSystemFile( + root: string, + id: string, + relativePath: string, +): Promise { + const dirId = stripPrefixAndValidateId(id, 'user:'); + const cleanPath = sanitizeRelativeFilePath(relativePath); + if (!dirId || !cleanPath) return null; + const base = path.join(root, dirId); + const resolvedBase = path.resolve(base); + const filePath = path.resolve(base, cleanPath); + if (filePath !== resolvedBase && !filePath.startsWith(`${resolvedBase}${path.sep}`)) + return null; + await ensureGeneratedDesignSystemFiles(root, dirId); + try { + const stats = await stat(filePath); + if (!stats.isFile()) return null; + const content = await readFile(filePath, 'utf8'); + return { + path: cleanPath, + name: path.basename(cleanPath), + kind: classifyDesignSystemFile(cleanPath, false), + size: stats.size, + updatedAt: stats.mtime.toISOString(), + content, + }; + } catch { + return null; + } +} + +async function ensureGeneratedDesignSystemFiles(root: string, id: string): Promise { + const metadata = await readUserMetadata(root, id); + await migrateLegacyDesignSystemPackage(root, id, metadata); + if (metadata.artifactMode === 'agent-managed') return; + try { + const existing = await stat(path.join(root, id, 'README.md')); + if (existing.isFile()) return; + } catch { + // Generate the derived review files below. + } + try { + const body = await readFile(path.join(root, id, 'DESIGN.md'), 'utf8'); + const title = normalizeTitle(metadata.title ?? firstHeading(body) ?? id); + const category = metadata.category ?? extractCategory(body) ?? 'Custom'; + const surface = metadata.surface ?? extractSurface(body) ?? 'web'; + await writeGeneratedDesignSystemFiles(root, id, { + title, + category, + surface, + summary: summarize(body), + ...(metadata.provenance ? { provenance: metadata.provenance } : {}), + ...(metadata.provenance ? { sourceNotes: provenanceToNotes(metadata.provenance) } : {}), + body, + }); + } catch { + // Listing/reading still returns whatever exists. + } +} + +async function migrateLegacyDesignSystemPackage( + root: string, + id: string, + metadata: UserDesignSystemMetadata, +): Promise { + const dir = path.join(root, id); + let body = ''; + try { + body = await readFile(path.join(dir, 'DESIGN.md'), 'utf8'); + } catch { + return; + } + const title = normalizeTitle(metadata.title ?? firstHeading(body) ?? id); + const summary = summarize(body) || 'A reusable Open Design design system.'; + const palette = normalizeSwatches(body); + const copyIfMissing = async (from: string, to: string): Promise => { + const fromPath = path.join(dir, ...from.split('/')); + const toPath = path.join(dir, ...to.split('/')); + try { + const existing = await stat(toPath); + if (existing.isFile()) return false; + } catch (err) { + if (!isAbsenceError(err)) throw err; + } + let content: Buffer; + try { + content = await readFile(fromPath); + } catch (err) { + if (isAbsenceError(err)) return false; + throw err; + } + await mkdir(path.dirname(toPath), { recursive: true }); + await writeFile(toPath, content); + return true; + }; + const writeIfMissing = async (relativePath: string, content: string): Promise => { + const target = path.join(dir, ...relativePath.split('/')); + try { + const existing = await stat(target); + if (existing.isFile()) return false; + } catch (err) { + if (!isAbsenceError(err)) throw err; + } + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content, 'utf8'); + return true; + }; + + const migratedArtifacts = await Promise.all([ + copyIfMissing('preview/colors-ui-palette.html', 'preview/colors-primary.html'), + copyIfMissing('preview/colors-node-types.html', 'preview/colors-theme-light.html'), + copyIfMissing('preview/colors-node-types.html', 'preview/colors-theme-dark.html'), + copyIfMissing('preview/typography-scale.html', 'preview/typography-specimens.html'), + copyIfMissing('preview/spacing-system.html', 'preview/spacing-tokens.html'), + copyIfMissing('preview/spacing-system.html', 'preview/spacing-radius.html'), + copyIfMissing('preview/spacing-system.html', 'preview/spacing-shadows.html'), + copyIfMissing('preview/logo-variants.html', 'preview/brand-assets.html'), + copyIfMissing('ui_kits/generated_interface/index.html', 'ui_kits/app/index.html'), + ]); + + const appKitExists = await fileExists(path.join(dir, 'ui_kits', 'app', 'index.html')); + const hasLegacyArtifacts = await hasAnyLegacyDesignSystemArtifact(dir); + if (!hasLegacyArtifacts && !migratedArtifacts.some(Boolean)) { + await rewriteLegacyPackageDocumentationReferences(dir); + if (appKitExists) await writeDefaultUiKitComponentsIfMissing(dir, title); + return; + } + + await Promise.all([ + writeIfMissing( + 'preview/components-buttons.html', + renderComponentCatalogHtml('Buttons', title, summary, palette), + ), + writeIfMissing( + 'preview/components-inputs.html', + renderComponentCatalogHtml('Inputs', title, summary, palette), + ), + appKitExists + ? writeIfMissing( + 'ui_kits/app/README.md', + `# ${title} UI Kit\n\nThis package was migrated from an earlier Open Design design-system workspace. Use \`index.html\` as the applied interface example and replace it with source-backed modular components when new repository evidence is available.\n`, + ) + : Promise.resolve(false), + appKitExists + ? writeDefaultUiKitComponentsIfMissing(dir, title) + : Promise.resolve(false), + ]); + await rewriteLegacyPackageDocumentationReferences(dir); + await removeLegacyDesignSystemArtifacts(dir); +} + +async function rewriteLegacyPackageDocumentationReferences(dir: string): Promise { + await Promise.all(['DESIGN.md', 'README.md', 'SKILL.md', 'ui_kits/app/README.md'].map(async (relativePath) => { + const target = path.join(dir, ...relativePath.split('/')); + const current = await readFileOptional(target); + if (current === undefined) return; + const next = rewriteLegacyPackageReferences(current); + if (next !== current) await writeFile(target, next, 'utf8'); + })); +} + +function rewriteLegacyPackageReferences(text: string): string { + return text + .replaceAll('preview/colors-ui-palette.html', 'preview/colors-primary.html') + .replaceAll('preview/colors-node-types.html', 'preview/colors-theme-light.html and preview/colors-theme-dark.html') + .replaceAll('preview/typography-scale.html', 'preview/typography-specimens.html') + .replaceAll('preview/spacing-system.html', 'preview/spacing-tokens.html, preview/spacing-radius.html, and preview/spacing-shadows.html') + .replaceAll('preview/logo-variants.html', 'preview/brand-assets.html') + .replaceAll('ui_kits/generated_interface/index.html', 'ui_kits/app/index.html') + .replaceAll('ui_kits/generated_interface/', 'ui_kits/app/') + .replaceAll('ui_kits/generated_interface', 'ui_kits/app'); +} + +async function writeDefaultUiKitComponentsIfMissing(dir: string, title: string): Promise { + const componentDir = path.join(dir, 'ui_kits', 'app', 'components'); + let wroteAny = false; + await mkdir(componentDir, { recursive: true }); + for (const { fileName, componentName, purpose } of defaultUiKitComponentSpecs()) { + const target = path.join(componentDir, fileName); + try { + const existing = await stat(target); + if (existing.isFile()) { + const current = await readFileOptional(target) ?? ''; + if (!isReplaceableUiKitScaffold(current)) continue; + } + } catch (err) { + if (!isAbsenceError(err)) throw err; + } + await writeFile(target, renderUiKitComponent(componentName, title, purpose), 'utf8'); + wroteAny = true; + } + return wroteAny; +} + +async function hasAnyLegacyDesignSystemArtifact(dir: string): Promise { + for (const artifact of LEGACY_DESIGN_SYSTEM_ARTIFACTS) { + try { + await stat(path.join(dir, ...artifact.legacyPath.split('/'))); + return true; + } catch (err) { + if (!isAbsenceError(err)) throw err; + } + } + return false; +} + +async function removeLegacyDesignSystemArtifacts(dir: string): Promise { + await Promise.all( + LEGACY_DESIGN_SYSTEM_ARTIFACTS.map(async (artifact) => { + const replacementReady = await Promise.all( + artifact.replacementPaths.map((replacementPath) => + fileExists(path.join(dir, ...replacementPath.split('/'))), + ), + ); + if (!replacementReady.every(Boolean)) return; + await rm(path.join(dir, ...artifact.legacyPath.split('/')), { + recursive: 'removeDirectory' in artifact && artifact.removeDirectory === true, + force: true, + }); + }), + ); +} + +async function fileExists(filePath: string): Promise { + try { + const existing = await stat(filePath); + return existing.isFile(); + } catch (err) { + if (isAbsenceError(err)) return false; + throw err; + } +} + +async function collectDesignSystemFiles( + base: string, + relativeDir: string, + files: DesignSystemFileSummary[], +): Promise { + const dir = path.join(base, relativeDir); + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + if (!relativeDir && (entry.name === 'metadata.json' || entry.name === 'revisions')) continue; + const relativePath = relativeDir + ? path.posix.join(relativeDir.replaceAll(path.sep, '/'), entry.name) + : entry.name; + const fullPath = path.join(base, relativePath); + const stats = await stat(fullPath); + files.push({ + path: relativePath, + name: entry.name, + kind: classifyDesignSystemFile(relativePath, entry.isDirectory()), + ...(entry.isDirectory() ? {} : { size: stats.size }), + updatedAt: stats.mtime.toISOString(), + }); + if (entry.isDirectory()) { + await collectDesignSystemFiles(base, relativePath, files); + } + } +} + +function sanitizeRelativeFilePath(raw: string): string | null { + if (typeof raw !== 'string') return null; + const trimmed = raw.trim().replace(/\\/g, '/'); + if (!trimmed || trimmed.includes('\0') || path.posix.isAbsolute(trimmed)) + return null; + const normalized = path.posix.normalize(trimmed); + if ( + normalized === '.' + || normalized === '..' + || normalized.startsWith('../') + || normalized.includes('/../') + ) { + return null; + } + return normalized; +} + +function classifyDesignSystemFile( + relativePath: string, + isDirectory: boolean, +): DesignSystemFileKind { + if (isDirectory) return 'folder'; + const ext = path.extname(relativePath).toLowerCase(); + if (ext === '.html') return 'page'; + if (ext === '.css') return 'stylesheet'; + if (ext === '.md') return 'document'; + if (ext === '.json') return 'data'; + if (['.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp'].includes(ext)) return 'image'; + return 'asset'; +} + +async function writeGeneratedDesignSystemFiles( + root: string, + id: string, + input: { + title: string; + category: string; + surface: DesignSystemSurface; + summary: string; + sourceNotes?: string; + provenance?: DesignSystemProvenance; + body: string; + }, +): Promise { + const dir = path.join(root, id); + await Promise.all([ + mkdir(path.join(dir, 'assets'), { recursive: true }), + mkdir(path.join(dir, 'context'), { recursive: true }), + mkdir(path.join(dir, 'preview'), { recursive: true }), + mkdir(path.join(dir, 'src', 'assets'), { recursive: true }), + mkdir(path.join(dir, 'src', 'components'), { recursive: true }), + mkdir(path.join(dir, 'ui_kits', 'app'), { recursive: true }), + mkdir(path.join(dir, 'ui_kits', 'app', 'components'), { recursive: true }), + ]); + + const palette = normalizeSwatches(input.body); + const summary = input.summary || 'A user-created Open Design design system.'; + const sections = extractMarkdownSections(input.body); + const provenance = input.provenance ?? normalizeProvenance(undefined, { + ...(input.sourceNotes ? { sourceNotes: input.sourceNotes } : {}), + }); + await Promise.all([ + writeFile( + path.join(dir, 'README.md'), + renderReadme({ ...input, summary, palette, sections }), + 'utf8', + ), + writeFile( + path.join(dir, 'SKILL.md'), + renderSkill({ ...input, summary, palette }), + 'utf8', + ), + writeFile( + path.join(dir, 'context', 'provenance.json'), + `${JSON.stringify(provenance ?? {}, null, 2)}\n`, + 'utf8', + ), + writeFile( + path.join(dir, 'context', 'provenance.md'), + renderProvenanceMarkdown(provenance, input.title), + 'utf8', + ), + writeFile( + path.join(dir, 'colors_and_type.css'), + renderCssTokens({ title: input.title, palette }), + 'utf8', + ), + writeFile( + path.join(dir, 'package.json'), + `${JSON.stringify( + { + name: slugify(input.title), + private: true, + type: 'module', + scripts: { + preview: 'open index.html', + }, + }, + null, + 2, + )}\n`, + 'utf8', + ), + writeFile(path.join(dir, 'assets', 'logo.svg'), renderLogoSvg(input.title, palette), 'utf8'), + writeFile( + path.join(dir, 'src', 'components', 'design-system-reference.tsx'), + renderReferenceComponent(input.title), + 'utf8', + ), + writeFile( + path.join(dir, 'src', 'assets', 'README.md'), + '# Assets\n\nPlace product screenshots, icons, logos, fonts, and brand references here.\n', + 'utf8', + ), + writeFile(path.join(dir, 'index.html'), renderOverviewHtml(input.title, summary, palette, sections), 'utf8'), + writeFile( + path.join(dir, 'preview', 'colors-primary.html'), + renderColorPreviewHtml('Primary Colors', palette), + 'utf8', + ), + writeFile( + path.join(dir, 'preview', 'colors-theme-light.html'), + renderColorPreviewHtml('Light Theme Palette', palette), + 'utf8', + ), + writeFile( + path.join(dir, 'preview', 'colors-theme-dark.html'), + renderColorPreviewHtml('Dark Theme Palette', { + ...palette, + background: palette.foreground, + foreground: '#ffffff', + muted: '#d6d6d6', + border: '#3f3f46', + }), + 'utf8', + ), + writeFile( + path.join(dir, 'preview', 'typography-specimens.html'), + renderTypographyPreviewHtml(input.title), + 'utf8', + ), + writeFile( + path.join(dir, 'preview', 'spacing-tokens.html'), + renderSpacingPreviewHtml('Spacing Tokens'), + 'utf8', + ), + writeFile( + path.join(dir, 'preview', 'spacing-radius.html'), + renderSpacingPreviewHtml('Border Radius'), + 'utf8', + ), + writeFile( + path.join(dir, 'preview', 'spacing-shadows.html'), + renderSpacingPreviewHtml('Shadow Elevation'), + 'utf8', + ), + writeFile( + path.join(dir, 'preview', 'components-buttons.html'), + renderComponentCatalogHtml('Buttons', input.title, summary, palette), + 'utf8', + ), + writeFile( + path.join(dir, 'preview', 'components-inputs.html'), + renderComponentCatalogHtml('Inputs', input.title, summary, palette), + 'utf8', + ), + writeFile( + path.join(dir, 'preview', 'brand-assets.html'), + renderLogoPreviewHtml(input.title, palette), + 'utf8', + ), + writeFile( + path.join(dir, 'ui_kits', 'app', 'index.html'), + renderComponentPreviewHtml(input.title, summary, palette), + 'utf8', + ), + writeFile( + path.join(dir, 'ui_kits', 'app', 'README.md'), + renderUiKitReadme(input.title), + 'utf8', + ), + ...defaultUiKitComponentSpecs().map(({ fileName, componentName, purpose }) => + writeFile( + path.join(dir, 'ui_kits', 'app', 'components', fileName), + renderUiKitComponent(componentName, input.title, purpose), + 'utf8', + ), + ), + ]); +} + +function defaultUiKitComponentSpecs(): Array<{ fileName: string; componentName: string; purpose: string }> { + return [ + { fileName: 'App.jsx', componentName: 'App', purpose: 'Composes the workspace shell, navigation rail, review content, and composer surface.' }, + { fileName: 'Sidebar.jsx', componentName: 'Sidebar', purpose: 'Defines the compact navigation rail and active-section rhythm.' }, + { fileName: 'AssistantsList.jsx', componentName: 'AssistantsList', purpose: 'Models the assistant, thread, or object list that anchors a product workspace.' }, + { fileName: 'ChatArea.jsx', componentName: 'ChatArea', purpose: 'Composes the main conversation or review workspace with a header, content stream, and empty state.' }, + { fileName: 'InputBar.jsx', componentName: 'InputBar', purpose: 'Models the primary composer with attachments, actions, and send affordances.' }, + { fileName: 'MessageBubble.jsx', componentName: 'MessageBubble', purpose: 'Captures reusable message, note, or review-comment surfaces with metadata and status.' }, + ]; +} + +function renderUiKitComponent(name: string, title: string, purpose: string): string { + if (name === 'App') return renderAppUiKitComponent(title); + if (name === 'Sidebar') return renderSidebarUiKitComponent(title); + if (name === 'AssistantsList') return renderAssistantsListUiKitComponent(title); + if (name === 'ChatArea') return renderChatAreaUiKitComponent(title); + if (name === 'InputBar') return renderInputBarUiKitComponent(title); + if (name === 'MessageBubble') return renderMessageBubbleUiKitComponent(title); + if (name === 'PreviewCard') return renderPreviewCardUiKitComponent(title); + if (name === 'Composer') return renderComposerUiKitComponent(title); + return `function ${name}({ children, title = '${escapeJsString(title)}' }) { + return ( +
+ ${escapeTsxText(purpose)} +

{title}

+
{children}
+
+ ); +} + +window.${name} = ${name}; +`; +} + +function isReplaceableUiKitScaffold(text: string): boolean { + return Buffer.byteLength(text, 'utf8') < 700 && /od-ui-kit-[a-z-]+/u.test(text); +} + +function renderAppUiKitComponent(title: string): string { + return `const reviewModules = [ + { id: 'colors', label: 'Color review', summary: 'Primary, theme, and semantic color cards' }, + { id: 'type', label: 'Typography review', summary: 'Specimens, scale, and dense metadata rhythm' }, + { id: 'components', label: 'Component review', summary: 'Buttons, inputs, cards, and feedback states' }, +]; + +const appStyles = { + shell: { display: 'grid', gridTemplateColumns: '280px minmax(240px, 300px) 1fr', minHeight: '720px', background: 'var(--color-background, #f7f8fa)', color: 'var(--color-text, #202124)' }, + workspace: { padding: '24px', display: 'grid', gap: '16px', alignContent: 'start' }, + card: { border: '1px solid var(--color-border, #dfe3e8)', borderRadius: 12, background: 'var(--color-surface, #fff)', padding: '16px' }, + eyebrow: { color: 'var(--color-text-secondary, #73777f)', fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0 }, +}; + +function App({ title = '${escapeJsString(title)}', modules = reviewModules, summary = 'Source-backed design-system workspace' }) { + const Sidebar = window.Sidebar; + const AssistantsList = window.AssistantsList; + const ChatArea = window.ChatArea; + return ( +
+ + +
+ Review surface +

{title}

+

{summary}

+ +
+ {modules.map((module) => ( +
+ {module.label} +

{module.summary}

+
+ ))} +
+
+
+ ); +} + +window.App = App; +`; +} + +function renderSidebarUiKitComponent(title: string): string { + return `const sidebarItems = [ + { id: 'design-system', label: 'Design System', badge: 'ready' }, + { id: 'design-files', label: 'Design Files', badge: '2' }, + { id: 'preview', label: 'Preview', badge: 'html' }, +]; + +const sidebarStyles = { + wrap: { width: 280, minHeight: 640, borderRight: '1px solid var(--color-border, #dfe3e8)', background: 'var(--color-background-soft, #fff)', padding: 16 }, + header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18 }, + mark: { width: 34, height: 34, borderRadius: 10, background: 'var(--color-primary, #00b96b)', color: '#fff', display: 'grid', placeItems: 'center', fontWeight: 700 }, + item: { display: 'grid', gridTemplateColumns: '1fr auto', gap: 10, alignItems: 'center', padding: '11px 12px', borderRadius: 10, marginBottom: 8, border: '1px solid transparent' }, + active: { borderColor: 'var(--color-primary, #00b96b)', background: 'var(--color-primary-soft, rgba(0,185,107,.1))' }, + badge: { fontSize: 11, color: 'var(--color-text-secondary, #73777f)' }, +}; + +function Sidebar({ title = '${escapeJsString(title)}', activeId = 'design-system', items = sidebarItems }) { + return ( + + ); +} + +window.Sidebar = Sidebar; +`; +} + +function renderAssistantsListUiKitComponent(title: string): string { + return `const assistantItems = [ + { id: 'default', name: '${escapeJsString(title)} reviewer', meta: 'Design review workspace', active: true }, + { id: 'tokens', name: 'Token specialist', meta: 'Colors, type, spacing, and states', active: false }, + { id: 'components', name: 'Component reviewer', meta: 'Cards, inputs, messages, and navigation', active: false }, +]; + +const assistantsListStyles = { + panel: { width: 280, borderRight: '1px solid var(--color-border, #dfe3e8)', background: 'var(--color-surface, #fff)', padding: 14, display: 'grid', alignContent: 'start', gap: 10 }, + header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }, + row: { display: 'grid', gridTemplateColumns: '32px 1fr', gap: 10, alignItems: 'center', padding: 10, borderRadius: 10, border: '1px solid transparent' }, + active: { borderColor: 'var(--color-primary, #00b96b)', background: 'var(--color-primary-soft, rgba(0,185,107,.1))' }, + avatar: { width: 32, height: 32, borderRadius: 10, background: 'var(--color-background-soft, #f7f8fa)', display: 'grid', placeItems: 'center', fontWeight: 700 }, + meta: { color: 'var(--color-text-secondary, #73777f)', fontSize: 12 }, +}; + +function AssistantsList({ items = assistantItems }) { + return ( + + ); +} + +window.AssistantsList = AssistantsList; +`; +} + +function renderChatAreaUiKitComponent(title: string): string { + return `const chatMessages = [ + { id: 'user', role: 'You', text: 'Create a compact review surface from the captured source evidence.' }, + { id: 'assistant', role: '${escapeJsString(title)}', text: 'The system uses focused preview cards, source-backed tokens, and reusable app-kit components.' }, +]; + +const chatAreaStyles = { + wrap: { minHeight: 640, background: 'var(--color-background, #f7f8fa)', display: 'grid', gridTemplateRows: 'auto 1fr auto' }, + header: { minHeight: 54, borderBottom: '1px solid var(--color-border, #dfe3e8)', padding: '0 18px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--color-surface, #fff)' }, + stream: { padding: 22, display: 'grid', alignContent: 'start', gap: 14, overflow: 'auto' }, + note: { border: '1px solid var(--color-border, #dfe3e8)', borderRadius: 12, background: 'var(--color-surface, #fff)', padding: 14 }, + composerSlot: { borderTop: '1px solid var(--color-border, #dfe3e8)', background: 'var(--color-surface, #fff)', padding: 16 }, +}; + +function ChatArea({ title = '${escapeJsString(title)} review', messages = chatMessages }) { + const InputBar = window.InputBar; + const MessageBubble = window.MessageBubble; + return ( +
+
+ {title} + +
+
+ {messages.map((message) => ( + + ))} +
+
+
+ ); +} + +window.ChatArea = ChatArea; +`; +} + +function renderInputBarUiKitComponent(title: string): string { + return `const inputActions = ['Attach', 'Source', 'Revise']; + +const inputBarStyles = { + wrap: { border: '1px solid var(--color-border, #dfe3e8)', borderRadius: 14, background: 'var(--color-surface, #fff)', padding: 12, display: 'grid', gap: 10 }, + field: { minHeight: 82, border: 0, outline: 0, resize: 'vertical', font: 'inherit', color: 'var(--color-text, #202124)' }, + toolbar: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }, + actions: { display: 'flex', flexWrap: 'wrap', gap: 8 }, + chip: { border: '1px solid var(--color-border, #dfe3e8)', borderRadius: 999, padding: '6px 10px', background: 'var(--color-background-soft, #f7f8fa)' }, + send: { border: 0, borderRadius: 10, padding: '9px 14px', background: 'var(--color-primary, #00b96b)', color: '#fff', fontWeight: 700 }, +}; + +function InputBar({ title = '${escapeJsString(title)} prompt', actions = inputActions }) { + return ( +
+ ` + : ``} + + `, + palette, + ); +} + +function renderLogoPreviewHtml(title: string, palette: GeneratedPalette): string { + return renderHtmlDocument( + 'Logo Variants', + `
+

Logo Variants

+

${escapeHtml(title)}

+
${renderLogoSvg(title, palette)}
+
${renderLogoSvg(title, { ...palette, background: palette.foreground, foreground: '#ffffff' })}
+
`, + palette, + ); +} + +function renderComponentPreviewHtml( + title: string, + summary: string, + palette: GeneratedPalette, +): string { + const componentScripts = [ + ...defaultUiKitComponentSpecs().filter((spec) => spec.componentName !== 'App'), + ...defaultUiKitComponentSpecs().filter((spec) => spec.componentName === 'App'), + ].map((spec) => ` `).join('\n'); + return ` + + + + + ${escapeHtml(title)} Interface + + + + + + + +
Loading ${escapeHtml(title)} UI kit...
+${componentScripts} + + + +`; +} + +function renderHtmlDocument(title: string, body: string, palette: GeneratedPalette): string { + return ` + + + + + ${escapeHtml(title)} + + +${body} + +`; +} + +function renderSwatch(name: string, value: string): string { + return `
${escapeHtml(name)}${escapeHtml(value)}
`; +} + +function escapeHtml(raw: string): string { + return raw + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function scriptJson(raw: string): string { + return JSON.stringify(raw) + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026'); +} + +function escapeTsxText(raw: string): string { + return raw.replace(/[{}<>]/g, ''); +} + +function escapeJsString(raw: string): string { + return raw.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + async function readFileOptional(file: string): Promise { try { return await readFile(file, 'utf8'); } catch (err) { - // Only swallow "file genuinely does not exist" failures. Today the - // ~138 brands without hand-authored or derived tokens.css / - // components.html siblings hit this path on the empty side, which - // is the legacy fallback we deliberately preserve. Every other - // failure mode — permission denied (`EACCES`), parent-shadowed by - // a non-directory (`EPERM`), a directory at the file path - // (`EISDIR`), a broken packaged-resource symlink, a transient I/O - // error — means the token channel is misconfigured; the caller - // (and the smoke-test rollout) needs to see that explicitly - // instead of silently degrading to the DESIGN.md-only prompt and - // making the experiment look ineffective for the wrong reason. if (isAbsenceError(err)) return undefined; throw err; } @@ -362,8 +2359,9 @@ function summarize(raw: string): string { const nextHeading = afterH1.findIndex((l) => /^#{1,6}\s+/.test(l)); const window = (nextHeading === -1 ? afterH1 : afterH1.slice(0, nextHeading)) .join('\n') - // Drop the Category metadata line — it's surfaced separately. + // Drop blockquote metadata lines — they are surfaced separately. .replace(/^>\s*Category:.*$/gim, '') + .replace(/^>\s*Surface:.*$/gim, '') .replace(/^>\s*/gm, '') .trim(); return window.split(/\n\n/)[0]?.slice(0, 240) ?? ''; @@ -375,6 +2373,12 @@ function extractCategory(raw: string): string | undefined { } const KNOWN_SURFACES = new Set(['web', 'image', 'video', 'audio']); +const KNOWN_STATUSES = new Set(['draft', 'published']); +const KNOWN_REVISION_STATUSES = new Set([ + 'pending', + 'accepted', + 'rejected', +]); function extractSurface(raw: string): DesignSystemSurface | undefined { const m = /^>\s*Surface:\s*(.+?)\s*$/im.exec(raw); if (!m) return undefined; @@ -386,6 +2390,16 @@ function isDesignSystemSurface(value: string | undefined): value is DesignSystem return value !== undefined && KNOWN_SURFACES.has(value as DesignSystemSurface); } +function isDesignSystemStatus(value: string | undefined): value is DesignSystemStatus { + return value !== undefined && KNOWN_STATUSES.has(value as DesignSystemStatus); +} + +function isDesignSystemRevisionStatus( + value: string | undefined, +): value is DesignSystemRevisionStatus { + return value !== undefined && KNOWN_REVISION_STATUSES.has(value as DesignSystemRevisionStatus); +} + // Strip boilerplate like "Design System Inspired by Cohere" → "Cohere" so // the picker dropdown reads cleanly. Hand-authored titles that don't match // the pattern (e.g. "Neutral Modern") pass through unchanged. diff --git a/apps/daemon/src/import-export-routes.ts b/apps/daemon/src/import-export-routes.ts index 0cd296750..933c18608 100644 --- a/apps/daemon/src/import-export-routes.ts +++ b/apps/daemon/src/import-export-routes.ts @@ -7,7 +7,7 @@ import { type InlineAssetReader, } from './inline-assets.js'; -export interface RegisterImportRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'ids' | 'paths' | 'imports' | 'auth' | 'projectStore' | 'conversations' | 'projectFiles'> {} +export interface RegisterImportRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'ids' | 'paths' | 'imports' | 'auth' | 'projectStore' | 'conversations' | 'projectFiles' | 'validation'> {} export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps) { const { db } = ctx; @@ -27,6 +27,7 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps const { insertProject } = ctx.projectStore; const { insertConversation } = ctx.conversations; const { setTabs } = ctx.projectFiles; + const { validateProjectDesignSystemId } = ctx.validation; app.post( '/api/import/claude-design', importUpload.single('file'), @@ -185,12 +186,21 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps ? name.trim() : path.basename(normalizedPath); const entryFile = await detectEntryFile(normalizedPath); + const designSystemValidation = await validateProjectDesignSystemId(designSystemId); + if (!designSystemValidation.ok) { + return sendApiError( + res, + 400, + designSystemValidation.code, + designSystemValidation.message, + ); + } const project = insertProject(db, { id, name: projectName, skillId: skillId ?? null, - designSystemId: designSystemId ?? null, + designSystemId: designSystemValidation.id, pendingPrompt: null, metadata: { kind: 'prototype', diff --git a/apps/daemon/src/project-routes.ts b/apps/daemon/src/project-routes.ts index 9dcff9440..558db37dd 100644 --- a/apps/daemon/src/project-routes.ts +++ b/apps/daemon/src/project-routes.ts @@ -14,8 +14,9 @@ import { } from './plugins/index.js'; import type { RouteDeps } from './server-context.js'; import { listSkills } from './skills.js'; +import { auditDesignSystemPackage } from './tools-connectors-cli.js'; -export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry'> {} +export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'validation'> {} export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDeps) { const { db, design } = ctx; @@ -28,6 +29,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe const { listLatestProjectRunStatuses, listProjectsAwaitingInput, normalizeProjectDisplayStatus, composeProjectDisplayStatus, listProjects } = ctx.status; const { subscribeFileEvents, activeProjectEventSinks } = ctx.events; const { randomId } = ctx.ids; + const { validateProjectDesignSystemId } = ctx.validation; async function loadPluginRegistryView() { const [skills, designSystems] = await Promise.all([ listSkills(SKILLS_DIR), @@ -166,6 +168,16 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe if (skipDiscoveryBrief !== undefined && typeof skipDiscoveryBrief !== 'boolean') { return sendApiError(res, 400, 'BAD_REQUEST', 'skipDiscoveryBrief must be a boolean'); } + const designSystemValidation = await validateProjectDesignSystemId(designSystemId); + if (!designSystemValidation.ok) { + return sendApiError( + res, + 400, + designSystemValidation.code, + designSystemValidation.message, + ); + } + const normalizedDesignSystemId = designSystemValidation.id; const projectMetadata = metadata && typeof metadata === 'object' ? { @@ -186,7 +198,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe id, name: name.trim(), skillId: skillId ?? null, - designSystemId: designSystemId ?? null, + designSystemId: normalizedDesignSystemId, pendingPrompt: pendingPrompt || null, metadata: projectMetadata, customInstructions: @@ -231,8 +243,8 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe conversationId: cid, registry, activeProjectDesignSystem: - typeof designSystemId === 'string' && designSystemId.length > 0 - ? { id: designSystemId } + typeof normalizedDesignSystemId === 'string' && normalizedDesignSystemId.length > 0 + ? { id: normalizedDesignSystemId } : undefined, }); if (resolved && !resolved.ok) { @@ -306,7 +318,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe res.json(body); }); - app.patch('/api/projects/:id', (req, res) => { + app.patch('/api/projects/:id', async (req, res) => { try { const patch = req.body || {}; // baseDir / folder-import state is privileged: it's set only by the @@ -377,6 +389,18 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe if (typeof patch.customInstructions === 'string' && patch.customInstructions.length > 5000) { return sendApiError(res, 400, 'BAD_REQUEST', 'customInstructions exceeds 5 000 character limit'); } + if (Object.prototype.hasOwnProperty.call(patch, 'designSystemId')) { + const designSystemValidation = await validateProjectDesignSystemId(patch.designSystemId); + if (!designSystemValidation.ok) { + return sendApiError( + res, + 400, + designSystemValidation.code, + designSystemValidation.message, + ); + } + patch.designSystemId = designSystemValidation.id; + } const project = updateProject(db, req.params.id, patch); if (!project) return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); @@ -781,7 +805,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile const { upload } = ctx.uploads; const { fs } = ctx.node; const { getProject } = ctx.projectStore; - const { listFiles, searchProjectFiles, readProjectFile, resolveProjectFilePath, parseByteRange, renameProjectFile, deleteProjectFile, writeProjectFile, sanitizeName, ensureProject } = ctx.projectFiles; + const { listFiles, searchProjectFiles, readProjectFile, resolveProjectDir, resolveProjectFilePath, parseByteRange, renameProjectFile, deleteProjectFile, writeProjectFile, sanitizeName, ensureProject } = ctx.projectFiles; const { buildDocumentPreview } = ctx.documents; const { validateArtifactManifestInput } = ctx.artifacts; @@ -826,6 +850,22 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile } }); + app.get('/api/projects/:id/design-system-package-audit', async (req, res) => { + try { + const project = getProject(db, req.params.id); + if (!project) { + sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + return; + } + const projectRoot = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata); + const audit = await auditDesignSystemPackage(projectRoot); + res.setHeader('Cache-Control', 'no-store'); + res.json({ audit }); + } catch (err: any) { + sendApiError(res, 400, 'BAD_REQUEST', String(err)); + } + }); + // Preflight for the raw file route. Current artifact fetches are simple GETs // (no preflight needed), but an explicit handler future-proofs the route if diff --git a/apps/daemon/src/prompts/discovery.ts b/apps/daemon/src/prompts/discovery.ts index d462f3d83..95ed7e57e 100644 --- a/apps/daemon/src/prompts/discovery.ts +++ b/apps/daemon/src/prompts/discovery.ts @@ -28,6 +28,13 @@ You are an expert designer working with the user as your manager. You produce de Three hard rules govern the start of every new design task. They are not optional. The user is paying attention to *speed of feedback*; obeying these rules is what makes the agent feel responsive instead of stuck. +Active design system exception: if a later section in this same system prompt is titled \`## Active design system\`, the user has already selected the brand and visual direction. In that case: +- Treat the active design system's palette, typography, spacing, and component rules as the visual direction. +- Do not ask the user to pick a separate theme color, visual direction, palette, typography mood, or direction card. +- Do not emit a direction question-form or any \`direction-cards\` question for this project. +- In the turn-1 discovery form, drop brand/direction/theme-color questions unless the user explicitly asks to switch away from the active design system. +- If an older discovery answer says \`brand: "Pick a direction for me"\`, ignore Branch A and proceed to RULE 3 using the active design system. + --- ## RULE 1 — turn 1 must emit a \`\` (not tools, not thinking) diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 396e73450..ce4e38fe9 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -63,10 +63,20 @@ import { buildWindowsFolderDialogCommand, parseFolderDialogStdout } from './nati import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js'; import { syncCommunityPets } from './community-pets-sync.js'; import { + createUserDesignSystem, + deleteUserDesignSystem, + LEGACY_DESIGN_SYSTEM_ARTIFACTS, + linkUserDesignSystemProject, listDesignSystems, + listUserDesignSystemFiles, + listUserDesignSystemRevisions, readDesignSystem, + readUserDesignSystemFile, resolveDesignSystemAssets, + updateUserDesignSystem, + updateUserDesignSystemRevisionStatus, } from './design-systems.js'; +import { createDesignSystemGenerationJobStore } from './design-system-generation-jobs.js'; import { applyDiffReviewDecisionToCwd, applyPlugin, @@ -1169,6 +1179,9 @@ for (const dir of [USER_SKILLS_DIR, USER_DESIGN_SYSTEMS_DIR, USER_DESIGN_TEMPLAT } fs.mkdirSync(CRITIQUE_ARTIFACTS_DIR, { recursive: true }); const orbitService = new OrbitService(RUNTIME_DATA_DIR); +const designSystemGenerationJobs = createDesignSystemGenerationJobStore({ + root: USER_DESIGN_SYSTEMS_DIR, +}); let routineService = null; // In-memory OAuth state cache. Lives for the daemon process's lifetime. @@ -2589,17 +2602,206 @@ export async function startServer({ const builtIn = (await listDesignSystems(DESIGN_SYSTEMS_DIR)).map((s) => ({ ...s, source: 'built-in', + isEditable: false, + status: 'published', })); let installed = []; try { - installed = (await listDesignSystems(USER_DESIGN_SYSTEMS_DIR)).map( - (s) => ({ ...s, source: 'installed' }), - ); + installed = await listDesignSystems(USER_DESIGN_SYSTEMS_DIR, { + idPrefix: 'user:', + source: 'user', + isEditable: true, + defaultStatus: 'draft', + }); } catch { // User directory may not exist yet or be unreadable. } const seen = new Set(builtIn.map((s) => s.id)); - return [...builtIn, ...installed.filter((s) => !seen.has(s.id))]; + return [ + ...installed + .filter((s) => s.source === 'user') + .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')), + ...builtIn, + ...installed.filter((s) => s.source !== 'user' && !seen.has(s.id)), + ]; + } + + async function readAvailableDesignSystem(id) { + if (typeof id === 'string' && id.startsWith('user:')) { + return readDesignSystem(USER_DESIGN_SYSTEMS_DIR, id, { idPrefix: 'user:' }); + } + return ( + (await readDesignSystem(DESIGN_SYSTEMS_DIR, id)) + ?? (await readDesignSystem(USER_DESIGN_SYSTEMS_DIR, id)) + ); + } + + function isProjectUsableDesignSystem(summary) { + return summary?.status !== 'draft'; + } + + async function validateProjectDesignSystemId(id) { + if (id === undefined || id === null || id === '') return { ok: true, id: null }; + if (typeof id !== 'string') { + return { + ok: false, + code: 'INVALID_DESIGN_SYSTEM', + message: 'designSystemId must be a string or null', + }; + } + const systems = await listAllDesignSystems(); + const summary = systems.find((system) => system.id === id); + if (!summary) { + return { + ok: false, + code: 'DESIGN_SYSTEM_NOT_FOUND', + message: 'design system not found', + }; + } + if (!isProjectUsableDesignSystem(summary)) { + return { + ok: false, + code: 'DESIGN_SYSTEM_NOT_PUBLISHED', + message: 'draft design systems cannot be used by projects', + }; + } + return { ok: true, id }; + } + + function userDesignSystemWorkspaceProjectId(id) { + if (typeof id !== 'string' || !id.startsWith('user:')) return null; + const dirId = id.slice('user:'.length); + if (!/^[A-Za-z0-9._-]{1,120}$/.test(dirId)) return null; + return `ds-${dirId}`.slice(0, 128); + } + + function projectBackedDesignSystemProjectId(id, summary) { + if (typeof summary?.projectId === 'string' && isSafeId(summary.projectId)) { + return summary.projectId; + } + return userDesignSystemWorkspaceProjectId(id); + } + + async function ensureUserDesignSystemWorkspaceProject(db, id) { + const systems = await listAllDesignSystems(); + const summary = systems.find((s) => s.id === id && s.source === 'user'); + if (!summary) return null; + const projectId = projectBackedDesignSystemProjectId(id, summary); + if (!projectId) return null; + + const now = Date.now(); + const metadata = { + kind: 'other', + importedFrom: 'design-system', + entryFile: 'DESIGN.md', + sourceFileName: id, + }; + const existing = getProject(db, projectId); + const project = existing + ? updateProject(db, projectId, { + name: summary.title, + designSystemId: id, + metadata: { ...existing.metadata, ...metadata }, + updatedAt: now, + }) + : insertProject(db, { + id: projectId, + name: summary.title, + skillId: null, + designSystemId: id, + pendingPrompt: null, + metadata, + createdAt: now, + updatedAt: now, + }); + if (!project) return null; + + const files = await listUserDesignSystemFiles(USER_DESIGN_SYSTEMS_DIR, id); + if (!files) return null; + for (const file of files) { + if (file.kind === 'folder') continue; + const detail = await readUserDesignSystemFile(USER_DESIGN_SYSTEMS_DIR, id, file.path); + if (!detail) continue; + if (existing) { + try { + const existingFile = await readProjectFile(PROJECTS_DIR, projectId, detail.path, project.metadata); + if (!isReplaceableDesignSystemWorkspaceFile(detail.path, existingFile)) continue; + } catch (err) { + if (!err || err.code !== 'ENOENT') throw err; + } + } + await writeProjectFile( + PROJECTS_DIR, + projectId, + detail.path, + Buffer.from(detail.content, 'utf8'), + {}, + project.metadata, + ); + } + await removeLegacyDesignSystemWorkspaceArtifacts(project); + await linkUserDesignSystemProject(USER_DESIGN_SYSTEMS_DIR, id, project.id); + const projectFiles = await listFiles(PROJECTS_DIR, projectId, { metadata: project.metadata }); + return { project, files: projectFiles }; + } + + function isReplaceableDesignSystemWorkspaceFile(filePath, file) { + const buffer = file?.buffer; + if (!Buffer.isBuffer(buffer)) return false; + const text = buffer.toString('utf8'); + if (/^ui_kits\/app\/components\/.+\.(jsx|tsx|js|ts|css|html)$/u.test(filePath)) { + return buffer.length < 700 && /od-ui-kit-[a-z-]+/u.test(text); + } + if (!/^(DESIGN\.md|README\.md|SKILL\.md|ui_kits\/app\/README\.md)$/u.test(filePath)) { + return false; + } + return hasLegacyDesignSystemPackageReferences(text); + } + + function hasLegacyDesignSystemPackageReferences(text) { + return /preview\/(colors-node-types|colors-ui-palette|typography-scale|spacing-system|logo-variants)\.html|ui_kits\/generated_interface(?:\/index\.html|\/)?/u.test(text); + } + + async function removeLegacyDesignSystemWorkspaceArtifacts(project) { + if (project?.metadata?.importedFrom !== 'design-system') return; + const dir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata); + for (const artifact of LEGACY_DESIGN_SYSTEM_ARTIFACTS) { + const replacementReady = await Promise.all( + artifact.replacementPaths.map(async (replacementPath) => { + try { + const stats = await fs.promises.stat(path.join(dir, ...replacementPath.split('/'))); + return stats.isFile(); + } catch (err) { + if (!err || (err.code !== 'ENOENT' && err.code !== 'ENOTDIR')) throw err; + return false; + } + }), + ); + if (!replacementReady.every(Boolean)) continue; + await fs.promises.rm(path.join(dir, ...artifact.legacyPath.split('/')), { + recursive: artifact.removeDirectory === true, + force: true, + }); + } + } + + async function readDesignSystemWorkspaceTextFile(db, summary, filePath) { + if (!summary?.projectId || !isSafeId(summary.projectId)) return null; + const project = getProject(db, summary.projectId); + if (!project) return null; + try { + const file = await readProjectFile( + PROJECTS_DIR, + project.id, + filePath, + project.metadata, + ); + const text = file.buffer.toString('utf8'); + if (text.includes('\0')) return null; + return text; + } catch { + return null; + } } // Chrome may strip the port from the Origin header on same-origin GET @@ -3632,7 +3834,7 @@ export async function startServer({ isFinalizeProviderProtocol, redactSecrets, }; - const validationDeps = { isSafeId, validateExternalApiBaseUrl, validateBaseUrl }; + const validationDeps = { isSafeId, validateExternalApiBaseUrl, validateBaseUrl, validateProjectDesignSystemId }; const agentDeps = { listProviderModels, testProviderConnection, @@ -3678,6 +3880,7 @@ export async function startServer({ events: projectEventDeps, ids: idDeps, telemetry: { reportFinalizedMessage }, + validation: validationDeps, }); registerImportRoutes(app, { db, @@ -3691,6 +3894,7 @@ export async function startServer({ projectStore: projectStoreDeps, conversations: conversationDeps, projectFiles: projectFileDeps, + validation: validationDeps, }); // Resource catalog @@ -4191,7 +4395,7 @@ export async function startServer({ app.get('/api/design-systems', async (_req, res) => { try { - const systems = await listDesignSystems(DESIGN_SYSTEMS_DIR); + const systems = await listAllDesignSystems(); res.json({ designSystems: systems.map(({ body, ...rest }) => rest), }); @@ -4200,12 +4404,165 @@ export async function startServer({ } }); + app.post('/api/design-systems', async (req, res) => { + try { + const created = await createUserDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.body || {}); + res.status(201).json({ ...created, designSystem: created }); + } catch (err) { + res.status(400).json({ error: String(err) }); + } + }); + + app.post('/api/design-systems/generation-jobs', async (req, res) => { + try { + const job = designSystemGenerationJobs.start(req.body || {}); + res.status(202).json({ job }); + } catch (err) { + res.status(400).json({ error: String(err) }); + } + }); + + app.get('/api/design-systems/generation-jobs/:jobId', async (req, res) => { + try { + const job = designSystemGenerationJobs.get(req.params.jobId); + if (!job) { + return res.status(404).json({ error: 'design system generation job not found' }); + } + res.json({ job }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } + }); + + app.post('/api/design-systems/:id/revision-jobs', async (req, res) => { + try { + const feedback = typeof req.body?.feedback === 'string' ? req.body.feedback : ''; + if (!feedback.trim()) return res.status(400).json({ error: 'feedback is required' }); + const job = designSystemGenerationJobs.revise({ + designSystemId: req.params.id, + feedback, + sectionTitle: typeof req.body?.sectionTitle === 'string' ? req.body.sectionTitle : undefined, + body: typeof req.body?.body === 'string' ? req.body.body : undefined, + }); + res.status(202).json({ job }); + } catch (err) { + res.status(400).json({ error: String(err) }); + } + }); + + app.get('/api/design-systems/:id/revisions', async (req, res) => { + try { + const revisions = await listUserDesignSystemRevisions( + USER_DESIGN_SYSTEMS_DIR, + req.params.id, + ); + if (!revisions) { + return res.status(404).json({ error: 'editable design system not found' }); + } + res.json({ revisions }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } + }); + + app.patch('/api/design-systems/:id/revisions/:revisionId', async (req, res) => { + try { + const status = typeof req.body?.status === 'string' ? req.body.status : ''; + if (status !== 'accepted' && status !== 'rejected') { + return res.status(400).json({ error: 'status must be accepted or rejected' }); + } + const revision = await updateUserDesignSystemRevisionStatus( + USER_DESIGN_SYSTEMS_DIR, + req.params.id, + req.params.revisionId, + status, + ); + if (!revision) { + return res.status(404).json({ error: 'design system revision not found' }); + } + res.json({ revision }); + } catch (err) { + res.status(400).json({ error: String(err) }); + } + }); + app.get('/api/design-systems/:id', async (req, res) => { try { - const body = await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id); - if (body === null) + const systems = await listAllDesignSystems(); + const summary = systems.find((s) => s.id === req.params.id); + const projectBody = await readDesignSystemWorkspaceTextFile(db, summary, 'DESIGN.md'); + const body = projectBody ?? await readAvailableDesignSystem(req.params.id); + if (body === null || !summary) return res.status(404).json({ error: 'design system not found' }); - res.json({ id: req.params.id, body }); + const detail = { ...summary, body }; + res.json({ ...detail, designSystem: detail }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } + }); + + app.post('/api/design-systems/:id/workspace', async (req, res) => { + try { + const workspace = await ensureUserDesignSystemWorkspaceProject(db, req.params.id); + if (!workspace) { + return res.status(404).json({ error: 'editable design system not found' }); + } + res.status(201).json(workspace); + } catch (err) { + res.status(400).json({ error: String(err) }); + } + }); + + app.get('/api/design-systems/:id/files', async (req, res) => { + try { + const files = await listUserDesignSystemFiles(USER_DESIGN_SYSTEMS_DIR, req.params.id); + if (!files) { + return res.status(404).json({ error: 'editable design system not found' }); + } + res.json({ files }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } + }); + + app.get('/api/design-systems/:id/file', async (req, res) => { + try { + const requestedPath = typeof req.query.path === 'string' ? req.query.path : ''; + const file = await readUserDesignSystemFile( + USER_DESIGN_SYSTEMS_DIR, + req.params.id, + requestedPath, + ); + if (!file) return res.status(404).json({ error: 'design system file not found' }); + res.json({ file }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } + }); + + app.patch('/api/design-systems/:id', async (req, res) => { + try { + const updated = await updateUserDesignSystem( + USER_DESIGN_SYSTEMS_DIR, + req.params.id, + req.body || {}, + ); + if (!updated) { + return res.status(404).json({ error: 'editable design system not found' }); + } + res.json({ ...updated, designSystem: updated }); + } catch (err) { + res.status(400).json({ error: String(err) }); + } + }); + + app.delete('/api/design-systems/:id', async (req, res) => { + try { + const ok = await deleteUserDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.params.id); + if (!ok) { + return res.status(404).json({ error: 'editable design system not found' }); + } + res.status(204).end(); } catch (err) { res.status(500).json({ error: String(err) }); } @@ -5786,7 +6143,7 @@ export async function startServer({ // file shows up on the next view, no rebuild needed. app.get('/api/design-systems/:id/preview', async (req, res) => { try { - const body = await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id); + const body = await readAvailableDesignSystem(req.params.id); if (body === null) return res.status(404).type('text/plain').send('not found'); const html = renderDesignSystemPreview(req.params.id, body); @@ -5801,7 +6158,7 @@ export async function startServer({ // /preview: built at request time, no caching. app.get('/api/design-systems/:id/showcase', async (req, res) => { try { - const body = await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id); + const body = await readAvailableDesignSystem(req.params.id); if (body === null) return res.status(404).type('text/plain').send('not found'); const html = renderDesignSystemShowcase(req.params.id, body); @@ -7735,25 +8092,34 @@ export async function startServer({ let designSystemComponentsManifest; let designSystemFixtureHtml; if (effectiveDesignSystemId) { - const systems = await listAllDesignSystems(); - const summary = systems.find((s) => s.id === effectiveDesignSystemId); + let systems = await listAllDesignSystems(); + let summary = systems.find((s) => s.id === effectiveDesignSystemId); + if (summary?.source === 'user') { + await ensureUserDesignSystemWorkspaceProject(db, effectiveDesignSystemId); + systems = await listAllDesignSystems(); + summary = systems.find((s) => s.id === effectiveDesignSystemId); + } + const editingOwnDraftDesignSystem = + project?.metadata?.importedFrom === 'design-system' + && project.designSystemId === effectiveDesignSystemId; designSystemTitle = summary?.title; - designSystemBody = - (await readDesignSystem(DESIGN_SYSTEMS_DIR, effectiveDesignSystemId)) ?? - (await readDesignSystem(USER_DESIGN_SYSTEMS_DIR, effectiveDesignSystemId)) ?? - undefined; - // Single seam: env gate + built-in→user-installed fallback chain - // live together inside `resolveDesignSystemAssets` so the whole - // server-side asset-resolution path can be tested end-to-end - // from real disk fixtures (see `tests/design-system-assets.test.ts`). - const assets = await resolveDesignSystemAssets( - effectiveDesignSystemId, - DESIGN_SYSTEMS_DIR, - USER_DESIGN_SYSTEMS_DIR, - ); - designSystemTokensCss = assets.tokensCss; - designSystemComponentsManifest = assets.componentsManifest; - designSystemFixtureHtml = assets.fixtureHtml; + if (summary && (isProjectUsableDesignSystem(summary) || editingOwnDraftDesignSystem)) { + const workspaceBody = await readDesignSystemWorkspaceTextFile(db, summary, 'DESIGN.md'); + const registryBody = await readAvailableDesignSystem(effectiveDesignSystemId); + designSystemBody = (workspaceBody ?? registryBody) ?? undefined; + // Single seam: env gate + built-in→user-installed fallback chain + // live together inside `resolveDesignSystemAssets` so the whole + // server-side asset-resolution path can be tested end-to-end + // from real disk fixtures (see `tests/design-system-assets.test.ts`). + const assets = await resolveDesignSystemAssets( + effectiveDesignSystemId, + DESIGN_SYSTEMS_DIR, + USER_DESIGN_SYSTEMS_DIR, + ); + designSystemTokensCss = assets.tokensCss; + designSystemComponentsManifest = assets.componentsManifest; + designSystemFixtureHtml = assets.fixtureHtml; + } } const template = diff --git a/apps/daemon/src/static-resource-routes.ts b/apps/daemon/src/static-resource-routes.ts index 80166de9f..3e1d70cf0 100644 --- a/apps/daemon/src/static-resource-routes.ts +++ b/apps/daemon/src/static-resource-routes.ts @@ -301,17 +301,11 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe } }); - app.get('/api/design-systems/:id', async (req, res) => { - try { - const body = - (await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id)) ?? - (await readDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.params.id)); - if (body === null) - return res.status(404).json({ error: 'design system not found' }); - res.json({ id: req.params.id, body }); - } catch (err: any) { - res.status(500).json({ error: String(err) }); - } + app.get('/api/design-systems/:id', (_req, _res, next) => { + // The design-system workflow owns the detail shape now because user-created + // systems may be backed by a review workspace project. Let the richer route + // registered in server.ts answer this request. + next(); }); app.get('/api/prompt-templates', async (_req, res) => { @@ -344,35 +338,15 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe // samples, sample components, and the full DESIGN.md rendered as prose. // Built at request time from the on-disk DESIGN.md so any update to the // file shows up on the next view, no rebuild needed. - app.get('/api/design-systems/:id/preview', async (req, res) => { - try { - const body = - (await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id)) ?? - (await readDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.params.id)); - if (body === null) - return res.status(404).type('text/plain').send('not found'); - const html = renderDesignSystemPreview(req.params.id, body); - res.type('text/html').send(html); - } catch (err: any) { - res.status(500).type('text/plain').send(String(err)); - } + app.get('/api/design-systems/:id/preview', (_req, _res, next) => { + next(); }); // Marketing-style showcase derived from the same DESIGN.md — full landing // page parameterised by the system's tokens. Same lazy-render strategy as // /preview: built at request time, no caching. - app.get('/api/design-systems/:id/showcase', async (req, res) => { - try { - const body = - (await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id)) ?? - (await readDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.params.id)); - if (body === null) - return res.status(404).type('text/plain').send('not found'); - const html = renderDesignSystemShowcase(req.params.id, body); - res.type('text/html').send(html); - } catch (err: any) { - res.status(500).type('text/plain').send(String(err)); - } + app.get('/api/design-systems/:id/showcase', (_req, _res, next) => { + next(); }); // Pre-built example HTML for a skill — what a typical artifact from this @@ -719,8 +693,11 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe } }); - app.delete('/api/design-systems/:id', async (req, res) => { + app.delete('/api/design-systems/:id', async (req, res, next) => { if (!requireLocalOrigin(req, res)) return; + if (req.params.id.startsWith('user:')) { + return next(); + } try { const result = await uninstallById( req.params.id, diff --git a/apps/daemon/src/tools-connectors-cli.ts b/apps/daemon/src/tools-connectors-cli.ts index 131733cab..53cd8eb58 100644 --- a/apps/daemon/src/tools-connectors-cli.ts +++ b/apps/daemon/src/tools-connectors-cli.ts @@ -1,4 +1,6 @@ -import { readFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; type JsonObject = Record; @@ -20,6 +22,14 @@ interface ParsedOptions { connectorId?: string; toolName?: string; inputPath?: string; + localPath?: string; + repo?: string; + ref?: string; + outputPath?: string; + maxFiles?: number; + requireConnector?: boolean; + referencePackage?: boolean; + failOnWarnings?: boolean; useCase?: 'personal_daily_digest'; format: 'compact' | 'json'; help: boolean; @@ -28,6 +38,9 @@ interface ParsedOptions { const CONNECTORS_USAGE = `Usage: od tools connectors list [--use-case personal_daily_digest] [--format compact] od tools connectors execute --connector --tool --input input.json + od tools connectors github-design-context --repo owner/repo [--ref main] [--output context/github/owner-repo.md] [--max-files 48] [--require-connector] + od tools connectors local-design-context --path /path/to/project [--output context/local-code/project.md] [--max-files 48] + od tools connectors design-system-package-audit --path /path/to/project [--reference-package] [--fail-on-warnings] Environment: OD_NODE_BIN Node-compatible runtime for agent wrapper invocations @@ -39,6 +52,122 @@ Agent runtime invocation: "$OD_NODE_BIN" "$OD_BIN" tools connectors list --use-case personal_daily_digest --format compact `; +const GITHUB_CONNECTOR_ID = 'github'; +const GITHUB_GET_REPOSITORY_TOOL = 'github.github_get_a_repository'; +const GITHUB_GET_TREE_TOOL = 'github.github_get_a_tree'; +const GITHUB_GET_README_TOOL = 'github.github_get_a_repository_readme'; +const GITHUB_GET_RAW_CONTENT_TOOL = 'github.github_get_raw_repository_content'; +const GITHUB_GET_REPOSITORY_CONTENT_TOOL = 'github.github_get_repository_content'; + +const DEFAULT_GITHUB_CONTEXT_MAX_FILES = 48; +const MAX_GITHUB_CONTEXT_FILES = 80; +const DEFAULT_LOCAL_CONTEXT_MAX_FILES = 64; +const MAX_LOCAL_CONTEXT_FILES = 120; +const MAX_CONTEXT_FILE_BYTES = 120_000; +const MAX_CONTEXT_ASSET_BYTES = 1_500_000; +const MAX_MARKDOWN_EXCERPT_CHARS = 2_400; +const MAX_CONNECTOR_DIRECTORY_SCAN_DIRS = 48; +const GITHUB_CLONE_TIMEOUT_MS = 120_000; +const GH_AUTH_TIMEOUT_MS = 10_000; +const MAX_PROCESS_OUTPUT_CHARS = 8_000; +const UI_KIT_ENTRY_GUIDANCE = [ + '- Claude-style UI-kit entry skeleton for direct JSX kits:', + ' - ``', + ' - ``', + ' - ``', + ' - ``', + ' - `
`', + ' - Load role components from `components/*.jsx` with ``.', + ' - Mount with `const { App } = window; const root = ReactDOM.createRoot(document.getElementById("root")); root.render();`.', +]; + +interface ParsedGitHubRepo { + owner: string; + repo: string; + source: string; +} + +interface GithubSnapshotFile { + repoPath: string; + outputPath?: string; + content: string | Buffer; + bytes: number; + source: 'connector' | 'git-clone' | 'local-folder'; + binary?: boolean; +} + +interface GithubDesignEvidence { + repo: ParsedGitHubRepo; + ref?: string; + resolvedRef?: string; + method: 'connector' | 'git-clone'; + localCloneMethod?: 'git' | 'gh-cli'; + repositoryMetadata?: JsonObject; + readme?: { path: string; content: string }; + treePaths: string[]; + files: GithubSnapshotFile[]; + materializedFiles?: string[]; + warnings: string[]; +} + +type GithubEvidenceInventoryCategory = + | 'Product docs and manifests' + | 'Brand assets and icons' + | 'Fonts' + | 'Theme, tokens, and styling' + | 'App shell and navigation' + | 'Chat and input surfaces' + | 'Reusable components' + | 'Other design evidence'; + +interface GithubEvidenceInventorySection { + title: GithubEvidenceInventoryCategory; + description: string; + files: GithubSnapshotFile[]; +} + +interface LocalDesignEvidence { + sourcePath: string; + sourceName: string; + method: 'local-folder'; + treePaths: string[]; + files: GithubSnapshotFile[]; + materializedFiles?: string[]; + readme?: { path: string; content: string }; + warnings: string[]; +} + +export type DesignSystemAuditSeverity = 'error' | 'warning'; + +export interface DesignSystemAuditIssue { + severity: DesignSystemAuditSeverity; + code: string; + message: string; + path?: string; +} + +export interface DesignSystemPackageAudit { + ok: boolean; + projectPath: string; + filesInspected: number; + errors: DesignSystemAuditIssue[]; + warnings: DesignSystemAuditIssue[]; +} + +interface ProcessRunResult { + ok: boolean; + stdout: string; + stderr: string; + code?: number | null; + timedOut?: boolean; + error?: string; +} + +interface LocalGitHubCloneResult { + method: 'git' | 'gh-cli'; + warnings: string[]; +} + function writeJson(value: unknown, stream: NodeJS.WriteStream = process.stdout): void { stream.write(`${JSON.stringify(value)}\n`); } @@ -70,6 +199,36 @@ function parseOptions(args: string[]): ParsedOptions | { error: string } { const value = rest[++index]; if (!value) return { error: '--input requires a file path' }; options.inputPath = value; + } else if (arg === '--path') { + const value = rest[++index]; + if (!value) return { error: '--path requires a local folder path' }; + options.localPath = value; + } else if (arg === '--repo') { + const value = rest[++index]; + if (!value) return { error: '--repo requires owner/repo or a GitHub repository URL' }; + options.repo = value; + } else if (arg === '--ref') { + const value = rest[++index]; + if (!value) return { error: '--ref requires a branch, tag, or commit' }; + options.ref = value; + } else if (arg === '--output') { + const value = rest[++index]; + if (!value) return { error: '--output requires a file path' }; + options.outputPath = value; + } else if (arg === '--max-files') { + const value = rest[++index]; + const parsed = value === undefined ? Number.NaN : Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 1) return { error: '--max-files must be a positive integer' }; + options.maxFiles = Math.min( + parsed, + options.command === 'local-design-context' ? MAX_LOCAL_CONTEXT_FILES : MAX_GITHUB_CONTEXT_FILES, + ); + } else if (arg === '--require-connector') { + options.requireConnector = true; + } else if (arg === '--reference-package') { + options.referencePackage = true; + } else if (arg === '--fail-on-warnings') { + options.failOnWarnings = true; } else if (arg === '--format') { const value = rest[++index]; if (value !== 'compact' && value !== 'json') return { error: '--format must be compact or json' }; @@ -135,6 +294,2323 @@ async function readJsonObject(filePath: string): Promise { return value as JsonObject; } +function parseGithubRepo(input: string): ParsedGitHubRepo { + const raw = input.trim(); + const sshMatch = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/iu.exec(raw); + if (sshMatch?.[1] && sshMatch[2]) { + return { owner: sshMatch[1], repo: stripGitSuffix(sshMatch[2]), source: raw }; + } + + if (/^https?:\/\//iu.test(raw)) { + const url = new URL(raw); + if (url.hostname.toLowerCase() !== 'github.com') { + throw new Error('--repo must point to github.com'); + } + const [owner, repo] = url.pathname.replace(/^\/+|\/+$/gu, '').split('/'); + if (!owner || !repo) throw new Error('--repo URL must include owner and repository'); + return { owner, repo: stripGitSuffix(repo), source: raw }; + } + + const [owner, repo] = raw.replace(/^\/+|\/+$/gu, '').split('/'); + if (!owner || !repo) { + throw new Error('--repo must be owner/repo or a GitHub repository URL'); + } + return { owner, repo: stripGitSuffix(repo), source: raw }; +} + +function stripGitSuffix(value: string): string { + return value.replace(/\.git$/iu, ''); +} + +function repoSlug(repo: ParsedGitHubRepo): string { + return `${safePathSegment(repo.owner)}-${safePathSegment(repo.repo)}`; +} + +function safePathSegment(value: string): string { + const normalized = value.trim().replace(/[^a-z0-9._-]+/giu, '-').replace(/^-+|-+$/gu, ''); + return normalized || 'repo'; +} + +function safeRepoRelativePath(repoPath: string): string { + return repoPath + .split('/') + .filter((segment) => segment && segment !== '.' && segment !== '..') + .map(safePathSegment) + .join('/'); +} + +function defaultGithubContextOutputPath(repo: ParsedGitHubRepo): string { + return path.join('context', 'github', `${repoSlug(repo)}.md`); +} + +function githubSnapshotRoot(outputPath: string, repo: ParsedGitHubRepo): string { + const dir = path.dirname(outputPath); + return path.join(dir, repoSlug(repo), 'files'); +} + +function localSourceName(sourcePath: string): string { + return safePathSegment(path.basename(path.resolve(sourcePath)) || 'local-source'); +} + +function defaultLocalContextOutputPath(sourcePath: string): string { + return path.join('context', 'local-code', `${localSourceName(sourcePath)}.md`); +} + +function localSnapshotRoot(outputPath: string, sourcePath: string): string { + const dir = path.dirname(outputPath); + return path.join(dir, localSourceName(sourcePath), 'files'); +} + +async function ensureParentDirectory(filePath: string): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); +} + +function isAbsenceError(error: unknown): boolean { + return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'); +} + +async function requestJsonOrThrow(baseUrl: URL, token: string, pathname: string, init: RequestInit = {}): Promise { + const response = await requestJson(baseUrl, token, pathname, init); + if (response.status >= 200 && response.status < 300) return response.body; + const error = normalizeCliError(response.body); + throw new Error(`${error.code ? `${error.code}: ` : ''}${error.message}`); +} + +async function executeConnectorReadTool( + baseUrl: URL, + token: string, + toolName: string, + input: JsonObject, +): Promise { + const body = await requestJsonOrThrow(baseUrl, token, '/api/tools/connectors/execute', { + method: 'POST', + body: JSON.stringify({ connectorId: GITHUB_CONNECTOR_ID, toolName, input }), + }); + if (!body || typeof body !== 'object') return body; + const output = (body as JsonObject).output; + if (output && typeof output === 'object' && !Array.isArray(output) && 'data' in output) { + return (output as JsonObject).data; + } + return output; +} + +async function assertGithubConnectorIsListable(baseUrl: URL, token: string): Promise { + const body = await requestJsonOrThrow(baseUrl, token, '/api/tools/connectors/list', { method: 'GET' }); + const connectors = body && typeof body === 'object' && Array.isArray((body as JsonObject).connectors) + ? (body as { connectors: JsonObject[] }).connectors + : []; + const github = connectors.find((connector) => connector.id === GITHUB_CONNECTOR_ID); + if (!github) throw new Error('GitHub connector is not connected or has no auto-approved read tools'); + const status = typeof github.status === 'string' ? github.status.toLowerCase() : ''; + if (status && status !== 'connected') { + throw new Error(`GitHub connector status is ${status}; connect GitHub before repository intake`); + } +} + +function getStringAtKeys(value: unknown, keys: string[]): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const record = value as JsonObject; + for (const key of keys) { + const direct = record[key]; + if (typeof direct === 'string' && direct.trim()) return direct; + } + for (const child of Object.values(record)) { + const found = getStringAtKeys(child, keys); + if (found) return found; + } + return undefined; +} + +function getDefaultBranch(metadata: unknown): string | undefined { + return getStringAtKeys(metadata, ['default_branch', 'defaultBranch']); +} + +function decodeContentPayload(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (!value || typeof value !== 'object') return undefined; + const record = value as JsonObject; + const content = typeof record.content === 'string' + ? record.content + : typeof record.data === 'string' + ? record.data + : undefined; + if (content !== undefined) { + const encoding = typeof record.encoding === 'string' ? record.encoding.toLowerCase() : ''; + if (encoding === 'base64') return decodeBase64Content(content); + return content; + } + for (const [key, child] of Object.entries(record)) { + if (key === 'mimetype' || key === 'name' || key === 's3url') continue; + const decoded = decodeContentPayload(child); + if (decoded !== undefined) return decoded; + } + return undefined; +} + +function decodeBase64Content(value: string): string { + return decodeBase64Buffer(value).toString('utf8'); +} + +function decodeBase64Buffer(value: string): Buffer { + return Buffer.from(value.replace(/\s+/gu, ''), 'base64'); +} + +function decodeBinaryContentPayload(value: unknown): Buffer | undefined { + if (!value || typeof value !== 'object') return undefined; + if (Array.isArray(value)) { + for (const item of value) { + const decoded = decodeBinaryContentPayload(item); + if (decoded) return decoded; + } + return undefined; + } + const record = value as JsonObject; + const content = typeof record.content === 'string' + ? record.content + : typeof record.data === 'string' + ? record.data + : undefined; + if (content !== undefined) { + const encoding = typeof record.encoding === 'string' ? record.encoding.toLowerCase() : ''; + if (encoding === 'base64') return decodeBase64Buffer(content); + } + for (const [key, child] of Object.entries(record)) { + if (key === 'mimetype' || key === 'name' || key === 's3url') continue; + const decoded = decodeBinaryContentPayload(child); + if (decoded) return decoded; + } + return undefined; +} + +function findConnectorSignedContentUrl(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + if (Array.isArray(value)) { + for (const item of value) { + const found = findConnectorSignedContentUrl(item); + if (found) return found; + } + return undefined; + } + const record = value as JsonObject; + if (typeof record.s3url === 'string' && /^https:\/\//iu.test(record.s3url)) return record.s3url; + for (const child of Object.values(record)) { + const found = findConnectorSignedContentUrl(child); + if (found) return found; + } + return undefined; +} + +async function readConnectorTextContent(value: unknown): Promise { + const decoded = decodeContentPayload(value); + if (decoded !== undefined) return decoded; + const signedUrl = findConnectorSignedContentUrl(value); + if (!signedUrl) return undefined; + const response = await fetch(signedUrl); + if (!response.ok) { + throw new Error(`connector content download failed with HTTP ${response.status}`); + } + const text = await response.text(); + return text.slice(0, MAX_CONTEXT_FILE_BYTES); +} + +async function readConnectorBinaryContent(value: unknown): Promise { + const decoded = decodeBinaryContentPayload(value); + if (decoded) return decoded; + const signedUrl = findConnectorSignedContentUrl(value); + if (!signedUrl) return undefined; + const response = await fetch(signedUrl); + if (!response.ok) { + throw new Error(`connector content download failed with HTTP ${response.status}`); + } + return Buffer.from(await response.arrayBuffer()); +} + +async function readConnectorSnapshotContent( + repoPath: string, + value: unknown, +): Promise<{ content: string | Buffer; bytes: number; binary?: boolean } | undefined> { + const normalizedPath = repoPath.toLowerCase(); + if (isBinaryDesignAssetPath(normalizedPath)) { + const binaryContent = await readConnectorBinaryContent(value); + if (!binaryContent) return undefined; + if (binaryContent.length > MAX_CONTEXT_ASSET_BYTES) { + throw new Error(`binary asset exceeds ${MAX_CONTEXT_ASSET_BYTES} bytes`); + } + return { content: binaryContent, bytes: binaryContent.length, binary: true }; + } + const textContent = await readConnectorTextContent(value); + if (textContent === undefined) return undefined; + const content = textContent.slice(0, MAX_CONTEXT_FILE_BYTES); + return { content, bytes: Buffer.byteLength(content, 'utf8') }; +} + +function extractTreePaths(value: unknown): string[] { + const paths = new Set(); + const visit = (node: unknown) => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const item of node) visit(item); + return; + } + const record = node as JsonObject; + const rawPath = typeof record.path === 'string' ? record.path : undefined; + const rawType = typeof record.type === 'string' ? record.type.toLowerCase() : ''; + if (rawPath && rawType !== 'tree' && rawType !== 'dir') { + paths.add(rawPath); + } + for (const child of Object.values(record)) visit(child); + }; + visit(value); + return [...paths].sort((left, right) => left.localeCompare(right)); +} + +interface GithubDirectoryEntry { + path: string; + type: 'file' | 'dir'; +} + +function extractDirectoryEntries(value: unknown): GithubDirectoryEntry[] { + const entries = new Map(); + const visit = (node: unknown) => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const item of node) visit(item); + return; + } + const record = node as JsonObject; + const rawPath = typeof record.path === 'string' ? record.path : undefined; + const rawType = typeof record.type === 'string' ? record.type.toLowerCase() : ''; + if (rawPath && (rawType === 'file' || rawType === 'dir')) { + entries.set(rawPath, { path: rawPath, type: rawType }); + } + for (const child of Object.values(record)) visit(child); + }; + visit(value); + return [...entries.values()].sort((left, right) => left.path.localeCompare(right.path)); +} + +function scoreDesignFile(repoPath: string): number { + const normalized = repoPath.toLowerCase(); + if (shouldSkipRepoPath(normalized)) return -1; + let score = 0; + if (/(^|\/)readme\.(md|mdx|txt|rst)$/u.test(normalized)) score += 100; + if (/(^|\/)package\.json$/u.test(normalized)) score += 95; + if (/(^|\/)(tailwind|theme|themes?|themeprovider|antdprovider|tokens?|colors?|typography|design-system|design|constant|constants|env|style|styles)\.(config\.)?(ts|tsx|js|jsx|json|css|scss|less|md)$/u.test(normalized)) score += 95; + if (/(^|\/)(globals?|index|style|styles|app|root)\.(css|scss|less)$/u.test(normalized)) score += 88; + if (/^(build|assets?|public|resources)\/(cherry[-_])?(logo|icon|tray[_-]?icon|avatar|wordmark|brand|mark)[^/]*\.(svg|png|jpe?g|webp|ico)$/u.test(normalized)) score += 150; + if (/^(fonts?|assets?\/fonts?|public\/fonts?|resources\/fonts?)\/.*\.(ttf|otf|woff2?)$/u.test(normalized)) score += 145; + if (/\/assets\/fonts?\/.*\.(ttf|otf|woff2?|css)$/u.test(normalized)) score += 145; + if (/\/assets\/fonts?\/.*ubuntu.*\.(ttf|otf|woff2?|css)$/u.test(normalized)) score += 18; + if (/(^|\/)(build|assets?|public|resources|fonts?)\/.*(logo|icon|avatar|tray|brand|wordmark|mark)[^/]*\.(svg|png|jpe?g|webp|ico)$/u.test(normalized)) score += 86; + if (/(^|\/)(build|assets?|public|resources|fonts?)\/.*\.(ttf|otf|woff2?)$/u.test(normalized)) score += 84; + if (/\/(context|providers?|theme|styles?|config|utils?)\//u.test(normalized)) score += 70; + if (/\/(app|layout|shell|navbar|sidebar|home|chat|settings|inputbar|assistants?|topics?)\//u.test(normalized)) score += 68; + if (/\/(components?|ui|design-system|primitives?)\//u.test(normalized)) score += 65; + if (/(button|card|dialog|modal|input|form|nav|navbar|sidebar|table|badge|avatar|toast|menu|tabs|layout|shell|composer|message|assistant|model|provider|settings)\.(tsx|ts|jsx|js|css|scss)$/u.test(normalized)) score += 58; + if (/\/components\/app\/(navbar|sidebar)\.(tsx|ts|jsx|js|css|scss)$/u.test(normalized)) score += 150; + if (/\/pages\/home\/(homepage|chat|navbar)\.(tsx|ts|jsx|js)$/u.test(normalized)) score += 155; + if (/\/pages\/home\/(inputbar|messages|tabs)\/(inputbar|inputbarcore|messages|message|messagegroup|messagecontent|assistantlist|assistantitem|assistantstab|topicstab|index)\.(tsx|ts|jsx|js)$/u.test(normalized)) score += 145; + if (/\/pages\/home\/tabs\/components\/(assistantlist|assistantitem|topics?)\.(tsx|ts|jsx|js)$/u.test(normalized)) score += 90; + if (/\/pages\/home\/inputbar\/(components\/inputbarcore|sendmessagebutton|attachmentpreview)\.(tsx|ts|jsx|js)$/u.test(normalized)) score += 80; + if (/\/pages\/home\/components\/chatnavbar\/(index|chatnavbarcontent\/index|chatnavbarcontent\/topiccontent)\.(tsx|ts|jsx|js)$/u.test(normalized)) score += 115; + if (/(^|\/)(app|pages|src)\/(layout|page|app|index|main)\.(tsx|ts|jsx|js|css)$/u.test(normalized)) score += 45; + if (isDesignAssetPath(normalized)) score += 42; + if (/\.(css|scss|less|tsx|ts|jsx|js|md|mdx|json|svg)$/u.test(normalized)) score += 10; + if (isBinaryDesignAssetPath(normalized)) score += 6; + if (/\/pages\/home\/inputbar\/tools\/components\//u.test(normalized)) score -= 80; + if (/\/pages\/settings\//u.test(normalized)) score -= 120; + if (/\/assets\/images\/providers?\//u.test(normalized)) score -= 72; + return score; +} + +function scoreDesignDirectory(repoPath: string): number { + const normalized = repoPath.toLowerCase(); + if (shouldSkipRepoPath(`${normalized}/`)) return -1; + const segments = normalized.split('/'); + const basename = segments.at(-1) ?? normalized; + let score = 0; + if (/^(apps?|packages?|src|source|frontend|web|client|ui|components?|design-system|styles?|theme|themes|tokens?|assets?|public|resources|build|fonts?)$/u.test(basename)) { + score += 80; + } + if (/(^|\/)(apps?|packages?)\//u.test(normalized)) score += 35; + if (/(^|\/)(components?|ui|design-system|primitives?|styles?|theme|tokens?|assets?|public|resources|build|fonts?)$/u.test(normalized)) score += 45; + if (segments.length <= 2) score += 10; + if (segments.length > 5) score -= 20; + return score; +} + +function shouldSkipRepoPath(normalizedPath: string): boolean { + if (isDesignAssetDirectory(normalizedPath) || isDesignAssetPath(normalizedPath)) return false; + return /(^|\/)(node_modules|vendor|dist|build|coverage|\.next|\.nuxt|\.git|out|target|storybook-static)\//u.test(normalizedPath) + || /(^|\/)(package-lock\.json|pnpm-lock\.ya?ml|yarn\.lock|bun\.lockb)$/u.test(normalizedPath) + || /(^|\/)(__tests__|__snapshots__|test|tests)\//u.test(normalizedPath) + || /\.(test|spec|bench)\.(tsx|ts|jsx|js)$/u.test(normalizedPath) + || /\.(gif|avif|mp4|mov|zip|tar|gz|pdf)$/u.test(normalizedPath) + || (/\.(png|jpe?g|webp|ico|woff2?|ttf|otf)$/u.test(normalizedPath) && !isDesignAssetPath(normalizedPath)); +} + +function isDesignAssetDirectory(normalizedPath: string): boolean { + return /(^|\/)(assets?|public|resources|build|fonts?)\/$/u.test(normalizedPath) + || /(^|\/)src\/renderer\/src\/assets\//u.test(normalizedPath); +} + +function isDesignAssetPath(normalizedPath: string): boolean { + return /(^|\/)(assets?|public|resources|build|fonts?)\/.*(logo|icon|avatar|tray|brand|wordmark|mark|font|ubuntu)[^/]*\.(svg|png|jpe?g|webp|ico|ttf|otf|woff2?)$/u.test(normalizedPath) + || /(^|\/)src\/renderer\/src\/assets\/.*\.(svg|png|jpe?g|webp|ico|ttf|otf|woff2?)$/u.test(normalizedPath); +} + +function isBinaryDesignAssetPath(normalizedPath: string): boolean { + return /\.(png|jpe?g|webp|ico|ttf|otf|woff2?)$/u.test(normalizedPath); +} + +function isTextSnapshotPath(normalizedPath: string): boolean { + return /\.(css|scss|less|tsx|ts|jsx|js|md|mdx|json|svg|txt|rst)$/u.test(normalizedPath); +} + +function selectDesignFiles(paths: string[], maxFiles: number): string[] { + return paths + .map((repoPath) => ({ repoPath, score: scoreDesignFile(repoPath) })) + .filter((entry) => entry.score > 0) + .sort((left, right) => right.score - left.score || left.repoPath.localeCompare(right.repoPath)) + .slice(0, maxFiles) + .map((entry) => entry.repoPath); +} + +function selectDesignFilesWithPreferredReadme(paths: string[], maxFiles: number): string[] { + const selected = selectDesignFiles(paths, maxFiles); + const preferredReadme = preferredReadmePath(paths); + if (!preferredReadme || selected.includes(preferredReadme)) return selected; + return [preferredReadme, ...selected.filter((repoPath) => repoPath !== preferredReadme)].slice(0, maxFiles); +} + +function preferredReadmePath(paths: string[]): string | undefined { + return paths + .filter((repoPath) => /(^|\/)readme\.(md|mdx|txt|rst)$/iu.test(repoPath)) + .sort((left, right) => { + const leftSegments = left.split('/').length; + const rightSegments = right.split('/').length; + return leftSegments - rightSegments || left.localeCompare(right); + })[0]; +} + +async function collectGithubTreePathsWithConnector( + baseUrl: URL, + token: string, + repo: ParsedGitHubRepo, + resolvedRef: string, + warnings: string[], +): Promise { + try { + const treePayload = await executeConnectorReadTool(baseUrl, token, GITHUB_GET_TREE_TOOL, { + owner: repo.owner, + repo: repo.repo, + tree_sha: resolvedRef, + recursive: true, + }); + return extractTreePaths(treePayload); + } catch (error) { + warnings.push( + `Recursive tree connector read failed; falling back to bounded directory browsing: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return collectGithubTreePathsFromDirectoryListings(baseUrl, token, repo, resolvedRef, warnings); + } +} + +async function collectGithubTreePathsFromDirectoryListings( + baseUrl: URL, + token: string, + repo: ParsedGitHubRepo, + resolvedRef: string, + warnings: string[], +): Promise { + const filePaths = new Set(); + const seenDirs = new Set(); + const queue: string[] = ['']; + + while (queue.length > 0 && seenDirs.size < MAX_CONNECTOR_DIRECTORY_SCAN_DIRS) { + const currentDir = queue.shift() ?? ''; + if (seenDirs.has(currentDir)) continue; + seenDirs.add(currentDir); + + let entries: GithubDirectoryEntry[] = []; + try { + const payload = await executeConnectorReadTool(baseUrl, token, GITHUB_GET_REPOSITORY_CONTENT_TOOL, { + owner: repo.owner, + repo: repo.repo, + ref: resolvedRef, + path: currentDir, + }); + entries = extractDirectoryEntries(payload); + } catch (error) { + warnings.push(`Skipped directory ${currentDir || '.'}: ${error instanceof Error ? error.message : String(error)}`); + continue; + } + + for (const entry of entries) { + if (entry.type === 'file') { + if (!shouldSkipRepoPath(entry.path.toLowerCase())) filePaths.add(entry.path); + continue; + } + if (entry.type === 'dir' && !seenDirs.has(entry.path) && scoreDesignDirectory(entry.path) > 0) { + queue.push(entry.path); + } + } + + queue.sort((left, right) => scoreDesignDirectory(right) - scoreDesignDirectory(left) || left.localeCompare(right)); + } + + if (queue.length > 0) { + warnings.push(`Directory browsing stopped after ${MAX_CONNECTOR_DIRECTORY_SCAN_DIRS} directories; evidence is a bounded connector snapshot.`); + } + return [...filePaths].sort((left, right) => left.localeCompare(right)); +} + +async function collectGithubEvidenceWithConnector( + baseUrl: URL, + token: string, + repo: ParsedGitHubRepo, + options: { ref?: string; maxFiles: number }, +): Promise { + await assertGithubConnectorIsListable(baseUrl, token); + const warnings: string[] = []; + let metadata: unknown; + try { + metadata = await executeConnectorReadTool(baseUrl, token, GITHUB_GET_REPOSITORY_TOOL, { + owner: repo.owner, + repo: repo.repo, + }); + } catch (error) { + if (!connectorIntakeIsRecoverable(error)) throw error; + warnings.push( + `Repository metadata connector read failed; continuing with ${ + options.ref ?? 'main' + } as the ref: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const resolvedRef = options.ref ?? getDefaultBranch(metadata) ?? 'main'; + + let readme: GithubDesignEvidence['readme']; + try { + const readmePayload = await executeConnectorReadTool(baseUrl, token, GITHUB_GET_README_TOOL, { + owner: repo.owner, + repo: repo.repo, + ref: resolvedRef, + }); + const content = await readConnectorTextContent(readmePayload); + if (content) { + readme = { + path: getStringAtKeys(readmePayload, ['path', 'name']) ?? 'README.md', + content, + }; + } + } catch (error) { + warnings.push(`README connector read failed: ${error instanceof Error ? error.message : String(error)}`); + } + + const treePaths = await collectGithubTreePathsWithConnector(baseUrl, token, repo, resolvedRef, warnings); + const selectedPaths = selectDesignFiles(treePaths, options.maxFiles); + const files: GithubSnapshotFile[] = []; + for (const repoPath of selectedPaths) { + if (readme?.path === repoPath) continue; + try { + const contentPayload = await executeConnectorReadTool(baseUrl, token, GITHUB_GET_RAW_CONTENT_TOOL, { + owner: repo.owner, + repo: repo.repo, + ref: resolvedRef, + path: repoPath, + }); + const snapshot = await readConnectorSnapshotContent(repoPath, contentPayload); + if (snapshot === undefined) { + warnings.push(`Skipped ${repoPath}: connector returned no readable content`); + continue; + } + files.push({ + repoPath, + content: snapshot.content, + bytes: snapshot.bytes, + source: 'connector', + ...(snapshot.binary ? { binary: true } : {}), + }); + } catch (error) { + warnings.push(`Skipped ${repoPath}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + if (!readme && files.length === 0) { + throw new Error( + [ + 'GitHub connector did not produce readable repository evidence through bounded intake.', + warnings.length ? `Warnings: ${warnings.join(' | ')}` : '', + ].filter(Boolean).join(' '), + ); + } + + const metadataObject = metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? metadata as JsonObject + : undefined; + return { + repo, + ...(options.ref === undefined ? {} : { ref: options.ref }), + resolvedRef, + method: 'connector', + ...(metadataObject === undefined ? {} : { repositoryMetadata: metadataObject }), + ...(readme === undefined ? {} : { readme }), + treePaths, + files, + warnings, + }; +} + +async function collectGithubEvidenceWithGitClone( + repo: ParsedGitHubRepo, + options: { ref?: string; maxFiles: number; reason?: string; warnings?: string[] }, +): Promise { + const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'od-github-context-')); + const cloneDir = path.join(tmpDir, 'repo'); + try { + const clone = await cloneGithubRepository(repo, cloneDir, options.ref); + const paths = await listLocalRepoFiles(cloneDir); + const selectedPaths = selectDesignFilesWithPreferredReadme(paths, options.maxFiles); + const files: GithubSnapshotFile[] = []; + let readme: GithubDesignEvidence['readme']; + const preferredReadme = preferredReadmePath(paths); + for (const repoPath of selectedPaths) { + const absolutePath = path.join(cloneDir, repoPath); + const fileStat = await stat(absolutePath); + if (!fileStat.isFile()) continue; + const normalizedPath = repoPath.toLowerCase(); + const binary = isBinaryDesignAssetPath(normalizedPath); + if (binary) { + if (fileStat.size > MAX_CONTEXT_ASSET_BYTES) continue; + files.push({ + repoPath, + content: await readFile(absolutePath), + bytes: fileStat.size, + source: 'git-clone', + binary: true, + }); + continue; + } + if (!isTextSnapshotPath(normalizedPath) || fileStat.size > MAX_CONTEXT_FILE_BYTES) continue; + const content = await readFile(absolutePath, 'utf8'); + if (!readme && repoPath === preferredReadme) { + readme = { path: repoPath, content }; + continue; + } + files.push({ + repoPath, + content, + bytes: Buffer.byteLength(content, 'utf8'), + source: 'git-clone', + }); + } + return { + repo, + ...(options.ref === undefined ? {} : { ref: options.ref }), + ...(options.ref === undefined ? {} : { resolvedRef: options.ref }), + method: 'git-clone', + localCloneMethod: clone.method, + ...(readme === undefined ? {} : { readme }), + treePaths: paths, + files, + warnings: [ + ...(options.warnings ?? []), + ...clone.warnings, + ...(options.reason ? [`This-device GitHub intake note: ${options.reason}`] : []), + ], + }; + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +} + +async function collectLocalDesignEvidence( + sourcePath: string, + options: { maxFiles: number }, +): Promise { + const resolvedSourcePath = path.resolve(sourcePath); + const sourceStat = await stat(resolvedSourcePath); + if (!sourceStat.isDirectory()) { + throw new Error(`local-design-context requires --path to be a directory: ${resolvedSourcePath}`); + } + const paths = await listLocalRepoFiles(resolvedSourcePath); + const selectedPaths = selectDesignFilesWithPreferredReadme(paths, options.maxFiles); + const files: GithubSnapshotFile[] = []; + const warnings: string[] = []; + let readme: LocalDesignEvidence['readme']; + const preferredReadme = preferredReadmePath(paths); + + for (const repoPath of selectedPaths) { + const absolutePath = path.join(resolvedSourcePath, repoPath); + const fileStat = await stat(absolutePath); + if (!fileStat.isFile()) continue; + const normalizedPath = repoPath.toLowerCase(); + const binary = isBinaryDesignAssetPath(normalizedPath); + if (binary) { + if (fileStat.size > MAX_CONTEXT_ASSET_BYTES) { + warnings.push(`Skipped ${repoPath}: binary asset exceeds ${MAX_CONTEXT_ASSET_BYTES} bytes`); + continue; + } + files.push({ + repoPath, + content: await readFile(absolutePath), + bytes: fileStat.size, + source: 'local-folder', + binary: true, + }); + continue; + } + if (!isTextSnapshotPath(normalizedPath)) continue; + if (fileStat.size > MAX_CONTEXT_FILE_BYTES) { + warnings.push(`Skipped ${repoPath}: text file exceeds ${MAX_CONTEXT_FILE_BYTES} bytes`); + continue; + } + const content = await readFile(absolutePath, 'utf8'); + if (!readme && repoPath === preferredReadme) { + readme = { path: repoPath, content }; + continue; + } + files.push({ + repoPath, + content, + bytes: Buffer.byteLength(content, 'utf8'), + source: 'local-folder', + }); + } + + if (!readme && files.length === 0) { + throw new Error(`No design-relevant local evidence was selected from ${resolvedSourcePath}`); + } + + return { + sourcePath: resolvedSourcePath, + sourceName: localSourceName(resolvedSourcePath), + method: 'local-folder', + treePaths: paths, + files, + ...(readme === undefined ? {} : { readme }), + warnings, + }; +} + +function connectorIntakeIsRecoverable(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + if (/\b(ACCESS_DENIED|NOT_FOUND|FORBIDDEN|UNAUTHORIZED)\b|access denied|repository not found|not found|forbidden|permission|unauthorized|\b40[134]\b/iu.test(message)) { + return false; + } + return /\b(CONNECTOR_OUTPUT_TOO_LARGE|CONNECTOR_RATE_LIMITED)\b/u.test(message) + || /did not produce readable repository evidence/iu.test(message) + || /produced no snapshot files/iu.test(message); +} + +function connectorEvidenceNeedsCloneFallback(evidence: GithubDesignEvidence): boolean { + return evidence.files.length === 0; +} + +async function cloneGithubRepository( + repo: ParsedGitHubRepo, + cloneDir: string, + ref: string | undefined, +): Promise { + const repoUrl = /^https?:\/\//iu.test(repo.source) || repo.source.startsWith('git@') + ? repo.source + : `https://github.com/${repo.owner}/${repo.repo}.git`; + const gitArgs = ['clone', '--depth=1', '--single-branch']; + if (ref) gitArgs.push('--branch', ref); + gitArgs.push(repoUrl, cloneDir); + + const gitResult = await runProcessBuffered('git', gitArgs, { + timeoutMs: GITHUB_CLONE_TIMEOUT_MS, + env: { GIT_TERMINAL_PROMPT: '0' }, + }); + if (gitResult.ok) return { method: 'git', warnings: [] }; + + await rm(cloneDir, { recursive: true, force: true }); + const gitFailure = summarizeProcessFailure('git clone', gitResult); + const gh = await checkGitHubCliAuthentication(); + if (!gh.installed) { + throw new Error( + `${gitFailure}; GitHub CLI is not installed. Install GitHub CLI or configure local git credentials, then rerun github-design-context.`, + ); + } + if (!gh.authenticated) { + throw new Error( + `${gitFailure}; GitHub CLI is installed but not authenticated. Run \`gh auth login --web\`, grant this repository, then rerun github-design-context.`, + ); + } + + const ghArgs = ['repo', 'clone', `${repo.owner}/${repo.repo}`, cloneDir, '--', '--depth=1', '--single-branch']; + if (ref) ghArgs.push('--branch', ref); + const ghResult = await runProcessBuffered('gh', ghArgs, { + timeoutMs: GITHUB_CLONE_TIMEOUT_MS, + env: { GIT_TERMINAL_PROMPT: '0' }, + }); + if (ghResult.ok) { + return { + method: 'gh-cli', + warnings: [ + `Plain git clone could not read the repository, so the intake used authenticated GitHub CLI clone instead. ${gitFailure}`, + ], + }; + } + + throw new Error(`${gitFailure}; ${summarizeProcessFailure('gh repo clone', ghResult)}`); +} + +async function checkGitHubCliAuthentication(): Promise<{ installed: boolean; authenticated: boolean }> { + const version = await runProcessBuffered('gh', ['--version'], { timeoutMs: GH_AUTH_TIMEOUT_MS }); + if (!version.ok) return { installed: false, authenticated: false }; + const auth = await runProcessBuffered('gh', ['auth', 'status', '--hostname', 'github.com'], { + timeoutMs: GH_AUTH_TIMEOUT_MS, + env: { GIT_TERMINAL_PROMPT: '0' }, + }); + return { installed: true, authenticated: auth.ok }; +} + +async function runProcessBuffered( + command: string, + args: string[], + options: { timeoutMs: number; env?: Record }, +): Promise { + return await new Promise((resolve) => { + let settled = false; + let timedOut = false; + let stdout = ''; + let stderr = ''; + let timeout: ReturnType | undefined; + const settle = (result: ProcessRunResult) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + resolve({ + ...result, + stdout: redactSensitiveProcessOutput(result.stdout), + stderr: redactSensitiveProcessOutput(result.stderr), + ...(result.error === undefined ? {} : { error: redactSensitiveProcessOutput(result.error) }), + }); + }; + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, ...(options.env ?? {}) }, + }); + timeout = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + setTimeout(() => { + if (!settled) child.kill('SIGKILL'); + }, 2_000).unref(); + }, options.timeoutMs); + timeout.unref(); + child.stdout.on('data', (chunk) => { + stdout = appendProcessOutput(stdout, chunk); + }); + child.stderr.on('data', (chunk) => { + stderr = appendProcessOutput(stderr, chunk); + }); + child.on('error', (error) => { + settle({ ok: false, stdout, stderr, error: error.message }); + }); + child.on('close', (code) => { + settle({ ok: code === 0 && !timedOut, stdout, stderr, code, ...(timedOut ? { timedOut } : {}) }); + }); + }); +} + +function appendProcessOutput(current: string, chunk: unknown): string { + return `${current}${String(chunk)}`.slice(-MAX_PROCESS_OUTPUT_CHARS); +} + +function summarizeProcessFailure(label: string, result: ProcessRunResult): string { + const details = [ + result.timedOut ? `timed out after ${Math.round(GITHUB_CLONE_TIMEOUT_MS / 1000)}s` : '', + result.error, + result.stderr.trim(), + result.stdout.trim(), + result.code === undefined || result.code === 0 ? '' : `exit code ${result.code}`, + ].filter(Boolean); + return `${label} failed${details.length ? `: ${details.join(' | ')}` : ''}`; +} + +function redactSensitiveProcessOutput(value: string): string { + return value + .replace(/https?:\/\/[^@\s]+@github\.com/giu, 'https://***@github.com') + .replace(/(gh[opsu]_[A-Za-z0-9_]+)/gu, '***'); +} + +async function listLocalRepoFiles(root: string): Promise { + const files: string[] = []; + const walk = async (dir: string) => { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const absolutePath = path.join(dir, entry.name); + const relativePath = path.relative(root, absolutePath).split(path.sep).join('/'); + const normalized = relativePath.toLowerCase(); + if (entry.isDirectory()) { + if (shouldSkipRepoPath(`${normalized}/`)) continue; + await walk(absolutePath); + continue; + } + if (entry.isFile() && !shouldSkipRepoPath(normalized)) files.push(relativePath); + } + }; + await walk(root); + return files.sort((left, right) => left.localeCompare(right)); +} + +async function listAuditFiles(root: string): Promise { + const files: string[] = []; + const walk = async (dir: string) => { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const absolutePath = path.join(dir, entry.name); + const relativePath = path.relative(root, absolutePath).split(path.sep).join('/'); + const normalized = relativePath.toLowerCase(); + if (entry.isDirectory()) { + if (shouldSkipAuditPath(`${normalized}/`)) continue; + await walk(absolutePath); + continue; + } + if (entry.isFile() && !shouldSkipAuditPath(normalized)) files.push(relativePath); + } + }; + await walk(root); + return files.sort((left, right) => left.localeCompare(right)); +} + +function shouldSkipAuditPath(normalizedPath: string): boolean { + return /(^|\/)(node_modules|vendor|dist|coverage|\.next|\.nuxt|\.git|out|target|storybook-static)\//u.test(normalizedPath) + || /(^|\/)(package-lock\.json|pnpm-lock\.ya?ml|yarn\.lock|bun\.lockb|\.ds_store)$/u.test(normalizedPath); +} + +async function writeGithubDesignEvidence(outputPath: string, evidence: GithubDesignEvidence): Promise { + const resolvedOutputPath = path.resolve(outputPath); + const snapshotRoot = githubSnapshotRoot(resolvedOutputPath, evidence.repo); + const writtenFiles: GithubSnapshotFile[] = []; + for (const file of evidence.files) { + const safeRelativePath = safeRepoRelativePath(file.repoPath); + if (!safeRelativePath) continue; + const fileOutputPath = path.join(snapshotRoot, safeRelativePath); + await ensureParentDirectory(fileOutputPath); + if (file.binary) { + await writeFile(fileOutputPath, file.content); + } else { + await writeFile(fileOutputPath, file.content, 'utf8'); + } + writtenFiles.push({ ...file, outputPath: path.relative(process.cwd(), fileOutputPath).split(path.sep).join('/') }); + } + const materializedFiles = await materializePackageEvidenceArtifacts(writtenFiles); + const nextEvidence = { ...evidence, files: writtenFiles, materializedFiles }; + await ensureParentDirectory(resolvedOutputPath); + await writeFile(resolvedOutputPath, renderGithubDesignEvidenceMarkdown(nextEvidence), 'utf8'); + return nextEvidence; +} + +async function writeLocalDesignEvidence(outputPath: string, evidence: LocalDesignEvidence): Promise { + const resolvedOutputPath = path.resolve(outputPath); + const snapshotRoot = localSnapshotRoot(resolvedOutputPath, evidence.sourcePath); + const writtenFiles: GithubSnapshotFile[] = []; + for (const file of evidence.files) { + const safeRelativePath = safeRepoRelativePath(file.repoPath); + if (!safeRelativePath) continue; + const fileOutputPath = path.join(snapshotRoot, safeRelativePath); + await ensureParentDirectory(fileOutputPath); + if (file.binary) { + await writeFile(fileOutputPath, file.content); + } else { + await writeFile(fileOutputPath, file.content, 'utf8'); + } + writtenFiles.push({ ...file, outputPath: path.relative(process.cwd(), fileOutputPath).split(path.sep).join('/') }); + } + const materializedFiles = await materializePackageEvidenceArtifacts(writtenFiles); + const nextEvidence = { ...evidence, files: writtenFiles, materializedFiles }; + await ensureParentDirectory(resolvedOutputPath); + await writeFile(resolvedOutputPath, renderLocalDesignEvidenceMarkdown(nextEvidence), 'utf8'); + return nextEvidence; +} + +async function materializePackageEvidenceArtifacts(files: GithubSnapshotFile[]): Promise { + const materialized: string[] = []; + for (const file of packageBuildAssetCandidates(files)) { + const target = packageBuildAssetTarget(file.repoPath); + if (target === undefined) continue; + if (await writePackageFileIfMissing(target, file.content, file.binary === true)) { + materialized.push(target); + } + } + for (const file of packageFontAssetCandidates(files)) { + const target = packageFontAssetTarget(file.repoPath); + if (target === undefined) continue; + if (await writePackageFileIfMissing(target, file.content, file.binary === true)) { + materialized.push(target); + } + } + for (const file of packageSourceExampleCandidates(files)) { + const safeRelativePath = safeRepoRelativePath(file.repoPath); + if (!safeRelativePath) continue; + const target = path.join('source_examples', safeRelativePath).split(path.sep).join('/'); + if (await writePackageFileIfMissing(target, file.content, false)) { + materialized.push(target); + } + } + return materialized; +} + +function packageBuildAssetCandidates(files: GithubSnapshotFile[]): GithubSnapshotFile[] { + return files + .filter((file) => file.binary === true && packageBuildAssetTarget(file.repoPath) !== undefined) + .slice(0, 8); +} + +function packageBuildAssetTarget(repoPath: string): string | undefined { + const safeRelativePath = safeRepoRelativePath(repoPath); + if (!safeRelativePath) return undefined; + if (!/\.(svg|png|jpe?g|webp|ico)$/iu.test(safeRelativePath)) return undefined; + if (!/(^|\/)[^/]*(logo|icon|tray|wordmark|mark)[^/]*\.(svg|png|jpe?g|webp|ico)$/iu.test(safeRelativePath)) return undefined; + const parts = safeRelativePath.split('/'); + const buildIndex = parts.findIndex((part) => /^build$/iu.test(part)); + const assetRootIndex = buildIndex === -1 + ? parts.findIndex((part) => /^(resources|public-resources)$/iu.test(part)) + : buildIndex; + if (assetRootIndex === -1 || assetRootIndex === parts.length - 1) return undefined; + return path.join('build', ...parts.slice(assetRootIndex + 1)).split(path.sep).join('/'); +} + +function packageFontAssetCandidates(files: GithubSnapshotFile[]): GithubSnapshotFile[] { + return files + .filter((file) => packageFontAssetTarget(file.repoPath) !== undefined) + .slice(0, 8); +} + +function packageFontAssetTarget(repoPath: string): string | undefined { + const safeRelativePath = safeRepoRelativePath(repoPath); + if (!safeRelativePath) return undefined; + const isFontBinary = /\.(ttf|otf|woff2?)$/iu.test(safeRelativePath); + const isFontStylesheet = /(^|\/)(fonts?|assets\/fonts?|public\/fonts?|resources\/fonts?)\//iu.test(safeRelativePath) + && /\.css$/iu.test(safeRelativePath); + if (!isFontBinary && !isFontStylesheet) return undefined; + const parts = safeRelativePath.split('/'); + const fontRootIndex = parts.findIndex((part) => /^fonts?$/iu.test(part)); + if (fontRootIndex !== -1 && fontRootIndex < parts.length - 1) { + return path.join('fonts', ...parts.slice(fontRootIndex + 1)).split(path.sep).join('/'); + } + const assetFontIndex = parts.findIndex((part, index) => + /^(assets?|public|resources)$/iu.test(part) && /^fonts?$/iu.test(parts[index + 1] ?? ''), + ); + if (assetFontIndex !== -1 && assetFontIndex < parts.length - 2) { + return path.join('fonts', ...parts.slice(assetFontIndex + 2)).split(path.sep).join('/'); + } + if (!isFontBinary) return undefined; + return path.join('fonts', path.basename(safeRelativePath)).split(path.sep).join('/'); +} + +function packageSourceExampleCandidates(files: GithubSnapshotFile[]): GithubSnapshotFile[] { + const seen = new Set(); + const candidates = files + .filter((file) => !file.binary && typeof file.content === 'string') + .filter((file) => /\.(tsx|ts|jsx|js)$/iu.test(file.repoPath)) + .filter((file) => { + const name = sourceComponentNameFromPath(file.repoPath); + if (name === undefined || !isSourceSurfaceComponentName(name)) return false; + const key = normalizeAnchorText(name); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort((left, right) => sourceExamplePriority(right.repoPath) - sourceExamplePriority(left.repoPath)); + return candidates.slice(0, 6); +} + +function sourceExamplePriority(repoPath: string): number { + const category = designEvidenceInventoryCategory(repoPath); + if (category === 'App shell and navigation') return 4; + if (category === 'Chat and input surfaces') return 3; + if (category === 'Reusable components') return 2; + return 1; +} + +async function writePackageFileIfMissing(relativePath: string, content: string | Buffer, binary: boolean): Promise { + const safeRelativePath = safeRepoRelativePath(relativePath); + if (!safeRelativePath) return false; + const targetPath = path.resolve(process.cwd(), safeRelativePath); + const cwd = path.resolve(process.cwd()); + if (targetPath !== cwd && !targetPath.startsWith(`${cwd}${path.sep}`)) return false; + try { + await stat(targetPath); + return false; + } catch (error) { + if (!isAbsenceError(error)) throw error; + } + await ensureParentDirectory(targetPath); + if (binary) { + await writeFile(targetPath, content); + } else { + await writeFile(targetPath, content, 'utf8'); + } + return true; +} + +function renderGithubDesignEvidenceMarkdown(evidence: GithubDesignEvidence): string { + const inventory = buildDesignEvidenceInventory(evidence.files); + const lines = [ + `# GitHub Design Evidence: ${evidence.repo.owner}/${evidence.repo.repo}`, + '', + `Source: ${evidence.repo.source}`, + `Read method: ${evidence.method}`, + ...(evidence.localCloneMethod ? [`Local clone method: ${evidence.localCloneMethod === 'gh-cli' ? 'GitHub CLI authenticated clone' : 'git clone'}`] : []), + `Ref: ${evidence.resolvedRef ?? evidence.ref ?? 'default branch'}`, + `Repository paths discovered: ${evidence.treePaths.length}`, + `Snapshot files written: ${evidence.files.length}`, + '', + '## Intake Status', + '', + evidence.method === 'connector' + ? '- Connector platform fallback was used through `od tools connectors`.' + : '- This-device intake was used through local git or GitHub CLI.', + ]; + if (evidence.warnings.length > 0) { + lines.push('', '## Warnings', '', ...evidence.warnings.map((warning) => `- ${warning}`)); + } + if (evidence.readme) { + lines.push('', `## README (${evidence.readme.path})`, '', '```md', excerpt(evidence.readme.content), '```'); + } + if (inventory.length > 0) { + lines.push('', '## Source Evidence Inventory', ''); + for (const section of inventory) { + lines.push(`### ${section.title}`, '', section.description, ''); + for (const file of section.files) { + const kind = file.binary ? 'binary asset' : 'source'; + lines.push(`- ${file.repoPath}${file.outputPath ? ` -> \`${file.outputPath}\`` : ''} (${kind})`); + } + lines.push(''); + } + } + if (evidence.files.length > 0) { + lines.push('', '## Files Inspected', ''); + for (const file of evidence.files) { + const kind = file.binary ? ', binary asset' : ''; + lines.push(`- ${file.repoPath}${file.outputPath ? ` -> \`${file.outputPath}\`` : ''} (${file.bytes} bytes, ${file.source}${kind})`); + } + const binaryFiles = evidence.files.filter((file) => file.binary); + if (binaryFiles.length > 0) { + lines.push('', '## Binary Assets Preserved', ''); + for (const file of binaryFiles) { + lines.push(`- ${file.repoPath}${file.outputPath ? ` -> \`${file.outputPath}\`` : ''}`); + } + } + const textFiles = evidence.files.filter((file): file is GithubSnapshotFile & { content: string } => !file.binary && typeof file.content === 'string'); + if (textFiles.length > 0) { + lines.push('', '## Design-Relevant Excerpts', ''); + for (const file of textFiles.slice(0, 12)) { + lines.push(`### ${file.repoPath}`, '', fencedExcerpt(file.repoPath, file.content), ''); + } + } + } + if (evidence.materializedFiles && evidence.materializedFiles.length > 0) { + lines.push('', '## Package Files Materialized', ''); + for (const file of evidence.materializedFiles) { + lines.push(`- \`${file}\``); + } + } + lines.push( + '', + '## Next Design-System Work', + '', + '- Use these source paths and snapshots as evidence before writing `DESIGN.md`.', + '- Convert the inventory above into a Claude Design-style package: `README.md`, `SKILL.md`, `colors_and_type.css`, `preview/colors-*`, `preview/typography-specimens.html`, `preview/spacing-*`, `preview/components-*`, `preview/brand-assets.html`, `ui_kits/app/`, and preserved `assets/`, `build/`, or `fonts/` when evidence exists.', + '- `ui_kits/app/index.html` must be a browser-reviewable component entry: load `../../colors_and_type.css`, load or import at least three files from `ui_kits/app/components/`, and mount the composed UI through ReactDOM/Babel or compiled browser-ready JavaScript. Do not duplicate a static HTML mock when modular component files exist.', + '- `ui_kits/app/components/App.jsx` (or equivalent app shell) must compose source-backed role components such as Sidebar, AssistantsList, ChatArea, InputBar, and MessageBubble, not merely list their filenames.', + ...UI_KIT_ENTRY_GUIDANCE, + '- Preserve at least three high-signal source examples outside `context/` under `source_examples/` when reusable component snapshots exist, so future agents can compare generated components against original source structure.', + '- When a captured asset path begins with `build/`, copy the snapshot back into a root `build/` path with its original filename, such as `context/.../files/build/icon.png` -> `build/icon.png`. Do not satisfy build/runtime icon evidence by only renaming those files into `assets/`.', + '- Make `preview/brand-assets.html` visibly load preserved asset files from `assets/` or `build/`; do not redraw captured logos/icons as inline placeholders.', + '- Extract concrete colors, typography, spacing, radius, component behavior, assets, and product tone only when supported by inspected files.', + '- If evidence is missing or ambiguous, mark that uncertainty instead of inventing tokens.', + '', + ); + return lines.join('\n'); +} + +function renderLocalDesignEvidenceMarkdown(evidence: LocalDesignEvidence): string { + const inventory = buildDesignEvidenceInventory(evidence.files); + const lines = [ + `# Local Design Evidence: ${evidence.sourceName}`, + '', + `Source path: ${evidence.sourcePath}`, + `Read method: ${evidence.method}`, + `Local paths discovered: ${evidence.treePaths.length}`, + `Snapshot files written: ${evidence.files.length}`, + '', + '## Intake Status', + '', + '- Local source folder was read through bounded `od tools connectors local-design-context` intake.', + ]; + if (evidence.warnings.length > 0) { + lines.push('', '## Warnings', '', ...evidence.warnings.map((warning) => `- ${warning}`)); + } + if (evidence.readme) { + lines.push('', `## README (${evidence.readme.path})`, '', '```md', excerpt(evidence.readme.content), '```'); + } + if (inventory.length > 0) { + lines.push('', '## Source Evidence Inventory', ''); + for (const section of inventory) { + lines.push(`### ${section.title}`, '', section.description, ''); + for (const file of section.files) { + const kind = file.binary ? 'binary asset' : 'source'; + lines.push(`- ${file.repoPath}${file.outputPath ? ` -> \`${file.outputPath}\`` : ''} (${kind})`); + } + lines.push(''); + } + } + if (evidence.files.length > 0) { + lines.push('', '## Files Inspected', ''); + for (const file of evidence.files) { + const kind = file.binary ? ', binary asset' : ''; + lines.push(`- ${file.repoPath}${file.outputPath ? ` -> \`${file.outputPath}\`` : ''} (${file.bytes} bytes, ${file.source}${kind})`); + } + const binaryFiles = evidence.files.filter((file) => file.binary); + if (binaryFiles.length > 0) { + lines.push('', '## Binary Assets Preserved', ''); + for (const file of binaryFiles) { + lines.push(`- ${file.repoPath}${file.outputPath ? ` -> \`${file.outputPath}\`` : ''}`); + } + } + const textFiles = evidence.files.filter((file): file is GithubSnapshotFile & { content: string } => !file.binary && typeof file.content === 'string'); + if (textFiles.length > 0) { + lines.push('', '## Design-Relevant Excerpts', ''); + for (const file of textFiles.slice(0, 12)) { + lines.push(`### ${file.repoPath}`, '', fencedExcerpt(file.repoPath, file.content), ''); + } + } + } + if (evidence.materializedFiles && evidence.materializedFiles.length > 0) { + lines.push('', '## Package Files Materialized', ''); + for (const file of evidence.materializedFiles) { + lines.push(`- \`${file}\``); + } + } + lines.push( + '', + '## Next Design-System Work', + '', + '- Use these local source paths and snapshots as evidence before writing `DESIGN.md`.', + '- Convert the inventory above into a Claude Design-style package: `README.md`, `SKILL.md`, `colors_and_type.css`, `preview/colors-*`, `preview/typography-specimens.html`, `preview/spacing-*`, `preview/components-*`, `preview/brand-assets.html`, `ui_kits/app/`, and preserved `assets/`, `build/`, or `fonts/` when evidence exists.', + '- `ui_kits/app/index.html` must be a browser-reviewable component entry: load `../../colors_and_type.css`, load or import at least three files from `ui_kits/app/components/`, and mount the composed UI through ReactDOM/Babel or compiled browser-ready JavaScript. Do not duplicate a static HTML mock when modular component files exist.', + '- `ui_kits/app/components/App.jsx` (or equivalent app shell) must compose source-backed role components such as Sidebar, AssistantsList, ChatArea, InputBar, and MessageBubble, not merely list their filenames.', + ...UI_KIT_ENTRY_GUIDANCE, + '- Preserve at least three high-signal source examples outside `context/` under `source_examples/` when reusable component snapshots exist, so future agents can compare generated components against original source structure.', + '- When a captured asset path begins with `build/`, copy the snapshot back into a root `build/` path with its original filename, such as `context/.../files/build/icon.png` -> `build/icon.png`. Do not satisfy build/runtime icon evidence by only renaming those files into `assets/`.', + '- Make `preview/brand-assets.html` visibly load preserved asset files from `assets/` or `build/`; do not redraw captured logos/icons as inline placeholders.', + '- Extract concrete colors, typography, spacing, radius, component behavior, assets, and product tone only when supported by inspected files.', + '- If evidence is missing or ambiguous, mark that uncertainty instead of inventing tokens.', + '', + ); + return lines.join('\n'); +} + +function buildDesignEvidenceInventory(files: GithubSnapshotFile[]): GithubEvidenceInventorySection[] { + const descriptions: Record = { + 'Product docs and manifests': 'Use these to understand product purpose, dependency stack, scripts, and public naming.', + 'Brand assets and icons': 'Preserve source build/runtime paths: files under `build/` should be copied back into root `build/` with their original filenames, while non-build logos, avatars, or wordmarks can be copied into `assets/`. Reflect the preserved files in `preview/brand-assets.html`.', + Fonts: 'Preserve source font files or declarations into `fonts/` and bind them in `colors_and_type.css` when applicable.', + 'Theme, tokens, and styling': 'Extract concrete color, typography, spacing, radius, shadow, and theme-variable values from these files.', + 'App shell and navigation': 'Use these to recreate the product frame, navigation density, sidebars, window chrome, and layout rhythm.', + 'Chat and input surfaces': 'Use these for the applied UI-kit surface and interaction model when the product includes chat or composer flows.', + 'Reusable components': 'Use these to derive buttons, inputs, cards, dialogs, avatars, selectors, menus, and feedback states.', + 'Other design evidence': 'Inspect these only after the primary design evidence above has been used.', + }; + const order: GithubEvidenceInventoryCategory[] = [ + 'Product docs and manifests', + 'Brand assets and icons', + 'Fonts', + 'Theme, tokens, and styling', + 'App shell and navigation', + 'Chat and input surfaces', + 'Reusable components', + 'Other design evidence', + ]; + const grouped = new Map(); + for (const file of files) { + const category = designEvidenceInventoryCategory(file.repoPath); + const files = grouped.get(category) ?? []; + files.push(file); + grouped.set(category, files); + } + return order + .map((title) => { + const files = grouped.get(title) ?? []; + return { title, description: descriptions[title], files }; + }) + .filter((section) => section.files.length > 0); +} + +function designEvidenceInventoryCategory(repoPath: string): GithubEvidenceInventoryCategory { + const normalized = repoPath.toLowerCase(); + if (/(^|\/)(readme\.(md|mdx|txt|rst)|package\.json)$/u.test(normalized)) { + return 'Product docs and manifests'; + } + if (/(^|\/)(assets?|public|resources|build)\/.*(logo|icon|avatar|tray|brand|wordmark|mark)[^/]*\.(svg|png|jpe?g|webp|ico)$/u.test(normalized)) { + return 'Brand assets and icons'; + } + if (/(^|\/)(fonts?|assets?\/fonts?|public\/fonts?|resources\/fonts?)\/.*\.(ttf|otf|woff2?)$/u.test(normalized) || /\.(ttf|otf|woff2?)$/u.test(normalized)) { + return 'Fonts'; + } + if (/(^|\/)(tailwind|theme|themes?|themeprovider|antdprovider|tokens?|colors?|typography|design-system|design|constant|constants|env|style|styles)\.(config\.)?(ts|tsx|js|jsx|json|css|scss|less|md)$/u.test(normalized) + || /\/(context|providers?|theme|styles?|config|utils?)\//u.test(normalized) + || /\.(css|scss|less)$/u.test(normalized)) { + return 'Theme, tokens, and styling'; + } + if (/\/(app|layout|shell|navbar|sidebar|nav|chrome)\//u.test(normalized) + || /\/pages\/home\/(homepage|navbar)\.(tsx|ts|jsx|js|css|scss)$/u.test(normalized) + || /(navbar|sidebar|layout|shell|window|workspace)\.(tsx|ts|jsx|js|css|scss)$/u.test(normalized)) { + return 'App shell and navigation'; + } + if (/\/(chat|inputbar|composer|messages?|assistants?|topics?|models?)\//u.test(normalized) + || /(chat|inputbar|composer|message|assistant|topic|selectmodel|updateapp|model)\.(tsx|ts|jsx|js|css|scss)$/u.test(normalized)) { + return 'Chat and input surfaces'; + } + if (/\/(components?|ui|primitives?)\//u.test(normalized) + || /(button|card|dialog|modal|input|form|table|badge|avatar|toast|menu|tabs|popover|select|settings)\.(tsx|ts|jsx|js|css|scss)$/u.test(normalized)) { + return 'Reusable components'; + } + return 'Other design evidence'; +} + +function excerpt(content: string): string { + return content.length > MAX_MARKDOWN_EXCERPT_CHARS + ? `${content.slice(0, MAX_MARKDOWN_EXCERPT_CHARS)}\n...` + : content; +} + +function fencedExcerpt(repoPath: string, content: string): string { + const ext = path.extname(repoPath).replace('.', '').toLowerCase(); + const info = ext === 'tsx' || ext === 'ts' || ext === 'jsx' || ext === 'js' ? ext : ext === 'json' ? 'json' : ext === 'css' || ext === 'scss' || ext === 'less' ? ext : ''; + return `\`\`\`${info}\n${excerpt(content)}\n\`\`\``; +} + +async function runGithubDesignContext(options: ParsedOptions): Promise { + if (!options.repo) return fail('github-design-context requires --repo owner/repo'); + const repo = parseGithubRepo(options.repo); + const maxFiles = options.maxFiles ?? DEFAULT_GITHUB_CONTEXT_MAX_FILES; + const outputPath = options.outputPath ?? defaultGithubContextOutputPath(repo); + const baseUrl = daemonUrl(); + const token = toolToken(); + let evidence: GithubDesignEvidence; + + try { + evidence = await collectGithubEvidenceWithGitClone(repo, { + ...(options.ref === undefined ? {} : { ref: options.ref }), + maxFiles, + }); + } catch (localError) { + const localReason = localError instanceof Error ? localError.message : String(localError); + const connectorReady = !('error' in baseUrl) && typeof token === 'string'; + if (connectorReady) { + let connectorReason: string | undefined; + try { + evidence = await collectGithubEvidenceWithConnector(baseUrl, token, repo, { + ...(options.ref === undefined ? {} : { ref: options.ref }), + maxFiles, + }); + if (connectorEvidenceNeedsCloneFallback(evidence)) { + throw new Error('GitHub connector bounded intake produced no snapshot files.'); + } + evidence.warnings.unshift( + `This-device GitHub intake failed; used Composio GitHub connector fallback. Reason: ${localReason}`, + ); + } catch (connectorError) { + connectorReason = connectorError instanceof Error ? connectorError.message : String(connectorError); + if (options.requireConnector) { + return fail('Required GitHub repository intake could not read the repository through git, GitHub CLI, or connector', { + repo: `${repo.owner}/${repo.repo}`, + localReason, + connectorReason, + nextStep: 'Run `gh auth login --web`, configure local git credentials, or connect GitHub through Composio with access to this repository. Do not draft design-system files from URL text alone.', + }); + } + throw new Error( + `GitHub repository intake failed through this device and connector fallback. This device: ${localReason}; Connector: ${connectorReason}`, + ); + } + } else { + const connectorReason = 'error' in baseUrl + ? baseUrl.error + : typeof token === 'string' + ? 'OD_TOOL_TOKEN is not available' + : token.error; + if (options.requireConnector) { + return fail('Required GitHub repository intake could not read the repository through git, GitHub CLI, or connector', { + repo: `${repo.owner}/${repo.repo}`, + localReason, + connectorReason, + nextStep: 'Run `gh auth login --web`, configure local git credentials, or connect GitHub through Composio with access to this repository. Do not draft design-system files from URL text alone.', + }); + } + throw localError; + } + } + + const written = await writeGithubDesignEvidence(outputPath, evidence); + writeJson({ + ok: true, + repo: `${repo.owner}/${repo.repo}`, + method: written.method, + ...(written.localCloneMethod === undefined ? {} : { localCloneMethod: written.localCloneMethod }), + outputPath: path.relative(process.cwd(), path.resolve(outputPath)).split(path.sep).join('/'), + snapshotFiles: written.files.map((file) => file.outputPath).filter(Boolean), + materializedFiles: written.materializedFiles ?? [], + warnings: written.warnings, + }); + return { exitCode: 0 }; +} + +async function runLocalDesignContext(options: ParsedOptions): Promise { + if (!options.localPath) return fail('local-design-context requires --path /path/to/project'); + const maxFiles = options.maxFiles ?? DEFAULT_LOCAL_CONTEXT_MAX_FILES; + const outputPath = options.outputPath ?? defaultLocalContextOutputPath(options.localPath); + const evidence = await collectLocalDesignEvidence(options.localPath, { maxFiles }); + const written = await writeLocalDesignEvidence(outputPath, evidence); + writeJson({ + ok: true, + sourcePath: written.sourcePath, + method: written.method, + outputPath: path.relative(process.cwd(), path.resolve(outputPath)).split(path.sep).join('/'), + snapshotFiles: written.files.map((file) => file.outputPath).filter(Boolean), + materializedFiles: written.materializedFiles ?? [], + warnings: written.warnings, + }); + return { exitCode: 0 }; +} + +async function runDesignSystemPackageAudit(options: ParsedOptions): Promise { + const projectPath = path.resolve(options.localPath ?? '.'); + const audit = await auditDesignSystemPackage(projectPath, { referencePackage: options.referencePackage === true }); + const ok = audit.ok && (options.failOnWarnings !== true || audit.warnings.length === 0); + writeJson(options.failOnWarnings === true ? { ...audit, ok } : audit); + return { exitCode: ok ? 0 : 1 }; +} + +export async function auditDesignSystemPackage( + projectPath: string, + options: { referencePackage?: boolean } = {}, +): Promise { + const projectStat = await stat(projectPath); + if (!projectStat.isDirectory()) { + throw new Error(`design-system-package-audit requires --path to be a directory: ${projectPath}`); + } + const files = await listAuditFiles(projectPath); + const fileSet = new Set(files); + const issues: DesignSystemAuditIssue[] = []; + const addIssue = (severity: DesignSystemAuditSeverity, code: string, message: string, issuePath?: string) => { + issues.push({ + severity, + code, + message, + ...(issuePath === undefined ? {} : { path: issuePath }), + }); + }; + const requireFile = (filePath: string, message: string) => { + if (!fileSet.has(filePath)) addIssue('error', 'missing_required_file', message, filePath); + }; + const requireContent = async ( + filePath: string, + minBytes: number, + code: string, + message: string, + validate?: (text: string) => string | undefined, + ) => { + if (!fileSet.has(filePath)) return; + const text = await readAuditText(projectPath, filePath); + if (text === undefined) return; + if (Buffer.byteLength(text, 'utf8') < minBytes) { + addIssue('error', code, message, filePath); + return; + } + const validationMessage = validate?.(text); + if (validationMessage) addIssue('error', code, validationMessage, filePath); + }; + + if (options.referencePackage === true) { + if (!fileSet.has('DESIGN.md')) { + addIssue('warning', 'missing_open_design_rules', 'Reference packages may omit DESIGN.md, but generated Open Design packages must include it as the canonical rules file.', 'DESIGN.md'); + } + } else { + requireFile('DESIGN.md', 'Claude Design-style packages need DESIGN.md as the canonical system rules.'); + } + requireFile('README.md', 'Claude Design-style packages need README.md so the system is reusable outside the current run.'); + requireFile('SKILL.md', 'Claude Design-style packages need SKILL.md with agent-facing usage instructions.'); + requireFile('colors_and_type.css', 'Claude Design-style packages need colors_and_type.css for reusable color, type, spacing, radius, and state tokens.'); + await requireContent('DESIGN.md', 800, 'thin_design_rules', 'DESIGN.md is too thin to be a reusable rules document; include source-backed context, foundations, tokens, components, motion, voice, and anti-patterns.', validateDesignRules); + await requireContent('README.md', 600, 'thin_readme', 'README.md is too thin to explain the package, source evidence, generated files, and reuse workflow.', requireMarkdownHeading); + await requireContent('SKILL.md', 500, 'thin_skill', 'SKILL.md is too thin to guide future agents on how to use this design system.', validateSkillInstructions); + await requireContent('colors_and_type.css', 500, 'thin_token_css', 'colors_and_type.css is too thin to carry reusable color, typography, spacing, radius, and state tokens.', validateTokenCss); + if (fileSet.has('SKILL.md')) { + const skillText = await readAuditText(projectPath, 'SKILL.md'); + if (skillText !== undefined && !skillHasAgentFrontmatter(skillText)) { + addIssue( + 'warning', + 'missing_skill_frontmatter', + 'SKILL.md should include Claude-style YAML frontmatter with name, description, and user-invocable so future agents can discover and invoke the design system package.', + 'SKILL.md', + ); + } + if (skillText !== undefined && !skillHasReusableSections(skillText)) { + addIssue( + 'warning', + 'skill_missing_reuse_sections', + 'SKILL.md should read like a reusable Claude Design skill package: include What is inside, Source context, When to use, How to use, and design-system highlights grounded in source evidence.', + 'SKILL.md', + ); + } + } + const readmeText = fileSet.has('README.md') ? await readAuditText(projectPath, 'README.md') : undefined; + if (fileSet.has('README.md')) { + if (readmeText !== undefined && !readmeHasProductOverview(readmeText)) { + addIssue( + 'warning', + 'readme_missing_product_overview', + 'README.md should include a Claude-style Product Overview or Product Context section that explains the source product, primary surfaces, and core capabilities instead of only listing tokens or generated files.', + 'README.md', + ); + } + if (readmeText !== undefined && !readmeHasPackageReuseGuide(readmeText)) { + addIssue( + 'warning', + 'readme_missing_package_reuse_guide', + 'README.md should work as a Claude Design package guide: list source/context references, package contents, preview cards, preserved assets/fonts/build artifacts, ui_kits/app, and a concrete reuse or review workflow.', + 'README.md', + ); + } + } + for (const docPath of ['DESIGN.md', 'README.md', 'SKILL.md', 'ui_kits/app/README.md']) { + if (!fileSet.has(docPath)) continue; + const text = await readAuditText(projectPath, docPath); + const staleReferences = text ? stalePackageReferences(text) : []; + if (staleReferences.length > 0) { + addIssue( + options.referencePackage === true ? 'warning' : 'error', + 'stale_package_manifest_references', + `Package documentation still references old scaffold paths: ${staleReferences.join(', ')}. Rewrite it to point at preview/* focused cards and ui_kits/app/.`, + docPath, + ); + } + } + for (const filePath of protocolTitleAuditFiles(files)) { + const text = await readAuditText(projectPath, filePath); + const protocolTitle = text ? protocolDerivedDesignSystemTitle(text) : undefined; + if (!protocolTitle) continue; + addIssue( + options.referencePackage === true ? 'warning' : 'error', + 'protocol_derived_title', + `${filePath} uses "${protocolTitle}" as a product/design-system title. Derive the package title from source evidence or repository slug instead of URL protocol text.`, + filePath, + ); + } + + const previewFiles = files.filter((filePath) => /^preview\/.+\.html$/u.test(filePath)); + if (previewFiles.length < 6) { + addIssue('error', 'insufficient_preview_cards', `Expected at least 6 focused preview HTML cards, found ${previewFiles.length}.`, 'preview/'); + } + requirePreviewCategory(previewFiles, /^preview\/colors-[^/]+\.html$/u, 'missing_color_preview', 'Expected at least one focused color preview card such as preview/colors-primary.html.', addIssue); + requirePreviewCategory(previewFiles, /^preview\/typography-specimens\.html$/u, 'missing_typography_preview', 'Expected preview/typography-specimens.html.', addIssue); + requirePreviewCategory(previewFiles, /^preview\/spacing-[^/]+\.html$/u, 'missing_spacing_preview', 'Expected at least one focused spacing preview card such as preview/spacing-tokens.html.', addIssue); + requirePreviewCategory(previewFiles, /^preview\/components-[^/]+\.html$/u, 'missing_component_preview', 'Expected at least one focused component preview card such as preview/components-buttons.html.', addIssue); + if (readmeText !== undefined && !readmeHasPreviewManifest(readmeText, previewFiles)) { + addIssue( + 'warning', + 'readme_missing_preview_manifest', + 'README.md should include a concrete preview manifest that lists the generated preview/*.html cards so reviewers and future agents know what to inspect.', + 'README.md', + ); + } + + const oldPreviewFiles = previewFiles.filter((filePath) => /preview\/(colors-node-types|colors-ui-palette|typography-scale|spacing-system|logo-variants)\.html$/u.test(filePath)); + if (oldPreviewFiles.length > 0) { + addIssue('warning', 'old_generic_preview_names', `Replace old generic preview names with Claude-style focused cards: ${oldPreviewFiles.join(', ')}.`, 'preview/'); + } + if (files.some((filePath) => filePath.startsWith('ui_kits/generated_interface/'))) { + const level = fileSet.has('ui_kits/app/index.html') ? 'warning' : 'error'; + addIssue(level, 'old_generated_interface', 'Replace ui_kits/generated_interface/ with the reusable Claude-style ui_kits/app/ package.', 'ui_kits/generated_interface/'); + } + + requireFile('ui_kits/app/index.html', 'Claude Design-style packages need an applied interface kit at ui_kits/app/index.html.'); + await requireContent('ui_kits/app/index.html', 900, 'thin_ui_kit', 'ui_kits/app/index.html is too thin; include an applied interface example with real layout, components, and states.', validateHtmlDocument); + if (!fileSet.has('ui_kits/app/README.md')) { + addIssue('warning', 'missing_ui_kit_readme', 'Add ui_kits/app/README.md so future projects know how to reuse the applied UI kit.', 'ui_kits/app/README.md'); + } else { + const uiKitReadmeText = await readAuditText(projectPath, 'ui_kits/app/README.md'); + if (uiKitReadmeText !== undefined && !uiKitReadmeHasReuseGuide(uiKitReadmeText)) { + addIssue( + 'warning', + 'ui_kit_readme_missing_reuse_guide', + 'ui_kits/app/README.md should document the applied kit structure, component files, usage workflow, design notes, and source basis so future agents can reuse it like a Claude Design package.', + 'ui_kits/app/README.md', + ); + } + } + await Promise.all(previewFiles.map((filePath) => + requireContent(filePath, 900, 'thin_preview_card', `${filePath} is too thin to be a reviewable focused preview card.`, validateHtmlDocument), + )); + + const sourceManifest = await readAuditText(projectPath, 'context/source-context.md'); + const evidenceNotes = files.filter((filePath) => /^context\/(github|local-code)\/[^/]+\.md$/u.test(filePath)); + const evidenceTexts = await Promise.all(evidenceNotes.map(async (filePath) => ({ + filePath, + text: await readAuditText(projectPath, filePath) ?? '', + }))); + const evidenceText = evidenceTexts.map((item) => item.text).join('\n'); + if (sourceManifest !== undefined) { + if (manifestHasLinkedGithub(sourceManifest) && !evidenceNotes.some((filePath) => filePath.startsWith('context/github/'))) { + addIssue('error', 'missing_github_evidence', 'Linked GitHub repositories require context/github/*.md evidence notes before final design-system files are trusted.', 'context/github/'); + } + if (manifestHasLinkedLocalFolder(sourceManifest) && !evidenceNotes.some((filePath) => filePath.startsWith('context/local-code/'))) { + addIssue('error', 'missing_local_evidence', 'Linked local folders require context/local-code/*.md evidence notes before final design-system files are trusted.', 'context/local-code/'); + } + } + for (const evidence of evidenceTexts) { + if (/Snapshot files written:\s*0\b/iu.test(evidence.text)) { + addIssue('error', 'empty_evidence_snapshot', 'Evidence note reports zero snapshot files; rerun bounded intake before drafting final artifacts.', evidence.filePath); + } + } + if (evidenceNotes.length > 0 && !files.some((filePath) => /^context\/(github|local-code)\/[^/]+\/files\//u.test(filePath))) { + addIssue('error', 'missing_evidence_snapshot_files', 'Evidence notes exist but no command-written snapshot files were found under context/github/*/files/ or context/local-code/*/files/.', 'context/'); + } + + const hasAssetEvidence = evidenceHasAssets(evidenceText) || files.some((filePath) => /^context\/(github|local-code)\/.+\/files\/.+\.(svg|png|jpe?g|webp|ico)$/iu.test(filePath)); + const hasFontEvidence = evidenceHasFonts(evidenceText) || files.some((filePath) => /^context\/(github|local-code)\/.+\/files\/.+\.(ttf|otf|woff2?)$/iu.test(filePath)); + const evidenceAssetFiles = evidenceSnapshotFiles(files, evidenceText, /\.(svg|png|jpe?g|webp|ico)$/iu); + const evidenceBuildAssetFiles = evidenceSnapshotFiles(files, evidenceText, /(^|\/)(build|resources|public-resources)\/[^`\s)]*(logo|icon|tray|wordmark|mark)[^/]*\.(svg|png|jpe?g|webp|ico)$/iu); + const evidenceFontFiles = evidenceSnapshotFiles(files, evidenceText, /\.(ttf|otf|woff2?)$/iu); + const preservedAssetFiles = files.filter((filePath) => /^assets\/.+\.(svg|png|jpe?g|webp|ico)$/iu.test(filePath)); + const preservedBuildAssetFiles = files.filter((filePath) => /^build\/.+\.(svg|png|jpe?g|webp|ico)$/iu.test(filePath)); + const preservedFontFiles = files.filter((filePath) => /^fonts\/.+\.(ttf|otf|woff2?|css)$/iu.test(filePath)); + const evidenceComponentNames = sourceComponentNamesFromEvidence(files, evidenceText); + const evidenceSurfaceComponentNames = evidenceComponentNames.filter(isSourceSurfaceComponentName); + const suggestedComponentNames = evidenceSurfaceComponentNames.length >= 3 + ? evidenceSurfaceComponentNames + : evidenceComponentNames; + const visualSourceAnchors = await sourceComponentAnchorsInVisualArtifacts(projectPath, files, evidenceComponentNames); + const componentPreviewGaps = await sourceComponentPreviewGaps(projectPath, previewFiles, evidenceSurfaceComponentNames); + const sourceExampleAnchors = sourceComponentExamplesInPackage(files, evidenceComponentNames); + const hasComponentEvidence = evidenceHasReusableComponents(evidenceText) + || files.some((filePath) => /^context\/(github|local-code)\/.+\/files\/.+(?:\/|^)(components?|ui|app|layout|shell|navbar|sidebar|chat|input|composer|assistant|message|model)[^/]*\/?.*\.(tsx|ts|jsx|js|css|scss|less)$/iu.test(filePath)); + const hasChatUiEvidence = evidenceHasChatInterface(evidenceText) + || files.some((filePath) => /^context\/(github|local-code)\/.+\/files\/.+(?:pages\/home|components\/app|inputbar|messages?|chat|assistants?|sidebar).*\.(tsx|ts|jsx|js|css|scss|less)$/iu.test(filePath)); + const uiKitComponentFiles = files.filter((filePath) => /^ui_kits\/app\/components\/.+\.(jsx|tsx|js|ts|css|html)$/iu.test(filePath)); + const uiKitScriptComponentFiles = uiKitComponentFiles.filter((filePath) => /\.(jsx|tsx|js|ts)$/iu.test(filePath)); + const uiKitIndexText = await readAuditText(projectPath, 'ui_kits/app/index.html'); + if (fileSet.has('colors_and_type.css') && uiKitIndexText !== undefined && !/colors_and_type\.css/iu.test(uiKitIndexText)) { + addIssue( + 'error', + 'ui_kit_missing_token_stylesheet', + 'ui_kits/app/index.html must load colors_and_type.css so the applied interface kit uses the extracted design tokens.', + 'ui_kits/app/index.html', + ); + } + if (uiKitComponentFiles.length >= 3 && uiKitIndexText !== undefined) { + const referencedComponents = uiKitComponentFiles.filter((filePath) => + uiKitIndexText.includes(path.basename(filePath)), + ); + const requiredReferences = Math.min(3, uiKitComponentFiles.length); + if (referencedComponents.length < requiredReferences) { + addIssue( + 'error', + 'ui_kit_index_missing_component_references', + `ui_kits/app/index.html must load or import at least ${requiredReferences} modular UI-kit component file(s) from ui_kits/app/components/. Found ${referencedComponents.length}.`, + 'ui_kits/app/index.html', + ); + } + } + if (uiKitScriptComponentFiles.length >= 3 && uiKitIndexText !== undefined) { + if (!uiKitIndexHasRuntimeBootstrap(uiKitIndexText)) { + addIssue( + 'error', + 'ui_kit_index_missing_runtime_bootstrap', + 'ui_kits/app/index.html must mount or render the applied UI kit so reviewers see a real composed interface, not only disconnected component files.', + 'ui_kits/app/index.html', + ); + } + const composedComponents = componentNamesComposedInUiKitIndex(uiKitIndexText, uiKitScriptComponentFiles); + if (composedComponents.length === 0) { + addIssue( + 'error', + 'ui_kit_index_missing_component_composition', + 'ui_kits/app/index.html must compose at least one modular UI-kit component in the rendered entry surface, not only list component filenames.', + 'ui_kits/app/index.html', + ); + } + if (uiKitIndexLoadsJsxComponents(uiKitIndexText, uiKitScriptComponentFiles) && !uiKitIndexHasBrowserJsxRuntime(uiKitIndexText)) { + addIssue( + 'error', + 'ui_kit_index_missing_jsx_runtime', + 'ui_kits/app/index.html directly loads JSX/TSX component files, so it must include React, ReactDOM, and Babel standalone scripts or use compiled browser-ready JavaScript instead.', + 'ui_kits/app/index.html', + ); + } + const directlyLoadedJsxComponents = directScriptLoadedJsxComponents(uiKitIndexText, uiKitScriptComponentFiles); + for (const filePath of directlyLoadedJsxComponents) { + const componentText = await readAuditText(projectPath, filePath); + const componentName = componentNameFromUiKitFile(filePath); + if (componentText !== undefined && componentName !== undefined && !componentTextExposesBrowserGlobal(componentText, componentName)) { + addIssue( + 'error', + 'ui_kit_component_missing_browser_global', + `${filePath} is loaded by ui_kits/app/index.html as a browser script, so it must assign \`window.${componentName}\` or \`globalThis.${componentName}\` for the entry renderer to compose it.`, + filePath, + ); + } + } + } + if (hasComponentEvidence && uiKitComponentFiles.length < 3) { + addIssue( + 'error', + 'missing_modular_ui_kit', + `Source evidence includes reusable product components; add at least 3 reusable files under ui_kits/app/components/. Found ${uiKitComponentFiles.length}.`, + 'ui_kits/app/components/', + ); + } + if (hasComponentEvidence && uiKitComponentFiles.length >= 3) { + const componentByteTotal = await totalAuditBytes(projectPath, uiKitComponentFiles); + if (componentByteTotal < 3000) { + addIssue( + 'error', + 'thin_modular_ui_kit', + `ui_kits/app/components/ is too thin for source-backed component evidence; expected at least 3000 bytes across reusable components, found ${componentByteTotal}.`, + 'ui_kits/app/components/', + ); + } + } + if (hasChatUiEvidence) { + const missingRoles = missingUiKitComponentRoles(uiKitComponentFiles); + if (missingRoles.length > 0) { + addIssue( + 'error', + 'missing_ui_kit_component_roles', + `Chat/workspace evidence requires UI kit components covering these roles: ${missingRoles.join(', ')}.`, + 'ui_kits/app/components/', + ); + } + const appShellFiles = uiKitScriptComponentFiles.filter(isUiKitAppShellComponent); + if (appShellFiles.length > 0 && uiKitScriptComponentFiles.length >= 4) { + const bestComposition = await bestUiKitAppShellComposition(projectPath, appShellFiles, uiKitScriptComponentFiles); + const requiredComposedRoles = Math.min(3, uiKitScriptComponentFiles.length - 1); + if (bestComposition.composed.length < requiredComposedRoles) { + addIssue( + 'error', + 'ui_kit_app_missing_role_composition', + `Chat/workspace UI kits need an app shell component that composes at least ${requiredComposedRoles} role component(s) such as Sidebar, AssistantsList, ChatArea, InputBar, or MessageBubble. Found ${bestComposition.composed.length}.`, + bestComposition.filePath ?? 'ui_kits/app/components/', + ); + } + } + } + if (hasComponentEvidence && evidenceComponentNames.length >= 6 && visualSourceAnchors.length < 3) { + addIssue( + 'warning', + 'generic_visual_artifacts', + `Source evidence includes ${evidenceComponentNames.length} component snapshots, but preview/UI-kit visuals only reference ${visualSourceAnchors.length} source component name(s). Model or label at least 3 source-backed components such as ${suggestedComponentNames.slice(0, 5).join(', ')}.`, + 'preview/', + ); + } + if (hasComponentEvidence && evidenceSurfaceComponentNames.length >= 3 && componentPreviewGaps.length > 0) { + addIssue( + 'warning', + 'preview_cards_missing_source_component_context', + `Focused component/spacing preview cards should model or label real source components, not only abstract token swatches. Add source-backed examples to ${componentPreviewGaps.slice(0, 6).join(', ')} using components such as ${evidenceSurfaceComponentNames.slice(0, 5).join(', ')}.`, + 'preview/', + ); + } + if (hasComponentEvidence && evidenceComponentNames.length >= 6 && sourceExampleAnchors.length < 3) { + addIssue( + 'warning', + 'missing_source_component_examples', + `Source evidence includes ${evidenceComponentNames.length} component snapshots, but the package preserves only ${sourceExampleAnchors.length} source-backed component example(s) outside context/. Copy at least 3 high-signal examples such as ${suggestedComponentNames.slice(0, 5).join(', ')} into source_examples/, a component examples folder, or root/nested TSX files like Claude Design exports.`, + 'source_examples/', + ); + } + if (hasComponentEvidence && evidenceComponentNames.length >= 6 && sourceExampleAnchors.length >= 3) { + const sourceExampleBytes = await totalAuditBytes(projectPath, sourceExampleAnchors); + if (sourceExampleBytes < 2400) { + addIssue( + 'warning', + 'thin_source_component_examples', + `Source examples should preserve substantive component code, not filename-only stubs. Found ${sourceExampleAnchors.length} source-backed example file(s) totaling ${sourceExampleBytes} bytes; preserve larger high-signal examples from the original evidence, similar to Claude Design exports.`, + 'source_examples/', + ); + } + } + if (hasAssetEvidence) { + if (preservedAssetFiles.length === 0) { + addIssue('error', 'missing_preserved_assets', 'Source evidence includes brand assets; preserve selected logos/icons/avatars under assets/.', 'assets/'); + } + if (evidenceAssetFiles.length >= 3 && preservedAssetFiles.length < 3) { + addIssue( + 'error', + 'insufficient_preserved_assets', + `Source evidence includes ${evidenceAssetFiles.length} brand asset snapshots; preserve at least 3 representative logos/icons/avatars under assets/. Found ${preservedAssetFiles.length}.`, + 'assets/', + ); + } + if (!fileSet.has('preview/brand-assets.html')) { + addIssue('error', 'missing_brand_assets_preview', 'Source evidence includes brand assets; add preview/brand-assets.html.', 'preview/brand-assets.html'); + } + } + const preservedBrandAssetFiles = [...preservedAssetFiles, ...preservedBuildAssetFiles]; + if (preservedBrandAssetFiles.length > 0 && fileSet.has('preview/brand-assets.html')) { + const brandAssetPreview = await readAuditText(projectPath, 'preview/brand-assets.html'); + const referencedAssets = brandAssetPreview === undefined ? [] : preservedAssetsReferencedInPreview(brandAssetPreview, preservedBrandAssetFiles); + const requiredAssetReferences = Math.min(2, preservedBrandAssetFiles.length); + if (referencedAssets.length < requiredAssetReferences) { + addIssue( + 'warning', + 'brand_assets_preview_not_using_preserved_assets', + `preview/brand-assets.html should visibly reference at least ${requiredAssetReferences} preserved asset file(s) from assets/ or build/ so the review card shows real logos/icons instead of generated placeholders. Found ${referencedAssets.length}.`, + 'preview/brand-assets.html', + ); + } + } + if (evidenceBuildAssetFiles.length > 0 && preservedBuildAssetFiles.length === 0) { + addIssue( + 'warning', + 'missing_build_assets', + `Source evidence includes ${evidenceBuildAssetFiles.length} build/runtime icon asset(s); preserve representative app, installer, tray, or wordmark files under build/ like Claude Design exports instead of collapsing them into prose.`, + 'build/', + ); + } + if (evidenceBuildAssetFiles.length > 0 && preservedBuildAssetFiles.length > 0) { + const sourceBackedBuildAssets = await sourceBackedBuildAssetFiles(projectPath, fileSet, evidenceBuildAssetFiles); + if (sourceBackedBuildAssets.length === 0) { + addIssue( + 'warning', + 'build_assets_not_source_backed', + `Root build/ contains preserved-looking runtime assets, but none match the captured build/resource snapshots byte-for-byte. Copy representative originals such as ${evidenceBuildAssetFiles.slice(0, 3).join(', ')} into build/ with original filenames instead of redrawing or re-encoding placeholders.`, + 'build/', + ); + } + } + if (hasFontEvidence) { + if (preservedFontFiles.length === 0) { + addIssue('error', 'missing_preserved_fonts', 'Source evidence includes font files; preserve selected fonts under fonts/ and bind them in colors_and_type.css.', 'fonts/'); + } + const tokenCss = await readAuditText(projectPath, 'colors_and_type.css'); + if (preservedFontFiles.length > 0 && tokenCss !== undefined && !tokenCssBindsPreservedFonts(tokenCss, preservedFontFiles)) { + addIssue( + 'error', + 'font_tokens_not_bound', + 'Source font files are preserved under fonts/, but colors_and_type.css does not bind them with @font-face, @import, or a url(...) reference to the preserved font files.', + 'colors_and_type.css', + ); + } + if (evidenceFontFiles.length >= 3 && preservedFontFiles.length < 3) { + addIssue( + 'error', + 'insufficient_preserved_fonts', + `Source evidence includes ${evidenceFontFiles.length} font snapshots; preserve at least 3 representative font files or declarations under fonts/. Found ${preservedFontFiles.length}.`, + 'fonts/', + ); + } + } + + const errors = issues.filter((issue) => issue.severity === 'error'); + const warnings = issues.filter((issue) => issue.severity === 'warning'); + return { + ok: errors.length === 0, + projectPath, + filesInspected: files.length, + errors, + warnings, + }; +} + +function requirePreviewCategory( + previewFiles: string[], + pattern: RegExp, + code: string, + message: string, + addIssue: (severity: DesignSystemAuditSeverity, code: string, message: string, path?: string) => void, +): void { + if (!previewFiles.some((filePath) => pattern.test(filePath))) { + addIssue('error', code, message, 'preview/'); + } +} + +async function readAuditText(projectPath: string, relativePath: string): Promise { + try { + return await readFile(path.join(projectPath, relativePath), 'utf8'); + } catch { + return undefined; + } +} + +async function sourceBackedBuildAssetFiles( + projectPath: string, + fileSet: Set, + evidenceBuildAssetFiles: string[], +): Promise { + const matchedFiles: string[] = []; + const seenTargets = new Set(); + for (const evidenceFilePath of evidenceBuildAssetFiles) { + if (!fileSet.has(evidenceFilePath)) continue; + const repoPath = repoPathFromEvidenceSnapshot(evidenceFilePath); + if (repoPath === undefined) continue; + const target = packageBuildAssetTarget(repoPath); + if (target === undefined || seenTargets.has(target) || !fileSet.has(target)) continue; + seenTargets.add(target); + try { + const [sourceBytes, targetBytes] = await Promise.all([ + readFile(path.join(projectPath, evidenceFilePath)), + readFile(path.join(projectPath, target)), + ]); + if (sourceBytes.equals(targetBytes)) matchedFiles.push(target); + } catch { + // Missing or unreadable files are already covered by structural audit checks. + } + } + return matchedFiles; +} + +function repoPathFromEvidenceSnapshot(filePath: string): string | undefined { + const match = /^context\/(?:github|local-code)\/[^/]+\/files\/(.+)$/u.exec(filePath); + return match?.[1]; +} + +async function totalAuditBytes(projectPath: string, relativePaths: string[]): Promise { + let total = 0; + for (const relativePath of relativePaths) { + try { + const info = await stat(path.join(projectPath, relativePath)); + if (info.isFile()) total += info.size; + } catch { + // Missing files are reported by the caller's structural checks. + } + } + return total; +} + +function requireMarkdownHeading(text: string): string | undefined { + return /^#\s+\S+/mu.test(text) ? undefined : 'Expected a top-level markdown heading.'; +} + +function validateSkillInstructions(text: string): string | undefined { + if (requireMarkdownHeading(text) === undefined) return undefined; + if (/^---\n[\s\S]*?\n---/u.test(text) && /^description:\s+\S+/mu.test(text) && /\*\*How to use:\*\*/iu.test(text)) { + return undefined; + } + return 'Expected a top-level markdown heading or skill frontmatter with usage instructions.'; +} + +function skillHasAgentFrontmatter(text: string): boolean { + const match = /^---\n([\s\S]*?)\n---/u.exec(text); + if (!match) return false; + const frontmatter = match[1] ?? ''; + return /^name:\s+\S+/mu.test(frontmatter) + && /^description:\s+\S+/mu.test(frontmatter) + && /^user-invocable:\s+(true|false)/imu.test(frontmatter); +} + +function skillHasReusableSections(text: string): boolean { + if (text.trim().length < 800) return false; + const hasInside = (/\*\*What's inside:\*\*/iu.test(text) || /^##\s+(?:What's inside|Contents)\s*$/imu.test(text)) + && /\b(tokens?|assets?|fonts?|preview|ui\s*kit|components?)\b/iu.test(text); + const hasSourceContext = (/\*\*Source context:\*\*/iu.test(text) || /^##\s+(?:Source Context|Source)\s*$/imu.test(text)) + && /\b(source|repository|github|local|based on|evidence)\b/iu.test(text); + const hasWhenToUse = (/\*\*When to use(?: this skill)?:\*\*/iu.test(text) || /^##\s+When to use(?: this skill)?\s*$/imu.test(text)) + && /\b(prototypes?|mockups?|interfaces?|artifacts?|production|design|build(?:ing)?)\b/iu.test(text); + const hasHowToUse = (/\*\*How to use:\*\*/iu.test(text) || /^##\s+(?:How to use|Usage)\s*$/imu.test(text)) + && /\b(README\.md|DESIGN\.md|colors_and_type\.css|preview\/|assets\/|build\/|fonts\/|ui_kits\/app)\b/iu.test(text); + const hasHighlights = (/\*\*Design system highlights:\*\*/iu.test(text) || /^##\s+(?:(?:Design System|Design) )?Highlights\s*$/imu.test(text)) + && /\b(colors?|typography|spacing|radius|shadows?|icons?|layout|interaction)\b/iu.test(text); + return hasInside && hasSourceContext && hasWhenToUse && hasHowToUse && hasHighlights; +} + +function readmeHasProductOverview(text: string): boolean { + const section = [ + markdownSection(text, 'Product Overview'), + markdownSection(text, 'Product Context'), + markdownSection(text, 'Overview'), + ].find((value): value is string => value !== undefined && value.trim().length > 0); + if (section === undefined) return false; + const body = section.trim(); + return body.length >= 180 + && /\b(product|app|application|workspace|client|platform|tool|service)\b/iu.test(body) + && /\b(supports?|provides?|features?|includes?|built|designed|helps?|enables?|offers?)\b/iu.test(body); +} + +function readmeHasPackageReuseGuide(text: string): boolean { + const hasPackageContents = /##\s+(?:Package Contents|What's inside|Contents|Files)\b/iu.test(text) + && /\bDESIGN\.md\b/iu.test(text) + && /\bcolors_and_type\.css\b/iu.test(text) + && /\bpreview\//iu.test(text) + && /\bui_kits\/app\/?\b/iu.test(text); + const hasSourceContext = /##\s+(?:Source Context|Source Evidence|Sources?|Product Overview|Product Context)\b/iu.test(text) + && /\b(?:GitHub|repository|source|evidence|context\/|local folder)\b/iu.test(text); + const hasPreservedArtifacts = /\b(?:assets\/|build\/|fonts\/|source_examples\/)\b/iu.test(text) + && /\b(?:preserv|source-backed|captured|runtime|brand|font|component)\b/iu.test(text); + const hasReuseWorkflow = /##\s+(?:Review Workflow|Reuse Workflow|Usage|How to use|Workflow)\b/iu.test(text) + && /\b(?:reuse|review|inspect|copy|load|compose|start with|open)\b/iu.test(text) + && /\b(?:preview|DESIGN\.md|colors_and_type\.css|ui_kits\/app|assets\/|fonts\/)\b/iu.test(text); + return hasPackageContents && hasSourceContext && hasPreservedArtifacts && hasReuseWorkflow; +} + +function readmeHasPreviewManifest(text: string, previewFiles: string[]): boolean { + if (previewFiles.length === 0) return true; + const previewSection = markdownSection(text, 'Preview Manifest') + ?? markdownSection(text, 'Preview Cards') + ?? markdownSection(text, 'Review Previews') + ?? markdownSection(text, 'Previews'); + if (previewSection === undefined) return false; + const referencedPreviews = previewFiles.filter((filePath) => + new RegExp(`\\b${escapeRegExp(filePath)}\\b`, 'iu').test(previewSection), + ); + return referencedPreviews.length >= Math.min(4, previewFiles.length); +} + +function uiKitReadmeHasReuseGuide(text: string): boolean { + if (text.trim().length < 350) return false; + const hasStructure = /##\s+(Structure|Files|Components)\b/iu.test(text) + && /\bindex\.html\b/iu.test(text) + && /\bcomponents\//iu.test(text); + const hasUsage = /##\s+(Usage|How to use|Reuse)\b/iu.test(text) + && /\b(copy|compose|import|use|build|create)\b/iu.test(text); + const hasDesignOrSourceNotes = /##\s+(Design Notes|Design|Layout|Source)\b/iu.test(text) + && /\b(source|based on|layout|colors?|typography|tokens?)\b/iu.test(text); + const componentMentions = new Set( + [...text.matchAll(/\b(?:App|Sidebar|AssistantsList|ChatArea|MessageBubble|InputBar|Composer|PreviewCard)\b|components\/[^`\s)]+\.jsx/giu)] + .map((match) => match[0].toLowerCase()), + ); + return hasStructure && hasUsage && hasDesignOrSourceNotes && componentMentions.size >= 3; +} + +function validateDesignRules(text: string): string | undefined { + const headings = new Set([...text.matchAll(/^##\s+(.+?)\s*$/gmu)].map((match) => (match[1] ?? '').toLowerCase())); + const requiredGroups = [ + ['context', 'product'], + ['color', 'palette'], + ['typography', 'type'], + ['spacing', 'layout'], + ['component'], + ['motion', 'interaction'], + ['voice', 'brand'], + ['anti-pattern'], + ]; + const missing = requiredGroups.filter((group) => + ![...headings].some((heading) => group.some((needle) => heading.includes(needle))), + ); + return missing.length === 0 + ? undefined + : `DESIGN.md is missing source-backed sections for ${missing.map((group) => group[0]).join(', ')}.`; +} + +function validateTokenCss(text: string): string | undefined { + const variables = [...text.matchAll(/--[a-z0-9_-]+\s*:/giu)].length; + if (variables < 12) return `Expected at least 12 CSS custom properties, found ${variables}.`; + const colors = [...text.matchAll(/#[0-9a-f]{3,8}\b|rgb[a]?\(|hsl[a]?\(/giu)].length; + if (colors < 4) return `Expected concrete color values in colors_and_type.css, found ${colors}.`; + if (!/font(-family)?|--[^:]*font/iu.test(text)) return 'Expected font-family or font token declarations.'; + if (!/radius|border-radius/iu.test(text)) return 'Expected radius token declarations.'; + if (!/space|spacing|gap/iu.test(text)) return 'Expected spacing token declarations.'; + return undefined; +} + +function validateHtmlDocument(text: string): string | undefined { + if (!/|]/iu.test(text)) return 'Expected a complete HTML document.'; + if (!/]/iu.test(text)) return 'Expected embedded CSS styles for review fidelity.'; + if (!/<(main|section|article|aside|header|div)\b/iu.test(text)) return 'Expected real layout markup, not only metadata.'; + return undefined; +} + +function tokenCssBindsPreservedFonts(text: string, preservedFontFiles: string[]): boolean { + const fontAssets = preservedFontFiles.filter((filePath) => /\.(ttf|otf|woff2?)$/iu.test(filePath)); + if (fontAssets.length === 0) { + return /@import\s+[^;]*fonts\//iu.test(text) || /url\([^)]*fonts\//iu.test(text); + } + const hasFontRule = /@font-face/iu.test(text) || /@import\s+[^;]*fonts\//iu.test(text); + if (!hasFontRule) return false; + if (/@import\s+[^;]*fonts\/[^;]*\.css/iu.test(text)) return true; + return fontAssets.some((filePath) => { + const baseName = escapeRegExp(path.basename(filePath)); + return new RegExp(`url\\([^)]*(?:fonts\\/[^)]*)?${baseName}`, 'iu').test(text) + || new RegExp(`@import\\s+[^;]*(?:fonts\\/[^;]*)?${baseName}`, 'iu').test(text); + }) || /url\([^)]*fonts\/[^)]*\.(ttf|otf|woff2?)/iu.test(text); +} + +function preservedAssetsReferencedInPreview(text: string, preservedAssetFiles: string[]): string[] { + return preservedAssetFiles.filter((filePath) => { + const escapedPath = escapeRegExp(filePath); + const escapedParentPath = escapeRegExp(`../${filePath}`); + const escapedBaseName = escapeRegExp(path.basename(filePath)); + return new RegExp(`(?:src|href)=["'][^"']*(?:${escapedPath}|${escapedParentPath}|${escapedBaseName})["']`, 'iu').test(text) + || new RegExp(`url\\([^)]*(?:${escapedPath}|${escapedParentPath}|${escapedBaseName})`, 'iu').test(text); + }); +} + +function evidenceSnapshotFiles(files: string[], evidenceText: string, pattern: RegExp): string[] { + const fromFiles = files.filter((filePath) => /^context\/(github|local-code)\/.+\/files\//u.test(filePath) && pattern.test(filePath)); + const fromText = [...evidenceText.matchAll(/context\/(?:github|local-code)\/[^`\s)]+\/files\/[^`\s)]+/giu)] + .map((match) => match[0]) + .filter((filePath) => pattern.test(filePath)); + return [...new Set([...fromFiles, ...fromText])]; +} + +function sourceComponentNamesFromEvidence(files: string[], evidenceText: string): string[] { + const paths = [ + ...files.filter((filePath) => /^context\/(github|local-code)\/.+\/files\//u.test(filePath)), + ...[...evidenceText.matchAll(/context\/(?:github|local-code)\/[^`\s)]+\/files\/[^`\s)]+/giu)].map((match) => match[0]), + ]; + const names = paths + .filter((filePath) => /\.(tsx|ts|jsx|js|css|scss|less)$/iu.test(filePath)) + .map(sourceComponentNameFromPath) + .filter((name): name is string => name !== undefined); + return [...new Set(names)]; +} + +function sourceComponentNameFromPath(filePath: string): string | undefined { + const parts = filePath.split('/').filter(Boolean); + const fileName = parts.at(-1); + if (!fileName) return undefined; + const base = fileName.replace(/\.(tsx|ts|jsx|js|css|scss|less)$/iu, ''); + const name = /^(index|style|styles|constants?|types?|utils?|hooks?)$/iu.test(base) + ? parts.at(-2) + : base; + if (!name || name.length < 4) return undefined; + if (/^(component|components|page|pages|button|input|card|modal|dialog|index)$/iu.test(name)) return undefined; + return name; +} + +function isSourceSurfaceComponentName(name: string): boolean { + const normalized = normalizeAnchorText(name); + if (normalized.length < 4) return false; + return !/(provider|config|constant|theme|token|style|util|hook|store|locale|schema|type|client|server)$/iu.test(normalized); +} + +async function sourceComponentAnchorsInVisualArtifacts( + projectPath: string, + files: string[], + sourceNames: string[], +): Promise { + if (sourceNames.length === 0) return []; + const visualFiles = files.filter((filePath) => + /^preview\/.+\.html$/u.test(filePath) + || /^ui_kits\/app\/(?:index\.html|components\/.+\.(jsx|tsx|js|ts|css|html))$/u.test(filePath), + ); + const texts = await Promise.all(visualFiles.map(async (filePath) => await readAuditText(projectPath, filePath) ?? '')); + const normalizedText = normalizeAnchorText(texts.join('\n')); + return sourceNames.filter((name) => normalizedText.includes(normalizeAnchorText(name))); +} + +async function sourceComponentPreviewGaps( + projectPath: string, + previewFiles: string[], + sourceNames: string[], +): Promise { + if (sourceNames.length === 0) return []; + const focusedPreviewFiles = previewFiles.filter((filePath) => + /^preview\/(?:components|spacing)-.+\.html$/u.test(filePath), + ); + const normalizedSourceNames = sourceNames.map(normalizeAnchorText); + const missing: string[] = []; + for (const filePath of focusedPreviewFiles) { + const text = await readAuditText(projectPath, filePath); + const normalizedText = normalizeAnchorText(text ?? ''); + if (!normalizedSourceNames.some((name) => normalizedText.includes(name))) { + missing.push(filePath); + } + } + return missing; +} + +function sourceComponentExamplesInPackage(files: string[], sourceNames: string[]): string[] { + if (sourceNames.length === 0) return []; + const sourceNameSet = new Set(sourceNames.map(normalizeAnchorText)); + return files.filter(isPackageSourceExampleFile).filter((filePath) => { + const name = sourceComponentNameFromPath(filePath); + return name !== undefined && sourceNameSet.has(normalizeAnchorText(name)); + }); +} + +function isPackageSourceExampleFile(filePath: string): boolean { + return /\.(tsx|ts|jsx|js)$/iu.test(filePath) + && !/^context\//u.test(filePath) + && !/^preview\//u.test(filePath) + && !/^ui_kits\/app\//u.test(filePath) + && !/^assets\//u.test(filePath) + && !/^fonts\//u.test(filePath) + && !/^dist\//u.test(filePath) + && !/^node_modules\//u.test(filePath) + && !/(^|\/)(package|tsconfig|vite\.config|next\.config|design-system-reference)\.(tsx|ts|jsx|js)$/iu.test(filePath); +} + +function normalizeAnchorText(text: string): string { + return text.toLowerCase().replace(/[^a-z0-9]+/gu, ''); +} + +function uiKitIndexHasRuntimeBootstrap(text: string): boolean { + return /ReactDOM\.createRoot\s*\(|\bcreateRoot\s*\(|ReactDOM\.render\s*\(|\broot\.render\s*\(|\brender\s*\(\s*<|customElements\.define\s*\(|\bmount\s*\(|document\.(?:getElementById|querySelector)\([^)]*\)\.(?:append|appendChild|replaceChildren)\s*\(|document\.(?:getElementById|querySelector)\([^)]*\)\.innerHTML\s*=/iu.test(text); +} + +function uiKitIndexLoadsJsxComponents(text: string, componentFiles: string[]): boolean { + return componentFiles + .filter((filePath) => /\.(jsx|tsx)$/iu.test(filePath)) + .some((filePath) => text.includes(path.basename(filePath))); +} + +function uiKitIndexHasBrowserJsxRuntime(text: string): boolean { + const hasReact = /\breact(?:\.development|\.production)?\.js\b|\breact@\d|from\s+['"][^'"]*react(?:\/[^'"]*)?['"]|\bReact\./iu.test(text); + const hasReactDom = /\breact-dom\b|react-dom(?:\.development|\.production)?\.js\b|from\s+['"][^'"]*react-dom(?:\/[^'"]*)?['"]|\bReactDOM\./iu.test(text); + const hasBabel = /@babel\/standalone|babel\.min\.js|\bBabel\.transform\b/iu.test(text); + return hasReact && hasReactDom && hasBabel; +} + +function directScriptLoadedJsxComponents(text: string, componentFiles: string[]): string[] { + return componentFiles + .filter((filePath) => /\.(jsx|tsx)$/iu.test(filePath)) + .filter((filePath) => { + const fileName = escapeRegExp(path.basename(filePath)); + return new RegExp(`]*\\bsrc=["'][^"']*components/${fileName}["'][^>]*>`, 'iu').test(text); + }); +} + +function componentNameFromUiKitFile(filePath: string): string | undefined { + const name = path.basename(filePath).replace(/\.(jsx|tsx|js|ts|html)$/iu, ''); + return name.length > 0 ? name : undefined; +} + +function componentTextExposesBrowserGlobal(text: string, componentName: string): boolean { + const escaped = escapeRegExp(componentName); + return new RegExp(`(?:window|globalThis)\\s*\\.\\s*${escaped}\\s*=|(?:window|globalThis)\\s*\\[\\s*["']${escaped}["']\\s*\\]\\s*=|Object\\.assign\\s*\\(\\s*(?:window|globalThis)\\s*,\\s*\\{[^}]*\\b${escaped}\\b`, 'u').test(text); +} + +function componentNamesComposedInUiKitIndex(text: string, componentFiles: string[]): string[] { + const textWithoutExternalComponentRefs = text + .replace(/]*\bsrc=["'][^"']*components\/[^"']+["'][^>]*>\s*<\/script>/giu, ' ') + .replace(/components\/[a-z0-9_.-]+/giu, ' '); + return componentNamesInText(textWithoutExternalComponentRefs, componentFiles); +} + +function isUiKitAppShellComponent(filePath: string): boolean { + return /(^|\/)(app|shell|layout|workspace)\.(jsx|tsx|js|ts)$/iu.test(path.basename(filePath)); +} + +async function bestUiKitAppShellComposition( + projectPath: string, + appShellFiles: string[], + componentFiles: string[], +): Promise<{ filePath?: string; composed: string[] }> { + let best: { filePath?: string; composed: string[] } = { composed: [] }; + for (const filePath of appShellFiles) { + const text = await readAuditText(projectPath, filePath); + if (text === undefined) continue; + const composed = componentNamesComposedInComponentText(text, componentFiles, path.basename(filePath)); + if (best.filePath === undefined || composed.length > best.composed.length) best = { filePath, composed }; + } + return best; +} + +function componentNamesInText(text: string, componentFiles: string[], excludeBaseName?: string): string[] { + const excluded = excludeBaseName?.replace(/\.(jsx|tsx|js|ts)$/iu, ''); + const componentNames = componentFiles + .map((filePath) => path.basename(filePath).replace(/\.(jsx|tsx|js|ts|html)$/iu, '')) + .filter((componentName) => componentName.length > 0 && componentName !== excluded); + return componentNames.filter((componentName) => + new RegExp(`\\b${escapeRegExp(componentName)}\\b`, 'u').test(text), + ); +} + +function componentNamesComposedInComponentText(text: string, componentFiles: string[], excludeBaseName?: string): string[] { + return componentNamesInText(text, componentFiles, excludeBaseName).filter((componentName) => { + const escaped = escapeRegExp(componentName); + return new RegExp(`<\\s*${escaped}(?:\\s|/|>)|React\\.createElement\\s*\\(\\s*${escaped}\\b|\\b${escaped}\\s*\\(`, 'u').test(text); + }); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); +} + +function stalePackageReferences(text: string): string[] { + const stalePreviewPaths = [ + 'preview/colors-node-types.html', + 'preview/colors-ui-palette.html', + 'preview/typography-scale.html', + 'preview/spacing-system.html', + 'preview/logo-variants.html', + ]; + const references = stalePreviewPaths.filter((stalePath) => text.includes(stalePath)); + if (text.includes('ui_kits/generated_interface/index.html')) { + references.push('ui_kits/generated_interface/index.html'); + } else if (text.includes('ui_kits/generated_interface')) { + references.push('ui_kits/generated_interface/'); + } + return references; +} + +function protocolTitleAuditFiles(files: string[]): string[] { + return files.filter((filePath) => + /^(DESIGN|README|SKILL)\.md$/u.test(filePath) + || /^preview\/.+\.html$/u.test(filePath) + || /^ui_kits\/app\/(?:README\.md|index\.html|components\/.+\.(jsx|tsx|js|ts|html))$/u.test(filePath) + || /^index\.html$/u.test(filePath), + ); +} + +function protocolDerivedDesignSystemTitle(text: string): string | undefined { + const match = /\bhttps?[^\S\r\n]+Design[^\S\r\n]+System(?:[^\S\r\n]+[A-Za-z][A-Za-z ]*)?/iu.exec(text); + if (!match) return undefined; + return match[0].trim().replace(/\s+/gu, ' '); +} + +function manifestHasLinkedGithub(manifest: string): boolean { + const section = markdownSection(manifest, 'GitHub Repositories'); + return section !== undefined && /github\.com[:/][^\s]+|^- https?:\/\/github\.com\//imu.test(section) && !/- None linked\./iu.test(section); +} + +function manifestHasLinkedLocalFolder(manifest: string): boolean { + const section = markdownSection(manifest, 'Local Code'); + return section !== undefined + && /Linked folders readable by the local agent:\s*\n- (?!none\.)(.+)/iu.test(section); +} + +function markdownSection(markdown: string, title: string): string | undefined { + const lines = markdown.split(/\r?\n/u); + const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${title}`.toLowerCase()); + if (start === -1) return undefined; + const rest = lines.slice(start + 1); + const end = rest.findIndex((line) => /^##\s+/u.test(line)); + return (end === -1 ? rest : rest.slice(0, end)).join('\n'); +} + +function evidenceHasAssets(evidenceText: string): boolean { + return /### Brand assets and icons|## Binary Assets Preserved|\.(svg|png|jpe?g|webp|ico)\b/iu.test(evidenceText); +} + +function evidenceHasFonts(evidenceText: string): boolean { + return /### Fonts|\.(ttf|otf|woff2?)\b/iu.test(evidenceText); +} + +function evidenceHasReusableComponents(evidenceText: string): boolean { + return /### Reusable components|### App shell and navigation|### Chat and input surfaces|components?\/|ui_kits?\/|sidebar|navbar|composer|message bubble|assistant row|model selector/iu.test(evidenceText); +} + +function evidenceHasChatInterface(evidenceText: string): boolean { + return /### Chat and input surfaces|pages\/home|inputbar|messages?\/|chat(area)?|assistant(list|item|stab)?|message bubble|composer/iu.test(evidenceText); +} + +function missingUiKitComponentRoles(componentFiles: string[]): string[] { + const normalized = componentFiles.map((filePath) => path.basename(filePath).toLowerCase()); + const roles = [ + ['app shell', /(app|shell|layout|workspace)\.(jsx|tsx|js|ts|html|css)$/u], + ['navigation/sidebar', /(sidebar|nav|rail)\.(jsx|tsx|js|ts|html|css)$/u], + ['assistant/list rail', /(assistants?list|assistantitem|list|panel|tabs?)\.(jsx|tsx|js|ts|html|css)$/u], + ['chat area', /(chatarea|chat|messages?)\.(jsx|tsx|js|ts|html|css)$/u], + ['message bubble', /(messagebubble|message)\.(jsx|tsx|js|ts|html|css)$/u], + ['input bar/composer', /(inputbar|composer|input|messageinput)\.(jsx|tsx|js|ts|html|css)$/u], + ] as const; + return roles + .filter(([, pattern]) => !normalized.some((fileName) => pattern.test(fileName))) + .map(([role]) => role); +} + async function requestJson(baseUrl: URL, token: string, pathname: string, init: RequestInit = {}): Promise<{ status: number; body: unknown }> { const response = await fetch(endpoint(baseUrl, pathname), { ...init, @@ -257,6 +2733,33 @@ export async function runConnectorsToolCli(args: string[]): Promise { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'od-design-system-jobs-')); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('creates a pollable job that produces a user design system draft', async () => { + const store = createDesignSystemGenerationJobStore({ + root, + delayMs: 0, + idFactory: () => 'job-1', + collectSourceContext: async () => ({ github: [], notes: '' }), + }); + + const started = store.start({ + title: 'Acme Product', + summary: 'Dense product UI.', + category: 'Custom', + status: 'draft', + provenance: { + companyBlurb: 'Acme builds dense product UI.', + githubUrls: ['https://github.com/acme/product'], + }, + }); + + expect(started).toMatchObject({ + id: 'job-1', + status: 'running', + progress: 0, + }); + + const done = await waitForJob(store, 'job-1'); + + expect(done).toMatchObject({ + id: 'job-1', + status: 'succeeded', + progress: 100, + designSystemId: 'user:acme-product', + }); + expect(done.steps.map((step) => step.status)).toEqual([ + 'succeeded', + 'succeeded', + 'succeeded', + 'succeeded', + 'succeeded', + ]); + expect(done.steps.map((step) => step.message).join('\n')).toContain( + '1 GitHub link(s)', + ); + }); + + it('merges collected source context into the generated draft', async () => { + let capturedInput: UserDesignSystemInput | undefined; + const store = createDesignSystemGenerationJobStore({ + root, + delayMs: 0, + idFactory: () => 'job-context', + collectSourceContext: async () => ({ + github: [{ + url: 'https://github.com/acme/product', + owner: 'acme', + repo: 'product', + description: 'Acme repository.', + }], + notes: 'Fetched GitHub context:\n- acme/product: README excerpt: Dense editor primitives.', + }), + createDesignSystem: async (targetRoot, input) => { + capturedInput = input; + return createUserDesignSystem(targetRoot, input); + }, + }); + + store.start({ + title: 'Context Product', + sourceNotes: 'GitHub/code: https://github.com/acme/product', + provenance: { + githubUrls: ['https://github.com/acme/product'], + sourceNotes: 'GitHub/code: https://github.com/acme/product', + }, + }); + + const done = await waitForJob(store, 'job-context'); + const body = await readDesignSystem(root, done.designSystemId ?? '', { idPrefix: 'user:' }); + + expect(done.steps.find((step) => step.id === 'explore-resources')?.message).toContain('read 1 GitHub repo'); + expect(capturedInput?.sourceNotes).toContain('GitHub/code: https://github.com/acme/product'); + expect(capturedInput?.sourceNotes).toContain('Fetched GitHub context'); + expect(capturedInput?.provenance?.sourceNotes).toContain('Dense editor primitives'); + expect(capturedInput?.provenance?.sourceNotes).not.toContain('GitHub/code:'); + expect(body).toContain('Dense editor primitives'); + }); + + it('exposes failed generation status when draft creation fails', async () => { + const store = createDesignSystemGenerationJobStore({ + root, + delayMs: 0, + idFactory: () => 'job-fail', + createDesignSystem: async () => { + throw new Error('draft write failed'); + }, + }); + + store.start({ title: 'Broken System' }); + + const done = await waitForJob(store, 'job-fail'); + + expect(done).toMatchObject({ + id: 'job-fail', + status: 'failed', + error: 'draft write failed', + }); + expect(done.steps.find((step) => step.id === 'create-draft')?.status).toBe('failed'); + }); + + it('runs a revision job against an existing user design system', async () => { + const store = createDesignSystemGenerationJobStore({ + root, + delayMs: 0, + idFactory: () => 'revision-1', + }); + const created = await createUserDesignSystem(root, { + title: 'Revision Product', + summary: 'Initial system.', + status: 'draft', + }); + + const started = store.revise({ + designSystemId: created.id, + sectionTitle: 'Visual Foundations', + feedback: 'Make the palette warmer and reduce decorative effects.', + }); + + expect(started).toMatchObject({ + id: 'revision-1', + kind: 'revision', + designSystemId: created.id, + }); + + const done = await waitForJob(store, 'revision-1'); + const body = await readDesignSystem(root, created.id, { idPrefix: 'user:' }); + const revisions = await listUserDesignSystemRevisions(root, created.id); + + expect(done).toMatchObject({ + id: 'revision-1', + status: 'succeeded', + progress: 100, + designSystemId: created.id, + revisionId: expect.any(String), + }); + expect(body).not.toContain('## Revision Request: Visual Foundations'); + expect(revisions?.[0]).toMatchObject({ + status: 'pending', + feedback: 'Make the palette warmer and reduce decorative effects.', + sectionTitle: 'Visual Foundations', + }); + expect(revisions?.[0]?.proposedBody).toContain('## Revision Request: Visual Foundations'); + + const accepted = await updateUserDesignSystemRevisionStatus( + root, + created.id, + revisions?.[0]?.id ?? '', + 'accepted', + ); + const acceptedBody = await readDesignSystem(root, created.id, { idPrefix: 'user:' }); + + expect(accepted?.status).toBe('accepted'); + expect(acceptedBody).toContain('## Revision Request: Visual Foundations'); + }); +}); + +async function waitForJob( + store: ReturnType, + id: string, +): Promise { + for (let attempt = 0; attempt < 30; attempt += 1) { + const job = store.get(id); + if (job && (job.status === 'succeeded' || job.status === 'failed')) return job; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`Timed out waiting for ${id}`); +} diff --git a/apps/daemon/tests/design-system-source-context.test.ts b/apps/daemon/tests/design-system-source-context.test.ts new file mode 100644 index 000000000..533c6787d --- /dev/null +++ b/apps/daemon/tests/design-system-source-context.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; + +import { + collectDesignSystemSourceContext, + mergeSourceContextIntoInput, + type FetchLike, +} from '../src/design-system-source-context.js'; + +describe('design system source context', () => { + it('reads GitHub metadata, README, and package context', async () => { + const fetchFn: FetchLike = async (url) => { + if (url === 'https://api.github.com/repos/acme/product') { + return jsonResponse({ + description: 'Acme product UI repository.', + homepage: 'https://acme.example', + default_branch: 'trunk', + language: 'TypeScript', + stargazers_count: 42, + topics: ['design-system', 'dashboard'], + }); + } + if (url === 'https://raw.githubusercontent.com/acme/product/trunk/README.md') { + return textResponse('# Acme Product\n\nDesign tokens and dense workflow components for Acme.'); + } + if (url === 'https://raw.githubusercontent.com/acme/product/trunk/package.json') { + return textResponse(JSON.stringify({ + name: '@acme/product', + description: 'Workspace UI package.', + })); + } + return textResponse('not found', 404); + }; + + const context = await collectDesignSystemSourceContext({ + provenance: { + githubUrls: ['https://github.com/acme/product/tree/trunk'], + }, + }, { + fetch: fetchFn, + maxReadmeChars: 160, + }); + + expect(context.github[0]).toMatchObject({ + owner: 'acme', + repo: 'product', + description: 'Acme product UI repository.', + defaultBranch: 'trunk', + language: 'TypeScript', + stars: 42, + packageName: '@acme/product', + }); + expect(context.notes).toContain('Fetched GitHub context'); + expect(context.notes).toContain('Design tokens and dense workflow components'); + + const merged = mergeSourceContextIntoInput({ + sourceNotes: 'GitHub/code: https://github.com/acme/product', + provenance: { + githubUrls: ['https://github.com/acme/product'], + sourceNotes: 'GitHub/code: https://github.com/acme/product', + }, + }, context); + + expect(merged.sourceNotes).toContain('GitHub/code: https://github.com/acme/product'); + expect(merged.sourceNotes).toContain('Fetched GitHub context'); + expect(merged.provenance?.sourceNotes).toContain('README excerpt'); + expect(merged.provenance?.sourceNotes).not.toContain('GitHub/code:'); + }); + + it('keeps generation usable when GitHub metadata is unavailable', async () => { + const context = await collectDesignSystemSourceContext({ + provenance: { + githubUrls: ['git@github.com:acme/missing.git'], + }, + }, { + fetch: async () => textResponse('not found', 404), + }); + + expect(context.github[0]).toMatchObject({ + owner: 'acme', + repo: 'missing', + error: 'GitHub repository metadata unavailable (HTTP 404)', + }); + expect(context.notes).toContain('GitHub repository metadata unavailable'); + }); +}); + +function jsonResponse(value: unknown, status = 200): ReturnType { + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: async () => value, + text: async () => JSON.stringify(value), + }); +} + +function textResponse(value: string, status = 200): ReturnType { + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: async () => JSON.parse(value), + text: async () => value, + }); +} diff --git a/apps/daemon/tests/design-systems.test.ts b/apps/daemon/tests/design-systems.test.ts new file mode 100644 index 000000000..f7f57a123 --- /dev/null +++ b/apps/daemon/tests/design-systems.test.ts @@ -0,0 +1,350 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createUserDesignSystem, + deleteUserDesignSystem, + linkUserDesignSystemProject, + listDesignSystems, + listUserDesignSystemFiles, + readDesignSystem, + readUserDesignSystemFile, + updateUserDesignSystem, +} from '../src/design-systems.js'; + +describe('design systems registry', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'od-design-systems-')); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('lists bundled design systems as published and non-editable', async () => { + await mkdir(path.join(root, 'acme'), { recursive: true }); + await writeFile( + path.join(root, 'acme', 'DESIGN.md'), + '# Acme\n\n> Category: Custom\n> Surface: web\n\nAcme brand.\n', + ); + + const systems = await listDesignSystems(root); + + expect(systems).toMatchObject([ + { + id: 'acme', + title: 'Acme', + category: 'Custom', + status: 'published', + source: 'built-in', + isEditable: false, + }, + ]); + }); + + it('creates, updates, reads, and deletes user design systems with prefixed ids', async () => { + const created = await createUserDesignSystem(root, { + title: 'Acme Product', + summary: 'Dense product UI.', + category: 'Custom', + status: 'draft', + provenance: { + companyBlurb: 'Acme builds dense product UI.', + githubUrls: ['https://github.com/acme/product'], + localCodeFiles: ['src/components/Button.tsx'], + figFiles: ['brand.fig'], + assetFiles: ['logo.svg'], + notes: 'Use compact review flows.', + }, + }); + + expect(created.id).toBe('user:acme-product'); + expect(created.source).toBe('user'); + expect(created.isEditable).toBe(true); + expect(created.status).toBe('draft'); + expect(created.provenance).toMatchObject({ + companyBlurb: 'Acme builds dense product UI.', + githubUrls: ['https://github.com/acme/product'], + localCodeFiles: ['src/components/Button.tsx'], + figFiles: ['brand.fig'], + assetFiles: ['logo.svg'], + notes: 'Use compact review flows.', + }); + const files = await listUserDesignSystemFiles(root, created.id); + expect(files?.map((file) => file.path)).toEqual( + expect.arrayContaining([ + 'DESIGN.md', + 'README.md', + 'SKILL.md', + 'context/provenance.json', + 'context/provenance.md', + 'colors_and_type.css', + 'preview/colors-primary.html', + 'preview/typography-specimens.html', + 'assets/logo.svg', + 'ui_kits/app/index.html', + 'ui_kits/app/README.md', + 'ui_kits/app/components/App.jsx', + 'ui_kits/app/components/Sidebar.jsx', + 'ui_kits/app/components/AssistantsList.jsx', + 'ui_kits/app/components/ChatArea.jsx', + 'ui_kits/app/components/InputBar.jsx', + 'ui_kits/app/components/MessageBubble.jsx', + ]), + ); + await expect(readUserDesignSystemFile(root, created.id, 'ui_kits/app/index.html')) + .resolves + .toMatchObject({ + content: expect.stringContaining('ReactDOM.createRoot'), + }); + await expect(readUserDesignSystemFile(root, created.id, 'ui_kits/app/index.html')) + .resolves + .toMatchObject({ + content: expect.stringContaining('components/App.jsx'), + }); + await expect(readUserDesignSystemFile(root, created.id, 'ui_kits/app/components/App.jsx')) + .resolves + .toMatchObject({ + content: expect.stringContaining(' Category: Custom\n> Surface: web\n\nPublished.\n', + }); + + expect(updated?.status).toBe('published'); + expect(updated?.title).toBe('Acme Product System'); + expect(updated?.projectId).toBe('ds-acme-product'); + await expect(readDesignSystem(root, created.id, { idPrefix: 'user:' })) + .resolves + .toContain('Published.'); + + await expect(deleteUserDesignSystem(root, created.id)).resolves.toBe(true); + await expect(listDesignSystems(root, { idPrefix: 'user:' })).resolves.toEqual([]); + }); + + it('rejects traversal ids when reading design systems', async () => { + await expect(readDesignSystem(root, '../package')).resolves.toBeNull(); + await expect(readDesignSystem(root, 'user:../package', { idPrefix: 'user:' })) + .resolves + .toBeNull(); + }); + + it('backfills generated files for older user design systems', async () => { + await mkdir(path.join(root, 'legacy'), { recursive: true }); + await writeFile( + path.join(root, 'legacy', 'DESIGN.md'), + '# Legacy System\n\n> Category: Custom\n> Surface: web\n\nLegacy body.\n', + ); + + const files = await listUserDesignSystemFiles(root, 'user:legacy'); + + expect(files?.map((file) => file.path)).toEqual( + expect.arrayContaining([ + 'README.md', + 'SKILL.md', + 'context/provenance.json', + 'colors_and_type.css', + 'preview/colors-primary.html', + 'ui_kits/app/components/App.jsx', + 'ui_kits/app/components/Sidebar.jsx', + 'ui_kits/app/components/AssistantsList.jsx', + 'ui_kits/app/components/ChatArea.jsx', + 'ui_kits/app/components/InputBar.jsx', + 'ui_kits/app/components/MessageBubble.jsx', + ]), + ); + }); + + it('migrates older review artifact names into the Claude-style package structure', async () => { + await mkdir(path.join(root, 'legacy', 'preview'), { recursive: true }); + await mkdir(path.join(root, 'legacy', 'ui_kits', 'generated_interface'), { recursive: true }); + await writeFile( + path.join(root, 'legacy', 'DESIGN.md'), + '# Legacy System\n\n> Category: Custom\n> Surface: web\n\nLegacy body.\n', + ); + await writeFile( + path.join(root, 'legacy', 'README.md'), + '# Legacy\n\nReview preview/typography-scale.html and ui_kits/generated_interface/index.html first.\n', + ); + await writeFile( + path.join(root, 'legacy', 'SKILL.md'), + '# Legacy Skill\n\nUse preview/colors-ui-palette.html, preview/spacing-system.html, and ui_kits/generated_interface/.\n', + ); + await writeFile(path.join(root, 'legacy', 'preview', 'colors-ui-palette.html'), 'colors'); + await writeFile(path.join(root, 'legacy', 'preview', 'colors-node-types.html'), 'nodes'); + await writeFile(path.join(root, 'legacy', 'preview', 'typography-scale.html'), 'type'); + await writeFile(path.join(root, 'legacy', 'preview', 'spacing-system.html'), 'spacing'); + await writeFile(path.join(root, 'legacy', 'preview', 'logo-variants.html'), 'logo'); + await writeFile( + path.join(root, 'legacy', 'ui_kits', 'generated_interface', 'index.html'), + 'legacy app kit', + ); + + const files = await listUserDesignSystemFiles(root, 'user:legacy'); + + expect(files?.map((file) => file.path)).toEqual( + expect.arrayContaining([ + 'preview/colors-primary.html', + 'preview/colors-theme-light.html', + 'preview/colors-theme-dark.html', + 'preview/typography-specimens.html', + 'preview/spacing-tokens.html', + 'preview/spacing-radius.html', + 'preview/spacing-shadows.html', + 'preview/components-buttons.html', + 'preview/components-inputs.html', + 'preview/brand-assets.html', + 'ui_kits/app/index.html', + 'ui_kits/app/README.md', + 'ui_kits/app/components/App.jsx', + 'ui_kits/app/components/Sidebar.jsx', + 'ui_kits/app/components/AssistantsList.jsx', + 'ui_kits/app/components/ChatArea.jsx', + 'ui_kits/app/components/InputBar.jsx', + 'ui_kits/app/components/MessageBubble.jsx', + ]), + ); + expect(files?.map((file) => file.path)).not.toEqual( + expect.arrayContaining([ + 'preview/colors-ui-palette.html', + 'preview/colors-node-types.html', + 'preview/typography-scale.html', + 'preview/spacing-system.html', + 'preview/logo-variants.html', + 'ui_kits/generated_interface/index.html', + ]), + ); + await expect(readUserDesignSystemFile(root, 'user:legacy', 'ui_kits/app/index.html')) + .resolves + .toMatchObject({ + content: expect.stringContaining('legacy app kit'), + }); + await expect(readUserDesignSystemFile(root, 'user:legacy', 'README.md')) + .resolves + .toMatchObject({ + content: expect.not.stringContaining('ui_kits/generated_interface'), + }); + await expect(readUserDesignSystemFile(root, 'user:legacy', 'README.md')) + .resolves + .toMatchObject({ + content: expect.stringContaining('ui_kits/app/index.html'), + }); + await expect(readUserDesignSystemFile(root, 'user:legacy', 'SKILL.md')) + .resolves + .toMatchObject({ + content: expect.not.stringContaining('preview/colors-ui-palette.html'), + }); + }); + + it('adds modular UI-kit components to existing app kits', async () => { + await mkdir(path.join(root, 'legacy', 'ui_kits', 'app'), { recursive: true }); + await writeFile( + path.join(root, 'legacy', 'DESIGN.md'), + '# Legacy System\n\n> Category: Custom\n> Surface: web\n\nLegacy body.\n', + ); + await writeFile(path.join(root, 'legacy', 'README.md'), '# Legacy\n'); + await writeFile(path.join(root, 'legacy', 'ui_kits', 'app', 'index.html'), 'app kit'); + + const files = await listUserDesignSystemFiles(root, 'user:legacy'); + + expect(files?.map((file) => file.path)).toEqual( + expect.arrayContaining([ + 'ui_kits/app/components/App.jsx', + 'ui_kits/app/components/Sidebar.jsx', + 'ui_kits/app/components/AssistantsList.jsx', + 'ui_kits/app/components/ChatArea.jsx', + 'ui_kits/app/components/InputBar.jsx', + 'ui_kits/app/components/MessageBubble.jsx', + ]), + ); + await expect(readUserDesignSystemFile(root, 'user:legacy', 'ui_kits/app/components/App.jsx')) + .resolves + .toMatchObject({ + content: expect.stringContaining(' { + const created = await createUserDesignSystem(root, { + title: 'Agent Managed', + summary: 'The agent will create review artifacts in the workspace.', + status: 'draft', + artifactMode: 'agent-managed', + }); + + const initialFiles = await listUserDesignSystemFiles(root, created.id); + + expect(initialFiles?.map((file) => file.path)).toEqual(['DESIGN.md']); + expect(initialFiles?.map((file) => file.path)).not.toEqual(expect.arrayContaining(['README.md', 'preview/colors-primary.html'])); + await expect(readUserDesignSystemFile(root, created.id, 'README.md')) + .resolves + .toBeNull(); + + const contextDir = path.join(root, created.id.slice('user:'.length), 'context'); + await mkdir(contextDir, { recursive: true }); + await writeFile( + path.join(contextDir, 'source-context.md'), + '# Source Context\n\nConnector evidence remains available as project context.\n', + 'utf8', + ); + + const generatedFiles = await listUserDesignSystemFiles(root, created.id); + + expect(generatedFiles?.map((file) => file.path)).toEqual( + expect.arrayContaining([ + 'DESIGN.md', + 'context/source-context.md', + ]), + ); + expect(generatedFiles?.map((file) => file.path)).not.toEqual(expect.arrayContaining(['README.md'])); + }); +}); diff --git a/apps/daemon/tests/project-design-system-routes.test.ts b/apps/daemon/tests/project-design-system-routes.test.ts new file mode 100644 index 000000000..9b1efa52e --- /dev/null +++ b/apps/daemon/tests/project-design-system-routes.test.ts @@ -0,0 +1,326 @@ +import type http from 'node:http'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { startServer } from '../src/server.js'; + +describe('project design system route gates', () => { + let server: http.Server; + let baseUrl: string; + const projectsToClean: string[] = []; + const designSystemsToClean: string[] = []; + const tempDirs: string[] = []; + + beforeAll(async () => { + const started = (await startServer({ port: 0, returnServer: true })) as { + url: string; + server: http.Server; + }; + baseUrl = started.url; + server = started.server; + }); + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + afterAll(async () => { + for (const id of projectsToClean.splice(0)) { + await fetch(`${baseUrl}/api/projects/${encodeURIComponent(id)}`, { + method: 'DELETE', + }).catch(() => {}); + } + for (const id of designSystemsToClean.splice(0)) { + await fetch(`${baseUrl}/api/design-systems/${encodeURIComponent(id)}`, { + method: 'DELETE', + }).catch(() => {}); + } + await new Promise((resolve) => server.close(() => resolve())); + }); + + function uniqueId(prefix: string): string { + return `${prefix}-${randomUUID()}`; + } + + async function createUserDesignSystem(status: 'draft' | 'published') { + const resp = await fetch(`${baseUrl}/api/design-systems`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: `Route Gate ${uniqueId(status)}`, + summary: 'Route-level design system usage guard.', + status, + }), + }); + expect(resp.status).toBe(201); + const body = (await resp.json()) as { + designSystem: { id: string; status: string }; + }; + designSystemsToClean.push(body.designSystem.id); + return body.designSystem; + } + + async function createProject(body: Record) { + return fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + async function writeProjectText(projectId: string, name: string, content: string) { + const resp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(projectId)}/files`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, content }), + }); + expect(resp.status).toBe(200); + } + + async function readProjectText(projectId: string, name: string) { + const resp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(projectId)}/files/${name}`); + expect(resp.status).toBe(200); + return resp.text(); + } + + it('rejects draft design systems when creating a project', async () => { + const draft = await createUserDesignSystem('draft'); + const id = uniqueId('project-draft-ds'); + + const resp = await createProject({ + id, + name: 'Draft Design System Project', + designSystemId: draft.id, + }); + + expect(resp.status).toBe(400); + const body = (await resp.json()) as { error?: { message?: string } }; + expect(body.error?.message).toMatch(/draft design systems cannot be used/i); + }); + + it('allows published design systems when creating a project', async () => { + const published = await createUserDesignSystem('published'); + const id = uniqueId('project-published-ds'); + + const resp = await createProject({ + id, + name: 'Published Design System Project', + designSystemId: published.id, + }); + + expect(resp.status).toBe(200); + projectsToClean.push(id); + const body = (await resp.json()) as { + project: { id: string; designSystemId: string | null }; + }; + expect(body.project.designSystemId).toBe(published.id); + }); + + it('preserves a pending first agent task when a design-system workspace is re-opened', async () => { + const draft = await createUserDesignSystem('draft'); + + const workspaceResp = await fetch( + `${baseUrl}/api/design-systems/${encodeURIComponent(draft.id)}/workspace`, + { method: 'POST' }, + ); + expect(workspaceResp.status).toBe(201); + const workspaceBody = (await workspaceResp.json()) as { + project: { id: string; pendingPrompt?: string | null }; + }; + const projectId = workspaceBody.project.id; + projectsToClean.push(projectId); + + const prompt = + 'Create this project as a complete Open Design design system workspace.'; + const patchResp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(projectId)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pendingPrompt: prompt }), + }); + expect(patchResp.status).toBe(200); + + const reopenedResp = await fetch( + `${baseUrl}/api/design-systems/${encodeURIComponent(draft.id)}/workspace`, + { method: 'POST' }, + ); + expect(reopenedResp.status).toBe(201); + const reopenedBody = (await reopenedResp.json()) as { + project: { id: string; pendingPrompt?: string | null }; + }; + + expect(reopenedBody.project.id).toBe(projectId); + expect(reopenedBody.project.pendingPrompt).toBe(prompt); + }); + + it('audits generated design-system package files from the project workspace', async () => { + const projectId = uniqueId('project-ds-audit'); + const createResp = await createProject({ + id: projectId, + name: 'Package Audit Project', + skillId: null, + designSystemId: null, + }); + expect(createResp.status).toBe(200); + projectsToClean.push(projectId); + + await writeProjectText(projectId, 'DESIGN.md', '# Package Audit Project\n\nOnly the rules file exists so far.\n'); + + const auditResp = await fetch( + `${baseUrl}/api/projects/${encodeURIComponent(projectId)}/design-system-package-audit`, + ); + expect(auditResp.status).toBe(200); + const body = (await auditResp.json()) as { + audit: { + ok: boolean; + filesInspected: number; + errors: Array<{ code: string; path?: string }>; + }; + }; + + expect(body.audit.ok).toBe(false); + expect(body.audit.filesInspected).toBeGreaterThan(0); + expect(body.audit.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'missing_required_file', path: 'README.md' }), + expect.objectContaining({ code: 'missing_required_file', path: 'SKILL.md' }), + ]), + ); + }); + + it('removes legacy design-system artifact names when re-opening a migrated workspace', async () => { + const draft = await createUserDesignSystem('draft'); + + const workspaceResp = await fetch( + `${baseUrl}/api/design-systems/${encodeURIComponent(draft.id)}/workspace`, + { method: 'POST' }, + ); + expect(workspaceResp.status).toBe(201); + const workspaceBody = (await workspaceResp.json()) as { + project: { id: string }; + }; + const projectId = workspaceBody.project.id; + projectsToClean.push(projectId); + + await writeProjectText( + projectId, + 'preview/typography-scale.html', + 'old type', + ); + await writeProjectText( + projectId, + 'preview/colors-ui-palette.html', + 'old colors', + ); + await writeProjectText( + projectId, + 'ui_kits/generated_interface/index.html', + 'old app', + ); + + const reopenedResp = await fetch( + `${baseUrl}/api/design-systems/${encodeURIComponent(draft.id)}/workspace`, + { method: 'POST' }, + ); + expect(reopenedResp.status).toBe(201); + const reopenedBody = (await reopenedResp.json()) as { + files: Array<{ path: string }>; + }; + const paths = reopenedBody.files.map((file) => file.path); + + expect(paths).toEqual(expect.arrayContaining([ + 'preview/typography-specimens.html', + 'preview/colors-primary.html', + 'ui_kits/app/index.html', + ])); + expect(paths).not.toEqual(expect.arrayContaining([ + 'preview/typography-scale.html', + 'preview/colors-ui-palette.html', + 'ui_kits/generated_interface/index.html', + ])); + }); + + it('refreshes stale design-system workspace docs that still point at legacy package paths', async () => { + const draft = await createUserDesignSystem('draft'); + + const workspaceResp = await fetch( + `${baseUrl}/api/design-systems/${encodeURIComponent(draft.id)}/workspace`, + { method: 'POST' }, + ); + expect(workspaceResp.status).toBe(201); + const workspaceBody = (await workspaceResp.json()) as { + project: { id: string }; + }; + const projectId = workspaceBody.project.id; + projectsToClean.push(projectId); + + await writeProjectText( + projectId, + 'README.md', + '# Stale README\n\nReview preview/typography-scale.html and ui_kits/generated_interface/index.html.\n', + ); + await writeProjectText( + projectId, + 'SKILL.md', + '# Stale Skill\n\nUse preview/colors-ui-palette.html and ui_kits/generated_interface/.\n', + ); + + const reopenedResp = await fetch( + `${baseUrl}/api/design-systems/${encodeURIComponent(draft.id)}/workspace`, + { method: 'POST' }, + ); + expect(reopenedResp.status).toBe(201); + + const readme = await readProjectText(projectId, 'README.md'); + const skill = await readProjectText(projectId, 'SKILL.md'); + expect(readme).toContain('preview/'); + expect(readme).not.toContain('ui_kits/generated_interface'); + expect(readme).not.toContain('preview/typography-scale.html'); + expect(skill).not.toContain('ui_kits/generated_interface'); + expect(skill).not.toContain('preview/colors-ui-palette.html'); + }); + + it('rejects patching an existing project to a draft design system', async () => { + const draft = await createUserDesignSystem('draft'); + const id = uniqueId('project-patch-draft-ds'); + const createResp = await createProject({ id, name: 'Patch Guard Project' }); + expect(createResp.status).toBe(200); + projectsToClean.push(id); + + const resp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ designSystemId: draft.id }), + }); + + expect(resp.status).toBe(400); + const body = (await resp.json()) as { error?: { message?: string } }; + expect(body.error?.message).toMatch(/draft design systems cannot be used/i); + }); + + it('rejects draft design systems when importing a folder as a project', async () => { + const draft = await createUserDesignSystem('draft'); + const folder = mkdtempSync(path.join(tmpdir(), 'od-import-draft-ds-')); + tempDirs.push(folder); + await writeFile(path.join(folder, 'index.html'), ''); + + const resp = await fetch(`${baseUrl}/api/import/folder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + baseDir: folder, + name: 'Imported Draft Design System Project', + designSystemId: draft.id, + }), + }); + + expect(resp.status).toBe(400); + const body = (await resp.json()) as { error?: { message?: string } }; + expect(body.error?.message).toMatch(/draft design systems cannot be used/i); + }); +}); diff --git a/apps/daemon/tests/system-prompt-template.test.ts b/apps/daemon/tests/system-prompt-template.test.ts index f436fd4ff..82a622e90 100644 --- a/apps/daemon/tests/system-prompt-template.test.ts +++ b/apps/daemon/tests/system-prompt-template.test.ts @@ -55,6 +55,7 @@ describe('composeSystemPrompt — metadata.promptTemplate', () => { designSystemTitle: 'Brand', }); + expect(out).toContain('Do not emit a direction question-form'); expect(out).not.toContain(' `

${title} ${index + 1}

Source-backed review content for compact desktop app surfaces, component states, spacing, typography, and reusable product modules.

`).join(''); + const brandAssets = title === 'brand-assets.html' + ? ` +
+ Cherry Studio logo + Cherry Studio app icon +
` + : ''; + return ` + + + + ${title} + + + +
+

${title}

+

A focused review card that preserves product density, component rhythm, and real source-backed design evidence.

+ ${brandAssets} + ${cards} +
+ + +`; +} + +function auditUiKitIndex(componentFiles: string[] = AUDIT_COMPONENT_FILES): string { + const scripts = componentFiles + .map((fileName) => ` `) + .join('\n'); + const componentNames = componentFiles.map((fileName) => fileName.replace(/\.(jsx|tsx|js|ts|html)$/u, '')); + const componentCards = componentNames + .map((componentName) => `

${componentName}

${componentName} is loaded from ui_kits/app/components/${componentName}.jsx and composed into this applied interface kit.

`) + .join('\n '); + return ` + + + + + Cherry Studio UI kit + + + + + + + +
+${scripts} + +
+ +
+

Applied modular UI kit

+

This entry page loads the extracted token CSS and composes reusable component modules instead of standing alone as a generic mock.

+ ${componentCards} +
+
+ + +`; +} + +function auditComponent(componentName: string): string { + return `const ${componentName}Items = [ + { id: 'primary', label: '${componentName} primary state', detail: 'Source-backed density, spacing, and active state.' }, + { id: 'secondary', label: '${componentName} secondary state', detail: 'Muted state with compact metadata and clear affordance.' }, + { id: 'review', label: '${componentName} review state', detail: 'Reusable review surface for future Open Design projects.' }, +]; + +const ${componentName}Styles = { + shell: { display: 'grid', gap: 12, padding: 16, border: '1px solid var(--cherry-border)', borderRadius: 12, background: 'var(--cherry-surface)' }, + header: { display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center' }, + row: { display: 'grid', gap: 4, padding: '10px 12px', borderRadius: 10, background: 'var(--cherry-surface-muted)' }, + label: { fontWeight: 700, color: 'var(--cherry-fg)' }, + detail: { color: 'var(--cherry-muted)', fontSize: 13 }, + action: { border: '1px solid var(--cherry-primary)', background: 'var(--cherry-primary)', color: '#fff', borderRadius: 8, padding: '8px 10px' }, +}; + +function ${componentName}({ title = '${componentName}', items = ${componentName}Items }) { + return ( +
+
+ {title} + +
+ {items.map((item) => ( +
+ {item.label} + {item.detail} +
+ ))} +
+ ); +} + +window.${componentName} = ${componentName}; +`; +} + +function auditAppComponent(): string { + return `const { Sidebar, AssistantsList, ChatArea } = window; + +const appStyles = { + container: { + display: 'flex', + width: '100%', + minHeight: '720px', + background: 'var(--cherry-bg)', + color: 'var(--cherry-fg)' + } +}; + +function App() { + return ( +
+ + + +
+ ); +} + +window.App = App; +`; +} + +function auditUiKitComponent(componentName: string): string { + const baseName = componentName.replace(/\.(jsx|tsx|js|ts)$/u, ''); + return baseName === 'App' ? auditAppComponent() : auditComponent(baseName); +} + describe('connectors tool CLI', () => { let stdoutWrite: { mockRestore: () => void }; let stderrWrite: { mockRestore: () => void }; let stdoutOutput: string[]; let stderrOutput: string[]; let fetchMock: ReturnType; + let cwd: string; beforeEach(() => { process.env = { ...ORIGINAL_ENV }; + cwd = process.cwd(); stdoutOutput = []; stderrOutput = []; stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { @@ -32,8 +457,21 @@ describe('connectors tool CLI', () => { stdoutWrite.mockRestore(); stderrWrite.mockRestore(); process.env = ORIGINAL_ENV; + process.chdir(cwd); }); + async function installFailingLocalGithubTools(tmpDir: string): Promise { + const fakeBinDir = path.join(tmpDir, 'bin'); + await mkdir(fakeBinDir, { recursive: true }); + const fakeGitPath = path.join(fakeBinDir, 'git'); + await writeFile(fakeGitPath, `#!/bin/sh +echo "fatal: repository not found" >&2 +exit 128 +`, 'utf8'); + await chmod(fakeGitPath, 0o755); + process.env.PATH = fakeBinDir; + } + it('appends curated useCase query params for connector listing', async () => { process.env.OD_DAEMON_URL = 'http://127.0.0.1:7456/base/'; process.env.OD_TOOL_TOKEN = 'agent-run-token'; @@ -93,4 +531,2159 @@ describe('connectors tool CLI', () => { }); expect(stderrOutput.join('')).toBe(''); }); + + it('writes GitHub design evidence through connected connector tools', async () => { + const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'od-connectors-cli-')); + process.chdir(tmpDir); + process.env.OD_DAEMON_URL = 'http://127.0.0.1:7456'; + process.env.OD_TOOL_TOKEN = 'agent-run-token'; + await installFailingLocalGithubTools(tmpDir); + + const encode = (value: string) => Buffer.from(value, 'utf8').toString('base64'); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + connectors: [{ + id: 'github', + name: 'GitHub', + provider: 'composio', + category: 'Developer', + status: 'connected', + tools: [{ name: 'github.github_get_repository_content' }], + }], + }), { headers: { 'Content-Type': 'application/json' }, status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + ok: true, + output: { data: { default_branch: 'main', html_url: 'https://github.com/acme/ui' } }, + }), { headers: { 'Content-Type': 'application/json' }, status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + ok: true, + output: { data: { path: 'README.md', encoding: 'base64', content: encode('# Acme UI') } }, + }), { headers: { 'Content-Type': 'application/json' }, status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + ok: true, + output: { data: { tree: [ + { path: 'build/logo.png', type: 'blob' }, + { path: 'package.json', type: 'blob' }, + { path: 'src/pages/home/HomePage.tsx', type: 'blob' }, + { path: 'src/components/Button.tsx', type: 'blob' }, + { path: 'src/styles.css', type: 'blob' }, + ] } }, + }), { headers: { 'Content-Type': 'application/json' }, status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + ok: true, + output: { data: { encoding: 'base64', content: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64') } }, + }), { headers: { 'Content-Type': 'application/json' }, status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + ok: true, + output: { data: 'export function HomePage(){ return
}' }, + }), { headers: { 'Content-Type': 'application/json' }, status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + ok: true, + output: { data: ':root { --color-brand: #ff5500; --radius-md: 8px; }' }, + }), { headers: { 'Content-Type': 'application/json' }, status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + ok: true, + output: { data: { content: { mimetype: 'text/plain', name: 'Button.tsx', s3url: 'https://signed.example/Button.tsx' } } }, + }), { headers: { 'Content-Type': 'application/json' }, status: 200 })) + .mockResolvedValueOnce(new Response('export function Button(){ return