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