Add bun feedback command for user feedback submission

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 <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2025-09-15 12:13:19 +00:00
parent 6bafe2602e
commit 78d9cfec0e
4 changed files with 482 additions and 0 deletions

View File

@@ -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() },

View File

@@ -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] == '.') {

173
src/js/eval/feedback.ts Normal file
View File

@@ -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);
}
})();

284
test/cli/feedback.test.ts Normal file
View File

@@ -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<void>(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<void>(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<void>(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<void>(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<void>((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);
});