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:
MechaCat02
2026-05-28 20:53:49 +02:00
parent b42e273479
commit 7b50047730
13 changed files with 1448 additions and 0 deletions

View 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(())
}

View 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",
}
}

View 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}")
}
}

View File

@@ -0,0 +1,5 @@
pub mod apps;
pub mod login;
pub mod logs;
pub mod scripts;
pub mod whoami;

View 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}");
}
}

View 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(())
}