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>
185 lines
5.0 KiB
Rust
185 lines
5.0 KiB
Rust
//! `pic routes` subcommands: `ls`, `create`, `rm`, `check`, `match`.
|
|
|
|
use anyhow::{anyhow, Result};
|
|
use picloud_shared::{AppId, DispatchMode, HostKind, PathKind};
|
|
|
|
use crate::client::{CheckRouteBody, Client, CreateRouteBody, MatchRouteBody};
|
|
use crate::config;
|
|
use crate::output::{KvBlock, OutputMode, Table};
|
|
|
|
pub async fn ls(script_id: &str, mode: OutputMode) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let routes = client.routes_list_for_script(script_id).await?;
|
|
let mut table = Table::new([
|
|
"id",
|
|
"method",
|
|
"host",
|
|
"path_kind",
|
|
"path",
|
|
"dispatch",
|
|
"created_at",
|
|
]);
|
|
for r in routes {
|
|
table.row([
|
|
r.id.to_string(),
|
|
r.method.clone().unwrap_or_else(|| "ANY".into()),
|
|
host_label(r.host_kind, &r.host),
|
|
path_kind_label(r.path_kind).to_string(),
|
|
r.path.clone(),
|
|
dispatch_label(r.dispatch_mode).to_string(),
|
|
r.created_at.to_rfc3339(),
|
|
]);
|
|
}
|
|
table.print(mode);
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn create(
|
|
script_id: &str,
|
|
method: Option<&str>,
|
|
path: &str,
|
|
path_kind: PathKind,
|
|
host: &str,
|
|
host_kind: HostKind,
|
|
host_param_name: Option<&str>,
|
|
dispatch_mode: DispatchMode,
|
|
) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let body = CreateRouteBody {
|
|
host_kind,
|
|
host,
|
|
host_param_name,
|
|
path_kind,
|
|
path,
|
|
method,
|
|
dispatch_mode,
|
|
};
|
|
let r = client.routes_create(script_id, &body).await?;
|
|
println!(
|
|
"Created route {} ({} {} {})",
|
|
r.id,
|
|
r.method.unwrap_or_else(|| "ANY".into()),
|
|
host_label(r.host_kind, &r.host),
|
|
r.path
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn rm(route_id: &str) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
client.routes_delete(route_id).await?;
|
|
println!("Deleted route {route_id}");
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn check(
|
|
app: &str,
|
|
method: Option<&str>,
|
|
path: &str,
|
|
path_kind: PathKind,
|
|
host: &str,
|
|
host_kind: HostKind,
|
|
mode: OutputMode,
|
|
) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let app_id = resolve_app_id(&client, app).await?;
|
|
let body = CheckRouteBody {
|
|
app_id,
|
|
host_kind,
|
|
host: host.to_string(),
|
|
path_kind,
|
|
path: path.to_string(),
|
|
method: method.map(str::to_string),
|
|
};
|
|
let resp = client.routes_check(&body).await?;
|
|
let mut block = KvBlock::new();
|
|
block.field("ok", resp.ok.to_string());
|
|
if let Some(reason) = resp.conflict_reason {
|
|
block.field("conflict_reason", reason);
|
|
}
|
|
if let Some(r) = resp.conflicting_route {
|
|
block
|
|
.field("conflicting_route_id", r.id.to_string())
|
|
.field(
|
|
"conflicting_method",
|
|
r.method.unwrap_or_else(|| "ANY".into()),
|
|
)
|
|
.field("conflicting_host", host_label(r.host_kind, &r.host))
|
|
.field("conflicting_path", r.path);
|
|
}
|
|
block.print(mode);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn match_route(app: &str, url: &str, method: &str, mode: OutputMode) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let app_id = resolve_app_id(&client, app).await?;
|
|
let resp = client
|
|
.routes_match(&MatchRouteBody {
|
|
app_id,
|
|
url,
|
|
method,
|
|
})
|
|
.await?;
|
|
let mut block = KvBlock::new();
|
|
match resp.matched {
|
|
None => {
|
|
block.field("matched", "false");
|
|
}
|
|
Some(m) => {
|
|
block
|
|
.field("matched", "true")
|
|
.field("route_id", m.route_id)
|
|
.field("script_id", m.script_id.to_string());
|
|
if !m.params.is_empty() {
|
|
for (k, v) in m.params {
|
|
block.field(format!("param.{k}"), v);
|
|
}
|
|
}
|
|
if let Some(rest) = m.rest {
|
|
block.field("rest", rest);
|
|
}
|
|
}
|
|
}
|
|
block.print(mode);
|
|
Ok(())
|
|
}
|
|
|
|
async fn resolve_app_id(client: &Client, ident: &str) -> Result<AppId> {
|
|
// Accept either a slug or a UUID. The apps_get endpoint takes both.
|
|
let lookup = client
|
|
.apps_get(ident)
|
|
.await
|
|
.map_err(|e| anyhow!("resolve app `{ident}`: {e}"))?;
|
|
Ok(lookup.app.id)
|
|
}
|
|
|
|
const fn dispatch_label(d: DispatchMode) -> &'static str {
|
|
match d {
|
|
DispatchMode::Sync => "sync",
|
|
DispatchMode::Async => "async",
|
|
}
|
|
}
|
|
|
|
const fn path_kind_label(k: PathKind) -> &'static str {
|
|
match k {
|
|
PathKind::Exact => "exact",
|
|
PathKind::Prefix => "prefix",
|
|
PathKind::Param => "param",
|
|
}
|
|
}
|
|
|
|
fn host_label(kind: HostKind, host: &str) -> String {
|
|
match kind {
|
|
HostKind::Any => "*".to_string(),
|
|
HostKind::Strict => host.to_string(),
|
|
HostKind::Wildcard => format!("*.{host}"),
|
|
}
|
|
}
|