From d771f99f9ed178c55a0aff85af427849a2073229 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 15 Feb 2026 00:12:01 -0800 Subject: [PATCH] fix: persist DATABASE_BACKEND to ~/.ironclaw/.env for libSQL startup The wizard saved database_backend only to the database, but Config::from_env() needs it BEFORE connecting to any database (to decide which backend to use). Without it, the backend defaults to Postgres and then fails with "Missing required setting database_url". Now save all database bootstrap vars (DATABASE_BACKEND, DATABASE_URL, LIBSQL_PATH, LIBSQL_URL) to ~/.ironclaw/.env via save_bootstrap_env(). Co-Authored-By: Claude Opus 4.6 --- src/bootstrap.rs | 81 ++++++++++++++++++++++++++++++++++++++++++--- src/setup/wizard.rs | 38 ++++++++++++++++----- 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/src/bootstrap.rs b/src/bootstrap.rs index 51b5935e..948d34b5 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -81,17 +81,34 @@ fn migrate_bootstrap_json_to_env(env_path: &std::path::Path) { } } -/// Write `DATABASE_URL` to `~/.ironclaw/.env`. +/// Write database bootstrap vars to `~/.ironclaw/.env`. +/// +/// These settings form the chicken-and-egg layer: they must be available +/// from the filesystem (env vars) BEFORE any database connection, because +/// they determine which database to connect to. Everything else is stored +/// in the database itself. /// /// Creates the parent directory if it doesn't exist. -/// The value is double-quoted so that `#` (common in URL-encoded passwords) +/// Values are double-quoted so that `#` (common in URL-encoded passwords) /// and other shell-special characters are preserved by dotenvy. -pub fn save_database_url(url: &str) -> std::io::Result<()> { +pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> { let path = ironclaw_env_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(&path, format!("DATABASE_URL=\"{}\"\n", url)) + let mut content = String::new(); + for (key, value) in vars { + content.push_str(&format!("{}=\"{}\"\n", key, value)); + } + std::fs::write(&path, content) +} + +/// Write `DATABASE_URL` to `~/.ironclaw/.env`. +/// +/// Convenience wrapper around `save_bootstrap_env` for single-value migration +/// paths. Prefer `save_bootstrap_env` for new code. +pub fn save_database_url(url: &str) -> std::io::Result<()> { + save_bootstrap_env(&[("DATABASE_URL", url)]) } /// One-time migration of legacy `~/.ironclaw/settings.json` into the database. @@ -385,4 +402,60 @@ mod tests { // Nothing should happen assert!(!env_path.exists()); } + + #[test] + fn test_save_bootstrap_env_multiple_vars() { + let dir = tempdir().unwrap(); + let env_path = dir.path().join("nested").join(".env"); + + std::fs::create_dir_all(env_path.parent().unwrap()).unwrap(); + + let vars = [ + ("DATABASE_BACKEND", "libsql"), + ("LIBSQL_PATH", "/home/user/.ironclaw/ironclaw.db"), + ]; + + // Write manually to the temp path (save_bootstrap_env uses the global path) + let mut content = String::new(); + for (key, value) in &vars { + content.push_str(&format!("{}=\"{}\"\n", key, value)); + } + std::fs::write(&env_path, &content).unwrap(); + + // Verify dotenvy can parse all entries + let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0], ("DATABASE_BACKEND".to_string(), "libsql".to_string())); + assert_eq!( + parsed[1], + ( + "LIBSQL_PATH".to_string(), + "/home/user/.ironclaw/ironclaw.db".to_string() + ) + ); + } + + #[test] + fn test_save_bootstrap_env_overwrites_previous() { + let dir = tempdir().unwrap(); + let env_path = dir.path().join(".env"); + + // Write initial content + std::fs::write(&env_path, "DATABASE_URL=\"postgres://old\"\n").unwrap(); + + // Overwrite with new vars (simulating save_bootstrap_env behavior) + let content = "DATABASE_BACKEND=\"libsql\"\nLIBSQL_PATH=\"/new/path.db\"\n"; + std::fs::write(&env_path, content).unwrap(); + + let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + // Old DATABASE_URL should be gone + assert_eq!(parsed.len(), 2); + assert!(parsed.iter().all(|(k, _)| k != "DATABASE_URL")); + } } diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 9a6096cc..63f955be 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -1505,15 +1505,35 @@ impl SetupWizard { } } - // Save DATABASE_URL to ~/.ironclaw/.env (the only field that needs - // disk persistence before the DB is available). - if let Some(ref url) = self.settings.database_url { - crate::bootstrap::save_database_url(url).map_err(|e| { - SetupError::Io(std::io::Error::other(format!( - "Failed to save DATABASE_URL to .env: {}", - e - ))) - })?; + // Persist database bootstrap vars to ~/.ironclaw/.env. + // These are the chicken-and-egg settings: we need them to decide + // which database to connect to, so they can't live in the database. + { + let mut env_vars: Vec<(&str, String)> = Vec::new(); + + if let Some(ref backend) = self.settings.database_backend { + env_vars.push(("DATABASE_BACKEND", backend.clone())); + } + if let Some(ref url) = self.settings.database_url { + env_vars.push(("DATABASE_URL", url.clone())); + } + if let Some(ref path) = self.settings.libsql_path { + env_vars.push(("LIBSQL_PATH", path.clone())); + } + if let Some(ref url) = self.settings.libsql_url { + env_vars.push(("LIBSQL_URL", url.clone())); + } + + if !env_vars.is_empty() { + let pairs: Vec<(&str, &str)> = + env_vars.iter().map(|(k, v)| (*k, v.as_str())).collect(); + crate::bootstrap::save_bootstrap_env(&pairs).map_err(|e| { + SetupError::Io(std::io::Error::other(format!( + "Failed to save bootstrap env to .env: {}", + e + ))) + })?; + } } println!();