use std::io::Read;
use aristo_core::auth::{
self, derive_repo_full_name, login_server, login_server_discovering, AuthError,
LoginServerSource, ServerUrl, Token,
};
use crate::{AuthAction, CliError, CliResult};
pub(crate) fn run(action: AuthAction) -> CliResult<()> {
match action {
AuthAction::Login {
stdin,
token,
server,
repo,
} => login(stdin, token, server, repo),
AuthAction::Status => status(),
AuthAction::Token { repo } => token(repo),
AuthAction::Logout { all, repo } => logout(all, repo),
}
}
fn login(
read_stdin: bool,
token_flag: Option<String>,
server_flag: Option<String>,
repo_flag: Option<String>,
) -> CliResult<()> {
if read_stdin || token_flag.is_some() {
return login_with_raw_token(read_stdin, token_flag, server_flag, repo_flag);
}
let repo_full_name = resolve_repo_full_name(repo_flag)?;
let env_override = std::env::var("ARETTA_API_URL").ok();
let platform = discovery_platform();
let (server, source) = login_server_discovering(
server_flag.as_deref(),
env_override.as_deref(),
&platform,
|p| auth::discover_org(p, &repo_full_name),
);
login_via_oauth(&server, source, repo_full_name)
}
fn discovery_platform() -> ServerUrl {
match std::env::var("ARETTA_DISCOVERY_URL").ok() {
Some(v) if !v.trim().is_empty() => ServerUrl::parse(&v),
_ => ServerUrl::Prod,
}
}
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!();
match source.provenance(&repo_full_name) {
Some(prov) => eprintln!("Authenticating against {server} ({prov})"),
None => eprintln!("Authenticating against {server}"),
}
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()),
};
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());
println!(" `aristo auth status` to verify; `aristo auth logout` to remove.");
Ok(())
}
fn login_with_raw_token(
read_stdin: bool,
token_flag: Option<String>,
server_flag: Option<String>,
repo_flag: Option<String>,
) -> CliResult<()> {
let token_raw = collect_raw_token(read_stdin, token_flag)?;
let trimmed = token_raw.trim();
if trimmed.is_empty() {
return Err(CliError::Other {
message: "no token provided.\n\
Run `aristo auth login` (OAuth flow, default) to mint one interactively, or if you already have an arta_* token:\n \
`aristo auth login --stdin` (pipe), or\n \
`aristo auth login --token <TOKEN>` (scripting)."
.into(),
exit_code: 2,
});
}
let env_override = std::env::var("ARETTA_API_URL").ok();
let (server, _) = login_server(server_flag.as_deref(), env_override.as_deref());
let repo = resolve_repo_best_effort(repo_flag)?;
let creds = aristo_core::auth::CredentialsRecord {
token: Token::new(trimmed),
server,
user_login: None,
user_id: None,
repo,
};
aristo_core::auth::save_full(&creds).map_err(CliError::Io)?;
let path = auth::credentials_path().map_err(auth_error_to_cli)?;
println!("ok: authenticated. token saved to {}", path.display());
println!(" `aristo auth status` to verify; `aristo auth logout` to remove.");
Ok(())
}
fn collect_raw_token(read_stdin: bool, token_flag: Option<String>) -> CliResult<String> {
if let Some(t) = token_flag {
return Ok(t);
}
if read_stdin {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(CliError::Io)?;
return Ok(buf);
}
Err(CliError::Other {
message: "internal: collect_raw_token called without --stdin or --token".into(),
exit_code: 1,
})
}
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 `aristo auth login` to re-create it."
),
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_set = std::env::var(auth::ENV_VAR)
.ok()
.is_some_and(|v| !v.trim().is_empty());
if env_set {
println!(
"ok: authenticated via {} environment variable.",
auth::ENV_VAR
);
println!(" (env var takes precedence over the on-disk credentials file.)");
}
let store = auth::load_store().map_err(store_error_to_cli)?;
if store.is_empty() {
if !env_set {
println!("not authenticated.");
println!(
" Run `aristo auth login` to log in, or set the {} env var for CI.",
auth::ENV_VAR
);
}
return Ok(());
}
let path = auth::credentials_path().map_err(auth_error_to_cli)?;
if env_set {
println!(
" also stored (shadowed by {}): {} credential(s) in {}",
auth::ENV_VAR,
store.len(),
path.display()
);
} else {
println!(
"ok: authenticated — {} credential(s) in {}",
store.len(),
path.display()
);
}
for e in &store.entries {
let repo = e.repo.as_deref().unwrap_or("(unscoped)");
match &e.user_login {
Some(user) => println!(" • server: {} repo: {repo} user: {user}", e.server),
None => println!(" • server: {} repo: {repo}", e.server),
}
}
Ok(())
}
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: format!(
"not authenticated — no token found.\n \
Run `aristo auth login` to mint one, or set the {} env var.",
auth::ENV_VAR
),
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 `aristo auth login --repo {repo}` \
(or `aristo auth status` to list what's stored)."
),
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))
.or_else(|| store.sole())
};
match entry {
Some(e) => {
println!("{}", e.token.as_str());
Ok(())
}
None => Err(CliError::Other {
message: "several credentials stored — pass `--repo <owner/repo>` to pick one \
(or `aristo auth status` to list)."
.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 repo_hint = resolve_repo_best_effort(repo_flag)?;
let removed_label = match &repo_hint {
Some(r) => {
if store.remove_by_repo(r) == 0 {
println!("ok: no credential for {r} to remove (nothing changed).");
note_env_still_set();
return Ok(());
}
format!("of {r}")
}
None => {
if store.len() == 1 {
store.entries.clear();
"the stored credential".to_string()
} else {
return Err(CliError::Other {
message: "several credentials stored — pass `--repo <owner/repo>` to log out \
of one, or `--all` to clear everything."
.into(),
exit_code: 2,
});
}
}
};
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(())
}