Address the review findings on the CLI surface: * `pic login` now prompts for username + password and POSTs to `/api/v1/admin/auth/login`. `--token` (and `PICLOUD_TOKEN`) still works for paste-a-bearer flows (CI, long-lived API keys). Falls back to a plain stdin read when no controlling tty is attached. * `pic logout` revokes the session server-side and deletes the local credentials file. Idempotent. * `PICLOUD_URL` / `PICLOUD_TOKEN` now override the on-disk credentials file for every command via `config::resolve`, not just for `pic login`. Matches gcloud/aws/kubectl semantics. * New commands: `pic apps delete [--force]`, `pic apps show`, `pic scripts delete`, `pic api-keys mint|ls|rm`, plus top-level `pic invoke` / `pic deploy` shortcuts. * `pic scripts ls` (no `--app`) now issues a single `GET /admin/scripts` + one `apps_list` in parallel and joins client-side, instead of walking N+1 per-app calls that aborted on the first 404 — the bug the test suite was retrying around. * Global `--output tsv|json` flag wired through every list/show and through `whoami` / `logs`. TSV stays pipe-friendly; JSON is a real array of objects (or a flat object for single-row views). * `whoami` and `logs` now emit labeled output instead of headerless tab lines, consistent with the existing `apps ls` / `scripts ls`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
198 lines
6.3 KiB
Rust
198 lines
6.3 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, 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<AppId, String> = 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 <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}");
|
||
}
|
||
}
|