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
2 changes: 1 addition & 1 deletion docs/guides/login-and-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ 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:
`voltage context` shows the effective profile, account, organization, environments, wallet, API and auth URLs, 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`, `bound_elsewhere` for an API key used outside its organization or environment, or `auth_url_mismatch` for a browser login used with a different `--auth-url`), 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
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/exit-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@

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`.

Many errors for exit codes 2 and 3 carry a `hint:` (or `error.hint` with `--json`) that names the next command: the discovery command for a missing `--org`, `--env`, or `--wallet`, the saved credential and profile names for an unknown or ambiguous selection, `voltage logout --local --account NAME` and then `voltage login --account NAME` for an expired or revoked login, `voltage auth status` for a rejected account credential (HTTP 401), and `voltage context` for an account credential that cannot reach the selected scope (HTTP 403). A rejected checkout token points to `--token-file` or the variable that supplies it, never to the account credential. A payment rejected with `quote_required` points to `voltage quotes create`.
Many errors for exit codes 2 and 3 carry a `hint:` (or `error.hint` with `--json`) that names the next command: the discovery command for a missing `--org`, `--env`, or `--wallet`, the saved credential and profile names for an unknown or ambiguous selection, `voltage logout --local --account NAME` and then `voltage login --account NAME` for an expired or revoked login, `voltage logout --account NAME` and then `voltage login --account NAME --auth-url URL` to move a browser login to a different auth URL, `voltage auth status` for a rejected account credential (HTTP 401), and `voltage context` for an account credential that cannot reach the selected scope (HTTP 403). A rejected checkout token points to `--token-file` or the variable that supplies it, never to the account credential. A payment rejected with `quote_required` points to `voltage quotes create`.
2 changes: 1 addition & 1 deletion docs/reference/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ API fields stay inside `data`. With `--all`, JSON output collects the pages into

