Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 6 |
| 7 | use std::io::IsTerminal; |
| 8 | use std::time::{Duration, Instant}; |
| 9 |
| 10 | use serde::Deserialize; |
| 11 | use serde_json::json; |
| 12 |
| 13 | use crate::out::Fail; |
| 14 |
| 15 | const POLL: Duration = Duration::from_secs(2); |
| 16 | const GIVE_UP: Duration = Duration::from_secs(5 * 60); |
| 17 |
| 18 | #[derive(Deserialize)] |
| 19 | struct Envelope<T> { |
| 20 | data: Option<T>, |
| 21 | } |
| 22 |
| 23 | #[derive(Deserialize)] |
| 24 | #[serde(rename_all = "camelCase")] |
| 25 | struct Created { |
| 26 | challenge_id: String, |
| 27 | verify_url: String, |
| 28 | } |
| 29 |
| 30 | #[derive(Deserialize)] |
| 31 | struct Status { |
| 32 | status: String, |
| 33 | #[serde(default)] |
| 34 | token: Option<String>, |
| 35 | } |
| 36 |
| 37 | fn open_in_browser(url: &str) { |
| 38 | let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; |
| 39 | let _ = std::process::Command::new(opener) |
| 40 | .arg(url) |
| 41 | .stdin(std::process::Stdio::null()) |
| 42 | .stdout(std::process::Stdio::null()) |
| 43 | .stderr(std::process::Stdio::null()) |
| 44 | .spawn(); |
| 45 | } |
| 46 |
| 47 | /// Gets a token by sending the person to `login`, the host's sign-in endpoint. |
| 48 | pub fn browser(host: &str, login: Option<&str>) -> Result<String, Fail> { |
| 49 | let Some(login) = login else { |
| 50 | return Err(Fail::new("bad_usage", format!("{host} doesn't offer browser sign-in")) |
| 51 | .state("nothing changed") |
| 52 | .next(format!("vcs login {host} --token <token>"))); |
| 53 | }; |
| 54 | let agent = crate::remote::http(); |
| 55 | let offline = |e: ureq::Error| Fail::new("offline", format!("can't reach {login}: {e}")).state("nothing changed"); |
| 56 | let mut response = agent.post(login).send_json(json!({})).map_err(offline)?; |
| 57 | if response.status().as_u16() >= 300 { |
| 58 | return Err(Fail::new("failed", format!("{host}'s sign-in answered {}", response.status())).state("nothing changed")); |
| 59 | } |
| 60 | let created: Envelope<Created> = response.body_mut().read_json().map_err(|e| Fail::new("failed", format!("{host}'s sign-in sent something unexpected: {e}")))?; |
| 61 | let created = created.data.ok_or_else(|| Fail::new("failed", format!("{host}'s sign-in sent something unexpected")))?; |
| 62 | eprintln!("Open this in a browser to sign in to {host}:\n {}\n\nWaiting for you to approve it (Ctrl+C to stop)...", created.verify_url); |
| 63 | if std::io::stderr().is_terminal() && std::env::var_os("CI").is_none() { |
| 64 | open_in_browser(&created.verify_url); |
| 65 | } |
| 66 | let started = Instant::now(); |
| 67 | while started.elapsed() < GIVE_UP { |
| 68 | std::thread::sleep(POLL); |
| 69 | let Ok(mut response) = agent.get(format!("{login}/{}", created.challenge_id)).call() else { continue }; |
| 70 | if response.status().as_u16() != 200 { |
| 71 | continue; |
| 72 | } |
| 73 | let Ok(status) = response.body_mut().read_json::<Envelope<Status>>() else { continue }; |
| 74 | let Some(status) = status.data else { continue }; |
| 75 | match status.status.as_str() { |
| 76 | "approved" => { |
| 77 | if let Some(token) = status.token.filter(|t| !t.is_empty()) { |
| 78 | return Ok(token); |
| 79 | } |
| 80 | } |
| 81 | "denied" => return Err(Fail::new("not_logged_in", "the sign-in was denied in the browser").state("nothing changed")), |
| 82 | "expired" => return Err(Fail::new("not_logged_in", "the sign-in request expired").state("nothing changed").next(format!("vcs login {host}"))), |
| 83 | _ => {} |
| 84 | } |
| 85 | } |
| 86 | Err(Fail::new("not_logged_in", "gave up waiting for the sign-in after 5 minutes").state("nothing changed").next(format!("vcs login {host}"))) |
| 87 | } |
| 88 |