diff --git a/.gitignore b/.gitignore index 280c49d..54466f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ /target -TODO.md + diff --git a/Cargo.lock b/Cargo.lock index 04965fd..2ea3b3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -58,6 +67,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "aws-lc-rs" version = "1.17.0" @@ -128,6 +143,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.6.1" @@ -579,6 +607,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -858,6 +910,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1506,8 +1567,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tofu-cli" -version = "0.1.0" +version = "0.1.1" dependencies = [ + "chrono", "clap", "colored", "comfy-table", @@ -1890,6 +1952,41 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index d97d28e..e324504 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tofu-cli" -version = "0.1.0" +version = "0.1.1" edition = "2024" description = "A terminal-first webhook relay CLI for local development." homepage = "https://trytofu.dev" @@ -19,6 +19,7 @@ dirs = "6" comfy-table = "7" colored = "3.0.0" thiserror = "2.0.18" +chrono = "0.4" [[bin]] name = "tofu" diff --git a/README.md b/README.md index 9c7acd0..a1506f1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Tofu CLI -A terminal-first webhook relay for local development. Give webhook providers one stable URL, forward events to localhost, inspect deliveries, and replay requests. +A terminal-first webhook relay for local development. Give webhook providers one stable URL, forward events to your dev or preview services, inspect requests, and replay them after code changes. > Tofu is in early access. Some features are still being tested. @@ -16,39 +16,74 @@ curl -fsSL https://trytofu.dev/install | sh brew install trytofu/tap/tofu ``` +## What you need + +- A Tofu account +- A local, staging, or preview URL that your webhook provider can reach +- One workspace selected in the CLI + ## Quick start ```bash tofu login + +tofu workspaces list +tofu workspaces use + tofu hooks create stripe --name "Stripe" -tofu hooks url stripe tofu targets set local "http://127.0.0.1:3000/api/webhooks/stripe" --hook stripe -tofu watch stripe +tofu hooks url stripe + +tofu events list --hook stripe +tofu replay latest --hook stripe ``` -1. `tofu login` opens browser-based device approval. -2. `tofu hooks create` gives you a stable URL to paste into the provider dashboard. -3. `tofu targets set` points that hook at your local server. -4. `tofu watch` streams incoming events and delivery results. +1. `tofu login` opens browser-based device approval and saves your token. +2. `tofu workspaces use` chooses where hooks and targets are managed. +3. `tofu hooks create` creates a stable provider URL. +4. `tofu targets set` forwards matching events to your service. +5. `tofu hooks url` prints the URL to paste into Stripe, GitHub, Resend, or another provider. +6. `tofu events list` and `tofu replay` let you inspect and resend retained events. + +`http://127.0.0.1:3000/...` only works if your local service is reachable from the Tofu API. In many setups that means using a tunnel, staging URL, or preview deployment as the target URL. ## Commands | Command | Description | |---------|-------------| | `tofu login` | Authenticate via browser device approval | +| `tofu login --token ` | Authenticate with a personal API token | +| `tofu logout` | Clear the saved token | +| `tofu whoami` | Show the authenticated user | +| `tofu usage` | Show plan usage and remaining limits | +| `tofu workspaces list` | List workspaces you can access | +| `tofu workspaces use ` | Set the active workspace | +| `tofu workspaces create ` | Create a workspace | +| `tofu workspaces members list` | List active workspace members | +| `tofu workspaces members add ` | Add a member to the active workspace | | `tofu hooks create ` | Create a named hook with a stable provider URL | | `tofu hooks url ` | Print the provider URL | | `tofu hooks list` | List hooks in the current workspace | +| `tofu hooks status ` | Show hook details and targets | +| `tofu targets add --hook ` | Add a forwarding target | | `tofu targets set --hook ` | Set a forwarding target | | `tofu targets list --hook ` | List targets for a hook | -| `tofu watch ` | Stream live events and delivery status | -| `tofu replay latest --hook ` | Replay the most recent event | +| `tofu targets enable --hook ` | Enable a target | +| `tofu targets disable --hook ` | Disable a target | +| `tofu targets delete --hook ` | Delete a target | | `tofu events list --hook ` | List recent events | +| `tofu events latest --hook ` | Show the latest event for a hook | +| `tofu events show ` | Show event headers, body preview, and deliveries | | `tofu events expire ` | Remove stored payload immediately | +| `tofu replay ` | Replay an event to enabled targets | +| `tofu replay --target ` | Replay an event to one target | +| `tofu replay latest --hook ` | Replay the most recent event for a hook | | `tofu health` | Check API connectivity | | `tofu config show` | Show current config (token masked) | -All commands accept `--json` for machine-readable output. +All commands accept `--json` for machine-readable output. Use `--api-base-url ` to temporarily point the CLI at another API. + +Live event streaming with `tofu watch` is not included in this rewrite yet. Until it lands, use `tofu events list --hook ` or `tofu events latest --hook ` after sending a test webhook. ## Configuration @@ -59,15 +94,16 @@ api_base_url = "https://api.trytofu.dev" token = "tofu_pat_xxxxxxxx" ``` +You can override the config location with `TOFU_CONFIG_PATH`, which is useful for scripts and tests. + ## What Tofu does - Accepts webhooks from providers (Stripe, GitHub, Resend, etc.) at a stable URL - Stores request body and headers temporarily for replay - Forwards events to one or more local/dev targets -- Streams live delivery status via `tofu watch` - Replays retained events after code changes -Tofu is not an ngrok replacement. Use ngrok, localhost, staging, or a preview URL as your target. Tofu gives the provider a stable URL so you don't have to reconfigure it when your local setup changes. +Tofu is not an ngrok replacement. Use ngrok, staging, a preview URL, or another reachable endpoint as your target. Tofu gives the provider a stable URL so you do not have to reconfigure provider webhooks when your target changes. ## Docs diff --git a/src/api/client.rs b/src/api/client.rs index fc0f8ec..e916248 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -260,6 +260,13 @@ impl ApiClient { /// Workspace impl ApiClient { + pub async fn get_workspace(&self, workspace_id: &str) -> Result { + let token = self.token.as_ref().ok_or(ApiError::NotAuthenticated)?; + let url = format!("{}/api/workspaces/{workspace_id}", self.base_url); + let r = self.get_authenticated_response(&url, token).await?; + r.json().await.map_err(ApiError::Request) + } + pub async fn list_workspaces(&self) -> Result, ApiError> { let token = self.token.as_ref().ok_or(ApiError::NotAuthenticated)?; let url = format!("{}/api/workspaces", self.base_url); @@ -350,15 +357,6 @@ impl ApiClient { .await?; response.json::().await.map_err(ApiError::Request) } - - #[allow(dead_code)] - pub async fn get_hook(&self, hook_id: &str) -> Result { - let token = self.token.as_ref().ok_or(ApiError::NotAuthenticated)?; - let url = format!("{}/api/hooks/{hook_id}", self.base_url); - - let response = self.get_authenticated_response(&url, token).await?; - response.json::().await.map_err(ApiError::Request) - } } // Targets @@ -493,3 +491,13 @@ impl ApiClient { Ok(()) } } + +impl ApiClient { + pub async fn stream_events(&self, hook_id: &str) -> Result { + let token = self.token.as_ref().ok_or(ApiError::NotAuthenticated)?; + let url = format!("{}/api/hooks/{hook_id}/events/stream", self.base_url); + + let r = self.get_authenticated_response(&url, token).await?; + Ok(r) + } +} diff --git a/src/commands/cli.rs b/src/commands/cli.rs index 5202bff..4136016 100644 --- a/src/commands/cli.rs +++ b/src/commands/cli.rs @@ -19,6 +19,8 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { + /// Print the CLI version + Version, /// Checks API health Health, /// Log in with your browser approval or an API token @@ -70,6 +72,14 @@ pub enum Commands { #[arg(long, help = "Target name to replay to")] target: Option, }, + /// Watch events for a hook + Watch { + slug: String, + #[arg(long, help = "Show delivery updates")] + deliveries: bool, + #[arg(long, help = "Filter delivery updates to a target name")] + target: Option, + }, } #[derive(Subcommand)] diff --git a/src/commands/events.rs b/src/commands/events.rs index 16bc2a8..9436279 100644 --- a/src/commands/events.rs +++ b/src/commands/events.rs @@ -1,6 +1,8 @@ +use std::fmt::Write as _; + use crate::{ api::ApiClient, - models::events::EventDetail, + models::events::{DeliveryDetail, EventDetail}, utils::{ api_errors::exit_api_error, output::{self, Tone}, @@ -30,7 +32,8 @@ pub async fn list(client: &ApiClient, hook_slug: String, limit: u32, json: bool) summary.total, summary.success, summary.failed ); if summary.pending > 0 { - status.push_str(&format!(", {} pending", summary.pending)); + write!(status, ", {} pending", summary.pending) + .expect("writing to String cannot fail"); } status.push(')'); let replay_status = if e.replay_available { @@ -153,112 +156,152 @@ pub async fn expire(client: &ApiClient, event_id: String, json: bool) { fn print_event(event: EventDetail, json: bool) { if json { - println!( - "{}", - serde_json::json!({ - "id": event.id, - "hook_id": event.hook_id, - "method": event.method, - "path": event.path, - "query_string": event.query_string, - "headers": event.headers, - "body_preview": event.body_preview, - "received_at": event.received_at, - "payload_expires_at": event.payload_expires_at, - "metadata_expires_at": event.metadata_expires_at, - "payload_expired_at": event.payload_expired_at, - "manually_expired_at": event.manually_expired_at, - "payload_expired": event.payload_expired, - "replay_available": event.replay_available, - "deliveries": event.deliveries, - }) - ); + print_event_json(&event); } else { - let mut rows = vec![ - ("Event", event.id), - ("Method", event.method), - ("Path", event.path), - ("Received", fmt_time(&event.received_at)), - ]; - if let Some(qs) = event.query_string { - rows.push(("Query", qs)); - } - if event.payload_expired { - rows.push(("Payload", output::paint("expired", Tone::Warning))); - rows.push(("Replay", output::paint("unavailable", Tone::Warning))); - } else { - rows.push(("Payload expires", fmt_time(&event.payload_expires_at))); - rows.push(("Replay", output::paint("available", Tone::Success))); - } - print_detail_rows(&rows); + print_event_text(event); + } +} - if let Some(preview) = event.body_preview { - println!("\n{}", output::paint("Body preview", Tone::Muted)); - println!("{}", fmt_body_preview(&preview)); - } +fn print_event_json(event: &EventDetail) { + println!( + "{}", + serde_json::json!({ + "id": &event.id, + "hook_id": &event.hook_id, + "method": &event.method, + "path": &event.path, + "query_string": &event.query_string, + "headers": &event.headers, + "body_preview": &event.body_preview, + "received_at": &event.received_at, + "payload_expires_at": &event.payload_expires_at, + "metadata_expires_at": &event.metadata_expires_at, + "payload_expired_at": &event.payload_expired_at, + "manually_expired_at": &event.manually_expired_at, + "payload_expired": event.payload_expired, + "replay_available": event.replay_available, + "deliveries": &event.deliveries, + }) + ); +} - if let Some(headers) = event.headers.as_object() { - if !headers.is_empty() { - let mut t = output::data_table(&["Header", "Value"]); - for (key, value) in headers { - t.add_row(vec![ - output::cell(key.to_string()), - output::cell(value.to_string()), - ]); - } - println!("\nHeaders:\n{t}"); - } +fn print_event_text(event: EventDetail) { + let EventDetail { + id, + method, + path, + query_string, + headers, + body_preview, + received_at, + payload_expires_at, + payload_expired, + deliveries, + .. + } = event; + + let mut rows = vec![ + ("Event", id), + ("Method", method), + ("Path", path), + ("Received", fmt_time(&received_at)), + ]; + if let Some(qs) = query_string { + rows.push(("Query", qs)); + } + if payload_expired { + rows.push(("Payload", output::paint("expired", Tone::Warning))); + rows.push(("Replay", output::paint("unavailable", Tone::Warning))); + } else { + rows.push(("Payload expires", fmt_time(&payload_expires_at))); + rows.push(("Replay", output::paint("available", Tone::Success))); + } + print_detail_rows(&rows); + print_body_preview(body_preview); + print_headers(&headers); + print_deliveries(deliveries); +} + +fn print_body_preview(body_preview: Option) { + if let Some(preview) = body_preview { + println!("\n{}", output::paint("Body preview", Tone::Muted)); + println!("{}", fmt_body_preview(&preview)); + } +} + +fn print_headers(headers: &serde_json::Value) { + if let Some(headers) = headers.as_object() + && !headers.is_empty() + { + let mut table = output::data_table(&["Header", "Value"]); + for (key, value) in headers { + table.add_row(vec![ + output::cell(key.to_string()), + output::cell(value.to_string()), + ]); } + println!("\nHeaders:\n{table}"); + } +} - if event.deliveries.is_empty() { - output::empty("\nNo deliveries found for this event."); - } else { - let mut t = output::data_table(&["Attempted", "Target", "URL", "Result", "Duration"]); - for d in event.deliveries { - match d.status.as_str() { - "success" => { - let status = d.response_status.unwrap_or(0); - let ms = d.duration_ms.unwrap_or(0); - t.add_row(vec![ - output::cell(fmt_time(&d.attempted_at)), - output::cell(d.target_name), - output::url_cell(&d.target_url), - output::tone_cell(format!("HTTP {}", status), Tone::Success), - output::cell(format!("{}ms", ms)), - ]); - } - "failed" => { - let ms = d.duration_ms.unwrap_or(0); - let reason = d.error_message.unwrap_or_default(); - let result = if reason.is_empty() { - "failed".to_string() - } else { - format!("failed: {}", reason) - }; - t.add_row(vec![ - output::cell(fmt_time(&d.attempted_at)), - output::cell(d.target_name), - output::url_cell(&d.target_url), - output::tone_cell(result, Tone::Error), - output::cell(format!("{}ms", ms)), - ]); - } - _ => { - t.add_row(vec![ - output::cell(fmt_time(&d.attempted_at)), - output::cell(d.target_name), - output::url_cell(&d.target_url), - output::status_cell(d.status), - output::cell(""), - ]); - } - } - } - println!("\nDeliveries:\n{t}"); +fn print_deliveries(deliveries: Vec) { + if deliveries.is_empty() { + output::empty("\nNo deliveries found for this event."); + return; + } + + let mut table = output::data_table(&["Attempted", "Target", "URL", "Result", "Duration"]); + for delivery in deliveries { + add_delivery_row(&mut table, delivery); + } + println!("\nDeliveries:\n{table}"); +} + +fn add_delivery_row(table: &mut comfy_table::Table, delivery: DeliveryDetail) { + match delivery.status.as_str() { + "success" => add_success_delivery_row(table, delivery), + "failed" => add_failed_delivery_row(table, delivery), + _ => { + table.add_row(vec![ + output::cell(fmt_time(&delivery.attempted_at)), + output::cell(delivery.target_name), + output::url_cell(&delivery.target_url), + output::status_cell(delivery.status), + output::cell(""), + ]); } } } +fn add_success_delivery_row(table: &mut comfy_table::Table, delivery: DeliveryDetail) { + let status = delivery.response_status.unwrap_or(0); + let ms = delivery.duration_ms.unwrap_or(0); + table.add_row(vec![ + output::cell(fmt_time(&delivery.attempted_at)), + output::cell(delivery.target_name), + output::url_cell(&delivery.target_url), + output::tone_cell(format!("HTTP {status}"), Tone::Success), + output::cell(format!("{ms}ms")), + ]); +} + +fn add_failed_delivery_row(table: &mut comfy_table::Table, delivery: DeliveryDetail) { + let ms = delivery.duration_ms.unwrap_or(0); + let reason = delivery.error_message.unwrap_or_default(); + let result = if reason.is_empty() { + "failed".to_string() + } else { + format!("failed: {reason}") + }; + table.add_row(vec![ + output::cell(fmt_time(&delivery.attempted_at)), + output::cell(delivery.target_name), + output::url_cell(&delivery.target_url), + output::tone_cell(result, Tone::Error), + output::cell(format!("{ms}ms")), + ]); +} + fn print_detail_rows(rows: &[(&str, String)]) { let label_width = rows.iter().map(|(label, _)| label.len()).max().unwrap_or(0); for (label, value) in rows { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c545504..14c060b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod hooks; pub mod replay; pub mod targets; pub mod usage; +pub mod watch; pub mod workspaces; pub use cli::{ diff --git a/src/commands/replay.rs b/src/commands/replay.rs index 1c92f9e..78ce84d 100644 --- a/src/commands/replay.rs +++ b/src/commands/replay.rs @@ -11,118 +11,147 @@ pub async fn run( target: Option, json: bool, ) { - let (event_id, hook_id) = if event_id == "latest" { - let hook_slug = match hook_slug { - Some(h) => h, - None => { - output::error("--hook is required when replaying 'latest'."); - std::process::exit(1); - } - }; - - let hook = resolve_hook_or_exit(client, &hook_slug).await; - let events = match client.list_events(&hook.id, 1).await { - Ok(events) => events, + let (event_id, hook_id) = + resolve_replay_event(client, event_id, hook_slug, target.is_some()).await; + + if let Some(target_name) = target { + replay_to_target(client, &event_id, hook_id, target_name, json).await; + } else { + replay_to_all_targets(client, &event_id, json).await; + } +} + +async fn resolve_replay_event( + client: &ApiClient, + event_id: String, + hook_slug: Option, + target_requested: bool, +) -> (String, Option) { + if event_id == "latest" { + return resolve_latest_event(client, hook_slug).await; + } + + let hook_id = if target_requested { + match client.get_event(&event_id).await { + Ok(event) => Some(event.hook_id), Err(e) => exit_api_error( e, - "list events", - Some("Hook not found or you do not have access."), + "fetch event", + Some("Event not found or you do not have access."), ), - }; + } + } else { + None + }; + + (event_id, hook_id) +} + +async fn resolve_latest_event( + client: &ApiClient, + hook_slug: Option, +) -> (String, Option) { + let Some(hook_slug) = hook_slug else { + output::error("--hook is required when replaying 'latest'."); + std::process::exit(1); + }; + + let hook = resolve_hook_or_exit(client, &hook_slug).await; + let events = match client.list_events(&hook.id, 1).await { + Ok(events) => events, + Err(e) => exit_api_error( + e, + "list events", + Some("Hook not found or you do not have access."), + ), + }; + + let Some(event) = events.first() else { + output::error(format!("No events found for hook '{hook_slug}'.")); + std::process::exit(1); + }; + + (event.id.clone(), Some(hook.id)) +} - if events.is_empty() { - output::error(format!("No events found for hook '{hook_slug}'.")); +async fn replay_to_target( + client: &ApiClient, + event_id: &str, + hook_id: Option, + target_name: String, + json: bool, +) { + let Some(hook_id) = hook_id else { + output::error("Could not determine hook for target resolution."); + std::process::exit(1); + }; + + let target_id = match resolve_target_id_by_hook_id(client, &hook_id, &target_name).await { + Ok(Some(id)) => id, + Ok(None) => { + output::error(format!("Target '{target_name}' not found.")); + output::warning(format!( + "Run {} to see available targets.", + output::command("tofu targets list --hook ") + )); std::process::exit(1); } + Err(e) => exit_api_error( + e, + "resolve target", + Some("Hook not found or you do not have access."), + ), + }; + + match client.replay_event_to_target(event_id, &target_id).await { + Ok(()) => print_target_replay_started(event_id, &target_name, json), + Err(e) => exit_api_error( + e, + "replay event", + Some("Event or target not found or you do not have access."), + ), + } +} - (events[0].id.clone(), Some(hook.id)) +async fn replay_to_all_targets(client: &ApiClient, event_id: &str, json: bool) { + match client.replay_event(event_id).await { + Ok(()) => print_replay_started(event_id, json), + Err(e) => exit_api_error( + e, + "replay event", + Some("Event not found or you do not have access."), + ), + } +} + +fn print_target_replay_started(event_id: &str, target_name: &str, json: bool) { + if json { + println!( + "{}", + serde_json::json!({ + "status": "accepted", + "event_id": event_id, + "target": target_name, + }) + ); } else { - let hook_id = if target.is_some() { - match client.get_event(&event_id).await { - Ok(event) => Some(event.hook_id), - Err(e) => exit_api_error( - e, - "fetch event", - Some("Event not found or you do not have access."), - ), - } - } else { - None - }; - - (event_id, hook_id) - }; + output::success(format!( + "Replay started for event {event_id} to target {target_name}" + )); + } +} - if let Some(target_name) = target { - let hook_id = match hook_id { - Some(id) => id, - None => { - output::error("Could not determine hook for target resolution."); - std::process::exit(1); - } - }; - - let target_id = match resolve_target_id_by_hook_id(client, &hook_id, &target_name).await { - Ok(Some(id)) => id, - Ok(None) => { - output::error(format!("Target '{target_name}' not found.")); - output::warning(format!( - "Run {} to see available targets.", - output::command("tofu targets list --hook ") - )); - std::process::exit(1); - } - Err(e) => exit_api_error( - e, - "resolve target", - Some("Hook not found or you do not have access."), - ), - }; - - match client.replay_event_to_target(&event_id, &target_id).await { - Ok(()) => { - if json { - println!( - "{}", - serde_json::json!({ - "status": "accepted", - "event_id": event_id, - "target": target_name, - }) - ); - } else { - output::success(format!( - "Replay started for event {event_id} to target {target_name}" - )); - } - } - Err(e) => exit_api_error( - e, - "replay event", - Some("Event or target not found or you do not have access."), - ), - } +fn print_replay_started(event_id: &str, json: bool) { + if json { + println!( + "{}", + serde_json::json!({ + "status": "accepted", + "event_id": event_id, + }) + ); } else { - match client.replay_event(&event_id).await { - Ok(()) => { - if json { - println!( - "{}", - serde_json::json!({ - "status": "accepted", - "event_id": event_id, - }) - ); - } else { - output::success(format!("Replay started for event {event_id}")); - } - } - Err(e) => exit_api_error( - e, - "replay event", - Some("Event not found or you do not have access."), - ), - }; + output::success(format!("Replay started for event {event_id}")); } } diff --git a/src/commands/targets.rs b/src/commands/targets.rs index 584db98..3d62337 100644 --- a/src/commands/targets.rs +++ b/src/commands/targets.rs @@ -65,20 +65,21 @@ impl TargetStatus { } } +#[derive(Clone, Copy)] enum SetResult { Created, Updated, } impl SetResult { - fn status(&self) -> &'static str { + fn status(self) -> &'static str { match self { Self::Created => "created", Self::Updated => "updated", } } - fn past_tense(&self) -> &'static str { + fn past_tense(self) -> &'static str { match self { Self::Created => "Created", Self::Updated => "Updated", diff --git a/src/commands/usage.rs b/src/commands/usage.rs index c8b8999..32041ef 100644 --- a/src/commands/usage.rs +++ b/src/commands/usage.rs @@ -6,31 +6,31 @@ use crate::{ pub async fn run(client: &ApiClient, json: bool) { match client.billing_status().await { - Ok(s) => print_usage(s, json), + Ok(s) => print_usage(&s, json), Err(e) => exit_api_error(e, "fetch usage", None), } } -fn print_usage(status: BillingStatus, json: bool) { +fn print_usage(status: &BillingStatus, json: bool) { if json { println!( "{}", serde_json::json!({ - "plan": status.plan, - "status": status.status, - "current_period_start": status.current_period_start, - "current_period_end": status.current_period_end, + "plan": &status.plan, + "status": &status.status, + "current_period_start": &status.current_period_start, + "current_period_end": &status.current_period_end, "cancel_at_period_end": status.cancel_at_period_end, - "usage": status.usage, - "limits": status.limits, + "usage": &status.usage, + "limits": &status.limits, "remaining": usage_remaining(&status.usage, &status.limits), }) ); return; } - print_plan_summary(&status); - print_usage_table(&status); + print_plan_summary(status); + print_usage_table(status); } fn print_plan_summary(status: &BillingStatus) { diff --git a/src/commands/watch.rs b/src/commands/watch.rs new file mode 100644 index 0000000..a7c7fd0 --- /dev/null +++ b/src/commands/watch.rs @@ -0,0 +1,212 @@ +use futures_util::StreamExt; + +use crate::{ + api::ApiClient, + models::{api_error::ApiError, sse_message::SseMessage}, + utils::{ + output::{self, Tone, print_plan_limit_error}, + time::{current_clock_time, fmt_clock_time}, + workspace::resolve_hook_or_exit, + }, +}; + +pub async fn run( + client: &ApiClient, + slug: String, + deliveries: bool, + target: Option, + json: bool, +) { + let hook = resolve_hook_or_exit(client, &slug).await; + + if !json { + match client.get_workspace(&hook.workspace_id).await { + Ok(w) => output::success(format!("Watching {} in workspace {}", hook.slug, w.slug)), + Err(_) => output::success(format!("Watching {}", hook.slug)), + } + output::next_step(format!("Provider URL: {}", output::url(&hook.provider_url))); + } + + let target_filter = target.as_deref(); + + tokio::select! { + _ = tokio::signal::ctrl_c() => { + if !json { + output::warning("\nStopped watching."); + } + } + + _ = async { + loop { + match watch(client, &hook.id, deliveries, target_filter, json).await { + Ok(()) => { + // Stream ended, reconnect after a short delay + } + Err(ApiError::NotAuthenticated) => { + output::error("Not authenticated."); + output::warning(format!("Run {}.", output::command("tofu login"))); + std::process::exit(1); + } + Err(ApiError::UnexpectedStatus { status }) + if status == reqwest::StatusCode::UNAUTHORIZED => + { + output::error("Invalid token."); + output::warning(format!( + "Run {} to re-authenticate.", + output::command("tofu login") + )); + std::process::exit(1); + } + Err(ApiError::UnexpectedStatus {status}) + if status == reqwest::StatusCode::NOT_FOUND => + { + output::error("Hook not found or you do not have access."); + std::process::exit(1); + } + Err(ApiError::UnexpectedStatus {status}) => { + if !json { + output::warning(format!("Server error: {status}. Reconnecting...")); + } + } + Err(ApiError::PlanLimitReached(err)) => { + if !json { + print_plan_limit_error(&err); + } + std::process::exit(1); + } + Err(ApiError::PayloadExpired(message)) => { + if !json { + output::warning(message); + } + } + Err(ApiError::Request(e)) => { + if !json { + output::warning(format!("Connection error: {e}. Reconnecting...")); + } + } + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + } => {} + } +} + +async fn watch( + client: &ApiClient, + hook_id: &str, + deliveries: bool, + target_filter: Option<&str>, + json: bool, +) -> Result<(), ApiError> { + let r = client.stream_events(hook_id).await?; + let mut stream = r.bytes_stream(); + let mut buff = String::new(); + + while let Some(chunk) = stream.next().await { + match chunk { + Ok(b) => { + let text = String::from_utf8_lossy(&b); + for event_data in parse_sse_events(&mut buff, &text) { + match serde_json::from_str::(&event_data) { + Ok(SseMessage::EventReceived { event }) => { + if json { + println!("{event_data}"); + continue; + } + let time = fmt_clock_time(&event.received_at); + let short_id = short_id(&event.id); + println!( + "{} {} {:<7} {}", + output::paint(time, Tone::Muted), + short_id, + event.method, + output::paint("received", Tone::Success) + ); + } + Ok(SseMessage::DeliveryCompleted { delivery }) => { + if !deliveries { + continue; + } + + let matches_target = + target_filter.is_none_or(|f| delivery.target_name == f); + + if !matches_target { + continue; + } + + if json { + println!("{event_data}"); + continue; + } + + let time = current_clock_time(); + let short_id = short_id(&delivery.event_id); + + if delivery.status == "success" { + let status = delivery.response_status.unwrap_or(0); + let ms = delivery.duration_ms.unwrap_or(0); + println!( + "{} {} -> {:<15} {} {}ms", + output::paint(time, Tone::Muted), + short_id, + delivery.target_name, + output::paint(format!("HTTP {status}"), Tone::Success), + ms + ); + } else { + let ms = delivery.duration_ms.unwrap_or(0); + println!( + "{} {} -> {:<15} {} {}ms", + output::paint(time, Tone::Muted), + short_id, + delivery.target_name, + output::paint("failed", Tone::Error), + ms + ); + } + } + Err(_) => { + // Ignore malformed events + } + } + } + } + Err(e) => { + output::error(format!("Stream error: {e}")); + return Ok(()); + } + } + } + + Ok(()) +} + +fn parse_sse_events(buffer: &mut String, chunk: &str) -> Vec { + buffer.push_str(chunk); + let mut events = Vec::new(); + + while let Some(pos) = buffer.find("\n\n") { + let event_text = buffer[..pos].to_string(); + buffer.replace_range(..pos + 2, ""); + + let mut data = String::new(); + for line in event_text.lines() { + if let Some(rest) = line.strip_prefix("data:") { + if !data.is_empty() { + data.push('\n'); + } + data.push_str(rest.trim_start()); + } + } + if !data.is_empty() { + events.push(data); + } + } + + events +} + +fn short_id(uuid: &str) -> &str { + uuid.get(..8).unwrap_or(uuid) +} diff --git a/src/commands/workspaces.rs b/src/commands/workspaces.rs index f4ce147..07eda5d 100644 --- a/src/commands/workspaces.rs +++ b/src/commands/workspaces.rs @@ -32,8 +32,7 @@ pub async fn list(client: &ApiClient, json: bool) { output::cell(ws.name), output::cell( ws.hook_count - .map(|count| count.to_string()) - .unwrap_or_else(|| "-".to_string()), + .map_or_else(|| "-".to_string(), |count| count.to_string()), ), ]); } diff --git a/src/config/config.rs b/src/config/config.rs index 20ed5f5..85308fd 100644 --- a/src/config/config.rs +++ b/src/config/config.rs @@ -8,18 +8,16 @@ pub struct Config { impl Config { pub fn load() -> Self { - let path = match Self::path() { - Some(p) => p, - None => return Self::default(), + let Some(path) = Self::path() else { + return Self::default(); }; if !path.exists() { return Self::default(); } - let contents = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(_) => return Self::default(), + let Ok(contents) = std::fs::read_to_string(&path) else { + return Self::default(); }; toml::from_str(&contents).unwrap_or_default() diff --git a/src/config/mod.rs b/src/config/mod.rs index 43fe76c..dfaa25d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,3 +1,4 @@ +#[allow(clippy::module_inception)] mod config; pub use config::Config; diff --git a/src/main.rs b/src/main.rs index 4cb081c..12e385c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use crate::{ Cli, Commands, ConfigCommands, EventsCommands, HooksCommands, MembersCommands, TargetsCommand, WorkspaceCommands, auth, cli_config, events, health, hooks, replay, targets::{self, TargetStatus}, - usage, workspaces, + usage, watch, workspaces, }, config::Config, }; @@ -26,6 +26,18 @@ async fn main() { let client = ApiClient::new(api_base_url, config.resolve_token()); match cli.command { + Commands::Version => { + if cli.json { + println!( + "{}", + serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), + }) + ); + } else { + println!("{}", env!("CARGO_PKG_VERSION")); + } + } Commands::Health => health::run(&client, cli.json).await, Commands::Login { token, @@ -70,30 +82,30 @@ async fn main() { }, Commands::Targets { command } => match command { TargetsCommand::Add { name, url, hook } => { - targets::add(&client, name, url, hook, cli.json).await + targets::add(&client, name, url, hook, cli.json).await; } TargetsCommand::Set { name, url, hook } => { - targets::set(&client, name, url, hook, cli.json).await + targets::set(&client, name, url, hook, cli.json).await; } TargetsCommand::List { hook } => targets::list(&client, hook, cli.json).await, TargetsCommand::Enable { name, hook } => { - targets::toggle(TargetStatus::On, &client, name, hook, cli.json).await + targets::toggle(TargetStatus::On, &client, name, hook, cli.json).await; } TargetsCommand::Disable { name, hook } => { - targets::toggle(TargetStatus::Off, &client, name, hook, cli.json).await + targets::toggle(TargetStatus::Off, &client, name, hook, cli.json).await; } TargetsCommand::Delete { name, hook } => { - targets::delete(&client, name, hook, cli.json).await + targets::delete(&client, name, hook, cli.json).await; } }, Commands::Events { command } => match command { EventsCommands::List { hook, limit } => { - events::list(&client, hook, limit, cli.json).await + events::list(&client, hook, limit, cli.json).await; } EventsCommands::Show { event_id } => events::show(&client, event_id, cli.json).await, EventsCommands::Latest { hook } => events::latest(&client, hook, cli.json).await, EventsCommands::Expire { event_id } => { - events::expire(&client, event_id, cli.json).await + events::expire(&client, event_id, cli.json).await; } }, Commands::Replay { @@ -101,5 +113,10 @@ async fn main() { hook, target, } => replay::run(&client, event, hook, target, cli.json).await, + Commands::Watch { + slug, + deliveries, + target, + } => watch::run(&client, slug, deliveries, target, cli.json).await, } } diff --git a/src/models/mod.rs b/src/models/mod.rs index 58075cd..8dcbc04 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -4,6 +4,7 @@ pub mod events; pub mod health_status; pub mod hook; pub mod member; +pub mod sse_message; pub mod target; pub mod user_me; pub mod workspace; diff --git a/src/models/sse_message.rs b/src/models/sse_message.rs new file mode 100644 index 0000000..cf76e43 --- /dev/null +++ b/src/models/sse_message.rs @@ -0,0 +1,26 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum SseMessage { + #[serde(rename = "event.received")] + EventReceived { event: EventPayload }, + #[serde(rename = "delivery.completed")] + DeliveryCompleted { delivery: DeliveryPayload }, +} + +#[derive(Debug, Deserialize)] +pub struct EventPayload { + pub id: String, + pub method: String, + pub received_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct DeliveryPayload { + pub event_id: String, + pub target_name: String, + pub status: String, + pub response_status: Option, + pub duration_ms: Option, +} diff --git a/src/utils/output.rs b/src/utils/output.rs index c2f8759..62f2aa6 100644 --- a/src/utils/output.rs +++ b/src/utils/output.rs @@ -68,7 +68,7 @@ impl Theme { pub fn tone_cell(self, value: impl Into, tone: Tone) -> Cell { let cell = Cell::new(value.into()); - if stdout_color_enabled() { + if self.colors { cell.fg(table_color(tone)) } else { cell @@ -113,7 +113,6 @@ pub fn info(message: impl AsRef) { println!("{}", paint(message, Tone::Info)); } -#[allow(dead_code)] pub fn empty(message: impl AsRef) { println!("{}", paint(message, Tone::Muted)); } @@ -155,12 +154,10 @@ pub fn cell(value: impl Into) -> Cell { Cell::new(value.into()) } -#[allow(dead_code)] pub fn tone_cell(value: impl Into, tone: Tone) -> Cell { theme().tone_cell(value, tone) } -#[allow(dead_code)] pub fn url_cell(value: impl AsRef) -> Cell { tone_cell(value.as_ref(), Tone::Info) } diff --git a/src/utils/time.rs b/src/utils/time.rs index 5683aea..ea3772c 100644 --- a/src/utils/time.rs +++ b/src/utils/time.rs @@ -1,3 +1,23 @@ +use chrono::{DateTime, Utc}; + pub fn fmt_time(iso: &str) -> String { - iso.get(..19).unwrap_or(iso).replace('T', " ") + DateTime::parse_from_rfc3339(iso).map_or_else( + |_| iso.get(..19).unwrap_or(iso).replace('T', " "), + |dt| { + dt.with_timezone(&Utc) + .format("%Y-%m-%d %H:%M:%S") + .to_string() + }, + ) +} + +pub fn fmt_clock_time(iso: &str) -> String { + DateTime::parse_from_rfc3339(iso).map_or_else( + |_| "??:??:??".to_string(), + |dt| dt.with_timezone(&Utc).format("%H:%M:%S").to_string(), + ) +} + +pub fn current_clock_time() -> String { + Utc::now().format("%H:%M:%S").to_string() } diff --git a/src/utils/workspace.rs b/src/utils/workspace.rs index 48c3c83..d6900eb 100644 --- a/src/utils/workspace.rs +++ b/src/utils/workspace.rs @@ -38,28 +38,6 @@ pub async fn resolve_hook_or_exit(client: &ApiClient, slug: &str) -> Hook { } } -#[allow(dead_code)] -pub async fn resolve_target_id( - client: &ApiClient, - hook_slug: &str, - target_name: &str, -) -> Result, ApiError> { - let Some(workspace_id) = resolve_workspace_id(client).await? else { - return Ok(None); - }; - - let Some(hook) = resolve_hook_in_workspace(client, &workspace_id, hook_slug).await? else { - return Ok(None); - }; - - let targets = client.list_targets(&hook.id).await?; - - Ok(targets - .into_iter() - .find(|t| t.name == target_name) - .map(|t| t.id)) -} - pub async fn resolve_target_id_or_exit( client: &ApiClient, hook_slug: &str, @@ -71,8 +49,10 @@ pub async fn resolve_target_id_or_exit( Ok(targets) => targets .into_iter() .find(|target| target.name == target_name) - .map(|target| target.id) - .unwrap_or_else(|| exit_target_not_found(target_name, hook_slug)), + .map_or_else( + || exit_target_not_found(target_name, hook_slug), + |target| target.id, + ), Err(e) => exit_api_error( e, "resolve target", diff --git a/tests/replay_commands.rs b/tests/replay_commands.rs index 12d88ec..1f3cb5f 100644 --- a/tests/replay_commands.rs +++ b/tests/replay_commands.rs @@ -139,6 +139,64 @@ fn replay_latest_resolves_hook_and_posts_event_replay() { fs::remove_dir_all(home).expect("remove temp home"); } +#[test] +fn replay_latest_to_target_resolves_hook_event_and_target() { + let server = MockServer::start(vec![ + ok_json(user_with_active_workspace()), + ok_json(hooks()), + ok_json(event_list()), + ok_json(targets()), + ok_json("{}"), + ]); + let base_url = server.base_url.clone(); + let home = temp_home("replay-latest-target"); + write_config( + &home, + &format!("api_base_url = \"{base_url}\"\ntoken = \"tofu_pat_saved\"\n"), + ); + + let output = tofu_command(&home) + .args([ + "--json", "replay", "latest", "--hook", "stripe", "--target", "dev", + ]) + .output() + .expect("run tofu-cli replay latest target"); + + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains(r#""status":"accepted""#)); + assert!(stdout.contains(r#""event_id":"event_1""#)); + assert!(stdout.contains(r#""target":"dev""#)); + + let requests = server.finish(); + assert_eq!(requests.len(), 5); + assert_eq!(requests[0].method, "GET"); + assert_eq!(requests[0].path, "/api/me"); + assert_eq!(requests[1].method, "GET"); + assert_eq!(requests[1].path, "/api/workspaces/workspace_1/hooks"); + assert_eq!(requests[2].method, "GET"); + assert_eq!(requests[2].path, "/api/hooks/hook_1/events?limit=1"); + assert_eq!(requests[3].method, "GET"); + assert_eq!(requests[3].path, "/api/hooks/hook_1/targets"); + assert_eq!(requests[4].method, "POST"); + assert_eq!(requests[4].path, "/api/events/event_1/replay/target_1"); + assert_eq!( + requests[4].header("authorization"), + Some("Bearer tofu_pat_saved") + ); + assert!( + requests[4].body.is_empty(), + "targeted replay should not send a JSON null body" + ); + + fs::remove_dir_all(home).expect("remove temp home"); +} + #[test] fn replay_event_to_target_fetches_event_then_resolves_target() { let server = MockServer::start(vec![ok_json(event()), ok_json(targets()), ok_json("{}")]); diff --git a/tests/version_commands.rs b/tests/version_commands.rs new file mode 100644 index 0000000..aca1800 --- /dev/null +++ b/tests/version_commands.rs @@ -0,0 +1,49 @@ +mod support; + +use support::{temp_home, tofu_command}; + +#[test] +fn version_prints_package_version() { + let home = temp_home("version"); + + let output = tofu_command(&home) + .arg("version") + .output() + .expect("run tofu-cli version"); + + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + env!("CARGO_PKG_VERSION") + ); + + std::fs::remove_dir_all(home).expect("remove temp home"); +} + +#[test] +fn version_prints_json() { + let home = temp_home("version-json"); + + let output = tofu_command(&home) + .args(["--json", "version"]) + .output() + .expect("run tofu-cli version json"); + + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("version JSON output"); + assert_eq!(value["version"], env!("CARGO_PKG_VERSION")); + + std::fs::remove_dir_all(home).expect("remove temp home"); +}