From bcd89e676d06e0f5f94583194145f4d8d455b676 Mon Sep 17 00:00:00 2001 From: zhouwang <184232456@qq.com> Date: Fri, 17 Jul 2026 21:31:25 +0800 Subject: [PATCH] Complete machine-readable CLI output --- CHANGELOG.md | 10 +++++ README.md | 33 +++++++++----- packages/cli/README.md | 2 + packages/cli/src/index.test.ts | 34 +++++++++++++-- packages/cli/src/index.ts | 78 ++++++++++++++++++++++------------ 5 files changed, 117 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0976619..855d36e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 9391bc6..e4a96ed 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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). diff --git a/packages/cli/README.md b/packages/cli/README.md index b9dee62..de708e7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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. diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index e394548..c608477 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, 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"; @@ -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 "); }); 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); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8eae349..e143278 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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 ] -- [arguments...] + bugbundle capture [--output ] [--json] -- [arguments...] bugbundle inspect [--json] bugbundle verify [--run] [--json] bugbundle --help @@ -32,23 +32,25 @@ async function main(args: readonly string[]): Promise { 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 { - 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 { + 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; } @@ -59,42 +61,55 @@ async function preview(args: readonly string[]): Promise { async function capture(args: readonly string[]): Promise { 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 `-- [arguments...]`\n"); - return 2; + return usageError("capture requires `-- [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 ", 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 { - const bundlePath = args.find((value) => !value.startsWith("-")); - if (!bundlePath) return usageError("inspect requires "); + 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 ", 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 { - const bundlePath = args.find((value) => !value.startsWith("-")); - if (!bundlePath) return usageError("verify requires "); + 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 ", 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; } @@ -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; });