jarvy 0.3.0

Jarvy is a fast, cross-platform CLI that installs and manages developer tools across macOS and Linux.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
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,
    /// Telemetry configuration (OTLP endpoint, signals, etc.)
    #[serde(default)]
    pub telemetry: TelemetryConfig,
    /// Shell init configuration for `jarvy ensure`
    #[serde(default)]
    pub shell_init: Option<ShellInitConfig>,
    /// MCP server preferences
    #[serde(default)]
    pub mcp: McpPreferences,
}

/// MCP preferences stored in ~/.jarvy/config.toml
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct McpPreferences {
    /// Auto-approve tool installations without prompting
    #[serde(default)]
    pub auto_approve_installs: bool,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub(crate) struct Settings {
    /// Legacy telemetry switch (kept for backward compatibility)
    /// Use [telemetry] section for full configuration
    #[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())),
        }
    }
}

/// Lazily-cached parse of `~/.jarvy/config.toml`. The OnceLock is populated
/// the first time `initialize()` is invoked. Mutating writes through
/// `save_global_config` / `modify_global_config` invalidate the cache so a
/// subsequent `initialize()` call sees the new values.
static GLOBAL_CONFIG: std::sync::OnceLock<std::sync::RwLock<Option<CliConfig>>> =
    std::sync::OnceLock::new();

/// Side-channel for the telemetry disclosure trigger. `initialize_from_disk`
/// runs BEFORE `telemetry::init` (the subscriber + OTLP exporter wiring
/// happen in `main.rs` after the config is loaded), so emitting the
/// `telemetry.disclosure_shown` event inline would always drop — the
/// `is_enabled()` gate reads an uninitialized TELEMETRY OnceLock and
/// returns false. Stash the trigger here; `main.rs` drains it after
/// `telemetry::init` completes.
static PENDING_DISCLOSURE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();

/// Return and clear any pending telemetry disclosure trigger. Called
/// from `main.rs` after `telemetry::init` so the OTLP layer is ready
/// to ship the event.
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))
}

/// Drop the cached `CliConfig` so the next `initialize()` call re-reads from
/// disk. Called by save paths to avoid stale reads.
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 {
    // Test probe: allow tests to assert initialization ordering without side-effects
    if std::env::var("JARVY_INIT_PROBE").as_deref() == Ok("1") {
        eprintln!("TEST: initialize called");
    }
    // In test mode, avoid any filesystem side effects and just return defaults
    if std::env::var("JARVY_TEST_MODE").as_deref() == Ok("1") {
        return CliConfig::default();
    }

    // Resolve `~/.jarvy/` and `~/.jarvy/config.toml` through the canonical
    // resolver so a future XDG migration / `JARVY_HOME` override is honored.
    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");

    // Create the .jarvy directory if it doesn't exist. Notice →
    // stderr (stdout stays clean for `--format json` consumers).
    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();
        }
    }

    // Load the current on-disk config content (empty if missing).
    let config_content = fs::read_to_string(&config_file_path).unwrap_or_default();

    // Telemetry is **opt-out** (CLAUDE.md commitment). On the first
    // run we have nothing on disk; on subsequent runs the user may
    // have a config that pre-dates the `[telemetry]` block. In both
    // cases the user has not persisted an explicit decision —
    // `telemetry::user_decided` is the section-aware predicate that
    // returns false. The boxed disclosure must surface, the config
    // must then be rewritten with `enabled = true` so the next run
    // is "decided" and the disclosure doesn't repeat.
    if !crate::telemetry::user_decided(&config_content) {
        let trigger: &'static str = if is_first_run {
            "first_run"
        } else {
            "legacy_upgrade"
        };
        render_telemetry_disclosure();
        // Telemetry isn't initialized yet — stash the trigger for
        // main.rs to emit after `telemetry::init`.
        let _ = PENDING_DISCLOSURE.set(trigger);

        // Build the persisted config: keep any other fields the user
        // already set; only ensure `[telemetry]` has the default
        // (opt-out, enabled = true) so future runs are "decided".
        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}"),
            }
        }
    }

    // Read existing or just-created config.toml
    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
}

/// Render the opt-out telemetry disclosure to stderr.
///
/// Triggered by `initialize_from_disk` on every run where the user
/// has no persisted `[telemetry] enabled` decision (first run or a
/// legacy config that pre-dates the `[telemetry]` block). The file
/// is written with `enabled = true` regardless of whether the user
/// reads the banner — this is a disclosure, not a consent prompt.
/// The boxed format is deliberate: an opt-out default is only
/// ethically defensible when the disclosure is unmissable.
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>                                │
╰─────────────────────────────────────────────────────────────────╯
"#
    );
}

/// Save the global config back to ~/.jarvy/config.toml
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(())
}

/// Single source of truth for the global config path: `~/.jarvy/config.toml`.
///
/// Honors `JARVY_TEST_HOME` as a deliberate test override (overrides the
/// home directory; the `.jarvy` segment is appended). `dirs::home_dir()`
/// on Windows uses `SHGetKnownFolderPath` and ignores HOME/USERPROFILE,
/// so env-var-based isolation does not work there without an explicit
/// hook. `JARVY_TEST_HOME` is opt-in and Jarvy-namespaced; production
/// environments will never set it.
///
/// `JARVY_HOME` (handled inside `crate::paths::jarvy_home`) overrides
/// the entire `~/.jarvy/` location and is honored ahead of the
/// test-only override.
pub fn global_config_path() -> Option<std::path::PathBuf> {
    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()
}

/// Load the current global config (returning `Default` if missing/unreadable),
/// hand it to `modify`, then atomically persist the result.
///
/// Use this instead of hand-rolling load → mutate → write in callers.
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;

    /// Serializes tests that mutate $HOME and the global config file. Cargo
    /// runs unit tests in parallel by default; without serialization these
    /// tests would race on `~/.jarvy/config.toml`.
    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");

        // global_config_path() honors JARVY_TEST_HOME above all else.
        // Setting that single var isolates tests on every platform,
        // including Windows where dirs::home_dir() ignores env vars.
        let prev = std::env::var("JARVY_TEST_HOME").ok();

        // SAFETY: tests are serialized via HOME_MUTEX so set_var/remove_var
        // races are prevented by construction.
        #[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| {
            // Seed with a config that has telemetry disabled.
            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| {
            // No config exists yet.
            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| {
                // Verify previously-written field is read back, not lost.
                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);

    // Add components for the fingerprint.
    builder
        .add_component(HWIDComponent::SystemID) // System UUID
        .add_component(HWIDComponent::CPUCores) // CPU core count
        .add_component(HWIDComponent::OSName) // Operating System name
        .add_component(HWIDComponent::DriveSerial); // Main disk serial

    // Build the ID with a custom key.
    const SALT: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15ac1e289f66085";
    // The key should be constant for your application to ensure consistency.
    builder.build(SALT).ok()
}