runtime-cli 0.1.0

Command-line client for managing git projects on Runtime
Documentation
//! `runtime auth` — login / logout / status.
//!
//! `login` follows a two-step API dance:
//! 1. `signIn(username, password)` → session token.
//! 2. `createPersonalAccessToken { name, scopes: [REPO_ADMIN] }` →
//!    persistent PAT.
//!
//! The PAT is what we keep on disk; the short-lived session token is
//! discarded. The public hosted Runtime remote is the default host.

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 {
    /// Authenticate against a Runtime instance and store a PAT.
    Login(LoginArgs),
    /// Forget the saved credentials.
    Logout,
    /// Print the current host and username.
    Status,
}

#[derive(Debug, clap::Args)]
pub struct LoginArgs {
    /// Base URL of the Runtime instance.
    #[arg(long, env = "RUNTIME_HOST", default_value = DEFAULT_REMOTE_HOST)]
    pub host: Option<String>,
    /// Username for the account to log in as.
    #[arg(long, env = "RUNTIME_USER")]
    pub username: Option<String>,
    /// Read the password from stdin (one line). Otherwise prompt
    /// interactively.
    #[arg(long, default_value_t = false)]
    pub password_stdin: bool,
    /// PAT display name (helps you find it in the UI later).
    #[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)?;

    // Step 1: signIn → session token. We don't support TOTP in v1; if
    // the API returns a partial token we surface a helpful error.
    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();

    // Step 2: createPersonalAccessToken with REPO_ADMIN scope.
    let authed = Client::new(host.clone(), Some(session_token))?;
    // Mint a broadly-scoped PAT so every subcommand works. We include
    // the read/write/admin tier plus workflow dispatch so `runtime run
    // cancel` is callable.
    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")));
    }
}