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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user