feat: E2E #2 (Stash) gap remediation + S6 hardening
Closes the gaps and the one security finding from the second end-to-end
CLI test (E2E_STASH_REPORT.md), plus the H1 boot-regression found while
re-reviewing those fixes.
Security
- S6: reserved-path validation (`check_reserved`) now case-folds before
comparing, so `/API/v2/x`, `/HEALTHZ`, `/Admin/x` are rejected like
their lowercase forms. Request-time matching stays case-sensitive.
- S10: "public route != public data" callout in sdk-shape.md (script_gate
skips authz when the principal is anonymous).
Observability / features
- G1: trigger executions now write `execution_logs`. Migration 0043 adds
a `source` column (CHECK mirrors ExecutionSource/OutboxSourceKind,
DEFAULT 'http' backfills history); a shared `build_execution_log` helper
in executor-core; dispatcher logging for outbox triggers + queue
consumers (skips sync-HTTP rows the orchestrator already logs). `pic
logs` gains a source column + `--source` filter.
- G5: dev-only in-memory email capture under PICLOUD_DEV_MODE with no SMTP
(email::send succeeds locally), readable at GET /api/v1/admin/dev/emails
(Owner/Admin only; route mounted only in capture mode).
- G6: generalized the Rhai in-place-mutation footgun note (trim/replace/
make_upper/make_lower/crop/truncate/pad return ()).
- G2/G3/G4 (CLI): `pic members`, `pic files`, `pic queues`, read-only
`pic kv` (+ new kv_api.rs); `pic deploy --timeout/--memory/--kind/
--sandbox`; first-class `pic triggers create-{docs,files,pubsub,queue,
email}` wrappers. All new client path segments percent-encoded via seg().
H1 regression fix (found in re-review)
- The S6 change also runs in `compile_routes`, which compiles every stored
route at boot and on each route CRUD. A single stored route the new
validation rejects (creatable while the S6 gap existed) made the whole
compile Err and aborted startup. `compile_routes` is now lenient: it
skips an un-compilable row with a warning instead of bricking boot
(route creation still validates separately). Migration 0044 sweeps
pre-existing reserved-path routes on upgrade (WHERE mirrors
check_reserved exactly). Added regression tests for both.
Verified: cargo fmt, clippy --all-targets --all-features -D warnings, the
schema_snapshot test, and the new S6/lenient-compile unit tests all pass;
boot-resilience and G1/G5 confirmed live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ use chrono::{DateTime, Utc};
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
|
||||
use picloud_shared::{
|
||||
AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog,
|
||||
HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId,
|
||||
HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind, ScriptSandbox,
|
||||
};
|
||||
use reqwest::{header, Method, RequestBuilder, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -171,10 +171,23 @@ impl Client {
|
||||
}
|
||||
|
||||
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
|
||||
/// uses PUT despite the field-level update semantics.
|
||||
pub async fn scripts_update_source(&self, id: &str, source: &str) -> Result<Script> {
|
||||
/// uses PUT despite the field-level update semantics. `cfg` carries
|
||||
/// optional per-script runtime overrides (G3); unset fields are
|
||||
/// omitted so they keep their stored value.
|
||||
pub async fn scripts_update_source(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &str,
|
||||
cfg: &ScriptConfig,
|
||||
) -> Result<Script> {
|
||||
let id = seg(id);
|
||||
let body = UpdateScriptBody { source };
|
||||
let body = UpdateScriptBody {
|
||||
source,
|
||||
timeout_seconds: cfg.timeout_seconds,
|
||||
memory_limit_mb: cfg.memory_limit_mb,
|
||||
kind: cfg.kind,
|
||||
sandbox: cfg.sandbox,
|
||||
};
|
||||
let resp = self
|
||||
.request(Method::PUT, &format!("/api/v1/admin/scripts/{id}"))
|
||||
.json(&body)
|
||||
@@ -222,15 +235,19 @@ impl Client {
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/scripts/{id}/logs?limit=N`
|
||||
pub async fn logs_list(&self, script_id: &str, limit: u32) -> Result<Vec<ExecutionLog>> {
|
||||
pub async fn logs_list(
|
||||
&self,
|
||||
script_id: &str,
|
||||
limit: u32,
|
||||
source: Option<&str>,
|
||||
) -> Result<Vec<ExecutionLog>> {
|
||||
let script_id = seg(script_id);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let mut path = format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}");
|
||||
if let Some(src) = source {
|
||||
path.push_str("&source=");
|
||||
path.push_str(&seg(src));
|
||||
}
|
||||
let resp = self.request(Method::GET, &path).send().await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
@@ -709,6 +726,180 @@ impl Client {
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
// --- App membership (G2) ----------------------------------------------
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/members`
|
||||
pub async fn members_list(&self, app: &str) -> Result<Vec<AppMemberDto>> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/members"))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id_or_slug}/members`
|
||||
pub async fn members_grant(
|
||||
&self,
|
||||
app: &str,
|
||||
user_id: &str,
|
||||
role: AppRole,
|
||||
) -> Result<AppMemberDto> {
|
||||
let app = seg(app);
|
||||
let body = serde_json::json!({ "user_id": user_id, "role": role });
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/members"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `PATCH /api/v1/admin/apps/{id_or_slug}/members/{user_id}`
|
||||
pub async fn members_set_role(
|
||||
&self,
|
||||
app: &str,
|
||||
user_id: &str,
|
||||
role: AppRole,
|
||||
) -> Result<AppMemberDto> {
|
||||
let (app, user_id) = (seg(app), seg(user_id));
|
||||
let body = serde_json::json!({ "role": role });
|
||||
let resp = self
|
||||
.request(
|
||||
Method::PATCH,
|
||||
&format!("/api/v1/admin/apps/{app}/members/{user_id}"),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/apps/{id_or_slug}/members/{user_id}`
|
||||
pub async fn members_remove(&self, app: &str, user_id: &str) -> Result<()> {
|
||||
let (app, user_id) = (seg(app), seg(user_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/admin/apps/{app}/members/{user_id}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
// --- Files (G2, read-only admin surface) ------------------------------
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/files?collection=&limit=`
|
||||
pub async fn files_list(
|
||||
&self,
|
||||
app: &str,
|
||||
collection: &str,
|
||||
limit: u32,
|
||||
) -> Result<ListFilesResponse> {
|
||||
let app = seg(app);
|
||||
let collection = seg(collection);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/files?collection={collection}&limit={limit}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/files/{collection}/{file_id}` —
|
||||
/// streams the raw bytes (download). Returns the body verbatim.
|
||||
pub async fn files_get_bytes(
|
||||
&self,
|
||||
app: &str,
|
||||
collection: &str,
|
||||
file_id: &str,
|
||||
) -> Result<Vec<u8>> {
|
||||
let (app, collection, file_id) = (seg(app), seg(collection), seg(file_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/files/{collection}/{file_id}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
if resp.status().is_success() {
|
||||
Ok(resp.bytes().await.context("reading file bytes")?.to_vec())
|
||||
} else {
|
||||
Err(server_error(resp).await)
|
||||
}
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/apps/{id_or_slug}/files/{collection}/{file_id}`
|
||||
pub async fn files_delete(&self, app: &str, collection: &str, file_id: &str) -> Result<()> {
|
||||
let (app, collection, file_id) = (seg(app), seg(collection), seg(file_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/admin/apps/{app}/files/{collection}/{file_id}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
// --- KV (G2, read-only admin surface) ---------------------------------
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/kv?collection=&limit=`
|
||||
pub async fn kv_list(&self, app: &str, collection: &str, limit: u32) -> Result<KvListPageDto> {
|
||||
let app = seg(app);
|
||||
let collection = seg(collection);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/kv?collection={collection}&limit={limit}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/kv/{collection}/{key}`
|
||||
pub async fn kv_get(&self, app: &str, collection: &str, key: &str) -> Result<Value> {
|
||||
let (app, collection, key) = (seg(app), seg(collection), seg(key));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/kv/{collection}/{key}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let wrapped: KvGetResponse = decode(resp).await?;
|
||||
Ok(wrapped.value)
|
||||
}
|
||||
|
||||
// --- Queues (G2, read-only admin surface) -----------------------------
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/queues`
|
||||
pub async fn queues_list(&self, app: &str) -> Result<Vec<QueueSummaryDto>> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/queues"))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/queues/{queue_name}`
|
||||
pub async fn queue_get(&self, app: &str, queue_name: &str) -> Result<QueueDetailDto> {
|
||||
let (app, queue_name) = (seg(app), seg(queue_name));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/queues/{queue_name}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
|
||||
@@ -957,6 +1148,17 @@ pub struct SecretItemDto {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Per-script runtime config the CLI can now set (G3). All optional — an
|
||||
/// unset field is omitted so the server applies its own default (and the
|
||||
/// `PICLOUD_SANDBOX_MAX_*` admin ceilings still clamp overrides).
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ScriptConfig {
|
||||
pub timeout_seconds: Option<i32>,
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
pub kind: Option<ScriptKind>,
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateScriptBody<'a> {
|
||||
pub app_id: AppId,
|
||||
@@ -964,11 +1166,104 @@ pub struct CreateScriptBody<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
pub source: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_seconds: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<ScriptKind>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UpdateScriptBody<'a> {
|
||||
source: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
timeout_seconds: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
memory_limit_mb: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
kind: Option<ScriptKind>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
sandbox: Option<ScriptSandbox>,
|
||||
}
|
||||
|
||||
// --- G2 response DTOs (deserialize-only mirrors of the server shapes) ---
|
||||
|
||||
// `#[allow(dead_code)]` on a couple of fields below: these structs mirror
|
||||
// the full server response shape (so the surface is documented and future
|
||||
// columns are a one-line add), but the TSV/KvBlock renderers don't print
|
||||
// every field today.
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AppMemberDto {
|
||||
pub user_id: AdminUserId,
|
||||
pub username: String,
|
||||
#[allow(dead_code)]
|
||||
pub email: Option<String>,
|
||||
pub instance_role: InstanceRole,
|
||||
pub is_active: bool,
|
||||
pub role: AppRole,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FileMetaDto {
|
||||
pub id: String,
|
||||
#[allow(dead_code)]
|
||||
pub collection: String,
|
||||
pub name: String,
|
||||
pub content_type: String,
|
||||
pub size: u64,
|
||||
#[allow(dead_code)]
|
||||
pub checksum: String,
|
||||
#[allow(dead_code)]
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListFilesResponse {
|
||||
pub files: Vec<FileMetaDto>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct KvListPageDto {
|
||||
pub keys: Vec<String>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct KvGetResponse {
|
||||
value: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct QueueSummaryDto {
|
||||
pub queue_name: String,
|
||||
pub total: u64,
|
||||
pub pending: u64,
|
||||
pub claimed: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct QueueConsumerDto {
|
||||
pub trigger_id: String,
|
||||
pub script_id: ScriptId,
|
||||
pub script_name: String,
|
||||
pub visibility_timeout_secs: u32,
|
||||
pub last_fired_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct QueueDetailDto {
|
||||
pub queue_name: String,
|
||||
pub total: u64,
|
||||
pub pending: u64,
|
||||
pub claimed: u64,
|
||||
pub consumer: Option<QueueConsumerDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
71
crates/picloud-cli/src/cmds/files.rs
Normal file
71
crates/picloud-cli/src/cmds/files.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! `pic files ls | get | rm` — operator-facing files inspection (G2).
|
||||
//!
|
||||
//! Wraps the read-only `/api/v1/admin/apps/{id}/files*` surface. There is
|
||||
//! no `set`/`upload` — blob writes go through scripts (`files::create`);
|
||||
//! the admin surface is inspect + delete only, matching the dashboard.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let page = client.files_list(app, collection, limit).await?;
|
||||
let mut table = Table::new(["id", "name", "content_type", "size", "updated_at"]);
|
||||
for f in page.files {
|
||||
table.row([
|
||||
f.id,
|
||||
f.name,
|
||||
f.content_type,
|
||||
f.size.to_string(),
|
||||
f.updated_at,
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
if page.next_cursor.is_some() {
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"(more results available — raise --limit to see them)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download a file's bytes. With `--out <path>` writes to disk; otherwise
|
||||
/// streams raw bytes to stdout (pipe to a file or `xxd`).
|
||||
pub async fn get(app: &str, collection: &str, file_id: &str, out: Option<&Path>) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let bytes = client.files_get_bytes(app, collection, file_id).await?;
|
||||
match out {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &bytes).with_context(|| format!("writing {}", path.display()))?;
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"Wrote {} bytes to {}",
|
||||
bytes.len(),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
None => {
|
||||
std::io::stdout()
|
||||
.write_all(&bytes)
|
||||
.context("writing bytes to stdout")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(app: &str, collection: &str, file_id: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.files_delete(app, collection, file_id).await?;
|
||||
println!("Deleted file {file_id} from {collection}");
|
||||
Ok(())
|
||||
}
|
||||
42
crates/picloud-cli/src/cmds/kv.rs
Normal file
42
crates/picloud-cli/src/cmds/kv.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! `pic kv ls | get` — read-only KV inspection (G2).
|
||||
//!
|
||||
//! Wraps the read-only `/api/v1/admin/apps/{id}/kv*` surface. There is no
|
||||
//! `set`/`rm` on purpose: KV writes go through `kv::set` in scripts (which
|
||||
//! emit the change events triggers depend on); an admin write would bypass
|
||||
//! that, so the CLI stays read-only (matching the queues precedent).
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let page = client.kv_list(app, collection, limit).await?;
|
||||
let mut table = Table::new(["key"]);
|
||||
for k in page.keys {
|
||||
table.row([k]);
|
||||
}
|
||||
table.print(mode);
|
||||
if page.next_cursor.is_some() {
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"(more keys available — raise --limit to see them)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(app: &str, collection: &str, key: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let value = client.kv_get(app, collection, key).await?;
|
||||
// Always emit the JSON value (pretty) so it pipes cleanly into jq.
|
||||
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
|
||||
println!("{pretty}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -12,10 +12,15 @@ use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
pub async fn run(
|
||||
script_id: &str,
|
||||
limit: u32,
|
||||
source: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let entries = client.logs_list(script_id, limit).await?;
|
||||
let entries = client.logs_list(script_id, limit, source).await?;
|
||||
match mode {
|
||||
OutputMode::Tsv => render_tsv(&entries),
|
||||
OutputMode::Json => render_json(&entries),
|
||||
@@ -24,11 +29,14 @@ pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
}
|
||||
|
||||
fn render_tsv(entries: &[ExecutionLog]) {
|
||||
let mut table = Table::new(["created_at", "status", "summary"]);
|
||||
// `source` is shown so background runs (queue/cron/invoke/…) are
|
||||
// distinguishable from HTTP at a glance — the whole point of G1.
|
||||
let mut table = Table::new(["created_at", "source", "status", "summary"]);
|
||||
for e in entries {
|
||||
let summary = summarize(&e.response_body, &e.script_logs);
|
||||
table.row([
|
||||
e.created_at.to_rfc3339(),
|
||||
e.source.as_str().to_string(),
|
||||
status_label(&e.status).to_string(),
|
||||
truncate(&summary, 120),
|
||||
]);
|
||||
|
||||
86
crates/picloud-cli/src/cmds/members.rs
Normal file
86
crates/picloud-cli/src/cmds/members.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
//! `pic members ls | add | set | rm` — manage app membership (G2).
|
||||
//!
|
||||
//! Wraps `/api/v1/admin/apps/{id}/members*`. All gated on
|
||||
//! `AppAdmin(app)` server-side; Editors/Viewers get a 403.
|
||||
|
||||
use anyhow::Result;
|
||||
use picloud_shared::AppRole;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let members = client.members_list(app).await?;
|
||||
let mut table = Table::new(["user_id", "username", "role", "instance_role", "active"]);
|
||||
for m in members {
|
||||
table.row([
|
||||
m.user_id.to_string(),
|
||||
m.username,
|
||||
app_role_str(m.role).to_string(),
|
||||
format!("{:?}", m.instance_role).to_lowercase(),
|
||||
m.is_active.to_string(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add(app: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let m = client
|
||||
.members_grant(app, user_id, parse_role(role)?)
|
||||
.await?;
|
||||
print_member(&m, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set(app: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let m = client
|
||||
.members_set_role(app, user_id, parse_role(role)?)
|
||||
.await?;
|
||||
print_member(&m, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(app: &str, user_id: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.members_remove(app, user_id).await?;
|
||||
println!("Removed {user_id} from {app}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_member(m: &crate::client::AppMemberDto, mode: OutputMode) {
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("user_id", m.user_id.to_string())
|
||||
.field("username", m.username.clone())
|
||||
.field("role", app_role_str(m.role))
|
||||
.field("created_at", m.created_at.to_rfc3339());
|
||||
block.print(mode);
|
||||
}
|
||||
|
||||
fn parse_role(s: &str) -> Result<AppRole> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"app_admin" | "admin" => Ok(AppRole::AppAdmin),
|
||||
"editor" => Ok(AppRole::Editor),
|
||||
"viewer" => Ok(AppRole::Viewer),
|
||||
other => Err(anyhow::anyhow!(
|
||||
"unknown role {other:?} (want app_admin | editor | viewer)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn app_role_str(r: AppRole) -> &'static str {
|
||||
match r {
|
||||
AppRole::AppAdmin => "app_admin",
|
||||
AppRole::Editor => "editor",
|
||||
AppRole::Viewer => "viewer",
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,13 @@ pub mod api_keys;
|
||||
pub mod apps;
|
||||
pub mod apps_domains;
|
||||
pub mod dead_letters;
|
||||
pub mod files;
|
||||
pub mod kv;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod logs;
|
||||
pub mod members;
|
||||
pub mod queues;
|
||||
pub mod routes;
|
||||
pub mod scripts;
|
||||
pub mod secrets;
|
||||
|
||||
62
crates/picloud-cli/src/cmds/queues.rs
Normal file
62
crates/picloud-cli/src/cmds/queues.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
//! `pic queues ls | show` — read-only queue inspection (G2).
|
||||
//!
|
||||
//! Wraps the read-only `/api/v1/admin/apps/{id}/queues*` surface (no
|
||||
//! purge/requeue — that is v1.2). Gated on `AppLogRead` server-side.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let queues = client.queues_list(app).await?;
|
||||
let mut table = Table::new(["queue", "total", "pending", "claimed"]);
|
||||
for q in queues {
|
||||
table.row([
|
||||
q.queue_name,
|
||||
q.total.to_string(),
|
||||
q.pending.to_string(),
|
||||
q.claimed.to_string(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn show(app: &str, queue_name: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let q = client.queue_get(app, queue_name).await?;
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("queue", q.queue_name)
|
||||
.field("total", q.total.to_string())
|
||||
.field("pending", q.pending.to_string())
|
||||
.field("claimed", q.claimed.to_string());
|
||||
match q.consumer {
|
||||
Some(c) => {
|
||||
block
|
||||
.field("consumer_script", c.script_name)
|
||||
.field("consumer_script_id", c.script_id.to_string())
|
||||
.field("consumer_trigger_id", c.trigger_id)
|
||||
.field(
|
||||
"visibility_timeout_secs",
|
||||
c.visibility_timeout_secs.to_string(),
|
||||
)
|
||||
.field(
|
||||
"last_fired_at",
|
||||
c.last_fired_at
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
block.field("consumer", "(none registered)");
|
||||
}
|
||||
}
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use anyhow::{anyhow, Context, Result};
|
||||
use picloud_shared::AppId;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::{Client, CreateScriptBody};
|
||||
use crate::client::{Client, CreateScriptBody, ScriptConfig};
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
@@ -62,6 +62,7 @@ pub async fn deploy(
|
||||
app_ident: &str,
|
||||
name_override: Option<&str>,
|
||||
description: Option<&str>,
|
||||
cfg: &ScriptConfig,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
@@ -90,7 +91,7 @@ pub async fn deploy(
|
||||
let existing = client.scripts_list_by_app(app_ident).await?;
|
||||
let (script, action) = if let Some(s) = existing.into_iter().find(|s| s.name == name) {
|
||||
let updated = client
|
||||
.scripts_update_source(&s.id.to_string(), &source)
|
||||
.scripts_update_source(&s.id.to_string(), &source, cfg)
|
||||
.await?;
|
||||
(updated, "updated")
|
||||
} else {
|
||||
@@ -99,6 +100,10 @@ pub async fn deploy(
|
||||
name: &name,
|
||||
description,
|
||||
source: &source,
|
||||
timeout_seconds: cfg.timeout_seconds,
|
||||
memory_limit_mb: cfg.memory_limit_mb,
|
||||
kind: cfg.kind,
|
||||
sandbox: cfg.sandbox,
|
||||
};
|
||||
(client.scripts_create(&body).await?, "created")
|
||||
};
|
||||
|
||||
@@ -124,6 +124,114 @@ pub async fn create_dead_letter(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// docs/files share KV's `{collection_glob, ops?}` shape.
|
||||
async fn create_collection_trigger(
|
||||
kind: &str,
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
collection_glob: &str,
|
||||
ops: &[String],
|
||||
dispatch: &str,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let mut body = base_body(script_id, dispatch);
|
||||
body["collection_glob"] = json!(collection_glob);
|
||||
if !ops.is_empty() {
|
||||
body["ops"] = json!(ops);
|
||||
}
|
||||
let created = client.triggers_create(app, kind, &body).await?;
|
||||
print_created(&created, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_docs(
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
collection_glob: &str,
|
||||
ops: &[String],
|
||||
dispatch: &str,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
create_collection_trigger("docs", app, script_id, collection_glob, ops, dispatch, mode).await
|
||||
}
|
||||
|
||||
pub async fn create_files(
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
collection_glob: &str,
|
||||
ops: &[String],
|
||||
dispatch: &str,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
create_collection_trigger(
|
||||
"files",
|
||||
app,
|
||||
script_id,
|
||||
collection_glob,
|
||||
ops,
|
||||
dispatch,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_pubsub(
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
topic_pattern: &str,
|
||||
dispatch: &str,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let mut body = base_body(script_id, dispatch);
|
||||
body["topic_pattern"] = json!(topic_pattern);
|
||||
let created = client.triggers_create(app, "pubsub", &body).await?;
|
||||
print_created(&created, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_queue(
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
queue_name: &str,
|
||||
visibility_timeout_secs: Option<u32>,
|
||||
dispatch: &str,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let mut body = base_body(script_id, dispatch);
|
||||
body["queue_name"] = json!(queue_name);
|
||||
if let Some(v) = visibility_timeout_secs {
|
||||
body["visibility_timeout_secs"] = json!(v);
|
||||
}
|
||||
let created = client.triggers_create(app, "queue", &body).await?;
|
||||
print_created(&created, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_email(
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
inbound_secret: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
// Email triggers take no dispatch_mode (inbound webhook only) and an
|
||||
// optional shared HMAC secret the provider signs POSTs with.
|
||||
let mut body = json!({ "script_id": script_id });
|
||||
if let Some(secret) = inbound_secret {
|
||||
body["inbound_secret"] = json!(secret);
|
||||
}
|
||||
let created = client.triggers_create(app, "email", &body).await?;
|
||||
print_created(&created, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_from_json(app: &str, kind: &str, body: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
@@ -128,6 +128,138 @@ enum Cmd {
|
||||
#[command(subcommand)]
|
||||
cmd: SecretsCmd,
|
||||
},
|
||||
|
||||
/// App membership — list members and grant / change / revoke their
|
||||
/// per-app role (app_admin | editor | viewer).
|
||||
Members {
|
||||
#[command(subcommand)]
|
||||
cmd: MembersCmd,
|
||||
},
|
||||
|
||||
/// Files inspection — list a collection's blobs, download bytes, or
|
||||
/// delete a file. Read + delete only; writes go through scripts.
|
||||
Files {
|
||||
#[command(subcommand)]
|
||||
cmd: FilesCmd,
|
||||
},
|
||||
|
||||
/// Queue inspection — list queues with depth counts, or drill into
|
||||
/// one queue's stats + registered consumer. Read-only.
|
||||
Queues {
|
||||
#[command(subcommand)]
|
||||
cmd: QueuesCmd,
|
||||
},
|
||||
|
||||
/// KV inspection — list keys in a collection or fetch one value.
|
||||
/// Read-only; writes go through `kv::set` in scripts.
|
||||
Kv {
|
||||
#[command(subcommand)]
|
||||
cmd: KvCmd,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum KvCmd {
|
||||
/// List keys in a collection.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
#[arg(long, default_value_t = 100)]
|
||||
limit: u32,
|
||||
},
|
||||
/// Fetch one key's value (printed as JSON).
|
||||
Get {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
key: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum MembersCmd {
|
||||
/// List app members.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
},
|
||||
/// Grant a user a role on the app.
|
||||
Add {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long = "user")]
|
||||
user_id: String,
|
||||
/// `app_admin` | `editor` | `viewer`.
|
||||
#[arg(long)]
|
||||
role: String,
|
||||
},
|
||||
/// Change an existing member's role.
|
||||
Set {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long = "user")]
|
||||
user_id: String,
|
||||
#[arg(long)]
|
||||
role: String,
|
||||
},
|
||||
/// Remove a member.
|
||||
Rm {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long = "user")]
|
||||
user_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum FilesCmd {
|
||||
/// List files in a collection.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
#[arg(long, default_value_t = 100)]
|
||||
limit: u32,
|
||||
},
|
||||
/// Download a file's bytes (to `--out <path>` or stdout).
|
||||
Get {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
#[arg(long = "id")]
|
||||
file_id: String,
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Delete a file.
|
||||
Rm {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
#[arg(long = "id")]
|
||||
file_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum QueuesCmd {
|
||||
/// List queues with depth counts.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
},
|
||||
/// Show one queue's stats + consumer.
|
||||
Show {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
queue_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -234,6 +366,82 @@ struct DeployArgs {
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
/// Per-script wall-clock timeout in seconds (overrides the instance
|
||||
/// default; still clamped by `PICLOUD_SANDBOX_MAX_*` ceilings).
|
||||
#[arg(long)]
|
||||
timeout: Option<i32>,
|
||||
/// Per-script memory ceiling in MB.
|
||||
#[arg(long)]
|
||||
memory: Option<i32>,
|
||||
/// Script kind: `endpoint` (default) or `module` (importable, no route).
|
||||
#[arg(long, value_enum)]
|
||||
kind: Option<ScriptKindArg>,
|
||||
/// Sandbox override as `key=value`, repeatable. Keys: `max_operations`,
|
||||
/// `max_string_size`, `max_array_size`, `max_map_size`,
|
||||
/// `max_call_levels`, `max_expr_depth`. E.g. `--sandbox max_operations=500000`.
|
||||
#[arg(long = "sandbox", value_name = "KEY=VALUE")]
|
||||
sandbox: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, clap::ValueEnum)]
|
||||
enum ScriptKindArg {
|
||||
Endpoint,
|
||||
Module,
|
||||
}
|
||||
|
||||
impl From<ScriptKindArg> for picloud_shared::ScriptKind {
|
||||
fn from(v: ScriptKindArg) -> Self {
|
||||
match v {
|
||||
ScriptKindArg::Endpoint => Self::Endpoint,
|
||||
ScriptKindArg::Module => Self::Module,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeployArgs {
|
||||
/// Fold the runtime-config flags into a `ScriptConfig`, parsing and
|
||||
/// validating the `--sandbox key=value` pairs.
|
||||
fn script_config(&self) -> anyhow::Result<client::ScriptConfig> {
|
||||
let sandbox = parse_sandbox_overrides(&self.sandbox)?;
|
||||
Ok(client::ScriptConfig {
|
||||
timeout_seconds: self.timeout,
|
||||
memory_limit_mb: self.memory,
|
||||
kind: self.kind.map(Into::into),
|
||||
sandbox,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse repeatable `--sandbox key=value` flags into a `ScriptSandbox`.
|
||||
/// Unknown keys and non-integer values are hard errors so a typo doesn't
|
||||
/// silently deploy an unrestricted script.
|
||||
fn parse_sandbox_overrides(
|
||||
pairs: &[String],
|
||||
) -> anyhow::Result<Option<picloud_shared::ScriptSandbox>> {
|
||||
use anyhow::{anyhow, Context};
|
||||
if pairs.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut sb = picloud_shared::ScriptSandbox::default();
|
||||
for raw in pairs {
|
||||
let (key, val) = raw
|
||||
.split_once('=')
|
||||
.ok_or_else(|| anyhow!("--sandbox expects key=value, got {raw:?}"))?;
|
||||
let n: u64 = val
|
||||
.trim()
|
||||
.parse()
|
||||
.with_context(|| format!("--sandbox {key}: {val:?} is not a non-negative integer"))?;
|
||||
match key.trim() {
|
||||
"max_operations" => sb.max_operations = Some(n),
|
||||
"max_string_size" => sb.max_string_size = Some(n),
|
||||
"max_array_size" => sb.max_array_size = Some(n),
|
||||
"max_map_size" => sb.max_map_size = Some(n),
|
||||
"max_call_levels" => sb.max_call_levels = Some(n),
|
||||
"max_expr_depth" => sb.max_expr_depth = Some(n),
|
||||
other => return Err(anyhow!("unknown --sandbox key: {other:?}")),
|
||||
}
|
||||
}
|
||||
Ok(Some(sb))
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -275,6 +483,11 @@ struct LogsArgs {
|
||||
script_id: String,
|
||||
#[arg(long, default_value_t = 50)]
|
||||
limit: u32,
|
||||
/// Filter by execution origin: `http`, `kv`, `cron`, `queue`,
|
||||
/// `invoke`, `dead_letter`, `docs`, `files`, `pubsub`, `email`.
|
||||
/// Omit (or `all`) to show every source.
|
||||
#[arg(long)]
|
||||
source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, ValueEnum)]
|
||||
@@ -498,10 +711,89 @@ enum TriggersCmd {
|
||||
dispatch: DispatchModeArg,
|
||||
},
|
||||
|
||||
/// Create a docs trigger — fires on document mutations in matching
|
||||
/// collections.
|
||||
#[command(name = "create-docs")]
|
||||
CreateDocs {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
script: String,
|
||||
/// Glob over collection names (`*`, `posts`, `events_*`, …).
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
/// Repeat to filter ops: `--op insert --op delete`. Empty fires on any.
|
||||
#[arg(long = "op")]
|
||||
ops: Vec<String>,
|
||||
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
|
||||
dispatch: DispatchModeArg,
|
||||
},
|
||||
|
||||
/// Create a files trigger — fires on blob create/update/delete in
|
||||
/// matching collections.
|
||||
#[command(name = "create-files")]
|
||||
CreateFiles {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
script: String,
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
#[arg(long = "op")]
|
||||
ops: Vec<String>,
|
||||
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
|
||||
dispatch: DispatchModeArg,
|
||||
},
|
||||
|
||||
/// Create a pub/sub trigger — fires on messages published to topics
|
||||
/// matching the pattern.
|
||||
#[command(name = "create-pubsub")]
|
||||
CreatePubsub {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
script: String,
|
||||
/// Topic glob (`*`, `orders.*`, `user.signup`, …).
|
||||
#[arg(long = "topic")]
|
||||
topic_pattern: String,
|
||||
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
|
||||
dispatch: DispatchModeArg,
|
||||
},
|
||||
|
||||
/// Create a queue consumer trigger — fires per message claimed off
|
||||
/// the named queue.
|
||||
#[command(name = "create-queue")]
|
||||
CreateQueue {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
script: String,
|
||||
#[arg(long = "queue")]
|
||||
queue_name: String,
|
||||
/// Per-message visibility timeout in seconds (claim lease).
|
||||
#[arg(long = "visibility-timeout")]
|
||||
visibility_timeout_secs: Option<u32>,
|
||||
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
|
||||
dispatch: DispatchModeArg,
|
||||
},
|
||||
|
||||
/// Create an email trigger — fires on inbound mail POSTed to the
|
||||
/// webhook receiver. No dispatch mode (inbound webhook only).
|
||||
#[command(name = "create-email")]
|
||||
CreateEmail {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
script: String,
|
||||
/// Shared HMAC secret the provider signs inbound POSTs with.
|
||||
/// Omit to accept unsigned POSTs.
|
||||
#[arg(long = "inbound-secret")]
|
||||
inbound_secret: Option<String>,
|
||||
},
|
||||
|
||||
/// Create a trigger of any kind from a raw JSON body — escape
|
||||
/// hatch for kinds the CLI doesn't expose per-kind wrappers for
|
||||
/// (docs/files/pubsub/email/queue) and for advanced retry/dispatch
|
||||
/// settings beyond the per-kind defaults.
|
||||
/// hatch for advanced retry/dispatch settings beyond the per-kind
|
||||
/// wrappers' defaults.
|
||||
#[command(name = "create-from-json")]
|
||||
CreateFromJson {
|
||||
#[arg(long)]
|
||||
@@ -790,16 +1082,20 @@ async fn main() -> ExitCode {
|
||||
} => cmds::scripts::ls(app.as_deref(), mode).await,
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Deploy(args),
|
||||
} => {
|
||||
cmds::scripts::deploy(
|
||||
&args.file,
|
||||
&args.app,
|
||||
args.name.as_deref(),
|
||||
args.description.as_deref(),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
} => match args.script_config() {
|
||||
Ok(cfg) => {
|
||||
cmds::scripts::deploy(
|
||||
&args.file,
|
||||
&args.app,
|
||||
args.name.as_deref(),
|
||||
args.description.as_deref(),
|
||||
&cfg,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Invoke(args),
|
||||
} => cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await,
|
||||
@@ -821,20 +1117,28 @@ async fn main() -> ExitCode {
|
||||
Cmd::ApiKeys {
|
||||
cmd: ApiKeysCmd::Rm { id },
|
||||
} => cmds::api_keys::rm(&id).await,
|
||||
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit, mode).await,
|
||||
Cmd::Logs(LogsArgs {
|
||||
script_id,
|
||||
limit,
|
||||
source,
|
||||
}) => cmds::logs::run(&script_id, limit, source.as_deref(), mode).await,
|
||||
Cmd::Invoke(args) => {
|
||||
cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await
|
||||
}
|
||||
Cmd::Deploy(args) => {
|
||||
cmds::scripts::deploy(
|
||||
&args.file,
|
||||
&args.app,
|
||||
args.name.as_deref(),
|
||||
args.description.as_deref(),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Deploy(args) => match args.script_config() {
|
||||
Ok(cfg) => {
|
||||
cmds::scripts::deploy(
|
||||
&args.file,
|
||||
&args.app,
|
||||
args.name.as_deref(),
|
||||
args.description.as_deref(),
|
||||
&cfg,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Cmd::Routes {
|
||||
cmd: RoutesCmd::Ls { script_id },
|
||||
} => cmds::routes::ls(&script_id, mode).await,
|
||||
@@ -1004,6 +1308,92 @@ async fn main() -> ExitCode {
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Triggers {
|
||||
cmd:
|
||||
TriggersCmd::CreateDocs {
|
||||
app,
|
||||
script,
|
||||
collection,
|
||||
ops,
|
||||
dispatch,
|
||||
},
|
||||
} => {
|
||||
cmds::triggers::create_docs(
|
||||
&app,
|
||||
&script,
|
||||
&collection,
|
||||
&ops,
|
||||
dispatch_wire(dispatch),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Triggers {
|
||||
cmd:
|
||||
TriggersCmd::CreateFiles {
|
||||
app,
|
||||
script,
|
||||
collection,
|
||||
ops,
|
||||
dispatch,
|
||||
},
|
||||
} => {
|
||||
cmds::triggers::create_files(
|
||||
&app,
|
||||
&script,
|
||||
&collection,
|
||||
&ops,
|
||||
dispatch_wire(dispatch),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Triggers {
|
||||
cmd:
|
||||
TriggersCmd::CreatePubsub {
|
||||
app,
|
||||
script,
|
||||
topic_pattern,
|
||||
dispatch,
|
||||
},
|
||||
} => {
|
||||
cmds::triggers::create_pubsub(
|
||||
&app,
|
||||
&script,
|
||||
&topic_pattern,
|
||||
dispatch_wire(dispatch),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Triggers {
|
||||
cmd:
|
||||
TriggersCmd::CreateQueue {
|
||||
app,
|
||||
script,
|
||||
queue_name,
|
||||
visibility_timeout_secs,
|
||||
dispatch,
|
||||
},
|
||||
} => {
|
||||
cmds::triggers::create_queue(
|
||||
&app,
|
||||
&script,
|
||||
&queue_name,
|
||||
visibility_timeout_secs,
|
||||
dispatch_wire(dispatch),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Triggers {
|
||||
cmd:
|
||||
TriggersCmd::CreateEmail {
|
||||
app,
|
||||
script,
|
||||
inbound_secret,
|
||||
},
|
||||
} => cmds::triggers::create_email(&app, &script, inbound_secret.as_deref(), mode).await,
|
||||
Cmd::Triggers {
|
||||
cmd: TriggersCmd::CreateFromJson { app, kind, body },
|
||||
} => cmds::triggers::create_from_json(&app, &kind, &body, mode).await,
|
||||
@@ -1074,6 +1464,65 @@ async fn main() -> ExitCode {
|
||||
Cmd::Secrets {
|
||||
cmd: SecretsCmd::Rm { app, name },
|
||||
} => cmds::secrets::rm(&app, &name).await,
|
||||
Cmd::Members {
|
||||
cmd: MembersCmd::Ls { app },
|
||||
} => cmds::members::ls(&app, mode).await,
|
||||
Cmd::Members {
|
||||
cmd: MembersCmd::Add { app, user_id, role },
|
||||
} => cmds::members::add(&app, &user_id, &role, mode).await,
|
||||
Cmd::Members {
|
||||
cmd: MembersCmd::Set { app, user_id, role },
|
||||
} => cmds::members::set(&app, &user_id, &role, mode).await,
|
||||
Cmd::Members {
|
||||
cmd: MembersCmd::Rm { app, user_id },
|
||||
} => cmds::members::rm(&app, &user_id).await,
|
||||
Cmd::Files {
|
||||
cmd:
|
||||
FilesCmd::Ls {
|
||||
app,
|
||||
collection,
|
||||
limit,
|
||||
},
|
||||
} => cmds::files::ls(&app, &collection, limit, mode).await,
|
||||
Cmd::Files {
|
||||
cmd:
|
||||
FilesCmd::Get {
|
||||
app,
|
||||
collection,
|
||||
file_id,
|
||||
out,
|
||||
},
|
||||
} => cmds::files::get(&app, &collection, &file_id, out.as_deref()).await,
|
||||
Cmd::Files {
|
||||
cmd:
|
||||
FilesCmd::Rm {
|
||||
app,
|
||||
collection,
|
||||
file_id,
|
||||
},
|
||||
} => cmds::files::rm(&app, &collection, &file_id).await,
|
||||
Cmd::Queues {
|
||||
cmd: QueuesCmd::Ls { app },
|
||||
} => cmds::queues::ls(&app, mode).await,
|
||||
Cmd::Queues {
|
||||
cmd: QueuesCmd::Show { app, queue_name },
|
||||
} => cmds::queues::show(&app, &queue_name, mode).await,
|
||||
Cmd::Kv {
|
||||
cmd:
|
||||
KvCmd::Ls {
|
||||
app,
|
||||
collection,
|
||||
limit,
|
||||
},
|
||||
} => cmds::kv::ls(&app, &collection, limit, mode).await,
|
||||
Cmd::Kv {
|
||||
cmd:
|
||||
KvCmd::Get {
|
||||
app,
|
||||
collection,
|
||||
key,
|
||||
},
|
||||
} => cmds::kv::get(&app, &collection, &key).await,
|
||||
};
|
||||
|
||||
match result {
|
||||
|
||||
Reference in New Issue
Block a user