mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
bun feedback (#22710)
### What does this PR do? ### How did you verify your code works? --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -743,6 +743,7 @@ fn addInternalImports(b: *Build, mod: *Module, opts: *BunBuildOptions) void {
|
||||
.{ .file = "node-fallbacks/url.js", .enable = opts.shouldEmbedCode() },
|
||||
.{ .file = "node-fallbacks/util.js", .enable = opts.shouldEmbedCode() },
|
||||
.{ .file = "node-fallbacks/zlib.js", .enable = opts.shouldEmbedCode() },
|
||||
.{ .file = "eval/feedback.ts", .enable = opts.shouldEmbedCode() },
|
||||
}) |entry| {
|
||||
if (!@hasField(@TypeOf(entry), "enable") or entry.enable) {
|
||||
const path = b.pathJoin(&.{ opts.codegen_path, entry.file });
|
||||
|
||||
@@ -187,6 +187,8 @@ pub const HelpCommand = struct {
|
||||
\\ <b><cyan>init<r> Start an empty Bun project from a built-in template
|
||||
\\ <b><cyan>create<r> <d>{s:<16}<r> Create a new project from a template <d>(bun c)<r>
|
||||
\\ <b><cyan>upgrade<r> Upgrade to latest version of Bun.
|
||||
\\ <b><cyan>feedback<r> <d>./file1 ./file2<r> Provide feedback to the Bun team.
|
||||
\\
|
||||
\\ <d>\<command\><r> <b><cyan>--help<r> Print help text for command.
|
||||
\\
|
||||
;
|
||||
|
||||
@@ -1603,6 +1603,12 @@ pub const RunCommand = struct {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ctx.filters.len == 0 and !ctx.workspaces and CLI.Cli.cmd != null and CLI.Cli.cmd.? == .AutoCommand) {
|
||||
if (bun.strings.eqlComptime(target_name, "feedback")) {
|
||||
try @"bun feedback"(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
if (log_errors) {
|
||||
const ext = std.fs.path.extension(target_name);
|
||||
const default_loader = options.defaultLoaders.get(ext);
|
||||
@@ -1665,6 +1671,19 @@ pub const RunCommand = struct {
|
||||
Global.exit(1);
|
||||
};
|
||||
}
|
||||
|
||||
fn @"bun feedback"(ctx: Command.Context) !noreturn {
|
||||
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);
|
||||
ctx.runtime_options.eval.script = if (bun.Environment.codegen_embed)
|
||||
@embedFile("eval/feedback.ts")
|
||||
else
|
||||
bun.runtimeEmbedFile(.codegen, "eval/feedback.ts");
|
||||
try Run.boot(ctx, entry_point_buf[0 .. cwd.len + trigger.len], null);
|
||||
Global.exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
pub const BunXFastPath = struct {
|
||||
|
||||
@@ -541,6 +541,27 @@ declare module "module" {
|
||||
|
||||
mark("Generate Code");
|
||||
|
||||
const evalFiles = new Bun.Glob(path.join(BASE, "eval", "*.ts")).scanSync();
|
||||
for (const file of evalFiles) {
|
||||
const {
|
||||
outputs: [output],
|
||||
} = await Bun.build({
|
||||
entrypoints: [file],
|
||||
|
||||
// Shrink it.
|
||||
minify: !debug,
|
||||
|
||||
target: "bun",
|
||||
format: "esm",
|
||||
env: "disable",
|
||||
define: {
|
||||
"process.platform": JSON.stringify(process.platform),
|
||||
"process.arch": JSON.stringify(process.arch),
|
||||
},
|
||||
});
|
||||
writeIfNotChanged(path.join(CODEGEN_DIR, "eval", path.basename(file)), await output.text());
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
console.log("");
|
||||
console.timeEnd(timeString);
|
||||
|
||||
1
src/js/eval/README.md
Normal file
1
src/js/eval/README.md
Normal file
@@ -0,0 +1 @@
|
||||
These are not bundled as builtin modules and instead are minified.
|
||||
761
src/js/eval/feedback.ts
Normal file
761
src/js/eval/feedback.ts
Normal file
@@ -0,0 +1,761 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { closeSync, promises as fsp, openSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import tty from "node:tty";
|
||||
import { parseArgs as nodeParseArgs } from "node:util";
|
||||
|
||||
const supportsAnsi = Boolean(process.stdout.isTTY && !("NO_COLOR" in process.env));
|
||||
const reset = supportsAnsi ? "\x1b[0m" : "";
|
||||
const bold = supportsAnsi ? "\x1b[1m" : "";
|
||||
const dim = supportsAnsi ? "\x1b[2m" : "";
|
||||
const red = supportsAnsi ? "\x1b[31m" : "";
|
||||
const green = supportsAnsi ? "\x1b[32m" : "";
|
||||
const cyan = supportsAnsi ? "\x1b[36m" : "";
|
||||
const gray = supportsAnsi ? "\x1b[90m" : "";
|
||||
const symbols = {
|
||||
question: `${cyan}?${reset}`,
|
||||
check: `${green}✔${reset}`,
|
||||
cross: `${red}✖${reset}`,
|
||||
};
|
||||
const inputPrefix = `${gray}> ${reset}`;
|
||||
const thankYouBanner = `
|
||||
${supportsAnsi ? bold : ""}THANK YOU! ${reset}`;
|
||||
const enum IPSupport {
|
||||
ipv4 = "ipv4",
|
||||
ipv6 = "ipv6",
|
||||
ipv4_and_ipv6 = "ipv4_and_ipv6",
|
||||
none = "none",
|
||||
}
|
||||
|
||||
type TerminalIO = {
|
||||
input: tty.ReadStream;
|
||||
output: tty.WriteStream;
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
function openTerminal(): TerminalIO | null {
|
||||
if (process.stdin.isTTY && process.stdout.isTTY) {
|
||||
return {
|
||||
input: process.stdin as unknown as tty.ReadStream,
|
||||
output: process.stdout as unknown as tty.WriteStream,
|
||||
cleanup: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const candidates = process.platform === "win32" ? ["CON"] : ["/dev/tty"];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const fd = openSync(candidate, "r+");
|
||||
const input = new tty.ReadStream(fd);
|
||||
const output = new tty.WriteStream(fd);
|
||||
input.setEncoding("utf8");
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
cleanup: () => {
|
||||
input.destroy();
|
||||
output.destroy();
|
||||
try {
|
||||
closeSync(fd);
|
||||
} catch {}
|
||||
},
|
||||
};
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
const logError = (message: string) => {
|
||||
process.stderr.write(`${symbols.cross} ${message}\n`);
|
||||
};
|
||||
const logInfo = (message: string) => {
|
||||
process.stdout.write(`${bold}${message}${reset}\n`);
|
||||
};
|
||||
|
||||
const isValidEmail = (value: string | undefined): value is string => {
|
||||
if (!value) return false;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.includes("@")) return false;
|
||||
if (!trimmed.includes(".")) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
type ParsedArgs = {
|
||||
email?: string;
|
||||
help: boolean;
|
||||
positionals: string[];
|
||||
};
|
||||
|
||||
function parseCliArgs(argv: string[]): ParsedArgs {
|
||||
try {
|
||||
const { values, positionals } = nodeParseArgs({
|
||||
args: argv,
|
||||
allowPositionals: true,
|
||||
strict: false,
|
||||
options: {
|
||||
email: {
|
||||
type: "string",
|
||||
short: "e",
|
||||
},
|
||||
help: {
|
||||
type: "boolean",
|
||||
short: "h",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
email: values.email,
|
||||
help: Boolean(values.help),
|
||||
positionals,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logError(message);
|
||||
process.exit(1);
|
||||
return { email: undefined, help: false, positionals: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
const heading = `${bold}${cyan}bun feedback${reset}`;
|
||||
const usage = `${bold}Usage${reset}
|
||||
bun feedback [options] [feedback text ... | files ...]`;
|
||||
const options = `${bold}Options${reset}
|
||||
${cyan}-e${reset}, ${cyan}--email${reset} <email> Set the email address used for this submission
|
||||
${cyan}-h${reset}, ${cyan}--help${reset} Show this help message and exit`;
|
||||
const examples = `${bold}Examples${reset}
|
||||
bun feedback "Love the new release!"
|
||||
bun feedback report.txt details.log
|
||||
echo "please document X" | bun feedback --email you@example.com`;
|
||||
|
||||
console.log([heading, "", usage, "", options, "", examples].join("\n"));
|
||||
}
|
||||
|
||||
async function readEmailFromBunInstall(): Promise<string | undefined> {
|
||||
const installRoot = process.env.BUN_INSTALL ?? path.join(os.homedir(), ".bun");
|
||||
const emailFile = path.join(installRoot, "feedback");
|
||||
try {
|
||||
const data = await fsp.readFile(emailFile, "utf8");
|
||||
const trimmed = data.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
console.warn(`Unable to read ${emailFile}:`, (error as Error).message);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistEmailToBunInstall(email: string): Promise<void> {
|
||||
const installRoot = process.env.BUN_INSTALL;
|
||||
if (!installRoot) return;
|
||||
|
||||
const emailFile = path.join(installRoot, "feedback");
|
||||
try {
|
||||
await fsp.mkdir(path.dirname(emailFile), { recursive: true });
|
||||
await fsp.writeFile(emailFile, `${email.trim()}\n`, "utf8");
|
||||
} catch (error) {
|
||||
console.warn(`Unable to persist email to ${emailFile}:`, (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function readEmailFromGitConfig(): string | undefined {
|
||||
const result = spawnSync("git", ["config", "user.email"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
return undefined;
|
||||
}
|
||||
const output = result.stdout.trim();
|
||||
return output.length > 0 ? output : undefined;
|
||||
}
|
||||
|
||||
async function promptForEmail(terminal: TerminalIO | null, defaultEmail?: string): Promise<string | undefined> {
|
||||
if (!terminal) {
|
||||
return defaultEmail && isValidEmail(defaultEmail) ? defaultEmail : undefined;
|
||||
}
|
||||
|
||||
let currentDefault = defaultEmail;
|
||||
|
||||
for (;;) {
|
||||
const answer = await promptForEmailInteractive(terminal, currentDefault);
|
||||
if (typeof answer === "string" && isValidEmail(answer)) {
|
||||
return answer.trim();
|
||||
}
|
||||
|
||||
terminal.output.write(`${symbols.cross} Please provide a valid email address containing "@" and ".".\n`);
|
||||
currentDefault = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function promptForEmailInteractive(terminal: TerminalIO, defaultEmail?: string): Promise<string | undefined> {
|
||||
const input = terminal.input;
|
||||
const output = terminal.output;
|
||||
|
||||
readline.emitKeypressEvents(input);
|
||||
const hadRawMode = typeof input.isRaw === "boolean" ? input.isRaw : undefined;
|
||||
if (typeof input.setRawMode === "function") {
|
||||
input.setRawMode(true);
|
||||
}
|
||||
if (typeof input.resume === "function") {
|
||||
input.resume();
|
||||
}
|
||||
|
||||
const placeholder = defaultEmail ?? "";
|
||||
let placeholderActive = placeholder.length > 0;
|
||||
let value = "";
|
||||
let resolved = false;
|
||||
|
||||
const render = () => {
|
||||
output.write(`\r\x1b[2K${symbols.question} ${bold}Email${reset}: `);
|
||||
if (placeholderActive && placeholder.length > 0) {
|
||||
output.write(`${dim}<${placeholder}>${reset}`);
|
||||
output.write(`\x1b[${placeholder.length + 2}D`);
|
||||
} else {
|
||||
output.write(value);
|
||||
}
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
return await new Promise<string | undefined>(resolve => {
|
||||
const cleanup = (result?: string) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
input.removeListener("keypress", onKeypress);
|
||||
if (typeof input.setRawMode === "function") {
|
||||
if (typeof hadRawMode === "boolean") {
|
||||
input.setRawMode(hadRawMode);
|
||||
} else {
|
||||
input.setRawMode(false);
|
||||
}
|
||||
}
|
||||
if (typeof input.pause === "function") {
|
||||
input.pause();
|
||||
}
|
||||
output.write("\n");
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const onKeypress = (str: string, key: readline.Key) => {
|
||||
if (!key && str) {
|
||||
if (placeholderActive) {
|
||||
placeholderActive = false;
|
||||
value = "";
|
||||
render();
|
||||
}
|
||||
value += str;
|
||||
output.write(str);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key && (key.sequence === "\u0003" || (key.ctrl && key.name === "c"))) {
|
||||
cleanup();
|
||||
process.exit(130);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key?.name === "return") {
|
||||
if (placeholderActive && placeholder.length > 0) {
|
||||
cleanup(placeholder);
|
||||
return;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
cleanup(trimmed.length > 0 ? trimmed : undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key?.name === "backspace") {
|
||||
if (placeholderActive) {
|
||||
return;
|
||||
}
|
||||
if (value.length > 0) {
|
||||
value = value.slice(0, -1);
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!str) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key && key.name && key.name.length > 1 && key.name !== "space") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (placeholderActive) {
|
||||
placeholderActive = false;
|
||||
value = "";
|
||||
render();
|
||||
}
|
||||
|
||||
value += str;
|
||||
output.write(str);
|
||||
};
|
||||
|
||||
input.on("keypress", onKeypress);
|
||||
});
|
||||
}
|
||||
|
||||
async function promptForBody(
|
||||
terminal: TerminalIO | null,
|
||||
attachments: PositionalContent["files"],
|
||||
): Promise<string | undefined> {
|
||||
if (!terminal) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const input = terminal.input;
|
||||
const output = terminal.output;
|
||||
|
||||
readline.emitKeypressEvents(input);
|
||||
const hadRawMode = typeof input.isRaw === "boolean" ? input.isRaw : undefined;
|
||||
if (typeof input.setRawMode === "function") {
|
||||
input.setRawMode(true);
|
||||
}
|
||||
if (typeof input.resume === "function") {
|
||||
input.resume();
|
||||
}
|
||||
|
||||
const header = `${symbols.question} ${bold}Share your feedback with Bun's team${reset} ${dim}(Enter to send, Shift+Enter for a newline)${reset}`;
|
||||
output.write(`${header}\n`);
|
||||
if (attachments.length > 0) {
|
||||
output.write(`${dim}+ ${attachments.map(file => file.filename).join(", ")}${reset}\n`);
|
||||
}
|
||||
output.write(`${inputPrefix}`);
|
||||
|
||||
const lines: string[] = [""];
|
||||
let currentLine = 0;
|
||||
let resolved = false;
|
||||
|
||||
return await new Promise<string | undefined>(resolve => {
|
||||
const cleanup = (value?: string) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
input.removeListener("keypress", onKeypress);
|
||||
if (typeof input.setRawMode === "function") {
|
||||
if (typeof hadRawMode === "boolean") {
|
||||
input.setRawMode(hadRawMode);
|
||||
} else {
|
||||
input.setRawMode(false);
|
||||
}
|
||||
}
|
||||
if (typeof input.pause === "function") {
|
||||
input.pause();
|
||||
}
|
||||
output.write("\n");
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const onKeypress = (str: string, key: readline.Key) => {
|
||||
if (!key) {
|
||||
if (str) {
|
||||
lines[currentLine] += str;
|
||||
output.write(str);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.sequence === "\u0003" || (key.ctrl && key.name === "c")) {
|
||||
cleanup();
|
||||
process.exit(130);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "return") {
|
||||
if (key.shift) {
|
||||
lines.push("");
|
||||
currentLine += 1;
|
||||
output.write(`\n${inputPrefix}`);
|
||||
return;
|
||||
}
|
||||
const message = lines.join("\n");
|
||||
cleanup(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "backspace") {
|
||||
const current = lines[currentLine];
|
||||
if (current.length > 0) {
|
||||
lines[currentLine] = current.slice(0, -1);
|
||||
output.write("\b \b");
|
||||
} else if (currentLine > 0) {
|
||||
lines.pop();
|
||||
currentLine -= 1;
|
||||
output.write("\r\x1b[2K");
|
||||
output.write("\x1b[F");
|
||||
output.write("\r\x1b[2K");
|
||||
output.write(`${inputPrefix}${lines[currentLine]}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name && key.name.length > 1 && key.name !== "space") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (str) {
|
||||
lines[currentLine] += str;
|
||||
output.write(str);
|
||||
}
|
||||
};
|
||||
|
||||
input.on("keypress", onKeypress);
|
||||
});
|
||||
}
|
||||
|
||||
async function readFromStdin(): Promise<string | undefined> {
|
||||
const stdin = process.stdin;
|
||||
if (!stdin || stdin.isTTY) return undefined;
|
||||
|
||||
if (typeof stdin.setEncoding === "function") {
|
||||
stdin.setEncoding("utf8");
|
||||
}
|
||||
|
||||
if (typeof stdin.resume === "function") {
|
||||
stdin.resume();
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
for await (const chunk of stdin as AsyncIterable<string | Buffer>) {
|
||||
chunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
|
||||
}
|
||||
|
||||
const content = chunks.join("");
|
||||
return content.length > 0 ? content : undefined;
|
||||
}
|
||||
|
||||
type PositionalContent = {
|
||||
messageParts: string[];
|
||||
files: { filename: string; content: Uint8Array<ArrayBuffer> }[];
|
||||
};
|
||||
|
||||
async function resolveFileCandidate(token: string): Promise<string | undefined> {
|
||||
const candidates = new Set<string>();
|
||||
candidates.add(token);
|
||||
|
||||
if (token.startsWith("~/")) {
|
||||
candidates.add(path.join(os.homedir(), token.slice(2)));
|
||||
}
|
||||
|
||||
const resolved = path.join(process.cwd(), token);
|
||||
candidates.add(resolved);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const stat = await fsp.stat(candidate);
|
||||
if (stat.isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code && (code === "ENOENT" || code === "ENOTDIR")) {
|
||||
continue;
|
||||
}
|
||||
console.warn(`Unable to inspect ${candidate}:`, (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function readFromPositionals(positionals: string[]): Promise<PositionalContent> {
|
||||
const messageParts: string[] = [];
|
||||
const files: PositionalContent["files"] = [];
|
||||
let literalTokens: string[] = [];
|
||||
|
||||
const flushTokens = () => {
|
||||
if (literalTokens.length > 0) {
|
||||
messageParts.push(literalTokens.join(" "));
|
||||
literalTokens = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const token of positionals) {
|
||||
const filePath = await resolveFileCandidate(token);
|
||||
|
||||
if (filePath) {
|
||||
try {
|
||||
let fileContents = await Bun.file(filePath).bytes();
|
||||
// Truncate to
|
||||
if (fileContents.length > 1024 * 1024 * 10) {
|
||||
fileContents = fileContents.slice(0, 1024 * 1024 * 10);
|
||||
}
|
||||
|
||||
flushTokens();
|
||||
files.push({
|
||||
filename: path.normalize(path.relative(process.cwd(), filePath)),
|
||||
content: fileContents,
|
||||
});
|
||||
continue;
|
||||
} catch {
|
||||
// Ignore read errors; treat token as part of the message instead.
|
||||
}
|
||||
}
|
||||
|
||||
literalTokens.push(token);
|
||||
}
|
||||
|
||||
flushTokens();
|
||||
return { messageParts, files };
|
||||
}
|
||||
|
||||
function getIPSupport(networkInterface: os.NetworkInterfaceInfo, original: IPSupport): IPSupport {
|
||||
if (networkInterface.family === "IPv4") {
|
||||
switch (original) {
|
||||
case IPSupport.none:
|
||||
return IPSupport.ipv4;
|
||||
case IPSupport.ipv4:
|
||||
return IPSupport.ipv4_and_ipv6;
|
||||
case IPSupport.ipv6:
|
||||
return IPSupport.ipv4_and_ipv6;
|
||||
case IPSupport.ipv4_and_ipv6:
|
||||
return IPSupport.ipv4_and_ipv6;
|
||||
}
|
||||
} else if (networkInterface.family === "IPv6") {
|
||||
switch (original) {
|
||||
case IPSupport.none:
|
||||
return IPSupport.ipv6;
|
||||
case IPSupport.ipv4:
|
||||
return IPSupport.ipv4_and_ipv6;
|
||||
case IPSupport.ipv6:
|
||||
return IPSupport.ipv4_and_ipv6;
|
||||
case IPSupport.ipv4_and_ipv6:
|
||||
return IPSupport.ipv4_and_ipv6;
|
||||
}
|
||||
}
|
||||
return original;
|
||||
}
|
||||
|
||||
function getOldestGitSha(): string | undefined {
|
||||
const result = spawnSync("git", ["rev-list", "--max-parents=0", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const firstLine = result.stdout.split(/\r?\n/).find(line => line.trim().length > 0);
|
||||
return firstLine?.trim();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const rawArgv = [...process.argv.slice(1)];
|
||||
|
||||
let terminal: TerminalIO | null = null;
|
||||
try {
|
||||
const { email: emailFlag, help, positionals } = parseCliArgs(rawArgv);
|
||||
if (help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
terminal = openTerminal();
|
||||
|
||||
const exit = (code: number): never => {
|
||||
terminal?.cleanup();
|
||||
process.exit(code);
|
||||
};
|
||||
|
||||
if (emailFlag && !isValidEmail(emailFlag)) {
|
||||
logError("The provided email must include both '@' and '.'.");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const storedEmailRaw = await readEmailFromBunInstall();
|
||||
const storedEmail = isValidEmail(storedEmailRaw) ? storedEmailRaw.trim() : undefined;
|
||||
|
||||
const gitEmailRaw = readEmailFromGitConfig();
|
||||
const gitEmail = isValidEmail(gitEmailRaw) ? gitEmailRaw.trim() : undefined;
|
||||
|
||||
const canPrompt = terminal !== null;
|
||||
|
||||
let email = emailFlag?.trim() ?? storedEmail ?? gitEmail;
|
||||
|
||||
if (canPrompt && !emailFlag && !storedEmail) {
|
||||
email = await promptForEmail(terminal, email ?? gitEmail ?? undefined);
|
||||
}
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
if (!canPrompt) {
|
||||
logError("Unable to determine email automatically. Pass --email <address>.");
|
||||
} else {
|
||||
logError("An email address is required. Pass --email or configure git user.email.");
|
||||
}
|
||||
exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedEmail = email.trim();
|
||||
|
||||
if (process.env.BUN_INSTALL && !storedEmail) {
|
||||
await persistEmailToBunInstall(normalizedEmail);
|
||||
}
|
||||
|
||||
const stdinContent = await readFromStdin();
|
||||
const positionalContent = await readFromPositionals(positionals);
|
||||
const positionalParts = positionalContent.messageParts;
|
||||
const pieces: string[] = [];
|
||||
if (stdinContent && stdinContent.trim().length > 0) pieces.push(stdinContent);
|
||||
for (const part of positionalParts) {
|
||||
if (part.trim().length > 0) {
|
||||
pieces.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
let message = pieces.length > 0 ? pieces.join(pieces.length > 1 ? "\n\n" : "") : "";
|
||||
|
||||
if (message.trim().length === 0 && terminal) {
|
||||
const interactiveBody = await promptForBody(terminal, positionalContent.files);
|
||||
if (interactiveBody && interactiveBody.trim().length > 0) {
|
||||
message = interactiveBody;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedMessage = message.trim();
|
||||
if (normalizedMessage.length === 0) {
|
||||
logError("No feedback provided. Supply text, file paths, or pipe input.");
|
||||
exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const messageBody = normalizedMessage;
|
||||
|
||||
const projectId = getOldestGitSha();
|
||||
const endpoint = process.env.BUN_FEEDBACK_URL || "https://bun.report/v1/feedback";
|
||||
|
||||
const form = new FormData();
|
||||
form.append("email", normalizedEmail);
|
||||
const fileList = positionalContent.files.map(file => file.filename);
|
||||
form.append("message", messageBody);
|
||||
for (const file of positionalContent.files) {
|
||||
form.append("files[]", new Blob([file.content]), file.filename);
|
||||
}
|
||||
|
||||
const id = Bun.randomUUIDv7();
|
||||
|
||||
form.append("platform", process.platform);
|
||||
form.append("arch", process.arch);
|
||||
form.append("bunRevision", Bun.revision);
|
||||
form.append("hardwareConcurrency", String(navigator.hardwareConcurrency));
|
||||
form.append("bunVersion", Bun.version);
|
||||
form.append("bunBuild", path.basename(process.release.sourceUrl!, path.extname(process.release.sourceUrl!)));
|
||||
form.append("availableMemory", String(process.availableMemory()));
|
||||
form.append("totalMemory", String(os.totalmem()));
|
||||
form.append("osVersion", String(os.version()));
|
||||
form.append("osRelease", String(os.release()));
|
||||
form.append("id", id);
|
||||
|
||||
// Check if we're running in Docker
|
||||
let inDocker = false;
|
||||
if (process.platform === "linux") {
|
||||
if (require("fs").existsSync("/.dockerenv")) {
|
||||
inDocker = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (inDocker) {
|
||||
form.append("docker", "true");
|
||||
}
|
||||
|
||||
let remoteIP: IPSupport = IPSupport.none;
|
||||
let localIP: IPSupport = IPSupport.none;
|
||||
|
||||
try {
|
||||
const networkInterfaces = Object.entries(os.networkInterfaces() || {});
|
||||
|
||||
for (const [name, interfaces] of networkInterfaces) {
|
||||
for (const networkInterface of interfaces || []) {
|
||||
if (networkInterface.family === "IPv4") {
|
||||
if (networkInterface.internal) {
|
||||
localIP = getIPSupport(networkInterface, localIP);
|
||||
} else {
|
||||
remoteIP = getIPSupport(networkInterface, remoteIP);
|
||||
}
|
||||
} else if (networkInterface.family === "IPv6") {
|
||||
if (networkInterface.internal) {
|
||||
localIP = getIPSupport(networkInterface, localIP);
|
||||
} else {
|
||||
remoteIP = getIPSupport(networkInterface, remoteIP);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors; treat as no IP support.
|
||||
}
|
||||
|
||||
form.append("localIPSupport", localIP);
|
||||
form.append("remoteIPSupport", remoteIP);
|
||||
|
||||
// Check if current working directory is on a remote filesystem
|
||||
if (process.platform === "linux" || process.platform === "darwin") {
|
||||
let isRemoteFilesystem = false;
|
||||
try {
|
||||
const cwd = process.cwd();
|
||||
const stats = await fsp.statfs(cwd);
|
||||
|
||||
// Check filesystem type based on the type field
|
||||
// Common remote filesystem types have specific type values
|
||||
const remoteFsTypes = new Set([
|
||||
0x6969, // NFS
|
||||
0xff534d42, // CIFS/SMB
|
||||
0x65735546, // FUSE (used by sshfs, etc.)
|
||||
]);
|
||||
|
||||
if (remoteFsTypes.has(stats.type)) {
|
||||
isRemoteFilesystem = true;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors; treat as local filesystem
|
||||
}
|
||||
|
||||
if (isRemoteFilesystem) {
|
||||
form.append("remoteFilesystem", "true");
|
||||
}
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
form.append("projectId", projectId);
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!response.ok || response.status !== 200) {
|
||||
const bodyText = await response.text().catch(() => "");
|
||||
logError(`Failed to send feedback (${response.status} ${response.statusText}).`);
|
||||
if (bodyText) {
|
||||
process.stderr.write(`${bodyText}\n`);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
let IDBanner = ``;
|
||||
if (supportsAnsi) {
|
||||
IDBanner = `\n${dim}ID: ${id}${reset}`;
|
||||
} else {
|
||||
IDBanner = `\nID: ${id}`;
|
||||
}
|
||||
|
||||
process.stdout.write(`${symbols.check} Feedback sent.\n${IDBanner}${thankYouBanner}\n`);
|
||||
} finally {
|
||||
terminal?.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch(error => {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
logError(`Unexpected error while sending feedback: ${detail}`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user