feat(setup): quick onboarding, preserve .env vars, clean up boot logging (#821)

* feat(setup): quick onboarding, preserve .env vars, clean up boot logging (#751, #674)

- Add upsert_bootstrap_vars() to preserve user-added .env vars on re-onboarding
- Add --quick mode: auto-defaults DB + security, asks only LLM provider (2 steps)
- Auto-triggered onboarding uses quick mode for near-instant first run
- Fix NEAR AI model fetch to use cloud-api.near.ai when API key is set
- Handle missing WASM tools/channels directories gracefully
- Downgrade all boot/shutdown tracing::info! to debug (boot screen shows user output)

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

* fix(setup): gate env_backend variable behind postgres feature flag [skip-regression-check]

Clippy lint fix — not a behavioral change, just moving a variable declaration
inside the cfg(feature = "postgres") block where it's used.

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

* fix(review): address PR review comments

- WASM loaders: use tokio::fs::metadata, only treat NotFound as empty,
  propagate other IO errors, handle TOCTOU in read_dir
- bootstrap: only ignore NotFound in read_to_string, propagate other errors
- wizard: restore print_info/print_success for migrations in interactive
  mode (gated by !config.quick), keep tracing::debug for diagnostics
- tests: use shared crate::config::helpers::ENV_MUTEX instead of separate
  NEARAI_ENV_MUTEX to prevent cross-test env var races
- README: fix quick mode description to mention model selection, clarify
  auto_setup_database may prompt when DATABASE_URL is set

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

* feat(setup): skip prompts for DATABASE_URL in quick mode [skip-regression-check]

auto_setup_database() now uses DATABASE_URL directly without calling
step_database_postgres() (which prompts for confirmation). Quick mode
should be fully non-interactive when env vars are already set.

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

* fix(cli): update --quick help text to mention model selection [skip-regression-check]

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-03-10 05:02:33 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 94d101924e
commit 3a2989d009
18 changed files with 564 additions and 102 deletions
+156
View File
@@ -198,6 +198,58 @@ pub fn save_bootstrap_env_to(path: &std::path::Path, vars: &[(&str, &str)]) -> s
Ok(())
}
/// Update or add multiple variables in `~/.ironclaw/.env`, preserving existing content.
///
/// Like `upsert_bootstrap_var` but batched — replaces lines for any key in `vars`
/// and preserves all other existing lines. Use this instead of `save_bootstrap_env`
/// when you want to update specific keys without destroying user-added variables.
pub fn upsert_bootstrap_vars(vars: &[(&str, &str)]) -> std::io::Result<()> {
upsert_bootstrap_vars_to(&ironclaw_env_path(), vars)
}
/// Update or add multiple variables at an arbitrary path (testable variant).
pub fn upsert_bootstrap_vars_to(
path: &std::path::Path,
vars: &[(&str, &str)],
) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let keys_being_written: std::collections::HashSet<&str> =
vars.iter().map(|(k, _)| *k).collect();
let existing = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e),
};
let mut result = String::new();
for line in existing.lines() {
// Extract key from lines matching `KEY=...`
let is_overwritten = line
.split_once('=')
.map(|(k, _)| keys_being_written.contains(k.trim()))
.unwrap_or(false);
if !is_overwritten {
result.push_str(line);
result.push('\n');
}
}
// Append all new key=value pairs
for (key, value) in vars {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
result.push_str(&format!("{}=\"{}\"\n", key, escaped));
}
std::fs::write(path, &result)?;
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
@@ -1237,4 +1289,108 @@ INJECTED="pwned"#;
let lock = PidLock::acquire_at(pid_path).unwrap();
drop(lock);
}
#[test]
fn upsert_bootstrap_vars_preserves_unknown_keys() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
// Simulate a user-edited .env with custom vars
let initial =
"HTTP_HOST=\"0.0.0.0\"\nDATABASE_BACKEND=\"postgres\"\nCUSTOM_VAR=\"keep_me\"\n";
std::fs::write(&env_path, initial).unwrap();
// Upsert wizard vars — should preserve HTTP_HOST and CUSTOM_VAR
let vars = [("DATABASE_BACKEND", "libsql"), ("LLM_BACKEND", "openai")];
upsert_bootstrap_vars_to(&env_path, &vars).unwrap();
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(
parsed.len(),
4,
"should have 4 vars (2 preserved + 2 upserted)"
);
// User-added vars must be preserved
assert!(
parsed
.iter()
.any(|(k, v)| k == "HTTP_HOST" && v == "0.0.0.0"),
"HTTP_HOST must be preserved"
);
assert!(
parsed
.iter()
.any(|(k, v)| k == "CUSTOM_VAR" && v == "keep_me"),
"CUSTOM_VAR must be preserved"
);
// Wizard vars must be updated/added
assert!(
parsed
.iter()
.any(|(k, v)| k == "DATABASE_BACKEND" && v == "libsql"),
"DATABASE_BACKEND must be updated to libsql"
);
assert!(
parsed
.iter()
.any(|(k, v)| k == "LLM_BACKEND" && v == "openai"),
"LLM_BACKEND must be added"
);
// Now update LLM_BACKEND and verify HTTP_HOST still preserved
let vars2 = [("LLM_BACKEND", "anthropic")];
upsert_bootstrap_vars_to(&env_path, &vars2).unwrap();
let parsed2: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(
parsed2.len(),
4,
"should still have 4 vars after second upsert"
);
assert!(
parsed2
.iter()
.any(|(k, v)| k == "HTTP_HOST" && v == "0.0.0.0"),
"HTTP_HOST must still be preserved after second upsert"
);
assert!(
parsed2
.iter()
.any(|(k, v)| k == "LLM_BACKEND" && v == "anthropic"),
"LLM_BACKEND must be updated to anthropic"
);
}
#[test]
fn upsert_bootstrap_vars_creates_file_if_missing() {
let dir = tempdir().unwrap();
let env_path = dir.path().join("subdir").join(".env");
// File doesn't exist yet
assert!(!env_path.exists());
let vars = [("DATABASE_BACKEND", "libsql")];
upsert_bootstrap_vars_to(&env_path, &vars).unwrap();
assert!(env_path.exists());
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(parsed.len(), 1);
assert_eq!(
parsed[0],
("DATABASE_BACKEND".to_string(), "libsql".to_string())
);
}
}