Skip to main content

lean_ctx/core/updater/
rewire.rs

1// SPDX-License-Identifier: Apache-2.0
2//! After an update: the one-time config migrations, LaunchAgent re-wrap,
3//! proxy restart and the setup refresh (`post_update_rewire`), limited to the
4//! recorded agent selection or the already-connected tools.
5
6/// #828: One-time migration — enable `shadow_mode` for users who never
7/// explicitly set it. Without shadow mode, most harnesses silently prefer
8/// native tools, negating lean-ctx's token savings. Idempotent: a state-dir
9/// marker prevents repeated flips if the user disables it after migration.
10fn migrate_shadow_mode_default() {
11    let Ok(state) = crate::core::paths::state_dir() else {
12        return;
13    };
14    let marker = state.join("shadow_mode_migrated");
15    if marker.exists() {
16        return;
17    }
18
19    // Read raw TOML to distinguish "explicitly set to false" from "never set".
20    let global = crate::core::config::Config::load_global();
21    let raw_toml = crate::core::config::Config::path()
22        .and_then(|p| std::fs::read_to_string(p).ok())
23        .unwrap_or_default();
24
25    let explicitly_set = raw_toml.lines().any(|line| {
26        let trimmed = line.trim();
27        trimmed.starts_with("shadow_mode") && trimmed.contains('=')
28    });
29
30    if !explicitly_set && !global.shadow_mode {
31        // User never touched shadow_mode — the old default was false.
32        // Set it to true in the global config so it persists.
33        match crate::core::config::Config::update_global(|c| c.shadow_mode = true) {
34            Ok(_) => {
35                eprintln!("  \u{2139} shadow_mode enabled (recommended default since v3.9.9).");
36                eprintln!("    Disable: lean-ctx config set shadow_mode false");
37            }
38            Err(e) => tracing::warn!("could not enable shadow_mode during update: {e}"),
39        }
40    } else if explicitly_set && !global.shadow_mode {
41        eprintln!("  \u{2139} shadow_mode is false in your config. Since v3.9.9, true is the");
42        eprintln!("    recommended default (60%+ more token savings). Enable:");
43        eprintln!("    lean-ctx config set shadow_mode true");
44    }
45
46    // Write marker so this migration runs only once.
47    if let Some(parent) = marker.parent() {
48        let _ = std::fs::create_dir_all(parent);
49    }
50    let _ = std::fs::write(&marker, "migrated");
51}
52
53/// Public entry point for the shadow_mode migration, callable from
54/// `cmd_dev_install` and other lifecycle commands.
55pub fn migrate_shadow_mode_default_public() {
56    migrate_shadow_mode_default();
57}
58
59pub(super) fn post_update_rewire(skip_rules: bool, quiet: bool, setup: &dyn SetupRefresh) {
60    // #356: regenerate installed LaunchAgent plists so they adopt the new
61    // deny-~/Documents seatbelt wrapper. A plist is only (re)written on install,
62    // so without this an upgrade keeps the old unwrapped plist — and the TCC
63    // prompt — until the next manual enable. Idempotent: install rewrites the
64    // plist and re-bootstraps it.
65    #[cfg(target_os = "macos")]
66    rewrap_launchagents_for_tcc();
67
68    // The persist decision reads the GLOBAL file only and writes via
69    // update_global, so a project-local override is never leaked into the
70    // global config (#443).
71    if crate::core::config::Config::load_global()
72        .proxy_enabled
73        .is_none()
74        && crate::proxy_autostart::is_installed()
75    {
76        match crate::core::config::Config::update_global(|c| c.proxy_enabled = Some(true)) {
77            Ok(_) => {
78                eprintln!("  \u{2139} Proxy was already active \u{2014} keeping enabled.");
79                eprintln!("    Disable anytime: lean-ctx proxy disable");
80            }
81            Err(e) => tracing::warn!("could not persist proxy_enabled during update: {e}"),
82        }
83    }
84
85    // #828: shadow_mode default changed to true. Migrate existing configs
86    // that never explicitly set it (still at old default false).
87    migrate_shadow_mode_default();
88
89    // Runtime decisions use the effective (global + project-local) config.
90    let cfg = crate::core::config::Config::load();
91    let proxy_active = cfg.proxy_enabled == Some(true);
92
93    // Determine whether rules should be injected during rewire.
94    // CLI --skip-rules always wins. Otherwise, respect the config setting.
95    let effective_skip_rules = if skip_rules {
96        true
97    } else {
98        !cfg.setup.should_inject_rules()
99    };
100
101    // PHASE 1: Restart proxy BEFORE writing env vars.
102    if proxy_active {
103        restart_proxy_if_running();
104        wait_for_proxy_health(crate::core::proxy_endpoint::default_port());
105    }
106
107    // PHASE 2: Run setup which writes MCP configs (always) and rules (if opted in).
108    // #1026: shell hooks are refreshed here — but install_all_with_style
109    // respects shell_hook_disabled, so users who opt out keep control.
110    if !cfg.shell_hook_disabled_effective() {
111        eprintln!("  \u{2139} Refreshing shell hooks in ~/.zshenv / ~/.bashenv.");
112        eprintln!("    Set shell_hook_disabled=true to prevent this in future updates.");
113    }
114    // Only the recorded selection (setup.agents) or, without one, the tools
115    // that are already connected: an update never connects a new tool.
116    if let Err(e) = setup.refresh(proxy_active, effective_skip_rules) {
117        tracing::error!("Setup refresh error: {e}");
118    }
119    if !quiet {
120        setup.print_unconnected_tools_hint();
121    }
122
123    // The Cockpit ships with LeanCTX: an update installs it when it is missing.
124    if crate::core::intelligence_runtime::cockpit::install_if_missing(true) && !quiet {
125        eprintln!("  \u{2713} LeanCTX Cockpit installed (open it with: lean-ctx cockpit).");
126    }
127}
128
129/// The setup refresh an update runs after replacing the binary. Setup sits
130/// above the engine, so the updater's caller supplies it.
131pub trait SetupRefresh {
132    /// Re-run setup in refresh-only mode: MCP configs, shell hooks and, unless
133    /// `skip_rules`, rules for the recorded or already-connected tools.
134    fn refresh(&self, proxy_active: bool, skip_rules: bool) -> Result<(), String>;
135    /// Print what [`SetupRefresh::refresh`] would change, without writing.
136    fn print_plan(&self, proxy_active: bool, skip_rules: bool);
137    /// Name installed tools the refresh leaves alone.
138    fn print_unconnected_tools_hint(&self);
139}
140
141/// `update --dry-run`: the refresh an update would run, without writing.
142pub(super) fn print_rewire_plan(skip_rules: bool, setup: &dyn SetupRefresh) {
143    let cfg = crate::core::config::Config::load();
144    let skip_rules = skip_rules || !cfg.setup.should_inject_rules();
145    setup.print_plan(cfg.proxy_enabled == Some(true), skip_rules);
146    setup.print_unconnected_tools_hint();
147}
148
149/// #356: rewrite every installed LaunchAgent plist (daemon, proxy, auto-updater)
150/// so an upgrade re-emits them with the deny-~/Documents seatbelt wrapper.
151/// plists are only generated on install, so an existing install would otherwise
152/// keep the pre-wrapper plist — and the prompt — until the next manual `enable`.
153/// Each `install` / `install_schedule` rewrites the plist and re-bootstraps it.
154#[cfg(target_os = "macos")]
155fn rewrap_launchagents_for_tcc() {
156    if crate::proxy_autostart::is_installed() {
157        crate::proxy_autostart::install(crate::core::proxy_endpoint::default_port(), true);
158    }
159    if crate::daemon_autostart::is_installed() {
160        crate::daemon_autostart::install(true);
161    }
162    if crate::core::update_scheduler::schedule_status().enabled {
163        let hours = crate::core::config::Config::load()
164            .updates
165            .check_interval_hours;
166        if let Err(e) = crate::core::update_scheduler::install_schedule(hours) {
167            tracing::warn!("#356 re-wrap of auto-update LaunchAgent failed: {e}");
168        }
169    }
170}
171
172fn wait_for_proxy_health(port: u16) {
173    let max_attempts = 20;
174    for i in 0..max_attempts {
175        if is_proxy_reachable(port) {
176            println!("  \x1b[32m✓\x1b[0m Proxy healthy on port {port}");
177            return;
178        }
179        if i == 0 {
180            print!("  \x1b[2mWaiting for proxy to become healthy");
181        }
182        print!(".");
183        use std::io::Write;
184        std::io::stdout().flush().ok();
185        std::thread::sleep(std::time::Duration::from_millis(500));
186    }
187    println!();
188    eprintln!(
189        "  \x1b[33m⚠\x1b[0m Proxy did not respond within {}s — writing env vars anyway",
190        max_attempts / 2
191    );
192    eprintln!("    If Claude Code shows connection errors, run: lean-ctx proxy start");
193}
194
195fn restart_proxy_if_running() {
196    let port = crate::core::proxy_endpoint::default_port();
197
198    if restart_managed_proxy() {
199        return;
200    }
201
202    if is_proxy_reachable(port) {
203        println!(
204            "  \x1b[33m⟳\x1b[0m Proxy running on port {port} — restart it to use the new binary:"
205        );
206        println!("    \x1b[1mlean-ctx proxy start --port={port}\x1b[0m");
207    }
208}
209
210/// Restart proxy managed by launchd (macOS) or systemd (Linux).
211/// Returns `true` if a managed service was found and restarted.
212fn restart_managed_proxy() -> bool {
213    #[cfg(target_os = "macos")]
214    {
215        let plist_path = dirs::home_dir()
216            .unwrap_or_default()
217            .join("Library/LaunchAgents/com.leanctx.proxy.plist");
218        if plist_path.exists() {
219            if crate::core::launchd::bootstrap("com.leanctx.proxy", &plist_path) {
220                println!("  \x1b[32m✓\x1b[0m Proxy restarted (LaunchAgent)");
221            } else {
222                println!("  \x1b[33m⚠\x1b[0m Could not restart proxy LaunchAgent");
223            }
224            return true;
225        }
226    }
227
228    #[cfg(target_os = "linux")]
229    {
230        let service_path = dirs::home_dir()
231            .unwrap_or_default()
232            .join(".config/systemd/user/lean-ctx-proxy.service");
233        if service_path.exists() {
234            let result = std::process::Command::new("systemctl")
235                .args(["--user", "restart", "lean-ctx-proxy"])
236                .output();
237            match result {
238                Ok(o) if o.status.success() => {
239                    println!("  \x1b[32m✓\x1b[0m Proxy restarted (systemd)");
240                }
241                _ => {
242                    println!("  \x1b[33m⚠\x1b[0m Could not restart proxy systemd service");
243                }
244            }
245            return true;
246        }
247    }
248
249    false
250}
251
252fn is_proxy_reachable(port: u16) -> bool {
253    ureq::get(&format!("http://127.0.0.1:{port}/health"))
254        .call()
255        .is_ok()
256}