From 1260d814b79876b6d1b197dd49eca431fd71a41d Mon Sep 17 00:00:00 2001 From: franzer Date: Wed, 1 Jul 2026 08:40:24 -0600 Subject: [PATCH] chore: remove cloud sync code and daemon sync timer --- Cargo.toml | 6 +- src/cli/commands/cloud.rs | 1224 ------------------------------------ src/cli/commands/init.rs | 2 +- src/cli/commands/login.rs | 628 ------------------ src/cli/commands/logout.rs | 51 -- src/cli/commands/mod.rs | 9 - src/cli/commands/status.rs | 135 ---- src/cloud/client.rs | 471 -------------- src/cloud/credentials.rs | 566 ----------------- src/cloud/encryption.rs | 8 - src/cloud/mod.rs | 133 ---- src/config/mod.rs | 151 +---- src/daemon/mod.rs | 18 +- src/daemon/sync.rs | 540 ---------------- src/lib.rs | 4 - src/main.rs | 34 - src/storage/db.rs | 182 +----- src/storage/mod.rs | 2 +- src/storage/models.rs | 4 +- src/sync/encryption.rs | 26 +- src/sync/keystore.rs | 27 +- src/sync/mod.rs | 8 +- src/sync/store.rs | 6 +- 23 files changed, 51 insertions(+), 4184 deletions(-) delete mode 100644 src/cli/commands/cloud.rs delete mode 100644 src/cli/commands/login.rs delete mode 100644 src/cli/commands/logout.rs delete mode 100644 src/cloud/client.rs delete mode 100644 src/cloud/credentials.rs delete mode 100644 src/cloud/encryption.rs delete mode 100644 src/cloud/mod.rs delete mode 100644 src/daemon/sync.rs diff --git a/Cargo.toml b/Cargo.toml index 8286a81..c8ed516 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,13 +51,13 @@ regex = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -# Cloud sync +# HTTP client for LLM summary providers reqwest = { version = "0.13", features = ["json", "rustls", "blocking"], default-features = false } + +# Sync encryption and key storage aes-gcm = "0.10" argon2 = "0.5" keyring = { version = "3", features = ["apple-native", "sync-secret-service", "windows-native"] } -base64 = "0.22" -webbrowser = "1" rpassword = "7" rand = "0.8" diff --git a/src/cli/commands/cloud.rs b/src/cli/commands/cloud.rs deleted file mode 100644 index 212789c..0000000 --- a/src/cli/commands/cloud.rs +++ /dev/null @@ -1,1224 +0,0 @@ -//! Cloud command - sync sessions with Lore cloud service. -//! -//! Provides subcommands for checking sync status, pushing sessions to the -//! cloud, and pulling sessions from other machines. - -use anyhow::{Context, Result}; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use chrono::Utc; -use colored::Colorize; -use serde::Serialize; -use std::io::{self, Write}; -use std::sync::mpsc; -use std::thread; - -use crate::cli::OutputFormat; -use crate::cloud::client::{CloudClient, PushSession, SessionMetadata}; -use crate::cloud::credentials::{require_login, CredentialsStore}; -use crate::cloud::encryption::{ - decode_base64, decode_key_hex, decrypt_data, derive_key, encode_base64, encode_key_hex, - encrypt_data, -}; -use crate::config::Config; -use crate::daemon::SyncState; -use crate::storage::models::{Message, Session}; -use crate::storage::Database; - -/// Arguments for the cloud command. -#[derive(clap::Args)] -#[command(after_help = "EXAMPLES:\n \ - lore cloud status Show cloud sync status\n \ - lore cloud push Push local sessions to cloud\n \ - lore cloud pull Pull sessions from cloud\n \ - lore cloud sync Pull then push (bidirectional sync)\n \ - lore cloud reset-sync Reset sync status to re-upload all sessions")] -pub struct Args { - #[command(subcommand)] - pub command: CloudSubcommand, -} - -/// Cloud subcommands. -#[derive(clap::Subcommand)] -pub enum CloudSubcommand { - /// Show cloud sync status - #[command( - long_about = "Shows the current cloud sync status including session count,\n\ - storage used, and last sync time. Also shows how many local\n\ - sessions are pending sync." - )] - Status { - /// Output format: text (default), json - #[arg(short, long, value_enum, default_value = "text")] - format: OutputFormat, - }, - - /// Push local sessions to the cloud - #[command( - long_about = "Uploads sessions that have not been synced to the cloud.\n\ - Session messages are encrypted locally before upload using your\n\ - encryption passphrase. On first push, you will be prompted to\n\ - create a passphrase." - )] - Push { - /// Show what would be pushed without actually pushing - #[arg(long)] - dry_run: bool, - }, - - /// Pull sessions from the cloud - #[command( - long_about = "Downloads sessions from the cloud that were created on other\n\ - machines. Requires your encryption passphrase to decrypt the\n\ - session content." - )] - Pull { - /// Pull all sessions, not just since last sync - #[arg(long)] - all: bool, - }, - - /// Sync sessions with the cloud (pull then push) - #[command(long_about = "Performs a full bidirectional sync with the cloud.\n\ - First pulls any new sessions from other machines, then pushes\n\ - local sessions that haven't been synced yet.")] - Sync, - - /// Reset sync status to re-upload sessions - #[command( - name = "reset-sync", - long_about = "Resets the sync status of local sessions, marking them as unsynced.\n\ - This is useful when switching cloud environments or fixing sync issues.\n\ - After running this command, use 'lore cloud push' to re-upload sessions." - )] - ResetSync { - /// Reset specific session(s) by ID or prefix - #[arg(long, value_name = "ID")] - session: Option>, - - /// Skip confirmation prompt - #[arg(long)] - force: bool, - }, -} - -/// JSON output for cloud status. -#[derive(Serialize)] -struct StatusOutput { - logged_in: bool, - email: Option, - plan: Option, - cloud: Option, - local: LocalStatus, -} - -#[derive(Serialize)] -struct CloudStatus { - session_count: i64, - storage_used_bytes: i64, - last_sync_at: Option, -} - -#[derive(Serialize)] -struct LocalStatus { - total_sessions: i32, - unsynced_sessions: i32, - last_sync_at: Option, - next_auto_sync_at: Option, -} - -/// Executes the cloud command. -pub fn run(args: Args) -> Result<()> { - match args.command { - CloudSubcommand::Status { format } => run_status(format), - CloudSubcommand::Push { dry_run } => run_push(dry_run), - CloudSubcommand::Pull { all } => run_pull(all), - CloudSubcommand::Sync => run_sync(), - CloudSubcommand::ResetSync { session, force } => run_reset_sync(session, force), - } -} - -/// Shows cloud sync status. -fn run_status(format: OutputFormat) -> Result<()> { - let db = Database::open_default()?; - let config = Config::load()?; - let store = CredentialsStore::with_keychain(config.use_keychain); - let creds = store.load().context("Failed to check login status")?; - - let total_sessions = db.session_count()?; - let unsynced_sessions = db.unsynced_session_count()?; - let last_local_sync = db.last_sync_time()?; - - // Load daemon sync state to get next auto-sync time - let sync_state = SyncState::load().ok(); - let next_auto_sync = sync_state.as_ref().and_then(|s| s.next_sync_at); - - match format { - OutputFormat::Json => { - let cloud_status = if let Some(ref creds) = creds { - let client = CloudClient::with_url(&creds.cloud_url).with_api_key(&creds.api_key); - match client.status() { - Ok(status) => Some(CloudStatus { - session_count: status.session_count, - storage_used_bytes: status.storage_used_bytes, - last_sync_at: status.last_sync_at.map(|t| t.to_rfc3339()), - }), - Err(e) => { - tracing::debug!("Failed to get cloud status: {e}"); - None - } - } - } else { - None - }; - - let output = StatusOutput { - logged_in: creds.is_some(), - email: creds.as_ref().map(|c| c.email.clone()), - plan: creds.as_ref().map(|c| c.plan.clone()), - cloud: cloud_status, - local: LocalStatus { - total_sessions, - unsynced_sessions, - last_sync_at: last_local_sync.map(|t| t.to_rfc3339()), - next_auto_sync_at: next_auto_sync.map(|t| t.to_rfc3339()), - }, - }; - - println!("{}", serde_json::to_string_pretty(&output)?); - } - OutputFormat::Text | OutputFormat::Markdown => { - println!("{}", "Cloud Sync".bold()); - println!(); - - match creds { - Some(creds) => { - println!("{}", "Account:".bold()); - println!(" Email: {}", creds.email.cyan()); - println!(" Plan: {}", creds.plan); - println!(); - - // Get cloud status - let client = - CloudClient::with_url(&creds.cloud_url).with_api_key(&creds.api_key); - match client.status() { - Ok(status) => { - println!("{}", "Cloud:".bold()); - println!(" Sessions: {}", status.session_count); - println!(" Storage: {}", format_bytes(status.storage_used_bytes)); - if let Some(last_sync) = status.last_sync_at { - println!(" Last sync: {}", format_relative_time(last_sync)); - } - println!(); - } - Err(e) => { - println!("{}: {}", "Cloud status unavailable".yellow(), e); - println!(); - } - } - } - None => { - println!( - "{} Run 'lore login' to authenticate.", - "Not logged in.".yellow() - ); - println!(); - } - } - - println!("{}", "Local:".bold()); - println!(" Total sessions: {}", total_sessions); - println!(" Pending sync: {}", unsynced_sessions); - if let Some(last_sync) = last_local_sync { - println!(" Last sync: {}", format_relative_time(last_sync)); - } - // Show next auto-sync time - match next_auto_sync { - Some(next_sync) => { - println!( - " Next auto-sync: {}", - format_future_relative_time(next_sync) - ); - } - None => { - println!(" Next auto-sync: {}", "Not scheduled".dimmed()); - } - } - } - } - - Ok(()) -} - -/// Number of sessions to include in each batch when pushing to the cloud. -/// Kept small to avoid 413 errors from large sessions with many messages. -const PUSH_BATCH_SIZE: usize = 3; - -/// Pushes local sessions to the cloud. -fn run_push(dry_run: bool) -> Result<()> { - let creds = require_login()?; - let db = Database::open_default()?; - let config = Config::load()?; - let client = CloudClient::with_url(&creds.cloud_url).with_api_key(&creds.api_key); - - // Ensure salt is synced to cloud (migration for existing users) - if let Some(ref local_salt) = config.encryption_salt { - match client.get_salt() { - Ok(None) => { - // Cloud doesn't have salt, upload it - if let Err(e) = client.set_salt(local_salt) { - tracing::debug!("Could not sync salt to cloud: {e}"); - } else { - tracing::debug!("Synced encryption salt to cloud"); - } - } - Ok(Some(_)) => { - // Cloud already has salt, nothing to do - } - Err(e) => { - tracing::debug!("Could not check cloud salt: {e}"); - } - } - } - - // Get unsynced sessions - let sessions = db.get_unsynced_sessions()?; - if sessions.is_empty() { - println!("{}", "All sessions are already synced.".green()); - return Ok(()); - } - - println!("Found {} sessions to sync.", sessions.len()); - - if dry_run { - println!(); - println!("{}", "Dry run - would push:".yellow()); - for session in &sessions { - println!( - " {} ({}, {} messages)", - &session.id.to_string()[..8], - session.tool, - session.message_count - ); - } - return Ok(()); - } - - // Get or create encryption key - let store = CredentialsStore::with_keychain(config.use_keychain); - let mut config = config; // Make mutable for potential salt creation - let encryption_key = match store.load_encryption_key()? { - Some(key_hex) => decode_key_hex(&key_hex)?, - None => { - // First push - prompt for passphrase - println!(); - println!("{}", "First sync - set up encryption".bold()); - println!( - "Your session content will be encrypted with a passphrase that only you know." - ); - println!("The cloud service cannot read your session content."); - println!(); - - let passphrase = prompt_new_passphrase()?; - let salt_b64 = config.get_or_create_encryption_salt()?; - let salt = BASE64.decode(&salt_b64)?; - let key = derive_key(&passphrase, &salt)?; - - // Store the derived key (not the passphrase) - store.store_encryption_key(&encode_key_hex(&key))?; - - // Sync salt to cloud for other machines - if let Err(e) = client.set_salt(&salt_b64) { - tracing::debug!("Could not sync salt to cloud (may already exist): {e}"); - } - - key - } - }; - - // Get machine ID - let machine_id = config.get_or_create_machine_id()?; - - // Pre-read all messages (fast - just SELECT queries) - println!(); - print!(" Reading sessions..."); - io::stdout().flush()?; - let session_data: Vec<_> = sessions - .iter() - .map(|session| { - let messages = db.get_messages(&session.id)?; - Ok((session.clone(), messages)) - }) - .collect::>>()?; - println!(" done"); - - // Split into batches for processing - let batches: Vec> = session_data - .chunks(PUSH_BATCH_SIZE) - .map(|chunk| chunk.to_vec()) - .collect(); - let total_batches = batches.len(); - - // Channel for encrypted batches (bounded to 2 for backpressure) - let (tx, rx) = mpsc::sync_channel::)>>(2); - - // Spawn encryption thread - let encrypt_handle = thread::spawn(move || { - for (batch_idx, batch) in batches.into_iter().enumerate() { - let mut push_sessions = Vec::new(); - for (session, messages) in batch { - let encrypted = match encrypt_session_messages(&messages, &encryption_key) { - Ok(e) => e, - Err(e) => { - let _ = tx.send(Err(e)); - return; - } - }; - push_sessions.push(PushSession { - id: session.id.to_string(), - machine_id: machine_id.clone(), - encrypted_data: encrypted, - metadata: SessionMetadata { - tool_name: session.tool.clone(), - project_path: session.working_directory.clone(), - started_at: session.started_at, - ended_at: session.ended_at, - message_count: session.message_count, - }, - updated_at: session.ended_at.unwrap_or_else(Utc::now), - }); - } - if tx.send(Ok((batch_idx, push_sessions))).is_err() { - return; // Receiver dropped, stop processing - } - } - }); - - // Main thread: receive encrypted batches and upload (pipelined) - println!(" Encrypting and uploading ({} batches)...", total_batches); - let mut total_synced: i64 = 0; - let mut batch_errors: Vec<(usize, String)> = Vec::new(); - let mut too_large_sessions: Vec = Vec::new(); - let mut quota_exceeded: Option = None; - - for received in rx { - let (batch_idx, batch) = received?; - let batch_num = batch_idx + 1; - print!(" Batch {}/{}... ", batch_num, total_batches); - io::stdout().flush()?; - - match client.push(batch.to_vec()) { - Ok(response) => { - println!("done"); - - // Mark sessions in this batch as synced immediately - let batch_session_ids: Vec<_> = batch - .iter() - .filter_map(|ps| uuid::Uuid::parse_str(&ps.id).ok()) - .collect(); - db.mark_sessions_synced(&batch_session_ids, response.server_time)?; - - total_synced += response.synced_count; - } - Err(e) => { - let error_str = e.to_string(); - - // Check if this is a quota error - fail fast and stop processing - if is_quota_error(&error_str) { - println!("{}", "quota limit reached".yellow()); - quota_exceeded = parse_quota_info(&error_str); - break; // Stop processing remaining batches - } else if is_size_error(&error_str) { - // Check if this is a size-related error (413 or "Too Large") - println!("{}", "failed (retrying individually)".yellow()); - - // Retry each session in the batch individually - for session in &batch { - let session_short_id = &session.id[..8]; - print!(" Session {}... ", session_short_id); - io::stdout().flush()?; - - match client.push(vec![session.clone()]) { - Ok(response) => { - println!("done"); - if let Ok(session_id) = uuid::Uuid::parse_str(&session.id) { - db.mark_sessions_synced(&[session_id], response.server_time)?; - } - total_synced += response.synced_count; - } - Err(individual_err) => { - let individual_error_str = individual_err.to_string(); - if is_quota_error(&individual_error_str) { - println!("{}", "quota limit reached".yellow()); - quota_exceeded = parse_quota_info(&individual_error_str); - break; // Stop processing remaining sessions - } else if is_size_error(&individual_error_str) { - println!("{}", "too large, skipping".yellow()); - too_large_sessions.push(session.id.clone()); - } else { - println!("{}", "failed".red()); - batch_errors.push(( - batch_num, - format!( - "Session {}: {}", - session_short_id, individual_error_str - ), - )); - } - } - } - } - // If quota was exceeded during individual retries, stop batch processing - if quota_exceeded.is_some() { - break; - } - } else { - println!("{}", "failed".red()); - batch_errors.push((batch_num, error_str)); - } - } - } - } - - // Wait for encryption thread to finish - encrypt_handle.join().expect("Encryption thread panicked"); - - println!(); - - // Handle quota exceeded case specially (takes precedence over other errors) - if let Some(quota) = quota_exceeded { - if total_synced > 0 { - println!( - "{} Synced {} sessions (reached {} plan limit of {}).", - "Done.".green().bold(), - total_synced, - quota.plan, - quota.limit - ); - } else { - println!( - "{} Could not sync - {} plan limit of {} sessions reached ({}/{} used).", - "Limit reached.".yellow().bold(), - quota.plan, - quota.limit, - quota.current, - quota.limit - ); - } - - let remaining = sessions.len() as i64 - total_synced; - if remaining > 0 { - println!("{} sessions could not be synced.", remaining); - } - println!(); - println!( - "Upgrade to Pro for unlimited sessions: {}", - "https://lore.varalys.com/pricing".cyan() - ); - return Ok(()); - } - - // Report results - if batch_errors.is_empty() && too_large_sessions.is_empty() { - println!( - "{} Synced {} sessions to the cloud.", - "Success!".green().bold(), - total_synced - ); - } else if batch_errors.is_empty() { - // Only size issues, no other errors - println!( - "{} Synced {} sessions to the cloud.", - "Success!".green().bold(), - total_synced - ); - println!( - "{} {} session(s) were too large to sync:", - "Note:".yellow(), - too_large_sessions.len() - ); - for session_id in &too_large_sessions { - println!(" {}", &session_id[..8]); - } - } else { - // Some batches failed with non-size errors - if total_synced > 0 { - println!( - "{} Synced {} sessions, but {} error(s) occurred:", - "Partial success.".yellow().bold(), - total_synced, - batch_errors.len() - ); - } else { - println!("{} All batches failed:", "Error!".red().bold()); - } - for (batch_num, error) in &batch_errors { - println!(" Batch {}: {}", batch_num, error); - } - if !too_large_sessions.is_empty() { - println!( - "{} {} session(s) were too large to sync:", - "Note:".yellow(), - too_large_sessions.len() - ); - for session_id in &too_large_sessions { - println!(" {}", &session_id[..8]); - } - } - } - - Ok(()) -} - -/// Pulls sessions from the cloud. -fn run_pull(all: bool) -> Result<()> { - let creds = require_login()?; - let mut db = Database::open_default()?; - - // Determine since time - let since = if all { None } else { db.last_sync_time()? }; - - // Create client early so we can fetch salt if needed - let client = CloudClient::with_url(&creds.cloud_url).with_api_key(&creds.api_key); - - // Get encryption key - let mut config = Config::load()?; - let store = CredentialsStore::with_keychain(config.use_keychain); - let encryption_key = match store.load_encryption_key()? { - Some(key_hex) => decode_key_hex(&key_hex)?, - None => { - // Need to prompt for passphrase - println!("Enter your encryption passphrase to decrypt sessions:"); - let passphrase = prompt_passphrase()?; - - // Try to get salt from local config first, then from cloud - let salt_b64 = match &config.encryption_salt { - Some(salt) => salt.clone(), - None => { - // Fetch salt from cloud - let cloud_salt = client.get_salt()?.ok_or_else(|| { - anyhow::anyhow!( - "No encryption salt found locally or on cloud. Run 'lore cloud push' on a machine with existing sessions first." - ) - })?; - // Save salt locally for future use - config.encryption_salt = Some(cloud_salt.clone()); - config.save()?; - cloud_salt - } - }; - let salt = BASE64.decode(&salt_b64)?; - let key = derive_key(&passphrase, &salt)?; - - // Store for future use - store.store_encryption_key(&encode_key_hex(&key))?; - key - } - }; - - println!("Downloading sessions from cloud..."); - let response = client.pull(since)?; - - if response.sessions.is_empty() { - println!("{}", "No new sessions to pull.".green()); - return Ok(()); - } - - println!("Found {} sessions to process.", response.sessions.len()); - - let mut imported = 0; - let mut updated = 0; - let mut skipped = 0; - let mut failed = 0; - let total = response.sessions.len(); - let config = Config::load()?; - let local_machine_id = config.machine_id.clone(); - - for (idx, pull_session) in response.sessions.into_iter().enumerate() { - // Progress indicator - use eprint to ensure immediate output - eprint!("\r Processing sessions... {}/{}", idx + 1, total); - - // Skip sessions from this machine (we already have them) - if Some(&pull_session.machine_id) == local_machine_id.as_ref() { - skipped += 1; - continue; - } - - // Check if session already exists and whether cloud version is newer - let existing_session = db - .find_session_by_id_prefix(&pull_session.id) - .ok() - .flatten(); - let is_update = if let Some(ref existing) = existing_session { - // Cloud version is newer if it has more messages or a later ended_at - let cloud_has_more_messages = - pull_session.metadata.message_count > existing.message_count; - let cloud_has_later_ended_at = match (pull_session.metadata.ended_at, existing.ended_at) - { - (Some(cloud_end), Some(local_end)) => cloud_end > local_end, - (Some(_), None) => true, // Cloud has ended_at, local does not - _ => false, - }; - cloud_has_more_messages || cloud_has_later_ended_at - } else { - false - }; - - // Skip if session exists and cloud version is not newer - if existing_session.is_some() && !is_update { - skipped += 1; - continue; - } - - // Decrypt messages - let messages = match decrypt_session_messages(&pull_session.encrypted_data, &encryption_key) - { - Ok(msgs) => msgs, - Err(e) => { - failed += 1; - tracing::debug!("Failed to decrypt session {}: {}", &pull_session.id[..8], e); - continue; - } - }; - - let session_id = uuid::Uuid::parse_str(&pull_session.id).context("Invalid session ID")?; - - let session = Session { - id: session_id, - tool: pull_session.metadata.tool_name, - tool_version: None, - started_at: pull_session.metadata.started_at, - ended_at: pull_session.metadata.ended_at, - model: None, - working_directory: pull_session.metadata.project_path, - git_branch: None, - source_path: None, - message_count: pull_session.metadata.message_count, - machine_id: Some(pull_session.machine_id), - }; - - // Import session and all messages in a single transaction - // This handles both new inserts and updates via ON CONFLICT - db.import_session_with_messages(&session, &messages, Some(response.server_time))?; - - if is_update { - updated += 1; - } else { - imported += 1; - } - } - - // Clear the progress line and print summary - eprintln!(); - if failed > 0 { - println!( - "{} Imported {} sessions, updated {} ({} skipped, {} failed to decrypt).", - "Done.".yellow().bold(), - imported, - updated, - skipped, - failed - ); - } else if updated > 0 { - println!( - "{} Imported {} sessions, updated {} ({} skipped).", - "Success!".green().bold(), - imported, - updated, - skipped - ); - } else { - println!( - "{} Imported {} sessions ({} skipped).", - "Success!".green().bold(), - imported, - skipped - ); - } - - Ok(()) -} - -/// Syncs sessions with the cloud (pull then push). -fn run_sync() -> Result<()> { - println!("{}", "Cloud Sync".bold()); - println!(); - - // Pull first to get any remote changes - println!("{}", "Step 1: Pull".bold()); - if let Err(e) = run_pull(false) { - // Don't fail the whole sync if pull fails, but warn - println!("{} Pull failed: {}", "Warning:".yellow(), e); - println!("Continuing with push..."); - println!(); - } - - println!(); - - // Then push local changes - println!("{}", "Step 2: Push".bold()); - run_push(false)?; - - Ok(()) -} - -/// Resets sync status for sessions so they can be re-uploaded. -fn run_reset_sync(session_ids: Option>, force: bool) -> Result<()> { - let db = Database::open_default()?; - - match session_ids { - Some(ids) => { - // Reset specific sessions - let mut resolved_sessions = Vec::new(); - for id_or_prefix in &ids { - match db.find_session_by_id_prefix(id_or_prefix) { - Ok(Some(session)) => resolved_sessions.push(session), - Ok(None) => { - anyhow::bail!("Session not found: {}", id_or_prefix); - } - Err(e) => { - anyhow::bail!("Error finding session '{}': {}", id_or_prefix, e); - } - } - } - - if resolved_sessions.is_empty() { - println!("{}", "No sessions to reset.".yellow()); - return Ok(()); - } - - // Confirm unless --force - if !force { - println!( - "This will mark {} session(s) as unsynced.", - resolved_sessions.len() - ); - println!("They will be re-uploaded on the next 'lore cloud push'."); - println!(); - print!("Continue? [y/N] "); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - if !input.trim().eq_ignore_ascii_case("y") { - println!("{}", "Cancelled".dimmed()); - return Ok(()); - } - } - - let session_uuids: Vec<_> = resolved_sessions.iter().map(|s| s.id).collect(); - let count = db.clear_sync_status_for_sessions(&session_uuids)?; - - println!(); - println!( - "{} Reset sync status for {} session(s).", - "Done.".green(), - count - ); - println!("Run 'lore cloud push' to sync to cloud."); - } - None => { - // Reset all sessions - let total = db.session_count()?; - - if total == 0 { - println!("{}", "No sessions to reset.".yellow()); - return Ok(()); - } - - // Confirm unless --force - if !force { - println!("This will mark all {} sessions as unsynced.", total); - println!("They will be re-uploaded on the next 'lore cloud push'."); - println!(); - print!("Continue? [y/N] "); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - if !input.trim().eq_ignore_ascii_case("y") { - println!("{}", "Cancelled".dimmed()); - return Ok(()); - } - } - - let count = db.clear_sync_status()?; - - println!(); - println!( - "{} Reset sync status for {} sessions.", - "Done.".green(), - count - ); - println!("Run 'lore cloud push' to sync to cloud."); - } - } - - Ok(()) -} - -/// Encrypts session messages for cloud storage. -fn encrypt_session_messages(messages: &[Message], key: &[u8]) -> Result { - let json = serde_json::to_vec(messages)?; - let encrypted = encrypt_data(&json, key)?; - Ok(encode_base64(&encrypted)) -} - -/// Decrypts session messages from cloud storage. -fn decrypt_session_messages(encrypted_b64: &str, key: &[u8]) -> Result> { - let encrypted = decode_base64(encrypted_b64)?; - let decrypted = decrypt_data(&encrypted, key)?; - let messages: Vec = serde_json::from_slice(&decrypted)?; - Ok(messages) -} - -/// Prompts for a new passphrase (with confirmation). -fn prompt_new_passphrase() -> Result { - loop { - print!("Enter passphrase: "); - io::stdout().flush()?; - let passphrase = rpassword::read_password()?; - - if passphrase.len() < 8 { - println!("{}", "Passphrase must be at least 8 characters.".red()); - continue; - } - - print!("Confirm passphrase: "); - io::stdout().flush()?; - let confirm = rpassword::read_password()?; - - if passphrase != confirm { - println!("{}", "Passphrases do not match.".red()); - continue; - } - - return Ok(passphrase); - } -} - -/// Prompts for an existing passphrase. -fn prompt_passphrase() -> Result { - print!("Passphrase: "); - io::stdout().flush()?; - let passphrase = rpassword::read_password()?; - Ok(passphrase) -} - -/// Formats bytes as human-readable size. -fn format_bytes(bytes: i64) -> String { - const KB: i64 = 1024; - const MB: i64 = KB * 1024; - const GB: i64 = MB * 1024; - - if bytes >= GB { - format!("{:.1} GB", bytes as f64 / GB as f64) - } else if bytes >= MB { - format!("{:.1} MB", bytes as f64 / MB as f64) - } else if bytes >= KB { - format!("{:.1} KB", bytes as f64 / KB as f64) - } else { - format!("{} bytes", bytes) - } -} - -/// Checks if an error message indicates a payload size issue (413 or "Too Large"). -fn is_size_error(error_msg: &str) -> bool { - error_msg.contains("413") - || error_msg.to_lowercase().contains("too large") - || error_msg.to_lowercase().contains("payload") -} - -/// Checks if an error message indicates a quota/limit exceeded error. -fn is_quota_error(error_msg: &str) -> bool { - error_msg.contains("Would exceed session limit") - || error_msg.contains("quota") - || (error_msg.contains("403") && error_msg.contains("limit")) -} - -/// Quota information extracted from a limit error response. -struct QuotaInfo { - current: i64, - limit: i64, - plan: String, -} - -/// Attempts to parse quota information from an error message. -fn parse_quota_info(error_msg: &str) -> Option { - // Look for JSON in the error message - // Example: {"error":"Would exceed session limit","details":{"current":48,"limit":50,"requested":3,"available":2,"plan":"free"}} - if let Some(start) = error_msg.find('{') { - if let Ok(json) = serde_json::from_str::(&error_msg[start..]) { - if let Some(details) = json.get("details") { - return Some(QuotaInfo { - current: details.get("current").and_then(|v| v.as_i64()).unwrap_or(0), - limit: details.get("limit").and_then(|v| v.as_i64()).unwrap_or(0), - plan: details - .get("plan") - .and_then(|v| v.as_str()) - .unwrap_or("free") - .to_string(), - }); - } - } - } - None -} - -/// Formats a timestamp as relative time (past). -fn format_relative_time(time: chrono::DateTime) -> String { - let now = Utc::now(); - let duration = now.signed_duration_since(time); - - let hours = duration.num_hours(); - if hours < 1 { - let minutes = duration.num_minutes(); - if minutes < 1 { - "just now".to_string() - } else { - format!("{} minutes ago", minutes) - } - } else if hours < 24 { - format!("{} hours ago", hours) - } else { - let days = duration.num_days(); - format!("{} days ago", days) - } -} - -/// Formats a timestamp as relative time (future). -fn format_future_relative_time(time: chrono::DateTime) -> String { - let now = Utc::now(); - let duration = time.signed_duration_since(now); - - // If the time has already passed, show "now" or past tense - if duration.num_seconds() <= 0 { - return "now".to_string(); - } - - let hours = duration.num_hours(); - let minutes = duration.num_minutes(); - - if hours >= 24 { - let days = duration.num_days(); - if days == 1 { - "in 1 day".to_string() - } else { - format!("in {} days", days) - } - } else if hours >= 1 { - let remaining_minutes = minutes % 60; - if remaining_minutes > 0 { - if hours == 1 { - format!("in 1 hour {} minutes", remaining_minutes) - } else { - format!("in {} hours {} minutes", hours, remaining_minutes) - } - } else if hours == 1 { - "in 1 hour".to_string() - } else { - format!("in {} hours", hours) - } - } else if minutes == 1 { - "in 1 minute".to_string() - } else { - format!("in {} minutes", minutes) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_format_bytes() { - assert_eq!(format_bytes(0), "0 bytes"); - assert_eq!(format_bytes(512), "512 bytes"); - assert_eq!(format_bytes(1024), "1.0 KB"); - assert_eq!(format_bytes(1024 * 1024), "1.0 MB"); - assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB"); - } - - #[test] - fn test_is_size_error() { - // Should detect 413 status code - assert!(is_size_error("HTTP error: 413 Payload Too Large")); - assert!(is_size_error("Server returned 413")); - - // Should detect "too large" text (case insensitive) - assert!(is_size_error("Request body too large")); - assert!(is_size_error("Session Too Large to upload")); - - // Should detect payload-related errors - assert!(is_size_error("Payload size exceeded")); - assert!(is_size_error("Request payload too big")); - - // Should not match unrelated errors - assert!(!is_size_error("Connection refused")); - assert!(!is_size_error("HTTP error: 500 Internal Server Error")); - assert!(!is_size_error("Authentication failed")); - assert!(!is_size_error("Network timeout")); - } - - #[test] - fn test_is_quota_error() { - // Should detect "Would exceed session limit" message - assert!(is_quota_error( - "Server error (403): {\"error\":\"Would exceed session limit\"}" - )); - - // Should detect "quota" keyword - assert!(is_quota_error("quota exceeded")); - assert!(is_quota_error("User quota limit reached")); - - // Should detect 403 + limit combination - assert!(is_quota_error("403 limit reached")); - assert!(is_quota_error("Server returned 403: limit exceeded")); - - // Should not match unrelated errors - assert!(!is_quota_error("Connection refused")); - assert!(!is_quota_error("500 Internal Server Error")); - assert!(!is_quota_error("HTTP error: 403 Forbidden")); // 403 without "limit" - assert!(!is_quota_error("Session limit")); // "limit" without 403 - } - - #[test] - fn test_parse_quota_info() { - let error_msg = "Server error (403): {\"error\":\"Would exceed session limit\",\"details\":{\"current\":48,\"limit\":50,\"requested\":3,\"available\":2,\"plan\":\"free\"}}"; - let quota = parse_quota_info(error_msg).expect("Should parse quota info"); - assert_eq!(quota.current, 48); - assert_eq!(quota.limit, 50); - assert_eq!(quota.plan, "free"); - } - - #[test] - fn test_parse_quota_info_pro_plan() { - let error_msg = "{\"error\":\"Would exceed session limit\",\"details\":{\"current\":999,\"limit\":1000,\"requested\":5,\"available\":1,\"plan\":\"pro\"}}"; - let quota = parse_quota_info(error_msg).expect("Should parse quota info"); - assert_eq!(quota.current, 999); - assert_eq!(quota.limit, 1000); - assert_eq!(quota.plan, "pro"); - } - - #[test] - fn test_parse_quota_info_missing() { - // Random error message with no JSON - assert!(parse_quota_info("Some random error").is_none()); - - // 403 error without details - assert!(parse_quota_info("403 Forbidden").is_none()); - - // JSON without details field - assert!(parse_quota_info("{\"error\":\"Something went wrong\"}").is_none()); - } - - #[test] - fn test_parse_quota_info_partial_details() { - // JSON with partial details (missing some fields should use defaults) - let error_msg = "{\"error\":\"limit\",\"details\":{\"limit\":100}}"; - let quota = parse_quota_info(error_msg).expect("Should parse with defaults"); - assert_eq!(quota.current, 0); // default - assert_eq!(quota.limit, 100); - assert_eq!(quota.plan, "free"); // default - } - - #[test] - fn test_encrypt_decrypt_roundtrip() { - use crate::cloud::encryption::generate_salt; - use crate::storage::models::{MessageContent, MessageRole}; - use chrono::Utc; - use uuid::Uuid; - - let salt = generate_salt(); - let key = derive_key("test passphrase", &salt).unwrap(); - - let messages = vec![Message { - id: Uuid::new_v4(), - session_id: Uuid::new_v4(), - parent_id: None, - index: 0, - timestamp: Utc::now(), - role: MessageRole::User, - content: MessageContent::Text("Hello, world!".to_string()), - model: None, - git_branch: None, - cwd: None, - }]; - - let encrypted = encrypt_session_messages(&messages, &key).unwrap(); - let decrypted = decrypt_session_messages(&encrypted, &key).unwrap(); - - assert_eq!(decrypted.len(), 1); - assert_eq!(decrypted[0].content.text(), "Hello, world!"); - } - - #[test] - fn test_format_future_relative_time_minutes() { - let now = Utc::now(); - - // 2 minutes (use > 1 to avoid timing edge cases) - let time = now + chrono::Duration::minutes(2); - let result = format_future_relative_time(time); - assert!( - result.contains("minute"), - "Expected 'minute' in result, got: {}", - result - ); - - // 30 minutes - let time = now + chrono::Duration::minutes(30); - let result = format_future_relative_time(time); - assert!( - result.starts_with("in 29 minutes") || result.starts_with("in 30 minutes"), - "Expected ~30 minutes, got: {}", - result - ); - } - - #[test] - fn test_format_future_relative_time_hours() { - let now = Utc::now(); - - // 2 hours (use > 1 to avoid timing edge cases) - let time = now + chrono::Duration::hours(2); - let result = format_future_relative_time(time); - assert!( - result.contains("hour"), - "Expected 'hour' in result, got: {}", - result - ); - - // 3 hours 42 minutes - let time = now + chrono::Duration::hours(3) + chrono::Duration::minutes(42); - let result = format_future_relative_time(time); - assert!( - result.starts_with("in 3 hours"), - "Expected 'in 3 hours...', got: {}", - result - ); - } - - #[test] - fn test_format_future_relative_time_days() { - let now = Utc::now(); - - // 2 days (use > 1 day to avoid timing edge cases) - let time = now + chrono::Duration::days(2); - let result = format_future_relative_time(time); - assert!( - result.contains("day"), - "Expected 'day' in result, got: {}", - result - ); - } - - #[test] - fn test_format_future_relative_time_past() { - let now = Utc::now(); - - // Past time should show "now" - let time = now - chrono::Duration::minutes(5); - assert_eq!(format_future_relative_time(time), "now"); - } -} diff --git a/src/cli/commands/init.rs b/src/cli/commands/init.rs index a9a6835..a9b1a4f 100644 --- a/src/cli/commands/init.rs +++ b/src/cli/commands/init.rs @@ -193,7 +193,7 @@ pub fn run(args: Args) -> Result<()> { println!(" Created: {}", db_path.display()); } - // Register this machine in the machines table for cloud sync + // Register this machine in the machines table for sync deduplication let machine = Machine { id: machine_id.clone(), name: machine_name.clone(), diff --git a/src/cli/commands/login.rs b/src/cli/commands/login.rs deleted file mode 100644 index 6ba6df7..0000000 --- a/src/cli/commands/login.rs +++ /dev/null @@ -1,628 +0,0 @@ -//! Login command - authenticate with Lore cloud service. -//! -//! Opens a browser for OAuth authentication and stores the resulting -//! API key in the OS keychain or a fallback credentials file. - -use anyhow::{bail, Context, Result}; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use colored::Colorize; -use std::io::{self, BufRead, BufReader, Write}; -use std::net::{TcpListener, TcpStream}; -use std::time::{Duration, Instant}; - -use crate::cloud::client::CloudClient; -use crate::cloud::credentials::{Credentials, CredentialsStore}; -use crate::cloud::encryption::{derive_key, encode_key_hex}; -use crate::cloud::DEFAULT_CLOUD_URL; -use crate::config::Config; - -/// Message shown when keychain is enabled. -const KEYCHAIN_INFO: &str = "\ -Note: Credentials will be stored in your OS keychain. - You may be prompted for permission on first access. - Select 'Always Allow' to avoid repeated prompts."; - -/// Message shown on Linux when secret service is not available. -#[cfg(target_os = "linux")] -const LINUX_SECRET_SERVICE_WARNING: &str = "\ -Note: OS keychain requires gnome-keyring or kwallet to be running. - Using file storage instead."; - -/// Arguments for the login command. -#[derive(clap::Args)] -#[command(after_help = "EXAMPLES:\n \ - lore login Authenticate with Lore cloud")] -pub struct Args { - /// Cloud service URL (for self-hosted deployments). - #[arg(long)] - pub url: Option, -} - -/// Timeout for waiting for browser callback. -const LOGIN_TIMEOUT: Duration = Duration::from_secs(120); - -/// Executes the login command. -/// -/// Opens a browser to the cloud service OAuth page, waits for the callback -/// with credentials, and stores them securely. -pub fn run(args: Args) -> Result<()> { - // Load config to get use_keychain setting - let mut config = Config::load()?; - - // Check if use_keychain has been explicitly configured - let is_configured = Config::is_use_keychain_configured()?; - - // If not configured, prompt user for storage preference on first login - if !is_configured { - config.use_keychain = prompt_storage_preference()?; - config.save()?; - } - - let store = CredentialsStore::with_keychain(config.use_keychain); - - // Check if already logged in - if let Ok(Some(creds)) = store.load() { - println!( - "Already logged in as {} ({} plan)", - creds.email.cyan(), - creds.plan - ); - println!("Run 'lore logout' first to log out."); - return Ok(()); - } - - // Show keychain info if enabled - if config.use_keychain { - println!("{}", KEYCHAIN_INFO.dimmed()); - println!(); - } - let cloud_url = args - .url - .as_deref() - .unwrap_or_else(|| config.cloud_url.as_deref().unwrap_or(DEFAULT_CLOUD_URL)); - - // Start local HTTP server on a random available port - let listener = - TcpListener::bind("127.0.0.1:0").context("Failed to start local callback server")?; - let port = listener.local_addr()?.port(); - - // Generate random state parameter for CSRF protection - let state = generate_state(); - - // Build OAuth URL - let auth_url = format!( - "{}/auth/cli?port={}&state={}", - cloud_url.trim_end_matches('/'), - port, - state - ); - - println!("Opening browser for authentication..."); - println!(); - println!("If the browser does not open, visit:"); - println!(" {}", auth_url.cyan()); - println!(); - - // Open browser - if let Err(e) = webbrowser::open(&auth_url) { - eprintln!("Failed to open browser: {e}"); - println!("Please open the URL above manually."); - } - - // Wait for callback with timeout - listener - .set_nonblocking(true) - .context("Failed to set non-blocking mode")?; - - let start = Instant::now(); - let credentials = loop { - if start.elapsed() > LOGIN_TIMEOUT { - bail!("Login timed out waiting for browser authentication"); - } - - match listener.accept() { - Ok((stream, _)) => { - match handle_callback(stream, &state) { - Ok(creds) => break creds, - Err(e) => { - // Log error but continue waiting - might be browser prefetch - tracing::debug!("Callback error (will retry): {e}"); - } - } - } - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { - // No connection yet, wait a bit - std::thread::sleep(Duration::from_millis(100)); - } - Err(e) => { - bail!("Failed to accept connection: {e}"); - } - } - }; - - // Store credentials - store - .store(&credentials) - .context("Failed to store credentials")?; - - println!(); - println!( - "{} Logged in as {} ({} plan)", - "Success!".green().bold(), - credentials.email.cyan(), - credentials.plan - ); - - // Prompt for auto-sync setup - println!(); - if let Err(e) = prompt_auto_sync_setup(&credentials, &store, &mut config) { - // Log the error but don't fail the login - user can set up later - tracing::warn!("Auto-sync setup failed: {e}"); - eprintln!("{} Could not set up auto-sync: {e}", "Warning:".yellow()); - eprintln!("You can set it up later by running 'lore cloud push'."); - } - - Ok(()) -} - -/// Prompts the user to choose their credential storage preference. -/// -/// Returns true if the user selects keychain storage, false for file storage. -/// On Linux, checks if a secret service is available before offering keychain. -fn prompt_storage_preference() -> Result { - println!("How would you like to store credentials?"); - println!(); - - // Check if keychain is available on this system - let keychain_available = is_keychain_option_available(); - - println!( - " {} File storage (recommended) - Simple, works everywhere", - "1.".bold() - ); - - if keychain_available { - println!( - " {} OS Keychain - More secure, may prompt for access", - "2.".bold() - ); - } else { - #[cfg(target_os = "linux")] - { - println!( - " {} OS Keychain - {} (requires gnome-keyring or kwallet)", - "2.".dimmed(), - "not available".dimmed() - ); - } - #[cfg(not(target_os = "linux"))] - { - println!( - " {} OS Keychain - {}", - "2.".dimmed(), - "not available".dimmed() - ); - } - } - - println!(); - print!("Enter choice [1]: "); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - let choice = input.trim(); - - // Default to option 1 (file storage) if empty or invalid - if choice.is_empty() || choice == "1" { - println!(); - println!("Using file storage for credentials."); - return Ok(false); - } - - if choice == "2" { - if !keychain_available { - #[cfg(target_os = "linux")] - { - println!(); - println!("{}", LINUX_SECRET_SERVICE_WARNING.yellow()); - println!(); - println!("Using file storage for credentials."); - return Ok(false); - } - #[cfg(not(target_os = "linux"))] - { - println!(); - println!( - "{}", - "OS keychain is not available. Using file storage.".yellow() - ); - return Ok(false); - } - } - - println!(); - println!("{}", KEYCHAIN_INFO.dimmed()); - return Ok(true); - } - - // Invalid input, default to file storage - println!(); - println!("Invalid choice. Using file storage for credentials."); - Ok(false) -} - -/// Checks if the keychain option should be offered to the user. -/// -/// On Linux, this checks if a secret service (gnome-keyring, kwallet) is available. -/// On macOS and Windows, the keychain is always available. -fn is_keychain_option_available() -> bool { - CredentialsStore::is_secret_service_available() -} - -/// Prompts the user to set up auto-sync with encryption passphrase. -/// -/// If the user agrees, this will: -/// 1. Check if an encryption salt exists on the cloud (fetch it if so) -/// 2. Generate a new salt if needed -/// 3. Prompt for passphrase with confirmation -/// 4. Derive encryption key using Argon2id -/// 5. Store the derived key locally -/// 6. Sync salt to cloud if newly generated -fn prompt_auto_sync_setup( - credentials: &Credentials, - store: &CredentialsStore, - config: &mut Config, -) -> Result<()> { - println!( - "{}", - "Enable auto-sync? This will automatically push sessions to the cloud as you work.".bold() - ); - print!("Enter encryption passphrase now? [Y/n]: "); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - let choice = input.trim().to_lowercase(); - - // Default to yes if empty or starts with y - if !choice.is_empty() && !choice.starts_with('y') { - println!(); - println!( - "Auto-sync disabled. Run '{}' manually to sync sessions.", - "lore cloud push".cyan() - ); - return Ok(()); - } - - if store.load_encryption_key()?.is_some() { - println!(); - println!( - "{} Auto-sync already configured. Sessions will sync automatically.", - "OK".green() - ); - return Ok(()); - } - - let client = CloudClient::with_url(&credentials.cloud_url).with_api_key(&credentials.api_key); - - let cloud_salt = match client.get_salt() { - Ok(salt) => salt, - Err(e) => { - tracing::debug!("Could not fetch salt from cloud: {e}"); - println!(); - println!( - "{} Could not connect to cloud service: {e}", - "Warning:".yellow() - ); - println!("Please check your network connection and try again later."); - return Ok(()); - } - }; - - let (salt_b64, is_new_salt) = if let Some(ref existing_salt) = cloud_salt { - // Salt exists on cloud - use it - tracing::debug!("Using existing encryption salt from cloud"); - // Also save it locally if not already present - if config.encryption_salt.is_none() { - config.encryption_salt = Some(existing_salt.clone()); - config.save()?; - } - (existing_salt.clone(), false) - } else { - // No salt on cloud - generate new one - tracing::debug!("Generating new encryption salt"); - (config.get_or_create_encryption_salt()?, true) - }; - - // Decode salt for key derivation - let salt = BASE64 - .decode(&salt_b64) - .context("Invalid encryption salt encoding")?; - - // Prompt for passphrase with confirmation - println!(); - println!( - "{}", - "Your sessions will be encrypted with a passphrase that only you know.".dimmed() - ); - println!( - "{}", - "The cloud service cannot read your session content.".dimmed() - ); - println!(); - - let passphrase = prompt_new_passphrase()?; - - // Derive encryption key - let key = derive_key(&passphrase, &salt).context("Failed to derive encryption key")?; - - // Store the derived key - store - .store_encryption_key(&encode_key_hex(&key)) - .context("Failed to store encryption key")?; - - // Sync salt to cloud if we generated a new one - if is_new_salt { - if let Err(e) = client.set_salt(&salt_b64) { - tracing::debug!("Could not sync salt to cloud (may already exist): {e}"); - } - } - - println!(); - println!( - "{} Auto-sync enabled. Sessions will sync automatically.", - "OK".green() - ); - - Ok(()) -} - -/// Prompts for a new passphrase with confirmation. -/// -/// Requires at least 8 characters and matching confirmation entry. -fn prompt_new_passphrase() -> Result { - loop { - print!("Enter passphrase: "); - io::stdout().flush()?; - let passphrase = rpassword::read_password().context("Failed to read passphrase")?; - - if passphrase.len() < 8 { - println!("{}", "Passphrase must be at least 8 characters.".red()); - continue; - } - - print!("Confirm passphrase: "); - io::stdout().flush()?; - let confirm = rpassword::read_password().context("Failed to read passphrase")?; - - if passphrase != confirm { - println!("{}", "Passphrases do not match.".red()); - continue; - } - - return Ok(passphrase); - } -} - -/// Generates a random state string for CSRF protection. -fn generate_state() -> String { - use rand::RngCore; - let mut bytes = [0u8; 16]; - rand::thread_rng().fill_bytes(&mut bytes); - bytes.iter().map(|b| format!("{:02x}", b)).collect() -} - -/// Handles the OAuth callback request. -/// -/// Parses the callback URL parameters, validates the state, and extracts credentials. -fn handle_callback(mut stream: TcpStream, expected_state: &str) -> Result { - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .context("Failed to set read timeout")?; - - let mut reader = BufReader::new(&stream); - let mut request_line = String::new(); - reader - .read_line(&mut request_line) - .context("Failed to read request")?; - - // Parse the request line: GET /callback?key=...&state=...&email=...&plan=... HTTP/1.1 - let parts: Vec<&str> = request_line.split_whitespace().collect(); - if parts.len() < 2 || parts[0] != "GET" { - bail!("Invalid HTTP request"); - } - - let path = parts[1]; - if !path.starts_with("/callback?") { - // Send 404 and continue waiting - send_response(&mut stream, 404, "Not Found", "Invalid callback path"); - bail!("Invalid callback path"); - } - - // Parse query parameters - let query = path.strip_prefix("/callback?").unwrap_or(""); - let params = parse_query_string(query); - - // Validate state - let state = params - .get("state") - .ok_or_else(|| anyhow::anyhow!("Missing state parameter"))?; - if state != expected_state { - send_response( - &mut stream, - 403, - "Forbidden", - "State mismatch - possible CSRF attack", - ); - bail!("OAuth state mismatch - possible CSRF attack"); - } - - // Extract credentials - let api_key = params - .get("key") - .ok_or_else(|| anyhow::anyhow!("Missing API key in callback"))? - .to_string(); - - let email = params - .get("email") - .ok_or_else(|| anyhow::anyhow!("Missing email in callback"))? - .to_string(); - - let plan = params - .get("plan") - .ok_or_else(|| anyhow::anyhow!("Missing plan in callback"))? - .to_string(); - - let cloud_url = params - .get("url") - .map(|s| s.to_string()) - .unwrap_or_else(|| DEFAULT_CLOUD_URL.to_string()); - - // Send success response to browser - let success_html = r#" - - - Lore - Login Successful - - - -
-

