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;