Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
200 lines
5.3 KiB
TypeScript
200 lines
5.3 KiB
TypeScript
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<void> {
|
|
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<PrdDocument> {
|
|
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<PrdDocument> {
|
|
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<void> {
|
|
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<PrdDocument[]> {
|
|
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;
|
|
}
|