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 = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"directories",
|
"directories",
|
||||||
"libc",
|
"libc",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ reqwest = { workspace = true, features = ["json"] }
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||||
|
chrono = { workspace = true }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
directories = "5"
|
directories = "5"
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
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 reqwest::{header, Method, RequestBuilder, StatusCode};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
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 {
|
pub fn url(&self) -> &str {
|
||||||
&self.url
|
&self.url
|
||||||
}
|
}
|
||||||
@@ -97,6 +101,42 @@ impl Client {
|
|||||||
decode(resp).await
|
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`
|
/// `POST /api/v1/admin/scripts`
|
||||||
pub async fn scripts_create(&self, body: &CreateScriptBody<'_>) -> Result<Script> {
|
pub async fn scripts_create(&self, body: &CreateScriptBody<'_>) -> Result<Script> {
|
||||||
let resp = self
|
let resp = self
|
||||||
@@ -167,6 +207,68 @@ impl Client {
|
|||||||
.await?;
|
.await?;
|
||||||
decode(resp).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) ----------
|
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||||
@@ -216,6 +318,63 @@ struct UpdateScriptBody<'a> {
|
|||||||
source: &'a str,
|
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)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ExecuteResponse {
|
pub struct ExecuteResponse {
|
||||||
@@ -265,6 +424,15 @@ async fn decode<T: for<'de> Deserialize<'de>>(resp: reqwest::Response) -> Result
|
|||||||
Err(server_error(resp).await)
|
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 {
|
async fn server_error(resp: reqwest::Response) -> anyhow::Error {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body = resp.text().await.unwrap_or_default();
|
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 anyhow::Result;
|
||||||
|
use picloud_shared::AppRole;
|
||||||
|
|
||||||
use crate::client::{Client, CreateAppBody};
|
use crate::client::{Client, CreateAppBody};
|
||||||
use crate::config::load;
|
use crate::config;
|
||||||
use crate::output::Table;
|
use crate::output::{KvBlock, OutputMode, Table};
|
||||||
|
|
||||||
pub async fn ls() -> Result<()> {
|
pub async fn ls(mode: OutputMode) -> Result<()> {
|
||||||
let creds = load()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
let apps = client.apps_list().await?;
|
let apps = client.apps_list().await?;
|
||||||
let mut table = Table::new(["slug", "name", "my_role", "created_at"]);
|
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(),
|
app.created_at.to_rfc3339(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
table.print();
|
table.print(mode);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
|
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 client = Client::from_creds(&creds)?;
|
||||||
let body = CreateAppBody {
|
let body = CreateAppBody {
|
||||||
slug,
|
slug,
|
||||||
@@ -38,3 +39,46 @@ pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -
|
|||||||
println!("Created app {}", app.slug);
|
println!("Created app {}", app.slug);
|
||||||
Ok(())
|
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
|
//! `pic login` — primary auth entry point.
|
||||||
//! shortcut for non-interactive contexts like CI and integration tests)
|
//!
|
||||||
//! capture the URL + bearer token, validate against `/auth/me`, save.
|
//! 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 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};
|
use crate::config::{save, Credentials};
|
||||||
|
|
||||||
const DEFAULT_URL: &str = "http://localhost:8000";
|
const DEFAULT_URL: &str = "http://localhost:8000";
|
||||||
|
|
||||||
pub async fn run() -> Result<()> {
|
pub async fn run(url_arg: Option<&str>, token_arg: Option<&str>) -> Result<()> {
|
||||||
let (url, token) = collect_credentials()?;
|
let url = resolve_url(url_arg)?;
|
||||||
let client = Client::new(&url, &token)?;
|
let token_from_env = std::env::var("PICLOUD_TOKEN")
|
||||||
let me = client.auth_me().await?;
|
.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 {
|
let creds = Credentials {
|
||||||
url: client.url().to_string(),
|
url: url.clone(),
|
||||||
token,
|
token,
|
||||||
username: me.username.clone(),
|
username: username.clone(),
|
||||||
};
|
};
|
||||||
save(&creds)?;
|
save(&creds)?;
|
||||||
println!(
|
println!(
|
||||||
"Logged in as {} ({}) at {}",
|
"Logged in as {username} ({}) at {url}",
|
||||||
me.username,
|
instance_role_label(&role)
|
||||||
instance_role_label(&me.instance_role),
|
|
||||||
creds.url
|
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_credentials() -> Result<(String, String)> {
|
async fn login_with_password(url: &str) -> Result<(String, String, InstanceRole)> {
|
||||||
// Non-interactive shortcut: both vars set → use as-is. Used by the
|
let username = prompt_line("Username: ")?;
|
||||||
// integration test and any CI flow that wants to skip the prompts.
|
if username.is_empty() {
|
||||||
if let (Ok(url), Ok(tok)) = (std::env::var("PICLOUD_URL"), std::env::var("PICLOUD_TOKEN")) {
|
anyhow::bail!("username is required");
|
||||||
if !url.is_empty() && !tok.is_empty() {
|
}
|
||||||
return Ok((url, tok));
|
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> {
|
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()
|
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.
|
//! `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 anyhow::Result;
|
||||||
use picloud_shared::ExecutionStatus;
|
use picloud_shared::{ExecutionLog, ExecutionStatus};
|
||||||
|
|
||||||
use crate::client::Client;
|
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<()> {
|
pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||||
let creds = load()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
let entries = client.logs_list(script_id, limit).await?;
|
let entries = client.logs_list(script_id, limit).await?;
|
||||||
for e in entries {
|
match mode {
|
||||||
let summary = summarize(&e.response_body, &e.script_logs);
|
OutputMode::Tsv => render_tsv(&entries),
|
||||||
println!(
|
OutputMode::Json => render_json(&entries),
|
||||||
"{}\t{}\t{}",
|
|
||||||
e.created_at.to_rfc3339(),
|
|
||||||
status_label(&e.status),
|
|
||||||
truncate(&summary, 120),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Ok(())
|
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 {
|
fn status_label(s: &ExecutionStatus) -> &'static str {
|
||||||
match s {
|
match s {
|
||||||
ExecutionStatus::Success => "success",
|
ExecutionStatus::Success => "success",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
pub mod api_keys;
|
||||||
pub mod apps;
|
pub mod apps;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
|
pub mod logout;
|
||||||
pub mod logs;
|
pub mod logs;
|
||||||
pub mod scripts;
|
pub mod scripts;
|
||||||
pub mod whoami;
|
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::io::{self, Read, Write};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use picloud_shared::AppId;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::client::{Client, CreateScriptBody};
|
use crate::client::{Client, CreateScriptBody};
|
||||||
use crate::config::load;
|
use crate::config;
|
||||||
use crate::output::Table;
|
use crate::output::{OutputMode, Table};
|
||||||
|
|
||||||
pub async fn ls(app: Option<&str>) -> Result<()> {
|
pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||||
let creds = load()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
|
|
||||||
let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]);
|
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 {
|
} else {
|
||||||
// No filter → walk every accessible app. One request per app is
|
// No filter → use the single `GET /admin/scripts` call. Server
|
||||||
// fine at MVP scale (handful of apps); a bulk endpoint can come
|
// filters by membership for `Member`; for `Admin`/`Owner` it
|
||||||
// later if the count grows.
|
// returns every script. Two requests total (apps + scripts) run
|
||||||
let apps = client.apps_list().await?;
|
// in parallel; the per-app walk we used to do here aborted on
|
||||||
for a in apps {
|
// the first 404 when another caller deleted an app mid-listing,
|
||||||
let scripts = client.scripts_list_by_app(&a.slug).await?;
|
// 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 {
|
for s in scripts {
|
||||||
|
let app_slug = slug_by_id
|
||||||
|
.get(&s.app_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
table.row([
|
table.row([
|
||||||
s.id.to_string(),
|
s.id.to_string(),
|
||||||
a.slug.clone(),
|
app_slug,
|
||||||
s.name,
|
s.name,
|
||||||
s.version.to_string(),
|
s.version.to_string(),
|
||||||
s.updated_at.to_rfc3339(),
|
s.updated_at.to_rfc3339(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
table.print(mode);
|
||||||
table.print();
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +63,7 @@ pub async fn deploy(
|
|||||||
name_override: Option<&str>,
|
name_override: Option<&str>,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let creds = load()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
|
|
||||||
let source =
|
let source =
|
||||||
@@ -99,7 +106,7 @@ pub async fn deploy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn invoke(id: &str, body_arg: Option<&str>, headers: &[(String, String)]) -> Result<()> {
|
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 client = Client::from_creds(&creds)?;
|
||||||
|
|
||||||
let body = parse_body_arg(body_arg)?;
|
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> {
|
fn parse_body_arg(arg: Option<&str>) -> Result<Value> {
|
||||||
match arg {
|
match arg {
|
||||||
None => Ok(Value::Object(serde_json::Map::new())),
|
None => Ok(Value::Object(serde_json::Map::new())),
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
//! `pic whoami` — re-validates the saved token by hitting `/auth/me`
|
//! `pic whoami` — re-validates the saved token by hitting `/auth/me`
|
||||||
//! every time. Cached username in the credentials file is for
|
//! every time. Cached username in the credentials file is for
|
||||||
//! display-only contexts; this command is the source of truth.
|
//! 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 anyhow::Result;
|
||||||
|
use picloud_shared::InstanceRole;
|
||||||
|
|
||||||
use crate::client::Client;
|
use crate::client::Client;
|
||||||
use crate::config::load;
|
use crate::config;
|
||||||
|
use crate::output::{KvBlock, OutputMode};
|
||||||
|
|
||||||
pub async fn run() -> Result<()> {
|
pub async fn run(mode: OutputMode) -> Result<()> {
|
||||||
let creds = load()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
let me = client.auth_me().await?;
|
let me = client.auth_me().await?;
|
||||||
let role = match me.instance_role {
|
let role = match me.instance_role {
|
||||||
picloud_shared::InstanceRole::Owner => "owner",
|
InstanceRole::Owner => "owner",
|
||||||
picloud_shared::InstanceRole::Admin => "admin",
|
InstanceRole::Admin => "admin",
|
||||||
picloud_shared::InstanceRole::Member => "member",
|
InstanceRole::Member => "member",
|
||||||
};
|
};
|
||||||
let email = me.email.as_deref().unwrap_or("-");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,41 @@ pub fn load() -> Result<Credentials> {
|
|||||||
toml::from_str(&body).with_context(|| format!("failed to parse {}", path.display()))
|
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<()> {
|
pub fn save(creds: &Credentials) -> Result<()> {
|
||||||
let path = credentials_path()?;
|
let path = credentials_path()?;
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
|
|||||||
@@ -14,17 +14,31 @@ mod cmds;
|
|||||||
mod config;
|
mod config;
|
||||||
mod output;
|
mod output;
|
||||||
|
|
||||||
|
use crate::output::OutputMode;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "pic", version, about = "PiCloud command-line client")]
|
#[command(name = "pic", version, about = "PiCloud command-line client")]
|
||||||
struct Cli {
|
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)]
|
#[command(subcommand)]
|
||||||
cmd: Cmd,
|
cmd: Cmd,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Cmd {
|
enum Cmd {
|
||||||
/// Save URL + bearer token to `~/.picloud/credentials`.
|
/// Authenticate with the server. Default flow prompts for username
|
||||||
Login,
|
/// + 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.
|
/// Print the principal the saved token resolves to.
|
||||||
Whoami,
|
Whoami,
|
||||||
@@ -41,8 +55,35 @@ enum Cmd {
|
|||||||
cmd: ScriptsCmd,
|
cmd: ScriptsCmd,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Long-lived bearer API key management.
|
||||||
|
#[command(name = "api-keys")]
|
||||||
|
ApiKeys {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: ApiKeysCmd,
|
||||||
|
},
|
||||||
|
|
||||||
/// Tail recent execution logs for a script.
|
/// Tail recent execution logs for a script.
|
||||||
Logs(LogsArgs),
|
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)]
|
#[derive(Subcommand)]
|
||||||
@@ -58,12 +99,23 @@ enum AppsCmd {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
description: Option<String>,
|
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)]
|
#[derive(Subcommand)]
|
||||||
enum ScriptsCmd {
|
enum ScriptsCmd {
|
||||||
/// List scripts. With `--app`, scoped to one app; without,
|
/// List scripts. With `--app`, scoped to one app; without, one
|
||||||
/// iterates over every app the caller can see.
|
/// `GET /admin/scripts` for everything the caller can see.
|
||||||
Ls {
|
Ls {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
app: Option<String>,
|
app: Option<String>,
|
||||||
@@ -71,7 +123,18 @@ enum ScriptsCmd {
|
|||||||
|
|
||||||
/// Upload a `.rhai` file. Patches the existing script with the
|
/// Upload a `.rhai` file. Patches the existing script with the
|
||||||
/// matching name in `--app` if one exists, otherwise creates it.
|
/// matching name in `--app` if one exists, otherwise creates it.
|
||||||
Deploy {
|
Deploy(DeployArgs),
|
||||||
|
|
||||||
|
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
|
||||||
|
/// `--body @-` for stdin, or inline JSON.
|
||||||
|
Invoke(InvokeArgs),
|
||||||
|
|
||||||
|
/// Delete a script. Requires AppAdmin on the owning app.
|
||||||
|
Delete { id: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct DeployArgs {
|
||||||
file: PathBuf,
|
file: PathBuf,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
app: String,
|
app: String,
|
||||||
@@ -79,17 +142,40 @@ enum ScriptsCmd {
|
|||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
},
|
}
|
||||||
|
|
||||||
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
|
#[derive(Args)]
|
||||||
/// `--body @-` for stdin, or inline JSON.
|
struct InvokeArgs {
|
||||||
Invoke {
|
|
||||||
id: String,
|
id: String,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
body: Option<String>,
|
body: Option<String>,
|
||||||
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)]
|
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)]
|
||||||
headers: Vec<(String, String)>,
|
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)]
|
||||||
|
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)]
|
#[derive(Args)]
|
||||||
@@ -102,10 +188,12 @@ struct LogsArgs {
|
|||||||
#[tokio::main(flavor = "current_thread")]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
async fn main() -> ExitCode {
|
async fn main() -> ExitCode {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
let mode = cli.output;
|
||||||
let result = match cli.cmd {
|
let result = match cli.cmd {
|
||||||
Cmd::Login => cmds::login::run().await,
|
Cmd::Login(args) => cmds::login::run(args.url.as_deref(), args.token.as_deref()).await,
|
||||||
Cmd::Whoami => cmds::whoami::run().await,
|
Cmd::Logout => cmds::logout::run().await,
|
||||||
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls().await,
|
Cmd::Whoami => cmds::whoami::run(mode).await,
|
||||||
|
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
|
||||||
Cmd::Apps {
|
Cmd::Apps {
|
||||||
cmd:
|
cmd:
|
||||||
AppsCmd::Create {
|
AppsCmd::Create {
|
||||||
@@ -114,22 +202,60 @@ async fn main() -> ExitCode {
|
|||||||
description,
|
description,
|
||||||
},
|
},
|
||||||
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref()).await,
|
} => 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::Scripts {
|
||||||
cmd: ScriptsCmd::Ls { app },
|
cmd: ScriptsCmd::Ls { app },
|
||||||
} => cmds::scripts::ls(app.as_deref()).await,
|
} => cmds::scripts::ls(app.as_deref(), mode).await,
|
||||||
Cmd::Scripts {
|
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:
|
cmd:
|
||||||
ScriptsCmd::Deploy {
|
ApiKeysCmd::Mint {
|
||||||
file,
|
|
||||||
app,
|
|
||||||
name,
|
name,
|
||||||
description,
|
scopes,
|
||||||
|
app,
|
||||||
|
expires,
|
||||||
},
|
},
|
||||||
} => cmds::scripts::deploy(&file, &app, name.as_deref(), description.as_deref()).await,
|
} => cmds::api_keys::mint(&name, &scopes, app.as_deref(), expires.as_deref(), mode).await,
|
||||||
Cmd::Scripts {
|
Cmd::ApiKeys {
|
||||||
cmd: ScriptsCmd::Invoke { id, body, headers },
|
cmd: ApiKeysCmd::Ls,
|
||||||
} => cmds::scripts::invoke(&id, body.as_deref(), &headers).await,
|
} => cmds::api_keys::ls(mode).await,
|
||||||
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit).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 {
|
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
|
//! Two formats:
|
||||||
//! pipe-friendly: `pic apps ls | awk -F'\t' '{print $1}'` works without
|
//! * **TSV** (default): aligned columns separated by `\t`. Stays
|
||||||
//! parsing box-drawing.
|
//! 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 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 {
|
pub struct Table {
|
||||||
headers: Vec<String>,
|
headers: Vec<String>,
|
||||||
rows: Vec<Vec<String>>,
|
rows: Vec<Vec<String>>,
|
||||||
@@ -32,7 +55,7 @@ impl Table {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render(&self) -> String {
|
pub fn render_tsv(&self) -> String {
|
||||||
let mut widths: Vec<usize> = self.headers.iter().map(String::len).collect();
|
let mut widths: Vec<usize> = self.headers.iter().map(String::len).collect();
|
||||||
for row in &self.rows {
|
for row in &self.rows {
|
||||||
for (i, cell) in row.iter().enumerate() {
|
for (i, cell) in row.iter().enumerate() {
|
||||||
@@ -52,8 +75,36 @@ impl Table {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn print(&self) {
|
/// JSON form: `[{header: cell, …}, …]`. Cells go in as strings even
|
||||||
let s = self.render();
|
/// 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
|
// Best-effort write — broken pipe from `| head` etc. shouldn't
|
||||||
// surface as an error.
|
// surface as an error.
|
||||||
let _ = io::stdout().write_all(s.as_bytes());
|
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');
|
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) {
|
pub fn print_error(err: &anyhow::Error) {
|
||||||
let mut stderr = io::stderr();
|
let mut stderr = io::stderr();
|
||||||
let _ = writeln!(stderr, "error: {err:#}");
|
let _ = writeln!(stderr, "error: {err:#}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn table_aligns_columns() {
|
fn table_aligns_columns_tsv() {
|
||||||
let mut t = Table::new(["slug", "name"]);
|
let mut t = Table::new(["slug", "name"]);
|
||||||
t.row(["a", "Alpha"]).row(["bravo", "B"]);
|
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");
|
assert_eq!(out, "slug \tname\na \tAlpha\nbravo\tB\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn table_empty_rows() {
|
fn table_empty_rows_tsv() {
|
||||||
let t = Table::new(["a", "b"]);
|
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