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