feat(cli): add pic routes + pic admins subcommands

Closes the audit's Critical "operator can't deploy a serverless endpoint
end-to-end from the CLI" finding. Two new subcommand families bring the
CLI coverage from 14 commands to ~25:

- pic routes {ls, create, rm, check, match}: full route CRUD plus the
  dry-run conflict checker and the URL matcher already exposed by the
  dashboard. The create form accepts --path-kind, --host-kind, and
  --dispatch (sync|async) so async routes can finally be created
  headlessly. The ls output adds a dispatch column.

- pic admins {ls, create, show, set, rm}: per-instance admin user
  management. Create reads passwords from stdin via --password - so
  shell history never sees the cleartext. Set is a JSON-Merge-Patch
  shape that lets operators deactivate accounts or rotate roles
  without touching the dashboard.

Six new ignored integration tests follow the established #[ignore]
pattern (DATABASE_URL gates them). They cover the happy-path round
trip, the async-dispatch persistence path, the password-required
error path, and the capability gate (a Member sees HTTP 403). All
pass against the local dev stack with PICLOUD_DEV_MODE=true +
PICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure.

The `pic members` and `pic domains` subcommands the audit mentioned
are deferred — the apps_api shape may shift in v1.2 with per-app
roles and rebuilding the CLI surface twice would be churn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-10 21:35:09 +02:00
parent 649246213e
commit 59645e8159
9 changed files with 1177 additions and 2 deletions

View File

@@ -7,7 +7,7 @@
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Args, Parser, Subcommand};
use clap::{Args, Parser, Subcommand, ValueEnum};
mod client;
mod cmds;
@@ -70,6 +70,20 @@ enum Cmd {
/// 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,
},
}
#[derive(Args)]
@@ -185,6 +199,190 @@ struct LogsArgs {
limit: u32,
}
#[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,
}
}
}
#[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 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. `-` reads from stdin (one line interactive, or the
/// full input piped). Passing the password inline puts it in
/// shell history — pipe instead when scripting.
#[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>,
/// `-` reads from stdin. Inline value lands in shell history.
#[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();
@@ -256,6 +454,107 @@ async fn main() -> ExitCode {
)
.await
}
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,
};
match result {