From 7b500477306faf6b81fe67cb682e154635b87aab Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 28 May 2026 20:53:49 +0200 Subject: [PATCH 1/3] feat(cli): add pic command-line client (login, apps, scripts, logs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- Cargo.lock | 351 +++++++++++++++++++++++++ Cargo.toml | 1 + crates/picloud-cli/Cargo.toml | 31 +++ crates/picloud-cli/src/client.rs | 333 +++++++++++++++++++++++ crates/picloud-cli/src/cmds/apps.rs | 40 +++ crates/picloud-cli/src/cmds/login.rs | 66 +++++ crates/picloud-cli/src/cmds/logs.rs | 58 ++++ crates/picloud-cli/src/cmds/mod.rs | 5 + crates/picloud-cli/src/cmds/scripts.rs | 178 +++++++++++++ crates/picloud-cli/src/cmds/whoami.rs | 22 ++ crates/picloud-cli/src/config.rs | 118 +++++++++ crates/picloud-cli/src/main.rs | 142 ++++++++++ crates/picloud-cli/src/output.rs | 103 ++++++++ 13 files changed, 1448 insertions(+) create mode 100644 crates/picloud-cli/Cargo.toml create mode 100644 crates/picloud-cli/src/client.rs create mode 100644 crates/picloud-cli/src/cmds/apps.rs create mode 100644 crates/picloud-cli/src/cmds/login.rs create mode 100644 crates/picloud-cli/src/cmds/logs.rs create mode 100644 crates/picloud-cli/src/cmds/mod.rs create mode 100644 crates/picloud-cli/src/cmds/scripts.rs create mode 100644 crates/picloud-cli/src/cmds/whoami.rs create mode 100644 crates/picloud-cli/src/config.rs create mode 100644 crates/picloud-cli/src/main.rs create mode 100644 crates/picloud-cli/src/output.rs diff --git a/Cargo.lock b/Cargo.lock index a33eac1..d579e41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,6 +40,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -68,6 +118,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -236,6 +301,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -302,6 +378,52 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -440,6 +562,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -452,6 +580,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -516,6 +665,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "figment" version = "0.10.19" @@ -536,6 +691,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "flume" version = "0.11.1" @@ -1010,6 +1174,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -1077,6 +1247,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1161,6 +1337,12 @@ dependencies = [ "spin 0.5.2", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1231,6 +1413,18 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking" version = "2.2.1" @@ -1335,6 +1529,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "picloud-cli" +version = "0.6.0" +dependencies = [ + "anyhow", + "assert_cmd", + "clap", + "directories", + "picloud-shared", + "predicates", + "reqwest", + "rpassword", + "serde", + "serde_json", + "tempfile", + "tokio", + "toml", +] + [[package]] name = "picloud-executor" version = "0.6.0" @@ -1509,6 +1722,36 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -1704,6 +1947,29 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1729,7 +1995,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -1812,6 +2080,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835a57a69104632d64deb0df2e09a69945cd7a6eab4070fc9b1d7e50cf6c3edc" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + [[package]] name = "rsa" version = "0.9.10" @@ -1832,6 +2111,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rust-multipart-rfc7578_2" version = "0.8.0" @@ -1853,6 +2142,19 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.40" @@ -2327,6 +2629,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -2364,6 +2672,25 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thin-vec" version = "0.2.18" @@ -2783,6 +3110,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.1" @@ -2813,6 +3146,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "want" version = "0.3.1" @@ -3066,6 +3408,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index d02b263..d2f1056 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/picloud-manager", "crates/picloud-orchestrator", "crates/picloud-executor", + "crates/picloud-cli", ] [workspace.package] diff --git a/crates/picloud-cli/Cargo.toml b/crates/picloud-cli/Cargo.toml new file mode 100644 index 0000000..cef4c75 --- /dev/null +++ b/crates/picloud-cli/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "picloud-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "PiCloud command-line client" + +[[bin]] +name = "pic" +path = "src/main.rs" + +[dependencies] +picloud-shared.workspace = true +reqwest = { workspace = true, features = ["json"] } +serde.workspace = true +serde_json.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +clap = { version = "4", features = ["derive"] } +toml = "0.8" +directories = "5" +rpassword = "7" +anyhow = "1" + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +tempfile = "3" +reqwest = { workspace = true, features = ["json", "blocking"] } diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs new file mode 100644 index 0000000..da24696 --- /dev/null +++ b/crates/picloud-cli/src/client.rs @@ -0,0 +1,333 @@ +//! Reqwest-backed HTTP client + minimal wire DTOs. +//! +//! The CLI deliberately re-declares small request/response structs here +//! rather than depending on `manager-core` (and pulling its Postgres +//! transitive surface). Fields kept to what the CLI actually sends or +//! reads. + +use std::collections::BTreeMap; + +use anyhow::{anyhow, Context, Result}; +use picloud_shared::{App, AppId, AppRole, ExecutionLog, InstanceRole, Script}; +use reqwest::{header, Method, RequestBuilder, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::config::Credentials; + +pub struct Client { + http: reqwest::Client, + url: String, + token: String, +} + +impl Client { + pub fn from_creds(creds: &Credentials) -> Result { + Self::new(&creds.url, &creds.token) + } + + pub fn new(url: &str, token: &str) -> Result { + let http = reqwest::Client::builder() + .user_agent(concat!("pic/", env!("CARGO_PKG_VERSION"))) + .build() + .context("building HTTP client")?; + Ok(Self { + http, + url: url.trim_end_matches('/').to_string(), + token: token.to_string(), + }) + } + + pub fn url(&self) -> &str { + &self.url + } + + fn request(&self, method: Method, path: &str) -> RequestBuilder { + self.http + .request(method, format!("{}{path}", self.url)) + .header(header::AUTHORIZATION, format!("Bearer {}", self.token)) + } + + /// `GET /api/v1/admin/auth/me` + pub async fn auth_me(&self) -> Result { + let resp = self + .request(Method::GET, "/api/v1/admin/auth/me") + .send() + .await?; + decode(resp).await + } + + /// `GET /api/v1/admin/apps` + pub async fn apps_list(&self) -> Result> { + let resp = self + .request(Method::GET, "/api/v1/admin/apps") + .send() + .await?; + decode(resp).await + } + + /// `GET /api/v1/admin/apps/{id_or_slug}` — slug or UUID accepted. + pub async fn apps_get(&self, ident: &str) -> Result { + let resp = self + .request(Method::GET, &format!("/api/v1/admin/apps/{ident}")) + .send() + .await?; + decode(resp).await + } + + /// `POST /api/v1/admin/apps` + pub async fn apps_create(&self, body: &CreateAppBody<'_>) -> Result { + let resp = self + .request(Method::POST, "/api/v1/admin/apps") + .json(body) + .send() + .await?; + decode(resp).await + } + + /// `GET /api/v1/admin/scripts?app={ident}` + pub async fn scripts_list_by_app(&self, ident: &str) -> Result> { + let resp = self + .request( + Method::GET, + &format!("/api/v1/admin/scripts?app={}", urlencoded(ident)), + ) + .send() + .await?; + decode(resp).await + } + + /// `POST /api/v1/admin/scripts` + pub async fn scripts_create(&self, body: &CreateScriptBody<'_>) -> Result