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