feat(cli): add pic command-line client (login, apps, scripts, logs)
Adds a new workspace crate `picloud-cli` shipping a `pic` binary that drives the edit-deploy-invoke-tail-logs loop against PiCloud's admin and execute HTTP surface. Eight subcommands cover the minimum a developer needs to never open the dashboard: pic login (paste URL + bearer token, validates via /auth/me) pic whoami (re-validates and prints principal) pic apps ls | create pic scripts ls | deploy | invoke pic logs <id> Credentials persist as TOML under the platform config dir (resolved via `directories`); on POSIX the file is forced to mode 0600. PICLOUD_URL + PICLOUD_TOKEN env vars short-circuit interactive prompts for CI and integration tests. The CLI redeclares minimal request/response structs in `client.rs` rather than depending on `manager-core` — keeps the blast radius contained without touching the existing crate boundaries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
103
crates/picloud-cli/src/output.rs
Normal file
103
crates/picloud-cli/src/output.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
//! Tab-separated table writer + error formatting.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
||||
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(&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
|
||||
}
|
||||
|
||||
pub fn print(&self) {
|
||||
let s = self.render();
|
||||
// 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');
|
||||
}
|
||||
|
||||
pub fn print_error(err: &anyhow::Error) {
|
||||
let mut stderr = io::stderr();
|
||||
let _ = writeln!(stderr, "error: {err:#}");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_aligns_columns() {
|
||||
let mut t = Table::new(["slug", "name"]);
|
||||
t.row(["a", "Alpha"]).row(["bravo", "B"]);
|
||||
let out = t.render();
|
||||
assert_eq!(out, "slug \tname\na \tAlpha\nbravo\tB\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_empty_rows() {
|
||||
let t = Table::new(["a", "b"]);
|
||||
assert_eq!(t.render(), "a\tb\n");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user