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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.

## [Unreleased]

### Added

- machine-readable `--json` output for `init` and `capture`;
- structured JSON usage and runtime errors for automation; and
- npm-based Quick Start documentation for the published CLI.

### Changed

- reject unknown and extra `inspect` or `verify` arguments instead of silently ignoring them.

## [0.1.0] - 2026-07-11

### Added
Expand Down
33 changes: 23 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,32 @@

BugBundle captures a failing command as a privacy-safe, executable bug report artifact.

> Early development: the bundle format and CLI are not stable yet.
It runs locally, requires no account or network connection, and never uploads the bundle. The project is in the experimental `0.x` series, so review release notes before upgrading.

## Prototype
## Quick start

```bash
pnpm install
pnpm build
node packages/cli/dist/index.js init
node packages/cli/dist/index.js preview
node packages/cli/dist/index.js capture --output bugbundle.zip -- node -e "console.error('failed'); process.exit(1)"
node packages/cli/dist/index.js inspect bugbundle.zip
node packages/cli/dist/index.js verify bugbundle.zip
npx bugbundle init
npx bugbundle preview
npx bugbundle capture --output bugbundle.zip -- npm test
npx bugbundle inspect bugbundle.zip
npx bugbundle verify bugbundle.zip
```

Install it globally if you use it frequently:

```bash
npm install --global bugbundle
```

The prototype writes a deterministic ZIP containing a manifest, redacted logs, and an allowlisted set of project metadata files. It never uploads data. `verify` checks file hashes without executing anything; pass `--run` only when you trust the bundle and explicitly want to replay its command.
BugBundle writes a deterministic ZIP containing a manifest, redacted logs, and an allowlisted set of project metadata files. `verify` checks file hashes without executing anything; pass `--run` only when you trust the bundle and explicitly want to replay its command.

Every operational command supports `--json` for scripts and AI tools. Successful results go to stdout, structured errors go to stderr, and exit codes remain stable: `0` for success, `1` for runtime or replay mismatch, and `2` for invalid usage.

```bash
bugbundle capture --json --output bugbundle.zip -- npm test
bugbundle verify --json bugbundle.zip
```

## File allowlist

Expand Down Expand Up @@ -52,3 +63,5 @@ pnpm build
See [selection research](docs/selection-research.md) for the product rationale and validation targets.

See [bundle format](docs/bundle-format.md) for the current archive contract and security model.

