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

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