diff --git a/core-new/docs/cli-multi-instance.md b/core-new/docs/cli-multi-instance.md new file mode 100644 index 000000000..3ff4a1ea3 --- /dev/null +++ b/core-new/docs/cli-multi-instance.md @@ -0,0 +1,162 @@ +# Multi-Instance Daemon Support + +Spacedrive CLI now supports running multiple daemon instances simultaneously, enabling local testing of device pairing and other multi-device features. + +## Overview + +Multiple daemon instances allow you to: +- Test device pairing locally by running two instances +- Simulate multi-device scenarios on a single machine +- Isolate different development/testing environments +- Run production and development daemons side-by-side + +## Usage + +### Starting Multiple Instances + +```bash +# Start default instance +spacedrive start + +# Start named instances +spacedrive start --instance alice +spacedrive start --instance bob + +# Start with networking enabled +spacedrive start --instance alice --enable-networking +spacedrive start --instance bob --enable-networking +``` + +### Targeting Specific Instances + +Use the `--instance` flag to target commands to specific daemon instances: + +```bash +# Default instance +spacedrive library list + +# Named instances +spacedrive --instance alice library list +spacedrive --instance bob library create "Bob's Library" +``` + +### Instance Management + +```bash +# List all daemon instances +spacedrive instance list + +# Stop specific instance +spacedrive instance stop alice +spacedrive --instance bob stop # Alternative syntax + +# Check status of specific instance +spacedrive --instance alice daemon +``` + +### Device Pairing Example + +Test device pairing locally using two instances: + +```bash +# Terminal 1: Start Alice's daemon +spacedrive start --instance alice --enable-networking --foreground + +# Terminal 2: Start Bob's daemon +spacedrive start --instance bob --enable-networking --foreground + +# Terminal 3: Alice generates pairing code +spacedrive --instance alice network init --password "test123" +spacedrive --instance alice network pair generate --auto-accept + +# Terminal 4: Bob joins using Alice's code +spacedrive --instance bob network init --password "test123" +spacedrive --instance bob network pair join "word1 word2 word3 ... word12" +``` + +## Architecture + +### Instance Isolation + +Each instance has completely isolated: + +- **Socket paths**: `spacedrive.sock`, `spacedrive-alice.sock`, `spacedrive-bob.sock` +- **PID files**: `spacedrive.pid`, `spacedrive-alice.pid`, `spacedrive-bob.pid` +- **Data directories**: `data/spacedrive-cli-data/`, `data/spacedrive-cli-data/instance-alice/` +- **CLI state**: Separate `cli_state.json` per instance + +### File Structure + +``` +$runtime_dir/ # /tmp or $XDG_RUNTIME_DIR +├── spacedrive.sock # Default instance socket +├── spacedrive.pid # Default instance PID +├── spacedrive-alice.sock # Alice instance socket +├── spacedrive-alice.pid # Alice instance PID +├── spacedrive-bob.sock # Bob instance socket +└── spacedrive-bob.pid # Bob instance PID + +data/spacedrive-cli-data/ # Default instance data +├── spacedrive.json +├── libraries/ +└── cli_state.json + +data/spacedrive-cli-data/instance-alice/ # Alice instance data +├── spacedrive.json +├── libraries/ +└── cli_state.json + +data/spacedrive-cli-data/instance-bob/ # Bob instance data +├── spacedrive.json +├── libraries/ +└── cli_state.json +``` + +## Development Workflow + +### Testing Pairing Protocol + +```bash +# Start two instances for pairing test +spacedrive start --instance initiator --enable-networking --foreground & +spacedrive start --instance joiner --enable-networking --foreground & + +# Initialize networking +spacedrive --instance initiator network init --password "dev123" +spacedrive --instance joiner network init --password "dev123" + +# Test pairing +CODE=$(spacedrive --instance initiator network pair generate --auto-accept | grep "Pairing code:" | cut -d' ' -f3-) +spacedrive --instance joiner network pair join "$CODE" + +# Verify connection +spacedrive --instance initiator network devices +spacedrive --instance joiner network devices +``` + +### Instance Cleanup + +```bash +# Stop all instances +spacedrive instance list +spacedrive instance stop alice +spacedrive instance stop bob +spacedrive stop # Default instance + +# Clean up sockets (if needed) +rm /tmp/spacedrive*.sock /tmp/spacedrive*.pid +``` + +## Backwards Compatibility + +The implementation maintains full backwards compatibility: +- All existing commands work unchanged with the default instance +- No breaking changes to CLI interface +- Default instance behavior is identical to single-instance mode + +## Implementation Notes + +- Instance names must be valid filenames (no special characters) +- Socket discovery happens automatically via filesystem scanning +- Daemon startup checks for instance conflicts +- Each instance runs independently with separate process trees \ No newline at end of file diff --git a/core-new/src/infrastructure/cli/daemon.rs b/core-new/src/infrastructure/cli/daemon.rs index 74a008bd8..ac5dea46c 100644 --- a/core-new/src/infrastructure/cli/daemon.rs +++ b/core-new/src/infrastructure/cli/daemon.rs @@ -18,20 +18,48 @@ pub struct DaemonConfig { pub socket_path: PathBuf, pub pid_file: PathBuf, pub log_file: Option, + pub instance_name: Option, } impl Default for DaemonConfig { fn default() -> Self { + Self::new(None) + } +} + +impl DaemonConfig { + /// Create a new daemon config with optional instance name + pub fn new(instance_name: Option) -> Self { let runtime_dir = dirs::runtime_dir() .or_else(|| dirs::cache_dir()) .unwrap_or_else(|| PathBuf::from("/tmp")); + let (socket_name, pid_name, log_name) = if let Some(ref name) = instance_name { + ( + format!("spacedrive-{}.sock", name), + format!("spacedrive-{}.pid", name), + format!("spacedrive-{}.log", name) + ) + } else { + ( + "spacedrive.sock".to_string(), + "spacedrive.pid".to_string(), + "spacedrive.log".to_string() + ) + }; + Self { - socket_path: runtime_dir.join("spacedrive.sock"), - pid_file: runtime_dir.join("spacedrive.pid"), - log_file: Some(runtime_dir.join("spacedrive.log")), + socket_path: runtime_dir.join(socket_name), + pid_file: runtime_dir.join(pid_name), + log_file: Some(runtime_dir.join(log_name)), + instance_name, } } + + /// Get instance display name ("default" for None, or the actual name) + pub fn instance_display_name(&self) -> &str { + self.instance_name.as_deref().unwrap_or("default") + } } /// Commands that can be sent to the daemon @@ -179,6 +207,14 @@ pub struct Daemon { impl Daemon { /// Create a new daemon instance pub async fn new(data_dir: PathBuf) -> Result> { + Self::new_with_instance(data_dir, None).await + } + + /// Create a new daemon instance with optional instance name + pub async fn new_with_instance( + data_dir: PathBuf, + instance_name: Option, + ) -> Result> { let core = Arc::new(Core::new_with_config(data_dir).await?); // Ensure device is registered for all libraries @@ -231,7 +267,7 @@ impl Daemon { Ok(Self { core, - config: DaemonConfig::default(), + config: DaemonConfig::new(instance_name.clone()), start_time: std::time::Instant::now(), shutdown_tx: Arc::new(tokio::sync::Mutex::new(None)), }) @@ -241,6 +277,15 @@ impl Daemon { pub async fn new_with_networking( data_dir: PathBuf, networking_password: &str + ) -> Result> { + Self::new_with_networking_and_instance(data_dir, networking_password, None).await + } + + /// Create a new daemon instance with networking enabled and optional instance name + pub async fn new_with_networking_and_instance( + data_dir: PathBuf, + networking_password: &str, + instance_name: Option, ) -> Result> { let mut core = Core::new_with_config(data_dir).await?; @@ -300,7 +345,7 @@ impl Daemon { Ok(Self { core, - config: DaemonConfig::default(), + config: DaemonConfig::new(instance_name.clone()), start_time: std::time::Instant::now(), shutdown_tx: Arc::new(tokio::sync::Mutex::new(None)), }) @@ -353,7 +398,12 @@ impl Daemon { /// Check if daemon is running pub fn is_running() -> bool { - let config = DaemonConfig::default(); + Self::is_running_instance(None) + } + + /// Check if daemon instance is running + pub fn is_running_instance(instance_name: Option) -> bool { + let config = DaemonConfig::new(instance_name); if let Ok(pid_str) = std::fs::read_to_string(&config.pid_file) { if let Ok(pid) = pid_str.trim().parse::() { @@ -377,11 +427,16 @@ impl Daemon { /// Stop a running daemon pub async fn stop() -> Result<(), Box> { - let config = DaemonConfig::default(); + Self::stop_instance(None).await + } + + /// Stop a running daemon instance + pub async fn stop_instance(instance_name: Option) -> Result<(), Box> { + let config = DaemonConfig::new(instance_name.clone()); // First check if daemon is actually running - if !Self::is_running() { - return Err("Daemon is not running".into()); + if !Self::is_running_instance(instance_name) { + return Err(format!("Daemon instance '{}' is not running", config.instance_display_name()).into()); } // Try to connect and send shutdown command @@ -417,6 +472,69 @@ impl Daemon { Ok(()) } + + /// List all daemon instances + pub fn list_instances() -> Result, Box> { + let runtime_dir = dirs::runtime_dir() + .or_else(|| dirs::cache_dir()) + .unwrap_or_else(|| PathBuf::from("/tmp")); + + let mut instances = Vec::new(); + + // Find all spacedrive-*.sock files + if let Ok(entries) = std::fs::read_dir(&runtime_dir) { + for entry in entries.flatten() { + let file_name = entry.file_name(); + let file_str = file_name.to_string_lossy(); + + if file_str.starts_with("spacedrive") && file_str.ends_with(".sock") { + let instance_name = if file_str == "spacedrive.sock" { + None // Default instance + } else { + // Extract instance name from spacedrive-{name}.sock + Some(file_str.strip_prefix("spacedrive-") + .and_then(|s| s.strip_suffix(".sock")) + .unwrap_or("unknown") + .to_string()) + }; + + let is_running = Self::is_running_instance(instance_name.clone()); + instances.push(DaemonInstance { + name: instance_name, + socket_path: entry.path(), + is_running, + }); + } + } + } + + // Sort by name for consistent output + instances.sort_by(|a, b| { + match (&a.name, &b.name) { + (None, None) => std::cmp::Ordering::Equal, + (None, Some(_)) => std::cmp::Ordering::Less, // Default first + (Some(_), None) => std::cmp::Ordering::Greater, + (Some(a), Some(b)) => a.cmp(b), + } + }); + + Ok(instances) + } +} + +/// Daemon instance information +#[derive(Debug)] +pub struct DaemonInstance { + pub name: Option, // None for default instance + pub socket_path: PathBuf, + pub is_running: bool, +} + +impl DaemonInstance { + /// Get instance display name (\"default\" for None, or the actual name) + pub fn display_name(&self) -> &str { + self.name.as_deref().unwrap_or("default") + } } /// Handle a client connection @@ -938,12 +1056,19 @@ async fn handle_command( /// Client for communicating with the daemon pub struct DaemonClient { socket_path: PathBuf, + instance_name: Option, } impl DaemonClient { pub fn new() -> Self { + Self::new_with_instance(None) + } + + pub fn new_with_instance(instance_name: Option) -> Self { + let config = DaemonConfig::new(instance_name.clone()); Self { - socket_path: DaemonConfig::default().socket_path, + socket_path: config.socket_path, + instance_name, } } @@ -969,6 +1094,6 @@ impl DaemonClient { /// Check if daemon is running pub fn is_running(&self) -> bool { - Daemon::is_running() + Daemon::is_running_instance(self.instance_name.clone()) } } diff --git a/core-new/src/infrastructure/cli/mod.rs b/core-new/src/infrastructure/cli/mod.rs index 72e06a240..1d8fb9990 100644 --- a/core-new/src/infrastructure/cli/mod.rs +++ b/core-new/src/infrastructure/cli/mod.rs @@ -21,6 +21,10 @@ pub struct Cli { #[arg(short = 'v', long, global = true)] pub verbose: bool, + /// Daemon instance name (for multiple daemon support) + #[arg(long, global = true)] + pub instance: Option, + #[command(subcommand)] pub command: Commands, } @@ -79,11 +83,28 @@ pub enum Commands { /// Check if the daemon is running Daemon, + /// Manage daemon instances + #[command(subcommand)] + Instance(InstanceCommands), + /// Manage device networking and connections #[command(subcommand)] Network(commands::NetworkCommands), } +#[derive(Subcommand, Clone)] +pub enum InstanceCommands { + /// List all daemon instances + List, + /// Stop a specific daemon instance + Stop { + /// Instance name to stop + name: String + }, + /// Show currently targeted instance + Current, +} + pub async fn run() -> Result<(), Box> { let cli = Cli::parse(); @@ -96,10 +117,16 @@ pub async fn run() -> Result<(), Box> { )) .init(); - // Determine data directory - let data_dir = cli + // Determine data directory with instance isolation + let base_data_dir = cli .data_dir .unwrap_or_else(|| PathBuf::from("./data/spacedrive-cli-data")); + + let data_dir = if let Some(ref instance) = cli.instance { + base_data_dir.join(format!("instance-{}", instance)) + } else { + base_data_dir + }; // Handle daemon commands first (they don't need Core) match &cli.command { @@ -107,19 +134,27 @@ pub async fn run() -> Result<(), Box> { foreground, enable_networking, } => { - return handle_start_daemon(data_dir, *foreground, *enable_networking).await; + return handle_start_daemon(data_dir, *foreground, *enable_networking, cli.instance.clone()).await; } Commands::Stop => { - return handle_stop_daemon().await; + return handle_stop_daemon(cli.instance.clone()).await; } Commands::Daemon => { - return handle_daemon_status().await; + return handle_daemon_status(cli.instance.clone()).await; + } + Commands::Instance(instance_cmd) => { + return handle_instance_command(instance_cmd.clone()).await; } _ => { // For all other commands, check if daemon is running - if !daemon::Daemon::is_running() { - println!("❌ Spacedrive daemon is not running"); - println!(" Start it with: spacedrive start"); + if !daemon::Daemon::is_running_instance(cli.instance.clone()) { + let instance_display = cli.instance.as_deref().unwrap_or("default"); + println!("❌ Spacedrive daemon instance '{}' is not running", instance_display); + if cli.instance.is_some() { + println!(" Start it with: spacedrive --instance {} start", instance_display); + } else { + println!(" Start it with: spacedrive start"); + } return Ok(()); } } @@ -128,16 +163,16 @@ pub async fn run() -> Result<(), Box> { // For library, location, and job commands, use the daemon match &cli.command { Commands::Library(library_cmd) => { - return handle_library_daemon_command(library_cmd.clone()).await; + return handle_library_daemon_command(library_cmd.clone(), cli.instance.clone()).await; } Commands::Location(location_cmd) => { - return handle_location_daemon_command(location_cmd.clone()).await; + return handle_location_daemon_command(location_cmd.clone(), cli.instance.clone()).await; } Commands::Job(job_cmd) => { - return handle_job_daemon_command(job_cmd.clone()).await; + return handle_job_daemon_command(job_cmd.clone(), cli.instance.clone()).await; } Commands::Network(network_cmd) => { - return handle_network_daemon_command(network_cmd.clone()).await; + return handle_network_daemon_command(network_cmd.clone(), cli.instance.clone()).await; } Commands::Monitor => { // Special case - monitor needs event streaming @@ -151,7 +186,12 @@ pub async fn run() -> Result<(), Box> { // Initialize core (temporary - for commands not yet converted to daemon) let core = Core::new_with_config(data_dir.clone()).await?; - // Load CLI state + // Load CLI state (instance-specific) + let state_path = if cli.instance.is_some() { + data_dir.join("cli_state.json") + } else { + data_dir.join("cli_state.json") + }; let mut state = state::CliState::load(&data_dir)?; // Execute command @@ -168,7 +208,7 @@ pub async fn run() -> Result<(), Box> { } Commands::Monitor => monitor::run_monitor(&core).await?, Commands::Status => commands::handle_status_command(&core, &state).await?, - Commands::Start { .. } | Commands::Stop | Commands::Daemon | Commands::Network(_) => { + Commands::Start { .. } | Commands::Stop | Commands::Daemon | Commands::Instance(_) | Commands::Network(_) => { // These are handled above, should never reach here unreachable!() } @@ -187,9 +227,11 @@ async fn handle_start_daemon( data_dir: PathBuf, foreground: bool, enable_networking: bool, + instance_name: Option, ) -> Result<(), Box> { - if daemon::Daemon::is_running() { - println!("⚠️ Spacedrive daemon is already running"); + if daemon::Daemon::is_running_instance(instance_name.clone()) { + let instance_display = instance_name.as_deref().unwrap_or("default"); + println!("⚠️ Spacedrive daemon instance '{}' is already running", instance_display); return Ok(()); } @@ -205,17 +247,17 @@ async fn handle_start_daemon( println!(" Using default networking configuration."); println!(" Use 'spacedrive network init --password ' to set a custom password."); - match daemon::Daemon::new_with_networking(data_dir.clone(), default_password).await { + match daemon::Daemon::new_with_networking_and_instance(data_dir.clone(), default_password, instance_name.clone()).await { Ok(daemon) => daemon.start().await?, Err(e) => { println!("❌ Failed to start daemon with networking: {}", e); println!(" Falling back to daemon without networking..."); - let daemon = daemon::Daemon::new(data_dir).await?; + let daemon = daemon::Daemon::new_with_instance(data_dir, instance_name.clone()).await?; daemon.start().await?; } } } else { - let daemon = daemon::Daemon::new(data_dir).await?; + let daemon = daemon::Daemon::new_with_instance(data_dir, instance_name.clone()).await?; daemon.start().await?; } } else { @@ -229,6 +271,10 @@ async fn handle_start_daemon( .arg("--data-dir") .arg(data_dir); + if let Some(ref instance) = instance_name { + cmd.arg("--instance").arg(instance); + } + if enable_networking { cmd.arg("--enable-networking"); } @@ -255,45 +301,51 @@ async fn handle_start_daemon( // Wait a bit to see if it started tokio::time::sleep(std::time::Duration::from_secs(2)).await; - if daemon::Daemon::is_running() { - println!("✅ Spacedrive daemon started successfully"); + if daemon::Daemon::is_running_instance(instance_name.clone()) { + let instance_display = instance_name.as_deref().unwrap_or("default"); + println!("✅ Spacedrive daemon instance '{}' started successfully", instance_display); } else { - println!("❌ Failed to start Spacedrive daemon"); + let instance_display = instance_name.as_deref().unwrap_or("default"); + println!("❌ Failed to start Spacedrive daemon instance '{}'", instance_display); } } Ok(()) } -async fn handle_stop_daemon() -> Result<(), Box> { - if !daemon::Daemon::is_running() { - println!("⚠️ Spacedrive daemon is not running"); +async fn handle_stop_daemon(instance_name: Option) -> Result<(), Box> { + if !daemon::Daemon::is_running_instance(instance_name.clone()) { + let instance_display = instance_name.as_deref().unwrap_or("default"); + println!("⚠️ Spacedrive daemon instance '{}' is not running", instance_display); return Ok(()); } - println!("🛑 Stopping Spacedrive daemon..."); - daemon::Daemon::stop().await?; + let instance_display = instance_name.as_deref().unwrap_or("default"); + println!("🛑 Stopping Spacedrive daemon instance '{}'...", instance_display); + daemon::Daemon::stop_instance(instance_name.clone()).await?; // Wait a bit to ensure it's stopped tokio::time::sleep(std::time::Duration::from_secs(1)).await; - if !daemon::Daemon::is_running() { - println!("✅ Spacedrive daemon stopped"); + if !daemon::Daemon::is_running_instance(instance_name.clone()) { + println!("✅ Spacedrive daemon instance '{}' stopped", instance_display); } else { - println!("❌ Failed to stop Spacedrive daemon"); + println!("❌ Failed to stop Spacedrive daemon instance '{}'", instance_display); } Ok(()) } -async fn handle_daemon_status() -> Result<(), Box> { +async fn handle_daemon_status(instance_name: Option) -> Result<(), Box> { use colored::Colorize; - if daemon::Daemon::is_running() { - println!("✅ Spacedrive daemon is running"); + let instance_display = instance_name.as_deref().unwrap_or("default"); + + if daemon::Daemon::is_running_instance(instance_name.clone()) { + println!("✅ Spacedrive daemon instance '{}' is running", instance_display); // Try to get more info from daemon - let client = daemon::DaemonClient::new(); + let client = daemon::DaemonClient::new_with_instance(instance_name); // Get status match client.send_command(daemon::DaemonCommand::GetStatus).await { @@ -364,8 +416,73 @@ async fn handle_daemon_status() -> Result<(), Box> { _ => {} } } else { - println!("❌ Spacedrive daemon is not running"); - println!(" Start it with: spacedrive start"); + println!("❌ Spacedrive daemon instance '{}' is not running", instance_display); + if instance_name.is_some() { + println!(" Start it with: spacedrive --instance {} start", instance_display); + } else { + println!(" Start it with: spacedrive start"); + } + } + + Ok(()) +} + +async fn handle_instance_command( + cmd: InstanceCommands, +) -> Result<(), Box> { + use colored::Colorize; + + match cmd { + InstanceCommands::List => { + match daemon::Daemon::list_instances() { + Ok(instances) => { + if instances.is_empty() { + println!("📭 No daemon instances found"); + } else { + use comfy_table::Table; + let mut table = Table::new(); + table.set_header(vec!["Instance", "Status", "Socket Path"]); + + for instance in instances { + let status = if instance.is_running { + "Running".green() + } else { + "Stopped".red() + }; + + table.add_row(vec![ + instance.display_name().to_string(), + status.to_string(), + instance.socket_path.display().to_string(), + ]); + } + + println!("{}", table); + } + } + Err(e) => { + println!("❌ Failed to list instances: {}", e); + } + } + } + + InstanceCommands::Stop { name } => { + let instance_name = if name == "default" { None } else { Some(name.clone()) }; + match daemon::Daemon::stop_instance(instance_name).await { + Ok(_) => { + println!("✅ Daemon instance '{}' stopped", name); + } + Err(e) => { + println!("❌ Failed to stop instance '{}': {}", name, e); + } + } + } + + InstanceCommands::Current => { + // This would show the current instance based on CLI args or context + println!("Current instance functionality not yet implemented"); + println!("Use --instance flag to target specific instances"); + } } Ok(()) @@ -373,10 +490,11 @@ async fn handle_daemon_status() -> Result<(), Box> { async fn handle_library_daemon_command( cmd: commands::LibraryCommands, + instance_name: Option, ) -> Result<(), Box> { use colored::Colorize; - let client = daemon::DaemonClient::new(); + let client = daemon::DaemonClient::new_with_instance(instance_name.clone()); match cmd { commands::LibraryCommands::Create { name, path } => { @@ -500,10 +618,11 @@ async fn handle_library_daemon_command( async fn handle_location_daemon_command( cmd: commands::LocationCommands, + instance_name: Option, ) -> Result<(), Box> { use colored::Colorize; - let client = daemon::DaemonClient::new(); + let client = daemon::DaemonClient::new_with_instance(instance_name.clone()); match cmd { commands::LocationCommands::Add { path, name, mode } => { @@ -700,10 +819,11 @@ async fn handle_location_daemon_command( async fn handle_job_daemon_command( cmd: commands::JobCommands, + instance_name: Option, ) -> Result<(), Box> { use colored::Colorize; - let client = daemon::DaemonClient::new(); + let client = daemon::DaemonClient::new_with_instance(instance_name.clone()); match cmd { commands::JobCommands::List { status, recent: _ } => { @@ -898,10 +1018,11 @@ async fn handle_job_daemon_command( async fn handle_network_daemon_command( cmd: commands::NetworkCommands, + instance_name: Option, ) -> Result<(), Box> { use colored::Colorize; - let client = daemon::DaemonClient::new(); + let client = daemon::DaemonClient::new_with_instance(instance_name.clone()); // Check if daemon is running for most commands match &cmd {