Skip to main content

kura_cli/
util.rs

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