use machineid_rs::{Encryption, HWIDComponent, IdBuilder};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use uuid::Uuid;
use crate::shell_init::ShellInitConfig;
use crate::telemetry::TelemetryConfig;
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub(crate) struct CliConfig {
pub settings: Settings,
#[serde(default)]
pub telemetry: TelemetryConfig,
#[serde(default)]
pub shell_init: Option<ShellInitConfig>,
#[serde(default)]
pub mcp: McpPreferences,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct McpPreferences {
#[serde(default)]
pub auto_approve_installs: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub(crate) struct Settings {
#[serde(default = "default_true")]
pub telemetry: bool,
#[serde(default)]
pub fingerprint: Option<String>,
}
fn default_true() -> bool {
true
}
impl Default for Settings {
fn default() -> Self {
Settings {
telemetry: true,
fingerprint: get_hwid_fingerprint().or_else(|| Some(Uuid::now_v7().to_string())),
}
}
}
static GLOBAL_CONFIG: std::sync::OnceLock<std::sync::RwLock<Option<CliConfig>>> =
std::sync::OnceLock::new();
static PENDING_DISCLOSURE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
pub(crate) fn take_pending_disclosure() -> Option<&'static str> {
PENDING_DISCLOSURE.get().copied()
}
fn config_cache() -> &'static std::sync::RwLock<Option<CliConfig>> {
GLOBAL_CONFIG.get_or_init(|| std::sync::RwLock::new(None))
}
pub(crate) fn invalidate_global_config_cache() {
if let Ok(mut guard) = config_cache().write() {
*guard = None;
}
}
pub(crate) fn initialize() -> CliConfig {
if let Ok(guard) = config_cache().read() {
if let Some(cfg) = guard.as_ref() {
return cfg.clone();
}
}
let fresh = initialize_from_disk();
if let Ok(mut guard) = config_cache().write() {
*guard = Some(fresh.clone());
}
fresh
}
fn initialize_from_disk() -> CliConfig {
if std::env::var("JARVY_INIT_PROBE").as_deref() == Ok("1") {
eprintln!("TEST: initialize called");
}
if std::env::var("JARVY_TEST_MODE").as_deref() == Ok("1") {
return CliConfig::default();
}
let jarvy_dir = match crate::paths::jarvy_home() {
Ok(p) => p,
Err(e) => {
eprintln!("Failed to get home directory: {e}");
return CliConfig::default();
}
};
let config_file_path = jarvy_dir.join("config.toml");
let is_first_run = !jarvy_dir.exists();
if is_first_run {
if let Err(e) = fs::create_dir(&jarvy_dir) {
eprintln!("Unable to create jarvy config directory: {e}");
return CliConfig::default();
}
}
let config_content = fs::read_to_string(&config_file_path).unwrap_or_default();
if !crate::telemetry::user_decided(&config_content) {
let trigger: &'static str = if is_first_run {
"first_run"
} else {
"legacy_upgrade"
};
render_telemetry_disclosure();
let _ = PENDING_DISCLOSURE.set(trigger);
let mut persisted: CliConfig = toml::from_str(&config_content).unwrap_or_default();
persisted.telemetry = TelemetryConfig::default();
if let Ok(toml) = toml::to_string(&persisted) {
match fs::File::create(&config_file_path) {
Ok(mut file) => {
if let Err(e) = file.write_all(toml.as_bytes()) {
eprintln!("Unable to write content to config file: {e}");
}
}
Err(e) => eprintln!("Unable to create config file: {e}"),
}
}
}
let config: CliConfig = {
let config_content = fs::read_to_string(&config_file_path).unwrap_or_default();
if config_content.trim().is_empty() {
CliConfig::default()
} else {
toml::from_str(&config_content).unwrap_or_default()
}
};
config
}
fn render_telemetry_disclosure() {
eprintln!(
r#"
â•─────────────────────────────────────────────────────────────────╮
│ Jarvy telemetry is currently ENABLED. │
│ │
│ Anonymized usage data (which tools you install, setup │
│ durations, failure categories) helps prioritize what to fix │
│ and improve. No file contents, no command output, no │
│ hostnames, no IPs. Full schema + data-handling policy: │
│ https://jarvy.dev/telemetry/ │
│ │
│ Forwarder security model (TLS, rate limits, PII scrubbing, │
│ fan-out to Grafana Cloud): │
│ https://jarvy.dev/operations/telemetry-forwarder/ │
│ │
│ Opt out (you can opt back in any time): │
│ jarvy telemetry disable │
│ │
│ Or per-invocation: │
│ JARVY_TELEMETRY=0 jarvy <cmd> │
╰─────────────────────────────────────────────────────────────────╯
"#
);
}
pub fn save_global_config(config: &CliConfig) -> Result<(), String> {
let path = global_config_path().ok_or_else(|| "no home directory".to_string())?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("failed to create config dir: {e}"))?;
}
let toml =
toml::to_string_pretty(config).map_err(|e| format!("failed to serialize config: {e}"))?;
fs::write(&path, toml).map_err(|e| format!("failed to write config: {e}"))?;
invalidate_global_config_cache();
Ok(())
}
pub fn global_config_path() -> Option<std::path::PathBuf> {
#[cfg(feature = "test-bypass")]
{
if let Ok(custom_home) = std::env::var("JARVY_TEST_HOME") {
return Some(
std::path::PathBuf::from(custom_home)
.join(".jarvy")
.join("config.toml"),
);
}
}
crate::paths::config_toml().ok()
}
pub(crate) fn modify_global_config<F>(modify: F) -> Result<(), String>
where
F: FnOnce(&mut CliConfig),
{
let path = global_config_path().ok_or_else(|| "no home directory".to_string())?;
let mut config: CliConfig = if path.exists() {
let content = fs::read_to_string(&path).unwrap_or_default();
if content.trim().is_empty() {
CliConfig::default()
} else {
toml::from_str(&content).unwrap_or_default()
}
} else {
CliConfig::default()
};
modify(&mut config);
save_global_config(&config)
}
#[cfg(test)]
#[allow(clippy::items_after_test_module)]
mod tests {
use super::*;
use std::sync::Mutex;
static HOME_MUTEX: Mutex<()> = Mutex::new(());
fn with_isolated_home<F: FnOnce(&std::path::Path)>(f: F) {
let _guard = HOME_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::TempDir::new().expect("tempdir");
let prev = std::env::var("JARVY_TEST_HOME").ok();
#[allow(unsafe_code)]
unsafe {
std::env::set_var("JARVY_TEST_HOME", tmp.path());
}
let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
f(tmp.path());
}));
#[allow(unsafe_code)]
unsafe {
match prev {
Some(v) => std::env::set_var("JARVY_TEST_HOME", v),
None => std::env::remove_var("JARVY_TEST_HOME"),
}
}
if let Err(payload) = res {
std::panic::resume_unwind(payload);
}
}
#[test]
fn save_global_config_creates_jarvy_dir_when_missing() {
with_isolated_home(|home| {
let cfg = CliConfig::default();
save_global_config(&cfg).expect("save");
assert!(home.join(".jarvy").join("config.toml").exists());
});
}
#[test]
fn modify_global_config_updates_existing_field() {
with_isolated_home(|home| {
let mut initial = CliConfig::default();
initial.telemetry.enabled = false;
save_global_config(&initial).expect("seed");
modify_global_config(|cfg| {
cfg.telemetry.enabled = true;
cfg.mcp.auto_approve_installs = true;
})
.expect("modify");
let path = home.join(".jarvy").join("config.toml");
let content = std::fs::read_to_string(path).unwrap();
let reloaded: CliConfig = toml::from_str(&content).expect("reparse");
assert!(reloaded.telemetry.enabled);
assert!(reloaded.mcp.auto_approve_installs);
});
}
#[test]
fn modify_global_config_creates_when_missing() {
with_isolated_home(|home| {
assert!(!home.join(".jarvy").join("config.toml").exists());
modify_global_config(|cfg| {
cfg.mcp.auto_approve_installs = true;
})
.expect("create + modify");
let content = std::fs::read_to_string(home.join(".jarvy").join("config.toml")).unwrap();
assert!(content.contains("auto_approve_installs"));
});
}
#[test]
fn modify_global_config_is_roundtrip_safe() {
with_isolated_home(|_home| {
modify_global_config(|cfg| {
cfg.settings.fingerprint = Some("0123abcd".to_string());
cfg.telemetry.enabled = true;
})
.expect("first");
modify_global_config(|cfg| {
assert_eq!(cfg.settings.fingerprint.as_deref(), Some("0123abcd"));
cfg.settings.fingerprint = None;
})
.expect("second");
modify_global_config(|cfg| {
assert!(cfg.settings.fingerprint.is_none());
assert!(cfg.telemetry.enabled);
})
.expect("third");
});
}
}
fn get_hwid_fingerprint() -> Option<String> {
let mut builder = IdBuilder::new(Encryption::SHA256);
builder
.add_component(HWIDComponent::SystemID) .add_component(HWIDComponent::CPUCores) .add_component(HWIDComponent::OSName) .add_component(HWIDComponent::DriveSerial);
const SALT: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15ac1e289f66085";
builder.build(SALT).ok()
}