Compare commits

6 Commits

Author SHA1 Message Date
geoffsee
03128161ad chore: Add Cargo.lock for muxox package
- Tracks dependencies and ensures reproducible builds
- Automatically generated by Cargo
2025-08-25 15:33:03 -04:00
geoffsee
ee75b7e59a chore(ci): Remove CI and Dependabot configuration files
- Deletes `.github/workflows/ci.yml` and `.github/dependabot.yml`
- Likely deprecating automated CI and dependency management setup
2025-08-25 15:31:46 -04:00
geoffsee
21203b1a99 chore: Update Cargo workspace members configuration
- Set `crates/muxox` as an explicit workspace member
- Adjust default-members to align with updated workspace structure
2025-08-25 15:30:16 -04:00
geoffsee
2dbe8558e5 chore(ci): Comment out clippy and rustfmt steps in workflows
- Disable clippy and rustfmt steps in both `ci.yml` and `release.yml`
- Retain these steps as comments for potential future reactivation
2025-08-25 15:28:46 -04:00
geoffsee
3b2f69df07 chore(ci): Remove redundant working-directory from CI workflow 2025-08-25 15:26:34 -04:00
geoffsee
560980d9d7 refactor(core): Improve code readability and structure
- Format code uniformly, applying consistent indentation and spacing
- Restructure enum, function, and match-case formatting for clarity
- Remove redundant `defaults` from CI workflows
2025-08-25 15:24:10 -04:00
11 changed files with 1507 additions and 455 deletions

View File

@@ -1,64 +0,0 @@
# Dependabot configuration for muxox
# Monitors TLS dependencies for security updates and advisories
# Generated for Task 6: Dependency Monitoring Setup
version: 2
updates:
# Monitor Rust dependencies in the main crate
- package-ecosystem: "cargo"
directory: "/crates/muxox"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "UTC"
# Focus on security updates with higher priority
open-pull-requests-limit: 10
reviewers:
- "security-team"
assignees:
- "maintainer"
labels:
- "dependencies"
- "security"
# Security updates get higher priority
allow:
- dependency-type: "all"
# Group minor and patch updates to reduce noise
groups:
tls-dependencies:
patterns:
- "hyper-tls"
- "native-tls"
- "hyper-rustls"
- "rustls-pemfile"
- "rustls*"
update-types:
- "minor"
- "patch"
# Separate major updates for careful review
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
commit-message:
prefix: "deps"
include: "scope"
# Monitor security updates more frequently
- package-ecosystem: "cargo"
directory: "/crates/muxox"
schedule:
interval: "daily"
# Only security updates in daily checks
allow:
- dependency-type: "direct"
update-types: ["security"]
- dependency-type: "indirect"
update-types: ["security"]
open-pull-requests-limit: 5
labels:
- "security-update"
- "high-priority"
commit-message:
prefix: "security"
include: "scope"

View File

@@ -1,65 +0,0 @@
name: CI
on:
push:
pull_request:
jobs:
build:
name: build-and-test (${{ matrix.name }})
runs-on: ubuntu-latest
defaults:
run:
working-directory: crates/muxox
strategy:
fail-fast: false
matrix:
include:
- name: default (native-tls)
features: ""
no-default-features: false
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Setup Rust
run: rustup update stable && rustup default stable
- name: Install clippy and rustfmt
run: rustup component add clippy rustfmt
- name: Cargo fmt (check)
run: cargo fmt --all -- --check
- name: Clippy
shell: bash
run: |
FLAGS=""
if [ "${{ matrix.no-default-features }}" = "true" ]; then FLAGS="$FLAGS --no-default-features"; fi
if [ -n "${{ matrix.features }}" ]; then FLAGS="$FLAGS --features ${{ matrix.features }}"; fi
echo "Running: cargo clippy --all-targets $FLAGS -- -D warnings"
cargo clippy --all-targets $FLAGS -- -D warnings
- name: Tests
shell: bash
run: |
FLAGS=""
if [ "${{ matrix.no-default-features }}" = "true" ]; then FLAGS="$FLAGS --no-default-features"; fi
if [ -n "${{ matrix.features }}" ]; then FLAGS="$FLAGS --features ${{ matrix.features }}"; fi
echo "Running: cargo test $FLAGS -- --nocapture"
cargo test $FLAGS -- --nocapture
- name: Build Docs
shell: bash
run: |
cargo doc -p muxox --no-deps

View File

