1use crate::error::{Error, Result};
19use std::path::PathBuf;
20use std::process::Stdio;
21use std::time::Duration;
22use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines};
23use tokio::process::{Child, ChildStdout};
24
25pub const META_API_KEY_VAR: &str = "META_API_KEY";
27
28pub fn credentials_path() -> Option<PathBuf> {
31 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
32 return Some(PathBuf::from(xdg).join("muse/auth.json"));
33 }
34 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
35 Some(PathBuf::from(home).join(".config/muse/auth.json"))
36}
37
38pub fn credentials_present() -> bool {
43 if std::env::var(META_API_KEY_VAR).map(|v| !v.trim().is_empty()) == Ok(true) {
44 return true;
45 }
46 let Some(path) = credentials_path() else {
47 return false;
48 };
49 match std::fs::read_to_string(&path) {
50 Ok(raw) => serde_json::from_str::<AuthFile>(&raw)
51 .map(|f| !f.providers.is_empty())
52 .unwrap_or(true),
54 Err(_) => false,
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
63pub struct AuthFile {
64 pub schema_version: u32,
65 pub providers: std::collections::BTreeMap<String, ProviderCredential>,
66 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
67 pub extra: serde_json::Map<String, serde_json::Value>,
68}
69
70#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
72pub struct ProviderCredential {
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub api_key: Option<String>,
75 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
76 pub extra: serde_json::Map<String, serde_json::Value>,
77}
78
79fn resolve(binary: &str) -> Result<PathBuf> {
80 which::which(binary).map_err(|_| Error::BinaryNotFound {
81 name: binary.to_string(),
82 })
83}
84
85pub async fn auth_set(api_key: &str, provider: Option<&str>) -> Result<()> {
90 auth_set_with_binary("muse", api_key, provider).await
91}
92
93pub async fn auth_set_with_binary(
95 binary: &str,
96 api_key: &str,
97 provider: Option<&str>,
98) -> Result<()> {
99 let mut cmd = tokio::process::Command::new(resolve(binary)?);
100 cmd.args([
101 "auth",
102 "set",
103 "--provider",
104 provider.unwrap_or("meta"),
105 "--api-key-stdin",
106 ])
107 .stdin(Stdio::piped())
108 .stdout(Stdio::piped())
109 .stderr(Stdio::piped())
110 .kill_on_drop(true);
111 let mut child = cmd.spawn()?;
112 let mut stdin = child
113 .stdin
114 .take()
115 .ok_or_else(|| Error::Protocol("failed to get stdin".to_string()))?;
116 stdin.write_all(api_key.trim().as_bytes()).await?;
117 stdin.write_all(b"\n").await?;
118 drop(stdin); let out = child.wait_with_output().await?;
120 if out.status.success() {
121 Ok(())
122 } else {
123 Err(Error::Protocol(format!(
124 "muse auth set failed (exit {:?}): {}",
125 out.status.code(),
126 String::from_utf8_lossy(&out.stderr).trim()
127 )))
128 }
129}
130
131pub async fn logout() -> Result<()> {
133 logout_with_binary("muse").await
134}
135
136pub async fn logout_with_binary(binary: &str) -> Result<()> {
138 let out = tokio::process::Command::new(resolve(binary)?)
139 .arg("logout")
140 .stdin(Stdio::null())
141 .output()
142 .await?;
143 if out.status.success() {
144 Ok(())
145 } else {
146 Err(Error::Protocol(format!(
147 "muse logout failed (exit {:?}): {}",
148 out.status.code(),
149 String::from_utf8_lossy(&out.stderr).trim()
150 )))
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct DeviceCode {
157 pub verification_url: String,
159 pub code: String,
161}
162
163pub struct DeviceLoginFlow {
169 child: Child,
170 lines: Lines<BufReader<ChildStdout>>,
171}
172
173impl DeviceLoginFlow {
174 pub async fn start() -> Result<Self> {
176 Self::start_with_binary("muse").await
177 }
178
179 pub async fn start_with_binary(binary: &str) -> Result<Self> {
181 let mut cmd = tokio::process::Command::new(resolve(binary)?);
182 cmd.arg("login")
183 .stdin(Stdio::null())
184 .stdout(Stdio::piped())
185 .stderr(Stdio::piped())
186 .kill_on_drop(true);
187 let mut child = cmd.spawn()?;
188 let stdout = child
189 .stdout
190 .take()
191 .ok_or_else(|| Error::Protocol("failed to get stdout".to_string()))?;
192 Ok(Self {
193 child,
194 lines: BufReader::new(stdout).lines(),
195 })
196 }
197
198 pub async fn device_code(&mut self, timeout: Duration) -> Result<DeviceCode> {
203 let read = async {
204 let mut url: Option<String> = None;
205 let mut code: Option<String> = None;
206 while let Some(line) = self.lines.next_line().await? {
207 if let Some(u) = extract_url(&line) {
208 url = Some(u);
209 }
210 if let Some(c) = extract_code_from_url_or_line(&line) {
211 code = Some(c);
212 }
213 if let (Some(u), Some(c)) = (&url, &code) {
214 return Ok(DeviceCode {
215 verification_url: u.clone(),
216 code: c.clone(),
217 });
218 }
219 }
220 Err(Error::Protocol(
221 "muse login ended before printing a device code".to_string(),
222 ))
223 };
224 tokio::time::timeout(timeout, read)
225 .await
226 .map_err(|_| Error::Protocol("timed out waiting for device code".to_string()))?
227 }
228
229 pub async fn wait_approved(mut self, timeout: Duration) -> Result<()> {
232 let status = tokio::time::timeout(timeout, self.child.wait())
233 .await
234 .map_err(|_| Error::Protocol("timed out waiting for login approval".to_string()))??;
235 if status.success() {
236 Ok(())
237 } else {
238 Err(Error::Protocol(format!(
239 "muse login exited with {:?} before approval",
240 status.code()
241 )))
242 }
243 }
244
245 pub async fn cancel(mut self) -> Result<()> {
247 self.child.kill().await?;
248 Ok(())
249 }
250}
251
252fn extract_url(line: &str) -> Option<String> {
255 let start = line.find("https://")?;
256 let url: String = line[start..]
257 .chars()
258 .take_while(|c| !c.is_whitespace())
259 .collect();
260 Some(url)
261}
262
263fn extract_code_from_url_or_line(line: &str) -> Option<String> {
266 if let Some(pos) = line.find("code=") {
267 let code: String = line[pos + 5..]
268 .chars()
269 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
270 .collect();
271 if !code.is_empty() {
272 return Some(code);
273 }
274 }
275 let t = line.trim();
276 let is_code_shaped = t.len() >= 7
277 && t.len() <= 12
278 && t.chars()
279 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-')
280 && t.contains('-');
281 is_code_shaped.then(|| t.to_string())
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 const CAPTURE: &str = "Open this page to sign in:\n \
290 https://auth.meta.com/oauth/device/?code=TBSS-QJWM\n\
291 confirm this code matches:\n TBSS-QJWM\n\nWaiting for approval…\n";
292
293 #[test]
294 fn device_code_extracted_from_captured_output() {
295 let mut url = None;
296 let mut code = None;
297 for line in CAPTURE.lines() {
298 if let Some(u) = extract_url(line) {
299 url = Some(u);
300 }
301 if let Some(c) = extract_code_from_url_or_line(line) {
302 code = Some(c);
303 }
304 }
305 assert_eq!(
306 url.as_deref(),
307 Some("https://auth.meta.com/oauth/device/?code=TBSS-QJWM")
308 );
309 assert_eq!(code.as_deref(), Some("TBSS-QJWM"));
310 }
311
312 #[test]
313 fn bare_code_line_matches_and_prose_does_not() {
314 assert_eq!(
315 extract_code_from_url_or_line(" TBSS-QJWM"),
316 Some("TBSS-QJWM".to_string())
317 );
318 assert_eq!(extract_code_from_url_or_line("Waiting for approval…"), None);
319 assert_eq!(
320 extract_code_from_url_or_line("Open this page to sign in:"),
321 None
322 );
323 }
324}