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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <QUOTE_ID|--id <ID>>`.
- 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

Expand Down
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|
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
6 changes: 3 additions & 3 deletions docs/guides/login-and-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion docs/guides/payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 |',
'|---|---|---|',
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
55 changes: 54 additions & 1 deletion src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uuid>) -> Option<String> {
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()
}
Comment thread
thebrandonlucas marked this conversation as resolved.
Resolved

/// 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() {
Expand Down Expand Up @@ -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())));
Expand Down
2 changes: 1 addition & 1 deletion src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
17 changes: 14 additions & 3 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,11 +310,22 @@ fn parse_query_override(value: &str) -> std::result::Result<QueryOverride, Strin
.ok_or_else(|| "--query requires NAME=VALUE".into())
}

/// Positional resource identifier for operations with a target.
/// Positional resource identifier for operations with a target. `--id` names the same
/// identifier, because `quotes create --id` and `payments send --id` take it as a flag.
#[derive(Debug, Args)]
#[group(required = true, multiple = false)]
struct TargetFlags {
#[arg(help_heading = "Arguments")]
resource_id: Uuid,
resource_id: Option<Uuid>,
/// The same ID as the argument, given as a flag
#[arg(long = "id", id = "target_id", value_name = "ID")]
target_id: Option<Uuid>,
}

impl TargetFlags {
fn id(&self) -> Option<Uuid> {
self.resource_id.or(self.target_id)
}
}

#[derive(Debug, Args)]
Expand Down Expand Up @@ -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),
Expand Down
5 changes: 5 additions & 0 deletions src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
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}"
);
}
}
}
Loading
Loading