mirror of
https://github.com/oven-sh/bun
synced 2026-02-10 10:58:56 +00:00
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:
@@ -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() },
|
||||
|
||||
@@ -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
173
src/js/eval/feedback.ts
Normal 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
284
test/cli/feedback.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user