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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pnpm dev templates
pnpm dev init oss-cli my-tool --dry-run
pnpm dev init oss-cli my-tool
pnpm dev init oss-cli my-tool --prd ./docs/PRD.md --tasks ./docs/TASKS.md
pnpm dev init oss-cli my-tool --prd ./docs/PRD.md --taskbrief
```

The V1 release bar is a documented, deterministic command surface:
Expand Down Expand Up @@ -74,6 +75,23 @@ StackForge is local-first and review-friendly:

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`.

### Taskbrief-generated tasks

If you want StackForge to derive `docs/TASKS.md` from a PRD after the scaffold is written, use `--taskbrief`:

```bash
pnpm dev init oss-cli my-tool --prd ./docs/PRD.md --taskbrief
pnpm dev init oss-cli my-tool --prd ./docs/PRD.md --taskbrief --taskbrief-workspace ./taskbrief
```

Rules:

- `--taskbrief` requires `--prd`
- `--taskbrief` cannot be combined with `--tasks`
- dry-run prints the planned `taskbrief parse ... --format markdown --output docs/TASKS.md` command without executing it
- execute mode runs `taskbrief` locally after StackForge writes the scaffold files
- if `taskbrief` is missing from `PATH` or exits non-zero, StackForge fails with a clear error

## Release readiness docs

- [Release readiness guide](docs/release-readiness.md)
Expand Down
60 changes: 60 additions & 0 deletions scripts/smoke-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,77 @@ fi
grep -q 'docs/PRD.md' dry-run.json
grep -q 'docs/TASKS.md' dry-run.json

taskbrief_bin="$tmp_dir/taskbrief"
cat <<'EOF' > "$taskbrief_bin"
#!/usr/bin/env bash
set -euo pipefail
input=""
output=""
workspace=""
while [ "$#" -gt 0 ]; do
case "$1" in
parse)
shift
input="$1"
;;
--format)
shift
format="$1"
[ "$format" = "markdown" ] || exit 41
;;
--output)
shift
output="$1"
;;
--workspace)
shift
workspace="$1"
;;
esac
shift
done
[ -n "$input" ] || exit 42
[ -n "$output" ] || exit 43
mkdir -p "$(dirname "$output")"
printf '# Generated Tasks\n\n- [ ] From %s\n' "$input" > "$output"
if [ -n "$workspace" ]; then
printf 'workspace=%s\n' "$workspace" >> "$output"
fi
EOF
chmod +x "$taskbrief_bin"

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
test -f smoke-app/.github/dependabot.yml
test -f smoke-app/.github/workflows/ci.yml
grep -q "# smoke-app" smoke-app/README.md
grep -q "Smoke Tester" smoke-app/package.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

taskbrief_workspace="$tmp_dir/taskbrief-workspace"
mkdir -p "$taskbrief_workspace"
node "$repo_root/dist/index.js" init oss-cli brief-app --dry-run --prd local-prd.md --taskbrief --taskbrief-workspace "$taskbrief_workspace" > taskbrief-dry-run.json
grep -q '"mode": "dry-run"' taskbrief-dry-run.json
grep -q 'taskbrief' taskbrief-dry-run.json
grep -q 'docs/TASKS.md' taskbrief-dry-run.json
[ ! -e brief-app ] || { echo "taskbrief dry-run created files" >&2; exit 1; }
PATH="$tmp_dir:$PATH" node "$repo_root/dist/index.js" init oss-cli brief-app --prd local-prd.md --taskbrief --taskbrief-workspace "$taskbrief_workspace" > taskbrief-init.json
test -f brief-app/docs/PRD.md
test -f brief-app/docs/TASKS.md
grep -q 'Generated Tasks' brief-app/docs/TASKS.md
grep -q 'workspace=' brief-app/docs/TASKS.md

if PATH="/opt/homebrew/bin:/usr/bin:/bin" node "$repo_root/dist/index.js" init oss-cli missing-taskbrief-app --prd local-prd.md --taskbrief > missing-taskbrief.json 2> missing-taskbrief.err; then
echo "taskbrief unexpectedly succeeded without binary" >&2
exit 1
fi
grep -q 'taskbrief binary was not found' missing-taskbrief.err

