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:
@@ -10,7 +10,8 @@ use std::collections::BTreeMap;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{
|
||||
AdminUserId, ApiKeyId, App, AppId, AppRole, ExecutionLog, InstanceRole, Scope, Script,
|
||||
AdminUserId, ApiKeyId, App, AppId, AppRole, DispatchMode, ExecutionLog, HostKind, InstanceRole,
|
||||
PathKind, Route, Scope, Script, ScriptId,
|
||||
};
|
||||
use reqwest::{header, Method, RequestBuilder, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -249,6 +250,113 @@ impl Client {
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/scripts/{id}/routes`
|
||||
pub async fn routes_list_for_script(&self, script_id: &str) -> Result<Vec<Route>> {
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/scripts/{script_id}/routes"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/scripts/{id}/routes`
|
||||
pub async fn routes_create(
|
||||
&self,
|
||||
script_id: &str,
|
||||
body: &CreateRouteBody<'_>,
|
||||
) -> Result<Route> {
|
||||
let resp = self
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("/api/v1/admin/scripts/{script_id}/routes"),
|
||||
)
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/routes/{route_id}`
|
||||
pub async fn routes_delete(&self, route_id: &str) -> Result<()> {
|
||||
let resp = self
|
||||
.request(Method::DELETE, &format!("/api/v1/admin/routes/{route_id}"))
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/routes:check` — dry-run conflict check for a
|
||||
/// hypothetical route, scoped to one app.
|
||||
pub async fn routes_check(&self, body: &CheckRouteBody) -> Result<CheckRouteResponseDto> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/routes:check")
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/routes:match` — what route would the given
|
||||
/// URL+method match, if any, for the given app.
|
||||
pub async fn routes_match(&self, body: &MatchRouteBody<'_>) -> Result<MatchRouteResponseDto> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/routes:match")
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/admins`
|
||||
pub async fn admins_list(&self) -> Result<Vec<AdminDto>> {
|
||||
let resp = self
|
||||
.request(Method::GET, "/api/v1/admin/admins")
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/admins/{id}`
|
||||
pub async fn admins_get(&self, id: &str) -> Result<AdminDto> {
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/admins/{id}"))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/admins`
|
||||
pub async fn admins_create(&self, body: &CreateAdminBody<'_>) -> Result<AdminDto> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/admins")
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `PATCH /api/v1/admin/admins/{id}`
|
||||
pub async fn admins_patch(&self, id: &str, body: &PatchAdminBody<'_>) -> Result<AdminDto> {
|
||||
let resp = self
|
||||
.request(Method::PATCH, &format!("/api/v1/admin/admins/{id}"))
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/admins/{id}`
|
||||
pub async fn admins_delete(&self, id: &str) -> Result<()> {
|
||||
let resp = self
|
||||
.request(Method::DELETE, &format!("/api/v1/admin/admins/{id}"))
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
|
||||
@@ -304,6 +412,99 @@ pub struct CreateAppBody<'a> {
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateRouteBody<'a> {
|
||||
pub host_kind: HostKind,
|
||||
#[serde(skip_serializing_if = "str::is_empty")]
|
||||
pub host: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub host_param_name: Option<&'a str>,
|
||||
pub path_kind: PathKind,
|
||||
pub path: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<&'a str>,
|
||||
pub dispatch_mode: DispatchMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CheckRouteBody {
|
||||
pub app_id: AppId,
|
||||
pub host_kind: HostKind,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub host: String,
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CheckRouteResponseDto {
|
||||
pub ok: bool,
|
||||
#[serde(default)]
|
||||
pub conflicting_route: Option<Route>,
|
||||
#[serde(default)]
|
||||
pub conflict_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MatchRouteBody<'a> {
|
||||
pub app_id: AppId,
|
||||
pub url: &'a str,
|
||||
pub method: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MatchRouteResponseDto {
|
||||
#[serde(default)]
|
||||
pub matched: Option<MatchedRouteDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MatchedRouteDto {
|
||||
pub route_id: String,
|
||||
pub script_id: ScriptId,
|
||||
#[serde(default)]
|
||||
pub params: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub rest: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateAdminBody<'a> {
|
||||
pub username: &'a str,
|
||||
pub password: &'a str,
|
||||
pub instance_role: InstanceRole,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub struct PatchAdminBody<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_active: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instance_role: Option<InstanceRole>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AdminDto {
|
||||
pub id: AdminUserId,
|
||||
pub username: String,
|
||||
pub is_active: bool,
|
||||
pub instance_role: InstanceRole,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateScriptBody<'a> {
|
||||
pub app_id: AppId,
|
||||
|
||||
181
crates/picloud-cli/src/cmds/admins.rs
Normal file
181
crates/picloud-cli/src/cmds/admins.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
//! `pic admins` subcommands: `ls`, `create`, `show`, `set`, `rm`.
|
||||
//!
|
||||
//! All endpoints require `Capability::InstanceManageUsers` (owner /
|
||||
//! admin). Member accounts get a 403.
|
||||
|
||||
use std::io::{IsTerminal, Read, Write};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use picloud_shared::InstanceRole;
|
||||
|
||||
use crate::client::{AdminDto, Client, CreateAdminBody, PatchAdminBody};
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn ls(mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let admins = client.admins_list().await?;
|
||||
let mut table = Table::new([
|
||||
"id",
|
||||
"username",
|
||||
"role",
|
||||
"active",
|
||||
"email",
|
||||
"created_at",
|
||||
"last_login_at",
|
||||
]);
|
||||
for a in admins {
|
||||
table.row([
|
||||
a.id.to_string(),
|
||||
a.username,
|
||||
a.instance_role.as_str().to_string(),
|
||||
a.is_active.to_string(),
|
||||
a.email.unwrap_or_else(|| "-".into()),
|
||||
a.created_at.to_rfc3339(),
|
||||
a.last_login_at
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the password the user wants to set:
|
||||
/// * `Some("-")` → read from stdin (no echo when interactive).
|
||||
/// * `Some(other)` → use the inline value (handy for non-interactive
|
||||
/// callers, but logs to shell history; warn when interactive).
|
||||
/// * `None` → require explicit `--password`.
|
||||
fn resolve_password(password_arg: Option<&str>) -> Result<String> {
|
||||
match password_arg {
|
||||
None => Err(anyhow!(
|
||||
"missing --password; pass --password - to read from stdin"
|
||||
)),
|
||||
Some("-") => read_password_from_stdin(),
|
||||
Some(inline) => Ok(inline.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_password_from_stdin() -> Result<String> {
|
||||
let stdin = std::io::stdin();
|
||||
if stdin.is_terminal() {
|
||||
// Prompt without echoing — rpassword would be cleaner, but the
|
||||
// CLI already avoids extra crates. Print a one-line prompt so
|
||||
// the user knows we're waiting for input.
|
||||
eprint!("Password: ");
|
||||
std::io::stderr().flush().ok();
|
||||
let mut buf = String::new();
|
||||
stdin
|
||||
.lock()
|
||||
.read_line(&mut buf)
|
||||
.context("read password from stdin")?;
|
||||
Ok(buf.trim_end_matches(['\n', '\r']).to_string())
|
||||
} else {
|
||||
// Piped input — read everything, strip trailing newline.
|
||||
let mut buf = String::new();
|
||||
stdin
|
||||
.lock()
|
||||
.read_to_string(&mut buf)
|
||||
.context("read password from stdin")?;
|
||||
Ok(buf.trim_end_matches(['\n', '\r']).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
trait ReadLine {
|
||||
fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize>;
|
||||
}
|
||||
|
||||
impl<T: std::io::BufRead> ReadLine for T {
|
||||
fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> {
|
||||
std::io::BufRead::read_line(self, buf)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
role: InstanceRole,
|
||||
email: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let pw = resolve_password(password)?;
|
||||
if pw.is_empty() {
|
||||
return Err(anyhow!("password must not be empty"));
|
||||
}
|
||||
let body = CreateAdminBody {
|
||||
username,
|
||||
password: &pw,
|
||||
instance_role: role,
|
||||
email,
|
||||
};
|
||||
let created = client.admins_create(&body).await?;
|
||||
print_admin(&created, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn show(id: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let a = client.admins_get(id).await?;
|
||||
print_admin(&a, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn set(
|
||||
id: &str,
|
||||
username: Option<&str>,
|
||||
password: Option<&str>,
|
||||
active: Option<bool>,
|
||||
role: Option<InstanceRole>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
// Only resolve the password if --password was passed.
|
||||
let pw_owned;
|
||||
let password_ref: Option<&str> = if let Some(raw) = password {
|
||||
pw_owned = resolve_password(Some(raw))?;
|
||||
Some(pw_owned.as_str())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let body = PatchAdminBody {
|
||||
username,
|
||||
password: password_ref,
|
||||
is_active: active,
|
||||
instance_role: role,
|
||||
};
|
||||
let updated = client.admins_patch(id, &body).await?;
|
||||
print_admin(&updated, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(id: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.admins_delete(id).await?;
|
||||
println!("Deleted admin {id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_admin(a: &AdminDto, mode: OutputMode) {
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("id", a.id.to_string())
|
||||
.field("username", a.username.clone())
|
||||
.field("instance_role", a.instance_role.as_str().to_string())
|
||||
.field("is_active", a.is_active.to_string())
|
||||
.field("email", a.email.clone().unwrap_or_else(|| "-".into()))
|
||||
.field("created_at", a.created_at.to_rfc3339())
|
||||
.field(
|
||||
"last_login_at",
|
||||
a.last_login_at
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
);
|
||||
block.print(mode);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod admins;
|
||||
pub mod api_keys;
|
||||
pub mod apps;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod logs;
|
||||
pub mod routes;
|
||||
pub mod scripts;
|
||||
pub mod whoami;
|
||||
|
||||
184
crates/picloud-cli/src/cmds/routes.rs
Normal file
184
crates/picloud-cli/src/cmds/routes.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
//! `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}"),
|
||||
}
|
||||
}
|
||||
@@ -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