From 7d33a9a60ca747c45641221784ae57520176f37b Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 29 Apr 2026 14:34:01 +1000 Subject: [PATCH] Publish scaffold after GitHub repo creation --- README.md | 6 ++--- scripts/smoke-init.sh | 31 ++++++++++++++++++++++++++ src/index.ts | 52 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 81 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5357365..ad0c611 100644 --- a/README.md +++ b/README.md @@ -40,20 +40,20 @@ stackforge init oss-cli demo ### GitHub repository creation -StackForge never creates a GitHub repository by default. To request GitHub creation, add `--github-create`; the first run is still a dry run for the `gh repo create` command so you can review it safely: +StackForge never creates a GitHub repository by default. To request GitHub creation, add `--github-create`; the first run is still a dry run for the GitHub and git publish commands so you can review them safely: ```bash pnpm dev init oss-cli my-tool --github-create pnpm dev init oss-cli my-tool --github-create --github-visibility public ``` -After reviewing the printed `github.command`, rerun with `--github-execute` to create the repository through the GitHub CLI: +After reviewing the printed `github.command` and `github.publish.commands`, rerun with `--github-execute` to create the repository through the GitHub CLI, initialize the generated project as a local git repo, create the initial scaffold commit, add `origin`, and push `main`: ```bash pnpm dev init oss-cli my-tool --github-create --github-execute ``` -`--github-execute` requires `--github-create` and cannot be combined with `--dry-run`. The default visibility is `private`; use `--github-visibility public` only when you intentionally want a public repository. +`--github-execute` requires `--github-create` and cannot be combined with `--dry-run`. The default visibility is `private`; use `--github-visibility public` only when you intentionally want a public repository. For safety, git initialization and push only run for a fresh generated project directory, not an existing directory being overwritten with `--force`. ## Safety model diff --git a/scripts/smoke-init.sh b/scripts/smoke-init.sh index 5f1bc19..1e76434 100755 --- a/scripts/smoke-init.sh +++ b/scripts/smoke-init.sh @@ -28,6 +28,37 @@ 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 github-dry-run --dry-run --github-create > github-dry-run.json +[ ! -e github-dry-run ] || { echo "github dry-run created files" >&2; exit 1; } +grep -q '"publish"' github-dry-run.json +grep -q '"push"' github-dry-run.json +grep -q '"origin"' github-dry-run.json +grep -q '"main"' github-dry-run.json + +mock_bin="$tmp_dir/mock-bin" +mkdir -p "$mock_bin" +cat <<'EOF' > "$mock_bin/gh" +#!/usr/bin/env bash +set -euo pipefail +printf 'gh %s\n' "$*" >> "$STACKFORGE_MOCK_LOG" +EOF +cat <<'EOF' > "$mock_bin/git" +#!/usr/bin/env bash +set -euo pipefail +printf 'git %s\n' "$*" >> "$STACKFORGE_MOCK_LOG" +EOF +chmod +x "$mock_bin/gh" "$mock_bin/git" +STACKFORGE_MOCK_LOG="$tmp_dir/github-publish.log" PATH="$mock_bin:$PATH" node "$repo_root/dist/index.js" init oss-cli github-app --github-create --github-execute --github-visibility public > github-init.json +test -f github-app/README.md +grep -q 'gh repo create rogerchappel/github-app --public' "$tmp_dir/github-publish.log" +grep -q 'git -C .*/github-app init -b main' "$tmp_dir/github-publish.log" +grep -q 'git -C .*/github-app push -u origin main' "$tmp_dir/github-publish.log" +if STACKFORGE_MOCK_LOG="$tmp_dir/github-existing.log" PATH="$mock_bin:$PATH" node "$repo_root/dist/index.js" init oss-cli github-app --force --github-create --github-execute > github-existing.json 2> github-existing.err; then + echo "github execute unexpectedly published an existing directory" >&2 + exit 1 +fi +grep -q 'Refusing to initialize and push git history for an existing project directory' github-existing.err + taskbrief_bin="$tmp_dir/taskbrief" cat <<'EOF' > "$taskbrief_bin" #!/usr/bin/env bash diff --git a/src/index.ts b/src/index.ts index ea0404d..00b4cf2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,13 @@ type GithubPlan = { visibility: GithubVisibility; repository: string; command: string[]; + publish: GitPublishPlan; +}; + +type GitPublishPlan = { + mode: 'noop' | 'dry-run' | 'execute'; + remote: string; + commands: string[][]; }; type WritePlanItem = { @@ -165,6 +172,7 @@ program return; } + const projectRootExisted = await pathExists(projectRoot); const plan = await buildWritePlan(templateScaffolds[template], projectRoot, variables, options); const existing = plan.filter((item) => item.existed); const githubPlan = buildGithubPlan(projectRoot, variables, options); @@ -198,6 +206,15 @@ program return; } + if (githubPlan.mode === 'execute' && projectRootExisted) { + console.error(JSON.stringify({ + ok: false, + error: 'Refusing to initialize and push git history for an existing project directory. Choose a fresh project name for --github-execute so StackForge only publishes a newly generated scaffold.' + }, null, 2)); + process.exitCode = 1; + return; + } + if (!options.dryRun) { for (const item of plan) { await mkdir(path.dirname(item.destination), { recursive: true }); @@ -210,6 +227,7 @@ program if (githubPlan.mode === 'execute') { await runGithubCreate(githubPlan.command); + await runGitPublish(githubPlan.publish.commands); } } @@ -386,9 +404,10 @@ function buildTaskbriefPlan(projectRoot: string, options: InitOptions): Taskbrie }; } -function buildGithubPlan(_projectRoot: string, variables: Record, options: InitOptions): GithubPlan { +function buildGithubPlan(projectRoot: string, variables: Record, options: InitOptions): GithubPlan { const visibility = options.githubVisibility ?? 'private'; const repository = `${variables.GITHUB_OWNER}/${variables.GITHUB_REPO}`; + const mode = options.githubCreate ? (options.githubExecute ? 'execute' : 'dry-run') : 'noop'; const command = [ 'gh', 'repo', @@ -398,13 +417,26 @@ function buildGithubPlan(_projectRoot: string, variables: Record '--description', variables.PROJECT_DESCRIPTION ]; + const remote = `https://github.com/${repository}.git`; + const publishCommands = [ + ['git', '-C', projectRoot, 'init', '-b', 'main'], + ['git', '-C', projectRoot, 'add', '.'], + ['git', '-C', projectRoot, '-c', 'user.name=StackForge', '-c', 'user.email=stackforge@example.invalid', 'commit', '-m', 'Initial StackForge scaffold'], + ['git', '-C', projectRoot, 'remote', 'add', 'origin', remote], + ['git', '-C', projectRoot, 'push', '-u', 'origin', 'main'] + ]; return { requested: Boolean(options.githubCreate), - mode: options.githubCreate ? (options.githubExecute ? 'execute' : 'dry-run') : 'noop', + mode, visibility, repository, - command + command, + publish: { + mode, + remote, + commands: publishCommands + } }; } @@ -432,8 +464,18 @@ async function runTaskbrief(command: string[]): Promise { } async function runGithubCreate(command: string[]): Promise { + await runCommand(command, 'GitHub repository creation'); +} + +async function runGitPublish(commands: string[][]): Promise { + for (const command of commands) { + await runCommand(command, `Git publish step "${command.join(' ')}"`); + } +} + +async function runCommand(command: string[], label: string): Promise { await new Promise((resolve, reject) => { - const child = spawn(command[0] ?? 'gh', command.slice(1), { stdio: 'inherit' }); + const child = spawn(command[0] ?? '', command.slice(1), { stdio: 'inherit' }); child.once('error', reject); child.once('exit', (code) => { @@ -442,7 +484,7 @@ async function runGithubCreate(command: string[]): Promise { return; } - reject(new Error(`GitHub repository creation failed with exit code ${code ?? 'unknown'}.`)); + reject(new Error(`${label} failed with exit code ${code ?? 'unknown'}.`)); }); }); }