diff --git a/Cargo.toml b/Cargo.toml index 3ffc13d..67f92d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,10 +46,56 @@ hex = "0.4" # Optional Tauri support tauri = { version = "2", optional = true } +# CLI dependencies (optional) +clap = { version = "4.5", features = ["derive", "cargo", "env"], optional = true } +tokio = { version = "1.40", features = ["full"], optional = true } +toml = { version = "0.8", optional = true } +dirs = { version = "5.0", optional = true } +env_logger = { version = "0.11", optional = true } +log = { version = "0.4", optional = true } +colored = { version = "2.1", optional = true } +indicatif = { version = "0.17", optional = true } +prettytable-rs = { version = "0.10", optional = true } +daemonize = { version = "0.5", optional = true } +sysinfo = { version = "0.30", optional = true } +qr2term = { version = "0.3", optional = true } +anyhow = { version = "1.0", optional = true } +signal-hook = { version = "0.3", optional = true } +hostname = { version = "0.4", optional = true } +rpassword = { version = "7.3", optional = true } +ctrlc = { version = "3.4", optional = true } + +# System dependencies +libc = "0.2" + [features] default = [] +cli = [ + "clap", + "tokio", + "toml", + "dirs", + "env_logger", + "log", + "colored", + "indicatif", + "prettytable-rs", + "daemonize", + "sysinfo", + "qr2term", + "anyhow", + "signal-hook", + "hostname", + "rpassword", + "ctrlc", +] tauri-api = ["tauri"] +[[bin]] +name = "nexus-cli" +path = "src/bin/nexus-cli.rs" +required-features = ["cli"] + [profile.release] opt-level = "z" # Optimize for size lto = true # Enable link-time optimization diff --git a/README.md b/README.md index 7e5f4ce..2d7ac72 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ [![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](#license) [![Crates.io](https://img.shields.io/crates/v/nexus-core)](https://crates.io/crates/nexus-core) -> Core Rust library for the FocusSuite ecosystem - providing database operations, business logic, and peer-to-peer synchronization capabilities. +> Core Rust library for the FocusSuite ecosystem - providing database operations, business logic, and peer-to-peer synchronization capabilities. Now available as a CLI tool for developers! ## Overview -`nexus-core` is a cross-platform Rust library that powers FocusSuite's productivity applications. It provides: +`nexus-core` is a cross-platform Rust library and CLI tool that powers FocusSuite's productivity applications. It provides: - **SQLite database** with automatic schema migrations - **User authentication** with Argon2 password hashing @@ -35,6 +35,8 @@ rustup default nightly ### Installation +#### As a Library + Add to your `Cargo.toml`: ```toml @@ -42,8 +44,23 @@ Add to your `Cargo.toml`: nexus-core = { path = "../nexus-core" } ``` +#### As a CLI Tool + +```bash +# Build and install +cargo install --path . --features cli + +# Or build from source +cargo build --release --features cli + +# Verify installation +nexus-cli --version +``` + ### Basic Usage +#### Library Usage + ```rust use nexus_core::{initialize_database, register_user, login_user}; @@ -64,6 +81,25 @@ let authenticated_user = login_user(&conn, "alice", "secure_password")?; println!("Welcome, {}!", authenticated_user.user_name); ``` +#### CLI Usage + +```bash +# Initialize with user +nexus-cli init --user alice --email alice@example.com + +# Start sync daemon +nexus-cli start --daemon + +# Check status +nexus-cli status + +# View logs +nexus-cli logs --follow + +# For complete CLI documentation: +# See docs/CLI_USAGE.md +``` + ## Features ### Database & Migrations @@ -201,6 +237,7 @@ cargo test -- --nocapture | Document | Description | |----------|-------------| +| [CLI_USAGE.md](docs/CLI_USAGE.md) | **Complete CLI tool guide** | | [DATABASE_MIGRATIONS.md](docs/DATABASE_MIGRATIONS.md) | Complete migration system guide | | [MIGRATION_QUICK_START.md](docs/MIGRATION_QUICK_START.md) | Quick reference for migrations | | [MIGRATION_SYSTEM_SUMMARY.md](MIGRATION_SYSTEM_SUMMARY.md) | Implementation summary | diff --git a/docs/CLI_USAGE.md b/docs/CLI_USAGE.md new file mode 100644 index 0000000..651931c --- /dev/null +++ b/docs/CLI_USAGE.md @@ -0,0 +1,774 @@ +# Nexus CLI Usage Guide + +The Nexus CLI is a command-line interface for managing and synchronizing data across devices using the Nexus synchronization engine. + +## Table of Contents + +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Commands](#commands) + - [Initialization](#initialization) + - [Daemon Management](#daemon-management) + - [Sync Operations](#sync-operations) + - [Peer Management](#peer-management) + - [Device Management](#device-management) + - [Configuration](#configuration) + - [Logs & Debugging](#logs--debugging) + - [Utilities](#utilities) +- [Configuration File](#configuration-file) +- [Examples](#examples) +- [Troubleshooting](#troubleshooting) + +## Installation + +### From Source + +```bash +# Clone the repository +git clone https://github.com/kodfikirsanat/focussuite +cd nexus-core + +# Build with CLI feature +cargo build --release --features cli + +# Install the binary +cargo install --path . --features cli + +# Verify installation +nexus-cli --version +``` + +### From Cargo + +```bash +cargo install nexus-cli --features cli +``` + +## Quick Start + +1. **Initialize the database and create a user:** + +```bash +nexus-cli init --user alice --email alice@example.com +``` + +You'll be prompted to enter a password. + +2. **Start the sync daemon:** + +```bash +# Foreground mode (recommended for first-time setup) +nexus-cli start + +# Background mode +nexus-cli start --daemon +``` + +3. **Check sync status:** + +```bash +nexus-cli status +``` + +4. **View logs:** + +```bash +nexus-cli logs --follow +``` + +## Commands + +### Initialization + +#### `nexus-cli init` + +Initialize a new Nexus database and configuration. + +**Options:** +- `--path ` - Database path (default: `~/.nexus/nexus.db`) +- `--user ` - Username for initial user +- `--email ` - Email for initial user +- `--password ` - Password (will prompt if not provided) + +**Examples:** + +```bash +# Initialize with user +nexus-cli init --user alice --email alice@example.com + +# Initialize with custom database path +nexus-cli init --path /var/lib/nexus/db.sqlite --user alice --email alice@example.com + +# Initialize database only (no user) +nexus-cli init +``` + +### Daemon Management + +#### `nexus-cli start` + +Start the synchronization daemon. + +**Options:** +- `--daemon, -d` - Run in background mode +- `--port ` - Listen port (default: 0 for random) +- `--config ` - Configuration file path + +**Examples:** + +```bash +# Start in foreground (recommended for debugging) +nexus-cli start + +# Start in background +nexus-cli start --daemon + +# Start on specific port +nexus-cli start --port 9000 +``` + +#### `nexus-cli stop` + +Stop the running daemon. + +```bash +nexus-cli stop +``` + +#### `nexus-cli restart` + +Restart the daemon. + +**Options:** +- `--daemon, -d` - Run in background after restart + +```bash +# Restart in foreground +nexus-cli restart + +# Restart in background +nexus-cli restart --daemon +``` + +#### `nexus-cli status` + +Show daemon status and synchronization information. + +**Options:** +- `--watch, -w` - Watch mode (continuous monitoring) +- `--interval ` - Update interval for watch mode (default: 2) +- `--json` - Output in JSON format + +**Examples:** + +```bash +# Single status check +nexus-cli status + +# Watch mode (updates every 2 seconds) +nexus-cli status --watch + +# Watch with custom interval +nexus-cli status --watch --interval 5 + +# JSON output +nexus-cli status --json +``` + +### Sync Operations + +#### `nexus-cli sync` + +Trigger a synchronization now. + +**Options:** +- `--force, -f` - Force full synchronization + +**Examples:** + +```bash +# Trigger sync +nexus-cli sync + +# Force full sync +nexus-cli sync --force +``` + +### Peer Management + +#### `nexus-cli peer list` + +List all connected peers. + +**Options:** +- `--json` - Output in JSON format + +```bash +# List peers +nexus-cli peer list + +# JSON output +nexus-cli peer list --json +``` + +#### `nexus-cli peer add ` + +Add a peer or bootstrap node. + +```bash +nexus-cli peer add "/ip4/192.168.1.100/tcp/9000/p2p/12D3KooWABC..." +``` + +#### `nexus-cli peer remove ` + +Remove a peer. + +```bash +nexus-cli peer remove "12D3KooWABC..." +``` + +#### `nexus-cli peer info ` + +Show detailed information about a peer. + +**Options:** +- `--json` - Output in JSON format + +```bash +# Show peer info +nexus-cli peer info "12D3KooWABC..." + +# JSON output +nexus-cli peer info "12D3KooWABC..." --json +``` + +### Device Management + +#### `nexus-cli device list` + +List all user devices. + +**Options:** +- `--json` - Output in JSON format + +```bash +# List devices +nexus-cli device list + +# JSON output +nexus-cli device list --json +``` + +#### `nexus-cli device pair` + +Generate a QR code for pairing a new device. + +**Options:** +- `--device-type ` - Device type (default: mobile) +- `--name ` - Device name + +```bash +# Generate pairing QR for mobile +nexus-cli device pair + +# Generate for specific device type +nexus-cli device pair --device-type tablet --name "iPad Pro" +``` + +#### `nexus-cli device authorize ` + +Authorize a device using a pairing code. + +```bash +nexus-cli device authorize "ABC123DEF456" +``` + +#### `nexus-cli device remove ` + +Remove a device from the user account. + +```bash +nexus-cli device remove "550e8400-e29b-41d4-a716-446655440000" +``` + +### Configuration + +#### `nexus-cli config set ` + +Set a configuration value. + +**Examples:** + +```bash +# Enable mDNS discovery +nexus-cli config set sync.enable_mdns true + +# Set listen port +nexus-cli config set network.listen_port 9000 + +# Set log level +nexus-cli config set logging.level debug +``` + +#### `nexus-cli config get ` + +Get a configuration value. + +```bash +# Get database path +nexus-cli config get database.path + +# Get sync status +nexus-cli config get sync.enabled +``` + +#### `nexus-cli config list` + +List all configuration settings. + +**Options:** +- `--json` - Output in JSON format + +```bash +# List all config +nexus-cli config list + +# JSON output +nexus-cli config list --json +``` + +#### `nexus-cli config edit` + +Open the configuration file in your default editor. + +```bash +nexus-cli config edit +``` + +The editor is determined by the `$EDITOR` environment variable (defaults to `vi` on Unix, `notepad` on Windows). + +### Logs & Debugging + +#### `nexus-cli logs` + +View synchronization logs. + +**Options:** +- `--follow, -f` - Follow log output (like `tail -f`) +- `--lines, -n ` - Number of lines to show (default: 50) +- `--level ` - Filter by log level (trace, debug, info, warn, error) + +**Examples:** + +```bash +# View last 50 lines +nexus-cli logs + +# Follow logs in real-time +nexus-cli logs --follow + +# Show last 100 lines +nexus-cli logs --lines 100 + +# Filter by level +nexus-cli logs --level error --follow +``` + +#### `nexus-cli query ` + +Execute a SQL query on the database (for debugging). + +**Options:** +- `--json` - Output in JSON format + +**Examples:** + +```bash +# Query users +nexus-cli query "SELECT * FROM users" + +# Count tasks +nexus-cli query "SELECT COUNT(*) FROM tasks" + +# JSON output +nexus-cli query "SELECT * FROM users" --json +``` + +**⚠️ Warning:** This command provides direct database access. Use with caution! + +#### `nexus-cli oplog` + +View the operation log (sync history). + +**Options:** +- `--since ` - Show entries since timestamp +- `--device ` - Filter by device ID +- `--limit ` - Number of entries to show (default: 50) +- `--json` - Output in JSON format + +**Examples:** + +```bash +# View recent operations +nexus-cli oplog + +# View last 100 operations +nexus-cli oplog --limit 100 + +# View operations from specific device +nexus-cli oplog --device "550e8400-e29b-41d4-a716-446655440000" + +# View operations since timestamp +nexus-cli oplog --since 1704067200 + +# JSON output +nexus-cli oplog --json +``` + +### Utilities + +#### `nexus-cli info` + +Show system and version information. + +**Options:** +- `--json` - Output in JSON format + +```bash +# Show info +nexus-cli info + +# JSON output +nexus-cli info --json +``` + +#### `nexus-cli doctor` + +Run diagnostic checks on the system. + +```bash +nexus-cli doctor +``` + +This command checks: +- Configuration file existence +- Database existence and connectivity +- User configuration +- Device configuration + +#### `nexus-cli export ` + +Export the database to a file. + +```bash +nexus-cli export backup.db +``` + +#### `nexus-cli import ` + +Import a database from a file. + +**Options:** +- `--force, -f` - Overwrite existing database + +```bash +# Import database +nexus-cli import backup.db + +# Force overwrite +nexus-cli import backup.db --force +``` + +## Configuration File + +The configuration file is located at `~/.nexus/config.toml` by default. + +### Configuration Structure + +```toml +[database] +path = "~/.nexus/nexus.db" +auto_migrate = true + +[user] +id = "550e8400-e29b-41d4-a716-446655440000" +name = "alice" +email = "alice@example.com" + +[device] +id = "660e8400-e29b-41d4-a716-446655440001" +type = "cli" +name = "my-laptop" + +[sync] +enabled = true +auto_start = false +enable_mdns = true +enable_relay = true +heartbeat_interval_secs = 10 +max_message_size = 65536 + +[network] +listen_port = 0 # 0 = random port +listen_address = "0.0.0.0" +bootstrap_nodes = [] +relay_servers = [] + +[logging] +level = "info" # trace, debug, info, warn, error +format = "pretty" # pretty, json, compact +file = "~/.nexus/nexus.log" +max_size_mb = 100 +max_files = 5 +``` + +### Configuration Keys + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `database.path` | string | `~/.nexus/nexus.db` | Database file path | +| `database.auto_migrate` | boolean | `true` | Auto-apply database migrations | +| `sync.enabled` | boolean | `true` | Enable synchronization | +| `sync.auto_start` | boolean | `false` | Auto-start daemon on login | +| `sync.enable_mdns` | boolean | `true` | Enable mDNS peer discovery | +| `sync.enable_relay` | boolean | `true` | Enable relay servers | +| `sync.heartbeat_interval_secs` | integer | `10` | Heartbeat interval in seconds | +| `sync.max_message_size` | integer | `65536` | Max sync message size in bytes | +| `network.listen_port` | integer | `0` | Listen port (0 = random) | +| `network.listen_address` | string | `"0.0.0.0"` | Listen address | +| `network.bootstrap_nodes` | array | `[]` | Bootstrap node multiaddresses | +| `network.relay_servers` | array | `[]` | Relay server multiaddresses | +| `logging.level` | string | `"info"` | Log level | +| `logging.format` | string | `"pretty"` | Log format | +| `logging.file` | string | `~/.nexus/nexus.log` | Log file path | +| `logging.max_size_mb` | integer | `100` | Max log file size in MB | +| `logging.max_files` | integer | `5` | Max number of log files | + +## Examples + +### Complete Setup Workflow + +```bash +# 1. Initialize with user +nexus-cli init --user alice --email alice@example.com + +# 2. Configure bootstrap nodes (optional) +nexus-cli config set network.bootstrap_nodes '["node1.example.com", "node2.example.com"]' + +# 3. Start daemon in foreground (for testing) +nexus-cli start + +# In another terminal: +# 4. Check status +nexus-cli status + +# 5. View logs +nexus-cli logs --follow + +# 6. Once verified, stop foreground daemon +# (Ctrl+C in the daemon terminal) + +# 7. Start in background +nexus-cli start --daemon + +# 8. Verify it's running +nexus-cli status +``` + +### Monitoring and Debugging + +```bash +# Watch status continuously +nexus-cli status --watch + +# Follow logs with error filter +nexus-cli logs --follow --level error + +# Check system health +nexus-cli doctor + +# View recent sync operations +nexus-cli oplog --limit 20 + +# Query database +nexus-cli query "SELECT COUNT(*) as total FROM tasks" +``` + +### Multi-Device Setup + +On the primary device: +```bash +# Generate pairing QR code +nexus-cli device pair +``` + +On the new device: +```bash +# Initialize and authorize +nexus-cli init +nexus-cli device authorize "PAIRING_CODE_FROM_QR" +nexus-cli start --daemon +``` + +### Backup and Restore + +```bash +# Backup database +nexus-cli export ~/backups/nexus-backup-$(date +%Y%m%d).db + +# Restore database +nexus-cli stop +nexus-cli import ~/backups/nexus-backup-20250109.db --force +nexus-cli start --daemon +``` + +## Troubleshooting + +### Daemon Won't Start + +1. Check if already running: + ```bash + nexus-cli status + ``` + +2. Check configuration: + ```bash + nexus-cli config list + nexus-cli doctor + ``` + +3. Check logs: + ```bash + nexus-cli logs --lines 100 + ``` + +4. Try foreground mode for debugging: + ```bash + nexus-cli start + ``` + +### Sync Not Working + +1. Check daemon status: + ```bash + nexus-cli status + ``` + +2. Check peer connections: + ```bash + nexus-cli peer list + ``` + +3. Check logs for errors: + ```bash + nexus-cli logs --follow --level error + ``` + +4. Verify network configuration: + ```bash + nexus-cli config get network.listen_port + nexus-cli config get sync.enable_mdns + ``` + +### Database Corruption + +1. Stop daemon: + ```bash + nexus-cli stop + ``` + +2. Export current database (if possible): + ```bash + nexus-cli export ~/nexus-damaged.db + ``` + +3. Restore from backup: + ```bash + nexus-cli import ~/backups/nexus-backup-latest.db --force + ``` + +4. Restart daemon: + ```bash + nexus-cli start --daemon + ``` + +### Configuration Issues + +1. Check configuration file: + ```bash + cat ~/.nexus/config.toml + ``` + +2. Reset to defaults: + ```bash + rm ~/.nexus/config.toml + nexus-cli init + ``` + +3. Edit configuration: + ```bash + nexus-cli config edit + ``` + +## Global Options + +All commands support these global options: + +- `--config, -c ` - Use custom configuration file +- `--verbose, -v` - Enable verbose output +- `--json` - Output in JSON format (where supported) +- `--help, -h` - Show help for command +- `--version, -V` - Show version information + +**Examples:** + +```bash +# Use custom config file +nexus-cli --config /etc/nexus/config.toml status + +# Verbose output +nexus-cli --verbose start + +# Show version +nexus-cli --version +``` + +## Environment Variables + +- `RUST_LOG` - Control log level (set automatically by `--verbose`) +- `EDITOR` - Editor to use for `nexus-cli config edit` + +**Examples:** + +```bash +# Set custom editor +export EDITOR=nano +nexus-cli config edit + +# Override log level +RUST_LOG=debug nexus-cli start +``` + +## Exit Codes + +- `0` - Success +- `1` - Error + +## Getting Help + +For command-specific help: + +```bash +nexus-cli --help +``` + +For general help: + +```bash +nexus-cli --help +``` + +For more information, see: +- [Main README](../README.md) +- [Database Migrations](./DATABASE_MIGRATIONS.md) +- [Installation Guide](../INSTALL.md) diff --git a/src/bin/nexus-cli.rs b/src/bin/nexus-cli.rs new file mode 100644 index 0000000..e9d7dbc --- /dev/null +++ b/src/bin/nexus-cli.rs @@ -0,0 +1,330 @@ +use clap::{Parser, Subcommand}; +use nexus_core::cli::{commands, config::Config, errors::CliResult, output}; +use std::process; + +#[derive(Parser)] +#[command(name = "nexus-cli")] +#[command(version, about = "CLI tool for Nexus synchronization engine", long_about = None)] +struct Cli { + /// Configuration file path + #[arg(short, long, value_name = "FILE")] + config: Option, + + /// Enable verbose output + #[arg(short, long)] + verbose: bool, + + /// Output in JSON format + #[arg(long)] + json: bool, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Initialize a new Nexus database + Init { + /// Database path + #[arg(short, long, value_name = "PATH")] + path: Option, + + /// Username for initial user + #[arg(short, long)] + user: Option, + + /// Email for initial user + #[arg(short, long)] + email: Option, + + /// Password for initial user + #[arg(short = 'P', long)] + password: Option, + }, + + /// Start the sync daemon + Start { + /// Run in background as daemon + #[arg(short, long)] + daemon: bool, + + /// Port to listen on (0 = random) + #[arg(short, long, default_value = "0")] + port: u16, + + /// Configuration file + #[arg(short, long)] + config: Option, + }, + + /// Stop the sync daemon + Stop, + + /// Restart the sync daemon + Restart { + /// Run in background as daemon + #[arg(short, long)] + daemon: bool, + }, + + /// Show sync status + Status { + /// Watch mode (continuous monitoring) + #[arg(short, long)] + watch: bool, + + /// Update interval in seconds for watch mode + #[arg(short, long, default_value = "2")] + interval: u64, + }, + + /// Trigger a sync now + Sync { + /// Force full sync + #[arg(short, long)] + force: bool, + }, + + /// Peer management commands + #[command(subcommand)] + Peer(PeerCommands), + + /// Device management commands + #[command(subcommand)] + Device(DeviceCommands), + + /// Configuration management + #[command(subcommand)] + Config(ConfigCommands), + + /// View logs + Logs { + /// Follow log output + #[arg(short, long)] + follow: bool, + + /// Number of lines to show + #[arg(short, long, default_value = "50")] + lines: usize, + + /// Filter by log level + #[arg(short = 'L', long)] + level: Option, + }, + + /// Execute SQL query (debugging) + Query { + /// SQL query to execute + sql: String, + }, + + /// View operation log + Oplog { + /// Show entries since timestamp + #[arg(long)] + since: Option, + + /// Filter by device ID + #[arg(long)] + device: Option, + + /// Number of entries to show + #[arg(short, long, default_value = "50")] + limit: usize, + }, + + /// Show system information + Info, + + /// Diagnose system issues + Doctor, + + /// Export database + Export { + /// Output path + path: String, + }, + + /// Import database + Import { + /// Input path + path: String, + + /// Force overwrite existing database + #[arg(short, long)] + force: bool, + }, +} + +#[derive(Subcommand)] +enum PeerCommands { + /// List connected peers + List, + + /// Add a peer or bootstrap node + Add { + /// Multiaddress of the peer + multiaddr: String, + }, + + /// Remove a peer + Remove { + /// Peer ID to remove + peer_id: String, + }, + + /// Show peer information + Info { + /// Peer ID to query + peer_id: String, + }, +} + +#[derive(Subcommand)] +enum DeviceCommands { + /// List user devices + List, + + /// Generate device pairing QR code + Pair { + /// Device type + #[arg(short, long, default_value = "mobile")] + device_type: String, + + /// Device name + #[arg(short, long)] + name: Option, + }, + + /// Authorize a device with pairing code + Authorize { + /// Authorization code + code: String, + }, + + /// Remove a device + Remove { + /// Device ID to remove + device_id: String, + }, +} + +#[derive(Subcommand)] +enum ConfigCommands { + /// Set a configuration value + Set { + /// Configuration key + key: String, + + /// Configuration value + value: String, + }, + + /// Get a configuration value + Get { + /// Configuration key + key: String, + }, + + /// List all configuration + List, + + /// Edit configuration file + Edit, +} + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + + // Initialize logging + unsafe { + if cli.verbose { + std::env::set_var("RUST_LOG", "debug"); + } else { + std::env::set_var("RUST_LOG", "info"); + } + } + env_logger::init(); + + // Load configuration + let config = match Config::load(cli.config.as_deref()) { + Ok(cfg) => cfg, + Err(e) => { + // If config load fails, use default for init command + match cli.command { + Commands::Init { .. } => Config::default(), + _ => { + output::error(&format!("Failed to load configuration: {}", e)); + process::exit(1); + } + } + } + }; + + // Execute command + let result = match cli.command { + Commands::Init { + path, + user, + email, + password, + } => { + commands::init::execute(path.as_deref(), user.as_deref(), email.as_deref(), password.as_deref()).await + } + Commands::Start { daemon, port, config: config_path } => { + commands::daemon::start(daemon, port, config_path.as_deref(), &config).await + } + Commands::Stop => commands::daemon::stop(&config).await, + Commands::Restart { daemon } => commands::daemon::restart(daemon, &config).await, + Commands::Status { watch, interval } => { + commands::daemon::status(watch, interval, cli.json, &config).await + } + Commands::Sync { force } => commands::sync::sync(force, &config).await, + Commands::Peer(peer_cmd) => match peer_cmd { + PeerCommands::List => commands::peer::list(cli.json, &config).await, + PeerCommands::Add { multiaddr } => commands::peer::add(&multiaddr, &config).await, + PeerCommands::Remove { peer_id } => commands::peer::remove(&peer_id, &config).await, + PeerCommands::Info { peer_id } => commands::peer::info(&peer_id, cli.json, &config).await, + }, + Commands::Device(device_cmd) => match device_cmd { + DeviceCommands::List => commands::device::list(cli.json, &config).await, + DeviceCommands::Pair { device_type, name } => { + commands::device::pair(&device_type, name.as_deref(), &config).await + } + DeviceCommands::Authorize { code } => commands::device::authorize(&code, &config).await, + DeviceCommands::Remove { device_id } => commands::device::remove(&device_id, &config).await, + }, + Commands::Config(config_cmd) => match config_cmd { + ConfigCommands::Set { key, value } => commands::config::set(&key, &value, &config).await, + ConfigCommands::Get { key } => commands::config::get(&key, &config).await, + ConfigCommands::List => commands::config::list(cli.json, &config).await, + ConfigCommands::Edit => commands::config::edit(&config).await, + }, + Commands::Logs { + follow, + lines, + level, + } => commands::logs::view(follow, lines, level.as_deref(), &config).await, + Commands::Query { sql } => commands::utils::query(&sql, cli.json, &config).await, + Commands::Oplog { + since, + device, + limit, + } => commands::utils::oplog(since, device.as_deref(), limit, cli.json, &config).await, + Commands::Info => commands::utils::info(cli.json).await, + Commands::Doctor => commands::utils::doctor(&config).await, + Commands::Export { path } => commands::utils::export(&path, &config).await, + Commands::Import { path, force } => commands::utils::import(&path, force, &config).await, + }; + + // Handle result + match result { + Ok(_) => {} + Err(e) => { + output::error(&format!("{}", e)); + process::exit(1); + } + } +} diff --git a/src/cli/commands/config.rs b/src/cli/commands/config.rs new file mode 100644 index 0000000..b5c5476 --- /dev/null +++ b/src/cli/commands/config.rs @@ -0,0 +1,102 @@ +use crate::cli::config::Config; +use crate::cli::errors::{CliError, CliResult}; +use crate::cli::output; +use std::process::Command; + +pub async fn set(key: &str, value: &str, config: &Config) -> CliResult<()> { + let mut config = config.clone(); + + output::step(&format!("Setting {} = {}", key, value)); + + config.set_value(key, value)?; + config.save(None)?; + + output::success(&format!("Configuration updated: {} = {}", key, value)); + + Ok(()) +} + +pub async fn get(key: &str, config: &Config) -> CliResult<()> { + let value = config.get_value(key)?; + println!("{}", value); + Ok(()) +} + +pub async fn list(json: bool, config: &Config) -> CliResult<()> { + if json { + let config_json = serde_json::to_value(config) + .map_err(|e| CliError::ConfigError(format!("Failed to serialize config: {}", e)))?; + output::json(&config_json); + } else { + output::header("Current Configuration"); + + println!(); + output::key_value("Database Path", &config.database.path); + output::key_value("Auto Migrate", &config.database.auto_migrate.to_string()); + + if let Some(user) = &config.user { + println!(); + output::key_value("User ID", &user.id); + output::key_value("User Name", &user.name); + output::key_value("User Email", &user.email); + } + + if let Some(device) = &config.device { + println!(); + output::key_value("Device ID", &device.id); + output::key_value("Device Type", &device.device_type); + output::key_value("Device Name", &device.name); + } + + println!(); + output::key_value("Sync Enabled", &config.sync.enabled.to_string()); + output::key_value("Auto Start", &config.sync.auto_start.to_string()); + output::key_value("Enable mDNS", &config.sync.enable_mdns.to_string()); + output::key_value("Enable Relay", &config.sync.enable_relay.to_string()); + + println!(); + output::key_value("Listen Port", &config.network.listen_port.to_string()); + output::key_value("Listen Address", &config.network.listen_address); + + println!(); + output::key_value("Log Level", &config.logging.level); + output::key_value("Log File", &config.logging.file); + } + + Ok(()) +} + +pub async fn edit(config: &Config) -> CliResult<()> { + let config_path = Config::default_path(); + + // Make sure config file exists + if !config_path.exists() { + config.save(None)?; + } + + // Get editor from environment or use default + let editor = std::env::var("EDITOR").unwrap_or_else(|_| { + if cfg!(target_os = "windows") { + "notepad".to_string() + } else { + "vi".to_string() + } + }); + + output::step(&format!("Opening {} in {}", config_path.display(), editor)); + + // Open editor + let status = Command::new(&editor) + .arg(&config_path) + .status() + .map_err(|e| CliError::IoError(e))?; + + if !status.success() { + return Err(CliError::Other("Editor exited with error".to_string())); + } + + output::success("Configuration file edited"); + output::info("Configuration will be reloaded on next command"); + + Ok(()) +} diff --git a/src/cli/commands/daemon.rs b/src/cli/commands/daemon.rs new file mode 100644 index 0000000..a0763cb --- /dev/null +++ b/src/cli/commands/daemon.rs @@ -0,0 +1,244 @@ +use crate::cli::config::Config; +use crate::cli::daemon as daemon_utils; +use crate::cli::errors::{CliError, CliResult}; +use crate::cli::output; +use crate::db::operations::initialize_database; +use crate::logic::sync::{create_swarm, P2PConfig}; +use crate::logic::sync_manager::SyncManager; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::time; + +pub async fn start( + daemon: bool, + port: u16, + config_path: Option<&str>, + config: &Config, +) -> CliResult<()> { + let pid_file = Config::pid_file(); + + // Check if already running + if daemon_utils::is_running(&pid_file) { + return Err(CliError::DaemonError( + "Daemon is already running".to_string(), + )); + } + + // Check if user is configured + let user_config = config + .user + .as_ref() + .ok_or_else(|| CliError::ConfigError("User not configured. Run 'nexus-cli init' first.".to_string()))?; + + let device_config = config + .device + .as_ref() + .ok_or_else(|| CliError::ConfigError("Device not configured. Run 'nexus-cli init' first.".to_string()))?; + + if daemon { + output::step("Starting daemon in background mode"); + + #[cfg(unix)] + { + daemon_utils::daemonize(&pid_file)?; + // After daemonize, we're in the child process + run_sync_loop(port, config).await?; + } + + #[cfg(not(unix))] + { + return Err(CliError::DaemonError( + "Daemon mode is only supported on Unix-like systems. Run in foreground mode.".to_string(), + )); + } + } else { + output::step("Starting sync in foreground mode"); + + // Write PID file + let pid = std::process::id() as i32; + daemon_utils::write_pid(&pid_file, pid)?; + + // Setup Ctrl+C handler + let pid_file_clone = pid_file.clone(); + ctrlc::set_handler(move || { + output::info("Received shutdown signal, cleaning up..."); + daemon_utils::remove_pid_file(&pid_file_clone).ok(); + std::process::exit(0); + }) + .map_err(|e| CliError::DaemonError(format!("Failed to set Ctrl+C handler: {}", e)))?; + + output::success("Sync started (press Ctrl+C to stop)"); + + // Run sync loop + run_sync_loop(port, config).await?; + } + + Ok(()) +} + +async fn run_sync_loop(port: u16, config: &Config) -> CliResult<()> { + let user_config = config.user.as_ref().unwrap(); + let device_config = config.device.as_ref().unwrap(); + + // Parse UUIDs + let user_id = uuid::Uuid::parse_str(&user_config.id) + .map_err(|_| CliError::ConfigError("Invalid user ID".to_string()))?; + let device_id = uuid::Uuid::parse_str(&device_config.id) + .map_err(|_| CliError::ConfigError("Invalid device ID".to_string()))?; + + // Initialize database connection + let db_path = config.db_path(); + let conn = initialize_database(&db_path) + .map_err(|e| CliError::DatabaseError(e.to_string()))?; + let conn = Arc::new(Mutex::new(conn)); + + // Generate keypair for P2P + let (peer_id, keypair) = crate::logic::sync::generate_device_id(); + log::info!("Generated peer ID: {}", peer_id); + + // Create P2P config + let p2p_config = P2PConfig { + enable_mdns: config.sync.enable_mdns, + enable_relay: config.sync.enable_relay, + bootstrap_nodes: config.network.bootstrap_nodes.clone(), + relay_servers: config.network.relay_servers.clone(), + heartbeat_interval: Duration::from_secs(config.sync.heartbeat_interval_secs), + max_message_size: config.sync.max_message_size, + }; + + // Create sync manager + let mut sync_manager = SyncManager::new(keypair, user_id, device_id, conn.clone(), p2p_config) + .map_err(|e| CliError::SyncError(format!("Failed to create sync manager: {}", e)))?; + + // Start listening + let listen_addr = format!("/ip4/{}/tcp/{}", config.network.listen_address, port); + sync_manager + .listen(port) + .map_err(|e| CliError::SyncError(format!("Failed to start listening: {}", e)))?; + + log::info!("Listening on {}", listen_addr); + + // Connect to bootstrap nodes and relay servers + if !config.network.bootstrap_nodes.is_empty() || !config.network.relay_servers.is_empty() { + sync_manager + .connect_to_network(&config.network.bootstrap_nodes, &config.network.relay_servers) + .map_err(|e| CliError::SyncError(format!("Failed to connect to network: {}", e)))?; + } + + // Announce presence + sync_manager + .announce_presence() + .map_err(|e| CliError::SyncError(format!("Failed to announce presence: {}", e)))?; + + log::info!("Sync manager initialized and running"); + + // Main event loop + loop { + // Process events + if let Err(e) = sync_manager.process_event().await { + log::error!("Error processing event: {}", e); + } + + // Small delay to prevent busy loop + time::sleep(Duration::from_millis(100)).await; + } +} + +pub async fn stop(config: &Config) -> CliResult<()> { + let pid_file = Config::pid_file(); + + output::step("Stopping daemon"); + + daemon_utils::stop_daemon(&pid_file)?; + + output::success("Daemon stopped"); + + Ok(()) +} + +pub async fn restart(daemon: bool, config: &Config) -> CliResult<()> { + let pid_file = Config::pid_file(); + + // Stop if running + if daemon_utils::is_running(&pid_file) { + output::step("Stopping existing daemon"); + daemon_utils::stop_daemon(&pid_file)?; + output::success("Daemon stopped"); + } + + // Wait a moment + tokio::time::sleep(Duration::from_secs(1)).await; + + // Start again + start(daemon, 0, None, config).await?; + + Ok(()) +} + +pub async fn status(watch: bool, interval: u64, json: bool, config: &Config) -> CliResult<()> { + let pid_file = Config::pid_file(); + + if watch { + // Watch mode - continuous monitoring + loop { + print_status(json, config, &pid_file)?; + + if !json { + // Clear screen on next iteration + print!("\x1B[2J\x1B[1;1H"); + } + + tokio::time::sleep(Duration::from_secs(interval)).await; + } + } else { + // Single status check + print_status(json, config, &pid_file)?; + } + + Ok(()) +} + +fn print_status(json: bool, config: &Config, pid_file: &std::path::Path) -> CliResult<()> { + let is_running = daemon_utils::is_running(pid_file); + + if json { + let status = serde_json::json!({ + "status": if is_running { "running" } else { "stopped" }, + "pid": if is_running { daemon_utils::get_pid(pid_file).ok() } else { None }, + "uptime_seconds": if is_running { daemon_utils::get_uptime(pid_file).ok() } else { None }, + "database": config.db_path(), + "sync_enabled": config.sync.enabled, + }); + output::json(&status); + } else { + let status_text = if is_running { "Running" } else { "Stopped" }; + let uptime_text = if is_running { + daemon_utils::get_uptime(pid_file) + .map(|u| daemon_utils::format_uptime(u)) + .unwrap_or_else(|_| "Unknown".to_string()) + } else { + "N/A".to_string() + }; + + let pid_text = if is_running { + daemon_utils::get_pid(pid_file) + .map(|p| p.to_string()) + .unwrap_or_else(|_| "Unknown".to_string()) + } else { + "N/A".to_string() + }; + + output::print_box( + "Nexus Sync Status", + vec![ + ("Status", status_text), + ("PID", &pid_text), + ("Uptime", &uptime_text), + ("Database", &config.db_path()), + ("Sync Enabled", if config.sync.enabled { "Yes" } else { "No" }), + ], + ); + } + + Ok(()) +} diff --git a/src/cli/commands/device.rs b/src/cli/commands/device.rs new file mode 100644 index 0000000..0d23d9d --- /dev/null +++ b/src/cli/commands/device.rs @@ -0,0 +1,82 @@ +use crate::cli::config::Config; +use crate::cli::errors::{CliError, CliResult}; +use crate::cli::output; +use crate::db::operations::{get_devices_by_user_id, initialize_database}; + +pub async fn list(json: bool, config: &Config) -> CliResult<()> { + let user_config = config + .user + .as_ref() + .ok_or_else(|| CliError::ConfigError("User not configured".to_string()))?; + + let user_id = uuid::Uuid::parse_str(&user_config.id) + .map_err(|_| CliError::ConfigError("Invalid user ID".to_string()))?; + + let db_path = config.db_path(); + let conn = initialize_database(&db_path) + .map_err(|e| CliError::DatabaseError(e.to_string()))?; + + let devices = get_devices_by_user_id(&conn, user_id) + .map_err(|e| CliError::DatabaseError(e.to_string()))?; + + if json { + let devices_json: Vec<_> = devices + .iter() + .map(|d| { + serde_json::json!({ + "device_id": d.device_id.to_string(), + "device_type": d.device_type, + "last_seen": d.last_seen, + }) + }) + .collect(); + output::json(&serde_json::json!(devices_json)); + } else { + if devices.is_empty() { + output::info("No devices found"); + return Ok(()); + } + + let mut table = output::create_table(vec!["Device ID", "Type", "Last Seen"]); + + for device in devices { + table.add_row(prettytable::Row::new(vec![ + prettytable::Cell::new(&device.device_id.to_string()), + prettytable::Cell::new(&device.device_type), + prettytable::Cell::new(&device.last_seen.map(|t| t.to_string()).unwrap_or_else(|| "Never".to_string())), + ])); + } + + table.printstd(); + } + + Ok(()) +} + +pub async fn pair(device_type: &str, name: Option<&str>, config: &Config) -> CliResult<()> { + output::step(&format!("Generating pairing QR code for {} device", device_type)); + + // TODO: Implement device pairing with QR code generation + output::warning("Device pairing not yet fully implemented"); + output::info("This feature requires the authentication challenge system to be integrated"); + + Ok(()) +} + +pub async fn authorize(code: &str, config: &Config) -> CliResult<()> { + output::step("Authorizing device with code"); + + // TODO: Implement device authorization + output::warning("Device authorization not yet fully implemented"); + + Ok(()) +} + +pub async fn remove(device_id: &str, config: &Config) -> CliResult<()> { + output::step(&format!("Removing device: {}", device_id)); + + // TODO: Implement device removal + output::warning("Device removal not yet implemented"); + + Ok(()) +} diff --git a/src/cli/commands/init.rs b/src/cli/commands/init.rs new file mode 100644 index 0000000..4ba28de --- /dev/null +++ b/src/cli/commands/init.rs @@ -0,0 +1,117 @@ +use crate::cli::config::{Config, DeviceConfig, UserConfig}; +use crate::cli::errors::{CliError, CliResult}; +use crate::cli::output; +use crate::db::operations::initialize_database; +use crate::logic; +use std::fs; +use uuid::Uuid; + +pub async fn execute( + db_path: Option<&str>, + username: Option<&str>, + email: Option<&str>, + password: Option<&str>, +) -> CliResult<()> { + output::header("Initializing Nexus"); + + // Load or create default config + let mut config = Config::default(); + + // Set database path if provided + if let Some(path) = db_path { + config.database.path = path.to_string(); + } + + // Expand the database path + let db_path_expanded = Config::expand_path(&config.database.path); + + // Create parent directory if needed + if let Some(parent) = std::path::Path::new(&db_path_expanded).parent() { + fs::create_dir_all(parent) + .map_err(|e| CliError::IoError(e))?; + } + + // Initialize database + output::step(&format!("Creating database at {}", db_path_expanded)); + let conn = initialize_database(&db_path_expanded) + .map_err(|e| CliError::DatabaseError(e.to_string()))?; + output::success("Database initialized"); + + // Create user if credentials provided + if let (Some(user), Some(mail)) = (username, email) { + output::step(&format!("Creating user '{}'", user)); + + // Get password (prompt if not provided) + let pass = if let Some(p) = password { + p.to_string() + } else { + rpassword::prompt_password("Password: ") + .map_err(|e| CliError::IoError(e))? + }; + + let user_obj = logic::register_user(&conn, user.to_string(), mail.to_string(), pass) + .map_err(|e| CliError::DatabaseError(e))?; + + output::success(&format!("User '{}' created ({})", user, user_obj.user_id)); + + // Generate device ID and register + output::step("Registering CLI device"); + let device_name = hostname::get() + .ok() + .and_then(|h| h.into_string().ok()) + .unwrap_or_else(|| "cli-device".to_string()); + + let device = logic::add_device_to_user( + &conn, + user_obj.user_id, + "cli".to_string(), + None, + ) + .map_err(|e| CliError::DatabaseError(e))?; + + output::success(&format!("Device registered ({})", device.device_id)); + + // Update config with user and device info + config.user = Some(UserConfig { + id: user_obj.user_id.to_string(), + name: user_obj.user_name.clone(), + email: user_obj.user_mail.clone(), + }); + + config.device = Some(DeviceConfig { + id: device.device_id.to_string(), + device_type: "cli".to_string(), + name: device_name, + }); + } + + // Save configuration + output::step("Saving configuration"); + config.save(None)?; + output::success(&format!( + "Configuration saved to {}", + Config::default_path().display() + )); + + // Print summary + println!(); + output::header("Setup Complete"); + output::key_value("Database", &db_path_expanded); + output::key_value("Config", &Config::default_path().display().to_string()); + + if config.user.is_some() { + output::key_value("User", username.unwrap_or("N/A")); + output::key_value("Device", &config.device.as_ref().map(|d| d.id.as_str()).unwrap_or("N/A")); + } else { + println!(); + output::info("No user created. Run 'nexus-cli init --user --email ' to create a user."); + } + + println!(); + output::info("Next steps:"); + println!(" 1. Start sync daemon: nexus-cli start --daemon"); + println!(" 2. Check status: nexus-cli status"); + println!(" 3. View logs: nexus-cli logs --follow"); + + Ok(()) +} diff --git a/src/cli/commands/logs.rs b/src/cli/commands/logs.rs new file mode 100644 index 0000000..5e0dc59 --- /dev/null +++ b/src/cli/commands/logs.rs @@ -0,0 +1,93 @@ +use crate::cli::config::Config; +use crate::cli::errors::{CliError, CliResult}; +use crate::cli::output; +use std::fs::File; +use std::io::{BufRead, BufReader, Seek, SeekFrom}; +use std::path::Path; +use tokio::time::{sleep, Duration}; + +pub async fn view( + follow: bool, + lines: usize, + level: Option<&str>, + config: &Config, +) -> CliResult<()> { + let log_path = config.log_path(); + + if !Path::new(&log_path).exists() { + output::warning(&format!("Log file not found: {}", log_path)); + output::info("The log file will be created when the daemon starts"); + return Ok(()); + } + + if follow { + // Follow mode - tail -f style + tail_follow(&log_path, level).await + } else { + // Single read - show last N lines + tail_lines(&log_path, lines, level) + } +} + +fn tail_lines(log_path: &str, lines: usize, level: Option<&str>) -> CliResult<()> { + let file = File::open(log_path)?; + let reader = BufReader::new(file); + + let all_lines: Vec = reader + .lines() + .filter_map(|line| line.ok()) + .collect(); + + let start_idx = if all_lines.len() > lines { + all_lines.len() - lines + } else { + 0 + }; + + for line in &all_lines[start_idx..] { + if should_show_line(line, level) { + println!("{}", line); + } + } + + Ok(()) +} + +async fn tail_follow(log_path: &str, level: Option<&str>) -> CliResult<()> { + let mut file = File::open(log_path)?; + let mut reader = BufReader::new(file); + + // Seek to end + reader.seek(SeekFrom::End(0))?; + + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => { + // No new data, wait and try again + sleep(Duration::from_millis(100)).await; + + // Re-open file in case it was rotated + file = File::open(log_path)?; + reader = BufReader::new(file); + } + Ok(_) => { + if should_show_line(&line, level) { + print!("{}", line); + } + } + Err(e) => { + return Err(CliError::IoError(e)); + } + } + } +} + +fn should_show_line(line: &str, level_filter: Option<&str>) -> bool { + if let Some(level) = level_filter { + let level_upper = level.to_uppercase(); + line.contains(&level_upper) + } else { + true + } +} diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs new file mode 100644 index 0000000..3dc7298 --- /dev/null +++ b/src/cli/commands/mod.rs @@ -0,0 +1,8 @@ +pub mod config; +pub mod daemon; +pub mod device; +pub mod init; +pub mod logs; +pub mod peer; +pub mod sync; +pub mod utils; diff --git a/src/cli/commands/peer.rs b/src/cli/commands/peer.rs new file mode 100644 index 0000000..b8fb0b3 --- /dev/null +++ b/src/cli/commands/peer.rs @@ -0,0 +1,103 @@ +use crate::cli::config::Config; +use crate::cli::errors::{CliError, CliResult}; +use crate::cli::output; +use crate::db::operations::{get_all_peers, initialize_database}; + +pub async fn list(json: bool, config: &Config) -> CliResult<()> { + let db_path = config.db_path(); + let conn = initialize_database(&db_path) + .map_err(|e| CliError::DatabaseError(e.to_string()))?; + + let peers = get_all_peers(&conn) + .map_err(|e| CliError::DatabaseError(e.to_string()))?; + + if json { + let peers_json: Vec<_> = peers + .iter() + .map(|p| { + serde_json::json!({ + "peer_id": p.peer_id.to_string(), + "user_id": p.user_id.to_string(), + "device_id": p.device_id.to_string(), + "last_known_ip": p.last_known_ip, + "last_sync_time": p.last_sync_time, + }) + }) + .collect(); + output::json(&serde_json::json!(peers_json)); + } else { + if peers.is_empty() { + output::info("No peers connected"); + return Ok(()); + } + + let mut table = output::create_table(vec!["Peer ID", "Device ID", "Last IP", "Last Sync"]); + + for peer in peers { + table.add_row(prettytable::Row::new(vec![ + prettytable::Cell::new(&peer.peer_id.to_string()), + prettytable::Cell::new(&peer.device_id.to_string()), + prettytable::Cell::new(peer.last_known_ip.as_deref().unwrap_or("N/A")), + prettytable::Cell::new(&peer.last_sync_time.map(|t| t.to_string()).unwrap_or_else(|| "Never".to_string())), + ])); + } + + table.printstd(); + } + + Ok(()) +} + +pub async fn add(multiaddr: &str, config: &Config) -> CliResult<()> { + output::step(&format!("Adding peer: {}", multiaddr)); + + // TODO: Implement peer addition via IPC to daemon + output::warning("Peer addition not yet implemented"); + output::info("Add bootstrap nodes to config: nexus-cli config set network.bootstrap_nodes"); + + Ok(()) +} + +pub async fn remove(peer_id: &str, config: &Config) -> CliResult<()> { + output::step(&format!("Removing peer: {}", peer_id)); + + // TODO: Implement peer removal + output::warning("Peer removal not yet implemented"); + + Ok(()) +} + +pub async fn info(peer_id: &str, json: bool, config: &Config) -> CliResult<()> { + let db_path = config.db_path(); + let conn = initialize_database(&db_path) + .map_err(|e| CliError::DatabaseError(e.to_string()))?; + + let peer_uuid = uuid::Uuid::parse_str(peer_id) + .map_err(|_| CliError::ValidationError("Invalid peer ID".to_string()))?; + + let peer = crate::db::operations::get_peer(&conn, peer_uuid) + .map_err(|e| CliError::DatabaseError(e.to_string()))?; + + if json { + output::json(&serde_json::json!({ + "peer_id": peer.peer_id.to_string(), + "user_id": peer.user_id.to_string(), + "device_id": peer.device_id.to_string(), + "last_known_ip": peer.last_known_ip, + "last_sync_time": peer.last_sync_time, + })); + } else { + output::print_box( + "Peer Information", + vec![ + ("Peer ID", &peer.peer_id.to_string()), + ("User ID", &peer.user_id.to_string()), + ("Device ID", &peer.device_id.to_string()), + ("Last Known IP", peer.last_known_ip.as_deref().unwrap_or("N/A")), + ("Last Sync", &peer.last_sync_time.map(|t| t.to_string()).unwrap_or_else(|| "Never".to_string())), + ], + ); + } + + Ok(()) +} diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs new file mode 100644 index 0000000..43bbbac --- /dev/null +++ b/src/cli/commands/sync.rs @@ -0,0 +1,19 @@ +use crate::cli::config::Config; +use crate::cli::errors::{CliError, CliResult}; +use crate::cli::output; + +pub async fn sync(force: bool, config: &Config) -> CliResult<()> { + output::step("Triggering synchronization"); + + // TODO: Implement sync trigger via IPC to daemon + // For now, just show a message + + if force { + output::info("Force sync requested"); + } + + output::warning("Sync trigger not yet implemented. The daemon syncs automatically."); + output::info("To monitor sync activity, use: nexus-cli logs --follow"); + + Ok(()) +} diff --git a/src/cli/commands/utils.rs b/src/cli/commands/utils.rs new file mode 100644 index 0000000..48e02fa --- /dev/null +++ b/src/cli/commands/utils.rs @@ -0,0 +1,298 @@ +use crate::cli::config::Config; +use crate::cli::errors::{CliError, CliResult}; +use crate::cli::output; +use crate::db::operations::initialize_database; +use rusqlite::params; +use std::fs; + +pub async fn query(sql: &str, json: bool, config: &Config) -> CliResult<()> { + let db_path = config.db_path(); + let conn = initialize_database(&db_path) + .map_err(|e| CliError::DatabaseError(e.to_string()))?; + + // Execute query + let mut stmt = conn + .prepare(sql) + .map_err(|e| CliError::DatabaseError(e.to_string()))?; + + let column_count = stmt.column_count(); + let column_names: Vec = stmt.column_names().iter().map(|s| s.to_string()).collect(); + + let rows = stmt + .query_map(params![], |row| { + let mut values = Vec::new(); + for i in 0..column_count { + let value: String = row + .get::<_, Option>(i) + .unwrap_or(None) + .unwrap_or_else(|| "NULL".to_string()); + values.push(value); + } + Ok(values) + }) + .map_err(|e| CliError::DatabaseError(e.to_string()))?; + + let mut results: Vec> = Vec::new(); + for row in rows { + results.push(row.map_err(|e| CliError::DatabaseError(e.to_string()))?); + } + + if json { + let json_results: Vec<_> = results + .iter() + .map(|row| { + let mut obj = serde_json::Map::new(); + for (i, col_name) in column_names.iter().enumerate() { + obj.insert(col_name.clone(), serde_json::Value::String(row[i].clone())); + } + serde_json::Value::Object(obj) + }) + .collect(); + output::json(&serde_json::json!(json_results)); + } else { + if results.is_empty() { + output::info("No results"); + return Ok(()); + } + + let header_refs: Vec<&str> = column_names.iter().map(|s| s.as_str()).collect(); + let mut table = output::create_table(header_refs); + + for row in results { + let cells: Vec<_> = row.iter().map(|v| prettytable::Cell::new(v)).collect(); + table.add_row(prettytable::Row::new(cells)); + } + + table.printstd(); + } + + Ok(()) +} + +pub async fn oplog( + since: Option, + device: Option<&str>, + limit: usize, + json: bool, + config: &Config, +) -> CliResult<()> { + let db_path = config.db_path(); + let conn = initialize_database(&db_path) + .map_err(|e| CliError::DatabaseError(e.to_string()))?; + + let mut sql = "SELECT id, device_id, timestamp, table_name, op_type, data FROM oplog".to_string(); + let mut conditions = Vec::new(); + + if let Some(ts) = since { + conditions.push(format!("timestamp > {}", ts)); + } + + if let Some(dev) = device { + conditions.push(format!("device_id = '{}'", dev)); + } + + if !conditions.is_empty() { + sql.push_str(" WHERE "); + sql.push_str(&conditions.join(" AND ")); + } + + sql.push_str(&format!(" ORDER BY timestamp DESC LIMIT {}", limit)); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| CliError::DatabaseError(e.to_string()))?; + + let entries = stmt + .query_map(params![], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + )) + }) + .map_err(|e| CliError::DatabaseError(e.to_string()))?; + + let mut results = Vec::new(); + for entry in entries { + results.push(entry.map_err(|e| CliError::DatabaseError(e.to_string()))?); + } + + if json { + let json_results: Vec<_> = results + .iter() + .map(|(id, device_id, timestamp, table, op_type, data)| { + serde_json::json!({ + "id": id, + "device_id": device_id, + "timestamp": timestamp, + "table": table, + "op_type": op_type, + "data": data, + }) + }) + .collect(); + output::json(&serde_json::json!(json_results)); + } else { + if results.is_empty() { + output::info("No oplog entries"); + return Ok(()); + } + + let mut table = output::create_table(vec!["Timestamp", "Table", "Op Type", "Device ID"]); + + for (_, device_id, timestamp, table_name, op_type, _) in results { + table.add_row(prettytable::Row::new(vec![ + prettytable::Cell::new(×tamp.to_string()), + prettytable::Cell::new(&table_name), + prettytable::Cell::new(&op_type), + prettytable::Cell::new(&device_id[..8]), // Show first 8 chars + ])); + } + + table.printstd(); + } + + Ok(()) +} + +pub async fn info(json: bool) -> CliResult<()> { + let version = env!("CARGO_PKG_VERSION"); + let system = sysinfo::System::new_all(); + + if json { + output::json(&serde_json::json!({ + "version": version, + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "hostname": hostname::get().ok().and_then(|h| h.into_string().ok()), + })); + } else { + output::print_box( + "Nexus CLI Information", + vec![ + ("Version", version), + ("OS", std::env::consts::OS), + ("Architecture", std::env::consts::ARCH), + ("Hostname", &hostname::get().ok().and_then(|h| h.into_string().ok()).unwrap_or_else(|| "Unknown".to_string())), + ], + ); + } + + Ok(()) +} + +pub async fn doctor(config: &Config) -> CliResult<()> { + output::header("Running diagnostics"); + + let mut issues = Vec::new(); + let mut checks_passed = 0; + let total_checks = 5; + + // Check 1: Config file + output::step("Checking configuration file"); + if Config::default_path().exists() { + output::success("Configuration file exists"); + checks_passed += 1; + } else { + output::warning("Configuration file not found"); + issues.push("Run 'nexus-cli init' to create configuration"); + } + + // Check 2: Database + output::step("Checking database"); + let db_path = config.db_path(); + if std::path::Path::new(&db_path).exists() { + output::success("Database exists"); + checks_passed += 1; + + // Try to connect + match initialize_database(&db_path) { + Ok(_) => { + output::success("Database connection successful"); + checks_passed += 1; + } + Err(e) => { + output::error(&format!("Database connection failed: {}", e)); + issues.push("Database is corrupted or incompatible"); + } + } + } else { + output::warning("Database not found"); + issues.push("Run 'nexus-cli init' to create database"); + } + + // Check 3: User configured + output::step("Checking user configuration"); + if config.user.is_some() { + output::success("User is configured"); + checks_passed += 1; + } else { + output::warning("User not configured"); + issues.push("Run 'nexus-cli init --user --email ' to configure user"); + } + + // Check 4: Device configured + output::step("Checking device configuration"); + if config.device.is_some() { + output::success("Device is configured"); + checks_passed += 1; + } else { + output::warning("Device not configured"); + issues.push("Device should be configured during user initialization"); + } + + println!(); + output::header("Diagnostic Results"); + println!("Checks passed: {}/{}", checks_passed, total_checks); + + if !issues.is_empty() { + println!(); + output::warning("Issues found:"); + for issue in issues { + println!(" • {}", issue); + } + } else { + println!(); + output::success("All checks passed! System is healthy."); + } + + Ok(()) +} + +pub async fn export(path: &str, config: &Config) -> CliResult<()> { + let db_path = config.db_path(); + + output::step(&format!("Exporting database to {}", path)); + + fs::copy(&db_path, path)?; + + output::success(&format!("Database exported to {}", path)); + + Ok(()) +} + +pub async fn import(path: &str, force: bool, config: &Config) -> CliResult<()> { + let db_path = config.db_path(); + + if std::path::Path::new(&db_path).exists() && !force { + return Err(CliError::ValidationError( + "Database already exists. Use --force to overwrite".to_string(), + )); + } + + output::step(&format!("Importing database from {}", path)); + + // Create parent directory if needed + if let Some(parent) = std::path::Path::new(&db_path).parent() { + fs::create_dir_all(parent)?; + } + + fs::copy(path, &db_path)?; + + output::success(&format!("Database imported to {}", db_path)); + + Ok(()) +} diff --git a/src/cli/config.rs b/src/cli/config.rs new file mode 100644 index 0000000..37f59b9 --- /dev/null +++ b/src/cli/config.rs @@ -0,0 +1,324 @@ +use crate::cli::errors::{CliError, CliResult}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub database: DatabaseConfig, + pub user: Option, + pub device: Option, + pub sync: SyncConfig, + pub network: NetworkConfig, + pub logging: LoggingConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DatabaseConfig { + pub path: String, + pub auto_migrate: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserConfig { + pub id: String, + pub name: String, + pub email: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceConfig { + pub id: String, + #[serde(rename = "type")] + pub device_type: String, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncConfig { + pub enabled: bool, + pub auto_start: bool, + pub enable_mdns: bool, + pub enable_relay: bool, + pub heartbeat_interval_secs: u64, + pub max_message_size: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkConfig { + pub listen_port: u16, + pub listen_address: String, + pub bootstrap_nodes: Vec, + pub relay_servers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoggingConfig { + pub level: String, + pub format: String, + pub file: String, + pub max_size_mb: u64, + pub max_files: u32, +} + +impl Default for Config { + fn default() -> Self { + let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + let nexus_dir = home_dir.join(".nexus"); + let db_path = nexus_dir.join("nexus.db"); + let log_path = nexus_dir.join("nexus.log"); + + Config { + database: DatabaseConfig { + path: db_path.to_string_lossy().to_string(), + auto_migrate: true, + }, + user: None, + device: None, + sync: SyncConfig { + enabled: true, + auto_start: false, + enable_mdns: true, + enable_relay: true, + heartbeat_interval_secs: 10, + max_message_size: 65536, + }, + network: NetworkConfig { + listen_port: 0, + listen_address: "0.0.0.0".to_string(), + bootstrap_nodes: vec![], + relay_servers: vec![], + }, + logging: LoggingConfig { + level: "info".to_string(), + format: "pretty".to_string(), + file: log_path.to_string_lossy().to_string(), + max_size_mb: 100, + max_files: 5, + }, + } + } +} + +impl Config { + /// Get the default config path + pub fn default_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".nexus") + .join("config.toml") + } + + /// Get the nexus directory path + pub fn nexus_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".nexus") + } + + /// Get the PID file path + pub fn pid_file() -> PathBuf { + Self::nexus_dir().join("nexus.pid") + } + + /// Load configuration from file + pub fn load(path: Option<&str>) -> CliResult { + let config_path = if let Some(p) = path { + PathBuf::from(p) + } else { + Self::default_path() + }; + + if !config_path.exists() { + return Ok(Config::default()); + } + + let contents = fs::read_to_string(&config_path)?; + let config: Config = toml::from_str(&contents)?; + Ok(config) + } + + /// Save configuration to file + pub fn save(&self, path: Option<&str>) -> CliResult<()> { + let config_path = if let Some(p) = path { + PathBuf::from(p) + } else { + Self::default_path() + }; + + // Create parent directory if it doesn't exist + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent)?; + } + + let contents = toml::to_string_pretty(self)?; + fs::write(&config_path, contents)?; + Ok(()) + } + + /// Update a configuration value by key path (e.g., "sync.enabled") + pub fn set_value(&mut self, key: &str, value: &str) -> CliResult<()> { + let parts: Vec<&str> = key.split('.').collect(); + if parts.len() != 2 { + return Err(CliError::ValidationError( + "Key must be in format: section.key".to_string(), + )); + } + + match parts[0] { + "database" => match parts[1] { + "path" => self.database.path = value.to_string(), + "auto_migrate" => self.database.auto_migrate = value.parse().map_err(|_| { + CliError::ValidationError("Invalid boolean value".to_string()) + })?, + _ => return Err(CliError::NotFound(format!("Unknown key: {}", key))), + }, + "sync" => match parts[1] { + "enabled" => self.sync.enabled = value.parse().map_err(|_| { + CliError::ValidationError("Invalid boolean value".to_string()) + })?, + "auto_start" => self.sync.auto_start = value.parse().map_err(|_| { + CliError::ValidationError("Invalid boolean value".to_string()) + })?, + "enable_mdns" => self.sync.enable_mdns = value.parse().map_err(|_| { + CliError::ValidationError("Invalid boolean value".to_string()) + })?, + "enable_relay" => self.sync.enable_relay = value.parse().map_err(|_| { + CliError::ValidationError("Invalid boolean value".to_string()) + })?, + "heartbeat_interval_secs" => { + self.sync.heartbeat_interval_secs = value.parse().map_err(|_| { + CliError::ValidationError("Invalid number value".to_string()) + })? + } + "max_message_size" => { + self.sync.max_message_size = value.parse().map_err(|_| { + CliError::ValidationError("Invalid number value".to_string()) + })? + } + _ => return Err(CliError::NotFound(format!("Unknown key: {}", key))), + }, + "network" => match parts[1] { + "listen_port" => self.network.listen_port = value.parse().map_err(|_| { + CliError::ValidationError("Invalid port number".to_string()) + })?, + "listen_address" => self.network.listen_address = value.to_string(), + _ => return Err(CliError::NotFound(format!("Unknown key: {}", key))), + }, + "logging" => match parts[1] { + "level" => self.logging.level = value.to_string(), + "format" => self.logging.format = value.to_string(), + "file" => self.logging.file = value.to_string(), + "max_size_mb" => self.logging.max_size_mb = value.parse().map_err(|_| { + CliError::ValidationError("Invalid number value".to_string()) + })?, + "max_files" => self.logging.max_files = value.parse().map_err(|_| { + CliError::ValidationError("Invalid number value".to_string()) + })?, + _ => return Err(CliError::NotFound(format!("Unknown key: {}", key))), + }, + _ => return Err(CliError::NotFound(format!("Unknown section: {}", parts[0]))), + } + + Ok(()) + } + + /// Get a configuration value by key path + pub fn get_value(&self, key: &str) -> CliResult { + let parts: Vec<&str> = key.split('.').collect(); + if parts.len() != 2 { + return Err(CliError::ValidationError( + "Key must be in format: section.key".to_string(), + )); + } + + let value = match parts[0] { + "database" => match parts[1] { + "path" => self.database.path.clone(), + "auto_migrate" => self.database.auto_migrate.to_string(), + _ => return Err(CliError::NotFound(format!("Unknown key: {}", key))), + }, + "sync" => match parts[1] { + "enabled" => self.sync.enabled.to_string(), + "auto_start" => self.sync.auto_start.to_string(), + "enable_mdns" => self.sync.enable_mdns.to_string(), + "enable_relay" => self.sync.enable_relay.to_string(), + "heartbeat_interval_secs" => self.sync.heartbeat_interval_secs.to_string(), + "max_message_size" => self.sync.max_message_size.to_string(), + _ => return Err(CliError::NotFound(format!("Unknown key: {}", key))), + }, + "network" => match parts[1] { + "listen_port" => self.network.listen_port.to_string(), + "listen_address" => self.network.listen_address.clone(), + _ => return Err(CliError::NotFound(format!("Unknown key: {}", key))), + }, + "logging" => match parts[1] { + "level" => self.logging.level.clone(), + "format" => self.logging.format.clone(), + "file" => self.logging.file.clone(), + "max_size_mb" => self.logging.max_size_mb.to_string(), + "max_files" => self.logging.max_files.to_string(), + _ => return Err(CliError::NotFound(format!("Unknown key: {}", key))), + }, + "user" => match parts[1] { + "id" => self + .user + .as_ref() + .map(|u| u.id.clone()) + .ok_or_else(|| CliError::NotFound("User not configured".to_string()))?, + "name" => self + .user + .as_ref() + .map(|u| u.name.clone()) + .ok_or_else(|| CliError::NotFound("User not configured".to_string()))?, + "email" => self + .user + .as_ref() + .map(|u| u.email.clone()) + .ok_or_else(|| CliError::NotFound("User not configured".to_string()))?, + _ => return Err(CliError::NotFound(format!("Unknown key: {}", key))), + }, + "device" => match parts[1] { + "id" => self + .device + .as_ref() + .map(|d| d.id.clone()) + .ok_or_else(|| CliError::NotFound("Device not configured".to_string()))?, + "type" => self + .device + .as_ref() + .map(|d| d.device_type.clone()) + .ok_or_else(|| CliError::NotFound("Device not configured".to_string()))?, + "name" => self + .device + .as_ref() + .map(|d| d.name.clone()) + .ok_or_else(|| CliError::NotFound("Device not configured".to_string()))?, + _ => return Err(CliError::NotFound(format!("Unknown key: {}", key))), + }, + _ => return Err(CliError::NotFound(format!("Unknown section: {}", parts[0]))), + }; + + Ok(value) + } + + /// Expand paths with ~ to full paths + pub fn expand_path(path: &str) -> String { + if path.starts_with("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(&path[2..]).to_string_lossy().to_string(); + } + } + path.to_string() + } + + /// Get the expanded database path + pub fn db_path(&self) -> String { + Self::expand_path(&self.database.path) + } + + /// Get the expanded log file path + pub fn log_path(&self) -> String { + Self::expand_path(&self.logging.file) + } +} diff --git a/src/cli/daemon.rs b/src/cli/daemon.rs new file mode 100644 index 0000000..5ea690c --- /dev/null +++ b/src/cli/daemon.rs @@ -0,0 +1,216 @@ +use crate::cli::errors::{CliError, CliResult}; +use std::fs; +use std::path::Path; +use std::process::{Command, Stdio}; + +/// Check if the daemon is running +pub fn is_running(pid_file: &Path) -> bool { + if !pid_file.exists() { + return false; + } + + // Read PID from file + let pid_str = match fs::read_to_string(pid_file) { + Ok(s) => s.trim().to_string(), + Err(_) => return false, + }; + + let pid: i32 = match pid_str.parse() { + Ok(p) => p, + Err(_) => return false, + }; + + // Check if process with PID is running + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // Send signal 0 to check if process exists + let result = unsafe { libc::kill(pid, 0) }; + if result == 0 { + return true; + } + // Process doesn't exist, clean up PID file + fs::remove_file(pid_file).ok(); + false + } + + #[cfg(windows)] + { + // On Windows, try to open the process + use std::os::windows::io::AsRawHandle; + use std::ptr; + use winapi::um::processthreadsapi::OpenProcess; + use winapi::um::winnt::PROCESS_QUERY_INFORMATION; + + unsafe { + let handle = OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid as u32); + if handle != ptr::null_mut() { + winapi::um::handleapi::CloseHandle(handle); + return true; + } + } + // Process doesn't exist, clean up PID file + fs::remove_file(pid_file).ok(); + false + } + + #[cfg(not(any(unix, windows)))] + { + // Fallback: assume running if PID file exists + true + } +} + +/// Get the PID from the PID file +pub fn get_pid(pid_file: &Path) -> CliResult { + if !pid_file.exists() { + return Err(CliError::DaemonError("Daemon is not running".to_string())); + } + + let pid_str = fs::read_to_string(pid_file)?; + let pid: i32 = pid_str + .trim() + .parse() + .map_err(|_| CliError::DaemonError("Invalid PID file".to_string()))?; + + Ok(pid) +} + +/// Write PID to file +pub fn write_pid(pid_file: &Path, pid: i32) -> CliResult<()> { + // Create parent directory if needed + if let Some(parent) = pid_file.parent() { + fs::create_dir_all(parent)?; + } + + fs::write(pid_file, pid.to_string())?; + Ok(()) +} + +/// Remove PID file +pub fn remove_pid_file(pid_file: &Path) -> CliResult<()> { + if pid_file.exists() { + fs::remove_file(pid_file)?; + } + Ok(()) +} + +/// Stop the daemon process +pub fn stop_daemon(pid_file: &Path) -> CliResult<()> { + if !is_running(pid_file) { + return Err(CliError::DaemonError("Daemon is not running".to_string())); + } + + let pid = get_pid(pid_file)?; + + #[cfg(unix)] + { + // Send SIGTERM to gracefully stop the process + unsafe { + if libc::kill(pid, libc::SIGTERM) != 0 { + return Err(CliError::DaemonError(format!( + "Failed to stop daemon (PID: {})", + pid + ))); + } + } + } + + #[cfg(windows)] + { + // On Windows, use taskkill + let output = Command::new("taskkill") + .args(&["/PID", &pid.to_string(), "/F"]) + .output()?; + + if !output.status.success() { + return Err(CliError::DaemonError(format!( + "Failed to stop daemon (PID: {})", + pid + ))); + } + } + + // Wait a moment for the process to stop + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Remove PID file + remove_pid_file(pid_file)?; + + Ok(()) +} + +/// Start the daemon in the background +#[cfg(unix)] +pub fn daemonize(pid_file: &Path) -> CliResult<()> { + use daemonize::Daemonize; + use std::fs::File; + + let log_file = pid_file + .parent() + .unwrap_or(Path::new("/tmp")) + .join("nexus-daemon.log"); + + let stdout = File::create(&log_file) + .map_err(|e| CliError::DaemonError(format!("Failed to create log file: {}", e)))?; + let stderr = File::create(&log_file) + .map_err(|e| CliError::DaemonError(format!("Failed to create log file: {}", e)))?; + + let daemonize = Daemonize::new() + .pid_file(pid_file) + .working_directory(std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"))) + .stdout(stdout) + .stderr(stderr); + + daemonize + .start() + .map_err(|e| CliError::DaemonError(format!("Failed to daemonize: {}", e)))?; + + Ok(()) +} + +/// Start the daemon in the background (Windows fallback) +#[cfg(windows)] +pub fn daemonize(pid_file: &Path) -> CliResult<()> { + // On Windows, we'll just spawn a detached process + // The actual implementation would use Windows services, but for simplicity + // we'll just note that full daemon support requires more work on Windows + Err(CliError::DaemonError( + "Daemon mode is not fully supported on Windows. Run in foreground mode.".to_string(), + )) +} + +/// Get daemon uptime in seconds +pub fn get_uptime(pid_file: &Path) -> CliResult { + if !is_running(pid_file) { + return Err(CliError::DaemonError("Daemon is not running".to_string())); + } + + // Get file modification time as proxy for start time + let metadata = fs::metadata(pid_file)?; + let modified = metadata.modified()?; + let now = std::time::SystemTime::now(); + + let duration = now + .duration_since(modified) + .map_err(|_| CliError::DaemonError("Failed to calculate uptime".to_string()))?; + + Ok(duration.as_secs()) +} + +/// Format uptime duration +pub fn format_uptime(seconds: u64) -> String { + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let secs = seconds % 60; + + if hours > 0 { + format!("{}h {}m", hours, minutes) + } else if minutes > 0 { + format!("{}m {}s", minutes, secs) + } else { + format!("{}s", secs) + } +} + +use std::path::PathBuf; diff --git a/src/cli/errors.rs b/src/cli/errors.rs new file mode 100644 index 0000000..cc13abc --- /dev/null +++ b/src/cli/errors.rs @@ -0,0 +1,68 @@ +use std::fmt; + +pub type CliResult = Result; + +#[derive(Debug)] +pub enum CliError { + ConfigError(String), + DatabaseError(String), + IoError(std::io::Error), + SyncError(String), + DaemonError(String), + ValidationError(String), + NotFound(String), + Other(String), +} + +impl fmt::Display for CliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CliError::ConfigError(msg) => write!(f, "Configuration error: {}", msg), + CliError::DatabaseError(msg) => write!(f, "Database error: {}", msg), + CliError::IoError(err) => write!(f, "IO error: {}", err), + CliError::SyncError(msg) => write!(f, "Sync error: {}", msg), + CliError::DaemonError(msg) => write!(f, "Daemon error: {}", msg), + CliError::ValidationError(msg) => write!(f, "Validation error: {}", msg), + CliError::NotFound(msg) => write!(f, "Not found: {}", msg), + CliError::Other(msg) => write!(f, "{}", msg), + } + } +} + +impl std::error::Error for CliError {} + +impl From for CliError { + fn from(err: std::io::Error) -> Self { + CliError::IoError(err) + } +} + +impl From for CliError { + fn from(err: toml::de::Error) -> Self { + CliError::ConfigError(err.to_string()) + } +} + +impl From for CliError { + fn from(err: toml::ser::Error) -> Self { + CliError::ConfigError(err.to_string()) + } +} + +impl From for CliError { + fn from(err: String) -> Self { + CliError::Other(err) + } +} + +impl From<&str> for CliError { + fn from(err: &str) -> Self { + CliError::Other(err.to_string()) + } +} + +impl From for CliError { + fn from(err: rusqlite::Error) -> Self { + CliError::DatabaseError(err.to_string()) + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..b316392 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,10 @@ +#[cfg(feature = "cli")] +pub mod commands; +#[cfg(feature = "cli")] +pub mod config; +#[cfg(feature = "cli")] +pub mod daemon; +#[cfg(feature = "cli")] +pub mod errors; +#[cfg(feature = "cli")] +pub mod output; diff --git a/src/cli/output.rs b/src/cli/output.rs new file mode 100644 index 0000000..9a72c7f --- /dev/null +++ b/src/cli/output.rs @@ -0,0 +1,105 @@ +use colored::Colorize; +use prettytable::{Cell, Row, Table}; +use serde_json::Value; + +/// Print a success message +pub fn success(msg: &str) { + println!("{} {}", "✓".green().bold(), msg); +} + +/// Print an error message +pub fn error(msg: &str) { + eprintln!("{} {}", "✗".red().bold(), msg); +} + +/// Print a warning message +pub fn warning(msg: &str) { + println!("{} {}", "⚠".yellow().bold(), msg); +} + +/// Print an info message +pub fn info(msg: &str) { + println!("{} {}", "ℹ".blue().bold(), msg); +} + +/// Print a step message +pub fn step(msg: &str) { + println!("{} {}", "→".cyan().bold(), msg); +} + +/// Print a header +pub fn header(msg: &str) { + println!("\n{}", msg.bold().underline()); +} + +/// Print key-value pair +pub fn key_value(key: &str, value: &str) { + println!(" {}: {}", key.cyan(), value); +} + +/// Print JSON output +pub fn json(data: &Value) { + println!("{}", serde_json::to_string_pretty(data).unwrap_or_default()); +} + +/// Create a formatted table +pub fn create_table(headers: Vec<&str>) -> Table { + let mut table = Table::new(); + table.set_format(*prettytable::format::consts::FORMAT_BOX_CHARS); + + let header_cells: Vec = headers + .iter() + .map(|h| Cell::new(h).style_spec("Fb")) + .collect(); + table.add_row(Row::new(header_cells)); + + table +} + +/// Print a box with content +pub fn print_box(title: &str, content: Vec<(&str, &str)>) { + let max_key_len = content.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + let max_val_len = content.iter().map(|(_, v)| v.len()).max().unwrap_or(0); + let box_width = (max_key_len + max_val_len + 6).max(title.len() + 4); + + // Top border + println!("┌{}┐", "─".repeat(box_width)); + + // Title + let title_padding = (box_width - title.len()) / 2; + println!( + "│{}{}{}│", + " ".repeat(title_padding), + title.bold(), + " ".repeat(box_width - title.len() - title_padding) + ); + + // Separator + println!("├{}┤", "─".repeat(box_width)); + + // Content + for (key, value) in content { + let padding = max_key_len - key.len(); + println!( + "│ {}{}: {}{}│", + key.cyan(), + " ".repeat(padding), + value, + " ".repeat(box_width - key.len() - value.len() - padding - 3) + ); + } + + // Bottom border + println!("└{}┘", "─".repeat(box_width)); +} + +/// Print a progress message +pub fn progress(msg: &str) { + print!("{} {}...", "●".cyan(), msg); + std::io::Write::flush(&mut std::io::stdout()).ok(); +} + +/// Complete a progress message +pub fn progress_done() { + println!(" {}", "done".green()); +} diff --git a/src/db/operations.rs b/src/db/operations.rs index 51aa522..7cd727a 100644 --- a/src/db/operations.rs +++ b/src/db/operations.rs @@ -780,6 +780,26 @@ pub fn get_peers_by_user_id(conn: &Connection, user_id: Uuid) -> Result Result { + let mut stmt = conn.prepare("SELECT peer_id, user_id, device_id, last_known_ip, last_sync_time FROM peers WHERE peer_id = ?1")?; + let peer = stmt.query_row(params![peer_id.to_string()], row_to_peer)?; + Ok(peer) +} + +// Peer READ (all) +pub fn get_all_peers(conn: &Connection) -> Result> { + let mut stmt = conn.prepare("SELECT peer_id, user_id, device_id, last_known_ip, last_sync_time FROM peers")?; + let rows = stmt.query_map(params![], row_to_peer)?; + + let mut peers = Vec::new(); + for row in rows { + peers.push(row?); + } + + Ok(peers) +} + // TaskList READ (by user) pub fn get_task_lists_by_user_id(conn: &Connection, user_id: Uuid) -> Result> { let mut stmt = diff --git a/src/lib.rs b/src/lib.rs index 62d5a51..19b2dfd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,6 +38,10 @@ pub mod logic; pub mod models; pub mod tauri_api; +// CLI module (optional, enabled with "cli" feature) +#[cfg(feature = "cli")] +pub mod cli; + // Re-export error types for convenient usage pub use error::{NexusError, Result}; @@ -79,6 +83,7 @@ pub use db::operations::{ create_user, delete_favorite_sound, get_active_blocked_items_by_user_id, + get_all_peers, get_all_sounds, get_block, get_device, @@ -87,6 +92,7 @@ pub use db::operations::{ get_habit, get_habit_entries_sorted_by_date, get_oplog_entries_since, + get_peer, get_peers_by_user_id, get_pomodoros_by_user_id, get_sound, diff --git a/src/logic/sync_manager.rs b/src/logic/sync_manager.rs index 636f42f..c2f536d 100644 --- a/src/logic/sync_manager.rs +++ b/src/logic/sync_manager.rs @@ -1,4 +1,4 @@ -fuse rusqlite::Connection; +use rusqlite::Connection; use std::sync::{Arc, Mutex}; use std::collections::VecDeque; use uuid::Uuid;