diff --git a/CHANGELOG.md b/CHANGELOG.md index 5718cbc..534c3f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1. ### Added - a copy-paste GitHub adoption kit with a maintainer policy and reproducible bug-report Issue Form; and -- a public design-partner pilot and feature-request workflow. +- a public design-partner pilot and feature-request workflow; and +- `bugbundle init --github` for safe, one-command installation of the policy and Issue Form. ## [0.2.0] - 2026-07-17 diff --git a/README.md b/README.md index 819396e..a7994c1 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,13 @@ bugbundle verify --json bugbundle.zip ## Adopt it in your project -The [GitHub adoption kit](examples/github/README.md) contains a maintainer-owned policy and Issue Form that you can copy into a Node.js repository. Review the allowlist and replace the example test command before publishing the form. +Install a maintainer-owned policy and GitHub Issue Form in one command: + +```bash +npx bugbundle init --github +``` + +This creates `.bugbundle.yml` and `.github/ISSUE_TEMPLATE/bug-report.yml`. It refuses to overwrite either file unless you explicitly pass `--force`. Review the allowlist and replace the example `npm test` command before publishing the form. The [GitHub adoption kit](examples/github/README.md) documents the generated files and safety checklist. We are recruiting five Node.js CLI, build-tool, or library maintainers for a hands-on design-partner pilot. [Introduce your repository in the pilot discussion](https://github.com/bugbundleZ/bugbundle/discussions/9). diff --git a/examples/github/README.md b/examples/github/README.md index 1fd55fe..49bb17c 100644 --- a/examples/github/README.md +++ b/examples/github/README.md @@ -2,11 +2,12 @@ This directory is a copy-paste starting point for maintainers who want reproducible, reviewable bug reports from Node.js projects. -1. Copy `.bugbundle.yml` to the repository root. +1. Run `npx bugbundle init --github` from the repository root. The command refuses to overwrite either generated file unless `--force` is explicit. 2. Review every `files.include` pattern. Add source files only when they are safe and necessary to reproduce failures. -3. Copy `bug-report.yml` to `.github/ISSUE_TEMPLATE/bug-report.yml`. -4. Replace the example `npm test` command with the narrowest stable reproduction command for the project. -5. Run the workflow yourself before asking contributors to use it. +3. Replace the example `npm test` command in `.github/ISSUE_TEMPLATE/bug-report.yml` with the narrowest stable reproduction command for the project. +4. Run the workflow yourself before asking contributors to use it. + +You can also copy the files in this directory manually when you want to customize them before installation. Reporters should run `npx bugbundle@0.2.0 preview`, inspect the complete allowlist, capture the failure, and review the ZIP before attaching it. A BugBundle can still contain sensitive project information that no generic redactor can recognize. diff --git a/examples/github/bug-report.yml b/examples/github/bug-report.yml index 62fb472..3f52c1c 100644 --- a/examples/github/bug-report.yml +++ b/examples/github/bug-report.yml @@ -1,7 +1,7 @@ name: Reproducible bug report description: Report a failure with a reviewed BugBundle title: "[Bug]: " -labels: [bug, needs-triage] +labels: [] body: - type: markdown attributes: @@ -35,7 +35,7 @@ body: id: version attributes: label: BugBundle version - placeholder: 0.2.0 + placeholder: "0.2.0" validations: required: true - type: dropdown diff --git a/packages/cli/README.md b/packages/cli/README.md index de708e7..e97d8cd 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -9,6 +9,12 @@ npx bugbundle capture --output issue.zip -- npm test npx bugbundle verify issue.zip ``` +Add a `.bugbundle.yml` policy and GitHub Issue Form without overwriting existing files: + +```bash +npx bugbundle init --github +``` + Verification does not execute code. Replaying a bundle requires the explicit `--run` option. Review files with `bugbundle preview` before sharing an archive. Use `--json` with `init`, `preview`, `capture`, `inspect`, or `verify` for machine-readable stdout and structured errors on stderr. diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index c608477..dd4a104 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { mkdtemp, realpath, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, realpath, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { join } from "node:path"; @@ -62,6 +62,23 @@ describe("bugbundle CLI", () => { expect(replay.code).toBe(0); expect(JSON.parse(replay.stdout).replay).toMatchObject({ exitCode: 6, matched: true }); }); + + it("installs the GitHub adoption files with stable JSON output", async () => { + const cwd = await mkdtemp(join(tmpdir(), "bugbundle-cli-github-")); + const initialized = await run(["init", "--github", "--json"], cwd); + + expect(initialized.code).toBe(0); + const result = JSON.parse(initialized.stdout); + expect(await realpath(result.configPath)).toBe(await realpath(join(cwd, ".bugbundle.yml"))); + expect(await realpath(result.githubIssueFormPath)).toBe( + await realpath(join(cwd, ".github", "ISSUE_TEMPLATE", "bug-report.yml")), + ); + await expect(readFile(result.githubIssueFormPath, "utf8")).resolves.toContain("npx bugbundle@0.2.0 preview"); + + const duplicate = await run(["init", "--github", "--json"], cwd); + expect(duplicate.code).toBe(1); + expect(JSON.parse(duplicate.stderr).error).toMatchObject({ code: "RUNTIME_ERROR" }); + }); }); async function run(args: readonly string[], cwd = process.cwd()): Promise<{ code: number | null; stdout: string; stderr: string }> { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e143278..0aad9f3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,16 +1,18 @@ #!/usr/bin/env node +import { readFile } from "node:fs/promises"; import { captureCommand, inspectBundle, previewProjectFiles, verifyBundle, writeDefaultConfig, + writeGitHubSetup, } from "@bugbundle/core"; const HELP = `BugBundle captures and verifies local bug report artifacts. Usage: - bugbundle init [--force] [--json] + bugbundle init [--github] [--force] [--json] bugbundle preview [--json] bugbundle capture [--output ] [--json] -- [arguments...] bugbundle inspect [--json] @@ -37,14 +39,25 @@ async function main(args: readonly string[]): Promise { async function init(args: readonly string[]): Promise { const json = args.includes("--json"); - const unknown = args.filter((value) => value !== "--force" && value !== "--json"); + const unknown = args.filter((value) => value !== "--github" && value !== "--force" && value !== "--json"); if (unknown.length > 0) return usageError(`Unknown init option: ${unknown[0]}`, json); - const path = await writeDefaultConfig(process.cwd(), args.includes("--force")); - if (json) process.stdout.write(`${JSON.stringify({ configPath: path }, null, 2)}\n`); - else process.stdout.write(`${path}\n`); + const force = args.includes("--force"); + const result = args.includes("--github") + ? await writeGitHubSetup(process.cwd(), { force, cliVersion: await readCliVersion() }) + : { configPath: await writeDefaultConfig(process.cwd(), force) }; + if (json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + else process.stdout.write(`${Object.values(result).join("\n")}\n`); return 0; } +async function readCliVersion(): Promise { + const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as { + version?: unknown; + }; + if (typeof packageJson.version !== "string") throw new Error("BugBundle package version is missing"); + return packageJson.version; +} + async function preview(args: readonly string[]): Promise { const json = args.includes("--json"); const unknown = args.filter((value) => value !== "--json"); diff --git a/packages/core/src/github-setup.test.ts b/packages/core/src/github-setup.test.ts new file mode 100644 index 0000000..a0063dc --- /dev/null +++ b/packages/core/src/github-setup.test.ts @@ -0,0 +1,56 @@ +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; +import { GITHUB_ISSUE_FORM, writeGitHubSetup } from "./github-setup.js"; + +describe("GitHub setup", () => { + it("writes a config and valid Issue Form without project-specific labels", async () => { + const cwd = await mkdtemp(join(tmpdir(), "bugbundle-github-")); + const result = await writeGitHubSetup(cwd, { cliVersion: "9.8.7" }); + + expect(result).toEqual({ + configPath: join(cwd, ".bugbundle.yml"), + githubIssueFormPath: join(cwd, GITHUB_ISSUE_FORM), + }); + const issueForm = parse(await readFile(result.githubIssueFormPath, "utf8")); + expect(issueForm).toMatchObject({ + name: "Reproducible bug report", + labels: [], + body: expect.any(Array), + }); + await expect(readFile(result.githubIssueFormPath, "utf8")).resolves.toContain("npx bugbundle@9.8.7 preview"); + }); + + it("refuses the whole setup when either target already exists", async () => { + const cwd = await mkdtemp(join(tmpdir(), "bugbundle-github-existing-")); + const issueFormPath = join(cwd, GITHUB_ISSUE_FORM); + await mkdir(join(cwd, ".github", "ISSUE_TEMPLATE"), { recursive: true }); + await writeFile(issueFormPath, "maintainer-owned\n"); + + await expect(writeGitHubSetup(cwd, { cliVersion: "0.2.0" })).rejects.toThrow( + "Refusing to overwrite existing file", + ); + await expect(readFile(join(cwd, ".bugbundle.yml"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(issueFormPath, "utf8")).resolves.toBe("maintainer-owned\n"); + }); + + it("overwrites both generated files only with force", async () => { + const cwd = await mkdtemp(join(tmpdir(), "bugbundle-github-force-")); + const result = await writeGitHubSetup(cwd, { cliVersion: "0.2.0" }); + await writeFile(result.configPath, "custom config\n"); + await writeFile(result.githubIssueFormPath, "custom form\n"); + + await writeGitHubSetup(cwd, { force: true, cliVersion: "0.2.0" }); + await expect(readFile(result.configPath, "utf8")).resolves.toContain("schemaVersion: 1"); + await expect(readFile(result.githubIssueFormPath, "utf8")).resolves.toContain("Reproducible bug report"); + }); + + it("rejects a version that could inject Issue Form YAML", async () => { + const cwd = await mkdtemp(join(tmpdir(), "bugbundle-github-version-")); + await expect(writeGitHubSetup(cwd, { cliVersion: "1.0.0\nlabels: [unsafe]" })).rejects.toThrow( + "Invalid BugBundle CLI version", + ); + }); +}); diff --git a/packages/core/src/github-setup.ts b/packages/core/src/github-setup.ts new file mode 100644 index 0000000..33050f3 --- /dev/null +++ b/packages/core/src/github-setup.ts @@ -0,0 +1,121 @@ +import { access, mkdir, unlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { CONFIG_FILE, writeDefaultConfig } from "./config.js"; + +export const GITHUB_ISSUE_FORM = ".github/ISSUE_TEMPLATE/bug-report.yml"; + +export interface GitHubSetupResult { + readonly configPath: string; + readonly githubIssueFormPath: string; +} + +export interface GitHubSetupOptions { + readonly force?: boolean; + readonly cliVersion: string; +} + +function issueForm(cliVersion: string): string { + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(cliVersion)) { + throw new Error(`Invalid BugBundle CLI version: ${cliVersion}`); + } + return `name: Reproducible bug report +description: Report a failure with a reviewed BugBundle +title: "[Bug]: " +labels: [] +body: + - type: markdown + attributes: + value: | + Thanks for helping us reproduce the problem. + + Run these commands from a safe reproduction project. Replace \`npm test\` if the maintainer documents another command. + + \`\`\`bash + npx bugbundle@${cliVersion} preview + npx bugbundle@${cliVersion} capture --output bugbundle.zip -- npm test + npx bugbundle@${cliVersion} inspect bugbundle.zip + \`\`\` + + Review the complete ZIP before uploading it. Never attach secrets or proprietary source code. + - type: textarea + id: description + attributes: + label: Description + description: What happened, and what did you expect? + validations: + required: true + - type: textarea + id: bundle + attributes: + label: Reviewed BugBundle + description: Drag \`bugbundle.zip\` here after reviewing its complete contents. + validations: + required: true + - type: input + id: version + attributes: + label: BugBundle version + placeholder: "${cliVersion}" + validations: + required: true + - type: dropdown + id: operating-system + attributes: + label: Operating system + options: + - Linux + - macOS + - Windows + - Other + validations: + required: true + - type: checkboxes + id: safety + attributes: + label: Safety confirmation + options: + - label: I ran \`bugbundle preview\` and reviewed every file in the bundle. + required: true + - label: The bundle contains no secrets or proprietary source code. + required: true +`; +} + +export async function writeGitHubSetup( + cwd: string, + options: GitHubSetupOptions, +): Promise { + const { force = false, cliVersion } = options; + const configPath = join(cwd, CONFIG_FILE); + const githubIssueFormPath = join(cwd, GITHUB_ISSUE_FORM); + const form = issueForm(cliVersion); + + if (!force) { + for (const path of [configPath, githubIssueFormPath]) { + if (await exists(path)) throw new Error(`Refusing to overwrite existing file: ${path}`); + } + } + + await mkdir(dirname(githubIssueFormPath), { recursive: true }); + let createdConfig = false; + try { + await writeDefaultConfig(cwd, force); + createdConfig = true; + await writeFile(githubIssueFormPath, form, { encoding: "utf8", flag: force ? "w" : "wx" }); + } catch (error) { + if (createdConfig && !force) await unlink(configPath).catch(() => undefined); + throw error; + } + + return { configPath, githubIssueFormPath }; +} + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return false; + throw error; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7e384e6..f4f1b2e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,3 +20,5 @@ export type { VerifyResult, } from "./types.js"; export { redactText } from "./redact.js"; +export { GITHUB_ISSUE_FORM, writeGitHubSetup } from "./github-setup.js"; +export type { GitHubSetupOptions, GitHubSetupResult } from "./github-setup.js";