Skip to content

Commit b3fbf01

Browse files
committed
feat: stackForge PRD/task Ingestion: Add Explicit Local Input Flags So Generated Repos
1 parent c13dc34 commit b3fbf01

3 files changed

Lines changed: 130 additions & 60 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pnpm install
1313
pnpm build
1414
pnpm dev templates
1515
pnpm dev init oss-cli my-tool --dry-run
16+
pnpm dev init oss-cli my-tool --prd ./docs/PRD.md --tasks ./docs/TASKS.md
1617
```
1718

1819

@@ -45,6 +46,10 @@ pnpm dev init oss-cli my-tool --github-create --github-execute
4546
- `next-app`: Next.js application
4647
- `python-api`: Python API service
4748

49+
## Local planning docs
50+
51+
Use `--prd <path>` and `--tasks <path>` with `stackforge init` to copy local planning inputs into the generated repo as `docs/PRD.md` and `docs/TASKS.md`.
52+
4853
## PRD
4954

5055
See [docs/PRD.md](docs/PRD.md).

scripts/smoke-init.sh

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,35 @@ cd "$repo_root"
99
pnpm build >/dev/null
1010

1111
cd "$tmp_dir"
12-
node "$repo_root/dist/index.js" init oss-cli smoke-app --dry-run > dry-run.json
12+
cat <<'EOF' > local-prd.md
13+
# Local PRD
14+
15+
This is a copied PRD.
16+
EOF
17+
cat <<'EOF' > local-tasks.md
18+
# Local Tasks
19+
20+
- [ ] Ship it
21+
EOF
22+
23+
node "$repo_root/dist/index.js" init oss-cli smoke-app --dry-run --prd local-prd.md --tasks local-tasks.md > dry-run.json
1324
if [ -e smoke-app ]; then
1425
echo "dry-run created files" >&2
1526
exit 1
1627
fi
28+
grep -q 'docs/PRD.md' dry-run.json
29+
grep -q 'docs/TASKS.md' dry-run.json
1730

18-
node "$repo_root/dist/index.js" init oss-cli smoke-app --var AUTHOR_NAME="Smoke Tester" > init.json
31+
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
1932
test -f smoke-app/README.md
2033
test -f smoke-app/package.json
34+
test -f smoke-app/docs/PRD.md
35+
test -f smoke-app/docs/TASKS.md
2136
grep -q "# smoke-app" smoke-app/README.md
2237
grep -q "Smoke Tester" smoke-app/package.json
23-
node "$repo_root/dist/index.js" init oss-cli smoke-app --dry-run > dry-run-existing.json
38+
grep -q "This is a copied PRD" smoke-app/docs/PRD.md
39+
grep -q -- "- \[ \] Ship it" smoke-app/docs/TASKS.md
40+
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
2441

2542
if node "$repo_root/dist/index.js" init oss-cli smoke-app > overwrite.json 2> overwrite.err; then
2643
echo "init overwrote without --force" >&2

src/index.ts

Lines changed: 105 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type TemplateFile = {
1414
source?: string;
1515
destination: string;
1616
content?: string;
17+
render?: boolean;
1718
};
1819

1920
type TemplateScaffold = {
@@ -24,6 +25,8 @@ type TemplateScaffold = {
2425
type InitOptions = {
2526
dryRun?: boolean;
2627
force?: boolean;
28+
prd?: string;
29+
tasks?: string;
2730
var?: string[];
2831
githubCreate?: boolean;
2932
githubExecute?: boolean;
@@ -118,72 +121,82 @@ program
118121
.argument('[name]', 'Project directory/name')
119122
.option('--dry-run', 'Print planned actions without writing files')
120123
.option('-f, --force', 'Overwrite existing files')
124+
.option('--prd <path>', 'Copy a local PRD markdown file into docs/PRD.md')
125+
.option('--tasks <path>', 'Copy a local tasks markdown file into docs/TASKS.md')
121126
.option('--var <KEY=VALUE>', 'Template variable override. Can be repeated.', collectVars, [])
122127
.option('--github-create', 'Plan a GitHub repository creation with gh. Defaults to dry-run; add --github-execute to run it')
123128
.option('--github-execute', 'Execute the planned gh repo create command. Requires --github-create and cannot be combined with --dry-run')
124129
.option('--github-visibility <public|private>', 'GitHub repository visibility for --github-create', parseGithubVisibility, 'private')
125130
.action(async (template: TemplateKey, name: string | undefined, options: InitOptions) => {
126-
const projectName = name ?? template;
127-
const projectRoot = path.resolve(process.cwd(), projectName);
128-
const variables = buildVariables(projectName, options.var ?? []);
129-
const plan = await buildWritePlan(templateScaffolds[template], projectRoot, variables);
130-
const existing = plan.filter((item) => item.existed);
131-
const githubPlan = buildGithubPlan(projectRoot, variables, options);
132-
133-
if (options.githubExecute && !options.githubCreate) {
134-
console.error(JSON.stringify({
135-
ok: false,
136-
error: '--github-execute requires --github-create so repository creation is always explicit.'
137-
}, null, 2));
138-
process.exitCode = 1;
139-
return;
140-
}
131+
try {
132+
const projectName = name ?? template;
133+
const projectRoot = path.resolve(process.cwd(), projectName);
134+
const variables = buildVariables(projectName, options.var ?? []);
135+
const plan = await buildWritePlan(templateScaffolds[template], projectRoot, variables, options);
136+
const existing = plan.filter((item) => item.existed);
137+
const githubPlan = buildGithubPlan(projectRoot, variables, options);
138+
139+
if (options.githubExecute && !options.githubCreate) {
140+
console.error(JSON.stringify({
141+
ok: false,
142+
error: '--github-execute requires --github-create so repository creation is always explicit.'
143+
}, null, 2));
144+
process.exitCode = 1;
145+
return;
146+
}
141147

142-
if (options.githubExecute && options.dryRun) {
143-
console.error(JSON.stringify({
144-
ok: false,
145-
error: '--github-execute cannot be combined with --dry-run. Run once without --github-execute to review the gh command first.'
146-
}, null, 2));
147-
process.exitCode = 1;
148-
return;
149-
}
148+
if (options.githubExecute && options.dryRun) {
149+
console.error(JSON.stringify({
150+
ok: false,
151+
error: '--github-execute cannot be combined with --dry-run. Run once without --github-execute to review the gh command first.'
152+
}, null, 2));
153+
process.exitCode = 1;
154+
return;
155+
}
156+
157+
if (existing.length > 0 && !options.force && !options.dryRun) {
158+
console.error(JSON.stringify({
159+
ok: false,
160+
error: 'Refusing to overwrite existing files. Re-run with --force to overwrite.',
161+
files: existing.map((item) => path.relative(process.cwd(), item.destination))
162+
}, null, 2));
163+
process.exitCode = 1;
164+
return;
165+
}
166+
167+
if (!options.dryRun) {
168+
for (const item of plan) {
169+
await mkdir(path.dirname(item.destination), { recursive: true });
170+
await writeFile(item.destination, item.source, 'utf8');
171+
}
150172

151-
if (existing.length > 0 && !options.force && !options.dryRun) {
173+
if (githubPlan.mode === 'execute') {
174+
await runGithubCreate(githubPlan.command);
175+
}
176+
}
177+
178+
console.log(JSON.stringify({
179+
ok: true,
180+
command: 'init',
181+
template,
182+
projectName,
183+
projectRoot,
184+
mode: options.dryRun ? 'dry-run' : 'write',
185+
force: Boolean(options.force),
186+
github: githubPlan,
187+
files: plan.map((item) => ({
188+
path: path.relative(process.cwd(), item.destination),
189+
existed: item.existed,
190+
bytes: item.bytes
191+
}))
192+
}, null, 2));
193+
} catch (error) {
152194
console.error(JSON.stringify({
153195
ok: false,
154-
error: 'Refusing to overwrite existing files. Re-run with --force to overwrite.',
155-
files: existing.map((item) => path.relative(process.cwd(), item.destination))
196+
error: error instanceof Error ? error.message : 'Unknown init error'
156197
}, null, 2));
157198
process.exitCode = 1;
158-
return;
159199
}
160-
161-
if (!options.dryRun) {
162-
for (const item of plan) {
163-
await mkdir(path.dirname(item.destination), { recursive: true });
164-
await writeFile(item.destination, item.source, 'utf8');
165-
}
166-
167-
if (githubPlan.mode === 'execute') {
168-
await runGithubCreate(githubPlan.command);
169-
}
170-
}
171-
172-
console.log(JSON.stringify({
173-
ok: true,
174-
command: 'init',
175-
template,
176-
projectName,
177-
projectRoot,
178-
mode: options.dryRun ? 'dry-run' : 'write',
179-
force: Boolean(options.force),
180-
github: githubPlan,
181-
files: plan.map((item) => ({
182-
path: path.relative(process.cwd(), item.destination),
183-
existed: item.existed,
184-
bytes: item.bytes
185-
}))
186-
}, null, 2));
187200
});
188201

189202
await program.parseAsync(process.argv);
@@ -246,13 +259,15 @@ function buildVariables(projectName: string, overrides: string[]): Record<string
246259
async function buildWritePlan(
247260
template: TemplateScaffold,
248261
projectRoot: string,
249-
variables: Record<string, string>
262+
variables: Record<string, string>,
263+
options: InitOptions
250264
): Promise<WritePlanItem[]> {
251265
const items: WritePlanItem[] = [];
266+
const files = [...template.files, ...await buildLocalInputFiles(options)];
252267

253-
for (const file of template.files) {
268+
for (const file of files) {
254269
const rawContent = file.content ?? await readFile(path.join(sourceRoot, file.source ?? ''), 'utf8');
255-
const renderedContent = render(rawContent, variables);
270+
const renderedContent = file.render === false ? rawContent : render(rawContent, variables);
256271
const renderedDestination = render(file.destination, variables);
257272
const destination = path.join(projectRoot, renderedDestination);
258273

@@ -267,6 +282,39 @@ async function buildWritePlan(
267282
return items;
268283
}
269284

285+
async function buildLocalInputFiles(options: InitOptions): Promise<TemplateFile[]> {
286+
const files: TemplateFile[] = [];
287+
288+
if (options.prd) {
289+
files.push({
290+
destination: 'docs/PRD.md',
291+
content: await readLocalInputFile(options.prd, 'PRD'),
292+
render: false
293+
});
294+
}
295+
296+
if (options.tasks) {
297+
files.push({
298+
destination: 'docs/TASKS.md',
299+
content: await readLocalInputFile(options.tasks, 'tasks'),
300+
render: false
301+
});
302+
}
303+
304+
return files;
305+
}
306+
307+
async function readLocalInputFile(inputPath: string, label: string): Promise<string> {
308+
const resolvedPath = path.resolve(process.cwd(), inputPath);
309+
310+
try {
311+
return await readFile(resolvedPath, 'utf8');
312+
} catch (error) {
313+
const detail = error instanceof Error ? error.message : 'Unknown file read error';
314+
throw new Error(`Unable to read ${label} input file at ${resolvedPath}: ${detail}`);
315+
}
316+
}
317+
270318
function render(content: string, variables: Record<string, string>): string {
271319
return content.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_match, key: string) => variables[key] ?? '');
272320
}

0 commit comments

Comments
 (0)