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