Login Successful!

-

You can close this window and return to your terminal.

- -"#; - - send_response(&mut stream, 200, "OK", success_html); - - Ok(Credentials { - api_key, - email, - plan, - cloud_url, - }) -} - -/// Parses a query string into key-value pairs. -fn parse_query_string(query: &str) -> std::collections::HashMap { - query - .split('&') - .filter_map(|pair| { - if pair.is_empty() { - return None; - } - let mut parts = pair.splitn(2, '='); - let key = parts.next()?; - if key.is_empty() { - return None; - } - let value = parts.next().unwrap_or(""); - Some((urlencoding_decode(key), urlencoding_decode(value))) - }) - .collect() -} - -/// Simple URL decoding (handles %XX escapes). -fn urlencoding_decode(s: &str) -> String { - let mut result = String::with_capacity(s.len()); - let mut chars = s.chars().peekable(); - - while let Some(c) = chars.next() { - if c == '%' { - let hex: String = chars.by_ref().take(2).collect(); - if hex.len() == 2 { - if let Ok(byte) = u8::from_str_radix(&hex, 16) { - result.push(byte as char); - continue; - } - } - result.push('%'); - result.push_str(&hex); - } else if c == '+' { - result.push(' '); - } else { - result.push(c); - } - } - - result -} - -/// Sends an HTTP response. -fn send_response(stream: &mut TcpStream, status: u16, status_text: &str, body: &str) { - let response = format!( - "HTTP/1.1 {} {}\r\n\ - Content-Type: text/html\r\n\ - Content-Length: {}\r\n\ - Connection: close\r\n\ - \r\n\ - {}", - status, - status_text, - body.len(), - body - ); - - let _ = stream.write_all(response.as_bytes()); - let _ = stream.flush(); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_generate_state_length() { - let state = generate_state(); - assert_eq!(state.len(), 32); // 16 bytes = 32 hex chars - } - - #[test] - fn test_generate_state_uniqueness() { - let state1 = generate_state(); - let state2 = generate_state(); - assert_ne!(state1, state2); - } - - #[test] - fn test_parse_query_string() { - let params = parse_query_string("key=abc123&email=test@example.com&plan=pro&state=xyz"); - assert_eq!(params.get("key"), Some(&"abc123".to_string())); - assert_eq!(params.get("email"), Some(&"test@example.com".to_string())); - assert_eq!(params.get("plan"), Some(&"pro".to_string())); - assert_eq!(params.get("state"), Some(&"xyz".to_string())); - } - - #[test] - fn test_parse_query_string_empty() { - let params = parse_query_string(""); - assert!(params.is_empty()); - } - - #[test] - fn test_parse_query_string_encoded() { - let params = parse_query_string("email=test%40example.com&name=John+Doe"); - assert_eq!(params.get("email"), Some(&"test@example.com".to_string())); - assert_eq!(params.get("name"), Some(&"John Doe".to_string())); - } - - #[test] - fn test_urlencoding_decode() { - assert_eq!(urlencoding_decode("hello%20world"), "hello world"); - assert_eq!(urlencoding_decode("test%40example.com"), "test@example.com"); - assert_eq!(urlencoding_decode("hello+world"), "hello world"); - assert_eq!(urlencoding_decode("no%encoding"), "no%encoding"); // Invalid escape - } - - #[test] - fn test_is_keychain_option_available_returns_bool() { - // This test verifies the function exists and returns a boolean. - // The actual result depends on the system's keychain support. - let _result: bool = is_keychain_option_available(); - } -} diff --git a/src/cli/commands/logout.rs b/src/cli/commands/logout.rs deleted file mode 100644 index 0638bd2..0000000 --- a/src/cli/commands/logout.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Logout command - remove cloud service credentials. -//! -//! Deletes stored API keys and encryption keys from the keychain -//! and any fallback credential files. - -use anyhow::{Context, Result}; -use colored::Colorize; - -use crate::cloud::credentials::CredentialsStore; -use crate::config::Config; - -/// Arguments for the logout command. -#[derive(clap::Args)] -#[command(after_help = "EXAMPLES:\n \ - lore logout Log out of Lore cloud")] -pub struct Args {} - -/// Executes the logout command. -/// -/// Removes all stored credentials and encryption keys. -pub fn run(_args: Args) -> Result<()> { - let config = Config::load()?; - let store = CredentialsStore::with_keychain(config.use_keychain); - - // Check if logged in - match store.load().context("Failed to check login status")? { - Some(creds) => { - // Delete credentials - store.delete().context("Failed to delete credentials")?; - - // Also delete encryption key - if let Err(e) = store.delete_encryption_key() { - tracing::debug!("Could not delete encryption key: {e}"); - } - - println!("Logged out from {} ({})", creds.email.cyan(), creds.plan); - } - None => { - println!("{}", "Not currently logged in.".yellow()); - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - // Login/logout tests require credential storage which may not be available - // in all test environments. The functionality is tested through integration - // tests that can set up appropriate mocks. -} diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 759810c..7be2ffb 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -9,9 +9,6 @@ pub mod annotate; /// Show which AI session led to a specific line of code. pub mod blame; -/// Cloud sync operations (status, push, pull). -pub mod cloud; - /// Shell completion script generation. pub mod completions; @@ -54,12 +51,6 @@ pub mod init; /// Link sessions to git commits. pub mod link; -/// Authenticate with the cloud service. -pub mod login; - -/// Log out from the cloud service. -pub mod logout; - /// MCP (Model Context Protocol) server. pub mod mcp; diff --git a/src/cli/commands/status.rs b/src/cli/commands/status.rs index 8931c12..a9bf26f 100644 --- a/src/cli/commands/status.rs +++ b/src/cli/commands/status.rs @@ -10,7 +10,6 @@ use serde::Serialize; use crate::capture::watchers::{default_registry, WatcherRegistry}; use crate::cli::OutputFormat; -use crate::cloud::CredentialsStore; use crate::config::Config; use crate::daemon::{send_command_sync, DaemonCommand, DaemonResponse, DaemonState}; use crate::git; @@ -235,9 +234,6 @@ fn run_text(db: &Database, registry: &WatcherRegistry, config: &Config) -> Resul // Database statistics print_database_stats(db)?; - // Cloud account status - let is_logged_in = print_cloud_status(db, config); - // Current commit section (if in a git repo) print_current_commit_links(db)?; @@ -259,11 +255,6 @@ fn run_text(db: &Database, registry: &WatcherRegistry, config: &Config) -> Resul // Show recent sessions if any print_recent_sessions(db)?; - // Show login tip only if not logged in - if !is_logged_in { - print_login_tip(); - } - Ok(()) } @@ -366,41 +357,6 @@ fn print_watchers_status(registry: &WatcherRegistry, config: &Config) { println!(); } -/// Prints the cloud account status section. -/// -/// Shows whether the user is logged in, their account info, and last sync time. -/// Returns true if logged in, false otherwise. -fn print_cloud_status(db: &Database, config: &Config) -> bool { - let store = CredentialsStore::with_keychain(config.use_keychain); - - match store.load() { - Ok(Some(creds)) => { - println!(); - println!("{}", "Cloud:".bold()); - println!( - " Logged in as {} ({} plan)", - creds.email.cyan(), - creds.plan - ); - - // Show last sync time - match db.last_sync_time() { - Ok(Some(last_sync)) => { - println!(" Last sync: {}", format_relative_time(last_sync)); - } - Ok(None) => { - println!(" Last sync: {}", "never".dimmed()); - } - Err(_) => { - // Silently skip if we cannot read sync time - } - } - true - } - _ => false, - } -} - /// Prints enhanced database statistics. /// /// Shows total sessions, messages, links, and database file size. @@ -443,35 +399,6 @@ fn format_file_size(bytes: u64) -> String { } } -/// Formats a timestamp as relative time (e.g., "2 hours ago", "3 days ago"). -fn format_relative_time(time: chrono::DateTime) -> String { - let now = chrono::Utc::now(); - let duration = now.signed_duration_since(time); - - let hours = duration.num_hours(); - if hours < 1 { - let minutes = duration.num_minutes(); - if minutes < 1 { - "just now".to_string() - } else if minutes == 1 { - "1 minute ago".to_string() - } else { - format!("{} minutes ago", minutes) - } - } else if hours == 1 { - "1 hour ago".to_string() - } else if hours < 24 { - format!("{} hours ago", hours) - } else { - let days = duration.num_days(); - if days == 1 { - "1 day ago".to_string() - } else { - format!("{} days ago", days) - } - } -} - /// Prints information about sessions linked to the current HEAD commit. /// /// If not in a git repository, this section is silently skipped. @@ -571,17 +498,6 @@ fn print_recent_sessions(db: &Database) -> Result<()> { Ok(()) } -/// Prints a tip about logging in. -/// -/// This helps users discover the cloud sync feature. -fn print_login_tip() { - println!(); - println!( - "{}", - "Tip: Run 'lore login' to sync sessions across machines".dimmed() - ); -} - #[cfg(test)] mod tests { use super::*; @@ -612,55 +528,4 @@ mod tests { assert_eq!(format_file_size(1024 * 1024 * 1024), "1.0 GB"); assert_eq!(format_file_size(1024 * 1024 * 1024 * 2), "2.0 GB"); } - - #[test] - fn test_format_relative_time_just_now() { - let now = chrono::Utc::now(); - assert_eq!(format_relative_time(now), "just now"); - - let seconds_ago = now - chrono::Duration::seconds(30); - assert_eq!(format_relative_time(seconds_ago), "just now"); - } - - #[test] - fn test_format_relative_time_minutes() { - let now = chrono::Utc::now(); - - let one_min = now - chrono::Duration::minutes(1); - assert_eq!(format_relative_time(one_min), "1 minute ago"); - - let five_mins = now - chrono::Duration::minutes(5); - assert_eq!(format_relative_time(five_mins), "5 minutes ago"); - - let fifty_nine_mins = now - chrono::Duration::minutes(59); - assert_eq!(format_relative_time(fifty_nine_mins), "59 minutes ago"); - } - - #[test] - fn test_format_relative_time_hours() { - let now = chrono::Utc::now(); - - let one_hour = now - chrono::Duration::hours(1); - assert_eq!(format_relative_time(one_hour), "1 hour ago"); - - let two_hours = now - chrono::Duration::hours(2); - assert_eq!(format_relative_time(two_hours), "2 hours ago"); - - let twenty_three_hours = now - chrono::Duration::hours(23); - assert_eq!(format_relative_time(twenty_three_hours), "23 hours ago"); - } - - #[test] - fn test_format_relative_time_days() { - let now = chrono::Utc::now(); - - let one_day = now - chrono::Duration::days(1); - assert_eq!(format_relative_time(one_day), "1 day ago"); - - let three_days = now - chrono::Duration::days(3); - assert_eq!(format_relative_time(three_days), "3 days ago"); - - let thirty_days = now - chrono::Duration::days(30); - assert_eq!(format_relative_time(thirty_days), "30 days ago"); - } } diff --git a/src/cloud/client.rs b/src/cloud/client.rs deleted file mode 100644 index 12e3ac5..0000000 --- a/src/cloud/client.rs +++ /dev/null @@ -1,471 +0,0 @@ -//! HTTP client for cloud API communication. -//! -//! Provides the `CloudClient` for interacting with the Lore cloud service, -//! including sync operations (push/pull) and status queries. - -use chrono::{DateTime, Utc}; -use reqwest::blocking::Client; -use serde::{Deserialize, Serialize}; -use std::time::Duration; - -use super::{CloudError, DEFAULT_CLOUD_URL}; - -/// Timeout for establishing a connection (30 seconds). -const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); - -/// Timeout for the entire request including response (60 seconds). -const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); - -/// Cloud API client for sync operations. -pub struct CloudClient { - /// HTTP client instance. - client: Client, - /// Base URL of the cloud service. - base_url: String, - /// API key for authentication (if logged in). - api_key: Option, -} - -impl CloudClient { - /// Creates a new cloud client with the default URL. - pub fn new() -> Self { - Self { - client: Self::build_client(), - base_url: DEFAULT_CLOUD_URL.to_string(), - api_key: None, - } - } - - /// Creates a new cloud client with a custom URL. - pub fn with_url(base_url: &str) -> Self { - Self { - client: Self::build_client(), - base_url: base_url.trim_end_matches('/').to_string(), - api_key: None, - } - } - - /// Builds the HTTP client with configured timeouts. - fn build_client() -> Client { - Client::builder() - .connect_timeout(CONNECT_TIMEOUT) - .timeout(REQUEST_TIMEOUT) - .build() - .expect("Failed to build HTTP client") - } - - /// Sets the API key for authentication. - pub fn with_api_key(mut self, api_key: &str) -> Self { - self.api_key = Some(api_key.to_string()); - self - } - - /// Returns the configured base URL. - #[allow(dead_code)] - pub fn base_url(&self) -> &str { - &self.base_url - } - - /// Gets the sync status from the cloud service. - /// - /// Returns information about the user's sync state including session count, - /// storage usage, and last sync time. - pub fn status(&self) -> Result { - let api_key = self.api_key.as_ref().ok_or(CloudError::NotLoggedIn)?; - - let url = format!("{}/api/sync/status", self.base_url); - let response = self - .client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .send()?; - - if !response.status().is_success() { - let status = response.status().as_u16(); - let message = response - .text() - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(CloudError::ServerError { status, message }); - } - - let body: ApiResponse = response.json()?; - Ok(body.data) - } - - /// Pushes sessions to the cloud service. - /// - /// Uploads encrypted session data to the cloud. Session metadata is stored - /// unencrypted for display purposes, while message content is encrypted. - pub fn push(&self, sessions: Vec) -> Result { - let api_key = self.api_key.as_ref().ok_or(CloudError::NotLoggedIn)?; - - let url = format!("{}/api/sync/push", self.base_url); - let payload = PushRequest { sessions }; - - let response = self - .client - .post(&url) - .header("Authorization", format!("Bearer {api_key}")) - .json(&payload) - .send()?; - - if !response.status().is_success() { - let status = response.status().as_u16(); - let message = response - .text() - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(CloudError::ServerError { status, message }); - } - - let body: ApiResponse = response.json()?; - Ok(body.data) - } - - /// Pulls sessions from the cloud service. - /// - /// Downloads sessions that have been modified since the given timestamp. - /// Session message content is encrypted and must be decrypted by the caller. - pub fn pull(&self, since: Option>) -> Result { - let api_key = self.api_key.as_ref().ok_or(CloudError::NotLoggedIn)?; - - let mut url = format!("{}/api/sync/pull", self.base_url); - if let Some(since) = since { - url = format!("{}?since={}", url, since.to_rfc3339()); - } - - let response = self - .client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .send()?; - - if !response.status().is_success() { - let status = response.status().as_u16(); - let message = response - .text() - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(CloudError::ServerError { status, message }); - } - - let body: ApiResponse = response.json()?; - Ok(body.data) - } - - /// Gets the encryption salt from the cloud service. - /// - /// Returns the base64-encoded salt if set, or None if not yet configured. - /// Returns Ok(None) for 404 responses (salt not set), Err for other failures. - pub fn get_salt(&self) -> Result, CloudError> { - let api_key = self.api_key.as_ref().ok_or(CloudError::NotLoggedIn)?; - - let url = format!("{}/api/sync/salt", self.base_url); - let response = self - .client - .get(&url) - .header("Authorization", format!("Bearer {api_key}")) - .send()?; - - let status = response.status(); - if status.as_u16() == 404 { - return Ok(None); - } - - if !status.is_success() { - let message = response - .text() - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(CloudError::ServerError { - status: status.as_u16(), - message, - }); - } - - let body: ApiResponse = response.json()?; - Ok(body.data.salt) - } - - /// Sets the encryption salt on the cloud service. - /// - /// This should only be called once during initial setup. The server will - /// reject attempts to overwrite an existing salt. - pub fn set_salt(&self, salt: &str) -> Result<(), CloudError> { - let api_key = self.api_key.as_ref().ok_or(CloudError::NotLoggedIn)?; - - let url = format!("{}/api/sync/salt", self.base_url); - let response = self - .client - .put(&url) - .header("Authorization", format!("Bearer {api_key}")) - .json(&SaltRequest { - salt: salt.to_string(), - }) - .send()?; - - if !response.status().is_success() { - let status = response.status().as_u16(); - let message = response - .text() - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(CloudError::ServerError { status, message }); - } - - Ok(()) - } -} - -impl Default for CloudClient { - fn default() -> Self { - Self::new() - } -} - -// ==================== API Types ==================== - -/// Generic API response wrapper. -#[derive(Debug, Deserialize)] -pub struct ApiResponse { - /// The response data. - pub data: T, -} - -/// Sync status response from the cloud service. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SyncStatus { - /// Number of sessions stored in the cloud. - pub session_count: i64, - - /// Timestamp of the last sync operation. - pub last_sync_at: Option>, - - /// Storage used in bytes. - pub storage_used_bytes: i64, -} - -/// Request payload for pushing sessions. -#[derive(Debug, Serialize)] -pub struct PushRequest { - /// Sessions to push. - pub sessions: Vec, -} - -/// A session prepared for pushing to the cloud. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PushSession { - /// Session UUID. - pub id: String, - - /// Machine UUID that created this session. - pub machine_id: String, - - /// Base64-encoded encrypted message data. - pub encrypted_data: String, - - /// Unencrypted session metadata. - pub metadata: SessionMetadata, - - /// When this session was last updated locally. - pub updated_at: DateTime, -} - -/// Unencrypted session metadata for cloud display. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionMetadata { - /// Tool that created this session (e.g., "claude-code"). - pub tool_name: String, - - /// Working directory path. - pub project_path: String, - - /// When the session started. - pub started_at: DateTime, - - /// When the session ended (if completed). - pub ended_at: Option>, - - /// Number of messages in the session. - pub message_count: i32, -} - -/// Response from pushing sessions. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PushResponse { - /// Number of sessions successfully synced. - pub synced_count: i64, - - /// Server timestamp for recording sync time. - pub server_time: DateTime, -} - -/// Response from getting the encryption salt. -#[derive(Debug, Clone, Deserialize)] -pub struct SaltResponse { - /// The base64-encoded encryption salt, or None if not set. - pub salt: Option, -} - -/// Request for setting the encryption salt. -#[derive(Debug, Clone, Serialize)] -pub struct SaltRequest { - /// The base64-encoded encryption salt. - pub salt: String, -} - -/// Response from pulling sessions. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PullResponse { - /// Sessions to import. - pub sessions: Vec, - - /// Server timestamp for recording sync time. - pub server_time: DateTime, -} - -/// A session returned from the cloud for pulling. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PullSession { - /// Session UUID. - pub id: String, - - /// Machine UUID that created this session. - pub machine_id: String, - - /// Base64-encoded encrypted message data. - pub encrypted_data: String, - - /// Unencrypted session metadata. - pub metadata: SessionMetadata, - - /// When this session was last updated on the server. - pub updated_at: DateTime, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_cloud_client_new() { - let client = CloudClient::new(); - assert_eq!(client.base_url(), DEFAULT_CLOUD_URL); - } - - #[test] - fn test_cloud_client_with_url() { - let client = CloudClient::with_url("https://custom.example.com/"); - assert_eq!(client.base_url(), "https://custom.example.com"); - } - - #[test] - fn test_cloud_client_with_url_no_trailing_slash() { - let client = CloudClient::with_url("https://custom.example.com"); - assert_eq!(client.base_url(), "https://custom.example.com"); - } - - #[test] - fn test_cloud_client_with_api_key() { - let client = CloudClient::new().with_api_key("test_key"); - assert_eq!(client.api_key, Some("test_key".to_string())); - } - - #[test] - fn test_sync_status_deserialize() { - let json = r#"{ - "sessionCount": 42, - "lastSyncAt": "2024-01-01T00:00:00Z", - "storageUsedBytes": 1234567 - }"#; - - let status: SyncStatus = serde_json::from_str(json).unwrap(); - assert_eq!(status.session_count, 42); - assert!(status.last_sync_at.is_some()); - assert_eq!(status.storage_used_bytes, 1234567); - } - - #[test] - fn test_sync_status_deserialize_null_last_sync() { - let json = r#"{ - "sessionCount": 0, - "lastSyncAt": null, - "storageUsedBytes": 0 - }"#; - - let status: SyncStatus = serde_json::from_str(json).unwrap(); - assert_eq!(status.session_count, 0); - assert!(status.last_sync_at.is_none()); - } - - #[test] - fn test_push_session_serialize() { - let session = PushSession { - id: "550e8400-e29b-41d4-a716-446655440000".to_string(), - machine_id: "machine-uuid".to_string(), - encrypted_data: "base64encodeddata".to_string(), - metadata: SessionMetadata { - tool_name: "claude-code".to_string(), - project_path: "/path/to/project".to_string(), - started_at: Utc::now(), - ended_at: None, - message_count: 10, - }, - updated_at: Utc::now(), - }; - - let json = serde_json::to_string(&session).unwrap(); - assert!(json.contains("encryptedData")); - assert!(json.contains("toolName")); - assert!(json.contains("projectPath")); - } - - #[test] - fn test_session_metadata_serialize() { - let metadata = SessionMetadata { - tool_name: "aider".to_string(), - project_path: "/home/user/project".to_string(), - started_at: DateTime::parse_from_rfc3339("2024-01-01T12:00:00Z") - .unwrap() - .with_timezone(&Utc), - ended_at: Some( - DateTime::parse_from_rfc3339("2024-01-01T13:00:00Z") - .unwrap() - .with_timezone(&Utc), - ), - message_count: 25, - }; - - let json = serde_json::to_string(&metadata).unwrap(); - assert!(json.contains("\"toolName\":\"aider\"")); - assert!(json.contains("\"messageCount\":25")); - } - - #[test] - fn test_api_response_deserialize() { - let json = r#"{ - "data": { - "sessionCount": 5, - "lastSyncAt": null, - "storageUsedBytes": 1000 - } - }"#; - - let response: ApiResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.data.session_count, 5); - } - - #[test] - fn test_cloud_client_uses_timeouts() { - assert_eq!(CONNECT_TIMEOUT.as_secs(), 30); - assert_eq!(REQUEST_TIMEOUT.as_secs(), 60); - - let client = CloudClient::new(); - assert_eq!(client.base_url(), DEFAULT_CLOUD_URL); - - let client = CloudClient::with_url("https://example.com"); - assert_eq!(client.base_url(), "https://example.com"); - } -} diff --git a/src/cloud/credentials.rs b/src/cloud/credentials.rs deleted file mode 100644 index 9550a70..0000000 --- a/src/cloud/credentials.rs +++ /dev/null @@ -1,566 +0,0 @@ -//! Credential storage for cloud authentication. -//! -//! Provides secure storage for API keys and encryption keys using the OS -//! keychain when available, with a file-based fallback for systems where -//! the keychain is not accessible. - -use anyhow::{Context, Result}; -use keyring::Entry; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; - -use super::{CloudError, KEYRING_API_KEY_USER, KEYRING_ENCRYPTION_KEY_USER, KEYRING_SERVICE}; - -/// Cloud service credentials. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Credentials { - /// The API key for authenticating with the cloud service. - pub api_key: String, - - /// User email address associated with the account. - pub email: String, - - /// Subscription plan (e.g., "free", "pro"). - pub plan: String, - - /// Cloud service URL (for custom deployments). - #[serde(default = "default_cloud_url")] - pub cloud_url: String, -} - -fn default_cloud_url() -> String { - super::DEFAULT_CLOUD_URL.to_string() -} - -/// Credential storage abstraction. -/// -/// By default, stores credentials in a JSON file (~/.lore/credentials.json). -/// Can optionally use the OS keychain (macOS Keychain, GNOME Keyring, Windows -/// Credential Manager) when enabled via `use_keychain` config option. -pub struct CredentialsStore { - /// Whether to use keyring (enabled via config and available on system). - use_keyring: bool, - /// Base directory for file-backed storage (defaults to ~/.lore). - base_dir: Option, -} - -impl CredentialsStore { - /// Creates a new credential store with file-based storage (default). - /// - /// Credentials are stored in ~/.lore/credentials.json with restricted permissions. - pub fn new() -> Self { - Self { - use_keyring: false, - base_dir: None, - } - } - - /// Creates a credential store with optional keychain support. - /// - /// If `use_keychain` is true and the OS keychain is available, credentials - /// will be stored in the keychain. Otherwise, falls back to file storage. - /// - /// Note: On first keychain access, the OS may prompt for permission. - pub fn with_keychain(use_keychain: bool) -> Self { - let use_keyring = if use_keychain { - Self::is_keyring_available() - } else { - false - }; - Self { - use_keyring, - base_dir: None, - } - } - - #[cfg(test)] - pub(crate) fn with_base_dir(base_dir: PathBuf, use_keychain: bool) -> Self { - let use_keyring = if use_keychain { - Self::is_keyring_available() - } else { - false - }; - Self { - use_keyring, - base_dir: Some(base_dir), - } - } - - /// Tests whether the keyring is available by attempting a dummy operation. - /// - /// This is useful for checking if the OS keychain can be used before - /// prompting the user about credential storage options. - pub fn is_keyring_available() -> bool { - // Try to create an entry - this will fail on systems without keyring support - match Entry::new(KEYRING_SERVICE, "test-availability") { - Ok(entry) => { - // Try to get a non-existent key - should return NotFound, not an error - match entry.get_password() { - Ok(_) => true, - Err(keyring::Error::NoEntry) => true, - Err(_) => false, - } - } - Err(_) => false, - } - } - - /// Checks if a secret service is likely available on Linux. - /// - /// On Linux, the keyring crate requires a running secret service - /// (gnome-keyring, kwallet, etc.) to function. This method checks - /// for common indicators that a secret service is available. - /// - /// On non-Linux platforms, this always returns true since they have - /// built-in credential storage (macOS Keychain, Windows Credential Manager). - #[cfg(target_os = "linux")] - pub fn is_secret_service_available() -> bool { - // Check for common secret service environment indicators - // DBUS_SESSION_BUS_ADDRESS is required for secret service communication - if std::env::var("DBUS_SESSION_BUS_ADDRESS").is_err() { - return false; - } - - // Try to actually test the keyring - this is the most reliable check - Self::is_keyring_available() - } - - /// On non-Linux platforms, secret service is always available. - #[cfg(not(target_os = "linux"))] - pub fn is_secret_service_available() -> bool { - true - } - - /// Stores credentials securely. - /// - /// Uses file storage by default, or keychain if enabled and available. - pub fn store(&self, credentials: &Credentials) -> Result<(), CloudError> { - if self.use_keyring { - self.store_to_keyring(credentials) - } else { - self.store_to_file(credentials) - } - } - - /// Loads stored credentials. - /// - /// Loads from keychain if enabled, otherwise from file storage. - /// Also checks the alternate location for migration purposes. - pub fn load(&self) -> Result, CloudError> { - if self.use_keyring { - // Try keyring first, fall back to file - if let Some(creds) = self.load_from_keyring()? { - return Ok(Some(creds)); - } - self.load_from_file() - } else { - // Try file first, fall back to keyring (for migration) - if let Some(creds) = self.load_from_file()? { - return Ok(Some(creds)); - } - // Check keyring as fallback (user may have stored there previously) - if Self::is_keyring_available() { - self.load_from_keyring() - } else { - Ok(None) - } - } - } - - /// Deletes stored credentials. - /// - /// Removes credentials from both file and keyring storage to ensure - /// complete cleanup regardless of how they were stored. - pub fn delete(&self) -> Result<(), CloudError> { - // Delete from file - self.delete_from_file()?; - - // Also delete from keyring if available (cleanup any legacy storage) - if Self::is_keyring_available() { - self.delete_from_keyring()?; - } - - Ok(()) - } - - /// Stores the derived encryption key securely. - /// - /// The encryption key is stored separately from credentials and should - /// be a hex-encoded string of the derived key bytes. - pub fn store_encryption_key(&self, key_hex: &str) -> Result<(), CloudError> { - if self.use_keyring { - let entry = Entry::new(KEYRING_SERVICE, KEYRING_ENCRYPTION_KEY_USER) - .map_err(|e| CloudError::KeyringError(e.to_string()))?; - entry - .set_password(key_hex) - .map_err(|e| CloudError::KeyringError(e.to_string()))?; - } else { - // Use file storage - let path = self.encryption_key_path()?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| CloudError::KeyringError(format!("Failed to create dir: {e}")))?; - } - fs::write(&path, key_hex) - .map_err(|e| CloudError::KeyringError(format!("Failed to write key: {e}")))?; - - // Set restrictive permissions on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = fs::Permissions::from_mode(0o600); - fs::set_permissions(&path, perms).map_err(|e| { - CloudError::KeyringError(format!("Failed to set permissions: {e}")) - })?; - } - } - Ok(()) - } - - /// Loads the stored encryption key. - /// - /// Returns the hex-encoded encryption key, or None if not stored. - pub fn load_encryption_key(&self) -> Result, CloudError> { - if self.use_keyring { - let entry = Entry::new(KEYRING_SERVICE, KEYRING_ENCRYPTION_KEY_USER) - .map_err(|e| CloudError::KeyringError(e.to_string()))?; - match entry.get_password() { - Ok(key) => return Ok(Some(key)), - Err(keyring::Error::NoEntry) => {} - Err(e) => return Err(CloudError::KeyringError(e.to_string())), - } - } - - // Check file storage - let path = self.encryption_key_path()?; - if path.exists() { - let key = fs::read_to_string(&path) - .map_err(|e| CloudError::KeyringError(format!("Failed to read key: {e}")))?; - return Ok(Some(key.trim().to_string())); - } - - // Check keyring as fallback (for migration from keyring to file) - if !self.use_keyring && Self::is_keyring_available() { - let entry = Entry::new(KEYRING_SERVICE, KEYRING_ENCRYPTION_KEY_USER) - .map_err(|e| CloudError::KeyringError(e.to_string()))?; - match entry.get_password() { - Ok(key) => return Ok(Some(key)), - Err(keyring::Error::NoEntry) => {} - Err(e) => return Err(CloudError::KeyringError(e.to_string())), - } - } - - Ok(None) - } - - /// Deletes the stored encryption key. - /// - /// Removes from both file and keyring to ensure complete cleanup. - pub fn delete_encryption_key(&self) -> Result<(), CloudError> { - // Delete from file - let path = self.encryption_key_path()?; - if path.exists() { - fs::remove_file(&path) - .map_err(|e| CloudError::KeyringError(format!("Failed to delete key file: {e}")))?; - } - - // Also delete from keyring if available (cleanup any legacy storage) - if Self::is_keyring_available() { - let entry = Entry::new(KEYRING_SERVICE, KEYRING_ENCRYPTION_KEY_USER) - .map_err(|e| CloudError::KeyringError(e.to_string()))?; - match entry.delete_credential() { - Ok(()) => {} - Err(keyring::Error::NoEntry) => {} - Err(e) => return Err(CloudError::KeyringError(e.to_string())), - } - } - - Ok(()) - } - - // ==================== Keyring operations ==================== - - fn store_to_keyring(&self, credentials: &Credentials) -> Result<(), CloudError> { - let entry = Entry::new(KEYRING_SERVICE, KEYRING_API_KEY_USER) - .map_err(|e| CloudError::KeyringError(e.to_string()))?; - - // Store credentials as JSON - let json = serde_json::to_string(credentials) - .map_err(|e| CloudError::KeyringError(format!("Serialization error: {e}")))?; - - entry - .set_password(&json) - .map_err(|e| CloudError::KeyringError(e.to_string()))?; - - Ok(()) - } - - fn load_from_keyring(&self) -> Result, CloudError> { - let entry = Entry::new(KEYRING_SERVICE, KEYRING_API_KEY_USER) - .map_err(|e| CloudError::KeyringError(e.to_string()))?; - - match entry.get_password() { - Ok(json) => { - let credentials: Credentials = serde_json::from_str(&json) - .map_err(|e| CloudError::KeyringError(format!("Deserialization error: {e}")))?; - Ok(Some(credentials)) - } - Err(keyring::Error::NoEntry) => Ok(None), - Err(e) => Err(CloudError::KeyringError(e.to_string())), - } - } - - fn delete_from_keyring(&self) -> Result<(), CloudError> { - let entry = Entry::new(KEYRING_SERVICE, KEYRING_API_KEY_USER) - .map_err(|e| CloudError::KeyringError(e.to_string()))?; - - match entry.delete_credential() { - Ok(()) => Ok(()), - Err(keyring::Error::NoEntry) => Ok(()), // Already deleted - Err(e) => Err(CloudError::KeyringError(e.to_string())), - } - } - - // ==================== File operations ==================== - - fn credentials_path(&self) -> Result { - let config_dir = match &self.base_dir { - Some(base_dir) => base_dir.clone(), - None => dirs::home_dir() - .ok_or_else(|| { - CloudError::KeyringError("Could not find home directory".to_string()) - })? - .join(".lore"), - }; - - Ok(config_dir.join("credentials.json")) - } - - fn encryption_key_path(&self) -> Result { - let config_dir = match &self.base_dir { - Some(base_dir) => base_dir.clone(), - None => dirs::home_dir() - .ok_or_else(|| { - CloudError::KeyringError("Could not find home directory".to_string()) - })? - .join(".lore"), - }; - - Ok(config_dir.join("encryption.key")) - } - - fn store_to_file(&self, credentials: &Credentials) -> Result<(), CloudError> { - let path = self.credentials_path()?; - - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| { - CloudError::KeyringError(format!("Failed to create config directory: {e}")) - })?; - } - - let json = serde_json::to_string_pretty(credentials) - .map_err(|e| CloudError::KeyringError(format!("Serialization error: {e}")))?; - - fs::write(&path, json).map_err(|e| { - CloudError::KeyringError(format!("Failed to write credentials file: {e}")) - })?; - - // Set restrictive permissions on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = fs::Permissions::from_mode(0o600); - fs::set_permissions(&path, perms).map_err(|e| { - CloudError::KeyringError(format!("Failed to set file permissions: {e}")) - })?; - } - - Ok(()) - } - - fn load_from_file(&self) -> Result, CloudError> { - let path = self.credentials_path()?; - - if !path.exists() { - return Ok(None); - } - - let json = fs::read_to_string(&path).map_err(|e| { - CloudError::KeyringError(format!("Failed to read credentials file: {e}")) - })?; - - let credentials: Credentials = serde_json::from_str(&json) - .map_err(|e| CloudError::KeyringError(format!("Invalid credentials file: {e}")))?; - - Ok(Some(credentials)) - } - - fn delete_from_file(&self) -> Result<(), CloudError> { - let path = self.credentials_path()?; - - if path.exists() { - fs::remove_file(&path).map_err(|e| { - CloudError::KeyringError(format!("Failed to delete credentials file: {e}")) - })?; - } - - Ok(()) - } -} - -impl Default for CredentialsStore { - fn default() -> Self { - Self::new() - } -} - -/// Checks if the user is currently logged in. -/// -/// Returns true if valid credentials are stored, false otherwise. -/// Respects the `use_keychain` config setting. -#[allow(dead_code)] -pub fn is_logged_in() -> bool { - let use_keychain = crate::config::Config::load() - .map(|c| c.use_keychain) - .unwrap_or(false); - let store = CredentialsStore::with_keychain(use_keychain); - matches!(store.load(), Ok(Some(_))) -} - -/// Gets the current credentials if logged in. -/// -/// Returns None if not logged in or credentials cannot be loaded. -/// Respects the `use_keychain` config setting. -#[allow(dead_code)] -pub fn get_credentials() -> Option { - let use_keychain = crate::config::Config::load() - .map(|c| c.use_keychain) - .unwrap_or(false); - let store = CredentialsStore::with_keychain(use_keychain); - get_credentials_with_store(&store) -} - -/// Requires login, returning an error if not logged in. -/// -/// This is a convenience function for commands that require authentication. -/// Respects the `use_keychain` config setting. -pub fn require_login() -> Result { - let use_keychain = crate::config::Config::load() - .map(|c| c.use_keychain) - .unwrap_or(false); - let store = CredentialsStore::with_keychain(use_keychain); - require_login_with_store(&store) -} - -fn get_credentials_with_store(store: &CredentialsStore) -> Option { - store.load().ok().flatten() -} - -fn require_login_with_store(store: &CredentialsStore) -> Result { - store - .load() - .context("Failed to check login status")? - .ok_or_else(|| anyhow::anyhow!("Not logged in. Run 'lore login' first.")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_credentials_default_cloud_url() { - let creds = Credentials { - api_key: "test".to_string(), - email: "test@example.com".to_string(), - plan: "free".to_string(), - cloud_url: default_cloud_url(), - }; - assert_eq!(creds.cloud_url, super::super::DEFAULT_CLOUD_URL); - } - - #[test] - fn test_credentials_serialization() { - let creds = Credentials { - api_key: "lore_test123".to_string(), - email: "user@example.com".to_string(), - plan: "pro".to_string(), - cloud_url: "https://custom.example.com".to_string(), - }; - - let json = serde_json::to_string(&creds).unwrap(); - let parsed: Credentials = serde_json::from_str(&json).unwrap(); - - assert_eq!(parsed.api_key, creds.api_key); - assert_eq!(parsed.email, creds.email); - assert_eq!(parsed.plan, creds.plan); - assert_eq!(parsed.cloud_url, creds.cloud_url); - } - - #[test] - fn test_credentials_deserialization_default_url() { - // Test that cloud_url gets a default value when not present in JSON - let json = r#"{"api_key":"test","email":"test@example.com","plan":"free"}"#; - let creds: Credentials = serde_json::from_str(json).unwrap(); - assert_eq!(creds.cloud_url, super::super::DEFAULT_CLOUD_URL); - } - - #[test] - fn test_is_logged_in_returns_bool() { - // This test verifies the function exists and runs without panic. - // The actual result depends on whether there are existing - // credentials on the system. - let _result: bool = is_logged_in(); - } - - #[test] - fn test_is_keyring_available_smoke() { - // This test verifies the function exists and returns a boolean. - // The actual result depends on the system's keychain support. - let _result: bool = CredentialsStore::is_keyring_available(); - } - - #[test] - fn test_is_secret_service_available_smoke() { - // This test verifies the function exists and returns a boolean. - // On macOS and Windows, this should always return true. - // On Linux, it depends on whether a secret service is running. - let _result: bool = CredentialsStore::is_secret_service_available(); - } - - #[test] - fn test_require_login_with_store_deterministic() { - let temp_dir = tempfile::TempDir::new().unwrap(); - let store = CredentialsStore::with_base_dir(temp_dir.path().to_path_buf(), false); - - let creds = Credentials { - api_key: "test_key".to_string(), - email: "user@example.com".to_string(), - plan: "pro".to_string(), - cloud_url: default_cloud_url(), - }; - - store.store(&creds).unwrap(); - let loaded = require_login_with_store(&store).unwrap(); - assert_eq!(loaded.email, creds.email); - assert_eq!(loaded.api_key, creds.api_key); - } - - #[test] - fn test_get_credentials_with_store_deterministic() { - let temp_dir = tempfile::TempDir::new().unwrap(); - let store = CredentialsStore::with_base_dir(temp_dir.path().to_path_buf(), false); - - let creds = Credentials { - api_key: "test_key".to_string(), - email: "user@example.com".to_string(), - plan: "free".to_string(), - cloud_url: default_cloud_url(), - }; - - store.store(&creds).unwrap(); - let loaded = get_credentials_with_store(&store).unwrap(); - assert_eq!(loaded.email, creds.email); - assert_eq!(loaded.api_key, creds.api_key); - } -} diff --git a/src/cloud/encryption.rs b/src/cloud/encryption.rs deleted file mode 100644 index 2386e10..0000000 --- a/src/cloud/encryption.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! End-to-end encryption for cloud sync. -//! -//! The implementation now lives in [`crate::sync::encryption`]; this module -//! re-exports it so existing cloud call sites keep compiling unchanged. The -//! cloud path stays live until it is decommissioned in a later phase, at which -//! point this re-export can be deleted along with the rest of the cloud module. - -pub use crate::sync::encryption::*; diff --git a/src/cloud/mod.rs b/src/cloud/mod.rs deleted file mode 100644 index 82c019d..0000000 --- a/src/cloud/mod.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Cloud sync module for Lore. -//! -//! Provides functionality for syncing sessions to the Lore cloud service, -//! including authentication, encryption, and API communication. -//! -//! # Submodules -//! -//! - `client` - HTTP client for cloud API communication -//! - `credentials` - Secure credential storage (keychain + fallback) -//! - `encryption` - End-to-end encryption for session content - -pub mod client; -pub mod credentials; -pub mod encryption; - -// Re-exports for external use -#[allow(unused_imports)] -pub use client::CloudClient; -#[allow(unused_imports)] -pub use credentials::{Credentials, CredentialsStore}; -#[allow(unused_imports)] -pub use encryption::{decrypt_data, derive_key, encrypt_data}; - -/// Default cloud service URL. -pub const DEFAULT_CLOUD_URL: &str = "https://app.lore.varalys.com"; - -/// Service name for keyring storage. -pub const KEYRING_SERVICE: &str = "lore-cloud"; - -/// User identifier for API key in keyring. -pub const KEYRING_API_KEY_USER: &str = "api-key"; - -/// User identifier for encryption key in keyring. -pub const KEYRING_ENCRYPTION_KEY_USER: &str = "encryption-key"; - -/// Custom error type for cloud operations. -#[derive(Debug, thiserror::Error)] -pub enum CloudError { - /// Not logged in to the cloud service. - #[error("Not logged in. Run 'lore login' first.")] - NotLoggedIn, - - /// Authentication failed. - #[error("Authentication failed: {0}")] - #[allow(dead_code)] - AuthFailed(String), - - /// Network or API error. - #[error("Cloud API error: {0}")] - #[allow(dead_code)] - ApiError(String), - - /// HTTP request error. - #[error("HTTP request failed: {0}")] - HttpError(#[from] reqwest::Error), - - /// Encryption or decryption error. - #[error("Encryption error: {0}")] - EncryptionError(String), - - /// Keyring storage error. - #[error("Credential storage error: {0}")] - KeyringError(String), - - /// Invalid or missing encryption key. - #[error("Encryption key not set. Run 'lore cloud push' to set up encryption.")] - #[allow(dead_code)] - NoEncryptionKey, - - /// State mismatch during OAuth callback. - #[error("OAuth state mismatch - possible CSRF attack")] - #[allow(dead_code)] - StateMismatch, - - /// Login timeout. - #[error("Login timed out waiting for browser authentication")] - #[allow(dead_code)] - LoginTimeout, - - /// Server returned an error response. - #[error("Server error ({status}): {message}")] - ServerError { status: u16, message: String }, -} - -/// Bridges sync errors into the cloud error type. -/// -/// The encryption primitives moved into [`crate::sync`] and now report -/// [`crate::sync::SyncError`]. This conversion lets the still-live cloud code -/// keep propagating those failures with `?` in `CloudError`-returning paths. -impl From for CloudError { - fn from(err: crate::sync::SyncError) -> Self { - CloudError::EncryptionError(err.to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_cloud_error_display_not_logged_in() { - let err = CloudError::NotLoggedIn; - assert!(err.to_string().contains("Not logged in")); - } - - #[test] - fn test_cloud_error_display_auth_failed() { - let err = CloudError::AuthFailed("invalid token".to_string()); - assert!(err.to_string().contains("invalid token")); - } - - #[test] - fn test_cloud_error_display_server_error() { - let err = CloudError::ServerError { - status: 500, - message: "Internal error".to_string(), - }; - assert!(err.to_string().contains("500")); - assert!(err.to_string().contains("Internal error")); - } - - #[test] - fn test_default_cloud_url() { - assert_eq!(DEFAULT_CLOUD_URL, "https://app.lore.varalys.com"); - } - - #[test] - fn test_keyring_constants() { - assert_eq!(KEYRING_SERVICE, "lore-cloud"); - assert_eq!(KEYRING_API_KEY_USER, "api-key"); - assert_eq!(KEYRING_ENCRYPTION_KEY_USER, "encryption-key"); - } -} diff --git a/src/config/mod.rs b/src/config/mod.rs index 04269f5..4985413 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -30,7 +30,7 @@ pub struct Config { /// Whether to append session references to commit messages. pub commit_footer: bool, - /// Unique machine identifier (UUID) for cloud sync deduplication. + /// Unique machine identifier (UUID) for sync deduplication. /// /// Auto-generated on first access via `get_or_create_machine_id()`. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -42,25 +42,18 @@ pub struct Config { #[serde(default, skip_serializing_if = "Option::is_none")] pub machine_name: Option, - /// Cloud service URL for sync operations. - /// - /// Defaults to the official Lore cloud service. Can be customized for - /// self-hosted deployments. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cloud_url: Option, - /// Salt for encryption key derivation (base64-encoded). /// - /// Generated on first cloud push and stored for consistent key derivation. + /// Generated on first sync setup and stored for consistent key derivation. /// This is NOT secret - only the passphrase needs to be kept private. #[serde(default, skip_serializing_if = "Option::is_none")] pub encryption_salt: Option, /// Whether to use the OS keychain for credential storage. /// - /// When false (default), credentials are stored in ~/.lore/credentials.json. - /// When true, uses macOS Keychain, GNOME Keyring, or Windows Credential Manager. - /// Note: Keychain may prompt for permission on first access. + /// When false (default), the sync passphrase key is stored in a file under + /// ~/.lore. When true, uses macOS Keychain, GNOME Keyring, or Windows + /// Credential Manager. Note: Keychain may prompt for permission on first access. #[serde(default)] pub use_keychain: bool, @@ -119,7 +112,6 @@ impl Default for Config { commit_footer: false, machine_id: None, machine_name: None, - cloud_url: None, encryption_salt: None, use_keychain: false, summary_provider: None, @@ -196,7 +188,7 @@ impl Config { /// /// If no machine_id exists in config, generates a new UUIDv4 and saves /// it to the config file. This ensures a consistent machine identifier - /// across sessions for cloud sync deduplication. + /// across sessions for sync deduplication. pub fn get_or_create_machine_id(&mut self) -> Result { if let Some(ref id) = self.machine_id { return Ok(id.clone()); @@ -233,45 +225,6 @@ impl Config { self.save() } - /// Returns the cloud service URL. - /// - /// If a custom cloud_url is set, returns that. Otherwise returns - /// the default Lore cloud service URL. - pub fn get_cloud_url(&self) -> String { - self.cloud_url - .clone() - .unwrap_or_else(|| "https://app.lore.varalys.com".to_string()) - } - - /// Sets the cloud service URL and saves the configuration. - #[allow(dead_code)] - pub fn set_cloud_url(&mut self, url: &str) -> Result<()> { - self.cloud_url = Some(url.to_string()); - self.save() - } - - /// Returns the encryption salt (base64-encoded), generating one if needed. - /// - /// The salt is stored in the config file and used for deriving the - /// encryption key from the user's passphrase. - pub fn get_or_create_encryption_salt(&mut self) -> Result { - if let Some(ref salt) = self.encryption_salt { - return Ok(salt.clone()); - } - - // Generate a new random salt - use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; - use rand::RngCore; - - let mut salt_bytes = [0u8; 16]; - rand::thread_rng().fill_bytes(&mut salt_bytes); - let salt_b64 = BASE64.encode(salt_bytes); - - self.encryption_salt = Some(salt_b64.clone()); - self.save()?; - Ok(salt_b64) - } - /// Gets a configuration value by key. /// /// Supported keys: @@ -281,7 +234,6 @@ impl Config { /// - `commit_footer` - "true" or "false" /// - `machine_id` - the machine UUID (read-only, auto-generated) /// - `machine_name` - human-readable machine name - /// - `cloud_url` - cloud service URL /// - `encryption_salt` - salt for encryption key derivation (read-only) /// - `summary_provider` - LLM provider for summaries /// - `summary_api_key_anthropic` - Anthropic API key @@ -303,7 +255,6 @@ impl Config { "commit_footer" => Some(self.commit_footer.to_string()), "machine_id" => self.machine_id.clone(), "machine_name" => Some(self.get_machine_name()), - "cloud_url" => Some(self.get_cloud_url()), "encryption_salt" => self.encryption_salt.clone(), "use_keychain" => Some(self.use_keychain.to_string()), "summary_provider" => self.summary_provider.clone(), @@ -328,7 +279,6 @@ impl Config { /// - `auto_link_threshold` - float between 0.0 and 1.0 (inclusive) /// - `commit_footer` - "true" or "false" /// - `machine_name` - human-readable machine name - /// - `cloud_url` - cloud service URL /// - `summary_provider` - "anthropic", "openai", or "openrouter" /// - `summary_api_key_anthropic` - Anthropic API key /// - `summary_api_key_openai` - OpenAI API key @@ -372,9 +322,6 @@ impl Config { "machine_name" => { self.machine_name = Some(value.to_string()); } - "cloud_url" => { - self.cloud_url = Some(value.to_string()); - } "machine_id" => { bail!("machine_id cannot be set manually; it is auto-generated"); } @@ -461,7 +408,6 @@ impl Config { "commit_footer", "machine_id", "machine_name", - "cloud_url", "encryption_salt", "use_keychain", "summary_provider", @@ -496,32 +442,6 @@ impl Config { _ => None, } } - - /// Checks if use_keychain was explicitly set in the config file. - /// - /// Returns true if the config file exists and contains a use_keychain key, - /// false if the file does not exist or does not contain the key (meaning - /// the default value is being used). - pub fn is_use_keychain_configured() -> Result { - let path = Self::config_path()?; - if !path.exists() { - return Ok(false); - } - - let content = fs::read_to_string(&path) - .with_context(|| format!("Failed to read config file: {}", path.display()))?; - - if content.trim().is_empty() { - return Ok(false); - } - - // Check if the YAML content contains use_keychain key - // We look for the key at the start of a line (not in comments) - Ok(content.lines().any(|line| { - let trimmed = line.trim(); - trimmed.starts_with("use_keychain:") - })) - } } /// Returns the default minimum message count for auto-summary generation. @@ -615,7 +535,6 @@ mod tests { commit_footer: true, machine_id: Some("test-uuid".to_string()), machine_name: Some("test-machine".to_string()), - cloud_url: None, encryption_salt: None, use_keychain: false, ..Default::default() @@ -750,64 +669,6 @@ mod tests { assert!(yaml.contains("machine_name")); } - #[test] - fn test_is_use_keychain_configured_with_default_config() { - // Test the detection logic by checking serialized config content. - // The function checks the default config path, not a custom one, - // so we test the behavior through the serialization logic. - let temp_dir = TempDir::new().unwrap(); - - let config_path = temp_dir.path().join("config.yaml"); - let config = Config::default(); - config.save_to_path(&config_path).unwrap(); - - // Read the saved content - default config should contain use_keychain - // since serde serializes all fields by default - let content = fs::read_to_string(&config_path).unwrap(); - let has_use_keychain = content.lines().any(|line| { - let trimmed = line.trim(); - trimmed.starts_with("use_keychain:") - }); - // serde includes all fields by default, so this will be true - assert!(has_use_keychain); - } - - #[test] - fn test_is_use_keychain_configured_detects_explicit_setting() { - let temp_dir = TempDir::new().unwrap(); - let config_path = temp_dir.path().join("config.yaml"); - - // Write config with explicit use_keychain: true - let config = Config { - use_keychain: true, - ..Default::default() - }; - config.save_to_path(&config_path).unwrap(); - - let content = fs::read_to_string(&config_path).unwrap(); - let has_use_keychain = content.lines().any(|line| { - let trimmed = line.trim(); - trimmed.starts_with("use_keychain:") - }); - assert!(has_use_keychain); - } - - #[test] - fn test_is_use_keychain_configured_returns_false_for_empty_file() { - let temp_dir = TempDir::new().unwrap(); - let config_path = temp_dir.path().join("config.yaml"); - - // Write empty file - fs::write(&config_path, "").unwrap(); - - let content = fs::read_to_string(&config_path).unwrap(); - let has_use_keychain = content.lines().any(|line| { - let trimmed = line.trim(); - trimmed.starts_with("use_keychain:") - }); - assert!(!has_use_keychain); - } - #[test] fn test_default_config_summary_fields() { let config = Config::default(); diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 8ec322a..1abb887 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -7,16 +7,14 @@ //! - Incremental parsing of session files //! - Unix socket IPC for CLI communication //! - Graceful shutdown handling -//! - Periodic cloud sync (every 4 hours) //! //! # Architecture //! -//! The daemon consists of four main components: +//! The daemon consists of three main components: //! //! - **Watcher**: Monitors the file system for new/modified session files //! - **Server**: Handles IPC commands from CLI (status, stop, stats) //! - **State**: Manages PID file, socket path, and runtime state -//! - **Sync**: Periodic cloud synchronization of pending sessions //! //! # Usage //! @@ -26,7 +24,6 @@ pub mod server; pub mod state; -pub mod sync; pub mod watcher; use anyhow::Result; @@ -41,9 +38,6 @@ pub use server::{send_command_sync, DaemonCommand, DaemonResponse}; pub use state::{DaemonState, DaemonStats}; pub use watcher::SessionWatcher; -// Re-export SyncState for use by cloud status command -pub use sync::SyncState; - /// Runs the daemon in the foreground. /// /// This is the main entry point for the daemon. It: @@ -99,9 +93,6 @@ pub async fn run_daemon() -> Result<()> { // Create shared stats let stats = Arc::new(RwLock::new(DaemonStats::default())); - // Create shared sync state - let sync_state = Arc::new(RwLock::new(sync::SyncState::load().unwrap_or_default())); - // Create shutdown channels let (stop_tx, stop_rx) = oneshot::channel::<()>(); let (broadcast_tx, _) = tokio::sync::broadcast::channel::<()>(1); @@ -133,12 +124,6 @@ pub async fn run_daemon() -> Result<()> { } }); - // Start the periodic sync timer - let sync_broadcast_rx = broadcast_tx.subscribe(); - let sync_handle = tokio::spawn(async move { - sync::run_periodic_sync(sync_state, sync_broadcast_rx).await; - }); - // Wait for shutdown signal tokio::select! { _ = signal::ctrl_c() => { @@ -158,7 +143,6 @@ pub async fn run_daemon() -> Result<()> { // Abort handles if they haven't finished server_handle.abort(); watcher_handle.abort(); - sync_handle.abort(); // Clean up state files state.cleanup()?; diff --git a/src/daemon/sync.rs b/src/daemon/sync.rs deleted file mode 100644 index a41795a..0000000 --- a/src/daemon/sync.rs +++ /dev/null @@ -1,540 +0,0 @@ -//! Periodic cloud sync for the daemon. -//! -//! Provides automatic synchronization of sessions to the cloud at regular -//! intervals. The sync timer checks for credentials and encryption key -//! availability before attempting to push pending sessions. - -use anyhow::{Context, Result}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::RwLock; -use tokio::time::{interval, Duration}; - -use crate::cloud::client::{CloudClient, PushSession, SessionMetadata}; -use crate::cloud::credentials::CredentialsStore; -use crate::cloud::encryption::{decode_key_hex, encode_base64, encrypt_data}; -use crate::config::Config; -use crate::storage::models::Message; -use crate::storage::Database; - -/// Default interval between automatic syncs (4 hours). -const SYNC_INTERVAL_HOURS: u64 = 4; - -/// Number of sessions to include in each batch when pushing to the cloud. -const PUSH_BATCH_SIZE: usize = 3; - -/// Persistent state for daemon sync scheduling. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct SyncState { - /// When the last sync was performed (successfully or not). - pub last_sync_at: Option>, - /// When the next sync is scheduled. - pub next_sync_at: Option>, - /// Number of sessions synced in the last sync. - pub last_sync_count: Option, - /// Whether the last sync was successful. - pub last_sync_success: Option, -} - -impl SyncState { - /// Returns the path to the sync state file. - fn state_path() -> Result { - let lore_dir = dirs::home_dir() - .context("Could not find home directory")? - .join(".lore"); - Ok(lore_dir.join("daemon_state.json")) - } - - /// Loads the sync state from a specific path. - /// - /// Returns the default state if the file does not exist. - pub fn load_from_path(path: &std::path::Path) -> Result { - if !path.exists() { - return Ok(Self::default()); - } - - let content = fs::read_to_string(path).context("Failed to read sync state file")?; - let state: SyncState = - serde_json::from_str(&content).context("Failed to parse sync state file")?; - Ok(state) - } - - /// Loads the sync state from disk. - /// - /// Returns the default state if the file does not exist. - pub fn load() -> Result { - let path = Self::state_path()?; - Self::load_from_path(&path) - } - - /// Saves the sync state to a specific path atomically. - /// - /// Creates parent directories if they do not exist. - pub fn save_to_path(&self, path: &std::path::Path) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).context("Failed to create parent directory")?; - } - - let content = serde_json::to_string_pretty(self)?; - - // Write to temp file first, then rename for atomicity. - // On Windows, rename fails if target exists, so remove it first. - let temp_path = path.with_extension("json.tmp"); - fs::write(&temp_path, &content).context("Failed to write sync state temp file")?; - - #[cfg(windows)] - if path.exists() { - let _ = fs::remove_file(path); - } - - fs::rename(&temp_path, path).context("Failed to rename sync state file")?; - - Ok(()) - } - - /// Saves the sync state to disk atomically. - fn save(&self) -> Result<()> { - let path = Self::state_path()?; - self.save_to_path(&path) - } - - /// Updates the state with next sync time and saves. - fn schedule_next(&mut self, next_at: DateTime) -> Result<()> { - self.next_sync_at = Some(next_at); - self.save() - } - - /// Updates the state after a sync attempt and saves. - fn record_sync(&mut self, success: bool, count: u64, next_at: DateTime) -> Result<()> { - self.last_sync_at = Some(Utc::now()); - self.last_sync_success = Some(success); - self.last_sync_count = Some(count); - self.next_sync_at = Some(next_at); - self.save() - } -} - -/// Shared sync state for the daemon. -pub type SharedSyncState = Arc>; - -/// Calculates the next sync time based on the last sync. -/// -/// If there was a previous sync, schedules the next one SYNC_INTERVAL_HOURS -/// after that. If not, schedules SYNC_INTERVAL_HOURS from now. -fn calculate_next_sync(state: &SyncState) -> DateTime { - let interval = chrono::Duration::hours(SYNC_INTERVAL_HOURS as i64); - - if let Some(last_sync) = state.last_sync_at { - // Schedule from last sync + interval - let next = last_sync + interval; - // If that time has already passed, schedule from now - let now = Utc::now(); - if next <= now { - now + interval - } else { - next - } - } else { - // No previous sync, schedule from now - Utc::now() + interval - } -} - -/// Runs the periodic sync timer. -/// -/// This function runs until the shutdown signal is received. It checks -/// periodically if a sync is needed and performs it if credentials and -/// encryption key are available. -pub async fn run_periodic_sync( - sync_state: SharedSyncState, - mut shutdown_rx: tokio::sync::broadcast::Receiver<()>, -) { - { - let mut state = sync_state.write().await; - let next_sync = if let Some(persisted_next) = state.next_sync_at { - if persisted_next > Utc::now() { - persisted_next - } else { - calculate_next_sync(&state) - } - } else { - calculate_next_sync(&state) - }; - if let Err(e) = state.schedule_next(next_sync) { - tracing::warn!("Failed to save initial sync state: {e}"); - } else { - tracing::info!( - "Periodic sync scheduled for {}", - next_sync.format("%Y-%m-%d %H:%M:%S UTC") - ); - } - } - - let mut check_interval = interval(Duration::from_secs(60)); - - loop { - tokio::select! { - _ = check_interval.tick() => { - let should_sync = { - let state = sync_state.read().await; - if let Some(next_sync) = state.next_sync_at { - Utc::now() >= next_sync - } else { - false - } - }; - - if should_sync { - let result = perform_sync().await; - let next_sync = Utc::now() + chrono::Duration::hours(SYNC_INTERVAL_HOURS as i64); - - let mut state = sync_state.write().await; - match result { - Ok(count) => { - tracing::info!("Periodic sync completed: {} sessions synced", count); - if let Err(e) = state.record_sync(true, count, next_sync) { - tracing::warn!("Failed to save sync state: {e}"); - } - } - Err(e) => { - tracing::info!("Periodic sync skipped or failed: {e}"); - if let Err(e) = state.record_sync(false, 0, next_sync) { - tracing::warn!("Failed to save sync state: {e}"); - } - } - } - } - } - _ = shutdown_rx.recv() => { - tracing::info!("Periodic sync shutting down"); - break; - } - } - } -} - -/// Performs a sync operation, pushing pending sessions to the cloud. -/// -/// Returns the number of sessions synced, or an error if sync cannot proceed -/// (e.g., not logged in, no encryption key). -async fn perform_sync() -> Result { - tokio::task::spawn_blocking(perform_sync_blocking) - .await - .context("Sync task panicked")? -} - -/// Blocking implementation of sync operation. -/// -/// This runs in a blocking thread pool to avoid stalling tokio worker threads. -fn perform_sync_blocking() -> Result { - let config = Config::load().context("Could not load config")?; - - let store = CredentialsStore::with_keychain(config.use_keychain); - - let credentials = match store.load()? { - Some(creds) => creds, - None => { - return Err(anyhow::anyhow!("Not logged in")); - } - }; - - let encryption_key = match store.load_encryption_key()? { - Some(key_hex) => decode_key_hex(&key_hex)?, - None => { - return Err(anyhow::anyhow!("Encryption key not configured")); - } - }; - - let machine_id = match config.machine_id.clone() { - Some(id) => id, - None => { - return Err(anyhow::anyhow!("Machine ID not configured")); - } - }; - - let db = Database::open_default().context("Could not open database")?; - - let sessions = db.get_unsynced_sessions()?; - if sessions.is_empty() { - tracing::debug!("No sessions to sync"); - return Ok(0); - } - - tracing::info!("Found {} sessions to sync", sessions.len()); - - let client = CloudClient::with_url(&credentials.cloud_url).with_api_key(&credentials.api_key); - - let session_data: Vec<_> = sessions - .iter() - .filter_map(|session| match db.get_messages(&session.id) { - Ok(messages) => Some((session.clone(), messages)), - Err(e) => { - tracing::warn!( - "Failed to get messages for session {}: {}", - &session.id.to_string()[..8], - e - ); - None - } - }) - .collect(); - - let mut total_synced: u64 = 0; - - for batch in session_data.chunks(PUSH_BATCH_SIZE) { - let mut push_sessions = Vec::new(); - - for (session, messages) in batch { - let encrypted = encrypt_session_messages(messages, &encryption_key)?; - push_sessions.push(PushSession { - id: session.id.to_string(), - machine_id: machine_id.clone(), - encrypted_data: encrypted, - metadata: SessionMetadata { - tool_name: session.tool.clone(), - project_path: session.working_directory.clone(), - started_at: session.started_at, - ended_at: session.ended_at, - message_count: session.message_count, - }, - updated_at: session.ended_at.unwrap_or_else(Utc::now), - }); - } - - match client.push(push_sessions.clone()) { - Ok(response) => { - let batch_session_ids: Vec<_> = push_sessions - .iter() - .filter_map(|ps| uuid::Uuid::parse_str(&ps.id).ok()) - .collect(); - - if let Err(e) = db.mark_sessions_synced(&batch_session_ids, response.server_time) { - tracing::warn!("Failed to mark sessions as synced: {e}"); - } - - total_synced += response.synced_count as u64; - } - Err(e) => { - let error_str = e.to_string(); - if error_str.contains("quota") - || error_str.contains("Would exceed session limit") - || (error_str.contains("403") && error_str.contains("limit")) - { - tracing::debug!("Sync stopped due to quota limit"); - break; - } - tracing::warn!("Failed to push batch: {e}"); - } - } - } - - Ok(total_synced) -} - -/// Encrypts session messages for cloud storage. -fn encrypt_session_messages(messages: &[Message], key: &[u8]) -> Result { - let json = serde_json::to_vec(messages)?; - let encrypted = encrypt_data(&json, key)?; - Ok(encode_base64(&encrypted)) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_sync_state_default() { - let state = SyncState::default(); - assert!(state.last_sync_at.is_none()); - assert!(state.next_sync_at.is_none()); - assert!(state.last_sync_count.is_none()); - assert!(state.last_sync_success.is_none()); - } - - #[test] - fn test_calculate_next_sync_no_previous() { - let state = SyncState::default(); - let next = calculate_next_sync(&state); - - let expected = Utc::now() + chrono::Duration::hours(SYNC_INTERVAL_HOURS as i64); - let diff = (next - expected).num_seconds().abs(); - assert!(diff < 5, "Next sync should be ~4 hours from now"); - } - - #[test] - fn test_calculate_next_sync_with_recent_previous() { - let last_sync = Utc::now() - chrono::Duration::hours(1); - let state = SyncState { - last_sync_at: Some(last_sync), - ..Default::default() - }; - - let next = calculate_next_sync(&state); - - let expected = last_sync + chrono::Duration::hours(SYNC_INTERVAL_HOURS as i64); - let diff = (next - expected).num_seconds().abs(); - assert!(diff < 5, "Next sync should be 4 hours after last sync"); - } - - #[test] - fn test_calculate_next_sync_with_old_previous() { - let state = SyncState { - last_sync_at: Some(Utc::now() - chrono::Duration::hours(10)), - ..Default::default() - }; - - let next = calculate_next_sync(&state); - - let expected = Utc::now() + chrono::Duration::hours(SYNC_INTERVAL_HOURS as i64); - let diff = (next - expected).num_seconds().abs(); - assert!( - diff < 5, - "Next sync should be ~4 hours from now when last sync is old" - ); - } - - #[test] - fn test_sync_state_serialization() { - let state = SyncState { - last_sync_at: Some(Utc::now()), - next_sync_at: Some(Utc::now() + chrono::Duration::hours(4)), - last_sync_count: Some(10), - last_sync_success: Some(true), - }; - - let json = serde_json::to_string(&state).unwrap(); - let parsed: SyncState = serde_json::from_str(&json).unwrap(); - - assert!(parsed.last_sync_at.is_some()); - assert!(parsed.next_sync_at.is_some()); - assert_eq!(parsed.last_sync_count, Some(10)); - assert_eq!(parsed.last_sync_success, Some(true)); - } - - #[test] - fn test_sync_state_save_load_round_trip() { - let temp_dir = TempDir::new().unwrap(); - let state_path = temp_dir.path().join("daemon_state.json"); - - let state = SyncState { - last_sync_at: Some(Utc::now()), - next_sync_at: Some(Utc::now() + chrono::Duration::hours(4)), - last_sync_count: Some(5), - last_sync_success: Some(true), - }; - - state.save_to_path(&state_path).unwrap(); - - let loaded = SyncState::load_from_path(&state_path).unwrap(); - - assert_eq!(loaded.last_sync_count, Some(5)); - assert_eq!(loaded.last_sync_success, Some(true)); - assert!(loaded.next_sync_at.is_some()); - assert!(loaded.last_sync_at.is_some()); - } - - #[test] - fn test_sync_state_save_creates_parent_directory() { - let temp_dir = TempDir::new().unwrap(); - let nested_path = temp_dir - .path() - .join("nested") - .join("deep") - .join("state.json"); - - let parent = nested_path.parent().unwrap(); - assert!(!parent.exists()); - - let state = SyncState::default(); - state.save_to_path(&nested_path).unwrap(); - - assert!(parent.exists()); - assert!(nested_path.exists()); - - // Verify we can load it back - let loaded = SyncState::load_from_path(&nested_path).unwrap(); - assert!(loaded.last_sync_at.is_none()); - } - - #[test] - fn test_persisted_next_sync_at_respected_when_future() { - let future_time = Utc::now() + chrono::Duration::hours(2); - let state = SyncState { - last_sync_at: Some(Utc::now() - chrono::Duration::hours(1)), - next_sync_at: Some(future_time), - last_sync_count: Some(3), - last_sync_success: Some(true), - }; - - let next_sync = if let Some(persisted_next) = state.next_sync_at { - if persisted_next > Utc::now() { - persisted_next - } else { - calculate_next_sync(&state) - } - } else { - calculate_next_sync(&state) - }; - - let diff = (next_sync - future_time).num_seconds().abs(); - assert!(diff < 1, "Should use persisted next_sync_at when in future"); - } - - #[test] - fn test_persisted_next_sync_at_recalculated_when_past() { - let past_time = Utc::now() - chrono::Duration::hours(1); - let state = SyncState { - last_sync_at: Some(Utc::now() - chrono::Duration::hours(2)), - next_sync_at: Some(past_time), - last_sync_count: Some(3), - last_sync_success: Some(true), - }; - - let next_sync = if let Some(persisted_next) = state.next_sync_at { - if persisted_next > Utc::now() { - persisted_next - } else { - calculate_next_sync(&state) - } - } else { - calculate_next_sync(&state) - }; - - assert!( - next_sync > Utc::now(), - "Should recalculate when persisted next_sync_at is in the past" - ); - } - - #[test] - fn test_sync_state_atomic_save_overwrites() { - let temp_dir = TempDir::new().unwrap(); - let state_path = temp_dir.path().join("daemon_state.json"); - - // Save initial state - let state1 = SyncState { - last_sync_count: Some(1), - ..Default::default() - }; - state1.save_to_path(&state_path).unwrap(); - - // Verify initial state - let loaded1 = SyncState::load_from_path(&state_path).unwrap(); - assert_eq!(loaded1.last_sync_count, Some(1)); - - // Overwrite with new state (tests atomic rename behavior) - let state2 = SyncState { - last_sync_count: Some(2), - ..Default::default() - }; - state2.save_to_path(&state_path).unwrap(); - - // Verify overwritten state - let loaded2 = SyncState::load_from_path(&state_path).unwrap(); - assert_eq!(loaded2.last_sync_count, Some(2)); - } -} diff --git a/src/lib.rs b/src/lib.rs index 3cc4dbb..d5ab99d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,6 @@ //! # Modules //! //! - [`capture`] - Session capture from AI coding tools -//! - [`cloud`] - Cloud sync for cross-machine session access //! - [`config`] - Configuration management //! - [`daemon`] - Background daemon for automatic session capture //! - [`git`] - Git repository integration and auto-linking @@ -20,9 +19,6 @@ /// Session capture from AI coding tools like Claude Code and Copilot. pub mod capture; -/// Cloud sync for cross-machine session access. -pub mod cloud; - /// Configuration management for Lore settings. pub mod config; diff --git a/src/main.rs b/src/main.rs index 08fa7d0..b746d22 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,6 @@ fn reset_sigpipe() { mod capture; mod cli; -mod cloud; mod config; mod daemon; mod git; @@ -263,29 +262,6 @@ enum Commands { )] Db(commands::db::Args), - /// Authenticate with the Lore cloud service - #[command( - long_about = "Opens a browser to authenticate with the Lore cloud service.\n\ - After authentication, your API key is stored securely in the\n\ - OS keychain (or a fallback file if keychain is unavailable)." - )] - Login(commands::login::Args), - - /// Log out from the Lore cloud service - #[command( - long_about = "Removes stored credentials and encryption keys from the keychain\n\ - and any fallback files." - )] - Logout(commands::logout::Args), - - /// Sync sessions with Lore cloud - #[command( - long_about = "Cloud sync commands for backing up sessions and syncing across\n\ - machines. Session content is encrypted end-to-end using a\n\ - passphrase that only you know." - )] - Cloud(commands::cloud::Args), - /// Sync reasoning history over git (serverless, per-repo) #[command( long_about = "Serverless sync that stores encrypted reasoning history in this\n\ @@ -339,8 +315,6 @@ fn is_configured() -> bool { /// - `completions` (should work without init for shell setup) /// - `doctor` (diagnostic command should work without init) /// - `mcp` (MCP server should work without init for tool integration) -/// - `login` (should work to set up cloud before init) -/// - `logout` (should work to clean up cloud state) fn should_skip_first_run_prompt(command: &Commands) -> bool { matches!( command, @@ -349,8 +323,6 @@ fn should_skip_first_run_prompt(command: &Commands) -> bool { | Commands::Completions(_) | Commands::Doctor(_) | Commands::Mcp(_) - | Commands::Login(_) - | Commands::Logout(_) ) } @@ -469,9 +441,6 @@ fn command_name(command: &Commands) -> &'static str { Commands::Hooks(_) => "hooks", Commands::Daemon(_) => "daemon", Commands::Db(_) => "db", - Commands::Login(_) => "login", - Commands::Logout(_) => "logout", - Commands::Cloud(_) => "cloud", Commands::Sync(_) => "sync", Commands::Doctor(_) => "doctor", Commands::Mcp(_) => "mcp", @@ -557,9 +526,6 @@ fn main() -> Result<()> { Commands::Hooks(args) => commands::hooks::run(args), Commands::Daemon(args) => commands::daemon::run(args), Commands::Db(args) => commands::db::run(args), - Commands::Login(args) => commands::login::run(args), - Commands::Logout(args) => commands::logout::run(args), - Commands::Cloud(args) => commands::cloud::run(args), Commands::Sync(args) => commands::sync::run(args), Commands::Doctor(args) => commands::doctor::run(args), Commands::Mcp(args) => commands::mcp::run(args), diff --git a/src/storage/db.rs b/src/storage/db.rs index 07dc70b..a6bb979 100644 --- a/src/storage/db.rs +++ b/src/storage/db.rs @@ -360,7 +360,7 @@ impl Database { // This handles upgrades from databases created before machine_id was added. self.migrate_add_machine_id()?; - // Migration: Add synced_at column for cloud sync tracking. + // Migration: Add synced_at column for sync tracking. self.migrate_add_synced_at()?; // Migration: Add global_synced_at column for the global personal store. @@ -413,8 +413,8 @@ impl Database { /// Adds the synced_at column to the sessions table if it does not exist. /// - /// This column tracks when each session was last synced to the cloud. - /// A NULL value indicates the session has never been synced. + /// This column tracks when each session was last synced to its per-repo + /// store. A NULL value indicates the session has never been synced. fn migrate_add_synced_at(&self) -> Result<()> { let columns: Vec = self .conn @@ -784,6 +784,10 @@ impl Database { /// This is much faster than calling `insert_session` and `insert_message` /// separately for each message, as it batches all operations into one /// database transaction. Optionally marks the session as synced. + /// + /// Note: retained as public API and used as the storage tests' fixture + /// builder; production import paths write sessions incrementally. + #[allow(dead_code)] pub fn import_session_with_messages( &mut self, session: &Session, @@ -1769,12 +1773,18 @@ impl Database { || (session_count > 0 && session_fts_count == 0)) } - // ==================== Cloud Sync ==================== + // ==================== Sync ==================== - /// Returns sessions that have not been synced to the cloud. + /// Returns sessions that have not been synced. /// /// Unsynced sessions are those where `synced_at` is NULL. Returns sessions /// ordered by start time (oldest first) to sync in chronological order. + /// + /// Note: production sync scopes pushes with + /// [`Database::get_unsynced_sessions_for_repo`] (per-repo) or + /// [`Database::get_unsynced_global_sessions`] (global). This unscoped + /// accessor is retained as public API and is exercised by the storage tests. + #[allow(dead_code)] pub fn get_unsynced_sessions(&self) -> Result> { let mut stmt = self.conn.prepare( "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id @@ -1940,22 +1950,12 @@ impl Database { } } - /// Returns the count of sessions that have not been synced. - pub fn unsynced_session_count(&self) -> Result { - let count: i32 = self.conn.query_row( - "SELECT COUNT(*) FROM sessions WHERE synced_at IS NULL", - [], - |row| row.get(0), - )?; - Ok(count) - } - /// Returns the count of unsynced sessions whose working directory is inside /// `repo_path`. /// - /// Repo-scoped counterpart of [`Database::unsynced_session_count`], using the - /// same directory scoping as [`Database::get_unsynced_sessions_for_repo`] so - /// `lore sync status` reports what this repo will actually push. + /// Uses the same directory scoping as + /// [`Database::get_unsynced_sessions_for_repo`] so `lore sync status` reports + /// what this repo will actually push. pub fn unsynced_session_count_for_repo(&self, repo_path: &Path) -> Result { let (predicate, binds) = repo_scope_predicate(repo_path); let sql = format!( @@ -2018,47 +2018,6 @@ impl Database { } } - /// Clears the synced_at timestamp for all sessions. - /// - /// This effectively marks all sessions as unsynced and is useful - /// for resetting sync state when switching cloud environments. - pub fn clear_sync_status(&self) -> Result { - let updated = self - .conn - .execute("UPDATE sessions SET synced_at = NULL", [])?; - Ok(updated) - } - - /// Clears the synced_at timestamp for specific sessions. - /// - /// This marks only the specified sessions as unsynced, useful for - /// selectively re-uploading sessions to the cloud. - /// - /// # Arguments - /// - /// * `session_ids` - The UUIDs of sessions to clear sync status for - /// - /// # Returns - /// - /// The number of sessions that were updated. - pub fn clear_sync_status_for_sessions(&self, session_ids: &[Uuid]) -> Result { - if session_ids.is_empty() { - return Ok(0); - } - - let mut total_updated = 0; - - for id in session_ids { - let updated = self.conn.execute( - "UPDATE sessions SET synced_at = NULL WHERE id = ?1", - params![id.to_string()], - )?; - total_updated += updated; - } - - Ok(total_updated) - } - // ==================== Stats ==================== /// Returns the total number of sessions in the database. @@ -2687,7 +2646,7 @@ impl Database { /// Registers a machine or updates its name if it already exists. /// - /// Used to store machine identity information for cloud sync. + /// Used to store machine identity information for sync deduplication. /// If a machine with the given ID already exists, updates the name. pub fn upsert_machine(&self, machine: &Machine) -> Result<()> { self.conn.execute( @@ -6701,109 +6660,6 @@ mod tests { ); } - #[test] - fn test_clear_sync_status_all_sessions() { - let (db, _dir) = create_test_db(); - - // Create and insert multiple sessions - let session1 = create_test_session("claude-code", "/home/user/project1", Utc::now(), None); - let session2 = create_test_session("aider", "/home/user/project2", Utc::now(), None); - let session3 = create_test_session("cline", "/home/user/project3", Utc::now(), None); - - db.insert_session(&session1) - .expect("Failed to insert session1"); - db.insert_session(&session2) - .expect("Failed to insert session2"); - db.insert_session(&session3) - .expect("Failed to insert session3"); - - // Mark all as synced - db.mark_sessions_synced(&[session1.id, session2.id, session3.id], Utc::now()) - .expect("Failed to mark synced"); - - // Verify all are synced - let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced"); - assert_eq!(unsynced.len(), 0, "All sessions should be synced"); - - // Clear sync status for all - let count = db.clear_sync_status().expect("Failed to clear sync status"); - assert_eq!(count, 3, "Should have cleared 3 sessions"); - - // Verify all are now unsynced - let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced"); - assert_eq!(unsynced.len(), 3, "All sessions should be unsynced now"); - } - - #[test] - fn test_clear_sync_status_for_specific_sessions() { - let (db, _dir) = create_test_db(); - - // Create and insert multiple sessions - let session1 = create_test_session("claude-code", "/home/user/project1", Utc::now(), None); - let session2 = create_test_session("aider", "/home/user/project2", Utc::now(), None); - let session3 = create_test_session("cline", "/home/user/project3", Utc::now(), None); - - db.insert_session(&session1) - .expect("Failed to insert session1"); - db.insert_session(&session2) - .expect("Failed to insert session2"); - db.insert_session(&session3) - .expect("Failed to insert session3"); - - // Mark all as synced - db.mark_sessions_synced(&[session1.id, session2.id, session3.id], Utc::now()) - .expect("Failed to mark synced"); - - // Verify all are synced - let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced"); - assert_eq!(unsynced.len(), 0, "All sessions should be synced"); - - // Clear sync status for only session1 and session3 - let count = db - .clear_sync_status_for_sessions(&[session1.id, session3.id]) - .expect("Failed to clear sync status"); - assert_eq!(count, 2, "Should have cleared 2 sessions"); - - // Verify only session2 is still synced - let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced"); - assert_eq!(unsynced.len(), 2, "Two sessions should be unsynced"); - assert!( - unsynced.iter().any(|s| s.id == session1.id), - "session1 should be unsynced" - ); - assert!( - !unsynced.iter().any(|s| s.id == session2.id), - "session2 should still be synced" - ); - assert!( - unsynced.iter().any(|s| s.id == session3.id), - "session3 should be unsynced" - ); - } - - #[test] - fn test_clear_sync_status_for_sessions_empty_list() { - let (db, _dir) = create_test_db(); - - // Clear sync status with empty list should return 0 - let count = db - .clear_sync_status_for_sessions(&[]) - .expect("Failed to clear sync status"); - assert_eq!(count, 0, "Should return 0 for empty list"); - } - - #[test] - fn test_clear_sync_status_for_nonexistent_session() { - let (db, _dir) = create_test_db(); - - // Try to clear sync status for a session that does not exist - let fake_id = Uuid::new_v4(); - let count = db - .clear_sync_status_for_sessions(&[fake_id]) - .expect("Failed to clear sync status"); - assert_eq!(count, 0, "Should return 0 for nonexistent session"); - } - // ==================== Insights Tests ==================== #[test] diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 8e5428e..d31eca7 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -33,7 +33,7 @@ pub use models::{Message, Session}; /// Returns the machine UUID for the current machine. /// /// Loads the config and returns the machine_id (UUID), generating one if needed. -/// Used to populate the `machine_id` field on sessions, allowing cloud sync +/// Used to populate the `machine_id` field on sessions, allowing sync /// to identify which machine created a session. Returns `None` if the config /// cannot be loaded or the machine ID cannot be determined. pub fn get_machine_id() -> Option { diff --git a/src/storage/models.rs b/src/storage/models.rs index 27470bf..10e28cf 100644 --- a/src/storage/models.rs +++ b/src/storage/models.rs @@ -42,7 +42,7 @@ pub struct Session { pub message_count: i32, /// Machine identifier (hostname) where the session was captured. - /// Used for cloud sync to identify which machine created the session. + /// Used for sync to identify which machine created the session. /// Optional for backwards compatibility with existing sessions. pub machine_id: Option, } @@ -452,7 +452,7 @@ pub struct Summary { /// Represents a machine that has captured sessions. /// -/// Used for cloud sync to map machine UUIDs to friendly names. Each machine +/// Used for sync to map machine UUIDs to friendly names. Each machine /// has a unique identifier (UUID) and a human-readable name that can be /// customized by the user. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/src/sync/encryption.rs b/src/sync/encryption.rs index 838c960..44fe23f 100644 --- a/src/sync/encryption.rs +++ b/src/sync/encryption.rs @@ -6,17 +6,13 @@ //! ensuring that anyone with repository access but without the passphrase //! cannot read the reasoning history. //! -//! This module was moved verbatim from the legacy cloud module; the only -//! change is that it now reports failures via [`SyncError`] rather than the -//! cloud error type. The cloud module re-exports these functions so existing -//! call sites keep working until the cloud path is decommissioned. +//! Failures are reported via [`SyncError`]. use aes_gcm::{ aead::{Aead, KeyInit}, Aes256Gcm, Nonce, }; use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use rand::RngCore; use super::SyncError; @@ -167,18 +163,6 @@ pub fn decrypt_data(data: &[u8], key: &[u8]) -> Result, SyncError> { Ok(plaintext) } -/// Encodes binary data as base64. -pub fn encode_base64(data: &[u8]) -> String { - BASE64.encode(data) -} - -/// Decodes base64 data to binary. -pub fn decode_base64(data: &str) -> Result, SyncError> { - BASE64 - .decode(data) - .map_err(|e| SyncError::Encryption(format!("Base64 decode failed: {e}"))) -} - /// Encodes a key as hexadecimal for storage. pub fn encode_key_hex(key: &[u8]) -> String { hex::encode(key) @@ -331,14 +315,6 @@ mod tests { assert!(result.is_err()); } - #[test] - fn test_base64_roundtrip() { - let data = b"test binary data \x00\x01\x02"; - let encoded = encode_base64(data); - let decoded = decode_base64(&encoded).unwrap(); - assert_eq!(decoded, data); - } - #[test] fn test_hex_roundtrip() { let data = vec![0u8, 1, 2, 255, 128, 64]; diff --git a/src/sync/keystore.rs b/src/sync/keystore.rs index 6911534..cebe4b3 100644 --- a/src/sync/keystore.rs +++ b/src/sync/keystore.rs @@ -7,14 +7,9 @@ //! caller (read from the ref by the [`gitref`](super::gitref) layer) and //! produces a fresh salt when a store is first initialized. //! -//! Unlike the legacy cloud module, sync keys do NOT share the single -//! `encryption.key` file / `encryption-key` keychain entry. Each repo or store -//! has its own passphrase plus salt and therefore its own derived key, so the -//! storage is namespaced by a store identifier derived from the salt. Reusing -//! the cloud slot would both corrupt the live cloud key and make it impossible -//! to hold more than one store key at a time. The file/keychain abstraction here -//! mirrors the pattern in [`crate::cloud::credentials`] but is kept self -//! contained and never touches the cloud credentials. +//! Each repo or store has its own passphrase plus salt and therefore its own +//! derived key, so the storage is namespaced by a store identifier derived from +//! the salt. This lets a single machine hold more than one store key at a time. use keyring::Entry; use sha2::{Digest, Sha256}; @@ -26,9 +21,8 @@ use super::SyncError; /// Keychain service name for sync store keys. /// -/// Deliberately distinct from the cloud module's `lore-cloud` service so the two -/// never collide. Each store's key is stored under this service with the -/// store-id as the account/user. +/// Each store's key is stored under this service with the store-id as the +/// account/user. const SYNC_KEYRING_SERVICE: &str = "lore-sync"; /// Derives the store encryption key from a passphrase and a salt. @@ -61,10 +55,10 @@ pub fn store_id_from_salt(salt: &[u8]) -> String { /// Persists the derived encryption key for a lore store, keyed by store-id. /// -/// Keys live in a dedicated namespace so the cloud encryption key and any other -/// store's key are never touched. Depending on `use_keychain`, the key lands in -/// the OS keychain (service `lore-sync`, account = store-id) or a -/// permission-restricted file at `~/.lore/sync-keys/.key`. +/// Keys live in a dedicated per-store namespace so one store's key never touches +/// another's. Depending on `use_keychain`, the key lands in the OS keychain +/// (service `lore-sync`, account = store-id) or a permission-restricted file at +/// `~/.lore/sync-keys/.key`. pub struct KeyStore { /// Whether to use the OS keychain (config-driven and available on system). use_keyring: bool, @@ -83,8 +77,7 @@ impl KeyStore { /// Creates a key store, using the OS keychain when requested and available. /// - /// Falls back to file storage when the keychain is unavailable, matching the - /// behavior of the cloud credentials store. + /// Falls back to file storage when the keychain is unavailable. pub fn with_keychain(use_keychain: bool) -> Self { Self { use_keyring: use_keychain && Self::is_keyring_available(), diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 93d2bb6..69ce0ef 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -1,14 +1,14 @@ //! Serverless git-ref sync for Lore. //! //! This module stores AI reasoning history in the user's own git repository -//! under `refs/lore/*` instead of a hosted cloud service. A lore store ref -//! points at a commit whose tree holds one encrypted blob per session plus a -//! plaintext salt, so the reasoning rides along with the code over plain git. +//! under `refs/lore/*`, with no hosted service. A lore store ref points at a +//! commit whose tree holds one encrypted blob per session plus a plaintext +//! salt, so the reasoning rides along with the code over plain git. //! //! The module is intentionally split into focused submodules: //! //! - [`encryption`] - Argon2id key derivation and AES-256-GCM encryption on -//! raw bytes (moved from the legacy cloud module, shared by both). +//! raw bytes. //! - [`keystore`] - passphrase-to-key derivation, salt generation, and //! persistence of the derived key (file or OS keychain). //! - [`store`] - the consolidated session-blob pipeline: serialize a full diff --git a/src/sync/store.rs b/src/sync/store.rs index 3113a30..1db81a4 100644 --- a/src/sync/store.rs +++ b/src/sync/store.rs @@ -2,9 +2,9 @@ //! //! A [`SessionRecord`] holds the complete reasoning record for a single //! session: the session row itself plus its messages, commit links, tags, -//! annotations, and optional summary. Unlike the legacy cloud sync (which only -//! synced messages), encrypting the full record means a teammate who pulls the -//! repo can run `lore blame` and recover the commit-to-reasoning linkage. +//! annotations, and optional summary. Encrypting the full record means a +//! teammate who pulls the repo can run `lore blame` and recover the +//! commit-to-reasoning linkage. //! //! The on-disk blob pipeline is `serde_json -> gzip -> encrypt`. Compression //! happens before encryption because ciphertext does not compress. The output