From 4a1785766b0fc77285524d88068a6b7408ddc1e7 Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:30:06 -0500 Subject: [PATCH] feat(cli): pick the wallet, organization, or environment to show on a terminal On a terminal, a wallet read without WALLET_ID or --wallet (wallets get, wallets ledger list, wallets policies get, wallets payments summary) opens the wallet picker and shows the wallet picked. New organizations get and environments get show the organization or environment the scope selects, and pick it on a terminal when none is selected. Wallet changes still name their wallet. Off a terminal, with --json, --output json|ndjson, or --no-input, nothing is asked and a missing ID fails before any request, as before. --- CHANGELOG.md | 1 + docs/commands.md | 2 + docs/guides/login-and-profiles.md | 10 +- docs/guides/wallets.md | 2 + scripts/check-coverage.py | 2 + src/auth.rs | 17 ++- src/cli.rs | 65 ++++++++--- src/pick.rs | 151 +++++++++++++++++++----- src/startup.rs | 65 ++++++++--- tests/cli_integration.rs | 187 ++++++++++++++++++++++++++++++ 10 files changed, 438 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39995ce..6052dac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - A payment that settles on a terminal closes with `⚡ Payment ID completed` instead of a check mark, printed after the payment's record so it stays on screen. - `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. +- Browse with the pickers: on a terminal, `voltage wallets get`, `wallets ledger list`, `wallets policies get`, and `wallets payments summary` without a wallet ID open the wallet picker and show the wallet you pick. New `voltage organizations get` and `voltage environments get` show the organization or environment the scope selects, and on a terminal pick it when none is selected. Wallet changes still name their wallet. Off a terminal, or with `--json` or `--no-input`, nothing is asked and a missing ID fails as before. ## 0.2.0 - 2026-10-22 diff --git a/docs/commands.md b/docs/commands.md index 69fffd8..e1c0a2f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -13,7 +13,9 @@ These commands use the auth service (`https://auth.voltage.cloud/api/v1`, overri | `voltage auth status` | local only | | `voltage auth import-key` | local only | | `voltage organizations list` | GET `/api/v1/users/current` | +| `voltage organizations get` | GET `/api/v1/users/current` | | `voltage environments list` | POST `/api/v1/oauth/token`
GET `/api/v1/organizations/{organization_id}/environments` | +| `voltage environments get` | POST `/api/v1/oauth/token`
GET `/api/v1/organizations/{organization_id}/environments` | | `voltage context` | local only | | `voltage profiles create NAME` | local only | | `voltage profiles get NAME` | local only | diff --git a/docs/guides/login-and-profiles.md b/docs/guides/login-and-profiles.md index cfeeeb0..4a3ece3 100644 --- a/docs/guides/login-and-profiles.md +++ b/docs/guides/login-and-profiles.md @@ -29,7 +29,7 @@ secret-manager-command | voltage auth import-key --stdin --account ci --org ORG_ VOLTAGE_API_KEY=... voltage wallets list --org ORG_ID ``` -An imported key remembers the organization and environment it belongs to, and a command that selects a different one fails before any request is sent. `VOLTAGE_API_KEY` is used only when neither `--profile` nor `--account` is given. `organizations list` and `environments list` require a browser login. +An imported key remembers the organization and environment it belongs to, and a command that selects a different one fails before any request is sent. `VOLTAGE_API_KEY` is used only when neither `--profile` nor `--account` is given. `organizations list|get` and `environments list|get` require a browser login. With several saved credentials, select one with `--account` or through a profile. @@ -58,6 +58,14 @@ voltage payments receive --currency btc --kind bolt11 --amount 1000 --unit sats 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`. +The same pickers browse your resources. `organizations get` and `environments get` show the organization or environment that the scope selects (`--org`, `--env`, or a profile), and a read of one wallet, such as `wallets get`, shows the wallet that `WALLET_ID` or `--wallet` names. When none is selected, a terminal picks it and shows it: + +```sh +voltage organizations get # asks for the organization, then shows it +voltage environments get --org ORG_ID # asks for the environment +voltage wallets get --profile prod # asks for the wallet +``` + 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. ## Profiles diff --git a/docs/guides/wallets.md b/docs/guides/wallets.md index 183c8b3..9161685 100644 --- a/docs/guides/wallets.md +++ b/docs/guides/wallets.md @@ -40,6 +40,8 @@ voltage wallets payments summary WALLET_ID --profile prod On a terminal, `wallets get` first shows the wallet's name, each available balance, labelled `Available` (with a USD estimate for a BTC balance on mainnet, from the price service), and a sparkline of its completed payment volume over the last 14 days. That takes two extra reads, the BTC price on mainnet and the wallet's 100 newest completed payments (payment checks excluded), summed from each payment's amount in the wallet's currency. Each read has 5 seconds at most, and one that fails or is slow leaves its line out. The wallet's fields follow as usual, and redirected or `--json` output is unchanged. +On a terminal, a read of one wallet without `WALLET_ID` or `--wallet`, such as `voltage wallets get --profile prod`, opens the [wallet picker](login-and-profiles.md#pick-the-scope-interactively) and shows the wallet you pick. A change, such as `wallets update` or `wallets delete`, always names its wallet and never offers a picker. Off a terminal, or with `--json` or `--no-input`, a read without a wallet fails as before and names both ways to pass it. + The ledger lists every credit, hold, capture, and release on the wallet. `--all` follows every page; with `--output ndjson` each page is written as it arrives. ## Change a wallet diff --git a/scripts/check-coverage.py b/scripts/check-coverage.py index c321b5a..96960f7 100644 --- a/scripts/check-coverage.py +++ b/scripts/check-coverage.py @@ -20,7 +20,9 @@ ('voltage auth status', []), ('voltage auth import-key', []), ('voltage organizations list', [('GET', '/api/v1/users/current')]), + ('voltage organizations get', [('GET', '/api/v1/users/current')]), ('voltage environments list', [('POST', '/api/v1/oauth/token'), ('GET', '/api/v1/organizations/{organization_id}/environments')]), + ('voltage environments get', [('POST', '/api/v1/oauth/token'), ('GET', '/api/v1/organizations/{organization_id}/environments')]), ('voltage context', []), ('voltage profiles create NAME', []), ('voltage profiles get NAME', []), diff --git a/src/auth.rs b/src/auth.rs index b053228..717162e 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -7,7 +7,7 @@ use crate::{ Error, Result, api::{SessionChange, SubmissionState}, - cli::{GlobalFlags, ImportKeyFlags, LoginFlags}, + cli::{DiscoveryCommand, GlobalFlags, ImportKeyFlags, LoginFlags}, config::{ AUTH_URL, Account, AccountKind, Credential, CredentialProblem, CredentialStore, InputSource, LOCK_TIMEOUT, Login, Scope, ScopeSources, Settings, Source, name_in_use, @@ -965,13 +965,20 @@ impl Resolver<'_> { } /// List organizations visible to the login, or environments in the selected organization. - pub async fn discover(self, scope: &Scope, what: Discovery) -> Result { + /// `command` names the subcommand in the error for an API key. + pub async fn discover( + self, + scope: &Scope, + what: Discovery, + command: DiscoveryCommand, + ) -> Result { // An API key cannot list organizations or environments; say which key was used. let requires_login = || { - let command = match what { - Discovery::Organizations => "organizations list", - Discovery::Environments => "environments list", + let noun = match what { + Discovery::Organizations => "organizations", + Discovery::Environments => "environments", }; + let command = format!("{noun} {}", command.as_str()); if ambient_api_key(self.flags).is_some() { return Error::auth(format!( "{command} needs a browser login, but this command used VOLTAGE_API_KEY from the environment" diff --git a/src/cli.rs b/src/cli.rs index d3899d4..d3ce6b0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -198,12 +198,12 @@ pub enum LocalCommand { /// Organizations visible to the login Organizations { #[command(subcommand)] - command: ListCommand, + command: DiscoveryCommand, }, /// Environments in the selected organization Environments { #[command(subcommand)] - command: ListCommand, + command: DiscoveryCommand, }, /// Named organization, environment, and credential selections Profiles { @@ -290,9 +290,23 @@ pub struct ImportKeyFlags { pub credential_store: CredentialStore, } -#[derive(Debug, Subcommand)] -pub enum ListCommand { +/// What `organizations` and `environments` show. `get` takes no ID: it shows the one the +/// scope selects, so `--org` or `--env` names it, as for every other command. +#[derive(Clone, Copy, Debug, Subcommand, Eq, PartialEq)] +pub enum DiscoveryCommand { + /// List every one the login can see List, + /// Show the selected one; on a terminal, pick it when none is selected + Get, +} + +impl DiscoveryCommand { + pub fn as_str(self) -> &'static str { + match self { + Self::List => "list", + Self::Get => "get", + } + } } #[derive(Debug, Subcommand, Eq, PartialEq)] @@ -815,6 +829,8 @@ fn payment_alias_parent(create_payment: &Operation) -> &[String] { /// The operation's resource ID: its argument or `--id`. A wallet operation also takes an /// explicit `--wallet`, which names the wallet everywhere else; a profile's or /// `VOLTAGE_WALLET_ID`'s wallet never selects it, so a delete always names its wallet. +/// Without either, a wallet operation's ID stays unset here: on a terminal a read picks it, +/// and otherwise `pick` stops with [`missing_wallet`] before any request. fn target_id( operation: &Operation, target: Option, @@ -829,18 +845,18 @@ fn target_id( "WALLET_ID {id} and --wallet {wallet} name different wallets" )) .with_hint("Pass the wallet once, as WALLET_ID or --wallet.")), - (Some(id), _) | (None, Some(id)) => Ok(Some(id)), - (None, None) => { - let command = operation.command.join(" "); - Err( - Error::usage(format!("{command} needs a wallet ID")).with_hint(format!( - "Pass it as an argument or with --wallet, as in `voltage {command} WALLET_ID`. `voltage wallets list` shows the IDs." - )), - ) - } + (id, wallet) => Ok(id.or(wallet)), } } +/// The error for a wallet operation that names no wallet. +pub fn missing_wallet(operation: &Operation) -> Error { + let command = operation.command.join(" "); + Error::usage(format!("{command} needs a wallet ID")).with_hint(format!( + "Pass it as an argument or with --wallet, as in `voltage {command} WALLET_ID`. `voltage wallets list` shows the IDs." + )) +} + fn api_invocation( operation: &'static Operation, alias: Option, @@ -1040,7 +1056,8 @@ fn list_args(cmd: ClapCommand, operation: &Operation) -> ClapCommand { } /// The resource ID argument and `--id`, one of which is required. A wallet command can -/// name its wallet with `--wallet` instead, so `target_id` checks that one was given. +/// name its wallet with `--wallet` instead, or pick it on a terminal, so `pick` checks that +/// one was given. fn target_args(cmd: ClapCommand, target: ResourceTarget, wallet: bool) -> ClapCommand { let cmd = TargetFlags::augment_args(cmd) .mut_arg("resource_id", |arg| arg.value_name(target.value_name())); @@ -1202,6 +1219,12 @@ fn endpoint_examples(id: OperationId, command_name: &str) -> Option<&'static str (OperationId::CreatePayment, _) => { " # Send a complete payment request body from a file voltage payments create --profile prod --data @payment.json --execute" + } + (OperationId::GetWallet, _) => { + " # Show one wallet + voltage wallets get WALLET_ID --profile prod + # On a terminal, pick the wallet from a list + voltage wallets get --profile prod" } (OperationId::GetPayment, _) => { " # Wait up to two minutes for a payment to settle @@ -1342,6 +1365,16 @@ fn local_examples(path: &[&str]) -> Option<&'static str> { ["profiles", "create"] => { " # Save an organization, environment, and credential as `prod` voltage profiles create prod --org ORG_ID --env ENV_ID --account work" + } + ["organizations", "get"] => { + " # Show one organization, or pick it from a list on a terminal + voltage organizations get --org ORG_ID + voltage organizations get" + } + ["environments", "get"] => { + " # Show the environment a profile selects, or pick one from a list on a terminal + voltage environments get --profile prod + voltage environments get --org ORG_ID" } ["context"] => { " # Show what a command with this profile would use, and where each value came from @@ -1586,7 +1619,9 @@ mod tests { Some("Pass the wallet once, as WALLET_ID or --wallet.") ); - let missing = Invocation::try_parse_from(["voltage", "wallets", "get"]).unwrap_err(); + // Without either, the ID is left for `pick`, which asks on a terminal or stops. + assert_eq!(api(&["wallets", "get"]).resource_id, None); + let missing = missing_wallet(operation(OperationId::GetWallet)); assert_eq!(missing.kind, Kind::Usage); assert_eq!(missing.message, "wallets get needs a wallet ID"); assert!( diff --git a/src/pick.rs b/src/pick.rs index 2d1efe3..7df4efe 100644 --- a/src/pick.rs +++ b/src/pick.rs @@ -1,15 +1,20 @@ //! Interactive scope: when a command lacks the organization, environment, or wallet it needs, -//! a person at a terminal picks one instead of copying a UUID. Scripts never see a prompt. +//! a person at a terminal picks one instead of copying a UUID. The same pickers browse: a +//! wallet read without its wallet ID, and `organizations get` or `environments get` without a +//! selected one, pick it and show it. Scripts never see a prompt. use crate::{ Error, Result, api, api::{SubmissionState, profile_offer_interrupted}, auth::{ApiCredential, Discovery, Resolver}, - cli::{BodySource, Command, GlobalFlags, LocalCommand, ProfileCommand}, + cli::{ + BodySource, Command, DiscoveryCommand, GlobalFlags, LocalCommand, ProfileCommand, + missing_wallet, + }, config::{Credential, Profile, Scope, Settings, shell_word}, error::ErrorKind, output::OutputFormat, - registry::{OperationId, ParameterLocation, ScopeParameter}, + registry::{Method, OperationId, ParameterLocation, ScopeParameter}, terminal::{Choice, Terminal}, }; use serde_json::Value; @@ -54,10 +59,16 @@ fn needs(command: &Command) -> Needs { scopes.contains(&ScopeParameter::Environment) || (friendly && operation.id == OperationId::CreateWallet), scopes.contains(&ScopeParameter::Wallet) - || (friendly && operation.id == OperationId::CreatePayment), + || (friendly && operation.id == OperationId::CreatePayment) + || (operation.targets_wallet() && api.resource_id.is_none()), ) } - Command::Local(LocalCommand::Environments { .. }) => Needs::new(true, false, false), + Command::Local(LocalCommand::Organizations { + command: DiscoveryCommand::Get, + }) => Needs::new(true, false, false), + Command::Local(LocalCommand::Environments { command }) => { + Needs::new(true, *command == DiscoveryCommand::Get, false) + } Command::Local(LocalCommand::Profiles { command: ProfileCommand::Create { .. }, }) => Needs::new(true, true, false), @@ -66,7 +77,9 @@ fn needs(command: &Command) -> Needs { LocalCommand::Login(_) | LocalCommand::Logout(_) | LocalCommand::Auth { .. } - | LocalCommand::Organizations { .. } + | LocalCommand::Organizations { + command: DiscoveryCommand::List, + } | LocalCommand::Profiles { command: ProfileCommand::List | ProfileCommand::Get { .. } | ProfileCommand::Delete { .. }, @@ -80,34 +93,70 @@ fn needs(command: &Command) -> Needs { } /// Ask for each missing part of the scope the command needs, in the order organization, -/// environment, wallet. Without an interactive terminal, with `--no-input`, or for JSON +/// environment, wallet. A wallet read without its own wallet asks for that wallet last and +/// stops when none is picked. Without an interactive terminal, with `--no-input`, or for JSON /// output, nothing is asked and the command reports what is missing as before. Esc, an empty /// list, or a credential that cannot list choices also leaves the rest of the scope unset. pub async fn fill( - command: &Command, + command: &mut Command, global: &GlobalFlags, resolver: Resolver<'_>, scope: &mut Scope, ) -> Result<()> { + let can_ask = can_ask(global); + let target = wallet_target(command, can_ask)?; let needs = needs(command); let missing = Needs { org: needs.org && scope.org.is_none(), env: needs.env && scope.envs.is_empty(), - wallet: needs.wallet && scope.wallet.is_none(), + // The wallet a wallet command acts on never comes from the scope. + wallet: needs.wallet && (target || scope.wallet.is_none()), }; let terminal = global.terminal(); - if missing == Needs::default() || !can_ask(global) { + if missing == Needs::default() || !can_ask { return Ok(()); } let picked = ask(missing, global, resolver, scope).await?; - if !picked.is_empty() { + if !picked.flags.is_empty() { let profile = match command { Command::Local(LocalCommand::Profiles { .. }) => "", _ => " or save a profile with voltage profiles create", }; - terminal.notice(format!("Next time, pass {}{profile}.", picked.join(" "))); + terminal.notice(format!( + "Next time, pass {}{profile}.", + picked.flags.join(" ") + )); + } + match command { + Command::Api(api) if target => { + api.resource_id = picked.wallet; + // Esc, or nothing to pick, leaves the command without its wallet. + match api.resource_id { + Some(_) => Ok(()), + None => Err(missing_wallet(api.operation)), + } + } + _ => { + scope.wallet = picked.wallet.or(scope.wallet); + Ok(()) + } } - Ok(()) +} + +/// Whether the command is a wallet read that names no wallet, which a terminal picks. A +/// wallet change without one, or a read off a terminal, stops here, before any credential is +/// resolved or request is sent. +fn wallet_target(command: &Command, can_ask: bool) -> Result { + let Command::Api(api) = command else { + return Ok(false); + }; + if !api.operation.targets_wallet() || api.resource_id.is_some() { + return Ok(false); + } + if !can_ask || api.operation.method != Method::Get { + return Err(missing_wallet(api.operation)); + } + Ok(true) } /// Whether a missing scope is picked rather than reported: only for table output on an @@ -211,20 +260,29 @@ async fn pin( Ok(Some(name)) } +/// What the pickers chose: the flags that select it again, and the wallet, which the caller +/// places in the scope or as the command's own wallet. +#[derive(Default)] +struct Picked { + flags: Vec, + wallet: Option, +} + /// The pickers themselves, with one credential resolution and at most one organization token /// exchange. An API key cannot list organizations or environments, so only its wallets are -/// offered. Returns the flags for what was picked, which Esc or an empty list can cut short. +/// offered. The organization and environment go into `scope`. Esc or an empty list can cut +/// the picks short. async fn ask( missing: Needs, global: &GlobalFlags, resolver: Resolver<'_>, scope: &mut Scope, -) -> Result> { +) -> Result { let terminal = global.terminal(); // Without a credential there is nothing to list. That, not the missing scope, is what the // person must fix first, so report it. let credential = resolver.resolve(scope).await?; - let mut picked = Vec::new(); + let mut picked = Picked::default(); if missing.org { let Credential::Login(login) = &credential else { terminal.notice("An API key cannot list organizations, so there is nothing to pick."); @@ -238,7 +296,7 @@ async fn ask( return Ok(picked); }; scope.org = Some(org); - picked.push(format!("--org {org}")); + picked.flags.push(format!("--org {org}")); } if !missing.env && !missing.wallet { return Ok(picked); @@ -255,15 +313,15 @@ async fn ask( return Ok(picked); }; scope.envs = vec![env]; - picked.push(format!("--env {env}")); + picked.flags.push(format!("--env {env}")); } if missing.wallet { let listed = api::wallets(global, credential, resolver.credential_sources, scope).await?; let Some(wallet) = choose(terminal, "Wallet:", "wallets", &listed, false).await? else { return Ok(picked); }; - scope.wallet = Some(wallet); - picked.push(format!("--wallet {wallet}")); + picked.wallet = Some(wallet); + picked.flags.push(format!("--wallet {wallet}")); } Ok(picked) } @@ -294,12 +352,7 @@ async fn choose( /// 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() - .or_else(|| listed.get("items").and_then(Value::as_array)) - .map(Vec::as_slice) - .unwrap_or_default(); - let mut choices: Vec = items + let mut choices: Vec = items(listed) .iter() .filter_map(|item| { let id = item.get("id")?.as_str()?.parse().ok()?; @@ -315,6 +368,15 @@ fn choices(listed: &Value) -> Vec { choices } +/// A listing's items: the response itself when it is an array, else its `items`. +pub fn items(listed: &Value) -> &[Value] { + listed + .as_array() + .or_else(|| listed.get("items").and_then(Value::as_array)) + .map(Vec::as_slice) + .unwrap_or_default() +} + /// 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 @@ -371,11 +433,20 @@ mod tests { needs_of(&["wallets", "create", "--name", "a"]), Needs::new(true, true, false) ); - // The positional target is typed, not picked. + // A typed target is not picked; a missing wallet target is. assert_eq!( needs_of(&["wallets", "get", ID]), Needs::new(true, false, false) ); + assert_eq!(needs_of(&["wallets", "get"]), Needs::new(true, false, true)); + assert_eq!( + needs_of(&["organizations", "get"]), + Needs::new(true, false, false) + ); + assert_eq!( + needs_of(&["environments", "get"]), + Needs::new(true, true, false) + ); assert_eq!( needs_of(&["environments", "list"]), Needs::new(true, false, false) @@ -389,6 +460,32 @@ mod tests { } } + #[test] + fn only_a_wallet_read_on_a_terminal_picks_its_missing_wallet() { + let target = |args: &[&str], can_ask: bool| { + let mut full = vec!["voltage"]; + full.extend_from_slice(args); + wallet_target(&Invocation::try_parse_from(full).unwrap().command, can_ask) + }; + assert!(target(&["wallets", "get"], true).unwrap()); + assert!(target(&["wallets", "policies", "get"], true).unwrap()); + // A named wallet, or a command that acts on something else, needs no pick. + assert!(!target(&["wallets", "get", ID], true).unwrap()); + assert!(!target(&["wallets", "get", "--wallet", ID], false).unwrap()); + assert!(!target(&["wallets", "list"], false).unwrap()); + assert!(!target(&["organizations", "get"], false).unwrap()); + // A change always names its wallet, and a script is told how. + for (args, can_ask) in [ + (&["wallets", "get"][..], false), + (&["wallets", "delete"], true), + (&["wallets", "update", "--name", "a"], true), + ] { + let error = target(args, can_ask).unwrap_err(); + assert_eq!(error.kind, ErrorKind::Usage, "{args:?}"); + assert!(error.message.ends_with("needs a wallet ID"), "{args:?}"); + } + } + #[test] fn choices_keep_identified_items_scrub_names_and_sort_by_name() { let listed = json!([ diff --git a/src/startup.rs b/src/startup.rs index ab3a316..3cbe33e 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -5,8 +5,8 @@ use crate::{ api::{self, Execution, SubmissionState}, auth::{self, Discovery, Resolver}, cli::{ - self, AuthCommand, Command, ConvertFlags, GlobalFlags, Invocation, LocalCommand, - ParseFailure, PriceFlags, ProfileCommand, + self, AuthCommand, Command, ConvertFlags, DiscoveryCommand, GlobalFlags, Invocation, + LocalCommand, ParseFailure, PriceFlags, ProfileCommand, }, config::{self, Profile, Scope, Settings}, context, @@ -19,6 +19,7 @@ use crate::{ wallet_currency, wallet_shortlist, }; use serde::Serialize; +use serde_json::Value; use std::process::ExitCode; const BINARY_NAME: &str = "voltage"; @@ -127,7 +128,7 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result ) }; // Price commands need neither configuration nor credentials. - let command = match PriceQuery::try_from(command) { + let mut command = match PriceQuery::try_from(command) { Ok(query) => { let mut out = output(true)?; let envelope = query.run(&global).await?; @@ -135,7 +136,7 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result } Err(command) => command, }; - let mut session = Session::open(&command, &global, submission).await?; + let mut session = Session::open(&mut command, &global, submission).await?; match command { Command::Api(api) if api.all_orgs => { let mut out = output(true)?; @@ -171,9 +172,10 @@ struct Session<'a> { impl<'a> Session<'a> { /// Read the configuration and select the scope, then ask for any scope the command needs - /// and lacks. The scope does not change after this. + /// and lacks, and for the wallet a wallet read acts on. The scope does not change after + /// this. async fn open( - command: &Command, + command: &mut Command, global: &'a GlobalFlags, submission: &'a SubmissionState, ) -> Result { @@ -235,16 +237,12 @@ impl<'a> Session<'a> { } => { Envelope::local(auth::import_key(&mut self.settings, scope, &flags, global).await?) } - LocalCommand::Organizations { .. } => Envelope::local( - self.resolver() - .discover(scope, Discovery::Organizations) - .await?, - ), - LocalCommand::Environments { .. } => Envelope::local( - self.resolver() - .discover(scope, Discovery::Environments) - .await?, - ), + LocalCommand::Organizations { command } => { + discover(self.resolver(), scope, Discovery::Organizations, command).await + } + LocalCommand::Environments { command } => { + discover(self.resolver(), scope, Discovery::Environments, command).await + } LocalCommand::Context => Envelope::settings(context::inspect(&self.settings, global)?) .map(|context| context.titled("voltage context")), LocalCommand::Profiles { command } => { @@ -257,6 +255,41 @@ impl<'a> Session<'a> { } } +/// `organizations` or `environments`: the whole listing, or the one the scope selects. The +/// auth service lists them but has no route for one, so `get` finds it in the listing. +async fn discover( + resolver: Resolver<'_>, + scope: &Scope, + what: Discovery, + command: DiscoveryCommand, +) -> Result { + let selected = match (command, what) { + (DiscoveryCommand::List, _) => None, + (DiscoveryCommand::Get, Discovery::Organizations) => Some(scope.require_org()?), + (DiscoveryCommand::Get, Discovery::Environments) => Some(scope.single_env()?), + }; + let listed = resolver.discover(scope, what, command).await?; + let Some(id) = selected else { + return Envelope::local(listed); + }; + let found = pick::items(&listed) + .iter() + .find(|item| item.get("id").and_then(Value::as_str) == Some(&id.to_string())); + match found { + Some(item) => Envelope::local(item), + None => { + let (noun, list) = match what { + Discovery::Organizations => ("Organization", "voltage organizations list"), + Discovery::Environments => ("Environment", "voltage environments list"), + }; + Err( + Error::usage(format!("{noun} {id} is not visible to this login")) + .with_hint(format!("`{list}` shows the ones it can see.")), + ) + } + } +} + /// A command that reads only the price service. enum PriceQuery { Report(PriceFlags), diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 53c22e4..0b248d5 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -4908,6 +4908,10 @@ async fn discovery_with_an_api_key_says_which_key_was_used() { seeded_api_key(dir.path(), "saved", "saved-key"); for (args, command) in [ (&["organizations", "list"][..], "organizations list"), + ( + &["organizations", "get", "--org", ORG][..], + "organizations get", + ), ( &["environments", "list", "--org", ORG][..], "environments list", @@ -5707,6 +5711,189 @@ async fn a_failed_wallet_listing_or_two_environments_end_the_pick_with_their_own server.verify().await; } +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_wallet_read_without_its_id_picks_the_wallet_and_a_change_still_names_it() { + let (server, dir) = fixture().await; + // Listed once for the pick and once for Esc; nothing else lists wallets. + respond( + &server, + route("GET", wallets_path(ORG)), + ok(json!([{"id": RESOURCE, "name": "ops"}, {"id": WALLET, "name": "Payroll"}])), + 2, + ) + .await; + respond_once( + &server, + route("GET", format!("{}/{WALLET}", wallets_path(ORG))), + ok(json!({"id": WALLET, "name": "Payroll"})), + ) + .await; + let get = ["wallets", "get", "--org", ORG, "--output", "table"]; + + // The picked wallet is read and shown, and the flag that names it is suggested. + let picked = on_a_terminal(dir.path(), &server, &get, &[("Wallet:", b"Payroll\r")]).await; + assert_eq!(picked.code, Some(0), "{}", picked.shown); + assert!(picked.stdout.contains(WALLET), "{}", picked.stdout); + assert!( + picked + .shown + .contains(&format!("Next time, pass --wallet {WALLET}")), + "{}", + picked.shown + ); + assert_eq!(picked.modes.0, picked.modes.1); + + // Esc leaves the read without a wallet, and it stops as it did before the picker. + let escaped = on_a_terminal(dir.path(), &server, &get, &[("Wallet:", b"\x1b")]).await; + assert_eq!(escaped.code, Some(2), "{}", escaped.shown); + assert!( + escaped.shown.contains("wallets get needs a wallet ID"), + "{}", + escaped.shown + ); + + // JSON output, --no-input, and a change never ask, and stop before any request. + let delete = ["wallets", "delete", "--org", ORG, "--output", "table"]; + for (args, extra) in [(&get, "--json"), (&get, "--no-input"), (&delete, "--quiet")] { + let mut args = args.to_vec(); + args.push(extra); + let run = on_a_terminal(dir.path(), &server, &args, &[]).await; + assert_eq!(run.code, Some(2), "{args:?}: {}", run.shown); + assert!(run.shown.contains("needs a wallet ID"), "{args:?}"); + assert!(!run.shown.contains("Wallet:"), "{args:?}"); + } + // Off a terminal, the error is the same. + let result = cli(dir.path(), &server) + .args(["wallets", "ledger", "list", "--org", ORG]) + .assert() + .code(2); + assert_eq!( + json_error(&result)["error"]["message"], + "wallets ledger list needs a wallet ID" + ); + server.verify().await; +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn organizations_and_environments_get_pick_the_one_to_show_on_a_terminal() { + let auth = MockServer::start().await; + let (api, dir) = fixture().await; + seeded_login(dir.path(), &auth, false); + let organizations = json!({"organizations": [ + {"id": ORG, "name": "Acme"}, {"id": OTHER_ORG, "name": "Beta"} + ]}); + // Listed for the pick, then again to show the picked one. + respond(&auth, route("GET", "/users/current"), ok(organizations), 2).await; + let args = [ + "organizations", + "get", + "--account", + LOGIN_NAME, + "--output", + "table", + ]; + let run = on_a_terminal(dir.path(), &api, &args, &[("Organization:", b"Beta\r")]).await; + assert_eq!(run.code, Some(0), "{}", run.shown); + assert!(run.stdout.contains(OTHER_ORG), "{}", run.stdout); + assert!(!run.stdout.contains(ORG), "{}", run.stdout); + assert!( + run.shown + .contains(&format!("Next time, pass --org {OTHER_ORG}")), + "{}", + run.shown + ); + auth.verify().await; + + // An environment is picked within the organization the flag names. + let auth = MockServer::start().await; + seeded_login(dir.path(), &auth, false); + organization_exchange(&auth, LOGIN_ACCESS, ORG, "picker-access", 2).await; + respond( + &auth, + route("GET", format!("/organizations/{ORG}/environments")), + ok(json!([{"id": ENV, "name": "prod"}, {"id": RESOURCE, "name": "staging"}])), + 2, + ) + .await; + let args = [ + "environments", + "get", + "--account", + LOGIN_NAME, + "--org", + ORG, + "--output", + "table", + ]; + let run = on_a_terminal(dir.path(), &api, &args, &[("Environment:", b"staging\r")]).await; + assert_eq!(run.code, Some(0), "{}", run.shown); + assert!(run.stdout.contains(RESOURCE), "{}", run.stdout); + assert!(!run.stdout.contains(ENV), "{}", run.stdout); + assert!(!run.shown.contains("Organization:"), "{}", run.shown); + auth.verify().await; + + // JSON output never asks, and reports the missing scope before any request. + let mut json = args.to_vec(); + json.truncate(4); + json.push("--json"); + let run = on_a_terminal(dir.path(), &api, &json, &[]).await; + assert_eq!(run.code, Some(2), "{}", run.shown); + assert!( + run.shown.contains("Exactly one --env is required"), + "{}", + run.shown + ); + assert!(!run.shown.contains("Environment:"), "{}", run.shown); + assert_eq!(auth.received_requests().await.unwrap().len(), 4); +} + +#[tokio::test(flavor = "multi_thread")] +async fn organizations_get_shows_the_selected_organization_and_never_asks_a_script() { + let auth = MockServer::start().await; + let (api, dir) = fixture().await; + seeded_login(dir.path(), &auth, false); + let organizations = json!({"organizations": [ + {"id": ORG, "name": "Acme"}, {"id": OTHER_ORG, "name": "Beta"} + ]}); + respond(&auth, route("GET", "/users/current"), ok(organizations), 2).await; + let get = |org: &str| { + cli(dir.path(), &api) + .args([ + "organizations", + "get", + "--account", + LOGIN_NAME, + "--org", + org, + ]) + .assert() + }; + let shown = get(ORG).success(); + let shown: Value = serde_json::from_slice(&shown.get_output().stdout).unwrap(); + assert_eq!(shown["data"], json!({"id": ORG, "name": "Acme"})); + + let result = get(RESOURCE).code(2); + let error = json_error(&result); + assert_eq!( + error["error"]["message"], + format!("Organization {RESOURCE} is not visible to this login") + ); + assert_eq!( + error["error"]["hint"], + "`voltage organizations list` shows the ones it can see." + ); + + // Without an organization, a script gets the missing scope error and no request. + let result = cli(dir.path(), &api) + .args(["organizations", "get", "--account", LOGIN_NAME]) + .assert() + .code(2); + assert_eq!(json_error(&result)["error"]["message"], "--org is required"); + auth.verify().await; +} + #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn a_signal_at_the_picker_restores_the_terminal() {