//! `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, CreateGroupScriptBody, CreateScriptBody, ScriptConfig}; use crate::config; use crate::output::{KvBlock, OutputMode, Table}; pub async fn ls(app: Option<&str>, group: 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(group_ident) = group { // Phase 4: a group's own (non-inherited) scripts. The owner column // shows the group, not an app. let scripts = client.group_scripts_list(group_ident).await?; for s in scripts { table.row([ s.id.to_string(), format!("group:{group_ident}"), s.name, s.version.to_string(), s.updated_at.to_rfc3339(), ]); } table.print(mode); return Ok(()); } 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 { // Group-owned scripts (Phase 4) have no app; show a marker rather // than a slug. App-owned scripts resolve their slug as before. let app_slug = match s.app_id { Some(app_id) => slug_by_id .get(&app_id) .cloned() .unwrap_or_else(|| "-".to_string()), None => "(group)".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: Option<&str>, group_ident: Option<&str>, name_override: Option<&str>, description: Option<&str>, cfg: &ScriptConfig, mode: OutputMode, ) -> 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() ) })?, }; // Deploy is create-or-update by name within the chosen owner. Exactly // one owner — clap marks `--group` as conflicting with `--app`, so this // only rejects the both-absent case. let (script, action) = match (app_ident, group_ident) { (Some(app_ident), None) => { // Slug-or-id resolution: a single GET gives the canonical app_id. 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, cfg) .await?; (updated, "updated") } else { let body = CreateScriptBody { app_id: app.app.id, name: &name, description, source: &source, timeout_seconds: cfg.timeout_seconds, memory_limit_mb: cfg.memory_limit_mb, kind: cfg.kind, sandbox: cfg.sandbox, }; (client.scripts_create(&body).await?, "created") } } (None, Some(group_ident)) => { // Group scripts update through the owner-polymorphic by-id // endpoint; create through the group endpoint. let existing = client.group_scripts_list(group_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, cfg) .await?; (updated, "updated") } else { let body = CreateGroupScriptBody { name: &name, description, source: &source, timeout_seconds: cfg.timeout_seconds, memory_limit_mb: cfg.memory_limit_mb, kind: cfg.kind, sandbox: cfg.sandbox, }; ( client.group_scripts_create(group_ident, &body).await?, "created", ) } } (Some(_), Some(_)) | (None, None) => { // Reuse the shared XOR message (both-or-neither always errors here). return Err(crate::cmds::require_one_owner(app_ident, group_ident) .expect_err("both-or-neither must error")); } }; // Emit the script object so `--output json` callers can capture the // id for the follow-up routes/triggers calls. let mut block = KvBlock::new(); block .field("id", script.id.to_string()) .field("name", script.name.clone()) .field("version", script.version.to_string()) .field("action", action) .field("updated_at", script.updated_at.to_rfc3339()); block.print(mode); 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}"); } }