feat(cli): add serve subcommand for browsing decoded txs#273
feat(cli): add serve subcommand for browsing decoded txs#273prasanna-anchorage wants to merge 5 commits into
serve subcommand for browsing decoded txs#273Conversation
e88e62a to
3918f22
Compare
serve subcommand for browsing decoded txs (stacked on #271)serve subcommand for browsing decoded txs
There was a problem hiding this comment.
Pull request overview
Adds a new serve subcommand to parser_cli, alongside moving the existing one-shot transaction decoding flow under a new decode subcommand. This extends the CLI from single-transaction inspection to local directory-based browsing of decoded fixtures in a lightweight web UI.
Changes:
- Refactors the CLI into
decodeand feature-gatedservesubcommands. - Adds the local HTTP server, HTML renderer, and tests for live reload / JSON passthrough behavior.
- Updates fixtures, docs, and Cargo feature wiring to match the new CLI shape.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
src/parser/cli/tests/fixtures/solana-text.input |
Updates fixture invocation to use the new decode subcommand. |
src/parser/cli/tests/fixtures/solana-json.input |
Updates fixture invocation to use the new decode subcommand. |
src/parser/cli/tests/fixtures/ethereum-json.input |
Updates fixture invocation to use the new decode subcommand. |
src/parser/cli/tests/fixtures/ethereum-from-file.input |
Updates fixture invocation to use the new decode subcommand. |
src/parser/cli/tests/cli_test.rs |
Adapts existing CLI tests and adds integration coverage for the new serve mode. |
src/parser/cli/src/serve.rs |
Introduces the new directory-scanning HTTP server, routes, HTML output, and unit tests. |
src/parser/cli/src/lib.rs |
Exposes the new serve module and refactors plugin-arg plumbing for multiple subcommands. |
src/parser/cli/src/cli.rs |
Reworks CLI parsing around subcommands and factors shared runtime preparation. |
src/parser/cli/Cargo.toml |
Adds the optional serve feature and feature-gated axum/tokio dependencies. |
src/Cargo.lock |
Records the new optional direct dependencies for parser_cli. |
CLAUDE.md |
Updates local usage examples for decode and documents the new serve command. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if metadata.is_dir() { | ||
| walk(base, &path, out, chain_str, runtime)?; |
There was a problem hiding this comment.
Fixed. Switched to entry.file_type() (uses lstat) and explicitly skip is_symlink() entries before recursing or decoding. The new test (fn walk_skips_symlinks would be a follow-up — the structural fix is the file_type/is_symlink path itself).
| ) -> Result<Json<serde_json::Value>, StatusCode> { | ||
| let entries = load_entries(&state) | ||
| .await | ||
| .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; | ||
| let entry = entries | ||
| .iter() | ||
| .find(|e| e.rel_path == q.path) | ||
| .ok_or(StatusCode::NOT_FOUND)?; | ||
|
|
||
| let response = match &entry.result { | ||
| Ok(payload) => FileResponse { | ||
| path: &entry.rel_path, | ||
| ok: true, | ||
| payload: Some(payload), | ||
| error: None, | ||
| }, | ||
| Err(err) => FileResponse { | ||
| path: &entry.rel_path, | ||
| ok: false, | ||
| payload: None, | ||
| error: Some(err), | ||
| }, | ||
| }; | ||
| serde_json::to_value(&response) | ||
| .map(Json) | ||
| .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) |
There was a problem hiding this comment.
Fixed. handle_file now always returns the { path, ok, payload?, error? } envelope. Missing paths and load_entries failures populate ok: false with the cause in error. The HTTP status code still reflects outcome (200/404/422/500) so status-aware clients keep working, but the body contract is honored unconditionally.
| const COPY_SCRIPT: &str = "document.addEventListener('click',function(e){\ | ||
| var btn=e.target.closest('button.copy');\ | ||
| if(!btn)return;\ | ||
| e.preventDefault();e.stopPropagation();\ | ||
| var pre=btn.closest('details').querySelector('pre');\ | ||
| if(!pre||!navigator.clipboard)return;\ | ||
| navigator.clipboard.writeText(pre.textContent).then(function(){\ | ||
| var orig=btn.textContent;\ | ||
| btn.textContent='copied!';btn.classList.add('copied');\ | ||
| setTimeout(function(){btn.textContent=orig;btn.classList.remove('copied');},1200);\ | ||
| });\ | ||
| });"; |
There was a problem hiding this comment.
PR description nit; the body actually already says "a vanilla-JS clipboard helper" and "No framework. ~10 lines of plain JS" in a later section, so the framing is internally consistent, just spread across sections. Will tighten the wording in the description but no code change is needed.
| full.strip_prefix(base) | ||
| .unwrap_or(full) | ||
| .display() | ||
| .to_string() |
There was a problem hiding this comment.
Fixed. rel_path now joins the strip_prefix-ed components() with / so Windows paths produce dir/file.hex to match the URL routes. It also returns None if strip_prefix fails (pepe-anchor flagged the same fallback path leaking the absolute host path) — callers skip the entry rather than emit a defective rel_path.
| // Ask the OS for a free port: bind ephemeral, drop, race-tolerable since | ||
| // the server immediately re-binds. | ||
| let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind"); | ||
| let port = probe.local_addr().expect("local_addr").port(); | ||
| drop(probe); |
There was a problem hiding this comment.
Fixed. The test now passes --port 0 and parses the actual bound port out of the server-printed Serving on http://127.0.0.1:N line. The existing local_addr() print already carries the real port, so this drops the probe/drop/rebind race entirely.
| let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind"); | ||
| let port = probe.local_addr().expect("local_addr").port(); | ||
| drop(probe); |
There was a problem hiding this comment.
Fixed. Same --port 0 + parse-from-stdout pattern as test_cli_serve_renders_directory.
| continue; | ||
| } | ||
| let path = entry.path(); | ||
| let metadata = match entry.metadata() { |
There was a problem hiding this comment.
walk() has no recursion depth limit — symlink loops crash the server
DirEntry::metadata() follows symlinks (stat, not lstat), so a symlink loop (e.g. dir/loop -> ..) causes unbounded recursion and a stack overflow on the first HTTP request. No visited-set or depth cap.
Fix: skip symlinks via entry.file_type().is_symlink().
Found by Claude on behalf of @pepe-anchor.
There was a problem hiding this comment.
Fixed in the same commit as the Copilot symlink thread above. entry.file_type() is lstat-based and is_symlink() entries are skipped before recursion. No visited-set/depth-cap needed once symlinks are out of the picture.
| Ok(()) | ||
| } | ||
|
|
||
| fn rel_path(base: &Path, full: &Path) -> String { |
There was a problem hiding this comment.
rel_path falls back to the absolute path when strip_prefix fails
If strip_prefix(base) fails (e.g. after symlink resolution diverges), unwrap_or(full) returns the full absolute path, which is then embedded in the rendered HTML. This leaks the host filesystem layout to anyone who can reach 127.0.0.1.
Better: skip the entry with a synthetic error.
Found by Claude on behalf of @pepe-anchor.
There was a problem hiding this comment.
Fixed in the same commit. rel_path now returns Option<String> and only emits a path when strip_prefix succeeds; callers skip the entry on None rather than pushing a DecodedEntry with an absolute path. The rendered HTML and route table can no longer carry host filesystem layout.
| /// `/token_2022/transfer_checked.json` returns just that file's payload as | ||
| /// JSON. Wraps no envelope around it so browsers and `curl` see the raw | ||
| /// `SignablePayload` (or the verbatim file content for `.json` passthrough). | ||
| async fn handle_payload( |
There was a problem hiding this comment.
Single-file endpoints decode the entire directory on every request
handle_payload and handle_file both call load_entries, which walks and decodes all N files in --dir just to find one by equality. For single-file routes, validate the path is under state.dir and call decode_file directly -- same freshness at O(1) instead of O(N).
Found by Claude on behalf of @pepe-anchor.
There was a problem hiding this comment.
Deferring intentionally: typical --dir is the in-repo tests/fixtures/... tree (tens of files), which decode_directory walks in single-digit ms. The doc comment on the live-reload behavior calls out re-decoding on every request as a deliberate design choice (so editing a file → refresh → see new state with no cache invalidation). If the use case grows to "point it at a hundreds-of-files dir", a single-file fast path that validates the rel_path is canonicalized under state.dir and then calls decode_file directly is a small follow-up — happy to do it if you want it in this PR, just flag and I will.
| pub struct Cli; | ||
| impl Cli { | ||
| /// start the parser cli | ||
| pub fn execute() -> Result<(), String> { |
There was a problem hiding this comment.
Cli::execute signature promises Result but always returns Ok(())
After the refactor, both execute_decode and execute_serve call process::exit(1) directly on errors, so Cli::execute never returns Err. The if let Err(e) = Cli::execute() branch in main.rs is now dead code.
Either change the signature to -> (), or thread errors back as Result from both delegates.
Found by Claude on behalf of @pepe-anchor.
There was a problem hiding this comment.
Fixed. Cli::execute now returns () (the Result<(), String> was always Ok(()) since both branches process::exit(1) directly), and the dead if let Err branch in main.rs is dropped.
| @@ -9,6 +9,21 @@ use visualsign::{SignablePayload, SignablePayloadField}; | |||
| #[command(version = "1.0")] | |||
| #[command(about = "Converts raw transactions to visual signing properties")] | |||
| pub(crate) struct Args { | |||
There was a problem hiding this comment.
Confirm no external callers of the old flat --chain / -t syntax
The old parser_cli --chain ethereum -t <hex> interface is hard-broken. In-tree tests and fixtures were updated correctly.
Before merging: any external scripts, CI pipelines, or docs outside this repo still using the flat syntax?
Found by Claude on behalf of @pepe-anchor.
There was a problem hiding this comment.
Confirmed: searched anchorageoss/* for parser_cli --chain and parser_cli -t invocations outside this repo via gh code-search and found none. In-tree calls (CI, integration tests, docs) all already use the decode subcommand. The break is intentional and pre-1.0; happy to ship a one-release transition alias (parser_cli --chain ... -t ... warns + dispatches to decode) if you want a softer migration, but my read is the explicit break is fine here.
Restructures `parser_cli` into a true subcommand model: parser_cli decode --chain X -t @file # was: parser_cli --chain X -t @file parser_cli serve --chain X --dir ./txs # new `serve` (gated behind a `serve` Cargo feature) scans a directory of raw transaction files at startup, decodes each one against the requested chain, and serves a small read-only web UI on `127.0.0.1:<port>`: - GET / HTML page, one collapsible <details> block per file with the pretty JSON inline - GET /api/file?path=<rel> JSON for a single entry Hand-rolled HTML, no JS, no template engine. Server is single-thread tokio (axum 0.8). Both `axum` and `tokio` are already transitive via `tonic` in the lock file, so the resolved dep tree gains nothing by adding them as direct deps for `parser_cli`. The default build (no `--features serve`) is unchanged: axum and tokio are not pulled in, the `Serve` variant of the subcommand enum is gated out, and `parser_app`/enclave callers see no new deps. Existing fixtures get a leading `decode` line to match the new grammar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-decode the directory on every HTTP request instead of caching at startup, so editing a fixture and refreshing the browser is enough to see the new state — no server restart needed. Useful when iterating on fixtures across multiple worktrees. Files with a `.json` extension are parsed as `serde_json::Value` and served as-is, so a directory of `.expected` outputs (or any pre-recorded JSON) can be browsed without going through the chain decoder. Other files take the existing hex-decode path. Mixed dirs work either way. `DecodedEntry.result` and `FileResponse.payload` switch from `SignablePayload` to `serde_json::Value` so both decode paths share one shape. Re-decoding moves into the request handlers via `tokio::task::spawn_blocking` to keep fs+CPU off the current-thread async executor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dev container already uses 8080 for REST, and 8080 collides with just about every other local dev tool. 47474 is uncommon enough to avoid most conflicts and easy enough to type. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a wildcard route so any rel path under the served directory becomes a bookmarkable URL — `GET /token_2022/transfer_checked.json` returns that file's payload directly as JSON, no envelope. Useful when sharing a fixture's decoded view (e.g. through a Cloud Workstations tunnel) or when piping into curl/jq. Each entry on the index page now has a small `[json]` link next to its path, pointing at the standalone URL. Clicking the path text still toggles the `<details>` block; the `[json]` link navigates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the missing viewport meta tag (the actual reason the page wasn't mobile-friendly), small responsive CSS tweaks, and a vanilla-JS copy button next to each entry's `[json]` link.
3918f22 to
cadccbf
Compare
shahan-khatchadourian-anchorage
left a comment
There was a problem hiding this comment.
LGTM
Summary
Adds a
servesubcommand toparser_clithat decodes a directory of raw-transaction files and serves a small read-only web UI on127.0.0.1so reviewers can scroll through results in a browser instead of running the CLI per-file.The directory is re-decoded on every request — edit a fixture, refresh the browser, see the new state. No restart, no file watcher, no JS framework.
.jsonfiles in the directory are passed through verbatim, so a tree of.expectedoutputs can be browsed alongside hex inputs (or on its own).Each entry has its own browseable /
curl-friendly URL, and the page is mobile-friendly with a per-entry copy button.Screenshots
Suggested shots: index page on desktop, same page narrow / on a phone, and the copy button mid-press (the
copied!confirmation state).Try it against existing fixtures
The chain-parser fixture trees have
.input(raw hex) and.expected(recorded JSON) sitting next to each other — perfect demo for this subcommand. The hex files get decoded fresh; the JSON files pass through as-is.Run from
src/(same directory as the workspaceCargo.toml):Then http://127.0.0.1:47474. Each fixture name appears twice (
1559.inputdecoded fresh,1559.expectedserved as-is). Edit any file, refresh, see the new state.--diris resolved relative to the shell's CWD, so pass an absolute path if you want to invoke it from anywhere. Default port is 47474 rather than 8080 to avoid colliding with the dev-container REST port and the long list of other tools that grab 8080. Override with--port.Routes
GET /— HTML index. One collapsible<details>per file, JSON inline. Each summary has a[json]link to the file's standalone URL plus acopybutton that puts the rendered JSON onto the clipboard.GET /<rel-path>— Bare payload as JSON, no envelope. e.g./token_2022/transfer_checked.jsonreturns that file's decoded payload (or its raw JSON, in passthrough mode). Browseable, bookmarkable,curl-friendly.GET /api/file?path=<rel-path>— Wrapped envelope:{path, ok, payload?, error?}. Kept around for tooling that wants the success/error flag without checking the HTTP status code.All routes re-walk the directory and re-decode each file on every hit. With ~tens of fixtures this is single-digit milliseconds; not worth a cache.
File handling
.jsonextension → file is parsed asserde_json::Valueand served as-is. No schema validation, no coercion toSignablePayload.SignablePayload, serialize to JSON.A directory can be all hex, all JSON, or mixed.
Mobile + copy button
The index page works on a phone and ships a vanilla-JS clipboard helper:
<meta name=viewport content="width=device-width, initial-scale=1">— the actual reason it wasn't mobile-friendly before.word-break: break-all;[json]link andcopybutton get tappable padding; narrow-viewport CSS via@media (max-width: 600px).<button class=copy>next to its[json]link. Click →navigator.clipboard.writeText(pre.textContent)→ button flips tocopied!for 1.2s.event.stopPropagation()so the click doesn't toggle the<details>open/closed.CLI shape (breaking)
The pre-existing flat invocation moves under a
decodesubcommand:Pre-1.0 internal CLI; happy to revisit if the breakage is undesirable.
Dependencies
axum 0.8+tokioare added as optional direct deps ofparser_cli, gated behind a newserveCargo feature. Both crates are already transitive viatonicin the lock file, so the resolved dep tree gains zero new compiled crates. Default builds (andparser_app/enclave) are unchanged — axum/tokio are not pulled in unless--features serveis passed.No new deps for live reload or the copy button — both are behavior changes in the existing handlers / a vanilla-JS snippet inlined into the rendered HTML.
Out of scope (deferred to follow-ups if useful)
--dirflags / multiple chains in one server instance--abi-json-mappings/--idl-mappingsfiles (registry stays static)127.0.0.1-only by designTest plan
cargo build -p parser_cli(default) — unchanged, no axum/tokiocargo build -p parser_cli --features servecargo clippy --workspace --all-targets -- -D warningscargo clippy -p parser_cli --features serve --all-targets -- -D warningscargo test -p parser_cli --features serve serve— 10 unit tests + 2 integration tests pass, including:json_files_passthrough_as_is—.jsonfile served verbatimmalformed_json_errors_with_invalid_json_prefix— bad JSON surfaces as an error entrymixed_hex_and_json_directory_decodes_both— both decode paths in one dirurl_encode_path_preserves_separators_and_safe_chars— rel-path → URL helperrender_html_contains_paths_and_payload— viewport meta tag present, copy button rendered per entry, clipboard JS hooked uprender_error_page_is_mobile_friendly— error page also carries the viewport metatest_cli_serve_live_reload_and_json_passthrough— end-to-end: spawn server, mutate JSON file → second GET reflects change, drop in a new file → it shows up at/, truncate hex file → flips to error state, standalone-URL route returns bare payload + 404 on miss, all without restartchain_parsers/visualsign-ethereum/tests/fixtures— both.inputand.expectedfiles render, edits live-reload on browser refresh, standalone URLs (/<rel-path>) return bare payloadshttp://<dev-host>:47474from a phone (or Chrome DevTools device emulation), confirm no horizontal scroll on the page chrome, copy button copies the entry's JSON🤖 Generated with Claude Code