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 @@ -13,6 +13,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes
- `-o` is short for `--output`, as in `voltage wallets list -o json`.
- Color on a terminal: help, `error:`, `hint:`, and `details:` labels. `--color auto|always|never` (or `--no-color`) decides, else `NO_COLOR`, `CLICOLOR_FORCE`, and `CLICOLOR`. JSON is never colored.
- An animated spinner on interactive stderr, with the time waited after two seconds; `VOLTAGE_SPINNER=0` keeps it still. A payment wait on a terminal reads as a timeline: one line per status change and a closing `✓` or `✗` line.
- `voltage login` on a terminal shows the verification URL as a clickable link, the code in bold, a countdown to the code's expiry, and a closing `✓ Logged in as EMAIL` line.

## 0.2.0 - 2026-10-22

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/login-and-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## 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.
`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. On a terminal the URL is a clickable link (in terminals that support links) whose text is the whole URL, the code is bold, the wait counts down to when the CLI stops waiting for approval (the code's expiry, or 10 minutes at most), and a closing `✓ Logged in as EMAIL` line names who signed in. 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
Expand Down
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 an animated spinner for network requests and waits, with the time waited so far on payment waits and device approval and on any wait past two seconds; it is cut to the terminal's width and cleared when the wait ends or is cancelled. `VOLTAGE_SPINNER=0` (or `false`) or `TERM=dumb` draws it as a still line; a Windows console without ANSI support shows none. Redirected stderr gets no spinner or control codes.
Diagnostics, prompts, and progress go to stderr. Interactive stderr shows an animated spinner for network requests and waits, with the time waited so far on payment waits and device approval and on any wait past two seconds, and a countdown to when login stops waiting for approval (the code's expiry, or 10 minutes at most); it is cut to the terminal's width and cleared when the wait ends or is cancelled. `VOLTAGE_SPINNER=0` (or `false`) or `TERM=dumb` draws it as a still line; a Windows console without ANSI support shows none. Redirected stderr gets no spinner or control codes.

On interactive stderr a payment wait reads as a timeline: `› Payment ID accepted; waiting for completed`, one `› status · time` line each time the status changes, and a closing `✓ Payment ID completed · 4s` or `✗ Payment ID failed · 4s` before the error. Redirected stderr keeps the plain `Payment ID status: sending; waiting for completed.` lines, repeated every 15 seconds while the status holds. Symbols are Unicode when the locale is UTF-8 and `TERM` is not `dumb` or `linux` (on Windows, in Windows Terminal), and ASCII otherwise (`ok`, `x`, `>`). `-q, --quiet` suppresses optional status and notices, never requested results, errors, warnings, or recovery IDs.

Expand Down
56 changes: 50 additions & 6 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use crate::{
},
error::{CredentialRejection, CredentialSources, ErrorDetail, ResponseLimit},
secret::Secret,
style::{Line, Role},
terminal::Terminal,
};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
Expand Down Expand Up @@ -274,10 +275,15 @@ impl AuthService {
if tokio::time::Instant::now() + Duration::from_secs(interval) >= deadline {
return Err(Error::timeout("Login expired; run voltage login again"));
}
// Polling stops one interval before the deadline, so that is when the login ends.
let last_poll = deadline
.checked_sub(Duration::from_secs(interval))
.unwrap_or(deadline);
self.terminal
.waiting(
"Waiting for device approval...",
.expiring(
"Waiting for approval in your browser...",
started,
last_poll,
tokio::time::sleep(Duration::from_secs(interval)),
)
.await;
Expand Down Expand Up @@ -687,17 +693,17 @@ pub async fn login(
}
let device = auth.device_authorization().await?;
let verification = device.verification_url()?;
terminal.important(format!(
"Open {verification}\nConfirm this code: {}",
device.user_code
));
show_device_code(terminal, verification, &device.user_code);
if !flags.no_browser && webbrowser::open(verification).is_err() {
terminal.important("Could not open a browser; open the URL above manually.");
}
let token = auth.poll_device_grant(&device, submission).await?;
let login = token.into_login(None, None)?;
let session = Secret::new(login.refresh_token.expose().to_owned());
let saved = save_login(settings, &auth, login, flags, global, submission).await;
if let Ok(outcome) = &saved {
show_logged_in(terminal, outcome);
}
if saved.is_err() {
// A local storage/discovery failure must not silently orphan a remote session.
if auth.revoke(&session).await.is_err() {
Expand All @@ -710,6 +716,44 @@ pub async fn login(
saved
}

/// Where to approve the login and the code to confirm there. A terminal gets a clickable link
/// and a bold code; redirected stderr keeps the plain sentences.
fn show_device_code(terminal: Terminal, verification: &str, code: &str) {
Comment thread
thebrandonlucas marked this conversation as resolved.
if !terminal.decorates() {
terminal.important(format!("Open {verification}\nConfirm this code: {code}"));
return;
}
let step = terminal.glyphs().step;
terminal.important_line(
Line::default()
.paint(Role::Hint, step)
.text(" Open ")
.link(verification),
);
terminal.important_line(
Line::default()
.paint(Role::Hint, step)
.text(" Confirm this code: ")
.styled(Role::Emphasis, code),
);
}

/// On a terminal, a closing line that names who signed in.
fn show_logged_in(terminal: Terminal, outcome: &LoginOutcome) {
Comment thread
thebrandonlucas marked this conversation as resolved.
if !terminal.decorates() {
return;
}
let who = outcome.email.as_deref().unwrap_or(&outcome.account);
let mut line = Line::default()
.paint(Role::Success, terminal.glyphs().done)
.text(" Logged in as ")
.styled(Role::Emphasis, who);
if who != outcome.account {
line = line.text(&format!(" (account: {})", outcome.account));
}
terminal.notice_line(line);
}

/// Discover the identity behind a fresh login and save it; a local failure revokes the
/// new session so it is never silently orphaned on the server.
async fn save_login(
Expand Down
103 changes: 81 additions & 22 deletions src/progress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,29 @@ impl Progress {
}

/// Draw `label` now and, when animated, every frame after. `since` starts the time of a
/// wait that spans several status lines.
pub fn show(label: String, since: Option<Instant>, look: Look) -> Self {
Self::start(label, since, look, to_stderr)
/// wait that spans several status lines; `expires` adds a countdown to that instant.
pub fn show(
label: String,
since: Option<Instant>,
expires: Option<Instant>,
look: Look,
) -> Self {
Self::start(label, since, expires, look, to_stderr)
}

/// `show`, writing each frame with `write`.
fn start(label: String, since: Option<Instant>, look: Look, write: fn(&[u8])) -> Self {
fn start(
label: String,
since: Option<Instant>,
expires: Option<Instant>,
look: Look,
write: fn(&[u8]),
) -> Self {
let line = Arc::new(StatusLine {
label,
since: since.unwrap_or_else(Instant::now),
timed: since.is_some(),
expires,
look,
// Read once: where crossterm cannot open `/dev/tty`, each read runs `tput`.
width: terminal::stderr_width(),
Expand Down Expand Up @@ -97,6 +109,7 @@ struct StatusLine {
label: String,
since: Instant,
timed: bool,
expires: Option<Instant>,
look: Look,
/// The terminal's width when the line was first drawn.
width: Option<usize>,
Expand All @@ -113,7 +126,10 @@ impl StatusLine {
if *cleared {
return;
}
let text = self.text(frame, self.since.elapsed(), self.width);
let left = self
.expires
.map(|expires| expires.saturating_duration_since(Instant::now()));
let text = self.text(frame, self.since.elapsed(), left, self.width);
(self.write)(format!("\r\x1b[2K{text}").as_bytes());
}

Expand All @@ -124,12 +140,22 @@ impl StatusLine {
}

/// The line for one frame, cut to fit `width` so clearing never leaves a wrapped remnant.
fn text(&self, frame: usize, waited: Duration, width: Option<usize>) -> String {
let label = if self.timed || waited >= SHOW_WAITED_AFTER {
fn text(
&self,
frame: usize,
waited: Duration,
left: Option<Duration>,
width: Option<usize>,
) -> String {
let mut label = if self.timed || waited >= SHOW_WAITED_AFTER {
format!("{} {}", self.label, terminal::elapsed(waited))
} else {
self.label.clone()
};
if let Some(left) = left {
let separator = self.look.glyphs.separator;
label.push_str(&format!(" {separator} expires in {}", countdown(left)));
}
if !self.look.animate {
return fit(&label, width);
}
Expand All @@ -154,6 +180,12 @@ fn to_stderr(bytes: &[u8]) {
let _ = stderr.flush();
}

/// Time left as `m:ss`, rounded up so a countdown reaches `0:00` only when time is up.
fn countdown(left: Duration) -> String {
let seconds = left.as_secs() + u64::from(left.subsec_nanos() > 0);
format!("{}:{:02}", seconds / 60, seconds % 60)
}

/// `text` cut to leave the last of `width` columns free, so the cursor never wraps.
fn fit(text: &str, width: Option<usize>) -> String {
match width {
Expand All @@ -173,6 +205,7 @@ mod tests {
label: "Waiting...".into(),
since: Instant::now(),
timed,
expires: None,
look,
width: None,
write: |_| {},
Expand All @@ -194,25 +227,25 @@ mod tests {
fn frames_advance_and_wrap_with_the_label_after_the_glyph() {
let spinner = line(false, SPINNING);
let short = Duration::from_millis(300);
assert_eq!(spinner.text(0, short, None), "⠋ Waiting...");
assert_eq!(spinner.text(1, short, None), "⠙ Waiting...");
assert_eq!(spinner.text(10, short, None), "⠋ Waiting...");
assert_eq!(spinner.text(0, short, None, None), "⠋ Waiting...");
assert_eq!(spinner.text(1, short, None, None), "⠙ Waiting...");
assert_eq!(spinner.text(10, short, None, None), "⠋ Waiting...");
let ascii = line(
false,
Look {
glyphs: Glyphs::ASCII,
..SPINNING
},
);
assert_eq!(ascii.text(5, short, None), "\\ Waiting...");
assert_eq!(ascii.text(5, short, None, None), "\\ Waiting...");
let colored = line(
false,
Look {
color: true,
..SPINNING
},
);
let text = colored.text(0, short, None);
let text = colored.text(0, short, None, None);
assert!(text.starts_with(&paint(Role::Pending, "⠋")), "{text:?}");
assert!(text.ends_with(" Waiting..."));
}
Expand All @@ -221,33 +254,59 @@ mod tests {
fn time_shows_from_two_seconds_or_from_the_start_of_a_timed_wait() {
let quick = line(false, STILL);
assert_eq!(
quick.text(0, Duration::from_millis(1999), None),
quick.text(0, Duration::from_millis(1999), None, None),
"Waiting..."
);
assert_eq!(quick.text(0, Duration::from_secs(2), None), "Waiting... 2s");
assert_eq!(
quick.text(0, Duration::from_secs(2), None, None),
"Waiting... 2s"
);
let timed = line(true, STILL);
assert_eq!(timed.text(0, Duration::ZERO, None), "Waiting... 0s");
assert_eq!(timed.text(0, Duration::ZERO, None, None), "Waiting... 0s");
assert_eq!(
timed.text(0, Duration::from_secs(65), None),
timed.text(0, Duration::from_secs(65), None, None),
"Waiting... 1m 05s"
);
}

#[test]
fn a_countdown_follows_the_time_waited_and_rounds_up() {
let timed = line(true, STILL);
let left = Some(Duration::from_millis(581_200));
assert_eq!(
timed.text(0, Duration::from_secs(3), left, None),
"Waiting... 3s · expires in 9:42"
);
assert_eq!(countdown(Duration::from_secs(59)), "0:59");
assert_eq!(countdown(Duration::ZERO), "0:00");
let ascii = line(
false,
Look {
glyphs: Glyphs::ASCII,
..STILL
},
);
assert_eq!(
ascii.text(0, Duration::ZERO, Some(Duration::from_secs(60)), None),
"Waiting... - expires in 1:00"
);
}

#[test]
fn a_line_never_reaches_the_last_column() {
let still = line(false, STILL);
assert_eq!(still.text(0, Duration::ZERO, Some(8)), "Waiting");
assert_eq!(still.text(0, Duration::ZERO, Some(11)), "Waiting...");
assert_eq!(still.text(0, Duration::ZERO, Some(10)), "Waiting..");
assert_eq!(still.text(0, Duration::ZERO, None, Some(8)), "Waiting");
assert_eq!(still.text(0, Duration::ZERO, None, Some(11)), "Waiting...");
assert_eq!(still.text(0, Duration::ZERO, None, Some(10)), "Waiting..");
let spinner = line(false, SPINNING);
assert_eq!(spinner.text(0, Duration::ZERO, Some(8)), "⠋ Waiti");
assert_eq!(spinner.text(0, Duration::ZERO, None, Some(8)), "⠋ Waiti");
assert_eq!(fit("abc", Some(0)), "");
assert_eq!(fit("abc", None), "abc");
}

#[tokio::test]
async fn dropping_the_guard_stops_the_redraw_and_clears_once() {
let progress = Progress::start("x".into(), None, SPINNING, |_| {});
let progress = Progress::start("x".into(), None, None, SPINNING, |_| {});
let redraw = progress
.redraw
.as_ref()
Expand All @@ -261,7 +320,7 @@ mod tests {
}
assert!(redraw.is_finished());
// A still line never starts a redraw.
let still = Progress::start("x".into(), None, STILL, |_| {});
let still = Progress::start("x".into(), None, None, STILL, |_| {});
assert!(still.redraw.is_none());
assert!(Progress::hidden().line.is_none());
}
Expand Down
Loading
Loading