From 04a24ea0b70b0b99338a7aa7af61bf75ac86cb3d Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 12 Jun 2026 20:43:03 +0200 Subject: [PATCH] feat(cli): close E2E To-Do CLI gaps (B1, B2, F2, F1, F3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CLI-only walkthrough (E2E_TODO_REPORT.md) found two control-plane operations with no `pic` command — every CLI-created app 404'd until it claimed a domain, and external SSE feeds needed a raw topic-registration call — plus several friction points. All admin APIs already existed; this adds thin wrappers mirroring `routes`/`triggers`: - B1: `pic apps domains {ls,add,rm}` over apps/{id}/domains. - B2: `pic topics {ls,create,update,rm}` over apps/{id}/topics, and a triggers help note distinguishing a pubsub trigger from topic registration. - F2: `pic users {ls,show,reset-password,revoke-sessions}` over the app end-user admin surface (read + the two admin actions; create/invitations deferred). - F1: `apps create` and `deploy` now honor `--output json`, emitting the created object so scripts can capture the id. - F3: non-interactive login via `--username` + `--password-stdin` (inline passwords still rejected, mirroring the `--token` rule). Co-Authored-By: Claude Opus 4.8 (1M context) --- E2E_TODO_REPORT.md | 195 +++++++++++++++++ crates/picloud-cli/src/client.rs | 204 +++++++++++++++++- crates/picloud-cli/src/cmds/apps.rs | 21 +- crates/picloud-cli/src/cmds/apps_domains.rs | 62 ++++++ crates/picloud-cli/src/cmds/login.rs | 55 ++++- crates/picloud-cli/src/cmds/mod.rs | 3 + crates/picloud-cli/src/cmds/scripts.rs | 22 +- crates/picloud-cli/src/cmds/topics.rs | 85 ++++++++ crates/picloud-cli/src/cmds/users.rs | 95 +++++++++ crates/picloud-cli/src/main.rs | 220 +++++++++++++++++++- 10 files changed, 941 insertions(+), 21 deletions(-) create mode 100644 E2E_TODO_REPORT.md create mode 100644 crates/picloud-cli/src/cmds/apps_domains.rs create mode 100644 crates/picloud-cli/src/cmds/topics.rs create mode 100644 crates/picloud-cli/src/cmds/users.rs diff --git a/E2E_TODO_REPORT.md b/E2E_TODO_REPORT.md new file mode 100644 index 0000000..8ac3233 --- /dev/null +++ b/E2E_TODO_REPORT.md @@ -0,0 +1,195 @@ +# E2E Developer Test — Rich To-Do App via the `pic` CLI + +**Date:** 2026-06-12 · **Tester:** manual end-to-end walkthrough · **Build:** v1.1.9 +(`product 1.1.9`, `sdk 1.10`, `api 1`, `schema 42`) · **Instance:** host-run `picloud` +in dev mode on `127.0.0.1:8099`, Postgres via `docker compose up postgres`. + +## Goal + +Act as a developer building a real product — a multi-user **To-Do app** (end-user +signup/login, per-user todos in document storage, full HTTP CRUD, a nightly cron cleanup, +and a realtime activity feed) — **entirely through the `pic` CLI**, and record every place +the happy path forced me off the CLI or behaved surprisingly. + +## Verdict + +**The platform can build and run the whole app — every capability exists and works.** The +data-plane journey (register → login → create → list → complete → ownership-checked delete → +auth rejection) is correct, `users::*` auth is solid, `docs::*` storage works, durable pubsub +fans out to an external SSE subscriber, and the cron trigger registers and runs. + +**But a CLI-only developer cannot reach the finish line.** Two control-plane operations have +**no `pic` command at all** and forced me to drop to raw `curl` against the admin API — and +without the first one, *every* app created through the CLI serves `404` on every route. + +--- + +## App as built + +| Surface | Implementation | +|---|---| +| `POST /auth/register` | `register.rhai` → `users::create` | +| `POST /auth/login` | `login.rhai` → `users::login` → session token | +| `GET /todos` | `todos_list.rhai` → `docs.find({user_id})` | +| `POST /todos` | `todos_create.rhai` → `docs.create` + `pubsub::publish_durable("activity", …)` | +| `PATCH /todos/:id` | `todos_update.rhai` → ownership check + complete + publish | +| `DELETE /todos/:id` | `todos_delete.rhai` → ownership check + delete | +| cron `0 0 3 * * *` | `cleanup.rhai` → purge completed todos older than 7d | +| realtime | SSE `GET /realtime/topics/activity` | + +7 scripts, 6 routes, 1 cron trigger, 1 app, 1 domain claim, 1 pubsub topic. Sources in +`/tmp/todo-app/*.rhai`. + +--- + +## BLOCKERS — no CLI path; app cannot serve traffic without raw API + +### B1. No domain-claim command — every CLI-created app 404s until you `curl` +**Severity: BLOCKER (highest-impact finding).** + +After `pic apps create`, deploying scripts, and binding routes, *every* request 404'd: +``` +$ curl -s -X POST localhost:8099/auth/register -d '{...}' +{"error":"no route matches POST /auth/register"} +``` +Reason: Host→app dispatch resolves `localhost` to the **default** app; a non-default app's +routes are unreachable until the app claims a domain. But `pic apps` exposes only +`ls / create / show / delete` — **no domain subcommand** — and there is no top-level +`pic domains` either. The only way through is the raw admin API: +``` +curl -X POST localhost:8099/api/v1/admin/apps/$APP/domains \ + -H "authorization: Bearer $TOKEN" -d '{"pattern":"todos.local"}' +``` +After that, routing works (with `Host: todos.local`). **A developer who only knows the CLI is +hard-stuck here** — they have created scripts and routes that silently never match, with no CLI +affordance explaining why. *Fix: add `pic apps domains {ls,add,rm}` (the API already exists in +`apps_api.rs`).* + +### B2. No pubsub topic-registration command — realtime feed needs raw API +**Severity: BLOCKER for the realtime requirement.** + +Scripts can `pubsub::publish_durable("activity", …)` fine (publishing needs no pre-registration), +but an **external** SSE subscriber on `/realtime/topics/activity` only receives events once the +topic is registered as `external_subscribable`. There is no `pic topics` command. Worse, the +`pic triggers` help text actively points you at `create-from-json` "for kinds the CLI doesn't +expose … (docs/files/pubsub/…)" — but that creates a *pubsub trigger* (run a script on publish), +which is a different concept and does **not** register a topic for outside subscribers. Workaround: +``` +curl -X POST localhost:8099/api/v1/admin/apps/$APP/topics \ + -H "authorization: Bearer $TOKEN" \ + -d '{"name":"activity","external_subscribable":true,"auth_mode":"public"}' +``` +Once registered, the SSE path works perfectly — a subscriber received: +``` +data: {"message":{"kind":"created","title":"Realtime test", …},"topic":"activity", …} +``` +*Fix: add `pic topics {ls,create,update,rm}` (API exists in `topics_api.rs`).* + +--- + +## FRICTION — completable via CLI, but sharp edges + +### F1. `pic deploy` / `apps create` ignore `--output json` +They print human strings even in JSON mode, so you can't capture the new id: +``` +$ pic --output json deploy register.rhai --app todos-e2e +Created register v1 # not JSON — no id emitted +$ pic --output json apps create todos-e2e +Created app todos-e2e # not JSON — no id emitted +``` +Every later `routes`/`triggers`/domain/topic call needs that id, so each create forces a +follow-up `scripts ls --output json | parse` or `apps show`. This breaks naive scripting/CI. +*Fix: emit the created object as JSON under `--output json`.* + +### F2. No `pic users` command for app end-users +The full `/api/v1/admin/apps/{id}/users` surface exists (list, get, reset-password, +revoke-sessions, invitations) but has no CLI wrapper, so a developer has zero CLI visibility +into who registered. Listing the two users I created required raw API. *Fix: `pic users …`.* + +### F3. Password login is interactive-only +`pic login` offers `--url` and `--token` but **no** `--username`/`--password`. Non-interactive +(CI) auth means obtaining a bearer out-of-band (raw `POST /auth/login`, or pre-minting an +`pic api-keys` token — chicken-and-egg if you have no token yet). *Fix: optional +`--username` + `--password-stdin`.* + +### F4. No `ctx.request.method` in scripts → one script per verb +The request map exposes `path, headers, body, params, query, rest` but **not the HTTP method** +(`engine.rs` builds the map without it). A script therefore cannot branch GET vs POST on the +same path, forcing a separate script + method-scoped route per verb. My app needed **7 scripts +where ~3 would do** (`/todos` GET+POST and `/todos/:id` PATCH+DELETE each had to split). +*Fix: add `ctx.request.method`.* + +--- + +## SDK / SCRIPTING SHARP EDGES — platform correct, but the natural code fails at runtime + +### S1. `users::find_by_email` is forbidden for anonymous (public) callers +The obvious registration pattern — "look up email, 409 if taken, else create" — fails: +``` +{"error":"Runtime error: users: forbidden"} # HTTP 502 +``` +`find_by_email` deliberately requires an authenticated principal (anti-enumeration, audit +finding F-S-003 in `users_service.rs`), while `users::create` is allowed for anonymous public +scripts. So a self-serve register script **must skip the pre-check** and rely on `create`'s +uniqueness error instead. The SDK doc-comment for `find_by_email` shows it with no caveat, so +this only surfaces as a runtime 502. *Fix: document the principal requirement on +`find_by_email`; consider a dedicated `users::email_available()` that's safe for anonymous use.* + +### S2. Response envelope is `statusCode`-gated (silent double-nesting) +A returned map is unwrapped as `{statusCode, headers, body}` **only if it contains +`statusCode`**. Without it, the *entire* map becomes the literal body — so `#{ body: #{token} }` +returns `{"body":{"token":…}}`, not `{"token":…}`. I hit this on login/list/update until I added +`statusCode: 200`. The doc-comment examples don't flag it. *Fix: doc note, or treat a lone +`body` key as an envelope.* + +### S3. Rhai `String.replace()` mutates in place and returns `()` +`let token = auth.replace("Bearer ", "")` sets `token` to unit, then `users::verify(())` → +`Function not found: users::verify (())`. Stock Rhai semantics, but a JS/Python dev will hit it; +`auth.sub_string(7)` after `starts_with("Bearer ")` is the correct idiom. *Fix: a one-line note ++ a bearer-parsing example in the stdlib reference.* + +--- + +## ONBOARDING + +### O1. Dev mode needs a second, undocumented acknowledgement var +`PICLOUD_DEV_MODE=true` alone aborts at startup: +``` +Error: PICLOUD_DEV_MODE=true without PICLOUD_SECRET_KEY requires an explicit acknowledgement. +Set PICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure to confirm … +``` +CLAUDE.md and the dev notes mention only `PICLOUD_DEV_MODE=true`. Good security default, but the +error message is the sole documentation. *Fix: document `PICLOUD_DEV_INSECURE_KEY` next to +`PICLOUD_DEV_MODE`.* + +--- + +## What worked well (no changes needed) + +- End-to-end data plane fully correct: register, login, per-user list, create, complete, delete, + **ownership 403** (Bob can't touch Alice's todo), **401** on missing/garbage token. +- `users::*`: login returns a 43-char session token, `verify` resolves it (sliding TTL), + password hashing + email uniqueness enforced. +- `docs::collection(...).find({field})` filtered correctly; full-blob `update` semantics as + documented. +- `pubsub::publish_durable` → external SSE delivery worked once the topic was registered. +- Cron trigger registered; `pic invoke ` ran it → `{"removed":0}`. +- `pic deploy` **updates in place** (v1→v2, same id) — no duplicate scripts on redeploy. +- `pic routes match` resolved param routes and captured `param.id`; empty param segment + (`/todos/`) correctly did **not** match. +- `pic logs ` listed executions with success/error status. + +## Reproduction + +Server: `docker compose up -d postgres`; then host-run with +`PICLOUD_DEV_MODE=true PICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure +PICLOUD_BIND=127.0.0.1:8099 PICLOUD_ADMIN_USERNAME=admin PICLOUD_ADMIN_PASSWORD=admin +target/debug/picloud`. CLI: `cargo build -p picloud-cli` → `target/debug/pic`. App scripts: +`/tmp/todo-app/*.rhai`. Every command + captured output above was run live against this instance. + +## Priority recommendation + +The two CLI gaps that turn "I built an app" into "…but it 404s and has no realtime" are **B1 +(domain claims)** and **B2 (topic registration)**. Both already have working admin APIs; they +just need thin `pic` wrappers (mirroring the existing `routes`/`triggers` commands). Ship those +two and a CLI-only developer can build this entire app without ever touching `curl`. diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index b2a6979..9343c75 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -10,8 +10,8 @@ use std::collections::BTreeMap; use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Utc}; use picloud_shared::{ - AdminUserId, ApiKeyId, App, AppId, AppRole, DispatchMode, ExecutionLog, HostKind, InstanceRole, - PathKind, Route, Scope, Script, ScriptId, + AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog, + HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, }; use reqwest::{header, Method, RequestBuilder, StatusCode}; use serde::{Deserialize, Serialize}; @@ -491,6 +491,169 @@ impl Client { .await?; decode_status(resp).await } + + // ---------- domains ---------- + + /// `GET /api/v1/admin/apps/{id_or_slug}/domains` + pub async fn domains_list(&self, app: &str) -> Result> { + let resp = self + .request(Method::GET, &format!("/api/v1/admin/apps/{app}/domains")) + .send() + .await?; + decode(resp).await + } + + /// `POST /api/v1/admin/apps/{id_or_slug}/domains` + pub async fn domains_create(&self, app: &str, pattern: &str) -> Result { + let resp = self + .request(Method::POST, &format!("/api/v1/admin/apps/{app}/domains")) + .json(&serde_json::json!({ "pattern": pattern })) + .send() + .await?; + decode(resp).await + } + + /// `DELETE /api/v1/admin/apps/{id_or_slug}/domains/{domain_id}` + pub async fn domains_delete(&self, app: &str, domain_id: &str) -> Result<()> { + let resp = self + .request( + Method::DELETE, + &format!("/api/v1/admin/apps/{app}/domains/{domain_id}"), + ) + .send() + .await?; + decode_status(resp).await + } + + // ---------- topics ---------- + + /// `GET /api/v1/admin/apps/{id_or_slug}/topics` + pub async fn topics_list(&self, app: &str) -> Result> { + let resp = self + .request(Method::GET, &format!("/api/v1/admin/apps/{app}/topics")) + .send() + .await?; + let body: TopicListDto = decode(resp).await?; + Ok(body.topics) + } + + /// `POST /api/v1/admin/apps/{id_or_slug}/topics` + pub async fn topics_create( + &self, + app: &str, + name: &str, + external_subscribable: bool, + auth_mode: &str, + ) -> Result { + let resp = self + .request(Method::POST, &format!("/api/v1/admin/apps/{app}/topics")) + .json(&serde_json::json!({ + "name": name, + "external_subscribable": external_subscribable, + "auth_mode": auth_mode, + })) + .send() + .await?; + decode(resp).await + } + + /// `PATCH /api/v1/admin/apps/{id_or_slug}/topics/{name}` — only the + /// fields the caller set are sent; the server leaves the rest alone. + pub async fn topics_update( + &self, + app: &str, + name: &str, + external_subscribable: Option, + auth_mode: Option<&str>, + ) -> Result { + let mut body = serde_json::Map::new(); + if let Some(e) = external_subscribable { + body.insert("external_subscribable".into(), Value::Bool(e)); + } + if let Some(m) = auth_mode { + body.insert("auth_mode".into(), Value::String(m.to_string())); + } + let resp = self + .request( + Method::PATCH, + &format!("/api/v1/admin/apps/{app}/topics/{name}"), + ) + .json(&Value::Object(body)) + .send() + .await?; + decode(resp).await + } + + /// `DELETE /api/v1/admin/apps/{id_or_slug}/topics/{name}` + pub async fn topics_delete(&self, app: &str, name: &str) -> Result<()> { + let resp = self + .request( + Method::DELETE, + &format!("/api/v1/admin/apps/{app}/topics/{name}"), + ) + .send() + .await?; + decode_status(resp).await + } + + // ---------- app end-users ---------- + + /// `GET /api/v1/admin/apps/{id_or_slug}/users?limit={n}` + pub async fn app_users_list(&self, app: &str, limit: u32) -> Result> { + let resp = self + .request( + Method::GET, + &format!("/api/v1/admin/apps/{app}/users?limit={limit}"), + ) + .send() + .await?; + let body: ListUsersResponseDto = decode(resp).await?; + Ok(body.users) + } + + /// `GET /api/v1/admin/apps/{id_or_slug}/users/{user_id}` + pub async fn app_user_get(&self, app: &str, user_id: &str) -> Result { + let resp = self + .request( + Method::GET, + &format!("/api/v1/admin/apps/{app}/users/{user_id}"), + ) + .send() + .await?; + decode(resp).await + } + + /// `POST /api/v1/admin/apps/{id_or_slug}/users/{user_id}/reset-password` + pub async fn app_user_reset_password( + &self, + app: &str, + user_id: &str, + ) -> Result { + let resp = self + .request( + Method::POST, + &format!("/api/v1/admin/apps/{app}/users/{user_id}/reset-password"), + ) + .send() + .await?; + decode(resp).await + } + + /// `POST /api/v1/admin/apps/{id_or_slug}/users/{user_id}/revoke-sessions` + pub async fn app_user_revoke_sessions( + &self, + app: &str, + user_id: &str, + ) -> Result { + let resp = self + .request( + Method::POST, + &format!("/api/v1/admin/apps/{app}/users/{user_id}/revoke-sessions"), + ) + .send() + .await?; + decode(resp).await + } } /// `POST /api/v1/admin/auth/login` — sits outside the `Client` because @@ -659,6 +822,43 @@ pub struct TriggerDto { pub details: Value, } +/// Topic registry row. The server's `Topic` derives only `Serialize`, +/// so the CLI carries its own `Deserialize` mirror (same wire shape). +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct TopicDto { + pub name: String, + pub external_subscribable: bool, + /// `public` | `token` | `session`. + pub auth_mode: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize)] +struct TopicListDto { + topics: Vec, +} + +#[derive(Debug, Deserialize)] +struct ListUsersResponseDto { + users: Vec, + // `next_cursor` exists on the wire but the CLI's `ls` is single-page + // (--limit); pagination is a follow-up. +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct ResetPasswordResponseDto { + pub token: String, + pub expires_in_seconds: i64, +} + +#[derive(Debug, Deserialize)] +pub struct RevokeSessionsResponseDto { + pub revoked: u64, +} + #[derive(Debug, Deserialize)] pub struct DeadLetterCountDto { pub unresolved: i64, diff --git a/crates/picloud-cli/src/cmds/apps.rs b/crates/picloud-cli/src/cmds/apps.rs index 34d6f5c..d19894a 100644 --- a/crates/picloud-cli/src/cmds/apps.rs +++ b/crates/picloud-cli/src/cmds/apps.rs @@ -27,7 +27,12 @@ pub async fn ls(mode: OutputMode) -> Result<()> { Ok(()) } -pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -> Result<()> { +pub async fn create( + slug: &str, + name: Option<&str>, + description: Option<&str>, + mode: OutputMode, +) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let body = CreateAppBody { @@ -36,7 +41,19 @@ pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) - description, }; let app = client.apps_create(&body).await?; - println!("Created app {}", app.slug); + // Emit the created object so `--output json` callers can capture the + // id (every later domains/topics/routes call needs it). + let mut block = KvBlock::new(); + block + .field("id", app.id.to_string()) + .field("slug", app.slug.clone()) + .field("name", app.name.clone()) + .field( + "description", + app.description.clone().unwrap_or_else(|| "-".into()), + ) + .field("created_at", app.created_at.to_rfc3339()); + block.print(mode); Ok(()) } diff --git a/crates/picloud-cli/src/cmds/apps_domains.rs b/crates/picloud-cli/src/cmds/apps_domains.rs new file mode 100644 index 0000000..5fd4453 --- /dev/null +++ b/crates/picloud-cli/src/cmds/apps_domains.rs @@ -0,0 +1,62 @@ +//! `pic apps domains {ls,add,rm}` — manage an app's domain claims. +//! +//! Host→app dispatch resolves an incoming `Host` to the app that claims +//! it; a non-default app's routes are unreachable until it claims a +//! domain. This is the CLI surface over the `apps/{id}/domains` admin API. +//! +//! Param-syntax note: domain patterns use `{name}` for the parameterized +//! wildcard (e.g. `{tenant}.example.com`) — never `:name`, which is +//! route-path syntax. + +use anyhow::Result; + +use crate::client::Client; +use crate::config; +use crate::output::{KvBlock, OutputMode, Table}; + +pub async fn ls(app: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let domains = client.domains_list(app).await?; + let mut table = Table::new(["id", "pattern", "shape", "created_at"]); + for d in domains { + table.row([ + d.id.to_string(), + d.pattern.clone(), + shape_label(d.shape).to_string(), + d.created_at.to_rfc3339(), + ]); + } + table.print(mode); + Ok(()) +} + +pub async fn add(app: &str, pattern: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let d = client.domains_create(app, pattern).await?; + let mut block = KvBlock::new(); + block + .field("id", d.id.to_string()) + .field("pattern", d.pattern.clone()) + .field("shape", shape_label(d.shape).to_string()) + .field("created_at", d.created_at.to_rfc3339()); + block.print(mode); + Ok(()) +} + +pub async fn rm(app: &str, domain_id: &str) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + client.domains_delete(app, domain_id).await?; + println!("Deleted domain {domain_id}"); + Ok(()) +} + +fn shape_label(shape: picloud_shared::DomainShape) -> &'static str { + match shape { + picloud_shared::DomainShape::Exact => "exact", + picloud_shared::DomainShape::Wildcard => "wildcard", + picloud_shared::DomainShape::Parameterized => "parameterized", + } +} diff --git a/crates/picloud-cli/src/cmds/login.rs b/crates/picloud-cli/src/cmds/login.rs index 4d65526..4993236 100644 --- a/crates/picloud-cli/src/cmds/login.rs +++ b/crates/picloud-cli/src/cmds/login.rs @@ -1,9 +1,11 @@ //! `pic login` — primary auth entry point. //! //! Two flows: -//! * **username + password** (default, interactive): POST -//! `/api/v1/admin/auth/login` with the credentials and persist the -//! returned session token. Mirrors the dashboard's login form. +//! * **username + password**: POST `/api/v1/admin/auth/login` with the +//! credentials and persist the returned session token. Mirrors the +//! dashboard's login form. Interactive by default; for CI pass +//! `--username --password-stdin` to read the password from stdin +//! (inline passwords are never accepted — they leak into history/`ps`). //! * **paste-a-token** (`--token `, or `PICLOUD_TOKEN` env): skip //! the credential exchange and persist a bearer string directly. //! Used by CI and by anyone using a long-lived API key minted via @@ -21,7 +23,12 @@ use crate::config::{save, Credentials}; const DEFAULT_URL: &str = "http://localhost:8000"; -pub async fn run(url_arg: Option<&str>, token_arg: Option<&str>) -> Result<()> { +pub async fn run( + url_arg: Option<&str>, + token_arg: Option<&str>, + username_arg: Option<&str>, + password_stdin: bool, +) -> Result<()> { let url = resolve_url(url_arg)?; let token_from_env = std::env::var("PICLOUD_TOKEN") .ok() @@ -44,7 +51,7 @@ pub async fn run(url_arg: Option<&str>, token_arg: Option<&str>) -> Result<()> { let (token, username, role) = match bearer_token { Some(t) => login_with_bearer(&url, &t).await?, - None => login_with_password(&url).await?, + None => login_with_password(&url, username_arg, password_stdin).await?, }; let creds = Credentials { @@ -78,16 +85,48 @@ fn read_token_from_stdin() -> Result { } } -async fn login_with_password(url: &str) -> Result<(String, String, InstanceRole)> { - let username = prompt_line("Username: ")?; +async fn login_with_password( + url: &str, + username_arg: Option<&str>, + password_stdin: bool, +) -> Result<(String, String, InstanceRole)> { + let username = match username_arg { + Some(u) => u.trim().to_string(), + None => prompt_line("Username: ")?, + }; if username.is_empty() { anyhow::bail!("username is required"); } - let password = read_password()?; + // `--password-stdin` reads one line from stdin without prompting — + // the CI path. The password is never accepted inline (it would leak + // into shell history, `ps`, and /proc), mirroring the `--token` rule. + let password = if password_stdin { + read_password_from_stdin()? + } else { + read_password()? + }; let resp = client::auth_login(url, &username, &password).await?; Ok((resp.token, resp.user.username, resp.user.instance_role)) } +/// Read a password from stdin without prompting — backs +/// `--password-stdin`. Interactive sessions still get a no-echo prompt +/// (a password is a secret); piped input reads one line. +fn read_password_from_stdin() -> Result { + use std::io::IsTerminal; + if io::stdin().is_terminal() { + let p = rpassword::prompt_password("Password: ").context("read password from stdin")?; + Ok(p.trim_end_matches(['\r', '\n']).to_string()) + } else { + let mut buf = String::new(); + io::stdin() + .lock() + .read_line(&mut buf) + .context("read password from stdin")?; + Ok(buf.trim_end_matches(['\r', '\n']).to_string()) + } +} + /// Read a password without echoing it where possible. Falls back to a /// plain stdin read when no controlling terminal is attached — CI /// systems and `cargo test`'s piped stdin both land here, and dying diff --git a/crates/picloud-cli/src/cmds/mod.rs b/crates/picloud-cli/src/cmds/mod.rs index 4c10df5..7abf7f0 100644 --- a/crates/picloud-cli/src/cmds/mod.rs +++ b/crates/picloud-cli/src/cmds/mod.rs @@ -1,6 +1,7 @@ pub mod admins; pub mod api_keys; pub mod apps; +pub mod apps_domains; pub mod dead_letters; pub mod login; pub mod logout; @@ -8,5 +9,7 @@ pub mod logs; pub mod routes; pub mod scripts; pub mod secrets; +pub mod topics; pub mod triggers; +pub mod users; pub mod whoami; diff --git a/crates/picloud-cli/src/cmds/scripts.rs b/crates/picloud-cli/src/cmds/scripts.rs index 8906c0f..74aa8c5 100644 --- a/crates/picloud-cli/src/cmds/scripts.rs +++ b/crates/picloud-cli/src/cmds/scripts.rs @@ -10,7 +10,7 @@ use serde_json::Value; use crate::client::{Client, CreateScriptBody}; use crate::config; -use crate::output::{OutputMode, Table}; +use crate::output::{KvBlock, OutputMode, Table}; pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> { let creds = config::resolve()?; @@ -62,6 +62,7 @@ pub async fn deploy( app_ident: &str, name_override: Option<&str>, description: Option<&str>, + mode: OutputMode, ) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; @@ -87,11 +88,11 @@ pub async fn deploy( let app = client.apps_get(app_ident).await?; let existing = client.scripts_list_by_app(app_ident).await?; - if let Some(s) = existing.into_iter().find(|s| s.name == name) { + let (script, action) = if let Some(s) = existing.into_iter().find(|s| s.name == name) { let updated = client .scripts_update_source(&s.id.to_string(), &source) .await?; - println!("Updated {} v{}", updated.name, updated.version); + (updated, "updated") } else { let body = CreateScriptBody { app_id: app.app.id, @@ -99,9 +100,18 @@ pub async fn deploy( description, source: &source, }; - let created = client.scripts_create(&body).await?; - println!("Created {} v{}", created.name, created.version); - } + (client.scripts_create(&body).await?, "created") + }; + // Emit the script object so `--output json` callers can capture the + // id for the follow-up routes/triggers calls. + let mut block = KvBlock::new(); + block + .field("id", script.id.to_string()) + .field("name", script.name.clone()) + .field("version", script.version.to_string()) + .field("action", action) + .field("updated_at", script.updated_at.to_rfc3339()); + block.print(mode); Ok(()) } diff --git a/crates/picloud-cli/src/cmds/topics.rs b/crates/picloud-cli/src/cmds/topics.rs new file mode 100644 index 0000000..3f820af --- /dev/null +++ b/crates/picloud-cli/src/cmds/topics.rs @@ -0,0 +1,85 @@ +//! `pic topics {ls,create,update,rm}` — manage the pub/sub topic +//! registry that controls external SSE subscribability. +//! +//! Note the distinction from `pic triggers create-from-json --kind pubsub`: +//! a *pubsub trigger* runs a script when a message is published; a *topic* +//! (here) is the registry row that lets an outside client subscribe to a +//! topic over SSE (`/realtime/topics/{name}`). Publishing from a script +//! needs neither — only external subscribers need the topic registered. + +use anyhow::Result; + +use crate::client::{Client, TopicDto}; +use crate::config; +use crate::output::{KvBlock, OutputMode, Table}; + +pub async fn ls(app: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let topics = client.topics_list(app).await?; + let mut table = Table::new([ + "name", + "external_subscribable", + "auth_mode", + "created_at", + "updated_at", + ]); + for t in topics { + table.row([ + t.name.clone(), + t.external_subscribable.to_string(), + t.auth_mode.clone(), + t.created_at.to_rfc3339(), + t.updated_at.to_rfc3339(), + ]); + } + table.print(mode); + Ok(()) +} + +pub async fn create( + app: &str, + name: &str, + external: bool, + auth_mode: &str, + mode: OutputMode, +) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let t = client.topics_create(app, name, external, auth_mode).await?; + print_topic(&t, mode); + Ok(()) +} + +pub async fn update( + app: &str, + name: &str, + external: Option, + auth_mode: Option<&str>, + mode: OutputMode, +) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let t = client.topics_update(app, name, external, auth_mode).await?; + print_topic(&t, mode); + Ok(()) +} + +pub async fn rm(app: &str, name: &str) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + client.topics_delete(app, name).await?; + println!("Deleted topic {name}"); + Ok(()) +} + +fn print_topic(t: &TopicDto, mode: OutputMode) { + let mut block = KvBlock::new(); + block + .field("name", t.name.clone()) + .field("external_subscribable", t.external_subscribable.to_string()) + .field("auth_mode", t.auth_mode.clone()) + .field("created_at", t.created_at.to_rfc3339()) + .field("updated_at", t.updated_at.to_rfc3339()); + block.print(mode); +} diff --git a/crates/picloud-cli/src/cmds/users.rs b/crates/picloud-cli/src/cmds/users.rs new file mode 100644 index 0000000..7a29bc9 --- /dev/null +++ b/crates/picloud-cli/src/cmds/users.rs @@ -0,0 +1,95 @@ +//! `pic users {ls,show,reset-password,revoke-sessions}` — admin view of +//! an app's *end-users* (people who registered via `users::create` in a +//! script), distinct from `pic admins` (instance control-plane accounts) +//! and app membership/collaborators. +//! +//! Scoped to read + the two admin actions the E2E report needed. Create, +//! delete, and invitations are deferred to a follow-up. + +use anyhow::Result; + +use crate::client::Client; +use crate::config; +use crate::output::{KvBlock, OutputMode, Table}; + +pub async fn ls(app: &str, limit: u32, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let users = client.app_users_list(app, limit).await?; + let mut table = Table::new(["id", "email", "display_name", "last_login_at", "created_at"]); + for u in users { + table.row([ + u.id.to_string(), + u.email.clone(), + u.display_name.clone().unwrap_or_else(|| "-".into()), + u.last_login_at + .map(|t| t.to_rfc3339()) + .unwrap_or_else(|| "-".into()), + u.created_at.to_rfc3339(), + ]); + } + table.print(mode); + Ok(()) +} + +pub async fn show(app: &str, user_id: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let u = client.app_user_get(app, user_id).await?; + let mut block = KvBlock::new(); + block + .field("id", u.id.to_string()) + .field("email", u.email.clone()) + .field( + "display_name", + u.display_name.clone().unwrap_or_else(|| "-".into()), + ) + .field( + "email_verified_at", + u.email_verified_at + .map(|t| t.to_rfc3339()) + .unwrap_or_else(|| "-".into()), + ) + .field( + "last_login_at", + u.last_login_at + .map(|t| t.to_rfc3339()) + .unwrap_or_else(|| "-".into()), + ) + .field( + "roles", + if u.roles.is_empty() { + "-".into() + } else { + u.roles.join(",") + }, + ) + .field("created_at", u.created_at.to_rfc3339()) + .field("updated_at", u.updated_at.to_rfc3339()); + block.print(mode); + Ok(()) +} + +/// Mint a one-shot password-reset token for an end-user. The token is +/// returned exactly once — the admin pastes it into a manual reset link. +pub async fn reset_password(app: &str, user_id: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let resp = client.app_user_reset_password(app, user_id).await?; + let mut block = KvBlock::new(); + block + .field("token", resp.token.clone()) + .field("expires_in_seconds", resp.expires_in_seconds.to_string()); + block.print(mode); + Ok(()) +} + +pub async fn revoke_sessions(app: &str, user_id: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let resp = client.app_user_revoke_sessions(app, user_id).await?; + let mut block = KvBlock::new(); + block.field("revoked", resp.revoked.to_string()); + block.print(mode); + Ok(()) +} diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index f2068a9..a6d8a61 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -90,12 +90,30 @@ enum Cmd { /// etc. `create-from-json` is the escape hatch for kinds the CLI /// doesn't expose a per-kind wrapper for (docs/files/pubsub/email/ /// queue) — pass the body JSON inline, via `@`, or `-` to - /// read from stdin. + /// read from stdin. Note: a `pubsub` trigger runs a script when a + /// message is published; to let an *external* client subscribe to a + /// topic over SSE, register it with `pic topics` instead. Triggers { #[command(subcommand)] cmd: TriggersCmd, }, + /// Pub/sub topic registry — control which topics external clients + /// can subscribe to over SSE (`/realtime/topics/{name}`). Publishing + /// from a script needs no registration; only outside subscribers do. + Topics { + #[command(subcommand)] + cmd: TopicsCmd, + }, + + /// App end-user management — list / inspect the users who registered + /// via `users::*` in scripts, mint reset tokens, revoke sessions. + /// Distinct from `pic admins` (instance accounts). + Users { + #[command(subcommand)] + cmd: UsersCmd, + }, + /// Dead-letter management — inspect, replay, and resolve rows the /// dispatcher wrote after exhausting a trigger's retries. #[command(name = "dead-letters")] @@ -125,6 +143,17 @@ struct LoginArgs { /// shell history / `ps`). For CI, set `PICLOUD_TOKEN` instead. #[arg(long)] token: Option, + + /// Username for non-interactive password login. Pair with + /// `--password-stdin`. Omit both for the interactive prompt. + #[arg(long)] + username: Option, + + /// Read the password from stdin instead of prompting (CI path). The + /// password is never accepted inline — that leaks into shell history, + /// `ps`, and /proc. + #[arg(long = "password-stdin")] + password_stdin: bool, } #[derive(Subcommand)] @@ -151,6 +180,28 @@ enum AppsCmd { #[arg(long)] force: bool, }, + + /// Manage an app's domain claims. A non-default app's routes only + /// match once it claims the request `Host` — without a claim every + /// route 404s. + Domains { + #[command(subcommand)] + cmd: DomainsCmd, + }, +} + +#[derive(Subcommand)] +enum DomainsCmd { + /// List the app's domain claims. + Ls { app: String }, + + /// Claim a domain for the app. Patterns: exact (`app.example.com`), + /// wildcard (`*.example.com`), or parameterized (`{tenant}.example.com` + /// — `{name}` syntax, never `:name`). + Add { app: String, pattern: String }, + + /// Release a domain claim by its id. + Rm { app: String, domain_id: String }, } #[derive(Subcommand)] @@ -465,6 +516,104 @@ enum TriggersCmd { }, } +#[derive(Clone, Copy, ValueEnum)] +enum TopicAuthModeArg { + /// No auth required to subscribe. + Public, + /// Subscriber bearer token required. + Token, + /// Per-app user session required. + Session, +} + +/// Wire form — the topics API accepts the lowercase strings directly. +const fn topic_auth_wire(m: TopicAuthModeArg) -> &'static str { + match m { + TopicAuthModeArg::Public => "public", + TopicAuthModeArg::Token => "token", + TopicAuthModeArg::Session => "session", + } +} + +#[derive(Subcommand)] +enum TopicsCmd { + /// List registered topics for an app. + Ls { + #[arg(long)] + app: String, + }, + + /// Register a topic. Defaults to not externally subscribable; pass + /// `--external` to open it to outside SSE subscribers. + Create { + #[arg(long)] + app: String, + /// Concrete topic name (no `*` wildcards). + name: String, + /// Allow external clients to subscribe over SSE. + #[arg(long)] + external: bool, + /// Auth required of external subscribers. Defaults to `public`. + #[arg(long = "auth-mode", value_enum, default_value_t = TopicAuthModeArg::Public)] + auth_mode: TopicAuthModeArg, + }, + + /// Update a topic's external/auth settings. Only the flags you pass + /// change; the rest are left as-is. + Update { + #[arg(long)] + app: String, + name: String, + /// `true` to open external SSE subscription, `false` to close it. + #[arg(long)] + external: Option, + #[arg(long = "auth-mode", value_enum)] + auth_mode: Option, + }, + + /// Unregister a topic. Live SSE subscribers are disconnected. + Rm { + #[arg(long)] + app: String, + name: String, + }, +} + +#[derive(Subcommand)] +enum UsersCmd { + /// List an app's registered end-users. + Ls { + #[arg(long)] + app: String, + #[arg(long, default_value_t = 50)] + limit: u32, + }, + + /// Show a single end-user by id. + Show { + #[arg(long)] + app: String, + user_id: String, + }, + + /// Mint a one-shot password-reset token for an end-user. Printed + /// exactly once — paste it into a manual reset link. + #[command(name = "reset-password")] + ResetPassword { + #[arg(long)] + app: String, + user_id: String, + }, + + /// Revoke all of an end-user's active sessions. + #[command(name = "revoke-sessions")] + RevokeSessions { + #[arg(long)] + app: String, + user_id: String, + }, +} + #[derive(Subcommand)] enum DeadLettersCmd { /// Print the unresolved DL count. Cheap probe for alerting / @@ -593,7 +742,15 @@ async fn main() -> ExitCode { let cli = Cli::parse(); let mode = cli.output; let result = match cli.cmd { - Cmd::Login(args) => cmds::login::run(args.url.as_deref(), args.token.as_deref()).await, + Cmd::Login(args) => { + cmds::login::run( + args.url.as_deref(), + args.token.as_deref(), + args.username.as_deref(), + args.password_stdin, + ) + .await + } Cmd::Logout => cmds::logout::run().await, Cmd::Whoami => cmds::whoami::run(mode).await, Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await, @@ -604,13 +761,30 @@ async fn main() -> ExitCode { name, description, }, - } => cmds::apps::create(&slug, name.as_deref(), description.as_deref()).await, + } => cmds::apps::create(&slug, name.as_deref(), description.as_deref(), mode).await, Cmd::Apps { cmd: AppsCmd::Show { ident }, } => cmds::apps::show(&ident, mode).await, Cmd::Apps { cmd: AppsCmd::Delete { ident, force }, } => cmds::apps::delete(&ident, force).await, + Cmd::Apps { + cmd: AppsCmd::Domains { + cmd: DomainsCmd::Ls { app }, + }, + } => cmds::apps_domains::ls(&app, mode).await, + Cmd::Apps { + cmd: + AppsCmd::Domains { + cmd: DomainsCmd::Add { app, pattern }, + }, + } => cmds::apps_domains::add(&app, &pattern, mode).await, + Cmd::Apps { + cmd: + AppsCmd::Domains { + cmd: DomainsCmd::Rm { app, domain_id }, + }, + } => cmds::apps_domains::rm(&app, &domain_id).await, Cmd::Scripts { cmd: ScriptsCmd::Ls { app }, } => cmds::scripts::ls(app.as_deref(), mode).await, @@ -622,6 +796,7 @@ async fn main() -> ExitCode { &args.app, args.name.as_deref(), args.description.as_deref(), + mode, ) .await } @@ -656,6 +831,7 @@ async fn main() -> ExitCode { &args.app, args.name.as_deref(), args.description.as_deref(), + mode, ) .await } @@ -831,6 +1007,44 @@ async fn main() -> ExitCode { Cmd::Triggers { cmd: TriggersCmd::CreateFromJson { app, kind, body }, } => cmds::triggers::create_from_json(&app, &kind, &body, mode).await, + Cmd::Topics { + cmd: TopicsCmd::Ls { app }, + } => cmds::topics::ls(&app, mode).await, + Cmd::Topics { + cmd: + TopicsCmd::Create { + app, + name, + external, + auth_mode, + }, + } => cmds::topics::create(&app, &name, external, topic_auth_wire(auth_mode), mode).await, + Cmd::Topics { + cmd: + TopicsCmd::Update { + app, + name, + external, + auth_mode, + }, + } => { + cmds::topics::update(&app, &name, external, auth_mode.map(topic_auth_wire), mode).await + } + Cmd::Topics { + cmd: TopicsCmd::Rm { app, name }, + } => cmds::topics::rm(&app, &name).await, + Cmd::Users { + cmd: UsersCmd::Ls { app, limit }, + } => cmds::users::ls(&app, limit, mode).await, + Cmd::Users { + cmd: UsersCmd::Show { app, user_id }, + } => cmds::users::show(&app, &user_id, mode).await, + Cmd::Users { + cmd: UsersCmd::ResetPassword { app, user_id }, + } => cmds::users::reset_password(&app, &user_id, mode).await, + Cmd::Users { + cmd: UsersCmd::RevokeSessions { app, user_id }, + } => cmds::users::revoke_sessions(&app, &user_id, mode).await, Cmd::DeadLetters { cmd: DeadLettersCmd::Count { app }, } => cmds::dead_letters::count(&app, mode).await,