semora-cli 1.0.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! `semora login` (a code allowed in a browser on any device) and `semora logout`.

use std::io::Write;
use std::time::{Duration, Instant};

use anyhow::Context;
use reqwest::StatusCode;
use semora_proto::{CliDeviceRequest, CliDeviceResponse, CliTokenRequest, CliTokenResponse};

use crate::client::{Client, check, http_client, no_server_error};
use crate::commands::docs::web_link;
use crate::config::{Settings, origin};
use crate::credentials::Stored;
use crate::{Ctx, exit, output};

pub(crate) fn hostname() -> String {
    for var in ["COMPUTERNAME", "HOSTNAME"] {
        if let Ok(h) = std::env::var(var)
            && !h.trim().is_empty()
        {
            return h.trim().to_lowercase();
        }
    }
    std::process::Command::new("hostname")
        .output()
        .ok()
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_lowercase())
        .filter(|h| !h.is_empty())
        .unwrap_or_else(|| "this-machine".into())
}

/// Whether a browser started here would show the person a page. Over SSH or without a
/// display the launcher fails, or starts a text browser in this terminal.
fn has_desktop() -> bool {
    let set = |var: &str| std::env::var_os(var).is_some_and(|v| !v.is_empty());
    if set("SSH_CONNECTION") || set("SSH_TTY") {
        return false;
    }
    cfg!(any(target_os = "macos", windows)) || set("DISPLAY") || set("WAYLAND_DISPLAY")
}

/// Polls that may fail in a row (server restarting, network gone) before login gives up.
const MAX_FAILED_POLLS: u32 = 5;

pub async fn login(ctx: &Ctx, no_browser: bool, name: Option<String>) -> anyhow::Result<i32> {
    let server = ctx.settings.server.clone().ok_or_else(no_server_error)?;
    let base = origin(&server);
    let name = name.unwrap_or_else(|| format!("cli@{}", hostname()));
    let http = http_client()?;

    let resp = http
        .post(format!("{base}/api/v1/cli/device"))
        .json(&CliDeviceRequest { name })
        .send()
        .await
        .with_context(|| format!("could not reach {base}"))?;
    if resp.status() == StatusCode::NOT_FOUND {
        return Err(exit::not_found(format!(
            "{base} has no sign-in for this CLI; it may be older than this CLI. Update the \
             server, or set SEMORA_API_KEY to a key from your account page."
        )));
    }
    let grant: CliDeviceResponse = check(resp, "sign-in").await?.json().await?;
    // The page address comes from the server and may be handed to the system to open.
    let url = web_link(&grant.verification_uri_complete)?;

    let wait = Duration::from_secs(grant.expires_in.clamp(1, 3600));
    // The code is the server's text; keep it from carrying terminal control codes.
    let code: String = grant
        .user_code
        .chars()
        .filter(|c| !c.is_control())
        .collect();
    // anstream drops the styling when the stream is not a terminal or NO_COLOR is set, so
    // the link on stdout stays a plain line for a script or an agent to read.
    let output::Styles { brand, done, .. } = output::err();
    anstream::eprintln!("Sign-in code: {brand}{code}{brand:#}");
    anstream::eprintln!("Open this link in any browser, on this machine or another:\n");
    anstream::println!("{LINK}{url}{LINK:#}", LINK = output::LINK);
    std::io::stdout().flush()?;
    anstream::eprintln!(
        "\n{DIM}Waiting for you to choose Allow there (up to {} minutes)...{DIM:#}",
        minutes(wait),
        DIM = output::DIM
    );
    if !no_browser && ctx.settings.open_browser && has_desktop() {
        // Best effort: the address is on the screen either way.
        let _ = open::that_detached(&url);
    }

    let deadline = Instant::now() + wait;
    let mut interval = Duration::from_secs(grant.interval.clamp(1, 30));
    let mut failed = 0u32;
    let (api_key, key_name, user) = loop {
        tokio::time::sleep(interval).await;
        if Instant::now() >= deadline {
            return Err(expired(wait));
        }
        let sent = http
            .post(format!("{base}/api/v1/cli/token"))
            .json(&CliTokenRequest {
                device_code: grant.device_code.clone(),
            })
            .send()
            .await;
        let resp = match sent {
            Ok(r) if r.status() == StatusCode::TOO_MANY_REQUESTS => {
                interval = (interval * 2).min(Duration::from_secs(30));
                continue;
            }
            Ok(r) if !r.status().is_server_error() => r,
            other => {
                failed += 1;
                if failed >= MAX_FAILED_POLLS {
                    return Err(match other {
                        Ok(r) => check(r, "sign-in").await.err().unwrap_or_else(|| {
                            anyhow::anyhow!("sign-in failed: the server kept failing")
                        }),
                        Err(e) => anyhow::Error::new(e).context(format!("could not reach {base}")),
                    });
                }
                continue;
            }
        };
        failed = 0;
        match check(resp, "sign-in").await?.json().await? {
            CliTokenResponse::Pending => {}
            CliTokenResponse::Denied => {
                return Err(exit::auth("sign-in was denied in the browser"));
            }
            CliTokenResponse::Expired => return Err(expired(wait)),
            CliTokenResponse::Approved {
                api_key,
                name,
                user,
            } => break (api_key, name, user),
        }
    };

    // The key this CLI had stored for this server, read before the new one replaces it.
    // The environment's key is not in the store and is never revoked here.
    let previous = replaced_key(ctx.store.get(&base), &api_key);
    let stored = ctx.store.set(&base, &api_key, &key_name)?;
    if let Some(old) = previous
        && let Err(e) = revoke_stored_key(&ctx.settings, old).await
    {
        output::warn(format!(
            "could not revoke the previous key on the server: {e:#}"
        ));
    }
    let place = match stored {
        Stored::Keyring => "the system keyring".to_string(),
        Stored::File => ctx.store.path().display().to_string(),
    };
    anstream::eprintln!(
        "{done}Signed in{done:#} as {}. Key \"{}\" saved in {place}.",
        output::clean_name(&user.email),
        output::clean_name(&key_name)
    );
    if ctx.settings.key_source == crate::config::KeySource::Env {
        output::warn("SEMORA_API_KEY is set, so it wins over this login until you unset it");
    }
    Ok(exit::OK)
}

