diff --git a/.gitignore b/.gitignore index d20b044..5eddf02 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,4 @@ emotion-gradient-config-*.json **/*.csv .op/ .sere +.envrc diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 685c875..a9fd601 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,6 +94,47 @@ cargo nextest run -- --nocapture cargo doc --open ``` +### Environment Variables with `.envrc` + +Marathon uses [direnv](https://direnv.net/) for managing environment variables. This is particularly useful for storing sensitive tokens like GitHub Personal Access Tokens (PAT). + +#### Setup + +1. **Install direnv** (if not already installed): + ```bash + # macOS + brew install direnv + + # Add to your shell profile (~/.zshrc or ~/.bashrc) + eval "$(direnv hook zsh)" # or bash + ``` + +2. **Create `.envrc` file** in the project root: + ```bash + # The .envrc file is already gitignored for security + export GH_TOKEN=your_github_personal_access_token + ``` + +3. **Allow direnv** to load the file: + ```bash + direnv allow . + ``` + +#### GitHub Token Setup + +For working with security scanning alerts and other GitHub features: + +1. Create a Personal Access Token at https://github.com/settings/tokens +2. Select the following scopes: + - ✅ `repo` (full control) + - ✅ `security_events` (read security events) +3. Add the token to your `.envrc` file: + ```bash + export GH_TOKEN=github_pat_YOUR_TOKEN_HERE + ``` + +The `.envrc` file is automatically ignored by git, so your tokens won't be committed. + ## How to Contribute ### Types of Contributions diff --git a/Cargo.lock b/Cargo.lock index de4ccce..74e07b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -269,6 +269,7 @@ dependencies = [ "bytes", "clap", "crossbeam-channel", + "crossterm", "egui", "futures-lite", "glam 0.29.3", @@ -279,6 +280,7 @@ dependencies = [ "libmarathon-macros", "objc", "rand 0.8.5", + "ratatui", "raw-window-handle", "rkyv", "serde", @@ -1935,12 +1937,27 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[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.46" @@ -2113,7 +2130,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ "serde", "termcolor", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] @@ -2132,6 +2149,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -2461,6 +2492,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -2565,6 +2621,40 @@ dependencies = [ "syn", ] +[[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", +] + +[[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", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -3594,6 +3684,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -3956,6 +4048,12 @@ dependencies = [ "zerovec", ] +[[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" @@ -4023,6 +4121,15 @@ 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 = "inflections" version = "1.1.1" @@ -4058,6 +4165,19 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "instant" version = "0.1.13" @@ -4160,7 +4280,7 @@ dependencies = [ "rustls-webpki", "serde", "smallvec", - "strum", + "strum 0.27.2", "swarm-discovery", "time", "tokio", @@ -4337,7 +4457,7 @@ dependencies = [ "serde", "serde_bytes", "sha1", - "strum", + "strum 0.27.2", "tokio", "tokio-rustls", "tokio-util", @@ -4716,6 +4836,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru" version = "0.13.0" @@ -4777,6 +4906,17 @@ dependencies = [ "libc", ] +[[package]] +name = "marathonctl" +version = "0.1.0" +dependencies = [ + "clap", + "crossterm", + "libmarathon", + "ratatui", + "uuid", +] + [[package]] name = "matchers" version = "0.2.0" @@ -4839,6 +4979,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -6278,6 +6419,27 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbbbbea733ec66275512d0b9694f34102e7d5406fdbe2ad8d21b28dce92887c" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.10.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru 0.12.5", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -6941,6 +7103,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.6" @@ -7146,13 +7329,35 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + [[package]] name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", ] [[package]] @@ -7852,10 +8057,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] -name = "unicode-width" -version = "0.2.2" +name = "unicode-truncate" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unicode-xid" diff --git a/Cargo.toml b/Cargo.toml index 39bf6a8..751165d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/libmarathon", "crates/macros", "crates/app", "crates/xtask"] +members = ["crates/libmarathon", "crates/macros", "crates/app", "crates/xtask", "crates/marathonctl"] resolver = "2" [workspace.package] diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 436669d..5f18123 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -38,6 +38,8 @@ futures-lite.workspace = true bytes.workspace = true crossbeam-channel.workspace = true clap.workspace = true +ratatui = "0.29" +crossterm = "0.28" [target.'cfg(target_os = "ios")'.dependencies] objc = "0.2" diff --git a/crates/marathonctl/Cargo.toml b/crates/marathonctl/Cargo.toml new file mode 100644 index 0000000..a59993c --- /dev/null +++ b/crates/marathonctl/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "marathonctl" +version = "0.1.0" +edition.workspace = true + +[[bin]] +name = "marathonctl" +path = "src/main.rs" + +[dependencies] +libmarathon = { path = "../libmarathon" } +clap.workspace = true +uuid.workspace = true +ratatui = "0.29" +crossterm = "0.28" diff --git a/crates/app/src/bin/marathonctl.rs b/crates/marathonctl/src/main.rs similarity index 67% rename from crates/app/src/bin/marathonctl.rs rename to crates/marathonctl/src/main.rs index 4128174..a3d08a3 100644 --- a/crates/app/src/bin/marathonctl.rs +++ b/crates/marathonctl/src/main.rs @@ -15,6 +15,8 @@ //! marathonctl --socket /tmp/marathon1.sock status //! ``` +mod ui; + use clap::{Parser, Subcommand}; use std::io::{Read, Write}; use std::os::unix::net::UnixStream; @@ -29,6 +31,10 @@ struct Args { #[arg(long, default_value = "/tmp/marathon-control.sock")] socket: String, + /// Show sensitive information (session IDs, etc.) in full + #[arg(short, long)] + show_sensitive: bool, + #[command(subcommand)] command: Commands, } @@ -72,6 +78,22 @@ enum Commands { }, } +/// Redacts a session ID for safe logging +/// Shows only the first 8 characters to prevent exposure of sensitive information +/// unless show_sensitive is true +fn redact_session_id(session_id: impl std::fmt::Display, show_sensitive: bool) -> String { + if show_sensitive { + session_id.to_string() + } else { + let session_str = session_id.to_string(); + if session_str.len() > 8 { + format!("{}...", &session_str[..8]) + } else { + "".to_string() + } + } +} + fn main() { let args = Args::parse(); @@ -132,7 +154,7 @@ fn main() { // Receive response match receive_response(&mut stream) { Ok(response) => { - print_response(response); + print_response(response, args.show_sensitive); } Err(e) => { eprintln!("Failed to receive response: {}", e); @@ -169,7 +191,7 @@ fn receive_response(stream: &mut UnixStream) -> Result { - println!("Session Status:"); - println!(" Node ID: {}", node_id); - println!(" Session: {}", session_id); - println!(" Outgoing Queue: {} messages", outgoing_queue_size); - println!(" Incoming Queue: {} messages", incoming_queue_size); + let mut builder = ui::table("Session Status") + .row("Node ID", node_id) + .row("Session", redact_session_id(session_id, show_sensitive)) + .row("Outgoing Queue", format!("{} messages", outgoing_queue_size)) + .row("Incoming Queue", format!("{} messages", incoming_queue_size)); + if let Some(peers) = connected_peers { - println!(" Connected Peers: {}", peers); + builder = builder.row("Connected Peers", peers); } + + builder.render(); } ControlResponse::SessionInfo(info) => { - println!("Session Info:"); - println!(" ID: {}", info.session_id); + let mut builder = ui::table("Session Info") + .row("ID", redact_session_id(&info.session_id, show_sensitive)); + if let Some(ref name) = info.session_name { - println!(" Name: {}", name); + builder = builder.row("Name", name); } - println!(" State: {:?}", info.state); - println!(" Entities: {}", info.entity_count); - println!(" Created: {}", info.created_at); - println!(" Last Active: {}", info.last_active); + + builder + .row("State", format!("{:?}", info.state)) + .row("Entities", info.entity_count) + .row("Created", info.created_at) + .row("Last Active", info.last_active) + .render(); } ControlResponse::Sessions(sessions) => { - println!("Sessions ({} total):", sessions.len()); - for session in sessions { - println!(" {}: {:?} ({} entities)", session.session_id, session.state, session.entity_count); + if sessions.is_empty() { + println!("No sessions found"); + return; } + + let mut builder = ui::grid(&format!("Sessions ({})", sessions.len())) + .header(&["Session ID", "State", "Entities"]); + + for session in sessions { + builder = builder.row(&[ + redact_session_id(&session.session_id, show_sensitive), + format!("{:?}", session.state), + session.entity_count.to_string(), + ]); + } + + builder.render(); } ControlResponse::Peers(peers) => { - println!("Connected Peers ({} total):", peers.len()); - for peer in peers { - print!(" {}", peer.node_id); - if let Some(since) = peer.connected_since { - println!(" (connected since: {})", since); - } else { - println!(); - } + if peers.is_empty() { + println!("No connected peers"); + return; } + + let mut builder = ui::list(&format!("Connected Peers ({})", peers.len())); + + for peer in peers { + let item = if let Some(since) = peer.connected_since { + format!("{} (connected since: {})", peer.node_id, since) + } else { + peer.node_id.to_string() + }; + builder = builder.item(item); + } + + builder.render(); } ControlResponse::Ok { message } => { println!("Success: {}", message); diff --git a/crates/marathonctl/src/ui.rs b/crates/marathonctl/src/ui.rs new file mode 100644 index 0000000..768f2bc --- /dev/null +++ b/crates/marathonctl/src/ui.rs @@ -0,0 +1,224 @@ +//! Beautiful CLI UI module for marathonctl using ratatui +//! +//! Provides simple builder APIs for rendering beautiful terminal output +//! without taking over the terminal (inline mode). +//! +//! # Examples +//! +//! ```rust +//! // Render a status table +//! ui::table("Session Status") +//! .row("Node ID", node_id) +//! .row("Session", session_id) +//! .row("Queue Size", queue_size) +//! .render(); +//! +//! // Render a list +//! ui::list("Connected Peers") +//! .item(peer1) +//! .item(peer2) +//! .render(); +//! +//! // Render a data grid +//! ui::grid("Sessions") +//! .header(&["ID", "State", "Entities"]) +//! .row(&[id1, state1, count1]) +//! .row(&[id2, state2, count2]) +//! .render(); +//! ``` + +use ratatui::prelude::*; +use ratatui::widgets::*; +use ratatui::{TerminalOptions, Viewport}; + +/// Create a key-value table (like status output) +pub fn table(title: &str) -> TableBuilder { + TableBuilder::new(title) +} + +/// Create a simple list +pub fn list(title: &str) -> ListBuilder { + ListBuilder::new(title) +} + +/// Create a data grid with headers +pub fn grid(title: &str) -> GridBuilder { + GridBuilder::new(title) +} + +/// Builder for key-value tables +pub struct TableBuilder { + title: String, + rows: Vec<(String, String)>, +} + +impl TableBuilder { + fn new(title: &str) -> Self { + Self { + title: title.to_string(), + rows: Vec::new(), + } + } + + /// Add a key-value row + pub fn row(mut self, key: impl std::fmt::Display, value: impl std::fmt::Display) -> Self { + self.rows.push((key.to_string(), value.to_string())); + self + } + + /// Render the table to the terminal + pub fn render(self) { + let height = self.rows.len() + 4; // rows + borders + header + padding + render_widget(height, |frame| { + let area = frame.area(); + + let rows: Vec = self + .rows + .iter() + .map(|(k, v)| Row::new(vec![k.clone(), v.clone()])) + .collect(); + + let table = Table::new( + rows, + [Constraint::Length(20), Constraint::Min(30)], + ) + .header( + Row::new(vec!["Field", "Value"]) + .bold() + .style(Style::default().fg(Color::Green)), + ) + .block( + Block::default() + .title(format!(" {} ", self.title)) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .column_spacing(2); + + frame.render_widget(table, area); + }); + } +} + +/// Builder for simple lists +pub struct ListBuilder { + title: String, + items: Vec, +} + +impl ListBuilder { + fn new(title: &str) -> Self { + Self { + title: title.to_string(), + items: Vec::new(), + } + } + + /// Add an item to the list + pub fn item(mut self, item: impl std::fmt::Display) -> Self { + self.items.push(item.to_string()); + self + } + + /// Render the list to the terminal + pub fn render(self) { + let height = self.items.len() + 3; // items + borders + title + render_widget(height, |frame| { + let area = frame.area(); + + let items: Vec = self + .items + .iter() + .map(|i| ListItem::new(format!(" • {}", i))) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .title(format!(" {} ", self.title)) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ); + + frame.render_widget(list, area); + }); + } +} + +/// Builder for data grids with headers +pub struct GridBuilder { + title: String, + headers: Vec, + rows: Vec>, +} + +impl GridBuilder { + fn new(title: &str) -> Self { + Self { + title: title.to_string(), + headers: Vec::new(), + rows: Vec::new(), + } + } + + /// Set the header row + pub fn header(mut self, headers: &[impl std::fmt::Display]) -> Self { + self.headers = headers.iter().map(|h| h.to_string()).collect(); + self + } + + /// Add a data row + pub fn row(mut self, cells: &[impl std::fmt::Display]) -> Self { + self.rows.push(cells.iter().map(|c| c.to_string()).collect()); + self + } + + /// Render the grid to the terminal + pub fn render(self) { + let height = self.rows.len() + 4; // rows + borders + header + padding + render_widget(height, |frame| { + let area = frame.area(); + + // Create constraints based on number of columns + let col_count = self.headers.len().max( + self.rows.iter().map(|r| r.len()).max().unwrap_or(0) + ); + let constraints = vec![Constraint::Ratio(1, col_count as u32); col_count]; + + let header = Row::new(self.headers.clone()) + .bold() + .style(Style::default().fg(Color::Green)); + + let rows: Vec = self + .rows + .iter() + .map(|r| Row::new(r.clone())) + .collect(); + + let table = Table::new(rows, constraints) + .header(header) + .block( + Block::default() + .title(format!(" {} ", self.title)) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .column_spacing(2); + + frame.render_widget(table, area); + }); + } +} + +/// Internal helper to render a widget using inline viewport +fn render_widget(height: usize, render_fn: F) +where + F: FnOnce(&mut Frame), +{ + let mut terminal = ratatui::init_with_options(TerminalOptions { + viewport: Viewport::Inline(height as u16), + }); + + terminal.draw(render_fn).unwrap(); + ratatui::restore(); +}