feat(cli): add pic docs/dead-letters ls group read mirrors

Two read-only operator surfaces existed server-side but the CLI was app-only:

- `pic dead-letters ls --group <slug>` — mirrors the app listing onto a group's
  shared-queue dead-letters (§11.6 D3, `GET /groups/{id}/dead-letters`), via the
  existing `require_one_owner`/`OwnerRef` dispatch. List-only (show/replay/
  resolve stay app-only, matching the server's operator surface); a group DL row
  shows its `collection` and carries no trigger, so it renders its own columns.
- `pic docs ls/get --group <slug>` — a new `Docs` command (`cmds/docs.rs`)
  wrapping `GET /groups/{id}/docs[/{collection}/{id}]`, mirroring `pic kv`.
  Group-only: per-app docs have no admin read route yet (unlike kv/files), so
  there is no `--app` variant — noted for a later pass.

Read-only by design (writes go through the SDK). New client methods +
`GroupDocsListDto`/`GroupDeadLetterDto`. Pinned by a new
`operator_reads_shared_docs_and_group_dead_letters_via_cli` journey
(152/152 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-13 20:00:37 +02:00
parent bc2538b5b5
commit 3da70a66c7
6 changed files with 348 additions and 27 deletions

View File

@@ -18,33 +18,73 @@ pub async fn count(app: &str, mode: OutputMode) -> Result<()> {
Ok(())
}
pub async fn ls(app: &str, unresolved: bool, limit: u32, mode: OutputMode) -> Result<()> {
/// `pic dead-letters ls` — list rows for an app (`--app`) or a group's shared
/// queues (`--group`, §11.6 D3; list-only, no show/replay/resolve — those stay
/// app-only, matching the server's operator surface).
pub async fn ls(
app: Option<&str>,
group: Option<&str>,
unresolved: bool,
limit: u32,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let resp = client.dead_letters_list(app, unresolved, limit).await?;
let mut table = Table::new([
"id",
"source",
"op",
"attempts",
"resolved",
"last_error",
"created_at",
]);
for d in resp.dead_letters {
let last_err = truncate(&d.last_error, 60);
let resolved = d.resolution.unwrap_or_else(|| "-".into());
table.row([
d.id,
d.source,
d.op,
d.attempt_count.to_string(),
resolved,
last_err,
d.created_at.to_rfc3339(),
]);
match crate::cmds::require_one_owner(app, group)? {
crate::cmds::OwnerRef::App(a) => {
let resp = client.dead_letters_list(a, unresolved, limit).await?;
let mut table = Table::new([
"id",
"source",
"op",
"attempts",
"resolved",
"last_error",
"created_at",
]);
for d in resp.dead_letters {
let resolved = d.resolution.unwrap_or_else(|| "-".into());
table.row([
d.id,
d.source,
d.op,
d.attempt_count.to_string(),
resolved,
truncate(&d.last_error, 60),
d.created_at.to_rfc3339(),
]);
}
table.print(mode);
}
crate::cmds::OwnerRef::Group(g) => {
let rows = client.group_dead_letters_list(g, unresolved, limit).await?;
// A group DL carries the `collection` it belonged to and no trigger.
let mut table = Table::new([
"id",
"collection",
"source",
"op",
"attempts",
"resolved",
"last_error",
"created_at",
]);
for d in rows {
let resolved = d.resolved_at.unwrap_or_else(|| "-".into());
table.row([
d.id,
d.collection,
d.source,
d.op,
d.attempt_count.to_string(),
resolved,
truncate(&d.last_error, 60),
d.created_at,
]);
}
table.print(mode);
}
}
table.print(mode);
Ok(())
}

View File

@@ -0,0 +1,38 @@
//! `pic docs ls | get --group` — read-only inspection of a group's shared-docs
//! collection (§11.6). Group-only: per-app docs have no admin read route yet
//! (unlike kv/files), so there is no `--app` variant here.
//!
//! Read-only by design (matching `pic kv`): docs writes go through
//! `docs::shared_collection(...).create/update` in scripts, which emit the
//! change events shared triggers depend on; an admin write would bypass that.
use anyhow::Result;
use crate::client::Client;
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn ls(group: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let page = client.group_docs_list(group, collection, limit).await?;
let mut table = Table::new(["id"]);
for d in page.docs {
table.row([d.id]);
}
table.print(mode);
if page.next_cursor.is_some() {
eprintln!("(more documents available — raise --limit to see them)");
}
Ok(())
}
pub async fn get(group: &str, collection: &str, doc_id: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let value = client.group_docs_get(group, collection, doc_id).await?;
// Emit the document JSON (pretty) so it pipes cleanly into jq.
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
println!("{pretty}");
Ok(())
}

View File

@@ -8,6 +8,7 @@ pub mod apps_domains;
pub mod collections;
pub mod config;
pub mod dead_letters;
pub mod docs;
pub mod extension_points;
pub mod files;
pub mod groups;