@@ -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"),
}
}
}