Adds a new workspace crate `picloud-cli` shipping a `pic` binary that drives the edit-deploy-invoke-tail-logs loop against PiCloud's admin and execute HTTP surface. Eight subcommands cover the minimum a developer needs to never open the dashboard: pic login (paste URL + bearer token, validates via /auth/me) pic whoami (re-validates and prints principal) pic apps ls | create pic scripts ls | deploy | invoke pic logs <id> Credentials persist as TOML under the platform config dir (resolved via `directories`); on POSIX the file is forced to mode 0600. PICLOUD_URL + PICLOUD_TOKEN env vars short-circuit interactive prompts for CI and integration tests. The CLI redeclares minimal request/response structs in `client.rs` rather than depending on `manager-core` — keeps the blast radius contained without touching the existing crate boundaries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
179 lines
5.3 KiB
Rust
179 lines
5.3 KiB
Rust
//! `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<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}");
|
|
}
|
|
}
|