ref:337a754ee74aaef94d3857ef81771ac88e462ffa

feat(auth): open create-token page in browser on `auth login` (#19)

Before this change, `anvil auth login` prompted for a PAT with no guidance on where to get one. Users had to know that PATs live at `/users/settings/tokens` (which only exists after fangorn/anvil#211 lands). The friction was the dominant complaint about the auth UX. Now: after the URL prompt, the CLI prints the create-token URL and attempts to open it in the user's default browser via `open` (macOS) / `xdg-open` (Linux) / `cmd /C start` (Windows). Browser-open failure is non-fatal — the URL was already printed and the password prompt proceeds either way. `--no-browser` flag suppresses the open attempt for headless boxes or scripted setups. This is the interim fix while the larger OAuth device-flow work is designed (CLI side: #18, server side: fangorn/anvil#212). The device-flow ticket will eventually replace this entirely. Tests: * `token_settings_url` strips trailing slashes correctly * `auth login` parses with defaults (no flags, all None/false) * `auth login --no-browser` parses * `auth login --url X --token Y` parses 32/32 cargo tests pass. clippy --all-targets -D warnings clean. cargo fmt clean. Closes #19
SHA: 337a754ee74aaef94d3857ef81771ac88e462ffa
Author: CI <ci@anvil.test>
Date: 2026-05-09 05:47
Parents: 6353656
1 files changed +122 -4
Type
src/commands/auth.rs +122 −4
@@ -18,6 +18,9 @@
/// Personal access token (or enter interactively)
#[arg(long)]
token: Option<String>,
/// Don't try to open the create-token page in the browser
#[arg(long)]
no_browser: bool,
},
/// Show current authentication status
Status,
@@ -27,15 +30,45 @@
pub async fn run(args: AuthArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
AuthCommand::Login { url, token } => login(url, token).await,
AuthCommand::Login {
url,
token,
no_browser,
} => login(url, token, no_browser).await,
AuthCommand::Status => status().await,
AuthCommand::Logout => logout().await,
}
}
/// URL of the per-user PAT-management page on the given server.
fn token_settings_url(server_url: &str) -> String {
format!("{}/users/settings/tokens", server_url.trim_end_matches('/'))
}
/// Try to open `url` in the user's default browser. Returns true on success.
/// Failure is non-fatal — the caller falls back to printing the URL.
fn try_open_browser(url: &str) -> bool {
let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
("open", vec![url])
} else if cfg!(target_os = "windows") {
("cmd", vec!["/C", "start", "", url])
} else {
("xdg-open", vec![url])
};
std::process::Command::new(cmd)
.args(&args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
async fn login(
url: Option<String>,
token: Option<String>,
no_browser: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let url = match url {
Some(u) => u,
@@ -47,9 +80,20 @@
let token = match token {
Some(t) => t,
None => {
// Help the user find the PAT-creation page. Without this, they
// have to know the URL by heart or hunt through settings menus.
let token_url = token_settings_url(&url);
output::info(&format!("Create a token at: {token_url}"));
if !no_browser && try_open_browser(&token_url) {
output::info("Opened the create-token page in your browser.");
}
dialoguer::Password::new()
.with_prompt("Paste the token here")
.interact()?
None => dialoguer::Password::new()
.with_prompt("Personal access token")
.interact()?,
}
};
// Validate by hitting the health endpoint
@@ -107,4 +151,78 @@
println!("Not logged in.");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser)]
#[command(no_binary_name = true)]
struct AuthCli {
#[command(subcommand)]
command: AuthCommand,
}
fn parse(args: &[&str]) -> AuthCommand {
AuthCli::try_parse_from(args).expect("parse").command
}
#[test]
fn token_url_strips_trailing_slash() {
assert_eq!(
token_settings_url("https://anvil.fangorn.io"),
"https://anvil.fangorn.io/users/settings/tokens"
);
assert_eq!(
token_settings_url("https://anvil.fangorn.io/"),
"https://anvil.fangorn.io/users/settings/tokens"
);
assert_eq!(
token_settings_url("https://anvil.fangorn.io///"),
"https://anvil.fangorn.io/users/settings/tokens"
);
}
#[test]
fn login_parses_with_defaults() {
match parse(&["login"]) {
AuthCommand::Login {
url,
token,
no_browser,
} => {
assert!(url.is_none());
assert!(token.is_none());
assert!(!no_browser);
}
_ => panic!("expected Login"),
}
}
#[test]
fn login_parses_no_browser_flag() {
match parse(&["login", "--no-browser"]) {
AuthCommand::Login { no_browser, .. } => assert!(no_browser),
_ => panic!("expected Login"),
}
}
#[test]
fn login_parses_url_and_token_args() {
match parse(&[
"login",
"--url",
"https://example.test",
"--token",
"anvil_secret",
]) {
AuthCommand::Login { url, token, .. } => {
assert_eq!(url.as_deref(), Some("https://example.test"));
assert_eq!(token.as_deref(), Some("anvil_secret"));
}
_ => panic!("expected Login"),
}
}
}