use std::path::Path;
use aristo_core::auth::{
self, derive_repo_full_name, login_command, login_server, AuthError, CredentialEntry,
CredentialStore, LoginServerSource, ServerUrl, Token, UpsertOutcome, UpsertReport,
};
use crate::{AuthAction, CliError, CliResult};
pub(crate) fn run(action: AuthAction) -> CliResult<()> {
match action {
AuthAction::Login { server, repo } => login(server, repo),
AuthAction::Status => status(),
AuthAction::Token { repo } => token(repo),
AuthAction::Logout { all, repo } => logout(all, repo),
}
}
fn login(server_flag: Option<String>, repo_flag: Option<String>) -> CliResult<()> {
let env_override = std::env::var("ARETTA_API_URL").ok();
let (server, source) = login_server(server_flag.as_deref(), env_override.as_deref())
.ok_or_else(|| CliError::Other {
message: "no server given.\n \
Pass `--server https://<org>.aretta.ai` (your Aretta dashboard's hostname), \
or set ARETTA_API_URL."
.into(),
exit_code: 2,
})?;
let repo_full_name = resolve_repo_full_name(repo_flag)?;
login_via_oauth(&server, source, repo_full_name)
}
fn login_via_oauth(
server: &ServerUrl,
source: LoginServerSource,
repo_full_name: String,
) -> CliResult<()> {
let init = auth::oauth_start(server).map_err(auth_error_to_cli)?;
eprintln!();
eprintln!("Authenticating against {server} ({})", source.provenance());
eprintln!("Scoping token to repo: {repo_full_name}");
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: "no OAuth code provided. Re-run `aristo auth login` and paste the code from the callback page.".into(),
exit_code: 2,
});
}
let resp = auth::oauth_exchange(server, code, &repo_full_name, Some("aristo-cli"))
.map_err(auth_error_to_cli)?;
let token = Token::new(&resp.arta_token);
let creds = aristo_core::auth::CredentialsRecord {
token,
server: server.clone(),
user_login: Some(resp.user.login.clone()),
user_id: Some(resp.user.id),
repo: Some(resp.repo_full_name.clone()),
};
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 {} for {}",
resp.user.login, resp.repo_full_name
);
println!(" token saved to {}", path.display());
print_login_report(&report, &creds.token)?;
println!(" `aristo auth status` to verify; `aristo auth logout` to remove.");
Ok(())
}
fn print_login_report(report: &UpsertReport, saved_token: &Token) -> CliResult<()> {
let saved = report
.store
.entries
.iter()
.find(|e| e.token.as_str() == saved_token.as_str())
.ok_or_else(|| CliError::Other {
message: "internal: the saved credential is not in the store just written".into(),
exit_code: 1,
})?;
println!(
" {}",
store_change_line(report.outcome, saved, report.store.len())
);
let cwd = std::env::current_dir().map_err(CliError::Io)?;
println!(" {}", login_verdict(&report.store, saved, &cwd));
if std::env::var(auth::ENV_VAR).is_ok_and(|v| !v.trim().is_empty()) {
println!(
" note: {} is set in the environment; it takes precedence over the saved entry.",
auth::ENV_VAR
);
}
Ok(())
}
fn store_change_line(outcome: UpsertOutcome, saved: &CredentialEntry, total: usize) -> String {
let what = match outcome {
UpsertOutcome::Added => "entry added".to_string(),
UpsertOutcome::Replaced { dropped } => format!(
"entry replaced (dropped {dropped} older {} for this repo)",
plural(dropped, "entry", "entries")
),
};
format!(
"{what}: {} — {total} {} on file.",
entry_key(saved),
plural(total, "entry", "entries")
)
}
fn entry_key(e: &CredentialEntry) -> String {
format!(
"server {}, repo {}",
e.server,
e.repo.as_deref().unwrap_or("(unscoped)")
)
}
fn plural(n: usize, one: &str, many: &str) -> String {
if n == 1 { one } else { many }.to_string()
}
fn resolution_at<'s>(
store: &'s CredentialStore,
dir: &Path,
) -> (Result<String, String>, Option<&'s CredentialEntry>) {
let checkout = auth::checkout_at(dir);
let picked = store.resolve_for(checkout.as_deref().ok());
(checkout, picked)
}
fn login_verdict(store: &CredentialStore, saved: &CredentialEntry, dir: &Path) -> String {
let (checkout, picked) = resolution_at(store, dir);
let uses_saved = picked.is_some_and(|e| e.token.as_str() == saved.token.as_str());
let remedy = match saved.repo.as_deref() {
Some(repo) => format!(
"run aristo from a {repo} checkout, or set ARETTA_TOKEN=$(aristo auth token --repo {repo})."
),
None => "set ARETTA_TOKEN to use it.".to_string(),
};
match (checkout, uses_saved) {
(Ok(repo), true) => format!("this checkout ({repo}) resolves to this entry."),
(Ok(repo), false) => {
format!("this checkout ({repo}) will NOT resolve to this entry — {remedy}")
}
(Err(why), _) => format!(
"this directory is not a GitHub checkout ({why}); nothing resolves here — {remedy}"
),
}
}
fn status_verdict(store: &CredentialStore, dir: &Path) -> String {
let (checkout, picked) = resolution_at(store, dir);
match (checkout, picked) {
(Ok(repo), Some(e)) => format!("this checkout ({repo}) resolves to: {}", entry_key(e)),
(Ok(repo), None) => format!(
"this checkout ({repo}) resolves to: no stored credential — \
run `{}` here, or set ARETTA_TOKEN + ARETTA_API_URL.",
login_command(Some(&repo))
),
(Err(why), _) => format!(
"this directory is not a GitHub checkout ({why}) — resolves to: no stored \
credential ({} on file; run from a checkout of one of them, or set ARETTA_TOKEN).",
store.len()
),
}
}
fn validate_repo_flag(raw: &str) -> CliResult<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(CliError::Other {
message: "--repo must be `owner/repo` (got empty string)".into(),
exit_code: 2,
});
}
if !trimmed.contains('/') {
return Err(CliError::Other {
message: format!("--repo `{trimmed}` is not in `owner/repo` form"),
exit_code: 2,
});
}
Ok(trimmed.to_string())
}
fn resolve_repo_full_name(repo_flag: Option<String>) -> CliResult<String> {
if let Some(r) = repo_flag {
return validate_repo_flag(&r);
}
let cwd = std::env::current_dir().map_err(CliError::Io)?;
derive_repo_full_name(&cwd).map_err(auth_error_to_cli)
}
fn resolve_repo_best_effort(repo_flag: Option<String>) -> CliResult<Option<String>> {
if let Some(r) = repo_flag {
return Ok(Some(validate_repo_flag(&r)?));
}
Ok(std::env::current_dir()
.ok()
.and_then(|cwd| derive_repo_full_name(&cwd).ok()))
}
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(None)
),
exit_code: 1,
},
other => auth_error_to_cli(other),
}
}
fn note_env_still_set() {
if std::env::var(auth::ENV_VAR).is_ok() {
println!(
" note: {} is set in the environment; canon calls will still use it.",
auth::ENV_VAR
);
}
}
fn status() -> CliResult<()> {
let env_token_set = std::env::var(auth::ENV_VAR).is_ok_and(|v| !v.trim().is_empty());
let env_server = std::env::var(auth::SERVER_ENV_VAR)
.ok()
.filter(|v| !v.trim().is_empty());
let store = auth::load_store().map_err(store_error_to_cli)?;
let path = auth::credentials_path().map_err(auth_error_to_cli)?;
let cwd = std::env::current_dir().map_err(CliError::Io)?;
let resolves = if env_token_set {
match env_server {
Some(server) => {
println!(
"ok: authenticated via {} for {} ({} takes precedence over every stored entry).",
auth::ENV_VAR,
ServerUrl::parse(&server),
auth::ENV_VAR
);
true
}
None => {
println!("not authenticated: {}", AuthError::EnvTokenWithoutServer);
false
}
}
} else if store.is_empty() {
println!("{}", AuthError::NoToken);
false
} else {
let (checkout, picked) = resolution_at(&store, &cwd);
println!(
"{}: {} credential(s) in {}",
if picked.is_some() {
"ok: authenticated"
} else {
"not authenticated for this checkout"
},
store.len(),
path.display()
);
for e in &store.entries {
println!(" • {}", e.summary());
}
println!(" {}", status_verdict(&store, &cwd));
let _ = checkout;
picked.is_some()
};
if env_token_set && !store.is_empty() {
println!(
" also stored (shadowed by {}): {} credential(s) in {}",
auth::ENV_VAR,
store.len(),
path.display()
);
}
if resolves {
Ok(())
} else {
Err(CliError::Silent { exit_code: 1 })
}
}
fn token(repo_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 entry = if let Some(raw) = repo_flag {
let repo = validate_repo_flag(&raw)?;
match store.find_by_repo(&repo) {
Some(e) => Some(e),
None => {
return Err(CliError::Other {
message: format!(
"no credential for {repo}; run `{}` \
(or `aristo auth status` to list what's stored).",
login_command(Some(&repo))
),
exit_code: 1,
})
}
}
} else {
let cwd_repo = std::env::current_dir()
.ok()
.and_then(|cwd| derive_repo_full_name(&cwd).ok());
cwd_repo.as_deref().and_then(|r| store.find_by_repo(r))
};
match entry {
Some(e) => {
println!("{}", e.token.as_str());
Ok(())
}
None => Err(CliError::Other {
message: "no credential for this checkout — pass `--repo <owner/repo>` to pick one \
(or `aristo auth status` to list what's stored)."
.into(),
exit_code: 1,
}),
}
}
fn logout(all: bool, repo_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 Some(repo) = resolve_repo_best_effort(repo_flag)? else {
return Err(CliError::Other {
message: "not a GitHub checkout — pass `--repo <owner/repo>` to log out of one \
credential, or `--all` to clear everything."
.into(),
exit_code: 2,
});
};
if store.remove_by_repo(&repo) == 0 {
println!("ok: no credential for {repo} to remove (nothing changed).");
note_env_still_set();
return Ok(());
}
let removed_label = format!("of {repo}");
if store.is_empty() {
auth::clear().map_err(CliError::Io)?;
} else {
auth::save_store(&store).map_err(CliError::Io)?;
}
println!("ok: logged out {removed_label}. updated {}", path.display());
note_env_still_set();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn entry(server: &str, repo: Option<&str>, token: &str) -> CredentialEntry {
CredentialEntry::bare(
Token::new(token),
ServerUrl::parse(server),
repo.map(str::to_string),
)
}
fn store(entries: Vec<CredentialEntry>) -> CredentialStore {
CredentialStore { entries }
}
fn checkout(parent: &Path, name: &str, owner_repo: &str) -> std::path::PathBuf {
let dir = parent.join(name);
std::fs::create_dir_all(dir.join(".git")).unwrap();
std::fs::write(
dir.join(".git/config"),
format!("[remote \"origin\"]\n url = git@github.com:{owner_repo}.git\n"),
)
.unwrap();
dir
}
#[test]
fn store_change_line_names_outcome_key_and_count() {
let e = entry("https://acme.aretta.ai", Some("acme/widgets"), "t");
assert_eq!(
store_change_line(UpsertOutcome::Added, &e, 1),
"entry added: server https://acme.aretta.ai, repo acme/widgets — 1 entry on file."
);
assert_eq!(
store_change_line(UpsertOutcome::Replaced { dropped: 2 }, &e, 3),
"entry replaced (dropped 2 older entries for this repo): server https://acme.aretta.ai, repo acme/widgets — 3 entries on file."
);
let unscoped = entry("https://code.aretta.ai", None, "t");
assert!(store_change_line(UpsertOutcome::Added, &unscoped, 1).contains("repo (unscoped)"));
}
#[test]
fn login_verdict_matching_checkout_resolves() {
let tmp = TempDir::new().unwrap();
let dir = checkout(tmp.path(), "w", "acme/widgets");
let saved = entry("https://code.aretta.ai", Some("acme/widgets"), "t1");
let st = store(vec![
entry("https://code.aretta.ai", Some("other/x"), "t0"),
saved.clone(),
]);
assert_eq!(
login_verdict(&st, &saved, &dir),
"this checkout (acme/widgets) resolves to this entry."
);
}
#[test]
fn login_verdict_mismatched_checkout_names_the_repo_and_both_remedies() {
let tmp = TempDir::new().unwrap();
let dir = checkout(tmp.path(), "fork", "alice/widgets");
let saved = entry("https://code.aretta.ai", Some("acme/widgets"), "t1");
let st = store(vec![
entry("https://code.aretta.ai", Some("other/x"), "t0"),
saved.clone(),
]);
let v = login_verdict(&st, &saved, &dir);
assert!(
v.starts_with("this checkout (alice/widgets) will NOT resolve to this entry"),
"{v}"
);
assert!(v.contains("run aristo from a acme/widgets checkout"), "{v}");
assert!(
v.contains("ARETTA_TOKEN=$(aristo auth token --repo acme/widgets)"),
"{v}"
);
}
#[test]
fn login_verdict_outside_a_checkout_never_resolves() {
let tmp = TempDir::new().unwrap();
let plain = tmp.path().join("plain");
std::fs::create_dir_all(&plain).unwrap();
let saved = entry("https://code.aretta.ai", Some("acme/widgets"), "t1");
let one = store(vec![saved.clone()]);
let v = login_verdict(&one, &saved, &plain);
assert!(v.contains("not a GitHub checkout (no .git/config"), "{v}");
assert!(v.contains("nothing resolves here"), "{v}");
assert!(v.contains("run aristo from a acme/widgets checkout"), "{v}");
assert!(v.contains("ARETTA_TOKEN"), "{v}");
}
#[test]
fn status_verdict_covers_all_four_branches() {
let tmp = TempDir::new().unwrap();
let acme = checkout(tmp.path(), "acme", "acme/widgets");
let fork = checkout(tmp.path(), "fork", "alice/widgets");
let plain = tmp.path().join("plain");
std::fs::create_dir_all(&plain).unwrap();
let a = entry("https://acme.aretta.ai", Some("acme/widgets"), "t1");
let two = store(vec![
entry("https://code.aretta.ai", Some("other/x"), "t0"),
a.clone(),
]);
assert_eq!(
status_verdict(&two, &acme),
"this checkout (acme/widgets) resolves to: server https://acme.aretta.ai, repo acme/widgets"
);
let v = status_verdict(&two, &fork);
assert!(
v.starts_with("this checkout (alice/widgets) resolves to: no stored credential"),
"{v}"
);
assert!(
v.contains(
"`aristo auth login --server https://<org>.aretta.ai --repo alice/widgets` here"
),
"{v}"
);
let v = status_verdict(&two, &plain);
assert!(v.contains("not a GitHub checkout"), "{v}");
assert!(v.contains("no stored credential (2 on file"), "{v}");
let one = store(vec![a]);
let v = status_verdict(&one, &plain);
assert!(v.contains("no stored credential (1 on file"), "{v}");
}
}