1use std::io::Write;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::json;
6
7#[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 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
101pub async fn resolve_token(api_url: &str) -> Result<String, Box<dyn std::error::Error>> {
106 if let Ok(key) = std::env::var("KURA_API_KEY") {
108 return Ok(key);
109 }
110
111 if let Some(creds) = load_credentials() {
113 let buffer = chrono::Duration::minutes(5);
115 if Utc::now() + buffer >= creds.expires_at {
116 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
163pub 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 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
280pub 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
306pub 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
327pub 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 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#[cfg(unix)]
353use std::os::unix::fs::OpenOptionsExt;
354
355#[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}