feat(cli): real auth, delete commands, api-keys, JSON output, env override

Address the review findings on the CLI surface:

* `pic login` now prompts for username + password and POSTs to
  `/api/v1/admin/auth/login`. `--token` (and `PICLOUD_TOKEN`) still
  works for paste-a-bearer flows (CI, long-lived API keys). Falls
  back to a plain stdin read when no controlling tty is attached.
* `pic logout` revokes the session server-side and deletes the local
  credentials file. Idempotent.
* `PICLOUD_URL` / `PICLOUD_TOKEN` now override the on-disk credentials
  file for every command via `config::resolve`, not just for
  `pic login`. Matches gcloud/aws/kubectl semantics.
* New commands: `pic apps delete [--force]`, `pic apps show`,
  `pic scripts delete`, `pic api-keys mint|ls|rm`, plus top-level
  `pic invoke` / `pic deploy` shortcuts.
* `pic scripts ls` (no `--app`) now issues a single
  `GET /admin/scripts` + one `apps_list` in parallel and joins
  client-side, instead of walking N+1 per-app calls that aborted on
  the first 404 — the bug the test suite was retrying around.
* Global `--output tsv|json` flag wired through every list/show and
  through `whoami` / `logs`. TSV stays pipe-friendly; JSON is a real
  array of objects (or a flat object for single-row views).
* `whoami` and `logs` now emit labeled output instead of headerless
  tab lines, consistent with the existing `apps ls` / `scripts ls`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-29 23:33:44 +02:00
parent e4851b3deb
commit f147665157
14 changed files with 996 additions and 125 deletions

1
Cargo.lock generated
View File