## 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.
Diagnostics, prompts, and progress go to stderr. Interactive stderr shows immediate, cancellable status for network requests and waits, with the time waited so far on payment waits and device approval; 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:`, which leads with the API's reason when it rejects a request with a client error (HTTP 4xx), and may include a safe `hint:` and an indented `details:` block of `field value` lines. `--json` reports the complete API response in `error.detail`, and 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.

Expand Down
13 changes: 11 additions & 2 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use crate::{
payment::{PaymentDirection, PaymentView, ReceiveKind, StatusText, WaitProgress, WaitTarget},
registry::{AuthScheme, Method, Operation, OperationId, operation},
secret::Secret,
terminal::Terminal,
terminal::{self, Terminal},
};
use futures_util::StreamExt;
use serde::{Deserialize, Deserializer, Serialize};
Expand Down Expand Up @@ -1124,6 +1124,7 @@ async fn wait_for_payment(
let mut invoice_presented = false;
let mut last_status: Option<StatusText> = None;
let mut last_notice = Instant::now() - STATUS_NOTICE_INTERVAL;
let started = Instant::now();
loop {
let view = PaymentView::from_body(&response.body);
if let Some(status) = &view.status
Expand Down Expand Up @@ -1177,7 +1178,15 @@ async fn wait_for_payment(
))
.with_detail(ErrorDetail::pending(id)));
}
tokio::time::sleep(pause).await;
api.terminal
.during(
format!(
"Waiting for the payment to be {target}... {}",
terminal::elapsed(started.elapsed())
),
tokio::time::sleep(pause),
)
.await;
match tokio::time::timeout_at(deadline, api.read(&path)).await {
Ok(Ok(next)) => {
pause = next.retry_after.unwrap_or_else(|| backoff.pause());
Expand Down
28 changes: 21 additions & 7 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::{
Login, Scope, Settings, read_secret,
},
secret::Secret,
terminal::Terminal,
terminal::{self, Terminal},
};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::Value;
Expand Down Expand Up @@ -402,6 +402,20 @@ pub(crate) fn select<'a>(
selected: Some(Selected::Saved { name, account }),
});
}
// A login refreshes only at its recorded origin. An invalid flag is not a mismatch here:
// `resolve` reports it as an invalid URL.
if account.kind == AccountKind::User
&& flags
.auth_url
.as_deref()
.and_then(|raw| base_url(raw).ok())
.is_some_and(|url| url != account.auth_url)
{
return Err(Rejected {
problem: CredentialProblem::AuthUrlMismatch,
selected: Some(Selected::Saved { name, account }),
});
}
Ok(Selected::Saved { name, account })
}

Expand Down Expand Up @@ -492,13 +506,17 @@ async fn poll_device_grant(
.interval
.unwrap_or(MIN_POLL_INTERVAL)
.clamp(MIN_POLL_INTERVAL, MAX_POLL_INTERVAL);
let started = tokio::time::Instant::now();
loop {
if tokio::time::Instant::now() + Duration::from_secs(interval) >= deadline {
return Err(Error::timeout("Login expired; run voltage login again"));
}
terminal
.during(
"Waiting for device approval...",
format!(
"Waiting for device approval... {}",
terminal::elapsed(started.elapsed())
),
tokio::time::sleep(Duration::from_secs(interval)),
)
.await;
Expand Down Expand Up @@ -634,12 +652,8 @@ pub async fn resolve(
match account.kind {
AccountKind::ApiKey => settings.read_credential(&name),
AccountKind::User => {
// `select` rejected a valid flag that differs from the saved URL.
let url = base_url(flags.auth_url.as_deref().unwrap_or(&account.auth_url))?;
if url != account.auth_url {
return Err(Error::usage(
"Saved credentials cannot be refreshed at a different auth URL",
));
}
// Credential writes replace the whole value atomically, so a fresh login is used
// without the lock. Only a refresh, which spends the single-use refresh token, is
// serialized.
Expand Down
9 changes: 9 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ pub(crate) enum CredentialProblem {
UnknownCredential,
/// The saved API key is bound to a different organization or environment.
BoundElsewhere,
/// `--auth-url` names a different auth service than the one the browser login was saved
/// with, and a login refreshes only at its recorded origin.
AuthUrlMismatch,
}

impl CredentialProblem {
Expand Down Expand Up @@ -139,6 +142,12 @@ impl CredentialProblem {
"Saved credentials: {}. Select a key for this scope or a browser login with --account NAME.",
saved.unwrap_or_default()
)),
Self::AuthUrlMismatch => Error::usage(
"Saved credentials cannot be refreshed at a different auth URL",
)
.with_hint(
"Omit --auth-url to use the URL the login was saved with (`voltage context` shows it). To move the login to this URL, run `voltage logout --account NAME`, then `voltage login --account NAME --auth-url URL`.",
),
}
}
}
Expand Down
27 changes: 26 additions & 1 deletion src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ use crate::{
Result,
auth::{self, Rejected, Selected},
cli::GlobalFlags,
config::{self, API_URL, AccountKind, CredentialProblem, CredentialStore, Settings, Source},
config::{
self, API_URL, AUTH_URL, AccountKind, CredentialProblem, CredentialStore, Settings, Source,
},
};
use serde::Serialize;
use uuid::Uuid;
Expand Down Expand Up @@ -81,6 +83,11 @@ pub struct Context {
wallet_id: Setting<Option<Uuid>>,
webhook_id: Setting<Option<Uuid>>,
api_url: Setting<String>,
/// Where a browser login signs in and refreshes. A selected browser login always uses the
/// URL it was saved with (sourced like the credential); a different `--auth-url` is the
/// `auth_url_mismatch` credential problem. An API key never uses the auth service, so
/// otherwise it is the normalized flag, else the built-in default.
auth_url: Setting<String>,
config_dir: Setting<String>,
config_file: ConfigFile,
/// Whether changes are sent without `--execute`, from `VOLTAGE_EXECUTE`, so an exported
Expand Down Expand Up @@ -123,6 +130,23 @@ pub fn inspect(settings: &Settings, global: &GlobalFlags) -> Result<Context> {
},
};
let credential = selected.as_ref().map(CredentialSummary::from);
let flag = global.auth_url.as_deref().map(auth::base_url).transpose()?;
let auth_url = match (&selected, flag) {
(Some(Selected::Saved { account: saved, .. }), _) if saved.kind == AccountKind::User => {
Setting {
value: saved.auth_url.clone(),
source: account.source,
}
}
(_, Some(url)) => Setting {
value: url,
source: Source::Flag,
},
(_, None) => Setting {
value: AUTH_URL.to_owned(),
source: Source::Default,
},
};
let config_file = settings.config_file();
Ok(Context {
profile: Setting {
Expand Down Expand Up @@ -162,6 +186,7 @@ pub fn inspect(settings: &Settings, global: &GlobalFlags) -> Result<Context> {
source: Source::Default,
},
},
auth_url,
config_dir: Setting {
value: settings.dir.display().to_string(),
source: config_dir_source,
Expand Down
59 changes: 36 additions & 23 deletions src/output/human.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,16 +276,16 @@ fn amount(value: &Value) -> Option<String> {
Some(scrub_line(&format!("{sign}{amount} {unit}")))
}

/// One `field value` line, with where a local setting's value came from.
/// One field: its value's lines, and where a local setting's value came from.
struct Line {
key: String,
value: String,
values: Vec<String>,
source: Option<String>,
}

/// Nested objects flatten to dotted field names; amounts stay whole. In a local result, a
/// `{value, source}` setting is one line, `name value (source)`, and string lists are
/// comma-separated.
/// Nested objects flatten to dotted field names; amounts stay whole. A list of strings, such
/// as a dry run's notes, puts each item on its own line under the first. In a local result, a
/// `{value, source}` setting reads `name value (source)`.
fn fields(out: &mut impl Write, map: &Map<String, Value>, local: bool) -> Result<()> {
let mut lines = Vec::new();
flatten(String::new(), map, local, &mut lines);
Expand All @@ -294,10 +294,22 @@ fn fields(out: &mut impl Write, map: &Map<String, Value>, local: bool) -> Result
.map(|line| line.key.chars().count())
.max()
.unwrap_or(0);
for Line { key, value, source } in lines {
match source {
Some(source) => writeln!(out, "{key:<width$}{GAP}{value}{GAP}({source})")?,
None => writeln!(out, "{key:<width$}{GAP}{value}")?,
for Line {
key,
values,
source,
} in lines
{
let source = source
.map(|source| format!("{GAP}({source})"))
.unwrap_or_default();
for (index, value) in values.iter().enumerate() {
let (key, source) = if index == 0 {
(key.as_str(), source.as_str())
} else {
("", "")
};
writeln!(out, "{key:<width$}{GAP}{value}{source}")?;
}
}
Ok(())
Expand All @@ -313,7 +325,7 @@ fn flatten(prefix: String, map: &Map<String, Value>, local: bool, lines: &mut Ve
if local && let Some((value, source)) = value.as_object().and_then(setting) {
lines.push(Line {
key,
value: local_cell(value),
values: value_lines(value),
source: Some(scrub_line(source)),
});
continue;
Expand All @@ -324,11 +336,7 @@ fn flatten(prefix: String, map: &Map<String, Value>, local: bool, lines: &mut Ve
}
other => lines.push(Line {
key,
value: if local {
local_cell(other)
} else {
cell(other)
},
values: value_lines(other),
source: None,
}),
}
Expand All @@ -343,15 +351,16 @@ fn setting(map: &Map<String, Value>) -> Option<(&Value, &str)> {
Some((map.get("value")?, map.get("source")?.as_str()?))
}

/// Like `cell`, but a list of strings is comma-separated, or `-` when empty.
fn local_cell(value: &Value) -> String {
/// A field's value as display lines: one per item of a list of strings, `-` for an empty
/// list, and otherwise the single `cell`.
fn value_lines(value: &Value) -> Vec<String> {
let strings = value
.as_array()
.and_then(|items| items.iter().map(Value::as_str).collect::<Option<Vec<_>>>());
match strings {
Some(items) if items.is_empty() => EMPTY_CELL.into(),
Some(items) => scrub_line(&items.join(", ")),
None => cell(value),
Some(items) if items.is_empty() => vec![EMPTY_CELL.into()],
Some(items) => items.into_iter().map(scrub_line).collect(),
None => vec![cell(value)],
}
}

Expand Down Expand Up @@ -495,16 +504,19 @@ mod tests {
#[test]
fn single_resources_render_as_flattened_fields() {
let text = rendered(
json!({"id": "w", "balance": {"available": {"amount": 5, "currency": "usd"}, "held": null}, "tags": ["a", "b"], "empty": {}}),
json!({"id": "w", "balance": {"available": {"amount": 5, "currency": "usd"}, "held": null}, "tags": ["a", "b"], "none": [], "empty": {}}),
None,
);
// Each list item gets its own line; an empty list reads `-`, not `[]`.
assert_eq!(
text,
"balance.available 5 cents\n\
balance.held -\n\
empty {}\n\
id w\n\
tags [\"a\",\"b\"]\n"
none -\n\
tags a\n\
\x20 b\n"
);
}

Expand All @@ -528,7 +540,8 @@ mod tests {
"config_file.exists false\n\
config_file.path /c/config.toml\n\
credential -\n\
environment_ids {ID}, e2 (profile)\n\
environment_ids {ID} (profile)\n\
\x20 e2\n\
ignored_variables VOLTAGE_WALLET_ID\n\
notes -\n\
organization_id {ID} (flag)\n\
Expand Down
24 changes: 22 additions & 2 deletions src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use inquire::{InquireError, Select};
use std::{
fmt::{self, Display},
io::{ErrorKind, IsTerminal, Write},
time::Duration,
};
use tokio::sync::oneshot;
use uuid::Uuid;
Expand All @@ -30,7 +31,7 @@ impl Terminal {

/// Optional status for a human, cleared when the guard drops; no control codes on
/// redirected stderr.
pub fn progress(self, label: &'static str) -> Progress {
pub fn progress(self, label: impl Display) -> Progress {
let active = !self.quiet && std::io::stderr().is_terminal();
if active {
eprint!("{label}");
Expand All @@ -41,7 +42,7 @@ impl Terminal {

/// Show `label` while `work` runs. The status cannot outlive the work: it clears on
/// completion, error, or cancellation.
pub async fn during<T>(self, label: &'static str, work: impl Future<Output = T>) -> T {
pub async fn during<T>(self, label: impl Display, work: impl Future<Output = T>) -> T {
let _progress = self.progress(label);
work.await
}
Expand Down Expand Up @@ -255,6 +256,17 @@ impl Drop for TerminalModes {
}
}

/// How long a wait has run, for a status line that would otherwise look stuck: `42s`, or
/// `3m 05s` from a minute on.
pub fn elapsed(duration: Duration) -> String {
let seconds = duration.as_secs();
if seconds < 60 {
format!("{seconds}s")
} else {
format!("{}m {:02}s", seconds / 60, seconds % 60)
}
}

/// Clears its status line when dropped, including when Ctrl-C cancels the owning future.
#[must_use = "progress is cleared as soon as the guard is dropped"]
pub struct Progress {
Expand Down Expand Up @@ -296,6 +308,14 @@ mod tests {
}
}

#[test]
fn elapsed_time_reads_in_seconds_then_minutes() {
assert_eq!(elapsed(Duration::from_millis(900)), "0s");
assert_eq!(elapsed(Duration::from_secs(59)), "59s");
assert_eq!(elapsed(Duration::from_secs(60)), "1m 00s");
assert_eq!(elapsed(Duration::from_secs(185)), "3m 05s");
}

#[test]
fn scrubbing_removes_controls_and_bidirectional_marks() {
let hostile = "a\u{1b}[2J\u{9b}b\u{202e}c\u{200b}d\te\nf";
Expand Down
Loading
Loading