diff --git a/.github/workflows/cli-test.yml b/.github/workflows/cli-test.yml new file mode 100644 index 0000000..126160f --- /dev/null +++ b/.github/workflows/cli-test.yml @@ -0,0 +1,28 @@ +name: CLI test + +on: + pull_request: + paths: + - "bin/**" + - "src/**" + - "tests/**" + - "package.json" + - ".github/workflows/cli-test.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + cli: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: "20" + - run: npm test + - run: node ./bin/beforecode.js help + - run: node ./bin/beforecode.js list + - run: node ./bin/beforecode.js init --type saas --docs tmp-docs + - run: node ./bin/beforecode.js check --type saas --docs tmp-docs diff --git a/bin/beforecode.js b/bin/beforecode.js new file mode 100755 index 0000000..bcd8e21 --- /dev/null +++ b/bin/beforecode.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import { runCli } from "../src/cli.js"; + +runCli(process.argv.slice(2)).catch((error) => { + console.error(`BeforeCode error: ${error.message}`); + process.exit(1); +}); diff --git a/docs/cli-prd.md b/docs/cli-prd.md new file mode 100644 index 0000000..6418624 --- /dev/null +++ b/docs/cli-prd.md @@ -0,0 +1,60 @@ +# BeforeCode CLI PRD + +## Overview + +BeforeCode CLI turns the Markdown toolkit into a project-integrated generator. + +Users can run one command inside any software project and generate the right documentation set for the project type. + +## Goal + +Make BeforeCode usable without manual copying from GitHub. + +## Target Users + +- Developers starting a new project +- Existing project maintainers adding documentation +- AI coding users preparing source-of-truth docs +- Students creating structured project documentation + +## MVP Commands + +| Command | Purpose | +|---|---| +| `beforecode init --type saas` | Generate a documentation set for a project type | +| `beforecode add prd` | Add one template to the project | +| `beforecode check --type saas` | Check required docs for a project type | +| `beforecode score --type saas` | Show readiness score | +| `beforecode handoff` | Generate AI handoff files | +| `beforecode list` | Show supported project types and templates | + +## Supported Project Types + +- small +- portfolio +- saas +- crm +- ecommerce +- mobile +- ai-agent +- opensource + +## Success Criteria + +- CLI can run with Node 18 or later +- CLI can generate docs into a local `docs/` folder +- CLI does not overwrite files unless `--force` is used +- CLI supports `--dry-run` +- CLI creates `.beforecoderc.json` +- CLI can check missing docs +- CLI can generate a basic AI handoff package +- CLI can run tests with Node built-in test runner +- CLI works with `npx beforecode` after package publishing + +## Non-goals for MVP + +- No web UI +- No remote API +- No AI generation inside CLI +- No npm publishing automation yet +- No TypeScript build step yet diff --git a/docs/cli-roadmap.md b/docs/cli-roadmap.md new file mode 100644 index 0000000..92c40f8 --- /dev/null +++ b/docs/cli-roadmap.md @@ -0,0 +1,29 @@ +# CLI Roadmap + +## v0.1 + +- Generate docs by project type +- Add one template +- Check missing docs +- Show readiness score +- Generate basic AI handoff files +- Add Node test workflow + +## v0.2 + +- Interactive prompts +- Template variable injection +- More project-specific template packs +- Better config editing + +## v0.3 + +- Section-level readiness checks +- Traceability report +- AI handoff profiles for common coding tools + +## v1.0 + +- Stable npm package +- Versioned template packs +- Strong docs site integration diff --git a/docs/cli-trd.md b/docs/cli-trd.md new file mode 100644 index 0000000..59e85ba --- /dev/null +++ b/docs/cli-trd.md @@ -0,0 +1,62 @@ +# BeforeCode CLI TRD + +## Overview + +The CLI is a zero-dependency Node.js tool that copies BeforeCode templates into a project and checks documentation readiness. + +## Runtime + +- Node.js 18+ +- ECMAScript modules +- Built-in filesystem APIs +- Node built-in test runner + +## Entry Point + +```text +bin/beforecode.js +``` + +## Source Layout + +```text +src/cli.js +src/commands/ +src/core/ +src/data/ +src/args.js +src/fs-utils.js +src/paths.js +``` + +## Commands + +- `init` generates a docs set +- `add` copies one template +- `check` checks required docs +- `score` shows readiness score +- `handoff` creates AI handoff files +- `list` shows supported options + +## Safety Rules + +- Do not overwrite files by default +- Use `--force` to replace existing files +- Use `--dry-run` to preview changes +- Generate `.beforecoderc.json` for future checks + +## Test Strategy + +Use Node's built-in test runner. + +```bash +npm test +``` + +## Future Scope + +- Interactive prompts +- Template variables beyond basic fields +- Section-level scoring +- Traceability scoring +- npm publishing workflow diff --git a/docs/cli-usage.md b/docs/cli-usage.md new file mode 100644 index 0000000..4c6b5fd --- /dev/null +++ b/docs/cli-usage.md @@ -0,0 +1,91 @@ +# CLI Usage + +BeforeCode CLI generates project documentation inside an existing or new software project. + +## Local usage from the repository + +```bash +npm install +node ./bin/beforecode.js help +node ./bin/beforecode.js list +``` + +## Generate a docs set + +```bash +beforecode init --type saas +``` + +Use a custom docs folder: + +```bash +beforecode init --type ai-agent --docs project-docs +``` + +Preview without writing files: + +```bash +beforecode init --type saas --dry-run +``` + +Replace existing generated files intentionally: + +```bash +beforecode init --type saas --force +``` + +## Add one template + +```bash +beforecode add prd +beforecode add api-documentation +beforecode add qa-test-plan +``` + +## Check readiness + +```bash +beforecode check --type saas +``` + +If `.beforecoderc.json` exists, the CLI can read the project type and docs path automatically: + +```bash +beforecode check +``` + +## Score readiness + +```bash +beforecode score --type saas +``` + +The MVP score is based on whether required files exist. Later versions can inspect sections and traceability. + +## Generate AI handoff files + +```bash +beforecode handoff +``` + +This creates: + +```text +AGENTS.md +docs/ai-handoff.md +``` + +## Supported project types + +- small +- portfolio +- saas +- crm +- ecommerce +- mobile +- ai-agent +- opensource + +## Safe behavior + +The CLI does not overwrite files by default. Use `--force` only when replacing existing generated files is intentional. diff --git a/package.json b/package.json new file mode 100644 index 0000000..d8ae685 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "beforecode", + "version": "0.1.0", + "description": "Spec-first project planning toolkit for humans and AI coding agents.", + "type": "module", + "bin": { + "beforecode": "./bin/beforecode.js" + }, + "scripts": { + "cli": "node ./bin/beforecode.js", + "test": "node --test", + "test:cli": "node ./bin/beforecode.js help && node ./bin/beforecode.js list" + }, + "files": [ + "bin", + "src", + "templates", + "checklists", + "prompts", + "docs/cli-usage.md", + "README.md", + "LICENSE" + ], + "keywords": [ + "prd", + "documentation", + "project-planning", + "software-architecture", + "ai-coding", + "qa", + "cli" + ], + "author": "Mohit Kumar", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } +} diff --git a/src/args.js b/src/args.js new file mode 100644 index 0000000..24e77cf --- /dev/null +++ b/src/args.js @@ -0,0 +1,30 @@ +export function parseArgs(args) { + const result = { _: [] }; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + + if (!arg.startsWith("--")) { + result._.push(arg); + continue; + } + + const raw = arg.slice(2); + const equalsIndex = raw.indexOf("="); + + if (equalsIndex !== -1) { + result[raw.slice(0, equalsIndex)] = raw.slice(equalsIndex + 1); + continue; + } + + const next = args[i + 1]; + if (!next || next.startsWith("--")) { + result[raw] = true; + } else { + result[raw] = next; + i += 1; + } + } + + return result; +} diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..43a9833 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,61 @@ +import { parseArgs } from "./args.js"; +import { getProjectTypes } from "./data/project-types.js"; +import { initCommand } from "./commands/init.js"; +import { addCommand } from "./commands/add.js"; +import { checkCommand } from "./commands/check.js"; +import { scoreCommand } from "./commands/score.js"; +import { handoffCommand } from "./commands/handoff.js"; +import { listCommand } from "./commands/list.js"; + +const VERSION = "0.1.0"; + +export async function runCli(argv, context = { cwd: process.cwd() }) { + const options = parseArgs(argv); + const command = options._[0] || "help"; + + if (options.version || command === "version") { + return printVersion(); + } + + if (options.help || command === "help") { + return printHelp(); + } + + if (command === "init") return initCommand(options, context); + if (command === "add") return addCommand(options, context); + if (command === "check") return checkCommand(options, context); + if (command === "score") return scoreCommand(options, context); + if (command === "handoff") return handoffCommand(options, context); + if (command === "list") return listCommand(options, context); + + throw new Error(`Unknown command: ${command}`); +} + +function printVersion() { + console.log(`beforecode ${VERSION}`); +} + +function printHelp() { + console.log(`BeforeCode CLI + +Usage: + beforecode init --type saas + beforecode init --type ai-agent --docs project-docs + beforecode add prd + beforecode check --type saas + beforecode score --type saas + beforecode handoff + beforecode list + +Options: + --type Project type + --docs Documentation folder + --name Project name + --force Replace existing generated files + --dry-run Preview changes without writing files + --version Show version + +Project types: + ${getProjectTypes().join(", ")} +`); +} diff --git a/src/commands/add.js b/src/commands/add.js new file mode 100644 index 0000000..88bd9f8 --- /dev/null +++ b/src/commands/add.js @@ -0,0 +1,27 @@ +import { resolveOptions } from "../core/config.js"; +import { addTemplate } from "../core/generator.js"; + +export async function addCommand(options, context) { + const templateName = options._[1]; + + if (!templateName) { + throw new Error("Missing template name. Example: beforecode add prd"); + } + + const resolved = await resolveOptions(options, context.cwd); + const result = await addTemplate({ + cwd: context.cwd, + templateName, + docsPath: resolved.docsPath, + force: resolved.force, + dryRun: resolved.dryRun + }); + + if (result.dryRun) { + console.log(`would create ${result.target}`); + } else if (result.created) { + console.log(`Added ${templateName}`); + } else { + console.log(`Skipped existing ${templateName}`); + } +} diff --git a/src/commands/check.js b/src/commands/check.js new file mode 100644 index 0000000..1499ebd --- /dev/null +++ b/src/commands/check.js @@ -0,0 +1,29 @@ +import { resolveOptions } from "../core/config.js"; +import { checkProject } from "../core/checker.js"; + +export async function checkCommand(options, context) { + const resolved = await resolveOptions(options, context.cwd); + const result = await checkProject({ + cwd: context.cwd, + projectType: resolved.projectType, + docsPath: resolved.docsPath + }); + + console.log(`BeforeCode check: ${result.label}`); + console.log(""); + + for (const item of result.results) { + console.log(`${item.found ? "✓" : "✗"} ${item.file}`); + } + + if (result.missing.length) { + console.log(""); + console.log("Missing:"); + for (const item of result.missing) { + console.log(`- ${item.file}`); + } + } + + console.log(""); + console.log(`Readiness: ${result.score}%`); +} diff --git a/src/commands/handoff.js b/src/commands/handoff.js new file mode 100644 index 0000000..6d78c82 --- /dev/null +++ b/src/commands/handoff.js @@ -0,0 +1,19 @@ +import { resolveOptions } from "../core/config.js"; +import { generateHandoff } from "../core/handoff.js"; + +export async function handoffCommand(options, context) { + const resolved = await resolveOptions(options, context.cwd); + const result = await generateHandoff({ cwd: context.cwd, ...resolved }); + + if (result.dryRun) { + console.log("BeforeCode handoff dry run"); + for (const file of result.files) { + console.log(`would create ${file.path}`); + } + return; + } + + console.log("BeforeCode AI handoff generated"); + console.log(`Created: ${result.created.length}`); + console.log(`Skipped: ${result.skipped.length}`); +} diff --git a/src/commands/init.js b/src/commands/init.js new file mode 100644 index 0000000..4258c6b --- /dev/null +++ b/src/commands/init.js @@ -0,0 +1,26 @@ +import { resolveOptions } from "../core/config.js"; +import { initProject } from "../core/generator.js"; + +export async function initCommand(options, context) { + const resolved = await resolveOptions(options, context.cwd); + const result = await initProject({ cwd: context.cwd, ...resolved }); + + if (result.dryRun) { + console.log(`BeforeCode dry run: ${result.label}`); + for (const item of result.planned) { + console.log(`would create ${relative(context.cwd, item.target)}`); + } + return; + } + + console.log("BeforeCode initialized"); + console.log(`Project type: ${result.label}`); + console.log(`Docs path: ${result.docsPath}`); + console.log(`Created: ${result.created.length}`); + console.log(`Skipped: ${result.skipped.length}`); + console.log("Config: .beforecoderc.json"); +} + +function relative(cwd, file) { + return file.replace(`${cwd}/`, "").replace(`${cwd}\\`, ""); +} diff --git a/src/commands/list.js b/src/commands/list.js new file mode 100644 index 0000000..e2aa068 --- /dev/null +++ b/src/commands/list.js @@ -0,0 +1,24 @@ +import { getProjectTypes, getProjectType } from "../data/project-types.js"; +import { listTemplates } from "../data/templates.js"; + +export function listCommand(options) { + const mode = options._[1] || "all"; + + if (mode === "all" || mode === "types") { + console.log("Project types:"); + for (const type of getProjectTypes()) { + console.log(`- ${type}: ${getProjectType(type).label}`); + } + } + + if (mode === "all") { + console.log(""); + } + + if (mode === "all" || mode === "templates") { + console.log("Templates:"); + for (const template of listTemplates()) { + console.log(`- ${template}`); + } + } +} diff --git a/src/commands/score.js b/src/commands/score.js new file mode 100644 index 0000000..d125c95 --- /dev/null +++ b/src/commands/score.js @@ -0,0 +1,33 @@ +import { resolveOptions } from "../core/config.js"; +import { scoreProject } from "../core/scorer.js"; + +export async function scoreCommand(options, context) { + const resolved = await resolveOptions(options, context.cwd); + const result = await scoreProject({ + cwd: context.cwd, + projectType: resolved.projectType, + docsPath: resolved.docsPath + }); + + console.log(`BeforeCode readiness score: ${result.label}`); + console.log(""); + + for (const [category, score] of Object.entries(result.categories)) { + console.log(`${label(category)}: ${score}%`); + } + + console.log(""); + console.log(`Overall: ${result.score}%`); + + if (result.score < 70) { + console.log("Status: Needs more documentation before full build."); + } else if (result.score < 90) { + console.log("Status: Good start, but review missing areas."); + } else { + console.log("Status: Ready for implementation review."); + } +} + +function label(value) { + return value.replace("-", " ").replace(/\b\w/g, (char) => char.toUpperCase()); +} diff --git a/src/core/checker.js b/src/core/checker.js new file mode 100644 index 0000000..24f3c11 --- /dev/null +++ b/src/core/checker.js @@ -0,0 +1,34 @@ +import { join } from "node:path"; +import { readDirSafe } from "../fs-utils.js"; +import { getProjectDocs, getProjectType } from "../data/project-types.js"; +import { getTemplateFile } from "../data/templates.js"; + +export async function checkProject({ cwd, projectType, docsPath }) { + const docs = getProjectDocs(projectType); + const typeConfig = getProjectType(projectType); + + if (!docs) { + throw new Error(`Unknown project type: ${projectType}`); + } + + const files = await readDirSafe(join(cwd, docsPath)); + const results = docs.map((templateName) => { + const file = getTemplateFile(templateName); + const found = files.some((existing) => existing === file || existing.endsWith(`-${file}`)); + return { templateName, file, found }; + }); + + const present = results.filter((item) => item.found); + const missing = results.filter((item) => !item.found); + const score = Math.round((present.length / results.length) * 100); + + return { + projectType, + label: typeConfig.label, + docsPath, + results, + present, + missing, + score + }; +} diff --git a/src/core/config.js b/src/core/config.js new file mode 100644 index 0000000..6c0cb2f --- /dev/null +++ b/src/core/config.js @@ -0,0 +1,35 @@ +import { join } from "node:path"; +import { exists, readText, writeText } from "../fs-utils.js"; + +export const CONFIG_FILE = ".beforecoderc.json"; + +export async function readConfig(cwd) { + const path = join(cwd, CONFIG_FILE); + + if (!await exists(path)) { + return null; + } + + try { + return JSON.parse(await readText(path)); + } catch { + throw new Error(`${CONFIG_FILE} exists but is not valid JSON.`); + } +} + +export async function writeConfig(cwd, config, { force = true } = {}) { + const path = join(cwd, CONFIG_FILE); + return writeText(path, `${JSON.stringify(config, null, 2)}\n`, { force }); +} + +export async function resolveOptions(options, cwd) { + const config = await readConfig(cwd); + + return { + projectName: options.name || config?.projectName || "Untitled Project", + projectType: options.type || config?.projectType || "small", + docsPath: options.docs || config?.docsPath || "docs", + force: Boolean(options.force), + dryRun: Boolean(options["dry-run"]) + }; +} diff --git a/src/core/generator.js b/src/core/generator.js new file mode 100644 index 0000000..8120ed2 --- /dev/null +++ b/src/core/generator.js @@ -0,0 +1,115 @@ +import { join } from "node:path"; +import { TEMPLATE_DIR } from "../paths.js"; +import { ensureDir, exists, readText, writeText } from "../fs-utils.js"; +import { getProjectDocs, getProjectType } from "../data/project-types.js"; +import { getTemplateFile } from "../data/templates.js"; +import { writeConfig } from "./config.js"; + +const VERSION = "0.1.0"; + +export async function initProject({ cwd, projectName, projectType, docsPath, force = false, dryRun = false }) { + const docs = getProjectDocs(projectType); + const typeConfig = getProjectType(projectType); + + if (!docs) { + throw new Error(`Unknown project type: ${projectType}`); + } + + const docsDir = join(cwd, docsPath); + const planned = docs.map((templateName, index) => { + const file = getTemplateFile(templateName); + return { + templateName, + file, + target: join(docsDir, numberedName(index + 1, file)) + }; + }); + + if (dryRun) { + return { + dryRun: true, + projectType, + label: typeConfig.label, + docsPath, + planned, + created: [], + skipped: [] + }; + } + + await ensureDir(docsDir); + + const created = []; + const skipped = []; + + for (const item of planned) { + if (!force && await exists(item.target)) { + skipped.push(item.target); + continue; + } + + const source = join(TEMPLATE_DIR, item.file); + const content = renderTemplate(await readText(source), { + projectName, + projectType, + docsPath, + templateName: item.templateName + }); + + await writeText(item.target, content, { force: true }); + created.push(item.target); + } + + await writeConfig(cwd, { + projectName, + projectType, + docsPath, + createdBy: "beforecode", + version: VERSION, + documents: planned.map((item) => item.target.split(/[\\/]/).pop()) + }); + + return { + dryRun: false, + projectType, + label: typeConfig.label, + docsPath, + planned, + created, + skipped + }; +} + +export async function addTemplate({ cwd, templateName, docsPath, force = false, dryRun = false }) { + const file = getTemplateFile(templateName); + + if (!file) { + throw new Error(`Unknown template: ${templateName}`); + } + + const target = join(cwd, docsPath, file); + const source = join(TEMPLATE_DIR, file); + + if (dryRun) { + return { dryRun: true, target, created: false, skipped: false }; + } + + if (!force && await exists(target)) { + return { dryRun: false, target, created: false, skipped: true }; + } + + await writeText(target, await readText(source), { force: true }); + return { dryRun: false, target, created: true, skipped: false }; +} + +function numberedName(index, file) { + return `${String(index).padStart(2, "0")}-${file}`; +} + +function renderTemplate(content, values) { + return content + .replaceAll("{{projectName}}", values.projectName) + .replaceAll("{{projectType}}", values.projectType) + .replaceAll("{{docsPath}}", values.docsPath) + .replaceAll("{{templateName}}", values.templateName); +} diff --git a/src/core/handoff.js b/src/core/handoff.js new file mode 100644 index 0000000..bfb7e06 --- /dev/null +++ b/src/core/handoff.js @@ -0,0 +1,85 @@ +import { join } from "node:path"; +import { writeText } from "../fs-utils.js"; + +export async function generateHandoff({ cwd, projectName, projectType, docsPath, force = false, dryRun = false }) { + const files = [ + { + path: join(cwd, "AGENTS.md"), + content: agentsContent({ projectName, projectType, docsPath }) + }, + { + path: join(cwd, docsPath, "ai-handoff.md"), + content: handoffContent({ projectName, projectType, docsPath }) + } + ]; + + if (dryRun) { + return { dryRun: true, files, created: [], skipped: [] }; + } + + const created = []; + const skipped = []; + + for (const file of files) { + const result = await writeText(file.path, file.content, { force }); + if (result.written) created.push(file.path); + if (result.skipped) skipped.push(file.path); + } + + return { dryRun: false, files, created, skipped }; +} + +function agentsContent({ projectName, projectType, docsPath }) { + return `# AGENTS.md + +## Project + +${projectName} + +## Project type + +${projectType} + +## Source of truth + +Read the approved documents in \`${docsPath}/\` before changing code. + +## Required workflow + +1. Inspect the repository. +2. Read product and technical docs. +3. Map work to requirements. +4. Keep changes inside the requested scope. +5. Run available checks. +6. Report what changed, how it was verified, and what remains. + +## Approval required + +Ask before destructive data changes, production deployment, new external services, permission changes, or undocumented scope expansion. +`; +} + +function handoffContent({ projectName, projectType, docsPath }) { + return `# AI Handoff + +## Project + +${projectName} + +## Project type + +${projectType} + +## Documents + +Use \`${docsPath}/\` as the source-of-truth folder. + +## Build instruction + +Implement only the current approved scope. If requirements conflict, stop and report the conflict before coding. + +## Verification + +After implementation, report tests run, files changed, risks, and any requirement that could not be completed. +`; +} diff --git a/src/core/scorer.js b/src/core/scorer.js new file mode 100644 index 0000000..9a106b7 --- /dev/null +++ b/src/core/scorer.js @@ -0,0 +1,30 @@ +import { checkProject } from "./checker.js"; + +const CATEGORIES = { + product: ["project-brief.md", "research-report.md", "business-requirements.md", "prd.md", "srs.md"], + experience: ["ux-flows.md", "ui-system.md", "permission-matrix.md"], + engineering: ["trd.md", "database-schema.md", "api-documentation.md"], + quality: ["qa-test-plan.md"], + delivery: ["implementation-plan.md", "build-plan.md", "deployment-plan.md"] +}; + +export async function scoreProject(options) { + const check = await checkProject(options); + const categories = {}; + + for (const [name, files] of Object.entries(CATEGORIES)) { + const relevant = check.results.filter((item) => files.includes(item.file)); + + if (!relevant.length) { + continue; + } + + const present = relevant.filter((item) => item.found).length; + categories[name] = Math.round((present / relevant.length) * 100); + } + + return { + ...check, + categories + }; +} diff --git a/src/data/project-types.js b/src/data/project-types.js new file mode 100644 index 0000000..788a930 --- /dev/null +++ b/src/data/project-types.js @@ -0,0 +1,100 @@ +export const PROJECT_TYPES = { + small: { + label: "Small app", + documents: ["project-brief", "prd", "build-plan", "qa-test-plan"] + }, + portfolio: { + label: "Portfolio", + documents: ["project-brief", "ux-flows", "ui-system", "build-plan", "qa-test-plan"] + }, + saas: { + label: "SaaS product", + documents: [ + "project-brief", + "research-report", + "prd", + "ux-flows", + "trd", + "database-schema", + "api-documentation", + "permission-matrix", + "qa-test-plan", + "implementation-plan", + "deployment-plan" + ] + }, + crm: { + label: "CRM or operations system", + documents: [ + "business-requirements", + "research-report", + "prd", + "srs", + "permission-matrix", + "database-schema", + "api-documentation", + "qa-test-plan", + "implementation-plan" + ] + }, + ecommerce: { + label: "E-commerce or marketplace", + documents: [ + "project-brief", + "research-report", + "business-requirements", + "prd", + "ux-flows", + "trd", + "database-schema", + "api-documentation", + "permission-matrix", + "qa-test-plan", + "deployment-plan" + ] + }, + mobile: { + label: "Mobile app", + documents: [ + "project-brief", + "prd", + "ux-flows", + "api-documentation", + "permission-matrix", + "qa-test-plan", + "implementation-plan", + "deployment-plan" + ] + }, + "ai-agent": { + label: "AI agent", + documents: [ + "project-brief", + "research-report", + "prd", + "trd", + "database-schema", + "api-documentation", + "permission-matrix", + "qa-test-plan", + "implementation-plan", + "deployment-plan" + ] + }, + opensource: { + label: "Open-source tool", + documents: ["project-brief", "prd", "trd", "build-plan", "qa-test-plan", "deployment-plan"] + } +}; + +export function getProjectType(type) { + return PROJECT_TYPES[type] || null; +} + +export function getProjectDocs(type) { + return getProjectType(type)?.documents || null; +} + +export function getProjectTypes() { + return Object.keys(PROJECT_TYPES); +} diff --git a/src/data/templates.js b/src/data/templates.js new file mode 100644 index 0000000..550f26a --- /dev/null +++ b/src/data/templates.js @@ -0,0 +1,27 @@ +export const TEMPLATE_FILES = { + "project-brief": "project-brief.md", + "research-report": "research-report.md", + "business-requirements": "business-requirements.md", + prd: "prd.md", + srs: "srs.md", + "release-scope": "release-scope.md", + "ux-flows": "ux-flows.md", + "ui-system": "ui-system.md", + "permission-matrix": "permission-matrix.md", + trd: "trd.md", + "database-schema": "database-schema.md", + "api-documentation": "api-documentation.md", + "decision-record": "decision-record.md", + "implementation-plan": "implementation-plan.md", + "build-plan": "build-plan.md", + "qa-test-plan": "qa-test-plan.md", + "deployment-plan": "deployment-plan.md" +}; + +export function getTemplateFile(name) { + return TEMPLATE_FILES[name] || null; +} + +export function listTemplates() { + return Object.keys(TEMPLATE_FILES).sort(); +} diff --git a/src/fs-utils.js b/src/fs-utils.js new file mode 100644 index 0000000..735d344 --- /dev/null +++ b/src/fs-utils.js @@ -0,0 +1,48 @@ +import { access, copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import { dirname } from "node:path"; + +export async function ensureDir(path) { + await mkdir(path, { recursive: true }); +} + +export async function exists(path) { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +export async function readText(path) { + return readFile(path, "utf8"); +} + +export async function readDirSafe(path) { + try { + return await readdir(path); + } catch { + return []; + } +} + +export async function writeText(path, content, { force = false } = {}) { + if (!force && await exists(path)) { + return { written: false, skipped: true, path }; + } + + await ensureDir(dirname(path)); + await writeFile(path, content, "utf8"); + return { written: true, skipped: false, path }; +} + +export async function copyFileSafe(source, target, { force = false } = {}) { + if (!force && await exists(target)) { + return { copied: false, skipped: true, target }; + } + + await ensureDir(dirname(target)); + await copyFile(source, target); + return { copied: true, skipped: false, target }; +} diff --git a/src/paths.js b/src/paths.js new file mode 100644 index 0000000..9388a88 --- /dev/null +++ b/src/paths.js @@ -0,0 +1,12 @@ +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SRC_DIR = dirname(fileURLToPath(import.meta.url)); + +export const PACKAGE_ROOT = resolve(SRC_DIR, ".."); +export const TEMPLATE_DIR = join(PACKAGE_ROOT, "templates"); +export const PROMPT_DIR = join(PACKAGE_ROOT, "prompts"); + +export function projectPath(cwd, ...parts) { + return resolve(cwd, ...parts); +} diff --git a/src/project-types.js b/src/project-types.js new file mode 100644 index 0000000..a2774e2 --- /dev/null +++ b/src/project-types.js @@ -0,0 +1 @@ +export { PROJECT_TYPES, getProjectDocs, getProjectType, getProjectTypes } from "./data/project-types.js"; diff --git a/src/template-map.js b/src/template-map.js new file mode 100644 index 0000000..2b79f9b --- /dev/null +++ b/src/template-map.js @@ -0,0 +1 @@ +export { TEMPLATE_FILES, getTemplateFile, listTemplates } from "./data/templates.js"; diff --git a/tests/add-check-score.test.js b/tests/add-check-score.test.js new file mode 100644 index 0000000..8a3e917 --- /dev/null +++ b/tests/add-check-score.test.js @@ -0,0 +1,52 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +test("add creates one template and check reports readiness", () => { + const cwd = mkdtempSync(join(tmpdir(), "beforecode-add-")); + + try { + execFileSync("node", [process.cwd() + "/bin/beforecode.js", "add", "prd"], { + cwd, + encoding: "utf8" + }); + + assert.ok(existsSync(join(cwd, "docs", "prd.md"))); + + const check = execFileSync("node", [process.cwd() + "/bin/beforecode.js", "check", "--type", "small"], { + cwd, + encoding: "utf8" + }); + + assert.match(check, /prd.md/); + assert.match(check, /Readiness:/); + + const score = execFileSync("node", [process.cwd() + "/bin/beforecode.js", "score", "--type", "small"], { + cwd, + encoding: "utf8" + }); + + assert.match(score, /Overall:/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("handoff creates AGENTS.md", () => { + const cwd = mkdtempSync(join(tmpdir(), "beforecode-handoff-")); + + try { + execFileSync("node", [process.cwd() + "/bin/beforecode.js", "handoff", "--name", "Demo"], { + cwd, + encoding: "utf8" + }); + + assert.ok(existsSync(join(cwd, "AGENTS.md"))); + assert.ok(existsSync(join(cwd, "docs", "ai-handoff.md"))); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/tests/cli-help.test.js b/tests/cli-help.test.js new file mode 100644 index 0000000..182a71f --- /dev/null +++ b/tests/cli-help.test.js @@ -0,0 +1,16 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; + +test("help command prints usage", () => { + const output = execFileSync("node", ["./bin/beforecode.js", "help"], { encoding: "utf8" }); + assert.match(output, /BeforeCode CLI/); + assert.match(output, /beforecode init --type saas/); +}); + +test("list command prints project types", () => { + const output = execFileSync("node", ["./bin/beforecode.js", "list"], { encoding: "utf8" }); + assert.match(output, /Project types:/); + assert.match(output, /saas/); + assert.match(output, /Templates:/); +}); diff --git a/tests/init-command.test.js b/tests/init-command.test.js new file mode 100644 index 0000000..5b0472c --- /dev/null +++ b/tests/init-command.test.js @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +test("init creates SaaS docs and config", () => { + const cwd = mkdtempSync(join(tmpdir(), "beforecode-init-")); + + try { + const output = execFileSync("node", [process.cwd() + "/bin/beforecode.js", "init", "--type", "saas"], { + cwd, + encoding: "utf8" + }); + + assert.match(output, /BeforeCode initialized/); + assert.ok(existsSync(join(cwd, "docs", "01-project-brief.md"))); + assert.ok(existsSync(join(cwd, "docs", "03-prd.md"))); + assert.ok(existsSync(join(cwd, ".beforecoderc.json"))); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("dry run does not create files", () => { + const cwd = mkdtempSync(join(tmpdir(), "beforecode-dry-")); + + try { + const output = execFileSync("node", [process.cwd() + "/bin/beforecode.js", "init", "--type", "small", "--dry-run"], { + cwd, + encoding: "utf8" + }); + + assert.match(output, /dry run/); + assert.equal(existsSync(join(cwd, "docs")), false); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +});