Files
PiCloud/crates/picloud-cli/src/main.rs
MechaCat02 b8a4f30219 feat(cli): pic init scaffolds a new declarative project
Offline project scaffolding — the on-ramp to pull/plan/apply. Writes a
`picloud.toml` describing a minimal *working* app (a `hello` endpoint
bound to GET /hello) plus commented examples for the other blocks,
`scripts/hello.rhai`, and a `.gitignore` that pre-ignores the project
tool's `.picloud/` link-state directory (the home for the bound-plan
state token, landing next). Refuses to overwrite an existing manifest
without `--force`; derives the app slug from the directory name when not
given (validated against the canonical slug rule).

The active manifest is rendered through the same `to_toml` model the rest
of the tool uses, so a freshly-init'd project is guaranteed valid and
immediately `pic plan`-able. Offline — no server contact, so the journey
tests need no DATABASE_URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:32:07 +02:00

1613 lines
47 KiB
Rust

//! PiCloud command-line client.
//!
//! Thin client over the existing admin + execute HTTP surface — the
//! server gains nothing for the CLI; the CLI is just a developer
//! ergonomics layer over endpoints the dashboard already uses.
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Args, Parser, Subcommand, ValueEnum};
mod client;
mod cmds;
mod config;
mod manifest;
mod output;
use crate::output::OutputMode;
#[derive(Parser)]
#[command(name = "pic", version, about = "PiCloud command-line client")]
struct Cli {
/// Output format for `ls` / `show` / `whoami` / `logs` commands.
/// TSV stays pipe-friendly; JSON is `jq`-ready.
#[arg(long, value_enum, global = true, default_value_t = OutputMode::Tsv)]
output: OutputMode,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Authenticate with the server. Default flow prompts for username
/// + password and saves the returned session token; `--token` skips
/// the password exchange and persists a bearer string directly (use
/// this for long-lived API keys minted via `pic api-keys mint`).
Login(LoginArgs),
/// Revoke the saved session server-side and delete the local
/// credentials file. Idempotent.
Logout,
/// Print the principal the saved token resolves to.
Whoami,
/// App management.
Apps {
#[command(subcommand)]
cmd: AppsCmd,
},
/// Script management.
Scripts {
#[command(subcommand)]
cmd: ScriptsCmd,
},
/// Long-lived bearer API key management.
#[command(name = "api-keys")]
ApiKeys {
#[command(subcommand)]
cmd: ApiKeysCmd,
},
/// Tail recent execution logs for a script.
Logs(LogsArgs),
/// Top-level alias for `pic scripts invoke <id>`.
Invoke(InvokeArgs),
/// Top-level alias for `pic scripts deploy <file> --app <slug>`.
Deploy(DeployArgs),
/// Route management — bind script ids to HTTP method+host+path.
Routes {
#[command(subcommand)]
cmd: RoutesCmd,
},
/// Admin user management — list / create / patch / delete the
/// per-instance admin accounts. All endpoints require
/// `instance:admin` or `instance:owner`.
Admins {
#[command(subcommand)]
cmd: AdminsCmd,
},
/// Trigger management — list / create / delete the triggers that
/// fire scripts on KV mutations, cron schedules, dead-letter rows,
/// 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. 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")]
DeadLetters {
#[command(subcommand)]
cmd: DeadLettersCmd,
},
/// Per-app secret storage — list names, set values from stdin,
/// delete by name. Values never leave the server after `set`.
Secrets {
#[command(subcommand)]
cmd: SecretsCmd,
},
/// App membership — list members and grant / change / revoke their
/// per-app role (app_admin | editor | viewer).
Members {
#[command(subcommand)]
cmd: MembersCmd,
},
/// Files inspection — list a collection's blobs, download bytes, or
/// delete a file. Read + delete only; writes go through scripts.
Files {
#[command(subcommand)]
cmd: FilesCmd,
},
/// Queue inspection — list queues with depth counts, or drill into
/// one queue's stats + registered consumer. Read-only.
Queues {
#[command(subcommand)]
cmd: QueuesCmd,
},
/// KV inspection — list keys in a collection or fetch one value.
/// Read-only; writes go through `kv::set` in scripts.
Kv {
#[command(subcommand)]
cmd: KvCmd,
},
/// Reconcile the live app to a `picloud.toml` manifest in one
/// transaction (creates + updates; `--prune` also deletes resources
/// absent from the manifest).
Apply(ApplyArgs),
/// Diff a `picloud.toml` manifest against the live app and print the
/// changes (create / update / no-op / delete). Read-only.
Plan(PlanArgs),
/// Export an app's current server state into a `picloud.toml` manifest
/// (+ `scripts/<name>.rhai` sources) for declarative management with
/// `pic plan` / `pic apply`.
Pull(PullArgs),
/// Scaffold a new declarative project: a `picloud.toml` (minimal working
/// app), `scripts/hello.rhai`, and a `.gitignore`. Offline; deploy with
/// `pic plan` then `pic apply`.
Init(InitArgs),
}
#[derive(Args)]
struct ApplyArgs {
/// Path to the manifest.
#[arg(long, default_value = "picloud.toml")]
file: PathBuf,
/// Delete live scripts/routes/triggers absent from the manifest.
/// Secrets are never pruned.
#[arg(long)]
prune: bool,
/// Skip the `--prune` confirmation prompt. Required to prune
/// non-interactively (CI).
#[arg(long)]
yes: bool,
}
#[derive(Args)]
struct PlanArgs {
/// Path to the manifest.
#[arg(long, default_value = "picloud.toml")]
file: PathBuf,
}
#[derive(Args)]
struct PullArgs {
/// App slug or id to export.
app: String,
/// Directory to write `picloud.toml` + `scripts/` into.
#[arg(long, default_value = ".")]
dir: PathBuf,
}
#[derive(Args)]
struct InitArgs {
/// App slug for the new project. Defaults to a slug derived from the
/// target directory's name.
slug: Option<String>,
/// Directory to scaffold into.
#[arg(long, default_value = ".")]
dir: PathBuf,
/// Display name for the app. Defaults to a title-cased slug.
#[arg(long)]
name: Option<String>,
/// Overwrite an existing `picloud.toml`.
#[arg(long)]
force: bool,
}
#[derive(Subcommand)]
enum KvCmd {
/// List keys in a collection.
Ls {
#[arg(long)]
app: String,
#[arg(long)]
collection: String,
#[arg(long, default_value_t = 100)]
limit: u32,
},
/// Fetch one key's value (printed as JSON).
Get {
#[arg(long)]
app: String,
#[arg(long)]
collection: String,
key: String,
},
}
#[derive(Subcommand)]
enum MembersCmd {
/// List app members.
Ls {
#[arg(long)]
app: String,
},
/// Grant a user a role on the app.
Add {
#[arg(long)]
app: String,
#[arg(long = "user")]
user_id: String,
/// `app_admin` | `editor` | `viewer`.
#[arg(long)]
role: String,
},
/// Change an existing member's role.
Set {
#[arg(long)]
app: String,
#[arg(long = "user")]
user_id: String,
#[arg(long)]
role: String,
},
/// Remove a member.
Rm {
#[arg(long)]
app: String,
#[arg(long = "user")]
user_id: String,
},
}
#[derive(Subcommand)]
enum FilesCmd {
/// List files in a collection.
Ls {
#[arg(long)]
app: String,
#[arg(long)]
collection: String,
#[arg(long, default_value_t = 100)]
limit: u32,
},
/// Download a file's bytes (to `--out <path>` or stdout).
Get {
#[arg(long)]
app: String,
#[arg(long)]
collection: String,
#[arg(long = "id")]
file_id: String,
#[arg(long)]
out: Option<PathBuf>,
},
/// Delete a file.
Rm {
#[arg(long)]
app: String,
#[arg(long)]
collection: String,
#[arg(long = "id")]
file_id: String,
},
}
#[derive(Subcommand)]
enum QueuesCmd {
/// List queues with depth counts.
Ls {
#[arg(long)]
app: String,
},
/// Show one queue's stats + consumer.
Show {
#[arg(long)]
app: String,
queue_name: String,
},
}
#[derive(Args)]
struct LoginArgs {
/// Override the URL prompt non-interactively. Also reads
/// `PICLOUD_URL`.
#[arg(long)]
url: Option<String>,
/// Skip the username + password exchange and persist a bearer
/// directly (validated against `/auth/me` first). Pass `--token -`
/// to read it from stdin; an inline value is rejected (it leaks into
/// 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)]
enum AppsCmd {
/// List apps the caller can see.
Ls,
/// Create a new app.
Create {
slug: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
},
/// Show a single app, including the caller's role in it.
Show { ident: String },
/// Delete an app. Without `--force`, the server rejects if the app
/// still owns scripts.
Delete {
ident: String,
#[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)]
enum ScriptsCmd {
/// List scripts. With `--app`, scoped to one app; without, one
/// `GET /admin/scripts` for everything the caller can see.
Ls {
#[arg(long)]
app: Option<String>,
},
/// Upload a `.rhai` file. Patches the existing script with the
/// matching name in `--app` if one exists, otherwise creates it.
Deploy(DeployArgs),
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
/// `--body @-` for stdin, or inline JSON.
Invoke(InvokeArgs),
/// Delete a script. Requires AppAdmin on the owning app.
Delete { id: String },
}
#[derive(Args)]
struct DeployArgs {
file: PathBuf,
#[arg(long)]
app: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
/// Per-script wall-clock timeout in seconds (overrides the instance
/// default; still clamped by `PICLOUD_SANDBOX_MAX_*` ceilings).
#[arg(long)]
timeout: Option<i32>,
/// Per-script memory ceiling in MB.
#[arg(long)]
memory: Option<i32>,
/// Script kind: `endpoint` (default) or `module` (importable, no route).
#[arg(long, value_enum)]
kind: Option<ScriptKindArg>,
/// Sandbox override as `key=value`, repeatable. Keys: `max_operations`,
/// `max_string_size`, `max_array_size`, `max_map_size`,
/// `max_call_levels`, `max_expr_depth`. E.g. `--sandbox max_operations=500000`.
#[arg(long = "sandbox", value_name = "KEY=VALUE")]
sandbox: Vec<String>,
}
#[derive(Copy, Clone, clap::ValueEnum)]
enum ScriptKindArg {
Endpoint,
Module,
}
impl From<ScriptKindArg> for picloud_shared::ScriptKind {
fn from(v: ScriptKindArg) -> Self {
match v {
ScriptKindArg::Endpoint => Self::Endpoint,
ScriptKindArg::Module => Self::Module,
}
}
}
impl DeployArgs {
/// Fold the runtime-config flags into a `ScriptConfig`, parsing and
/// validating the `--sandbox key=value` pairs.
fn script_config(&self) -> anyhow::Result<client::ScriptConfig> {
let sandbox = parse_sandbox_overrides(&self.sandbox)?;
Ok(client::ScriptConfig {
timeout_seconds: self.timeout,
memory_limit_mb: self.memory,
kind: self.kind.map(Into::into),
sandbox,
})
}
}
/// Parse repeatable `--sandbox key=value` flags into a `ScriptSandbox`.
/// Unknown keys and non-integer values are hard errors so a typo doesn't
/// silently deploy an unrestricted script.
fn parse_sandbox_overrides(
pairs: &[String],
) -> anyhow::Result<Option<picloud_shared::ScriptSandbox>> {
use anyhow::{anyhow, Context};
if pairs.is_empty() {
return Ok(None);
}
let mut sb = picloud_shared::ScriptSandbox::default();
for raw in pairs {
let (key, val) = raw
.split_once('=')
.ok_or_else(|| anyhow!("--sandbox expects key=value, got {raw:?}"))?;
let n: u64 = val
.trim()
.parse()
.with_context(|| format!("--sandbox {key}: {val:?} is not a non-negative integer"))?;
match key.trim() {
"max_operations" => sb.max_operations = Some(n),
"max_string_size" => sb.max_string_size = Some(n),
"max_array_size" => sb.max_array_size = Some(n),
"max_map_size" => sb.max_map_size = Some(n),
"max_call_levels" => sb.max_call_levels = Some(n),
"max_expr_depth" => sb.max_expr_depth = Some(n),
other => return Err(anyhow!("unknown --sandbox key: {other:?}")),
}
}
Ok(Some(sb))
}
#[derive(Args)]
struct InvokeArgs {
id: String,
#[arg(long)]
body: Option<String>,
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)]
headers: Vec<(String, String)>,
}
#[derive(Subcommand)]
enum ApiKeysCmd {
/// Mint a new long-lived bearer key. Token printed exactly once.
Mint {
name: String,
/// Repeat for multiple scopes: `--scope script:read --scope log:read`.
#[arg(long = "scope", required = true)]
scopes: Vec<String>,
/// Bind the key to a single app (slug or id). Rejects
/// `instance:*` scopes when set.
#[arg(long)]
app: Option<String>,
/// Absolute RFC 3339 (`2026-12-31T23:59:59Z`) or shorthand
/// `<N>d`/`<N>h`/`<N>m`.
#[arg(long)]
expires: Option<String>,
},
/// List the caller's keys (no `raw_token` after mint).
Ls,
/// Revoke a key by id.
Rm { id: String },
}
#[derive(Args)]
struct LogsArgs {
script_id: String,
#[arg(long, default_value_t = 50)]
limit: u32,
/// Filter by execution origin: `http`, `kv`, `cron`, `queue`,
/// `invoke`, `dead_letter`, `docs`, `files`, `pubsub`, `email`.
/// Omit (or `all`) to show every source.
#[arg(long)]
source: Option<String>,
}
#[derive(Clone, Copy, ValueEnum)]
enum DispatchModeArg {
Sync,
Async,
}
impl From<DispatchModeArg> for picloud_shared::DispatchMode {
fn from(v: DispatchModeArg) -> Self {
match v {
DispatchModeArg::Sync => Self::Sync,
DispatchModeArg::Async => Self::Async,
}
}
}
/// Wire form for trigger dispatch — the trigger handlers accept
/// `"sync"` / `"async"` as JSON strings, so we send that directly.
const fn dispatch_wire(d: DispatchModeArg) -> &'static str {
match d {
DispatchModeArg::Sync => "sync",
DispatchModeArg::Async => "async",
}
}
#[derive(Clone, Copy, ValueEnum)]
enum HostKindArg {
/// Any host (default).
Any,
/// Exact `host` only.
Strict,
/// `*.<host>` — wildcard subdomain.
Wildcard,
}
impl From<HostKindArg> for picloud_shared::HostKind {
fn from(v: HostKindArg) -> Self {
match v {
HostKindArg::Any => Self::Any,
HostKindArg::Strict => Self::Strict,
HostKindArg::Wildcard => Self::Wildcard,
}
}
}
#[derive(Clone, Copy, ValueEnum)]
enum PathKindArg {
Exact,
Prefix,
Param,
}
impl From<PathKindArg> for picloud_shared::PathKind {
fn from(v: PathKindArg) -> Self {
match v {
PathKindArg::Exact => Self::Exact,
PathKindArg::Prefix => Self::Prefix,
PathKindArg::Param => Self::Param,
}
}
}
#[derive(Clone, Copy, ValueEnum)]
enum InstanceRoleArg {
Owner,
Admin,
Member,
}
impl From<InstanceRoleArg> for picloud_shared::InstanceRole {
fn from(v: InstanceRoleArg) -> Self {
match v {
InstanceRoleArg::Owner => Self::Owner,
InstanceRoleArg::Admin => Self::Admin,
InstanceRoleArg::Member => Self::Member,
}
}
}
#[derive(Subcommand)]
enum RoutesCmd {
/// List routes bound to a script.
Ls { script_id: String },
/// Create a new route. Host defaults to `*` (any). Path-kind
/// defaults to `exact`. Dispatch defaults to `sync`.
Create {
/// The script to bind. Routes inherit the script's `app_id`.
#[arg(long)]
script: String,
/// HTTP path (e.g. `/webhook`, `/users/:id`, `/blobs/*`).
#[arg(long)]
path: String,
/// HTTP method to match. Omit for ANY.
#[arg(long)]
method: Option<String>,
/// `exact`, `prefix`, or `param`.
#[arg(long = "path-kind", value_enum, default_value_t = PathKindArg::Exact)]
path_kind: PathKindArg,
/// Host pattern (e.g. `*`, `api.example.com`, `*.example.com`).
/// Pair with `--host-kind` when ambiguous; the CLI does not
/// auto-detect.
#[arg(long, default_value = "")]
host: String,
/// `any` (default), `strict`, or `wildcard`.
#[arg(long = "host-kind", value_enum, default_value_t = HostKindArg::Any)]
host_kind: HostKindArg,
/// Name to bind the host-wildcard segment to (for
/// `host-kind=wildcard` routes that want to read the subdomain).
#[arg(long = "host-param-name")]
host_param_name: Option<String>,
/// `sync` (default) or `async`. Async returns 202 immediately;
/// the dispatcher fires the script in the background.
#[arg(long, value_enum, default_value_t = DispatchModeArg::Sync)]
dispatch: DispatchModeArg,
},
/// Delete a route by id.
Rm { route_id: String },
/// Dry-run conflict check for a hypothetical route.
Check {
/// App slug or UUID (conflict checks are intra-app).
#[arg(long)]
app: String,
#[arg(long)]
path: String,
#[arg(long)]
method: Option<String>,
#[arg(long = "path-kind", value_enum, default_value_t = PathKindArg::Exact)]
path_kind: PathKindArg,
#[arg(long, default_value = "")]
host: String,
#[arg(long = "host-kind", value_enum, default_value_t = HostKindArg::Any)]
host_kind: HostKindArg,
},
/// What route would the given URL+method match, if any?
Match {
/// App slug or UUID.
#[arg(long)]
app: String,
/// Full URL — host+path must be present.
url: String,
#[arg(long, default_value = "GET")]
method: String,
},
}
#[derive(Subcommand)]
enum TriggersCmd {
/// List every trigger in an app.
Ls {
#[arg(long)]
app: String,
},
/// Delete a trigger by id.
Rm {
#[arg(long)]
app: String,
trigger_id: String,
},
/// Create a KV trigger.
#[command(name = "create-kv")]
CreateKv {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
/// Glob over collection names (`*`, `users`, `events_*`, …).
#[arg(long)]
collection: String,
/// Repeat to filter ops: `--op insert --op delete`. Empty
/// (the default) means any op fires the trigger.
#[arg(long = "op")]
ops: Vec<String>,
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
dispatch: DispatchModeArg,
},
/// Create a cron trigger.
#[command(name = "create-cron")]
CreateCron {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
/// 6-field cron expression (with seconds).
#[arg(long)]
schedule: String,
/// IANA timezone name. Defaults to UTC.
#[arg(long)]
timezone: Option<String>,
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
dispatch: DispatchModeArg,
},
/// Create a dead-letter trigger — fires when ANOTHER trigger's
/// retries exhaust. Filters narrow the match: omit to fire on
/// every DL in the app.
#[command(name = "create-dead-letter")]
CreateDeadLetter {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
/// Source filter — match only DL rows that originated from
/// `kv` / `docs` / `queue` / etc.
#[arg(long = "source-filter")]
source_filter: Option<String>,
/// Trigger id filter — match only DL rows from one trigger.
#[arg(long = "trigger-id-filter")]
trigger_id_filter: Option<String>,
/// Script id filter — match only DL rows from one script.
#[arg(long = "script-id-filter")]
script_id_filter: Option<String>,
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
dispatch: DispatchModeArg,
},
/// Create a docs trigger — fires on document mutations in matching
/// collections.
#[command(name = "create-docs")]
CreateDocs {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
/// Glob over collection names (`*`, `posts`, `events_*`, …).
#[arg(long)]
collection: String,
/// Repeat to filter ops: `--op insert --op delete`. Empty fires on any.
#[arg(long = "op")]
ops: Vec<String>,
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
dispatch: DispatchModeArg,
},
/// Create a files trigger — fires on blob create/update/delete in
/// matching collections.
#[command(name = "create-files")]
CreateFiles {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
#[arg(long)]
collection: String,
#[arg(long = "op")]
ops: Vec<String>,
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
dispatch: DispatchModeArg,
},
/// Create a pub/sub trigger — fires on messages published to topics
/// matching the pattern.
#[command(name = "create-pubsub")]
CreatePubsub {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
/// Topic glob (`*`, `orders.*`, `user.signup`, …).
#[arg(long = "topic")]
topic_pattern: String,
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
dispatch: DispatchModeArg,
},
/// Create a queue consumer trigger — fires per message claimed off
/// the named queue.
#[command(name = "create-queue")]
CreateQueue {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
#[arg(long = "queue")]
queue_name: String,
/// Per-message visibility timeout in seconds (claim lease).
#[arg(long = "visibility-timeout")]
visibility_timeout_secs: Option<u32>,
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
dispatch: DispatchModeArg,
},
/// Create an email trigger — fires on inbound mail POSTed to the
/// webhook receiver. No dispatch mode (inbound webhook only).
#[command(name = "create-email")]
CreateEmail {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
/// Shared HMAC secret the provider signs inbound POSTs with.
/// Omit to accept unsigned POSTs.
#[arg(long = "inbound-secret")]
inbound_secret: Option<String>,
},
/// Create a trigger of any kind from a raw JSON body — escape
/// hatch for advanced retry/dispatch settings beyond the per-kind
/// wrappers' defaults.
#[command(name = "create-from-json")]
CreateFromJson {
#[arg(long)]
app: String,
/// Trigger kind: `kv`, `docs`, `cron`, `files`, `pubsub`,
/// `dead_letter`, `email`, or `queue`.
#[arg(long)]
kind: String,
/// Body JSON. Pass inline, `@<file>`, or `-` for stdin.
#[arg(long)]
body: String,
},
}
#[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 /
/// dashboards — no row payloads, just the bare integer.
Count {
#[arg(long)]
app: String,
},
/// List dead-letter rows for an app.
Ls {
#[arg(long)]
app: String,
/// Show only unresolved rows.
#[arg(long)]
unresolved: bool,
#[arg(long, default_value_t = 50)]
limit: u32,
},
/// Show a single dead-letter row's full payload.
Show {
#[arg(long)]
app: String,
dl_id: String,
},
/// Re-enqueue the original event so the trigger fires again. The
/// DL row is marked resolved with reason `replayed`.
Replay {
#[arg(long)]
app: String,
dl_id: String,
},
/// Mark a DL row resolved without replaying. Useful for permanent
/// failures the operator has already addressed manually.
Resolve {
#[arg(long)]
app: String,
dl_id: String,
/// Free-form resolution reason. Required by the server.
#[arg(long)]
reason: String,
},
}
#[derive(Subcommand)]
enum SecretsCmd {
/// List secret names + last-modified for an app. Values never
/// leave the server.
Ls {
#[arg(long)]
app: String,
},
/// Set a secret. Reads the value from stdin (the only safe
/// channel — inline values would land in shell history). Pipe
/// the value in: `echo -n "mysecret" | pic secrets set --app foo my_key`.
Set {
#[arg(long)]
app: String,
name: String,
/// Treat stdin as raw JSON (numbers, maps, …) instead of a
/// string literal.
#[arg(long)]
json: bool,
},
/// Delete a secret by name.
Rm {
#[arg(long)]
app: String,
name: String,
},
}
#[derive(Subcommand)]
enum AdminsCmd {
/// List admin accounts.
Ls,
/// Create a new admin. Password via `--password -` reads from
/// stdin (the recommended path for non-interactive use). Without
/// `--instance-role`, defaults to `admin`.
Create {
username: String,
/// Password. Use `--password -` to read from stdin (interactive
/// no-echo prompt, or piped). An inline value is rejected — it
/// would leak into shell history, `ps`, and /proc.
#[arg(long)]
password: Option<String>,
/// `owner`, `admin`, or `member`. Defaults to `admin`.
#[arg(long = "instance-role", value_enum, default_value_t = InstanceRoleArg::Admin)]
instance_role: InstanceRoleArg,
#[arg(long)]
email: Option<String>,
},
/// Show a single admin by id.
Show { id: String },
/// Patch an admin. Pass only the fields you want to change.
Set {
id: String,
#[arg(long)]
username: Option<String>,
/// Use `--password -` to read from stdin. An inline value is
/// rejected (it leaks into shell history, `ps`, and /proc).
#[arg(long)]
password: Option<String>,
/// `true` to (re)activate, `false` to deactivate (deactivation
/// also expires every API key owned by the user).
#[arg(long)]
active: Option<bool>,
#[arg(long = "instance-role", value_enum)]
instance_role: Option<InstanceRoleArg>,
},
/// Delete an admin by id.
Rm { id: String },
}
#[tokio::main(flavor = "current_thread")]
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(),
args.username.as_deref(),
args.password_stdin,
)
.await
}
Cmd::Logout => cmds::logout::run().await,
Cmd::Whoami => cmds::whoami::run(mode).await,
Cmd::Apply(args) => cmds::apply::run(&args.file, args.prune, args.yes, mode).await,
Cmd::Plan(args) => cmds::plan::run(&args.file, mode).await,
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, mode).await,
Cmd::Init(args) => cmds::init::run(
&args.dir,
args.slug.as_deref(),
args.name.as_deref(),
args.force,
mode,
),
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
Cmd::Apps {
cmd:
AppsCmd::Create {
slug,
name,
description,
},
} => 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,
Cmd::Scripts {
cmd: ScriptsCmd::Deploy(args),
} => match args.script_config() {
Ok(cfg) => {
cmds::scripts::deploy(
&args.file,
&args.app,
args.name.as_deref(),
args.description.as_deref(),
&cfg,
mode,
)
.await
}
Err(e) => Err(e),
},
Cmd::Scripts {
cmd: ScriptsCmd::Invoke(args),
} => cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await,
Cmd::Scripts {
cmd: ScriptsCmd::Delete { id },
} => cmds::scripts::delete(&id).await,
Cmd::ApiKeys {
cmd:
ApiKeysCmd::Mint {
name,
scopes,
app,
expires,
},
} => cmds::api_keys::mint(&name, &scopes, app.as_deref(), expires.as_deref(), mode).await,
Cmd::ApiKeys {
cmd: ApiKeysCmd::Ls,
} => cmds::api_keys::ls(mode).await,
Cmd::ApiKeys {
cmd: ApiKeysCmd::Rm { id },
} => cmds::api_keys::rm(&id).await,
Cmd::Logs(LogsArgs {
script_id,
limit,
source,
}) => cmds::logs::run(&script_id, limit, source.as_deref(), mode).await,
Cmd::Invoke(args) => {
cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await
}
Cmd::Deploy(args) => match args.script_config() {
Ok(cfg) => {
cmds::scripts::deploy(
&args.file,
&args.app,
args.name.as_deref(),
args.description.as_deref(),
&cfg,
mode,
)
.await
}
Err(e) => Err(e),
},
Cmd::Routes {
cmd: RoutesCmd::Ls { script_id },
} => cmds::routes::ls(&script_id, mode).await,
Cmd::Routes {
cmd:
RoutesCmd::Create {
script,
path,
method,
path_kind,
host,
host_kind,
host_param_name,
dispatch,
},
} => {
cmds::routes::create(
&script,
method.as_deref(),
&path,
path_kind.into(),
&host,
host_kind.into(),
host_param_name.as_deref(),
dispatch.into(),
)
.await
}
Cmd::Routes {
cmd: RoutesCmd::Rm { route_id },
} => cmds::routes::rm(&route_id).await,
Cmd::Routes {
cmd:
RoutesCmd::Check {
app,
path,
method,
path_kind,
host,
host_kind,
},
} => {
cmds::routes::check(
&app,
method.as_deref(),
&path,
path_kind.into(),
&host,
host_kind.into(),
mode,
)
.await
}
Cmd::Routes {
cmd: RoutesCmd::Match { app, url, method },
} => cmds::routes::match_route(&app, &url, &method, mode).await,
Cmd::Admins { cmd: AdminsCmd::Ls } => cmds::admins::ls(mode).await,
Cmd::Admins {
cmd:
AdminsCmd::Create {
username,
password,
instance_role,
email,
},
} => {
cmds::admins::create(
&username,
password.as_deref(),
instance_role.into(),
email.as_deref(),
mode,
)
.await
}
Cmd::Admins {
cmd: AdminsCmd::Show { id },
} => cmds::admins::show(&id, mode).await,
Cmd::Admins {
cmd:
AdminsCmd::Set {
id,
username,
password,
active,
instance_role,
},
} => {
cmds::admins::set(
&id,
username.as_deref(),
password.as_deref(),
active,
instance_role.map(Into::into),
mode,
)
.await
}
Cmd::Admins {
cmd: AdminsCmd::Rm { id },
} => cmds::admins::rm(&id).await,
Cmd::Triggers {
cmd: TriggersCmd::Ls { app },
} => cmds::triggers::ls(&app, mode).await,
Cmd::Triggers {
cmd: TriggersCmd::Rm { app, trigger_id },
} => cmds::triggers::rm(&app, &trigger_id).await,
Cmd::Triggers {
cmd:
TriggersCmd::CreateKv {
app,
script,
collection,
ops,
dispatch,
},
} => {
cmds::triggers::create_kv(
&app,
&script,
&collection,
&ops,
dispatch_wire(dispatch),
mode,
)
.await
}
Cmd::Triggers {
cmd:
TriggersCmd::CreateCron {
app,
script,
schedule,
timezone,
dispatch,
},
} => {
cmds::triggers::create_cron(
&app,
&script,
&schedule,
timezone.as_deref(),
dispatch_wire(dispatch),
mode,
)
.await
}
Cmd::Triggers {
cmd:
TriggersCmd::CreateDeadLetter {
app,
script,
source_filter,
trigger_id_filter,
script_id_filter,
dispatch,
},
} => {
cmds::triggers::create_dead_letter(
&app,
&script,
source_filter.as_deref(),
trigger_id_filter.as_deref(),
script_id_filter.as_deref(),
dispatch_wire(dispatch),
mode,
)
.await
}
Cmd::Triggers {
cmd:
TriggersCmd::CreateDocs {
app,
script,
collection,
ops,
dispatch,
},
} => {
cmds::triggers::create_docs(
&app,
&script,
&collection,
&ops,
dispatch_wire(dispatch),
mode,
)
.await
}
Cmd::Triggers {
cmd:
TriggersCmd::CreateFiles {
app,
script,
collection,
ops,
dispatch,
},
} => {
cmds::triggers::create_files(
&app,
&script,
&collection,
&ops,
dispatch_wire(dispatch),
mode,
)
.await
}
Cmd::Triggers {
cmd:
TriggersCmd::CreatePubsub {
app,
script,
topic_pattern,
dispatch,
},
} => {
cmds::triggers::create_pubsub(
&app,
&script,
&topic_pattern,
dispatch_wire(dispatch),
mode,
)
.await
}
Cmd::Triggers {
cmd:
TriggersCmd::CreateQueue {
app,
script,
queue_name,
visibility_timeout_secs,
dispatch,
},
} => {
cmds::triggers::create_queue(
&app,
&script,
&queue_name,
visibility_timeout_secs,
dispatch_wire(dispatch),
mode,
)
.await
}
Cmd::Triggers {
cmd:
TriggersCmd::CreateEmail {
app,
script,
inbound_secret,
},
} => cmds::triggers::create_email(&app, &script, inbound_secret.as_deref(), mode).await,
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,
Cmd::DeadLetters {
cmd:
DeadLettersCmd::Ls {
app,
unresolved,
limit,
},
} => cmds::dead_letters::ls(&app, unresolved, limit, mode).await,
Cmd::DeadLetters {
cmd: DeadLettersCmd::Show { app, dl_id },
} => cmds::dead_letters::show(&app, &dl_id, mode).await,
Cmd::DeadLetters {
cmd: DeadLettersCmd::Replay { app, dl_id },
} => cmds::dead_letters::replay(&app, &dl_id).await,
Cmd::DeadLetters {
cmd: DeadLettersCmd::Resolve { app, dl_id, reason },
} => cmds::dead_letters::resolve(&app, &dl_id, &reason).await,
Cmd::Secrets {
cmd: SecretsCmd::Ls { app },
} => cmds::secrets::ls(&app, mode).await,
Cmd::Secrets {
cmd: SecretsCmd::Set { app, name, json },
} => cmds::secrets::set(&app, &name, json).await,
Cmd::Secrets {
cmd: SecretsCmd::Rm { app, name },
} => cmds::secrets::rm(&app, &name).await,
Cmd::Members {
cmd: MembersCmd::Ls { app },
} => cmds::members::ls(&app, mode).await,
Cmd::Members {
cmd: MembersCmd::Add { app, user_id, role },
} => cmds::members::add(&app, &user_id, &role, mode).await,
Cmd::Members {
cmd: MembersCmd::Set { app, user_id, role },
} => cmds::members::set(&app, &user_id, &role, mode).await,
Cmd::Members {
cmd: MembersCmd::Rm { app, user_id },
} => cmds::members::rm(&app, &user_id).await,
Cmd::Files {
cmd:
FilesCmd::Ls {
app,
collection,
limit,
},
} => cmds::files::ls(&app, &collection, limit, mode).await,
Cmd::Files {
cmd:
FilesCmd::Get {
app,
collection,
file_id,
out,
},
} => cmds::files::get(&app, &collection, &file_id, out.as_deref()).await,
Cmd::Files {
cmd:
FilesCmd::Rm {
app,
collection,
file_id,
},
} => cmds::files::rm(&app, &collection, &file_id).await,
Cmd::Queues {
cmd: QueuesCmd::Ls { app },
} => cmds::queues::ls(&app, mode).await,
Cmd::Queues {
cmd: QueuesCmd::Show { app, queue_name },
} => cmds::queues::show(&app, &queue_name, mode).await,
Cmd::Kv {
cmd:
KvCmd::Ls {
app,
collection,
limit,
},
} => cmds::kv::ls(&app, &collection, limit, mode).await,
Cmd::Kv {
cmd:
KvCmd::Get {
app,
collection,
key,
},
} => cmds::kv::get(&app, &collection, &key).await,
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
output::print_error(&err);
ExitCode::FAILURE
}
}
}