use crate::core;
pub(super) fn cmd_stop() {
use crate::daemon;
use crate::ipc;
eprintln!("Stopping all lean-ctx processes…");
crate::proxy_autostart::stop();
crate::daemon_autostart::stop();
eprintln!(" Unloaded autostart (LaunchAgent/systemd).");
if let Err(e) = daemon::stop_daemon() {
eprintln!(" Warning: daemon stop: {e}");
}
let killed = ipc::process::kill_all_by_name("lean-ctx");
if killed > 0 {
eprintln!(" Sent SIGTERM to {killed} process(es).");
}
std::thread::sleep(std::time::Duration::from_millis(500));
let remaining = ipc::process::find_killable_pids("lean-ctx");
if !remaining.is_empty() {
eprintln!(" Force-killing {} stubborn process(es)…", remaining.len());
for &pid in &remaining {
let _ = ipc::process::force_kill(pid);
}
std::thread::sleep(std::time::Duration::from_millis(300));
}
daemon::cleanup_daemon_files();
let final_check = ipc::process::find_killable_pids("lean-ctx");
if final_check.is_empty() {
eprintln!(" ✓ All lean-ctx processes stopped.");
} else {
eprintln!(
" ✗ {} process(es) could not be killed: {:?}",
final_check.len(),
final_check
);
eprintln!(
" Try: sudo kill -9 {}",
final_check
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join(" ")
);
std::process::exit(1);
}
}
pub(super) fn cmd_restart() {
use crate::daemon;
use crate::ipc;
eprintln!("Restarting lean-ctx…");
crate::proxy_autostart::stop();
crate::daemon_autostart::stop();
if let Err(e) = daemon::stop_daemon() {
eprintln!(" Warning: daemon stop: {e}");
}
let orphans = ipc::process::kill_all_by_name("lean-ctx");
if orphans > 0 {
eprintln!(" Terminated {orphans} orphan process(es).");
}
std::thread::sleep(std::time::Duration::from_millis(500));
let remaining = ipc::process::find_killable_pids("lean-ctx");
if !remaining.is_empty() {
eprintln!(
" Force-killing {} stubborn process(es): {:?}",
remaining.len(),
remaining
);
for &pid in &remaining {
let _ = ipc::process::force_kill(pid);
}
std::thread::sleep(std::time::Duration::from_millis(300));
}
daemon::cleanup_daemon_files();
crate::proxy_autostart::start();
if crate::daemon_autostart::is_installed() {
crate::daemon_autostart::start();
eprintln!(" ✓ Daemon restarted via autostart.");
} else {
match daemon::start_daemon(&[]) {
Ok(()) => eprintln!(" ✓ Daemon restarted."),
Err(e) => {
eprintln!(" ✗ Daemon start failed: {e}");
std::process::exit(1);
}
}
}
}
pub(super) fn cmd_dev_install() {
use crate::ipc;
let cargo_root = find_cargo_project_root();
let Some(cargo_root) = cargo_root else {
eprintln!("Error: No Cargo.toml found. Run from the lean-ctx project directory.");
std::process::exit(1);
};
eprintln!("\x1b[1m◆ lean-ctx dev-install\x1b[0m \x1b[2m(builds from source)\x1b[0m");
eprintln!(
" \x1b[2mCompiles the binary from source — this can take several minutes the\n \
first time while cargo fetches and builds the dependency tree. The live\n \
build output below is normal progress, not a hang.\x1b[0m"
);
eprintln!(
" \x1b[2mJust want the latest release? Run\x1b[0m \x1b[1mlean-ctx update\x1b[0m \x1b[2m— it \
downloads a prebuilt\n binary in seconds, no toolchain required.\x1b[0m"
);
eprintln!();
eprintln!("\x1b[2m→ cargo build --release\x1b[0m");
let build = std::process::Command::new("cargo")
.args(["build", "--release"])
.current_dir(&cargo_root)
.status();
match build {
Ok(s) if s.success() => {}
Ok(s) => {
eprintln!(" Build failed with exit code {}", s.code().unwrap_or(-1));
std::process::exit(1);
}
Err(e) => {
eprintln!(" Build failed: {e}");
std::process::exit(1);
}
}
let built_binary = resolve_cargo_target_dir(&cargo_root)
.join("release")
.join(format!("lean-ctx{}", std::env::consts::EXE_SUFFIX));
if !built_binary.exists() {
eprintln!(
" Error: Built binary not found at {}",
built_binary.display()
);
eprintln!(
" Hint: is CARGO_TARGET_DIR or a [build] target-dir override pointing elsewhere?"
);
std::process::exit(1);
}
let install_path = resolve_install_path();
eprintln!("Installing to {}…", install_path.display());
eprintln!(" Stopping all lean-ctx processes…");
crate::proxy_autostart::stop();
crate::daemon_autostart::stop();
let _ = crate::daemon::stop_daemon();
ipc::process::kill_all_by_name("lean-ctx");
std::thread::sleep(std::time::Duration::from_millis(500));
let remaining = ipc::process::find_killable_pids("lean-ctx");
if !remaining.is_empty() {
eprintln!(" Force-killing {} stubborn process(es)…", remaining.len());
for &pid in &remaining {
let _ = ipc::process::force_kill(pid);
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if let Err(e) = atomic_install_binary(&built_binary, &install_path) {
eprintln!(" Error: {e}");
std::process::exit(1);
}
eprintln!(" ✓ Binary installed.");
#[cfg(target_os = "macos")]
if !crate::core::codesign::is_ready() {
eprintln!(
" ⚠ macOS: run `lean-ctx codesign-setup` once to stop the recurring\n \
\"lean-ctx wants to access your Documents\" prompt after updates (#356)."
);
}
reconcile_binary_drift(&install_path);
let mut verify = std::process::Command::new(&install_path);
verify.arg("--version");
let version = ipc::process::run_with_timeout(verify, std::time::Duration::from_secs(10))
.filter(|o| o.status.success())
.map_or_else(
|| "unknown (version check timed out)".to_string(),
|o| String::from_utf8_lossy(&o.stdout).trim().to_string(),
);
eprintln!(" ✓ dev-install complete: {version}");
eprintln!(" Re-enabling autostart…");
if crate::proxy_autostart::is_installed() {
crate::proxy_autostart::install(crate::proxy_setup::default_port(), true);
}
if crate::daemon_autostart::is_installed() {
crate::daemon_autostart::install(true);
eprintln!(" ✓ Daemon restarted via autostart.");
} else {
eprintln!(" Starting daemon…");
match crate::daemon::start_daemon(&[]) {
Ok(()) => {}
Err(e) => eprintln!(" Warning: daemon start: {e} (will be started by editor)"),
}
}
crate::core::updater::migrate_shadow_mode_default_public();
let cfg = crate::core::config::Config::load();
if cfg.setup.should_inject_rules()
&& let Some(home) = dirs::home_dir()
{
let result = crate::rules_inject::inject_all_rules(&home);
if !result.updated.is_empty() {
eprintln!(" ✓ Rules updated: {}", result.updated.join(", "));
}
}
}
#[cfg(target_os = "macos")]
pub(super) fn cmd_codesign_setup() {
use crate::core::codesign::{SetupOutcome, setup_identity, sign_binary};
eprintln!("Setting up a stable code-signing identity for lean-ctx (#356)…");
eprintln!(
" This stops the recurring macOS \"access to your Documents folder\" prompt.\n \
macOS will ask ONCE to authorize the trust setting — confirm with Touch ID\n \
or your login password.\n"
);
match setup_identity() {
Ok(SetupOutcome::AlreadyReady) => {
eprintln!(" ✓ Identity already set up and trusted. Nothing to do.");
}
Ok(SetupOutcome::Created) => {
eprintln!(" ✓ Signing identity created and trusted.");
if let Ok(exe) = std::env::current_exe()
&& sign_binary(&exe) == crate::core::codesign::SignKind::Stable
{
eprintln!(" ✓ Re-signed {} with the stable identity.", exe.display());
}
eprintln!(
"\n Done. `dev-install` and self-updates now reuse this identity.\n \
Click \"Allow\" on the next Documents prompt — it won't come back."
);
}
Err(e) => {
eprintln!(" ✗ Setup failed: {e}");
eprintln!(
" The binary still works (ad-hoc signed); the prompt may recur until\n \
setup succeeds. Re-run `lean-ctx codesign-setup` to retry."
);
std::process::exit(1);
}
}
}
#[cfg(not(target_os = "macos"))]
pub(super) fn cmd_codesign_setup() {
eprintln!("codesign-setup is only needed on macOS.");
}
fn atomic_install_binary(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> {
let staged = dst.with_extension("new");
let _ = std::fs::remove_file(&staged);
std::fs::copy(src, &staged).map_err(|e| format!("staging copy failed: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("chmod failed: {e}"))?;
}
#[cfg(target_os = "macos")]
let _ = std::fs::remove_file(dst);
#[cfg(windows)]
move_locked_destination_aside(dst);
if let Err(e) = std::fs::rename(&staged, dst) {
let _ = std::fs::remove_file(&staged);
let hint = if cfg!(windows) {
"\n Another process still has the old binary open and its lock even blocks\n \
the rename-aside swap (rare — usually AV/EDR or a debugger, not the MCP\n \
server). Disconnect MCP clients (e.g. `/mcp` in Claude Code), wait a\n \
moment, and re-run the install."
} else {
""
};
return Err(format!("atomic rename failed: {e}{hint}"));
}
#[cfg(target_os = "macos")]
{
let _ = crate::core::codesign::sign_binary(dst);
}
Ok(())
}
#[cfg(windows)]
fn move_locked_destination_aside(dst: &std::path::Path) {
let old = dst.with_extension("old.exe");
let _ = std::fs::remove_file(&old);
if dst.exists() && !old.exists() {
let _ = std::fs::rename(dst, &old);
}
}
fn resolve_cargo_target_dir(cargo_root: &std::path::Path) -> std::path::PathBuf {
use crate::ipc;
let mut cmd = std::process::Command::new("cargo");
cmd.args(["metadata", "--no-deps", "--format-version=1"])
.current_dir(cargo_root);
ipc::process::run_with_timeout(cmd, std::time::Duration::from_secs(15))
.filter(|o| o.status.success())
.and_then(|o| target_dir_from_metadata(&String::from_utf8_lossy(&o.stdout)))
.unwrap_or_else(|| cargo_root.join("target"))
}
fn target_dir_from_metadata(metadata_json: &str) -> Option<std::path::PathBuf> {
let value: serde_json::Value = serde_json::from_str(metadata_json).ok()?;
value
.get("target_directory")?
.as_str()
.map(std::path::PathBuf::from)
}
pub(super) fn find_cargo_project_root() -> Option<std::path::PathBuf> {
let mut dir = std::env::current_dir().ok()?;
loop {
if dir.join("Cargo.toml").exists() {
return Some(dir);
}
if !dir.pop() {
return None;
}
}
}
pub(super) fn resolve_install_path() -> std::path::PathBuf {
if let Ok(exe) = std::env::current_exe()
&& let Ok(canonical) = exe.canonicalize()
{
let is_in_cargo_target = canonical.components().any(|c| c.as_os_str() == "target");
if !is_in_cargo_target && canonical.exists() {
return canonical;
}
}
if let Ok(home) = std::env::var("HOME") {
let local_bin = std::path::PathBuf::from(&home).join(".local/bin/lean-ctx");
if local_bin.parent().is_some_and(std::path::Path::exists) {
return local_bin;
}
}
std::path::PathBuf::from("/usr/local/bin/lean-ctx")
}
#[cfg(unix)]
fn is_homebrew_cellar_link(target: &std::path::Path) -> bool {
let s = target.to_string_lossy();
s.contains("/Cellar/") || s.contains("/linuxbrew/")
}
fn reconcile_binary_drift(install_path: &std::path::Path) {
#[cfg(unix)]
{
let install_canon =
std::fs::canonicalize(install_path).unwrap_or_else(|_| install_path.to_path_buf());
for shim in [
"/opt/homebrew/bin/lean-ctx",
"/usr/local/bin/lean-ctx",
"/home/linuxbrew/.linuxbrew/bin/lean-ctx",
] {
let shim_path = std::path::Path::new(shim);
let Ok(target) = std::fs::read_link(shim_path) else {
continue;
};
if !is_homebrew_cellar_link(&target) {
continue;
}
if std::fs::canonicalize(shim_path).is_ok_and(|c| c == install_canon) {
continue;
}
let _ = std::fs::remove_file(shim_path);
match std::os::unix::fs::symlink(install_path, shim_path) {
Ok(()) => eprintln!(
" ✓ Repointed stale Homebrew shim {shim} → {} (#559 drift fix)",
install_path.display()
),
Err(e) => eprintln!(
" ⚠ Stale Homebrew shim {shim} → {} couldn't be repointed ({e}). \
Run: brew unlink lean-ctx",
target.display()
),
}
}
if let Ok(path_var) = std::env::var("PATH") {
for dir in std::env::split_paths(&path_var) {
let cand = dir.join("lean-ctx");
if !cand.exists() {
continue;
}
let cand_canon = std::fs::canonicalize(&cand).unwrap_or_else(|_| cand.clone());
if cand_canon == install_canon {
break; }
eprintln!(
" ⚠ PATH shadow: {} resolves before {} — plain `lean-ctx` may run an older build.",
cand.display(),
install_path.display()
);
break;
}
}
}
#[cfg(not(unix))]
{
let _ = install_path;
}
}
fn should_spawn_proxy_locally(
proxy_enabled: bool,
managed_service_loaded: bool,
already_running: bool,
) -> bool {
proxy_enabled && !managed_service_loaded && !already_running
}
pub(super) fn spawn_proxy_if_needed() {
use std::net::TcpStream;
let cfg = core::config::Config::load();
if cfg.proxy_enabled != Some(true) {
return;
}
let managed_service_loaded = crate::proxy_autostart::is_loaded();
let port = crate::proxy_setup::default_port();
let already_running = {
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
TcpStream::connect_timeout(&addr, crate::proxy_setup::proxy_timeout()).is_ok()
};
if !should_spawn_proxy_locally(
cfg.proxy_enabled == Some(true),
managed_service_loaded,
already_running,
) {
if managed_service_loaded {
tracing::debug!("managed proxy service owns startup on port {port}");
} else if already_running {
tracing::debug!("proxy already running on port {port}");
}
return;
}
let binary = core::portable_binary::resolve_portable_binary();
let mut cmd = std::process::Command::new(&binary);
cmd.args(["proxy", "start", &format!("--port={port}")])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
match crate::ipc::process::spawn_detached(&mut cmd) {
Ok(_) => tracing::info!("auto-started proxy on port {port}"),
Err(e) => tracing::debug!("could not auto-start proxy: {e}"),
}
}
#[cfg(test)]
mod target_dir_tests {
use super::{resolve_cargo_target_dir, should_spawn_proxy_locally, target_dir_from_metadata};
use std::path::{Path, PathBuf};
#[test]
fn managed_proxy_service_prevents_a_duplicate_local_spawn() {
assert!(should_spawn_proxy_locally(true, false, false));
assert!(!should_spawn_proxy_locally(false, false, false));
assert!(!should_spawn_proxy_locally(true, true, false));
assert!(!should_spawn_proxy_locally(true, false, true));
}
#[test]
fn extracts_target_directory_from_metadata_json() {
let json = r#"{"packages":[],"target_directory":"/shared/build/target","version":1}"#;
assert_eq!(
target_dir_from_metadata(json),
Some(PathBuf::from("/shared/build/target"))
);
}
#[test]
fn windows_paths_survive_json_unescaping() {
let json = r#"{"target_directory":"C:\\Users\\dev\\shared\\target"}"#;
assert_eq!(
target_dir_from_metadata(json),
Some(PathBuf::from(r"C:\Users\dev\shared\target"))
);
}
#[test]
fn malformed_or_incomplete_metadata_yields_none() {
assert_eq!(target_dir_from_metadata("not json"), None);
assert_eq!(target_dir_from_metadata("{}"), None);
assert_eq!(target_dir_from_metadata(r#"{"target_directory":42}"#), None);
}
#[test]
fn resolve_falls_back_to_root_target_without_manifest() {
let root = if cfg!(windows) { r"C:\" } else { "/" };
assert_eq!(
resolve_cargo_target_dir(Path::new(root)),
Path::new(root).join("target")
);
}
}
#[cfg(all(test, windows))]
mod windows_swap_tests {
use super::atomic_install_binary;
use std::fs;
use std::os::windows::fs::OpenOptionsExt;
const FILE_SHARE_READ: u32 = 0x1;
const FILE_SHARE_DELETE: u32 = 0x4;
#[test]
fn swap_installs_over_destination_held_open_with_share_delete() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("built.exe");
let dst = dir.path().join("lean-ctx.exe");
fs::write(&src, b"new-binary").unwrap();
fs::write(&dst, b"old-binary").unwrap();
let _holder = fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE)
.open(&dst)
.unwrap();
atomic_install_binary(&src, &dst).unwrap();
assert_eq!(fs::read(&dst).unwrap(), b"new-binary");
assert_eq!(
fs::read(dir.path().join("lean-ctx.old.exe")).unwrap(),
b"old-binary"
);
}
#[test]
fn stale_old_sidecar_is_reclaimed_on_next_install() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("built.exe");
let dst = dir.path().join("lean-ctx.exe");
let old = dir.path().join("lean-ctx.old.exe");
fs::write(&src, b"v3").unwrap();
fs::write(&dst, b"v2").unwrap();
fs::write(&old, b"v1").unwrap();
atomic_install_binary(&src, &dst).unwrap();
assert_eq!(fs::read(&dst).unwrap(), b"v3");
assert_eq!(fs::read(&old).unwrap(), b"v2");
}
#[test]
fn exclusive_lock_yields_actionable_error() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("built.exe");
let dst = dir.path().join("lean-ctx.exe");
fs::write(&src, b"new-binary").unwrap();
fs::write(&dst, b"old-binary").unwrap();
let holder = fs::OpenOptions::new()
.read(true)
.share_mode(0)
.open(&dst)
.unwrap();
let err = atomic_install_binary(&src, &dst).unwrap_err();
assert!(err.contains("atomic rename failed"), "got: {err}");
assert!(err.contains("re-run the install"), "hint missing: {err}");
drop(holder);
assert_eq!(fs::read(&dst).unwrap(), b"old-binary");
}
}
#[cfg(all(test, unix))]
mod tests {
use super::is_homebrew_cellar_link;
use std::path::Path;
#[test]
fn cellar_and_linuxbrew_links_are_detected() {
assert!(is_homebrew_cellar_link(Path::new(
"../Cellar/lean-ctx/3.7.1/bin/lean-ctx"
)));
assert!(is_homebrew_cellar_link(Path::new(
"/opt/homebrew/Cellar/lean-ctx/3.8.0/bin/lean-ctx"
)));
assert!(is_homebrew_cellar_link(Path::new(
"/home/linuxbrew/.linuxbrew/Cellar/lean-ctx/1.0/bin/lean-ctx"
)));
}
#[test]
fn non_brew_targets_are_left_alone() {
assert!(!is_homebrew_cellar_link(Path::new(
"/Users/me/.local/bin/lean-ctx"
)));
assert!(!is_homebrew_cellar_link(Path::new("/usr/local/bin/other")));
assert!(!is_homebrew_cellar_link(Path::new("lean-ctx")));
}
}