use std::io::{Read, Write};
use std::process::Command;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use clap::Subcommand;
use console::style;
use ryra_core::system::account::{self, Credentials, DevicePoll};
#[derive(Subcommand)]
pub enum AccountAction {
Login {
#[arg(long)]
with_token: bool,
},
Logout,
Status,
}
pub async fn run(action: AccountAction) -> Result<()> {
match action {
AccountAction::Login { with_token } => login(with_token).await,
AccountAction::Logout => logout(),
AccountAction::Status => status(),
}
}
async fn login(with_token: bool) -> Result<()> {
if let Some(token) = scripted_login_token(with_token)? {
let token = token.trim().to_string();
if token.is_empty() {
bail!("no API key provided");
}
account::verify_token(&token).context("validating the API key with the control plane")?;
account::save_credentials(&Credentials {
token: token.clone(),
})?;
println!("Connected to {}.", account::api_base_url());
return Ok(());
}
if !super::is_interactive() {
bail!(
"no TTY and no key supplied: pipe the key on stdin with \
`ryra account login --with-token`, or set RYRA_TOKEN. \
Generate a key at {}/account.",
account::api_base_url()
);
}
device_login().await
}
fn scripted_login_token(with_token: bool) -> Result<Option<String>> {
if let Ok(t) = std::env::var("RYRA_TOKEN")
&& !t.trim().is_empty()
{
return Ok(Some(t));
}
if with_token {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("reading API key from stdin")?;
return Ok(Some(buf));
}
Ok(None)
}
pub(crate) async fn device_login() -> Result<()> {
let base = account::api_base_url();
let label = box_label();
let start = account::device_start(&label).context("starting the device login")?;
println!("To connect this machine to {base}, approve it in your browser:");
println!();
println!(
" {} {}",
super::style::arrow(),
style(&start.verification_uri_complete).cyan().bold()
);
println!(
" or go to {} and enter the code: {}",
start.verification_uri,
style(&start.user_code).bold()
);
println!();
open_browser(&start.verification_uri_complete);
let interval = Duration::from_secs(start.interval.max(1));
let deadline = Instant::now() + Duration::from_secs(start.expires_in.max(1));
print!("Waiting for approval");
let _ = std::io::stdout().flush();
loop {
if Instant::now() >= deadline {
println!();
bail!("the login request expired before it was approved; re-run `ryra account login`");
}
match account::device_poll(&start.device_code).context("checking the device login")? {
DevicePoll::Pending => {
print!(".");
let _ = std::io::stdout().flush();
tokio::time::sleep(interval).await;
}
DevicePoll::Approved(key) => {
println!();
account::save_credentials(&Credentials { token: key })?;
println!("Connected to {base}.");
return Ok(());
}
DevicePoll::Denied => {
println!();
bail!("the login was denied in the browser");
}
DevicePoll::Expired => {
println!();
bail!("the login request expired; re-run `ryra account login`");
}
}
}
}
fn box_label() -> String {
if let Ok(v) = std::env::var("RYRA_BOX_LABEL")
&& !v.trim().is_empty()
{
return v.trim().to_string();
}
if let Ok(v) = std::env::var("HOSTNAME")
&& !v.trim().is_empty()
{
return v.trim().to_string();
}
if let Ok(out) = Command::new("hostname").output()
&& out.status.success()
{
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !name.is_empty() {
return name;
}
}
"a ryra machine".to_string()
}
pub(crate) fn open_browser(url: &str) {
let opener = if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
let _ = Command::new(opener)
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
fn logout() -> Result<()> {
let revoked = match account::revoke_stored_key() {
Ok(sent) => sent,
Err(e) => {
eprintln!(
"warning: couldn't revoke the key on the control plane ({e:#}); \
removing it locally anyway. Revoke it from Settings if needed."
);
false
}
};
if account::delete_credentials()? {
if revoked {
println!(
"Logged out; revoked the API key on the control plane and removed it locally."
);
} else {
println!("Logged out; removed the stored API key.");
}
} else {
println!("Not logged in; nothing to remove.");
}
Ok(())
}
fn status() -> Result<()> {
let base = account::api_base_url();
let Some(src) = account::effective_token()? else {
println!("Not logged in.");
println!("Run `ryra account login` to connect to {base}.");
return Ok(());
};
let origin = match src {
account::TokenSource::Env(_) => "RYRA_TOKEN (managed machine / env)",
account::TokenSource::Stored(_) => "stored credentials",
};
match account::verify_token(src.token()) {
Ok(()) => {
println!("Logged in to {base} via {origin}.");
println!(" API key: {}", mask_token(src.token()));
}
Err(e) => {
println!(
"Logged in to {base} via {origin} (key {}), but it could not be verified:",
mask_token(src.token())
);
println!(" {e:#}");
if matches!(src, account::TokenSource::Stored(_)) {
println!(" Run `ryra account login` to refresh it.");
}
}
}
Ok(())
}
fn mask_token(token: &str) -> String {
let chars: Vec<char> = token.chars().collect();
let n = chars.len();
if n <= 8 {
return "****".to_string();
}
let head: String = chars[..7].iter().collect();
let tail: String = chars[n - 4..].iter().collect();
format!("{head}...{tail}")
}