diff --git a/AGENTS.md b/AGENTS.md index ef560da..14c65aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,14 @@ Rules for coding agents working in this repository. Engineering conventions live change would exceed that, split the rest onto a new branch based on the one you changed and open it as a stacked pull request. +## Work plans + +Agent work plans live in `docs/plans//` as Markdown files. This directory is +gitignored on purpose: plans are local, disposable handoff notes for chunks of work, not +reviewed project documentation or a source of truth. Keep durable decisions and user-facing +behavior in tracked docs, code, and tests; create a subfolder per work item and revise or +remove it as work progresses. + ## Agent friction log The CLI must be easy for AI agents to use. When it is not, record why in diff --git a/README.md b/README.md index 1ac1028..b4a08ff 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,35 @@ -# Voltage CLI +

+ + + Voltage + +

-`voltage` is the command-line client for the [Voltage API](https://voltageapi.com/v1/docs). It covers wallets, payments, quotes, lines of credit, bills, webhooks, and checkout, and it signs you in through your browser or with an environment API key. +

Bitcoin payments from your terminal.

-## Install - -Download the archive for your platform from the [releases page](https://github.com/voltagecloud/voltage-cli/releases). Each release ships macOS (Apple Silicon and Intel), Linux (x86-64 and ARM64), and Windows (x86-64) archives with shell completions, a signed `SHA256SUMS` file, and a Homebrew formula (`voltage.rb`). Extract the archive and put `voltage` (or `voltage.exe`) on your `PATH`. - -### Verify a download +

+ CI + Release + License: MIT +

-Every archive carries a SLSA build-provenance attestation, and `SHA256SUMS` is signed with Sigstore (keyless; no keys to trust out of band). Before installing, verify both with the [GitHub CLI](https://cli.github.com) and [cosign](https://docs.sigstore.dev/cosign/system_config/installation/): +`voltage` is the command-line client for the [Voltage API](https://voltageapi.com/v1/docs). Create wallets, receive and send Lightning payments (and on-chain payments, where enabled), and manage quotes, webhooks, and checkout from a terminal, a script, or an AI agent. -```sh -# The archive was built by this repository's release workflow from its version tag. -gh attestation verify voltage-*.tar.gz --repo voltagecloud/voltage-cli +- **Safe by default.** A change is only described until you add `--execute`. Every payment carries an ID, and the CLI never resubmits one on its own. +- **Built for scripts.** Tables on a terminal, stable JSON when piped, and exit codes that say what happened. +- **Your credentials stay yours.** Browser login with your usual MFA, kept in your OS credential store by default. Secrets are redacted unless you ask for them. -# The checksums were signed by that same workflow run. -cosign verify-blob SHA256SUMS --bundle SHA256SUMS.sigstore.json \ - --certificate-identity-regexp 'https://github.com/voltagecloud/voltage-cli/.github/workflows/release.yml' \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com +## Install -# The archive matches the signed checksums. -sha256sum --check SHA256SUMS --ignore-missing -``` +Download the archive for macOS, Linux, or Windows from the [releases page](https://github.com/voltagecloud/voltage-cli/releases), extract it, and put `voltage` on your `PATH`. Releases are signed and attested: [verify a download](docs/guides/install.md#verify-a-download). -To build from source, install [Rust](https://rustup.rs) and run: +Or build from source with [Rust](https://rustup.rs): ```sh -cargo install --path . --locked +cargo install --git https://github.com/voltagecloud/voltage-cli --locked voltage --version ``` -The toolchain is pinned in `rust-toolchain.toml`; no system libraries are needed. - -Credentials are stored in the macOS Keychain, the Linux Secret Service (over D-Bus, with no system library required), or the Windows Credential Manager. On a headless Linux machine without an unlocked Secret Service, pass `--credential-store file` when you log in or import a key. - ## Quick start ```sh @@ -44,197 +40,53 @@ voltage profiles create prod --org ORG_ID --env ENV_ID --account you@example.com voltage wallets list --profile prod ``` -Replace `ORG_ID`, `ENV_ID`, and the other placeholders in this document with real UUIDs. - -## Authenticate - -`voltage login` prints a verification URL and a code, opens the URL in your browser, and waits for your approval after your usual login and MFA. Use `--no-browser` on a remote machine and open the URL yourself. The saved login refreshes its tokens automatically; the refresh token rotates. - -```sh -voltage login # saved under your email address -voltage login --account work # saved under a name of your choice -voltage login --no-browser --credential-store file -voltage auth status # identity and expiry, never the secret -voltage logout # revokes the session, then removes it -voltage logout --local # removes local credentials only -``` - -A login grants the permissions your account already has. Profiles do not restrict them. - -For automation, use an environment API key. Import one so it never lands in shell history, or pass it through `VOLTAGE_API_KEY`: - -```sh -voltage auth import-key --account ci --org ORG_ID --env ENV_ID -secret-manager-command | voltage auth import-key --stdin --account ci --org ORG_ID --env ENV_ID -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. - -With several saved credentials, select one with `--account` or through a profile. - -## Scope and profiles - -Resource IDs are positional. The enclosing scope comes from `--org`, `--env`, `--wallet`, and `--webhook`: - -```sh -voltage wallets list --org ORG_ID --env ENV_ID -voltage wallets get WALLET_ID --org ORG_ID -voltage payments list --org ORG_ID --env ENV_ID --env OTHER_ENV_ID -``` - -Without a profile, flags override `VOLTAGE_ORGANIZATION_ID`, `VOLTAGE_ENVIRONMENT_ID`, and `VOLTAGE_WALLET_ID`. A profile bundles an organization, an environment, and a credential, and ignores those variables and `VOLTAGE_API_KEY`; flags still override its scope for one command. - -```sh -voltage profiles create staging --org ORG_ID --env ENV_ID --account work -voltage profiles list -voltage profiles get staging -voltage profiles delete staging -``` - -`voltage context` shows the effective profile, account, organization, environments, wallet, API URL, and configuration directory, and where each came from (`flag`, `profile`, `environment`, `default`, or `unset`). It reports why commands would reject the credential selection as `credential_problem` (`empty_api_key`, `no_credential`, `multiple_credentials`, `unknown_credential`, or `bound_elsewhere` for an API key used outside its organization or environment), using the same selection commands use. It also lists `VOLTAGE_*` variables that are set but ignored because a flag or the selected profile takes precedence. It reads no saved secret, makes no network request, and creates no files: - -```sh -voltage context --profile staging -voltage context --json | jq -r '.data.organization_id.value' -``` - -Configuration lives in `$XDG_CONFIG_HOME/voltage` or `~/.config/voltage`; override the directory with `VOLTAGE_CONFIG_DIR` or `--config-dir`. The directory and everything in it are owner-only. - -Only endpoints that filter by environment receive `--env`. Organization-wide commands say so in their help, and wallet mutations verify a supplied environment against the wallet before changing anything. - -## Commands and help - -Commands mirror the API contract; see [the command reference](docs/commands.md) for the full list with routes. Every command and group has descriptive `--help`; body-building commands also show required flag combinations and examples: - -```sh -voltage --help -voltage payments --help -voltage payments receive --help -``` - -Shell completions come from the same definitions: - -```sh -voltage completions bash > voltage.bash -voltage completions zsh > _voltage -voltage completions fish > voltage.fish -voltage completions powershell > _voltage.ps1 -``` - -## Requests - -Every operation that takes a body accepts its complete API payload from a file or stdin. The JSON is sent exactly as given, including fields the CLI does not know about, and is limited to 16 MiB: - -```sh -voltage payments create --profile prod --data @payment.json --execute -cat payment.json | voltage payments create --profile prod --data - --execute -``` - -Common operations also offer friendly flags (`wallets create`, `wallets update`, `payments receive`, `payments send`, `quotes create`, `webhooks create`, `webhooks update`); `--help` lists them. Friendly flags and `--data` are mutually exclusive, and a payload whose IDs conflict with the selected scope is rejected. - -Documented query filters are flags named after the parameter. Parameters with a fixed set of values accept only those values, case-insensitively, and are sent in the contract's spelling. `--query NAME=VALUE` sets any documented parameter and can be repeated: - -```sh -voltage payments list --profile prod --statuses completed --statuses failed \ - --metadata order_id=123 --query sort_order=desc --all -``` - -Commands that change something (any method but GET) only describe their request unless you pass `--execute`, then exit with code 6 so a script that forgot it fails loudly. Reads run as usual. To send every change in a script or chain of commands, set `VOLTAGE_EXECUTE=1` (or `true`) instead; `0`, `false`, or an empty value keeps the default, and any other value fails every API command, reads included. The flag wins over the variable, a change sent because of the variable says so on stderr, and `voltage context` reports it as `execute_changes` with its source. `--execute` is the only approval: nothing asks again before sending. A described change reports the same `resource_id` a real run would use, so chained commands see the same IDs in both modes. - -`-n, --dry-run` validates any API command and describes the request it would send, then stops: it reports the method, URL, query, `Origin` header, and body, plus a `run` object with what a real run would do next. That is the same plan a real run follows: a `read` says whether it checks the wallet's environment and whether it then pages, waits for a payment, or waits for a checkout session; a `mutation` says whether it verifies the wallet's environment, the ID it would journal for recovery, whether a failed payment submission is reconciled, and what it waits for; an `event_stream` follows checkout events. It authenticates nothing, makes no network request (including the wallet environment check a real mutation performs), prompts for nothing, writes no journal entry, and does not create `--output-file`. Body and metadata-filter values are replaced with `[OMITTED]` unless the field holds only identifiers, enums, or amounts; `--show-secrets` shows them. `--data -` still reads stdin, and a friendly create generates a fresh ID each run unless you pass `--id`. Passing local validation does not mean the API will accept the request. - -```sh -voltage payments send --profile prod --wallet WALLET_ID --currency btc \ - --invoice BOLT11_INVOICE --max-fee 10 --fee-unit sats --dry-run -voltage wallets delete WALLET_ID --profile prod -n --json -``` - -The result is a normal envelope with `outcome: dry_run` and no `http_status`. - -`--all` follows pagination by cursor, which the API recommends and the CLI requests by default; endpoints that only page by offset are followed by offset and count. The API has deprecated explicit offset paging (`--offset`, `--pagination offset`) where cursors exist, and the CLI warns when you use it. `--timeout SECONDS` (default 60) bounds the whole request, including paging and waits. - -## Payments +Replace `ORG_ID`, `ENV_ID`, and the other placeholders with real UUIDs. Then take a payment: ```sh voltage payments receive --profile prod --wallet WALLET_ID \ - --currency btc --kind bolt11 --amount 1000 --unit sats --wait ready --execute -voltage payments send --profile prod --wallet WALLET_ID \ - --currency btc --invoice BOLT11_INVOICE --max-fee 10 --fee-unit sats --execute -voltage payments send --profile prod --wallet WALLET_ID \ - --currency btc --address BITCOIN_ADDRESS --amount 1000 --unit sats --execute -voltage quotes create --profile prod --credit-line CREDIT_LINE_ID \ - --network mutinynet --amount 10 --unit usd --to btc --execute -``` - -Amounts are decimals in the unit you name (`msats`, `sats`, `btc`, `cents`, `usd`) and are converted to the API's integer base units exactly; a value with more precision than the unit allows is rejected. `--currency` is the wallet currency for sends and the receive currency for open-amount receives. Fee limits cover network and provider fees only. - -Quotes apply to USD lines of credit: a USD payment requires `--quote` with a quote for that line of credit. On-chain and BIP21 payments and treasury movements are features Voltage enables per organization; the API rejects them with `feature_flag_disabled` until then, and the CLI says so. - -To see what an amount is worth in the other currency, or the current BTC/USD price, use the price service (no credentials needed): - -```sh -voltage price -voltage convert 10 usd --to btc -voltage convert 21000 sats --to usd --at 2026-09-01T00:00:00Z + --currency btc --kind bolt11 --amount 1000 --unit sats --qr --execute ``` -A conversion reports the result in every unit of its currency (`msats`, `sats`, and `btc`, or `cents` and `usd`) together with the quote it used, including the minute the service rounded to. +The invoice prints as a QR code as soon as it is ready. Leave out `--execute` to see the exact request first. -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: +## Use it in scripts ```sh -voltage payments get PAYMENT_ID --profile prod --wait completed --timeout 120 -``` - -For a BOLT11 receive, `--qr` prints the invoice as a terminal QR code and `--copy` puts it on the clipboard as soon as it is ready. Either flag implies `--wait ready`; with `--wait completed` the invoice is shown first and polling continues to settlement. - -There is no confirmation prompt: run a change without `--execute` to see exactly what it would send, then add `--execute` to send it. Sending a payment notes on stderr that its fee limit excludes processing fees. `--no-input` forbids the hidden API-key prompt even on a terminal; supply credentials via environment, private file, or `--stdin` (for `auth import-key`). - -Payments and treasury movements carry an ID, generated for you unless you pass `--id`. Before sending, the CLI records the ID, operation, scope, and request hash (never the body) in a private journal under `requests/`, and it refuses to reuse an ID with a different request. If the connection drops after a submission, the CLI makes one short read of the ID: if the payment is visible, it reports acceptance; otherwise it exits with code 4 and the ID, without resubmitting. Check the ID before deciding to retry. Ctrl-C exits with code 130, including at the hidden API-key prompt: before a write is sent it reports that no resource change was submitted, or, if a login was being refreshed, created, or logged out, how to recover it; once a write may have been sent it reports the outcome as unknown, with the original ID and reconciliation instructions for a payment. It never cancels a submitted request. - -## Output - -On a terminal, results are readable tables; when piped, they are JSON. `--json` forces JSON and `--output table|json|ndjson` selects explicitly. Tables show up to seven of the fields a list's items share (ID, name, status, amount, and similar) with aligned columns, a result count, and the next cursor; amounts are shown in their base units (`msats` or `cents`). A table wider than the terminal (its width, or `COLUMNS`) hides trailing columns and then shortens text cells with `…`, naming what it hid; IDs, timestamps, and amounts are never shortened. Timestamps are shown in UTC to the second, such as `2024-02-27 01:58:51 UTC`; JSON keeps the exact value. A table starts with the outcome only when it adds something, such as `accepted` or `dry_run`, or when there is no data to show. A single resource is shown as aligned `field value` lines, with nested fields joined by dots; `voltage context` shows each setting as `name value (source)`. API text is stripped of terminal control, bidirectional formatting, and invisible characters (including tag characters that can carry hidden text) in tables, notices, and errors. The table layout may change between releases; scripts should use JSON. The JSON envelope is stable: - -```json -{"http_status":202,"data":null,"resource_id":"PAYMENT_ID","outcome":"accepted"} +voltage payments list --profile prod --json | jq '.data.items[].status' +voltage payments send --profile prod --wallet WALLET_ID \ + --currency btc --invoice BOLT11_INVOICE --max-fee 10 --fee-unit sats --dry-run +VOLTAGE_EXECUTE=1 ./pay-invoices.sh ``` -API fields stay inside `data`. With `--all`, JSON output collects the pages into `data.pages`, and NDJSON writes one envelope per page as it arrives. Diagnostics, prompts, and progress go to stderr. Interactive stderr shows immediate, cancellable status for network requests and waits; redirected stderr gets no spinner/control codes. `-q, --quiet` suppresses optional status and notices, never requested results, errors, warnings, or recovery IDs. Human errors start with `error:` and may include a safe `hint:`; `--json` makes command-line parse errors machine-readable as well as runtime errors. A downstream consumer that closes stdout early is treated as successful pipeline completion. - -Known secret fields and the credentials the CLI presented are redacted from normal output and from error details. Operations that return a one-time secret (webhook creation and key rotation, checkout sessions, stream tokens) refuse to run unless you pass `--output-file PATH`, which writes the complete response to a new owner-only file, or `--show-secrets`. Treat your own metadata as potentially sensitive; redaction cannot recognise it. - -## Checkout - -Checkout commands use their own credentials and never fall back to your account. Provide a session token through `VOLTAGE_CHECKOUT_TOKEN` and a stream token through `VOLTAGE_STREAM_TOKEN`, or read either from a private file or stdin with `--token-file PATH` / `--token-file -`. Pass `--origin https://shop.example.com` when the session requires a browser origin. - -```sh -voltage checkout sessions get SESSION_ID --token-file ./session.token -voltage checkout events watch --token-file - < ./stream.token -``` +| Exit code | Meaning | +|---|---| +| 0 | Success, or accepted without `--wait` | +| 1 | The API rejected the request or a payment failed | +| 4 | Unknown outcome: check the payment ID before you retry | +| 6 | A change was described but not sent | -`checkout sessions get` retries while the session projection is being created, following the API's retry hints. `checkout events watch` streams server-sent events as `outcome: event` envelopes until the connection closes or `--timeout` passes; it does not reconnect. Ctrl-C exits cleanly. +All codes are in the [exit code reference](docs/reference/exit-codes.md). -## Exit codes +## Documentation -| Code | Meaning | +| | | |---|---| -| 0 | Success, or an accepted submission without waiting | -| 1 | The API rejected the request or a payment failed | -| 2 | Invalid invocation or configuration | -| 3 | Authentication or authorization failure | -| 4 | Transport failure or a submission with an unknown outcome | -| 5 | A wait or pagination deadline passed | -| 6 | A change was described but not sent: pass `--execute` or set `VOLTAGE_EXECUTE=1` | -| 130 | Interrupted | +| [Install](docs/guides/install.md) | Platforms, download verification, completions, credential stores | +| [Log in and use profiles](docs/guides/login-and-profiles.md) | Browser login, API keys, scope, profiles, `voltage context` | +| [Payments](docs/guides/payments.md) | Receive, send, wait, quotes, prices | +| [Requests](docs/guides/requests.md) | Request bodies, filters, paging | +| [Checkout](docs/guides/checkout.md) | Session and stream tokens, events | +| [Scripting](docs/guides/scripting.md) | JSON, `--execute` in scripts, quiet output | +| [Safety model](docs/concepts/safety-model.md) | Describe-by-default, dry runs, payment IDs, Ctrl-C, redaction | +| [Output reference](docs/reference/output.md) | Tables, the JSON envelope, streams | +| [Command reference](docs/commands.md) | Every command and its API route | + +Every command also has `--help`. [The docs index](docs/README.md) lists everything. ## Contributing -Report security issues as described in [SECURITY.md](SECURITY.md). Build, test, and release instructions are in [the development guide](docs/development.md); code conventions are in [the style guide](docs/quality.md). - -Agent work plans live in `docs/plans//` as Markdown files. This directory is gitignored on purpose: plans are local, disposable handoff notes for chunks of work, not reviewed project documentation or a source of truth. Keep durable decisions and user-facing behavior in tracked docs, code, and tests; create a subfolder per work item and revise or remove it as work progresses. +Report security issues as described in [SECURITY.md](SECURITY.md). Build, test, and release instructions are in [the development guide](docs/development.md). Code conventions are in [the style guide](docs/quality.md). ## License -MIT; see [LICENSE](LICENSE). +MIT. See [LICENSE](LICENSE). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..91cd112 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,31 @@ +# Voltage CLI documentation + +Start with the [README](../README.md) for install and a quick start. + +## Do a task + +- [Install](guides/install.md): platforms, download verification, building from source, credential stores, completions +- [Log in and use profiles](guides/login-and-profiles.md): browser login, API keys, scope, profiles, `voltage context` +- [Payments](guides/payments.md): receive, send, wait, invoices, quotes, prices +- [Requests](guides/requests.md): request bodies, friendly flags, filters, paging +- [Checkout](guides/checkout.md): session and stream tokens, events +- [Scripting](guides/scripting.md): JSON, `VOLTAGE_EXECUTE`, running without a person + +## Look it up + +- [Command reference](commands.md): every command and its API route +- [Output](reference/output.md): tables, the JSON envelope, stdout and stderr +- [Exit codes](reference/exit-codes.md) + +## Understand + +- [Safety model](concepts/safety-model.md): `--execute`, dry runs, payment IDs, Ctrl-C, redaction + +## For AI agents + +Use `--json` and branch on exit codes. Describe a change with `--dry-run` before you send it with `--execute`. Never pass `--show-secrets`. For product concepts, see the [Voltage API docs](https://voltageapi.com/v1/docs). + +## For contributors + +- [Development guide](development.md): build, test, release +- [Style guide](quality.md): code conventions diff --git a/docs/assets/voltage-logo-dark.svg b/docs/assets/voltage-logo-dark.svg new file mode 100644 index 0000000..090111f --- /dev/null +++ b/docs/assets/voltage-logo-dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/assets/voltage-logo-light.svg b/docs/assets/voltage-logo-light.svg new file mode 100644 index 0000000..18e4bd9 --- /dev/null +++ b/docs/assets/voltage-logo-light.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/concepts/safety-model.md b/docs/concepts/safety-model.md new file mode 100644 index 0000000..bed6953 --- /dev/null +++ b/docs/concepts/safety-model.md @@ -0,0 +1,41 @@ +# Safety model + +`voltage` moves money. You see every change before it is sent. You can trace every payment after a failure. Secrets stay out of output. + +## Changes need `--execute` + +Commands that change something (any method but GET) only describe their request unless you pass `--execute`, then exit with code 6. Reads run as usual. `--execute` is the only approval: nothing asks again before sending. A described change reports the same `resource_id` a real run would use, so chained commands see the same IDs in both modes. Scripts can set `VOLTAGE_EXECUTE=1` instead; see [scripting](../guides/scripting.md#send-changes-from-a-script). + +## Dry runs + +`-n, --dry-run` validates any API command and describes the request it would send, then stops: it reports the method, URL, query, `Origin` header, and body, plus a `run` object with what a real run would do next. That is the same plan a real run follows: + +- A `read` says whether it checks the wallet's environment and whether it then pages, waits for a payment, or waits for a checkout session. +- A `mutation` says whether it verifies the wallet's environment, the ID it would journal for recovery, whether a failed payment submission is reconciled, and what it waits for. +- An `event_stream` follows checkout events. + +A dry run authenticates nothing, makes no network request (including the wallet environment check a real mutation performs), prompts for nothing, writes no journal entry, and does not create `--output-file`. Body and metadata-filter values are replaced with `[OMITTED]` unless the field holds only identifiers, enums, or amounts; `--show-secrets` shows them. `--data -` still reads stdin, and a friendly create generates a fresh ID each run unless you pass `--id`. Passing local validation does not mean the API will accept the request. + +```sh +voltage payments send --profile prod --wallet WALLET_ID --currency btc \ + --invoice BOLT11_INVOICE --max-fee 10 --fee-unit sats --dry-run +voltage wallets delete WALLET_ID --profile prod -n --json +``` + +The result is a normal envelope with `outcome: dry_run` and no `http_status`. + +## Payment IDs and the request journal + +Payments and treasury movements carry an ID, generated for you unless you pass `--id`. Before sending, the CLI records the ID, operation, scope, and request hash (never the body) in a private journal under `requests/`, and it refuses to reuse an ID with a different request. If the connection drops after a submission, the CLI makes one short read of the ID: if the payment is visible, it reports acceptance; otherwise it exits with code 4 and the ID, without resubmitting. Check the ID before deciding to retry. + +## Ctrl-C + +Ctrl-C exits with code 130, including at the hidden API-key prompt. Before a write is sent, it reports that no resource change was submitted, or, if a login was being refreshed, created, or logged out, how to recover it. Once a write may have been sent, it reports the outcome as unknown, with the original ID and reconciliation instructions for a payment. It never cancels a submitted request. + +## Secrets and redaction + +Known secret fields and the credentials the CLI presented are redacted from normal output and from error details. Operations that return a one-time secret (webhook creation and key rotation, checkout sessions, stream tokens) refuse to run unless you pass `--output-file PATH`, which writes the complete response to a new owner-only file, or `--show-secrets`. Treat your own metadata as potentially sensitive; redaction cannot recognise it. + +API text is stripped of terminal control, bidirectional formatting, and invisible characters (including tag characters that can carry hidden text) in tables, notices, and errors. + +Related: [payments](../guides/payments.md), [exit codes](../reference/exit-codes.md). diff --git a/docs/development.md b/docs/development.md index 4bb2fdd..beaba13 100644 --- a/docs/development.md +++ b/docs/development.md @@ -172,7 +172,7 @@ the same way you would against production. voltage profiles create staging --account me --org ORG_ID --env ENV_ID voltage wallets list --profile staging voltage payments receive --profile staging --wallet WALLET_ID \ - --currency btc --kind bolt11 --amount 1000 --unit sats --qr + --currency btc --kind bolt11 --amount 1000 --unit sats --qr --execute ``` Once the profile exists, fold it into the alias to drop the flag as well: diff --git a/docs/guides/checkout.md b/docs/guides/checkout.md new file mode 100644 index 0000000..b19e03a --- /dev/null +++ b/docs/guides/checkout.md @@ -0,0 +1,12 @@ +# Checkout + +Checkout commands use their own credentials and never fall back to your account. Provide a session token through `VOLTAGE_CHECKOUT_TOKEN` and a stream token through `VOLTAGE_STREAM_TOKEN`, or read either from a private file or stdin with `--token-file PATH` / `--token-file -`. Pass `--origin https://shop.example.com` when the session requires a browser origin. + +```sh +voltage checkout sessions get SESSION_ID --token-file ./session.token +voltage checkout events watch --token-file - < ./stream.token +``` + +`checkout sessions get` retries while the session projection is being created, following the API's retry hints. `checkout events watch` streams server-sent events as `outcome: event` envelopes until the connection closes or `--timeout` passes; it does not reconnect. Ctrl-C exits cleanly. + +Related: [output reference](../reference/output.md). diff --git a/docs/guides/install.md b/docs/guides/install.md new file mode 100644 index 0000000..0bd3762 --- /dev/null +++ b/docs/guides/install.md @@ -0,0 +1,48 @@ +# Install + +Download the archive for your platform from the [releases page](https://github.com/voltagecloud/voltage-cli/releases). Each release ships macOS (Apple Silicon and Intel), Linux (x86-64 and ARM64), and Windows (x86-64) archives with shell completions, a signed `SHA256SUMS` file, and a Homebrew formula (`voltage.rb`). Extract the archive and put `voltage` (or `voltage.exe`) on your `PATH`. + +## Verify a download + +Every archive carries a SLSA build-provenance attestation, and `SHA256SUMS` is signed with Sigstore (keyless; no keys to trust out of band). Before installing, verify both with the [GitHub CLI](https://cli.github.com) and [cosign](https://docs.sigstore.dev/cosign/system_config/installation/): + +```sh +# The archive was built by this repository's release workflow from its version tag. +gh attestation verify voltage-*.tar.gz --repo voltagecloud/voltage-cli + +# The checksums were signed by that same workflow run. +cosign verify-blob SHA256SUMS --bundle SHA256SUMS.sigstore.json \ + --certificate-identity-regexp 'https://github.com/voltagecloud/voltage-cli/.github/workflows/release.yml' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com + +# The archive matches the signed checksums. +sha256sum --check SHA256SUMS --ignore-missing +``` + +## Build from source + +Install [Rust](https://rustup.rs) and run: + +```sh +cargo install --git https://github.com/voltagecloud/voltage-cli --locked +voltage --version +``` + +From a clone, `cargo install --path . --locked` does the same. The toolchain is pinned in `rust-toolchain.toml`; no system libraries are needed. + +## Credential stores + +Credentials are stored in the macOS Keychain, the Linux Secret Service (over D-Bus, with no system library required), or the Windows Credential Manager. On a headless Linux machine without an unlocked Secret Service, pass `--credential-store file` when you log in or import a key. + +## Shell completions + +Completions come from the same definitions as the commands: + +```sh +voltage completions bash > voltage.bash +voltage completions zsh > _voltage +voltage completions fish > voltage.fish +voltage completions powershell > _voltage.ps1 +``` + +Related: [log in and use profiles](login-and-profiles.md). diff --git a/docs/guides/login-and-profiles.md b/docs/guides/login-and-profiles.md new file mode 100644 index 0000000..8ba311d --- /dev/null +++ b/docs/guides/login-and-profiles.md @@ -0,0 +1,70 @@ +# Log in and use profiles + +## Log in + +`voltage login` prints a verification URL and a code, opens the URL in your browser, and waits for your approval after your usual login and MFA. Use `--no-browser` on a remote machine and open the URL yourself. The saved login refreshes its tokens automatically; the refresh token rotates. + +```sh +voltage login # saved under your email address +voltage login --account work # saved under a name of your choice +voltage login --no-browser --credential-store file +voltage auth status # identity and expiry, never the secret +voltage logout # revokes the session, then removes it +voltage logout --local # removes local credentials only +``` + +A login grants the permissions your account already has. Profiles do not restrict them. + +## API keys + +For automation, use an environment API key. Import one so it never lands in shell history, or pass it through `VOLTAGE_API_KEY`: + +```sh +voltage auth import-key --account ci --org ORG_ID --env ENV_ID +secret-manager-command | voltage auth import-key --stdin --account ci --org ORG_ID --env ENV_ID +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. + +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`: + +```sh +voltage wallets list --org ORG_ID --env ENV_ID +voltage wallets get WALLET_ID --org ORG_ID +voltage payments list --org ORG_ID --env ENV_ID --env OTHER_ENV_ID +``` + +Without a profile, flags override `VOLTAGE_ORGANIZATION_ID`, `VOLTAGE_ENVIRONMENT_ID`, and `VOLTAGE_WALLET_ID`. + +Only endpoints that filter by environment receive `--env`. Organization-wide commands say so in their help, and wallet mutations verify a supplied environment against the wallet before changing anything. + +## Profiles + +A profile bundles an organization, an environment, and a credential, and ignores the scope variables above and `VOLTAGE_API_KEY`; flags still override its scope for one command. + +```sh +voltage profiles create staging --org ORG_ID --env ENV_ID --account work +voltage profiles list +voltage profiles get staging +voltage profiles delete staging +``` + +## See the effective scope + +`voltage context` shows the effective profile, account, organization, environments, wallet, API URL, and configuration directory, and where each came from (`flag`, `profile`, `environment`, `default`, or `unset`). It reports why commands would reject the credential selection as `credential_problem` (`empty_api_key`, `no_credential`, `multiple_credentials`, `unknown_credential`, or `bound_elsewhere` for an API key used outside its organization or environment), using the same selection commands use. It also lists `VOLTAGE_*` variables that are set but ignored because a flag or the selected profile takes precedence. It reads no saved secret, makes no network request, and creates no files: + +```sh +voltage context --profile staging +voltage context --json | jq -r '.data.organization_id.value' +``` + +## Configuration directory + +Configuration lives in `$XDG_CONFIG_HOME/voltage` or `~/.config/voltage`; override the directory with `VOLTAGE_CONFIG_DIR` or `--config-dir`. The directory and everything in it are owner-only. + +Related: [install](install.md), [payments](payments.md), [scripting](scripting.md). diff --git a/docs/guides/payments.md b/docs/guides/payments.md new file mode 100644 index 0000000..f09189c --- /dev/null +++ b/docs/guides/payments.md @@ -0,0 +1,53 @@ +# Payments + +## Receive and send + +```sh +voltage payments receive --profile prod --wallet WALLET_ID \ + --currency btc --kind bolt11 --amount 1000 --unit sats --wait ready --execute +voltage payments send --profile prod --wallet WALLET_ID \ + --currency btc --invoice BOLT11_INVOICE --max-fee 10 --fee-unit sats --execute +voltage payments send --profile prod --wallet WALLET_ID \ + --currency btc --address BITCOIN_ADDRESS --amount 1000 --unit sats --execute +``` + +Amounts are decimals in the unit you name (`msats`, `sats`, `btc`, `cents`, `usd`) and are converted to the API's integer base units exactly; a value with more precision than the unit allows is rejected. `--currency` is the wallet currency for sends and the receive currency for open-amount receives. Fee limits cover network and provider fees only. A send notes on stderr that its fee limit excludes processing fees. + +Run a change without `--execute` to see exactly what it would send, then add `--execute` to send it. There is no confirmation prompt. See the [safety model](../concepts/safety-model.md). + +## 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: + +```sh +voltage payments get PAYMENT_ID --profile prod --wait completed --timeout 120 +``` + +## Show an invoice + +For a BOLT11 receive, `--qr` prints the invoice as a terminal QR code and `--copy` puts it on the clipboard as soon as it is ready. Either flag implies `--wait ready`; with `--wait completed` the invoice is shown first and polling continues to settlement. + +## Quotes and lines of credit + +Quotes apply to USD lines of credit: a USD payment requires `--quote` with a quote for that line of credit. + +```sh +voltage quotes create --profile prod --credit-line CREDIT_LINE_ID \ + --network mutinynet --amount 10 --unit usd --to btc --execute +``` + +On-chain and BIP21 payments and treasury movements are features Voltage enables per organization; the API rejects them with `feature_flag_disabled` until then, and the CLI says so. + +## Prices + +To see what an amount is worth in the other currency, or the current BTC/USD price, use the price service (no credentials needed): + +```sh +voltage price +voltage convert 10 usd --to btc +voltage convert 21000 sats --to usd --at 2026-09-01T00:00:00Z +``` + +A conversion reports the result in every unit of its currency (`msats`, `sats`, and `btc`, or `cents` and `usd`) together with the quote it used, including the minute the service rounded to. + +Related: [requests](requests.md), [safety model](../concepts/safety-model.md), [exit codes](../reference/exit-codes.md). diff --git a/docs/guides/requests.md b/docs/guides/requests.md new file mode 100644 index 0000000..e4bec17 --- /dev/null +++ b/docs/guides/requests.md @@ -0,0 +1,35 @@ +# Requests + +Commands mirror the API contract; see [the command reference](../commands.md) for the full list with routes. Every command and group has descriptive `--help`; body-building commands also show required flag combinations and examples: + +```sh +voltage --help +voltage payments --help +voltage payments receive --help +``` + +## Request bodies + +Every operation that takes a body accepts its complete API payload from a file or stdin. The JSON is sent exactly as given, including fields the CLI does not know about, and is limited to 16 MiB: + +```sh +voltage payments create --profile prod --data @payment.json --execute +cat payment.json | voltage payments create --profile prod --data - --execute +``` + +Common operations also offer friendly flags (`wallets create`, `wallets update`, `payments receive`, `payments send`, `quotes create`, `webhooks create`, `webhooks update`); `--help` lists them. Friendly flags and `--data` are mutually exclusive, and a payload whose IDs conflict with the selected scope is rejected. + +## Filters + +Documented query filters are flags named after the parameter. Parameters with a fixed set of values accept only those values, case-insensitively, and are sent in the contract's spelling. `--query NAME=VALUE` sets any documented parameter and can be repeated: + +```sh +voltage payments list --profile prod --statuses completed --statuses failed \ + --metadata order_id=123 --query sort_order=desc --all +``` + +## Paging and timeouts + +`--all` follows pagination by cursor, which the API recommends and the CLI requests by default; endpoints that only page by offset are followed by offset and count. The API has deprecated explicit offset paging (`--offset`, `--pagination offset`) where cursors exist, and the CLI warns when you use it. `--timeout SECONDS` (default 60) bounds the whole request, including paging and waits. + +Related: [safety model](../concepts/safety-model.md), [output reference](../reference/output.md). diff --git a/docs/guides/scripting.md b/docs/guides/scripting.md new file mode 100644 index 0000000..9bace58 --- /dev/null +++ b/docs/guides/scripting.md @@ -0,0 +1,27 @@ +# Scripting + +On a terminal, results are tables; when piped, they are JSON. `--json` forces JSON. Scripts should read JSON: the [envelope](../reference/output.md#json) is stable, and the table layout may change between releases. + +```sh +voltage payments list --profile prod --json | jq '.data.items[].status' +voltage context --json | jq -r '.data.organization_id.value' +``` + +## Send changes from a script + +Commands that change something only describe their request unless you pass `--execute`, then exit with code 6 so a script that forgot it fails loudly. To send every change in a script or chain of commands, set `VOLTAGE_EXECUTE=1` (or `true`) instead: + +```sh +VOLTAGE_EXECUTE=1 ./pay-invoices.sh +``` + +`0`, `false`, or an empty value keeps the default, and any other value fails every API command, reads included. The flag wins over the variable, a change sent because of the variable says so on stderr, and `voltage context` reports it as `execute_changes` with its source. + +## Run without a person + +- `--no-input` forbids the hidden API-key prompt even on a terminal. Supply credentials through the environment, a private file, or `--stdin` (for `auth import-key`). +- `-q, --quiet` suppresses optional status and notices, never requested results, errors, warnings, or recovery IDs. +- Diagnostics, prompts, and progress go to stderr, so stdout carries only results. +- Branch on [exit codes](../reference/exit-codes.md). Exit code 4 means the outcome is unknown: check the payment ID before you retry. + +Related: [safety model](../concepts/safety-model.md), [output reference](../reference/output.md). diff --git a/docs/reference/exit-codes.md b/docs/reference/exit-codes.md new file mode 100644 index 0000000..d47ecaa --- /dev/null +++ b/docs/reference/exit-codes.md @@ -0,0 +1,14 @@ +# Exit codes + +| Code | Meaning | +|---|---| +| 0 | Success, or an accepted submission without waiting | +| 1 | The API rejected the request or a payment failed | +| 2 | Invalid invocation or configuration | +| 3 | Authentication or authorization failure | +| 4 | Transport failure or a submission with an unknown outcome | +| 5 | A wait or pagination deadline passed | +| 6 | A change was described but not sent: pass `--execute` or set `VOLTAGE_EXECUTE=1` | +| 130 | Interrupted | + +After exit code 4 for a payment, check its ID before you retry; see [payment IDs](../concepts/safety-model.md#payment-ids-and-the-request-journal). After exit code 5, keep watching with `voltage payments get PAYMENT_ID --profile prod --wait completed`. diff --git a/docs/reference/output.md b/docs/reference/output.md new file mode 100644 index 0000000..ab97dcc --- /dev/null +++ b/docs/reference/output.md @@ -0,0 +1,32 @@ +# Output reference + +On a terminal, results are readable tables; when piped, they are JSON. `--json` forces JSON and `--output table|json|ndjson` selects explicitly. + +## Tables + +- Tables show up to seven of the fields a list's items share (ID, name, status, amount, and similar) with aligned columns, a result count, and the next cursor. +- Amounts are shown in their base units (`msats` or `cents`). +- A table wider than the terminal (its width, or `COLUMNS`) hides trailing columns and then shortens text cells with `…`, naming what it hid. IDs, timestamps, and amounts are never shortened. +- Timestamps are shown in UTC to the second, such as `2024-02-27 01:58:51 UTC`; JSON keeps the exact value. +- A table starts with the outcome only when it adds something, such as `accepted` or `dry_run`, or when there is no data to show. +- A single resource is shown as aligned `field value` lines, with nested fields joined by dots. `voltage context` shows each setting as `name value (source)`. + +The table layout may change between releases; scripts should use JSON. + +## JSON + +The JSON envelope is stable: + +```json +{"http_status":202,"data":null,"resource_id":"PAYMENT_ID","outcome":"accepted"} +``` + +API fields stay inside `data`. With `--all`, JSON output collects the pages into `data.pages`, and NDJSON writes one envelope per page as it arrives. + +## Streams + +Diagnostics, prompts, and progress go to stderr. Interactive stderr shows immediate, cancellable status for network requests and waits; redirected stderr gets no spinner/control codes. `-q, --quiet` suppresses optional status and notices, never requested results, errors, warnings, or recovery IDs. + +Human errors start with `error:` and may include a safe `hint:`. `--json` makes command-line parse errors machine-readable as well as runtime errors. A downstream consumer that closes stdout early is treated as successful pipeline completion. + +Related: [scripting](../guides/scripting.md), [safety model](../concepts/safety-model.md#secrets-and-redaction). diff --git a/src/cli.rs b/src/cli.rs index 6420ab6..8cdc0e4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -983,10 +983,10 @@ fn endpoint_examples(id: OperationId, command_name: &str) -> Option<&'static str " voltage payments send --profile prod --wallet WALLET_ID --currency btc --invoice BOLT11_INVOICE --max-fee 10 --fee-unit sats --execute\n voltage payments send --profile prod --wallet WALLET_ID --currency btc --address BITCOIN_ADDRESS --amount 1000 --unit sats --execute", ), (OperationId::CreatePayment, "receive") => Some( - " voltage payments receive --profile prod --wallet WALLET_ID --currency btc --kind bolt11 --amount 1000 --unit sats --wait ready", + " voltage payments receive --profile prod --wallet WALLET_ID --currency btc --kind bolt11 --amount 1000 --unit sats --wait ready --execute", ), (OperationId::CreateWallet, "create") => Some( - " voltage wallets create --profile prod --name treasury --credit-line CREDIT_LINE_ID --network mutinynet --limit 100000", + " voltage wallets create --profile prod --name treasury --credit-line CREDIT_LINE_ID --network mutinynet --limit 100000 --execute", ), _ => None, } @@ -1376,4 +1376,193 @@ mod tests { ); assert!(Invocation::try_parse_from(["voltage", "bogus"]).is_err()); } + + /// The README and the docs index, guides, concepts, reference, and development guide, as + /// (path from the repository root, contents). + fn user_docs() -> Vec<(PathBuf, String)> { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mut paths = ["README.md", "docs/README.md", "docs/development.md"] + .map(|path| root.join(path)) + .to_vec(); + for dir in ["guides", "concepts", "reference"] { + for entry in std::fs::read_dir(root.join("docs").join(dir)).unwrap() { + paths.push(entry.unwrap().path()); + } + } + paths + .into_iter() + .map(|path| { + let text = std::fs::read_to_string(&path).unwrap(); + (path.strip_prefix(&root).unwrap().to_owned(), text) + }) + .collect() + } + + /// Every `voltage` line in a documentation shell block, with continuations joined, + /// comments dropped, and pipes, redirections, and leading variable assignments cut away. + fn doc_examples() -> Vec { + let mut examples = Vec::new(); + for (_, text) in user_docs() { + let mut shell = false; + let mut line = String::new(); + for raw in text.lines().map(str::trim) { + if let Some(language) = raw.strip_prefix("```") { + shell = matches!(language, "sh" | "bash" | "shell" | "console"); + continue; + } + if !shell { + continue; + } + line.push_str(raw.trim_end_matches('\\')); + if raw.ends_with('\\') { + continue; + } + let command = line.split(" #").next().unwrap_or_default(); + let command = command.split('|').find(|part| part.contains("voltage ")); + if let Some(command) = command.and_then(|part| part.split(['<', '>']).next()) { + let start = command.find("voltage ").unwrap_or_default(); + let command = command[start..].trim(); + // `--build` belongs to the development guide's staging wrapper. + if command != "voltage --build" { + examples.push(command.to_owned()); + } + } + line.clear(); + } + } + examples + } + + /// GitHub's anchors for a document's headings: lowercase, punctuation dropped, spaces + /// as hyphens, and `-1`, `-2`, ... after repeats. Lines inside code fences are skipped. + fn anchors(text: &str) -> Vec { + let mut anchors: Vec = Vec::new(); + let mut seen: Vec = Vec::new(); + let mut fenced = false; + for line in text.lines() { + if line.trim_start().starts_with("```") { + fenced = !fenced; + } + if fenced || !line.starts_with('#') { + continue; + } + let base: String = line + .trim_start_matches('#') + .trim() + .to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_')) + .map(|c| if c == ' ' { '-' } else { c }) + .collect(); + let repeats = seen.iter().filter(|heading| **heading == base).count(); + anchors.push(match repeats { + 0 => base.clone(), + n => format!("{base}-{n}"), + }); + seen.push(base); + } + anchors + } + + #[test] + fn documentation_links_reach_existing_files_and_headings() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + for (path, text) in user_docs() { + // Markdown links, and the README's HTML image paths. + let markdown = text + .split("](") + .skip(1) + .filter_map(|rest| rest.split(')').next()); + let html = ["src=\"", "srcset=\""] + .into_iter() + .flat_map(|attribute| text.split(attribute).skip(1)) + .filter_map(|rest| rest.split('"').next()); + for link in markdown.chain(html) { + if link.contains("://") { + continue; + } + let (file, fragment) = link.split_once('#').unwrap_or((link, "")); + let target = if file.is_empty() { + root.join(&path) + } else { + root.join(&path).parent().unwrap().join(file) + }; + let contents = std::fs::read_to_string(&target) + .unwrap_or_else(|error| panic!("{}: {link}: {error}", path.display())); + if !fragment.is_empty() { + assert!( + anchors(&contents).iter().any(|anchor| anchor == fragment), + "{}: no heading for {link}", + path.display() + ); + } + } + } + } + + /// Every example line in the long help of every command. + fn help_examples(cmd: &mut ClapCommand, examples: &mut Vec) { + let help = cmd.render_long_help().to_string(); + if let Some((_, section)) = help.split_once("Examples:") { + examples.extend( + section + .lines() + .skip(1) + .map(str::trim) + .take_while(|line| line.starts_with("voltage ")) + .map(str::to_owned), + ); + } + for sub in cmd.get_subcommands_mut() { + help_examples(sub, examples); + } + } + + /// A floor below the number of documented examples, so a scanner that stops finding + /// them fails the test instead of checking nothing. + const MIN_DOC_EXAMPLES: usize = 40; + + #[test] + fn documented_examples_parse_and_changes_say_whether_to_send() { + let mut examples = doc_examples(); + let from_docs = examples.len(); + let mut root = command(); + root.build(); + help_examples(&mut root, &mut examples); + assert!( + from_docs >= MIN_DOC_EXAMPLES && examples.len() > from_docs, + "{examples:?}" + ); + for example in &examples { + // Placeholder IDs stand for real UUIDs; every other placeholder parses as typed. + let args = example.split_whitespace().map(|word| { + if word.ends_with("_ID") && word.chars().all(|c| c.is_ascii_uppercase() || c == '_') + { + WALLET + } else { + word + } + }); + let matches = match command().try_get_matches_from(args) { + Ok(matches) => matches, + Err(error) + if matches!( + error.kind(), + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion + ) => + { + continue; + } + Err(error) => panic!("{example}: {error}"), + }; + let invocation = Invocation::from_matches(&matches) + .unwrap_or_else(|error| panic!("{example}: {error:?}")); + if let Command::Api(api) = invocation.command { + assert!( + api.operation.method == Method::Get || api.requested != Requested::Unspecified, + "a change without --execute or --dry-run only describes itself: {example}" + ); + } + } + } }