Report bugs and feature requests in [GitHub Issues](https://github.com/bugbundleZ/bugbundle/issues).
2 changes: 2 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ npx bugbundle verify issue.zip
```

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.
34 changes: 30 additions & 4 deletions packages/cli/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import { mkdtemp, writeFile } from "node:fs/promises";
import { mkdtemp, realpath, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
Expand All @@ -12,23 +12,49 @@ describe("bugbundle CLI", () => {
await expect(run(["--help"])).resolves.toMatchObject({ code: 0, stderr: "" });
await expect(run(["unknown"])).resolves.toMatchObject({ code: 2 });
await expect(run(["capture"])).resolves.toMatchObject({ code: 2 });

const jsonError = await run(["unknown", "--json"]);
expect(jsonError.code).toBe(2);
expect(JSON.parse(jsonError.stderr)).toEqual({
error: { code: "USAGE_ERROR", message: "Unknown command: unknown" },
});

const unknownOption = await run(["inspect", "bundle.zip", "--unknown", "--json"]);
expect(unknownOption.code).toBe(2);
expect(JSON.parse(unknownOption.stderr).error.code).toBe("USAGE_ERROR");

const extraBundle = await run(["verify", "one.zip", "two.zip", "--json"]);
expect(extraBundle.code).toBe(2);
expect(JSON.parse(extraBundle.stderr).error.message).toBe("Unexpected verify argument: two.zip");

const missingOutput = await run(["capture", "--output", "--json", "--", process.execPath]);
expect(missingOutput.code).toBe(2);
expect(JSON.parse(missingOutput.stderr).error.message).toBe("--output requires <file>");
});

it("runs init, preview, capture, inspect, and verify as a black box", async () => {
const cwd = await mkdtemp(join(tmpdir(), "bugbundle-cli-"));
await writeFile(join(cwd, "package.json"), "{\"name\":\"cli-fixture\"}\n");

expect((await run(["init"], cwd)).code).toBe(0);
expect((await run(["init"], cwd)).code).toBe(1);
const initialized = await run(["init", "--json"], cwd);
expect(initialized.code).toBe(0);
expect(await realpath(JSON.parse(initialized.stdout).configPath)).toBe(await realpath(join(cwd, ".bugbundle.yml")));

const duplicateInit = await run(["init", "--json"], cwd);
expect(duplicateInit.code).toBe(1);
expect(JSON.parse(duplicateInit.stderr).error.code).toBe("RUNTIME_ERROR");
const preview = await run(["preview", "--json"], cwd);
expect(preview.code).toBe(0);
expect(JSON.parse(preview.stdout).files).toHaveLength(2);

const capture = await run(
["capture", "--output", "issue.zip", "--", process.execPath, "-e", "process.exit(6)"],
["capture", "--output", "issue.zip", "--json", "--", process.execPath, "-e", "process.exit(6)"],
cwd,
);
expect(capture.code).toBe(0);
const captureResult = JSON.parse(capture.stdout);
expect(await realpath(captureResult.bundlePath)).toBe(await realpath(join(cwd, "issue.zip")));
expect(captureResult.manifest).toMatchObject({ result: { exitCode: 6 } });
expect((await run(["inspect", "issue.zip", "--json"], cwd)).code).toBe(0);
expect((await run(["verify", "issue.zip"], cwd)).code).toBe(0);

Expand Down
78 changes: 52 additions & 26 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import {
const HELP = `BugBundle captures and verifies local bug report artifacts.

Usage:
bugbundle init [--force]
bugbundle init [--force] [--json]
bugbundle preview [--json]
bugbundle capture [--output <file>] -- <command> [arguments...]
bugbundle capture [--output <file>] [--json] -- <command> [arguments...]
bugbundle inspect <bundle.zip> [--json]
bugbundle verify <bundle.zip> [--run] [--json]
bugbundle --help
Expand All @@ -32,23 +32,25 @@ async function main(args: readonly string[]): Promise<number> {
if (args[0] === "capture") return capture(args.slice(1));
if (args[0] === "inspect") return inspect(args.slice(1));
if (args[0] === "verify") return verify(args.slice(1));
process.stderr.write(`Unknown command: ${args[0]}\n\n${HELP}`);
return 2;
return usageError(`Unknown command: ${args[0]}`, args.includes("--json"), true);
}

async function init(args: readonly string[]): Promise<number> {
const unknown = args.filter((value) => value !== "--force");
if (unknown.length > 0) return usageError(`Unknown init option: ${unknown[0]}`);
const json = args.includes("--json");
const unknown = args.filter((value) => 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"));
process.stdout.write(`${path}\n`);
if (json) process.stdout.write(`${JSON.stringify({ configPath: path }, null, 2)}\n`);
else process.stdout.write(`${path}\n`);
return 0;
}

async function preview(args: readonly string[]): Promise<number> {
const json = args.includes("--json");
const unknown = args.filter((value) => value !== "--json");
if (unknown.length > 0) return usageError(`Unknown preview option: ${unknown[0]}`);
if (unknown.length > 0) return usageError(`Unknown preview option: ${unknown[0]}`, json);
const result = await previewProjectFiles(process.cwd());
if (args.includes("--json")) {
if (json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return 0;
}
Expand All @@ -59,42 +61,55 @@ async function preview(args: readonly string[]): Promise<number> {

async function capture(args: readonly string[]): Promise<number> {
const separator = args.indexOf("--");
const options = separator === -1 ? args : args.slice(0, separator);
const json = options.includes("--json");
if (separator === -1 || separator === args.length - 1) {
process.stderr.write("capture requires `-- <command> [arguments...]`\n");
return 2;
return usageError("capture requires `-- <command> [arguments...]`", json);
}
const options = args.slice(0, separator);
let outputFile = "bugbundle.zip";
for (let index = 0; index < options.length; index += 1) {
const option = options[index];
if (option === "--output" && options[index + 1]) {
outputFile = options[index + 1] as string;
if (option === "--output") {
const value = options[index + 1];
if (!value || value.startsWith("--")) return usageError("--output requires <file>", json);
outputFile = value;
index += 1;
continue;
}
process.stderr.write(`Unknown capture option: ${option}\n`);
return 2;
if (option === "--json") continue;
return usageError(`Unknown capture option: ${option}`, json);
}
const command = args.slice(separator + 1) as [string, ...string[]];
const result = await captureCommand({ command, cwd: process.cwd(), outputFile });
process.stdout.write(`${result.bundlePath}\n`);
if (json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
else process.stdout.write(`${result.bundlePath}\n`);
return 0;
}

async function inspect(args: readonly string[]): Promise<number> {
const bundlePath = args.find((value) => !value.startsWith("-"));
if (!bundlePath) return usageError("inspect requires <bundle.zip>");
const json = args.includes("--json");
const unknown = args.find((value) => value.startsWith("-") && value !== "--json");
if (unknown) return usageError(`Unknown inspect option: ${unknown}`, json);
const positionals = args.filter((value) => !value.startsWith("-"));
const bundlePath = positionals[0];
if (!bundlePath) return usageError("inspect requires <bundle.zip>", json);
if (positionals.length > 1) return usageError(`Unexpected inspect argument: ${positionals[1]}`, json);
const result = await inspectBundle(bundlePath);
writeResult(result, args.includes("--json"));
writeResult(result, json);
return 0;
}

async function verify(args: readonly string[]): Promise<number> {
const bundlePath = args.find((value) => !value.startsWith("-"));
if (!bundlePath) return usageError("verify requires <bundle.zip>");
const json = args.includes("--json");
const unknown = args.find((value) => value.startsWith("-") && value !== "--run" && value !== "--json");
if (unknown) return usageError(`Unknown verify option: ${unknown}`, json);
const positionals = args.filter((value) => !value.startsWith("-"));
const bundlePath = positionals[0];
if (!bundlePath) return usageError("verify requires <bundle.zip>", json);
if (positionals.length > 1) return usageError(`Unexpected verify argument: ${positionals[1]}`, json);
const run = args.includes("--run");
const result = await verifyBundle(bundlePath, { run });
writeResult(result, args.includes("--json"));
writeResult(result, json);
return result.replay && !result.replay.matched ? 1 : 0;
}

Expand All @@ -109,17 +124,28 @@ function writeResult(result: object, json: boolean): void {
if (value.replay) process.stdout.write(`Replay matched: ${String(value.replay.matched)}\n`);
}

function usageError(message: string): number {
process.stderr.write(`${message}\n`);
function usageError(message: string, json = false, showHelp = false): number {
writeError("USAGE_ERROR", message, json);
if (showHelp && !json) process.stderr.write(`\n${HELP}`);
return 2;
}

function writeError(code: "USAGE_ERROR" | "RUNTIME_ERROR", message: string, json: boolean): void {
if (json) {
process.stderr.write(`${JSON.stringify({ error: { code, message } }, null, 2)}\n`);
return;
}
process.stderr.write(`${message}\n`);
}

main(process.argv.slice(2))
.then((code) => {
process.exitCode = code;
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`BugBundle failed: ${message}\n`);
const json = process.argv.slice(2).includes("--json");
if (json) writeError("RUNTIME_ERROR", message, true);
else process.stderr.write(`BugBundle failed: ${message}\n`);
process.exitCode = 1;
});