import { type Dirent, promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import yaml from 'js-yaml'; import { z } from 'zod'; import { resolveTemplate } from './templates.js'; import type { CreatePrdOptions, PrdDocument } from './types.js'; const PRD_DIRECTORY = path.join('docs', 'prdy'); const PRD_FILE_EXTENSIONS = new Set(['.yaml', '.yml']); const prdSectionSchema = z.object({ id: z.string().min(1), title: z.string().min(1), fields: z.record(z.string(), z.string()), }); const prdDocumentSchema = z.object({ id: z.string().min(1), title: z.string().min(1), status: z.enum(['draft', 'review', 'approved', 'archived']), projectPath: z.string().min(1), template: z.string().min(1), sections: z.array(prdSectionSchema), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }); function expandHome(projectPath: string): string { if (!projectPath.startsWith('~')) { return projectPath; } if (projectPath === '~') { return os.homedir(); } if (projectPath.startsWith('~/')) { return path.join(os.homedir(), projectPath.slice(2)); } return projectPath; } function resolveProjectPath(projectPath: string): string { return path.resolve(expandHome(projectPath)); } function toSlug(value: string): string { return value .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .replace(/-{2,}/g, '-'); } function buildTimestamp(date: Date): { datePart: string; timePart: string } { const iso = date.toISOString(); return { datePart: iso.slice(0, 10).replace(/-/g, ''), timePart: iso.slice(11, 19).replace(/:/g, ''), }; } function buildPrdId(name: string): string { const slug = toSlug(name); const { datePart, timePart } = buildTimestamp(new Date()); return `${slug || 'prd'}-${datePart}-${timePart}`; } function prdDirectory(projectPath: string): string { return path.join(projectPath, PRD_DIRECTORY); } function prdFilePath(projectPath: string, id: string): string { return path.join(prdDirectory(projectPath), `${id}.yaml`); } function isNodeErrorWithCode(error: unknown, code: string): boolean { return ( typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === code ); } async function writeFileAtomic(filePath: string, content: string): Promise { const directory = path.dirname(filePath); await fs.mkdir(directory, { recursive: true }); const tempPath = path.join( directory, `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random() .toString(16) .slice(2)}`, ); await fs.writeFile(tempPath, content, 'utf8'); await fs.rename(tempPath, filePath); } export async function createPrd(options: CreatePrdOptions): Promise { const resolvedProjectPath = resolveProjectPath(options.projectPath); const template = resolveTemplate(options.template); const now = new Date().toISOString(); const document: PrdDocument = { id: buildPrdId(options.name), title: options.name.trim(), status: 'draft', projectPath: resolvedProjectPath, template: template.id, sections: template.sections.map((section) => ({ id: section.id, title: section.title, fields: Object.fromEntries(section.fields.map((field) => [field, ''])), })), createdAt: now, updatedAt: now, }; await savePrd(document); return document; } export async function loadPrd(projectPath: string): Promise { const documents = await listPrds(projectPath); if (documents.length === 0) { const resolvedProjectPath = resolveProjectPath(projectPath); throw new Error(`No PRD documents found in ${prdDirectory(resolvedProjectPath)}`); } return documents[0]!; } export async function savePrd(doc: PrdDocument): Promise { const normalized = prdDocumentSchema.parse({ ...doc, projectPath: resolveProjectPath(doc.projectPath), }); const filePath = prdFilePath(normalized.projectPath, normalized.id); const serialized = yaml.dump(normalized, { noRefs: true, sortKeys: false, lineWidth: 120, }); const content = serialized.endsWith('\n') ? serialized : `${serialized}\n`; await writeFileAtomic(filePath, content); } export async function listPrds(projectPath: string): Promise { const resolvedProjectPath = resolveProjectPath(projectPath); const directory = prdDirectory(resolvedProjectPath); let entries: Dirent[]; try { entries = await fs.readdir(directory, { withFileTypes: true, encoding: 'utf8' }); } catch (error) { if (isNodeErrorWithCode(error, 'ENOENT')) { return []; } throw error; } const documents: PrdDocument[] = []; for (const entry of entries) { if (!entry.isFile()) { continue; } const ext = path.extname(entry.name); if (!PRD_FILE_EXTENSIONS.has(ext)) { continue; } const filePath = path.join(directory, entry.name); const raw = await fs.readFile(filePath, 'utf8'); let parsed: unknown; try { parsed = yaml.load(raw); } catch (error) { throw new Error(`Failed to parse PRD file ${filePath}: ${String(error)}`); } const document = prdDocumentSchema.parse(parsed); documents.push(document); } documents.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); return documents; }