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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions scripts/smoke-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 47 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Expand All @@ -210,6 +227,7 @@ program

if (githubPlan.mode === 'execute') {
await runGithubCreate(githubPlan.command);
await runGitPublish(githubPlan.publish.commands);
}
}

Expand Down Expand Up @@ -386,9 +404,10 @@ function buildTaskbriefPlan(projectRoot: string, options: InitOptions): Taskbrie
};
}

function buildGithubPlan(_projectRoot: string, variables: Record<string, string>, options: InitOptions): GithubPlan {
function buildGithubPlan(projectRoot: string, variables: Record<string, string>, 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',
Expand All @@ -398,13 +417,26 @@ function buildGithubPlan(_projectRoot: string, variables: Record<string, string>
'--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
}
};
}

Expand Down Expand Up @@ -432,8 +464,18 @@ async function runTaskbrief(command: string[]): Promise<void> {
}

async function runGithubCreate(command: string[]): Promise<void> {
await runCommand(command, 'GitHub repository creation');
}

async function runGitPublish(commands: string[][]): Promise<void> {
for (const command of commands) {
await runCommand(command, `Git publish step "${command.join(' ')}"`);
}
}

async function runCommand(command: string[], label: string): Promise<void> {
await new Promise<void>((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) => {
Expand All @@ -442,7 +484,7 @@ async function runGithubCreate(command: string[]): Promise<void> {
return;
}

reject(new Error(`GitHub repository creation failed with exit code ${code ?? 'unknown'}.`));
reject(new Error(`${label} failed with exit code ${code ?? 'unknown'}.`));
});
});
}
Expand Down