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

@@ -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);
}