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