From 3e6a8e72a3001b65f608debc21c04ca079e4a9d0 Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:03:41 -0500 Subject: [PATCH] feat(cli): show examples and a guide link in every command's help Every command that changes something now has examples in --help, each under a # comment that says what it does, as do login, auth import-key, profiles create, context, convert, payments get --wait, and checkout events watch. -h stays short: usage and options. --help adds the notes (request body, friendly flags, scope, feature requirement, examples) and a Learn more link to the command's guide in the docs of the running release. The root help lists common commands and links the documentation. Tests require an example for every change command and every alias, a Learn more link on every command that names an existing docs page, and no notes under -h. --- build.rs | 50 ++++-- docs/guides/requests.md | 2 +- docs/guides/wallets.md | 4 +- src/cli.rs | 328 +++++++++++++++++++++++++++++++++++++--- 4 files changed, 346 insertions(+), 38 deletions(-) diff --git a/build.rs b/build.rs index a39773e..b6cb3c8 100644 --- a/build.rs +++ b/build.rs @@ -5,7 +5,8 @@ //! operation without a mapping, a mapping without an operation, or a mapping whose target is //! not a placeholder in the operation's path, so the two files cannot drift apart silently. //! -//! It also stamps `voltage --version` with the git build it came from. +//! It also stamps `voltage --version` with the git build it came from, and picks the docs +//! ref that help links to. use serde_json::{Value, json}; use std::{ @@ -134,30 +135,45 @@ fn operation_record( }) } -/// The Cargo version, then the git build it came from. A clean build of its own `vX.Y.Z` -/// tag shows the commit and date, as `rustc --version` does. Any other build shows -/// `git describe`, such as `v0.1.0-14-g0e6078b-dirty`, or a bare commit when no tag is -/// reachable, so it cannot be mistaken for the release. Without a git checkout the version -/// stands alone and the build still succeeds. -fn version() -> String { +/// What `voltage --version` prints and which docs its help links to. +struct Build { + /// The Cargo version, then the git build it came from. A clean build of its own + /// `vX.Y.Z` tag shows the commit and date, as `rustc --version` does. Any other build + /// shows `git describe`, such as `v0.1.0-14-g0e6078b-dirty`, or a bare commit when no + /// tag is reachable, so it cannot be mistaken for the release. Without a git checkout + /// the version stands alone and the build still succeeds. + version: String, + /// The git ref of the docs that match this binary: the release tag for a clean build + /// of it, else `master`, because an unreleased page exists only there. + docs_ref: String, +} + +fn build() -> Build { let package = env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION"); + let tag = format!("v{package}"); + let unreleased = |version| Build { + version, + docs_ref: "master".to_owned(), + }; // A non-empty prefix means this package is a subdirectory of some other repository. if git(&["rev-parse", "--show-prefix"]).is_some_and(|prefix| !prefix.is_empty()) { - return package; + return unreleased(package); } watch_git_state(); let describe = [ "describe", "--tags", "--match", "v[0-9]*", "--dirty", "--always", ]; let Some(build) = git(&describe) else { - return package; + return unreleased(package); }; - let build = if build == format!("v{package}") { - git(&["log", "-1", "--format=%h %cs"]).unwrap_or(build) - } else { - build - }; - format!("{package} ({build})") + if build != tag { + return unreleased(format!("{package} ({build})")); + } + let commit = git(&["log", "-1", "--format=%h %cs"]).unwrap_or(build); + Build { + version: format!("{package} ({commit})"), + docs_ref: tag, + } } /// Trimmed stdout of a successful, non-empty git command. Optional locks are off so that @@ -199,7 +215,9 @@ fn watch_git_state() { } fn main() { - println!("cargo:rustc-env=VOLTAGE_VERSION={}", version()); + let build = build(); + println!("cargo:rustc-env=VOLTAGE_VERSION={}", build.version); + println!("cargo:rustc-env=VOLTAGE_DOCS_REF={}", build.docs_ref); println!("cargo:rerun-if-changed={SNAPSHOT}"); println!("cargo:rerun-if-changed={MAPPING}"); let spec = read_json(SNAPSHOT); diff --git a/docs/guides/requests.md b/docs/guides/requests.md index e4bec17..69ea8e8 100644 --- a/docs/guides/requests.md +++ b/docs/guides/requests.md @@ -1,6 +1,6 @@ # Requests -Commands mirror the API contract; see [the command reference](../commands.md) for the full list with routes. Every command and group has descriptive `--help`; body-building commands also show required flag combinations and examples: +Commands mirror the API contract; see [the command reference](../commands.md) for the full list with routes. `-h` shows a command's usage and options. `--help` adds its notes: required flag combinations, scope, examples for every command that changes something, and a "Learn more" link to the guide for the release you run: ```sh voltage --help diff --git a/docs/guides/wallets.md b/docs/guides/wallets.md index 6db1781..0ce117e 100644 --- a/docs/guides/wallets.md +++ b/docs/guides/wallets.md @@ -32,11 +32,11 @@ The ledger lists every credit, hold, capture, and release on the wallet. `--all` ```sh voltage wallets update WALLET_ID --profile prod --name operations --execute -voltage wallets policies get WALLET_ID --profile prod +voltage wallets policies get WALLET_ID --profile prod --json | jq .data > policies.json voltage wallets policies update WALLET_ID --profile prod --data @policies.json --execute ``` -Policies have no friendly flags: read the current policies, edit the JSON, and send the complete object with `--data`. Deleting a wallet cannot be undone, so check the request with a dry run first: +Policies have no friendly flags: save the current policies, edit the file, and send the complete object with `--data`. Save only `.data`: `--json` and `--output-file` wrap the response in an envelope, which is not a policy body. Deleting a wallet cannot be undone, so check the request with a dry run first: ```sh voltage wallets delete WALLET_ID --profile prod --dry-run diff --git a/src/cli.rs b/src/cli.rs index 5658b27..c740119 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -800,6 +800,25 @@ fn takes_origin(operation: &Operation) -> bool { operation.auth.is_checkout() || operation.id == OperationId::CreateEventStreamToken } +/// The git ref of the docs that match this binary: its release tag for a clean release +/// build, else `master`. `build.rs` decides, with the same test as `--version`. +const DOCS_REF: &str = env!("VOLTAGE_DOCS_REF"); + +/// The root help's common commands and where the documentation starts. +const ROOT_HELP: &str = concat!( + "Common commands: + voltage login Sign in through your browser + voltage context Show the profile, scope, and credential in use + voltage payments receive --help Create an invoice or address to get paid + voltage payments send --help Pay an invoice or address + voltage webhooks create --help Send payment events to your server + +Changes are only described until you add --execute. +Documentation: https://github.com/voltagecloud/voltage-cli/tree/", + env!("VOLTAGE_DOCS_REF"), + "/docs" +); + /// The complete command tree, for parsing, help, and completions. pub fn command() -> ClapCommand { let root = ClapCommand::new("voltage") @@ -807,9 +826,18 @@ pub fn command() -> ClapCommand { .subcommand_required(true) .arg_required_else_help(true); let root = GlobalFlags::augment_args(root); - let root = LocalCommand::augment_subcommands(root); + let mut root = LocalCommand::augment_subcommands(root); + for name in root + .get_subcommands() + .map(|sub| sub.get_name().to_owned()) + .collect::>() + { + root = root.mut_subcommand(&name, |sub| describe_local(sub, &mut vec![name.clone()])); + } // Derived types apply their own doc comments as `about`, so the command's text goes last. - add_children(root, &[]).about("Manage wallets, payments, and more with the Voltage API") + add_children(root, &[]) + .about("Manage wallets, payments, and more with the Voltage API") + .after_help(ROOT_HELP) } /// Add every registry command below `prefix`, recursing through shared prefixes. @@ -837,7 +865,10 @@ fn add_children(mut parent: ClapCommand, prefix: &[String]) -> ClapCommand { .arg_required_else_help(true), &path, ) - .about(group_about(&path)), + .about(group_about(&path)) + .after_long_help(learn_more( + &path.iter().map(String::as_str).collect::>(), + )), }; parent = parent.subcommand(cmd); } @@ -883,9 +914,15 @@ fn endpoint(mut cmd: ClapCommand, operation: &Operation, about: String) -> ClapC feature.as_str() )); } - if !notes.is_empty() { - cmd = cmd.after_help(notes.join("\n\n")); - } + notes.push(learn_more( + &operation + .command + .iter() + .map(String::as_str) + .collect::>(), + )); + // `-h` stays short; `--help` adds the notes. + cmd = cmd.after_long_help(notes.join("\n\n")); if let Some(target) = operation.target { cmd = TargetFlags::augment_args(cmd) .mut_arg("resource_id", |arg| arg.value_name(target.value_name())); @@ -976,22 +1013,198 @@ fn friendly_requirements(id: OperationId, command_name: &str) -> Option<&'static } } -/// Focused examples for the commands where friendly-body combinations are least obvious. +/// Examples for every command that changes something, plus the reads whose flags are least +/// obvious. Each `# comment` line says what the command under it does. fn endpoint_examples(id: OperationId, command_name: &str) -> Option<&'static str> { - match (id, command_name) { - (OperationId::CreatePayment, "send") => Some( - " voltage payments send --profile prod --wallet WALLET_ID --currency btc --invoice BOLT11_INVOICE --max-fee 10 --fee-unit sats --execute\n voltage payments send --profile prod --wallet WALLET_ID --currency btc --address BITCOIN_ADDRESS --amount 1000 --unit sats --execute", - ), - (OperationId::CreatePayment, "receive") => Some( - " voltage payments receive --profile prod --wallet WALLET_ID --currency btc --kind bolt11 --amount 1000 --unit sats --wait ready --execute", - ), - (OperationId::CreateWallet, "create") => Some( - " voltage wallets create --profile prod --name treasury --credit-line CREDIT_LINE_ID --network mutinynet --limit 100000 --execute", - ), - _ => None, + Some(match (id, command_name) { + (OperationId::CreatePayment, "send") => { + " # Pay a Lightning invoice, paying at most 10 sats in fees + voltage payments send --profile prod --wallet WALLET_ID --currency btc --invoice BOLT11_INVOICE --max-fee 10 --fee-unit sats --execute + # Send 1,000 sats on-chain + voltage payments send --profile prod --wallet WALLET_ID --currency btc --address BITCOIN_ADDRESS --amount 1000 --unit sats --execute" + } + (OperationId::CreatePayment, "receive") => { + " # Create a 1,000 sat Lightning invoice and print it when it is ready + voltage payments receive --profile prod --wallet WALLET_ID --currency btc --kind bolt11 --amount 1000 --unit sats --wait ready --execute + # Show the invoice as a QR code and wait until it is paid + voltage payments receive --profile prod --wallet WALLET_ID --currency btc --kind bolt11 --amount 1000 --unit sats --qr --wait completed --execute" + } + (OperationId::CreatePayment, _) => { + " # Send a complete payment request body from a file + voltage payments create --profile prod --data @payment.json --execute" + } + (OperationId::GetPayment, _) => { + " # Wait up to two minutes for a payment to settle + voltage payments get PAYMENT_ID --profile prod --wait completed --timeout 120" + } + (OperationId::CheckPayment, _) => { + " # Check whether a payment would be accepted, without creating a payment + voltage payments check --profile prod --data @payment.json --execute" + } + (OperationId::CreateTreasuryMovement, _) => { + " # Move funds between treasury accounts + voltage treasury movements create --profile prod --data @movement.json --execute" + } + (OperationId::CreateWallet, _) => { + " # Create a wallet on a line of credit with a limit of 100,000 in its base units + voltage wallets create --profile prod --name treasury --credit-line CREDIT_LINE_ID --network mutinynet --limit 100000 --execute" + } + (OperationId::UpdateWallet, _) => { + " # Rename a wallet + voltage wallets update WALLET_ID --profile prod --name operations --execute" + } + (OperationId::DeleteWallet, _) => { + " # Check the request, then delete the wallet + voltage wallets delete WALLET_ID --profile prod --dry-run + voltage wallets delete WALLET_ID --profile prod --execute" + } + (OperationId::UpdateWalletPolicies, _) => { + " # Save only the current policies, not the output envelope, edit them, and send them back + voltage wallets policies get WALLET_ID --profile prod --json | jq .data > policies.json + voltage wallets policies update WALLET_ID --profile prod --data @policies.json --execute" + } + (OperationId::UpdateSandboxLineOfCredit, _) => { + " # Change a sandbox line of credit + voltage credit-lines update-sandbox CREDIT_LINE_ID --profile prod --data @sandbox.json --execute" + } + (OperationId::CreateWebhook, _) => { + " # Send completed receives to your URL; the one-time secret is saved to a private file + voltage webhooks create --profile prod --name orders --url https://example.com/voltage --event receive.completed --output-file webhook.json --execute" + } + (OperationId::UpdateWebhook, _) => { + " # Replace the event selection; list every event you still want + voltage webhooks update WEBHOOK_ID --profile prod --event receive.completed --event send.failed --execute" + } + (OperationId::DeleteWebhook, _) => { + " # Check the request, then delete the webhook + voltage webhooks delete WEBHOOK_ID --profile prod --dry-run + voltage webhooks delete WEBHOOK_ID --profile prod --execute" + } + (OperationId::GenerateWebhookKey, _) => { + " # Rotate the shared secret and save the new one to a private file + voltage webhooks keys rotate WEBHOOK_ID --profile prod --output-file webhook-key.json --execute" + } + (OperationId::TestWebhook, _) => { + " # Send a test delivery with a delivery_id and a sample payload + voltage webhooks test WEBHOOK_ID --profile prod --data @test-delivery.json --execute" + } + (OperationId::StartWebhookAsUser, _) => { + " # Resume deliveries + voltage webhooks start WEBHOOK_ID --profile prod --execute" + } + (OperationId::StopWebhookAsUser, _) => { + " # Pause deliveries + voltage webhooks stop WEBHOOK_ID --profile prod --execute" + } + (OperationId::RetryWebhookDelivery, _) => { + " # Deliver a failed event again + voltage webhooks deliveries retry DELIVERY_ID --profile prod --webhook WEBHOOK_ID --execute" + } + (OperationId::AbandonWebhookDelivery, _) => { + " # Stop retrying a delivery + voltage webhooks deliveries abandon DELIVERY_ID --profile prod --webhook WEBHOOK_ID --execute" + } + (OperationId::RequestAQuote, _) => { + " # Quote $10 in BTC for a USD payment on a line of credit + voltage quotes create --profile prod --credit-line CREDIT_LINE_ID --network mutinynet --amount 10 --unit usd --to btc --execute" + } + (OperationId::CreateSession, _) => { + " # Create a hosted checkout session; its one-time token is saved to a private file + voltage checkout sessions create --profile prod --data @session-request.json --output-file session.json --execute" + } + (OperationId::CreateEventStreamToken, _) => { + " # Create a checkout event stream token and save it to a private file + voltage checkout streams create --profile prod --data @stream.json --output-file stream-token.json --execute" + } + (OperationId::UpdateSettings, _) => { + " # Replace the environment's checkout settings + voltage checkout settings update --profile prod --data @settings.json --execute" + } + (OperationId::GetEvents, _) => { + " # Follow checkout events with a stream token read from stdin + voltage checkout events watch --token-file - --json" + } + _ => return None, + }) +} + +/// Where each part of the CLI is explained, under `docs/`. +fn guide(path: &[&str]) -> &'static str { + match path { + [ + "login" | "logout" | "auth" | "organizations" | "environments" | "profiles" | "context", + .., + ] => "guides/login-and-profiles.md", + ["wallets", ..] => "guides/wallets.md", + ["webhooks", ..] => "guides/webhooks.md", + ["checkout", ..] => "guides/checkout.md", + [ + "payments" | "quotes" | "credit-lines" | "treasury" | "price" | "convert", + .., + ] => "guides/payments.md", + ["completions", ..] => "guides/install.md", + _ => "README.md", } } +/// The guide for `path` in the docs that match this binary; see [`DOCS_REF`]. +fn learn_more(path: &[&str]) -> String { + format!( + "Learn more: https://github.com/voltagecloud/voltage-cli/blob/{DOCS_REF}/docs/{}", + guide(path) + ) +} + +/// Examples for commands the CLI defines itself rather than the API contract. +fn local_examples(path: &[&str]) -> Option<&'static str> { + Some(match path { + ["login"] => { + " # Sign in through your browser and save the login as `work` + voltage login --account work" + } + ["auth", "import-key"] => { + " # Save an environment API key read from stdin + voltage auth import-key --account ci --stdin < key.txt" + } + ["profiles", "create"] => { + " # Save an organization, environment, and credential as `prod` + voltage profiles create prod --org ORG_ID --env ENV_ID --account work" + } + ["context"] => { + " # Show what a command with this profile would use, and where each value came from + voltage context --profile prod" + } + ["convert"] => { + " # What $10 buys in bitcoin now, and 21,000 sats in dollars on a past date + voltage convert 10 usd --to btc + voltage convert 21000 sats --to usd --at 2026-09-01T00:00:00Z" + } + _ => return None, + }) +} + +/// Add `Examples:` and `Learn more:` to the long help of the CLI's own commands, which +/// clap derives without them. +fn describe_local(mut cmd: ClapCommand, path: &mut Vec) -> ClapCommand { + let words: Vec<&str> = path.iter().map(String::as_str).collect(); + let mut notes = Vec::new(); + if let Some(examples) = local_examples(&words) { + notes.push(format!("Examples:\n{examples}")); + } + notes.push(learn_more(&words)); + cmd = cmd.after_long_help(notes.join("\n\n")); + for name in cmd + .get_subcommands() + .map(|sub| sub.get_name().to_owned()) + .collect::>() + { + path.push(name.clone()); + cmd = cmd.mut_subcommand(&name, |sub| describe_local(sub, path)); + path.pop(); + } + cmd +} + /// The generated flag for one documented filter, typed by the contract's schema. fn filter_arg(parameter: &Parameter) -> Arg { let flag = parameter.flag(); @@ -1461,7 +1674,15 @@ mod tests { .lines() .skip(1) .map(str::trim) - .take_while(|line| line.starts_with("voltage ")) + .take_while(|line| !line.is_empty()) + .filter(|line| !line.starts_with('#')) + // Keep the command, not a redirection of its input or output. + .map(|line| { + line.split(['<', '>', '|']) + .next() + .unwrap_or_default() + .trim() + }) .map(str::to_owned), ); } @@ -1470,6 +1691,75 @@ mod tests { } } + /// Every command below `cmd`, by path, with its rendered long help. + fn long_helps(cmd: &mut ClapCommand, path: &mut Vec, out: &mut Vec<(String, String)>) { + out.push((path.join(" "), cmd.render_long_help().to_string())); + for sub in cmd.get_subcommands_mut() { + path.push(sub.get_name().to_owned()); + long_helps(sub, path, out); + path.pop(); + } + } + + #[test] + fn every_change_has_an_example_and_every_command_links_its_guide() { + let mut root = command(); + root.build(); + let mut helps = Vec::new(); + long_helps(&mut root, &mut vec!["voltage".to_owned()], &mut helps); + let help = |path: &str| { + helps + .iter() + .find(|(name, _)| name == path) + .map(|(_, help)| help.as_str()) + .unwrap_or_else(|| panic!("no command {path}")) + }; + let changes = OPERATIONS + .iter() + .filter(|operation| operation.method != Method::Get) + .map(|operation| format!("voltage {}", operation.command.join(" "))) + .chain( + PAYMENT_ALIASES + .iter() + .map(|(name, _)| format!("voltage payments {name}")), + ); + for path in changes { + assert!( + help(&path).contains("Examples:\n #"), + "{path} has no example" + ); + } + let root_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + // One docs ref for every link: the release tag of a release build, else master. + assert!( + DOCS_REF == "master" || DOCS_REF == format!("v{}", env!("CARGO_PKG_VERSION")), + "{DOCS_REF}" + ); + assert!(help("voltage").contains(&format!( + "Documentation: https://github.com/voltagecloud/voltage-cli/tree/{DOCS_REF}/docs" + ))); + let prefix = format!( + "Learn more: https://github.com/voltagecloud/voltage-cli/blob/{DOCS_REF}/docs/" + ); + for (path, help) in helps.iter().filter(|(path, _)| path != "voltage") { + // Clap's generated `help` subcommands print other commands' help. + if path.split(' ').any(|word| word == "help") { + continue; + } + let page = help + .lines() + .find_map(|line| line.strip_prefix(&prefix)) + .unwrap_or_else(|| panic!("{path} has no Learn more link")); + assert!(root_dir.join("docs").join(page).is_file(), "{path}: {page}"); + } + // `-h` stays short: the notes are only in `--help`. + let short = command() + .try_get_matches_from(["voltage", "payments", "send", "-h"]) + .unwrap_err() + .to_string(); + assert!(!short.contains("Examples:") && !short.contains("Learn more:")); + } + /// A floor below the number of documented examples, so a scanner that stops finding /// them fails the test instead of checking nothing. const MIN_DOC_EXAMPLES: usize = 40;