From f9a109611e64a30391ff881ae0ad9f80340273b9 Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:01:19 -0500 Subject: [PATCH] feat(cli): link the login URL, bold the code, and count down to expiry On an interactive stderr, voltage login shows the verification URL as an OSC 8 link whose visible text is the whole URL, the device code in bold, a spinner that counts down to the code's expiry, and a closing "Logged in as" line. Redirected stderr keeps the plain "Open URL" and "Confirm this code" lines. --- CHANGELOG.md | 1 + docs/guides/login-and-profiles.md | 2 +- docs/reference/output.md | 2 +- src/auth.rs | 56 ++++++++++++++-- src/progress.rs | 103 +++++++++++++++++++++++------- src/style.rs | 54 +++++++++++++++- src/terminal.rs | 25 ++++++-- tests/cli_integration.rs | 85 ++++++++++++++++++++++-- 8 files changed, 287 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b13904..df1b26f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/guides/login-and-profiles.md b/docs/guides/login-and-profiles.md index 9f38ccb..2e11cb0 100644 --- a/docs/guides/login-and-profiles.md +++ b/docs/guides/login-and-profiles.md @@ -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 diff --git a/docs/reference/output.md b/docs/reference/output.md index 94b3bed..3487cf8 100644 --- a/docs/reference/output.md +++ b/docs/reference/output.md @@ -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. diff --git a/src/auth.rs b/src/auth.rs index 5d4e12a..23c2446 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -15,6 +15,7 @@ use crate::{ }, error::{CredentialRejection, CredentialSources, ErrorDetail, ResponseLimit}, secret::Secret, + style::{Line, Role}, terminal::Terminal, }; use serde::{Deserialize, Serialize, de::DeserializeOwned}; @@ -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; @@ -687,10 +693,7 @@ 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."); } @@ -698,6 +701,9 @@ pub async fn login( 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() { @@ -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) { + 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) { + 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( diff --git a/src/progress.rs b/src/progress.rs index f1264f1..19bb5a6 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -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, 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, + expires: Option, + look: Look, + ) -> Self { + Self::start(label, since, expires, look, to_stderr) } /// `show`, writing each frame with `write`. - fn start(label: String, since: Option, look: Look, write: fn(&[u8])) -> Self { + fn start( + label: String, + since: Option, + expires: Option, + 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(), @@ -97,6 +109,7 @@ struct StatusLine { label: String, since: Instant, timed: bool, + expires: Option, look: Look, /// The terminal's width when the line was first drawn. width: Option, @@ -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()); } @@ -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) -> String { - let label = if self.timed || waited >= SHOW_WAITED_AFTER { + fn text( + &self, + frame: usize, + waited: Duration, + left: Option, + width: Option, + ) -> 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); } @@ -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) -> String { match width { @@ -173,6 +205,7 @@ mod tests { label: "Waiting...".into(), since: Instant::now(), timed, + expires: None, look, width: None, write: |_| {}, @@ -194,9 +227,9 @@ 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 { @@ -204,7 +237,7 @@ mod tests { ..SPINNING }, ); - assert_eq!(ascii.text(5, short, None), "\\ Waiting..."); + assert_eq!(ascii.text(5, short, None, None), "\\ Waiting..."); let colored = line( false, Look { @@ -212,7 +245,7 @@ mod tests { ..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...")); } @@ -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() @@ -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()); } diff --git a/src/style.rs b/src/style.rs index 1ae4eaf..e268abf 100644 --- a/src/style.rs +++ b/src/style.rs @@ -2,9 +2,10 @@ //! //! `--color always|never` decides; otherwise each stream follows anstream's automatic choice, //! which honors `NO_COLOR`, `CLICOLOR_FORCE`, `CLICOLOR`, `TERM=dumb`, and whether that -//! stream is a terminal. Only static labels are painted. Untrusted text stays scrubbed and -//! plain, so every escape sequence the CLI writes comes from this palette. Machine-readable -//! output never passes through here. +//! stream is a terminal. A `Line` takes fixed labels and untrusted text, and scrubs the text +//! before styling it, so every escape sequence the CLI writes comes from this palette or from +//! an OSC 8 link whose visible text is its target. Machine-readable output never passes +//! through here. use crate::terminal::scrub_line; use clap::{ @@ -104,6 +105,10 @@ pub enum Role { Success, /// The spinner and in-flight statuses such as `sending` Pending, + /// A URL a person opens + Link, + /// A value a person reads or types, such as a device code + Emphasis, } impl Role { @@ -114,6 +119,8 @@ impl Role { Self::Muted => Style::new().dimmed(), Self::Success => AnsiColor::Green.on_default(), Self::Pending => AnsiColor::Yellow.on_default(), + Self::Link => AnsiColor::Blue.on_default().underline(), + Self::Emphasis => Style::new().bold(), } } } @@ -135,6 +142,25 @@ impl Line { self } + /// `text` in a role's style. + pub fn styled(mut self, role: Role, text: &str) -> Self { + let style = role.style(); + self.0 + .push_str(&format!("{style}{}{style:#}", scrub_line(text))); + self + } + + /// A URL that terminals with OSC 8 support make clickable. The visible text is the whole + /// URL, so a link cannot hide where it goes; anstream strips the sequence with color off. + pub fn link(mut self, url: &str) -> Self { + let url = scrub_line(url); + let style = Role::Link.style(); + self.0.push_str(&format!( + "\x1b]8;;{url}\x1b\\{style}{url}{style:#}\x1b]8;;\x1b\\" + )); + self + } + pub fn as_str(&self) -> &str { &self.0 } @@ -257,6 +283,8 @@ mod tests { Role::Muted, Role::Success, Role::Pending, + Role::Link, + Role::Emphasis, ] { let painted = paint(role, "error:"); assert!(painted.starts_with('\u{1b}') && painted.ends_with("\u{1b}[0m")); @@ -276,6 +304,26 @@ mod tests { assert_eq!(line.as_str().matches('\u{1b}').count(), 2); } + #[test] + fn a_link_shows_its_whole_scrubbed_url_and_strips_to_it() { + let line = Line::default().link("https://x.test/a\u{1b}]8;;https://evil.test\u{7}"); + let shown = "https://x.test/a ]8;;https://evil.test "; + assert_eq!( + anstream::adapter::strip_str(line.as_str()).to_string(), + shown + ); + assert!( + line.as_str() + .starts_with(&format!("\u{1b}]8;;{shown}\u{1b}\\")) + ); + assert!(line.as_str().ends_with("\u{1b}]8;;\u{1b}\\")); + let code = Line::default().styled(Role::Emphasis, "AB\u{1b}CD"); + assert_eq!( + anstream::adapter::strip_str(code.as_str()).to_string(), + "AB CD" + ); + } + #[test] fn glyphs_are_unicode_only_for_a_utf8_locale_on_a_capable_terminal() { let detect = |vars: &[(&str, &str)]| { diff --git a/src/terminal.rs b/src/terminal.rs index 0a4b7c3..0eda03e 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -64,7 +64,7 @@ impl Terminal { /// Optional status for a human, cleared when the guard drops; no control codes on /// redirected stderr. pub fn progress(self, label: impl Display) -> Progress { - self.status(label, None) + self.status(label, None, None) } /// Show `label` while `work` runs. The status cannot outlive the work: it clears on @@ -81,11 +81,28 @@ impl Terminal { since: Instant, work: impl Future, ) -> T { - let _progress = self.status(label, Some(since)); + let _progress = self.status(label, Some(since), None); work.await } - fn status(self, label: impl Display, since: Option) -> Progress { + /// `waiting` for a wait that ends at `expires`: the line also counts down to it. + pub async fn expiring( + self, + label: impl Display, + since: Instant, + expires: Instant, + work: impl Future, + ) -> T { + let _progress = self.status(label, Some(since), Some(expires)); + work.await + } + + fn status( + self, + label: impl Display, + since: Option, + expires: Option, + ) -> Progress { if self.quiet || !self.decorates() || !draws_control_codes() { return Progress::hidden(); } @@ -95,7 +112,7 @@ impl Terminal { glyphs: self.glyphs(), }; // Labels are fixed text today; scrubbing keeps every escape the line's own. - Progress::show(scrub_line(&label.to_string()), since, look) + Progress::show(scrub_line(&label.to_string()), since, expires, look) } /// Optional information that `--quiet` suppresses. diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index cb81858..6cc9531 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -1430,6 +1430,18 @@ async fn browser_device_login_handles_pending_and_slowdown_without_exposing_toke // Pending, then slow-down: the device grant interval is honored, never shortened. assert!(begin.elapsed() >= Duration::from_secs(20)); assert_eq!(json_stdout(&login)["data"]["authenticated"], true); + // Redirected stderr keeps the plain lines, with no styles and no closing line. + let shown = stderr(&login); + assert!( + shown.contains( + "Open https://app.voltage.cloud/cli/authorize\nConfirm this code: ABCD-EFGH\n" + ), + "{shown}" + ); + assert!( + !shown.contains("Logged in") && !shown.contains('\u{1b}'), + "{shown}" + ); let combined = format!("{}{}", stdout(&login), stderr(&login)); for secret in [ "device-secret", @@ -5773,7 +5785,57 @@ async fn a_failed_payment_on_a_terminal_ends_with_a_cross_before_the_error() { #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn device_approval_on_a_terminal_shows_how_long_it_has_waited() { +async fn a_quiet_login_on_a_terminal_still_shows_the_code_but_no_closing_line() { + let (server, dir) = fixture().await; + respond_once( + &server, + route("POST", "/oauth/device_authorization"), + ok(json!({ + "device_code":"device-secret","user_code":"ABCD-EFGH", + "verification_uri":"https://app.voltage.cloud/cli/authorize","expires_in":60,"interval":5 + })), + ) + .await; + respond_once( + &server, + route("POST", "/oauth/token"), + ok(json!({ + "access_token":"login-access-secret","refresh_token":"login-refresh-secret", + "token_type":"Bearer","expires_in":600 + })), + ) + .await; + respond_once( + &server, + route("GET", "/users/current"), + ok(json!({"id":RESOURCE,"email":LOGIN_EMAIL})), + ) + .await; + let uri = server.uri(); + let args = [ + "login", + "--auth-url", + &uri, + "--no-browser", + "--account", + LOGIN_NAME, + "--credential-store", + "file", + "--quiet", + ]; + let run = on_a_terminal(dir.path(), &server, &args, &[]).await; + assert_eq!(run.code, Some(0), "{}", run.shown); + let shown = strip_styles(&run.shown); + // The URL and code are needed to finish, so `--quiet` keeps them; the closing line goes. + assert!(shown.contains("› Confirm this code: ABCD-EFGH"), "{shown}"); + assert!(!shown.contains("Logged in"), "{shown}"); + assert!(!shown.contains("Waiting for approval"), "{shown}"); + server.verify().await; +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn device_approval_on_a_terminal_links_the_url_and_counts_down() { let (server, dir) = fixture().await; respond_once( &server, @@ -5812,10 +5874,25 @@ async fn device_approval_on_a_terminal_shows_how_long_it_has_waited() { ]; let run = on_a_terminal(dir.path(), &server, &args, &[]).await; assert_eq!(run.code, Some(0), "{}", run.shown); + let shown = strip_styles(&run.shown); + // The link's text is the whole URL it opens. + let url = "https://app.voltage.cloud/cli/authorize"; + let link = format!("\u{1b}]8;;{url}\u{1b}\\{url}\u{1b}]8;;\u{1b}\\"); + assert!(shown.contains(&format!("› Open {link}\r\n")), "{shown:?}"); assert!( - run.shown.contains("Waiting for device approval... 0s"), - "{}", - run.shown + shown.contains("› Confirm this code: ABCD-EFGH\r\n"), + "{shown:?}" + ); + assert!(run.shown.contains("\u{1b}[1mABCD-EFGH"), "{:?}", run.shown); + assert!( + shown.contains("Waiting for approval in your browser... 0s · expires in "), + "{shown}" + ); + assert!( + shown.contains(&format!( + "✓ Logged in as {LOGIN_EMAIL} (account: {LOGIN_NAME})" + )), + "{shown}" ); server.verify().await; }