@@ -1535,6 +1535,7 @@ version = "0.6.0"
dependencies = [
"anyhow",
"assert_cmd",
"chrono",
"clap",
"directories",
"libc",

View File

@@ -27,6 +27,7 @@ reqwest = { workspace = true, features = ["json"] }
serde.workspace = true
serde_json.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
chrono = { workspace = true }
clap = { version = "4", features = ["derive"] }
toml = "0.8"
directories = "5"

View File

@@ -8,7 +8,10 @@
use std::collections::BTreeMap;
use anyhow::{anyhow, Context, Result};
use picloud_shared::{App, AppId, AppRole, ExecutionLog, InstanceRole, Script};
use chrono::{DateTime, Utc};
use picloud_shared::{
AdminUserId, ApiKeyId, App, AppId, AppRole, ExecutionLog, InstanceRole, Scope, Script,
};
use reqwest::{header, Method, RequestBuilder, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -38,6 +41,7 @@ impl Client {
})
}
#[allow(dead_code)] // used by the trailing-slash unit test below.
pub fn url(&self) -> &str {
&self.url
}
@@ -97,6 +101,42 @@ impl Client {
decode(resp).await
}
/// `GET /api/v1/admin/scripts` — every script the caller can see
/// (server filters by membership for `Member`). Lets `pic scripts ls`
/// (no `--app`) collapse what used to be an N+1 per-app walk into a
/// single request that can't be partially-broken by a concurrent app
/// delete.
pub async fn scripts_list_all(&self) -> Result<Vec<Script>> {
let resp = self
.request(Method::GET, "/api/v1/admin/scripts")
.send()
.await?;
decode(resp).await
}
/// `DELETE /api/v1/admin/apps/{id_or_slug}` with optional `?force=true`.
/// Server requires `AppAdmin` capability; without `force`, returns
/// 409 if the app still has scripts.
pub async fn apps_delete(&self, ident: &str, force: bool) -> Result<()> {
let path = if force {
format!("/api/v1/admin/apps/{ident}?force=true")
} else {
format!("/api/v1/admin/apps/{ident}")
};
let resp = self.request(Method::DELETE, &path).send().await?;
decode_status(resp).await
}
/// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the
/// owning app (stricter than the edit endpoints, by design).
pub async fn scripts_delete(&self, id: &str) -> Result<()> {
let resp = self
.request(Method::DELETE, &format!("/api/v1/admin/scripts/{id}"))
.send()
.await?;
decode_status(resp).await
}
/// `POST /api/v1/admin/scripts`
pub async fn scripts_create(&self, body: &CreateScriptBody<'_>) -> Result<Script> {
let resp = self
@@ -167,6 +207,68 @@ impl Client {
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/auth/logout` — best-effort: server returns
/// 204 whether or not the token matched a live session, so we just
/// fire and discard the body. Caller still wipes the local creds.
pub async fn auth_logout(&self) -> Result<()> {
let resp = self
.request(Method::POST, "/api/v1/admin/auth/logout")
.send()
.await?;
decode_status(resp).await
}
/// `GET /api/v1/admin/api-keys` — caller's keys only (server filters
/// by user_id, no cross-user enumeration).
pub async fn apikeys_list(&self) -> Result<Vec<ApiKeyDto>> {
let resp = self
.request(Method::GET, "/api/v1/admin/api-keys")
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/api-keys` — `raw_token` is in the response
/// **once** and never appears in `GET /api-keys` afterward.
pub async fn apikeys_mint(&self, body: &MintApiKeyBody<'_>) -> Result<MintApiKeyResponseDto> {
let resp = self
.request(Method::POST, "/api/v1/admin/api-keys")
.json(body)
.send()
.await?;
decode(resp).await
}
/// `DELETE /api/v1/admin/api-keys/{id}` — 404 covers both "doesn't
/// exist" and "not yours" (server flattens to avoid enumeration).
pub async fn apikeys_delete(&self, id: &str) -> Result<()> {
let resp = self
.request(Method::DELETE, &format!("/api/v1/admin/api-keys/{id}"))
.send()
.await?;
decode_status(resp).await
}
}
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
/// it runs before any token exists. Mirrors the dashboard's login.ts
/// wire shape (see `manager-core/src/auth_api.rs:49-60`).
pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<LoginResponseDto> {
let http = reqwest::Client::builder()
.user_agent(concat!("pic/", env!("CARGO_PKG_VERSION")))
.build()
.context("building HTTP client")?;
let body = LoginRequestBody { username, password };
let resp = http
.post(format!(
"{}/api/v1/admin/auth/login",
url.trim_end_matches('/')
))
.json(&body)
.send()
.await?;
decode(resp).await
}
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
@@ -216,6 +318,63 @@ struct UpdateScriptBody<'a> {
source: &'a str,
}
#[derive(Debug, Serialize)]
struct LoginRequestBody<'a> {
username: &'a str,
password: &'a str,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct LoginResponseDto {
pub user: LoginUserDto,
pub token: String,
pub expires_at: DateTime<Utc>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct LoginUserDto {
pub id: AdminUserId,
pub username: String,
pub instance_role: InstanceRole,
#[serde(default)]
pub email: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct MintApiKeyBody<'a> {
pub name: &'a str,
pub scopes: &'a [Scope],
#[serde(skip_serializing_if = "Option::is_none")]
pub app_id: Option<AppId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
}
/// Fresh-mint response. The `raw_token` field is the one and only
/// chance to capture the bearer string; subsequent `GET /api-keys`
/// returns the `ApiKeyDto` portion without it.
#[derive(Debug, Deserialize)]
pub struct MintApiKeyResponseDto {
#[serde(flatten)]
pub key: ApiKeyDto,
pub raw_token: String,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct ApiKeyDto {
pub id: ApiKeyId,
pub prefix: String,
pub name: String,
pub scopes: Vec<Scope>,
pub app_id: Option<AppId>,
pub expires_at: Option<DateTime<Utc>>,
pub last_used_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct ExecuteResponse {
@@ -265,6 +424,15 @@ async fn decode<T: for<'de> Deserialize<'de>>(resp: reqwest::Response) -> Result
Err(server_error(resp).await)
}
/// Like `decode` but for endpoints whose 2xx response has no body
/// (204 No Content) — DELETE handlers, logout.
async fn decode_status(resp: reqwest::Response) -> Result<()> {
if resp.status().is_success() {
return Ok(());
}
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();

View File

@@ -0,0 +1,201 @@
//! `pic api-keys` — long-lived bearer-key management.
//!
//! Server semantics (mirrored from `manager-core/src/api_keys_api.rs`):
//! * `raw_token` is returned **once** on mint and never again.
//! * `app_id` (optional `--app`) binds the key to one app; instance
//! scopes (`instance:*`) are rejected when `--app` is also set.
//! * `scopes` is a `text[]` in the wire form (`script:read`, …).
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use picloud_shared::Scope;
use crate::client::{Client, MintApiKeyBody};
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
pub async fn mint(
name: &str,
scope_strs: &[String],
app_ident: Option<&str>,
expires: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let scopes = parse_scopes(scope_strs)?;
let expires_at = expires.map(parse_expires).transpose()?;
let app_id = match app_ident {
Some(ident) => Some(client.apps_get(ident).await?.app.id),
None => None,
};
let body = MintApiKeyBody {
name,
scopes: &scopes,
app_id,
expires_at,
};
let resp = client.apikeys_mint(&body).await?;
let mut block = KvBlock::new();
block
.field("id", resp.key.id.to_string())
.field("name", resp.key.name.clone())
.field("prefix", resp.key.prefix.clone())
.field(
"scopes",
resp.key
.scopes
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(","),
)
.field(
"app_id",
resp.key
.app_id
.map(|a| a.to_string())
.unwrap_or_else(|| "-".into()),
)
.field(
"expires_at",
resp.key
.expires_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "-".into()),
)
.field("token", resp.raw_token.clone());
block.print(mode);
if matches!(mode, OutputMode::Tsv) {
// The token row is human-easy-to-miss in a wall of metadata;
// call it out exactly once on the human path. Skip on JSON
// since machine consumers don't need the nudge.
eprintln!("Save this token — it will not be shown again.");
}
Ok(())
}
pub async fn ls(mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let keys = client.apikeys_list().await?;
let mut table = Table::new([
"id",
"name",
"prefix",
"scopes",
"app_id",
"expires_at",
"last_used_at",
"created_at",
]);
for k in keys {
table.row([
k.id.to_string(),
k.name,
k.prefix,
k.scopes
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(","),
k.app_id
.map(|a| a.to_string())
.unwrap_or_else(|| "-".into()),
k.expires_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "-".into()),
k.last_used_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "-".into()),
k.created_at.to_rfc3339(),
]);
}
table.print(mode);
Ok(())
}
pub async fn rm(id: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.apikeys_delete(id).await?;
println!("Revoked api-key {id}");
Ok(())
}
fn parse_scopes(raw: &[String]) -> Result<Vec<Scope>> {
if raw.is_empty() {
return Err(anyhow!(
"at least one `--scope` is required (e.g. --scope script:read)"
));
}
raw.iter()
.map(|s| Scope::from_wire(s).ok_or_else(|| anyhow!("unknown scope: {s}")))
.collect()
}
/// `--expires` accepts either RFC 3339 (`2026-12-31T23:59:59Z`) or a
/// shorthand `<N>d` / `<N>h` / `<N>m` (days / hours / minutes from now).
/// Shorthand wins for the common "key good for 30 days" case; full
/// RFC 3339 keeps the door open for precise cutoffs.
fn parse_expires(raw: &str) -> Result<DateTime<Utc>> {
if let Some(spec) = raw.strip_suffix('d') {
let days: i64 = spec.parse().map_err(|_| anyhow!("bad days: {raw}"))?;
return Ok(Utc::now() + chrono::Duration::days(days));
}
if let Some(spec) = raw.strip_suffix('h') {
let hours: i64 = spec.parse().map_err(|_| anyhow!("bad hours: {raw}"))?;
return Ok(Utc::now() + chrono::Duration::hours(hours));
}
if let Some(spec) = raw.strip_suffix('m') {
let mins: i64 = spec.parse().map_err(|_| anyhow!("bad minutes: {raw}"))?;
return Ok(Utc::now() + chrono::Duration::minutes(mins));
}
DateTime::parse_from_rfc3339(raw)
.map(|d| d.with_timezone(&Utc))
.map_err(|e| anyhow!("expected RFC 3339 or `<N>d/h/m`, got {raw:?}: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_scopes_accepts_wire_form() {
let scopes = parse_scopes(&["script:read".into(), "log:read".into()]).unwrap();
assert_eq!(scopes, vec![Scope::ScriptRead, Scope::LogRead]);
}
#[test]
fn parse_scopes_rejects_empty() {
let err = parse_scopes(&[]).unwrap_err();
assert!(format!("{err}").contains("at least one"));
}
#[test]
fn parse_scopes_rejects_unknown() {
let err = parse_scopes(&["script:nope".into()]).unwrap_err();
assert!(format!("{err}").contains("unknown scope"));
}
#[test]
fn parse_expires_days_shorthand() {
let d = parse_expires("7d").unwrap();
let diff = (d - Utc::now()).num_days();
assert!((6..=7).contains(&diff), "got {diff}");
}
#[test]
fn parse_expires_rfc3339_passes_through() {
let d = parse_expires("2030-01-01T00:00:00Z").unwrap();
assert_eq!(d.timestamp(), 1893456000);
}
#[test]
fn parse_expires_garbage_errors() {
assert!(parse_expires("tomorrow").is_err());
}
}

View File

@@ -1,13 +1,14 @@
//! `pic apps ls` and `pic apps create`.
//! `pic apps` subcommands: `ls`, `create`, `show`, `delete`.
use anyhow::Result;
use picloud_shared::AppRole;
use crate::client::{Client, CreateAppBody};
use crate::config::load;
use crate::output::Table;
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
pub async fn ls() -> Result<()> {
let creds = load()?;
pub async fn ls(mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let apps = client.apps_list().await?;
let mut table = Table::new(["slug", "name", "my_role", "created_at"]);
@@ -22,12 +23,12 @@ pub async fn ls() -> Result<()> {
app.created_at.to_rfc3339(),
]);
}
table.print();
table.print(mode);
Ok(())
}
pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
let creds = load()?;
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let body = CreateAppBody {
slug,
@@ -38,3 +39,46 @@ pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -
println!("Created app {}", app.slug);
Ok(())
}
/// `pic apps show <slug>` — single-app inspect using the lookup
/// endpoint, which carries `my_role` for the caller (the `ls` endpoint
/// doesn't).
pub async fn show(ident: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let lookup = client.apps_get(ident).await?;
let mut block = KvBlock::new();
block
.field("id", lookup.app.id.to_string())
.field("slug", lookup.app.slug.clone())
.field("name", lookup.app.name.clone())
.field(
"description",
lookup.app.description.clone().unwrap_or_else(|| "-".into()),
)
.field("my_role", role_label(lookup.my_role.as_ref()))
.field("created_at", lookup.app.created_at.to_rfc3339())
.field("updated_at", lookup.app.updated_at.to_rfc3339());
block.print(mode);
Ok(())
}
/// `pic apps delete <slug> [--force]`. Without `--force` the server
/// returns 409 if the app still owns scripts — surface that as a
/// useful error rather than swallowing.
pub async fn delete(ident: &str, force: bool) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.apps_delete(ident, force).await?;
println!("Deleted app {ident}");
Ok(())
}
fn role_label(role: Option<&AppRole>) -> String {
// Use the wire form so the CLI label matches what the dashboard
// shows and what the membership APIs accept.
match role {
Some(r) => r.as_str().to_string(),
None => "-".into(),
}
}

