Skip to main content

broccoli_cli/commands/
login.rs

1use std::io::Write;
2use std::thread;
3use std::time::Duration;
4
5use anyhow::{Context, bail};
6use clap::Args;
7use console::style;
8use serde::Deserialize;
9
10use crate::auth;
11
12#[derive(Args)]
13pub struct LoginArgs {
14    /// Broccoli server URL
15    #[arg(long, default_value = "http://localhost:3000", env = "BROCCOLI_URL")]
16    pub server: String,
17}
18
19#[derive(Deserialize)]
20struct DeviceCodeResponse {
21    device_code: String,
22    user_code: String,
23    verification_url: String,
24    expires_in: u64,
25    interval: u64,
26}
27
28#[derive(Deserialize)]
29struct PollResponse {
30    #[serde(default)]
31    error: Option<String>,
32    #[serde(default)]
33    token: Option<String>,
34}
35
36pub fn run(args: LoginArgs) -> anyhow::Result<()> {
37    let client = reqwest::blocking::Client::new();
38
39    println!(
40        "{}  Requesting device code from {}...",
41        style("→").blue().bold(),
42        style(&args.server).cyan()
43    );
44
45    let resp = client
46        .post(format!("{}/api/v1/auth/device-code", args.server))
47        .json(&serde_json::json!({}))
48        .send()
49        .context("Failed to connect to server. Is it running?")?;
50
51    if !resp.status().is_success() {
52        let status = resp.status();
53        let body = resp.text().unwrap_or_default();
54        bail!("Server returned {}: {}", status, body);
55    }
56
57    let device_code_resp: DeviceCodeResponse = resp
58        .json()
59        .context("Failed to parse device code response")?;
60
61    println!();
62    println!(
63        "  Open {} and enter code:",
64        style(&device_code_resp.verification_url)
65            .underlined()
66            .cyan()
67    );
68    println!();
69    println!("    {}", style(&device_code_resp.user_code).bold().yellow());
70    println!();
71
72    // Best-effort open in browser
73    let _ = open::that(&device_code_resp.verification_url);
74
75    let interval = Duration::from_secs(device_code_resp.interval);
76    let max_polls = device_code_resp.expires_in / device_code_resp.interval;
77
78    print!("  Waiting for authorization");
79    std::io::stdout().flush().ok();
80
81    for _ in 0..max_polls {
82        thread::sleep(interval);
83        print!(".");
84        std::io::stdout().flush().ok();
85
86        let poll_resp = client
87            .post(format!("{}/api/v1/auth/device-token", args.server))
88            .json(&serde_json::json!({
89                "device_code": device_code_resp.device_code
90            }))
91            .send();
92
93        let poll_resp = match poll_resp {
94            Ok(r) => r,
95            Err(e) => {
96                eprintln!("\n  Connection error: {}. Retrying...", e);
97                continue;
98            }
99        };
100
101        let poll: PollResponse = match poll_resp.json() {
102            Ok(p) => p,
103            Err(_) => continue,
104        };
105
106        if let Some(token) = poll.token {
107            println!();
108            println!();
109
110            auth::save_credentials(&args.server, &token).context("Failed to save credentials")?;
111
112            println!("{}  Logged in successfully!", style("✓").green().bold());
113            println!(
114                "   Credentials saved to {}",
115                style("~/.config/broccoli/credentials.json").dim()
116            );
117
118            return Ok(());
119        }
120
121        if let Some(ref error) = poll.error {
122            match error.as_str() {
123                "authorization_pending" => continue,
124                "slow_down" => {
125                    // Back off a bit
126                    thread::sleep(Duration::from_secs(1));
127                    continue;
128                }
129                "expired_token" => {
130                    println!();
131                    bail!("Device code expired. Run `broccoli login` again to get a new code.");
132                }
133                other => {
134                    println!();
135                    bail!("Unexpected error from server: {}", other);
136                }
137            }
138        }
139    }
140
141    println!();
142    bail!("Timed out waiting for authorization. Run `broccoli login` again.");
143}