use crate::error::{Error, Result};
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines};
use tokio::process::{Child, ChildStdout};
pub const META_API_KEY_VAR: &str = "META_API_KEY";
pub fn credentials_path() -> Option<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
return Some(PathBuf::from(xdg).join("muse/auth.json"));
}
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home).join(".config/muse/auth.json"))
}
pub fn credentials_present() -> bool {
if std::env::var(META_API_KEY_VAR).map(|v| !v.trim().is_empty()) == Ok(true) {
return true;
}
let Some(path) = credentials_path() else {
return false;
};
match std::fs::read_to_string(&path) {
Ok(raw) => serde_json::from_str::<AuthFile>(&raw)
.map(|f| !f.providers.is_empty())
.unwrap_or(true),
Err(_) => false,
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct AuthFile {
pub schema_version: u32,
pub providers: std::collections::BTreeMap<String, ProviderCredential>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ProviderCredential {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
fn resolve(binary: &str) -> Result<PathBuf> {
which::which(binary).map_err(|_| Error::BinaryNotFound {
name: binary.to_string(),
})
}
pub async fn auth_set(api_key: &str, provider: Option<&str>) -> Result<()> {
auth_set_with_binary("muse", api_key, provider).await
}
pub async fn auth_set_with_binary(
binary: &str,
api_key: &str,
provider: Option<&str>,
) -> Result<()> {
let mut cmd = tokio::process::Command::new(resolve(binary)?);
cmd.args([
"auth",
"set",
"--provider",
provider.unwrap_or("meta"),
"--api-key-stdin",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn()?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| Error::Protocol("failed to get stdin".to_string()))?;
stdin.write_all(api_key.trim().as_bytes()).await?;
stdin.write_all(b"\n").await?;
drop(stdin); let out = child.wait_with_output().await?;
if out.status.success() {
Ok(())
} else {
Err(Error::Protocol(format!(
"muse auth set failed (exit {:?}): {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
)))
}
}
pub async fn logout() -> Result<()> {
logout_with_binary("muse").await
}
pub async fn logout_with_binary(binary: &str) -> Result<()> {
let out = tokio::process::Command::new(resolve(binary)?)
.arg("logout")
.stdin(Stdio::null())
.output()
.await?;
if out.status.success() {
Ok(())
} else {
Err(Error::Protocol(format!(
"muse logout failed (exit {:?}): {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
)))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeviceCode {
pub verification_url: String,
pub code: String,
}
pub struct DeviceLoginFlow {
child: Child,
lines: Lines<BufReader<ChildStdout>>,
}
impl DeviceLoginFlow {
pub async fn start() -> Result<Self> {
Self::start_with_binary("muse").await
}
pub async fn start_with_binary(binary: &str) -> Result<Self> {
let mut cmd = tokio::process::Command::new(resolve(binary)?);
cmd.arg("login")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn()?;
let stdout = child
.stdout
.take()
.ok_or_else(|| Error::Protocol("failed to get stdout".to_string()))?;
Ok(Self {
child,
lines: BufReader::new(stdout).lines(),
})
}
pub async fn device_code(&mut self, timeout: Duration) -> Result<DeviceCode> {
let read = async {
let mut url: Option<String> = None;
let mut code: Option<String> = None;
while let Some(line) = self.lines.next_line().await? {
if let Some(u) = extract_url(&line) {
url = Some(u);
}
if let Some(c) = extract_code_from_url_or_line(&line) {
code = Some(c);
}
if let (Some(u), Some(c)) = (&url, &code) {
return Ok(DeviceCode {
verification_url: u.clone(),
code: c.clone(),
});
}
}
Err(Error::Protocol(
"muse login ended before printing a device code".to_string(),
))
};
tokio::time::timeout(timeout, read)
.await
.map_err(|_| Error::Protocol("timed out waiting for device code".to_string()))?
}
pub async fn wait_approved(mut self, timeout: Duration) -> Result<()> {
let status = tokio::time::timeout(timeout, self.child.wait())
.await
.map_err(|_| Error::Protocol("timed out waiting for login approval".to_string()))??;
if status.success() {
Ok(())
} else {
Err(Error::Protocol(format!(
"muse login exited with {:?} before approval",
status.code()
)))
}
}
pub async fn cancel(mut self) -> Result<()> {
self.child.kill().await?;
Ok(())
}
}
fn extract_url(line: &str) -> Option<String> {
let start = line.find("https://")?;
let url: String = line[start..]
.chars()
.take_while(|c| !c.is_whitespace())
.collect();
Some(url)
}
fn extract_code_from_url_or_line(line: &str) -> Option<String> {
if let Some(pos) = line.find("code=") {
let code: String = line[pos + 5..]
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect();
if !code.is_empty() {
return Some(code);
}
}
let t = line.trim();
let is_code_shaped = t.len() >= 7
&& t.len() <= 12
&& t.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-')
&& t.contains('-');
is_code_shaped.then(|| t.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
const CAPTURE: &str = "Open this page to sign in:\n \
https://auth.meta.com/oauth/device/?code=TBSS-QJWM\n\
confirm this code matches:\n TBSS-QJWM\n\nWaiting for approval…\n";
#[test]
fn device_code_extracted_from_captured_output() {
let mut url = None;
let mut code = None;
for line in CAPTURE.lines() {
if let Some(u) = extract_url(line) {
url = Some(u);
}
if let Some(c) = extract_code_from_url_or_line(line) {
code = Some(c);
}
}
assert_eq!(
url.as_deref(),
Some("https://auth.meta.com/oauth/device/?code=TBSS-QJWM")
);
assert_eq!(code.as_deref(), Some("TBSS-QJWM"));
}
#[test]
fn bare_code_line_matches_and_prose_does_not() {
assert_eq!(
extract_code_from_url_or_line(" TBSS-QJWM"),
Some("TBSS-QJWM".to_string())
);
assert_eq!(extract_code_from_url_or_line("Waiting for approval…"), None);
assert_eq!(
extract_code_from_url_or_line("Open this page to sign in:"),
None
);
}
}