Skip to main content

kura_cli/
util.rs

1use std::collections::BTreeSet;
2use std::io::Write;
3use std::sync::{LazyLock, Mutex};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use serde_json::json;
8
9const KURA_CLIENT_NAME_HEADER: &str = "x-kura-client-name";
10const KURA_CLIENT_VERSION_HEADER: &str = "x-kura-client-version";
11const KURA_CLIENT_INSTALL_CHANNEL_HEADER: &str = "x-kura-client-install-channel";
12const KURA_CLIENT_NOTICE_ACK_HEADER: &str = "x-kura-client-notice-ack";
13const KURA_CLI_CLIENT_NAME: &str = "kura-cli";
14const KURA_NOTICE_ACK_MAX_IDS: usize = 16;
15
16static PENDING_NOTICE_ACK_IDS: LazyLock<Mutex<BTreeSet<String>>> =
17    LazyLock::new(|| Mutex::new(BTreeSet::new()));
18
19/// Stored credentials for the CLI
20#[derive(Debug, Serialize, Deserialize)]
21pub struct StoredCredentials {
22    pub api_url: String,
23    pub access_token: String,
24    pub refresh_token: String,
25    pub expires_at: DateTime<Utc>,
26}
27
28#[derive(Deserialize)]
29pub struct TokenResponse {
30    pub access_token: String,
31    pub refresh_token: String,
32    pub expires_in: i64,
33}
34
35pub fn client() -> reqwest::Client {
36    reqwest::Client::new()
37}
38
39fn cli_install_channel() -> String {
40    std::env::var("KURA_CLI_INSTALL_CHANNEL")
41        .ok()
42        .map(|value| value.trim().to_ascii_lowercase())
43        .filter(|value| !value.is_empty())
44        .unwrap_or_else(|| "cargo".to_string())
45}
46
47fn with_cli_client_headers(mut req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
48    req = req.header(KURA_CLIENT_NAME_HEADER, KURA_CLI_CLIENT_NAME);
49    req = req.header(KURA_CLIENT_VERSION_HEADER, env!("CARGO_PKG_VERSION"));
50    req = req.header(KURA_CLIENT_INSTALL_CHANNEL_HEADER, cli_install_channel());
51    if let Some(ack_header_value) = pending_notice_ack_header_value() {
52        req = req.header(KURA_CLIENT_NOTICE_ACK_HEADER, ack_header_value);
53    }
54    req
55}
56
57fn parse_user_notice_ack_ids(body: &serde_json::Value) -> Vec<String> {
58    body.get("user_notices")
59        .and_then(|value| value.as_array())
60        .map(|items| {
61            items
62                .iter()
63                .filter_map(|item| item.as_object())
64                .filter_map(|item| item.get("notice_id").and_then(|value| value.as_str()))
65                .map(str::trim)
66                .filter(|value| is_valid_notice_ack_id(value))
67                .map(ToString::to_string)
68                .collect::<Vec<_>>()
69        })
70        .unwrap_or_default()
71}
72
73fn queue_user_notice_acks(body: &serde_json::Value) {
74    let notice_ids = parse_user_notice_ack_ids(body);
75    if notice_ids.is_empty() {
76        return;
77    }
78    let mut pending = PENDING_NOTICE_ACK_IDS
79        .lock()
80        .unwrap_or_else(|poisoned| poisoned.into_inner());
81    for notice_id in notice_ids {
82        if pending.len() >= KURA_NOTICE_ACK_MAX_IDS {
83            break;
84        }
85        pending.insert(notice_id);
86    }
87}
88
89fn pending_notice_ack_header_value() -> Option<String> {
90    let pending = PENDING_NOTICE_ACK_IDS
91        .lock()
92        .unwrap_or_else(|poisoned| poisoned.into_inner());
93    if pending.is_empty() {
94        return None;
95    }
96    let value = pending
97        .iter()
98        .take(KURA_NOTICE_ACK_MAX_IDS)
99        .cloned()
100        .collect::<Vec<_>>()
101        .join(",");
102    if value.is_empty() { None } else { Some(value) }
103}
104
105fn is_valid_notice_ack_id(raw: &str) -> bool {
106    let trimmed = raw.trim();
107    if trimmed.is_empty() || trimmed.len() > 200 {
108        return false;
109    }
110    trimmed
111        .chars()
112        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '_' | '-' | '.'))
113}
114
115fn extract_user_notice_lines(body: &serde_json::Value) -> Vec<String> {
116    let notices = body
117        .get("user_notices")
118        .and_then(|value| value.as_array())
119        .cloned()
120        .unwrap_or_default();
121
122    let mut lines = Vec::new();
123    for notice in notices {
124        let Some(obj) = notice.as_object() else {
125            continue;
126        };
127        let message = obj
128            .get("message_short")
129            .and_then(|value| value.as_str())
130            .map(str::trim)
131            .filter(|value| !value.is_empty());
132        let cmd = obj
133            .get("upgrade_command")
134            .and_then(|value| value.as_str())
135            .map(str::trim)
136            .filter(|value| !value.is_empty());
137        let docs_hint = obj
138            .get("docs_hint")
139            .and_then(|value| value.as_str())
140            .map(str::trim)
141            .filter(|value| !value.is_empty());
142
143        let mut line = String::from("[kura notice]");
144        if let Some(message) = message {
145            line.push(' ');
146            line.push_str(message);
147        }
148        if let Some(cmd) = cmd {
149            line.push_str(" Update: ");
150            line.push_str(cmd);
151        } else if let Some(docs_hint) = docs_hint {
152            line.push(' ');
153            line.push_str(docs_hint);
154        }
155        if line != "[kura notice]" {
156            lines.push(line);
157        }
158    }
159    lines
160}
161
162pub fn env_flag_enabled(name: &str) -> bool {
163    std::env::var(name)
164        .ok()
165        .map(|value| {
166            let normalized = value.trim().to_ascii_lowercase();
167            matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
168        })
169        .unwrap_or(false)
170}
171
172pub fn admin_surface_enabled() -> bool {
173    env_flag_enabled("KURA_ENABLE_ADMIN_SURFACE")
174}
175
176pub fn is_admin_api_path(path: &str) -> bool {
177    let trimmed = path.trim();
178    if trimmed.is_empty() {
179        return false;
180    }
181
182    let normalized = if trimmed.starts_with('/') {
183        trimmed.to_ascii_lowercase()
184    } else {
185        format!("/{}", trimmed.to_ascii_lowercase())
186    };
187
188    normalized == "/v1/admin" || normalized.starts_with("/v1/admin/")
189}
190
191pub fn exit_error(message: &str, docs_hint: Option<&str>) -> ! {
192    let mut err = json!({
193        "error": "cli_error",
194        "message": message
195    });
196    if let Some(hint) = docs_hint {
197        err["docs_hint"] = json!(hint);
198    }
199    eprintln!("{}", serde_json::to_string_pretty(&err).unwrap());
200    std::process::exit(1);
201}
202
203pub fn config_path() -> std::path::PathBuf {
204    let config_dir = dirs::config_dir()
205        .unwrap_or_else(|| std::path::PathBuf::from("."))
206        .join("kura");
207    config_dir.join("config.json")
208}
209
210pub fn load_credentials() -> Option<StoredCredentials> {
211    let path = config_path();
212    let data = std::fs::read_to_string(&path).ok()?;
213    serde_json::from_str(&data).ok()
214}
215
216pub fn save_credentials(creds: &StoredCredentials) -> Result<(), Box<dyn std::error::Error>> {
217    let path = config_path();
218    if let Some(parent) = path.parent() {
219        std::fs::create_dir_all(parent)?;
220    }
221
222    let data = serde_json::to_string_pretty(creds)?;
223
224    // Write with restricted permissions (0o600)
225    let mut file = std::fs::OpenOptions::new()
226        .write(true)
227        .create(true)
228        .truncate(true)
229        .mode(0o600)
230        .open(&path)?;
231    file.write_all(data.as_bytes())?;
232
233    Ok(())
234}
235
236/// Resolve a Bearer token for API requests (priority order):
237/// 1. KURA_API_KEY env var
238/// 2. ~/.config/kura/config.json (with auto-refresh)
239/// 3. Error
240pub async fn resolve_token(api_url: &str) -> Result<String, Box<dyn std::error::Error>> {
241    // 1. Environment variable
242    if let Ok(key) = std::env::var("KURA_API_KEY") {
243        return Ok(key);
244    }
245
246    // 2. Stored credentials
247    if let Some(creds) = load_credentials() {
248        // Check if access token needs refresh (5-min buffer)
249        let buffer = chrono::Duration::minutes(5);
250        if Utc::now() + buffer >= creds.expires_at {
251            // Try to refresh
252            match refresh_stored_token(api_url, &creds).await {
253                Ok(new_creds) => {
254                    save_credentials(&new_creds)?;
255                    return Ok(new_creds.access_token);
256                }
257                Err(_) => {
258                    return Err(
259                        "Access token expired and refresh failed. Run `kura login` again.".into(),
260                    );
261                }
262            }
263        }
264        return Ok(creds.access_token);
265    }
266
267    Err("No credentials found. Run `kura login` or set KURA_API_KEY.".into())
268}
269
270async fn refresh_stored_token(
271    api_url: &str,
272    creds: &StoredCredentials,
273) -> Result<StoredCredentials, Box<dyn std::error::Error>> {
274    let resp = client()
275        .post(format!("{api_url}/v1/auth/token"))
276        .json(&json!({
277            "grant_type": "refresh_token",
278            "refresh_token": creds.refresh_token,
279            "client_id": "kura-cli"
280        }))
281        .send()
282        .await?;
283
284    if !resp.status().is_success() {
285        let body: serde_json::Value = resp.json().await?;
286        return Err(format!("Token refresh failed: {}", body).into());
287    }
288
289    let token_resp: TokenResponse = resp.json().await?;
290    Ok(StoredCredentials {
291        api_url: creds.api_url.clone(),
292        access_token: token_resp.access_token,
293        refresh_token: token_resp.refresh_token,
294        expires_at: Utc::now() + chrono::Duration::seconds(token_resp.expires_in),
295    })
296}
297
298/// Execute an authenticated API request, print response, exit with structured code.
299///
300/// Exit codes: 0=success (2xx), 1=client error (4xx), 2=server error (5xx),
301///             3=connection error, 4=usage error
302pub async fn api_request(
303    api_url: &str,
304    method: reqwest::Method,
305    path: &str,
306    token: Option<&str>,
307    body: Option<serde_json::Value>,
308    query: &[(String, String)],
309    extra_headers: &[(String, String)],
310    raw: bool,
311    include: bool,
312) -> i32 {
313    let url = match reqwest::Url::parse(&format!("{api_url}{path}")) {
314        Ok(mut u) => {
315            if !query.is_empty() {
316                let mut q = u.query_pairs_mut();
317                for (k, v) in query {
318                    q.append_pair(k, v);
319                }
320            }
321            u
322        }
323        Err(e) => {
324            let err = json!({
325                "error": "cli_error",
326                "message": format!("Invalid URL: {api_url}{path}: {e}")
327            });
328            eprintln!("{}", serde_json::to_string_pretty(&err).unwrap());
329            return 4;
330        }
331    };
332
333    let mut req = with_cli_client_headers(client().request(method, url));
334
335    if let Some(t) = token {
336        req = req.header("Authorization", format!("Bearer {t}"));
337    }
338
339    for (k, v) in extra_headers {
340        req = req.header(k.as_str(), v.as_str());
341    }
342
343    if let Some(b) = body {
344        req = req.json(&b);
345    }
346
347    let resp = match req.send().await {
348        Ok(r) => r,
349        Err(e) => {
350            let err = json!({
351                "error": "connection_error",
352                "message": format!("{e}"),
353                "docs_hint": "Is the API server running? Check KURA_API_URL."
354            });
355            eprintln!("{}", serde_json::to_string_pretty(&err).unwrap());
356            return 3;
357        }
358    };
359
360    let status = resp.status().as_u16();
361    let exit_code = match status {
362        200..=299 => 0,
363        400..=499 => 1,
364        _ => 2,
365    };
366
367    // Collect headers before consuming response
368    let headers: serde_json::Map<String, serde_json::Value> = if include {
369        resp.headers()
370            .iter()
371            .map(|(k, v)| (k.to_string(), json!(v.to_str().unwrap_or("<binary>"))))
372            .collect()
373    } else {
374        serde_json::Map::new()
375    };
376
377    let resp_body: serde_json::Value = match resp.bytes().await {
378        Ok(bytes) => {
379            if bytes.is_empty() {
380                serde_json::Value::Null
381            } else {
382                serde_json::from_slice(&bytes).unwrap_or_else(|_| {
383                    serde_json::Value::String(String::from_utf8_lossy(&bytes).to_string())
384                })
385            }
386        }
387        Err(e) => json!({"raw_error": format!("Failed to read response body: {e}")}),
388    };
389
390    let user_notice_lines = if exit_code == 0 {
391        queue_user_notice_acks(&resp_body);
392        extract_user_notice_lines(&resp_body)
393    } else {
394        Vec::new()
395    };
396
397    let output = if include {
398        json!({
399            "status": status,
400            "headers": headers,
401            "body": resp_body
402        })
403    } else {
404        resp_body
405    };
406
407    let formatted = if raw {
408        serde_json::to_string(&output).unwrap()
409    } else {
410        serde_json::to_string_pretty(&output).unwrap()
411    };
412
413    for line in user_notice_lines {
414        eprintln!("{line}");
415    }
416
417    if exit_code == 0 {
418        println!("{formatted}");
419    } else {
420        eprintln!("{formatted}");
421    }
422
423    exit_code
424}
425
426/// Execute a raw API request and return the response (no printing).
427/// Used by doctor and other commands that need to inspect the response.
428pub async fn raw_api_request(
429    api_url: &str,
430    method: reqwest::Method,
431    path: &str,
432    token: Option<&str>,
433) -> Result<(u16, serde_json::Value), String> {
434    let url = reqwest::Url::parse(&format!("{api_url}{path}"))
435        .map_err(|e| format!("Invalid URL: {e}"))?;
436
437    let mut req = with_cli_client_headers(client().request(method, url));
438    if let Some(t) = token {
439        req = req.header("Authorization", format!("Bearer {t}"));
440    }
441
442    let resp = req.send().await.map_err(|e| format!("{e}"))?;
443    let status = resp.status().as_u16();
444    let body: serde_json::Value = resp
445        .json()
446        .await
447        .unwrap_or(json!({"error": "non-json response"}));
448    if (200..=299).contains(&status) {
449        queue_user_notice_acks(&body);
450    }
451
452    Ok((status, body))
453}
454
455/// Check if auth is configured (without making a request).
456/// Returns (method_name, detail) or None.
457pub fn check_auth_configured() -> Option<(&'static str, String)> {
458    if let Ok(key) = std::env::var("KURA_API_KEY") {
459        let prefix = if key.len() > 12 { &key[..12] } else { &key };
460        return Some(("api_key (env)", format!("{prefix}...")));
461    }
462
463    if let Some(creds) = load_credentials() {
464        let expired = chrono::Utc::now() >= creds.expires_at;
465        let detail = if expired {
466            format!("expired at {}", creds.expires_at)
467        } else {
468            format!("valid until {}", creds.expires_at)
469        };
470        return Some(("oauth_token (stored)", detail));
471    }
472
473    None
474}
475
476/// Read JSON from a file path or stdin (when path is "-").
477pub fn read_json_from_file(path: &str) -> Result<serde_json::Value, String> {
478    let raw = if path == "-" {
479        let mut buf = String::new();
480        std::io::stdin()
481            .read_line(&mut buf)
482            .map_err(|e| format!("Failed to read stdin: {e}"))?;
483        // Read remaining lines too
484        let mut rest = String::new();
485        while std::io::stdin()
486            .read_line(&mut rest)
487            .map_err(|e| format!("Failed to read stdin: {e}"))?
488            > 0
489        {
490            buf.push_str(&rest);
491            rest.clear();
492        }
493        buf
494    } else {
495        std::fs::read_to_string(path).map_err(|e| format!("Failed to read file '{path}': {e}"))?
496    };
497    serde_json::from_str(&raw).map_err(|e| format!("Invalid JSON in '{path}': {e}"))
498}
499
500// Unix-specific imports for file permissions
501#[cfg(unix)]
502use std::os::unix::fs::OpenOptionsExt;
503
504// No-op on non-unix (won't compile for Windows without this)
505#[cfg(not(unix))]
506trait OpenOptionsExt {
507    fn mode(&mut self, _mode: u32) -> &mut Self;
508}
509
510#[cfg(not(unix))]
511impl OpenOptionsExt for std::fs::OpenOptions {
512    fn mode(&mut self, _mode: u32) -> &mut Self {
513        self
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::{
520        extract_user_notice_lines, is_admin_api_path, parse_user_notice_ack_ids,
521        pending_notice_ack_header_value, queue_user_notice_acks,
522    };
523    use serde_json::json;
524
525    #[test]
526    fn admin_path_detection_matches_v1_admin_namespace_only() {
527        assert!(is_admin_api_path("/v1/admin"));
528        assert!(is_admin_api_path("/v1/admin/invites"));
529        assert!(is_admin_api_path("v1/admin/security/kill-switch"));
530        assert!(!is_admin_api_path("/v1/agent/context"));
531        assert!(!is_admin_api_path("/health"));
532    }
533
534    #[test]
535    fn extract_user_notice_lines_reads_message_and_upgrade_command() {
536        let body = json!({
537            "user_notices": [{
538                "kind": "client_update",
539                "message_short": "Kura CLI update available (0.1.7).",
540                "upgrade_command": "cargo install kura-cli --locked --force"
541            }]
542        });
543        let lines = extract_user_notice_lines(&body);
544        assert_eq!(lines.len(), 1);
545        assert!(lines[0].contains("[kura notice]"));
546        assert!(lines[0].contains("Kura CLI update available"));
547        assert!(lines[0].contains("cargo install kura-cli --locked --force"));
548    }
549
550    #[test]
551    fn extract_user_notice_lines_returns_empty_when_absent() {
552        let lines = extract_user_notice_lines(&json!({"ok": true}));
553        assert!(lines.is_empty());
554    }
555
556    #[test]
557    fn parse_user_notice_ack_ids_collects_notice_ids() {
558        let body = json!({
559            "user_notices": [
560                {"notice_id": "client_update:kura-cli:0.1.5"},
561                {"notice_id": "client_update:kura-mcp:0.1.5"}
562            ]
563        });
564        let ids = parse_user_notice_ack_ids(&body);
565        assert_eq!(
566            ids,
567            vec![
568                "client_update:kura-cli:0.1.5".to_string(),
569                "client_update:kura-mcp:0.1.5".to_string()
570            ]
571        );
572    }
573
574    #[test]
575    fn queue_user_notice_acks_makes_ack_header_available() {
576        super::PENDING_NOTICE_ACK_IDS
577            .lock()
578            .unwrap_or_else(|poisoned| poisoned.into_inner())
579            .clear();
580        queue_user_notice_acks(&json!({
581            "user_notices": [{"notice_id": "client_update:kura-cli:0.1.5"}]
582        }));
583        let ack_header = pending_notice_ack_header_value();
584        assert_eq!(ack_header.as_deref(), Some("client_update:kura-cli:0.1.5"));
585    }
586}