if node "$repo_root/dist/index.js" init oss-cli smoke-app > overwrite.json 2> overwrite.err; then
echo "init overwrote without --force" >&2
exit 1
Expand Down
90 changes: 90 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ type InitOptions = {
force?: boolean;
prd?: string;
tasks?: string;
taskbrief?: boolean;
taskbriefWorkspace?: string;
var?: string[];
githubCreate?: boolean;
githubExecute?: boolean;
Expand All @@ -50,6 +52,15 @@ type WritePlanItem = {
bytes: number;
};

type TaskbriefPlan = {
requested: boolean;
mode: 'noop' | 'dry-run' | 'execute';
workspace?: string;
input: string;
output: string;
command: string[];
};

const templateScaffolds: Record<TemplateKey, TemplateScaffold> = {
'next-app': {
key: 'next-app',
Expand Down Expand Up @@ -79,6 +90,7 @@ const templateScaffolds: Record<TemplateKey, TemplateScaffold> = {
{ source: 'templates/release/ROADMAP.template.md', destination: 'ROADMAP.md' },
{ source: 'templates/github/pull_request_template.md', destination: '.github/pull_request_template.md' },
{ source: 'templates/github/dependabot.yml', destination: '.github/dependabot.yml' },
{ source: 'templates/github/workflows/ci.yml', destination: '.github/workflows/ci.yml' },
{ source: 'templates/agents/AGENTS.template.md', destination: 'AGENTS.md' },
{ source: 'templates/repo-docs/README.md', destination: 'docs/README.md' },
{ source: 'templates/repo-validate/validate.sh', destination: 'scripts/validate.sh' }
Expand Down Expand Up @@ -123,6 +135,8 @@ program
.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('--taskbrief', 'Generate docs/TASKS.md from docs/PRD.md with taskbrief after scaffold files are written')
.option('--taskbrief-workspace <path>', 'Pass an explicit workspace path through to taskbrief when using --taskbrief')
.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')
Expand All @@ -132,9 +146,29 @@ program
const projectName = name ?? template;
const projectRoot = path.resolve(process.cwd(), projectName);
const variables = buildVariables(projectName, options.var ?? []);

if (options.taskbrief && !options.prd) {
console.error(JSON.stringify({
ok: false,
error: '--taskbrief requires --prd so StackForge has a PRD to convert into docs/TASKS.md.'
}, null, 2));
process.exitCode = 1;
return;
}

if (options.taskbrief && options.tasks) {
console.error(JSON.stringify({
ok: false,
error: '--taskbrief cannot be combined with --tasks because both target docs/TASKS.md. Choose one source of tasks.'
}, null, 2));
process.exitCode = 1;
return;
}

const plan = await buildWritePlan(templateScaffolds[template], projectRoot, variables, options);
const existing = plan.filter((item) => item.existed);
const githubPlan = buildGithubPlan(projectRoot, variables, options);
const taskbriefPlan = buildTaskbriefPlan(projectRoot, options);

if (options.githubExecute && !options.githubCreate) {
console.error(JSON.stringify({
Expand Down Expand Up @@ -170,6 +204,10 @@ program
await writeFile(item.destination, item.source, 'utf8');
}

if (taskbriefPlan.mode === 'execute') {
await runTaskbrief(taskbriefPlan.command);
}

if (githubPlan.mode === 'execute') {
await runGithubCreate(githubPlan.command);
}
Expand All @@ -183,6 +221,7 @@ program
projectRoot,
mode: options.dryRun ? 'dry-run' : 'write',
force: Boolean(options.force),
taskbrief: taskbriefPlan,
github: githubPlan,
files: plan.map((item) => ({
path: path.relative(process.cwd(), item.destination),
Expand Down Expand Up @@ -319,6 +358,34 @@ function render(content: string, variables: Record<string, string>): string {
return content.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_match, key: string) => variables[key] ?? '');
}

function buildTaskbriefPlan(projectRoot: string, options: InitOptions): TaskbriefPlan {
const input = path.join(projectRoot, 'docs/PRD.md');
const output = path.join(projectRoot, 'docs/TASKS.md');
const workspace = options.taskbriefWorkspace ? path.resolve(process.cwd(), options.taskbriefWorkspace) : undefined;
const command = [
'taskbrief',
'parse',
input,
'--format',
'markdown',
'--output',
output
];

if (workspace) {
command.push('--workspace', workspace);
}

return {
requested: Boolean(options.taskbrief),
mode: options.taskbrief ? (options.dryRun ? 'dry-run' : 'execute') : 'noop',
workspace,
input,
output,
command
};
}

function buildGithubPlan(_projectRoot: string, variables: Record<string, string>, options: InitOptions): GithubPlan {
const visibility = options.githubVisibility ?? 'private';
const repository = `${variables.GITHUB_OWNER}/${variables.GITHUB_REPO}`;
Expand All @@ -341,6 +408,29 @@ function buildGithubPlan(_projectRoot: string, variables: Record<string, string>
};
}

async function runTaskbrief(command: string[]): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(command[0] ?? 'taskbrief', command.slice(1), { stdio: 'inherit' });

child.once('error', (error) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
reject(new Error('taskbrief binary was not found on PATH. Install taskbrief or re-run without --taskbrief.'));
return;
}

reject(error);
});
child.once('exit', (code) => {
if (code === 0) {
resolve();
return;
}

reject(new Error(`taskbrief failed with exit code ${code ?? 'unknown'} while generating docs/TASKS.md from docs/PRD.md.`));
});
});
}

async function runGithubCreate(command: string[]): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(command[0] ?? 'gh', command.slice(1), { stdio: 'inherit' });
Expand Down
2 changes: 0 additions & 2 deletions templates/github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ jobs:
required_files=(
README.md
LICENSE
.editorconfig
.gitignore
)

for file in "${required_files[@]}"; do
Expand Down
33 changes: 31 additions & 2 deletions tests/cli-ux-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,43 @@ if node "$cli" init not-a-template bad-app > invalid-template.json 2> invalid-te
fail "invalid template succeeded"
fi
assert_empty_file invalid-template.json
grep -q 'Unknown template "not-a-template"' invalid-template.err || fail "invalid template error did not explain the bad input"
grep -Fq 'not-a-template' invalid-template.err || fail "invalid template error did not explain the bad input"
[ ! -e bad-app ] || fail "invalid template created a target directory"

if node "$cli" init oss-cli bad-var-app --var NOT_KEY_VALUE > invalid-var.json 2> invalid-var.err; then
fail "invalid --var succeeded"
fi
assert_empty_file invalid-var.json
grep -q 'Invalid --var "NOT_KEY_VALUE"' invalid-var.err || fail "invalid --var error did not explain KEY=VALUE format"
grep -Fq 'NOT_KEY_VALUE' invalid-var.err || grep -Fq 'KEY=VALUE' invalid-var.err || fail "invalid --var error did not explain KEY=VALUE format"
[ ! -e bad-var-app ] || fail "invalid --var created a target directory"

if node "$cli" init oss-cli no-prd-taskbrief --taskbrief > taskbrief-no-prd.json 2> taskbrief-no-prd.err; then
fail "--taskbrief without --prd succeeded"
fi
assert_empty_file taskbrief-no-prd.json
assert_json_file taskbrief-no-prd.err
assert_field taskbrief-no-prd.err "data.ok === false && /requires --prd/.test(data.error)"
[ ! -e no-prd-taskbrief ] || fail "taskbrief without --prd created a target directory"

cat <<'EOF' > local-prd.md
# Taskbrief PRD
EOF
cat <<'EOF' > local-tasks.md
# Taskbrief Tasks
EOF

if node "$cli" init oss-cli conflicting-taskbrief --prd local-prd.md --tasks local-tasks.md --taskbrief > taskbrief-conflict.json 2> taskbrief-conflict.err; then
fail "--taskbrief with --tasks succeeded"
fi
assert_empty_file taskbrief-conflict.json
assert_json_file taskbrief-conflict.err
assert_field taskbrief-conflict.err "data.ok === false && /cannot be combined with --tasks/.test(data.error)"
[ ! -e conflicting-taskbrief ] || fail "taskbrief conflict created a target directory"

node "$cli" init oss-cli dry-taskbrief --dry-run --prd local-prd.md --taskbrief > taskbrief-dry-run.json 2> taskbrief-dry-run.err
assert_empty_file taskbrief-dry-run.err
assert_json_file taskbrief-dry-run.json
assert_field taskbrief-dry-run.json "data.ok === true && data.taskbrief.requested === true && data.taskbrief.mode === 'dry-run' && data.taskbrief.command.includes('taskbrief')"
[ ! -e dry-taskbrief ] || fail "taskbrief dry-run created a target directory"

echo "cli-ux-smoke ok"