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,

View File

@@ -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(())
}

View File

@@ -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",
}
}

View File

@@ -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 <U> --password-stdin` to read the password from stdin
//! (inline passwords are never accepted — they leak into history/`ps`).
//! * **paste-a-token** (`--token <T>`, 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<String> {
}
}
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<String> {
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

View File

@@ -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;

View File

@@ -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(())
}

View File

@@ -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<bool>,
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);
}

View File

@@ -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(())
}

View File

@@ -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 `@<file>`, 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<String>,
/// Username for non-interactive password login. Pair with
/// `--password-stdin`. Omit both for the interactive prompt.
#[arg(long)]
username: Option<String>,
/// 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<bool>,
#[arg(long = "auth-mode", value_enum)]
auth_mode: Option<TopicAuthModeArg>,
},
/// 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,