//! `pic scripts ls | deploy | invoke`. use std::io::{self, Read, Write}; use std::path::Path; use anyhow::{anyhow, Context, Result}; use serde_json::Value; use crate::client::{Client, CreateScriptBody}; use crate::config::load; use crate::output::Table; pub async fn ls(app: Option<&str>) -> Result<()> { let creds = load()?; let client = Client::from_creds(&creds)?; let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]); if let Some(ident) = app { let app = client.apps_get(ident).await?; let scripts = client.scripts_list_by_app(&app.app.slug).await?; for s in scripts { table.row([ s.id.to_string(), app.app.slug.clone(), s.name, s.version.to_string(), s.updated_at.to_rfc3339(), ]); } } else { // No filter → walk every accessible app. One request per app is // fine at MVP scale (handful of apps); a bulk endpoint can come // later if the count grows. let apps = client.apps_list().await?; for a in apps { let scripts = client.scripts_list_by_app(&a.slug).await?; for s in scripts { table.row([ s.id.to_string(), a.slug.clone(), s.name, s.version.to_string(), s.updated_at.to_rfc3339(), ]); } } } table.print(); Ok(()) } pub async fn deploy( file: &Path, app_ident: &str, name_override: Option<&str>, description: Option<&str>, ) -> Result<()> { let creds = load()?; let client = Client::from_creds(&creds)?; let source = std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?; let name = match name_override { Some(n) => n.to_string(), None => file .file_stem() .and_then(|s| s.to_str()) .map(str::to_string) .ok_or_else(|| { anyhow!( "could not derive script name from path {} (use --name)", file.display() ) })?, }; // Slug-or-id resolution: a single GET satisfies both lookups and // gives us the canonical app_id needed for create. let app = client.apps_get(app_ident).await?; let existing = client.scripts_list_by_app(app_ident).await?; if let Some(s) = existing.into_iter().find(|s| s.name == name) { let updated = client .scripts_update_source(&s.id.to_string(), &source) .await?; println!("Updated {} v{}", updated.name, updated.version); } else { let body = CreateScriptBody { app_id: app.app.id, name: &name, description, source: &source, }; let created = client.scripts_create(&body).await?; println!("Created {} v{}", created.name, created.version); } Ok(()) } pub async fn invoke(id: &str, body_arg: Option<&str>, headers: &[(String, String)]) -> Result<()> { let creds = load()?; let client = Client::from_creds(&creds)?; let body = parse_body_arg(body_arg)?; let resp = client.execute(id, body, headers).await?; // Status to stderr so stdout stays JSON for piping into jq. let _ = writeln!(io::stderr(), "<- HTTP {}", resp.status_code); let pretty = serde_json::to_string_pretty(&resp.body).unwrap_or_else(|_| resp.body.to_string()); println!("{pretty}"); if (200..400).contains(&resp.status_code) { Ok(()) } else { Err(anyhow!("execute returned HTTP {}", resp.status_code)) } } fn parse_body_arg(arg: Option<&str>) -> Result { match arg { None => Ok(Value::Object(serde_json::Map::new())), Some("@-") => { let mut buf = String::new(); io::stdin() .read_to_string(&mut buf) .context("reading stdin")?; parse_or_string(&buf) } Some(raw) if raw.starts_with('@') => { let path = &raw[1..]; let text = std::fs::read_to_string(path) .with_context(|| format!("reading body file {path}"))?; parse_or_string(&text) } Some(raw) => parse_or_string(raw), } } fn parse_or_string(s: &str) -> Result { let trimmed = s.trim(); if trimmed.is_empty() { return Ok(Value::Object(serde_json::Map::new())); } serde_json::from_str(trimmed) .with_context(|| format!("body is not valid JSON: {}", truncate(trimmed, 80))) } fn truncate(s: &str, n: usize) -> String { if s.len() <= n { s.to_string() } else { format!("{}…", &s[..n]) } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_body_inline_json() { let v = parse_body_arg(Some(r#"{"x":1}"#)).unwrap(); assert_eq!(v["x"], 1); } #[test] fn parse_body_none_is_empty_object() { let v = parse_body_arg(None).unwrap(); assert!(v.is_object()); assert_eq!(v.as_object().unwrap().len(), 0); } #[test] fn parse_body_invalid_json_reports() { let err = parse_body_arg(Some("not-json{")).unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("not valid JSON"), "got: {msg}"); } }