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 @@ -17,6 +17,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes
- A change described without `--execute` starts, on a terminal, with what it would do and the values it would use (`! Not sent: this would send a payment`) and ends with how to send it, instead of an `error:` line. Exit code 6 is unchanged.
- Human output shows amounts with grouped digits, every BTC amount in sats (`150,000 sats`, `1.5 sats`) and cents as dollars (`$1,234.56`), and table timestamps relative to now (`3 minutes ago`); single resources show the UTC time with the relative time beside it. With color on, headers and field names are bold and known statuses are colored. JSON is unchanged.
- Help headers and usage are Voltage orange where the terminal draws 256 or 24-bit color, and bold elsewhere. `FORCE_COLOR` is honored: `0` or `false` turns color off, other values turn it on when redirected; `--color` and `NO_COLOR` still win.
- A successful `voltage login` on a terminal shows the `voltage` wordmark, a lightning bolt beside the name in Voltage orange, above `✓ Logged in as`.

## 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. 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.
`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, under the `voltage` wordmark, a yellow lightning bolt beside the name in Voltage orange (left out on a terminal narrower than 40 columns, and with `--quiet`). 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: 2 additions & 0 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use crate::{
secret::Secret,
style::{Line, Role},
terminal::Terminal,
wordmark,
};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::Value;
Expand Down Expand Up @@ -743,6 +744,7 @@ fn show_logged_in(terminal: Terminal, outcome: &LoginOutcome) {
if !terminal.decorates() {
return;
}
wordmark::show(terminal);
let who = outcome.email.as_deref().unwrap_or(&outcome.account);
let mut line = Line::default()
.paint(Role::Success, terminal.glyphs().done)
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ mod terminal;
mod timeline;
mod wallet_currency;
mod wallet_shortlist;
mod wordmark;

pub(crate) use error::{Error, Result};
pub use startup::run;
11 changes: 11 additions & 0 deletions src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ pub enum Role {
Link,
/// A value a person reads or types, such as a device code
Emphasis,
/// The wordmark's lightning bolt; decoration, never a status
Bolt,
}

impl Role {
Expand All @@ -139,6 +141,7 @@ impl Role {
Self::Caution => AnsiColor::Yellow.on_default().bold(),
Self::Link => AnsiColor::Blue.on_default().underline(),
Self::Emphasis => Style::new().bold(),
Self::Bolt => AnsiColor::Yellow.on_default().bold(),
}
}
}
Expand Down Expand Up @@ -168,6 +171,13 @@ impl Line {
self
}

/// Fixed text in the brand color, at the depth the terminal draws.
pub fn brand(mut self, depth: Depth, label: &'static str) -> Self {
let style = brand(depth);
self.0.push_str(&format!("{style}{label}{style:#}"));
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 {
Expand Down Expand Up @@ -351,6 +361,7 @@ mod tests {
Role::Caution,
Role::Link,
Role::Emphasis,
Role::Bolt,
] {
let painted = paint(role, "error:");
assert!(painted.starts_with('\u{1b}') && painted.ends_with("\u{1b}[0m"));
Expand Down
90 changes: 90 additions & 0 deletions src/wordmark.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//! The `voltage` wordmark, shown on a terminal at moments worth marking, such as a first
//! login: a lightning bolt beside the name. ASCII only, so every font draws it; the bolt is
//! yellow and the name is in the brand color where there is one.

use crate::{
style::{Depth, Line, Role},
terminal::{self, Terminal},
};

/// The bolt, padded to one width with the gap before the name, so the name lines up.
#[rustfmt::skip]
const BOLT: [&str; 5] = [
" __ ",
" / / ",
" / /_ ",
" /_ / ",
" // ",
];

/// figlet's `smslant` font. Raw strings keep the backslashes as drawn.
const WORDMARK: [&str; 5] = [
r" ____",
r" _ _____ / / /____ ____ ____",
r"| |/ / _ \/ / __/ _ `/ _ `/ -_)",
r"|___/\___/_/\__/\_,_/\_, /\__/",
r" /___/",
];

/// Columns the bolt and name need; a narrower terminal would wrap them, so they are left out.
const WIDTH: usize = 40;

/// Show the wordmark on an interactive stderr wide enough for it. `--quiet` hides it.
pub fn show(terminal: Terminal) {
if !terminal.decorates() || !fits(terminal::stderr_width()) {
return;
}
let depth = Depth::detect(|name| std::env::var(name).ok());
for line in lines(depth) {
terminal.notice_line(line);
}
}

/// An unknown width is assumed to fit, as a table assumes it is not width-limited.
fn fits(width: Option<usize>) -> bool {
width.is_none_or(|width| width >= WIDTH)
}

fn lines(depth: Depth) -> Vec<Line> {
BOLT.iter()
.zip(WORDMARK)
.map(|(bolt, name)| Line::default().paint(Role::Bolt, bolt).brand(depth, name))
.chain(std::iter::once(Line::default()))
.collect()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn the_wordmark_is_ascii_and_fits_its_width() {
for (bolt, name) in BOLT.iter().zip(WORDMARK) {
assert!(bolt.is_ascii() && bolt.len() == BOLT[0].len(), "{bolt}");
assert!(name.is_ascii() && bolt.len() + name.len() < WIDTH, "{name}");
assert_eq!(name, name.trim_end());
}
}

#[test]
fn a_terminal_narrower_than_the_wordmark_leaves_it_out() {
assert!(fits(None));
assert!(fits(Some(WIDTH)));
assert!(!fits(Some(WIDTH - 1)));
}

#[test]
fn each_line_is_painted_in_the_brand_color_and_a_blank_line_follows() {
let lines = lines(Depth::Ansi256);
assert_eq!(lines.len(), WORDMARK.len() + 1);
assert_eq!(
lines[1].as_str(),
format!(
"{}\u{1b}[1m\u{1b}[38;5;202m{}\u{1b}[0m",
crate::style::paint(Role::Bolt, BOLT[1]),
WORDMARK[1]
)
);
assert_eq!(lines[WORDMARK.len()].as_str(), "");
}
}
12 changes: 11 additions & 1 deletion tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6032,12 +6032,22 @@ async fn device_approval_on_a_terminal_links_the_url_and_counts_down() {
shown.contains("Waiting for approval in your browser... 0s · expires in "),
"{shown}"
);
// The bolt and wordmark, in yellow and the brand color, lead the closing line.
let wordmark =
" /_ / |___/\\___/_/\\__/\\_,_/\\_, /\\__/\r\n // /___/\r\n\r\n";
assert!(
shown.contains(&format!(
"✓ Logged in as {LOGIN_EMAIL} (account: {LOGIN_NAME})"
"{wordmark}✓ Logged in as {LOGIN_EMAIL} (account: {LOGIN_NAME})"
)),
"{shown}"
);
assert!(
run.shown.contains(
"\u{1b}[1m\u{1b}[33m __ \u{1b}[0m\u{1b}[1m\u{1b}[38;5;202m ____\u{1b}[0m"
),
"{:?}",
run.shown
);
server.verify().await;
}

Expand Down
Loading