Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,12 @@ bssh -C production --stream "tail -f /var/log/syslog"
# [node1] Oct 30 10:15:23 systemd[1]: Started nginx.service
# [node2] Oct 30 10:15:24 kernel: [UFW BLOCK] IN=eth0 OUT=
# [node1] Oct 30 10:15:25 nginx: Configuration test successful

# Stream mode without hostname prefix (pdsh -N compatibility)
bssh -C production --stream --no-prefix "uname -a"
# Output (no [node] prefixes):
# Linux node1 5.15.0-generic
# Linux node2 5.15.0-generic
```

#### File Mode (Save to Per-Node Files)
Expand Down Expand Up @@ -930,6 +936,7 @@ Options:
-p, --parallel <PARALLEL> Maximum parallel connections [default: 10]
--timeout <TIMEOUT> Command timeout in seconds (0 for unlimited) [default: 300]
--output-dir <OUTPUT_DIR> Output directory for command results
-N, --no-prefix Disable hostname prefix in output (pdsh -N compatibility)
-v, --verbose Increase verbosity (-v, -vv, -vvv)
-h, --help Print help
-V, --version Print version
Expand Down
8 changes: 8 additions & 0 deletions docs/man/bssh.1
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,14 @@ monitoring long-running commands across multiple nodes. Automatically
disabled when output is piped or in CI environments. This disables the
interactive TUI mode.

.TP
.BR \-N ", " \-\-no\-prefix
Disable hostname prefix in output lines (pdsh -N compatibility). When
specified, output lines are displayed without the [hostname] prefix,
which is useful for programmatic parsing or cleaner display. Works
with both stream mode (--stream) and file mode (--output-dir).
Example: bssh -H host1,host2 --stream -N "uname -a"

.TP
.BR \-v ", " \-\-verbose
Increase verbosity (can be used multiple times: -v, -vv, -vvv)
Expand Down
1 change: 1 addition & 0 deletions src/app/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ async fn handle_exec_command(cli: &Cli, ctx: &AppContext, command: &str) -> Resu
use_keychain,
output_dir: cli.output_dir.as_deref(),
stream: cli.stream,
no_prefix: cli.no_prefix,
timeout,
jump_hosts: cli.jump_hosts.as_deref(),
port_forwards: if cli.has_port_forwards() {
Expand Down
7 changes: 7 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ pub struct Cli {
)]
pub stream: bool,

#[arg(
short = 'N',
long = "no-prefix",
help = "Disable hostname prefix in output lines (pdsh -N compatibility)\nUseful for programmatic parsing or cleaner display"
)]
pub no_prefix: bool,

#[arg(
long,
help = "Output directory for per-node command results\nCreates timestamped files:\n - hostname_TIMESTAMP.stdout (command output)\n - hostname_TIMESTAMP.stderr (error output)\n - hostname_TIMESTAMP.error (connection failures)\n - summary_TIMESTAMP.txt (execution summary)"
Expand Down
8 changes: 6 additions & 2 deletions src/commands/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ pub struct ExecuteCommandParams<'a> {
pub use_keychain: bool,
pub output_dir: Option<&'a Path>,
pub stream: bool,
pub no_prefix: bool,
pub timeout: Option<u64>,
pub jump_hosts: Option<&'a str>,
pub port_forwards: Option<Vec<ForwardingType>>,
Expand Down Expand Up @@ -213,8 +214,11 @@ async fn execute_command_without_forwarding(params: ExecuteCommandParams<'_>) ->
let executor = executor.with_keychain(params.use_keychain);

// Determine output mode
let output_mode =
OutputMode::from_args(params.stream, params.output_dir.map(|p| p.to_path_buf()));
let output_mode = OutputMode::from_args_with_no_prefix(
params.stream,
params.output_dir.map(|p| p.to_path_buf()),
params.no_prefix,
);

// Execute with appropriate mode
let results = if output_mode.is_normal() {
Expand Down
93 changes: 82 additions & 11 deletions src/executor/output_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ pub enum OutputMode {
/// Each line of output is prefixed with [hostname] and displayed
/// in real-time as it arrives. This allows monitoring long-running
/// commands across multiple nodes.
Stream,
/// The bool indicates whether to disable the prefix (no_prefix option).
Stream { no_prefix: bool },

/// File mode - save per-node output to separate files
///
/// Each node's output is saved to a separate file in the specified
/// directory. Files are named with hostname and timestamp.
File(PathBuf),
/// The bool indicates whether to disable the prefix in status messages.
File { path: PathBuf, no_prefix: bool },

/// TUI mode - interactive terminal UI with multiple view modes
///
Expand All @@ -61,10 +63,28 @@ impl OutputMode {
/// 3. Auto-detect TUI if TTY and no explicit mode
/// 4. Default (Normal mode)
pub fn from_args(stream: bool, output_dir: Option<PathBuf>) -> Self {
Self::from_args_with_no_prefix(stream, output_dir, false)
}

/// Create output mode from CLI arguments with no_prefix option
///
/// Priority:
/// 1. --output-dir (File mode)
/// 2. --stream (Stream mode)
/// 3. Auto-detect TUI if TTY and no explicit mode
/// 4. Default (Normal mode)
pub fn from_args_with_no_prefix(
stream: bool,
output_dir: Option<PathBuf>,
no_prefix: bool,
) -> Self {
if let Some(dir) = output_dir {
OutputMode::File(dir)
OutputMode::File {
path: dir,
no_prefix,
}
} else if stream {
OutputMode::Stream
OutputMode::Stream { no_prefix }
} else if is_tty() {
// Auto-enable TUI mode for interactive terminals
OutputMode::Tui
Expand All @@ -77,10 +97,25 @@ impl OutputMode {
///
/// Used when --no-tui or similar flags are present
pub fn from_args_explicit(stream: bool, output_dir: Option<PathBuf>, enable_tui: bool) -> Self {
Self::from_args_explicit_with_no_prefix(stream, output_dir, enable_tui, false)
}

/// Create output mode with explicit TUI disable option and no_prefix
///
/// Used when --no-tui or similar flags are present
pub fn from_args_explicit_with_no_prefix(
stream: bool,
output_dir: Option<PathBuf>,
enable_tui: bool,
no_prefix: bool,
) -> Self {
if let Some(dir) = output_dir {
OutputMode::File(dir)
OutputMode::File {
path: dir,
no_prefix,
}
} else if stream {
OutputMode::Stream
OutputMode::Stream { no_prefix }
} else if enable_tui && is_tty() {
OutputMode::Tui
} else {
Expand All @@ -95,12 +130,12 @@ impl OutputMode {

/// Check if this is stream mode
pub fn is_stream(&self) -> bool {
matches!(self, OutputMode::Stream)
matches!(self, OutputMode::Stream { .. })
}

/// Check if this is file mode
pub fn is_file(&self) -> bool {
matches!(self, OutputMode::File(_))
matches!(self, OutputMode::File { .. })
}

/// Check if this is TUI mode
Expand All @@ -111,10 +146,19 @@ impl OutputMode {
/// Get output directory if in file mode
pub fn output_dir(&self) -> Option<&PathBuf> {
match self {
OutputMode::File(dir) => Some(dir),
OutputMode::File { path, .. } => Some(path),
_ => None,
}
}

/// Check if prefix is disabled for this output mode
pub fn is_no_prefix(&self) -> bool {
match self {
OutputMode::Stream { no_prefix } => *no_prefix,
OutputMode::File { no_prefix, .. } => *no_prefix,
_ => false,
}
}
}

/// Check if stdout is a TTY
Expand Down Expand Up @@ -191,12 +235,15 @@ mod tests {
assert!(!normal.is_stream());
assert!(!normal.is_file());

let stream = OutputMode::Stream;
let stream = OutputMode::Stream { no_prefix: false };
assert!(!stream.is_normal());
assert!(stream.is_stream());
assert!(!stream.is_file());

let file = OutputMode::File(PathBuf::from("/tmp"));
let file = OutputMode::File {
path: PathBuf::from("/tmp"),
no_prefix: false,
};
assert!(!file.is_normal());
assert!(!file.is_stream());
assert!(file.is_file());
Expand All @@ -207,4 +254,28 @@ mod tests {
let mode = OutputMode::default();
assert!(mode.is_normal());
}

#[test]
fn test_no_prefix_option() {
// Stream mode with no_prefix
let mode = OutputMode::from_args_with_no_prefix(true, None, true);
assert!(mode.is_stream());
assert!(mode.is_no_prefix());

// Stream mode without no_prefix
let mode = OutputMode::from_args_with_no_prefix(true, None, false);
assert!(mode.is_stream());
assert!(!mode.is_no_prefix());

// File mode with no_prefix
let dir = PathBuf::from("/tmp/output");
let mode = OutputMode::from_args_with_no_prefix(false, Some(dir.clone()), true);
assert!(mode.is_file());
assert!(mode.is_no_prefix());
assert_eq!(mode.output_dir(), Some(&dir));

// Normal mode (no_prefix doesn't apply)
let mode = OutputMode::Normal;
assert!(!mode.is_no_prefix());
}
}
59 changes: 44 additions & 15 deletions src/executor/output_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,22 +87,36 @@ where
/// Synchronized output writer for node prefixed output
pub struct NodeOutputWriter {
node_prefix: String,
no_prefix: bool,
}

impl NodeOutputWriter {
/// Create a new writer with a node prefix
/// Create a new writer with a node prefix (prefix enabled by default)
#[allow(dead_code)]
pub fn new(node_host: &str) -> Self {
Self::new_with_no_prefix(node_host, false)
}

/// Create a new writer with optional prefix disabled
pub fn new_with_no_prefix(node_host: &str, no_prefix: bool) -> Self {
Self {
node_prefix: format!("[{node_host}]"),
no_prefix,
}
}

/// Format a line with or without prefix based on configuration
fn format_line(&self, line: &str) -> String {
if self.no_prefix {
line.to_string()
} else {
format!("{} {}", self.node_prefix, line)
}
}

/// Write stdout lines with node prefix atomically
/// Write stdout lines with optional node prefix atomically
pub fn write_stdout_lines(&self, text: &str) -> io::Result<()> {
let lines: Vec<String> = text
.lines()
.map(|line| format!("{} {}", self.node_prefix, line))
.collect();
let lines: Vec<String> = text.lines().map(|line| self.format_line(line)).collect();

if !lines.is_empty() {
let mut stdout = STDOUT_MUTEX.lock().unwrap();
Expand All @@ -114,12 +128,9 @@ impl NodeOutputWriter {
Ok(())
}

/// Write stderr lines with node prefix atomically
/// Write stderr lines with optional node prefix atomically
pub fn write_stderr_lines(&self, text: &str) -> io::Result<()> {
let lines: Vec<String> = text
.lines()
.map(|line| format!("{} {}", self.node_prefix, line))
.collect();
let lines: Vec<String> = text.lines().map(|line| self.format_line(line)).collect();

if !lines.is_empty() {
let mut stderr = STDERR_MUTEX.lock().unwrap();
Expand All @@ -131,15 +142,15 @@ impl NodeOutputWriter {
Ok(())
}

/// Write a single stdout line with node prefix
/// Write a single stdout line with optional node prefix
pub fn write_stdout(&self, line: &str) -> io::Result<()> {
synchronized_println(&format!("{} {}", self.node_prefix, line))
synchronized_println(&self.format_line(line))
}

/// Write a single stderr line with node prefix
/// Write a single stderr line with optional node prefix
#[allow(dead_code)]
pub fn write_stderr(&self, line: &str) -> io::Result<()> {
synchronized_eprintln(&format!("{} {}", self.node_prefix, line))
synchronized_eprintln(&self.format_line(line))
}
}

Expand All @@ -151,6 +162,24 @@ mod tests {
fn test_node_output_writer() {
let writer = NodeOutputWriter::new("test-host");
assert_eq!(writer.node_prefix, "[test-host]");
assert!(!writer.no_prefix);
}

#[test]
fn test_node_output_writer_with_no_prefix() {
let writer = NodeOutputWriter::new_with_no_prefix("test-host", true);
assert_eq!(writer.node_prefix, "[test-host]");
assert!(writer.no_prefix);

// Test format_line with no_prefix enabled
assert_eq!(writer.format_line("test output"), "test output");

// Test with no_prefix disabled
let writer_with_prefix = NodeOutputWriter::new_with_no_prefix("test-host", false);
assert_eq!(
writer_with_prefix.format_line("test output"),
"[test-host] test output"
);
}

#[test]
Expand Down
Loading