feat(cli): add pic triggers + dead-letters + secrets subcommands

Closes the audit's High-severity CLI coverage gap, raising the count
from ~25 to ~40+ subcommands and bringing the integration test count
from 63 to 73.

- pic triggers {ls, rm, create-kv, create-cron, create-dead-letter,
  create-from-json}: three per-kind wrappers cover the most common
  trigger shapes; the generic create-from-json is the escape hatch
  for docs/files/pubsub/email/queue and any future advanced retry
  knobs — body JSON inline, via @<file>, or "-" for stdin.

- pic dead-letters {ls, show, replay, resolve}: full operator
  workflow for the dispatcher's dead_letters rows, including the
  --unresolved filter and the per-row replay + manual-resolve actions.

- pic secrets {ls, set, rm}: list names + updated_at, set values
  via stdin (the only secure channel — inline values would leak
  into shell history), and delete by name. --json on set treats
  stdin as raw JSON for non-string values.

Ten new integration tests follow the established #[ignore] pattern,
gated on DATABASE_URL. All 73 ignored tests pass against the local
dev stack.

The `pic admin reset-password` server-binary subcommand exists on
the picloud binary side already; the audit's "surface it in pic
--help" note is a one-line addition deferred to Stage 6 with the
other small UX touches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-10 21:41:57 +02:00
parent 59645e8159
commit 24490d5ddb
10 changed files with 1215 additions and 0 deletions

View File

@@ -357,6 +357,128 @@ impl Client {
.await?;
decode_status(resp).await
}
/// `GET /api/v1/admin/apps/{id}/triggers`
pub async fn triggers_list(&self, app: &str) -> Result<TriggerListDto> {
let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/triggers"))
.send()
.await?;
decode(resp).await
}
/// `DELETE /api/v1/admin/apps/{id}/triggers/{trigger_id}`
pub async fn triggers_delete(&self, app: &str, trigger_id: &str) -> Result<()> {
let resp = self
.request(
Method::DELETE,
&format!("/api/v1/admin/apps/{app}/triggers/{trigger_id}"),
)
.send()
.await?;
decode_status(resp).await
}
/// `POST /api/v1/admin/apps/{id}/triggers/{kind}` — the body shape
/// depends on `kind`; the CLI passes a pre-built JSON value so
/// each create subcommand can construct its own per-kind body.
pub async fn triggers_create(
&self,
app: &str,
kind: &str,
body: &serde_json::Value,
) -> Result<TriggerDto> {
let resp = self
.request(
Method::POST,
&format!("/api/v1/admin/apps/{app}/triggers/{kind}"),
)
.json(body)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id}/dead_letters?unresolved={bool}&limit={n}`
pub async fn dead_letters_list(
&self,
app: &str,
unresolved: bool,
limit: u32,
) -> Result<DeadLetterListDto> {
let path =
format!("/api/v1/admin/apps/{app}/dead_letters?unresolved={unresolved}&limit={limit}");
let resp = self.request(Method::GET, &path).send().await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id}/dead_letters/{dl_id}`
pub async fn dead_letters_get(&self, app: &str, dl_id: &str) -> Result<DeadLetterDto> {
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/dead_letters/{dl_id}"),
)
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/apps/{id}/dead_letters/{dl_id}/replay`
pub async fn dead_letters_replay(&self, app: &str, dl_id: &str) -> Result<()> {
let resp = self
.request(
Method::POST,
&format!("/api/v1/admin/apps/{app}/dead_letters/{dl_id}/replay"),
)
.send()
.await?;
decode_status(resp).await
}
/// `POST /api/v1/admin/apps/{id}/dead_letters/{dl_id}/resolve`
pub async fn dead_letters_resolve(&self, app: &str, dl_id: &str, reason: &str) -> Result<()> {
let resp = self
.request(
Method::POST,
&format!("/api/v1/admin/apps/{app}/dead_letters/{dl_id}/resolve"),
)
.json(&serde_json::json!({ "reason": reason }))
.send()
.await?;
decode_status(resp).await
}
/// `GET /api/v1/admin/apps/{id}/secrets`
pub async fn secrets_list(&self, app: &str) -> Result<SecretListDto> {
let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/secrets"))
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/apps/{id}/secrets`
pub async fn secrets_set(&self, app: &str, name: &str, value: serde_json::Value) -> Result<()> {
let resp = self
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/secrets"))
.json(&serde_json::json!({ "name": name, "value": value }))
.send()
.await?;
decode_status(resp).await
}
/// `DELETE /api/v1/admin/apps/{id}/secrets/{name}`
pub async fn secrets_delete(&self, app: &str, name: &str) -> Result<()> {
let resp = self
.request(
Method::DELETE,
&format!("/api/v1/admin/apps/{app}/secrets/{name}"),
)
.send()
.await?;
decode_status(resp).await
}
}
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
@@ -505,6 +627,64 @@ pub struct AdminDto {
pub last_login_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
pub struct TriggerListDto {
pub triggers: Vec<TriggerDto>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct TriggerDto {
pub id: String,
pub app_id: AppId,
pub script_id: ScriptId,
pub kind: String,
pub enabled: bool,
pub dispatch_mode: String,
pub retry_max_attempts: u32,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub details: Value,
}
#[derive(Debug, Deserialize)]
pub struct DeadLetterListDto {
pub dead_letters: Vec<DeadLetterDto>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct DeadLetterDto {
pub id: String,
pub app_id: AppId,
pub source: String,
pub op: String,
pub trigger_id: Option<String>,
pub script_id: Option<ScriptId>,
pub payload: Value,
pub attempt_count: u32,
pub first_attempt_at: DateTime<Utc>,
pub last_attempt_at: DateTime<Utc>,
pub last_error: String,
pub created_at: DateTime<Utc>,
pub resolved_at: Option<DateTime<Utc>>,
pub resolution: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SecretListDto {
pub secrets: Vec<SecretItemDto>,
#[serde(default)]
#[allow(dead_code)]
pub next_cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SecretItemDto {
pub name: String,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
pub struct CreateScriptBody<'a> {
pub app_id: AppId,