diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..0f1fd36 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,26 @@ +--- +name: Bug report +about: Something in the voltage CLI does not work as documented +labels: bug +--- + + + +### What happened + +What you expected, and what happened instead. + +### Steps to reproduce + +The commands to run, with placeholders such as `WALLET_ID` for IDs. + +### Environment + +- Version: (`voltage --version`) +- Platform: +- Command: + +### Checks + +- [ ] I searched existing issues: https://github.com/voltagecloud/voltage-cli/issues?q=is%3Aissue +- [ ] An AI agent wrote this report diff --git a/CHANGELOG.md b/CHANGELOG.md index c956f43..c948041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - `voltage payments list --watch` follows payments until Ctrl-C: a live table that marks new and changed payments on a terminal, or one `snapshot`, `new`, or `updated` event per payment when piped or with `--json`. `--interval` sets the refresh (default 5 seconds). - `voltage` with no command, on a terminal, greets you with the wordmark, who you are signed in as, your saved scope, and commands to try; off a terminal it is still a usage error. An empty wallet, payment, or webhook list, and a new profile, end with a `›` line naming the next command. - A command reads a saved login's or API key's secret from the OS credential store once, instead of once for each lookup, so macOS asks for keychain access at most once per command. A login refresh still reads it again under the credential lock, to see a refresh that another command saved. +- `voltage feedback` prints a link that opens a new GitHub issue with the CLI version, platform, and the misbehaving command's words and flag names (never their values) filled in, and a link to search existing issues first. On a terminal it opens the link in your browser instead of printing it, unless `--no-browser`; off a terminal or with `--json` it never does, marks the report as agent-written, and returns the title, body, and labels for `gh issue create`. After a panic, the CLI prints the same kind of link with the source line and a scrubbed message. ## 0.2.0 - 2026-10-22 diff --git a/README.md b/README.md index 1bdbf66..d8cfbc2 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Every command also has `--help`. [The docs index](docs/README.md) lists everythi ## Contributing -Report security issues as described in [SECURITY.md](SECURITY.md). Build, test, and release instructions are in [the development guide](docs/development.md). Code conventions are in [the style guide](docs/quality.md). +Report security issues as described in [SECURITY.md](SECURITY.md). Report other problems with `voltage feedback`, which drafts a GitHub issue; see [report a problem](docs/guides/troubleshooting.md#report-a-problem). Build, test, and release instructions are in [the development guide](docs/development.md). Code conventions are in [the style guide](docs/quality.md). ## License diff --git a/docs/README.md b/docs/README.md index e8f3623..ef54c0c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ Start with the [README](../README.md) for install and a quick start, or follow [ - [Requests](guides/requests.md): request bodies, friendly flags, filters, paging - [Webhooks](guides/webhooks.md): create with a saved secret, events, deliveries, key rotation - [Checkout](guides/checkout.md): session and stream tokens, events -- [Troubleshooting](guides/troubleshooting.md): what each exit code and error means, and the fix +- [Troubleshooting](guides/troubleshooting.md): what each exit code and error means, and the fix, and how to report a problem - [Scripting](guides/scripting.md): JSON, `VOLTAGE_EXECUTE`, running without a person ## Look it up diff --git a/docs/commands.md b/docs/commands.md index 69fffd8..fe35ea1 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -20,6 +20,7 @@ These commands use the auth service (`https://auth.voltage.cloud/api/v1`, overri | `voltage profiles list` | local only | | `voltage profiles delete NAME` | local only | | `voltage completions SHELL` | local only | +| `voltage feedback` | local only | - Every organization API call first exchanges the saved login for an organization token with `POST /api/v1/oauth/token`, and an expiring login is refreshed there as well. - A failed login revokes its new session with `POST /api/v1/oauth/revoke` so nothing is left running on the server. diff --git a/docs/guides/scripting.md b/docs/guides/scripting.md index 9bace58..c848ab0 100644 --- a/docs/guides/scripting.md +++ b/docs/guides/scripting.md @@ -23,5 +23,6 @@ VOLTAGE_EXECUTE=1 ./pay-invoices.sh - `-q, --quiet` suppresses optional status and notices, never requested results, errors, warnings, or recovery IDs. - Diagnostics, prompts, and progress go to stderr, so stdout carries only results. - Branch on [exit codes](../reference/exit-codes.md). Exit code 4 means the outcome is unknown: check the payment ID before you retry. +- An agent that finds a bug in the CLI can draft an issue with `voltage feedback --json`; see [report a problem](troubleshooting.md#report-a-problem). Related: [safety model](../concepts/safety-model.md), [output reference](../reference/output.md). diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index cc6a5de..69e294b 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -112,4 +112,18 @@ The CLI refuses a USD payment without `--quote` itself, with `A USD payment need When the detail names a quoted amount, the quote does not convert to exactly the payment's amount. Check what it converts to with `voltage quotes get QUOTE_ID`. To pay a BTC invoice from a USD wallet, quote the invoice's BTC amount with `--unit sats --to usd`. See [quotes and lines of credit](payments.md#quotes-and-lines-of-credit). +## Report a problem + +When the CLI does something wrong, `voltage feedback` prints a link that opens a new GitHub issue with the build, the platform, and a form for what happened and how to reproduce it. On a terminal it opens the link in your browser instead of printing it; `--no-browser` prints it. Name the command that misbehaved after `--`: the report keeps its command words and flag names, never their values. + +```sh +voltage feedback --title Balance-looks-wrong -- wallets get WALLET_ID --json +``` + +Search the existing issues first; the command prints that link too, and you can add to a matching issue instead of opening another. Before you submit, replace IDs, invoices, addresses, and keys with placeholders such as `WALLET_ID`. + +Off a terminal, or with `--json`, the command never opens a browser and marks the report as written by an AI agent. `--json` returns the issue's `title`, `labels`, `body`, `url`, and `search_url`, so an agent can search with `search_url` and then file it with `gh issue create --title TITLE --body BODY`, or give the `url` to a person. Leave the agent box checked when an agent wrote the report. + +If the CLI itself fails unexpectedly (a panic, exit code 101), it prints the same kind of link after the panic message, with the source line and a scrubbed message filled in. Scrubbing removes words that look like IDs, keys, tokens, invoices, addresses, emails, or URLs. + Related: [exit codes](../reference/exit-codes.md), [environment variables](../reference/environment.md), [log in and use profiles](login-and-profiles.md). diff --git a/docs/quality.md b/docs/quality.md index ae4c92a..ee04629 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -58,7 +58,8 @@ not precedent. Reviews must approve and document exceptions. `terminal`; the animated status line in `progress`; a payment wait's status lines in `timeline`; and the color palette and symbols in `style`. Keep which commands ask for missing scope, and in what order, in `pick`. Keep the default `--currency` of a payment, read from its - wallet's line of credit, in `wallet_currency`. Keep exit-code categories and + wallet's line of credit, in `wallet_currency`. Keep prefilled GitHub issue reports, their + scrubbing, and the panic hook that prints one in `feedback`. Keep exit-code categories and typed error detail in `error`. - Name each module for the operation that it performs. Do not give two modules the same capability name. For example, keep private-file mechanics in diff --git a/scripts/check-coverage.py b/scripts/check-coverage.py index c321b5a..2dc8ee6 100644 --- a/scripts/check-coverage.py +++ b/scripts/check-coverage.py @@ -27,6 +27,7 @@ ('voltage profiles list', []), ('voltage profiles delete NAME', []), ('voltage completions SHELL', []), + ('voltage feedback', []), ] # Commands that use the coinprice service, with the routes they call. PRICE_COMMANDS = [ diff --git a/src/cli.rs b/src/cli.rs index d3899d4..4ed622b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -216,6 +216,8 @@ pub enum LocalCommand { Price(PriceFlags), /// Convert an amount between BTC and USD at the current or a past price Convert(ConvertFlags), + /// Print a link that files a GitHub issue with this build and platform filled in + Feedback(FeedbackFlags), /// Print shell completions Completions { #[arg(value_enum)] @@ -230,6 +232,30 @@ pub struct PriceFlags { pub at: Option, } +#[derive(Debug, Args)] +pub struct FeedbackFlags { + /// Issue title: what went wrong, in one line + #[arg(long)] + pub title: Option, + /// Print the link without opening it + #[arg(long)] + pub no_browser: bool, + /// The command that misbehaved, after `--`; the report keeps only its command words and + /// flag names, never their values + #[arg(last = true, value_name = "COMMAND")] + pub command: Vec, +} + +impl FeedbackFlags { + /// The misbehaving command as the report shows it, when one was given. + pub fn command_outline(&self) -> Option { + let args: Vec = std::iter::once(OsString::from("voltage")) + .chain(self.command.iter().cloned()) + .collect(); + (!self.command.is_empty()).then(|| outline(&args)) + } +} + #[derive(Debug, Args)] pub struct ConvertFlags { /// Exact decimal amount @@ -776,6 +802,34 @@ where .any(|arg| arg.as_ref() == OsStr::new("--json")) } +/// A command line as an issue report shows it: `voltage`, the subcommand words, and each +/// flag's name, without positional values or flag values, which can hold IDs and secrets. +/// It reads the command tree, not a parse, so it works on a line that does not parse. +pub fn outline(args: &[OsString]) -> String { + let root = command(); + let mut current = &root; + let mut words = vec!["voltage".to_owned()]; + for arg in args.iter().skip(1).map(|arg| arg.to_string_lossy()) { + if arg == "--" { + break; + } + if let Some(long) = arg.strip_prefix("--") { + words.push(format!("--{}", long.split('=').next().unwrap_or_default())); + } else if let Some(short) = arg + .strip_prefix('-') + .and_then(|rest| rest.chars().next()) + .filter(char::is_ascii_alphabetic) + { + // `-ojson` keeps only `-o`; `-5` is a value, not a flag. + words.push(format!("-{short}")); + } else if let Some(sub) = current.find_subcommand(arg.as_ref()) { + words.push(sub.get_name().to_owned()); + current = sub; + } + } + words.join(" ") +} + fn parse(matches: &ArgMatches) -> Result { T::from_arg_matches(matches).map_err(|error| Error::usage(error.to_string())) } @@ -1316,6 +1370,7 @@ fn guide(path: &[&str]) -> &'static str { .., ] => "guides/payments.md", ["completions", ..] => "guides/install.md", + ["feedback", ..] => "guides/troubleshooting.md", _ => "README.md", } } @@ -1346,6 +1401,12 @@ fn local_examples(path: &[&str]) -> Option<&'static str> { ["context"] => { " # Show what a command with this profile would use, and where each value came from voltage context --profile prod" + } + ["feedback"] => { + " # File an issue about a command; the link keeps its flag names, not their values + voltage feedback --title Balance-looks-wrong -- wallets get WALLET_ID --json + # Print the prefilled issue as JSON, for an agent that files it with `gh issue create` + voltage feedback --json" } ["convert"] => { " # What $10 buys in bitcoin now, and 21,000 sats in dollars on a past date @@ -1838,6 +1899,62 @@ mod tests { ); } + #[test] + fn an_outline_keeps_command_words_and_flag_names_but_no_values() { + let args = |words: &[&str]| words.iter().map(OsString::from).collect::>(); + assert_eq!( + outline(&args(&[ + "voltage", + "--profile", + "prod", + "wallets", + "get", + "6f1c2a9e-1b2d-4c3e-8f40-0a1b2c3d4e5f", + "--data=@secret.json", + "-ojson", + "-q", + ])), + "voltage --profile wallets get --data -o -q" + ); + // A negative amount is a value, and nothing after `--` is read. + assert_eq!( + outline(&args(&["voltage", "convert", "-5", "usd", "--", "--to"])), + "voltage convert" + ); + // A line that does not parse still outlines. + assert_eq!( + outline(&args(&["voltage", "walets", "--bogus"])), + "voltage --bogus" + ); + let feedback = |words: &[&str]| { + let Command::Local(LocalCommand::Feedback(flags)) = + Invocation::try_parse_from(words).unwrap().command + else { + panic!("expected feedback"); + }; + flags + }; + let flags = feedback(&[ + "voltage", + "feedback", + "--title", + "Broken", + "--no-browser", + "--", + "payments", + "send", + "lnbc1secret", + "--execute", + ]); + assert_eq!(flags.title.as_deref(), Some("Broken")); + assert!(flags.no_browser); + assert_eq!( + flags.command_outline().as_deref(), + Some("voltage payments send --execute") + ); + assert_eq!(feedback(&["voltage", "feedback"]).command_outline(), None); + } + #[test] fn local_commands_parse_into_their_variants() { let login = Invocation::try_parse_from([ diff --git a/src/feedback.rs b/src/feedback.rs new file mode 100644 index 0000000..473a519 --- /dev/null +++ b/src/feedback.rs @@ -0,0 +1,522 @@ +//! Prefilled GitHub issue links, for `voltage feedback` and after a panic. +//! +//! A report carries only what helps triage and cannot identify an account: the build, the +//! platform, the command's words and flag names (never flag values), and a scrubbed panic. +//! Scrubbing replaces any word that looks like an ID, key, token, invoice, address, email, or +//! URL, so text that echoes the API or a flag value cannot leak a secret into a public issue. + +use crate::{ + cli::{FeedbackFlags, outline}, + output::REDACTED, + style::{Line, Role}, + terminal::scrub_line, +}; +use serde::Serialize; +use std::{ + io::IsTerminal, + panic::{Location, PanicHookInfo}, + path::Path, +}; +use url::form_urlencoded; + +const REPOSITORY: &str = "https://github.com/voltagecloud/voltage-cli"; +/// GitHub answers longer `issues/new` URLs with an error, and terminals wrap them badly. +const MAX_URL: usize = 6000; +const MAX_TITLE: usize = 120; +const MAX_PROBLEM: usize = 500; +const MAX_COMMAND: usize = 300; +/// A shorter word with a digit is a count, a status, or a version, not a credential. +const MIN_SECRET_WITH_DIGIT: usize = 16; +/// A word this long is redacted even without a digit, such as a hex or base64 secret. +const MIN_SECRET: usize = 32; + +/// Who is filing. Only a person at a terminal is a person; a process without a terminal or +/// one that asked for JSON is almost always an agent or a script, so its report says so. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Reporter { + Person, + Agent, +} + +impl Reporter { + pub fn detect(interactive: bool) -> Self { + if interactive { + Self::Person + } else { + Self::Agent + } + } +} + +/// `voltage feedback`'s result: the prefilled issue, and whether a browser opened it. +#[derive(Debug, Serialize)] +pub struct Feedback { + #[serde(flatten)] + pub report: IssueReport, + pub opened: bool, +} + +/// The report for `voltage feedback`. Only a person at a terminal who did not pass +/// `--no-browser` gets a browser; an agent or script gets the link alone. +pub fn run(flags: &FeedbackFlags, interactive: bool) -> Feedback { + let report = Draft { + command: flags.command_outline(), + title: flags.title.clone(), + panic: None, + reporter: Reporter::detect(interactive), + } + .into_report(); + let opened = interactive && !flags.no_browser && webbrowser::open(&report.url).is_ok(); + Feedback { report, opened } +} + +impl Feedback { + /// On a terminal, what a person reads instead of the report's fields: where to search + /// first, then the link that files the issue. The prefilled link wraps across many lines, + /// so it is printed only when no browser opened it. + pub fn lines(&self, step: &'static str) -> Vec { + let file = Line::default().paint(Role::Hint, step); + let file = if self.opened { + file.text(" Opened the prefilled issue in your browser") + } else { + file.text(" File the issue: ").link(&self.report.url) + }; + vec![ + Line::default() + .paint(Role::Hint, step) + .text(" Search existing issues first: ") + .link(&self.report.search_url), + file, + ] + } +} + +/// What a report says before it becomes a link. +#[derive(Debug)] +pub struct Draft { + /// The command's words and flag names, such as `voltage wallets get --json`. + pub command: Option, + pub title: Option, + pub panic: Option, + pub reporter: Reporter, +} + +/// Where the CLI panicked, and its message, which is scrubbed when the report is built. +#[derive(Debug)] +pub struct Panic { + /// `file:line` in the CLI's source, or a dependency's file name. + pub at: Option, + pub message: String, +} + +impl Panic { + /// The CLI's own files are relative paths. A dependency's is absolute and can name the + /// builder's home directory, so only its file name is kept. + fn new(location: Option<&Location<'_>>, message: &str) -> Self { + Self { + at: location + .map(|location| format!("{}:{}", source_file(location.file()), location.line())), + message: message.to_owned(), + } + } + + fn of(info: &PanicHookInfo<'_>) -> Self { + Self::new(info.location(), info.payload_as_str().unwrap_or_default()) + } + + fn describe(&self) -> String { + let message = scrub(&self.message, MAX_PROBLEM); + match &self.at { + Some(at) => format!("voltage panicked at {at}: {message}"), + None => format!("voltage panicked: {message}"), + } + } +} + +/// A prefilled issue: the link opens GitHub's new-issue form with these fields filled in. +#[derive(Debug, Serialize)] +pub struct IssueReport { + pub title: String, + pub labels: Vec<&'static str>, + pub reporter: Reporter, + pub body: String, + pub url: String, + /// Existing issues to search before filing another. + pub search_url: String, +} + +impl Draft { + pub fn into_report(self) -> IssueReport { + let title = self + .title + .as_deref() + .map(|title| scrub(title, MAX_TITLE)) + .filter(|title| !title.trim().is_empty()) + .unwrap_or_else(|| "Describe the problem in one line".into()); + let labels = vec!["bug"]; + let search_url = format!("{REPOSITORY}/issues?q=is%3Aissue"); + let mut body = self.body(&search_url); + let mut url = new_issue_url(&title, &body, &labels); + // Every part is already capped, so this only trims a report that is still too long. + while url.len() > MAX_URL { + let keep = body.chars().count() * 9 / 10; + body = format!("{}…", body.chars().take(keep).collect::()); + url = new_issue_url(&title, &body, &labels); + } + IssueReport { + title, + labels, + reporter: self.reporter, + body, + url, + search_url, + } + } + + fn body(&self, search_url: &str) -> String { + let problem = self.panic.as_ref().map_or_else( + || "What you expected, and what happened instead.".into(), + Panic::describe, + ); + let command = self.command.as_deref().map_or_else( + || "not given".into(), + |command| format!("`{}`", scrub(command, MAX_COMMAND)), + ); + // A person's link is printed on a terminal, so only an agent's report carries the + // instructions a person already read there. + let (agent, preface) = match self.reporter { + Reporter::Agent => ( + "x", + "\n\n", + ), + Reporter::Person => (" ", ""), + }; + format!( + "{preface}### What happened + +{problem} + +### Steps to reproduce + +The commands to run, with placeholders such as `WALLET_ID` for IDs. + +### Environment + +- Version: voltage {version} +- Platform: {os} {arch} +- Command: {command} + +### Checks + +- [ ] I searched existing issues: {search_url} +- [{agent}] An AI agent wrote this report +", + version = env!("VOLTAGE_VERSION"), + os = std::env::consts::OS, + arch = std::env::consts::ARCH, + ) + } +} + +fn new_issue_url(title: &str, body: &str, labels: &[&str]) -> String { + let query = form_urlencoded::Serializer::new(String::new()) + .append_pair("title", title) + .append_pair("body", body) + .append_pair("labels", &labels.join(",")) + .finish(); + format!("{REPOSITORY}/issues/new?{query}") +} + +/// Text for a public issue: one line, no word that could be a secret or identify an account, +/// and at most `limit` characters. +pub fn scrub(text: &str, limit: usize) -> String { + let line = scrub_line(text); + let mut scrubbed = String::with_capacity(line.len()); + let mut word = String::new(); + for c in line.chars() { + if is_word(c) { + word.push(c); + } else { + push_word(&mut scrubbed, &mut word); + scrubbed.push(c); + } + } + push_word(&mut scrubbed, &mut word); + if scrubbed.chars().count() > limit { + scrubbed = scrubbed.chars().take(limit).collect(); + scrubbed.push('…'); + } + scrubbed +} + +/// Characters that IDs, keys, invoices, addresses, emails, and URLs are made of. +fn is_word(c: char) -> bool { + c.is_alphanumeric() || "-_./:+=@%~?&#".contains(c) +} + +fn push_word(scrubbed: &mut String, word: &mut String) { + let length = word.chars().count(); + let secret = word.contains('@') + || word.contains("://") + || length >= MIN_SECRET + || (length >= MIN_SECRET_WITH_DIGIT && word.chars().any(|c| c.is_ascii_digit())); + scrubbed.push_str(if secret { REDACTED } else { word }); + word.clear(); +} + +/// After the default panic report, print a link that files the panic as a bug. The command's +/// words are read when it panics, so a run that never panics never builds the command tree. +pub fn report_panics(reporter: Reporter) { + let default = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + default(info); + let args: Vec<_> = std::env::args_os().collect(); + let report = Draft { + command: Some(outline(&args)), + title: Some("voltage panicked".into()), + panic: Some(Panic::of(info)), + reporter, + } + .into_report(); + eprintln!("This is a bug in voltage. Report it: {}", report.url); + })); +} + +fn source_file(file: &str) -> std::borrow::Cow<'_, str> { + let path = Path::new(file); + if path.is_absolute() { + path.file_name().unwrap_or_default().to_string_lossy() + } else { + file.into() + } +} + +/// A panic's report marks an agent the same way `voltage feedback` does. +pub fn interactive(json: bool) -> bool { + !json && std::io::stdout().is_terminal() && std::io::stdin().is_terminal() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn query(url: &str) -> HashMap { + let url = url::Url::parse(url).unwrap(); + url.query_pairs().into_owned().collect() + } + + fn draft(message: Option<&str>, reporter: Reporter) -> Draft { + Draft { + command: Some("voltage wallets get --json".into()), + title: Some("Balance looks wrong".into()), + panic: message.map(|message| Panic::new(None, message)), + reporter, + } + } + + #[test] + fn a_report_links_to_a_prefilled_new_issue() { + let report = draft(Some("index out of bounds"), Reporter::Person).into_report(); + assert!( + report + .url + .starts_with("https://github.com/voltagecloud/voltage-cli/issues/new?") + ); + let fields = query(&report.url); + assert_eq!(fields["title"], "Balance looks wrong"); + assert_eq!(fields["labels"], "bug"); + assert_eq!(fields["body"], report.body); + for expected in [ + "voltage panicked: index out of bounds", + concat!("- Version: voltage ", env!("VOLTAGE_VERSION")), + &format!( + "- Platform: {} {}", + std::env::consts::OS, + std::env::consts::ARCH + ), + "- Command: `voltage wallets get --json`", + "- [ ] I searched existing issues: https://github.com/voltagecloud/voltage-cli/issues?q=is%3Aissue", + "- [ ] An AI agent wrote this report", + ] { + assert!( + report.body.contains(expected), + "{expected}\n{}", + report.body + ); + } + // A person read the instructions on the terminal, so the link leaves them out. + assert!(!report.body.contains("