View File

@@ -1,46 +1,118 @@
//! `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.
//! `pic login` — primary auth entry point.
//!
//! Two flows:
//! * **username + password** (default, interactive): POST
//! `/api/v1/admin/auth/login` with the credentials and persist the
//! returned session token. Mirrors the dashboard's login form.
//! * **paste-a-token** (`--token <T>`, or `PICLOUD_TOKEN` env): skip
//! the credential exchange and persist a bearer string directly.
//! Used by CI and by anyone using a long-lived API key minted via
//! `pic api-keys mint`. Validated against `/auth/me` before save.
//!
//! `--url <U>` (or `PICLOUD_URL`) overrides the URL prompt non-interactively.
use std::io::{self, BufRead, Write};
use anyhow::Result;
use anyhow::{Context, Result};
use picloud_shared::InstanceRole;
use crate::client::Client;
use crate::client::{self, 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?;
pub async fn run(url_arg: Option<&str>, token_arg: Option<&str>) -> Result<()> {
let url = resolve_url(url_arg)?;
let token_from_env = std::env::var("PICLOUD_TOKEN")
.ok()
.filter(|s| !s.is_empty());
let bearer_token = token_arg.map(str::to_string).or(token_from_env);
let (token, username, role) = match bearer_token {
Some(t) => login_with_bearer(&url, &t).await?,
None => login_with_password(&url).await?,
};
let creds = Credentials {
url: client.url().to_string(),
url: url.clone(),
token,
username: me.username.clone(),
username: username.clone(),
};
save(&creds)?;
println!(
"Logged in as {} ({}) at {}",
me.username,
instance_role_label(&me.instance_role),
creds.url
"Logged in as {username} ({}) at {url}",
instance_role_label(&role)
);
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));
async fn login_with_password(url: &str) -> Result<(String, String, InstanceRole)> {
let username = prompt_line("Username: ")?;
if username.is_empty() {
anyhow::bail!("username is required");
}
let password = read_password()?;
let resp = client::auth_login(url, &username, &password).await?;
Ok((resp.token, resp.user.username, resp.user.instance_role))
}
/// Read a password without echoing it where possible. Falls back to a
/// plain stdin read when no controlling terminal is attached — CI
/// systems and `cargo test`'s piped stdin both land here, and dying
/// outright would block scripted use entirely. The fallback is louder
/// (visible characters), but it's that or no functioning login.
fn read_password() -> Result<String> {
match rpassword::prompt_password("Password: ") {
Ok(p) => Ok(p),
Err(_) => {
eprint!("Password: ");
io::stderr().flush()?;
let mut buf = String::new();
io::stdin()
.lock()
.read_line(&mut buf)
.context("reading password from stdin")?;
Ok(buf.trim_end_matches(['\r', '\n']).to_string())
}
}
let url = prompt_with_default("PiCloud URL", DEFAULT_URL)?;
let token = rpassword::prompt_password("API token: ")?;
Ok((url, token))
}
/// Bearer-token path: validate against `/auth/me` so a typo doesn't get
/// persisted, then trust the username the server reports rather than
/// whatever the user typed (which they didn't type at all in this mode).
async fn login_with_bearer(url: &str, token: &str) -> Result<(String, String, InstanceRole)> {
let client = Client::new(url, token)?;
let me = client.auth_me().await?;
Ok((token.to_string(), me.username, me.instance_role))
}
fn instance_role_label(role: &InstanceRole) -> &'static str {
match role {
InstanceRole::Owner => "owner",
InstanceRole::Admin => "admin",
InstanceRole::Member => "member",
}
}
fn resolve_url(url_arg: Option<&str>) -> Result<String> {
if let Some(u) = url_arg {
return Ok(u.trim_end_matches('/').to_string());
}
if let Ok(env_url) = std::env::var("PICLOUD_URL") {
if !env_url.is_empty() {
return Ok(env_url.trim_end_matches('/').to_string());
}
}
let typed = prompt_with_default("PiCloud URL", DEFAULT_URL)?;
Ok(typed.trim_end_matches('/').to_string())
}
fn prompt_line(label: &str) -> Result<String> {
print!("{label}");
io::stdout().flush()?;
let mut buf = String::new();
io::stdin().lock().read_line(&mut buf)?;
Ok(buf.trim().to_string())
}
fn prompt_with_default(label: &str, default: &str) -> Result<String> {
@@ -55,12 +127,3 @@ fn prompt_with_default(label: &str, default: &str) -> Result<String> {
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,29 @@
//! `pic logout` — revoke the saved session server-side, then wipe the
//! local credentials file.
//!
//! Idempotent: if the file doesn't exist or the server already forgot
//! the session, we still succeed. The point is leaving the user in a
//! clean "no token" state, not enforcing that a session existed.
use anyhow::Result;
use crate::client::Client;
use crate::config;
pub async fn run() -> Result<()> {
// Load before delete so we have a token to POST /logout with; if
// there's no creds file there's also nothing to revoke server-side.
let creds = config::load().ok();
if let Some(creds) = creds {
let client = Client::from_creds(&creds)?;
// Best-effort: a 4xx (token already invalid) or network error
// shouldn't block the local wipe. The whole point of logout is
// leaving no credentials on disk.
let _ = client.auth_logout().await;
}
config::delete()?;
println!("Logged out");
Ok(())
}

View File

@@ -1,27 +1,48 @@
//! `pic logs <script-id>` — print recent execution log rows.
//!
//! In TSV mode emits a header + truncated-summary rows (`pic logs` was
//! previously headerless — inconsistent with `apps ls` / `scripts ls`).
//! In JSON mode emits the raw `ExecutionLog` array (no truncation),
//! letting `jq` consumers see request/response bodies in full.
use anyhow::Result;
use picloud_shared::ExecutionStatus;
use picloud_shared::{ExecutionLog, ExecutionStatus};
use crate::client::Client;
use crate::config::load;
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn run(script_id: &str, limit: u32) -> Result<()> {
let creds = load()?;
pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
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),
);
match mode {
OutputMode::Tsv => render_tsv(&entries),
OutputMode::Json => render_json(&entries),
}
Ok(())
}
fn render_tsv(entries: &[ExecutionLog]) {
let mut table = Table::new(["created_at", "status", "summary"]);
for e in entries {
let summary = summarize(&e.response_body, &e.script_logs);
table.row([
e.created_at.to_rfc3339(),
status_label(&e.status).to_string(),
truncate(&summary, 120),
]);
}
table.print(OutputMode::Tsv);
}
fn render_json(entries: &[ExecutionLog]) {
// Pretty for human jq-piping; consumers that want compact can pipe
// through `jq -c`.
let s = serde_json::to_string_pretty(entries).unwrap_or_else(|_| "[]".to_string());
println!("{s}");
}
fn status_label(s: &ExecutionStatus) -> &'static str {
match s {
ExecutionStatus::Success => "success",

View File

@@ -1,5 +1,7 @@
pub mod api_keys;
pub mod apps;
pub mod login;
pub mod logout;
pub mod logs;
pub mod scripts;
pub mod whoami;

View File

@@ -1,17 +1,19 @@
//! `pic scripts ls | deploy | invoke`.
//! `pic scripts ls | deploy | invoke | delete`.
use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::path::Path;
use anyhow::{anyhow, Context, Result};
use picloud_shared::AppId;
use serde_json::Value;
use crate::client::{Client, CreateScriptBody};
use crate::config::load;
use crate::output::Table;
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn ls(app: Option<&str>) -> Result<()> {
let creds = load()?;
pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]);
@@ -29,24 +31,29 @@ pub async fn ls(app: Option<&str>) -> Result<()> {
]);
}
} 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(),
]);
}
// No filter → use the single `GET /admin/scripts` call. Server
// filters by membership for `Member`; for `Admin`/`Owner` it
// returns every script. Two requests total (apps + scripts) run
// in parallel; the per-app walk we used to do here aborted on
// the first 404 when another caller deleted an app mid-listing,
// and was the entire reason a 5× retry existed in the tests.
let (apps, scripts) = tokio::try_join!(client.apps_list(), client.scripts_list_all())?;
let slug_by_id: HashMap<AppId, String> = apps.into_iter().map(|a| (a.id, a.slug)).collect();
for s in scripts {
let app_slug = slug_by_id
.get(&s.app_id)
.cloned()
.unwrap_or_else(|| "-".to_string());
table.row([
s.id.to_string(),
app_slug,
s.name,
s.version.to_string(),
s.updated_at.to_rfc3339(),
]);
}
}
table.print();
table.print(mode);
Ok(())
}
@@ -56,7 +63,7 @@ pub async fn deploy(
name_override: Option<&str>,
description: Option<&str>,
) -> Result<()> {
let creds = load()?;
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let source =
@@ -99,7 +106,7 @@ pub async fn deploy(
}
pub async fn invoke(id: &str, body_arg: Option<&str>, headers: &[(String, String)]) -> Result<()> {
let creds = load()?;
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let body = parse_body_arg(body_arg)?;
@@ -115,6 +122,18 @@ pub async fn invoke(id: &str, body_arg: Option<&str>, headers: &[(String, String
}
}
/// `pic scripts delete <id>`. Requires `AppAdmin` on the owning app
/// server-side, which is stricter than the edit endpoints — Editor
/// can deploy/update but not destroy. Surfaces that as a 403 with the
/// usual role hint.
pub async fn delete(id: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.scripts_delete(id).await?;
println!("Deleted script {id}");
Ok(())
}
fn parse_body_arg(arg: Option<&str>) -> Result<Value> {
match arg {
None => Ok(Value::Object(serde_json::Map::new())),

View File

@@ -1,22 +1,34 @@
//! `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.
//!
//! TSV output uses `KvBlock` (aligned `key: value` rows), JSON output
//! is a flat object — both downstream-friendly without the user having
//! to parse a headerless tab-line.
use anyhow::Result;
use picloud_shared::InstanceRole;
use crate::client::Client;
use crate::config::load;
use crate::config;
use crate::output::{KvBlock, OutputMode};
pub async fn run() -> Result<()> {
let creds = load()?;
pub async fn run(mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
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",
InstanceRole::Owner => "owner",
InstanceRole::Admin => "admin",
InstanceRole::Member => "member",
};
let email = me.email.as_deref().unwrap_or("-");
println!("{}\t{role}\t{email}\t{}", me.username, creds.url);
let mut block = KvBlock::new();
block
.field("username", me.username)
.field("role", role)
.field("email", email)
.field("url", creds.url.clone());
block.print(mode);
Ok(())
}

View File

@@ -43,6 +43,41 @@ pub fn load() -> Result<Credentials> {
toml::from_str(&body).with_context(|| format!("failed to parse {}", path.display()))
}
/// Resolution order used by every non-login command:
/// 1. If both `PICLOUD_URL` and `PICLOUD_TOKEN` are set (and non-empty),
/// use them directly. Matches gcloud/aws/kubectl semantics — env
/// wins so CI never accidentally reads a developer's stale file.
/// 2. Otherwise fall back to the on-disk credentials file.
///
/// Username is best-effort: env mode has no way to know the real one
/// (no round-trip to `/auth/me`), so it shows as `"-"` in `whoami`
/// output. Callers that need the canonical username re-fetch via
/// `Client::auth_me`.
pub fn resolve() -> Result<Credentials> {
if let (Ok(url), Ok(token)) = (std::env::var("PICLOUD_URL"), std::env::var("PICLOUD_TOKEN")) {
if !url.is_empty() && !token.is_empty() {
return Ok(Credentials {
url,
token,
username: "-".to_string(),
});
}
}
load()
}
/// Delete the on-disk credentials file. Idempotent — silently succeeds
/// if the file is already gone (the user already logged out, or never
/// logged in to begin with).
pub fn delete() -> Result<()> {
let path = credentials_path()?;
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err).with_context(|| format!("removing {}", path.display())),
}
}
pub fn save(creds: &Credentials) -> Result<()> {
let path = credentials_path()?;
if let Some(parent) = path.parent() {

View File

@@ -14,17 +14,31 @@ mod cmds;
mod config;
mod output;
use crate::output::OutputMode;
#[derive(Parser)]
#[command(name = "pic", version, about = "PiCloud command-line client")]
struct Cli {
/// Output format for `ls` / `show` / `whoami` / `logs` commands.
/// TSV stays pipe-friendly; JSON is `jq`-ready.
#[arg(long, value_enum, global = true, default_value_t = OutputMode::Tsv)]
output: OutputMode,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Save URL + bearer token to `~/.picloud/credentials`.
Login,
/// Authenticate with the server. Default flow prompts for username
/// + password and saves the returned session token; `--token` skips
/// the password exchange and persists a bearer string directly (use
/// this for long-lived API keys minted via `pic api-keys mint`).
Login(LoginArgs),
/// Revoke the saved session server-side and delete the local
/// credentials file. Idempotent.
Logout,
/// Print the principal the saved token resolves to.
Whoami,
@@ -41,8 +55,35 @@ enum Cmd {
cmd: ScriptsCmd,
},
/// Long-lived bearer API key management.
#[command(name = "api-keys")]
ApiKeys {
#[command(subcommand)]
cmd: ApiKeysCmd,
},
/// Tail recent execution logs for a script.
Logs(LogsArgs),
/// Top-level alias for `pic scripts invoke <id>`.
Invoke(InvokeArgs),
/// Top-level alias for `pic scripts deploy <file> --app <slug>`.
Deploy(DeployArgs),
}
#[derive(Args)]
struct LoginArgs {
/// Override the URL prompt non-interactively. Also reads
/// `PICLOUD_URL`.
#[arg(long)]
url: Option<String>,
/// Skip the username + password exchange and persist this bearer
/// directly (validated against `/auth/me` first). Also reads
/// `PICLOUD_TOKEN`.
#[arg(long)]
token: Option<String>,
}
#[derive(Subcommand)]
@@ -58,12 +99,23 @@ enum AppsCmd {
#[arg(long)]
description: Option<String>,
},
/// Show a single app, including the caller's role in it.
Show { ident: String },
/// Delete an app. Without `--force`, the server rejects if the app
/// still owns scripts.
Delete {
ident: String,
#[arg(long)]
force: bool,
},
}
#[derive(Subcommand)]
enum ScriptsCmd {
/// List scripts. With `--app`, scoped to one app; without,
/// iterates over every app the caller can see.
/// List scripts. With `--app`, scoped to one app; without, one
/// `GET /admin/scripts` for everything the caller can see.
Ls {
#[arg(long)]
app: Option<String>,
@@ -71,25 +123,59 @@ enum ScriptsCmd {
/// 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>,
},
Deploy(DeployArgs),
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
/// `--body @-` for stdin, or inline JSON.
Invoke {
id: String,
Invoke(InvokeArgs),
/// Delete a script. Requires AppAdmin on the owning app.
Delete { id: String },
}
#[derive(Args)]
struct DeployArgs {
file: PathBuf,
#[arg(long)]
app: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
}
#[derive(Args)]
struct InvokeArgs {
id: String,
#[arg(long)]
body: Option<String>,
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)]
headers: Vec<(String, String)>,
}
#[derive(Subcommand)]
enum ApiKeysCmd {
/// Mint a new long-lived bearer key. Token printed exactly once.
Mint {
name: String,
/// Repeat for multiple scopes: `--scope script:read --scope log:read`.
#[arg(long = "scope", required = true)]
scopes: Vec<String>,
/// Bind the key to a single app (slug or id). Rejects
/// `instance:*` scopes when set.
#[arg(long)]
body: Option<String>,
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)]
headers: Vec<(String, String)>,
app: Option<String>,
/// Absolute RFC 3339 (`2026-12-31T23:59:59Z`) or shorthand
/// `<N>d`/`<N>h`/`<N>m`.
#[arg(long)]
expires: Option<String>,
},
/// List the caller's keys (no `raw_token` after mint).
Ls,
/// Revoke a key by id.
Rm { id: String },
}
#[derive(Args)]
@@ -102,10 +188,12 @@ struct LogsArgs {
#[tokio::main(flavor = "current_thread")]
async fn main() -> ExitCode {
let cli = Cli::parse();
let mode = cli.output;
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::Login(args) => cmds::login::run(args.url.as_deref(), args.token.as_deref()).await,
Cmd::Logout => cmds::logout::run().await,
Cmd::Whoami => cmds::whoami::run(mode).await,
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
Cmd::Apps {
cmd:
AppsCmd::Create {
@@ -114,22 +202,60 @@ async fn main() -> ExitCode {
description,
},
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref()).await,
Cmd::Apps {
cmd: AppsCmd::Show { ident },
} => cmds::apps::show(&ident, mode).await,
Cmd::Apps {
cmd: AppsCmd::Delete { ident, force },
} => cmds::apps::delete(&ident, force).await,
Cmd::Scripts {
cmd: ScriptsCmd::Ls { app },
} => cmds::scripts::ls(app.as_deref()).await,
} => cmds::scripts::ls(app.as_deref(), mode).await,
Cmd::Scripts {
cmd: ScriptsCmd::Deploy(args),
} => {
cmds::scripts::deploy(
&args.file,
&args.app,
args.name.as_deref(),
args.description.as_deref(),
)
.await
}
Cmd::Scripts {
cmd: ScriptsCmd::Invoke(args),
} => cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await,
Cmd::Scripts {
cmd: ScriptsCmd::Delete { id },
} => cmds::scripts::delete(&id).await,
Cmd::ApiKeys {
cmd:
ScriptsCmd::Deploy {
file,
app,
ApiKeysCmd::Mint {
name,
description,
scopes,
app,
expires,
},
} => 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,
} => cmds::api_keys::mint(&name, &scopes, app.as_deref(), expires.as_deref(), mode).await,
Cmd::ApiKeys {
cmd: ApiKeysCmd::Ls,
} => cmds::api_keys::ls(mode).await,
Cmd::ApiKeys {
cmd: ApiKeysCmd::Rm { id },
} => cmds::api_keys::rm(&id).await,
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit, mode).await,
Cmd::Invoke(args) => {
cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await
}
Cmd::Deploy(args) => {
cmds::scripts::deploy(
&args.file,
&args.app,
args.name.as_deref(),
args.description.as_deref(),
)
.await
}
};
match result {

View File

@@ -1,11 +1,34 @@
//! Tab-separated table writer + error formatting.
//! Output rendering for the CLI.
//!
//! 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.
//! Two formats:
//! * **TSV** (default): aligned columns separated by `\t`. Stays
//! pipe-friendly — `pic apps ls | awk -F'\t' '{print $1}'` works
//! without parsing box-drawing.
//! * **JSON**: array of `{column: value, …}` objects (for tables) or
//! a flat object (for single-row `show`/`whoami`). Designed to be
//! `jq`-friendly without escaping the table column names.
//!
//! Mode is set globally by the top-level `--output` flag and threaded
//! through every command. Single-row commands (`whoami`, `apps show`)
//! use `KvBlock`; everything plural uses `Table`.
use std::io::{self, Write};
use clap::ValueEnum;
use serde_json::{Map, Value};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
#[clap(rename_all = "lowercase")]
pub enum OutputMode {
#[default]
Tsv,
Json,
}
// ----------------------------------------------------------------------------
// Table — list views (`apps ls`, `scripts ls`, `logs`)
// ----------------------------------------------------------------------------
pub struct Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
@@ -32,7 +55,7 @@ impl Table {
self
}
pub fn render(&self) -> String {
pub fn render_tsv(&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() {
@@ -52,8 +75,36 @@ impl Table {
out
}
pub fn print(&self) {
let s = self.render();
/// JSON form: `[{header: cell, …}, …]`. Cells go in as strings even
/// when they happen to look like numbers — the CLI doesn't carry
/// type information all the way through (e.g., `version` is already
/// `to_string`'d at the call site). Consumers that need typed
/// numbers should parse `jq -r '.[].version|tonumber'`.
pub fn render_json(&self) -> String {
let arr: Vec<Value> = self
.rows
.iter()
.map(|row| {
let mut obj = Map::new();
for (i, header) in self.headers.iter().enumerate() {
let cell = row.get(i).cloned().unwrap_or_default();
obj.insert(header.clone(), Value::String(cell));
}
Value::Object(obj)
})
.collect();
serde_json::to_string_pretty(&Value::Array(arr)).unwrap_or_else(|_| "[]".to_string())
}
pub fn print(&self, mode: OutputMode) {
let s = match mode {
OutputMode::Tsv => self.render_tsv(),
OutputMode::Json => {
let mut s = self.render_json();
s.push('\n');
s
}
};
// Best-effort write — broken pipe from `| head` etc. shouldn't
// surface as an error.
let _ = io::stdout().write_all(s.as_bytes());
@@ -78,26 +129,124 @@ fn write_row(out: &mut String, row: &[String], widths: &[usize]) {
out.push('\n');
}
// ----------------------------------------------------------------------------
// KvBlock — single-row views (`whoami`, `apps show`)
// ----------------------------------------------------------------------------
/// One row's worth of fields, rendered as aligned `key: value` lines in
/// TSV mode (one line per field — easier on the eye than a 1-row table)
/// or a flat JSON object.
pub struct KvBlock {
fields: Vec<(String, String)>,
}
impl KvBlock {
pub fn new() -> Self {
Self { fields: Vec::new() }
}
pub fn field(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
self.fields.push((key.into(), value.into()));
self
}
pub fn render_tsv(&self) -> String {
let key_width = self.fields.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
let mut out = String::new();
for (k, v) in &self.fields {
out.push_str(k);
for _ in k.len()..key_width {
out.push(' ');
}
out.push('\t');
out.push_str(v);
out.push('\n');
}
out
}
pub fn render_json(&self) -> String {
let mut obj = Map::new();
for (k, v) in &self.fields {
obj.insert(k.clone(), Value::String(v.clone()));
}
serde_json::to_string_pretty(&Value::Object(obj)).unwrap_or_else(|_| "{}".to_string())
}
pub fn print(&self, mode: OutputMode) {
let s = match mode {
OutputMode::Tsv => self.render_tsv(),
OutputMode::Json => {
let mut s = self.render_json();
s.push('\n');
s
}
};
let _ = io::stdout().write_all(s.as_bytes());
}
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
pub fn print_error(err: &anyhow::Error) {
let mut stderr = io::stderr();
let _ = writeln!(stderr, "error: {err:#}");
}
// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_aligns_columns() {
fn table_aligns_columns_tsv() {
let mut t = Table::new(["slug", "name"]);
t.row(["a", "Alpha"]).row(["bravo", "B"]);
let out = t.render();
let out = t.render_tsv();
assert_eq!(out, "slug \tname\na \tAlpha\nbravo\tB\n");
}
#[test]
fn table_empty_rows() {
fn table_empty_rows_tsv() {
let t = Table::new(["a", "b"]);
assert_eq!(t.render(), "a\tb\n");
assert_eq!(t.render_tsv(), "a\tb\n");
}
#[test]
fn table_render_json_is_array_of_objects() {
let mut t = Table::new(["slug", "name"]);
t.row(["a", "Alpha"]).row(["bravo", "B"]);
let raw = t.render_json();
let v: Value = serde_json::from_str(&raw).expect("valid JSON");
let arr = v.as_array().expect("array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["slug"], "a");
assert_eq!(arr[0]["name"], "Alpha");
assert_eq!(arr[1]["slug"], "bravo");
assert_eq!(arr[1]["name"], "B");
}
#[test]
fn kv_block_tsv_aligns_keys() {
let mut b = KvBlock::new();
b.field("username", "admin").field("role", "owner");
let out = b.render_tsv();
// username (8 chars) defines the key width.
assert_eq!(out, "username\tadmin\nrole \towner\n");
}
#[test]
fn kv_block_json_is_flat_object() {
let mut b = KvBlock::new();
b.field("username", "admin").field("role", "owner");
let raw = b.render_json();
let v: Value = serde_json::from_str(&raw).expect("valid JSON");
assert_eq!(v["username"], "admin");
assert_eq!(v["role"], "owner");
}
}