Compare commits
1 Commits
main
...
feat/wave3
| Author | SHA1 | Date | |
|---|---|---|---|
| 3106ca8cf8 |
29
packages/prdy/package.json
Normal file
29
packages/prdy/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@mosaic/prdy",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Mosaic PRD wizard — TypeScript rewrite",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaic/types": "workspace:*",
|
||||
"@clack/prompts": "^0.9",
|
||||
"commander": "^13",
|
||||
"js-yaml": "^4",
|
||||
"zod": "^3.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
"@types/js-yaml": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
103
packages/prdy/src/cli.ts
Normal file
103
packages/prdy/src/cli.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { createPrd, listPrds, loadPrd } from './prd.js';
|
||||
import { runPrdWizard } from './wizard.js';
|
||||
|
||||
interface InitCommandOptions {
|
||||
readonly name: string;
|
||||
readonly project: string;
|
||||
readonly template?: 'software' | 'feature' | 'spike';
|
||||
}
|
||||
|
||||
interface ListCommandOptions {
|
||||
readonly project: string;
|
||||
}
|
||||
|
||||
interface ShowCommandOptions {
|
||||
readonly project: string;
|
||||
readonly id?: string;
|
||||
}
|
||||
|
||||
export function buildPrdyCli(): Command {
|
||||
const program = new Command();
|
||||
program
|
||||
.name('mosaic')
|
||||
.description('Mosaic CLI')
|
||||
.exitOverride();
|
||||
|
||||
const prdy = program.command('prdy').description('PRD wizard commands');
|
||||
|
||||
prdy
|
||||
.command('init')
|
||||
.description('Create a PRD document')
|
||||
.requiredOption('--name <name>', 'PRD name')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.option('--template <template>', 'Template (software|feature|spike)')
|
||||
.action(async (options: InitCommandOptions) => {
|
||||
const doc = process.stdout.isTTY
|
||||
? await runPrdWizard({
|
||||
name: options.name,
|
||||
projectPath: options.project,
|
||||
template: options.template,
|
||||
interactive: true,
|
||||
})
|
||||
: await createPrd({
|
||||
name: options.name,
|
||||
projectPath: options.project,
|
||||
template: options.template,
|
||||
interactive: false,
|
||||
});
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
status: doc.status,
|
||||
projectPath: doc.projectPath,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
prdy
|
||||
.command('list')
|
||||
.description('List PRD documents for a project')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.action(async (options: ListCommandOptions) => {
|
||||
const docs = await listPrds(options.project);
|
||||
console.log(JSON.stringify(docs, null, 2));
|
||||
});
|
||||
|
||||
prdy
|
||||
.command('show')
|
||||
.description('Show a PRD document')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.option('--id <id>', 'PRD document id')
|
||||
.action(async (options: ShowCommandOptions) => {
|
||||
if (options.id !== undefined) {
|
||||
const docs = await listPrds(options.project);
|
||||
const match = docs.find((doc) => doc.id === options.id);
|
||||
|
||||
if (match === undefined) {
|
||||
throw new Error(`PRD id not found: ${options.id}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(match, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = await loadPrd(options.project);
|
||||
console.log(JSON.stringify(doc, null, 2));
|
||||
});
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
export async function runPrdyCli(argv: readonly string[] = process.argv): Promise<void> {
|
||||
const program = buildPrdyCli();
|
||||
await program.parseAsync(argv);
|
||||
}
|
||||
20
packages/prdy/src/index.ts
Normal file
20
packages/prdy/src/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
createPrd,
|
||||
loadPrd,
|
||||
savePrd,
|
||||
listPrds,
|
||||
} from './prd.js';
|
||||
export { runPrdWizard } from './wizard.js';
|
||||
export { buildPrdyCli, runPrdyCli } from './cli.js';
|
||||
export {
|
||||
BUILTIN_PRD_TEMPLATES,
|
||||
resolveTemplate,
|
||||
} from './templates.js';
|
||||
export type {
|
||||
PrdStatus,
|
||||
PrdTemplate,
|
||||
PrdTemplateSection,
|
||||
PrdSection,
|
||||
PrdDocument,
|
||||
CreatePrdOptions,
|
||||
} from './types.js';
|
||||
199
packages/prdy/src/prd.ts
Normal file
199
packages/prdy/src/prd.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { 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: import('node:fs').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;
|
||||
}
|
||||
86
packages/prdy/src/templates.ts
Normal file
86
packages/prdy/src/templates.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { PrdTemplate } from './types.js';
|
||||
|
||||
export const BUILTIN_PRD_TEMPLATES: Record<string, PrdTemplate> = {
|
||||
software: {
|
||||
id: 'software',
|
||||
name: 'Software Project',
|
||||
fields: ['owner', 'status', 'scopeVersion', 'successMetrics'],
|
||||
sections: [
|
||||
{ id: 'introduction', title: 'Introduction', fields: ['context', 'objective'] },
|
||||
{ id: 'problem-statement', title: 'Problem Statement', fields: ['painPoints'] },
|
||||
{ id: 'scope-non-goals', title: 'Scope / Non-Goals', fields: ['inScope', 'outOfScope'] },
|
||||
{ id: 'user-stories', title: 'User Stories / Requirements', fields: ['stories'] },
|
||||
{ id: 'functional-requirements', title: 'Functional Requirements', fields: ['requirements'] },
|
||||
{
|
||||
id: 'non-functional-requirements',
|
||||
title: 'Non-Functional Requirements',
|
||||
fields: ['performance', 'reliability', 'security'],
|
||||
},
|
||||
{ id: 'acceptance-criteria', title: 'Acceptance Criteria', fields: ['criteria'] },
|
||||
{
|
||||
id: 'technical-considerations',
|
||||
title: 'Technical Considerations',
|
||||
fields: ['constraints', 'dependencies'],
|
||||
},
|
||||
{
|
||||
id: 'risks-open-questions',
|
||||
title: 'Risks / Open Questions',
|
||||
fields: ['risks', 'openQuestions'],
|
||||
},
|
||||
{
|
||||
id: 'milestones-delivery',
|
||||
title: 'Milestones / Delivery',
|
||||
fields: ['milestones', 'timeline'],
|
||||
},
|
||||
],
|
||||
},
|
||||
feature: {
|
||||
id: 'feature',
|
||||
name: 'Feature PRD',
|
||||
fields: ['owner', 'status', 'releaseTarget'],
|
||||
sections: [
|
||||
{ id: 'problem-statement', title: 'Problem Statement', fields: ['problem'] },
|
||||
{ id: 'goals', title: 'Goals', fields: ['goals'] },
|
||||
{ id: 'scope', title: 'Scope', fields: ['inScope', 'outOfScope'] },
|
||||
{ id: 'user-stories', title: 'User Stories', fields: ['stories'] },
|
||||
{ id: 'requirements', title: 'Requirements', fields: ['functional', 'nonFunctional'] },
|
||||
{ id: 'acceptance-criteria', title: 'Acceptance Criteria', fields: ['criteria'] },
|
||||
{ id: 'technical-considerations', title: 'Technical Considerations', fields: ['constraints'] },
|
||||
{ id: 'risks-open-questions', title: 'Risks / Open Questions', fields: ['risks', 'questions'] },
|
||||
{ id: 'milestones', title: 'Milestones', fields: ['milestones'] },
|
||||
{ id: 'success-metrics', title: 'Success Metrics / Testing', fields: ['metrics', 'testing'] },
|
||||
],
|
||||
},
|
||||
spike: {
|
||||
id: 'spike',
|
||||
name: 'Research Spike',
|
||||
fields: ['owner', 'status', 'decisionDeadline'],
|
||||
sections: [
|
||||
{ id: 'background', title: 'Background', fields: ['context'] },
|
||||
{ id: 'research-questions', title: 'Research Questions', fields: ['questions'] },
|
||||
{ id: 'constraints', title: 'Constraints', fields: ['constraints'] },
|
||||
{ id: 'options', title: 'Options Considered', fields: ['options'] },
|
||||
{ id: 'evaluation', title: 'Evaluation Criteria', fields: ['criteria'] },
|
||||
{ id: 'findings', title: 'Findings', fields: ['findings'] },
|
||||
{ id: 'recommendation', title: 'Recommendation', fields: ['recommendation'] },
|
||||
{ id: 'risks', title: 'Risks / Unknowns', fields: ['risks', 'unknowns'] },
|
||||
{ id: 'next-steps', title: 'Next Steps', fields: ['nextSteps'] },
|
||||
{ id: 'milestones', title: 'Milestones / Delivery', fields: ['milestones'] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveTemplate(templateName?: string): PrdTemplate {
|
||||
if (templateName === undefined || templateName.trim().length === 0) {
|
||||
return BUILTIN_PRD_TEMPLATES.software;
|
||||
}
|
||||
|
||||
const template = BUILTIN_PRD_TEMPLATES[templateName];
|
||||
if (template === undefined) {
|
||||
throw new Error(
|
||||
`Unknown PRD template: ${templateName}. Expected one of: ${Object.keys(BUILTIN_PRD_TEMPLATES).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
38
packages/prdy/src/types.ts
Normal file
38
packages/prdy/src/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type PrdStatus = 'draft' | 'review' | 'approved' | 'archived';
|
||||
|
||||
export interface PrdTemplateSection {
|
||||
id: string;
|
||||
title: string;
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export interface PrdTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
sections: PrdTemplateSection[];
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export interface PrdSection {
|
||||
id: string;
|
||||
title: string;
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PrdDocument {
|
||||
id: string;
|
||||
title: string;
|
||||
status: PrdStatus;
|
||||
projectPath: string;
|
||||
template: string;
|
||||
sections: PrdSection[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreatePrdOptions {
|
||||
name: string;
|
||||
projectPath: string;
|
||||
template?: string;
|
||||
interactive?: boolean;
|
||||
}
|
||||
121
packages/prdy/src/wizard.ts
Normal file
121
packages/prdy/src/wizard.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
cancel,
|
||||
intro,
|
||||
isCancel,
|
||||
outro,
|
||||
select,
|
||||
text,
|
||||
} from '@clack/prompts';
|
||||
|
||||
import { createPrd, savePrd } from './prd.js';
|
||||
import type { CreatePrdOptions, PrdDocument } from './types.js';
|
||||
|
||||
interface WizardAnswers {
|
||||
goals: string;
|
||||
constraints: string;
|
||||
milestones: string;
|
||||
}
|
||||
|
||||
function updateSectionField(
|
||||
doc: PrdDocument,
|
||||
sectionKeyword: string,
|
||||
value: string,
|
||||
): void {
|
||||
const section = doc.sections.find((candidate) =>
|
||||
candidate.id.includes(sectionKeyword),
|
||||
);
|
||||
|
||||
if (section === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldName =
|
||||
Object.keys(section.fields).find((field) =>
|
||||
field.toLowerCase().includes(sectionKeyword),
|
||||
) ?? Object.keys(section.fields)[0];
|
||||
|
||||
if (fieldName !== undefined) {
|
||||
section.fields[fieldName] = value;
|
||||
}
|
||||
}
|
||||
|
||||
async function promptText(message: string, initialValue = ''): Promise<string> {
|
||||
const response = await text({
|
||||
message,
|
||||
initialValue,
|
||||
});
|
||||
|
||||
if (isCancel(response)) {
|
||||
cancel('PRD wizard cancelled.');
|
||||
throw new Error('PRD wizard cancelled');
|
||||
}
|
||||
|
||||
return response.trim();
|
||||
}
|
||||
|
||||
async function promptTemplate(template?: string): Promise<string> {
|
||||
if (template !== undefined && template.trim().length > 0) {
|
||||
return template;
|
||||
}
|
||||
|
||||
const choice = await select({
|
||||
message: 'PRD type',
|
||||
options: [
|
||||
{ value: 'software', label: 'Software project' },
|
||||
{ value: 'feature', label: 'Feature' },
|
||||
{ value: 'spike', label: 'Research spike' },
|
||||
],
|
||||
});
|
||||
|
||||
if (isCancel(choice)) {
|
||||
cancel('PRD wizard cancelled.');
|
||||
throw new Error('PRD wizard cancelled');
|
||||
}
|
||||
|
||||
return choice;
|
||||
}
|
||||
|
||||
function applyWizardAnswers(doc: PrdDocument, answers: WizardAnswers): PrdDocument {
|
||||
updateSectionField(doc, 'goal', answers.goals);
|
||||
updateSectionField(doc, 'constraint', answers.constraints);
|
||||
updateSectionField(doc, 'milestone', answers.milestones);
|
||||
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
return doc;
|
||||
}
|
||||
|
||||
export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocument> {
|
||||
intro('Mosaic PRD wizard');
|
||||
|
||||
const name =
|
||||
options.name.trim().length > 0
|
||||
? options.name.trim()
|
||||
: await promptText('Project name');
|
||||
const template = await promptTemplate(options.template);
|
||||
const goals = await promptText('Primary goals');
|
||||
const constraints = await promptText('Key constraints');
|
||||
const milestones = await promptText('Planned milestones');
|
||||
|
||||
const doc = await createPrd({
|
||||
...options,
|
||||
name,
|
||||
template,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
const updated = applyWizardAnswers(doc, {
|
||||
goals,
|
||||
constraints,
|
||||
milestones,
|
||||
});
|
||||
|
||||
await savePrd(updated);
|
||||
|
||||
outro(
|
||||
`PRD created: ${path.join(updated.projectPath, 'docs', 'prdy', `${updated.id}.yaml`)}`,
|
||||
);
|
||||
|
||||
return updated;
|
||||
}
|
||||
36
packages/prdy/tests/prd.test.ts
Normal file
36
packages/prdy/tests/prd.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createPrd, listPrds, loadPrd } from '../src/prd.js';
|
||||
|
||||
describe('prd document lifecycle', () => {
|
||||
it('creates and loads PRD documents', async () => {
|
||||
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prdy-project-'));
|
||||
|
||||
try {
|
||||
const created = await createPrd({
|
||||
name: 'User Authentication',
|
||||
projectPath: projectDir,
|
||||
template: 'feature',
|
||||
});
|
||||
|
||||
expect(created.title).toBe('User Authentication');
|
||||
expect(created.status).toBe('draft');
|
||||
expect(created.id).toMatch(/^user-authentication-\d{8}-\d{6}$/);
|
||||
|
||||
const loaded = await loadPrd(projectDir);
|
||||
expect(loaded.id).toBe(created.id);
|
||||
expect(loaded.title).toBe(created.title);
|
||||
expect(loaded.sections.length).toBeGreaterThan(0);
|
||||
|
||||
const listed = await listPrds(projectDir);
|
||||
expect(listed).toHaveLength(1);
|
||||
expect(listed[0]?.id).toBe(created.id);
|
||||
} finally {
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
22
packages/prdy/tests/templates.test.ts
Normal file
22
packages/prdy/tests/templates.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BUILTIN_PRD_TEMPLATES } from '../src/templates.js';
|
||||
|
||||
describe('built-in PRD templates', () => {
|
||||
it('includes software, feature, and spike templates with required fields', () => {
|
||||
expect(BUILTIN_PRD_TEMPLATES.software).toBeDefined();
|
||||
expect(BUILTIN_PRD_TEMPLATES.feature).toBeDefined();
|
||||
expect(BUILTIN_PRD_TEMPLATES.spike).toBeDefined();
|
||||
|
||||
for (const template of Object.values(BUILTIN_PRD_TEMPLATES)) {
|
||||
expect(template.sections.length).toBeGreaterThan(0);
|
||||
expect(template.fields.length).toBeGreaterThan(0);
|
||||
|
||||
for (const section of template.sections) {
|
||||
expect(section.id.length).toBeGreaterThan(0);
|
||||
expect(section.title.length).toBeGreaterThan(0);
|
||||
expect(section.fields.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
5
packages/prdy/tsconfig.json
Normal file
5
packages/prdy/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||
"include": ["src"]
|
||||
}
|
||||
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@@ -83,6 +83,37 @@ importers:
|
||||
specifier: ^2
|
||||
version: 2.1.9(@types/node@22.19.15)
|
||||
|
||||
packages/prdy:
|
||||
dependencies:
|
||||
'@clack/prompts':
|
||||
specifier: ^0.9
|
||||
version: 0.9.1
|
||||
'@mosaic/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
commander:
|
||||
specifier: ^13
|
||||
version: 13.1.0
|
||||
js-yaml:
|
||||
specifier: ^4
|
||||
version: 4.1.1
|
||||
zod:
|
||||
specifier: ^3.24
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/js-yaml':
|
||||
specifier: ^4
|
||||
version: 4.0.9
|
||||
'@types/node':
|
||||
specifier: ^22
|
||||
version: 22.19.15
|
||||
typescript:
|
||||
specifier: ^5
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2
|
||||
version: 2.1.9(@types/node@22.19.15)
|
||||
|
||||
packages/queue:
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk':
|
||||
|
||||
Reference in New Issue
Block a user