feat(cli): add pic command-line client (login, apps, scripts, logs)
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>
This commit is contained in:
333
crates/picloud-cli/src/client.rs
Normal file
333
crates/picloud-cli/src/client.rs
Normal file
@@ -0,0 +1,333 @@
|
||||
//! Reqwest-backed HTTP client + minimal wire DTOs.
|
||||
//!
|
||||
//! The CLI deliberately re-declares small request/response structs here
|
||||
//! rather than depending on `manager-core` (and pulling its Postgres
|
||||
//! transitive surface). Fields kept to what the CLI actually sends or
|
||||
//! reads.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use picloud_shared::{App, AppId, AppRole, ExecutionLog, InstanceRole, Script};
|
||||
use reqwest::{header, Method, RequestBuilder, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::config::Credentials;
|
||||
|
||||
pub struct Client {
|
||||
http: reqwest::Client,
|
||||
url: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn from_creds(creds: &Credentials) -> Result<Self> {
|
||||
Self::new(&creds.url, &creds.token)
|
||||
}
|
||||
|
||||
pub fn new(url: &str, token: &str) -> Result<Self> {
|
||||
let http = reqwest::Client::builder()
|
||||
.user_agent(concat!("pic/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.context("building HTTP client")?;
|
||||
Ok(Self {
|
||||
http,
|
||||
url: url.trim_end_matches('/').to_string(),
|
||||
token: token.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn url(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
|
||||
fn request(&self, method: Method, path: &str) -> RequestBuilder {
|
||||
self.http
|
||||
.request(method, format!("{}{path}", self.url))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {}", self.token))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/auth/me`
|
||||
pub async fn auth_me(&self) -> Result<AuthMeDto> {
|
||||
let resp = self
|
||||
.request(Method::GET, "/api/v1/admin/auth/me")
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps`
|
||||
pub async fn apps_list(&self) -> Result<Vec<App>> {
|
||||
let resp = self
|
||||
.request(Method::GET, "/api/v1/admin/apps")
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}` — slug or UUID accepted.
|
||||
pub async fn apps_get(&self, ident: &str) -> Result<AppLookupDto> {
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/apps/{ident}"))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps`
|
||||
pub async fn apps_create(&self, body: &CreateAppBody<'_>) -> Result<App> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/apps")
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/scripts?app={ident}`
|
||||
pub async fn scripts_list_by_app(&self, ident: &str) -> Result<Vec<Script>> {
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/scripts?app={}", urlencoded(ident)),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/scripts`
|
||||
pub async fn scripts_create(&self, body: &CreateScriptBody<'_>) -> Result<Script> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/scripts")
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
|
||||
/// uses PUT despite the field-level update semantics.
|
||||
pub async fn scripts_update_source(&self, id: &str, source: &str) -> Result<Script> {
|
||||
let body = UpdateScriptBody { source };
|
||||
let resp = self
|
||||
.request(Method::PUT, &format!("/api/v1/admin/scripts/{id}"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/execute/{id}` — returns the raw HTTP status, headers,
|
||||
/// and JSON body (the orchestrator marshals the script's output as
|
||||
/// the HTTP response itself, not a wrapper object).
|
||||
pub async fn execute(
|
||||
&self,
|
||||
id: &str,
|
||||
body: Value,
|
||||
headers: &[(String, String)],
|
||||
) -> Result<ExecuteResponse> {
|
||||
let mut req = self
|
||||
.request(Method::POST, &format!("/api/v1/execute/{id}"))
|
||||
.json(&body);
|
||||
for (k, v) in headers {
|
||||
req = req.header(k, v);
|
||||
}
|
||||
let resp = req.send().await?;
|
||||
let status = resp.status().as_u16();
|
||||
let mut headers_out: BTreeMap<String, String> = BTreeMap::new();
|
||||
for (k, v) in resp.headers() {
|
||||
if let Ok(val) = v.to_str() {
|
||||
headers_out.insert(k.as_str().to_string(), val.to_string());
|
||||
}
|
||||
}
|
||||
let bytes = resp.bytes().await.context("reading execute response")?;
|
||||
let body_json: Value = if bytes.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_slice(&bytes)
|
||||
.unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into_owned()))
|
||||
};
|
||||
Ok(ExecuteResponse {
|
||||
status_code: status,
|
||||
headers: headers_out,
|
||||
body: body_json,
|
||||
})
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/scripts/{id}/logs?limit=N`
|
||||
pub async fn logs_list(&self, script_id: &str, limit: u32) -> Result<Vec<ExecutionLog>> {
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthMeDto {
|
||||
// Part of the wire shape (and kept for symmetry with the dashboard's
|
||||
// MeDto), even though the CLI never displays it.
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub instance_role: InstanceRole,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AppLookupDto {
|
||||
#[serde(flatten)]
|
||||
pub app: App,
|
||||
// Not surfaced yet — `pic apps ls` only shows what `apps_list` returns.
|
||||
// Kept on the DTO so future `pic apps inspect <slug>` work is one-line.
|
||||
#[serde(default)]
|
||||
pub my_role: Option<AppRole>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateAppBody<'a> {
|
||||
pub slug: &'a str,
|
||||
pub name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateScriptBody<'a> {
|
||||
pub app_id: AppId,
|
||||
pub name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
pub source: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UpdateScriptBody<'a> {
|
||||
source: &'a str,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct ExecuteResponse {
|
||||
pub status_code: u16,
|
||||
// Captured for completeness; not displayed today, but `pic invoke -v`
|
||||
// could surface them later without changing this struct.
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
/// Parse `-H "Key: value"` or `-H "Key=value"` into a `(name, value)`
|
||||
/// pair. Trims surrounding whitespace on both sides.
|
||||
pub fn parse_kv_header(raw: &str) -> Result<(String, String), String> {
|
||||
let (k, v) = raw
|
||||
.split_once(':')
|
||||
.or_else(|| raw.split_once('='))
|
||||
.ok_or_else(|| format!("expected `Key: value` or `Key=value`, got {raw:?}"))?;
|
||||
let k = k.trim();
|
||||
let v = v.trim();
|
||||
if k.is_empty() {
|
||||
return Err(format!("empty header name in {raw:?}"));
|
||||
}
|
||||
Ok((k.to_string(), v.to_string()))
|
||||
}
|
||||
|
||||
fn urlencoded(s: &str) -> String {
|
||||
// Minimal pass: percent-encode the few chars that break the query.
|
||||
// Slugs and UUIDs don't contain them in practice, but be safe.
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'&' | '=' | '?' | '#' | ' ' => {
|
||||
out.push_str(&format!("%{:02X}", u32::from(ch)));
|
||||
}
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn decode<T: for<'de> Deserialize<'de>>(resp: reqwest::Response) -> Result<T> {
|
||||
if resp.status().is_success() {
|
||||
return resp.json::<T>().await.context("parsing response body");
|
||||
}
|
||||
Err(server_error(resp).await)
|
||||
}
|
||||
|
||||
async fn server_error(resp: reqwest::Response) -> anyhow::Error {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = parse_error_body(&body).unwrap_or(body);
|
||||
let hint = role_hint(status);
|
||||
if hint.is_empty() {
|
||||
anyhow!("HTTP {}: {}", status.as_u16(), msg)
|
||||
} else {
|
||||
anyhow!("HTTP {}: {} ({})", status.as_u16(), msg, hint)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_error_body(s: &str) -> Option<String> {
|
||||
let v: Value = serde_json::from_str(s).ok()?;
|
||||
let obj = v.as_object()?;
|
||||
if let Some(m) = obj.get("message").and_then(Value::as_str) {
|
||||
return Some(m.to_string());
|
||||
}
|
||||
if let Some(e) = obj.get("error").and_then(Value::as_str) {
|
||||
return Some(e.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn role_hint(status: StatusCode) -> &'static str {
|
||||
match status {
|
||||
StatusCode::FORBIDDEN => "your role may lack the required capability; check `pic whoami`",
|
||||
StatusCode::UNAUTHORIZED => "token rejected; re-run `pic login`",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_kv_colon() {
|
||||
let (k, v) = parse_kv_header("X-Foo: bar").unwrap();
|
||||
assert_eq!(k, "X-Foo");
|
||||
assert_eq!(v, "bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_kv_equals() {
|
||||
let (k, v) = parse_kv_header("X-Foo=bar").unwrap();
|
||||
assert_eq!(k, "X-Foo");
|
||||
assert_eq!(v, "bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_kv_rejects_no_separator() {
|
||||
assert!(parse_kv_header("X-Foo").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_kv_rejects_empty_name() {
|
||||
assert!(parse_kv_header(": bar").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_strip_trailing_slash() {
|
||||
let c = Client::new("http://localhost:8000/", "pic_x").unwrap();
|
||||
assert_eq!(c.url(), "http://localhost:8000");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user