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