Remediate the CLI/DX findings from the 2026-07-11 audit. B4 — `pic plan` now previews the apply-time desired-state warnings (disabled binding, unreachable endpoint, dangling `[suppress]`), so plan and apply agree for CI review. Wire fields are `#[serde(default)]` (older-server tolerant). B5 — `pic apply` no longer mutates on a first run (no recorded plan) without a preview + confirm, and a large blast radius now triggers an extra confirmation. Both gates check `is_terminal()` before any read — a non-TTY never hangs on stdin and fails closed without `--yes`; `--yes`/`--force` bypass headlessly. `--force` help now notes it also skips these prompts. C3 — `pic files ls`/`get` gain `--group`, mirroring `pic kv --group`, so the shipped group shared-files admin surface is reachable from the CLI. C4 — a shared `require_one_owner(app, group)` helper (cmds/mod.rs) gives one canonical message for the `--app`/`--group` XOR, wired into every such site (kv, files, vars, secrets, suppress, extension-points, triggers ls, scripts deploy). routes ls (positional script_id) and scripts ls (lists all) keep their own shapes deliberately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
271 lines
9.4 KiB
Rust
271 lines
9.4 KiB
Rust
//! `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<AppId, String> = 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 <id>`. 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<Value> {
|
||
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<Value> {
|
||
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}");
|
||
}
|
||
}
|