diff --git a/AGENTS.md b/AGENTS.md index c753c614a..504b5139e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Production: `https://webhooks.cc` (app) and `https://go.webhooks.cc` (webhook re | `content/docs/` | MDX source for `/docs/*`, rendered by the catch-all route in `apps/web/app/docs/`. | | `apps/receiver-rs/` | Rust (Axum, Tokio, sqlx) webhook receiver. Captures at `/w/{slug}`. Env vars are read in `src/config.rs`. | | `apps/mx-rs/` | Rust (Tokio) receive-only SMTP host for email capture on `mailhooks.cc`. Env vars are read in `src/config.rs`. | -| `apps/cli-rs/` | Rust CLI `whk` (Clap, Ratatui). Subcommands live in `src/cli/`. | +| `apps/cli-rs/` | Rust CLI `whk` (Clap). Subcommands live in `src/cli/`; exit statuses and error codes in `src/error.rs`. | | `apps/edge-rs/` | Rust (Tokio, hyper, yamux) tunnel edge: serves `whk share` previews on sharehooks.cc and relays `whk tunnel --live` captures. Env vars are read in `src/config.rs`. | | `crates/tunnel/` | `whk-tunnel`, the tunnel protocol (WebSocket, yamux, splice) shared by the edge and the CLI. | | `apps/parser-rs/` | Rust attachment parser `webhooks-parser` (CSV, TSV, XLSX, XLS, ODS, PDF text, text): a sandboxed child process of the web app's parse worker. | @@ -86,6 +86,8 @@ Capture path: a sender POSTs to `go.webhooks.cc/w/{slug}/...`. The receiver vali Decisions worth knowing, with the reasons: +- **Nobody needs the dashboard.** Every user-facing capability works from the API, the CLI, the SDKs and the MCP server, because people increasingly work through coding agents, and a step that needs a dashboard stops the agent. Signup and payment are the exceptions. Where a person has to decide (a sensitive change, a payment), the agent asks and the person approves through a link or a code, never by doing the work in the dashboard. The dashboard is a view, never the only way. +- **`whk` serves agents as well as people.** `--json` prints the API's own JSON (`Fetched` in `api/client.rs` keeps it beside the parsed value, so fields the CLI does not know still reach the caller), failures carry a code and a stable exit status (`src/error.rs`, listed in `whk --help` and `/docs/cli`), nothing reads stdin without a terminal (`cli/confirm.rs`: `--yes` instead), `whk wait` exits on the next arrival, and `whk api` reaches any route a command does not cover. There is no full-screen TUI: a bare `whk` opened one and hung any agent that ran it, so it was retired (November 2026) for one-line and NDJSON output from `listen` and `tunnel`. - **The receiver talks to Postgres directly** (`DATABASE_URL`), not through the web app. One fewer hop on the hot path, and the stored procedure keeps quota and counters atomic. In production it connects to Postgres on the same Docker network rather than through Supavisor: on one host the pooler only costs CPU, and a load test sustained about twice the throughput without it. - **DB failures are classified.** Transient errors (pool timeout, connection loss, SQLSTATE classes 08/40/53/57/58) return 503 with `Retry-After: 5` so senders retry; at-least-once delivery beats silent loss. Permanent errors fail open with 200 and are logged and counted in the receiver's once-a-minute `counts` log line (`capture_failed.`). NUL bytes in bodies and paths are sanitised (raw bytes kept in `body_raw`) instead of failing the insert. - **RLS is deny-by-default for client roles.** Anonymous users cannot read endpoints, requests, or device codes, and may only read published blog posts. Guest dashboard reads and guest endpoint creation go through server routes using the service role. Client roles have no write access to `users`, `endpoints`, or `api_keys` (migrations 00037 and 00045; every write goes through a server route that validates, rate-limits, and audits it) and no EXECUTE on `public` functions unless a migration grants it (00037 revoked them and reset the default privileges). Team members can read shared endpoints through `can_view_team_endpoint()`, which also lets them join the endpoint's Realtime topic. diff --git a/README.md b/README.md index af1c33100..b0fe12e52 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ curl -X POST https://go.webhooks.cc/w/ -d '{"test": true}' - **Verify signatures**: check provider signatures (Stripe, GitHub, Shopify and more) on captured requests - **Forward**: tunnel webhooks to localhost during development (with `--live`, the sender gets your local app's answer), or forward them to other URLs, chat and file drops - **Share localhost**: put the site on your machine at a public link on sharehooks.cc with `whk share` -- **CLI with interactive TUI**: manage endpoints, tunnel and stream requests from your terminal +- **CLI for people and agents**: manage endpoints, tunnel, stream and wait for requests from your terminal, with JSON output and stable exit codes for scripts and coding agents - **SDKs for TypeScript, Python and Go**: wait for and assert on webhooks in tests and CI, and share a local port from code - **pytest plugin**: `pip install pytest-webhooks-cc` gives every test a capture URL and an email address as fixtures - **GitHub Action**: [`setup-whk`](https://github.com/marketplace/actions/setup-whk) installs the CLI in a workflow @@ -124,35 +124,18 @@ See [webhooks.cc/installation](https://webhooks.cc/installation) for all install ## CLI -Run `whk` to launch the interactive TUI: - -``` -$ whk - - webhooks.cc - - ● Logged in as you@example.com - - ▸ Tunnel Forward webhooks to localhost - Listen Stream incoming requests - Create Create a new endpoint - Endpoints Manage your endpoints - Auth Login / logout - Update Check for updates -``` - -All commands also work directly from the command line: - ```bash whk tunnel 8080 # Forward webhooks to localhost:8080 whk share 3000 # Share localhost:3000 at https://.sharehooks.cc -whk listen # Stream incoming requests +whk listen # Stream incoming requests, one line each +whk wait --json # Block until the next request arrives, print it, exit whk create my-endpoint # Create a new endpoint whk list # List your endpoints whk replay --to :3000 # Replay a captured request +whk api endpoints//destinations # Call any API route with your credentials ``` -Pass `--nogui` or set `WHK_NOGUI=1` to skip the TUI and print help. +Every command takes `--json` (the API's own answers, NDJSON for streams, failures as `{"error": {...}}` on stderr) and exits with a stable status that says what went wrong; see `whk --help`. Nothing waits for input without a terminal: destructive commands need `--yes` there. ## SDK diff --git a/apps/cli-rs/Cargo.lock b/apps/cli-rs/Cargo.lock index c726ce81a..684ef0b2d 100644 --- a/apps/cli-rs/Cargo.lock +++ b/apps/cli-rs/Cargo.lock @@ -8,15 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -88,24 +79,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -182,42 +155,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "block-buffer" version = "0.12.0" @@ -233,12 +176,6 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" -[[package]] -name = "by_address" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" - [[package]] name = "bytemuck" version = "1.25.0" @@ -257,15 +194,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cc" version = "1.2.58" @@ -295,8 +223,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.0", + "cpufeatures", + "rand_core", ] [[package]] @@ -368,20 +296,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "compact_str" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "const-oid" version = "0.10.2" @@ -403,15 +317,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "cpufeatures" version = "0.3.0" @@ -430,19 +335,13 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - [[package]] name = "crossterm" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.13.0", + "bitflags", "crossterm_winapi", "derive_more", "document-features", @@ -463,16 +362,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "crypto-common" version = "0.2.1" @@ -482,71 +371,12 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "csscolorparser" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" -dependencies = [ - "lab", - "phf", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.117", -] - [[package]] name = "data-encoding" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" -[[package]] -name = "deltae" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -569,25 +399,15 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - [[package]] name = "digest" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ - "block-buffer 0.12.0", + "block-buffer", "const-oid", - "crypto-common 0.2.1", + "crypto-common", ] [[package]] @@ -631,12 +451,6 @@ dependencies = [ "litrs", ] -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - [[package]] name = "equivalent" version = "1.0.2" @@ -653,42 +467,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "euclid" -version = "0.22.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" -dependencies = [ - "num-traits", -] - -[[package]] -name = "fancy-regex" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" -dependencies = [ - "bit-set", - "regex", -] - -[[package]] -name = "fast-srgb8" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" - -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - [[package]] name = "filetime" version = "0.2.27" @@ -706,18 +484,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "finl_unicode" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" - -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "flate2" version = "1.1.11" @@ -729,12 +495,6 @@ dependencies = [ "zlib-rs", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -872,16 +632,6 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b" -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "gethostname" version = "1.1.0" @@ -905,18 +655,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.2" @@ -926,8 +664,8 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 6.0.0", - "rand_core 0.10.0", + "r-efi", + "rand_core", "wasip2", "wasip3", "wasm-bindgen", @@ -947,11 +685,6 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] [[package]] name = "hashbrown" @@ -1203,12 +936,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -1254,28 +981,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - -[[package]] -name = "instability" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" -dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -1317,15 +1022,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -1344,29 +1040,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kasuari" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" -dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.21", -] - -[[package]] -name = "lab" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -1379,33 +1052,18 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - [[package]] name = "libredox" version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags 2.13.0", + "bitflags", "libc", "plain", "redox_syscall 0.7.3", ] -[[package]] -name = "line-clipping" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" -dependencies = [ - "bitflags 2.13.0", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1454,16 +1112,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "mac_address" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" -dependencies = [ - "nix", - "winapi", -] - [[package]] name = "matchit" version = "0.8.4" @@ -1476,33 +1124,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memmem" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.9.1" @@ -1536,51 +1163,11 @@ dependencies = [ ] [[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - -[[package]] -name = "nohash-hasher" -version = "0.2.0" +name = "nohash-hasher" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1590,15 +1177,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -1627,39 +1205,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - -[[package]] -name = "palette" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" -dependencies = [ - "approx", - "fast-srgb8", - "libm", - "palette_derive", -] - -[[package]] -name = "palette_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" -dependencies = [ - "by_address", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -1689,101 +1234,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pest" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pest_meta" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" -dependencies = [ - "pest", - "sha2 0.10.9", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand 0.8.6", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - [[package]] name = "pin-project" version = "1.1.13" @@ -1816,12 +1266,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - [[package]] name = "potential_utf" version = "0.1.5" @@ -1831,12 +1275,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "prettyplease" version = "0.2.37" @@ -1882,7 +1320,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.21", + "thiserror", "tokio", "tracing", "web-time", @@ -1897,14 +1335,14 @@ dependencies = [ "bytes", "getrandom 0.4.2", "lru-slab", - "rand 0.10.3", + "rand", "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.21", + "thiserror", "tinyvec", "tracing", "web-time", @@ -1933,27 +1371,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.10.3" @@ -1962,15 +1385,9 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand_core 0.10.0", + "rand_core", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - [[package]] name = "rand_core" version = "0.10.0" @@ -1983,108 +1400,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.10.0", -] - -[[package]] -name = "ratatui" -version = "0.30.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" -dependencies = [ - "instability", - "ratatui-core", - "ratatui-crossterm", - "ratatui-macros", - "ratatui-termina", - "ratatui-termwiz", - "ratatui-widgets", - "serde", -] - -[[package]] -name = "ratatui-core" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" -dependencies = [ - "bitflags 2.13.0", - "compact_str", - "critical-section", - "hashbrown 0.17.1", - "itertools", - "kasuari", - "lru", - "palette", - "serde", - "strum", - "thiserror 2.0.21", - "unicode-segmentation", - "unicode-truncate", - "unicode-width", -] - -[[package]] -name = "ratatui-crossterm" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" -dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", -] - -[[package]] -name = "ratatui-macros" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" -dependencies = [ - "ratatui-core", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-termina" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" -dependencies = [ - "instability", - "ratatui-core", - "termina", -] - -[[package]] -name = "ratatui-termwiz" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" -dependencies = [ - "ratatui-core", - "termwiz", -] - -[[package]] -name = "ratatui-widgets" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.17.1", - "indoc", - "instability", - "itertools", - "line-clipping", - "ratatui-core", - "serde", - "strum", - "time", - "unicode-segmentation", - "unicode-width", + "rand_core", ] [[package]] @@ -2093,7 +1409,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags", ] [[package]] @@ -2102,7 +1418,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "bitflags 2.13.0", + "bitflags", ] [[package]] @@ -2113,38 +1429,9 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.21", + "thiserror", ] -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - [[package]] name = "reqwest" version = "0.12.28" @@ -2253,7 +1540,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -2392,19 +1679,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.2", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", + "cpufeatures", + "digest", ] [[package]] @@ -2414,8 +1690,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.2", + "cpufeatures", + "digest", ] [[package]] @@ -2461,12 +1737,6 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - [[package]] name = "slab" version = "0.4.12" @@ -2507,44 +1777,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.117" @@ -2598,109 +1836,13 @@ dependencies = [ "xattr", ] -[[package]] -name = "termina" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" -dependencies = [ - "bitflags 2.13.0", - "parking_lot", - "rustix", - "signal-hook", - "windows-sys 0.61.2", -] - -[[package]] -name = "terminfo" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" -dependencies = [ - "fnv", - "nom", - "phf", - "phf_codegen", -] - -[[package]] -name = "termios" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" -dependencies = [ - "libc", -] - -[[package]] -name = "termwiz" -version = "0.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" -dependencies = [ - "anyhow", - "base64 0.22.1", - "bitflags 2.13.0", - "fancy-regex", - "filedescriptor", - "finl_unicode", - "fixedbitset", - "hex", - "lazy_static", - "libc", - "log", - "memmem", - "nix", - "num-derive", - "num-traits", - "ordered-float", - "pest", - "pest_derive", - "phf", - "sha2 0.10.9", - "signal-hook", - "siphasher", - "terminfo", - "termios", - "thiserror 1.0.69", - "ucd-trie", - "unicode-segmentation", - "vtparse", - "wezterm-bidi", - "wezterm-blob-leases", - "wezterm-color-types", - "wezterm-dynamic", - "wezterm-input-types", - "winapi", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.21", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "thiserror-impl", ] [[package]] @@ -2714,27 +1856,6 @@ dependencies = [ "syn 3.0.2", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - [[package]] name = "tinystr" version = "0.8.3" @@ -2861,7 +1982,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.13.0", + "bitflags", "bytes", "futures-util", "http", @@ -2922,11 +2043,11 @@ dependencies = [ "http", "httparse", "log", - "rand 0.10.3", + "rand", "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.21", + "thiserror", ] [[package]] @@ -2935,12 +2056,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -2953,17 +2068,6 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" -[[package]] -name = "unicode-truncate" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" -dependencies = [ - "itertools", - "unicode-segmentation", - "unicode-width", -] - [[package]] name = "unicode-width" version = "0.2.3" @@ -3012,33 +2116,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "1.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" -dependencies = [ - "atomic", - "getrandom 0.4.2", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vtparse" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" -dependencies = [ - "utf8parse", -] - [[package]] name = "want" version = "0.3.1" @@ -3168,7 +2245,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.13.0", + "bitflags", "hashbrown 0.15.5", "indexmap", "semver", @@ -3212,78 +2289,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "wezterm-bidi" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" -dependencies = [ - "log", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-blob-leases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" -dependencies = [ - "getrandom 0.3.4", - "mac_address", - "sha2 0.10.9", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "wezterm-color-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" -dependencies = [ - "csscolorparser", - "deltae", - "lazy_static", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-dynamic" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" -dependencies = [ - "log", - "ordered-float", - "strsim", - "thiserror 1.0.69", - "wezterm-dynamic-derive", -] - -[[package]] -name = "wezterm-dynamic-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "wezterm-input-types" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" -dependencies = [ - "bitflags 1.3.2", - "euclid", - "lazy_static", - "serde", - "wezterm-dynamic", -] - [[package]] name = "whk" version = "0.1.0" @@ -3302,8 +2307,7 @@ dependencies = [ "hex", "open", "qrcode", - "rand 0.10.3", - "ratatui", + "rand", "reqwest", "rpassword", "rqrr", @@ -3311,9 +2315,9 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2 0.11.1", + "sha2", "tar", - "thiserror 2.0.21", + "thiserror", "tokio", "tokio-stream", "tokio-tungstenite", @@ -3632,7 +2636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.13.0", + "bitflags", "indexmap", "log", "serde", diff --git a/apps/cli-rs/Cargo.toml b/apps/cli-rs/Cargo.toml index 09f8bda91..77ec34a20 100644 --- a/apps/cli-rs/Cargo.toml +++ b/apps/cli-rs/Cargo.toml @@ -2,7 +2,7 @@ name = "whk" version = "0.1.0" edition = "2024" -description = "CLI and TUI for webhooks.cc — inspect, forward, and replay webhooks" +description = "The webhooks.cc CLI: capture, inspect, forward and replay webhooks and email, share localhost, for people and coding agents" license = "MIT" repository = "https://github.com/kroqdotdev/webhooks-cc" @@ -15,8 +15,7 @@ path = "src/main.rs" clap = { version = "4", features = ["derive", "env", "string"] } clap_complete = "4" -# TUI -ratatui = "0.30" +# Terminal width for wrapped output crossterm = "0.29" # Async diff --git a/apps/cli-rs/src/api/client.rs b/apps/cli-rs/src/api/client.rs index d4acbb1e7..4ea7cf6e4 100644 --- a/apps/cli-rs/src/api/client.rs +++ b/apps/cli-rs/src/api/client.rs @@ -1,11 +1,94 @@ use anyhow::{Context, Result}; +use reqwest::header::{HeaderMap, RETRY_AFTER}; use reqwest::{Method, Response, StatusCode}; +use serde::de::DeserializeOwned; +use serde_json::Value; use super::{ApiClient, api_error_message}; +use crate::error::ApiError; /// HTTP response with body pre-read. pub struct ApiResponse { pub body: String, + /// The server's clock from the `Date` header, in ms (whole seconds). + pub server_time_ms: Option, +} + +impl ApiResponse { + /// The body as `T`, keeping the server's JSON beside it so `--json` + /// prints exactly what the API answered, fields this build does not know + /// included. + pub fn parse(&self, what: &str) -> Result> { + let raw: Value = + serde_json::from_str(&self.body).with_context(|| format!("failed to parse {what}"))?; + let value = serde_json::from_value(raw.clone()) + .with_context(|| format!("failed to parse {what}"))?; + Ok(Fetched { value, raw }) + } +} + +/// A parsed answer and the JSON it came from. Derefs to the parsed value. +#[derive(Debug, Clone)] +pub struct Fetched { + pub value: T, + pub raw: Value, +} + +impl Fetched { + /// What `--json` prints: the server's JSON, plus any key the CLI's own + /// type always printed that the server left out (an `id` the stream + /// sends as `_id`, a field it omits instead of sending null), so output + /// only ever gains fields. + pub fn json(&self) -> Value { + let mut out = self.raw.clone(); + if let Ok(typed) = serde_json::to_value(&self.value) { + fill_missing(&mut out, &typed); + } + out + } +} + +/// Adds to `out` every key `extra` has and `out` lacks, in nested objects +/// and in arrays of the same length too. Values `out` has are kept. +pub fn fill_missing(out: &mut Value, extra: &Value) { + match (out, extra) { + (Value::Object(out), Value::Object(extra)) => { + for (key, value) in extra { + match out.get_mut(key) { + Some(existing) => fill_missing(existing, value), + None => { + out.insert(key.clone(), value.clone()); + } + } + } + } + (Value::Array(out), Value::Array(extra)) if out.len() == extra.len() => { + for (o, e) in out.iter_mut().zip(extra) { + fill_missing(o, e); + } + } + _ => {} + } +} + +impl std::ops::Deref for Fetched { + type Target = T; + fn deref(&self) -> &T { + &self.value + } +} + +impl std::ops::DerefMut for Fetched { + fn deref_mut(&mut self) -> &mut T { + &mut self.value + } +} + +/// A raw answer for `whk api`: the status and body whatever they are. +pub struct RawResponse { + pub status: StatusCode, + pub headers: HeaderMap, + pub body: Vec, } impl ApiClient { @@ -83,6 +166,39 @@ impl ApiClient { read_response(resp).await } + /// Sends any request to an API path and returns the answer whatever its + /// status (`whk api`). `extra` headers are added after the defaults, so + /// they can replace `Content-Type`; the caller keeps `Authorization` and + /// `User-Agent` out of them. + pub async fn send_raw( + &self, + method: Method, + path: &str, + body: Option>, + extra_headers: &HeaderMap, + ) -> Result { + let mut resp = self + .send_once_with(&method, path, &body, false, extra_headers) + .await?; + if resp.status() == StatusCode::UNAUTHORIZED && self.can_refresh() { + resp = self + .send_once_with(&method, path, &body, true, extra_headers) + .await?; + } + let status = resp.status(); + let headers = resp.headers().clone(); + let body = resp + .bytes() + .await + .context("failed to read response body")? + .to_vec(); + Ok(RawResponse { + status, + headers, + body, + }) + } + async fn send_once( &self, method: &Method, @@ -90,7 +206,22 @@ impl ApiClient { body: &Option>, refresh: bool, ) -> Result { - let headers = self.headers_with(refresh).await?; + self.send_once_with(method, path, body, refresh, &HeaderMap::new()) + .await + } + + async fn send_once_with( + &self, + method: &Method, + path: &str, + body: &Option>, + refresh: bool, + extra: &HeaderMap, + ) -> Result { + let mut headers = self.headers_with(refresh).await?; + for (name, value) in extra { + headers.insert(name.clone(), value.clone()); + } let mut request = self .http .request(method.clone(), self.url(path)) @@ -104,11 +235,58 @@ impl ApiClient { async fn read_response(resp: Response) -> Result { let status = resp.status(); + let retry_after = retry_after(resp.headers()); + let server_time_ms = resp + .headers() + .get(reqwest::header::DATE) + .and_then(|v| v.to_str().ok()) + .and_then(|v| chrono::DateTime::parse_from_rfc2822(v).ok()) + .map(|t| t.timestamp() * 1000); let body = resp.text().await.context("failed to read response body")?; if status.is_client_error() || status.is_server_error() { - anyhow::bail!("{}", api_error_message(status, &body)); + return Err(api_error(status, &body, retry_after).into()); + } + + Ok(ApiResponse { + body, + server_time_ms, + }) +} + +/// The error for an answer with a failing status. +pub fn api_error(status: StatusCode, body: &str, retry_after: Option) -> ApiError { + ApiError { + status: status.as_u16(), + message: api_error_message(status, body), + retry_after, } +} + +/// Seconds from a `Retry-After` header given in seconds. +pub fn retry_after(headers: &HeaderMap) -> Option { + headers + .get(RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse().ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; - Ok(ApiResponse { body }) + #[test] + fn printed_json_keeps_server_fields_and_the_old_ones() { + let mut out = + json!({"_id": "r1", "method": "POST", "nested": {"a": 1}, "list": [{"x": 1}]}); + let typed = json!({"id": "r1", "method": "POST", "signatureVerified": null, + "nested": {"a": 1, "b": null}, "list": [{"x": 1, "y": null}]}); + fill_missing(&mut out, &typed); + assert_eq!( + out, + json!({"_id": "r1", "id": "r1", "method": "POST", "signatureVerified": null, + "nested": {"a": 1, "b": null}, "list": [{"x": 1, "y": null}]}) + ); + } } diff --git a/apps/cli-rs/src/api/endpoints.rs b/apps/cli-rs/src/api/endpoints.rs index 7fbbae7f6..faeedb23b 100644 --- a/apps/cli-rs/src/api/endpoints.rs +++ b/apps/cli-rs/src/api/endpoints.rs @@ -1,36 +1,37 @@ use anyhow::{Context, Result}; use super::ApiClient; +use super::client::Fetched; use crate::types::{ CreateEndpointRequest, Endpoint, EndpointList, FavoriteState, UpdateEndpointRequest, }; impl ApiClient { - pub async fn create_endpoint(&self, req: &CreateEndpointRequest) -> Result { + pub async fn create_endpoint(&self, req: &CreateEndpointRequest) -> Result> { self.require_auth()?; let resp = self.post("/api/endpoints", req).await?; - serde_json::from_str(&resp.body).context("failed to parse endpoint") + resp.parse("endpoint") } - pub async fn list_endpoints(&self) -> Result { + pub async fn list_endpoints(&self) -> Result> { self.require_auth()?; let resp = self.get("/api/endpoints").await?; - serde_json::from_str(&resp.body).context("failed to parse endpoint list") + resp.parse("endpoint list") } - pub async fn get_endpoint(&self, slug: &str) -> Result { + pub async fn get_endpoint(&self, slug: &str) -> Result> { self.require_auth()?; let resp = self .get(&format!("/api/endpoints/{}", urlencoding::encode(slug))) .await?; - serde_json::from_str(&resp.body).context("failed to parse endpoint") + resp.parse("endpoint") } pub async fn update_endpoint( &self, slug: &str, req: &UpdateEndpointRequest, - ) -> Result { + ) -> Result> { self.require_auth()?; let resp = self .patch( @@ -38,7 +39,7 @@ impl ApiClient { req, ) .await?; - serde_json::from_str(&resp.body).context("failed to parse endpoint") + resp.parse("endpoint") } pub async fn delete_endpoint(&self, slug: &str) -> Result<()> { diff --git a/apps/cli-rs/src/api/mod.rs b/apps/cli-rs/src/api/mod.rs index c84dc407c..2cbff544a 100644 --- a/apps/cli-rs/src/api/mod.rs +++ b/apps/cli-rs/src/api/mod.rs @@ -298,12 +298,9 @@ impl ApiClient { /// Require auth or return a friendly error. pub fn require_auth(&self) -> Result<()> { if matches!(self.bearer, Bearer::None) { - match ignored_key_hint() { - Some(hint) => anyhow::bail!("Not logged in. {hint}"), - None => anyhow::bail!( - "Not logged in. Run `whk auth login` first, or connect an agent with `whk sandbox connect --email
`." - ), - } + // The next step (log in, or why WHK_API_KEY was ignored) comes + // with the error's hint; see `error::classify`. + return Err(crate::error::CliError::NotLoggedIn("Not logged in.".into()).into()); } Ok(()) } diff --git a/apps/cli-rs/src/api/requests.rs b/apps/cli-rs/src/api/requests.rs index 3a0297775..798c0f3ab 100644 --- a/apps/cli-rs/src/api/requests.rs +++ b/apps/cli-rs/src/api/requests.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use urlencoding::encode; use super::ApiClient; +use super::client::Fetched; use crate::types::{ CapturedRequest, CountResult, EmailFormat, PaginatedRequestList, RenderedEmail, RequestList, SearchResult, @@ -13,7 +14,7 @@ impl ApiClient { slug: &str, limit: Option, since: Option, - ) -> Result { + ) -> Result> { self.require_auth()?; let mut params = vec![]; if let Some(l) = limit { @@ -30,12 +31,15 @@ impl ApiClient { let resp = self .get(&format!("/api/endpoints/{}/requests{qs}", encode(slug))) .await?; - // API returns a bare array - let requests: Vec = - serde_json::from_str(&resp.body).context("failed to parse request list")?; - Ok(RequestList { - count: None, - requests, + // API returns a bare array; --json prints it as {"requests": [...]} + // as it always has. + let list = resp.parse::>("request list")?; + Ok(Fetched { + value: RequestList { + count: None, + requests: list.value, + }, + raw: serde_json::json!({ "requests": list.raw, "count": null }), }) } @@ -44,7 +48,7 @@ impl ApiClient { slug: &str, limit: Option, cursor: Option<&str>, - ) -> Result { + ) -> Result> { self.require_auth()?; let mut params = vec![]; if let Some(l) = limit { @@ -64,15 +68,15 @@ impl ApiClient { encode(slug) )) .await?; - serde_json::from_str(&resp.body).context("failed to parse paginated request list") + resp.parse("paginated request list") } - pub async fn get_request(&self, request_id: &str) -> Result { + pub async fn get_request(&self, request_id: &str) -> Result> { self.require_auth()?; let resp = self .get(&format!("/api/requests/{}", encode(request_id))) .await?; - serde_json::from_str(&resp.body).context("failed to parse request") + resp.parse("request") } /// Render a captured email for a handler (`POST /api/requests/{id}/render`): @@ -109,7 +113,7 @@ impl ApiClient { limit: Option, offset: Option, order: Option<&str>, - ) -> Result { + ) -> Result> { self.require_auth()?; let mut params = vec![]; if let Some(s) = slug { @@ -142,7 +146,7 @@ impl ApiClient { format!("?{}", params.join("&")) }; let resp = self.get(&format!("/api/search/requests{qs}")).await?; - serde_json::from_str(&resp.body).context("failed to parse search results") + resp.parse("search results") } pub async fn count_requests( @@ -190,3 +194,19 @@ impl ApiClient { Ok(()) } } + +#[cfg(test)] +mod page_tests { + use super::*; + + #[test] + fn a_cursor_page_parses_as_the_typed_list() { + let list: PaginatedRequestList = serde_json::from_str( + r#"{"items":[{"id":"r1","endpointId":"e","method":"POST","path":"/","headers":{},"queryParams":{},"ip":"192.0.2.1","size":0,"receivedAt":1}],"cursor":"c","hasMore":true}"#, + ) + .unwrap(); + assert_eq!(list.requests.len(), 1); + assert_eq!(list.next_cursor.as_deref(), Some("c")); + assert_eq!(list.has_more, Some(true)); + } +} diff --git a/apps/cli-rs/src/api/stream.rs b/apps/cli-rs/src/api/stream.rs index a61b1f75f..9633ca1da 100644 --- a/apps/cli-rs/src/api/stream.rs +++ b/apps/cli-rs/src/api/stream.rs @@ -9,7 +9,8 @@ use tokio::task::JoinHandle; use super::{ApiClient, extract_error}; use crate::agent::AgentError; -use crate::types::{CapturedRequest, SseEvent}; +use crate::error::ApiError; +use crate::types::{SseEvent, Streamed}; const MAX_BUFFER_SIZE: usize = 1024 * 1024; // 1 MB const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); @@ -45,6 +46,9 @@ enum ConnectError { Transient(String), /// A 401: terminal, unless a new token can be exchanged. Unauthorized(String), + /// A refusal with its HTTP status (403, 404, ...), so the command exits + /// with the matching status. + Rejected(u16, String), } impl ApiClient { @@ -61,6 +65,18 @@ impl ApiClient { /// connection the token endpoint refused). A token endpoint that cannot /// be reached or answers 408, 429 or 5xx goes through the backoff. pub async fn stream_requests(&self, slug: &str, tx: mpsc::Sender) -> Result<()> { + self.stream_requests_from(slug, None, tx).await + } + + /// [`Self::stream_requests`], starting from the requests received after + /// `since` (ms; the server sends those first, oldest first), or from the + /// stream's own start on the server's clock when `None`. + pub async fn stream_requests_from( + &self, + slug: &str, + since: Option, + tx: mpsc::Sender, + ) -> Result<()> { self.require_auth()?; let sse_client = reqwest::Client::builder() @@ -69,7 +85,10 @@ impl ApiClient { .context("failed to create SSE client")?; let base_url = self.url(&format!("/api/stream/{}", urlencoding::encode(slug))); - let mut cursor = ResumeCursor::default(); + let mut cursor = ResumeCursor { + floor: since, + ..ResumeCursor::default() + }; let mut attempt: u32 = 0; let mut rapid_rotations: u32 = 0; // An agent connection's token lives an hour, so each connection asks @@ -108,8 +127,22 @@ impl ApiClient { refresh = true; continue; } - Err(ConnectError::Terminal(msg) | ConnectError::Unauthorized(msg)) => { - anyhow::bail!(msg) + Err(ConnectError::Terminal(msg)) => anyhow::bail!(msg), + Err(ConnectError::Unauthorized(message)) => { + return Err(ApiError { + status: 401, + message, + retry_after: None, + } + .into()); + } + Err(ConnectError::Rejected(status, message)) => { + return Err(ApiError { + status, + message, + retry_after: None, + } + .into()); } Err(ConnectError::Transient(msg)) => StreamEnd::Failed(msg), }, @@ -217,6 +250,7 @@ async fn connect( ConnectError::Terminal(msg) if status == StatusCode::UNAUTHORIZED => { Err(ConnectError::Unauthorized(msg)) } + ConnectError::Terminal(msg) => Err(ConnectError::Rejected(status.as_u16(), msg)), other => Err(other), } } @@ -433,15 +467,12 @@ fn parse_sse_event(event_type: &str, data: &str) -> Option { let _: serde_json::Value = serde_json::from_str(data).ok()?; Some(SseEvent::Connected) } - "request" => { - let req: CapturedRequest = serde_json::from_str(data).ok()?; - Some(SseEvent::Request(Box::new(req))) - } + "request" => Some(SseEvent::Request(Box::new(Streamed::parse(data)?))), "endpoint_deleted" => Some(SseEvent::EndpointDeleted), "timeout" => Some(SseEvent::Timeout), _ => { if !data.is_empty() - && let Ok(req) = serde_json::from_str::(data) + && let Some(req) = Streamed::parse(data) { return Some(SseEvent::Request(Box::new(req))); } diff --git a/apps/cli-rs/src/api/teams.rs b/apps/cli-rs/src/api/teams.rs index 1c3f260fb..76f5baead 100644 --- a/apps/cli-rs/src/api/teams.rs +++ b/apps/cli-rs/src/api/teams.rs @@ -1,16 +1,17 @@ -use anyhow::{Context, Result}; +use anyhow::Result; use super::ApiClient; +use super::client::Fetched; use crate::types::{Team, TeamInvite, TeamMembersResponse}; impl ApiClient { - pub async fn list_teams(&self) -> Result> { + pub async fn list_teams(&self) -> Result>> { self.require_auth()?; let resp = self.get("/api/teams").await?; - serde_json::from_str(&resp.body).context("failed to parse team list") + resp.parse("team list") } - pub async fn list_team_members(&self, team_id: &str) -> Result { + pub async fn list_team_members(&self, team_id: &str) -> Result> { self.require_auth()?; let resp = self .get(&format!( @@ -18,7 +19,7 @@ impl ApiClient { urlencoding::encode(team_id) )) .await?; - serde_json::from_str(&resp.body).context("failed to parse team members") + resp.parse("team members") } /// Share an endpoint the caller owns with a team. Takes the endpoint id; @@ -45,7 +46,11 @@ impl ApiClient { Ok(()) } - pub async fn invite_team_member(&self, team_id: &str, email: &str) -> Result { + pub async fn invite_team_member( + &self, + team_id: &str, + email: &str, + ) -> Result> { self.require_auth()?; let body = serde_json::json!({ "email": email }); let resp = self @@ -54,13 +59,13 @@ impl ApiClient { &body, ) .await?; - serde_json::from_str(&resp.body).context("failed to parse invite") + resp.parse("invite") } - pub async fn list_invites(&self) -> Result> { + pub async fn list_invites(&self) -> Result>> { self.require_auth()?; let resp = self.get("/api/invites").await?; - serde_json::from_str(&resp.body).context("failed to parse invite list") + resp.parse("invite list") } pub async fn accept_invite(&self, invite_id: &str) -> Result<()> { diff --git a/apps/cli-rs/src/cli/api.rs b/apps/cli-rs/src/cli/api.rs new file mode 100644 index 000000000..c0f1a776b --- /dev/null +++ b/apps/cli-rs/src/cli/api.rs @@ -0,0 +1,179 @@ +//! `whk api `: any API route with the CLI's credentials, like `gh api`. +//! It covers whatever has no command of its own yet, so an agent is never +//! sent to the dashboard for something the API can do. + +use std::io::{IsTerminal, Read, Write}; + +use anyhow::{Context, Result}; +use reqwest::Method; +use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AGENT}; + +use crate::api::ApiClient; +use crate::api::client::{api_error, retry_after}; +use crate::error::CliError; + +pub async fn run( + client: &ApiClient, + path: &str, + method: Option<&str>, + data: Option<&str>, + headers: &[String], + include: bool, +) -> Result<()> { + let path = api_path(path)?; + client.require_auth()?; + let body = data.map(read_data).transpose()?; + let method = match method { + Some(m) => Method::from_bytes(m.trim().to_ascii_uppercase().as_bytes()) + .map_err(|_| CliError::Usage(format!("invalid HTTP method: {m}")))?, + None if body.is_some() => Method::POST, + None => Method::GET, + }; + let headers = header_map(headers)?; + + let resp = client.send_raw(method, &path, body, &headers).await?; + + if include { + let mut err = std::io::stderr().lock(); + writeln!(err, "HTTP {}", resp.status)?; + for (name, value) in &resp.headers { + writeln!(err, "{name}: {}", value.to_str().unwrap_or(""))?; + } + writeln!(err)?; + } + + let mut out = std::io::stdout().lock(); + out.write_all(&resp.body)?; + // The body goes out byte for byte; only a terminal gets a line end, so + // the prompt does not land after the JSON. + if out.is_terminal() && !resp.body.is_empty() && !resp.body.ends_with(b"\n") { + out.write_all(b"\n")?; + } + out.flush()?; + + if resp.status.is_client_error() || resp.status.is_server_error() { + let text = String::from_utf8_lossy(&resp.body); + return Err(api_error(resp.status, &text, retry_after(&resp.headers)).into()); + } + Ok(()) +} + +/// The path to request: under `/api`, never another host (the bearer must +/// only ever go to webhooks.cc). +fn api_path(path: &str) -> Result { + let path = path.trim(); + if path.contains("://") || path.starts_with("//") { + return Err(CliError::Usage( + "give an API path such as /api/endpoints, not a URL (whk only sends your credentials to the configured API)" + .into(), + ) + .into()); + } + let trimmed = path.trim_start_matches('/'); + if trimmed.is_empty() { + return Err(CliError::Usage("give an API path such as /api/endpoints".into()).into()); + } + if trimmed == "api" || trimmed.starts_with("api/") || trimmed.starts_with("api?") { + Ok(format!("/{trimmed}")) + } else { + Ok(format!("/api/{trimmed}")) + } +} + +/// `-H Key:Value` flags as headers. Malformed ones, and `Authorization` or +/// `User-Agent` (whk sends its own), are usage errors. +fn header_map(headers: &[String]) -> Result { + let mut map = HeaderMap::new(); + for h in headers { + let usage = |why: String| -> anyhow::Error { CliError::Usage(why).into() }; + let (name, value) = h + .split_once(':') + .ok_or_else(|| usage(format!("invalid header: {h} (expected Key:Value)")))?; + let name = HeaderName::from_bytes(name.trim().as_bytes()) + .map_err(|_| usage(format!("invalid header name: {}", name.trim())))?; + if name == AUTHORIZATION || name == USER_AGENT { + return Err(usage(format!("whk sets the {name} header itself"))); + } + let value = HeaderValue::from_str(value.trim()) + .map_err(|_| usage(format!("invalid value for header {name}")))?; + map.insert(name, value); + } + Ok(map) +} + +/// `-d`: the text itself, `@file`, or `@-` for stdin. +fn read_data(data: &str) -> Result> { + match data.strip_prefix('@') { + Some("-") => { + let mut buf = Vec::new(); + std::io::stdin() + .read_to_end(&mut buf) + .context("failed to read the body from stdin")?; + Ok(buf) + } + Some(file) => { + if file.is_empty() { + return Err( + CliError::Usage("-d @ needs a file name, or @- for stdin".into()).into(), + ); + } + std::fs::read(file).with_context(|| format!("failed to read {file}")) + } + None => Ok(data.as_bytes().to_vec()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_land_under_api() { + assert_eq!(api_path("endpoints").unwrap(), "/api/endpoints"); + assert_eq!(api_path("/api/endpoints").unwrap(), "/api/endpoints"); + assert_eq!(api_path("api/usage").unwrap(), "/api/usage"); + assert_eq!( + api_path("endpoints/acme/requests?limit=5").unwrap(), + "/api/endpoints/acme/requests?limit=5" + ); + // "apis" is not the api prefix. + assert_eq!(api_path("apis").unwrap(), "/api/apis"); + } + + #[test] + fn other_hosts_are_refused() { + assert!(api_path("https://evil.example/api/endpoints").is_err()); + assert!(api_path("//evil.example/x").is_err()); + assert!(api_path(" ").is_err()); + } + + #[test] + fn data_is_text_or_a_file() { + assert_eq!(read_data(r#"{"a":1}"#).unwrap(), br#"{"a":1}"#); + let dir = std::env::temp_dir().join(format!("whk-api-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("body.json"); + std::fs::write(&file, b"{}").unwrap(); + assert_eq!(read_data(&format!("@{}", file.display())).unwrap(), b"{}"); + std::fs::remove_dir_all(&dir).unwrap(); + let error = read_data("@").unwrap_err(); + assert_eq!(crate::error::classify(&error).code, "usage"); + } + + #[test] + fn bad_headers_are_usage_errors() { + let ok = header_map(&["Content-Type: text/plain".into()]).unwrap(); + assert_eq!(ok["content-type"], "text/plain"); + for bad in [ + "no-colon", + "Bad Header: x", + "X-Ok: a\nb", + "Authorization: Bearer x", + "user-agent: me", + ] { + let error = header_map(&[bad.to_string()]).unwrap_err(); + let c = crate::error::classify(&error); + assert_eq!((c.code, c.exit), ("usage", 2), "{bad}"); + } + } +} diff --git a/apps/cli-rs/src/cli/auth.rs b/apps/cli-rs/src/cli/auth.rs index fa30f2c0d..eadefca42 100644 --- a/apps/cli-rs/src/cli/auth.rs +++ b/apps/cli-rs/src/cli/auth.rs @@ -5,6 +5,7 @@ use crate::agent::CredentialStore; use crate::api::{ApiClient, AuthSource}; use crate::auth; use crate::cli::output::{bold, dim, green, red}; +use crate::error::CliError; use crate::types::Token; pub async fn login(client: &mut ApiClient, json: bool) -> Result<()> { @@ -25,7 +26,20 @@ pub async fn login(client: &mut ApiClient, json: bool) -> Result<()> { // Step 1: Create device code let device = client.create_device_code().await?; - if !json { + if json { + // One line per step (NDJSON), so an agent can show the human the + // code and the link while this keeps polling. + println!( + "{}", + serde_json::json!({ + "status": "pending", + "user_code": device.user_code, + "verification_url": device.verification_url, + "expires_at": device.expires_at, + "message": "Open verification_url and enter user_code to approve this login.", + }) + ); + } else { println!("\n {} {}\n", bold("Your code:"), green(&device.user_code)); println!( " Open {} and enter the code above.", @@ -49,14 +63,7 @@ pub async fn login(client: &mut ApiClient, json: bool) -> Result<()> { let poll = client.poll_device_code(&device.device_code).await?; match poll.status.as_str() { "authorized" => break, - "expired" => { - if json { - println!("{}", serde_json::json!({ "status": "expired" })); - } else { - println!("\n {} Code expired. Please try again.", red("Error:")); - } - return Ok(()); - } + "expired" => return Err(CliError::LoginExpired.into()), _ => continue, // "pending" } } diff --git a/apps/cli-rs/src/cli/confirm.rs b/apps/cli-rs/src/cli/confirm.rs new file mode 100644 index 000000000..c77c35c34 --- /dev/null +++ b/apps/cli-rs/src/cli/confirm.rs @@ -0,0 +1,76 @@ +//! Asking before a destructive command, in a way that never blocks a script +//! or an agent: without a terminal there is no one to ask, so the command +//! fails with `confirmation_required` (exit 9) and says to pass `--yes`. + +use std::io::{self, BufRead, IsTerminal, Write}; + +use anyhow::Result; + +use crate::error::CliError; + +/// Goes ahead when `yes`, else asks `question` on the terminal. Fails with +/// [`CliError::ConfirmationRequired`] when stdin or stderr is not a terminal, +/// and with [`CliError::Cancelled`] when the answer is not "y". +pub fn confirm(question: &str, yes: bool) -> Result<()> { + if yes { + return Ok(()); + } + let interactive = io::stdin().is_terminal() && io::stderr().is_terminal(); + decide(question, interactive, || { + // The question goes to stderr, so stdout stays clean for --json. + eprint!(" {question} [y/N] "); + io::stderr().flush()?; + let mut input = String::new(); + io::stdin().lock().read_line(&mut input)?; + Ok(input) + }) +} + +fn decide( + question: &str, + interactive: bool, + ask: impl FnOnce() -> io::Result, +) -> Result<()> { + if !interactive { + return Err(CliError::ConfirmationRequired(format!( + "{question} Not asked, because no terminal is attached: pass --yes to go ahead." + )) + .into()); + } + let answer = ask()?; + if answer.trim().eq_ignore_ascii_case("y") || answer.trim().eq_ignore_ascii_case("yes") { + Ok(()) + } else { + Err(CliError::Cancelled.into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::classify; + + #[test] + fn without_a_terminal_it_refuses_instead_of_waiting() { + let error = decide("Delete endpoint acme?", false, || { + panic!("must not read stdin without a terminal") + }) + .unwrap_err(); + let c = classify(&error); + assert_eq!((c.code, c.exit), ("confirmation_required", 9)); + assert!(error.to_string().contains("--yes")); + } + + #[test] + fn only_yes_goes_ahead() { + assert!(decide("Go?", true, || Ok("y\n".into())).is_ok()); + assert!(decide("Go?", true, || Ok("YES\n".into())).is_ok()); + let error = decide("Go?", true, || Ok("\n".into())).unwrap_err(); + assert_eq!(classify(&error).code, "cancelled"); + } + + #[test] + fn yes_skips_the_question() { + assert!(confirm("Go?", true).is_ok()); + } +} diff --git a/apps/cli-rs/src/cli/endpoints.rs b/apps/cli-rs/src/cli/endpoints.rs index 335096e3b..aaf0373b9 100644 --- a/apps/cli-rs/src/cli/endpoints.rs +++ b/apps/cli-rs/src/cli/endpoints.rs @@ -1,6 +1,5 @@ use anyhow::Result; use std::collections::HashMap; -use std::io::{self, Write}; use crate::api::ApiClient; use crate::cli::output::{bold, dim, green, print_endpoint_table, red, sanitize}; @@ -69,7 +68,7 @@ pub async fn create( } if json { - println!("{}", serde_json::to_string_pretty(&endpoint)?); + println!("{}", serde_json::to_string_pretty(&endpoint.json())?); } else { let url = client.webhook_url_for(&endpoint.slug); println!( @@ -109,10 +108,26 @@ pub async fn list(client: &ApiClient, team: Option<&str>, json: bool) -> Result< }; list.owned.retain(tied); list.shared.retain(tied); + // The printed JSON keeps the same endpoints. + let kept: std::collections::HashSet = list + .owned + .iter() + .chain(list.shared.iter()) + .map(|ep| ep.slug.clone()) + .collect(); + for key in ["owned", "shared"] { + if let Some(items) = list.raw.get_mut(key).and_then(|v| v.as_array_mut()) { + items.retain(|ep| { + ep.get("slug") + .and_then(|s| s.as_str()) + .is_some_and(|slug| kept.contains(slug)) + }); + } + } } if json { - println!("{}", serde_json::to_string_pretty(&list)?); + println!("{}", serde_json::to_string_pretty(&list.json())?); return Ok(()); } @@ -144,7 +159,7 @@ pub async fn get(client: &ApiClient, slug: &str, json: bool) -> Result<()> { let endpoint = client.get_endpoint(slug).await?; if json { - println!("{}", serde_json::to_string_pretty(&endpoint)?); + println!("{}", serde_json::to_string_pretty(&endpoint.json())?); return Ok(()); } @@ -215,7 +230,7 @@ pub async fn update_endpoint( let endpoint = client.update_endpoint(slug, &req).await?; if json { - println!("{}", serde_json::to_string_pretty(&endpoint)?); + println!("{}", serde_json::to_string_pretty(&endpoint.json())?); } else { println!(" {} Updated endpoint {}", green("✓"), bold(&endpoint.slug)); } @@ -256,20 +271,10 @@ pub async fn set_favorite( } pub async fn delete(client: &ApiClient, slug: &str, force: bool, json: bool) -> Result<()> { - if !force { - print!( - " Delete endpoint {}? This cannot be undone. [y/N] ", - bold(slug) - ); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - if !input.trim().eq_ignore_ascii_case("y") { - println!(" Cancelled."); - return Ok(()); - } - } + crate::cli::confirm::confirm( + &format!("Delete endpoint {slug}? This cannot be undone."), + force, + )?; client.delete_endpoint(slug).await?; diff --git a/apps/cli-rs/src/cli/listen.rs b/apps/cli-rs/src/cli/listen.rs index 1dce7a207..8bc3e01a4 100644 --- a/apps/cli-rs/src/cli/listen.rs +++ b/apps/cli-rs/src/cli/listen.rs @@ -6,7 +6,8 @@ use crate::api::stream::join_stream; use crate::cli::output::{ bold, dim, green, method_color, print_reconnected, print_reconnecting, red, }; -use crate::types::SseEvent; +use crate::cli::output::{sanitize, sanitize_line}; +use crate::types::{CapturedRequest, SseEvent}; use crate::util::format::format_bytes; pub async fn run(client: &ApiClient, slug: &str, json: bool) -> Result<()> { @@ -36,28 +37,9 @@ pub async fn run(client: &ApiClient, slug: &str, json: bool) -> Result<()> { match event { SseEvent::Request(req) => { if json { - println!("{}", serde_json::to_string(&req).unwrap_or_default()); + println!("{}", serde_json::to_string(&req.json()).unwrap_or_default()); } else { - let time = chrono::Local::now().format("%H:%M:%S"); - let sig_status = match req.signature_verified { - Some(true) => { - let provider = req.signing_provider.as_deref().unwrap_or("?"); - format!(" {} {}", green("✓"), provider) - } - Some(false) => { - let provider = req.signing_provider.as_deref().unwrap_or("?"); - format!(" {} {}", red("✗"), provider) - } - None => String::new(), - }; - println!( - " {} {} {}{} {}", - dim(&time.to_string()), - method_color(&req.method), - req.path, - sig_status, - dim(&format_bytes(req.size)), - ); + println!("{}", listen_line(&req)); } } SseEvent::EndpointDeleted => { @@ -94,3 +76,41 @@ pub async fn run(client: &ApiClient, slug: &str, json: bool) -> Result<()> { drop(rx); join_stream(stream_handle, interrupted).await } + +/// One line per arrival: the time, the method, the path (for an email the +/// recipient and the subject), the signature check, the size and the id +/// (for `whk requests get`). +fn listen_line(req: &CapturedRequest) -> String { + let time = chrono::Local::now().format("%H:%M:%S").to_string(); + // The stream sends an email's headers, not its parsed summary, so the + // subject comes from either (`email_subject`). + let what = if req.is_email() { + let subject = req + .email_subject() + .unwrap_or_else(|| "(no subject)".to_string()); + format!( + "{} {}", + sanitize_line(&req.path), + bold(&sanitize_line(&subject)) + ) + } else { + sanitize(&req.path) + }; + let signature = match req.signature_verified { + Some(ok) => { + let provider = sanitize(req.signing_provider.as_deref().unwrap_or("?")); + let mark = if ok { green("✓") } else { red("✗") }; + format!(" {mark} {provider}") + } + None => String::new(), + }; + format!( + " {} {} {}{} {} {}", + dim(&time), + method_color(&req.method), + what, + signature, + dim(&format_bytes(req.size)), + dim(&sanitize(&req.id)), + ) +} diff --git a/apps/cli-rs/src/cli/mod.rs b/apps/cli-rs/src/cli/mod.rs index 6f2255e54..c9df83ad6 100644 --- a/apps/cli-rs/src/cli/mod.rs +++ b/apps/cli-rs/src/cli/mod.rs @@ -1,4 +1,6 @@ +pub mod api; pub mod auth; +pub mod confirm; pub mod endpoints; pub mod listen; pub mod output; @@ -11,6 +13,7 @@ pub mod teams; pub mod tunnel; pub mod update; pub mod usage; +pub mod wait; use clap::{Parser, Subcommand}; @@ -19,16 +22,17 @@ use crate::types::EmailFormat; #[derive(Parser, Debug)] #[command( name = "whk", - about = "webhooks.cc — inspect, forward, and replay webhooks", + about = "webhooks.cc: capture, inspect, forward and replay webhooks and email, and share localhost", version = env!("WHK_VERSION"), arg_required_else_help = false, + after_help = AFTER_HELP, )] pub struct Cli { - /// Disable TUI and show help - #[arg(long)] + /// Accepted for scripts written for older versions, which had a TUI; does nothing + #[arg(long, hide = true)] pub nogui: bool, - /// Output as JSON where supported + /// Print JSON: the API's own answers on stdout, NDJSON for streams, and failures as {"error":{...}} on stderr #[arg(long, global = true)] pub json: bool, @@ -48,6 +52,28 @@ pub struct Cli { pub command: Option, } +/// Printed under `whk --help`. +pub const AFTER_HELP: &str = "\ +Exit status: + 0 success + 1 any other failure, or the server refused the input (bad_request) + 2 bad arguments (usage) + 3 not logged in, or the credentials were refused (not_logged_in, unauthorized, + connection_revoked, login_expired) + 4 not found (not_found) + 5 not allowed by the plan, a limit or a permission (forbidden) + 6 rate limited; wait and retry (rate_limited) + 7 server or network failure; retry (server_error, network_error, request_timeout) + 8 a wait ran out of time (timeout) + 9 a destructive command was not confirmed; pass --yes (confirmation_required, cancelled) + +With --json, a failure is written to stderr as + {\"error\":{\"code\":\"not_found\",\"message\":\"...\",\"exit\":4,\"status\":404,\"hint\":\"...\"}} + +Agents: `whk wait ` blocks until a request arrives and prints it, and +`whk api ` calls any API route with your credentials. +Docs: https://webhooks.cc/docs/cli"; + #[derive(Subcommand, Debug)] pub enum Command { /// Authenticate with webhooks.cc @@ -149,8 +175,8 @@ pub enum Command { /// Endpoint slug slug: String, - /// Skip confirmation prompt - #[arg(short, long)] + /// Delete without asking (needed when no terminal is attached) + #[arg(short = 'y', long = "yes", visible_alias = "force", short_alias = 'f')] force: bool, }, @@ -231,12 +257,80 @@ pub enum Command { action: NamesAction, }, - /// Stream incoming requests to terminal + /// Stream requests and emails as they arrive: one line each, or NDJSON with --json Listen { /// Endpoint slug to listen on slug: String, }, + /// Wait for the next request or email at an endpoint, print it and exit + #[command(after_help = "\ +Run it in the background before triggering the sender, or after: requests from +the last 5 minutes count unless --since says otherwise. Exits 8 (timeout) when +nothing matching arrived in time.")] + Wait { + /// Endpoint slug + slug: String, + + /// How long to wait (e.g. "30s", "5m", "1h") + #[arg(long, default_value = "30s")] + timeout: String, + + /// How many matching requests to wait for; more than one prints a JSON array with --json + #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u32).range(1..=100))] + count: u32, + + /// Only requests received after this: "now", a duration back ("2m"), or a timestamp in milliseconds + #[arg(long)] + since: Option, + + /// Only this HTTP method (e.g. POST) + #[arg(long)] + method: Option, + + /// Only HTTP requests or only emails + #[arg(long, value_enum)] + kind: Option, + + /// Only requests whose path contains this text (for an email, the recipient address) + #[arg(long)] + path: Option, + }, + + /// Call any webhooks.cc API route with your credentials and print the answer + #[command(after_help = "\ +Examples: + whk api /api/endpoints + whk api endpoints/acme-orders/destinations + whk api -X PATCH endpoints/acme-orders -d '{\"retentionDays\":7}' + whk api -X POST endpoints/acme-orders/destinations -d @destination.json + +The path is relative to /api when it does not start with it. Data given with +-d is sent as JSON (POST unless -X says otherwise); @file reads a file and @- +reads stdin. The body is printed as received; a failing status exits as +described in `whk --help`. Routes that need a browser session (billing, account +deletion) refuse API keys.")] + Api { + /// API path, e.g. "endpoints" or "/api/usage" + path: String, + + /// HTTP method (default GET, or POST when -d is given) + #[arg(short = 'X', long)] + method: Option, + + /// Request body: JSON text, @file, or @- for stdin + #[arg(short = 'd', long)] + data: Option, + + /// Extra header (repeatable, format: Key:Value) + #[arg(short = 'H', long = "header")] + headers: Vec, + + /// Print the status line and headers before the body (to stderr) + #[arg(short = 'i', long)] + include: bool, + }, + /// Replay a captured request Replay { /// Request ID to replay @@ -619,8 +713,8 @@ pub enum RequestsAction { #[arg(long)] before: Option, - /// Skip confirmation - #[arg(short, long)] + /// Clear without asking (needed when no terminal is attached) + #[arg(short = 'y', long = "yes", visible_alias = "force", short_alias = 'f')] force: bool, }, diff --git a/apps/cli-rs/src/cli/requests.rs b/apps/cli-rs/src/cli/requests.rs index f5ee1451b..eee9b6420 100644 --- a/apps/cli-rs/src/cli/requests.rs +++ b/apps/cli-rs/src/cli/requests.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use std::io::{self, Write}; use crate::api::ApiClient; use crate::cli::ExportFormat; @@ -20,7 +19,7 @@ pub async fn list( .list_requests_paginated(slug, Some(limit), Some(c)) .await?; if json { - println!("{}", serde_json::to_string_pretty(&result)?); + println!("{}", serde_json::to_string_pretty(&result.raw)?); return Ok(()); } if result.requests.is_empty() { @@ -30,13 +29,15 @@ pub async fn list( for req in &result.requests { print_request_line(req); } - if let Some(ref next) = result.next_cursor { + if let Some(ref next) = result.next_cursor + && result.has_more != Some(false) + { println!("\n {} --cursor {}", dim("Next page:"), next); } } else { let result = client.list_requests(slug, Some(limit), since).await?; if json { - println!("{}", serde_json::to_string_pretty(&result)?); + println!("{}", serde_json::to_string_pretty(&result.json())?); return Ok(()); } if result.requests.is_empty() { @@ -59,7 +60,7 @@ pub async fn list( pub async fn get(client: &ApiClient, id: &str, json: bool) -> Result<()> { let req = client.get_request(id).await?; if json { - println!("{}", serde_json::to_string_pretty(&req)?); + println!("{}", serde_json::to_string_pretty(&req.json())?); } else { print_request_detail(&req); } @@ -96,7 +97,7 @@ pub async fn search( .await?; if json { - println!("{}", serde_json::to_string_pretty(&result)?); + println!("{}", serde_json::to_string_pretty(&result.json())?); return Ok(()); } @@ -150,21 +151,11 @@ pub async fn clear( force: bool, json: bool, ) -> Result<()> { - if !force { - let msg = match before { - Some(b) => format!("Clear requests for {} before {}? [y/N] ", bold(slug), b), - None => format!("Clear ALL requests for {}? [y/N] ", bold(slug)), - }; - print!(" {msg}"); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - if !input.trim().eq_ignore_ascii_case("y") { - println!(" Cancelled."); - return Ok(()); - } - } + let question = match before { + Some(b) => format!("Clear requests for {slug} before {b}?"), + None => format!("Clear ALL requests for {slug}?"), + }; + crate::cli::confirm::confirm(&question, force)?; client.clear_requests(slug, before).await?; diff --git a/apps/cli-rs/src/cli/share.rs b/apps/cli-rs/src/cli/share.rs index 2d6bfc5e2..2134d5d5e 100644 --- a/apps/cli-rs/src/cli/share.rs +++ b/apps/cli-rs/src/cli/share.rs @@ -29,12 +29,15 @@ pub async fn run(client: &ApiClient, options: ShareOptions) -> Result<()> { let json = options.json; let result = run_inner(client, options).await; if json && let Err(e) = &result { - Event::Error { - message: &format!("{e:#}"), - } - .print(); + let c = crate::error::classify(e); + let message = match &c.hint { + Some(hint) => format!("{e:#} {hint}"), + None => format!("{e:#}"), + }; + Event::Error { message: &message }.print(); events::flush(events::FINAL_WAIT); - std::process::exit(1); + // The same exit status as any other failure (see `whk --help`). + std::process::exit(c.exit); } // What the share printed is out before the error, or the exit. events::flush(events::FINAL_WAIT); diff --git a/apps/cli-rs/src/cli/teams.rs b/apps/cli-rs/src/cli/teams.rs index e0059251c..c1acd5430 100644 --- a/apps/cli-rs/src/cli/teams.rs +++ b/apps/cli-rs/src/cli/teams.rs @@ -133,7 +133,7 @@ pub async fn list(client: &ApiClient, json: bool) -> Result<()> { let teams = client.list_teams().await?; if json { - println!("{}", serde_json::to_string_pretty(&teams)?); + println!("{}", serde_json::to_string_pretty(&teams.json())?); return Ok(()); } @@ -154,7 +154,7 @@ pub async fn members(client: &ApiClient, team: &str, json: bool) -> Result<()> { let result = client.list_team_members(&team.id).await?; if json { - println!("{}", serde_json::to_string_pretty(&result)?); + println!("{}", serde_json::to_string_pretty(&result.json())?); return Ok(()); } @@ -237,7 +237,7 @@ pub async fn invite(client: &ApiClient, team: &str, email: &str, json: bool) -> let invite = client.invite_team_member(&team.id, email).await?; if json { - println!("{}", serde_json::to_string_pretty(&invite)?); + println!("{}", serde_json::to_string_pretty(&invite.json())?); } else { println!( "{} Invited {} to {}", @@ -266,7 +266,7 @@ pub async fn invites(client: &ApiClient, json: bool) -> Result<()> { let invites = client.list_invites().await?; if json { - println!("{}", serde_json::to_string_pretty(&invites)?); + println!("{}", serde_json::to_string_pretty(&invites.json())?); return Ok(()); } diff --git a/apps/cli-rs/src/cli/tunnel.rs b/apps/cli-rs/src/cli/tunnel.rs index 0567012cb..c16ef77f4 100644 --- a/apps/cli-rs/src/cli/tunnel.rs +++ b/apps/cli-rs/src/cli/tunnel.rs @@ -66,7 +66,7 @@ pub async fn run( .get_endpoint(s) .await .ok() - .and_then(|ep| ep.email_address), + .and_then(|ep| ep.value.email_address), None => None, }; (s.to_string(), address, false) @@ -79,7 +79,7 @@ pub async fn run( mock_response: None, }; let ep = client.create_endpoint(&req).await?; - (ep.slug, ep.email_address, true) + (ep.value.slug, ep.value.email_address, true) } }; @@ -209,7 +209,7 @@ pub async fn run( // forwarded from the stream like a plain tunnel's. let req = if live && !req.is_email() { let mut relay = lock(&relay); - let Some(req) = relay.admit(*req, Instant::now()) else { continue }; + let Some(req) = relay.admit(req.request, Instant::now()) else { continue }; if relay.announce() { drop(relay); if json { @@ -227,7 +227,7 @@ pub async fn run( } req } else { - *req + req.request }; // --no-email: the email is listed, not posted. if req.is_email() && email_format.is_none() { @@ -835,6 +835,7 @@ fn forwarded_event( ) -> serde_json::Value { let mut event = serde_json::json!({ "event": "forwarded", + "id": req.id, "method": req.method, "path": req.path, "status": result.status_code, diff --git a/apps/cli-rs/src/cli/usage.rs b/apps/cli-rs/src/cli/usage.rs index ae9661c23..ce44af70a 100644 --- a/apps/cli-rs/src/cli/usage.rs +++ b/apps/cli-rs/src/cli/usage.rs @@ -13,7 +13,7 @@ pub async fn run(client: &ApiClient, json: bool) -> Result<()> { // as `teamsError` so an empty `teams` is never mistaken for "no teams". let mut teams_error: Option = None; let teams: Vec = match teams_result { - Ok(teams) => teams.into_iter().filter(|t| !t.suspended).collect(), + Ok(teams) => teams.value.into_iter().filter(|t| !t.suspended).collect(), Err(e) => { if !json { eprintln!(" {} {}", dim("Could not load team pools:"), e); diff --git a/apps/cli-rs/src/cli/wait.rs b/apps/cli-rs/src/cli/wait.rs new file mode 100644 index 000000000..e99553c1c --- /dev/null +++ b/apps/cli-rs/src/cli/wait.rs @@ -0,0 +1,471 @@ +//! `whk wait `: block until a matching request or email arrives, print +//! it and exit. Built for agents: run it in the background, trigger the +//! sender, and the command's exit is the signal that something arrived. +//! +//! It reads the endpoint's live stream (`/api/stream/{slug}`, as `whk listen` +//! does) from a start time: the server first sends every request after it in +//! (received_at, id) order and then each new one as it lands, so a wait +//! never skips or repeats a request however many arrive, and it wakes at +//! once. The start is taken on the server's clock (see [`server_start`]): +//! five minutes back by default (as the SDKs' `waitFor` does), or the moment +//! the command started with `--since now`, so a request sent while the +//! stream connects still counts. A match is then read in full from +//! `/api/requests/{id}`, since the stream leaves out the parsed email and the +//! signature check. + +use std::collections::HashSet; +use std::time::Duration; + +use anyhow::Result; +use serde_json::Value; +use tokio::sync::mpsc; +use tokio::time::Instant; + +use crate::api::ApiClient; +use crate::cli::output::{dim, print_request_detail, print_request_line}; +use crate::error::{ApiError, CliError, classify}; +use crate::types::{CapturedRequest, SseEvent}; +use crate::util::format::parse_duration; + +/// How far back a wait looks when `--since` is not given (as the SDKs do). +const LOOKBACK_MS: i64 = 5 * 60 * 1000; +/// Slack for the start when the server sent no `Date` header and this +/// machine's clock is all there is. +const CLOCK_MARGIN_MS: i64 = 2000; +/// How long past `--timeout` the full read of a match that did arrive may +/// take before the stream's copy is printed instead. +const FULL_READ_GRACE: Duration = Duration::from_secs(10); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum Kind { + Http, + Email, +} + +pub struct WaitOptions { + pub slug: String, + pub timeout: String, + pub count: u32, + pub since: Option, + pub method: Option, + pub kind: Option, + pub path: Option, +} + +/// Where the wait starts. +#[derive(Debug, PartialEq)] +enum Since { + /// Only what arrives after the command started, on the server's clock. + Now, + /// This long (ms) before the command started, on the server's clock. + Back(i64), + /// Requests received at or after this server time (ms). + At(i64), +} + +/// `--since`: unset is the default lookback, `now`, a duration back, or a +/// bare number as a timestamp (as for the other `--since` flags). +fn parse_since(since: Option<&str>) -> Result { + let Some(since) = since.map(str::trim) else { + return Ok(Since::Back(LOOKBACK_MS)); + }; + if since.eq_ignore_ascii_case("now") { + return Ok(Since::Now); + } + if let Ok(at) = since.parse::() { + return Ok(Since::At(at)); + } + let back = parse_duration(since).map_err(|e| { + CliError::Usage(format!( + "--since takes \"now\", a duration such as 2m, or a timestamp in ms ({e})" + )) + })?; + Ok(Since::Back(back)) +} + +struct Filter { + method: Option, + kind: Option, + path: Option, +} + +impl Filter { + fn matches(&self, request: &Value) -> bool { + let text = |key: &str| request.get(key).and_then(Value::as_str).unwrap_or(""); + if let Some(method) = &self.method + && !text("method").eq_ignore_ascii_case(method) + { + return false; + } + if let Some(kind) = self.kind { + let is_email = match request.get("kind").and_then(Value::as_str) { + Some(kind) => kind == "email", + None => { + text("method").eq_ignore_ascii_case("EMAIL") && !text("path").starts_with('/') + } + }; + if is_email != (kind == Kind::Email) { + return false; + } + } + if let Some(path) = &self.path + && !text("path").to_lowercase().contains(&path.to_lowercase()) + { + return false; + } + true + } +} + +fn request_id(request: &Value) -> Option { + request + .get("id") + .or_else(|| request.get("_id")) + .and_then(Value::as_str) + .map(String::from) +} + +fn now_ms() -> i64 { + chrono::Utc::now().timestamp_millis() +} + +/// The server's clock when the command started: the endpoint is read once +/// (which also fails early for a slug that does not exist), and its answer's +/// `Date` less the time it took is the start. `Date` counts whole seconds, +/// so this is at or before the true start: nothing sent after the command +/// started is missed, and at most a second before it can count. +async fn server_start(client: &ApiClient, slug: &str, deadline: Instant) -> Result { + let path = format!("/api/endpoints/{}", urlencoding::encode(slug)); + let (resp, started, local) = retrying(deadline, || async { + // Timed per try, so a retried read measures its own round trip. + let started = std::time::Instant::now(); + let local = now_ms(); + Ok((client.get(&path).await?, started, local)) + }) + .await?; + Ok(match resp.server_time_ms { + Some(t) => t - started.elapsed().as_millis() as i64, + None => local - CLOCK_MARGIN_MS, + }) +} + +/// Whether a failed read is worth another try: rate limits, our side, the +/// network. A missing endpoint or refused credentials are final. +fn transient(error: &anyhow::Error) -> bool { + matches!( + classify(error).code, + "rate_limited" | "server_error" | "network_error" | "request_timeout" + ) +} + +/// Runs `call` until it succeeds, a failure is final, or `deadline` passes +/// (then `timeout`), waiting a second between tries or as long as the server +/// asked (`Retry-After`). +async fn retrying(deadline: Instant, mut call: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + loop { + let error = match tokio::time::timeout_at(deadline, call()).await { + Ok(Ok(value)) => return Ok(value), + Ok(Err(error)) if transient(&error) => error, + Ok(Err(error)) => return Err(error), + Err(_) => { + return Err(CliError::Timeout("the wait ran out of time".into()).into()); + } + }; + let pause = Duration::from_secs(classify(&error).retry_after.unwrap_or(1).max(1)); + if Instant::now() + pause >= deadline { + return Err(error); + } + tokio::time::sleep(pause).await; + } +} + +/// `--timeout`: a duration with a unit. A bare number would be milliseconds +/// (as everywhere `parse_duration` reads one), which ends a wait at once, so +/// it is refused here. +fn parse_timeout(timeout: &str) -> Result { + if timeout.trim().parse::().is_ok() { + return Err(CliError::Usage(format!( + "--timeout needs a unit, such as {}s or {}m", + timeout.trim(), + timeout.trim() + )) + .into()); + } + parse_duration(timeout).map_err(|e| CliError::Usage(format!("--timeout: {e}")).into()) +} + +pub async fn run(client: &ApiClient, options: WaitOptions, json: bool) -> Result<()> { + client.require_auth()?; + let timeout_ms = parse_timeout(&options.timeout)?; + let deadline = Instant::now() + Duration::from_millis(timeout_ms.max(0) as u64); + let filter = Filter { + method: options.method.clone(), + kind: options.kind, + path: options.path.clone(), + }; + let count = options.count.max(1) as usize; + let slug = options.slug.as_str(); + // The arguments are checked before any request goes out; a duration + // back is then counted from the server's start, as `now` is. + let since = parse_since(options.since.as_deref())?; + let start = server_start(client, slug, deadline).await?; + let since = match since { + Since::Now => start, + Since::Back(back) => start - back, + Since::At(at) => at, + }; + + if !json { + let what = if count == 1 { + "a request".to_string() + } else { + format!("{count} requests") + }; + eprintln!( + " {}", + dim(&format!( + "Waiting up to {} for {what} at {slug}...", + options.timeout + )) + ); + } + + let (tx, mut rx) = mpsc::channel(256); + let stream_client = client.clone(); + let stream_slug = slug.to_string(); + let mut stream = tokio::spawn(async move { + stream_client + .stream_requests_from(&stream_slug, Some(since), tx) + .await + }); + + let mut seen: HashSet = HashSet::new(); + // Each match's id and the stream's copy of it. + let mut matched: Vec<(String, Value)> = Vec::new(); + let outcome: Result<()> = loop { + let event = match tokio::time::timeout_at(deadline, rx.recv()).await { + Ok(event) => event, + Err(_) => { + break Err(CliError::Timeout(format!( + "{} of {count} matching request(s) arrived at {slug} within {}", + matched.len(), + options.timeout + )) + .into()); + } + }; + match event { + Some(SseEvent::Request(req)) => { + let value = req.json(); + let Some(id) = request_id(&value) else { + continue; + }; + if seen.insert(id.clone()) && filter.matches(&value) { + matched.push((id, value)); + if matched.len() >= count { + break Ok(()); + } + } + } + Some(SseEvent::EndpointDeleted) => { + break Err(ApiError { + status: 404, + message: format!("Endpoint {slug} was deleted while waiting."), + retry_after: None, + } + .into()); + } + Some(SseEvent::Reconnecting { + attempt, reason, .. + }) => { + if !json { + eprintln!( + " {}", + dim(&format!("Reconnecting (attempt {attempt}): {reason}")) + ); + } + } + Some(SseEvent::Connected | SseEvent::Timeout) => {} + // The stream ended: its error (a missing endpoint, refused + // credentials) says why. + None => { + break match (&mut stream).await { + Ok(Err(error)) => Err(error), + _ => Err(anyhow::anyhow!("the request stream ended unexpectedly")), + }; + } + } + }; + drop(rx); + if !stream.is_finished() { + stream.abort(); + } + outcome?; + + // Each match in full: the stream leaves out the parsed email, the + // signature check and the answer the sender got. A match that arrived + // is never lost to a failed read: within a few seconds past the wait's + // own limit, else the stream's copy is printed. + let read_by = deadline.max(Instant::now()) + FULL_READ_GRACE; + let mut values = Vec::with_capacity(matched.len()); + for (id, streamed) in matched { + match retrying(read_by, || client.get_request(&id)).await { + Ok(full) => values.push(full.json()), + Err(error) => { + if !json { + eprintln!( + " {}", + dim(&format!("Showing the streamed copy of {id}: {error:#}")) + ); + } + values.push(streamed); + } + } + } + if json { + let out = if count == 1 { + values.into_iter().next().unwrap_or(Value::Null) + } else { + Value::Array(values) + }; + println!("{}", serde_json::to_string_pretty(&out)?); + return Ok(()); + } + let parsed: Vec = values + .into_iter() + .filter_map(|v| serde_json::from_value(v).ok()) + .collect(); + if let [one] = parsed.as_slice() { + print_request_detail(one); + } else { + for request in &parsed { + print_request_line(request); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn since_reads_now_durations_and_timestamps() { + assert_eq!(parse_since(None).unwrap(), Since::Back(LOOKBACK_MS)); + assert_eq!(parse_since(Some("now")).unwrap(), Since::Now); + assert_eq!(parse_since(Some("2m")).unwrap(), Since::Back(120_000)); + assert_eq!( + parse_since(Some("1774866106592")).unwrap(), + Since::At(1774866106592) + ); + let error = parse_since(Some("soon")).unwrap_err(); + assert_eq!(classify(&error).code, "usage"); + } + + #[tokio::test] + async fn transient_failures_are_retried_until_the_deadline() { + let tries = std::cell::Cell::new(0); + let deadline = Instant::now() + Duration::from_secs(5); + let value = retrying(deadline, || { + tries.set(tries.get() + 1); + let n = tries.get(); + async move { + if n < 2 { + Err(anyhow::Error::new(ApiError { + status: 503, + message: "busy".into(), + retry_after: None, + })) + } else { + Ok(n) + } + } + }) + .await + .unwrap(); + assert_eq!(value, 2); + + // A final failure is not retried. + tries.set(0); + let error = retrying(deadline, || { + tries.set(tries.get() + 1); + async { + Err::<(), _>(anyhow::Error::new(ApiError { + status: 404, + message: "gone".into(), + retry_after: None, + })) + } + }) + .await + .unwrap_err(); + assert_eq!(classify(&error).code, "not_found"); + assert_eq!(tries.get(), 1); + + // Past the deadline the wait times out. + let error = retrying(Instant::now(), || async { + tokio::time::sleep(Duration::from_secs(1)).await; + Ok::<_, anyhow::Error>(()) + }) + .await + .unwrap_err(); + assert_eq!(classify(&error).code, "timeout"); + } + + #[test] + fn a_timeout_needs_a_unit() { + assert_eq!(parse_timeout("30s").unwrap(), 30_000); + assert_eq!(parse_timeout("2m").unwrap(), 120_000); + let error = parse_timeout("30").unwrap_err(); + assert_eq!(classify(&error).code, "usage"); + assert!(error.to_string().contains("30s")); + } + + #[test] + fn filters_match_method_kind_and_path() { + let post = json!({"id":"1","method":"POST","path":"/stripe/events","receivedAt":1}); + let email = json!({"id":"2","method":"EMAIL","path":"orders@mailhooks.cc","kind":"email","receivedAt":2}); + let any = Filter { + method: None, + kind: None, + path: None, + }; + assert!(any.matches(&post) && any.matches(&email)); + + let posts = Filter { + method: Some("post".into()), + kind: None, + path: None, + }; + assert!(posts.matches(&post) && !posts.matches(&email)); + + let emails = Filter { + method: None, + kind: Some(Kind::Email), + path: None, + }; + assert!(!emails.matches(&post) && emails.matches(&email)); + let http = Filter { + method: None, + kind: Some(Kind::Http), + path: None, + }; + assert!(http.matches(&post) && !http.matches(&email)); + + let stripe = Filter { + method: None, + kind: None, + path: Some("STRIPE".into()), + }; + assert!(stripe.matches(&post) && !stripe.matches(&email)); + + // An older answer without `kind` is read the way the server reads it. + let legacy = json!({"id":"3","method":"EMAIL","path":"a@mailhooks.cc"}); + assert!(emails.matches(&legacy)); + } +} diff --git a/apps/cli-rs/src/error.rs b/apps/cli-rs/src/error.rs new file mode 100644 index 000000000..6b7216296 --- /dev/null +++ b/apps/cli-rs/src/error.rs @@ -0,0 +1,269 @@ +//! What a failed command reports: a stable error code and exit status that +//! scripts and coding agents can act on, the message, and a next step. +//! +//! Exit statuses (also in `whk --help`): +//! +//! | Status | Code | Meaning | +//! | ------ | ----------------------- | ----------------------------------------------- | +//! | 0 | | Success | +//! | 1 | `error`, `bad_request` | Any other failure, or the server refused input | +//! | 2 | `usage` | Bad arguments (clap answers these itself) | +//! | 3 | `not_logged_in`, `unauthorized`, `connection_revoked`, `login_expired` | No credentials, or they were refused | +//! | 4 | `not_found` | No such endpoint, request, team or share | +//! | 5 | `forbidden` | Not allowed: the plan, a limit, or a permission | +//! | 6 | `rate_limited` | Too many requests; wait and retry | +//! | 7 | `server_error`, `network_error`, `request_timeout` | Our side or the network failed; retry | +//! | 8 | `timeout` | `whk wait` (or another wait) ran out of time | +//! | 9 | `confirmation_required`, `cancelled` | A destructive command was not confirmed | + +use serde_json::{Value, json}; + +use crate::agent::AgentError; + +/// An API answer with an error status. The message is the server's own +/// words when it gave some. +#[derive(Debug, Clone)] +pub struct ApiError { + pub status: u16, + pub message: String, + /// Seconds from the `Retry-After` header. + pub retry_after: Option, +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ApiError {} + +/// Failures the CLI decides on itself. +#[derive(Debug, Clone)] +pub enum CliError { + /// No credentials at all. + NotLoggedIn(String), + /// A destructive command without `--yes` and without a terminal to ask on. + ConfirmationRequired(String), + /// The person answered no. + Cancelled, + /// A wait ran out of time. + Timeout(String), + /// The device login's code expired before anyone approved it. + LoginExpired, + /// Arguments that parse but make no sense together. + Usage(String), +} + +impl std::fmt::Display for CliError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CliError::NotLoggedIn(m) + | CliError::ConfirmationRequired(m) + | CliError::Timeout(m) + | CliError::Usage(m) => f.write_str(m), + CliError::Cancelled => f.write_str("Cancelled."), + CliError::LoginExpired => f.write_str("The login code expired before it was approved."), + } + } +} + +impl std::error::Error for CliError {} + +/// How a failure is reported. +#[derive(Debug, Clone, PartialEq)] +pub struct Classified { + pub code: &'static str, + pub exit: i32, + pub status: Option, + pub hint: Option, + pub retry_after: Option, +} + +impl Classified { + fn new(code: &'static str, exit: i32) -> Self { + Self { + code, + exit, + status: None, + hint: None, + retry_after: None, + } + } + + fn hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } +} + +const LOGIN_HINT: &str = + "Run `whk auth login`, or connect an agent with `whk sandbox connect --email
`."; + +fn from_status(status: u16, retry_after: Option) -> Classified { + let mut c = match status { + 401 => Classified::new("unauthorized", 3).hint(LOGIN_HINT), + 402 | 403 => Classified::new("forbidden", 5), + 404 | 410 => Classified::new("not_found", 4), + 408 => Classified::new("request_timeout", 7).hint("Retry in a little while."), + 429 => Classified::new("rate_limited", 6).hint("Wait, then retry."), + 500..=599 => Classified::new("server_error", 7).hint("Retry in a little while."), + _ => Classified::new("bad_request", 1), + }; + c.status = Some(status); + c.retry_after = retry_after; + if let (Some(seconds), "rate_limited" | "server_error" | "request_timeout") = + (retry_after, c.code) + { + c.hint = Some(format!("Retry after {seconds} s.")); + } + c +} + +/// The code, exit status and next step for a failure, from the first error +/// in its chain that says what went wrong. +pub fn classify(error: &anyhow::Error) -> Classified { + for cause in error.chain() { + if let Some(e) = cause.downcast_ref::() { + return match e { + CliError::NotLoggedIn(_) => { + let hint = crate::api::ignored_key_hint().unwrap_or(LOGIN_HINT); + Classified::new("not_logged_in", 3).hint(hint) + } + CliError::ConfirmationRequired(_) => Classified::new("confirmation_required", 9) + .hint("Pass --yes to go ahead without being asked."), + CliError::Cancelled => Classified::new("cancelled", 9), + CliError::Timeout(_) => Classified::new("timeout", 8) + .hint("Run it again with a longer --timeout, or check that the sender ran."), + CliError::LoginExpired => { + Classified::new("login_expired", 3).hint("Run `whk auth login` again.") + } + CliError::Usage(_) => Classified::new("usage", 2), + }; + } + if let Some(e) = cause.downcast_ref::() { + return from_status(e.status, e.retry_after); + } + if let Some(e) = cause.downcast_ref::() { + // The human disconnected the agent, or its registration ended: + // the saved connection is gone, which no retry fixes. + if e.code == "invalid_grant" { + let mut c = Classified::new("connection_revoked", 3).hint( + "The agent connection was revoked or has ended. Connect again with `whk sandbox connect --email
`, or run `whk auth login`.", + ); + c.status = Some(e.status); + return c; + } + return from_status(e.status, e.retry_after); + } + if let Some(e) = cause.downcast_ref::() { + let mut c = Classified::new("network_error", 7); + if let Some(status) = e.status() { + return from_status(status.as_u16(), None); + } + c = c.hint(if e.is_timeout() { + "The request timed out. Retry in a little while." + } else { + "Could not reach webhooks.cc. Check the network (and WHK_API_URL), then retry." + }); + return c; + } + } + Classified::new("error", 1) +} + +/// The JSON a failed command writes to stderr under `--json`. +pub fn error_json(error: &anyhow::Error, c: &Classified) -> Value { + let mut body = json!({ + "code": c.code, + "message": format!("{error:#}"), + "exit": c.exit, + }); + if let Some(status) = c.status { + body["status"] = json!(status); + } + if let Some(hint) = &c.hint { + body["hint"] = json!(hint); + } + if let Some(seconds) = c.retry_after { + body["retryAfter"] = json!(seconds); + } + json!({ "error": body }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn api(status: u16) -> anyhow::Error { + anyhow::Error::new(ApiError { + status, + message: "nope".into(), + retry_after: None, + }) + } + + #[test] + fn statuses_map_to_stable_codes_and_exits() { + let cases = [ + (400, "bad_request", 1), + (401, "unauthorized", 3), + (403, "forbidden", 5), + (404, "not_found", 4), + (408, "request_timeout", 7), + (409, "bad_request", 1), + (429, "rate_limited", 6), + (503, "server_error", 7), + ]; + for (status, code, exit) in cases { + let c = classify(&api(status)); + assert_eq!((c.code, c.exit, c.status), (code, exit, Some(status))); + } + } + + #[test] + fn context_does_not_hide_the_cause() { + let error = api(404).context("loading the endpoint"); + assert_eq!(classify(&error).code, "not_found"); + let json = error_json(&error, &classify(&error)); + assert_eq!(json["error"]["message"], "loading the endpoint: nope"); + assert_eq!(json["error"]["status"], 404); + assert_eq!(json["error"]["exit"], 4); + } + + #[test] + fn cli_failures_have_their_own_codes() { + let c = classify(&anyhow::Error::new(CliError::ConfirmationRequired( + "x".into(), + ))); + assert_eq!((c.code, c.exit), ("confirmation_required", 9)); + assert!(c.hint.unwrap().contains("--yes")); + let c = classify(&anyhow::Error::new(CliError::Timeout("x".into()))); + assert_eq!((c.code, c.exit), ("timeout", 8)); + assert_eq!(classify(&anyhow::anyhow!("plain")).code, "error"); + } + + #[test] + fn a_revoked_agent_connection_says_so() { + let error = anyhow::Error::new(AgentError::new("invalid_grant", 400, "revoked")); + let c = classify(&error); + assert_eq!( + (c.code, c.exit, c.status), + ("connection_revoked", 3, Some(400)) + ); + assert!(c.hint.unwrap().contains("sandbox connect")); + } + + #[test] + fn retry_after_becomes_the_hint() { + let error = anyhow::Error::new(ApiError { + status: 429, + message: "slow down".into(), + retry_after: Some(7), + }); + let c = classify(&error); + assert_eq!(c.retry_after, Some(7)); + assert_eq!(c.hint.as_deref(), Some("Retry after 7 s.")); + assert_eq!(error_json(&error, &c)["error"]["retryAfter"], 7); + } +} diff --git a/apps/cli-rs/src/lib.rs b/apps/cli-rs/src/lib.rs index 4c20d6432..8fed53caf 100644 --- a/apps/cli-rs/src/lib.rs +++ b/apps/cli-rs/src/lib.rs @@ -2,8 +2,8 @@ pub mod agent; pub mod api; pub mod auth; pub mod cli; +pub mod error; pub mod share; -pub mod tui; pub mod tunnel; pub mod types; pub mod util; diff --git a/apps/cli-rs/src/main.rs b/apps/cli-rs/src/main.rs index 1b7abcb5b..f53b482fc 100644 --- a/apps/cli-rs/src/main.rs +++ b/apps/cli-rs/src/main.rs @@ -1,3 +1,5 @@ +use std::process::ExitCode; + use anyhow::Result; use clap::Parser; @@ -6,26 +8,135 @@ use whk::cli::{ self, AuthAction, Cli, Command, NamesAction, RequestsAction, SandboxAction, SharesAction, TeamsAction, }; -use whk::tui; +use whk::error::{classify, error_json}; #[tokio::main] -async fn main() -> Result<()> { - let args = Cli::parse(); - +async fn main() -> ExitCode { + let args = match Cli::try_parse() { + Ok(args) => args, + Err(error) => return argument_error(error), + }; + let json = args.json; cli::output::set_no_color(args.no_color || std::env::var("NO_COLOR").is_ok()); - let mut client = ApiClient::new(args.api_url.as_deref(), args.webhook_url.as_deref())?; + match run(args).await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + let c = classify(&error); + if json { + eprintln!("{}", error_json(&error, &c)); + } else { + eprintln!("Error: {error:#}"); + // A hint the message already gives is not repeated. + if let Some(hint) = c.hint.as_deref() + && !format!("{error:#}").contains(hint) + { + eprintln!("{hint}"); + } + } + ExitCode::from(c.exit as u8) + } + } +} - let nogui = args.nogui || std::env::var("WHK_NOGUI").is_ok(); +/// A command line clap refused. Help and `--version` print as usual; under +/// `--json` a real mistake comes as the same `{"error": ...}` as any other +/// failure, with exit 2. +fn argument_error(error: clap::Error) -> ExitCode { + use clap::error::ErrorKind; + let informational = matches!( + error.kind(), + ErrorKind::DisplayHelp + | ErrorKind::DisplayVersion + | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand + ); + let json = std::env::args_os().skip(1).any(|a| a == "--json"); + if informational || !json { + error.exit(); + } + // clap's first line says what was wrong ("error: unexpected argument ..."). + // What clap says before its usage line, on one line. + let text = error.to_string(); + let message = text + .lines() + .take_while(|line| !line.starts_with("Usage:")) + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" ") + .trim_start_matches("error: ") + .to_string(); + eprintln!( + "{}", + serde_json::json!({ + "error": { + "code": "usage", + "message": message, + "exit": 2, + "hint": "Run the command with --help for its arguments.", + } + }) + ); + ExitCode::from(2) +} + +async fn run(args: Cli) -> Result<()> { + // A bare `whk` prints help, before any credentials are read, so a broken + // token file never hides it. It used to open a full-screen TUI, which + // hung any agent or script that ran it without a terminal. + if args.command.is_none() { + use clap::CommandFactory; + // A closed pipe (`whk | head`) is not a failure. + let _ = Cli::command().print_help(); + return Ok(()); + } + + let mut client = ApiClient::new(args.api_url.as_deref(), args.webhook_url.as_deref())?; match args.command { - None => { - if nogui { - use clap::CommandFactory; - Cli::command().print_help()?; - } else { - tui::run(client).await?; - } + None => unreachable!("handled above"), + + Some(Command::Wait { + slug, + timeout, + count, + since, + method, + kind, + path, + }) => { + cli::wait::run( + &client, + cli::wait::WaitOptions { + slug, + timeout, + count, + since, + method, + kind, + path, + }, + args.json, + ) + .await?; + } + + Some(Command::Api { + path, + method, + data, + headers, + include, + }) => { + cli::api::run( + &client, + &path, + method.as_deref(), + data.as_deref(), + &headers, + include, + ) + .await?; } Some(Command::Auth { action }) => match action { diff --git a/apps/cli-rs/src/tui/event.rs b/apps/cli-rs/src/tui/event.rs deleted file mode 100644 index 8885ee421..000000000 --- a/apps/cli-rs/src/tui/event.rs +++ /dev/null @@ -1,43 +0,0 @@ -use crossterm::event::{self, Event, KeyEvent, KeyEventKind}; -use std::time::Duration; -use tokio::sync::mpsc; - -/// Terminal events enriched with app-level ticks. -#[derive(Debug, Clone)] -pub enum AppEvent { - Key(KeyEvent), - Resize, - Tick, -} - -/// Spawn a background thread that reads crossterm events and sends them -/// over an mpsc channel. Also sends periodic ticks for animations. -pub fn spawn_event_reader(tick_rate: Duration) -> mpsc::UnboundedReceiver { - let (tx, rx) = mpsc::unbounded_channel(); - - std::thread::spawn(move || { - loop { - if event::poll(tick_rate).unwrap_or(false) { - let next = match event::read() { - Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => { - Some(AppEvent::Key(key)) - } - Ok(Event::Resize(_, _)) => Some(AppEvent::Resize), - _ => None, - }; - if let Some(event) = next - && tx.send(event).is_err() - { - break; - } - } else { - // No event within tick_rate — send a tick for animations - if tx.send(AppEvent::Tick).is_err() { - break; - } - } - } - }); - - rx -} diff --git a/apps/cli-rs/src/tui/keys.rs b/apps/cli-rs/src/tui/keys.rs deleted file mode 100644 index 175e9550a..000000000 --- a/apps/cli-rs/src/tui/keys.rs +++ /dev/null @@ -1,95 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - -pub fn is_quit(key: &KeyEvent) -> bool { - matches!( - key, - KeyEvent { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - .. - } | KeyEvent { - code: KeyCode::Char('q'), - modifiers: KeyModifiers::NONE, - .. - } - ) -} - -pub fn is_back(key: &KeyEvent) -> bool { - matches!( - key, - KeyEvent { - code: KeyCode::Esc, - .. - } - ) -} - -pub fn is_enter(key: &KeyEvent) -> bool { - matches!( - key, - KeyEvent { - code: KeyCode::Enter, - .. - } - ) -} - -pub fn is_up(key: &KeyEvent) -> bool { - matches!( - key, - KeyEvent { - code: KeyCode::Up, - .. - } | KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::NONE, - .. - } - ) -} - -pub fn is_down(key: &KeyEvent) -> bool { - matches!( - key, - KeyEvent { - code: KeyCode::Down, - .. - } | KeyEvent { - code: KeyCode::Char('j'), - modifiers: KeyModifiers::NONE, - .. - } - ) -} - -pub fn is_tab(key: &KeyEvent) -> bool { - matches!( - key, - KeyEvent { - code: KeyCode::Tab, - .. - } - ) -} - -pub fn is_backtab(key: &KeyEvent) -> bool { - matches!( - key, - KeyEvent { - code: KeyCode::BackTab, - .. - } - ) -} - -pub fn is_char(key: &KeyEvent, c: char) -> bool { - matches!( - key, - KeyEvent { - code: KeyCode::Char(ch), - modifiers: KeyModifiers::NONE, - .. - } if *ch == c - ) -} diff --git a/apps/cli-rs/src/tui/mod.rs b/apps/cli-rs/src/tui/mod.rs deleted file mode 100644 index 031ba86a0..000000000 --- a/apps/cli-rs/src/tui/mod.rs +++ /dev/null @@ -1,300 +0,0 @@ -pub mod event; -pub mod keys; -pub mod screens; -pub mod theme; -pub mod widgets; - -use std::time::Duration; - -use anyhow::Result; -use crossterm::{ - ExecutableCommand, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use ratatui::{ - DefaultTerminal, Frame, - layout::{Constraint, Layout}, - widgets::Widget, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::auth; - -use self::event::{AppEvent, spawn_event_reader}; -use self::screens::*; -use self::widgets::header::Header; -use self::widgets::status_bar::StatusBar; - -const TICK_RATE: Duration = Duration::from_millis(100); - -pub async fn run(client: ApiClient) -> Result<()> { - // Install panic hook to restore terminal - let original_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - let _ = std::io::stdout().execute(LeaveAlternateScreen); - let _ = disable_raw_mode(); - original_hook(info); - })); - - enable_raw_mode()?; - if let Err(e) = std::io::stdout().execute(EnterAlternateScreen) { - let _ = disable_raw_mode(); - return Err(e.into()); - } - let mut terminal = ratatui::init(); - - let result = run_app(&mut terminal, client).await; - - ratatui::restore(); - - result -} - -async fn run_app(terminal: &mut DefaultTerminal, client: ApiClient) -> Result<()> { - let mut event_rx = spawn_event_reader(TICK_RATE); - let (msg_tx, mut msg_rx) = mpsc::unbounded_channel::(); - - // Load auth email - let auth_email = auth::load_token().ok().flatten().map(|t| t.email); - - let mut app = App::new(client, auth_email, msg_tx.clone()); - - loop { - terminal.draw(|frame| app.render(frame))?; - - tokio::select! { - Some(event) = event_rx.recv() => { - match event { - AppEvent::Key(key) => { - if let Some(action) = app.handle_key(&key) { - match action { - Action::Quit => break, - Action::NavigateBack => app.navigate_back(), - Action::Navigate(screen) => app.navigate_to(screen), - Action::SetAuthEmail(email) => app.set_auth_email(email), - } - } - } - AppEvent::Resize => { - // ratatui handles resize automatically - } - AppEvent::Tick => { - app.tick(); - } - } - } - Some(msg) = msg_rx.recv() => { - app.handle_message(msg); - } - } - } - - Ok(()) -} - -struct App { - client: ApiClient, - screen_stack: Vec>, - auth_email: Option, - msg_tx: mpsc::UnboundedSender, - show_help: bool, -} - -impl App { - fn new( - client: ApiClient, - auth_email: Option, - msg_tx: mpsc::UnboundedSender, - ) -> Self { - let menu = Box::new(screens::menu::MenuScreen::new(auth_email.clone())); - Self { - client, - screen_stack: vec![menu], - auth_email, - msg_tx, - show_help: false, - } - } - - fn current_screen(&self) -> &dyn Screen { - self.screen_stack.last().unwrap().as_ref() - } - - fn current_screen_mut(&mut self) -> &mut dyn Screen { - self.screen_stack.last_mut().unwrap().as_mut() - } - - fn handle_key(&mut self, key: &crossterm::event::KeyEvent) -> Option { - // Toggle help overlay with '?' - if key.code == crossterm::event::KeyCode::Char('?') { - self.show_help = !self.show_help; - return None; - } - // Close help with Esc if open - if self.show_help { - if key.code == crossterm::event::KeyCode::Esc { - self.show_help = false; - } - return None; - } - self.current_screen_mut().handle_key(key) - } - - fn handle_message(&mut self, msg: Message) { - self.current_screen_mut().handle_message(msg); - } - - fn tick(&mut self) { - self.current_screen_mut().tick(); - } - - fn navigate_to(&mut self, screen_id: ScreenId) { - let webhook_url = self.client.webhook_url.clone(); - - let mut screen: Box = match screen_id { - ScreenId::Auth => Box::new(screens::auth::AuthScreen::new(self.auth_email.clone())), - ScreenId::Endpoints => Box::new(screens::endpoints::EndpointsScreen::new(webhook_url)), - ScreenId::EndpointDetail(slug) => Box::new( - screens::endpoint_detail::EndpointDetailScreen::new(slug, webhook_url), - ), - ScreenId::Tunnel => Box::new(screens::tunnel::TunnelScreen::new(webhook_url)), - ScreenId::Listen => Box::new(screens::listen::ListenScreen::new(webhook_url)), - ScreenId::RequestDetail(id) => { - Box::new(screens::request_detail::RequestDetailScreen::new(id)) - } - ScreenId::Search => Box::new(screens::search::SearchScreen::new()), - ScreenId::Send => Box::new(screens::send::SendScreen::new()), - ScreenId::Usage => Box::new(screens::usage::UsageScreen::new()), - ScreenId::Update => Box::new(screens::update::UpdateScreen::new()), - }; - - screen.on_enter(&self.client, self.msg_tx.clone()); - self.screen_stack.push(screen); - } - - fn navigate_back(&mut self) { - if self.screen_stack.len() > 1 { - let mut screen = self.screen_stack.pop().unwrap(); - screen.on_leave(); - - // Refresh the menu auth email when returning - if let Some(menu) = self.screen_stack.last_mut() - && let Some(menu_screen) = menu - .as_any_mut() - .downcast_mut::() - { - menu_screen.set_auth_email(self.auth_email.clone()); - } - } - } - - fn set_auth_email(&mut self, email: Option) { - self.auth_email = email; - } - - fn render(&mut self, frame: &mut Frame) { - let area = frame.area(); - - // Background - frame.render_widget( - ratatui::widgets::Block::default().style(theme::style_surface()), - area, - ); - - let chunks = Layout::vertical([ - Constraint::Length(2), // Header - Constraint::Min(0), // Content - Constraint::Length(2), // Status bar - ]) - .split(area); - - // Header - let breadcrumb = self.current_screen().breadcrumb(); - let header = Header::new(breadcrumb).auth_status(self.auth_email.as_deref()); - header.render(chunks[0], frame.buffer_mut()); - - // Content - self.current_screen_mut().render(frame, chunks[1]); - - // Help overlay - if self.show_help { - render_help_overlay(frame, chunks[1]); - } - - // Status bar - let mut keys = self.current_screen().status_keys(); - keys.push(("?", "help")); - let version = format!("v{}", env!("WHK_VERSION")); - let bar = StatusBar::new(keys).right(&version); - bar.render(chunks[2], frame.buffer_mut()); - } -} - -fn render_help_overlay(frame: &mut Frame, area: ratatui::layout::Rect) { - use ratatui::style::{Modifier, Style}; - use ratatui::text::{Line, Span}; - use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; - - let help_items: &[(&str, &str)] = &[ - ("↑ / k", "Move up"), - ("↓ / j", "Move down"), - ("Enter", "Select / confirm"), - ("Esc", "Go back / cancel"), - ("Tab", "Next field / tab"), - ("Shift+Tab", "Previous field / tab"), - ("1-3", "Jump to tab (detail view)"), - ("n", "New endpoint"), - ("d", "Delete endpoint"), - ("r", "Refresh"), - ("c", "Copy to clipboard"), - ("q / Ctrl+C", "Quit"), - ("?", "Toggle this help"), - ]; - - // Center the overlay - let width: u16 = 44; - let height = (help_items.len() as u16) + 4; - let x = area.x + area.width.saturating_sub(width) / 2; - let y = area.y + area.height.saturating_sub(height) / 2; - let overlay = ratatui::layout::Rect::new( - x.max(area.x), - y.max(area.y), - width.min(area.width), - height.min(area.height), - ); - - // Clear background - frame.render_widget(Clear, overlay); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::PRIMARY)) - .title(Span::styled( - " Keyboard Shortcuts ", - Style::default() - .fg(theme::PRIMARY) - .add_modifier(Modifier::BOLD), - )) - .padding(Padding::new(2, 2, 1, 0)); - - let inner = block.inner(overlay); - frame.render_widget(block, overlay); - - let lines: Vec = help_items - .iter() - .map(|(key, desc)| { - Line::from(vec![ - Span::styled( - format!("{:<14}", key), - Style::default() - .fg(theme::ACCENT) - .add_modifier(Modifier::BOLD), - ), - Span::styled(*desc, theme::style_dim()), - ]) - }) - .collect(); - - frame.render_widget(Paragraph::new(lines), inner); -} diff --git a/apps/cli-rs/src/tui/screens/auth.rs b/apps/cli-rs/src/tui/screens/auth.rs deleted file mode 100644 index c5961f4d2..000000000 --- a/apps/cli-rs/src/tui/screens/auth.rs +++ /dev/null @@ -1,398 +0,0 @@ -use crossterm::event::KeyEvent; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::auth; -use crate::tui::{keys, theme}; -use crate::types::Token; - -use super::{Action, Message, Screen}; - -enum State { - Idle, - Polling { - device_code: String, - user_code: String, - verification_url: String, - }, - Success(String), // email - Error(String), -} - -pub struct AuthScreen { - state: State, - auth_email: Option, - tick: usize, - tx: Option>, - client: Option, - tasks: Vec>, -} - -impl AuthScreen { - pub fn new(auth_email: Option) -> Self { - Self { - state: State::Idle, - auth_email, - tick: 0, - tx: None, - client: None, - tasks: Vec::new(), - } - } -} - -impl Screen for AuthScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - if keys::is_quit(key) { - return Some(Action::Quit); - } - - match &self.state { - State::Idle => { - if self.auth_email.is_some() { - // Logged in — press 'o' to logout - if keys::is_char(key, 'o') { - if let Err(e) = auth::clear_token() { - self.state = State::Error(format!("Failed to clear token: {e}")); - return None; - } - self.auth_email = None; - self.state = State::Idle; - return Some(Action::SetAuthEmail(None)); - } - } else { - // Not logged in — press 'l' to login - if keys::is_char(key, 'l') - && let (Some(tx), Some(client)) = (&self.tx, &self.client) - { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.create_device_code().await; - let _ = tx.send(Message::DeviceCode(result)); - }); - self.tasks.push(handle); - } - } - } - State::Polling { user_code, .. } => { - if keys::is_char(key, 'c') { - copy_to_clipboard(user_code); - } - } - State::Success(email) => { - if keys::is_enter(key) { - let email = email.clone(); - self.state = State::Idle; - return Some(Action::SetAuthEmail(Some(email))); - } - } - State::Error(_) => { - if keys::is_enter(key) { - self.state = State::Idle; - } - } - } - - None - } - - fn handle_message(&mut self, msg: Message) { - match msg { - Message::DeviceCode(Ok(device)) => { - if device.verification_url.starts_with("https://") { - let _ = open::that(&device.verification_url); - } - - let dc = device.device_code.clone(); - self.state = State::Polling { - device_code: device.device_code, - user_code: device.user_code, - verification_url: device.verification_url, - }; - - // Start polling - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - let result = client.poll_device_code(&dc).await; - let done = matches!(&result, Ok(r) if r.status != "pending"); - let _ = tx.send(Message::AuthPoll(result)); - if done { - break; - } - } - }); - self.tasks.push(handle); - } - } - Message::DeviceCode(Err(e)) => { - self.state = State::Error(e.to_string()); - } - Message::AuthPoll(Ok(poll)) => { - if poll.status == "authorized" { - // Claim the code - if let State::Polling { - ref device_code, .. - } = self.state - { - let dc = device_code.clone(); - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.claim_device_code(&dc).await; - let _ = tx.send(Message::AuthClaimed(result)); - }); - self.tasks.push(handle); - } - } - } else if poll.status == "expired" { - self.state = State::Error("Code expired. Press Enter and try again.".into()); - } - } - Message::AuthPoll(Err(e)) => { - self.state = State::Error(e.to_string()); - } - Message::AuthClaimed(Ok(claim)) => { - let token = Token { - access_token: claim.api_key, - user_id: claim.user_id, - email: claim.email.clone(), - }; - if let Err(e) = auth::save_token(&token) { - self.state = State::Error(format!("Failed to save token: {e}")); - return; - } - self.auth_email = Some(claim.email.clone()); - self.state = State::Success(claim.email); - } - Message::AuthClaimed(Err(e)) => { - self.state = State::Error(e.to_string()); - } - _ => {} - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - let chunks = Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).split(area); - - let content_area = centered_rect(60, 12, chunks[1]); - - match &self.state { - State::Idle => { - if let Some(ref email) = self.auth_email { - let lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" Logged in as ", theme::style_dim()), - Span::styled(email, theme::style_bold()), - ]), - Line::from(""), - Line::from(Span::styled(" Press o to logout", theme::style_muted())), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::SUCCESS)) - .title(Span::styled(" Authenticated ", theme::style_success())) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content_area); - } else { - let lines = vec![ - Line::from(""), - Line::from(Span::styled(" Not logged in", theme::style_muted())), - Line::from(""), - Line::from(Span::styled( - " Press l to login via browser", - theme::style_dim(), - )), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled(" Authentication ", theme::style_bold())) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content_area); - } - } - State::Polling { - user_code, - verification_url, - .. - } => { - let lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" Your code: ", theme::style_dim()), - Span::styled( - user_code, - Style::default() - .fg(theme::PRIMARY) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(""), - Line::from(vec![ - Span::styled(" Open ", theme::style_dim()), - Span::styled(verification_url, Style::default().fg(theme::ACCENT)), - ]), - Line::from(Span::styled( - " and enter the code above.", - theme::style_dim(), - )), - Line::from(""), - Line::from(Span::styled( - format!( - " {} Waiting for authorization...", - spinner_frame(self.tick) - ), - theme::style_primary(), - )), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::PRIMARY)) - .title(Span::styled(" Login ", theme::style_primary_bold())) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content_area); - } - State::Success(email) => { - let lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" ✓ ", theme::style_success()), - Span::styled("Logged in as ", theme::style_dim()), - Span::styled(email, theme::style_bold()), - ]), - Line::from(""), - Line::from(Span::styled( - " Press Enter to continue.", - theme::style_muted(), - )), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::SUCCESS)) - .title(Span::styled(" Success ", theme::style_success())) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content_area); - } - State::Error(msg) => { - let lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" ✗ ", theme::style_danger()), - Span::styled(msg, theme::style_dim()), - ]), - Line::from(""), - Line::from(Span::styled( - " Press Enter to try again.", - theme::style_muted(), - )), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::DANGER)) - .title(Span::styled(" Error ", theme::style_danger())) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content_area); - } - } - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx); - self.client = Some(client.clone()); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Auth"] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - if self.auth_email.is_some() { - vec![("o", "logout"), ("esc", "back")] - } else { - match &self.state { - State::Idle => vec![("l", "login"), ("esc", "back")], - State::Polling { .. } => vec![("c", "copy code"), ("esc", "cancel")], - _ => vec![("esc", "cancel")], - } - } - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} - -fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { - let x = area.x + area.width.saturating_sub(width) / 2; - let y = area.y + area.height.saturating_sub(height) / 2; - Rect::new( - x.max(area.x), - y.max(area.y), - width.min(area.width), - height.min(area.height), - ) -} - -const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - -fn spinner_frame(tick: usize) -> &'static str { - SPINNER_FRAMES[tick % SPINNER_FRAMES.len()] -} - -fn copy_to_clipboard(text: &str) { - #[cfg(target_os = "macos")] - { - use std::process::{Command, Stdio}; - if let Ok(mut child) = Command::new("pbcopy").stdin(Stdio::piped()).spawn() { - if let Some(mut stdin) = child.stdin.take() { - use std::io::Write; - let _ = stdin.write_all(text.as_bytes()); - } - let _ = child.wait(); - } - } - #[cfg(target_os = "linux")] - { - use std::process::{Command, Stdio}; - // Try xclip first, then xsel - let result = Command::new("xclip") - .args(["-selection", "clipboard"]) - .stdin(Stdio::piped()) - .spawn(); - if let Ok(mut child) = result { - if let Some(mut stdin) = child.stdin.take() { - use std::io::Write; - let _ = stdin.write_all(text.as_bytes()); - } - let _ = child.wait(); - } - } -} diff --git a/apps/cli-rs/src/tui/screens/endpoint_detail.rs b/apps/cli-rs/src/tui/screens/endpoint_detail.rs deleted file mode 100644 index b4cdd148f..000000000 --- a/apps/cli-rs/src/tui/screens/endpoint_detail.rs +++ /dev/null @@ -1,242 +0,0 @@ -use crossterm::event::KeyEvent; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::tui::widgets::request_list::{RequestList, RequestListState}; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; -use crate::types::Endpoint; - -use super::{Action, Message, Screen, ScreenId}; - -enum State { - Loading, - Loaded, - Error(String), -} - -pub struct EndpointDetailScreen { - slug: String, - state: State, - endpoint: Option, - requests: RequestListState, - webhook_url: String, - tx: Option>, - client: Option, - tasks: Vec>, - tick: usize, -} - -impl EndpointDetailScreen { - pub fn new(slug: String, webhook_url: String) -> Self { - Self { - slug, - state: State::Loading, - endpoint: None, - requests: RequestListState::new(), - webhook_url, - tx: None, - client: None, - tasks: Vec::new(), - tick: 0, - } - } -} - -impl Screen for EndpointDetailScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - if keys::is_quit(key) { - return Some(Action::Quit); - } - - if keys::is_up(key) { - self.requests.select_prev(); - return None; - } - if keys::is_down(key) { - self.requests.select_next(); - return None; - } - - if keys::is_enter(key) { - if let Some(req) = self.requests.selected_item() { - return Some(Action::Navigate(ScreenId::RequestDetail(req.id.clone()))); - } - return None; - } - - // 'r' to refresh - if keys::is_char(key, 'r') { - self.load_data(); - return None; - } - - None - } - - fn handle_message(&mut self, msg: Message) { - match msg { - Message::EndpointLoaded(Ok(ep)) => { - self.endpoint = Some(ep); - self.state = State::Loaded; - } - Message::EndpointLoaded(Err(e)) => { - self.state = State::Error(e.to_string()); - } - Message::RequestsLoaded(Ok(list)) => { - self.requests.items = list.requests; - if !self.requests.items.is_empty() && self.requests.selected == 0 { - self.requests.selected = 0; - } - } - Message::RequestsLoaded(Err(_)) => {} - _ => {} - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - if let State::Loading = &self.state { - frame.render_widget( - Spinner::new(self.tick, "Loading endpoint..."), - Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(4), 1), - ); - return; - } - - if let State::Error(msg) = &self.state { - let p = Paragraph::new(Line::from(vec![ - Span::styled(" Error: ", theme::style_danger()), - Span::styled(msg.as_str(), theme::style_dim()), - ])); - frame.render_widget(p, area); - return; - } - - let chunks = Layout::vertical([ - Constraint::Length(6), // Endpoint info - Constraint::Min(8), // Request list - ]) - .split(area); - - // Endpoint info panel - if let Some(ref ep) = self.endpoint { - let url = format!("{}/w/{}", self.webhook_url, ep.slug); - let mut lines = vec![ - Line::from(vec![ - Span::styled(" URL: ", theme::style_muted()), - Span::styled(&url, theme::style_bold()), - ]), - Line::from(vec![ - Span::styled(" Name: ", theme::style_muted()), - Span::styled(ep.name.as_deref().unwrap_or("—"), theme::style()), - ]), - Line::from(vec![ - Span::styled(" Requests: ", theme::style_muted()), - Span::styled(ep.request_count.unwrap_or(0).to_string(), theme::style()), - ]), - ]; - - if let Some(ref mock) = ep.mock_response { - lines.push(Line::from(vec![ - Span::styled(" Mock: ", theme::style_muted()), - Span::styled( - format!( - "{} — {}", - mock.status, - mock.body.chars().take(40).collect::() - ), - Style::default().fg(theme::ACCENT), - ), - ])); - } - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled( - format!(" {} ", ep.slug), - theme::style_primary_bold(), - )) - .padding(Padding::new(0, 0, 0, 0)); - - frame.render_widget(Paragraph::new(lines).block(block), chunks[0]); - } - - // Request list - let list = RequestList::new("Recent Requests"); - frame.render_stateful_widget(list, chunks[1], &mut self.requests); - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx); - self.client = Some(client.clone()); - self.load_data(); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Endpoints", &self.slug] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - vec![ - ("↑↓", "navigate"), - ("enter", "inspect"), - ("r", "refresh"), - ("esc", "back"), - ] - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} - -impl EndpointDetailScreen { - fn load_data(&mut self) { - // Abort any in-flight tasks before spawning new ones - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.state = State::Loading; - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx1 = tx.clone(); - let tx2 = tx.clone(); - let slug = self.slug.clone(); - let slug2 = self.slug.clone(); - let client = client.clone(); - let c2 = client.clone(); - - let h1 = tokio::spawn(async move { - let result = client.get_endpoint(&slug).await; - let _ = tx1.send(Message::EndpointLoaded(result)); - }); - let h2 = tokio::spawn(async move { - let result = c2.list_requests(&slug2, Some(50), None).await; - let _ = tx2.send(Message::RequestsLoaded(result)); - }); - self.tasks.push(h1); - self.tasks.push(h2); - } - } -} diff --git a/apps/cli-rs/src/tui/screens/endpoints.rs b/apps/cli-rs/src/tui/screens/endpoints.rs deleted file mode 100644 index 8161e4b5d..000000000 --- a/apps/cli-rs/src/tui/screens/endpoints.rs +++ /dev/null @@ -1,385 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph, Row, Table, TableState}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; -use crate::types::{CreateEndpointRequest, Endpoint, favorites_first}; - -use super::{Action, Message, Screen, ScreenId, favorite_marker}; - -enum State { - Loading, - Loaded, - Creating(String), // input buffer - Deleting(usize), // index to delete - Error(String), -} - -pub struct EndpointsScreen { - state: State, - endpoints: Vec, - table_state: TableState, - webhook_url: String, - tx: Option>, - client: Option, - tasks: Vec>, - tick: usize, -} - -impl EndpointsScreen { - pub fn new(webhook_url: String) -> Self { - Self { - state: State::Loading, - endpoints: Vec::new(), - table_state: TableState::default(), - webhook_url, - tx: None, - client: None, - tasks: Vec::new(), - tick: 0, - } - } -} - -impl Screen for EndpointsScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - // Handle creating state first - if let State::Creating(ref mut input) = self.state { - match key.code { - KeyCode::Enter => { - let name = if input.is_empty() { - None - } else { - Some(input.clone()) - }; - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let req = CreateEndpointRequest { - name, - is_ephemeral: None, - expires_at: None, - mock_response: None, - }; - let result = client.create_endpoint(&req).await; - let _ = tx.send(Message::EndpointCreated(result)); - }); - self.tasks.push(handle); - } - self.state = State::Loading; - return None; - } - KeyCode::Esc => { - self.state = State::Loaded; - return None; - } - KeyCode::Char(c) => { - input.push(c); - return None; - } - KeyCode::Backspace => { - input.pop(); - return None; - } - _ => return None, - } - } - - // Handle confirming delete - if let State::Deleting(idx) = self.state { - if keys::is_char(key, 'y') { - if let Some(ep) = self.endpoints.get(idx) { - let slug = ep.slug.clone(); - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.delete_endpoint(&slug).await.map(|_| slug); - let _ = tx.send(Message::EndpointDeleted(result)); - }); - self.tasks.push(handle); - } - } - self.state = State::Loading; - return None; - } else { - self.state = State::Loaded; - return None; - } - } - - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - if keys::is_quit(key) { - return Some(Action::Quit); - } - - if keys::is_up(key) { - let i = self.table_state.selected().unwrap_or(0); - self.table_state.select(Some(i.saturating_sub(1))); - return None; - } - if keys::is_down(key) { - let i = self.table_state.selected().unwrap_or(0); - let max = self.endpoints.len().saturating_sub(1); - self.table_state.select(Some((i + 1).min(max))); - return None; - } - - if keys::is_enter(key) { - if let Some(i) = self.table_state.selected() - && let Some(ep) = self.endpoints.get(i) - { - return Some(Action::Navigate(ScreenId::EndpointDetail(ep.slug.clone()))); - } - return None; - } - - // 'n' for new - if keys::is_char(key, 'n') { - self.state = State::Creating(String::new()); - return None; - } - - // 'd' for delete - if keys::is_char(key, 'd') { - if let Some(i) = self.table_state.selected() { - self.state = State::Deleting(i); - } - return None; - } - - // 'r' to refresh - if keys::is_char(key, 'r') { - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - self.state = State::Loading; - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.list_endpoints().await; - let _ = tx.send(Message::EndpointsLoaded(result)); - }); - self.tasks.push(handle); - } - return None; - } - - None - } - - fn handle_message(&mut self, msg: Message) { - match msg { - Message::EndpointsLoaded(Ok(list)) => { - // Keep the highlight on the same endpoint: a reload can move - // it, since favourites are listed first. - let selected_slug = self - .table_state - .selected() - .and_then(|i| self.endpoints.get(i)) - .map(|ep| ep.slug.clone()); - self.endpoints = list.owned; - self.endpoints.extend(list.shared); - self.endpoints.sort_by(|a, b| a.slug.cmp(&b.slug)); - favorites_first(&mut self.endpoints); - let kept = selected_slug - .and_then(|slug| self.endpoints.iter().position(|ep| ep.slug == slug)); - if kept.is_some() { - self.table_state.select(kept); - } else if self.endpoints.is_empty() { - self.table_state.select(None); - } else if self - .table_state - .selected() - .is_none_or(|i| i >= self.endpoints.len()) - { - self.table_state.select(Some(0)); - } - self.state = State::Loaded; - } - Message::EndpointsLoaded(Err(e)) => { - self.state = State::Error(e.to_string()); - } - Message::EndpointCreated(Ok(_ep)) => { - // Refresh the list - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.list_endpoints().await; - let _ = tx.send(Message::EndpointsLoaded(result)); - }); - self.tasks.push(handle); - } - } - Message::EndpointCreated(Err(e)) => { - self.state = State::Error(e.to_string()); - } - Message::EndpointDeleted(Ok(_slug)) => { - // Refresh - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.list_endpoints().await; - let _ = tx.send(Message::EndpointsLoaded(result)); - }); - self.tasks.push(handle); - } - } - Message::EndpointDeleted(Err(e)) => { - self.state = State::Error(e.to_string()); - } - _ => {} - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - match &self.state { - State::Loading => { - frame.render_widget( - Spinner::new(self.tick, "Loading endpoints..."), - Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(4), 1), - ); - return; - } - State::Error(msg) => { - let p = Paragraph::new(Line::from(vec![ - Span::styled(" Error: ", theme::style_danger()), - Span::styled(msg, theme::style_dim()), - ])); - frame.render_widget(p, area); - return; - } - _ => {} - } - - let chunks = Layout::vertical([ - Constraint::Min(0), // Table - Constraint::Length(2), // Footer - ]) - .split(area); - - // Endpoint table - let header = - Row::new(vec![" SLUG", "NAME", "REQUESTS", "URL"]).style(theme::style_muted()); - - let rows: Vec = self - .endpoints - .iter() - .map(|ep| { - let name = ep.name.as_deref().unwrap_or("—"); - let url = format!("{}/w/{}", self.webhook_url, ep.slug); - let count = ep.request_count.unwrap_or(0).to_string(); - Row::new(vec![ - format!("{} {}", favorite_marker(ep), ep.slug), - name.to_string(), - count, - url, - ]) - }) - .collect(); - - let widths = [ - Constraint::Length(22), - Constraint::Length(20), - Constraint::Length(10), - Constraint::Min(30), - ]; - - let table = Table::new(rows, widths) - .header(header) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled( - format!(" Endpoints ({}) ", self.endpoints.len()), - theme::style_bold(), - )) - .padding(Padding::vertical(0)), - ) - .row_highlight_style(theme::style_highlight()); - - frame.render_stateful_widget(table, chunks[0], &mut self.table_state); - - // Creating / Deleting overlay - match &self.state { - State::Creating(input) => { - let line = Line::from(vec![ - Span::styled(" Name: ", theme::style_dim()), - Span::styled(input, theme::style_bold()), - Span::styled("█", theme::style_primary()), - Span::styled(" (Enter to create, Esc to cancel)", theme::style_muted()), - ]); - frame.render_widget(Paragraph::new(line), chunks[1]); - } - State::Deleting(idx) => { - let slug = self - .endpoints - .get(*idx) - .map(|e| e.slug.as_str()) - .unwrap_or("?"); - let line = Line::from(vec![ - Span::styled(" Delete ", theme::style_danger()), - Span::styled(slug, theme::style_bold()), - Span::styled("? (y/n)", theme::style_dim()), - ]); - frame.render_widget(Paragraph::new(line), chunks[1]); - } - _ => {} - } - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx.clone()); - self.client = Some(client.clone()); - self.state = State::Loading; - - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.list_endpoints().await; - let _ = tx.send(Message::EndpointsLoaded(result)); - }); - self.tasks.push(handle); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Endpoints"] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - vec![ - ("↑↓", "navigate"), - ("enter", "detail"), - ("n", "new"), - ("d", "delete"), - ("r", "refresh"), - ("esc", "back"), - ] - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} diff --git a/apps/cli-rs/src/tui/screens/listen.rs b/apps/cli-rs/src/tui/screens/listen.rs deleted file mode 100644 index 24701f142..000000000 --- a/apps/cli-rs/src/tui/screens/listen.rs +++ /dev/null @@ -1,333 +0,0 @@ -use crossterm::event::KeyEvent; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Row, Table, TableState}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::api::stream::join_stream; -use crate::tui::widgets::request_list::{RequestList, RequestListState}; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; -use crate::types::{Endpoint, SseEvent, favorites_first}; - -use super::{Action, Message, Screen, ScreenId, favorite_marker}; - -enum State { - LoadingEndpoints, - Picking, - Connecting, - Streaming, - Error(String), -} - -pub struct ListenScreen { - state: State, - endpoints: Vec, - table_state: TableState, - slug: Option, - requests: RequestListState, - /// Stream status line (set while reconnecting, cleared on reconnect). - stream_status: Option, - webhook_url: String, - tx: Option>, - client: Option, - tasks: Vec>, - tick: usize, -} - -impl ListenScreen { - pub fn new(webhook_url: String) -> Self { - Self { - state: State::LoadingEndpoints, - endpoints: Vec::new(), - table_state: TableState::default(), - slug: None, - requests: RequestListState::new(), - stream_status: None, - webhook_url, - tx: None, - client: None, - tasks: Vec::new(), - tick: 0, - } - } -} - -impl Screen for ListenScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_quit(key) { - return Some(Action::Quit); - } - - match &self.state { - State::Picking => { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - if keys::is_up(key) { - let i = self.table_state.selected().unwrap_or(0); - self.table_state.select(Some(i.saturating_sub(1))); - return None; - } - if keys::is_down(key) { - let i = self.table_state.selected().unwrap_or(0); - let max = self.endpoints.len().saturating_sub(1); - self.table_state.select(Some((i + 1).min(max))); - return None; - } - if keys::is_enter(key) { - if let Some(i) = self.table_state.selected() - && let Some(ep) = self.endpoints.get(i) - { - self.slug = Some(ep.slug.clone()); - self.state = State::Connecting; - self.start_stream(); - } - return None; - } - } - State::Streaming => { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - if keys::is_up(key) { - self.requests.select_prev(); - return None; - } - if keys::is_down(key) { - self.requests.select_next(); - return None; - } - if keys::is_enter(key) - && let Some(req) = self.requests.selected_item() - { - return Some(Action::Navigate(ScreenId::RequestDetail(req.id.clone()))); - } - } - State::Error(_) => { - if keys::is_back(key) || keys::is_enter(key) { - return Some(Action::NavigateBack); - } - } - _ => { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - } - } - - None - } - - fn handle_message(&mut self, msg: Message) { - match msg { - Message::EndpointsLoaded(Ok(list)) => { - self.endpoints = list.owned; - self.endpoints.extend(list.shared); - favorites_first(&mut self.endpoints); - if !self.endpoints.is_empty() { - self.table_state.select(Some(0)); - } - self.state = State::Picking; - } - Message::EndpointsLoaded(Err(e)) => { - self.state = State::Error(e.to_string()); - } - Message::SseEvent(SseEvent::Connected) => { - self.stream_status = None; - self.state = State::Streaming; - } - Message::SseEvent(SseEvent::Request(req)) => { - self.state = State::Streaming; - self.requests.push(*req); - } - Message::SseEvent(SseEvent::EndpointDeleted) => { - self.state = State::Error("Endpoint was deleted.".into()); - } - Message::SseEvent(SseEvent::Reconnecting { - attempt, - delay_ms, - reason, - }) => { - let secs = (delay_ms + 500) / 1000; - self.stream_status = Some(format!( - "Reconnecting in {secs}s (attempt {attempt}): {reason}" - )); - } - Message::StreamClosed(Err(e)) => { - self.stream_status = None; - self.state = State::Error(format!("Stream failed: {e:#}")); - } - _ => {} - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - match &self.state { - State::LoadingEndpoints => { - frame.render_widget( - Spinner::new(self.tick, "Loading endpoints..."), - Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(4), 1), - ); - } - State::Picking => { - let rows: Vec = self - .endpoints - .iter() - .map(|ep| { - Row::new(vec![ - format!("{} {}", favorite_marker(ep), ep.slug), - ep.name.clone().unwrap_or_else(|| "—".into()), - ]) - }) - .collect(); - - let header = Row::new(vec![" SLUG", "NAME"]).style(theme::style_muted()); - let widths = [Constraint::Length(24), Constraint::Min(20)]; - - let table = Table::new(rows, widths) - .header(header) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled(" Select Endpoint ", theme::style_bold())), - ) - .row_highlight_style(theme::style_highlight()); - - frame.render_stateful_widget(table, area, &mut self.table_state); - } - State::Connecting => { - frame.render_widget( - Spinner::new(self.tick, "Connecting to stream..."), - Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(4), 1), - ); - } - State::Streaming => { - let chunks = Layout::vertical([ - Constraint::Length(3), // Info - Constraint::Min(8), // Request list - ]) - .split(area); - - let slug = self.slug.as_deref().unwrap_or("—"); - let url = format!("{}/w/{}", self.webhook_url, slug); - - let mut lines = vec![ - Line::from(vec![ - Span::styled(" ● ", theme::style_success()), - Span::styled(format!("Listening on {slug}"), theme::style_success()), - ]), - Line::from(vec![ - Span::styled(" Webhook URL: ", theme::style_muted()), - Span::styled(&url, theme::style_bold()), - ]), - ]; - if let Some(status) = &self.stream_status { - lines.push(Line::from(vec![ - Span::styled(" ● ", theme::style_primary()), - Span::styled(status.as_str(), theme::style_dim()), - ])); - } - let info = Paragraph::new(lines); - frame.render_widget(info, chunks[0]); - - let list = RequestList::new("Incoming Requests"); - frame.render_stateful_widget(list, chunks[1], &mut self.requests); - } - State::Error(msg) => { - let p = Paragraph::new(vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" ✗ ", theme::style_danger()), - Span::styled(msg.as_str(), theme::style_dim()), - ]), - ]); - frame.render_widget(p, area); - } - } - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx.clone()); - self.client = Some(client.clone()); - - if self.slug.is_some() { - // Direct slug — start streaming - self.start_stream(); - } else { - // Load endpoints for picker - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.list_endpoints().await; - let _ = tx.send(Message::EndpointsLoaded(result)); - }); - self.tasks.push(handle); - } - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - match &self.slug { - Some(_) => vec!["Listen"], - None => vec!["Listen"], - } - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - match &self.state { - State::Picking => vec![("↑↓", "navigate"), ("enter", "select"), ("esc", "back")], - State::Streaming => vec![("↑↓", "navigate"), ("enter", "inspect"), ("esc", "stop")], - _ => vec![("esc", "back")], - } - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} - -impl ListenScreen { - fn start_stream(&mut self) { - if let (Some(slug), Some(tx), Some(client)) = (&self.slug, &self.tx, &self.client) { - let slug = slug.clone(); - let tx = tx.clone(); - let client = client.clone(); - - let handle = tokio::spawn(async move { - let (sse_tx, mut sse_rx) = mpsc::channel(64); - let stream_handle = tokio::spawn({ - let slug = slug.clone(); - async move { client.stream_requests(&slug, sse_tx).await } - }); - - while let Some(event) = sse_rx.recv().await { - if tx.send(Message::SseEvent(event)).is_err() { - // UI is gone: stop streaming. - stream_handle.abort(); - return; - } - } - // Channel closed: the stream task finished. Surface its result. - let result = join_stream(stream_handle, false).await; - let _ = tx.send(Message::StreamClosed(result)); - }); - self.tasks.push(handle); - } - } -} diff --git a/apps/cli-rs/src/tui/screens/menu.rs b/apps/cli-rs/src/tui/screens/menu.rs deleted file mode 100644 index 80a95ab66..000000000 --- a/apps/cli-rs/src/tui/screens/menu.rs +++ /dev/null @@ -1,269 +0,0 @@ -use crossterm::event::KeyEvent; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::tui::{keys, theme}; - -use super::{Action, Message, Screen, ScreenId}; - -struct MenuItem { - key: &'static str, - label: &'static str, - desc: &'static str, - icon: &'static str, - screen: ScreenId, -} - -const ITEMS: &[MenuItem] = &[ - MenuItem { - key: "t", - label: "Tunnel", - desc: "Forward webhooks to localhost", - icon: "⇋", - screen: ScreenId::Tunnel, - }, - MenuItem { - key: "l", - label: "Listen", - desc: "Stream incoming requests", - icon: "◉", - screen: ScreenId::Listen, - }, - MenuItem { - key: "e", - label: "Endpoints", - desc: "Manage webhook endpoints", - icon: "◆", - screen: ScreenId::Endpoints, - }, - MenuItem { - key: "s", - label: "Send", - desc: "Send a test webhook", - icon: "➤", - screen: ScreenId::Send, - }, - MenuItem { - key: "/", - label: "Search", - desc: "Search captured requests", - icon: "⌕", - screen: ScreenId::Search, - }, - MenuItem { - key: "i", - label: "Usage", - desc: "Plan, quota, and billing", - icon: "◧", - screen: ScreenId::Usage, - }, - MenuItem { - key: "a", - label: "Auth", - desc: "Login / logout", - icon: "◈", - screen: ScreenId::Auth, - }, - MenuItem { - key: "u", - label: "Update", - desc: "Check for updates", - icon: "↻", - screen: ScreenId::Update, - }, -]; - -pub struct MenuScreen { - selected: usize, - auth_email: Option, -} - -impl MenuScreen { - pub fn new(auth_email: Option) -> Self { - Self { - selected: 0, - auth_email, - } - } - - pub fn set_auth_email(&mut self, email: Option) { - self.auth_email = email; - } -} - -impl Screen for MenuScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_quit(key) { - return Some(Action::Quit); - } - if keys::is_up(key) { - self.selected = self.selected.saturating_sub(1); - return None; - } - if keys::is_down(key) { - self.selected = (self.selected + 1).min(ITEMS.len() - 1); - return None; - } - if keys::is_enter(key) { - return Some(Action::Navigate(ITEMS[self.selected].screen.clone())); - } - - // Shortcut keys - for (i, item) in ITEMS.iter().enumerate() { - if keys::is_char(key, item.key.chars().next().unwrap()) { - self.selected = i; - return Some(Action::Navigate(item.screen.clone())); - } - } - - None - } - - fn handle_message(&mut self, _msg: Message) {} - - fn render(&mut self, frame: &mut Frame, area: Rect) { - let chunks = Layout::vertical([ - Constraint::Length(5), // Logo - Constraint::Length(1), // Spacer - Constraint::Min(10), // Menu items - Constraint::Length(3), // Auth status - ]) - .split(area); - - // Logo / branding - render_logo(frame, chunks[0]); - - // Menu items - render_menu_items(frame, chunks[2], self.selected); - - // Auth status bar - render_auth_status(frame, chunks[3], self.auth_email.as_deref()); - } - - fn on_enter(&mut self, _client: &ApiClient, _tx: mpsc::UnboundedSender) {} - - fn breadcrumb(&self) -> Vec<&str> { - vec![] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - vec![("↑↓", "navigate"), ("enter", "select"), ("q", "quit")] - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} - -fn render_logo(frame: &mut Frame, area: Rect) { - let logo = vec![ - Line::from(vec![Span::styled( - " ╔══════════════════════════╗", - Style::default().fg(theme::BORDER), - )]), - Line::from(vec![ - Span::styled(" ║ ", Style::default().fg(theme::BORDER)), - Span::styled("webhooks", theme::style_primary_bold()), - Span::styled(".", theme::style_dim()), - Span::styled("cc", theme::style_primary_bold()), - Span::styled(" ", Style::default()), - Span::styled( - "cli", - Style::default() - .fg(theme::ACCENT) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" ║", Style::default().fg(theme::BORDER)), - ]), - Line::from(vec![Span::styled( - " ╚══════════════════════════╝", - Style::default().fg(theme::BORDER), - )]), - ]; - - let p = Paragraph::new(logo); - frame.render_widget(p, area); -} - -fn render_menu_items(frame: &mut Frame, area: Rect, selected: usize) { - let block = Block::default().padding(Padding::horizontal(2)); - let inner = block.inner(area); - frame.render_widget(block, area); - - for (i, item) in ITEMS.iter().enumerate() { - if i as u16 >= inner.height { - break; - } - - let is_selected = i == selected; - let y = inner.y + (i as u16 * 2).min(inner.height.saturating_sub(1)); - - if y >= inner.y + inner.height { - break; - } - - let (indicator, ind_style) = if is_selected { - ("▸ ", Style::default().fg(theme::PRIMARY)) - } else { - (" ", theme::style_dim()) - }; - - let label_style = if is_selected { - theme::style_primary_bold() - } else { - theme::style_bold() - }; - - let line = Line::from(vec![ - Span::styled(indicator, ind_style), - Span::styled( - item.icon, - Style::default().fg(if is_selected { - theme::PRIMARY - } else { - theme::MUTED - }), - ), - Span::styled(" ", Style::default()), - Span::styled(item.label, label_style), - Span::styled(" ", Style::default()), - Span::styled(item.desc, theme::style_muted()), - ]); - - frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, inner.width, 1)); - } -} - -fn render_auth_status(frame: &mut Frame, area: Rect, email: Option<&str>) { - let block = Block::default() - .borders(Borders::TOP) - .border_style(Style::default().fg(theme::BORDER)) - .padding(Padding::horizontal(2)); - - let inner = block.inner(area); - frame.render_widget(block, area); - - if inner.height == 0 { - return; - } - - let line = match email { - Some(e) => Line::from(vec![ - Span::styled("● ", theme::style_success()), - Span::styled(e, theme::style_dim()), - ]), - None => Line::from(vec![ - Span::styled("● ", theme::style_danger()), - Span::styled("Not logged in", theme::style_muted()), - ]), - }; - - frame.render_widget(Paragraph::new(line), inner); -} diff --git a/apps/cli-rs/src/tui/screens/mod.rs b/apps/cli-rs/src/tui/screens/mod.rs deleted file mode 100644 index 15a318c24..000000000 --- a/apps/cli-rs/src/tui/screens/mod.rs +++ /dev/null @@ -1,118 +0,0 @@ -pub mod auth; -pub mod endpoint_detail; -pub mod endpoints; -pub mod listen; -pub mod menu; -pub mod request_detail; -pub mod search; -pub mod send; -pub mod tunnel; -pub mod update; -pub mod usage; - -use crossterm::event::KeyEvent; -use ratatui::{Frame, layout::Rect}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; - -/// The first column of an endpoint row: a star for the caller's favourites, -/// padding otherwise, so slugs line up either way. -pub(crate) fn favorite_marker(ep: &crate::types::Endpoint) -> &'static str { - if ep.is_favorite() { " \u{2605}" } else { " " } -} - -/// Messages from async tasks back to the TUI. -#[derive(Debug)] -pub enum Message { - // Endpoint operations - EndpointsLoaded(anyhow::Result), - EndpointCreated(anyhow::Result), - EndpointDeleted(anyhow::Result), - EndpointLoaded(anyhow::Result), - - // Request operations - RequestsLoaded(anyhow::Result), - RequestLoaded(anyhow::Result), - - // SSE - SseEvent(crate::types::SseEvent), - /// The stream task finished: `Ok` after endpoint deletion or shutdown, - /// `Err` for a terminal stream failure (not authenticated, no access, 404). - StreamClosed(anyhow::Result<()>), - - // Tunnel - ForwardResult { - request_id: String, - result: crate::types::ForwardResult, - }, - - // Auth - DeviceCode(anyhow::Result), - AuthPoll(anyhow::Result), - AuthClaimed(anyhow::Result), - - // Usage - UsageLoaded(anyhow::Result), - - // Send - SendResult(anyhow::Result), - - // Update - UpdateCheck(anyhow::Result>), - UpdateResult(anyhow::Result), -} - -/// Actions that screens emit to the app. -#[derive(Debug, Clone)] -pub enum Action { - Navigate(ScreenId), - NavigateBack, - Quit, - SetAuthEmail(Option), -} - -/// Screen identifiers. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ScreenId { - Auth, - Endpoints, - EndpointDetail(String), // slug - Tunnel, - Listen, - RequestDetail(String), // request ID - Search, - Send, - Usage, - Update, -} - -/// Trait that all TUI screens implement. -pub trait Screen { - /// Handle a key event. Return an action if needed. - fn handle_key(&mut self, key: &KeyEvent) -> Option; - - /// Handle an async message. - fn handle_message(&mut self, msg: Message); - - /// Render the screen. - fn render(&mut self, frame: &mut Frame, area: Rect); - - /// Called when the screen becomes active. - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender); - - /// Called when leaving the screen. - fn on_leave(&mut self) {} - - /// Current breadcrumb segments. - fn breadcrumb(&self) -> Vec<&str>; - - /// Status bar key hints. - fn status_keys(&self) -> Vec<(&str, &str)>; - - /// Tick counter for animations (called on each Tick event). - fn tick(&mut self) {} - - /// For downcasting. - fn as_any_mut(&mut self) -> &mut dyn std::any::Any; -} diff --git a/apps/cli-rs/src/tui/screens/request_detail.rs b/apps/cli-rs/src/tui/screens/request_detail.rs deleted file mode 100644 index ac9fc2bd9..000000000 --- a/apps/cli-rs/src/tui/screens/request_detail.rs +++ /dev/null @@ -1,475 +0,0 @@ -use crossterm::event::KeyEvent; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph, Wrap}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; -use crate::types::{CapturedRequest, LiveResponse, ParsedEmail}; -use crate::util::format::{format_bytes, format_timestamp}; -use crate::util::live::live_response_lines; -use crate::util::parsed::{Tone, parsed_lines}; - -use super::{Action, Message, Screen}; - -#[derive(Clone, Copy, PartialEq)] -enum Tab { - Overview, - Headers, - Body, -} - -const TABS: &[(&str, Tab)] = &[ - ("Overview", Tab::Overview), - ("Headers", Tab::Headers), - ("Body", Tab::Body), -]; - -pub struct RequestDetailScreen { - request_id: String, - request: Option, - active_tab: Tab, - scroll: u16, - loading: bool, - error: Option, - tx: Option>, - client: Option, - tasks: Vec>, - tick: usize, -} - -impl RequestDetailScreen { - pub fn new(request_id: String) -> Self { - Self { - request_id, - request: None, - active_tab: Tab::Overview, - scroll: 0, - loading: true, - error: None, - tx: None, - client: None, - tasks: Vec::new(), - tick: 0, - } - } -} - -impl Screen for RequestDetailScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - if keys::is_quit(key) { - return Some(Action::Quit); - } - - // Tab switching with Tab key or 1/2/3 - if keys::is_tab(key) { - self.active_tab = match self.active_tab { - Tab::Overview => Tab::Headers, - Tab::Headers => Tab::Body, - Tab::Body => Tab::Overview, - }; - self.scroll = 0; - return None; - } - if keys::is_backtab(key) { - self.active_tab = match self.active_tab { - Tab::Overview => Tab::Body, - Tab::Headers => Tab::Overview, - Tab::Body => Tab::Headers, - }; - self.scroll = 0; - return None; - } - if keys::is_char(key, '1') { - self.active_tab = Tab::Overview; - self.scroll = 0; - return None; - } - if keys::is_char(key, '2') { - self.active_tab = Tab::Headers; - self.scroll = 0; - return None; - } - if keys::is_char(key, '3') { - self.active_tab = Tab::Body; - self.scroll = 0; - return None; - } - - // Scrolling - if keys::is_up(key) { - self.scroll = self.scroll.saturating_sub(1); - return None; - } - if keys::is_down(key) { - self.scroll += 1; - return None; - } - - None - } - - fn handle_message(&mut self, msg: Message) { - if let Message::RequestLoaded(result) = msg { - self.loading = false; - match result { - Ok(req) => self.request = Some(req), - Err(e) => self.error = Some(e.to_string()), - } - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - if self.loading { - frame.render_widget( - Spinner::new(self.tick, "Loading request..."), - Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(4), 1), - ); - return; - } - - if let Some(ref msg) = self.error { - let p = Paragraph::new(Line::from(vec![ - Span::styled(" Error: ", theme::style_danger()), - Span::styled(msg.as_str(), theme::style_dim()), - ])); - frame.render_widget(p, area); - return; - } - - let req = match &self.request { - Some(r) => r, - None => return, - }; - - let chunks = Layout::vertical([ - Constraint::Length(3), // Tab bar - Constraint::Min(0), // Content - ]) - .split(area); - - // Tab bar - render_tabs(frame, chunks[0], self.active_tab); - - // Content based on active tab - let content_area = chunks[1]; - match self.active_tab { - Tab::Overview => render_overview(frame, content_area, req, self.scroll), - Tab::Headers => render_headers(frame, content_area, req, self.scroll), - Tab::Body => render_body(frame, content_area, req, self.scroll), - } - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx.clone()); - self.client = Some(client.clone()); - self.loading = true; - - let id = self.request_id.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.get_request(&id).await; - let _ = tx.send(Message::RequestLoaded(result)); - }); - self.tasks.push(handle); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Request"] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - vec![ - ("tab", "switch tab"), - ("1-3", "jump tab"), - ("↑↓", "scroll"), - ("esc", "back"), - ] - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} - -fn render_tabs(frame: &mut Frame, area: Rect, active: Tab) { - let mut spans: Vec = vec![Span::raw(" ")]; - - for (i, (label, tab)) in TABS.iter().enumerate() { - if i > 0 { - spans.push(Span::styled(" │ ", theme::style_muted())); - } - - if *tab == active { - spans.push(Span::styled( - format!(" {label} "), - Style::default() - .fg(theme::SURFACE) - .bg(theme::PRIMARY) - .add_modifier(Modifier::BOLD), - )); - } else { - spans.push(Span::styled(format!(" {label} "), theme::style_dim())); - } - } - - let block = Block::default() - .borders(Borders::BOTTOM) - .border_style(Style::default().fg(theme::BORDER)); - - let inner = block.inner(area); - frame.render_widget(block, area); - if inner.height > 0 { - frame.render_widget(Paragraph::new(Line::from(spans)), inner); - } -} - -fn render_overview(frame: &mut Frame, area: Rect, req: &CapturedRequest, scroll: u16) { - let method_style = Style::default() - .fg(theme::method_color(&req.method)) - .add_modifier(Modifier::BOLD); - - let mut lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" Method: ", theme::style_muted()), - Span::styled(&req.method, method_style), - ]), - Line::from(vec![ - Span::styled(" Path: ", theme::style_muted()), - Span::styled(&req.path, theme::style_bold()), - ]), - Line::from(vec![ - Span::styled(" IP: ", theme::style_muted()), - Span::styled(&req.ip, theme::style()), - ]), - Line::from(vec![ - Span::styled(" Size: ", theme::style_muted()), - Span::styled(format_bytes(req.size), theme::style()), - ]), - Line::from(vec![ - Span::styled(" Received: ", theme::style_muted()), - Span::styled(format_timestamp(req.received_at), theme::style()), - ]), - ]; - - if let Some(ref ct) = req.content_type { - lines.push(Line::from(vec![ - Span::styled(" Content-Type: ", theme::style_muted()), - Span::styled(ct.as_str(), theme::style()), - ])); - } - - if !req.query_params.is_empty() { - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - " Query Parameters", - theme::style_primary_bold(), - ))); - for (k, v) in &req.query_params { - lines.push(Line::from(vec![ - Span::styled(format!(" {k}"), theme::style_bold()), - Span::styled(" = ", theme::style_muted()), - Span::styled(v.as_str(), theme::style()), - ])); - } - } - - if let Some(ref parsed) = req.parsed { - lines.extend(parsed_overview(parsed, area.width as usize)); - } - - if let Some(ref live) = req.live_response { - lines.extend(live_overview(live)); - } - - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled(" ID: ", theme::style_muted()), - Span::styled(&req.id, theme::style_dim()), - ])); - - let p = Paragraph::new(lines).scroll((scroll, 0)); - frame.render_widget(p, area); -} - -/// The overview's "Parsed" section for an email: what the endpoint's layouts -/// found, the same lines `whk requests get` prints. -fn parsed_overview(parsed: &ParsedEmail, width: usize) -> Vec> { - let mut lines = vec![ - Line::from(""), - Line::from(Span::styled(" Parsed", theme::style_primary_bold())), - ]; - // The panel's width, less a line's indent (it neither wraps nor scrolls sideways). - for line in parsed_lines(parsed, width.saturating_sub(6)) { - let mut spans = vec![Span::raw(" ".repeat(line.depth + 2))]; - spans.extend(line.spans.into_iter().map(|(tone, text)| { - let style = match tone { - Tone::Plain => theme::style(), - Tone::Strong => theme::style_bold(), - Tone::Muted => theme::style_muted(), - Tone::Danger => theme::style_danger(), - }; - Span::styled(text, style) - })); - lines.push(Line::from(spans)); - } - lines -} - -/// The overview's "Live Response" section: what the sender got while -/// `whk tunnel --live` relayed the request, the lines `whk requests get` -/// prints. -fn live_overview(live: &LiveResponse) -> Vec> { - let mut lines = vec![ - Line::from(""), - Line::from(Span::styled(" Live Response", theme::style_primary_bold())), - ]; - for text in live_response_lines(live) { - lines.push(Line::from(Span::styled( - format!(" {text}"), - theme::style(), - ))); - } - lines -} - -fn render_headers(frame: &mut Frame, area: Rect, req: &CapturedRequest, scroll: u16) { - let mut headers: Vec<_> = req.headers.iter().collect(); - headers.sort_by_key(|(k, _)| k.to_lowercase()); - - let mut lines = vec![Line::from("")]; - for (k, v) in headers { - lines.push(Line::from(vec![ - Span::styled(format!(" {k}"), theme::style_bold()), - Span::styled(": ", theme::style_muted()), - Span::styled(v.as_str(), theme::style()), - ])); - } - - if lines.len() == 1 { - lines.push(Line::from(Span::styled( - " No headers.", - theme::style_muted(), - ))); - } - - let p = Paragraph::new(lines).scroll((scroll, 0)); - frame.render_widget(p, area); -} - -fn render_body(frame: &mut Frame, area: Rect, req: &CapturedRequest, scroll: u16) { - let body = match &req.body { - Some(b) if !b.is_empty() => b.clone(), - _ => { - let p = Paragraph::new(Line::from(Span::styled(" No body.", theme::style_muted()))); - frame.render_widget(p, area); - return; - } - }; - - // Try to pretty-print JSON - let formatted = if let Ok(val) = serde_json::from_str::(&body) { - serde_json::to_string_pretty(&val).unwrap_or(body) - } else { - body - }; - - let lines: Vec = std::iter::once(Line::from("")) - .chain( - formatted - .lines() - .map(|l| Line::from(Span::styled(format!(" {l}"), theme::style()))), - ) - .collect(); - - let block = Block::default().padding(Padding::new(0, 0, 0, 0)); - - let p = Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: false }) - .scroll((scroll, 0)); - frame.render_widget(p, area); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn text(line: &Line) -> String { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect() - } - - #[test] - fn overview_shows_an_emails_parsed_result() { - let request = crate::util::parsed::tests::acme_order(); - let lines: Vec = parsed_overview(request.parsed.as_ref().unwrap(), 120) - .iter() - .map(text) - .collect(); - assert_eq!(lines[1], " Parsed"); - assert_eq!(lines[2], " Layout: Acme orders"); - assert!(lines.contains(&" po: not found".to_string())); - assert!(lines.contains(&" sku qty price".to_string())); - assert!(lines.contains(&" scan.pdf pdf, is encrypted".to_string())); - } - - #[test] - fn the_overview_screen_draws_the_parsed_section() { - use ratatui::{Terminal, backend::TestBackend}; - let request = crate::util::parsed::tests::acme_order(); - let mut terminal = Terminal::new(TestBackend::new(100, 80)).unwrap(); - terminal - .draw(|frame| render_overview(frame, frame.area(), &request, 0)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let rows: Vec = (0..buffer.area.height) - .map(|y| { - (0..buffer.area.width) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - .trim_end() - .to_string() - }) - .collect(); - assert!(rows.iter().any(|row| row == " Parsed"), "{rows:#?}"); - assert!(rows.iter().any(|row| row == " Layout: Acme orders")); - assert!(rows.iter().any(|row| row == " po: not found")); - } - - #[test] - fn overview_shows_what_a_live_relay_answered() { - let live: LiveResponse = serde_json::from_value(serde_json::json!({ - "status": 201, "headers": {"content-type": "application/json"}, - "body": "{}", "bodyEncoding": "utf8", "ms": 7, "outcome": "answered" - })) - .unwrap(); - let lines: Vec = live_overview(&live).iter().map(text).collect(); - assert_eq!(lines[1], " Live Response"); - assert_eq!(lines[2], " Status: 201 (your app answered in 7 ms)"); - assert_eq!(lines[3], " content-type: application/json"); - } -} diff --git a/apps/cli-rs/src/tui/screens/search.rs b/apps/cli-rs/src/tui/screens/search.rs deleted file mode 100644 index 138d0eaeb..000000000 --- a/apps/cli-rs/src/tui/screens/search.rs +++ /dev/null @@ -1,471 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph, Row, Table, TableState}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; -use crate::types::CapturedRequest; -use crate::util::format::{format_bytes, format_timestamp}; - -use super::{Action, Message, Screen, ScreenId}; - -#[derive(Clone, Copy, PartialEq)] -enum Field { - Query, - Method, - Slug, -} - -const FIELDS: &[Field] = &[Field::Query, Field::Method, Field::Slug]; - -enum State { - Editing, - Loading, - Results, - Error(String), -} - -pub struct SearchScreen { - state: State, - active_field: Field, - query: String, - method: String, - slug: String, - results: Vec, - total: u64, - table_state: TableState, - tx: Option>, - client: Option, - tasks: Vec>, - tick: usize, -} - -impl Default for SearchScreen { - fn default() -> Self { - Self::new() - } -} - -impl SearchScreen { - pub fn new() -> Self { - Self { - state: State::Editing, - active_field: Field::Query, - query: String::new(), - method: String::new(), - slug: String::new(), - results: Vec::new(), - total: 0, - table_state: TableState::default(), - tx: None, - client: None, - tasks: Vec::new(), - tick: 0, - } - } - - fn active_input(&mut self) -> &mut String { - match self.active_field { - Field::Query => &mut self.query, - Field::Method => &mut self.method, - Field::Slug => &mut self.slug, - } - } - - fn run_search(&mut self) { - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - self.state = State::Loading; - let tx = tx.clone(); - let client = client.clone(); - let q = if self.query.is_empty() { - None - } else { - Some(self.query.clone()) - }; - let method = if self.method.is_empty() { - None - } else { - Some(self.method.clone()) - }; - let slug = if self.slug.is_empty() { - None - } else { - Some(self.slug.clone()) - }; - - let handle = tokio::spawn(async move { - // The search route returns one page and no total, so the - // total comes from the count route, fetched alongside. - let (result, count) = tokio::join!( - client.search_requests( - slug.as_deref(), - method.as_deref(), - q.as_deref(), - None, - None, - Some(50), - None, - Some("desc"), - ), - client.count_requests( - slug.as_deref(), - method.as_deref(), - q.as_deref(), - None, - None - ) - ); - - match result { - Ok(sr) => { - // Reuse RequestsLoaded for simplicity - let _ = tx.send(Message::RequestsLoaded(Ok(crate::types::RequestList { - requests: sr.requests.into_iter().map(CapturedRequest::from).collect(), - count: count.ok().map(|c| c.count), - }))); - } - Err(e) => { - let _ = tx.send(Message::RequestsLoaded(Err(e))); - } - } - }); - self.tasks.push(handle); - } - } -} - -impl Screen for SearchScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_quit(key) { - return Some(Action::Quit); - } - - match &self.state { - State::Editing => { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - - match key.code { - KeyCode::Tab => { - let idx = FIELDS - .iter() - .position(|f| *f == self.active_field) - .unwrap_or(0); - self.active_field = FIELDS[(idx + 1) % FIELDS.len()]; - return None; - } - KeyCode::BackTab => { - let idx = FIELDS - .iter() - .position(|f| *f == self.active_field) - .unwrap_or(0); - self.active_field = FIELDS[(idx + FIELDS.len() - 1) % FIELDS.len()]; - return None; - } - KeyCode::Enter => { - self.run_search(); - return None; - } - KeyCode::Char(c) => { - self.active_input().push(c); - return None; - } - KeyCode::Backspace => { - self.active_input().pop(); - return None; - } - _ => {} - } - } - State::Results => { - if keys::is_back(key) { - self.state = State::Editing; - return None; - } - - if keys::is_up(key) { - let i = self.table_state.selected().unwrap_or(0); - self.table_state.select(Some(i.saturating_sub(1))); - return None; - } - if keys::is_down(key) { - let i = self.table_state.selected().unwrap_or(0); - let max = self.results.len().saturating_sub(1); - self.table_state.select(Some((i + 1).min(max))); - return None; - } - if keys::is_enter(key) { - if let Some(i) = self.table_state.selected() - && let Some(req) = self.results.get(i) - { - return Some(Action::Navigate(ScreenId::RequestDetail(req.id.clone()))); - } - return None; - } - - // '/' to go back to editing - if keys::is_char(key, '/') { - self.state = State::Editing; - return None; - } - } - State::Error(_) => { - if keys::is_back(key) || keys::is_enter(key) { - self.state = State::Editing; - return None; - } - } - State::Loading => {} - } - - None - } - - fn handle_message(&mut self, msg: Message) { - if let Message::RequestsLoaded(result) = msg { - match result { - Ok(list) => { - self.results = list.requests; - self.total = list.count.unwrap_or(self.results.len() as u64); - if !self.results.is_empty() { - self.table_state.select(Some(0)); - } - self.state = State::Results; - } - Err(e) => { - self.state = State::Error(e.to_string()); - } - } - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - let chunks = Layout::vertical([ - Constraint::Length(7), // Search fields - Constraint::Min(0), // Results - ]) - .split(area); - - // Search fields - render_search_fields( - frame, - chunks[0], - &self.query, - &self.method, - &self.slug, - self.active_field, - matches!(self.state, State::Editing), - ); - - // Results area - match &self.state { - State::Loading => { - frame.render_widget( - Spinner::new(self.tick, "Searching..."), - Rect::new( - chunks[1].x + 2, - chunks[1].y + 1, - chunks[1].width.saturating_sub(4), - 1, - ), - ); - } - State::Results => { - render_results( - frame, - chunks[1], - &self.results, - self.total, - &mut self.table_state, - ); - } - State::Error(msg) => { - let p = Paragraph::new(Line::from(vec![ - Span::styled(" Error: ", theme::style_danger()), - Span::styled(msg.as_str(), theme::style_dim()), - ])); - frame.render_widget(p, chunks[1]); - } - State::Editing => { - if !self.results.is_empty() { - render_results( - frame, - chunks[1], - &self.results, - self.total, - &mut self.table_state, - ); - } else { - let p = Paragraph::new(Line::from(Span::styled( - " Press Enter to search.", - theme::style_muted(), - ))); - frame.render_widget(p, chunks[1]); - } - } - } - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx); - self.client = Some(client.clone()); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Search"] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - match &self.state { - State::Editing => vec![("tab", "next field"), ("enter", "search"), ("esc", "back")], - State::Results => vec![ - ("↑↓", "navigate"), - ("enter", "inspect"), - ("/", "edit"), - ("esc", "back"), - ], - _ => vec![("esc", "back")], - } - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} - -fn render_search_fields( - frame: &mut Frame, - area: Rect, - query: &str, - method: &str, - slug: &str, - active: Field, - editing: bool, -) { - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled(" Search Requests ", theme::style_bold())) - .padding(Padding::horizontal(1)); - - let inner = block.inner(area); - frame.render_widget(block, area); - - if inner.height < 3 { - return; - } - - let fields = [ - ("Query: ", query, Field::Query), - ("Method: ", method, Field::Method), - ("Slug: ", slug, Field::Slug), - ]; - - for (i, (label, value, field)) in fields.iter().enumerate() { - if i as u16 >= inner.height { - break; - } - let y = inner.y + i as u16; - let is_active = editing && active == *field; - - let cursor = if is_active { "█" } else { "" }; - let label_style = if is_active { - theme::style_primary() - } else { - theme::style_muted() - }; - let value_style = if is_active { - theme::style_bold() - } else { - theme::style() - }; - - let line = Line::from(vec![ - Span::styled(if is_active { "▸ " } else { " " }, label_style), - Span::styled(*label, label_style), - Span::styled(*value, value_style), - Span::styled(cursor, theme::style_primary()), - ]); - - frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, inner.width, 1)); - } -} - -fn render_results( - frame: &mut Frame, - area: Rect, - results: &[CapturedRequest], - total: u64, - table_state: &mut TableState, -) { - if results.is_empty() { - let p = Paragraph::new(Line::from(Span::styled( - " No matching requests found.", - theme::style_muted(), - ))); - frame.render_widget(p, area); - return; - } - - let header = Row::new(vec![" TIME", "METHOD", "PATH", "SIZE"]).style(theme::style_muted()); - - let rows: Vec = results - .iter() - .map(|r| { - let time = format_timestamp(r.received_at); - Row::new(vec![ - format!(" {}", time.get(11..19).unwrap_or("??:??:??")), - r.method.clone(), - r.path.clone(), - format_bytes(r.size), - ]) - .style(Style::default().fg(theme::TEXT)) - }) - .collect(); - - let widths = [ - Constraint::Length(12), - Constraint::Length(8), - Constraint::Min(20), - Constraint::Length(10), - ]; - - let table = Table::new(rows, widths) - .header(header) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled( - // The screen shows one page; say so when more matches exist. - if total > results.len() as u64 { - format!(" Results (first {} of {total}) ", results.len()) - } else { - format!(" Results ({total}) ") - }, - theme::style_bold(), - )), - ) - .row_highlight_style(theme::style_highlight()); - - frame.render_stateful_widget(table, area, table_state); -} diff --git a/apps/cli-rs/src/tui/screens/send.rs b/apps/cli-rs/src/tui/screens/send.rs deleted file mode 100644 index 83e4d1d74..000000000 --- a/apps/cli-rs/src/tui/screens/send.rs +++ /dev/null @@ -1,348 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph, Wrap}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; -use crate::types::{SendResponse, SendWebhookRequest}; - -use super::{Action, Message, Screen}; - -#[derive(Clone, Copy, PartialEq)] -enum Field { - Slug, - Method, - Body, -} - -const FIELDS: &[Field] = &[Field::Slug, Field::Method, Field::Body]; - -enum State { - Editing, - Sending, - Done(SendResponse), - Error(String), -} - -pub struct SendScreen { - state: State, - active_field: Field, - slug: String, - method: String, - body: String, - tx: Option>, - client: Option, - tasks: Vec>, - tick: usize, -} - -impl Default for SendScreen { - fn default() -> Self { - Self::new() - } -} - -impl SendScreen { - pub fn new() -> Self { - Self { - state: State::Editing, - active_field: Field::Slug, - slug: String::new(), - method: "POST".into(), - body: String::new(), - tx: None, - client: None, - tasks: Vec::new(), - tick: 0, - } - } - - fn active_input(&mut self) -> &mut String { - match self.active_field { - Field::Slug => &mut self.slug, - Field::Method => &mut self.method, - Field::Body => &mut self.body, - } - } - - fn send_webhook(&mut self) { - if self.slug.is_empty() { - self.state = State::Error("Slug is required.".into()); - return; - } - - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - self.state = State::Sending; - let tx = tx.clone(); - let client = client.clone(); - let req = SendWebhookRequest { - method: self.method.clone(), - slug: self.slug.clone(), - path: None, - headers: None, - body: if self.body.is_empty() { - None - } else { - Some(self.body.clone()) - }, - }; - - let handle = tokio::spawn(async move { - let result = client.send_webhook(&req).await; - let _ = tx.send(Message::SendResult(result)); - }); - self.tasks.push(handle); - } - } -} - -impl Screen for SendScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_quit(key) { - return Some(Action::Quit); - } - - match &self.state { - State::Editing => { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - match key.code { - KeyCode::Tab => { - let idx = FIELDS - .iter() - .position(|f| *f == self.active_field) - .unwrap_or(0); - self.active_field = FIELDS[(idx + 1) % FIELDS.len()]; - return None; - } - KeyCode::BackTab => { - let idx = FIELDS - .iter() - .position(|f| *f == self.active_field) - .unwrap_or(0); - self.active_field = FIELDS[(idx + FIELDS.len() - 1) % FIELDS.len()]; - return None; - } - KeyCode::Enter => { - self.send_webhook(); - return None; - } - KeyCode::Char(c) => { - self.active_input().push(c); - return None; - } - KeyCode::Backspace => { - self.active_input().pop(); - return None; - } - _ => {} - } - } - State::Done(_) | State::Error(_) => { - if keys::is_enter(key) || keys::is_back(key) { - self.state = State::Editing; - return None; - } - } - State::Sending => {} - } - - None - } - - fn handle_message(&mut self, msg: Message) { - if let Message::SendResult(result) = msg { - match result { - Ok(resp) => self.state = State::Done(resp), - Err(e) => self.state = State::Error(e.to_string()), - } - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - let chunks = Layout::vertical([ - Constraint::Length(9), // Form - Constraint::Min(0), // Result - ]) - .split(area); - - // Form - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled(" Send Test Webhook ", theme::style_bold())) - .padding(Padding::horizontal(1)); - - let inner = block.inner(chunks[0]); - frame.render_widget(block, chunks[0]); - - let fields = [ - ("Slug: ", &self.slug as &str, Field::Slug), - ("Method: ", &self.method, Field::Method), - ("Body: ", &self.body, Field::Body), - ]; - - let editing = matches!(self.state, State::Editing); - - for (i, (label, value, field)) in fields.iter().enumerate() { - if i as u16 >= inner.height { - break; - } - let y = inner.y + i as u16; - let is_active = editing && self.active_field == *field; - - let cursor = if is_active { "█" } else { "" }; - let label_style = if is_active { - theme::style_primary() - } else { - theme::style_muted() - }; - let value_style = if is_active { - theme::style_bold() - } else { - theme::style() - }; - - let line = Line::from(vec![ - Span::styled(if is_active { "▸ " } else { " " }, label_style), - Span::styled(*label, label_style), - Span::styled(*value, value_style), - Span::styled(cursor, theme::style_primary()), - ]); - - frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, inner.width, 1)); - } - - // Result area - match &self.state { - State::Sending => { - frame.render_widget( - Spinner::new(self.tick, "Sending..."), - Rect::new( - chunks[1].x + 2, - chunks[1].y + 1, - chunks[1].width.saturating_sub(4), - 1, - ), - ); - } - State::Done(resp) => { - let status_color = if resp.status < 400 { - theme::SUCCESS - } else { - theme::DANGER - }; - let mut lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" Status: ", theme::style_muted()), - Span::styled( - format!("{} {}", resp.status, resp.status_text), - Style::default().fg(status_color), - ), - ]), - ]; - - if let Some(ref body) = resp.body - && !body.is_empty() - { - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - " Response:", - theme::style_muted(), - ))); - - // Pretty-print JSON if possible - let formatted = if let Ok(val) = serde_json::from_str::(body) - { - serde_json::to_string_pretty(&val).unwrap_or_else(|_| body.clone()) - } else { - body.clone() - }; - for line in formatted.lines().take(20) { - lines.push(Line::from(Span::styled( - format!(" {line}"), - theme::style_dim(), - ))); - } - } - - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - " Press Enter to send another.", - theme::style_muted(), - ))); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(status_color)) - .title(Span::styled( - " Response ", - Style::default().fg(status_color), - )); - - frame.render_widget( - Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: false }), - chunks[1], - ); - } - State::Error(msg) => { - let p = Paragraph::new(vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" ✗ ", theme::style_danger()), - Span::styled(msg.as_str(), theme::style_dim()), - ]), - Line::from(""), - Line::from(Span::styled( - " Press Enter to try again.", - theme::style_muted(), - )), - ]); - frame.render_widget(p, chunks[1]); - } - State::Editing => {} - } - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx); - self.client = Some(client.clone()); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Send"] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - match &self.state { - State::Editing => vec![("tab", "next field"), ("enter", "send"), ("esc", "back")], - _ => vec![("enter", "continue"), ("esc", "back")], - } - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} diff --git a/apps/cli-rs/src/tui/screens/tunnel.rs b/apps/cli-rs/src/tui/screens/tunnel.rs deleted file mode 100644 index 0f90c361f..000000000 --- a/apps/cli-rs/src/tui/screens/tunnel.rs +++ /dev/null @@ -1,388 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph}, -}; -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::api::stream::join_stream; -use crate::tui::widgets::request_list::{RequestList, RequestListState}; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; -use crate::tunnel::{EmailOptions, Tunnel, parse_target}; -use crate::types::{CreateEndpointRequest, ForwardResult, SseEvent}; - -use super::{Action, Message, Screen, ScreenId}; - -enum State { - Input, - Connecting, - Active, - Error(String), -} - -pub struct TunnelScreen { - state: State, - input: String, - slug: Option, - webhook_url_str: Option, - target_url: Option, - /// Built once the endpoint exists; emails go as webhooks.cc's email JSON - /// (`whk tunnel --email-format` picks a mail provider's shape). - tunnel: Option>, - requests: RequestListState, - forward_results: HashMap, - /// Stream status line (set while reconnecting, cleared on reconnect). - stream_status: Option, - webhook_url: String, - tx: Option>, - client: Option, - tasks: Vec>, - tick: usize, -} - -impl TunnelScreen { - pub fn new(webhook_url: String) -> Self { - Self { - state: State::Input, - input: String::new(), - slug: None, - webhook_url_str: None, - target_url: None, - tunnel: None, - requests: RequestListState::new(), - forward_results: HashMap::new(), - stream_status: None, - webhook_url, - tx: None, - client: None, - tasks: Vec::new(), - tick: 0, - } - } -} - -impl Screen for TunnelScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - match &self.state { - State::Input => { - match key.code { - KeyCode::Enter => { - match parse_target(&self.input) { - Ok(target) => { - self.target_url = Some(target); - self.state = State::Connecting; - self.start_tunnel(); - } - Err(e) => { - self.state = State::Error(e.to_string()); - } - } - return None; - } - KeyCode::Esc => return Some(Action::NavigateBack), - KeyCode::Char(c) - if c.is_ascii_alphanumeric() - || c == '/' - || c == '-' - || c == '_' - || c == '.' - || c == ':' => - { - self.input.push(c); - return None; - } - KeyCode::Backspace => { - self.input.pop(); - return None; - } - _ => {} - } - if keys::is_quit(key) { - return Some(Action::Quit); - } - } - State::Active => { - if keys::is_back(key) || keys::is_quit(key) { - // Cleanup: delete ephemeral endpoint - if let (Some(slug), Some(client)) = (&self.slug, &self.client) { - let slug = slug.clone(); - let client = client.clone(); - tokio::spawn(async move { - let _ = client.delete_endpoint(&slug).await; - }); - } - return Some(Action::NavigateBack); - } - - if keys::is_up(key) { - self.requests.select_prev(); - return None; - } - if keys::is_down(key) { - self.requests.select_next(); - return None; - } - if keys::is_enter(key) - && let Some(req) = self.requests.selected_item() - { - return Some(Action::Navigate(ScreenId::RequestDetail(req.id.clone()))); - } - } - State::Connecting => { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - } - State::Error(_) => { - if keys::is_enter(key) || keys::is_back(key) { - self.state = State::Input; - return None; - } - } - } - None - } - - fn handle_message(&mut self, msg: Message) { - match msg { - Message::EndpointCreated(Ok(ep)) => { - let slug = ep.slug.clone(); - let url = format!("{}/w/{}", self.webhook_url, slug); - self.slug = Some(slug.clone()); - self.webhook_url_str = Some(url); - self.state = State::Active; - if let (Some(target), Some(client)) = (&self.target_url, &self.client) { - self.tunnel = Tunnel::new(target.clone(), HashMap::new()) - .ok() - .map(|t| Arc::new(t.with_emails(client.clone(), EmailOptions::default()))); - } - - // Start SSE stream - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let stream_slug = slug.clone(); - let handle = tokio::spawn(async move { - let (sse_tx, mut sse_rx) = mpsc::channel(64); - let stream_handle = tokio::spawn(async move { - client.stream_requests(&stream_slug, sse_tx).await - }); - - while let Some(event) = sse_rx.recv().await { - if tx.send(Message::SseEvent(event)).is_err() { - // UI is gone: stop streaming. - stream_handle.abort(); - return; - } - } - // Channel closed: the stream task finished. Surface its result. - let result = join_stream(stream_handle, false).await; - let _ = tx.send(Message::StreamClosed(result)); - }); - self.tasks.push(handle); - } - } - Message::EndpointCreated(Err(e)) => { - self.state = State::Error(e.to_string()); - } - Message::SseEvent(SseEvent::Reconnecting { - attempt, - delay_ms, - reason, - }) => { - let secs = (delay_ms + 500) / 1000; - self.stream_status = Some(format!( - "Reconnecting in {secs}s (attempt {attempt}): {reason}" - )); - } - Message::SseEvent(SseEvent::Connected) => { - self.stream_status = None; - } - Message::StreamClosed(Err(e)) => { - self.stream_status = None; - self.state = State::Error(format!("Stream failed: {e:#}")); - } - Message::SseEvent(SseEvent::Request(req)) => { - let req_id = req.id.clone(); - let req_for_fwd = (*req).clone(); - self.requests.push(*req); - - // Forward the request (emails are rendered through the API first) - if let Some(ref tunnel) = self.tunnel { - let tunnel = tunnel.clone(); - if let Some(ref tx) = self.tx { - let tx = tx.clone(); - let rid = req_id.clone(); - let handle = tokio::spawn(async move { - let result = tunnel.forward(&req_for_fwd).await; - let _ = tx.send(Message::ForwardResult { - request_id: rid, - result, - }); - }); - self.tasks.push(handle); - } - } - } - Message::SseEvent(SseEvent::EndpointDeleted) => { - self.state = State::Error("Endpoint was deleted.".into()); - } - Message::ForwardResult { request_id, result } => { - self.forward_results.insert(request_id, result); - } - _ => {} - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - match &self.state { - State::Input => { - let chunks = Layout::vertical([ - Constraint::Length(3), - Constraint::Length(5), - Constraint::Min(0), - ]) - .split(area); - - let hint = Paragraph::new(Line::from(vec![ - Span::styled(" Enter port", theme::style_dim()), - Span::styled(" (e.g. 8080 or 3000/api/webhooks)", theme::style_muted()), - ])); - frame.render_widget(hint, chunks[0]); - - let input_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::PRIMARY)) - .title(Span::styled(" Target ", theme::style_primary_bold())) - .padding(Padding::horizontal(1)); - - let input_text = Paragraph::new(Line::from(vec![ - Span::styled("localhost:", theme::style_dim()), - Span::styled(&self.input, theme::style_bold()), - Span::styled("█", theme::style_primary()), - ])) - .block(input_block); - - frame.render_widget(input_text, chunks[1]); - } - State::Connecting => { - frame.render_widget( - Spinner::new(self.tick, "Creating endpoint and connecting..."), - Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(4), 1), - ); - } - State::Active => { - let chunks = Layout::vertical([ - Constraint::Length(4), // Connection info - Constraint::Min(8), // Request list - ]) - .split(area); - - // Connection info - let url = self.webhook_url_str.as_deref().unwrap_or("—"); - let target = self.target_url.as_deref().unwrap_or("—"); - - let mut lines = vec![ - Line::from(vec![ - Span::styled(" ● ", theme::style_success()), - Span::styled("Tunnel active", theme::style_success()), - ]), - Line::from(vec![ - Span::styled(" Webhook URL: ", theme::style_muted()), - Span::styled(url, theme::style_bold()), - ]), - Line::from(vec![ - Span::styled(" Forwarding to: ", theme::style_muted()), - Span::styled(target, theme::style()), - ]), - ]; - if let Some(status) = &self.stream_status { - lines.push(Line::from(vec![ - Span::styled(" ● ", theme::style_primary()), - Span::styled(status.as_str(), theme::style_dim()), - ])); - } - let info = Paragraph::new(lines); - frame.render_widget(info, chunks[0]); - - // Request list with forward status - let list = RequestList::new("Requests").show_forward_status(); - frame.render_stateful_widget(list, chunks[1], &mut self.requests); - } - State::Error(msg) => { - let p = Paragraph::new(vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" ✗ ", theme::style_danger()), - Span::styled(msg.as_str(), theme::style_dim()), - ]), - Line::from(""), - Line::from(Span::styled( - " Press Enter to try again.", - theme::style_muted(), - )), - ]); - frame.render_widget(p, area); - } - } - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx); - self.client = Some(client.clone()); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Tunnel"] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - match &self.state { - State::Input => vec![("enter", "connect"), ("esc", "back")], - State::Active => vec![("↑↓", "navigate"), ("enter", "inspect"), ("esc", "stop")], - _ => vec![("esc", "back")], - } - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} - -impl TunnelScreen { - fn start_tunnel(&mut self) { - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let req = CreateEndpointRequest { - name: None, - is_ephemeral: Some(true), - expires_at: None, - mock_response: None, - }; - let result = client.create_endpoint(&req).await; - let _ = tx.send(Message::EndpointCreated(result)); - }); - self.tasks.push(handle); - } - } -} diff --git a/apps/cli-rs/src/tui/screens/update.rs b/apps/cli-rs/src/tui/screens/update.rs deleted file mode 100644 index 969bcc076..000000000 --- a/apps/cli-rs/src/tui/screens/update.rs +++ /dev/null @@ -1,268 +0,0 @@ -use crossterm::event::KeyEvent; -use ratatui::{ - Frame, - layout::Rect, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph}, -}; -use tokio::sync::mpsc; - -use crate::api::{ApiClient, update}; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; - -use super::{Action, Message, Screen}; - -enum State { - Checking, - UpToDate, - Available(update::Release), - Applying, - Done(String), - Error(String), -} - -pub struct UpdateScreen { - state: State, - version: String, - tx: Option>, - tasks: Vec>, - tick: usize, -} - -// We reuse UsageLoaded message to carry update results since we don't -// want to add more message variants. Use a simple channel instead. -// Actually, let's just use tokio::spawn with a flag. - -impl Default for UpdateScreen { - fn default() -> Self { - Self::new() - } -} - -impl UpdateScreen { - pub fn new() -> Self { - Self { - state: State::Checking, - version: env!("WHK_VERSION").to_string(), - tx: None, - tasks: Vec::new(), - tick: 0, - } - } -} - -impl Screen for UpdateScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - if keys::is_quit(key) { - return Some(Action::Quit); - } - - match &self.state { - State::Available(release) if keys::is_char(key, 'u') => { - let release = release.clone(); - self.state = State::Applying; - if let Some(ref tx) = self.tx { - let tx = tx.clone(); - let handle = tokio::spawn(async move { - match update::apply(&release).await { - Ok(()) => { - let _ = tx.send(Message::UpdateResult(Ok(release.version))); - } - Err(e) => { - let _ = tx.send(Message::UpdateResult(Err(e))); - } - } - }); - self.tasks.push(handle); - } - } - State::Done(_) | State::Error(_) | State::UpToDate if keys::is_enter(key) => { - return Some(Action::NavigateBack); - } - _ => {} - } - - None - } - - fn handle_message(&mut self, msg: Message) { - match msg { - Message::UpdateCheck(Ok(None)) => { - self.state = State::UpToDate; - } - Message::UpdateCheck(Ok(Some(release))) => { - self.state = State::Available(release); - } - Message::UpdateCheck(Err(e)) => { - self.state = State::Error(e.to_string()); - } - Message::UpdateResult(Ok(version)) => { - self.state = State::Done(version); - } - Message::UpdateResult(Err(e)) => { - self.state = State::Error(e.to_string()); - } - _ => {} - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - let content = Rect::new( - area.x + 2, - area.y + 1, - area.width.saturating_sub(4), - area.height.saturating_sub(2), - ); - - match &self.state { - State::Checking => { - frame.render_widget(Spinner::new(self.tick, "Checking for updates..."), content); - } - State::UpToDate => { - let lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" ✓ ", theme::style_success()), - Span::styled( - format!("Already on latest version (v{})", self.version), - theme::style(), - ), - ]), - Line::from(""), - Line::from(Span::styled( - " Press Enter to go back.", - theme::style_muted(), - )), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::SUCCESS)) - .title(Span::styled(" Up to date ", theme::style_success())) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content); - } - State::Available(release) => { - let lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" Current: ", theme::style_muted()), - Span::styled(format!("v{}", self.version), theme::style_dim()), - ]), - Line::from(vec![ - Span::styled(" Latest: ", theme::style_muted()), - Span::styled(&release.version, theme::style_primary_bold()), - ]), - Line::from(""), - Line::from(Span::styled(" Press u to update.", theme::style())), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::PRIMARY)) - .title(Span::styled( - " Update Available ", - theme::style_primary_bold(), - )) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content); - } - State::Applying => { - frame.render_widget( - Spinner::new(self.tick, "Downloading and installing..."), - content, - ); - } - State::Done(version) => { - let lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" ✓ ", theme::style_success()), - Span::styled(format!("Updated to {version}"), theme::style_bold()), - ]), - Line::from(""), - Line::from(Span::styled( - " Restart whk to use the new version.", - theme::style_muted(), - )), - Line::from(""), - Line::from(Span::styled( - " Press Enter to go back.", - theme::style_muted(), - )), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::SUCCESS)) - .title(Span::styled(" Updated ", theme::style_success())) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content); - } - State::Error(msg) => { - let lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" ✗ ", theme::style_danger()), - Span::styled(msg.as_str(), theme::style_dim()), - ]), - Line::from(""), - Line::from(Span::styled( - " Press Enter to go back.", - theme::style_muted(), - )), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::DANGER)) - .title(Span::styled(" Error ", theme::style_danger())) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content); - } - } - } - - fn on_enter(&mut self, _client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx.clone()); - - if self.version == "dev" { - self.state = State::Error("Cannot update a dev build.".into()); - return; - } - - let version = self.version.clone(); - let handle = tokio::spawn(async move { - let result = update::check(&version).await; - let _ = tx.send(Message::UpdateCheck(result)); - }); - self.tasks.push(handle); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Update"] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - match &self.state { - State::Available(_) => vec![("u", "update"), ("esc", "back")], - _ => vec![("esc", "back")], - } - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} diff --git a/apps/cli-rs/src/tui/screens/usage.rs b/apps/cli-rs/src/tui/screens/usage.rs deleted file mode 100644 index 5ee7da5e1..000000000 --- a/apps/cli-rs/src/tui/screens/usage.rs +++ /dev/null @@ -1,489 +0,0 @@ -use crossterm::event::KeyEvent; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Gauge, Padding, Paragraph}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; -use crate::types::UsageInfo; - -use super::{Action, Message, Screen}; - -enum State { - Loading, - Loaded, - Error(String), -} - -pub struct UsageScreen { - state: State, - usage: Option, - tx: Option>, - client: Option, - tasks: Vec>, - tick: usize, -} - -impl Default for UsageScreen { - fn default() -> Self { - Self::new() - } -} - -impl UsageScreen { - pub fn new() -> Self { - Self { - state: State::Loading, - usage: None, - tx: None, - client: None, - tasks: Vec::new(), - tick: 0, - } - } -} - -impl Screen for UsageScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - if keys::is_quit(key) { - return Some(Action::Quit); - } - if keys::is_char(key, 'r') { - self.load_usage(); - } - None - } - - fn handle_message(&mut self, msg: Message) { - if let Message::UsageLoaded(result) = msg { - match result { - Ok(usage) => { - self.usage = Some(usage); - self.state = State::Loaded; - } - Err(e) => { - self.state = State::Error(e.to_string()); - } - } - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - if let State::Loading = &self.state { - frame.render_widget( - Spinner::new(self.tick, "Loading usage info..."), - Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(4), 1), - ); - return; - } - - if let State::Error(msg) = &self.state { - let p = Paragraph::new(Line::from(vec![ - Span::styled(" Error: ", theme::style_danger()), - Span::styled(msg.as_str(), theme::style_dim()), - ])); - frame.render_widget(p, area); - return; - } - - let usage = match &self.usage { - Some(u) => u, - None => return, - }; - - let allowances = allowance_rows(usage); - // Each row is a label line and a gauge, with a blank line between rows. - let allowance_height = if allowances.is_empty() { - 0 - } else { - // Only what the title and the request card leave: on a short - // terminal this card shrinks first, never the request card. - ((allowances.len() * 3 - 1 + 4) as u16).min(area.height.saturating_sub(2 + 8)) - }; - let chunks = Layout::vertical([ - Constraint::Length(2), // Title - Constraint::Length(8), // Usage card - Constraint::Length(allowance_height), // Other allowances - Constraint::Min(0), // Spacer - ]) - .split(area); - - // Plan badge - let plan_style = if usage.plan == "pro" { - Style::default() - .fg(theme::SURFACE) - .bg(theme::PRIMARY) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(theme::TEXT) - .bg(theme::MUTED) - .add_modifier(Modifier::BOLD) - }; - - let title_line = Line::from(vec![ - Span::raw(" "), - Span::styled(format!(" {} ", usage.plan.to_uppercase()), plan_style), - Span::styled(" Plan", theme::style_bold()), - ]); - frame.render_widget(Paragraph::new(title_line), chunks[0]); - - // Usage card - let card = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled(" Request Usage ", theme::style_bold())) - .padding(Padding::new(2, 2, 1, 1)); - - let card_inner = card.inner(chunks[1]); - frame.render_widget(card, chunks[1]); - - if card_inner.height >= 4 { - // Usage stats - let stats = Line::from(vec![ - Span::styled(format!("{}", usage.used), theme::style_primary_bold()), - Span::styled(format!(" / {} requests", usage.limit), theme::style_dim()), - Span::styled( - format!(" ({} remaining)", usage.remaining), - theme::style_muted(), - ), - ]); - frame.render_widget( - Paragraph::new(stats), - Rect::new(card_inner.x, card_inner.y, card_inner.width, 1), - ); - - // Progress bar - let ratio = if usage.limit > 0 { - (usage.used as f64 / usage.limit as f64).min(1.0) - } else { - 0.0 - }; - - let bar_color = bar_color(ratio); - - let gauge = Gauge::default() - .ratio(ratio) - .gauge_style(Style::default().fg(bar_color).bg(theme::SURFACE_RAISED)) - .label(""); - - frame.render_widget( - gauge, - Rect::new(card_inner.x, card_inner.y + 2, card_inner.width, 1), - ); - - // Period end - if let Some(pe) = usage.period_end { - let period_line = Line::from(vec![ - Span::styled("Period ends: ", theme::style_muted()), - Span::styled(crate::util::format::format_timestamp(pe), theme::style()), - ]); - frame.render_widget( - Paragraph::new(period_line), - Rect::new(card_inner.x, card_inner.y + 4, card_inner.width, 1), - ); - } - } - - if !allowances.is_empty() { - let card = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled(" Other Allowances ", theme::style_bold())) - .padding(Padding::new(2, 2, 1, 1)); - let inner = card.inner(chunks[2]); - frame.render_widget(card, chunks[2]); - // A short terminal loses the blank lines, then the gauges, before it - // loses a row: the counts stay readable at one line each. - let rows = allowances.len(); - if (inner.height as usize) < rows { - // Too short even for a line each: every allowance on one line. - if inner.height > 0 { - // Short names and bare counts, so it fits an 80-column terminal. - let summary = allowances - .iter() - .map(|row| format!("{} {}", row.short, row.amount.replace(" / ", "/"))) - .collect::>() - .join(" "); - frame.render_widget( - Paragraph::new(Line::from(Span::styled(summary, theme::style()))), - Rect::new(inner.x, inner.y, inner.width, 1), - ); - } - return; - } - let stride: u16 = if inner.height as usize >= rows * 3 - 1 { - 3 - } else if inner.height as usize >= rows * 2 { - 2 - } else { - 1 - }; - for (i, row) in allowances.iter().enumerate() { - let y = inner.y + (i as u16) * stride; - if y >= inner.y + inner.height { - break; - } - let line = Line::from(vec![ - Span::styled(format!("{:<14}", row.label), theme::style_bold()), - Span::styled(row.amount.clone(), theme::style()), - Span::styled(format!(" {}", row.when), theme::style_muted()), - ]); - frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, inner.width, 1)); - let gauge = Gauge::default() - .ratio(row.ratio) - .gauge_style( - Style::default() - .fg(bar_color(row.ratio)) - .bg(theme::SURFACE_RAISED), - ) - .label(""); - if stride > 1 { - frame.render_widget(gauge, Rect::new(inner.x, y + 1, inner.width, 1)); - } - } - } - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx); - self.client = Some(client.clone()); - self.load_usage(); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Usage"] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - vec![("r", "refresh"), ("esc", "back")] - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} - -impl UsageScreen { - fn load_usage(&mut self) { - self.state = State::Loading; - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.get_usage().await; - let _ = tx.send(Message::UsageLoaded(result)); - }); - self.tasks.push(handle); - } - } -} - -fn bar_color(ratio: f64) -> ratatui::style::Color { - if ratio > 0.9 { - theme::DANGER - } else if ratio > 0.7 { - theme::ACCENT - } else { - theme::SUCCESS - } -} - -/// One allowance beside requests, as the screen draws it. -struct AllowanceRow { - label: &'static str, - /// The name in the one-line summary of a very short terminal. - short: &'static str, - ratio: f64, - amount: String, - when: String, -} - -fn ratio(used: u64, limit: u64) -> f64 { - if limit > 0 { - (used as f64 / limit as f64).min(1.0) - } else if used > 0 { - 1.0 - } else { - 0.0 - } -} - -/// Parsed pages, preview data and AI assists, each with when it frees up; -/// ones the server did not report are left out. -fn allowance_rows(usage: &UsageInfo) -> Vec { - let mut rows = Vec::new(); - if let Some(pages) = &usage.parsed_pages { - rows.push(AllowanceRow { - label: "Parsed pages", - short: "Pages", - ratio: ratio(pages.used, pages.limit), - amount: format!("{} / {} pages", pages.used, pages.limit), - when: match &pages.resets_at { - Some(at) => format!("resets {}", at.display()), - None => "a month from your first parse".to_string(), - }, - }); - } - if let Some(preview) = &usage.preview_data { - let at = preview.resets_at.as_ref().map(|t| t.display()); - rows.push(AllowanceRow { - label: "Preview data", - short: "Preview", - ratio: if preview.over { - 1.0 - } else { - ratio(preview.bytes, preview.limit_bytes) - }, - amount: format!( - "{} / {}", - crate::cli::share::data(preview.bytes), - crate::cli::share::data(preview.limit_bytes) - ), - when: match (preview.over, at) { - (true, Some(at)) => format!("previews paused until {at}"), - (true, None) => "previews paused".to_string(), - (false, Some(at)) => format!("resets {at}"), - (false, None) => String::new(), - }, - }); - } - if let Some(assists) = &usage.ai_assists { - rows.push(AllowanceRow { - label: "AI assists", - short: "AI", - ratio: ratio(assists.used, assists.allowed), - amount: format!("{} / {}", assists.used, assists.allowed), - when: match (&assists.next_at, assists.used >= assists.allowed) { - (Some(next), true) => format!("next one back {}", next.display()), - _ => "over the last 30 days".to_string(), - }, - }); - } - rows -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_screen_draws_the_other_allowances() { - use ratatui::{Terminal, backend::TestBackend}; - let usage: UsageInfo = serde_json::from_str( - r#"{"used":31420,"limit":50000,"remaining":18580,"plan":"pro", - "parsedPages":{"used":87,"limit":100,"creditPages":0,"resetsAt":null}, - "previewData":{"bytes":1024,"limitBytes":1073741824,"over":false}, - "aiAssists":{"used":3,"allowed":20,"nextAt":null}}"#, - ) - .unwrap(); - let mut screen = UsageScreen::new(); - screen.usage = Some(usage); - screen.state = State::Loaded; - let mut terminal = Terminal::new(TestBackend::new(90, 30)).unwrap(); - terminal - .draw(|frame| screen.render(frame, frame.area())) - .unwrap(); - let buffer = terminal.backend().buffer(); - let text: String = (0..buffer.area.height) - .map(|y| { - (0..buffer.area.width) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - + "\n" - }) - .collect(); - assert!(text.contains("Other Allowances"), "{text}"); - assert!( - text.contains("Parsed pages 87 / 100 pages a month from your first parse"), - "{text}" - ); - assert!(text.contains("Preview data 1.0 KB / 1.00 GB"), "{text}"); - assert!( - text.contains("AI assists 3 / 20 over the last 30 days"), - "{text}" - ); - } - - #[test] - fn a_short_terminal_still_shows_every_allowance() { - use ratatui::{Terminal, backend::TestBackend}; - let usage: UsageInfo = serde_json::from_str( - r#"{"used":1,"limit":50,"remaining":49,"plan":"free", - "parsedPages":{"used":2,"limit":20}, - "previewData":{"bytes":0,"limitBytes":1073741824}, - "aiAssists":{"used":0,"allowed":5}}"#, - ) - .unwrap(); - let mut screen = UsageScreen::new(); - screen.usage = Some(usage); - screen.state = State::Loaded; - // Title 2 + request card 8 leaves 7 lines: an inner card of 3. - let mut terminal = Terminal::new(TestBackend::new(90, 17)).unwrap(); - terminal - .draw(|frame| screen.render(frame, frame.area())) - .unwrap(); - let buffer = terminal.backend().buffer(); - let text: String = (0..buffer.area.height) - .map(|y| { - (0..buffer.area.width) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - + "\n" - }) - .collect(); - for label in ["Parsed pages", "Preview data", "AI assists"] { - assert!(text.contains(label), "{label} missing:\n{text}"); - } - - // An 80x20 terminal less the TUI's header and status bar: 16 lines, - // a card of two inner lines, so the allowances share one line. - let mut terminal = Terminal::new(TestBackend::new(80, 16)).unwrap(); - terminal - .draw(|frame| screen.render(frame, frame.area())) - .unwrap(); - let buffer = terminal.backend().buffer(); - let text: String = (0..buffer.area.height) - .map(|y| { - (0..buffer.area.width) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - + "\n" - }) - .collect(); - assert!( - text.contains("1 / 50 requests"), - "request card squeezed:\n{text}" - ); - for part in ["Pages 2/20 pages", "Preview 0 B/1.00 GB", "AI 0/5"] { - assert!(text.contains(part), "{part} missing at 80x16:\n{text}"); - } - } - - #[test] - fn an_older_server_draws_no_allowance_card() { - let usage: UsageInfo = - serde_json::from_str(r#"{"used":1,"limit":50,"remaining":49,"plan":"free"}"#).unwrap(); - assert!(allowance_rows(&usage).is_empty()); - } -} diff --git a/apps/cli-rs/src/tui/theme.rs b/apps/cli-rs/src/tui/theme.rs deleted file mode 100644 index 034b0686f..000000000 --- a/apps/cli-rs/src/tui/theme.rs +++ /dev/null @@ -1,73 +0,0 @@ -use ratatui::style::{Color, Modifier, Style}; - -// Neobrutalism palette — bold, high-contrast -pub const PRIMARY: Color = Color::Rgb(255, 107, 53); // #FF6B35 orange -pub const ACCENT: Color = Color::Rgb(252, 191, 73); // #FCBF49 yellow -pub const SUCCESS: Color = Color::Rgb(46, 196, 182); // #2EC4B6 teal -pub const DANGER: Color = Color::Rgb(231, 29, 54); // #E71D36 red -pub const MUTED: Color = Color::Rgb(107, 114, 128); // #6B7280 gray -pub const SURFACE: Color = Color::Rgb(17, 24, 39); // #111827 dark bg -pub const SURFACE_RAISED: Color = Color::Rgb(31, 41, 55); // #1F2937 slightly lighter -pub const BORDER: Color = Color::Rgb(55, 65, 81); // #374151 border gray -pub const TEXT: Color = Color::Rgb(243, 244, 246); // #F3F4F6 light text -pub const TEXT_DIM: Color = Color::Rgb(156, 163, 175); // #9CA3AF dimmed text - -// Method colors -pub const METHOD_GET: Color = Color::Rgb(16, 185, 129); // #10B981 green -pub const METHOD_POST: Color = Color::Rgb(59, 130, 246); // #3B82F6 blue -pub const METHOD_PUT: Color = Color::Rgb(245, 158, 11); // #F59E0B amber -pub const METHOD_DELETE: Color = Color::Rgb(239, 68, 68); // #EF4444 red -pub const METHOD_PATCH: Color = Color::Rgb(168, 85, 247); // #A855F7 purple - -pub fn method_color(method: &str) -> Color { - match method.to_uppercase().as_str() { - "GET" => METHOD_GET, - "POST" => METHOD_POST, - "PUT" => METHOD_PUT, - "DELETE" => METHOD_DELETE, - "PATCH" => METHOD_PATCH, - "HEAD" => METHOD_GET, - "OPTIONS" => MUTED, - _ => TEXT, - } -} - -pub fn style() -> Style { - Style::default().fg(TEXT) -} - -pub fn style_dim() -> Style { - Style::default().fg(TEXT_DIM) -} - -pub fn style_bold() -> Style { - Style::default().fg(TEXT).add_modifier(Modifier::BOLD) -} - -pub fn style_primary() -> Style { - Style::default().fg(PRIMARY) -} - -pub fn style_primary_bold() -> Style { - Style::default().fg(PRIMARY).add_modifier(Modifier::BOLD) -} - -pub fn style_success() -> Style { - Style::default().fg(SUCCESS) -} - -pub fn style_danger() -> Style { - Style::default().fg(DANGER) -} - -pub fn style_muted() -> Style { - Style::default().fg(MUTED) -} - -pub fn style_surface() -> Style { - Style::default().bg(SURFACE) -} - -pub fn style_highlight() -> Style { - Style::default().bg(Color::Rgb(55, 65, 81)).fg(TEXT) -} diff --git a/apps/cli-rs/src/tui/widgets/header.rs b/apps/cli-rs/src/tui/widgets/header.rs deleted file mode 100644 index 39f20f5cd..000000000 --- a/apps/cli-rs/src/tui/widgets/header.rs +++ /dev/null @@ -1,80 +0,0 @@ -use ratatui::{ - buffer::Buffer, - layout::Rect, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Widget}, -}; - -use crate::tui::theme; - -/// Top header bar with branding and breadcrumb navigation. -pub struct Header<'a> { - breadcrumb: Vec<&'a str>, - auth_status: Option<&'a str>, -} - -impl<'a> Header<'a> { - pub fn new(breadcrumb: Vec<&'a str>) -> Self { - Self { - breadcrumb, - auth_status: None, - } - } - - pub fn auth_status(mut self, email: Option<&'a str>) -> Self { - self.auth_status = email; - self - } -} - -impl Widget for Header<'_> { - fn render(self, area: Rect, buf: &mut Buffer) { - let block = Block::default() - .borders(Borders::BOTTOM) - .border_style(Style::default().fg(theme::BORDER)) - .padding(Padding::horizontal(1)); - - let inner = block.inner(area); - block.render(area, buf); - - if inner.height == 0 { - return; - } - - // Build breadcrumb: "whk" > "Endpoints" > "abc-123" - let mut spans = vec![Span::styled( - " whk ", - Style::default() - .fg(theme::SURFACE) - .bg(theme::PRIMARY) - .add_modifier(Modifier::BOLD), - )]; - - for (i, crumb) in self.breadcrumb.iter().enumerate() { - spans.push(Span::styled(" > ", theme::style_muted())); - let style = if i == self.breadcrumb.len() - 1 { - theme::style_bold() - } else { - theme::style_dim() - }; - spans.push(Span::styled(*crumb, style)); - } - - // Auth status on the right - if let Some(email) = self.auth_status { - let right_text = format!(" {} ", email); - let right_len = right_text.len() as u16; - let left_len: u16 = spans.iter().map(|s| s.width() as u16).sum(); - - if left_len + right_len + 2 < inner.width { - let gap = inner.width.saturating_sub(left_len + right_len); - spans.push(Span::raw(" ".repeat(gap as usize))); - spans.push(Span::styled(format!("● {email}"), theme::style_success())); - } - } - - let line = Line::from(spans); - buf.set_line(inner.x, inner.y, &line, inner.width); - } -} diff --git a/apps/cli-rs/src/tui/widgets/mod.rs b/apps/cli-rs/src/tui/widgets/mod.rs deleted file mode 100644 index 9d67dce2a..000000000 --- a/apps/cli-rs/src/tui/widgets/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod header; -pub mod request_list; -pub mod spinner; -pub mod status_bar; diff --git a/apps/cli-rs/src/tui/widgets/request_list.rs b/apps/cli-rs/src/tui/widgets/request_list.rs deleted file mode 100644 index 243210f04..000000000 --- a/apps/cli-rs/src/tui/widgets/request_list.rs +++ /dev/null @@ -1,194 +0,0 @@ -use ratatui::{ - buffer::Buffer, - layout::Rect, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Padding, StatefulWidget, Widget}, -}; - -use crate::cli::output::sanitize_line; -use crate::tui::theme; -use crate::types::CapturedRequest; -use crate::util::format::format_bytes; - -/// State for the scrollable request list. -pub struct RequestListState { - pub selected: usize, - pub offset: usize, - pub items: Vec, -} - -impl Default for RequestListState { - fn default() -> Self { - Self::new() - } -} - -impl RequestListState { - pub fn new() -> Self { - Self { - selected: 0, - offset: 0, - items: Vec::new(), - } - } - - pub fn select_next(&mut self) { - if !self.items.is_empty() { - self.selected = (self.selected + 1).min(self.items.len() - 1); - } - } - - pub fn select_prev(&mut self) { - self.selected = self.selected.saturating_sub(1); - } - - pub fn selected_item(&self) -> Option<&CapturedRequest> { - self.items.get(self.selected) - } - - pub fn push(&mut self, req: CapturedRequest) { - self.items.insert(0, req); - // Keep selection stable — after insert at front, the previously selected - // item has shifted down by 1, so increment to keep pointing at it - if !self.items.is_empty() && self.items.len() > 1 { - self.selected += 1; - } - } -} - -/// Widget that renders a scrollable list of captured requests. -pub struct RequestList<'a> { - title: &'a str, - show_forward_status: bool, -} - -impl<'a> RequestList<'a> { - pub fn new(title: &'a str) -> Self { - Self { - title, - show_forward_status: false, - } - } - - pub fn show_forward_status(mut self) -> Self { - self.show_forward_status = true; - self - } -} - -impl StatefulWidget for RequestList<'_> { - type State = RequestListState; - - fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let block = Block::default() - .title(Span::styled( - format!(" {} ", self.title), - theme::style_bold(), - )) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .padding(Padding::horizontal(1)); - - let inner = block.inner(area); - block.render(area, buf); - - if inner.height == 0 || inner.width == 0 || state.items.is_empty() { - if state.items.is_empty() { - let msg = Span::styled(" Waiting for requests...", theme::style_muted()); - if inner.height > 0 { - buf.set_span(inner.x, inner.y, &msg, inner.width); - } - } - return; - } - - let visible_height = inner.height as usize; - - // Adjust scroll offset to keep selection visible - if state.selected < state.offset { - state.offset = state.selected; - } - if state.selected >= state.offset + visible_height { - state.offset = state.selected - visible_height + 1; - } - - for (i, idx) in (state.offset..state.items.len()) - .take(visible_height) - .enumerate() - { - let req = &state.items[idx]; - let is_selected = idx == state.selected; - let y = inner.y + i as u16; - - // Time - let time = chrono::DateTime::from_timestamp_millis(req.received_at) - .map(|dt| { - dt.with_timezone(&chrono::Local) - .format("%H:%M:%S") - .to_string() - }) - .unwrap_or_else(|| "??:??:??".to_string()); - - // Method with color - let method_style = Style::default().fg(theme::method_color(&req.method)); - let method = format!("{:<7}", req.method); - - // Path (truncated to fit) - let size_str = format_bytes(req.size); - - let bg = if is_selected { - theme::SURFACE_RAISED - } else { - theme::SURFACE - }; - - // Render row - let indicator = if is_selected { "▸ " } else { " " }; - let indicator_style = if is_selected { - Style::default().fg(theme::PRIMARY).bg(bg) - } else { - Style::default().fg(theme::SURFACE).bg(bg) - }; - - let mut spans = vec![ - Span::styled(indicator, indicator_style), - Span::styled(&time, Style::default().fg(theme::TEXT_DIM).bg(bg)), - Span::styled(" ", Style::default().bg(bg)), - Span::styled(&method, method_style.bg(bg)), - Span::styled( - sanitize_line(&req.path), - Style::default().fg(theme::TEXT).bg(bg), - ), - ]; - // An email's path is its recipient; its subject says which one it is. - if req.is_email() { - let subject = req - .email_subject() - .unwrap_or_else(|| "(no subject)".to_string()); - spans.push(Span::styled(" ", Style::default().bg(bg))); - spans.push(Span::styled( - sanitize_line(&subject), - theme::style_bold().bg(bg), - )); - } - spans.push(Span::styled(" ", Style::default().bg(bg))); - spans.push(Span::styled( - &size_str, - Style::default().fg(theme::MUTED).bg(bg), - )); - let line = Line::from(spans); - - buf.set_line(inner.x, y, &line, inner.width); - - // Fill remaining width with bg - let rendered_width: u16 = line.width() as u16; - if rendered_width < inner.width && inner.width > 0 { - let end = inner.x + inner.width.saturating_sub(1); - for x in (inner.x + rendered_width)..=end { - buf[(x, y)].set_bg(bg); - } - } - } - } -} diff --git a/apps/cli-rs/src/tui/widgets/spinner.rs b/apps/cli-rs/src/tui/widgets/spinner.rs deleted file mode 100644 index 0b9f8c4b7..000000000 --- a/apps/cli-rs/src/tui/widgets/spinner.rs +++ /dev/null @@ -1,36 +0,0 @@ -use ratatui::{buffer::Buffer, layout::Rect, style::Style, text::Span, widgets::Widget}; - -use crate::tui::theme; - -const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - -pub struct Spinner<'a> { - tick: usize, - label: &'a str, - style: Style, -} - -impl<'a> Spinner<'a> { - pub fn new(tick: usize, label: &'a str) -> Self { - Self { - tick, - label, - style: theme::style_primary(), - } - } -} - -impl Widget for Spinner<'_> { - fn render(self, area: Rect, buf: &mut Buffer) { - if area.height == 0 || area.width < 4 { - return; - } - - let frame = FRAMES[self.tick % FRAMES.len()]; - let spinner = Span::styled(frame, self.style); - let label = Span::styled(format!(" {}", self.label), theme::style_dim()); - - buf.set_span(area.x, area.y, &spinner, 2); - buf.set_span(area.x + 2, area.y, &label, area.width.saturating_sub(2)); - } -} diff --git a/apps/cli-rs/src/tui/widgets/status_bar.rs b/apps/cli-rs/src/tui/widgets/status_bar.rs deleted file mode 100644 index 1a40d982c..000000000 --- a/apps/cli-rs/src/tui/widgets/status_bar.rs +++ /dev/null @@ -1,68 +0,0 @@ -use ratatui::{ - buffer::Buffer, - layout::Rect, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Widget}, -}; - -use crate::tui::theme; - -/// Bottom status bar showing keybinding hints. -pub struct StatusBar<'a> { - keys: Vec<(&'a str, &'a str)>, - right: Option<&'a str>, -} - -impl<'a> StatusBar<'a> { - pub fn new(keys: Vec<(&'a str, &'a str)>) -> Self { - Self { keys, right: None } - } - - pub fn right(mut self, text: &'a str) -> Self { - self.right = Some(text); - self - } -} - -impl Widget for StatusBar<'_> { - fn render(self, area: Rect, buf: &mut Buffer) { - let block = Block::default() - .borders(Borders::TOP) - .border_style(Style::default().fg(theme::BORDER)) - .padding(Padding::horizontal(1)); - - let inner = block.inner(area); - block.render(area, buf); - - if inner.height == 0 { - return; - } - - let mut spans: Vec = Vec::new(); - - for (i, (key, desc)) in self.keys.iter().enumerate() { - if i > 0 { - spans.push(Span::styled(" ", theme::style_muted())); - } - spans.push(Span::styled( - format!(" {key} "), - Style::default().fg(theme::SURFACE).bg(theme::MUTED), - )); - spans.push(Span::styled(format!(" {desc}"), theme::style_dim())); - } - - if let Some(right) = self.right { - let left_len: u16 = spans.iter().map(|s| s.width() as u16).sum(); - let right_len = right.len() as u16; - let gap = inner.width.saturating_sub(left_len + right_len + 1); - if gap > 0 { - spans.push(Span::raw(" ".repeat(gap as usize))); - spans.push(Span::styled(right, theme::style_muted())); - } - } - - let line = Line::from(spans); - buf.set_line(inner.x, inner.y, &line, inner.width); - } -} diff --git a/apps/cli-rs/src/types/mod.rs b/apps/cli-rs/src/types/mod.rs index c79eedf58..96d4459d8 100644 --- a/apps/cli-rs/src/types/mod.rs +++ b/apps/cli-rs/src/types/mod.rs @@ -389,9 +389,13 @@ pub struct RequestList { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PaginatedRequestList { + /// The route answers `{ items, cursor, hasMore }`. + #[serde(alias = "items")] pub requests: Vec, - #[serde(rename = "nextCursor", default)] + #[serde(rename = "nextCursor", alias = "cursor", default)] pub next_cursor: Option, + #[serde(rename = "hasMore", default)] + pub has_more: Option, } /// One row from `GET /api/search/requests`. Search rows carry the endpoint @@ -802,10 +806,44 @@ pub struct RenderedEmail { // SSE events // --------------------------------------------------------------------------- +/// A request from the live stream, and the JSON it came in, which +/// `whk listen --json` prints as is. +#[derive(Debug, Clone)] +pub struct Streamed { + pub request: CapturedRequest, + pub raw: serde_json::Value, +} + +impl std::ops::Deref for Streamed { + type Target = CapturedRequest; + fn deref(&self) -> &CapturedRequest { + &self.request + } +} + +impl Streamed { + /// What `whk listen --json` prints: the stream's JSON, plus the fields + /// the CLI always printed (`id` for the stream's `_id`, the signature + /// fields as null), so the output only ever gains fields. + pub fn json(&self) -> serde_json::Value { + let mut out = self.raw.clone(); + if let Ok(typed) = serde_json::to_value(&self.request) { + crate::api::client::fill_missing(&mut out, &typed); + } + out + } + + pub fn parse(data: &str) -> Option { + let raw: serde_json::Value = serde_json::from_str(data).ok()?; + let request = serde_json::from_value(raw.clone()).ok()?; + Some(Self { request, raw }) + } +} + #[derive(Debug, Clone)] pub enum SseEvent { Connected, - Request(Box), + Request(Box), EndpointDeleted, /// The server rotated the stream (it closes every connection after 30 minutes). /// The stream layer reconnects transparently; consumers can ignore this. diff --git a/apps/cli-rs/tests/integration.rs b/apps/cli-rs/tests/integration.rs index 1ed416d4f..2e1c4a7df 100644 --- a/apps/cli-rs/tests/integration.rs +++ b/apps/cli-rs/tests/integration.rs @@ -124,7 +124,7 @@ async fn test_endpoint_update_mock_response() { .expect("update failed"); assert_eq!(updated.name.as_deref(), Some("mock-test-updated")); assert!(updated.mock_response.is_some()); - assert_eq!(updated.mock_response.unwrap().status, 201); + assert_eq!(updated.mock_response.as_ref().unwrap().status, 201); // Cleanup client.delete_endpoint(&ep.slug).await.ok(); diff --git a/apps/web/app/api/stream/[slug]/route.ts b/apps/web/app/api/stream/[slug]/route.ts index d35f09920..b6e6e8bb8 100644 --- a/apps/web/app/api/stream/[slug]/route.ts +++ b/apps/web/app/api/stream/[slug]/route.ts @@ -3,16 +3,22 @@ import { serverEnv } from "@/lib/env"; import { resolveEndpointAccess } from "@/lib/supabase/teams"; import type { Database } from "@/lib/supabase/database"; import { + getStreamRequestsByIds, listRequestsAfterCursorForEndpointByUser, - type RequestCursor, + listStreamRequestIdsSince, type RequestRecord, } from "@/lib/supabase/requests"; +import { createStreamDrainer } from "@/lib/stream/drainer"; import { createClient, type RealtimeChannel } from "@supabase/supabase-js"; import { reportError } from "@/lib/observability"; export const dynamic = "force-dynamic"; const KEEPALIVE_INTERVAL_MS = 30_000; +// How often the stream looks back for a capture that committed behind its +// cursor and whose signal Realtime dropped: well inside a default 30 s +// `whk wait`. It reads ids only (see lib/stream/drainer.ts). +const LOOKBACK_INTERVAL_MS = 10_000; const MAX_CONNECTION_DURATION_MS = 30 * 60 * 1000; const BACKLOG_PAGE_SIZE = 100; @@ -103,22 +109,22 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug const abortSignal = request.signal; const supabase = createRealtimeAdminClient(); - const sentIds = new Set(); const afterTimestamp = since ?? connectionStart; - // Last streamed row; reads resume strictly after it. - let cursor: RequestCursor | null = null; let keepaliveTimer: ReturnType | null = null; + let lookbackTimer: ReturnType | null = null; let durationTimer: ReturnType | null = null; let closed = false; let channel: RealtimeChannel | null = null; - let draining = false; - let drainAgain = false; const cleanup = () => { if (keepaliveTimer) { clearInterval(keepaliveTimer); keepaliveTimer = null; } + if (lookbackTimer) { + clearInterval(lookbackTimer); + lookbackTimer = null; + } if (durationTimer) { clearTimeout(durationTimer); durationTimer = null; @@ -142,11 +148,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug }; const enqueueRequest = (record: RequestRecord) => { - if (closed || sentIds.has(record.id)) { + if (closed) { return; } - - sentIds.add(record.id); controller.enqueue( encoder.encode(`event: request\ndata: ${JSON.stringify(toStreamRequest(record))}\n\n`) ); @@ -163,47 +167,31 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug closeStream(); }; - // Broadcast signals carry ids only, so every signal (and the initial - // backlog) reads the rows after the last one streamed. Signals that land - // while a read is running fold into one more read. - const drain = async () => { - if (draining) { - drainAgain = true; - return; - } - draining = true; - try { - do { - drainAgain = false; - while (!closed) { - const page = await listRequestsAfterCursorForEndpointByUser({ - userId: auth.userId, - slug, - after: afterTimestamp, - cursor, - limit: BACKLOG_PAGE_SIZE, - }); - if (page === null) { - sendEndpointDeleted(); - return; - } - for (const record of page.records) { - enqueueRequest(record); - } - cursor = page.cursor; - if (page.records.length < BACKLOG_PAGE_SIZE) break; - } - } while (drainAgain && !closed); - } catch (error) { - reportError("Failed to read SSE stream backlog", error); - } finally { - draining = false; - } - }; + // What to send and when: see lib/stream/drainer.ts. + const drainer = createStreamDrainer({ + afterTimestamp, + readAfter: ({ after, cursor, limit }) => + listRequestsAfterCursorForEndpointByUser({ + userId: auth.userId, + slug, + after, + cursor, + limit, + }), + readByIds: (ids) => getStreamRequestsByIds(endpoint.id, ids), + readIdsSince: (from, limit) => listStreamRequestIdsSince(endpoint.id, from, limit), + send: enqueueRequest, + gone: sendEndpointDeleted, + isClosed: () => closed, + onError: (error) => reportError("Failed to read SSE stream backlog", error), + pageSize: BACKLOG_PAGE_SIZE, + recheckIntervalMs: LOOKBACK_INTERVAL_MS, + }); abortSignal.addEventListener("abort", closeStream); - // The keepalive also catches up, in case a signal was dropped. + // The keepalive keeps proxies from closing an idle stream; the + // look-back timer catches up in case Realtime dropped a signal. keepaliveTimer = setInterval(() => { if (closed || abortSignal.aborted) return; try { @@ -212,8 +200,11 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug closeStream(); return; } - void drain(); }, KEEPALIVE_INTERVAL_MS); + lookbackTimer = setInterval(() => { + if (closed || abortSignal.aborted) return; + drainer.recheck(); + }, LOOKBACK_INTERVAL_MS); durationTimer = setTimeout( () => { @@ -236,9 +227,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug // endpoint was checked above. channel = supabase .channel(`endpoint:${endpoint.id}`, { config: { private: true } }) - .on("broadcast", { event: "*" }, ({ event }) => { + .on("broadcast", { event: "*" }, ({ event, payload }) => { if (event === "request_created") { - void drain(); + const id = (payload as { request_id?: unknown } | undefined)?.request_id; + drainer.signal(typeof id === "string" ? id : null); } else if (event === "endpoint_deleted") { sendEndpointDeleted(); } @@ -246,7 +238,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug try { await waitForSubscribed(channel); - await drain(); + await drainer.drain(); } catch (error) { reportError("Failed to initialize SSE stream", error); closeStream(); diff --git a/apps/web/changelog/web/cli-for-agents-docs.json b/apps/web/changelog/web/cli-for-agents-docs.json new file mode 100644 index 000000000..b37447c22 --- /dev/null +++ b/apps/web/changelog/web/cli-for-agents-docs.json @@ -0,0 +1,9 @@ +{ + "title": "CLI docs for scripts and agents, and steadier request streams and pages", + "items": [ + "The CLI docs describe whk's JSON output, its exit statuses and error codes, whk wait and whk api", + "The interactive TUI is gone from the docs: whk prints its commands when run on its own", + "A live stream (whk listen, whk wait) no longer misses a request that finished saving after a newer one had already been sent", + "Paging through requests (API, SDKs, CLI, MCP) no longer skips or repeats a request when others arrive or are deleted in between" + ] +} diff --git a/apps/web/lib/stream/drainer.test.ts b/apps/web/lib/stream/drainer.test.ts new file mode 100644 index 000000000..ba5217bfe --- /dev/null +++ b/apps/web/lib/stream/drainer.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, it } from "vitest"; +import type { RequestCursor, RequestRecord } from "@/lib/supabase/requests"; +import { createStreamDrainer, type StreamDrainerDeps } from "./drainer"; + +/** A fake endpoint: rows as the database holds them, read like the real queries. */ +function fakeStore() { + const rows: { id: string; at: number }[] = []; + const iso = (ms: number) => new Date(ms).toISOString(); + const record = (row: { id: string; at: number }) => + ({ id: row.id, receivedAt: row.at, path: `/${row.id}` }) as unknown as RequestRecord; + const after = (row: { id: string; at: number }, cursor: RequestCursor) => { + const at = Date.parse(cursor.receivedAt); + return row.at > at || (row.at === at && row.id > cursor.id); + }; + let failByIds = 0; + let gone = false; + const calls = { byIds: [] as string[][], window: 0, forward: 0 }; + return { + insert(id: string, at: number) { + rows.push({ id, at }); + }, + failNextByIds() { + failByIds += 1; + }, + deleteEndpoint() { + gone = true; + }, + calls, + deps(overrides: Partial = {}) { + const sent: string[] = []; + const scheduled: (() => void)[] = []; + let closed = false; + const errors: unknown[] = []; + const deps: StreamDrainerDeps = { + afterTimestamp: 0, + pageSize: 2, + lateWindowMs: 60_000, + readAfter: async ({ after: since, cursor, limit }) => { + calls.forward += 1; + if (gone) return null; + const page = rows + .filter((row) => (cursor ? after(row, cursor) : row.at > since)) + .sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : 1)) + .slice(0, limit); + const last = page[page.length - 1]; + return { + records: page.map(record), + cursor: last ? { receivedAt: iso(last.at), id: last.id } : cursor, + }; + }, + readByIds: async (ids) => { + calls.byIds.push(ids); + if (failByIds > 0) { + failByIds -= 1; + throw new Error("database busy"); + } + return rows.filter((row) => ids.includes(row.id)).map(record); + }, + readIdsSince: async (from, limit) => { + calls.window += 1; + return rows + .filter((row) => row.at > from) + .sort((a, b) => b.at - a.at) + .slice(0, limit) + .map((row) => row.id); + }, + schedule: (run) => scheduled.push(run), + send: (r) => sent.push(r.id), + gone: () => { + closed = true; + sent.push(""); + }, + isClosed: () => closed, + onError: (error) => errors.push(error), + ...overrides, + }; + return { deps, sent, errors, scheduled }; + }, + }; +} + +describe("createStreamDrainer", () => { + it("sends the backlog in order, page by page, once each", async () => { + const store = fakeStore(); + for (const [i, id] of ["a", "b", "c", "d", "e"].entries()) store.insert(id, 1_000 + i); + const { deps, sent } = store.deps(); + const drainer = createStreamDrainer(deps); + await drainer.drain(); + await drainer.drain(); + expect(sent).toEqual(["a", "b", "c", "d", "e"]); + }); + + it("sends requests that arrive while it reads, without repeats", async () => { + const store = fakeStore(); + for (const [i, id] of ["a", "b", "c"].entries()) store.insert(id, 1_000 + i); + const { deps, sent } = store.deps(); + let reads = 0; + const readAfter = deps.readAfter; + deps.readAfter = async (input) => { + reads += 1; + // A request lands between the first and second page. + if (reads === 2) store.insert("new", 2_000); + return readAfter(input); + }; + const drainer = createStreamDrainer(deps); + await drainer.drain(); + expect(sent).toEqual(["a", "b", "c", "new"]); + }); + + it("sends a request that commits behind the cursor when its signal comes", async () => { + const store = fakeStore(); + store.insert("newer", 2_000); + const { deps, sent } = store.deps(); + const drainer = createStreamDrainer(deps); + await drainer.drain(); + // Stamped before "newer", committed after it. + store.insert("late", 1_500); + drainer.signal("late"); + await drainer.drain(); + expect(sent).toEqual(["newer", "late"]); + }); + + it("finds a late request whose signal was lost on the next recheck", async () => { + const store = fakeStore(); + store.insert("newer", 2_000); + const { deps, sent } = store.deps(); + const drainer = createStreamDrainer(deps); + await drainer.drain(); + store.insert("late", 1_500); + // No signal arrives for it; a plain read cannot see it behind the cursor. + await drainer.drain(); + expect(sent).toEqual(["newer"]); + drainer.recheck(); + await drainer.drain(); + expect(sent).toEqual(["newer", "late"]); + }); + + it("does not lose a signalled id whose fetch fails", async () => { + const store = fakeStore(); + store.insert("newer", 2_000); + const { deps, sent, errors } = store.deps(); + const drainer = createStreamDrainer(deps); + await drainer.drain(); + store.insert("late", 1_500); + store.failNextByIds(); + drainer.signal("late"); + await drainer.drain(); + // The failed fetch keeps the id and asks for a look-back, which finds it. + expect(errors).toHaveLength(1); + expect(sent).toEqual(["newer", "late"]); + }); + + it("fetches a kept id on the next read when the look-back is not due", async () => { + const store = fakeStore(); + store.insert("newer", 2_000); + const { deps, sent } = store.deps({ now: () => 0 }); + const drainer = createStreamDrainer(deps); + await drainer.drain(); + drainer.recheck(); // uses up this interval's look-back + await drainer.drain(); + store.insert("late", 1_500); + // signal() starts a read and drain() folds a second pass into it: both + // fail, and the id is kept (two of three attempts). + store.failNextByIds(); + store.failNextByIds(); + drainer.signal("late"); + await drainer.drain(); + expect(sent).toEqual(["newer"]); + await drainer.drain(); + expect(sent).toEqual(["newer", "late"]); + }); + + it("falls back to the late window when signals overflow", async () => { + const store = fakeStore(); + store.insert("newer", 2_000); + const { deps, sent } = store.deps({ maxPendingSignals: 1 }); + const drainer = createStreamDrainer(deps); + await drainer.drain(); + store.insert("late-1", 1_500); + store.insert("late-2", 1_600); + // The second id does not fit; the overflow forces a late-window read. + drainer.signal("late-1"); + drainer.signal("late-2"); + await drainer.drain(); + expect(sent.sort()).toEqual(["late-1", "late-2", "newer"]); + }); + + it("looks back by id and fetches only the rows not sent yet", async () => { + const store = fakeStore(); + store.insert("a", 1_000); + store.insert("b", 2_000); + const { deps, sent } = store.deps(); + const drainer = createStreamDrainer(deps); + await drainer.drain(); + store.insert("late", 1_500); + store.calls.byIds.length = 0; + drainer.recheck(); + await drainer.drain(); + expect(sent).toEqual(["a", "b", "late"]); + expect(store.calls.byIds).toEqual([["late"]]); + }); + + it("looks back at most once an interval for overflowing signals", async () => { + const store = fakeStore(); + store.insert("a", 1_000); + let clock = 0; + const { deps } = store.deps({ maxPendingSignals: 0, now: () => clock }); + const drainer = createStreamDrainer(deps); + await drainer.drain(); + for (let i = 0; i < 5; i++) { + drainer.signal(`x${i}`); + await drainer.drain(); + } + expect(store.calls.window).toBe(1); + clock += 30_000; + drainer.signal("y"); + await drainer.drain(); + expect(store.calls.window).toBe(2); + }); + + it("fetches signalled ids in chunks", async () => { + const store = fakeStore(); + store.insert("newer", 9_000); + const { deps, sent } = store.deps({ idChunkSize: 100 }); + const drainer = createStreamDrainer(deps); + await drainer.drain(); + for (let i = 0; i < 250; i++) { + store.insert(`late-${String(i).padStart(3, "0")}`, 1_000 + i); + drainer.signal(`late-${String(i).padStart(3, "0")}`); + } + await drainer.drain(); + expect(store.calls.byIds.map((chunk) => chunk.length)).toEqual([100, 100, 50]); + expect(sent).toHaveLength(251); + }); + + it("gives up on an id after a few failed fetches, and still looks back", async () => { + const store = fakeStore(); + store.insert("newer", 2_000); + const { deps, errors, scheduled } = store.deps({ maxFetchAttempts: 2 }); + const drainer = createStreamDrainer(deps); + await drainer.drain(); + store.failNextByIds(); + store.failNextByIds(); + drainer.signal("missing"); + await drainer.drain(); + await drainer.drain(); + expect(errors).toHaveLength(2); + // A failure schedules a retry instead of waiting for the next signal. + expect(scheduled.length).toBeGreaterThan(0); + store.calls.byIds.length = 0; + await drainer.drain(); + expect(store.calls.byIds).toEqual([]); + // A failed fetch asked for a look-back too. + expect(store.calls.window).toBeGreaterThan(0); + }); + + it("finds a new request whose signal was lost on an idle stream, reading ids only", async () => { + const store = fakeStore(); + store.insert("a", 1_000); + const { deps, sent } = store.deps(); + const drainer = createStreamDrainer(deps); + await drainer.drain(); + // Ahead of the cursor, but no signal came and none follows. + store.insert("b", 2_000); + store.calls.forward = 0; + drainer.recheck(); + await drainer.drain().catch(() => undefined); + expect(sent).toEqual(["a", "b"]); + }); + + it("a recheck alone does not read forward", async () => { + const store = fakeStore(); + store.insert("a", 1_000); + const { deps } = store.deps(); + const drainer = createStreamDrainer(deps); + await drainer.drain(); + store.calls.forward = 0; + drainer.recheck(); + // Let the recheck's pass finish without starting another. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(store.calls.forward).toBe(0); + expect(store.calls.window).toBeGreaterThan(0); + }); + + it("backs off while reads keep failing, and resets after a clean pass", async () => { + const store = fakeStore(); + store.insert("a", 1_000); + const delays: number[] = []; + const retries: (() => void)[] = []; + let failing = true; + const { deps } = store.deps({ + retryDelayMs: 1_000, + maxRetryDelayMs: 4_000, + schedule: (run, ms) => { + delays.push(ms); + retries.push(run); + }, + }); + const readAfter = deps.readAfter; + deps.readAfter = async (input) => { + if (failing) throw new Error("down"); + return readAfter(input); + }; + const drainer = createStreamDrainer(deps); + await drainer.drain(); + await drainer.drain(); // one retry pending at a time + expect(delays).toEqual([1_000]); + // Each retry that fails again waits twice as long, up to the maximum. + for (let i = 0; i < 3; i++) { + retries.shift()!(); + await drainer.drain(); + } + expect(delays).toEqual([1_000, 2_000, 4_000, 4_000]); + // A clean pass resets it. + failing = false; + retries.shift()!(); + await drainer.drain(); + failing = true; + await drainer.drain(); + expect(delays.at(-1)).toBe(1_000); + }); + + it("reads past the look-back's cap when many signals were lost", async () => { + const store = fakeStore(); + store.insert("first", 1_000); + const { deps, sent } = store.deps({ maxWindowIds: 5 }); + const drainer = createStreamDrainer(deps); + await drainer.drain(); + // Twelve requests after the cursor, and not one signal for them. + const later = Array.from({ length: 12 }, (_, i) => `r${String(i).padStart(2, "0")}`); + later.forEach((id, i) => store.insert(id, 2_000 + i)); + // The timer's recheck alone: no signal, no drain() that reads forward. + drainer.recheck(); + for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0)); + expect([...sent].sort()).toEqual(["first", ...later].sort()); + }); + + it("leaves out rows from before the start", async () => { + const store = fakeStore(); + store.insert("before", 500); + store.insert("after", 1_500); + const { deps, sent } = store.deps({ afterTimestamp: 1_000 }); + const drainer = createStreamDrainer(deps); + drainer.signal("before"); + await drainer.drain(); + drainer.recheck(); + await drainer.drain(); + expect(sent).toEqual(["after"]); + }); + + it("stops when the endpoint is gone", async () => { + const store = fakeStore(); + store.insert("a", 1_000); + store.deleteEndpoint(); + const { deps, sent } = store.deps(); + await createStreamDrainer(deps).drain(); + expect(sent).toEqual([""]); + }); +}); diff --git a/apps/web/lib/stream/drainer.ts b/apps/web/lib/stream/drainer.ts new file mode 100644 index 000000000..dbf4e272a --- /dev/null +++ b/apps/web/lib/stream/drainer.ts @@ -0,0 +1,265 @@ +import type { RequestCursor, RequestRecord } from "@/lib/supabase/requests"; + +/** + * What an endpoint's live stream (`/api/stream/{slug}`) sends, and when. + * + * Realtime signals carry request ids only, so each signal triggers a read: + * + * 1. Forward, in (received_at, id) order, after the last row read. This is + * the backlog on connect and every new request after it. + * 2. The signalled ids that read did not send. The receiver stamps + * received_at before the capture commits, so a capture that waited (for a + * capture slot, an email for its attachment upload) can commit after a + * newer one has gone out, behind the cursor where step 1 never looks. Ids + * are fetched in chunks and stay pending until their fetch succeeds, for + * a few tries. + * 3. A look-back: the ids received from `lateWindowMs` before the cursor + * on (from the start before anything was sent), newest first, and the + * full rows of those not sent yet. It finds a capture whose signal + * Realtime dropped (at its rate limit) or this stream could not hold, + * behind the cursor or ahead of it; when it finds one (or reads as many + * ids as it may), the forward read runs too, uncapped, so a larger burst + * of lost signals is read in full. `recheck()` (the stream's look-back + * timer, every 10 s) runs only this step, which reads ids, so an idle + * stream costs little; an overflow or a failure asks for it at most once + * per `recheckIntervalMs`. + * + * Two cases rely on the signal alone, because covering them would put a + * full read of the window on every open stream every 10 s: a capture stamped + * further back than the window (an email whose MX delivery was retried), and + * one behind the cursor while more than `maxWindowIds` newer captures arrived + * within the window. + * + * Every row is sent once (by id), and only rows received from + * `afterTimestamp` on (the stream's `since`, or its start). A failed read is + * tried again after `retryDelayMs`, doubling up to `maxRetryDelayMs` while + * reads keep failing. + */ +export interface StreamDrainerDeps { + afterTimestamp: number; + /** Rows after `cursor` (or after `after` without one), oldest first; null when the endpoint is gone. */ + readAfter(input: { + after: number; + cursor: RequestCursor | null; + limit: number; + }): Promise<{ records: RequestRecord[]; cursor: RequestCursor | null } | null>; + /** The endpoint's retained rows among `ids`. */ + readByIds(ids: string[]): Promise; + /** Ids received after `from` (ms), newest first, at most `limit`. */ + readIdsSince(from: number, limit: number): Promise; + send(record: RequestRecord): void; + /** The endpoint was deleted. */ + gone(): void; + isClosed(): boolean; + onError(error: unknown): void; + now?: () => number; + schedule?: (run: () => void, ms: number) => void; + pageSize?: number; + lateWindowMs?: number; + recheckIntervalMs?: number; + retryDelayMs?: number; + maxRetryDelayMs?: number; + maxPendingSignals?: number; + idChunkSize?: number; + maxWindowIds?: number; + maxFetchAttempts?: number; +} + +export interface StreamDrainer { + /** A `request_created` signal (with its id, when it carried one). */ + signal(id: string | null): void; + /** Read again with the look-back (the stream's look-back timer). */ + recheck(): void; + /** Read now; resolves when this read (and any it folded in) is done. */ + drain(): Promise; +} + +export function createStreamDrainer(deps: StreamDrainerDeps): StreamDrainer { + const pageSize = deps.pageSize ?? 100; + const lateWindowMs = deps.lateWindowMs ?? 60_000; + const recheckIntervalMs = deps.recheckIntervalMs ?? 30_000; + const retryDelayMs = deps.retryDelayMs ?? 2_000; + const maxRetryDelayMs = deps.maxRetryDelayMs ?? 30_000; + const maxPending = deps.maxPendingSignals ?? 1000; + const idChunkSize = deps.idChunkSize ?? 100; + const maxWindowIds = deps.maxWindowIds ?? 5_000; + const maxAttempts = deps.maxFetchAttempts ?? 3; + const now = deps.now ?? Date.now; + const schedule = deps.schedule ?? ((run, ms) => void setTimeout(run, ms)); + + // Sent ids and their received_at, pruned to the look-back window. + const sent = new Map(); + // Ids to fetch and how many fetches of them failed. + const pending = new Map(); + let cursor: RequestCursor | null = null; + // A recheck()'s look-back always runs; an overflow's or a failure's at + // most once per recheckIntervalMs. + let recheckForced = false; + let recheckWanted = false; + let lastRecheck = Number.NEGATIVE_INFINITY; + let retryScheduled = false; + let retryDelay = retryDelayMs; + let passFailed = false; + // A signal or a drain() wants the forward read; a recheck() only looks back. + let forwardWanted = false; + let running: Promise | null = null; + let again = false; + + const emit = (record: RequestRecord) => { + // receivedAt is whole milliseconds and the reads compare microseconds, + // so a row in the same millisecond as `afterTimestamp` is kept. + if (deps.isClosed() || sent.has(record.id) || record.receivedAt < deps.afterTimestamp) { + return; + } + sent.set(record.id, record.receivedAt); + deps.send(record); + }; + + const failed = (error: unknown) => { + recheckWanted = true; + passFailed = true; + deps.onError(error); + if (!retryScheduled && !deps.isClosed()) { + retryScheduled = true; + const delay = retryDelay; + retryDelay = Math.min(retryDelay * 2, maxRetryDelayMs); + schedule(() => { + retryScheduled = false; + void drain(); + }, delay); + } + }; + + /** Forward from the cursor to the end; false when the endpoint is gone. */ + const readForward = async (): Promise => { + while (!deps.isClosed()) { + const page = await deps.readAfter({ after: deps.afterTimestamp, cursor, limit: pageSize }); + if (page === null) { + deps.gone(); + return false; + } + for (const record of page.records) { + emit(record); + } + cursor = page.cursor; + if (page.records.length < pageSize) break; + } + return true; + }; + + /** Fetches the pending ids not sent yet, chunk by chunk. */ + const fetchPending = async () => { + for (const id of pending.keys()) { + if (sent.has(id)) pending.delete(id); + } + const ids = [...pending.keys()]; + for (let i = 0; i < ids.length && !deps.isClosed(); i += idChunkSize) { + const chunk = ids.slice(i, i + idChunkSize); + try { + const records = await deps.readByIds(chunk); + for (const id of chunk) pending.delete(id); + records.sort((a, b) => a.receivedAt - b.receivedAt).forEach(emit); + } catch (error) { + // Tried again next time, a few times; the look-back covers the rest. + for (const id of chunk) { + const attempts = (pending.get(id) ?? 0) + 1; + if (attempts >= maxAttempts) pending.delete(id); + else pending.set(id, attempts); + } + failed(error); + } + } + }; + + /** The look-back: ids from the window on, not sent yet. */ + const lookBack = async () => { + const at = now(); + const due = recheckForced || (recheckWanted && at - lastRecheck >= recheckIntervalMs); + if (!due || deps.isClosed()) return; + recheckForced = false; + recheckWanted = false; + lastRecheck = at; + const from = cursor + ? Math.max(deps.afterTimestamp, Date.parse(cursor.receivedAt) - lateWindowMs) + : deps.afterTimestamp; + // Rows older than the window are never read again but by a signal for + // a new id, so their ids need not be kept; pruned first, so a read that + // keeps failing cannot let them pile up. + for (const [id, receivedAt] of sent) { + if (receivedAt < from) sent.delete(id); + } + const ids = await deps.readIdsSince(from, maxWindowIds); + let unsent = false; + for (const id of ids) { + if (!sent.has(id) && !pending.has(id)) { + pending.set(id, 0); + unsent = true; + } + } + // Signals were lost (or more arrived than the look-back reads): the + // forward read pages through everything after the cursor, uncapped, + // and moves it, so a burst larger than the cap is read in full too. + if (unsent || ids.length >= maxWindowIds) { + forwardWanted = true; + again = true; + } + await fetchPending(); + }; + + const run = async (): Promise => { + do { + again = false; + passFailed = false; + if (forwardWanted) { + forwardWanted = false; + try { + if (!(await readForward())) return; + } catch (error) { + forwardWanted = true; + failed(error); + } + } + await fetchPending(); + try { + await lookBack(); + } catch (error) { + failed(error); + } + if (!passFailed) retryDelay = retryDelayMs; + } while (again && !deps.isClosed()); + }; + + const start = (): Promise => { + if (running) { + again = true; + return running; + } + running = run().finally(() => { + running = null; + }); + return running; + }; + + const drain = (): Promise => { + forwardWanted = true; + return start(); + }; + + return { + signal(id) { + if (id && (pending.has(id) || pending.size < maxPending)) { + if (!sent.has(id) && !pending.has(id)) pending.set(id, 0); + } else { + // An id that cannot be held (or a signal without one) is found by + // the look-back instead. + recheckWanted = true; + } + void drain(); + }, + recheck() { + recheckForced = true; + void start(); + }, + drain, + }; +} diff --git a/apps/web/lib/supabase/requests.ts b/apps/web/lib/supabase/requests.ts index 83948268c..a3b6183fa 100644 --- a/apps/web/lib/supabase/requests.ts +++ b/apps/web/lib/supabase/requests.ts @@ -355,26 +355,42 @@ function clampLimit(limit: number | undefined, fallback: number): number { return Math.min(Math.max(1, Math.floor(limit ?? fallback)), MAX_LIST_LIMIT); } -function encodeCursor(offset: number, cutoff: number): string { - return Buffer.from(JSON.stringify({ offset, cutoff }), "utf8").toString("base64url"); +/** + * A page cursor: the last row's position in (received_at, id) order and the + * retention cutoff pinned for the walk. The next page reads strictly after + * that row, so requests arriving or deleted while someone pages through + * never shift a page: none is skipped, none comes twice. Cursors written + * before this hold an offset instead and are still read, the old way. + */ +type PageCursor = + | { at: string; id: string; cutoff: number; offset?: undefined } + | { offset: number; cutoff: number; at?: undefined; id?: undefined }; + +function encodeCursor(at: string, id: string, cutoff: number): string { + return Buffer.from(JSON.stringify({ at, id, cutoff }), "utf8").toString("base64url"); } /** How far back a page cursor may hold the retention cutoff (see listPaginatedRequestsForEndpointByUser). */ const CURSOR_PIN_MS = DAY_MS; -function decodeCursor(cursor: string | undefined): { offset: number; cutoff: number } | null { +// A cursor is the client's to forge, and its position goes into a PostgREST +// filter, so only a plain timestamp and a uuid are accepted. +const CURSOR_TIMESTAMP = + /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d{1,6})?(Z|[+-]\d{2}(:?\d{2})?)?$/; +const CURSOR_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function decodeCursor(cursor: string | undefined): PageCursor | null { if (!cursor) return null; try { const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as { offset?: unknown; + at?: unknown; + id?: unknown; cutoff?: unknown; }; if ( - typeof parsed.offset !== "number" || - !Number.isFinite(parsed.offset) || - parsed.offset < 0 || typeof parsed.cutoff !== "number" || !Number.isFinite(parsed.cutoff) || parsed.cutoff < 0 || @@ -384,10 +400,26 @@ function decodeCursor(cursor: string | undefined): { offset: number; cutoff: num return null; } - return { - offset: parsed.offset, - cutoff: parsed.cutoff, - }; + if ( + typeof parsed.at === "string" && + typeof parsed.id === "string" && + CURSOR_TIMESTAMP.test(parsed.at) && + Number.isFinite(Date.parse(parsed.at)) && + CURSOR_ID.test(parsed.id) + ) { + return { at: parsed.at, id: parsed.id, cutoff: parsed.cutoff }; + } + + if ( + parsed.at === undefined && + typeof parsed.offset === "number" && + Number.isInteger(parsed.offset) && + parsed.offset >= 0 + ) { + return { offset: parsed.offset, cutoff: parsed.cutoff }; + } + + return null; } catch { return null; } @@ -541,6 +573,51 @@ export async function getRetainedRequestsByIds( return (data ?? []).map(normalizeRequest); } +/** The columns the live stream sends (see toStreamRequest in app/api/stream). */ +const STREAM_REQUEST_COLUMNS = + "id, endpoint_id, method, path, headers, body, body_raw, query_params, content_type, ip, size, received_at, team_id, signature_verified, signature_error, signing_provider, response_status, response_rule, live_response, kind, email"; + +/** An endpoint's retained requests among `ids`, with only what the live stream sends. */ +export async function getStreamRequestsByIds( + endpointId: string, + ids: string[] +): Promise { + if (ids.length === 0) return []; + const query = createAdminClient() + .from("requests") + .select(STREAM_REQUEST_COLUMNS) + .eq("endpoint_id", endpointId) + .in("id", ids); + await applyRequestRetention(query, endpointId); + const { data, error } = await query.returns(); + if (error) throw error; + return (data ?? []).map(normalizeRequest); +} + +/** + * The ids of an endpoint's retained requests received after `fromMs`, newest + * first, at most `limit`: the live stream's look-back for a capture whose + * signal it never got (behind its cursor or ahead of it). Ids only. + */ +export async function listStreamRequestIdsSince( + endpointId: string, + fromMs: number, + limit: number +): Promise { + const query = createAdminClient() + .from("requests") + .select("id") + .eq("endpoint_id", endpointId) + .gt("received_at", new Date(fromMs).toISOString()); + await applyRequestRetention(query, endpointId); + const { data, error } = await query + .order("received_at", { ascending: false }) + .limit(limit) + .returns<{ id: string }[]>(); + if (error) throw error; + return (data ?? []).map((row) => row.id); +} + /** Whether one of an endpoint's requests is within retention (an id check, no body read). */ export async function isRequestRetained(endpointId: string, requestId: string): Promise { const query = createAdminClient() @@ -733,6 +810,9 @@ export async function listRequestsAfterCursorForEndpointByUser(input: { if (input.cursor) { const at = `"${input.cursor.receivedAt}"`; query.or(`received_at.gt.${at},and(received_at.eq.${at},id.gt.${input.cursor.id})`); + // Implied by the filter above, but a plain range is what lets Postgres + // start the index scan at the cursor rather than filter its way there. + query.gte("received_at", input.cursor.receivedAt); } else { query.gt("received_at", new Date(input.after).toISOString()); } @@ -805,9 +885,18 @@ export async function listPaginatedRequestsForEndpointByUser(input: { if (input.kind) { query.eq("kind", input.kind); } + if (decoded?.at !== undefined) { + const at = `"${decoded.at}"`; + query.or(`received_at.lt.${at},and(received_at.eq.${at},id.lt.${decoded.id})`); + // Implied by the filter above; a plain range lets the index scan start + // at the cursor, so a deep page costs what the first one does. + query.lte("received_at", decoded.at); + } + // One row more than the page says whether another page follows. const { data, error } = await query .order("received_at", { ascending: false }) + .order("id", { ascending: false }) .range(offset, offset + limit) .returns(); @@ -816,12 +905,13 @@ export async function listPaginatedRequestsForEndpointByUser(input: { } const rows = data ?? []; - const items = rows.slice(0, limit).map(normalizeRequest); + const page = rows.slice(0, limit); const hasMore = rows.length > limit; + const last = page[page.length - 1]; return { - items, - cursor: hasMore ? encodeCursor(offset + limit, cutoff) : undefined, + items: page.map(normalizeRequest), + cursor: hasMore && last ? encodeCursor(last.received_at, last.id, cutoff) : undefined, hasMore, }; } diff --git a/apps/web/public/openapi.yaml b/apps/web/public/openapi.yaml index 300f6e7cc..a27b947f6 100644 --- a/apps/web/public/openapi.yaml +++ b/apps/web/public/openapi.yaml @@ -417,8 +417,9 @@ paths: tags: [Requests] summary: List requests (paginated) description: | - Cursor-based paginated listing of captured requests. Pass the returned `cursor` - to fetch the next page. + Cursor-based paginated listing of captured requests, newest first. Pass the + returned `cursor` to fetch the next page. The cursor marks the last request on + the page, so requests arriving or deleted while paging never skip or repeat one. parameters: - name: limit in: query diff --git a/apps/web/tests/integration/supabase-requests.test.ts b/apps/web/tests/integration/supabase-requests.test.ts index 3c7c31f74..5f54daec0 100644 --- a/apps/web/tests/integration/supabase-requests.test.ts +++ b/apps/web/tests/integration/supabase-requests.test.ts @@ -174,6 +174,79 @@ describe("Supabase Requests Integration", () => { expect(secondPage?.items[0]?.path).toBe("/three"); }); + it("keeps pages exact when requests arrive or are deleted between pages", async () => { + await clearRequestsForEndpointByUser({ userId: testUserId, slug: testEndpointSlug }); + const now = Date.now(); + const ids: Record = {}; + for (const [index, path] of ["/a", "/b", "/c", "/d", "/e"].entries()) { + ids[path] = await insertRequest(path, now - (index + 1) * 1_000); + } + const page = (cursor?: string) => + listPaginatedRequestsForEndpointByUser({ + userId: testUserId, + slug: testEndpointSlug, + limit: 2, + cursor, + }); + + const first = await page(); + expect(first?.items.map((item) => item.path)).toEqual(["/a", "/b"]); + + // A deletion above the cursor: an offset cursor would skip /c. + const { error } = await admin.from("requests").delete().eq("id", ids["/a"]); + expect(error).toBeNull(); + const second = await page(first?.cursor); + expect(second?.items.map((item) => item.path)).toEqual(["/c", "/d"]); + + // New requests at the top: an offset cursor would repeat /d. + await insertRequest("/new", now); + await insertRequest("/newer", now + 1_000); + const third = await page(second?.cursor); + expect(third?.items.map((item) => item.path)).toEqual(["/e"]); + expect(third?.hasMore).toBe(false); + }); + + it("still reads an offset cursor written before keyset cursors", async () => { + await clearRequestsForEndpointByUser({ userId: testUserId, slug: testEndpointSlug }); + const now = Date.now(); + await insertRequest("/x", now - 1_000); + await insertRequest("/y", now - 2_000); + await insertRequest("/z", now - 3_000); + const legacy = Buffer.from(JSON.stringify({ offset: 1, cutoff: 0 })).toString("base64url"); + const result = await listPaginatedRequestsForEndpointByUser({ + userId: testUserId, + slug: testEndpointSlug, + limit: 1, + cursor: legacy, + }); + expect(result?.items.map((item) => item.path)).toEqual(["/y"]); + // The next cursor is a keyset one and carries on from there. + const next = await listPaginatedRequestsForEndpointByUser({ + userId: testUserId, + slug: testEndpointSlug, + limit: 1, + cursor: result?.cursor, + }); + expect(next?.items.map((item) => item.path)).toEqual(["/z"]); + }); + + it("refuses a cursor whose position is not a timestamp and a uuid", async () => { + const forged = Buffer.from( + JSON.stringify({ + at: "2026-01-01T00:00:00Z,id.not.is.null", + id: "00000000-0000-0000-0000-000000000000", + cutoff: 0, + }) + ).toString("base64url"); + await expect( + listPaginatedRequestsForEndpointByUser({ + userId: testUserId, + slug: testEndpointSlug, + cursor: forged, + }) + ).rejects.toThrow("invalid_cursor"); + }); + it("clears endpoint requests and reports the delete count", async () => { await clearRequestsForEndpointByUser({ userId: testUserId, diff --git a/apps/web/tests/integration/supabase-stream.test.ts b/apps/web/tests/integration/supabase-stream.test.ts index 39f834464..c50bd9996 100644 --- a/apps/web/tests/integration/supabase-stream.test.ts +++ b/apps/web/tests/integration/supabase-stream.test.ts @@ -300,6 +300,61 @@ describe("Supabase Stream Route Integration", () => { await anonClient.auth.signOut(); }, 30_000); + it("streams a request that commits after a newer one already went out", async () => { + const anonClient = createAnonClient(); + const signIn = await anonClient.auth.signInWithPassword({ + email: testUserEmail, + password: TEST_PASSWORD, + }); + expect(signIn.error).toBeNull(); + + const controller = new AbortController(); + const response = await streamRoute( + authRequest( + `/api/stream/${testEndpointSlug}`, + signIn.data.session!.access_token, + controller.signal + ), + { params: Promise.resolve({ slug: testEndpointSlug }) } + ); + expect(response.status).toBe(200); + + const eventsPromise = collectEvents(response.body!, "request", 2); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const row = (path: string, receivedAt?: string) => ({ + endpoint_id: testEndpointId, + user_id: testUserId, + method: "POST", + path, + headers: {}, + body: "{}", + query_params: {}, + content_type: "application/json", + ip: "127.0.0.1", + size: 2, + ...(receivedAt ? { received_at: receivedAt } : {}), + }); + // The receiver stamps received_at before the capture commits, so a + // capture that waited (for a slot, for an attachment upload) commits + // stamped earlier than one that already streamed. + const newer = await admin.from("requests").insert(row("/commit-order/newer")); + expect(newer.error).toBeNull(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const late = await admin + .from("requests") + .insert(row("/commit-order/late", new Date(Date.now() - 1500).toISOString())); + expect(late.error).toBeNull(); + + const events = (await eventsPromise).map((data) => JSON.parse(data) as { path: string }); + expect(events.map((event) => event.path)).toEqual([ + "/commit-order/newer", + "/commit-order/late", + ]); + + controller.abort(); + await anonClient.auth.signOut(); + }, 30_000); + it("streams every request when more than a page share one timestamp", async () => { // Rows tied on received_at, read by the initial backlog through the // (received_at, id) cursor. user_id is null so the rows send no broadcast diff --git a/content/docs/api/index.mdx b/content/docs/api/index.mdx index 22ef5f598..f07232564 100644 --- a/content/docs/api/index.mdx +++ b/content/docs/api/index.mdx @@ -3,7 +3,7 @@ title: "REST API" description: "Language-agnostic REST API reference for webhooks.cc. Use any HTTP client to manage endpoints, inspect requests and captured emails, configure forwarding, and stream webhooks." section: getting-started order: 5 -lastUpdated: 2026-11-10 +lastUpdated: 2026-11-13 schema: none keywords: ["webhook API", "REST API", "webhook HTTP API", "curl webhook", "webhook Python", "webhook Go"] @@ -325,7 +325,7 @@ curl "https://webhooks.cc/api/endpoints/abc123/requests/paginated?limit=20&curso } ``` -Pass the returned `cursor` to the next request to fetch the next page. +Pass the returned `cursor` to the next request to fetch the next page. The cursor marks the last request on the page, so requests that arrive or are deleted while you page through never shift a page: none is skipped and none comes twice. ### Get single request diff --git a/content/docs/cli/commands.mdx b/content/docs/cli/commands.mdx index c9c91a04f..fc5473a61 100644 --- a/content/docs/cli/commands.mdx +++ b/content/docs/cli/commands.mdx @@ -3,7 +3,7 @@ title: "Command Reference" description: "Complete command reference for the webhooks.cc CLI, including authentication, endpoint management, tunneling, replay, the agent sandbox, and updates." section: cli order: 2 -lastUpdated: 2026-11-12 +lastUpdated: 2026-11-13 schema: none keywords: ["whk commands", "CLI reference", "webhook CLI commands"] seo: @@ -13,19 +13,7 @@ seo: All available commands for the `whk` CLI. -Run `whk` with no arguments to launch the interactive TUI, or use the subcommands below for scriptable, plain-text output. - -## whk (interactive) - -Launch the interactive TUI. Provides a menu-driven interface for all CLI features with real-time request streaming, color-coded methods, and a request detail viewer. - -```bash -whk -``` - -| Flag | Description | -| --------- | ------------------------------------------------------------ | -| `--nogui` | Disable the TUI and print help instead (also: `WHK_NOGUI=1`) | +Run `whk` with no arguments to print the list of commands. Every command prints plain text, or JSON with `--json`, and reports failures with the [exit statuses](/docs/cli#scripts-ci-and-ai-agents) listed in `whk --help`. ## Global flags @@ -33,7 +21,7 @@ These work with every subcommand. | Flag | Description | | --------------- | ------------------------------------------------------------------- | -| `--json` | Print JSON instead of plain text, where the command supports it | +| `--json` | Print the API's answers as JSON (NDJSON for streams) and failures as `{"error": {...}}` on stderr | | `--api-url` | Use another API base URL (also: `WHK_API_URL`) | | `--webhook-url` | Use another webhook receiver URL (also: `WHK_WEBHOOK_URL`) | | `--no-color` | Disable colored output | @@ -133,7 +121,7 @@ whk update-endpoint --mock-status 202 --mock-body '{"ok":true}' ## favorite -Mark an endpoint as a favourite, or remove it again with `unfavorite`. Favourites come first in `whk list`, in the interactive TUI's endpoint lists, and in the dashboard's endpoint switcher. A favourite is yours alone: you can favourite an endpoint shared with you through a team without changing it for anyone else. Both commands are idempotent, and `favourite` and `unfavourite` work as aliases. They need an account, not an agent sandbox. +Mark an endpoint as a favourite, or remove it again with `unfavorite`. Favourites come first in `whk list` and in the dashboard's endpoint switcher. A favourite is yours alone: you can favourite an endpoint shared with you through a team without changing it for anyone else. Both commands are idempotent, and `favourite` and `unfavourite` work as aliases. They need an account, not an agent sandbox. ```bash whk favorite @@ -144,15 +132,16 @@ With `--json` they print `{ "slug": "", "favorite": true }` (or `false`). ## delete -Delete an endpoint. Prompts for confirmation unless `--force` is set. +Delete an endpoint. In a terminal it asks first; without one (a script, CI, an agent) it stops with `confirmation_required` (exit 9) unless `--yes` is set. ```bash whk delete +whk delete --yes ``` -| Flag | Description | -| ------------- | ---------------------------- | -| `--force, -f` | Skip the confirmation prompt | +| Flag | Description | +| ----------- | --------------------------------------------- | +| `--yes, -y` | Delete without asking (`--force` still works) | ## tunnel @@ -227,19 +216,38 @@ whk names password --clear ## listen -Stream incoming requests for an endpoint to the terminal without forwarding them. +Stream incoming requests and emails for an endpoint without forwarding them: one line each, or one JSON object per line (NDJSON) with `--json`. ```bash whk listen ``` -When signature verification is configured, listen shows verification status inline: +Each line has the time, the method, the path (for an email, the address and the subject), the signature check when verification is configured, the size and the request id for `whk requests get`: ``` -14:32:01 POST /webhook ✓ stripe 423 B -14:32:05 POST /webhook ✗ stripe 423 B +14:32:01 POST /webhook ✓ stripe 423 B 6f1c... +14:32:05 POST /webhook ✗ stripe 423 B 9a2e... +``` + +## wait + +Wait for the next request or email at an endpoint, print it, and exit. Started before the sender, pass `--since now` so only a new request counts. Without it, requests from the last 5 minutes count too, which suits starting it after the sender ran, but can return an earlier delivery. When nothing matching arrives in time it exits with `timeout` (exit 8). + +```bash +whk wait --since now --json +whk wait --kind email --since now --timeout 2m --json +whk wait --method POST --path /stripe --count 3 --json ``` +| Flag | Description | +| --- | --- | +| `--timeout` | How long to wait, with a unit: `30s`, `5m`, `1h` (default `30s`) | +| `--count` | How many matching requests to wait for (1 to 100); more than one prints a JSON array | +| `--since` | `now` (only what arrives after the command starts, by the server's clock), a duration back such as `2m`, or a timestamp in milliseconds | +| `--method` | Only this HTTP method | +| `--kind` | `http` or `email` | +| `--path` | Only requests whose path (for an email, the recipient address) contains this text | + ## requests List, read, search, count, clear, and export captured requests. Run `whk requests --help` for each command's flags. @@ -249,7 +257,7 @@ whk requests list # newest first, --limit (default 25), --since, whk requests get whk requests search -q invoice --slug --from 1h # across retained requests: --method, --to, --limit, --offset, --order whk requests count --slug --method POST # same filters as search -whk requests clear # --before