fn expired(wait: Duration) -> anyhow::Error {
    exit::timeout(format!(
        "nobody allowed the sign-in within {} minutes; run `semora login` again",
        minutes(wait)
    ))
}

fn minutes(wait: Duration) -> u64 {
    wait.as_secs().div_ceil(60)
}

pub async fn logout(ctx: &Ctx) -> anyhow::Result<i32> {
    let server = ctx.settings.server.clone().ok_or_else(no_server_error)?;
    let base = origin(&server);
    let Some(key) = ctx.store.get(&base) else {
        eprintln!("No stored login for {base}.");
        if ctx.settings.key_source == crate::config::KeySource::Env {
            eprintln!("SEMORA_API_KEY comes from the environment or a .env file; remove it there.");
        }
        return Ok(exit::OK);
    };
    if let Err(e) = revoke_stored_key(&ctx.settings, key).await {
        output::warn(format!("could not revoke the key on the server: {e:#}"));
    }
    ctx.store.remove(&base)?;
    let done = output::err().done;
    anstream::eprintln!("{done}Signed out{done:#} of {base}.");
    Ok(exit::OK)
}

/// The key a login replaces: the one stored for the server, unless it is the new key itself.
fn replaced_key(stored: Option<String>, new: &str) -> Option<String> {
    stored.filter(|old| old != new)
}

/// Revokes on the server a key this CLI had stored: `whoami` names the key, then it is
/// revoked by id. `key` must come from the store. A key from `SEMORA_API_KEY` or `--api-key`
/// is not this CLI's to revoke. `Ok` means the key no longer works, whether it was revoked
/// now or already was (a 401 means it was revoked or has expired, which is what we want).
/// `Err` is any other failure; callers warn and carry on.
async fn revoke_stored_key(settings: &Settings, key: String) -> anyhow::Result<()> {
    let mut settings = settings.clone();
    settings.api_key = Some(key);
    settings.key_source = crate::config::KeySource::Stored;
    let client = Client::new(&settings)?;
    match client.whoami().await {
        Ok(w) => match w.key {
            Some(k) => client.revoke_key(k.id).await,
            None => Ok(()),
        },
        Err(e) if exit::code_of(&e) == exit::AUTH => Ok(()),
        Err(e) => Err(e),
    }
}

#[cfg(test)]
mod tests {
    use super::replaced_key;

    #[test]
    fn a_login_replaces_a_different_stored_key_only() {
        assert_eq!(replaced_key(None, "sm_new"), None);
        assert_eq!(replaced_key(Some("sm_new".into()), "sm_new"), None);
        assert_eq!(
            replaced_key(Some("sm_old".into()), "sm_new").as_deref(),
            Some("sm_old")
        );
    }
}