use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Value, json};
use super::ServiceAction;
pub(crate) fn cmd_service(action: ServiceAction) -> Result<()> {
let kind = |local_relay: bool| {
if local_relay {
crate::service::ServiceKind::LocalRelay
} else {
crate::service::ServiceKind::Daemon
}
};
let (report, as_json) = match action {
ServiceAction::Install { local_relay, json } => {
(crate::service::install_kind(kind(local_relay))?, json)
}
ServiceAction::Uninstall { local_relay, json } => {
(crate::service::uninstall_kind(kind(local_relay))?, json)
}
ServiceAction::Status { local_relay, json } => {
(crate::service::status_kind(kind(local_relay))?, json)
}
};
if as_json {
println!("{}", serde_json::to_string(&report)?);
} else {
println!("wire service {}", report.action);
println!(" platform: {}", report.platform);
println!(" unit: {}", report.unit_path);
println!(" status: {}", report.status);
println!(" detail: {}", report.detail);
}
Ok(())
}
const CRATE_NAME: &str = "slancha-wire";
fn release_asset_triple() -> Option<(&'static str, &'static str)> {
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
return Some(("x86_64-pc-windows-msvc", ".exe"));
}
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
return Some(("aarch64-apple-darwin", ""));
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
return Some(("x86_64-apple-darwin", ""));
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
return Some(("x86_64-unknown-linux-musl", ""));
}
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
{
return Some(("aarch64-unknown-linux-musl", ""));
}
#[allow(unreachable_code)]
None
}
fn fetch_latest_published_version() -> Result<String> {
let url = format!("https://crates.io/api/v1/crates/{CRATE_NAME}");
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.build()?;
let resp = client
.get(&url)
.header(
"User-Agent",
format!("wire/{} (self-update)", env!("CARGO_PKG_VERSION")),
)
.send()?;
if !resp.status().is_success() {
bail!("crates.io returned {} for {CRATE_NAME}", resp.status());
}
let v: Value = resp.json()?;
v.get("crate")
.and_then(|c| {
c.get("max_stable_version")
.or_else(|| c.get("newest_version"))
})
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| anyhow!("crates.io response missing crate.max_stable_version"))
}
fn version_is_newer(latest: &str, current: &str) -> bool {
let parse = |s: &str| -> (u64, u64, u64) {
let core = s.split('-').next().unwrap_or(s);
let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
(
it.next().unwrap_or(0),
it.next().unwrap_or(0),
it.next().unwrap_or(0),
)
};
parse(latest) > parse(current)
}
fn cargo_on_path() -> bool {
std::process::Command::new("cargo")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn self_update_from_release(latest: &str) -> Result<()> {
let (triple, ext) = release_asset_triple().ok_or_else(|| {
anyhow!(
"no prebuilt release binary for this platform — install a Rust toolchain and re-run, \
or `cargo install {CRATE_NAME}`"
)
})?;
let base =
format!("https://github.com/SlanchaAi/wire/releases/download/v{latest}/wire-{triple}{ext}");
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()?;
let resp = client
.get(&base)
.header("User-Agent", "wire-self-update")
.send()?;
if !resp.status().is_success() {
bail!("downloading {base} returned {}", resp.status());
}
let bytes = resp.bytes()?;
if let Ok(sha) = client
.get(format!("{base}.sha256"))
.header("User-Agent", "wire-self-update")
.send()
&& sha.status().is_success()
{
let expected = sha
.text()?
.split_whitespace()
.next()
.unwrap_or("")
.to_string();
if !expected.is_empty() {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(&bytes);
let actual = hex::encode(h.finalize());
if expected != actual {
bail!(
"SHA-256 mismatch — expected {expected}, got {actual} (aborting, binary NOT replaced)"
);
}
}
}
let exe = std::env::current_exe().context("locating current exe")?;
let dir = exe
.parent()
.ok_or_else(|| anyhow!("current exe has no parent dir"))?;
let tmp = dir.join(format!(".wire-update-{}", std::process::id()));
std::fs::write(&tmp, &bytes).with_context(|| format!("writing {tmp:?}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
std::fs::rename(&tmp, &exe).with_context(|| format!("replacing {exe:?}"))?;
}
#[cfg(windows)]
{
let old = exe.with_extension("old");
let _ = std::fs::remove_file(&old);
std::fs::rename(&exe, &old)
.with_context(|| format!("renaming running exe {exe:?} aside"))?;
std::fs::rename(&tmp, &exe).with_context(|| format!("installing new exe at {exe:?}"))?;
}
Ok(())
}
struct UpdateOutcome {
current: String,
latest: String,
available: bool,
installed: bool,
via: Option<&'static str>,
}
fn self_update_step(install: bool) -> Result<UpdateOutcome> {
let current = env!("CARGO_PKG_VERSION").to_string();
let latest = fetch_latest_published_version().context("checking crates.io for latest wire")?;
let available = version_is_newer(&latest, ¤t);
if !install || !available {
return Ok(UpdateOutcome {
current,
latest,
available,
installed: false,
via: None,
});
}
let via = if cargo_on_path() {
eprintln!(
"wire upgrade: {current} → {latest} — installing via `cargo install {CRATE_NAME}` …"
);
let status = std::process::Command::new("cargo")
.args([
"install",
CRATE_NAME,
"--version",
&latest,
"--force",
"--locked",
])
.status()
.context("running cargo install")?;
if !status.success() {
bail!("`cargo install {CRATE_NAME}` failed");
}
"cargo install"
} else {
eprintln!(
"wire upgrade: {current} → {latest} — no `cargo` on PATH, downloading the prebuilt release binary …"
);
self_update_from_release(&latest)?;
"prebuilt release binary"
};
Ok(UpdateOutcome {
current,
latest,
available,
installed: true,
via: Some(via),
})
}
pub(crate) fn upgrade_kill_set(
my_pid: Option<u32>,
found_daemon_pids: &[u32],
owned_session_pids: &std::collections::HashSet<u32>,
) -> Vec<u32> {
let mut k: Vec<u32> = Vec::new();
if let Some(p) = my_pid {
k.push(p);
}
for &p in found_daemon_pids {
if !owned_session_pids.contains(&p) && Some(p) != my_pid {
k.push(p); }
}
k.sort_unstable();
k.dedup();
k
}
#[derive(Debug, Clone)]
struct PathWireBinary {
path: std::path::PathBuf,
canonical: std::path::PathBuf,
sha256: Option<String>,
mtime: Option<std::time::SystemTime>,
path_index: usize,
is_current_exe: bool,
}
impl PathWireBinary {
fn is_active(&self) -> bool {
self.path_index == 0
}
fn sha256_short(&self) -> String {
self.sha256
.as_deref()
.map(|s| s[..s.len().min(8)].to_string())
.unwrap_or_else(|| "????????".to_string())
}
fn mtime_display(&self) -> String {
let Some(ts) = self.mtime else {
return "?".to_string();
};
let secs = match ts.duration_since(std::time::UNIX_EPOCH) {
Ok(d) => d.as_secs() as i64,
Err(_) => return "?".to_string(),
};
time::OffsetDateTime::from_unix_timestamp(secs)
.ok()
.and_then(|dt| {
dt.format(&time::format_description::well_known::Rfc3339)
.ok()
})
.unwrap_or_else(|| "?".to_string())
}
}
fn sha256_file(p: &std::path::Path) -> Result<String> {
use sha2::{Digest, Sha256};
let mut f = std::fs::File::open(p).with_context(|| format!("opening {}", p.display()))?;
let mut h = Sha256::new();
std::io::copy(&mut f, &mut h).with_context(|| format!("hashing {}", p.display()))?;
Ok(hex::encode(h.finalize()))
}
fn enumerate_path_wire_binaries() -> Vec<PathWireBinary> {
let path = std::env::var("PATH").unwrap_or_default();
let current_exe_canon: Option<std::path::PathBuf> = std::env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok());
enumerate_path_wire_binaries_from(&path, current_exe_canon.as_deref())
}
fn enumerate_path_wire_binaries_from(
path: &str,
current_exe_canon: Option<&std::path::Path>,
) -> Vec<PathWireBinary> {
if path.is_empty() {
return Vec::new();
}
let separator = if cfg!(windows) { ';' } else { ':' };
let names: &[&str] = if cfg!(windows) {
&["wire.exe", "wire"]
} else {
&["wire"]
};
let mut seen: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
let mut out: Vec<PathWireBinary> = Vec::new();
for dir in path.split(separator) {
if dir.is_empty() {
continue;
}
for name in names {
let candidate = std::path::PathBuf::from(dir).join(name);
if !candidate.is_file() {
continue;
}
let canon = candidate
.canonicalize()
.unwrap_or_else(|_| candidate.clone());
if !seen.insert(canon.clone()) {
break;
}
let meta = std::fs::metadata(&canon).ok();
let mtime = meta.as_ref().and_then(|m| m.modified().ok());
let sha256 = sha256_file(&canon).ok();
let is_current_exe = current_exe_canon
.map(|c| c == canon.as_path())
.unwrap_or(false);
let path_index = out.len();
out.push(PathWireBinary {
path: candidate,
canonical: canon,
sha256,
mtime,
path_index,
is_current_exe,
});
break;
}
}
out
}
fn path_shadow_warning(bins: &[PathWireBinary]) -> Option<String> {
let any_current = bins.iter().any(|b| b.is_current_exe);
let multi = bins.len() >= 2;
let off_path = !bins.is_empty() && !any_current;
let none_on_path = bins.is_empty();
if !multi && !off_path && !none_on_path {
return None;
}
let mut out = String::new();
if multi {
out.push_str(&format!(
"WARN: {} distinct `wire` binaries on PATH — older entries can shadow your fresh install:\n",
bins.len()
));
for b in bins {
let mut tags: Vec<&str> = Vec::new();
if b.is_active() {
tags.push("ACTIVE (bare `wire` resolves here)");
}
if b.is_current_exe {
tags.push("THIS upgrade ran against this binary");
}
let tag_str = if tags.is_empty() {
String::new()
} else {
format!(" ← {}", tags.join("; "))
};
out.push_str(&format!(
" [{}] {} (sha256:{} mtime:{}){}\n",
b.path_index,
b.path.display(),
b.sha256_short(),
b.mtime_display(),
tag_str,
));
}
if !any_current {
out.push_str(
" NOTE: none of the PATH-resident binaries is the one running this `wire upgrade`.\n",
);
out.push_str(
" Your upgrade will NOT affect bare `wire` calls in shells, scripts, or peer agents.\n",
);
} else if !bins[0].is_current_exe {
out.push_str(
" Bare `wire` calls (shells, scripts, daemons, peer agents) will use the\n",
);
out.push_str(
" ACTIVE binary [0], NOT the one you just upgraded. Recommended fixes:\n",
);
out.push_str(&format!(
" - rm {} (or symlink it to the upgraded binary)\n",
bins[0].path.display(),
));
out.push_str(
" - or reorder PATH so the upgraded binary's directory precedes the active one\n",
);
out.push_str(" Verify with: which -a wire\n");
}
} else if off_path {
let active = &bins[0];
out.push_str("WARN: this `wire upgrade` is running against an off-PATH binary;\n");
out.push_str(&format!(
" bare `wire` resolves to {} (sha256:{}),\n",
active.path.display(),
active.sha256_short(),
));
out.push_str(
" which was NOT touched by this upgrade. Shells, scripts, and peer agents\n",
);
out.push_str(" will continue to invoke the old binary.\n");
} else if none_on_path {
out.push_str("WARN: no `wire` binary on PATH; bare `wire` will fail in future shells.\n");
out.push_str(" This upgrade ran against an absolute-path invocation only.\n");
}
Some(out.trim_end().to_string())
}
pub(crate) fn cmd_upgrade(
check_only: bool,
local: bool,
restart_mcp: bool,
refresh_stale_children: bool,
as_json: bool,
) -> Result<()> {
let update: Option<UpdateOutcome> = if local {
None
} else {
match self_update_step(!check_only) {
Ok(o) => Some(o),
Err(e) => {
if !check_only {
eprintln!("wire upgrade: update check skipped — {e:#}");
}
None
}
}
};
if let Some(o) = &update
&& o.installed
{
eprintln!(
"wire upgrade: installed {} (was {}, via {}); restarting the daemon on the new binary.",
o.latest,
o.current,
o.via.unwrap_or("self-update")
);
}
let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
let mcp_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire mcp");
let running_pids: Vec<u32> = daemon_pids
.iter()
.chain(relay_pids.iter())
.copied()
.collect();
let record = crate::ensure_up::read_pid_record("daemon");
let recorded_version: Option<String> = match &record {
crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
_ => None,
};
let cli_version = env!("CARGO_PKG_VERSION").to_string();
let my_daemon_pid = record.pid();
let owned_session_pids: std::collections::HashSet<u32> = crate::session::list_sessions()
.unwrap_or_default()
.iter()
.filter_map(|s| crate::session::session_daemon_pid(&s.home_dir))
.collect();
let mut kill_set = upgrade_kill_set(my_daemon_pid, &daemon_pids, &owned_session_pids);
let stale_children_killed: Vec<serde_json::Value> = if refresh_stale_children {
match crate::daemon_supervisor::read_supervisor_state() {
Ok(sv) => {
let mut killed: Vec<serde_json::Value> = Vec::new();
let cli_v = env!("CARGO_PKG_VERSION");
for s in &sv.sessions {
if !sv.stale_binary_sessions.contains(&s.name) {
continue;
}
if let Some(pid) = s.daemon_pid {
if !kill_set.contains(&pid) {
kill_set.push(pid);
}
killed.push(json!({
"session": s.name,
"pid": pid,
"prev_version": s.daemon_version,
"cli_version": cli_v,
}));
}
}
if !killed.is_empty() && !as_json {
eprintln!(
"wire upgrade: --refresh-stale-children will kill {} stale-binary session daemon(s); supervisor respawns each on next 10s poll.",
killed.len()
);
}
killed
}
Err(e) => {
if !as_json {
eprintln!(
"wire upgrade: --refresh-stale-children skipped — could not read supervisor state ({e:#}). \
The flag is a no-op when no `wire daemon --all-sessions` supervisor is running."
);
}
Vec::new()
}
}
} else {
Vec::new()
};
if check_only {
let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
.unwrap_or_default()
.iter()
.filter(|s| s.daemon_running)
.map(|s| s.name.clone())
.collect();
let path_bins = enumerate_path_wire_binaries();
let path_dupes: Vec<String> = path_bins
.iter()
.map(|b| b.canonical.to_string_lossy().into_owned())
.collect();
let path_binaries_detail: Vec<serde_json::Value> = path_bins
.iter()
.map(|b| {
json!({
"path": b.path.to_string_lossy(),
"canonical": b.canonical.to_string_lossy(),
"sha256": b.sha256,
"mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
"path_index": b.path_index,
"is_active": b.is_active(),
"is_current_exe": b.is_current_exe,
})
})
.collect();
let path_warning_check = path_shadow_warning(&path_bins);
let installed_service_kinds: Vec<&'static str> = [
(crate::service::ServiceKind::Daemon, "daemon"),
(crate::service::ServiceKind::LocalRelay, "local-relay"),
]
.into_iter()
.filter_map(|(k, label)| {
crate::service::status_kind(k)
.ok()
.filter(|r| r.status != "absent")
.map(|_| label)
})
.collect();
let (update_latest, update_available) = match &update {
Some(o) => (Some(o.latest.clone()), o.available),
None => (None, false),
};
let report = json!({
"running_pids": running_pids,
"running_daemons": daemon_pids,
"running_relay_servers": relay_pids,
"running_mcp_servers": mcp_pids,
"would_warn_stale_mcp_servers": !mcp_pids.is_empty() && !restart_mcp,
"would_restart_mcp_servers": restart_mcp && !mcp_pids.is_empty(),
"restart_mcp_requested": restart_mcp,
"pidfile_version": recorded_version,
"cli_version": cli_version,
"latest_published": update_latest,
"update_available": update_available,
"would_kill": kill_set,
"would_refresh_services": installed_service_kinds,
"session_daemons_running": sessions_with_daemons,
"path_binaries": path_dupes,
"path_binaries_detail": path_binaries_detail,
"path_duplicate_warning": path_dupes.len() > 1,
"path_warning": path_warning_check,
});
if as_json {
println!("{}", serde_json::to_string(&report)?);
} else {
println!("wire upgrade --check");
println!(" cli version: {cli_version}");
match (&update_latest, update_available) {
(Some(l), true) => println!(" latest published: {l} (UPDATE AVAILABLE)"),
(Some(l), false) => println!(" latest published: {l} (up to date)"),
(None, _) => println!(" latest published: (crates.io check skipped)"),
}
println!(
" pidfile version: {}",
recorded_version.as_deref().unwrap_or("(missing)")
);
if running_pids.is_empty() {
println!(" running daemons: none");
println!(" running relays: none");
} else {
if daemon_pids.is_empty() {
println!(" running daemons: none");
} else {
let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
println!(" running daemons: pids {}", p.join(", "));
}
if relay_pids.is_empty() {
println!(" running relays: none");
} else {
let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
println!(" running relays: pids {}", p.join(", "));
}
println!(" would kill all + spawn fresh");
}
if !mcp_pids.is_empty() {
let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
if restart_mcp {
println!(
" wire mcp servers: pids {} (would be killed via --restart-mcp; host respawns on new binary)",
p.join(", ")
);
} else {
println!(
" wire mcp servers: pids {} (NOT killed; each Claude tab must `/mcp` reconnect, or re-run with --restart-mcp to signal them now)",
p.join(", ")
);
}
}
if !installed_service_kinds.is_empty() {
println!(
" would refresh: {} installed service unit(s) → new binary path",
installed_service_kinds.join(", ")
);
}
if !sessions_with_daemons.is_empty() {
println!(
" session daemons: {} (would respawn under new binary)",
sessions_with_daemons.join(", ")
);
}
if let Ok(sv) = crate::daemon_supervisor::read_supervisor_state()
&& !sv.stale_binary_sessions.is_empty()
{
let cli_v = env!("CARGO_PKG_VERSION");
if refresh_stale_children {
println!(
" stale children: {} session(s) on old binary; --refresh-stale-children WOULD kill each so supervisor respawns on v{cli_v}",
sv.stale_binary_sessions.len()
);
} else {
println!(
" stale children: {} session(s) on old binary (v{cli_v} is current); rerun with --refresh-stale-children to refresh them",
sv.stale_binary_sessions.len()
);
}
for name in &sv.stale_binary_sessions {
let ver = sv
.sessions
.iter()
.find(|s| &s.name == name)
.and_then(|s| s.daemon_version.clone())
.unwrap_or_else(|| "?".to_string());
println!(" - {name} running v{ver}");
}
}
if let Some(w) = &path_warning_check {
println!(" PATH check:");
for line in w.lines() {
println!(" {line}");
}
}
}
return Ok(());
}
for pid in &kill_set {
let _ = crate::platform::kill_process(*pid, false); }
if !kill_set.is_empty() {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
while std::time::Instant::now() < deadline
&& kill_set.iter().any(|p| super::process_alive_pid(*p))
{
std::thread::sleep(std::time::Duration::from_millis(50));
}
for pid in &kill_set {
if super::process_alive_pid(*pid) {
let _ = crate::platform::kill_process(*pid, true);
}
}
std::thread::sleep(std::time::Duration::from_millis(200)); }
let killed: Vec<u32> = kill_set
.iter()
.copied()
.filter(|p| !super::process_alive_pid(*p))
.collect();
let pidfile = crate::config::state_dir()?.join("daemon.pid");
if pidfile.exists() {
let _ = std::fs::remove_file(&pidfile);
}
let path_bins = enumerate_path_wire_binaries();
let path_dupes: Vec<String> = path_bins
.iter()
.map(|b| b.canonical.to_string_lossy().into_owned())
.collect();
let path_binaries_detail: Vec<Value> = path_bins
.iter()
.map(|b| {
json!({
"path": b.path.to_string_lossy(),
"canonical": b.canonical.to_string_lossy(),
"sha256": b.sha256,
"mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
"path_index": b.path_index,
"is_active": b.is_active(),
"is_current_exe": b.is_current_exe,
})
})
.collect();
let path_warning = path_shadow_warning(&path_bins);
let mut service_refreshes: Vec<Value> = Vec::new();
for kind in [
crate::service::ServiceKind::Daemon,
crate::service::ServiceKind::LocalRelay,
] {
let already_installed = crate::service::status_kind(kind)
.map(|r| r.status != "absent")
.unwrap_or(false);
if !already_installed {
continue;
}
match crate::service::install_kind(kind) {
Ok(rep) => service_refreshes.push(json!({
"kind": rep.kind,
"platform": rep.platform,
"status": rep.status,
"unit_path": rep.unit_path,
"action": "refreshed",
})),
Err(e) => service_refreshes.push(json!({
"kind": format!("{kind:?}"),
"action": "refresh_failed",
"error": format!("{e:#}"),
})),
}
}
let supervisor_will_spawn = service_refreshes.iter().any(|r| {
let kind = r.get("kind").and_then(Value::as_str).unwrap_or("");
let action = r.get("action").and_then(Value::as_str).unwrap_or("");
let status = r.get("status").and_then(Value::as_str).unwrap_or("");
kind == "daemon"
&& action == "refreshed"
&& matches!(
status,
"loaded" | "enabled" | "active" | "registered" | "running"
)
});
let spawned = if supervisor_will_spawn {
None
} else {
Some(crate::ensure_up::ensure_daemon_running()?)
};
let session_respawns: Vec<Value> = Vec::new();
let new_record = crate::ensure_up::read_pid_record("daemon");
let new_pid = new_record.pid();
let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
Some(d.version.clone())
} else {
None
};
let killed_mcp: Vec<u32> = if restart_mcp && !mcp_pids.is_empty() {
for pid in &mcp_pids {
let _ = crate::platform::kill_process(*pid, false);
}
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
while std::time::Instant::now() < deadline
&& mcp_pids.iter().any(|p| super::process_alive_pid(*p))
{
std::thread::sleep(std::time::Duration::from_millis(50));
}
for pid in &mcp_pids {
if super::process_alive_pid(*pid) {
let _ = crate::platform::kill_process(*pid, true);
}
}
mcp_pids
.iter()
.copied()
.filter(|p| !super::process_alive_pid(*p))
.collect()
} else {
Vec::new()
};
if as_json {
println!(
"{}",
serde_json::to_string(&json!({
"killed": killed,
"found_daemons": daemon_pids,
"spared_relay_servers": relay_pids,
"stale_mcp_server_pids": mcp_pids,
"killed_mcp_server_pids": killed_mcp,
"restart_mcp_requested": restart_mcp,
"stale_mcp_warning": if mcp_pids.is_empty() || restart_mcp {
Value::Null
} else {
json!(format!(
"{} `wire mcp` server subprocess(es) still on pre-upgrade code; each Claude tab must `/mcp` reconnect to pick up the new binary (or re-run with `wire upgrade --restart-mcp` to signal them now)",
mcp_pids.len()
))
},
"service_refreshes": service_refreshes,
"spawned_fresh_daemon": spawned,
"new_pid": new_pid,
"new_version": new_version,
"cli_version": cli_version,
"session_respawns": session_respawns,
"stale_children_killed": stale_children_killed,
"path_binaries": path_dupes,
"path_binaries_detail": path_binaries_detail,
"path_warning": path_warning,
}))?
);
} else {
if killed.is_empty() {
println!("wire upgrade: no stale wire processes running");
} else {
let killed_list = killed
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(", ");
if relay_pids.is_empty() {
println!(
"wire upgrade: killed {} daemon(s) [{killed_list}]",
killed.len()
);
} else {
let relay_list = relay_pids
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(", ");
println!(
"wire upgrade: killed {} daemon(s) [{killed_list}]; spared {} shared relay-server(s) [{relay_list}]",
killed.len(),
relay_pids.len()
);
}
}
if !stale_children_killed.is_empty() {
let cli_v = env!("CARGO_PKG_VERSION");
println!(
"wire upgrade: refreshed {} stale-binary session daemon(s) (supervisor respawns on v{cli_v} on next 10s poll):",
stale_children_killed.len()
);
for entry in &stale_children_killed {
let name = entry.get("session").and_then(Value::as_str).unwrap_or("?");
let pid = entry.get("pid").and_then(Value::as_u64).unwrap_or(0);
let prev = entry
.get("prev_version")
.and_then(Value::as_str)
.unwrap_or("?");
println!(" - {name} (pid {pid}, was v{prev})");
}
}
if !service_refreshes.is_empty() {
println!(
"wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
service_refreshes.len()
);
for r in &service_refreshes {
let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
let status = r.get("status").and_then(Value::as_str).unwrap_or("");
let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
if action == "refreshed" {
println!(" - {kind}: {action} ({status}, {platform})");
} else {
let err = r.get("error").and_then(Value::as_str).unwrap_or("");
println!(" - {kind}: {action} ({err})");
}
}
}
match spawned {
Some(true) => println!(
"wire upgrade: spawned fresh daemon (pid {} v{})",
new_pid
.map(|p| p.to_string())
.unwrap_or_else(|| "?".to_string()),
new_version.as_deref().unwrap_or(&cli_version),
),
Some(false) => {
println!("wire upgrade: daemon was already running on current binary");
}
None => println!(
"wire upgrade: daemon refresh deferred to {} supervisor (will spawn within 10s)",
if cfg!(target_os = "macos") {
"launchd"
} else if cfg!(target_os = "linux") {
"systemd"
} else if cfg!(target_os = "windows") {
"Task Scheduler"
} else {
"OS"
}
),
}
if !session_respawns.is_empty() {
println!(
"wire upgrade: refreshed {} session daemon(s):",
session_respawns.len()
);
for r in &session_respawns {
let h = r["session_home"].as_str().unwrap_or("?");
let s = r["status"].as_str().unwrap_or("?");
let label = std::path::Path::new(h)
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_else(|| h.to_string());
println!(" {label:<24} {s}");
}
}
if let Some(msg) = &path_warning {
eprintln!("wire upgrade: {msg}");
}
if restart_mcp {
if !killed_mcp.is_empty() {
let p: Vec<String> = killed_mcp.iter().map(|p| p.to_string()).collect();
println!(
"wire upgrade: killed {} `wire mcp` server subprocess(es) [{}]; host (Claude Code / Claude.app / Copilot CLI) will respawn on the new binary.",
killed_mcp.len(),
p.join(", ")
);
} else if mcp_pids.is_empty() {
println!(
"wire upgrade: --restart-mcp set, but no `wire mcp` server subprocesses were running."
);
} else {
let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
eprintln!(
"wire upgrade: WARNING — --restart-mcp requested but {} `wire mcp` subprocess(es) [{}] survived signaling. Check process ownership / OS permissions.",
mcp_pids.len(),
p.join(", ")
);
}
} else if !mcp_pids.is_empty() {
let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
eprintln!(
"wire upgrade: NOTE — {} `wire mcp` server subprocess(es) [{}] still on pre-upgrade code (Claude Code / Claude.app pin these at session start). Each Claude tab must `/mcp` reconnect (or restart the host app) to pick up the new binary. Run `wire upgrade --restart-mcp` to signal them now.",
mcp_pids.len(),
p.join(", ")
);
}
}
Ok(())
}
#[cfg(test)]
mod upgrade_tests {
use super::*;
use std::collections::HashSet;
#[test]
fn upgrade_kill_set_is_session_scoped() {
let owned: HashSet<u32> = [100, 200].into_iter().collect();
let k = upgrade_kill_set(Some(100), &[100, 200, 999], &owned);
assert!(k.contains(&100), "must kill my own daemon (to replace it)");
assert!(k.contains(&999), "must sweep a true orphan");
assert!(!k.contains(&200), "must SPARE a sibling session's daemon");
assert_eq!(
upgrade_kill_set(Some(100), &[], &owned),
vec![100],
"own daemon killed even when the process scan is empty"
);
assert_eq!(upgrade_kill_set(None, &[999], &HashSet::new()), vec![999]);
}
fn write_fake_wire(dir: &std::path::Path, body: &[u8]) -> std::path::PathBuf {
use std::io::Write;
let p = dir.join("wire");
let mut f = std::fs::File::create(&p).expect("create fake wire");
f.write_all(body).expect("write fake wire");
drop(f);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perm = std::fs::metadata(&p).unwrap().permissions();
perm.set_mode(0o755);
std::fs::set_permissions(&p, perm).unwrap();
}
p
}
#[test]
#[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
fn enumerate_finds_no_binaries_when_path_empty() {
let bins = enumerate_path_wire_binaries_from("", None);
assert!(
bins.is_empty(),
"empty PATH yields no binaries, got {bins:?}"
);
}
#[test]
#[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
fn enumerate_detects_two_distinct_binaries_in_path_order() {
let d1 = tempfile::tempdir().unwrap();
let d2 = tempfile::tempdir().unwrap();
let p1 = write_fake_wire(d1.path(), b"#!/bin/sh\necho A\n");
let p2 = write_fake_wire(d2.path(), b"#!/bin/sh\necho B\n");
let path = format!("{}:{}", d1.path().display(), d2.path().display());
let bins = enumerate_path_wire_binaries_from(&path, None);
assert_eq!(bins.len(), 2, "expected two distinct binaries: {bins:?}");
assert_eq!(bins[0].path_index, 0);
assert_eq!(bins[1].path_index, 1);
assert!(bins[0].is_active(), "first PATH entry is active");
assert!(!bins[1].is_active(), "second PATH entry is not active");
assert_ne!(
bins[0].sha256, bins[1].sha256,
"distinct contents must hash differently"
);
assert_eq!(bins[0].path, p1);
assert_eq!(bins[1].path, p2);
}
#[test]
#[cfg_attr(windows, ignore = "PATH separator + symlink semantics differ")]
fn enumerate_collapses_symlink_chains_to_one_entry() {
let real_dir = tempfile::tempdir().unwrap();
let link_dir = tempfile::tempdir().unwrap();
let real = write_fake_wire(real_dir.path(), b"#!/bin/sh\necho real\n");
let link = link_dir.path().join("wire");
#[cfg(unix)]
std::os::unix::fs::symlink(&real, &link).unwrap();
let path = format!(
"{}:{}",
link_dir.path().display(),
real_dir.path().display()
);
let bins = enumerate_path_wire_binaries_from(&path, None);
assert_eq!(
bins.len(),
1,
"symlink chain must collapse to a single entry: {bins:?}"
);
assert!(bins[0].is_active());
assert_eq!(bins[0].path, link);
assert_eq!(bins[0].canonical, real.canonicalize().unwrap());
}
#[test]
#[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
fn shadow_warning_off_path_when_current_exe_not_on_path() {
let d = tempfile::tempdir().unwrap();
write_fake_wire(d.path(), b"#!/bin/sh\necho only\n");
let elsewhere = tempfile::tempdir().unwrap();
let cur = elsewhere.path().join("not-on-path-wire");
let bins = enumerate_path_wire_binaries_from(&d.path().display().to_string(), Some(&cur));
assert_eq!(bins.len(), 1);
assert!(!bins[0].is_current_exe);
let warn = path_shadow_warning(&bins).expect("off-path single bin must warn");
assert!(
warn.contains("off-PATH binary"),
"off-path WARN must mention off-PATH; got: {warn}"
);
}
#[test]
fn shadow_warning_fires_when_no_binaries_at_all() {
let bins: Vec<PathWireBinary> = Vec::new();
let warn = path_shadow_warning(&bins).expect("empty must warn");
assert!(warn.contains("no `wire` binary on PATH"), "got: {warn}");
}
#[test]
#[cfg_attr(windows, ignore = "PATH separator differs")]
fn shadow_warning_multi_binaries_names_active_and_recommends_fix() {
let d1 = tempfile::tempdir().unwrap();
let d2 = tempfile::tempdir().unwrap();
write_fake_wire(d1.path(), b"published\n");
write_fake_wire(d2.path(), b"head\n");
let path = format!("{}:{}", d1.path().display(), d2.path().display());
let bins = enumerate_path_wire_binaries_from(&path, None);
let warn = path_shadow_warning(&bins).expect("two distinct bins must warn");
assert!(warn.contains("2 distinct"), "got: {warn}");
assert!(warn.contains("ACTIVE"), "must mark the active binary");
assert!(
warn.contains("which -a wire") || warn.contains("none of the PATH-resident"),
"must guide the operator to a fix; got: {warn}"
);
}
}