use std::io::{self, BufRead, Write};
use anyhow::{anyhow, bail, Context, Result};
use clap::Subcommand;
use serde_json::json;
use crate::client::Client;
use crate::config::Config;
pub const DEFAULT_REMOTE_HOST: &str = "https://gitruntime.com";
#[derive(Debug, Subcommand)]
pub enum AuthCmd {
Login(LoginArgs),
Logout,
Status,
}
#[derive(Debug, clap::Args)]
pub struct LoginArgs {
#[arg(long, env = "RUNTIME_HOST", default_value = DEFAULT_REMOTE_HOST)]
pub host: Option<String>,
#[arg(long, env = "RUNTIME_USER")]
pub username: Option<String>,
#[arg(long, default_value_t = false)]
pub password_stdin: bool,
#[arg(long, default_value = "runtime-cli")]
pub token_name: String,
}
pub fn run(cmd: AuthCmd) -> Result<()> {
match cmd {
AuthCmd::Login(a) => login(a),
AuthCmd::Logout => logout(),
AuthCmd::Status => status(),
}
}
fn login(args: LoginArgs) -> Result<()> {
let mut stdout = io::stdout().lock();
let host = args.host.unwrap_or_else(|| DEFAULT_REMOTE_HOST.to_string());
let username = match args.username {
Some(u) => u,
None => prompt(&mut stdout, "Username: ")?,
};
let password = if args.password_stdin {
let mut buf = String::new();
io::stdin()
.lock()
.read_line(&mut buf)
.context("read password from stdin")?;
trim_input_line(&buf)
} else {
rpassword::prompt_password("Password: ").context("read password")?
};
let client = Client::new(host.clone(), None)?;
let signin = client.execute(
SIGN_IN,
json!({ "input": { "username": username, "password": password } }),
)?;
if let Some(partial) = signin["signIn"]["partialToken"].as_str() {
let _ = partial;
bail!("TOTP-enabled accounts are not yet supported by `runtime auth login`");
}
let session_token = signin["signIn"]["token"]
.as_str()
.ok_or_else(|| anyhow!("signIn returned no token"))?
.to_string();
let authed = Client::new(host.clone(), Some(session_token))?;
let pat = authed.execute(
CREATE_PAT,
json!({
"input": {
"name": args.token_name,
"scopes": ["REPO_READ", "REPO_WRITE", "REPO_ADMIN", "WORKFLOW_DISPATCH"],
}
}),
)?;
let token = pat["createPersonalAccessToken"]["token"]
.as_str()
.ok_or_else(|| anyhow!("createPersonalAccessToken returned no token"))?
.to_string();
let cfg = login_config(host, username, token);
let path = cfg.save()?;
writeln!(stdout, "{}", login_success_message(&cfg, path.display()))?;
Ok(())
}
fn logout() -> Result<()> {
let removed = Config::delete()?;
let mut stdout = io::stdout().lock();
if removed {
writeln!(stdout, "Removed saved credentials")?;
} else {
writeln!(stdout, "Nothing to do — no saved credentials")?;
}
Ok(())
}
fn status() -> Result<()> {
let mut stdout = io::stdout().lock();
match Config::load() {
Ok(cfg) => {
for line in status_lines(&cfg) {
writeln!(stdout, "{line}")?;
}
Ok(())
}
Err(e) => {
writeln!(stdout, "Not logged in ({e:#})")?;
Ok(())
}
}
}
fn prompt(stdout: &mut io::StdoutLock<'_>, label: &str) -> Result<String> {
write!(stdout, "{label}")?;
stdout.flush()?;
let mut buf = String::new();
io::stdin().lock().read_line(&mut buf)?;
Ok(trim_input_line(&buf))
}
fn trim_input_line(input: &str) -> String {
input.trim_end_matches(['\n', '\r']).to_string()
}
fn login_config(host: String, username: String, token: String) -> Config {
Config {
host: host.trim_end_matches('/').to_string(),
username,
token,
}
}
fn login_success_message(cfg: &Config, path: impl std::fmt::Display) -> String {
format!(
"Logged in to {} as {} — credentials saved to {}",
cfg.host, cfg.username, path
)
}
fn status_lines(cfg: &Config) -> [String; 3] {
[
format!("Host: {}", cfg.host),
format!("User: {}", cfg.username),
"Token: ********".to_string(),
]
}
const SIGN_IN: &str = "mutation($input: SignInInput!) { \
signIn(input: $input) { token partialToken } \
}";
const CREATE_PAT: &str = "mutation($input: CreatePatInput!) { \
createPersonalAccessToken(input: $input) { token record { id name } } \
}";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trim_input_line_removes_only_line_endings() {
assert_eq!(trim_input_line("secret\n"), "secret");
assert_eq!(trim_input_line("secret\r\n"), "secret");
assert_eq!(trim_input_line(" secret \n"), " secret ");
}
#[test]
fn login_config_trims_trailing_host_slashes_and_preserves_secret() {
let cfg = login_config(
"https://runtime.test///".into(),
"bri".into(),
"token-value".into(),
);
assert_eq!(cfg.host, "https://runtime.test");
assert_eq!(cfg.username, "bri");
assert_eq!(cfg.token, "token-value");
}
#[test]
fn login_success_and_status_messages_never_print_token_value() {
let cfg = Config {
host: "https://runtime.test".into(),
username: "bri".into(),
token: "super-secret-token".into(),
};
let login = login_success_message(&cfg, "/tmp/runtime-config.json");
assert_eq!(
login,
"Logged in to https://runtime.test as bri — credentials saved to /tmp/runtime-config.json"
);
assert!(!login.contains("super-secret-token"));
let status = status_lines(&cfg);
assert_eq!(status[0], "Host: https://runtime.test");
assert_eq!(status[1], "User: bri");
assert_eq!(status[2], "Token: ********");
assert!(status
.iter()
.all(|line| !line.contains("super-secret-token")));
}
}