Files
spacedrive/apps/cli/src/main.rs
T
Jamie Pineandcapy-ai-staging[bot] f07010c850 Launch the daemon with the base data dir so a named instance nests once (#3171)
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
2026-11-05 03:06:39 +00:00

827 lines
24 KiB
Rust

#![allow(warnings)]
use anyhow::Result;
use clap::{Parser, Subcommand};
use comfy_table::{presets::UTF8_BORDERS_ONLY, Attribute, Cell, Table};
use sd_client::{
daemon_socket_addr, ensure_daemon, is_daemon_running, CoreClient, DaemonLaunchConfig,
EnsureDaemonOutcome,
};
use std::path::Path;
use crate::util::output::{format_bytes, format_transfer};
/// Validate instance name to prevent path traversal attacks
fn validate_instance_name(instance: &str) -> Result<(), String> {
if instance.is_empty() {
return Err("Instance name cannot be empty".to_string());
}
if instance.len() > 64 {
return Err("Instance name too long (max 64 characters)".to_string());
}
if !instance
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err("Instance name contains invalid characters. Only alphanumeric, dash, and underscore allowed".to_string());
}
Ok(())
}
mod config;
mod context;
mod domains;
mod ui;
mod util;
use crate::context::{Context, OutputFormat};
use crate::domains::{
cloud, config as config_cmd,
daemon::{self, DaemonCmd},
devices::{self, DevicesCmd},
events::{self, EventsCmd},
file::{self, FileCmd},
index::{self, IndexCmd},
job::{self, JobCmd},
library::{self, LibraryCmd},
logs::{self, LogsCmd},
network::{self, NetworkCmd},
redundancy::{self, RedundancyCmd},
search::{self, SearchCmd},
source::{self, SourceCmd},
spaces::{self, SpacesCmd},
sync::{self, SyncCmd},
tag::{self, TagCmd},
thumbs::{self, ThumbsCmd},
update,
volume::{self, VolumeCmd},
};
use config_cmd::ConfigCmd;
// OutputFormat is defined in context.rs and shared across domains
/// Safely reset only Spacedrive v2 specific files and directories
/// This preserves any user data that might be in the data directory (like v1 backups)
fn reset_spacedrive_v2_data(data_dir: &Path) -> Result<()> {
let mut removed_items = Vec::new();
let mut errors = Vec::new();
// List of specific Spacedrive v2 files and directories to remove
let v2_items = [
"spacedrive.json", // Main app config
"device.json", // Device config
"libraries", // All v2 libraries
"logs", // Application logs
"job_logs", // Job logs
];
for item in &v2_items {
let path = data_dir.join(item);
if path.exists() {
let result = if path.is_dir() {
std::fs::remove_dir_all(&path)
} else {
std::fs::remove_file(&path)
};
match result {
Ok(()) => {
removed_items.push(item.to_string());
println!(" Removed: {}", item);
}
Err(e) => {
errors.push(format!("Failed to remove {}: {}", item, e));
println!(" Failed to remove {}: {}", item, e);
}
}
} else {
println!(" Not found: {}", item);
}
}
if !removed_items.is_empty() {
println!(
"Reset complete. Removed {} items: {}",
removed_items.len(),
removed_items.join(", ")
);
} else {
println!(" No Spacedrive v2 data found to reset.");
}
if !errors.is_empty() {
println!(" {} errors occurred during reset:", errors.len());
for error in &errors {
println!(" • {}", error);
}
// Don't fail the entire operation for partial cleanup failures
}
Ok(())
}
#[derive(Parser, Debug)]
#[command(
name = "spacedrive",
about = "Spacedrive v2 CLI (daemon client)",
version = update::VERSION_STRING
)]
struct Cli {
/// Path to spacedrive data directory
#[arg(long)]
data_dir: Option<std::path::PathBuf>,
/// Daemon instance name
#[arg(long)]
instance: Option<String>,
/// Run the command on a paired device, by name, slug, or id.
/// Library commands resolve against the target's own open library.
#[arg(long, global = true)]
device: Option<String>,
/// Output format
#[arg(long, value_enum, default_value = "human")]
format: OutputFormat,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Start the Spacedrive daemon
Start {
/// Run daemon in foreground (show logs)
#[arg(long)]
foreground: bool,
/// Index only the sources you add, leaving out the system volume, the
/// home folder and whole-drive maps
#[arg(long)]
no_default_sources: bool,
},
/// Stop the Spacedrive daemon
Stop {
/// Reset all data (requires confirmation)
#[arg(long)]
reset: bool,
},
/// Restart the Spacedrive daemon
Restart {
/// Run daemon in foreground after restart (show logs)
#[arg(long)]
foreground: bool,
/// Reset all data before restart (requires confirmation)
#[arg(long)]
reset: bool,
/// Index only the sources you add, leaving out the system volume, the
/// home folder and whole-drive maps
#[arg(long)]
no_default_sources: bool,
},
/// Core info
Status,
/// Configuration management
#[command(subcommand)]
Config(ConfigCmd),
/// Daemon management (auto-start, etc)
#[command(subcommand)]
Daemon(DaemonCmd),
/// Device operations (library database)
#[command(subcommand)]
Devices(DevicesCmd),
/// Monitor event bus in real-time
#[command(subcommand)]
Events(EventsCmd),
/// Libraries operations
#[command(subcommand)]
Library(LibraryCmd),
/// File operations
#[command(subcommand)]
File(FileCmd),
/// Indexing operations
#[command(subcommand)]
Index(IndexCmd),
/// Thumbnail generation and regeneration
#[command(subcommand)]
Thumbs(ThumbsCmd),
/// Source operations (track, list, freeze)
#[command(subcommand)]
Sources(SourceCmd),
/// Networking and pairing
#[command(subcommand)]
Network(NetworkCmd),
/// Job commands
#[command(subcommand)]
Job(JobCmd),
/// View and follow logs
#[command(subcommand)]
Logs(LogsCmd),
/// Redundancy / cross-volume replication awareness
#[command(subcommand)]
Redundancy(RedundancyCmd),
/// Search operations
#[command(subcommand)]
Search(SearchCmd),
/// Spaces operations
#[command(subcommand)]
Spaces(SpacesCmd),
/// Sync operations and metrics
#[command(subcommand)]
Sync(SyncCmd),
/// Tag operations
#[command(subcommand)]
Tag(TagCmd),
/// Volume operations
#[command(subcommand)]
Volume(VolumeCmd),
/// Invoke any registered op by name (generic passthrough)
Op {
/// Op name as registered, e.g. "mounts.status" or "tags.create"
name: String,
/// JSON payload for the op's input
#[arg(long, default_value = "{}")]
json: String,
/// Library id for library-scoped ops
#[arg(long)]
library: Option<uuid::Uuid>,
},
/// Serve the Model Context Protocol over stdio: every registered op as a tool
Mcp,
/// Interactive cloud storage setup
Cloud,
/// Update CLI and daemon to the latest build on the configured channel
Update {
/// Reinstall even if already on the latest build
#[arg(long)]
force: bool,
/// Do not ask for confirmation (for timers and scripts)
#[arg(long, short = 'y')]
yes: bool,
#[command(subcommand)]
timer: Option<update::TimerCmd>,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let base_data_dir = cli.data_dir.unwrap_or(sd_core::config::default_data_dir()?);
let instance = cli.instance;
// Validate instance name for security
if let Some(ref inst) = instance {
validate_instance_name(inst)
.map_err(|e| anyhow::anyhow!("Invalid instance name: {}", e))?;
}
// Same rule the daemon applies, so a client addressing an instance reads
// the state that instance actually owns.
let data_dir =
sd_core::infra::daemon::addr::instance_data_dir(base_data_dir.clone(), instance.as_deref());
let socket_addr = daemon_socket_addr(instance.as_deref()).to_string();
match cli.command {
Commands::Start {
foreground,
no_default_sources,
} => {
crate::ui::print_compact_logo();
println!("Starting daemon...");
let client = CoreClient::new(socket_addr.clone());
let current_exe = std::env::current_exe()?;
let launch = DaemonLaunchConfig {
daemon_path: current_exe
.parent()
.unwrap()
.join(sd_client::daemon_binary_name()),
base_data_dir: base_data_dir.clone(),
instance: instance.clone(),
no_default_sources,
};
if foreground {
if is_daemon_running(&client).await {
println!("Daemon is already running");
return Ok(());
}
// Foreground mode: inherit stdout/stderr so logs are visible
println!("Starting daemon in foreground mode...");
println!("Press Ctrl+C to stop the daemon");
println!("═══════════════════════════════════════════════════════");
match launch.command()?.status() {
Ok(status) => {
if status.success() {
println!("Daemon exited successfully");
} else {
return Err(anyhow::anyhow!("Daemon exited with error: {}", status));
}
}
Err(e) => {
return Err(anyhow::anyhow!("Failed to start daemon: {}", e));
}
}
} else {
// Background mode: ping first, spawn only if not running
match ensure_daemon(&client, &launch).await {
Ok(EnsureDaemonOutcome::AlreadyRunning) => {
println!("Daemon is already running");
return Ok(());
}
Ok(EnsureDaemonOutcome::Started { pid, responsive }) => {
println!("Daemon started (PID: {})", pid);
if responsive {
println!("Daemon is ready and responding");
println!("Use 'sd logs follow' to view daemon logs");
} else {
println!("Warning: Daemon may not be fully initialized yet");
println!("Use 'sd logs follow' to check daemon status");
}
}
Err(e) => {
return Err(anyhow::anyhow!("Failed to start daemon: {}", e));
}
}
}
}
Commands::Stop { reset } => {
if reset {
use crate::util::confirm::confirm_or_abort;
confirm_or_abort(
" This will permanently delete Spacedrive v2 data (libraries, settings, logs). Other files in the data directory will be preserved. Are you sure?",
false
)?;
}
println!("Stopping daemon...");
let core = CoreClient::new(socket_addr.clone());
let stop_result = core
.send_raw_request(&sd_core::infra::daemon::types::DaemonRequest::Shutdown)
.await;
match stop_result {
Ok(_) => {
println!("Daemon shutdown initiated.");
println!("Note: If jobs are running, the daemon will wait for them to pause before fully shutting down.");
println!("Use 'sd logs follow' to monitor shutdown progress.");
}
Err(_) => {
if reset {
println!(" Daemon was not running, proceeding with reset...");
} else {
println!(" Daemon was not running or already stopped.");
}
}
}
if reset {
println!("Resetting Spacedrive v2 data...");
reset_spacedrive_v2_data(&data_dir)?;
}
}
Commands::Restart {
foreground,
reset,
no_default_sources,
} => {
if reset {
use crate::util::confirm::confirm_or_abort;
confirm_or_abort(
" This will permanently delete Spacedrive v2 data (libraries, settings, logs) before restart. Other files in the data directory will be preserved. Are you sure?",
false
)?;
}
// First, try to stop the daemon if it's running
println!("Stopping daemon...");
let core = CoreClient::new(socket_addr.clone());
let stop_result = core
.send_raw_request(&sd_core::infra::daemon::types::DaemonRequest::Shutdown)
.await;
match stop_result {
Ok(_) => {
println!("Daemon shutdown initiated.");
println!("Waiting for daemon to fully shut down before restart...");
// Give some time for shutdown to complete
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
Err(_) => println!(" Daemon was not running or already stopped."),
}
// Reset data if requested
if reset {
println!("Resetting Spacedrive v2 data...");
reset_spacedrive_v2_data(&data_dir)?;
}
// Wait a moment for cleanup
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// Start the daemon again
println!("Starting daemon...");
let current_exe = std::env::current_exe()?;
let launch = DaemonLaunchConfig {
daemon_path: current_exe
.parent()
.unwrap()
.join(sd_client::daemon_binary_name()),
base_data_dir: base_data_dir.clone(),
instance: instance.clone(),
no_default_sources,
};
let mut cmd = launch.command()?;
if foreground {
// Foreground mode: inherit stdout/stderr so logs are visible
println!("Starting daemon in foreground mode...");
println!("Press Ctrl+C to stop the daemon");
println!("═══════════════════════════════════════════════════════");
match cmd.status() {
Ok(status) => {
if status.success() {
println!("Daemon exited successfully");
} else {
return Err(anyhow::anyhow!("Daemon exited with error: {}", status));
}
}
Err(e) => {
return Err(anyhow::anyhow!("Failed to start daemon: {}", e));
}
}
} else {
// Run in background
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::null());
let child = cmd.spawn()?;
println!("Daemon restarted (PID: {})", child.id());
// Wait a moment and check if it's still running
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
// Try to connect to verify it started successfully
let core = CoreClient::new(socket_addr.clone());
match core
.send_raw_request(&sd_core::infra::daemon::types::DaemonRequest::Ping)
.await
{
Ok(_) => println!("Daemon restart successful"),
Err(e) => {
println!(" Warning: Could not verify daemon status: {}", e);
println!("Use 'sd status' to check daemon status");
}
}
}
}
Commands::Config(cmd) => {
// CLI settings need no client; daemon settings reach the daemon
// only for the keys that live there.
config_cmd::run(data_dir, socket_addr, cmd).await?;
}
Commands::Daemon(cmd) => {
// Daemon management doesn't need the client, handle directly
daemon::run(data_dir, base_data_dir, instance, cmd).await?;
}
Commands::Mcp => {
// Serves without a daemon so an MCP client can list tools; each
// call reports the daemon as unreachable until it is started.
domains::mcp::run(data_dir, socket_addr, cli.device).await?;
}
Commands::Update { force, yes, timer } => match timer {
Some(cmd) => update::run_timer(data_dir, base_data_dir, instance, cmd).await?,
None => {
update::run(
data_dir,
base_data_dir,
instance,
socket_addr,
update::UpdateOptions { force, yes },
)
.await?
}
},
_ => {
run_client_command(cli.command, cli.format, data_dir, socket_addr, cli.device).await?;
}
}
Ok(())
}
async fn run_client_command(
command: Commands,
format: OutputFormat,
data_dir: std::path::PathBuf,
socket_addr: String,
device: Option<String>,
) -> Result<()> {
// Initialize device ID and slug from device.json if it exists
if let Ok(device_config) = std::fs::read_to_string(data_dir.join("device.json")) {
if let Ok(device_json) = serde_json::from_str::<serde_json::Value>(&device_config) {
if let Some(device_id_str) = device_json.get("id").and_then(|v| v.as_str()) {
if let Ok(device_id) = uuid::Uuid::parse_str(device_id_str) {
sd_core::device::set_current_device_id(device_id);
}
}
if let Some(device_slug) = device_json.get("slug").and_then(|v| v.as_str()) {
sd_core::device::set_current_device_slug(device_slug.to_string());
}
}
}
let core = CoreClient::new(socket_addr.clone()).with_device(device);
let mut ctx = Context::new(core, format, data_dir, socket_addr)?;
ctx.validate_and_fix_library().await?;
match command {
Commands::Op {
name,
json,
library,
} => {
use sd_core::infra::daemon::types::{DaemonRequest, DaemonResponse};
let payload: serde_json::Value = serde_json::from_str(&json)
.map_err(|e| anyhow::anyhow!("--json is not valid JSON: {e}"))?;
// The registry keys queries and actions differently; try the
// query namespace first, then fall through to actions.
let query = DaemonRequest::Query {
method: format!("query:{name}"),
library_id: library,
payload: payload.clone(),
device: ctx.core.device().map(String::from),
};
let mut response = ctx
.core
.send_raw_request(&query)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let is_unknown = matches!(
&response,
DaemonResponse::Error(err) if format!("{err:?}").contains("Unknown method")
);
if is_unknown {
let action = DaemonRequest::Action {
method: format!("action:{name}.input"),
library_id: library,
payload,
device: ctx.core.device().map(String::from),
};
response = ctx
.core
.send_raw_request(&action)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
match response {
DaemonResponse::JsonOk(value) => {
println!("{}", serde_json::to_string_pretty(&value)?)
}
DaemonResponse::Error(err) => anyhow::bail!("op '{name}' failed: {err:?}"),
other => anyhow::bail!("unexpected daemon response: {other:?}"),
}
}
Commands::Status => {
let status: sd_core::ops::core::status::output::CoreStatus =
execute_core_query!(ctx, ());
match ctx.format {
OutputFormat::Human => {
// Display logo
crate::ui::logo::print_logo_colored();
println!();
// Device Information
let mut device_table = Table::new();
device_table.load_preset(UTF8_BORDERS_ONLY);
device_table.set_header(vec![
Cell::new("Device Information").add_attribute(Attribute::Bold),
Cell::new(""),
]);
device_table.add_row(vec!["Name", &status.device_info.name]);
device_table.add_row(vec!["ID", &status.device_info.id.to_string()]);
device_table.add_row(vec!["OS", &status.device_info.os]);
if let Some(model) = &status.device_info.hardware_model {
device_table.add_row(vec!["Hardware", model]);
}
device_table.add_row(vec![
"Created",
&status
.device_info
.created_at
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string(),
]);
println!("{}", device_table);
println!();
// System Status
let mut system_table = Table::new();
system_table.load_preset(UTF8_BORDERS_ONLY);
system_table.set_header(vec![
Cell::new("System Status").add_attribute(Attribute::Bold),
Cell::new(""),
]);
system_table.add_row(vec!["Core Version", &status.version]);
system_table.add_row(vec!["Built At", &status.built_at]);
system_table.add_row(vec!["Data Directory", &status.system.data_directory]);
if let Some(instance) = &status.system.instance_name {
system_table.add_row(vec!["Instance", instance]);
}
if let Some(current_lib) = &status.system.current_library {
system_table.add_row(vec!["Current Library", current_lib]);
} else {
system_table.add_row(vec!["Current Library", "None"]);
}
if let Some(uptime) = status.system.uptime {
let hours = uptime / 3600;
let minutes = (uptime % 3600) / 60;
system_table.add_row(vec!["Uptime", &format!("{}h {}m", hours, minutes)]);
}
system_table.add_row(vec!["Status", "● Running"]);
println!("{}", system_table);
println!();
// Libraries
let mut libraries_table = Table::new();
libraries_table.load_preset(UTF8_BORDERS_ONLY);
libraries_table.set_header(vec![
Cell::new(format!("Libraries ({})", status.library_count))
.add_attribute(Attribute::Bold),
Cell::new(""),
]);
if status.libraries.is_empty() {
libraries_table
.add_row(vec!["No libraries found".to_string(), "".to_string()]);
} else {
for lib in &status.libraries {
let lib_name = format!("● {}", lib.name);
libraries_table.add_row(vec![lib_name, lib.id.to_string()]);
libraries_table.add_row(vec![
format!(" Path: {}", lib.path.display()),
"".to_string(),
]);
if let Some(stats) = &lib.stats {
libraries_table.add_row(vec![
format!(" Files: {}", stats.total_files),
"".to_string(),
]);
libraries_table.add_row(vec![
format!(" Size: {}", format_bytes(stats.total_size)),
"".to_string(),
]);
libraries_table.add_row(vec![
format!(" Sources: {}", stats.source_count),
"".to_string(),
]);
}
}
}
println!("{}", libraries_table);
println!();
// Services
let mut services_table = Table::new();
services_table.load_preset(UTF8_BORDERS_ONLY);
services_table.set_header(vec![
Cell::new("Services").add_attribute(Attribute::Bold),
Cell::new(""),
]);
let services = &status.services;
let watcher_status = if services.location_watcher.running {
"● Running"
} else {
"○ Stopped"
};
services_table.add_row(vec!["Watcher", watcher_status]);
let net_status = if services.networking.running {
"● Running"
} else {
"○ Stopped"
};
services_table.add_row(vec!["Networking", net_status]);
let vol_status = if services.volume_monitor.running {
"● Running"
} else {
"○ Stopped"
};
services_table.add_row(vec!["Volume Monitor", vol_status]);
let share_status = if services.file_sharing.running {
"● Running"
} else {
"○ Stopped"
};
services_table.add_row(vec!["File Sharing", share_status]);
let replication = &status.replication;
let replication_status = if replication.paused {
"○ Paused".to_string()
} else if replication.transfers.is_empty() {
"● Idle".to_string()
} else {
format!(
"● Replicating {} source{}, {}",
replication.transfers.len(),
if replication.transfers.len() == 1 {
""
} else {
"s"
},
format_transfer(
replication.bytes(),
replication.total(),
replication.bytes_per_sec()
)
)
};
services_table.add_row(vec!["Replication", &replication_status]);
if replication.max_bytes_per_sec > 0 {
services_table.add_row(vec![
" Bandwidth cap",
&format!("{}/s", format_bytes(replication.max_bytes_per_sec)),
]);
}
println!("{}", services_table);
println!();
// Network
let mut network_table = Table::new();
network_table.load_preset(UTF8_BORDERS_ONLY);
network_table.set_header(vec![
Cell::new("Network").add_attribute(Attribute::Bold),
Cell::new(""),
]);
if status.network.running {
network_table.add_row(vec!["Status", "● Running"]);
if let Some(node_id) = &status.network.node_id {
let node_id_display = if node_id.len() > 50 {
format!("{}...", &node_id[..47])
} else {
node_id.clone()
};
network_table.add_row(vec!["Node ID", &node_id_display]);
}
network_table.add_row(vec![
"Paired devices",
&status.network.paired_devices.to_string(),
]);
network_table.add_row(vec![
"Connected devices",
&status.network.connected_devices.to_string(),
]);
if !status.network.addresses.is_empty() {
network_table.add_row(vec![
"Addresses",
&format!("{} address(es)", status.network.addresses.len()),
]);
for addr in &status.network.addresses {
network_table.add_row(vec![format!(" {}", addr), "".to_string()]);
}
}
network_table.add_row(vec!["Version", &status.network.version]);
} else {
network_table.add_row(vec!["Status", "○ Stopped"]);
}
println!("{}", network_table);
}
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&status)?),
}
}
Commands::Devices(cmd) => devices::run(&ctx, cmd).await?,
Commands::Events(cmd) => events::run(&ctx, cmd).await?,
Commands::Library(cmd) => library::run(&ctx, cmd).await?,
Commands::File(cmd) => file::run(&ctx, cmd).await?,
Commands::Index(cmd) => index::run(&ctx, cmd).await?,
Commands::Thumbs(cmd) => thumbs::run(&ctx, cmd).await?,
Commands::Sources(cmd) => source::run(&ctx, cmd).await?,
Commands::Network(cmd) => network::run(&ctx, cmd).await?,
Commands::Job(cmd) => job::run(&ctx, cmd).await?,
Commands::Sync(cmd) => sync::run(&ctx, cmd).await?,
Commands::Logs(cmd) => logs::run(&ctx, cmd).await?,
Commands::Redundancy(cmd) => redundancy::run(&ctx, cmd).await?,
Commands::Search(cmd) => search::run(&ctx, cmd).await?,
Commands::Spaces(cmd) => spaces::exec(cmd, &ctx).await?,
Commands::Tag(cmd) => tag::run(&ctx, cmd).await?,
Commands::Volume(cmd) => volume::run(&ctx, cmd).await?,
Commands::Cloud => cloud::run(&ctx).await?,
_ => {} // Start and Stop are handled in main
}
Ok(())
}