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");
|
||||
}
|
||||
}
|
||||
40
crates/picloud-cli/src/cmds/apps.rs
Normal file
40
crates/picloud-cli/src/cmds/apps.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! `pic apps ls` and `pic apps create`.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::{Client, CreateAppBody};
|
||||
use crate::config::load;
|
||||
use crate::output::Table;
|
||||
|
||||
pub async fn ls() -> Result<()> {
|
||||
let creds = load()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let apps = client.apps_list().await?;
|
||||
let mut table = Table::new(["slug", "name", "my_role", "created_at"]);
|
||||
for app in apps {
|
||||
// The list endpoint returns App without my_role. We do a per-app
|
||||
// lookup only on demand; for `ls` we leave the column dashed so
|
||||
// the call stays cheap (one HTTP request).
|
||||
table.row([
|
||||
app.slug.clone(),
|
||||
app.name.clone(),
|
||||
"-".to_string(),
|
||||
app.created_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
|
||||
let creds = load()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let body = CreateAppBody {
|
||||
slug,
|
||||
name: name.unwrap_or(slug),
|
||||
description,
|
||||
};
|
||||
let app = client.apps_create(&body).await?;
|
||||
println!("Created app {}", app.slug);
|
||||
Ok(())
|
||||
}
|
||||
66
crates/picloud-cli/src/cmds/login.rs
Normal file
66
crates/picloud-cli/src/cmds/login.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
//! `pic login` — interactively (or via PICLOUD_URL/PICLOUD_TOKEN env
|
||||
//! shortcut for non-interactive contexts like CI and integration tests)
|
||||
//! capture the URL + bearer token, validate against `/auth/me`, save.
|
||||
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config::{save, Credentials};
|
||||
|
||||
const DEFAULT_URL: &str = "http://localhost:8000";
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
let (url, token) = collect_credentials()?;
|
||||
let client = Client::new(&url, &token)?;
|
||||
let me = client.auth_me().await?;
|
||||
let creds = Credentials {
|
||||
url: client.url().to_string(),
|
||||
token,
|
||||
username: me.username.clone(),
|
||||
};
|
||||
save(&creds)?;
|
||||
println!(
|
||||
"Logged in as {} ({}) at {}",
|
||||
me.username,
|
||||
instance_role_label(&me.instance_role),
|
||||
creds.url
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_credentials() -> Result<(String, String)> {
|
||||
// Non-interactive shortcut: both vars set → use as-is. Used by the
|
||||
// integration test and any CI flow that wants to skip the prompts.
|
||||
if let (Ok(url), Ok(tok)) = (std::env::var("PICLOUD_URL"), std::env::var("PICLOUD_TOKEN")) {
|
||||
if !url.is_empty() && !tok.is_empty() {
|
||||
return Ok((url, tok));
|
||||
}
|
||||
}
|
||||
let url = prompt_with_default("PiCloud URL", DEFAULT_URL)?;
|
||||
let token = rpassword::prompt_password("API token: ")?;
|
||||
Ok((url, token))
|
||||
}
|
||||
|
||||
fn prompt_with_default(label: &str, default: &str) -> Result<String> {
|
||||
print!("{label} [{default}]: ");
|
||||
io::stdout().flush()?;
|
||||
let mut buf = String::new();
|
||||
io::stdin().lock().read_line(&mut buf)?;
|
||||
let trimmed = buf.trim();
|
||||
Ok(if trimmed.is_empty() {
|
||||
default.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn instance_role_label(role: &picloud_shared::InstanceRole) -> &'static str {
|
||||
use picloud_shared::InstanceRole as R;
|
||||
match role {
|
||||
R::Owner => "owner",
|
||||
R::Admin => "admin",
|
||||
R::Member => "member",
|
||||
}
|
||||
}
|
||||
58
crates/picloud-cli/src/cmds/logs.rs
Normal file
58
crates/picloud-cli/src/cmds/logs.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! `pic logs <script-id>` — print recent execution log rows.
|
||||
|
||||
use anyhow::Result;
|
||||
use picloud_shared::ExecutionStatus;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config::load;
|
||||
|
||||
pub async fn run(script_id: &str, limit: u32) -> Result<()> {
|
||||
let creds = load()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let entries = client.logs_list(script_id, limit).await?;
|
||||
for e in entries {
|
||||
let summary = summarize(&e.response_body, &e.script_logs);
|
||||
println!(
|
||||
"{}\t{}\t{}",
|
||||
e.created_at.to_rfc3339(),
|
||||
status_label(&e.status),
|
||||
truncate(&summary, 120),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn status_label(s: &ExecutionStatus) -> &'static str {
|
||||
match s {
|
||||
ExecutionStatus::Success => "success",
|
||||
ExecutionStatus::Error => "error",
|
||||
ExecutionStatus::Timeout => "timeout",
|
||||
ExecutionStatus::BudgetExceeded => "budget_exceeded",
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize(response_body: &Option<serde_json::Value>, script_logs: &serde_json::Value) -> String {
|
||||
// Prefer the last script-side log line (often the most useful for
|
||||
// grepping). Fall back to the response body.
|
||||
if let Some(arr) = script_logs.as_array() {
|
||||
if let Some(last) = arr.last() {
|
||||
if let Some(msg) = last.get("message").and_then(|m| m.as_str()) {
|
||||
return msg.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
response_body
|
||||
.as_ref()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
fn truncate(s: &str, n: usize) -> String {
|
||||
let normalized = s.replace('\n', " ");
|
||||
if normalized.chars().count() <= n {
|
||||
normalized
|
||||
} else {
|
||||
let head: String = normalized.chars().take(n).collect();
|
||||
format!("{head}…")
|
||||
}
|
||||
}
|
||||
5
crates/picloud-cli/src/cmds/mod.rs
Normal file
5
crates/picloud-cli/src/cmds/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod apps;
|
||||
pub mod login;
|
||||
pub mod logs;
|
||||
pub mod scripts;
|
||||
pub mod whoami;
|
||||
178
crates/picloud-cli/src/cmds/scripts.rs
Normal file
178
crates/picloud-cli/src/cmds/scripts.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
//! `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}");
|
||||
}
|
||||
}
|
||||
22
crates/picloud-cli/src/cmds/whoami.rs
Normal file
22
crates/picloud-cli/src/cmds/whoami.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! `pic whoami` — re-validates the saved token by hitting `/auth/me`
|
||||
//! every time. Cached username in the credentials file is for
|
||||
//! display-only contexts; this command is the source of truth.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config::load;
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
let creds = load()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let me = client.auth_me().await?;
|
||||
let role = match me.instance_role {
|
||||
picloud_shared::InstanceRole::Owner => "owner",
|
||||
picloud_shared::InstanceRole::Admin => "admin",
|
||||
picloud_shared::InstanceRole::Member => "member",
|
||||
};
|
||||
let email = me.email.as_deref().unwrap_or("-");
|
||||
println!("{}\t{role}\t{email}\t{}", me.username, creds.url);
|
||||
Ok(())
|
||||
}
|
||||
118
crates/picloud-cli/src/config.rs
Normal file
118
crates/picloud-cli/src/config.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
//! On-disk credentials store.
|
||||
//!
|
||||
//! Path is resolved via `directories::ProjectDirs` so the file lives in
|
||||
//! the platform-appropriate config dir (XDG on Linux, Library on macOS,
|
||||
//! AppData on Windows). On POSIX the file is forced to mode 0600 so the
|
||||
//! pasted bearer token isn't world-readable.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Credentials {
|
||||
pub url: String,
|
||||
pub token: String,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
/// Resolve the credentials file path. Honors `PICLOUD_CONFIG_DIR` as an
|
||||
/// override (used by tests to redirect to a tempdir) before falling
|
||||
/// back to the platform default.
|
||||
pub fn credentials_path() -> Result<PathBuf> {
|
||||
if let Ok(dir) = std::env::var("PICLOUD_CONFIG_DIR") {
|
||||
return Ok(PathBuf::from(dir).join("credentials"));
|
||||
}
|
||||
let dirs = ProjectDirs::from("dev", "picloud", "picloud")
|
||||
.ok_or_else(|| anyhow!("could not determine config directory"))?;
|
||||
Ok(dirs.config_dir().join("credentials"))
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Credentials> {
|
||||
let path = credentials_path()?;
|
||||
let body = fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"no credentials at {}. run `pic login` first",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
toml::from_str(&body).with_context(|| format!("failed to parse {}", path.display()))
|
||||
}
|
||||
|
||||
pub fn save(creds: &Credentials) -> Result<()> {
|
||||
let path = credentials_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
let body = toml::to_string(creds).context("serializing credentials")?;
|
||||
write_private(&path, body.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut f = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.with_context(|| format!("opening {}", path.display()))?;
|
||||
f.write_all(bytes)
|
||||
.with_context(|| format!("writing {}", path.display()))?;
|
||||
// Belt-and-suspenders: re-set perms in case the file already existed
|
||||
// with a wider mode (mode() on create doesn't downgrade existing).
|
||||
let mut perms = fs::metadata(path)?.permissions();
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
perms.set_mode(0o600);
|
||||
fs::set_permissions(path, perms)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_toml() {
|
||||
let creds = Credentials {
|
||||
url: "http://localhost:8000".to_string(),
|
||||
token: "pic_abc".to_string(),
|
||||
username: "admin".to_string(),
|
||||
};
|
||||
let serialized = toml::to_string(&creds).unwrap();
|
||||
let parsed: Credentials = toml::from_str(&serialized).unwrap();
|
||||
assert_eq!(creds, parsed);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn posix_mode_is_0600() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
std::env::set_var("PICLOUD_CONFIG_DIR", dir.path());
|
||||
let creds = Credentials {
|
||||
url: "http://localhost:8000".to_string(),
|
||||
token: "pic_secret".to_string(),
|
||||
username: "admin".to_string(),
|
||||
};
|
||||
save(&creds).unwrap();
|
||||
let path = credentials_path().unwrap();
|
||||
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "credentials must be readable only by owner");
|
||||
std::env::remove_var("PICLOUD_CONFIG_DIR");
|
||||
}
|
||||
}
|
||||
142
crates/picloud-cli/src/main.rs
Normal file
142
crates/picloud-cli/src/main.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
//! PiCloud command-line client.
|
||||
//!
|
||||
//! Thin client over the existing admin + execute HTTP surface — the
|
||||
//! server gains nothing for the CLI; the CLI is just a developer
|
||||
//! ergonomics layer over endpoints the dashboard already uses.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
|
||||
mod client;
|
||||
mod cmds;
|
||||
mod config;
|
||||
mod output;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "pic", version, about = "PiCloud command-line client")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Save URL + bearer token to `~/.picloud/credentials`.
|
||||
Login,
|
||||
|
||||
/// Print the principal the saved token resolves to.
|
||||
Whoami,
|
||||
|
||||
/// App management.
|
||||
Apps {
|
||||
#[command(subcommand)]
|
||||
cmd: AppsCmd,
|
||||
},
|
||||
|
||||
/// Script management.
|
||||
Scripts {
|
||||
#[command(subcommand)]
|
||||
cmd: ScriptsCmd,
|
||||
},
|
||||
|
||||
/// Tail recent execution logs for a script.
|
||||
Logs(LogsArgs),
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum AppsCmd {
|
||||
/// List apps the caller can see.
|
||||
Ls,
|
||||
|
||||
/// Create a new app.
|
||||
Create {
|
||||
slug: String,
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ScriptsCmd {
|
||||
/// List scripts. With `--app`, scoped to one app; without,
|
||||
/// iterates over every app the caller can see.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
},
|
||||
|
||||
/// Upload a `.rhai` file. Patches the existing script with the
|
||||
/// matching name in `--app` if one exists, otherwise creates it.
|
||||
Deploy {
|
||||
file: PathBuf,
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
},
|
||||
|
||||
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
|
||||
/// `--body @-` for stdin, or inline JSON.
|
||||
Invoke {
|
||||
id: String,
|
||||
#[arg(long)]
|
||||
body: Option<String>,
|
||||
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)]
|
||||
headers: Vec<(String, String)>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct LogsArgs {
|
||||
script_id: String,
|
||||
#[arg(long, default_value_t = 50)]
|
||||
limit: u32,
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
let result = match cli.cmd {
|
||||
Cmd::Login => cmds::login::run().await,
|
||||
Cmd::Whoami => cmds::whoami::run().await,
|
||||
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls().await,
|
||||
Cmd::Apps {
|
||||
cmd:
|
||||
AppsCmd::Create {
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
},
|
||||
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref()).await,
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Ls { app },
|
||||
} => cmds::scripts::ls(app.as_deref()).await,
|
||||
Cmd::Scripts {
|
||||
cmd:
|
||||
ScriptsCmd::Deploy {
|
||||
file,
|
||||
app,
|
||||
name,
|
||||
description,
|
||||
},
|
||||
} => cmds::scripts::deploy(&file, &app, name.as_deref(), description.as_deref()).await,
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Invoke { id, body, headers },
|
||||
} => cmds::scripts::invoke(&id, body.as_deref(), &headers).await,
|
||||
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit).await,
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(err) => {
|
||||
output::print_error(&err);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
103
crates/picloud-cli/src/output.rs
Normal file
103
crates/picloud-cli/src/output.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
//! Tab-separated table writer + error formatting.
|
||||
//!
|
||||
//! Aligned columns are nice for humans but `\t`-separated stays
|
||||
//! pipe-friendly: `pic apps ls | awk -F'\t' '{print $1}'` works without
|
||||
//! parsing box-drawing.
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
||||
pub struct Table {
|
||||
headers: Vec<String>,
|
||||
rows: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
pub fn new<I, S>(headers: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
Self {
|
||||
headers: headers.into_iter().map(Into::into).collect(),
|
||||
rows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn row<I, S>(&mut self, cells: I) -> &mut Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
self.rows.push(cells.into_iter().map(Into::into).collect());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn render(&self) -> String {
|
||||
let mut widths: Vec<usize> = self.headers.iter().map(String::len).collect();
|
||||
for row in &self.rows {
|
||||
for (i, cell) in row.iter().enumerate() {
|
||||
if i >= widths.len() {
|
||||
widths.push(cell.len());
|
||||
} else if cell.len() > widths[i] {
|
||||
widths[i] = cell.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
write_row(&mut out, &self.headers, &widths);
|
||||
for row in &self.rows {
|
||||
write_row(&mut out, row, &widths);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn print(&self) {
|
||||
let s = self.render();
|
||||
// Best-effort write — broken pipe from `| head` etc. shouldn't
|
||||
// surface as an error.
|
||||
let _ = io::stdout().write_all(s.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
fn write_row(out: &mut String, row: &[String], widths: &[usize]) {
|
||||
for (i, cell) in row.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push('\t');
|
||||
}
|
||||
out.push_str(cell);
|
||||
// Right-pad with spaces so tabs land on the column grid for
|
||||
// human readers. Skip on the final column.
|
||||
if i + 1 < row.len() {
|
||||
let w = widths.get(i).copied().unwrap_or(cell.len());
|
||||
for _ in cell.len()..w {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
pub fn print_error(err: &anyhow::Error) {
|
||||
let mut stderr = io::stderr();
|
||||
let _ = writeln!(stderr, "error: {err:#}");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_aligns_columns() {
|
||||
let mut t = Table::new(["slug", "name"]);
|
||||
t.row(["a", "Alpha"]).row(["bravo", "B"]);
|
||||
let out = t.render();
|
||||
assert_eq!(out, "slug \tname\na \tAlpha\nbravo\tB\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_empty_rows() {
|
||||
let t = Table::new(["a", "b"]);
|
||||
assert_eq!(t.render(), "a\tb\n");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user