Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```


Expand Down Expand Up @@ -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 <path>` and `--tasks <path>` 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).
23 changes: 20 additions & 3 deletions scripts/smoke-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
162 changes: 105 additions & 57 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type TemplateFile = {
source?: string;
destination: string;
content?: string;
render?: boolean;
};

type TemplateScaffold = {
Expand All @@ -24,6 +25,8 @@ type TemplateScaffold = {
type InitOptions = {
dryRun?: boolean;
force?: boolean;
prd?: string;
tasks?: string;
var?: string[];
githubCreate?: boolean;
githubExecute?: boolean;
Expand Down Expand Up @@ -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 <path>', 'Copy a local PRD markdown file into docs/PRD.md')
.option('--tasks <path>', 'Copy a local tasks markdown file into docs/TASKS.md')
.option('--var <KEY=VALUE>', '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 <public|private>', '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);
Expand Down Expand Up @@ -246,13 +259,15 @@ function buildVariables(projectName: string, overrides: string[]): Record<string
async function buildWritePlan(
template: TemplateScaffold,
projectRoot: string,
variables: Record<string, string>
variables: Record<string, string>,
options: InitOptions
): Promise<WritePlanItem[]> {
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);

Expand All @@ -267,6 +282,39 @@ async function buildWritePlan(
return items;
}

async function buildLocalInputFiles(options: InitOptions): Promise<TemplateFile[]> {
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<string> {
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, string>): string {
return content.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_match, key: string) => variables[key] ?? '');
}
Expand Down