fix: onboarding errors reset flow and remote server auth (#185, #186) (#248)

* fix: incremental settings persistence and remote server auth (#185, #186)

Persist settings after each wizard step so failures don't lose prior
progress. Load existing settings on re-run to recover from partial
onboarding. Add manual token paste option for remote/headless servers
where browser OAuth is unreachable, and support IRONCLAW_OAUTH_CALLBACK_URL
for custom callback URLs. Color prompt output (green/red/blue prefixes).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: replace session token paste with API key entry, address PR review

Replace option 4 in NEAR AI auth menu from session token paste to NEAR
AI Cloud API key entry (cloud.near.ai). Also address all PR review
feedback: restrict .env file permissions to 0o600, mask API key input
with secret_input, fix libsql loaded flag in try_load_existing_settings,
add ENV_MUTEX to oauth_defaults tests, and add NEARAI_API_KEY to secrets
injection.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: deduplicate keys in upsert_bootstrap_var

When the .env file contains duplicate keys (e.g. from manual editing),
only write the replacement once and skip subsequent duplicates.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: NEARAI_SESSION_TOKEN env var takes precedence over file-based tokens

Hosting providers inject session tokens via env var and expect them to
be used directly. Previously the env var was only picked up when no
session file existed and was treated as a legacy migration. Now the env
var always wins, without persisting to disk.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs: distinguish NEAR AI Chat and NEAR AI Cloud providers

Split documentation into two clearly named modes:
- NEAR AI Chat: Responses API at private.near.ai, session token auth
- NEAR AI Cloud: Chat Completions API at cloud-api.near.ai, API key auth

Update default base URLs so each mode points to its correct endpoint.
Update .env.example, deploy/env.example, CLAUDE.md, setup spec, and
code comments across config/llm.rs, nearai.rs, nearai_chat.rs, mod.rs.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: wizard recovery ordering — load DB before persist, fresh choices win

Previously, persist_after_step() ran after Step 1 but before
try_load_existing_settings(), bulk-upserting defaults that clobbered
prior settings. Additionally, merge_from gave stale DB values
precedence over fresh Step 1 choices.

Fix: snapshot Step 1 settings, load DB, then re-apply the snapshot.
This ensures prior progress (steps 2-7) is recovered while fresh
Step 1 choices override stale DB values.

Add two tests verifying wizard recovery merge ordering.

Addresses PR review comments from Copilot on wizard.rs:150,
wizard.rs:1607, and wizard.rs:1626.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix rustfmt formatting in config/llm.rs

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: collapse nested if per clippy collapsible_if lint

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: use print_success for API key confirmation, fix menu spacing

- Use print_success() for colored output consistency in api_key_login
- Fix box-drawing alignment: options 1-2 had an extra trailing space

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-20 08:02:22 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent bfe393eb38
commit 5725a62c83
15 changed files with 764 additions and 199 deletions
+17 -8
View File
@@ -2,18 +2,27 @@
DATABASE_URL=postgres://localhost/ironclaw
DATABASE_POOL_SIZE=10
# LLM Provider (NEAR AI)
# NEAR AI provides a unified interface to all models with user authentication
# Session token is stored in ~/.ironclaw/session.json and managed automatically.
# On first run, the agent will open a browser for OAuth authentication.
NEARAI_MODEL=claude-3-5-sonnet-20241022
# LLM Provider
# LLM_BACKEND=nearai # default
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil
# === NEAR AI Chat (Responses API, session token auth) ===
# Default mode. Uses browser OAuth (GitHub/Google) on first run.
# Session token stored in ~/.ironclaw/session.json automatically.
# For hosting providers: set NEARAI_SESSION_TOKEN env var directly.
NEARAI_MODEL=zai-org/GLM-5-FP8
NEARAI_BASE_URL=https://private.near.ai
NEARAI_AUTH_URL=https://private.near.ai
# NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown
# NEARAI_SESSION_TOKEN=sess_... # hosting providers: set this
# NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown
# === NEAR AI Cloud (Chat Completions API, API key auth) ===
# Auto-selected when NEARAI_API_KEY is set. Get a key from cloud.near.ai.
# NEARAI_API_KEY=...
# NEARAI_BASE_URL=https://cloud-api.near.ai # default for cloud mode
# NEARAI_API_MODE=chat_completions # auto-detected from API key
# Local LLM Providers (Ollama, LM Studio, vLLM, LiteLLM)
# LLM_BACKEND=nearai # default
# Possible values: nearai, ollama, openai_compatible, openai, anthropic
# === Ollama ===
# OLLAMA_MODEL=llama3.2
+10 -3
View File
@@ -339,9 +339,14 @@ LIBSQL_PATH=~/.ironclaw/ironclaw.db # libSQL local path (default)
# LIBSQL_AUTH_TOKEN=xxx # Required with LIBSQL_URL
# NEAR AI (when LLM_BACKEND=nearai, the default)
NEARAI_SESSION_TOKEN=sess_...
NEARAI_MODEL=claude-3-5-sonnet-20241022
# Two modes: "NEAR AI Chat" (session token) or "NEAR AI Cloud" (API key)
# NEAR AI Chat (Responses API, default):
NEARAI_SESSION_TOKEN=sess_... # session token for chat-api
NEARAI_BASE_URL=https://private.near.ai
# NEAR AI Cloud (Chat Completions API, auto-selected when API key is set):
# NEARAI_API_KEY=... # API key from cloud.near.ai
# NEARAI_BASE_URL=https://cloud-api.near.ai
NEARAI_MODEL=claude-3-5-sonnet-20241022
# Agent settings
AGENT_NAME=ironclaw
@@ -403,7 +408,9 @@ TINFOIL_MODEL=kimi-k2-5 # Default model
IronClaw supports multiple LLM backends via the `LLM_BACKEND` env var: `nearai` (default), `openai`, `anthropic`, `ollama`, `openai_compatible`, and `tinfoil`.
**NEAR AI** -- Uses the NEAR AI chat-api (`https://api.near.ai/v1/responses`) which provides unified access to multiple models, user authentication via session tokens (`sess_xxx`, 37 characters), and usage tracking/billing through NEAR AI.
**NEAR AI Chat** -- Uses the NEAR AI Responses API (`https://private.near.ai/v1/responses`). Authenticates with session tokens (`sess_xxx`) obtained via browser OAuth (GitHub/Google). Supports response chaining (delta-only follow-up messages) for efficient multi-turn conversations. This is the default mode when no `NEARAI_API_KEY` is set. Set `NEARAI_SESSION_TOKEN` env var for hosting providers that inject tokens via environment. Configure with `NEARAI_BASE_URL` (default: `https://private.near.ai`).
**NEAR AI Cloud** -- Uses the OpenAI-compatible Chat Completions API (`https://cloud-api.near.ai/v1/chat/completions`). Authenticates with API keys from `cloud.near.ai`. Auto-selected when `NEARAI_API_KEY` is set (or explicitly via `NEARAI_API_MODE=chat_completions`). Tool messages are flattened to plain text for compatibility. Configure with `NEARAI_API_KEY` and `NEARAI_BASE_URL` (default: `https://cloud-api.near.ai`).
**Tinfoil** -- Private inference via `https://inference.tinfoil.sh/v1`. Runs models inside hardware-attested TEEs so neither Tinfoil nor the cloud provider can see prompts or responses. Uses the OpenAI-compatible Chat Completions API only (not the Responses API, so tool calls are adapted to chat format). Configure with `TINFOIL_API_KEY` and `TINFOIL_MODEL` (default: `kimi-k2-5`).
+8 -5
View File
@@ -2,12 +2,15 @@
# Do not use placeholder passwords in production.
DATABASE_URL=postgres://ironclaw:CHANGE_ME@localhost:5432/ironclaw
# NEAR AI
NEARAI_SESSION_TOKEN=CHANGE_ME
# NEAR AI Cloud (API key auth, Chat Completions API)
# Get an API key from https://cloud.near.ai
NEARAI_API_KEY=CHANGE_ME
NEARAI_MODEL=claude-3-5-sonnet-20241022
NEARAI_BASE_URL=https://private.near.ai
NEARAI_AUTH_URL=https://private.near.ai
NEARAI_API_MODE=chat_completions
NEARAI_BASE_URL=https://cloud-api.near.ai
# Or use NEAR AI Chat (session token auth, Responses API):
# NEARAI_SESSION_TOKEN=sess_...
# NEARAI_BASE_URL=https://private.near.ai
# Agent
AGENT_NAME=ironclaw
+61 -1
View File
@@ -103,7 +103,67 @@ pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
content.push_str(&format!("{}=\"{}\"\n", key, escaped));
}
std::fs::write(&path, content)
std::fs::write(&path, &content)?;
restrict_file_permissions(&path)?;
Ok(())
}
/// Update or add a single variable in `~/.ironclaw/.env`, preserving existing content.
///
/// Unlike `save_bootstrap_env` (which overwrites the entire file), this
/// reads the current `.env`, replaces the line for `key` if it exists,
/// or appends it otherwise. Use this when writing a single bootstrap var
/// outside the wizard (which manages the full set via `save_bootstrap_env`).
pub fn upsert_bootstrap_var(key: &str, value: &str) -> std::io::Result<()> {
let path = ironclaw_env_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
let new_line = format!("{}=\"{}\"", key, escaped);
let prefix = format!("{}=", key);
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let mut found = false;
let mut result = String::new();
for line in existing.lines() {
if line.starts_with(&prefix) {
if !found {
result.push_str(&new_line);
result.push('\n');
found = true;
}
// Skip duplicate lines for this key
continue;
}
result.push_str(line);
result.push('\n');
}
if !found {
result.push_str(&new_line);
result.push('\n');
}
std::fs::write(&path, result)?;
restrict_file_permissions(&path)?;
Ok(())
}
/// Set restrictive file permissions (0o600) on Unix systems.
///
/// The `.env` file may contain database credentials and API keys,
/// so it should only be readable by the owner.
fn restrict_file_permissions(_path: &std::path::Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(_path, perms)?;
}
Ok(())
}
/// Write `DATABASE_URL` to `~/.ironclaw/.env`.
+60 -1
View File
@@ -62,6 +62,18 @@ pub fn builtin_credentials(secret_name: &str) -> Option<OAuthCredentials> {
/// `http://localhost:9876/callback` (or `/auth/callback` for NEAR AI).
pub const OAUTH_CALLBACK_PORT: u16 = 9876;
/// Returns the OAuth callback base URL.
///
/// Checks `IRONCLAW_OAUTH_CALLBACK_URL` env var first (useful for remote/VPS
/// deployments where `127.0.0.1` is unreachable from the user's browser),
/// then falls back to `http://127.0.0.1:{OAUTH_CALLBACK_PORT}`.
pub fn callback_url() -> String {
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| format!("http://127.0.0.1:{}", OAUTH_CALLBACK_PORT))
}
/// Error from the OAuth callback listener.
#[derive(Debug, thiserror::Error)]
pub enum OAuthCallbackError {
@@ -297,7 +309,54 @@ pub fn landing_html(provider_name: &str, success: bool) -> String {
#[cfg(test)]
mod tests {
use crate::cli::oauth_defaults::{builtin_credentials, landing_html};
use std::sync::Mutex;
use crate::cli::oauth_defaults::{builtin_credentials, callback_url, landing_html};
/// Serializes env-mutating tests to prevent parallel races.
static ENV_MUTEX: Mutex<()> = Mutex::new(());
#[test]
fn test_callback_url_default() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
// Clear the env var to test default behavior
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
}
let url = callback_url();
assert_eq!(url, "http://127.0.0.1:9876");
// Restore
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
}
}
}
#[test]
fn test_callback_url_env_override() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var(
"IRONCLAW_OAUTH_CALLBACK_URL",
"https://myserver.example.com:9876",
);
}
let url = callback_url();
assert_eq!(url, "https://myserver.example.com:9876");
// Restore
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
} else {
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
}
}
}
#[test]
fn test_unknown_provider_returns_none() {
+18 -8
View File
@@ -122,12 +122,15 @@ pub struct LlmConfig {
}
/// API mode for NEAR AI.
///
/// - `Responses` = **NEAR AI Chat** (`private.near.ai`, session token auth)
/// - `ChatCompletions` = **NEAR AI Cloud** (`cloud-api.near.ai`, API key auth)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NearAiApiMode {
/// Use the Responses API (chat-api proxy) - session-based auth
/// NEAR AI Chat: Responses API with session token auth
#[default]
Responses,
/// Use the Chat Completions API (cloud-api) - API key auth
/// NEAR AI Cloud: Chat Completions API with API key auth
ChatCompletions,
}
@@ -148,7 +151,7 @@ impl std::str::FromStr for NearAiApiMode {
}
}
/// NEAR AI chat-api configuration.
/// NEAR AI configuration (shared by Chat and Cloud modes).
#[derive(Debug, Clone)]
pub struct NearAiConfig {
/// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o")
@@ -156,15 +159,17 @@ pub struct NearAiConfig {
/// Cheap/fast model for lightweight tasks (heartbeat, routing, evaluation).
/// Falls back to the main model if not set.
pub cheap_model: Option<String>,
/// Base URL for the NEAR AI API (default: https://private.near.ai).
/// Base URL for the NEAR AI API.
/// Chat mode default: `https://private.near.ai`
/// Cloud mode default: `https://cloud-api.near.ai`
pub base_url: String,
/// Base URL for auth/refresh endpoints (default: https://private.near.ai)
pub auth_base_url: String,
/// Path to session file (default: ~/.ironclaw/session.json)
pub session_path: PathBuf,
/// API mode: "responses" (chat-api) or "chat_completions" (cloud-api)
/// API mode: NEAR AI Chat (Responses) or NEAR AI Cloud (ChatCompletions)
pub api_mode: NearAiApiMode,
/// API key for cloud-api (required for chat_completions mode)
/// API key for NEAR AI Cloud (required for ChatCompletions mode)
pub api_key: Option<SecretString>,
/// Optional fallback model for failover (default: None).
/// When set, a secondary provider is created with this model and wrapped
@@ -243,8 +248,13 @@ impl LlmConfig {
.to_string()
}),
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
base_url: optional_env("NEARAI_BASE_URL")?
.unwrap_or_else(|| "https://private.near.ai".to_string()),
base_url: optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
if api_mode == NearAiApiMode::ChatCompletions {
"https://cloud-api.near.ai".to_string()
} else {
"https://private.near.ai".to_string()
}
}),
auth_base_url: optional_env("NEARAI_AUTH_URL")?
.unwrap_or_else(|| "https://private.near.ai".to_string()),
session_path: optional_env("NEARAI_SESSION_PATH")?
+1
View File
@@ -219,6 +219,7 @@ pub async fn inject_llm_keys_from_secrets(
("llm_openai_api_key", "OPENAI_API_KEY"),
("llm_anthropic_api_key", "ANTHROPIC_API_KEY"),
("llm_compatible_api_key", "LLM_API_KEY"),
("llm_nearai_api_key", "NEARAI_API_KEY"),
];
let mut injected = HashMap::new();
+4 -2
View File
@@ -75,14 +75,16 @@ pub fn create_llm_provider_with_config(
NearAiApiMode::Responses => {
tracing::info!(
model = %config.model,
"Using Responses API (chat-api) with session auth"
base_url = %config.base_url,
"Using NEAR AI Chat (Responses API, session token auth)"
);
Ok(Arc::new(NearAiProvider::new(config.clone(), session)?))
}
NearAiApiMode::ChatCompletions => {
tracing::info!(
model = %config.model,
"Using Chat Completions API (cloud-api) with API key auth"
base_url = %config.base_url,
"Using NEAR AI Cloud (Chat Completions API, API key auth)"
);
Ok(Arc::new(NearAiChatProvider::new(config.clone())?))
}
+5 -3
View File
@@ -1,7 +1,9 @@
//! NEAR AI Chat API provider implementation.
//! NEAR AI Chat provider implementation (Responses API).
//!
//! This provider uses the NEAR AI chat-api which provides a unified interface
//! to multiple LLM models (OpenAI, Anthropic, etc.) with user authentication.
//! This provider uses the NEAR AI Responses API (`private.near.ai`) which
//! provides a unified interface to multiple LLM models with session token
//! authentication. Supports response chaining for efficient multi-turn
//! conversations.
use std::collections::HashMap;
use std::sync::Arc;
+7 -6
View File
@@ -1,7 +1,8 @@
//! NEAR AI Chat Completions API provider implementation.
//! NEAR AI Cloud provider implementation (Chat Completions API).
//!
//! This provider uses the standard OpenAI-compatible chat completions API
//! with API key authentication (for cloud-api).
//! This provider uses the NEAR AI Cloud API (`cloud-api.near.ai`) which
//! exposes an OpenAI-compatible chat completions endpoint with API key
//! authentication.
use async_trait::async_trait;
use reqwest::Client;
@@ -17,7 +18,7 @@ use crate::llm::provider::{
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
};
/// NEAR AI Chat Completions API provider.
/// NEAR AI Cloud provider (Chat Completions API, API key auth).
pub struct NearAiChatProvider {
client: Client,
config: NearAiConfig,
@@ -26,10 +27,10 @@ pub struct NearAiChatProvider {
}
impl NearAiChatProvider {
/// Create a new NEAR AI chat completions provider with API key auth.
/// Create a new NEAR AI Cloud provider with API key auth.
///
/// By default this enables tool-message flattening for compatibility with
/// providers that reject `role: "tool"` messages (e.g. NEAR cloud-api).
/// providers that reject `role: "tool"` messages.
pub fn new(config: NearAiConfig) -> Result<Self, LlmError> {
Self::new_with_flatten(config, true)
}
+120 -43
View File
@@ -217,38 +217,43 @@ impl SessionManager {
self.initiate_login().await
}
/// Start the OAuth login flow.
/// Start the login flow.
///
/// 1. Bind the fixed callback port
/// Shows the auth method menu FIRST (before binding any listener), so
/// that the API-key path can skip network binding entirely. This is
/// important for remote/headless servers where `127.0.0.1` is
/// unreachable from the user's browser.
///
/// For OAuth paths (GitHub, Google):
/// 1. Bind the callback listener
/// 2. Print the auth URL and attempt to open browser
/// 3. Wait for OAuth callback with session token
/// 4. Save and return the token
///
/// For NEAR AI Cloud API key:
/// 1. Prompt user for API key from cloud.near.ai
/// 2. Set NEARAI_API_KEY env var and save to bootstrap .env
/// 3. No session token saved (different auth model)
async fn initiate_login(&self) -> Result<(), LlmError> {
use crate::cli::oauth_defaults::{self, OAUTH_CALLBACK_PORT};
use crate::cli::oauth_defaults;
let listener = oauth_defaults::bind_callback_listener()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: e.to_string(),
})?;
let cb_url = oauth_defaults::callback_url();
let callback_url = format!("http://127.0.0.1:{}", OAUTH_CALLBACK_PORT);
// Show auth provider menu
// Show auth provider menu BEFORE binding the listener
println!();
println!("╔════════════════════════════════════════════════════════════════╗");
println!("║ NEAR AI Authentication ║");
println!("╠════════════════════════════════════════════════════════════════╣");
println!("║ Choose an authentication method: ║");
println!("║ ║");
println!("║ [1] GitHub ");
println!("║ [2] Google ");
println!("║ [1] GitHub (requires localhost browser access)");
println!("║ [2] Google (requires localhost browser access)");
println!("║ [3] NEAR Wallet (coming soon) ║");
println!("║ [4] NEAR AI Cloud API key ║");
println!("║ ║");
println!("╚════════════════════════════════════════════════════════════════╝");
println!();
print!("Enter choice [1-3]: ");
print!("Enter choice [1-4]: ");
// Flush stdout to ensure prompt is displayed
use std::io::Write;
@@ -263,23 +268,8 @@ impl SessionManager {
reason: format!("Failed to read input: {}", e),
})?;
let (auth_provider, auth_url) = match choice.trim() {
"1" | "" => {
let url = format!(
"{}/v1/auth/github?frontend_callback={}",
self.config.auth_base_url,
urlencoding::encode(&callback_url)
);
("github", url)
}
"2" => {
let url = format!(
"{}/v1/auth/google?frontend_callback={}",
self.config.auth_base_url,
urlencoding::encode(&callback_url)
);
("google", url)
}
match choice.trim() {
"4" => return self.api_key_login().await,
"3" => {
println!();
println!("NEAR Wallet authentication is not yet implemented.");
@@ -289,12 +279,41 @@ impl SessionManager {
reason: "NEAR Wallet auth not yet implemented".to_string(),
});
}
_ => {
"1" | "" | "2" => {} // handled below after listener bind
other => {
return Err(LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("Invalid choice: {}", choice.trim()),
reason: format!("Invalid choice: {}", other),
});
}
}
// OAuth paths: bind the callback listener now
let listener = oauth_defaults::bind_callback_listener()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: e.to_string(),
})?;
let (auth_provider, auth_url) = match choice.trim() {
"2" => {
let url = format!(
"{}/v1/auth/google?frontend_callback={}",
self.config.auth_base_url,
urlencoding::encode(&cb_url)
);
("google", url)
}
_ => {
// "1" or "" (default)
let url = format!(
"{}/v1/auth/github?frontend_callback={}",
self.config.auth_base_url,
urlencoding::encode(&cb_url)
);
("github", url)
}
};
println!();
@@ -341,6 +360,63 @@ impl SessionManager {
Ok(())
}
/// NEAR AI Cloud API key entry flow.
///
/// Prompts the user to enter a NEAR AI Cloud API key from
/// cloud.near.ai. The key is set as `NEARAI_API_KEY` env var so
/// `LlmConfig::resolve()` auto-selects ChatCompletions mode, and
/// saved to `~/.ironclaw/.env` for persistence across restarts.
/// No session token is saved and no `/v1/users/me` validation is
/// performed (different auth model).
async fn api_key_login(&self) -> Result<(), LlmError> {
println!();
println!("NEAR AI Cloud API key");
println!("─────────────────────");
println!();
println!(" 1. Open https://cloud.near.ai in your browser");
println!(" 2. Sign in and navigate to API Keys");
println!(" 3. Create or copy an existing API key");
println!();
let key_secret =
crate::setup::secret_input("API key").map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("Failed to read input: {}", e),
})?;
use secrecy::ExposeSecret;
let key = key_secret.expose_secret().to_string();
if key.is_empty() {
return Err(LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: "API key cannot be empty".to_string(),
});
}
// Set env var so Config picks it up immediately
// (LlmConfig::resolve() auto-selects ChatCompletions mode when
// NEARAI_API_KEY is present).
//
// SAFETY: called during single-threaded interactive login flow.
#[allow(unused_unsafe)]
unsafe {
std::env::set_var("NEARAI_API_KEY", &key);
}
// Persist to ~/.ironclaw/.env so the key survives restarts
// (bootstrap layer — available before DB is connected).
// Uses upsert to avoid clobbering existing bootstrap vars.
if let Err(e) = crate::bootstrap::upsert_bootstrap_var("NEARAI_API_KEY", &key) {
tracing::warn!("Failed to save API key to bootstrap .env: {}", e);
}
println!();
crate::setup::print_success("NEAR AI Cloud API key saved.");
println!();
Ok(())
}
/// Save session data to disk and (if available) to the database.
async fn save_session(&self, token: &str, auth_provider: Option<&str>) -> Result<(), LlmError> {
let session = SessionData {
@@ -508,20 +584,21 @@ impl SessionManager {
}
}
/// Create a session manager from a config, migrating from env var if present.
/// Create a session manager from a config, loading env var if present.
///
/// When `NEARAI_SESSION_TOKEN` is set, it takes precedence over file-based
/// tokens. This supports hosting providers that inject the token via env var.
pub async fn create_session_manager(config: SessionConfig) -> Arc<SessionManager> {
let manager = SessionManager::new_async(config).await;
// Check for legacy env var and migrate if present and no file token
if !manager.has_token().await
&& let Ok(token) = std::env::var("NEARAI_SESSION_TOKEN")
// NEARAI_SESSION_TOKEN env var always takes precedence over file-based
// tokens. Hosting providers set this env var and expect it to be used
// directly — no file persistence needed.
if let Ok(token) = std::env::var("NEARAI_SESSION_TOKEN")
&& !token.is_empty()
{
tracing::info!("Migrating session token from NEARAI_SESSION_TOKEN env var to file");
manager.set_token(SecretString::from(token.clone())).await;
if let Err(e) = manager.save_session(&token, None).await {
tracing::warn!("Failed to save migrated session: {}", e);
}
tracing::info!("Using session token from NEARAI_SESSION_TOKEN env var");
manager.set_token(SecretString::from(token)).await;
}
Arc::new(manager)
+103
View File
@@ -1229,4 +1229,107 @@ mod tests {
assert_eq!(s.tunnel.cf_token, Some("cf_tok_xyz".to_string()));
assert!(s.tunnel.ts_funnel);
}
/// Simulates the wizard recovery scenario:
///
/// 1. A prior partial run saved steps 1-4 to the DB
/// 2. User re-runs the wizard, Step 1 sets a new database_url
/// 3. Prior settings are loaded from the DB
/// 4. Step 1's fresh choices must win over stale DB values
///
/// This tests the ordering: load DB → merge_from(step1_overrides).
#[test]
fn wizard_recovery_step1_overrides_stale_db() {
// Simulate prior partial run (steps 1-4 completed):
let prior_run = Settings {
database_backend: Some("postgres".to_string()),
database_url: Some("postgres://old-host/ironclaw".to_string()),
llm_backend: Some("anthropic".to_string()),
selected_model: Some("claude-sonnet-4-5".to_string()),
embeddings: EmbeddingsSettings {
enabled: true,
provider: "openai".to_string(),
..Default::default()
},
..Default::default()
};
// Save to DB and reload (simulates persistence round-trip)
let db_map = prior_run.to_db_map();
let from_db = Settings::from_db_map(&db_map);
// Step 1 of the new wizard run: user enters a NEW database_url
let mut step1_settings = Settings::default();
step1_settings.database_backend = Some("postgres".to_string());
step1_settings.database_url = Some("postgres://new-host/ironclaw".to_string());
// Wizard flow: load DB → merge_from(step1_overrides)
let mut current = step1_settings.clone();
// try_load_existing_settings: merge DB into current
current.merge_from(&from_db);
// Re-apply Step 1 choices on top
current.merge_from(&step1_settings);
// Step 1's fresh database_url wins over stale DB value
assert_eq!(
current.database_url,
Some("postgres://new-host/ironclaw".to_string()),
"Step 1 fresh choice must override stale DB value"
);
// Prior run's steps 2-4 settings are preserved
assert_eq!(
current.llm_backend,
Some("anthropic".to_string()),
"Prior run's LLM backend must be recovered"
);
assert_eq!(
current.selected_model,
Some("claude-sonnet-4-5".to_string()),
"Prior run's model must be recovered"
);
assert!(
current.embeddings.enabled,
"Prior run's embeddings setting must be recovered"
);
}
/// Verifies that persisting defaults doesn't clobber prior settings
/// when the merge ordering is correct.
#[test]
fn wizard_recovery_defaults_dont_clobber_prior() {
// Prior run saved non-default settings
let prior_run = Settings {
llm_backend: Some("openai".to_string()),
selected_model: Some("gpt-4o".to_string()),
heartbeat: HeartbeatSettings {
enabled: true,
interval_secs: 900,
..Default::default()
},
..Default::default()
};
let db_map = prior_run.to_db_map();
let from_db = Settings::from_db_map(&db_map);
// New wizard run: Step 1 only sets DB fields (rest is default)
let step1 = Settings {
database_backend: Some("libsql".to_string()),
..Default::default()
};
// Correct merge ordering
let mut current = step1.clone();
current.merge_from(&from_db);
current.merge_from(&step1);
// Prior settings preserved (Step 1 doesn't touch these)
assert_eq!(current.llm_backend, Some("openai".to_string()));
assert_eq!(current.selected_model, Some("gpt-4o".to_string()));
assert!(current.heartbeat.enabled);
assert_eq!(current.heartbeat.interval_secs, 900);
// Step 1's choice applied
assert_eq!(current.database_backend, Some("libsql".to_string()));
}
}
+86 -16
View File
@@ -167,7 +167,8 @@ env-var mode or skipped secrets.
| Provider | Auth Method | Secret Name | Env Var |
|----------|-------------|-------------|---------|
| NEAR AI | Browser OAuth | (session token) | `NEARAI_SESSION_TOKEN` |
| NEAR AI Chat | Browser OAuth or session token | - | `NEARAI_SESSION_TOKEN` |
| NEAR AI Cloud | API key | `llm_nearai_api_key` | `NEARAI_API_KEY` |
| Anthropic | API key | `anthropic_api_key` | `ANTHROPIC_API_KEY` |
| OpenAI | API key | `openai_api_key` | `OPENAI_API_KEY` |
| Ollama | None | - | - |
@@ -180,8 +181,18 @@ env-var mode or skipped secrets.
4. **Cache key in `self.llm_api_key`** for model fetching in Step 4
**NEAR AI** (`setup_nearai`):
- Calls `session_manager.ensure_authenticated()` which opens browser
- Session token saved to `~/.ironclaw/session.json`
- Calls `session_manager.ensure_authenticated()` which shows the auth menu:
- Options 1-2 (GitHub/Google): browser OAuth → **NEAR AI Chat** mode
(Responses API at `private.near.ai`, session token auth)
- Option 4: NEAR AI Cloud API key → **NEAR AI Cloud** mode
(Chat Completions API at `cloud-api.near.ai`, API key auth)
- **NEAR AI Chat** path: session token saved to `~/.ironclaw/session.json`.
Hosting providers can set `NEARAI_SESSION_TOKEN` env var directly (takes
precedence over file-based tokens).
- **NEAR AI Cloud** path: `NEARAI_API_KEY` saved to `~/.ironclaw/.env`
(bootstrap) and encrypted secrets store (`llm_nearai_api_key`).
`LlmConfig::resolve()` auto-selects `ChatCompletions` mode when the
API key is present.
**`self.llm_api_key` caching:** The wizard caches the API key as
`Option<SecretString>` so that Step 4 (model fetching) and Step 5
@@ -372,25 +383,60 @@ heartbeat.enabled = "true"
heartbeat.interval_secs = "300"
```
### Incremental Persistence
Settings are persisted **after every successful step**, not just at the end.
This prevents data loss if a later step fails (e.g., the user enters an
API key in step 3 but step 5 crashes — they won't need to re-enter it).
**`persist_after_step()`** is called after each step in `run()` and:
1. Writes bootstrap vars to `~/.ironclaw/.env` via `write_bootstrap_env()`
2. Writes all current settings to the database via `persist_settings()`
3. Silently ignores errors (e.g., if called before Step 1 establishes a DB)
**`try_load_existing_settings()`** is called after Step 1 establishes a
database connection. It loads any previously saved settings from the
database using `get_all_settings("default")``Settings::from_db_map()`
`merge_from()`. This recovers progress from prior partial wizard runs.
**Ordering after Step 1 is critical:**
```
step_database() → sets DB fields in self.settings
let step1 = self.settings.clone() → snapshot Step 1 choices
try_load_existing_settings() → merge DB values into self.settings
self.settings.merge_from(&step1) → re-apply Step 1 (fresh wins over stale)
persist_after_step() → save merged state
```
This ordering ensures:
- Prior progress (steps 2-7 from a previous partial run) is recovered
- Fresh Step 1 choices override stale DB values (not the reverse)
- The first DB persist doesn't clobber prior settings with defaults
### save_and_summarize()
Final step of the wizard:
```
1. Mark onboard_completed = true
2. Write ALL settings to database (try postgres pool, then libSQL backend)
3. Write bootstrap vars to ~/.ironclaw/.env:
- DATABASE_BACKEND (always)
- DATABASE_URL (if postgres)
- LIBSQL_PATH (if libsql)
- LIBSQL_URL (if turso sync)
- LLM_BACKEND (always, when set)
- LLM_BASE_URL (if openai_compatible)
- OLLAMA_BASE_URL (if ollama)
- ONBOARD_COMPLETED (always, "true")
2. Call persist_settings() for final write (idempotent — ensures
onboard_completed flag is saved)
3. Call write_bootstrap_env() for final .env write (idempotent)
4. Print configuration summary
```
Bootstrap vars written to `~/.ironclaw/.env`:
- `DATABASE_BACKEND` (always)
- `DATABASE_URL` (if postgres)
- `LIBSQL_PATH` (if libsql)
- `LIBSQL_URL` (if turso sync)
- `LLM_BACKEND` (always, when set)
- `LLM_BASE_URL` (if openai_compatible)
- `OLLAMA_BASE_URL` (if ollama)
- `NEARAI_API_KEY` (if API key auth path)
- `ONBOARD_COMPLETED` (always, "true")
**Invariant:** Both Layer 1 and Layer 2 must be written. If the database
write fails, the wizard returns an error and the `.env` file is not written.
@@ -498,9 +544,9 @@ anthropic_api_key → encrypted API key
| `confirm(label, default)` | `[Y/n]` or `[y/N]` prompt |
| `print_header(text)` | Bold section header with underline |
| `print_step(n, total, text)` | `[1/7] Step Name` |
| `print_success(text)` | Green checkmark prefix |
| `print_error(text)` | Red X prefix |
| `print_info(text)` | Blue info prefix |
| `print_success(text)` | Green `✓` prefix (ANSI color), message in default color |
| `print_error(text)` | Red `✗` prefix (ANSI color), message in default color |
| `print_info(text)` | Blue `` prefix (ANSI color), message in default color |
`select_many` uses `crossterm` raw mode for arrow key navigation.
Must properly restore terminal state on all exit paths.
@@ -523,6 +569,30 @@ Must properly restore terminal state on all exit paths.
- May need `gnome-keyring` daemon running
- Collection unlock may prompt for password
### Remote Server Authentication
On remote/VPS servers, the browser-based OAuth flow for NEAR AI may not
work because `http://127.0.0.1:9876` is unreachable from the user's
local browser.
**Solutions:**
1. **NEAR AI Cloud API key (option 4 in auth menu):** Get an API key
from `https://cloud.near.ai` and paste it into the terminal. No
local listener is needed. The key is saved to `~/.ironclaw/.env`
and the encrypted secrets store. Uses the OpenAI-compatible
ChatCompletions API mode.
2. **Custom callback URL:** Set `IRONCLAW_OAUTH_CALLBACK_URL` to a
publicly accessible URL (e.g., via SSH tunnel or reverse proxy) that
forwards to port 9876 on the server:
```bash
export IRONCLAW_OAUTH_CALLBACK_URL=https://myserver.example.com:9876
```
The `callback_url()` function in `oauth_defaults.rs` checks this env var
and falls back to `http://127.0.0.1:{OAUTH_CALLBACK_PORT}`.
### URL Passwords
- `#` is common in URL-encoded passwords (`%23` decoded)
+29 -6
View File
@@ -293,19 +293,31 @@ pub fn print_step(current: usize, total: usize, name: &str) {
println!();
}
/// Print a success message with checkmark.
/// Print a success message with green checkmark.
pub fn print_success(message: &str) {
println!("{}", message);
let mut stdout = io::stdout();
let _ = execute!(stdout, SetForegroundColor(Color::Green));
print!("");
let _ = execute!(stdout, ResetColor);
println!(" {}", message);
}
/// Print an error message.
/// Print an error message with red X.
pub fn print_error(message: &str) {
eprintln!("{}", message);
let mut stderr = io::stderr();
let _ = execute!(stderr, SetForegroundColor(Color::Red));
eprint!("");
let _ = execute!(stderr, ResetColor);
eprintln!(" {}", message);
}
/// Print an info message.
/// Print an info message with blue info icon.
pub fn print_info(message: &str) {
println!(" {}", message);
let mut stdout = io::stdout();
let _ = execute!(stdout, SetForegroundColor(Color::Blue));
print!("");
let _ = execute!(stdout, ResetColor);
println!(" {}", message);
}
/// Read a simple line of input with a prompt.
@@ -358,4 +370,15 @@ mod tests {
super::print_step(1, 3, "Test Step");
super::print_step(3, 3, "Final Step");
}
#[test]
fn test_print_functions_do_not_panic() {
super::print_success("operation completed");
super::print_error("something went wrong");
super::print_info("here is some information");
// Also test with empty strings
super::print_success("");
super::print_error("");
super::print_info("");
}
}
+235 -97
View File
@@ -125,6 +125,11 @@ impl SetupWizard {
}
/// Run the setup wizard.
///
/// Settings are persisted incrementally after each successful step so
/// that progress is not lost if a later step fails. On re-run, existing
/// settings are loaded from the database after Step 1 establishes a
/// connection, so users don't have to re-enter everything.
pub async fn run(&mut self) -> Result<(), SetupError> {
print_header("IronClaw Setup Wizard");
@@ -141,9 +146,22 @@ impl SetupWizard {
print_step(1, total_steps, "Database Connection");
self.step_database().await?;
// After establishing a DB connection, load any previously saved
// settings so we recover progress from prior partial runs.
// We must load BEFORE persisting, otherwise persist_after_step()
// would overwrite prior settings with defaults.
// Save Step 1 choices first so they aren't clobbered by stale
// DB values (merge_from only applies non-default fields).
let step1_settings = self.settings.clone();
self.try_load_existing_settings().await;
self.settings.merge_from(&step1_settings);
self.persist_after_step().await;
// Step 2: Security
print_step(2, total_steps, "Security");
self.step_security().await?;
self.persist_after_step().await;
// Step 3: Inference provider selection (unless skipped)
if !self.config.skip_auth {
@@ -152,18 +170,22 @@ impl SetupWizard {
} else {
print_info("Skipping inference provider setup (using existing config)");
}
self.persist_after_step().await;
// Step 4: Model selection
print_step(4, total_steps, "Model Selection");
self.step_model_selection().await?;
self.persist_after_step().await;
// Step 5: Embeddings
print_step(5, total_steps, "Embeddings (Semantic Search)");
self.step_embeddings()?;
self.persist_after_step().await;
// Step 6: Channel configuration
print_step(6, total_steps, "Channel Configuration");
self.step_channels().await?;
self.persist_after_step().await;
// Step 7: Extensions (tools)
print_step(7, total_steps, "Extensions");
@@ -172,6 +194,7 @@ impl SetupWizard {
// Step 8: Heartbeat
print_step(8, total_steps, "Background Tasks");
self.step_heartbeat()?;
self.persist_after_step().await;
}
// Save settings and print summary
@@ -802,6 +825,20 @@ impl SetupWizard {
.map_err(|e| SetupError::Auth(e.to_string()))?;
self.session_manager = Some(session);
// If the user chose the API key path, NEARAI_API_KEY is now set
// in the environment. Persist it to the encrypted secrets store
// so inject_llm_keys_from_secrets() can load it on future runs.
if let Ok(api_key) = std::env::var("NEARAI_API_KEY")
&& !api_key.is_empty()
&& let Ok(ctx) = self.init_secrets_context().await
{
let key = SecretString::from(api_key);
if let Err(e) = ctx.save_secret("llm_nearai_api_key", &key).await {
tracing::warn!("Failed to persist NEARAI_API_KEY to secrets: {}", e);
}
}
print_success("NEAR AI configured");
Ok(())
}
@@ -1719,110 +1756,211 @@ impl SetupWizard {
Ok(())
}
/// Persist current settings to the database.
///
/// Returns `Ok(true)` if settings were saved, `Ok(false)` if no database
/// connection is available yet (e.g., before Step 1 completes).
async fn persist_settings(&self) -> Result<bool, SetupError> {
let db_map = self.settings.to_db_map();
let saved = false;
#[cfg(feature = "postgres")]
let saved = if !saved {
if let Some(ref pool) = self.db_pool {
let store = crate::history::Store::from_pool(pool.clone());
store
.set_all_settings("default", &db_map)
.await
.map_err(|e| {
SetupError::Database(format!("Failed to save settings to database: {}", e))
})?;
true
} else {
false
}
} else {
saved
};
#[cfg(feature = "libsql")]
let saved = if !saved {
if let Some(ref backend) = self.db_backend {
use crate::db::SettingsStore as _;
backend
.set_all_settings("default", &db_map)
.await
.map_err(|e| {
SetupError::Database(format!("Failed to save settings to database: {}", e))
})?;
true
} else {
false
}
} else {
saved
};
Ok(saved)
}
/// Write bootstrap environment variables to `~/.ironclaw/.env`.
///
/// These are the chicken-and-egg settings needed before the database is
/// connected (DATABASE_BACKEND, DATABASE_URL, LLM_BACKEND, etc.).
fn write_bootstrap_env(&self) -> Result<(), SetupError> {
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()));
}
// LLM bootstrap vars: same chicken-and-egg problem as DATABASE_BACKEND.
// Config::from_env() needs the backend before the DB is connected.
if let Some(ref backend) = self.settings.llm_backend {
env_vars.push(("LLM_BACKEND", backend.clone()));
}
if let Some(ref url) = self.settings.openai_compatible_base_url {
env_vars.push(("LLM_BASE_URL", url.clone()));
}
if let Some(ref url) = self.settings.ollama_base_url {
env_vars.push(("OLLAMA_BASE_URL", url.clone()));
}
// Preserve NEARAI_API_KEY if present (set by API key auth flow)
if let Ok(api_key) = std::env::var("NEARAI_API_KEY")
&& !api_key.is_empty()
{
env_vars.push(("NEARAI_API_KEY", api_key));
}
// Always write ONBOARD_COMPLETED so that check_onboard_needed()
// (which runs before the DB is connected) knows to skip re-onboarding.
if self.settings.onboard_completed {
env_vars.push(("ONBOARD_COMPLETED", "true".to_string()));
}
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
)))
})?;
}
Ok(())
}
/// Persist settings to DB and bootstrap .env after each step.
///
/// Silently ignores errors (e.g., DB not connected yet before step 1
/// completes). This is best-effort incremental persistence.
async fn persist_after_step(&self) {
// Write bootstrap .env (always possible)
if let Err(e) = self.write_bootstrap_env() {
tracing::debug!("Could not write bootstrap env after step: {}", e);
}
// Persist to DB
match self.persist_settings().await {
Ok(true) => tracing::debug!("Settings persisted to database after step"),
Ok(false) => tracing::debug!("No DB connection yet, skipping settings persist"),
Err(e) => tracing::debug!("Could not persist settings after step: {}", e),
}
}
/// Load previously saved settings from the database after Step 1
/// establishes a connection.
///
/// This enables recovery from partial onboarding runs: if the user
/// completed steps 1-4 previously but step 5 failed, re-running
/// the wizard will pre-populate settings from the database.
///
/// **Callers must re-apply any wizard choices made before this call**
/// via `self.settings.merge_from(&step_settings)`, since `merge_from`
/// prefers the `other` argument's non-default values. Without this,
/// stale DB values would overwrite fresh user choices.
async fn try_load_existing_settings(&mut self) {
let loaded = false;
#[cfg(feature = "postgres")]
let loaded = if !loaded {
if let Some(ref pool) = self.db_pool {
let store = crate::history::Store::from_pool(pool.clone());
match store.get_all_settings("default").await {
Ok(db_map) if !db_map.is_empty() => {
let existing = Settings::from_db_map(&db_map);
self.settings.merge_from(&existing);
tracing::info!("Loaded {} existing settings from database", db_map.len());
true
}
Ok(_) => false,
Err(e) => {
tracing::debug!("Could not load existing settings: {}", e);
false
}
}
} else {
false
}
} else {
loaded
};
#[cfg(feature = "libsql")]
let loaded = if !loaded {
if let Some(ref backend) = self.db_backend {
use crate::db::SettingsStore as _;
match backend.get_all_settings("default").await {
Ok(db_map) if !db_map.is_empty() => {
let existing = Settings::from_db_map(&db_map);
self.settings.merge_from(&existing);
tracing::info!("Loaded {} existing settings from database", db_map.len());
true
}
Ok(_) => false,
Err(e) => {
tracing::debug!("Could not load existing settings: {}", e);
false
}
}
} else {
false
}
} else {
loaded
};
// Suppress unused variable warning when only one backend is compiled.
let _ = loaded;
}
/// Save settings to the database and `~/.ironclaw/.env`, then print summary.
async fn save_and_summarize(&mut self) -> Result<(), SetupError> {
self.settings.onboard_completed = true;
// Write all settings to the database (whichever backend is active).
{
let db_map = self.settings.to_db_map();
let saved = false;
// Final persist (idempotent — earlier incremental saves already wrote
// most settings, but this ensures onboard_completed is saved).
let saved = self.persist_settings().await?;
#[cfg(feature = "postgres")]
let saved = if !saved {
if let Some(ref pool) = self.db_pool {
let store = crate::history::Store::from_pool(pool.clone());
store
.set_all_settings("default", &db_map)
.await
.map_err(|e| {
SetupError::Database(format!(
"Failed to save settings to database: {}",
e
))
})?;
true
} else {
false
}
} else {
saved
};
#[cfg(feature = "libsql")]
let saved = if !saved {
if let Some(ref backend) = self.db_backend {
use crate::db::SettingsStore as _;
backend
.set_all_settings("default", &db_map)
.await
.map_err(|e| {
SetupError::Database(format!(
"Failed to save settings to database: {}",
e
))
})?;
true
} else {
false
}
} else {
saved
};
if !saved {
return Err(SetupError::Database(
"No database connection, cannot save settings".to_string(),
));
}
if !saved {
return Err(SetupError::Database(
"No database connection, cannot save settings".to_string(),
));
}
// 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()));
}
// LLM bootstrap vars: same chicken-and-egg problem as DATABASE_BACKEND.
// Config::from_env() needs the backend before the DB is connected.
if let Some(ref backend) = self.settings.llm_backend {
env_vars.push(("LLM_BACKEND", backend.clone()));
}
if let Some(ref url) = self.settings.openai_compatible_base_url {
env_vars.push(("LLM_BASE_URL", url.clone()));
}
if let Some(ref url) = self.settings.ollama_base_url {
env_vars.push(("OLLAMA_BASE_URL", url.clone()));
}
// Always write ONBOARD_COMPLETED so that check_onboard_needed()
// (which runs before the DB is connected) knows to skip re-onboarding.
env_vars.push(("ONBOARD_COMPLETED", "true".to_string()));
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
)))
})?;
}
}
// Write bootstrap env (also idempotent)
self.write_bootstrap_env()?;
println!();
print_success("Configuration saved to database");