Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <QUOTE_ID|--id <ID>>`.
- 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

Expand Down
10 changes: 8 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/guides/login-and-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
16 changes: 11 additions & 5 deletions scripts/voltage-local
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
30 changes: 23 additions & 7 deletions scripts/voltage-staging
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand All @@ -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():
Expand Down Expand Up @@ -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"
Expand Down
50 changes: 48 additions & 2 deletions src/pick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Choice> {
let items = listed
.as_array()
Expand All @@ -191,13 +192,30 @@ fn choices(listed: &Value) -> Vec<Choice> {
.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::*;
Expand Down Expand Up @@ -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}"
);
}
}
}
13 changes: 12 additions & 1 deletion src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,24 +124,35 @@ impl Drop for PickerScreen {
pub struct Choice {
id: Uuid,
name: String,
currency: Option<String>,
}

impl Choice {
pub fn new(id: Uuid, name: &str) -> Self {
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(&currency.to_uppercase()));
self
}

pub fn name(&self) -> &str {
&self.name
}
}

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),
}
}
}

Expand Down
14 changes: 10 additions & 4 deletions tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"),
"{}",
Expand Down
Loading