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:
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}…")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user