From 64b6f559fdcfa617990c35f3eb3859fd95d221cc Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 12 Feb 2026 06:45:42 -0800 Subject: [PATCH] feat: enable onboarding wizard for libSQL builds Refactor the setup wizard to work with both postgres and libsql feature flags. Previously the wizard was gated behind #[cfg(feature = "postgres")] only, so libsql-only builds would print an error on `ironclaw onboard`. - Add libsql fields to Settings (database_backend, libsql_path, libsql_url) - Split wizard database/migration/secrets methods into feature-gated variants - Add step_database_libsql() with local path and Turso remote replica prompts - Update setup/mod.rs and main.rs feature gates to any(postgres, libsql) - Extend check_onboard_needed() to detect libsql database presence Co-Authored-By: Claude Opus 4.6 --- src/main.rs | 21 +-- src/settings.rs | 12 ++ src/setup/mod.rs | 4 +- src/setup/wizard.rs | 330 +++++++++++++++++++++++++++++++++++++++----- 4 files changed, 320 insertions(+), 47 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4081dcdf..054f8f81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,7 +44,7 @@ use ironclaw::secrets::SecretsCrypto; use ironclaw::secrets::PostgresSecretsStore; #[cfg(feature = "libsql")] use ironclaw::secrets::LibSqlSecretsStore; -#[cfg(feature = "postgres")] +#[cfg(any(feature = "postgres", feature = "libsql"))] use ironclaw::setup::{SetupConfig, SetupWizard}; #[tokio::main] @@ -273,7 +273,7 @@ async fn main() -> anyhow::Result<()> { // Load .env before running onboarding wizard let _ = dotenvy::dotenv(); - #[cfg(feature = "postgres")] + #[cfg(any(feature = "postgres", feature = "libsql"))] { let config = SetupConfig { skip_auth: *skip_auth, @@ -282,10 +282,10 @@ async fn main() -> anyhow::Result<()> { let mut wizard = SetupWizard::with_config(config); wizard.run().await?; } - #[cfg(not(feature = "postgres"))] + #[cfg(not(any(feature = "postgres", feature = "libsql")))] { let _ = (skip_auth, channels_only); - eprintln!("Onboarding wizard requires the 'postgres' feature. Configure settings via environment variables instead."); + eprintln!("Onboarding wizard requires the 'postgres' or 'libsql' feature."); } return Ok(()); } @@ -297,8 +297,8 @@ async fn main() -> anyhow::Result<()> { // Load .env if present let _ = dotenvy::dotenv(); - // Enhanced first-run detection (postgres only - libsql uses env vars) - #[cfg(feature = "postgres")] + // Enhanced first-run detection + #[cfg(any(feature = "postgres", feature = "libsql"))] if !cli.no_onboard { if let Some(reason) = check_onboard_needed().await { println!("Onboarding needed: {}", reason); @@ -1177,12 +1177,17 @@ async fn main() -> anyhow::Result<()> { /// Check if onboarding is needed and return the reason. /// /// Returns `Some(reason)` if onboarding should be triggered, `None` otherwise. -#[cfg(feature = "postgres")] +#[cfg(any(feature = "postgres", feature = "libsql"))] async fn check_onboard_needed() -> Option<&'static str> { let bootstrap = ironclaw::bootstrap::BootstrapConfig::load(); // Database not configured (and not in env) - if bootstrap.database_url.is_none() && std::env::var("DATABASE_URL").is_err() { + let has_db = bootstrap.database_url.is_some() + || std::env::var("DATABASE_URL").is_ok() + || std::env::var("LIBSQL_PATH").is_ok() + || ironclaw::config::default_libsql_path().exists(); + + if !has_db { return Some("Database not configured"); } diff --git a/src/settings.rs b/src/settings.rs index 59165c55..08cc6b6a 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -15,6 +15,10 @@ pub struct Settings { pub onboard_completed: bool, // === Step 1: Database === + /// Database backend: "postgres" or "libsql". + #[serde(default)] + pub database_backend: Option, + /// Database connection URL (postgres://...). #[serde(default)] pub database_url: Option, @@ -23,6 +27,14 @@ pub struct Settings { #[serde(default)] pub database_pool_size: Option, + /// Path to local libSQL database file. + #[serde(default)] + pub libsql_path: Option, + + /// Turso cloud URL for remote replica sync. + #[serde(default)] + pub libsql_url: Option, + // === Step 2: Security === /// Source for the secrets master key. #[serde(default)] diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 28793ec0..ca4d4c56 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -20,7 +20,7 @@ mod channels; mod prompts; -#[cfg(feature = "postgres")] +#[cfg(any(feature = "postgres", feature = "libsql"))] mod wizard; pub use channels::{ @@ -30,5 +30,5 @@ pub use prompts::{ confirm, input, optional_input, print_error, print_header, print_info, print_step, print_success, secret_input, select_many, select_one, }; -#[cfg(feature = "postgres")] +#[cfg(any(feature = "postgres", feature = "libsql"))] pub use wizard::{SetupConfig, SetupWizard}; diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 021938c5..c2a59e83 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -12,15 +12,17 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; +#[cfg(feature = "postgres")] use deadpool_postgres::{Config as PoolConfig, Runtime}; use secrecy::SecretString; +#[cfg(feature = "postgres")] use tokio_postgres::NoTls; use crate::channels::wasm::{ ChannelCapabilitiesFile, available_channel_names, install_bundled_channel, }; use crate::llm::{SessionConfig, SessionManager}; -use crate::secrets::SecretsCrypto; +use crate::secrets::{SecretsCrypto, SecretsStore}; use crate::settings::{KeySource, Settings}; use crate::setup::channels::{ SecretsContext, setup_http, setup_telegram, setup_tunnel, setup_wasm_channel, @@ -66,8 +68,12 @@ pub struct SetupWizard { config: SetupConfig, settings: Settings, session_manager: Option>, - /// Database pool (created during setup). + /// Database pool (created during setup, postgres only). + #[cfg(feature = "postgres")] db_pool: Option, + /// libSQL backend (created during setup, libsql only). + #[cfg(feature = "libsql")] + db_backend: Option, /// Secrets crypto (created during setup). secrets_crypto: Option>, } @@ -79,7 +85,10 @@ impl SetupWizard { config: SetupConfig::default(), settings: Settings::load(), session_manager: None, + #[cfg(feature = "postgres")] db_pool: None, + #[cfg(feature = "libsql")] + db_backend: None, secrets_crypto: None, } } @@ -90,7 +99,10 @@ impl SetupWizard { config, settings: Settings::load(), session_manager: None, + #[cfg(feature = "postgres")] db_pool: None, + #[cfg(feature = "libsql")] + db_backend: None, secrets_crypto: None, } } @@ -153,19 +165,48 @@ impl SetupWizard { /// Step 1: Database connection. async fn step_database(&mut self) -> Result<(), SetupError> { - // Check if we have an existing URL in env or settings + // Determine which backend to use based on compile-time features. + // When both features are enabled, prefer the currently configured backend + // or default to postgres. + #[cfg(all(feature = "postgres", feature = "libsql"))] + { + let backend = std::env::var("DATABASE_BACKEND") + .ok() + .or_else(|| self.settings.database_backend.clone()) + .unwrap_or_else(|| "postgres".to_string()); + + if backend == "libsql" || backend == "turso" || backend == "sqlite" { + return self.step_database_libsql().await; + } + return self.step_database_postgres().await; + } + + #[cfg(all(feature = "postgres", not(feature = "libsql")))] + { + return self.step_database_postgres().await; + } + + #[cfg(all(feature = "libsql", not(feature = "postgres")))] + { + return self.step_database_libsql().await; + } + } + + /// Step 1 (postgres): Database connection via PostgreSQL URL. + #[cfg(feature = "postgres")] + async fn step_database_postgres(&mut self) -> Result<(), SetupError> { + self.settings.database_backend = Some("postgres".to_string()); + let existing_url = std::env::var("DATABASE_URL") .ok() .or_else(|| self.settings.database_url.clone()); if let Some(ref url) = existing_url { - // Mask the password for display let display_url = mask_password_in_url(url); print_info(&format!("Existing database URL: {}", display_url)); if confirm("Use this database?", true).map_err(SetupError::Io)? { - // Test the connection - if let Err(e) = self.test_database_connection(url).await { + if let Err(e) = self.test_database_connection_postgres(url).await { print_error(&format!("Connection failed: {}", e)); print_info("Let's configure a new database URL."); } else { @@ -176,7 +217,6 @@ impl SetupWizard { } } - // Prompt for new URL println!(); print_info("Enter your PostgreSQL connection URL."); print_info("Format: postgres://user:password@host:port/database"); @@ -190,15 +230,13 @@ impl SetupWizard { continue; } - // Test the connection print_info("Testing connection..."); - match self.test_database_connection(&url).await { + match self.test_database_connection_postgres(&url).await { Ok(()) => { print_success("Database connection successful"); - // Ask if we should run migrations if confirm("Run database migrations?", true).map_err(SetupError::Io)? { - self.run_migrations().await?; + self.run_migrations_postgres().await?; } self.settings.database_url = Some(url); @@ -216,8 +254,112 @@ impl SetupWizard { } } - /// Test database connection and store the pool. - async fn test_database_connection(&mut self, url: &str) -> Result<(), SetupError> { + /// Step 1 (libsql): Database connection via local file or Turso remote replica. + #[cfg(feature = "libsql")] + async fn step_database_libsql(&mut self) -> Result<(), SetupError> { + self.settings.database_backend = Some("libsql".to_string()); + + let default_path = crate::config::default_libsql_path(); + let default_path_str = default_path.to_string_lossy().to_string(); + + // Check for existing configuration + let existing_path = std::env::var("LIBSQL_PATH") + .ok() + .or_else(|| self.settings.libsql_path.clone()); + + if let Some(ref path) = existing_path { + print_info(&format!("Existing database path: {}", path)); + if confirm("Use this database?", true).map_err(SetupError::Io)? { + let turso_url = std::env::var("LIBSQL_URL").ok() + .or_else(|| self.settings.libsql_url.clone()); + let turso_token = std::env::var("LIBSQL_AUTH_TOKEN").ok(); + + match self + .test_database_connection_libsql(path, turso_url.as_deref(), turso_token.as_deref()) + .await + { + Ok(()) => { + print_success("Database connection successful"); + self.settings.libsql_path = Some(path.clone()); + if let Some(url) = turso_url { + self.settings.libsql_url = Some(url); + } + return Ok(()); + } + Err(e) => { + print_error(&format!("Connection failed: {}", e)); + print_info("Let's configure a new database path."); + } + } + } + } + + println!(); + print_info("IronClaw uses an embedded SQLite database (libSQL)."); + print_info("No external database server required."); + println!(); + + let path_input = optional_input( + "Database file path", + Some(&format!("default: {}", default_path_str)), + ) + .map_err(SetupError::Io)?; + + let db_path = path_input.unwrap_or(default_path_str.clone()); + + // Ask about Turso cloud sync + println!(); + let use_turso = confirm("Enable Turso cloud sync (remote replica)?", false) + .map_err(SetupError::Io)?; + + let (turso_url, turso_token) = if use_turso { + print_info("Enter your Turso database URL and auth token."); + print_info("Format: libsql://your-db.turso.io"); + println!(); + + let url = input("Turso URL").map_err(SetupError::Io)?; + if url.is_empty() { + print_error("Turso URL is required for cloud sync."); + (None, None) + } else { + let token = input("Auth token").map_err(SetupError::Io)?; + if token.is_empty() { + print_error("Auth token is required for cloud sync."); + (None, None) + } else { + (Some(url), Some(token)) + } + } + } else { + (None, None) + }; + + print_info("Testing connection..."); + match self + .test_database_connection_libsql(&db_path, turso_url.as_deref(), turso_token.as_deref()) + .await + { + Ok(()) => { + print_success("Database connection successful"); + + // Always run migrations for libsql (they're idempotent) + self.run_migrations_libsql().await?; + + self.settings.libsql_path = Some(db_path); + if let Some(url) = turso_url { + self.settings.libsql_url = Some(url); + } + return Ok(()); + } + Err(e) => { + return Err(SetupError::Database(format!("Connection failed: {}", e))); + } + } + } + + /// Test PostgreSQL connection and store the pool. + #[cfg(feature = "postgres")] + async fn test_database_connection_postgres(&mut self, url: &str) -> Result<(), SetupError> { let mut cfg = PoolConfig::new(); cfg.url = Some(url.to_string()); cfg.pool = Some(deadpool_postgres::PoolConfig { @@ -229,7 +371,6 @@ impl SetupWizard { .create_pool(Some(Runtime::Tokio1), NoTls) .map_err(|e| SetupError::Database(format!("Failed to create pool: {}", e)))?; - // Test the connection let _ = pool .get() .await @@ -239,8 +380,36 @@ impl SetupWizard { Ok(()) } - /// Run database migrations. - async fn run_migrations(&self) -> Result<(), SetupError> { + /// Test libSQL connection and store the backend. + #[cfg(feature = "libsql")] + async fn test_database_connection_libsql( + &mut self, + path: &str, + turso_url: Option<&str>, + turso_token: Option<&str>, + ) -> Result<(), SetupError> { + use crate::db::libsql_backend::LibSqlBackend; + use std::path::Path; + + let db_path = Path::new(path); + + let backend = if let (Some(url), Some(token)) = (turso_url, turso_token) { + LibSqlBackend::new_remote_replica(db_path, url, token) + .await + .map_err(|e| SetupError::Database(format!("Failed to connect: {}", e)))? + } else { + LibSqlBackend::new_local(db_path) + .await + .map_err(|e| SetupError::Database(format!("Failed to open database: {}", e)))? + }; + + self.db_backend = Some(backend); + Ok(()) + } + + /// Run PostgreSQL migrations. + #[cfg(feature = "postgres")] + async fn run_migrations_postgres(&self) -> Result<(), SetupError> { if let Some(ref pool) = self.db_pool { use refinery::embed_migrations; embed_migrations!("migrations"); @@ -262,6 +431,24 @@ impl SetupWizard { Ok(()) } + /// Run libSQL migrations. + #[cfg(feature = "libsql")] + async fn run_migrations_libsql(&self) -> Result<(), SetupError> { + if let Some(ref backend) = self.db_backend { + use crate::db::Database; + + print_info("Running migrations..."); + + backend + .run_migrations() + .await + .map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?; + + print_success("Migrations applied"); + } + Ok(()) + } + /// Step 2: Security (secrets master key). async fn step_security(&mut self) -> Result<(), SetupError> { // Check current configuration @@ -532,24 +719,6 @@ impl SetupWizard { /// Initialize secrets context for channel setup. async fn init_secrets_context(&mut self) -> Result { - // Get database pool (should be set from step 1) - let pool = if let Some(ref p) = self.db_pool { - p.clone() - } else { - // Fall back to creating one from settings/env - let url = self - .settings - .database_url - .clone() - .or_else(|| std::env::var("DATABASE_URL").ok()) - .ok_or_else(|| SetupError::Config("Database URL not configured".to_string()))?; - - self.test_database_connection(&url).await?; - // Ensure secrets-related tables exist for channels-only onboarding flows. - self.run_migrations().await?; - self.db_pool.clone().unwrap() - }; - // Get crypto (should be set from step 2, or load from keychain/env) let crypto = if let Some(ref c) = self.secrets_crypto { Arc::clone(c) @@ -571,7 +740,75 @@ impl SetupWizard { Arc::clone(self.secrets_crypto.as_ref().unwrap()) }; - Ok(SecretsContext::new(pool, crypto, "default")) + // Create backend-appropriate secrets store + #[cfg(feature = "postgres")] + { + // Try postgres path first when postgres feature is available + if let Some(store) = self.create_postgres_secrets_store(&crypto).await? { + return Ok(SecretsContext::from_store(store, "default")); + } + } + + #[cfg(feature = "libsql")] + { + if let Some(store) = self.create_libsql_secrets_store(&crypto)? { + return Ok(SecretsContext::from_store(store, "default")); + } + } + + Err(SetupError::Config( + "No database backend available for secrets storage".to_string(), + )) + } + + /// Create a PostgreSQL secrets store from the current pool. + #[cfg(feature = "postgres")] + async fn create_postgres_secrets_store( + &mut self, + crypto: &Arc, + ) -> Result>, SetupError> { + let pool = if let Some(ref p) = self.db_pool { + p.clone() + } else { + // Fall back to creating one from settings/env + let url = self + .settings + .database_url + .clone() + .or_else(|| std::env::var("DATABASE_URL").ok()); + + if let Some(url) = url { + self.test_database_connection_postgres(&url).await?; + self.run_migrations_postgres().await?; + self.db_pool.clone().unwrap() + } else { + return Ok(None); + } + }; + + let store: Arc = Arc::new( + crate::secrets::PostgresSecretsStore::new(pool, Arc::clone(crypto)), + ); + Ok(Some(store)) + } + + /// Create a libSQL secrets store from the current backend. + #[cfg(feature = "libsql")] + fn create_libsql_secrets_store( + &self, + crypto: &Arc, + ) -> Result>, SetupError> { + if let Some(ref backend) = self.db_backend { + let conn = backend + .connect() + .map_err(|e| SetupError::Database(format!("Failed to create connection: {}", e)))?; + let store: Arc = Arc::new( + crate::secrets::LibSqlSecretsStore::new(conn, Arc::clone(crypto)), + ); + Ok(Some(store)) + } else { + Ok(None) + } } /// Step 6: Channel configuration. @@ -797,8 +1034,27 @@ impl SetupWizard { println!("Configuration Summary:"); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - if self.settings.database_url.is_some() { - println!(" Database: configured"); + let backend = self + .settings + .database_backend + .as_deref() + .unwrap_or("postgres"); + match backend { + "libsql" => { + if let Some(ref path) = self.settings.libsql_path { + println!(" Database: libSQL ({})", path); + } else { + println!(" Database: libSQL (default path)"); + } + if self.settings.libsql_url.is_some() { + println!(" Turso sync: enabled"); + } + } + _ => { + if self.settings.database_url.is_some() { + println!(" Database: PostgreSQL (configured)"); + } + } } match self.settings.secrets_master_key_source {