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:
195
E2E_TODO_REPORT.md
Normal file
195
E2E_TODO_REPORT.md
Normal file
@@ -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 <cleanup-id>` 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 <id>` 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`.
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
62
crates/picloud-cli/src/cmds/apps_domains.rs
Normal file
62
crates/picloud-cli/src/cmds/apps_domains.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
85
crates/picloud-cli/src/cmds/topics.rs
Normal file
85
crates/picloud-cli/src/cmds/topics.rs
Normal 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);
|
||||
}
|
||||
95
crates/picloud-cli/src/cmds/users.rs
Normal file
95
crates/picloud-cli/src/cmds/users.rs
Normal 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(())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user