From 386c61c575c6b7d7d6c46c30ba1df7194b16cfb3 Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:12:24 -0500 Subject: [PATCH 1/2] feat(cli): show each wallet's currency in the picker The wallet picker listed only a name and an ID, so a USD wallet looked like a BTC wallet. Each wallet now shows its currency between the two when all of its balances report the same one, and typing the currency filters on it. A wallet with no balance currency, or with balances that disagree, shows only its name and ID. Closes #50 --- CHANGELOG.md | 1 + docs/guides/login-and-profiles.md | 4 +-- src/pick.rs | 50 +++++++++++++++++++++++++++++-- src/terminal.rs | 13 +++++++- tests/cli_integration.rs | 14 ++++++--- 5 files changed, 73 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 941efff..bbb4515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - 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. +- 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/guides/login-and-profiles.md b/docs/guides/login-and-profiles.md index 3366199..75d377c 100644 --- a/docs/guides/login-and-profiles.md +++ b/docs/guides/login-and-profiles.md @@ -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/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 aeeea5c..81191f6 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -4048,8 +4048,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; @@ -4077,9 +4077,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"), "{}", From e3e0c9aabf3c1370fa34b43f2c6c6d0bd4b1a151 Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:17:54 -0500 Subject: [PATCH 2/2] fix(scripts): build, then run the command after a leading --build `scripts/voltage-staging --build login ...` passed `--build` to the binary, which rejected it. A leading `--build` in either wrapper now builds and then runs the remaining arguments, if any. The wrapper still refuses `--config-dir`, `--api-url` and `--auth-url` before it builds, and a failed build exits with cargo's status without running the command. Closes #37. --- docs/development.md | 10 ++++++++-- scripts/voltage-local | 16 +++++++++++----- scripts/voltage-staging | 30 +++++++++++++++++++++++------- 3 files changed, 42 insertions(+), 14 deletions(-) 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/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"