diff --git a/CHANGELOG.md b/CHANGELOG.md index 0abe222..7bdf8b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - A payment without `--currency` uses its wallet's line of credit currency, and its `--unit` must be in that currency. An amount without `--unit` is in the currency's base unit (msats or cents), except on a USD wallet read this way, which needs `--unit`. `--max-fee` without `--fee-unit` is in msats. - Every command that changes something has examples in `--help`, and every command links its guide. - Guides for wallets, webhooks, troubleshooting, and getting started, and references for global flags, environment variables, and configuration. +- A command that takes a resource ID as an argument also accepts `--id ID`, so the flag that `quotes create` and `payments send` take works there too. It takes one of the two, and its usage line shows both, as in `voltage quotes get >`. +- In human output, an accepted `payments receive` or `quotes create` names the command that shows the invoice, address, or quote, with the organization, environment, and account that the command used. +- The wallet picker shows a wallet's currency when its balances report one, and typing a currency filters on it. A wallet with no balance yet shows no currency. ### Changed diff --git a/docs/commands.md b/docs/commands.md index a198e28..1093e73 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -35,7 +35,7 @@ These commands use the coinprice service (`https://coinprice.voltage.cloud`, ove ## API operations -IDs are positional; enclosing scope uses flags. Paths are relative to the API base URL (`https://voltageapi.com/v1`, overridable with `--api-url`). +IDs are positional, or passed as `--id ID`; enclosing scope uses flags. Paths are relative to the API base URL (`https://voltageapi.com/v1`, overridable with `--api-url`). | Command | Method | Path | |---|---|---| diff --git a/docs/development.md b/docs/development.md index a26b7be..d7aca10 100644 --- a/docs/development.md +++ b/docs/development.md @@ -126,14 +126,17 @@ build when they disagree. `docs/commands.md` is generated; regenerate it with ## Scripts - `scripts/voltage-local` runs the development binary with isolated credentials, loopback - auth on port 8081, and a disabled API endpoint. `--build` compiles it first. + auth on port 8081, and a disabled API endpoint. A leading `--build` compiles it, then runs + the rest of the arguments, if any: `scripts/voltage-local --build login --account me`. - `scripts/voltage-staging` runs the development binary against staging. It reads the staging endpoints (`VOLTAGE_STAGING_API_URL`, `VOLTAGE_STAGING_AUTH_URL`) and, only for API-key runs, the optional `VOLTAGE_API_KEY`, `VOLTAGE_ORGANIZATION_ID`, `VOLTAGE_ENVIRONMENT_ID`, and `VOLTAGE_WALLET_ID` from the repository's private `.env` (copy `.env.example`, fill it in, `chmod 600 .env`) without evaluating it as shell code, keeps CLI state under `.work/staging/cli`, and refuses `--config-dir`, `--api-url`, and - `--auth-url` so a staging command cannot target another deployment. Browser login and + `--auth-url` so a staging command cannot target another deployment. A leading `--build` + works as it does for `voltage-local`, but checks `.env` before it builds when a command + follows; `--build` alone needs no `.env`. Browser login and profiles need no credential variables. The repository itself names no deployment. Before creating a payment, confirm the wallet's reported network is a test network. - `scripts/package.sh TARGET` builds the release archive for one Rust target and smoke-tests @@ -161,6 +164,9 @@ the same way you would against production. voltage --build ``` + A leading `--build` also runs a command after the build, such as + `voltage --build login --account me --credential-store file`. + 3. Sign in through the browser and discover your organization and environment. These two list commands require a browser login; an API key cannot use them. `--credential-store file` keeps the login under `.work/staging/cli` with the rest of the staging state. diff --git a/docs/guides/login-and-profiles.md b/docs/guides/login-and-profiles.md index 3366199..74a198a 100644 --- a/docs/guides/login-and-profiles.md +++ b/docs/guides/login-and-profiles.md @@ -31,7 +31,7 @@ With several saved credentials, select one with `--account` or through a profile ## Scope -Resource IDs are positional. The enclosing scope comes from `--org`, `--env`, `--wallet`, and `--webhook`: +A resource ID is positional, or passed as `--id ID`, as in `voltage wallets get --id WALLET_ID`. The enclosing scope comes from `--org`, `--env`, `--wallet`, and `--webhook`: ```sh voltage wallets list --org ORG_ID --env ENV_ID @@ -45,14 +45,14 @@ Only endpoints that filter by environment receive `--env`. Organization-wide com ## Pick the scope interactively -On a terminal, a command that lacks the organization, environment, or wallet it needs asks for it instead of failing. The picker lists each choice as `name UUID`; type to filter, use the arrow keys, and press Enter. Afterwards, the CLI prints the flags that select the same scope, so you can pass them next time or save a profile. +On a terminal, a command that lacks the organization, environment, or wallet it needs asks for it instead of failing. The picker lists each choice as `name UUID`, and each wallet as `name CURRENCY UUID` when all of its balances are in one currency. Type to filter, by name or currency, use the arrow keys, and press Enter. Afterwards, the CLI prints the flags that select the same scope, so you can pass them next time or save a profile. ```sh voltage wallets list # asks for the organization voltage payments receive --currency btc --kind bolt11 --amount 1000 --unit sats --execute # asks for the wallet ``` -The picker offers every wallet in the environment. For this example, pick a wallet on a BTC line of credit. A wallet on a USD line of credit needs a quote for every payment, so the API rejects the receive with `quote_required` unless you pass `--quote QUOTE_ID` from `voltage quotes create`. +The picker offers every wallet in the environment and shows a wallet's currency when its balances report one. A wallet with no balance yet shows no currency. For this example, pick a BTC wallet. Typing filters on the whole label, so `btc` matches wallets that show `BTC` and any wallet with `btc` in its name. A wallet on a USD line of credit needs a quote for every payment, so the API rejects the receive with `quote_required` unless you pass `--quote QUOTE_ID` from `voltage quotes create`. An API key cannot list organizations or environments, so with a key the CLI offers only wallets; pass `--org` and `--env` or use a profile. Esc leaves the scope unchanged, and the command then reports what is missing. Ctrl-C exits with code 130. Picking a scope never sends a change: changes still need `--execute`. The CLI never asks when stdin or stderr is not a terminal, when stdout is piped without `--output table` (as in `voltage wallets list | jq`), or with `--no-input`, `--json`, or `--output json|ndjson`. In those cases, the command fails with the same error as before. diff --git a/docs/guides/payments.md b/docs/guides/payments.md index b22d533..90704c8 100644 --- a/docs/guides/payments.md +++ b/docs/guides/payments.md @@ -17,7 +17,7 @@ Run a change without `--execute` to see exactly what it would send, then add `-- ## Wait for a payment -An accepted submission returns immediately with `outcome: accepted`. To wait, add `--wait ready` (the invoice or address exists) or `--wait completed` (the payment settled). Waiting polls the payment, starting quickly and backing off with jitter, and honors the API's retry hints. It tolerates a payment that is not visible yet and stops at `--timeout`, reporting the payment ID with exit code 5 so you can keep watching: +An accepted submission returns immediately with `outcome: accepted`. In human output, an accepted receive or quote then names the command that shows it, such as `voltage payments get PAYMENT_ID`. To wait, add `--wait ready` (the invoice or address exists) or `--wait completed` (the payment settled). Waiting polls the payment, starting quickly and backing off with jitter, and honors the API's retry hints. It tolerates a payment that is not visible yet and stops at `--timeout`, reporting the payment ID with exit code 5 so you can keep watching: ```sh voltage payments get PAYMENT_ID --profile prod --wait completed --timeout 120 diff --git a/scripts/check-coverage.py b/scripts/check-coverage.py index 3d33e70..47f516d 100644 --- a/scripts/check-coverage.py +++ b/scripts/check-coverage.py @@ -102,7 +102,7 @@ def operations_in(spec): lines += [ '## API operations', '', - 'IDs are positional; enclosing scope uses flags. Paths are relative to the API base URL (`https://voltageapi.com/v1`, overridable with `--api-url`).', + 'IDs are positional, or passed as `--id ID`; enclosing scope uses flags. Paths are relative to the API base URL (`https://voltageapi.com/v1`, overridable with `--api-url`).', '', '| Command | Method | Path |', '|---|---|---|', diff --git a/scripts/voltage-local b/scripts/voltage-local index 33ab96a..6d8395a 100755 --- a/scripts/voltage-local +++ b/scripts/voltage-local @@ -9,14 +9,20 @@ import sys root = Path(__file__).resolve().parents[1] env = {key: value for key, value in os.environ.items() if not key.startswith('VOLTAGE_')} args = sys.argv[1:] -if args == ['--build']: - if not shutil.which('cargo') and (root / '.work/cargo/bin/cargo').exists(): - env.update(CARGO_HOME=str(root / '.work/cargo'), RUSTUP_HOME=str(root / '.work/rustup')) - env['PATH'] = str(root / '.work/cargo/bin') + os.pathsep + env['PATH'] - sys.exit(subprocess.call(['cargo', 'build', '--locked'], cwd=root, env=env)) +build = args[:1] == ['--build'] +args = args[1:] if build else args for arg in args: if arg.split('=')[0] in ('--config-dir', '--auth-url', '--api-url'): sys.exit('This local wrapper fixes its endpoints and credential directory; use target/debug/voltage for other configurations.') +# A leading --build builds, then runs the rest of the arguments, if any. +if build: + build_env = dict(env) + if not shutil.which('cargo') and (root / '.work/cargo/bin/cargo').exists(): + build_env.update(CARGO_HOME=str(root / '.work/cargo'), RUSTUP_HOME=str(root / '.work/rustup')) + build_env['PATH'] = str(root / '.work/cargo/bin') + os.pathsep + build_env['PATH'] + status = subprocess.call(['cargo', 'build', '--locked'], cwd=root, env=build_env) + if status or not args: + sys.exit(status) binary = root / 'target/debug/voltage' if not binary.is_file(): sys.exit('Build the development CLI first: ./scripts/voltage-local --build') diff --git a/scripts/voltage-staging b/scripts/voltage-staging index 19d9644..f2ae052 100755 --- a/scripts/voltage-staging +++ b/scripts/voltage-staging @@ -24,13 +24,8 @@ root = Path(__file__).resolve().parents[1] env_path = root / ".env" args = sys.argv[1:] -# Building does not need or expose staging credentials. -if args == ["--build"]: - build_env = {key: value for key, value in os.environ.items() if not key.startswith("VOLTAGE_")} - if not shutil.which("cargo") and (root / ".work/cargo/bin/cargo").exists(): - build_env.update(CARGO_HOME=str(root / ".work/cargo"), RUSTUP_HOME=str(root / ".work/rustup")) - build_env["PATH"] = str(root / ".work/cargo/bin") + os.pathsep + build_env["PATH"] - sys.exit(subprocess.call(["cargo", "build", "--locked"], cwd=root, env=build_env)) +build = args[:1] == ["--build"] +args = args[1:] if build else args for arg in args: if arg.split("=", 1)[0] in ("--config-dir", "--auth-url", "--api-url"): @@ -39,6 +34,24 @@ for arg in args: "use target/debug/voltage for other configurations." ) + +def cargo_build(): + """Build the CLI, exiting with cargo's status on failure. The build sees no VOLTAGE_ variables.""" + build_env = {key: value for key, value in os.environ.items() if not key.startswith("VOLTAGE_")} + if not shutil.which("cargo") and (root / ".work/cargo/bin/cargo").exists(): + build_env.update(CARGO_HOME=str(root / ".work/cargo"), RUSTUP_HOME=str(root / ".work/rustup")) + build_env["PATH"] = str(root / ".work/cargo/bin") + os.pathsep + build_env["PATH"] + status = subprocess.call(["cargo", "build", "--locked"], cwd=root, env=build_env) + if status: + sys.exit(status) + + +# A leading --build builds, then runs the rest of the arguments, if any. --build alone does +# not need .env. With a command, .env is checked first so a bad .env fails before the build. +if build and not args: + cargo_build() + sys.exit(0) + if not env_path.exists(): sys.exit("Missing .env. Copy .env.example to .env and set the staging endpoints.") if env_path.is_symlink() or not env_path.is_file(): @@ -82,6 +95,9 @@ for key in sorted(ENDPOINT_ENV): if not loaded[key].startswith("https://"): sys.exit(f".env variable {key} must be an https:// URL") +if build: + cargo_build() + env = {key: value for key, value in os.environ.items() if not key.startswith("VOLTAGE_")} env.update({key: value for key, value in loaded.items() if key in CLI_ENV}) binary = root / "target/debug/voltage" diff --git a/src/api.rs b/src/api.rs index f8afa94..76f1fa7 100644 --- a/src/api.rs +++ b/src/api.rs @@ -870,12 +870,47 @@ impl Call<'_> { } else { Outcome::Succeeded }; - self.write_pages(response, settled, false).await + let accepted = response.status == 202; + self.write_pages(response, settled, false).await?; + if accepted + && self.out.is_human() + && let Some(step) = next_step(self.invocation, scope, self.request.resource_id) + { + self.api.terminal.notice(step); + } + Ok(()) } } } } +/// How to see what an accepted request creates once it exists: the invoice or address of a +/// receive, or a quote. Their 202 response carries no body. +fn next_step(invocation: &ApiInvocation, scope: &Scope, id: Option) -> Option { + let id = id?; + let scope = scope_flags(scope); + if invocation.alias == Some(PaymentDirection::Receive) { + return Some(format!( + "hint: Show the invoice or address with `voltage payments get {id}{scope}`, or pass --wait ready next time (--qr or --copy for a BOLT11 invoice)." + )); + } + (invocation.operation.id == OperationId::RequestAQuote).then(|| { + format!("hint: Show the quote with `voltage quotes get {id}{scope}` once it is ready.") + }) +} + +/// The effective scope as flags, so a command in a hint reaches the same organization, +/// environment, and saved credential however the original command selected them. +fn scope_flags(scope: &Scope) -> String { + let org = scope.org.map(|org| format!(" --org {org}")); + let envs = scope.envs.iter().map(|env| format!(" --env {env}")); + let account = scope + .account + .as_deref() + .map(|account| format!(" --account {}", auth::shell_word(account))); + org.into_iter().chain(envs).chain(account).collect() +} + /// A transport failure after a mutation may have been sent leaves its outcome unknown. fn uncertain(mut error: Error, request: &Request, scope: &Scope) -> Error { if error.is_transport() { @@ -1506,6 +1541,24 @@ mod tests { ); } + /// A hint's command carries the effective scope, and quotes an account name for the shell. + #[test] + fn scope_flags_name_the_effective_scope() { + let org = Uuid::from_u128(1); + let env = Uuid::from_u128(2); + let scope = Scope { + org: Some(org), + envs: vec![env], + account: Some("my login".into()), + ..Scope::default() + }; + assert_eq!( + scope_flags(&scope), + format!(" --org {org} --env {env} --account 'my login'") + ); + assert_eq!(scope_flags(&Scope::default()), ""); + } + #[test] fn credential_rejections_name_the_next_step_for_their_own_credential() { let account = Authorization::Account(ApiCredential::ApiKey(Secret::new("key".into()))); diff --git a/src/auth.rs b/src/auth.rs index 658d209..3209a7f 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -742,7 +742,7 @@ fn expired_login(account: &str, url: &str) -> Error { } /// `value` as one word a POSIX shell reads back unchanged, for a hint's copyable command. -fn shell_word(value: &str) -> String { +pub(crate) fn shell_word(value: &str) -> String { let plain = !value.is_empty() && value .chars() diff --git a/src/cli.rs b/src/cli.rs index afcb6fd..ee652fa 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -310,11 +310,22 @@ fn parse_query_override(value: &str) -> std::result::Result, + /// The same ID as the argument, given as a flag + #[arg(long = "id", id = "target_id", value_name = "ID")] + target_id: Option, +} + +impl TargetFlags { + fn id(&self) -> Option { + self.resource_id.or(self.target_id) + } } #[derive(Debug, Args)] @@ -737,7 +748,7 @@ fn api_invocation( Ok(ApiInvocation { operation, alias, - resource_id: target.map(|target| target.resource_id), + resource_id: target.and_then(|target| target.id()), filters, query: query.query, all: pagination.is_some_and(|flags| flags.all), diff --git a/src/output.rs b/src/output.rs index 1c94eca..e92f19b 100644 --- a/src/output.rs +++ b/src/output.rs @@ -209,6 +209,11 @@ impl Output { self.show_secrets || self.file.is_some() } + /// Human output, where a next step on stderr is read rather than parsed. + pub fn is_human(&self) -> bool { + !self.format.is_machine_readable() + } + /// JSON output collects `--all` pages into one envelope; NDJSON streams them. pub fn collects_pages(&self) -> bool { self.format == OutputFormat::Json diff --git a/src/pick.rs b/src/pick.rs index 5f0e0a9..20003ec 100644 --- a/src/pick.rs +++ b/src/pick.rs @@ -179,7 +179,8 @@ async fn choose( terminal.pick(question, choices).await } -/// The listed resources that carry a UUID `id`, sorted by name. +/// The listed resources that carry a UUID `id`, sorted by name. A wallet also shows its +/// currency when all of its balances report the same one. fn choices(listed: &Value) -> Vec { let items = listed .as_array() @@ -191,13 +192,30 @@ fn choices(listed: &Value) -> Vec { .filter_map(|item| { let id = item.get("id")?.as_str()?.parse().ok()?; let name = item.get("name").and_then(Value::as_str).unwrap_or("-"); - Some(Choice::new(id, name)) + let choice = Choice::new(id, name); + Some(match currency(item) { + Some(currency) => choice.with_currency(currency), + None => choice, + }) }) .collect(); choices.sort_by_key(|choice| choice.name().to_lowercase()); choices } +/// The one currency a wallet's balances report, or `None` when they report none or disagree. +fn currency(item: &Value) -> Option<&str> { + let mut currencies = item + .get("balances")? + .as_array()? + .iter() + .map(|balance| balance.get("currency").and_then(Value::as_str)); + let first = currencies.next()??; + currencies + .all(|currency| currency == Some(first)) + .then_some(first) +} + #[cfg(test)] mod tests { use super::*; @@ -276,4 +294,32 @@ mod tests { assert_eq!(choices(&json!({"items": [{"id": ID}]})).len(), 1); assert!(choices(&json!({"unexpected": true})).is_empty()); } + + #[test] + fn wallet_choices_show_a_currency_only_when_their_balances_agree() { + let wallet = |balances: Value| json!([{"id": ID, "name": "Payroll", "balances": balances}]); + let label = |listed: Value| choices(&listed)[0].to_string(); + let usd = json!({"currency": "usd", "available": 1}); + assert_eq!( + label(wallet(json!([usd, usd]))), + format!("Payroll USD {ID}") + ); + assert_eq!( + label(wallet(json!([{"currency": "btc\u{1b}[2J"}]))), + format!("Payroll BTC [2J {ID}") + ); + for balances in [ + json!([]), + json!([usd, {"currency": "btc"}]), + json!([usd, {"available": 1}]), + json!([{"available": 1}]), + json!("usd"), + ] { + assert_eq!( + label(wallet(balances.clone())), + format!("Payroll {ID}"), + "{balances}" + ); + } + } } diff --git a/src/terminal.rs b/src/terminal.rs index 0cbe154..94f33be 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -124,6 +124,7 @@ impl Drop for PickerScreen { pub struct Choice { id: Uuid, name: String, + currency: Option, } impl Choice { @@ -131,9 +132,16 @@ impl Choice { Self { id, name: scrub_line(name), + currency: None, } } + /// Show a wallet's currency between its name and ID, where typing can filter on it. + pub fn with_currency(mut self, currency: &str) -> Self { + self.currency = Some(scrub_line(¤cy.to_uppercase())); + self + } + pub fn name(&self) -> &str { &self.name } @@ -141,7 +149,10 @@ impl Choice { impl Display for Choice { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} {}", self.name, self.id) + match &self.currency { + Some(currency) => write!(f, "{} {currency} {}", self.name, self.id), + None => write!(f, "{} {}", self.name, self.id), + } } } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 4a18d65..1db41a8 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -2766,6 +2766,139 @@ async fn payment_timeout_preserves_id_and_does_not_retry() { server.verify().await; } +#[tokio::test(flavor = "multi_thread")] +async fn an_accepted_receive_or_quote_names_the_command_that_shows_it() { + let (server, dir) = fixture().await; + route("POST", payments_path()) + .respond_with(accepted()) + .expect(4) + .mount(&server) + .await; + let quotes = format!("/organizations/{ORG}/environments/{ENV}/quotes"); + route("POST", quotes) + .respond_with(accepted()) + .expect(2) + .mount(&server) + .await; + let receive = |extra: &[&str]| { + let mut process = human_process_at(dir.path(), &server.uri()); + let mut args = vec!["--wallet", WALLET, "--amount", "1000", "--unit", "sats"]; + args.extend_from_slice(extra); + process.args(receive_args(&args)); + Command::from_std(process).assert().success() + }; + let shown = receive(&["--output", "table"]); + assert!(stdout(&shown).contains("accepted")); + assert!(stderr(&shown).contains(&format!( + "hint: Show the invoice or address with `voltage payments get {RESOURCE} --org {ORG} --env {ENV}`, or pass --wait ready next time" + ))); + // --quiet and machine-readable output leave the hint out. + assert!(!stderr(&receive(&["--output", "table", "--quiet"])).contains("hint:")); + assert!(!stderr(&receive(&["--output", "json"])).contains("hint:")); + + // A new ID: the receives' recovery record already holds RESOURCE. + let quote_id = "55555555-5555-4555-8555-555555555555"; + let mut quote = human_process_at(dir.path(), &server.uri()); + quote.args([ + "quotes", + "create", + "--org", + ORG, + "--env", + ENV, + "--id", + quote_id, + "--credit-line", + WALLET, + "--network", + "mutinynet", + "--amount", + "10", + "--unit", + "usd", + "--to", + "btc", + "--output", + "table", + ]); + let quoted = Command::from_std(quote).assert().success(); + assert!(stderr("ed).contains(&format!( + "hint: Show the quote with `voltage quotes get {quote_id} --org {ORG} --env {ENV}` once it is ready." + ))); + + // A body without an ID gives no ID to name, so there is no hint. + let body = dir.path().join("quote.json"); + std::fs::write(&body, r#"{"amount":10}"#).unwrap(); + let mut unnamed = human_process_at(dir.path(), &server.uri()); + unnamed.args([ + "quotes", "create", "--org", ORG, "--env", ENV, "--output", "table", + ]); + unnamed.arg("--data").arg(format!("@{}", body.display())); + let unnamed = Command::from_std(unnamed).assert().success(); + assert!(!stderr(&unnamed).contains("hint:"), "{}", stderr(&unnamed)); + + // An accepted send creates nothing to show, so it names no next step. + let mut send = human_process_at(dir.path(), &server.uri()); + send.args([ + "payments", + "send", + "--org", + ORG, + "--env", + ENV, + "--id", + "66666666-6666-4666-8666-666666666666", + "--wallet", + WALLET, + "--currency", + "btc", + "--invoice", + "lnbc1", + "--output", + "table", + ]); + let sent = Command::from_std(send).assert().success(); + assert!(stdout(&sent).contains("accepted")); + assert!(!stderr(&sent).contains("hint:"), "{}", stderr(&sent)); + server.verify().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_get_takes_its_id_as_a_flag_too_but_not_both() { + let (server, dir) = fixture().await; + let quote = format!("/organizations/{ORG}/environments/{ENV}/quotes/{RESOURCE}"); + route("GET", quote) + .respond_with(ok(json!({"id": RESOURCE}))) + .expect(1) + .mount(&server) + .await; + let result = cli(dir.path(), &server) + .args([ + "quotes", "get", "--org", ORG, "--env", ENV, "--id", RESOURCE, + ]) + .assert() + .success(); + assert_eq!(json_stdout(&result)["data"]["id"], RESOURCE); + let both = cli(dir.path(), &server) + .args([ + "quotes", "get", RESOURCE, "--org", ORG, "--env", ENV, "--id", RESOURCE, + ]) + .assert() + .code(2); + assert!(stderr(&both).contains("cannot be used with")); + let neither = cli(dir.path(), &server) + .args(["quotes", "get", "--org", ORG, "--env", ENV]) + .assert() + .code(2); + assert!(stderr(&neither).contains(">")); + let help = cli(dir.path(), &server) + .args(["quotes", "get", "-h"]) + .assert() + .success(); + assert!(stdout(&help).contains("Usage: voltage quotes get [OPTIONS] >\n")); + server.verify().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn ambiguous_payment_is_reconciled_by_reading_the_same_id_without_resubmission() { let (server, dir) = fixture().await; @@ -3926,8 +4059,8 @@ async fn a_terminal_picks_a_missing_wallet_and_scripts_are_never_asked() { .and(path(wallets_path(ORG))) .and(query_param("environment_id", ENV)) .respond_with(ok(json!([ - {"id": RESOURCE, "name": "ops"}, - {"id": WALLET, "name": "Payroll"} + {"id": RESOURCE, "name": "ops", "balances": [{"currency": "usd"}]}, + {"id": WALLET, "name": "Payroll", "balances": [{"currency": "btc"}]} ]))) .mount(&server) .await; @@ -3955,9 +4088,15 @@ async fn a_terminal_picks_a_missing_wallet_and_scripts_are_never_asked() { .count() }; - // Typing filters the list, and Enter picks; the change is still only described. - let picked = on_a_terminal(dir.path(), &server, &send, &[("Wallet:", b"Pay\r")]).await; + // Each wallet shows its currency, typing filters on it, and Enter picks; the change is + // still only described. + let picked = on_a_terminal(dir.path(), &server, &send, &[("Wallet:", b"btc\r")]).await; assert_eq!(picked.code, Some(6), "{}", picked.shown); + assert!( + picked.shown.contains(&format!("ops USD {RESOURCE}")), + "{}", + picked.shown + ); assert!( picked.stdout.contains("body.wallet_id"), "{}",