From 78d9cfec0e772c50a8fdbdae933daab056e41a8a Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Mon, 15 Sep 2025 12:13:19 +0000 Subject: [PATCH] Add `bun feedback` command for user feedback submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a new `bun feedback` subcommand that allows users to send feedback to the Bun team. Features: - Accepts feedback via positional arguments or piped input - Collects system information (OS, CPU architecture, Bun version) - Manages user email with persistence and git config fallback - Sends feedback to https://bun.com/api/v1/feedback - Supports BUN_FEEDBACK_URL environment variable for testing - Shows progress indicator during submission - Comprehensive test coverage The command is implemented as an embedded TypeScript eval script for faster builds, avoiding the module system overhead. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- build.zig | 1 + src/cli/run_command.zig | 24 ++++ src/js/eval/feedback.ts | 173 +++++++++++++++++++++++ test/cli/feedback.test.ts | 284 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 482 insertions(+) create mode 100644 src/js/eval/feedback.ts create mode 100644 test/cli/feedback.test.ts diff --git a/build.zig b/build.zig index 4bc45fcdea..75f607f661 100644 --- a/build.zig +++ b/build.zig @@ -715,6 +715,7 @@ fn addInternalImports(b: *Build, mod: *Module, opts: *BunBuildOptions) void { .{ .file = "bun-error/index.js", .enable = opts.shouldEmbedCode() }, .{ .file = "bun-error/bun-error.css", .enable = opts.shouldEmbedCode() }, .{ .file = "fallback-decoder.js", .enable = opts.shouldEmbedCode() }, + .{ .file = "feedback.js", .enable = opts.shouldEmbedCode() }, .{ .file = "node-fallbacks/react-refresh.js", .enable = opts.shouldEmbedCode() }, .{ .file = "node-fallbacks/assert.js", .enable = opts.shouldEmbedCode() }, .{ .file = "node-fallbacks/buffer.js", .enable = opts.shouldEmbedCode() }, diff --git a/src/cli/run_command.zig b/src/cli/run_command.zig index 23fb3f5ff7..f00dc235f9 100644 --- a/src/cli/run_command.zig +++ b/src/cli/run_command.zig @@ -1352,6 +1352,30 @@ pub const RunCommand = struct { } const passthrough = ctx.passthrough; // unclear why passthrough is an escaped string, it should probably be []const []const u8 and allow its users to escape it. + // Check if this is the feedback command + if (bun.strings.eqlComptime(target_name, "feedback")) { + // Get the embedded feedback.js code + const code = if (bun.Environment.codegen_embed) + @embedFile("codegen/feedback.js") + else + bun.runtimeEmbedFile(.codegen, "feedback.js"); + + // Set up eval to run the feedback code + ctx.runtime_options.eval.script = code; + + // Ensure passthrough arguments are available to the eval script + // The passthrough already contains the arguments after "feedback" + // ctx.passthrough is already set correctly from the CLI parsing + + // Run as eval + const trigger = bun.pathLiteral("/[eval]"); + var entry_point_buf: [bun.MAX_PATH_BYTES + trigger.len]u8 = undefined; + const cwd = try std.posix.getcwd(&entry_point_buf); + @memcpy(entry_point_buf[cwd.len..][0..trigger.len], trigger); + try Run.boot(ctx, entry_point_buf[0 .. cwd.len + trigger.len], null); + return true; + } + var try_fast_run = false; var skip_script_check = false; if (target_name.len > 0 and target_name[0] == '.') { diff --git a/src/js/eval/feedback.ts b/src/js/eval/feedback.ts new file mode 100644 index 0000000000..9942ac4ef6 --- /dev/null +++ b/src/js/eval/feedback.ts @@ -0,0 +1,173 @@ +// Output banner immediately before any requires +console.error("\nbun feedback - Send feedback to the Bun team\n"); + +const { readFileSync, existsSync, writeFileSync, fstatSync } = require("fs"); +const { join } = require("path"); + +const VERSION = process.versions.bun || "unknown"; +const OS = process.platform; +const ARCH = process.arch; + +// Check if stdin is readable (not /dev/null or closed) +function isStdinReadable() { + try { + // Get file descriptor 0 (stdin) + const stats = fstatSync(0); + // Check if it's a regular file and has size 0 (like /dev/null) + // or if it's not a character device/pipe/socket + if (stats.isFile() && stats.size === 0) { + return false; + } + return true; + } catch { + return false; + } +} + +async function getEmail() { + const bunInstall = process.env.BUN_INSTALL; + + // Check for saved email + if (bunInstall) { + const feedbackPath = join(bunInstall, "feedback"); + if (existsSync(feedbackPath)) { + const savedEmail = readFileSync(feedbackPath, "utf8").trim(); + if (savedEmail) { + return savedEmail; + } + } + } + + // Try to get email from git config + let defaultEmail = ""; + try { + const result = Bun.spawnSync(["git", "config", "user.email"], { + stdout: "pipe", + stderr: "ignore", + }); + if (result.exitCode === 0 && result.stdout) { + defaultEmail = result.stdout.toString().trim(); + } + } catch {} + + // If stdin is not readable (e.g., /dev/null), return default or empty + if (!isStdinReadable()) { + return defaultEmail || ""; + } + + // Prompt for email + process.stderr.write(`? Email address${defaultEmail ? ` (${defaultEmail})` : ""}: `); + + const decoder = new TextDecoder(); + for await (const chunk of Bun.stdin.stream()) { + const line = decoder.decode(chunk).trim(); + const email = line || defaultEmail; + + // Save email if BUN_INSTALL is set + if (bunInstall && email) { + const feedbackPath = join(bunInstall, "feedback"); + try { + writeFileSync(feedbackPath, email); + } catch {} + } + + return email; + } + + return defaultEmail; +} + +async function getBody() { + // Get args from process.argv + // process.argv[0] = bun executable, process.argv[1+] = actual args + const args = process.argv.slice(1); + + // If we have positional arguments, use them + if (args.length > 0) { + return args.join(" "); + } + + // Check if stdin is readable + if (!isStdinReadable()) { + return ""; + } + + // If stdin is not a TTY, read from pipe + if (!process.stdin.isTTY) { + const chunks = []; + for await (const chunk of Bun.stdin.stream()) { + chunks.push(chunk); + } + const buffer = Buffer.concat(chunks); + return buffer.toString("utf8").trim(); + } + + // Otherwise prompt for message + process.stderr.write("? Feedback message (Press Enter to submit): "); + + const decoder = new TextDecoder(); + for await (const chunk of Bun.stdin.stream()) { + return decoder.decode(chunk).trim(); + } + + return ""; +} + +async function sendFeedback(email, body) { + const url = process.env.BUN_FEEDBACK_URL || "https://bun.com/api/v1/feedback"; + + const payload = JSON.stringify({ + os: OS, + cpu: ARCH, + version: VERSION, + body, + email, + }); + + // Show progress + process.stderr.write("Sending feedback..."); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: payload, + }); + + process.stderr.write("\r\x1b[K"); // Clear the line + + if (response.ok) { + console.error("\nāœ“ Thank you for your feedback!\n"); + } else { + console.error(`\nerror: Failed to send feedback (status code: ${response.status})\n`); + process.exit(1); + } + } catch (error) { + process.stderr.write("\r\x1b[K"); // Clear the line + console.error(`\nerror: Failed to send feedback: ${error?.message || error}\n`); + process.exit(1); + } +} + +(async () => { + try { + // Get email + const email = await getEmail(); + + // Get feedback body + const body = await getBody(); + + if (!body) { + console.error("error: No feedback message provided"); + process.exit(1); + } + + // Send feedback + await sendFeedback(email, body); + } catch (err) { + console.error("error: Unexpected error in feedback command:", err); + process.exit(1); + } +})(); \ No newline at end of file diff --git a/test/cli/feedback.test.ts b/test/cli/feedback.test.ts new file mode 100644 index 0000000000..396bfa0048 --- /dev/null +++ b/test/cli/feedback.test.ts @@ -0,0 +1,284 @@ +import { test, expect } from "bun:test"; +import { bunEnv, bunExe, tempDir, normalizeBunSnapshot } from "harness"; +import { createServer } from "node:http"; +import { join } from "node:path"; + +test("bun feedback sends POST request with correct payload", async () => { + let receivedRequest: any = null; + let receivedBody: string = ""; + + // Create test server + const server = createServer((req, res) => { + if (req.method === "POST" && req.url === "/api/v1/feedback") { + let body = ""; + req.on("data", chunk => { + body += chunk.toString(); + }); + req.on("end", () => { + receivedBody = body; + try { + receivedRequest = JSON.parse(body); + } catch {} + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: true })); + }); + } else { + res.writeHead(404); + res.end(); + } + }); + + await new Promise(resolve => { + server.listen(0, "127.0.0.1", () => { + resolve(); + }); + }); + + const port = (server.address() as any).port; + const feedbackUrl = `http://127.0.0.1:${port}/api/v1/feedback`; + + try { + // Test with positional arguments + using dir = tempDir("feedback-test", { + "feedback": "test@example.com", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "feedback", "this", "is", "a", "test", "message"], + env: { + ...bunEnv, + BUN_FEEDBACK_URL: feedbackUrl, + BUN_INSTALL: String(dir), + }, + stdin: Bun.file("/dev/null"), + stdout: "pipe", + stderr: "pipe", + cwd: String(dir), + }); + + const [exitCode] = await Promise.all([proc.exited]); + + expect(exitCode).toBe(0); + expect(receivedRequest).toBeTruthy(); + expect(receivedRequest.body).toBe("this is a test message"); + expect(receivedRequest.email).toBe("test@example.com"); + expect(receivedRequest.os).toBeTruthy(); + expect(receivedRequest.cpu).toBeTruthy(); + expect(receivedRequest.version).toBeTruthy(); + } finally { + server.close(); + } +}); + +test("bun feedback reads from stdin when piped", async () => { + let receivedRequest: any = null; + + const server = createServer((req, res) => { + if (req.method === "POST" && req.url === "/api/v1/feedback") { + let body = ""; + req.on("data", chunk => { + body += chunk.toString(); + }); + req.on("end", () => { + try { + receivedRequest = JSON.parse(body); + } catch {} + res.writeHead(200); + res.end(JSON.stringify({ success: true })); + }); + } + }); + + await new Promise(resolve => { + server.listen(0, "127.0.0.1", () => { + resolve(); + }); + }); + + const port = (server.address() as any).port; + const feedbackUrl = `http://127.0.0.1:${port}/api/v1/feedback`; + + try { + using dir = tempDir("feedback-test2", { + "feedback": "test@example.com", + "test.js": `console.log("Error from script");`, + }); + + // Run the script and pipe to feedback + await using proc1 = Bun.spawn({ + cmd: [bunExe(), "test.js"], + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + cwd: String(dir), + }); + + const output = await proc1.stdout.text(); + + await using proc2 = Bun.spawn({ + cmd: [bunExe(), "feedback"], + env: { + ...bunEnv, + BUN_FEEDBACK_URL: feedbackUrl, + BUN_INSTALL: String(dir), + }, + stdin: Buffer.from(output), + stdout: "pipe", + stderr: "pipe", + cwd: String(dir), + }); + + const [exitCode] = await Promise.all([proc2.exited]); + + expect(exitCode).toBe(0); + expect(receivedRequest).toBeTruthy(); + expect(receivedRequest.body).toContain("Error from script"); + expect(receivedRequest.email).toBe("test@example.com"); + } finally { + server.close(); + } +}); + +test("bun feedback saves and reuses email", async () => { + const server = createServer((req, res) => { + if (req.method === "POST" && req.url === "/api/v1/feedback") { + res.writeHead(200); + res.end(JSON.stringify({ success: true })); + } + }); + + await new Promise(resolve => { + server.listen(0, "127.0.0.1", () => { + resolve(); + }); + }); + + const port = (server.address() as any).port; + const feedbackUrl = `http://127.0.0.1:${port}/api/v1/feedback`; + + try { + using dir = tempDir("feedback-test3", { + "feedback": "saved@example.com", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "feedback", "test"], + env: { + ...bunEnv, + BUN_FEEDBACK_URL: feedbackUrl, + BUN_INSTALL: String(dir), + }, + stdin: Bun.file("/dev/null"), + stdout: "pipe", + stderr: "pipe", + cwd: String(dir), + }); + + const [exitCode] = await Promise.all([proc.exited]); + expect(exitCode).toBe(0); + + // Check that email was used + const savedEmail = await Bun.file(join(String(dir), "feedback")).text(); + expect(savedEmail).toBe("saved@example.com"); + } finally { + server.close(); + } +}); + +test("bun feedback handles server errors gracefully", async () => { + const server = createServer((req, res) => { + if (req.method === "POST" && req.url === "/api/v1/feedback") { + res.writeHead(500); + res.end("Internal Server Error"); + } + }); + + await new Promise(resolve => { + server.listen(0, "127.0.0.1", () => { + resolve(); + }); + }); + + const port = (server.address() as any).port; + const feedbackUrl = `http://127.0.0.1:${port}/api/v1/feedback`; + + try { + using dir = tempDir("feedback-test4", { + "feedback": "test@example.com", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "feedback", "test"], + env: { + ...bunEnv, + BUN_FEEDBACK_URL: feedbackUrl, + BUN_INSTALL: String(dir), + }, + stdin: Bun.file("/dev/null"), + stdout: "pipe", + stderr: "pipe", + cwd: String(dir), + }); + + const [exitCode, stderr] = await Promise.all([ + proc.exited, + proc.stderr.text(), + ]); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Failed to send feedback"); + } finally { + server.close(); + } +}); + +test("bun feedback command exists", async () => { + // Test that the feedback command is recognized and starts executing + // We'll test with a non-existent server to ensure it times out quickly + using dir = tempDir("feedback-test5", { + "feedback": "test@example.com", + }); + + // Use a promise that resolves when we see output + let outputReceived = false; + const outputPromise = new Promise((resolve) => { + const proc = Bun.spawn({ + cmd: [bunExe(), "feedback", "test", "message"], + env: { + ...bunEnv, + BUN_FEEDBACK_URL: `http://127.0.0.1:1/api/v1/feedback`, // Port 1 will fail immediately + BUN_INSTALL: String(dir), + }, + stdout: "pipe", + stderr: "pipe", + cwd: String(dir), + }); + + // Collect output + let stderr = ""; + proc.stderr.pipeTo(new WritableStream({ + write(chunk) { + const text = new TextDecoder().decode(chunk); + stderr += text; + if (text.includes("feedback") || text.includes("Failed to send")) { + outputReceived = true; + resolve(); + } + } + })); + + // Also resolve after timeout + setTimeout(() => { + if (!outputReceived) { + proc.kill(); + resolve(); + } + }, 2000); + }); + + await outputPromise; + + // The test passes if we got any output containing "feedback" + // (either the banner or the error message) + expect(outputReceived).toBe(true); +}); \ No newline at end of file