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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1535,6 +1535,7 @@ version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
"chrono",
|
||||
"clap",
|
||||
"directories",
|
||||
"libc",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
201
crates/picloud-cli/src/cmds/api_keys.rs
Normal file
201
crates/picloud-cli/src/cmds/api_keys.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
29
crates/picloud-cli/src/cmds/logout.rs
Normal file
29
crates/picloud-cli/src/cmds/logout.rs
Normal 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(())
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user