From f147665157e268fb930cfe39579caa6763b423bf Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 29 May 2026 23:33:44 +0200 Subject: [PATCH] feat(cli): real auth, delete commands, api-keys, JSON output, env override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Cargo.lock | 1 + crates/picloud-cli/Cargo.toml | 1 + crates/picloud-cli/src/client.rs | 170 +++++++++++++++++++- crates/picloud-cli/src/cmds/api_keys.rs | 201 ++++++++++++++++++++++++ crates/picloud-cli/src/cmds/apps.rs | 58 ++++++- crates/picloud-cli/src/cmds/login.rs | 129 +++++++++++---- crates/picloud-cli/src/cmds/logout.rs | 29 ++++ crates/picloud-cli/src/cmds/logs.rs | 45 ++++-- crates/picloud-cli/src/cmds/mod.rs | 2 + crates/picloud-cli/src/cmds/scripts.rs | 65 +++++--- crates/picloud-cli/src/cmds/whoami.rs | 26 ++- crates/picloud-cli/src/config.rs | 35 +++++ crates/picloud-cli/src/main.rs | 188 ++++++++++++++++++---- crates/picloud-cli/src/output.rs | 171 ++++++++++++++++++-- 14 files changed, 996 insertions(+), 125 deletions(-) create mode 100644 crates/picloud-cli/src/cmds/api_keys.rs create mode 100644 crates/picloud-cli/src/cmds/logout.rs diff --git a/Cargo.lock b/Cargo.lock index 31d3aac..bb8a9c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1535,6 +1535,7 @@ version = "0.6.0" dependencies = [ "anyhow", "assert_cmd", + "chrono", "clap", "directories", "libc", diff --git a/crates/picloud-cli/Cargo.toml b/crates/picloud-cli/Cargo.toml index 10f0757..85ad27b 100644 --- a/crates/picloud-cli/Cargo.toml +++ b/crates/picloud-cli/Cargo.toml @@ -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" diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index da24696..ad6938b 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -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> { + 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