feat(cli): close E2E To-Do CLI gaps (B1, B2, F2, F1, F3)

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-12 20:43:03 +02:00
parent 50db27806b
commit 04a24ea0b7
10 changed files with 941 additions and 21 deletions

View File

@@ -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<Vec<AppDomain>> {
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<AppDomain> {
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<Vec<TopicDto>> {
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<TopicDto> {
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<bool>,
auth_mode: Option<&str>,
) -> Result<TopicDto> {
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<Vec<AppUser>> {
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<AppUser> {
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<ResetPasswordResponseDto> {
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<RevokeSessionsResponseDto> {
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<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
struct TopicListDto {
topics: Vec<TopicDto>,
}
#[derive(Debug, Deserialize)]
struct ListUsersResponseDto {
users: Vec<AppUser>,
// `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,