//! `pic scripts ls | deploy | invoke | delete`. use std::collections::HashMap; use std::io::{self, Read, Write}; use std::path::Path; use anyhow::{anyhow, Context, Result}; use picloud_shared::AppId; use serde_json::Value; use crate::client::{Client, CreateScriptBody}; use crate::config; use crate::output::{OutputMode, Table}; pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> { let creds = config::resolve()?; 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 → use the single `GET /admin/scripts` call. Server // filters by membership for `Member`; for `Admin`/`Owner` it // returns every script. Two requests total (apps + scripts) run // in parallel; the per-app walk we used to do here aborted on // the first 404 when another caller deleted an app mid-listing, // and was the entire reason a 5× retry existed in the tests. let (apps, scripts) = tokio::try_join!(client.apps_list(), client.scripts_list_all())?; let slug_by_id: HashMap = apps.into_iter().map(|a| (a.id, a.slug)).collect(); for s in scripts { let app_slug = slug_by_id .get(&s.app_id) .cloned() .unwrap_or_else(|| "-".to_string()); table.row([ s.id.to_string(), app_slug, s.name, s.version.to_string(), s.updated_at.to_rfc3339(), ]); } } table.print(mode); Ok(()) } pub async fn deploy( file: &Path, app_ident: &str, name_override: Option<&str>, description: Option<&str>, ) -> Result<()> { let creds = config::resolve()?; 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 = config::resolve()?; 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)) } } /// `pic scripts delete `. Requires `AppAdmin` on the owning app /// server-side, which is stricter than the edit endpoints — Editor /// can deploy/update but not destroy. Surfaces that as a 403 with the /// usual role hint. pub async fn delete(id: &str) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; client.scripts_delete(id).await?; println!("Deleted script {id}"); Ok(()) } 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}"); } }