use aristo_core::auth::{
self, login_command, login_server, AuthError, CredentialStore, ServerUrl, Token, UpsertOutcome,
};
use crate::{AuthAction, CliError, CliResult};
pub(crate) fn run(action: AuthAction) -> CliResult<()> {
match action {
AuthAction::Login { server } => login(server),
AuthAction::Status => status(),
AuthAction::Token { server } => token(server),
AuthAction::Logout { all, server } => logout(all, server),
}
}
fn login(server_flag: Option<String>) -> CliResult<()> {
let env_override = std::env::var(auth::SERVER_ENV_VAR).ok();
let (server, source) = login_server(server_flag.as_deref(), env_override.as_deref())
.ok_or_else(|| CliError::Other {
message: format!(
"no server given.\n \
Pass `--server https://<org>.aretta.ai` (your Aretta dashboard's hostname), \
or set {}.",
auth::SERVER_ENV_VAR
),
exit_code: 2,
})?;
let init = auth::oauth_start(&server).map_err(auth_error_to_cli)?;
eprintln!();
eprintln!("Authenticating against {server} ({})", source.provenance());
eprintln!();
eprintln!("Open this URL to authorize with GitHub:");
eprintln!();
eprintln!(" {}", init.authorize_url);
eprintln!();
let _ = try_open_browser(&init.authorize_url);
eprintln!("After authorizing, the page will display a code. Paste it here:");
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(CliError::Io)?;
let code = line.trim();
if code.is_empty() {
return Err(CliError::Other {
message: format!(
"no OAuth code provided. Re-run `{}` and paste the code from the callback page.",
login_command()
),
exit_code: 2,
});
}
let repo_hint = std::env::current_dir()
.ok()
.and_then(|d| auth::derive_repo_full_name(&d).ok());
let resp = auth::oauth_exchange(&server, code, repo_hint.as_deref(), Some("aristo-cli"))
.map_err(auth_error_to_cli)?;
let creds = aristo_core::auth::CredentialsRecord {
token: Token::new(&resp.arta_token),
server: server.clone(),
user_login: Some(resp.user.login.clone()),
user_id: Some(resp.user.id),
};
let report = aristo_core::auth::save_full(&creds).map_err(CliError::Io)?;
let path = auth::credentials_path().map_err(auth_error_to_cli)?;
println!("ok: authenticated as {} at {server}", resp.user.login);
println!(" token saved to {}", path.display());
println!(
" {}",
store_change_line(report.outcome, &server, report.store.len())
);
if report.store.len() > 1 {
println!(
" note: several servers on file — commands use the one named by --server or {}.",
auth::SERVER_ENV_VAR
);
}
if env_token_set() {
println!(
" note: {} is set in the environment; it takes precedence over the saved entry.",
auth::ENV_VAR
);
}
println!(" `aristo auth status` to verify; `aristo auth logout --server <url>` to remove.");
Ok(())
}
fn store_change_line(outcome: UpsertOutcome, server: &ServerUrl, total: usize) -> String {
let what = match outcome {
UpsertOutcome::Added => "entry added",
UpsertOutcome::Replaced => "entry replaced",
};
format!(
"{what}: server {server} — {total} {} on file.",
if total == 1 { "server" } else { "servers" }
)
}
fn server_flag_or_env(flag: Option<String>) -> Option<ServerUrl> {
flag.map(|f| ServerUrl::parse(&f)).or_else(|| {
std::env::var(auth::SERVER_ENV_VAR)
.ok()
.filter(|v| !v.trim().is_empty())
.map(|v| ServerUrl::parse(&v))
})
}
fn env_token_set() -> bool {
std::env::var(auth::ENV_VAR).is_ok_and(|v| !v.trim().is_empty())
}
fn try_open_browser(url: &str) -> std::io::Result<()> {
if std::env::var("ARISTO_NO_BROWSER").is_ok() {
return Ok(());
}
let cmd = if cfg!(target_os = "macos") {
"open"
} else if cfg!(target_os = "windows") {
"start"
} else {
"xdg-open"
};
std::process::Command::new(cmd)
.arg(url)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map(|_| ())
}
fn auth_error_to_cli(e: AuthError) -> CliError {
CliError::Other {
message: e.to_string(),
exit_code: 1,
}
}
fn store_error_to_cli(e: AuthError) -> CliError {
match e {
AuthError::Malformed(msg) => CliError::Other {
message: format!(
"credentials file is malformed: {msg}\n \
Run `aristo auth logout --all`, then `{}` to re-create it.",
login_command()
),
exit_code: 1,
},
other => auth_error_to_cli(other),
}
}
fn note_env_still_set() {
if env_token_set() {
println!(
" note: {} is set in the environment; canon calls will still use it.",
auth::ENV_VAR
);
}
}
fn selection_line(store: &CredentialStore, named: Option<&ServerUrl>) -> String {
match (store.resolve_for(named), named) {
(Some(e), Some(_)) => format!(
"commands use: server {} (named by {}).",
e.server,
auth::SERVER_ENV_VAR
),
(Some(e), None) => format!("commands use: server {}.", e.server),
(None, Some(s)) => format!(
"commands use: nothing — {} names {s}, which is not on file; run `aristo auth login --server {s}`.",
auth::SERVER_ENV_VAR
),
(None, None) => format!(
"commands use: nothing — several servers on file; pass `--server <url>` or set {}.",
auth::SERVER_ENV_VAR
),
}
}
fn status() -> CliResult<()> {
let env_server = std::env::var(auth::SERVER_ENV_VAR)
.ok()
.filter(|v| !v.trim().is_empty())
.map(|v| ServerUrl::parse(&v));
let store = auth::load_store().map_err(store_error_to_cli)?;
let path = auth::credentials_path().map_err(auth_error_to_cli)?;
let resolves = if env_token_set() {
match &env_server {
Some(server) => {
println!(
"ok: signed in via {} for {server} ({} takes precedence over every stored entry).",
auth::ENV_VAR,
auth::ENV_VAR
);
true
}
None => {
println!("not authenticated: {}", AuthError::EnvTokenWithoutServer);
false
}
}
} else if store.is_empty() {
println!("{}", AuthError::NoToken);
false
} else {
let picked = store.resolve_for(env_server.as_ref());
println!(
"{}: {} server(s) in {}",
if picked.is_some() {
"ok: signed in"
} else {
"signed in, but nothing selected"
},
store.len(),
path.display()
);
for e in &store.entries {
println!(" • {}", e.summary());
}
println!(" {}", selection_line(&store, env_server.as_ref()));
picked.is_some()
};
if env_token_set() && !store.is_empty() {
println!(
" also stored (shadowed by {}): {} server(s) in {}",
auth::ENV_VAR,
store.len(),
path.display()
);
}
if resolves {
Ok(())
} else {
Err(CliError::Silent { exit_code: 1 })
}
}
fn token(server_flag: Option<String>) -> CliResult<()> {
if let Ok(v) = std::env::var(auth::ENV_VAR) {
let v = v.trim();
if !v.is_empty() {
println!("{v}");
return Ok(());
}
}
let store = auth::load_store().map_err(store_error_to_cli)?;
if store.is_empty() {
return Err(CliError::Other {
message: AuthError::NoToken.to_string(),
exit_code: 1,
});
}
let named = server_flag_or_env(server_flag);
match store.resolve_for(named.as_ref()) {
Some(e) => {
println!("{}", e.token.as_str());
Ok(())
}
None => Err(CliError::Other {
message: match named {
Some(s) => format!(
"no credential for {s}; run `aristo auth login --server {s}` \
(or `aristo auth status` to list what's stored)."
),
None => "several servers on file — pass `--server <url>` to pick one \
(or `aristo auth status` to list)."
.into(),
},
exit_code: 1,
}),
}
}
fn logout(all: bool, server_flag: Option<String>) -> CliResult<()> {
let path = auth::credentials_path().map_err(auth_error_to_cli)?;
if all {
let existed = path.exists();
auth::clear().map_err(CliError::Io)?;
if existed {
println!(
"ok: logged out. all credentials cleared from {}",
path.display()
);
} else {
println!("ok: not logged in (no credentials to clear).");
}
note_env_still_set();
return Ok(());
}
let mut store = auth::load_store().map_err(|e| match e {
AuthError::Malformed(msg) => CliError::Other {
message: format!(
"credentials file is malformed: {msg}\n \
Run `aristo auth logout --all` to reset it."
),
exit_code: 1,
},
other => auth_error_to_cli(other),
})?;
if store.is_empty() {
println!("ok: not logged in (no credentials to clear).");
note_env_still_set();
return Ok(());
}
let named = server_flag_or_env(server_flag);
let Some(server) = store.resolve_for(named.as_ref()).map(|e| e.server.clone()) else {
return Err(CliError::Other {
message: match named {
Some(s) => format!("ok: no credential for {s} to remove (nothing changed)."),
None => "several servers on file — pass `--server <url>` to log out of one, \
or `--all` to clear everything."
.into(),
},
exit_code: 2,
});
};
store.remove_by_server(&server);
if store.is_empty() {
auth::clear().map_err(CliError::Io)?;
} else {
auth::save_store(&store).map_err(CliError::Io)?;
}
println!("ok: logged out of {server}. updated {}", path.display());
note_env_still_set();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use aristo_core::auth::CredentialEntry;
fn entry(server: &str, token: &str) -> CredentialEntry {
CredentialEntry::bare(Token::new(token), ServerUrl::parse(server))
}
#[test]
fn store_change_line_names_outcome_server_and_count() {
let acme = ServerUrl::parse("https://acme.aretta.ai");
assert_eq!(
store_change_line(UpsertOutcome::Added, &acme, 1),
"entry added: server https://acme.aretta.ai — 1 server on file."
);
assert_eq!(
store_change_line(UpsertOutcome::Replaced, &acme, 2),
"entry replaced: server https://acme.aretta.ai — 2 servers on file."
);
}
#[test]
fn selection_line_covers_the_four_cases() {
let acme = ServerUrl::parse("https://acme.aretta.ai");
let other = ServerUrl::parse("https://other.aretta.ai");
let one = CredentialStore {
entries: vec![entry("https://acme.aretta.ai", "a")],
};
assert_eq!(
selection_line(&one, None),
"commands use: server https://acme.aretta.ai."
);
assert!(selection_line(&one, Some(&acme)).contains("named by ARETTA_API_URL"));
let v = selection_line(&one, Some(&other));
assert!(
v.contains("not on file") && v.contains("--server https://other.aretta.ai"),
"{v}"
);
let two = CredentialStore {
entries: vec![
entry("https://acme.aretta.ai", "a"),
entry("https://other.aretta.ai", "b"),
],
};
let v = selection_line(&two, None);
assert!(
v.contains("several servers") && v.contains("--server <url>"),
"{v}"
);
assert!(!v.contains("a\"") && !v.contains(" b"), "no token: {v}");
}
}