diff --git a/README.md b/README.md index 53e8eac..f3b2046 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ pnpm install pnpm build pnpm dev templates pnpm dev init oss-cli my-tool --dry-run +pnpm dev init oss-cli my-tool --prd ./docs/PRD.md --tasks ./docs/TASKS.md ``` @@ -45,6 +46,10 @@ pnpm dev init oss-cli my-tool --github-create --github-execute - `next-app`: Next.js application - `python-api`: Python API service +## Local planning docs + +Use `--prd ` and `--tasks ` with `stackforge init` to copy local planning inputs into the generated repo as `docs/PRD.md` and `docs/TASKS.md`. + ## PRD See [docs/PRD.md](docs/PRD.md). diff --git a/scripts/smoke-init.sh b/scripts/smoke-init.sh index 81b1dff..3beba04 100755 --- a/scripts/smoke-init.sh +++ b/scripts/smoke-init.sh @@ -9,18 +9,35 @@ cd "$repo_root" pnpm build >/dev/null cd "$tmp_dir" -node "$repo_root/dist/index.js" init oss-cli smoke-app --dry-run > dry-run.json +cat <<'EOF' > local-prd.md +# Local PRD + +This is a copied PRD. +EOF +cat <<'EOF' > local-tasks.md +# Local Tasks + +- [ ] Ship it +EOF + +node "$repo_root/dist/index.js" init oss-cli smoke-app --dry-run --prd local-prd.md --tasks local-tasks.md > dry-run.json if [ -e smoke-app ]; then echo "dry-run created files" >&2 exit 1 fi +grep -q 'docs/PRD.md' dry-run.json +grep -q 'docs/TASKS.md' dry-run.json -node "$repo_root/dist/index.js" init oss-cli smoke-app --var AUTHOR_NAME="Smoke Tester" > init.json +node "$repo_root/dist/index.js" init oss-cli smoke-app --var AUTHOR_NAME="Smoke Tester" --prd local-prd.md --tasks local-tasks.md > init.json test -f smoke-app/README.md test -f smoke-app/package.json +test -f smoke-app/docs/PRD.md +test -f smoke-app/docs/TASKS.md grep -q "# smoke-app" smoke-app/README.md grep -q "Smoke Tester" smoke-app/package.json -node "$repo_root/dist/index.js" init oss-cli smoke-app --dry-run > dry-run-existing.json +grep -q "This is a copied PRD" smoke-app/docs/PRD.md +grep -q -- "- \[ \] Ship it" smoke-app/docs/TASKS.md +node "$repo_root/dist/index.js" init oss-cli smoke-app --dry-run --prd local-prd.md --tasks local-tasks.md > dry-run-existing.json if node "$repo_root/dist/index.js" init oss-cli smoke-app > overwrite.json 2> overwrite.err; then echo "init overwrote without --force" >&2 diff --git a/src/index.ts b/src/index.ts index c3fe155..bf040b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ type TemplateFile = { source?: string; destination: string; content?: string; + render?: boolean; }; type TemplateScaffold = { @@ -24,6 +25,8 @@ type TemplateScaffold = { type InitOptions = { dryRun?: boolean; force?: boolean; + prd?: string; + tasks?: string; var?: string[]; githubCreate?: boolean; githubExecute?: boolean; @@ -118,72 +121,82 @@ program .argument('[name]', 'Project directory/name') .option('--dry-run', 'Print planned actions without writing files') .option('-f, --force', 'Overwrite existing files') + .option('--prd ', 'Copy a local PRD markdown file into docs/PRD.md') + .option('--tasks ', 'Copy a local tasks markdown file into docs/TASKS.md') .option('--var ', 'Template variable override. Can be repeated.', collectVars, []) .option('--github-create', 'Plan a GitHub repository creation with gh. Defaults to dry-run; add --github-execute to run it') .option('--github-execute', 'Execute the planned gh repo create command. Requires --github-create and cannot be combined with --dry-run') .option('--github-visibility ', 'GitHub repository visibility for --github-create', parseGithubVisibility, 'private') .action(async (template: TemplateKey, name: string | undefined, options: InitOptions) => { - const projectName = name ?? template; - const projectRoot = path.resolve(process.cwd(), projectName); - const variables = buildVariables(projectName, options.var ?? []); - const plan = await buildWritePlan(templateScaffolds[template], projectRoot, variables); - const existing = plan.filter((item) => item.existed); - const githubPlan = buildGithubPlan(projectRoot, variables, options); - - if (options.githubExecute && !options.githubCreate) { - console.error(JSON.stringify({ - ok: false, - error: '--github-execute requires --github-create so repository creation is always explicit.' - }, null, 2)); - process.exitCode = 1; - return; - } + try { + const projectName = name ?? template; + const projectRoot = path.resolve(process.cwd(), projectName); + const variables = buildVariables(projectName, options.var ?? []); + const plan = await buildWritePlan(templateScaffolds[template], projectRoot, variables, options); + const existing = plan.filter((item) => item.existed); + const githubPlan = buildGithubPlan(projectRoot, variables, options); + + if (options.githubExecute && !options.githubCreate) { + console.error(JSON.stringify({ + ok: false, + error: '--github-execute requires --github-create so repository creation is always explicit.' + }, null, 2)); + process.exitCode = 1; + return; + } - if (options.githubExecute && options.dryRun) { - console.error(JSON.stringify({ - ok: false, - error: '--github-execute cannot be combined with --dry-run. Run once without --github-execute to review the gh command first.' - }, null, 2)); - process.exitCode = 1; - return; - } + if (options.githubExecute && options.dryRun) { + console.error(JSON.stringify({ + ok: false, + error: '--github-execute cannot be combined with --dry-run. Run once without --github-execute to review the gh command first.' + }, null, 2)); + process.exitCode = 1; + return; + } + + if (existing.length > 0 && !options.force && !options.dryRun) { + console.error(JSON.stringify({ + ok: false, + error: 'Refusing to overwrite existing files. Re-run with --force to overwrite.', + files: existing.map((item) => path.relative(process.cwd(), item.destination)) + }, null, 2)); + process.exitCode = 1; + return; + } + + if (!options.dryRun) { + for (const item of plan) { + await mkdir(path.dirname(item.destination), { recursive: true }); + await writeFile(item.destination, item.source, 'utf8'); + } - if (existing.length > 0 && !options.force && !options.dryRun) { + if (githubPlan.mode === 'execute') { + await runGithubCreate(githubPlan.command); + } + } + + console.log(JSON.stringify({ + ok: true, + command: 'init', + template, + projectName, + projectRoot, + mode: options.dryRun ? 'dry-run' : 'write', + force: Boolean(options.force), + github: githubPlan, + files: plan.map((item) => ({ + path: path.relative(process.cwd(), item.destination), + existed: item.existed, + bytes: item.bytes + })) + }, null, 2)); + } catch (error) { console.error(JSON.stringify({ ok: false, - error: 'Refusing to overwrite existing files. Re-run with --force to overwrite.', - files: existing.map((item) => path.relative(process.cwd(), item.destination)) + error: error instanceof Error ? error.message : 'Unknown init error' }, null, 2)); process.exitCode = 1; - return; } - - if (!options.dryRun) { - for (const item of plan) { - await mkdir(path.dirname(item.destination), { recursive: true }); - await writeFile(item.destination, item.source, 'utf8'); - } - - if (githubPlan.mode === 'execute') { - await runGithubCreate(githubPlan.command); - } - } - - console.log(JSON.stringify({ - ok: true, - command: 'init', - template, - projectName, - projectRoot, - mode: options.dryRun ? 'dry-run' : 'write', - force: Boolean(options.force), - github: githubPlan, - files: plan.map((item) => ({ - path: path.relative(process.cwd(), item.destination), - existed: item.existed, - bytes: item.bytes - })) - }, null, 2)); }); await program.parseAsync(process.argv); @@ -246,13 +259,15 @@ function buildVariables(projectName: string, overrides: string[]): Record + variables: Record, + options: InitOptions ): Promise { const items: WritePlanItem[] = []; + const files = [...template.files, ...await buildLocalInputFiles(options)]; - for (const file of template.files) { + for (const file of files) { const rawContent = file.content ?? await readFile(path.join(sourceRoot, file.source ?? ''), 'utf8'); - const renderedContent = render(rawContent, variables); + const renderedContent = file.render === false ? rawContent : render(rawContent, variables); const renderedDestination = render(file.destination, variables); const destination = path.join(projectRoot, renderedDestination); @@ -267,6 +282,39 @@ async function buildWritePlan( return items; } +async function buildLocalInputFiles(options: InitOptions): Promise { + const files: TemplateFile[] = []; + + if (options.prd) { + files.push({ + destination: 'docs/PRD.md', + content: await readLocalInputFile(options.prd, 'PRD'), + render: false + }); + } + + if (options.tasks) { + files.push({ + destination: 'docs/TASKS.md', + content: await readLocalInputFile(options.tasks, 'tasks'), + render: false + }); + } + + return files; +} + +async function readLocalInputFile(inputPath: string, label: string): Promise { + const resolvedPath = path.resolve(process.cwd(), inputPath); + + try { + return await readFile(resolvedPath, 'utf8'); + } catch (error) { + const detail = error instanceof Error ? error.message : 'Unknown file read error'; + throw new Error(`Unable to read ${label} input file at ${resolvedPath}: ${detail}`); + } +} + function render(content: string, variables: Record): string { return content.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_match, key: string) => variables[key] ?? ''); }