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

Address the review findings on the CLI surface:

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

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

View File

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