Skip to main content

memstead_cli/commands/
login.rs

1//! `memstead login` — run the GitHub Device Flow and persist the resulting
2//! token at `~/.config/memstead/credentials` keyed on the registry host.
3//!
4//! This command is optional — `memstead publish` auto-triggers the same
5//! flow on first use. Useful for CI preflight, or users who want to
6//! authenticate before their first publish.
7
8use clap::Parser;
9use serde_json::json;
10
11use crate::CliError;
12use crate::auth::{credentials, device_flow};
13use crate::output::{ExitKind, print_json, print_markdown};
14use crate::registry;
15use crate::setup::CliContext;
16
17#[derive(Parser, Debug)]
18pub struct Args {
19    /// Registry URL (overrides `MEMSTEAD_REGISTRY`; defaults to https://memstead.io).
20    #[arg(long, value_name = "URL")]
21    pub registry: Option<String>,
22}
23
24pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
25    let base = registry::registry_base(args.registry.as_deref());
26    let host = registry::registry_host(&base);
27    let client = registry::build_http()?;
28
29    let outcome = device_flow::run(
30        &client,
31        device_flow::MEMSTEAD_GITHUB_CLIENT_ID,
32        device_flow::MEMSTEAD_GITHUB_SCOPE,
33        |url| {
34            let _ = device_flow::open_browser(url);
35        },
36    )
37    .map_err(|e| {
38        CliError::new(
39            ExitKind::Generic,
40            "LOGIN_FAILED",
41            format!("login failed: {e}"),
42        )
43    })?;
44
45    // Resolve the GitHub username right after the flow so the stored
46    // entry has a display name and we surface "logged in as <login>"
47    // on stdout. Failure here is non-fatal — the token still works.
48    let user_login = fetch_login(&client, &outcome.access_token).unwrap_or_default();
49
50    let entry = credentials::Entry::new(
51        outcome.access_token.clone(),
52        user_login.clone(),
53        outcome.scopes.clone(),
54    );
55    credentials::save_for(&host, entry)?;
56
57    if ctx.json {
58        print_json(&json!({
59            "ok": true,
60            "registry": host,
61            "user_login": user_login,
62            "scopes": outcome.scopes,
63        }))?;
64    } else {
65        let who = if user_login.is_empty() {
66            "authorized".to_string()
67        } else {
68            format!("logged in as {user_login}")
69        };
70        print_markdown(&format!("# {who}\n\n- Registry: {host}"));
71    }
72
73    Ok(())
74}
75
76/// Resolve the GitHub username for a token. Uses the env-overridable
77/// `MEMSTEAD_GITHUB_API_BASE` so the integration test can point this at a
78/// local mock.
79fn fetch_login(client: &reqwest::blocking::Client, token: &str) -> anyhow::Result<String> {
80    let base = std::env::var("MEMSTEAD_GITHUB_API_BASE")
81        .unwrap_or_else(|_| "https://api.github.com".to_string());
82    let url = format!("{}/user", base.trim_end_matches('/'));
83    let resp = client
84        .get(url)
85        .bearer_auth(token)
86        .header("accept", "application/vnd.github+json")
87        .send()?;
88    if !resp.status().is_success() {
89        anyhow::bail!("GitHub /user returned {}", resp.status());
90    }
91    #[derive(serde::Deserialize)]
92    struct User {
93        login: String,
94    }
95    let user: User = resp.json()?;
96    Ok(user.login)
97}