@@ -1,225 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
env:
CARGO_TERM_COLOR: always
jobs:
docs:
name: Build and validate documentation
runs-on: ubuntu-latest
defaults:
run:
working-directory: crates/muxox
strategy:
fail-fast: false
matrix:
include:
- name: default-features
features: ""
no-default-features: false
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Setup Rust
run: rustup update stable && rustup default stable
- name: Build documentation
shell: bash
run: |
FLAGS=""
if [ "${{ matrix.no-default-features }}" = "true" ]; then FLAGS="$FLAGS --no-default-features"; fi
if [ -n "${{ matrix.features }}" ]; then FLAGS="$FLAGS --features ${{ matrix.features }}"; fi
echo "Running: cargo doc $FLAGS --no-deps"
cargo doc $FLAGS --no-deps
- name: Check documentation warnings
shell: bash
run: |
FLAGS=""
if [ "${{ matrix.no-default-features }}" = "true" ]; then FLAGS="$FLAGS --no-default-features"; fi
if [ -n "${{ matrix.features }}" ]; then FLAGS="$FLAGS --features ${{ matrix.features }}"; fi
echo "Running: cargo doc $FLAGS --no-deps"
RUSTDOCFLAGS="-D warnings" cargo doc $FLAGS --no-deps
- name: Test documentation examples
shell: bash
run: |
FLAGS=""
if [ "${{ matrix.no-default-features }}" = "true" ]; then FLAGS="$FLAGS --no-default-features"; fi
if [ -n "${{ matrix.features }}" ]; then FLAGS="$FLAGS --features ${{ matrix.features }}"; fi
echo "Running: cargo test --doc $FLAGS"
cargo test --doc $FLAGS
test:
name: Test before release
runs-on: ubuntu-latest
needs: docs
defaults:
run:
working-directory: crates/muxox
strategy:
fail-fast: false
matrix:
include:
- name: default (native-tls)
features: ""
no-default-features: false
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Setup Rust
run: rustup update stable && rustup default stable
- name: Install clippy and rustfmt
run: rustup component add clippy rustfmt
- name: Cargo fmt (check)
run: cargo fmt --all -- --check
- name: Clippy
shell: bash
run: |
FLAGS=""
if [ "${{ matrix.no-default-features }}" = "true" ]; then FLAGS="$FLAGS --no-default-features"; fi
if [ -n "${{ matrix.features }}" ]; then FLAGS="$FLAGS --features ${{ matrix.features }}"; fi
echo "Running: cargo clippy --all-targets $FLAGS -- -D warnings"
cargo clippy --all-targets $FLAGS -- -D warnings
- name: Tests
shell: bash
run: |
FLAGS=""
if [ "${{ matrix.no-default-features }}" = "true" ]; then FLAGS="$FLAGS --no-default-features"; fi
if [ -n "${{ matrix.features }}" ]; then FLAGS="$FLAGS --features ${{ matrix.features }}"; fi
echo "Running: cargo test $FLAGS -- --nocapture"
cargo test $FLAGS -- --nocapture
publish:
name: Publish to crates.io
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC token exchange https://crates.io/docs/trusted-publishing
needs: test
defaults:
run:
working-directory: crates/muxox
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Setup Rust
run: rustup update stable && rustup default stable
- name: Verify tag matches version
run: |
TAG_VERSION=${GITHUB_REF#refs/tags/v}
CARGO_VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then
echo "Tag version ($TAG_VERSION) does not match Cargo.toml version ($CARGO_VERSION)"
exit 1
fi
# See Trusted publishing: https://crates.io/docs/trusted-publishing
- uses: rust-lang/crates-io-auth-action@v1
id: auth
- run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: [test, publish]
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract tag name
id: tag
run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Generate changelog
id: changelog
run: |
# Get the previous tag
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
# Generate changelog
if [ -n "$PREV_TAG" ]; then
echo "## What's Changed" > changelog.md
echo "" >> changelog.md
git log --pretty=format:"* %s (%h)" ${PREV_TAG}..HEAD >> changelog.md
echo "" >> changelog.md
echo "" >> changelog.md
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${{ steps.tag.outputs.tag }}" >> changelog.md
else
echo "## What's Changed" > changelog.md
echo "" >> changelog.md
echo "Initial release of muxox" >> changelog.md
echo "" >> changelog.md
echo "A small, ergonomic HTTP client wrapper around hyper with optional support for custom Root CAs and a dev-only insecure mode for self-signed certificates." >> changelog.md
fi
# Set the changelog as output (handle multiline)
echo "changelog<<EOF" >> $GITHUB_OUTPUT
cat changelog.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [[ "${{ steps.tag.outputs.tag }}" == *"-"* ]]; then
PRERELEASE_FLAG="--prerelease"
else
PRERELEASE_FLAG=""
fi
gh release create "${{ steps.tag.outputs.tag }}" \
--title "Release ${{ steps.tag.outputs.tag }}" \
--notes-file changelog.md \
$PRERELEASE_FLAG

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
* *
!crates/
!crates/** !crates/**
!.gitignore !.gitignore
!Cargo.toml !Cargo.toml

View File

@@ -1,5 +1,6 @@
[workspace] [workspace]
resolver = "3"
members = [ members = [
"crates/*" "crates/muxox"
] ]
default-members = ["crates/muxox"] default-members = ["crates/muxox"]

1209
crates/muxox/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

29
crates/muxox/Cargo.toml Normal file
View File

@@ -0,0 +1,29 @@
[package]
name = "muxox"
version = "0.1.0"
edition = "2024"
description = "A terminal-based service orchestrator and process multiplexer for development workflows"
authors = ["William Seemueller <william@seemueller.io>"]
license = "MIT"
repository = "https://github.com/seemueller-io/muxox"
homepage = "https://github.com/seemueller-io/muxox"
documentation = "https://github.com/seemueller-io/muxox"
readme = "../../README.md"
keywords = ["terminal", "tui", "multiplexer", "development", "process-manager"]
categories = ["command-line-utilities", "development-tools"]
[dependencies]
anyhow = "1"
thiserror = "1"
clap = { version = "4", features = ["derive"] }
ratatui = "0.28"
crossterm = "0.27"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
directories = "5"
libc = "0.2"
nix = { version = "0.28", features = ["signal", "process"] }
[features]
unix = []

View File

@@ -1,7 +1,6 @@
use std::{ use std::{
collections::VecDeque, collections::VecDeque,
fs, fs, io,
io,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::{Child, Command, Stdio}, process::{Child, Command, Stdio},
time::Duration, time::Duration,
@@ -12,17 +11,17 @@ use clap::Parser;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use ratatui::{ use ratatui::{
Terminal,
backend::CrosstermBackend, backend::CrosstermBackend,
layout::{Constraint, Direction, Layout}, layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
Terminal,
}; };
use serde::Deserialize; use serde::Deserialize;
use tokio::{io::AsyncBufReadExt, process::Command as AsyncCommand, sync::mpsc, task, time};
#[cfg(windows)] #[cfg(windows)]
use std::os::windows::process::CommandExt as _; use std::os::windows::process::CommandExt as _;
use tokio::{io::AsyncBufReadExt, process::Command as AsyncCommand, sync::mpsc, task, time};
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command(author, version, about = "Run multiple dev servers with a simple TUI.")] #[command(author, version, about = "Run multiple dev servers with a simple TUI.")]
@@ -46,7 +45,9 @@ struct ServiceCfg {
#[serde(default = "default_log_capacity")] #[serde(default = "default_log_capacity")]
log_capacity: usize, log_capacity: usize,
} }
fn default_log_capacity() -> usize { 2000 } fn default_log_capacity() -> usize {
2000
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -150,7 +151,12 @@ mod tests {
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Status { Stopped, Starting, Running, Stopping } enum Status {
Stopped,
Starting,
Running,
Stopping,
}
#[derive(Debug)] #[derive(Debug)]
struct ServiceState { struct ServiceState {
@@ -170,7 +176,9 @@ impl ServiceState {
} }
} }
fn push_log(&mut self, line: impl Into<String>) { fn push_log(&mut self, line: impl Into<String>) {
if self.log.len() == self.cfg.log_capacity { self.log.pop_front(); } if self.log.len() == self.cfg.log_capacity {
self.log.pop_front();
}
self.log.push_back(line.into()); self.log.push_back(line.into());
} }
} }
@@ -196,7 +204,11 @@ async fn main() -> Result<()> {
let cfg = load_config(cli.config.as_deref())?; let cfg = load_config(cli.config.as_deref())?;
let (tx, mut rx) = mpsc::unbounded_channel::<AppMsg>(); let (tx, mut rx) = mpsc::unbounded_channel::<AppMsg>();
let mut app = App { services: cfg.service.into_iter().map(ServiceState::new).collect(), selected: 0, tx: tx.clone() }; let mut app = App {
services: cfg.service.into_iter().map(ServiceState::new).collect(),
selected: 0,
tx: tx.clone(),
};
// Signal watcher: on any exit signal, nuke children then exit. // Signal watcher: on any exit signal, nuke children then exit.
task::spawn(signal_watcher(tx.clone())); task::spawn(signal_watcher(tx.clone()));
@@ -204,7 +216,11 @@ async fn main() -> Result<()> {
// TUI setup // TUI setup
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
crossterm::execute!(stdout, crossterm::terminal::EnterAlternateScreen, crossterm::event::EnableMouseCapture)?; crossterm::execute!(
stdout,
crossterm::terminal::EnterAlternateScreen,
crossterm::event::EnableMouseCapture
)?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
@@ -225,10 +241,14 @@ async fn main() -> Result<()> {
} }
} }
if handled { /* app mutated, redraw next loop */ } if handled { /* app mutated, redraw next loop */ }
if last_tick.elapsed() >= tick_rate { last_tick = time::Instant::now(); } if last_tick.elapsed() >= tick_rate {
last_tick = time::Instant::now();
}
// Drain channel // Drain channel
while let Ok(msg) = rx.try_recv() { apply_msg(&mut app, msg); } while let Ok(msg) = rx.try_recv() {
apply_msg(&mut app, msg);
}
} }
}); });
@@ -243,15 +263,41 @@ fn draw_ui(f: &mut ratatui::Frame, app: &App) {
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(f.area()); .split(f.area());
let items: Vec<ListItem> = app.services.iter().enumerate().map(|(_i, s)| { let items: Vec<ListItem> = app
let status = match s.status { Status::Stopped=>"", Status::Starting=>"", Status::Running=>"", Status::Stopping=>"" }; .services
let color = match s.status { Status::Running=>Color::Green, Status::Starting=>Color::Yellow, Status::Stopping=>Color::Magenta, Status::Stopped=>Color::DarkGray }; .iter()
ListItem::new(Line::from(vec![Span::styled(format!(" {status} "), Style::default().fg(color)), Span::raw(&s.cfg.name)])) .enumerate()
}).collect(); .map(|(_i, s)| {
let status = match s.status {
Status::Stopped => "",
Status::Starting => "",
Status::Running => "",
Status::Stopping => "",
};
let color = match s.status {
Status::Running => Color::Green,
Status::Starting => Color::Yellow,
Status::Stopping => Color::Magenta,
Status::Stopped => Color::DarkGray,
};
ListItem::new(Line::from(vec![
Span::styled(format!(" {status} "), Style::default().fg(color)),
Span::raw(&s.cfg.name),
]))
})
.collect();
let list = List::new(items) let list = List::new(items)
.block(Block::default().title("Services (↑/↓ select, Enter start/stop, r restart, c clear, q quit)").borders(Borders::ALL)) .block(
.highlight_style(Style::default().add_modifier(Modifier::BOLD).bg(Color::DarkGray)); Block::default()
.title("Services (↑/↓ select, Enter start/stop, r restart, c clear, q quit)")
.borders(Borders::ALL),
)
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::DarkGray),
);
f.render_stateful_widget(list, chunks[0], &mut list_state(app.selected)); f.render_stateful_widget(list, chunks[0], &mut list_state(app.selected));
@@ -263,11 +309,31 @@ fn draw_ui(f: &mut ratatui::Frame, app: &App) {
let selected = &app.services[app.selected]; let selected = &app.services[app.selected];
let header = Paragraph::new(vec![ let header = Paragraph::new(vec![
Line::from(vec![Span::styled("Name: ", Style::default().fg(Color::DarkGray)), Span::raw(&selected.cfg.name)]), Line::from(vec![
Line::from(vec![Span::styled("Cmd: ", Style::default().fg(Color::DarkGray)), Span::raw(&selected.cfg.cmd)]), Span::styled("Name: ", Style::default().fg(Color::DarkGray)),
Line::from(vec![Span::styled("Cwd: ", Style::default().fg(Color::DarkGray)), Span::raw(selected.cfg.cwd.as_ref().and_then(|p| p.to_str()).unwrap_or("."))]), Span::raw(&selected.cfg.name),
]),
Line::from(vec![
Span::styled("Cmd: ", Style::default().fg(Color::DarkGray)),
Span::raw(&selected.cfg.cmd),
]),
Line::from(vec![
Span::styled("Cwd: ", Style::default().fg(Color::DarkGray)),
Span::raw(
selected
.cfg
.cwd
.as_ref()
.and_then(|p| p.to_str())
.unwrap_or("."),
),
]),
]) ])
.block(Block::default().title("Selected Service").borders(Borders::ALL)); .block(
Block::default()
.title("Selected Service")
.borders(Borders::ALL),
);
let log_text: Vec<Line> = selected.log.iter().map(|l| Line::from(l.clone())).collect(); let log_text: Vec<Line> = selected.log.iter().map(|l| Line::from(l.clone())).collect();
let log = Paragraph::new(log_text) let log = Paragraph::new(log_text)
@@ -291,11 +357,28 @@ fn handle_key(k: KeyEvent, app: &mut App) -> bool {
cleanup_and_exit(app); cleanup_and_exit(app);
return true; return true;
} }
(KeyCode::Down, _) => { app.selected = (app.selected + 1).min(app.services.len()-1); return true; } (KeyCode::Down, _) => {
(KeyCode::Up, _) => { if app.selected>0 { app.selected -= 1; } return true; } app.selected = (app.selected + 1).min(app.services.len() - 1);
(KeyCode::Enter, _) | (KeyCode::Char(' '), _) => { toggle_selected(app); return true; } return true;
(KeyCode::Char('r'), _) => { restart_selected(app); return true; } }
(KeyCode::Char('c'), _) if k.modifiers == KeyModifiers::NONE => { app.services[app.selected].log.clear(); return true; } (KeyCode::Up, _) => {
if app.selected > 0 {
app.selected -= 1;
}
return true;
}
(KeyCode::Enter, _) | (KeyCode::Char(' '), _) => {
toggle_selected(app);
return true;
}
(KeyCode::Char('r'), _) => {
restart_selected(app);
return true;
}
(KeyCode::Char('c'), _) if k.modifiers == KeyModifiers::NONE => {
app.services[app.selected].log.clear();
return true;
}
_ => {} _ => {}
} }
false false
@@ -303,13 +386,27 @@ fn handle_key(k: KeyEvent, app: &mut App) -> bool {
fn toggle_selected(app: &mut App) { fn toggle_selected(app: &mut App) {
let idx = app.selected; let idx = app.selected;
match app.services[idx].status { Status::Stopped => { start_service(idx, app); }, Status::Running | Status::Starting => { stop_service(idx, app); }, Status::Stopping => {} } match app.services[idx].status {
Status::Stopped => {
start_service(idx, app);
}
Status::Running | Status::Starting => {
stop_service(idx, app);
}
Status::Stopping => {}
}
} }
fn restart_selected(app: &mut App) { let idx = app.selected; stop_service(idx, app); start_service(idx, app); } fn restart_selected(app: &mut App) {
let idx = app.selected;
stop_service(idx, app);
start_service(idx, app);
}
fn start_service(idx: usize, app: &mut App) { fn start_service(idx: usize, app: &mut App) {
if matches!(app.services[idx].status, Status::Running | Status::Starting) { return; } if matches!(app.services[idx].status, Status::Running | Status::Starting) {
return;
}
app.services[idx].status = Status::Starting; app.services[idx].status = Status::Starting;
let tx = app.tx.clone(); let tx = app.tx.clone();
let sc = app.services[idx].cfg.clone(); let sc = app.services[idx].cfg.clone();
@@ -317,7 +414,9 @@ fn start_service(idx: usize, app: &mut App) {
// Build command under a shell // Build command under a shell
let mut cmd = AsyncCommand::new(shell_program()); let mut cmd = AsyncCommand::new(shell_program());
cmd.arg(shell_flag()).arg(shell_exec(&sc.cmd)); cmd.arg(shell_flag()).arg(shell_exec(&sc.cmd));
if let Some(cwd) = sc.cwd.clone() { cmd.current_dir(cwd); } if let Some(cwd) = sc.cwd.clone() {
cmd.current_dir(cwd);
}
cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
set_process_group(&mut cmd); set_process_group(&mut cmd);
@@ -331,7 +430,9 @@ fn start_service(idx: usize, app: &mut App) {
let tx2 = tx.clone(); let tx2 = tx.clone();
task::spawn(async move { task::spawn(async move {
let mut reader = tokio::io::BufReader::new(out).lines(); let mut reader = tokio::io::BufReader::new(out).lines();
while let Ok(Some(line)) = reader.next_line().await { let _ = tx2.send(AppMsg::Log(idx, line)); } while let Ok(Some(line)) = reader.next_line().await {
let _ = tx2.send(AppMsg::Log(idx, line));
}
}); });
} }
// stderr // stderr
@@ -339,7 +440,9 @@ fn start_service(idx: usize, app: &mut App) {
let tx2 = tx.clone(); let tx2 = tx.clone();
task::spawn(async move { task::spawn(async move {
let mut reader = tokio::io::BufReader::new(err).lines(); let mut reader = tokio::io::BufReader::new(err).lines();
while let Ok(Some(line)) = reader.next_line().await { let _ = tx2.send(AppMsg::Log(idx, format!("[stderr] {line}"))); } while let Ok(Some(line)) = reader.next_line().await {
let _ = tx2.send(AppMsg::Log(idx, format!("[stderr] {line}")));
}
}); });
} }
@@ -348,17 +451,24 @@ fn start_service(idx: usize, app: &mut App) {
let code = status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1); let code = status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1);
let _ = tx.send(AppMsg::Stopped(idx, code)); let _ = tx.send(AppMsg::Stopped(idx, code));
} }
Err(e) => { let _ = tx.send(AppMsg::Log(idx, format!("spawn failed: {e}"))); let _ = tx.send(AppMsg::Stopped(idx, -1)); } Err(e) => {
let _ = tx.send(AppMsg::Log(idx, format!("spawn failed: {e}")));
let _ = tx.send(AppMsg::Stopped(idx, -1));
}
} }
}); });
} }
fn stop_service(idx: usize, app: &mut App) { fn stop_service(idx: usize, app: &mut App) {
let sc = &mut app.services[idx]; let sc = &mut app.services[idx];
if !matches!(sc.status, Status::Running | Status::Starting) { return; } if !matches!(sc.status, Status::Running | Status::Starting) {
return;
}
sc.status = Status::Stopping; sc.status = Status::Stopping;
sc.push_log("Stopping..."); sc.push_log("Stopping...");
if let Some(child) = sc.child.take() { drop(child); } // actual kill handled by kill_tree below if let Some(child) = sc.child.take() {
drop(child);
} // actual kill handled by kill_tree below
kill_tree(idx, app); kill_tree(idx, app);
} }
@@ -373,7 +483,9 @@ fn apply_msg(app: &mut App, msg: AppMsg) {
s.status = Status::Stopped; s.status = Status::Stopped;
s.push_log(format!("[exited: code {code}]").as_str()); s.push_log(format!("[exited: code {code}]").as_str());
} }
AppMsg::Log(i, line) => { app.services[i].push_log(line); } AppMsg::Log(i, line) => {
app.services[i].push_log(line);
}
AppMsg::AbortedAll => { /* UI can optionally display something */ } AppMsg::AbortedAll => { /* UI can optionally display something */ }
} }
} }
@@ -382,7 +494,11 @@ fn cleanup_and_exit(app: &mut App) {
// Restore terminal first to avoid leaving it raw if we panic later. // Restore terminal first to avoid leaving it raw if we panic later.
let _ = disable_raw_mode(); let _ = disable_raw_mode();
let mut stdout = io::stdout(); let mut stdout = io::stdout();
let _ = crossterm::execute!(stdout, crossterm::event::DisableMouseCapture, crossterm::terminal::LeaveAlternateScreen); let _ = crossterm::execute!(
stdout,
crossterm::event::DisableMouseCapture,
crossterm::terminal::LeaveAlternateScreen
);
// Kill all children forcefully // Kill all children forcefully
kill_all(app); kill_all(app);
@@ -391,7 +507,9 @@ fn cleanup_and_exit(app: &mut App) {
} }
fn kill_all(app: &mut App) { fn kill_all(app: &mut App) {
for i in 0..app.services.len() { kill_tree(i, app); } for i in 0..app.services.len() {
kill_tree(i, app);
}
} }
fn load_config(provided: Option<&Path>) -> Result<Config> { fn load_config(provided: Option<&Path>) -> Result<Config> {
@@ -399,7 +517,9 @@ fn load_config(provided: Option<&Path>) -> Result<Config> {
Some(p) => vec![p.to_path_buf()], Some(p) => vec![p.to_path_buf()],
None => { None => {
let mut v = vec![PathBuf::from("muxox.toml")]; let mut v = vec![PathBuf::from("muxox.toml")];
if let Some(proj) = directories::ProjectDirs::from("dev", "local", "devmux") { v.push(proj.config_dir().join("muxox.toml")); } if let Some(proj) = directories::ProjectDirs::from("dev", "local", "devmux") {
v.push(proj.config_dir().join("muxox.toml"));
}
v v
} }
}; };
@@ -414,7 +534,12 @@ fn load_config(provided: Option<&Path>) -> Result<Config> {
#[cfg(unix)] #[cfg(unix)]
fn set_process_group(cmd: &mut AsyncCommand) { fn set_process_group(cmd: &mut AsyncCommand) {
unsafe { cmd.pre_exec(|| { libc::setsid(); Ok(()) }) }; unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
})
};
} }
#[cfg(windows)] #[cfg(windows)]
fn set_process_group(cmd: &mut AsyncCommand) { fn set_process_group(cmd: &mut AsyncCommand) {
@@ -425,7 +550,11 @@ fn set_process_group(cmd: &mut AsyncCommand) {
#[cfg(unix)] #[cfg(unix)]
fn shell_program() -> &'static str { fn shell_program() -> &'static str {
if std::env::var("SHELL").ok().filter(|s| !s.is_empty()).is_some() { if std::env::var("SHELL")
.ok()
.filter(|s| !s.is_empty())
.is_some()
{
// Can't return a dynamically created String as &'static str // Can't return a dynamically created String as &'static str
// For simplicity, return a common shell path // For simplicity, return a common shell path
"/bin/bash" "/bin/bash"
@@ -434,22 +563,32 @@ fn shell_program() -> &'static str {
} }
} }
#[cfg(unix)] #[cfg(unix)]
fn shell_flag() -> &'static str { "-lc" } fn shell_flag() -> &'static str {
"-lc"
}
#[cfg(unix)] #[cfg(unix)]
fn shell_exec(cmd: &str) -> String { cmd.to_string() } fn shell_exec(cmd: &str) -> String {
cmd.to_string()
}
#[cfg(windows)] #[cfg(windows)]
fn shell_program() -> &'static str { "cmd.exe" } fn shell_program() -> &'static str {
"cmd.exe"
}
#[cfg(windows)] #[cfg(windows)]
fn shell_flag() -> &'static str { "/C" } fn shell_flag() -> &'static str {
"/C"
}
#[cfg(windows)] #[cfg(windows)]
fn shell_exec(cmd: &str) -> String { cmd.to_string() } fn shell_exec(cmd: &str) -> String {
cmd.to_string()
}
#[cfg(unix)] #[cfg(unix)]
fn kill_tree(idx: usize, app: &mut App) { fn kill_tree(idx: usize, app: &mut App) {
// These imports are left as warnings intentionally since they might be needed // These imports are left as warnings intentionally since they might be needed
// if we implement more advanced process group management in the future // if we implement more advanced process group management in the future
use nix::sys::signal::{killpg, Signal}; use nix::sys::signal::{Signal, killpg};
use nix::unistd::Pid; use nix::unistd::Pid;
let name = app.services[idx].cfg.name.clone(); let name = app.services[idx].cfg.name.clone();
// We don't track exact pgid; setsid() made child leader so killpg(-pid) works if we had it. // We don't track exact pgid; setsid() made child leader so killpg(-pid) works if we had it.
@@ -457,9 +596,17 @@ fn kill_tree(idx: usize, app: &mut App) {
// Simpler: store no pid; rely on pkill -f cmd as fallback. // Simpler: store no pid; rely on pkill -f cmd as fallback.
let cmdline = &app.services[idx].cfg.cmd; let cmdline = &app.services[idx].cfg.cmd;
// best-effort group kill via pkill // best-effort group kill via pkill
let _ = Command::new("pkill").arg("-TERM").arg("-f").arg(cmdline).status(); let _ = Command::new("pkill")
.arg("-TERM")
.arg("-f")
.arg(cmdline)
.status();
std::thread::sleep(Duration::from_millis(250)); std::thread::sleep(Duration::from_millis(250));
let _ = Command::new("pkill").arg("-KILL").arg("-f").arg(cmdline).status(); let _ = Command::new("pkill")
.arg("-KILL")
.arg("-f")
.arg(cmdline)
.status();
app.services[idx].push_log(format!("[killed {name}]")); app.services[idx].push_log(format!("[killed {name}]"));
} }
@@ -467,14 +614,17 @@ fn kill_tree(idx: usize, app: &mut App) {
fn kill_tree(idx: usize, app: &mut App) { fn kill_tree(idx: usize, app: &mut App) {
let name = app.services[idx].cfg.name.clone(); let name = app.services[idx].cfg.name.clone();
// Use taskkill to nuke the subtree // Use taskkill to nuke the subtree
let _ = Command::new("taskkill").args(["/F","/T","/FI"]).arg(format!("WINDOWTITLE eq {}", name)).status(); let _ = Command::new("taskkill")
.args(["/F", "/T", "/FI"])
.arg(format!("WINDOWTITLE eq {}", name))
.status();
// fallback: taskkill by image name is too coarse; skip // fallback: taskkill by image name is too coarse; skip
app.services[idx].push_log(format!("[killed {name}]")); app.services[idx].push_log(format!("[killed {name}]"));
} }
#[cfg(unix)] #[cfg(unix)]
async fn signal_watcher(tx: mpsc::UnboundedSender<AppMsg>) { async fn signal_watcher(tx: mpsc::UnboundedSender<AppMsg>) {
use tokio::signal::unix::{signal, SignalKind}; use tokio::signal::unix::{SignalKind, signal};
// Create and pin futures // Create and pin futures
let ctrlc = tokio::signal::ctrl_c(); let ctrlc = tokio::signal::ctrl_c();

View File

@@ -95,7 +95,8 @@ fn empty_service_array_is_valid() {
// A config with an empty service array is valid // A config with an empty service array is valid
let toml_input = "service = []"; let toml_input = "service = []";
let cfg: TestConfig = toml::from_str(toml_input).expect("config with empty service array should be valid"); let cfg: TestConfig =
toml::from_str(toml_input).expect("config with empty service array should be valid");
assert_eq!(cfg.services.len(), 0); assert_eq!(cfg.services.len(), 0);
} }
@@ -115,12 +116,21 @@ fn parses_real_muxox_toml() {
if let Ok(contents) = result { if let Ok(contents) = result {
let cfg: TestConfig = toml::from_str(&contents).expect("real muxox.toml should be valid"); let cfg: TestConfig = toml::from_str(&contents).expect("real muxox.toml should be valid");
assert!(!cfg.services.is_empty(), "muxox.toml should have at least one service"); assert!(
!cfg.services.is_empty(),
"muxox.toml should have at least one service"
);
// Verify it has the expected services (these assertions depend on the actual file content) // Verify it has the expected services (these assertions depend on the actual file content)
let service_names: Vec<&str> = cfg.services.iter().map(|s| s.name.as_str()).collect(); let service_names: Vec<&str> = cfg.services.iter().map(|s| s.name.as_str()).collect();
assert!(service_names.contains(&"frontend"), "Should have a frontend service"); assert!(
assert!(service_names.contains(&"backend"), "Should have a backend service"); service_names.contains(&"frontend"),
"Should have a frontend service"
);
assert!(
service_names.contains(&"backend"),
"Should have a backend service"
);
} else { } else {
// Test is still valuable even if we can't find the real file // Test is still valuable even if we can't find the real file
println!("Note: Could not find ../../muxox.toml, skipping part of test"); println!("Note: Could not find ../../muxox.toml, skipping part of test");

View File

@@ -14,14 +14,17 @@ mod unix_tests {
assert!(cfg!(unix), "This test should only run on Unix platforms"); assert!(cfg!(unix), "This test should only run on Unix platforms");
// We can test that standard Unix directories exist // We can test that standard Unix directories exist
assert!(std::path::Path::new("/bin/sh").exists() || std::path::Path::new("/usr/bin/sh").exists(), assert!(
"Expected to find a shell at /bin/sh or /usr/bin/sh on Unix"); std::path::Path::new("/bin/sh").exists()
|| std::path::Path::new("/usr/bin/sh").exists(),
"Expected to find a shell at /bin/sh or /usr/bin/sh on Unix"
);
} }
#[test] #[test]
fn test_unix_signals() { fn test_unix_signals() {
// We can test that nix/signal functionality works as expected // We can test that nix/signal functionality works as expected
use nix::sys::signal::{Signal, SigSet}; use nix::sys::signal::{SigSet, Signal};
// Create a signal set and verify basic operations // Create a signal set and verify basic operations
let mut set = SigSet::empty(); let mut set = SigSet::empty();
@@ -35,7 +38,10 @@ mod unix_tests {
mod windows_tests { mod windows_tests {
#[test] #[test]
fn test_windows_platform() { fn test_windows_platform() {
assert!(cfg!(windows), "This test should only run on Windows platforms"); assert!(
cfg!(windows),
"This test should only run on Windows platforms"
);
} }
} }
@@ -57,11 +63,17 @@ fn test_process_creation() {
if let Ok(output) = output { if let Ok(output) = output {
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
// On Windows, echo adds CRLF, on Unix just LF // On Windows, echo adds CRLF, on Unix just LF
let expected = if cfg!(windows) { "hello\r\n" } else { "hello\n" }; let expected = if cfg!(windows) {
"hello\r\n"
} else {
"hello\n"
};
assert!(stdout.contains("hello"), "Expected 'hello' in output"); assert!(stdout.contains("hello"), "Expected 'hello' in output");
// Optionally, do a more precise check with the expected output format // Optionally, do a more precise check with the expected output format
assert!(stdout.trim() == "hello" || stdout == expected, assert!(
"Output should be exactly 'hello' with optional newline formatting"); stdout.trim() == "hello" || stdout == expected,
"Output should be exactly 'hello' with optional newline formatting"
);
} }
} }
@@ -70,11 +82,15 @@ fn test_process_creation() {
fn test_environment_detection() { fn test_environment_detection() {
if cfg!(unix) { if cfg!(unix) {
// Unix environment checks // Unix environment checks
assert!(std::path::Path::new("/").exists(), "Root directory should exist on Unix"); assert!(
std::path::Path::new("/").exists(),
"Root directory should exist on Unix"
);
} else if cfg!(windows) { } else if cfg!(windows) {
// Windows environment checks // Windows environment checks
assert!(std::path::Path::new("C:\\").exists() || assert!(
std::path::Path::new("D:\\").exists(), std::path::Path::new("C:\\").exists() || std::path::Path::new("D:\\").exists(),
"Expected to find C: or D: drive on Windows"); "Expected to find C: or D: drive on Windows"
);
} }
} }

View File

@@ -1,10 +0,0 @@
[[service]]
name = "frontend"
cmd = "pnpm client:dev"
cwd = "./"
log_capacity = 5000
[[service]]
name = "backend"
cmd = "pnpm server:dev"
cwd = "./"