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>
253 lines
7.9 KiB
Rust
253 lines
7.9 KiB
Rust
//! Output rendering for the CLI.
|
|
//!
|
|
//! 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>>,
|
|
}
|
|
|
|
impl Table {
|
|
pub fn new<I, S>(headers: I) -> Self
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: Into<String>,
|
|
{
|
|
Self {
|
|
headers: headers.into_iter().map(Into::into).collect(),
|
|
rows: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn row<I, S>(&mut self, cells: I) -> &mut Self
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: Into<String>,
|
|
{
|
|
self.rows.push(cells.into_iter().map(Into::into).collect());
|
|
self
|
|
}
|
|
|
|
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() {
|
|
if i >= widths.len() {
|
|
widths.push(cell.len());
|
|
} else if cell.len() > widths[i] {
|
|
widths[i] = cell.len();
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut out = String::new();
|
|
write_row(&mut out, &self.headers, &widths);
|
|
for row in &self.rows {
|
|
write_row(&mut out, row, &widths);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
}
|
|
|
|
fn write_row(out: &mut String, row: &[String], widths: &[usize]) {
|
|
for (i, cell) in row.iter().enumerate() {
|
|
if i > 0 {
|
|
out.push('\t');
|
|
}
|
|
out.push_str(cell);
|
|
// Right-pad with spaces so tabs land on the column grid for
|
|
// human readers. Skip on the final column.
|
|
if i + 1 < row.len() {
|
|
let w = widths.get(i).copied().unwrap_or(cell.len());
|
|
for _ in cell.len()..w {
|
|
out.push(' ');
|
|
}
|
|
}
|
|
}
|
|
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_tsv() {
|
|
let mut t = Table::new(["slug", "name"]);
|
|
t.row(["a", "Alpha"]).row(["bravo", "B"]);
|
|
let out = t.render_tsv();
|
|
assert_eq!(out, "slug \tname\na \tAlpha\nbravo\tB\n");
|
|
}
|
|
|
|
#[test]
|
|
fn table_empty_rows_tsv() {
|
|
let t = Table::new(["a", "b"]);
|
|
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");
|
|
}
|
|
}
|