Skip to main content

mj_controller/controller/
update.rs

1//! Install-aware startup update checks for `mj`.
2//!
3//! Mjolnir reaches users through several channels — the curl release
4//! installer, the npm package, the Homebrew tap, and crates.io — and each
5//! channel publishes its "latest" somewhere else. This module decides how
6//! the running binary was installed, asks that channel whether a newer
7//! release exists, and (in [`check_prompt_and_apply`]) asks the
8//! user before changing anything. npm and Homebrew upgrades delegate to the
9//! package manager; curl installs self-replace; cargo and npx installs only
10//! print the command, because a live `cargo install` rebuild or a nested
11//! `npx` run is not something to start from inside mj.
12
13use mj_core::hex::lower_hex;
14use std::ffi::OsString;
15use std::io::{self, BufRead, Cursor, IsTerminal, Read, Write};
16use std::path::{Path, PathBuf};
17use std::process::Command;
18use std::time::Duration;
19
20use anyhow::{Context, Result, bail, ensure};
21use flate2::read::GzDecoder;
22use semver::Version;
23use serde::Deserialize;
24use sha2::{Digest, Sha256};
25
26const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/BrokkAi/mjolnir/releases/latest";
27const NPM_LATEST_URL: &str = "https://registry.npmjs.org/@brokkai%2Fmjolnir/latest";
28const HOMEBREW_FORMULA_URL: &str =
29    "https://raw.githubusercontent.com/BrokkAi/homebrew-tap/main/Formula/mjolnir.rb";
30const BIN_NAME: &str = "mj";
31const WINDOWS_BIN_NAME: &str = "mj.exe";
32const VOICE_WORKER_NAME: &str = "mj-voice-worker";
33const NPM_MANAGED_ENV: &str = "MJOLNIR_MANAGED_BY_NPM";
34const NPX_MANAGED_ENV: &str = "MJOLNIR_MANAGED_BY_NPX";
35const HOMEBREW_MANAGED_ENV: &str = "MJOLNIR_MANAGED_BY_HOMEBREW";
36const NO_UPDATE_CHECK_ENV: &str = "MJOLNIR_NO_UPDATE_CHECK";
37
38/// The endpoints a channel consults, grouped so loopback tests can point
39/// every fetch at a local server instead of the real registries.
40#[derive(Debug, Clone)]
41struct UpdateSources {
42    latest_release: String,
43    npm_latest: String,
44    homebrew_formula: String,
45    cargo_index: String,
46}
47
48impl Default for UpdateSources {
49    fn default() -> Self {
50        Self {
51            latest_release: LATEST_RELEASE_URL.to_string(),
52            npm_latest: NPM_LATEST_URL.to_string(),
53            homebrew_formula: HOMEBREW_FORMULA_URL.to_string(),
54            cargo_index: CARGO_INDEX_URL.to_string(),
55        }
56    }
57}
58
59/// How the running `mj` binary was installed. Decides both where the latest
60/// version is published and who is allowed to replace the binary: package
61/// managers own their trees, so upgrades there run the manager's own
62/// command, while a curl install may only be replaced in place.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum InstallMethod {
65    Npm,
66    Npx,
67    Homebrew,
68    Cargo { voice_worker: bool },
69    Direct,
70}
71
72impl InstallMethod {
73    /// Detects the install method from the launcher-declared environment
74    /// markers, falling back to executable-path forensics. The markers are
75    /// set by `npm/launcher/mj.js` and the Homebrew formula's wrapper script.
76    fn current() -> Self {
77        Self::detect(
78            |name| std::env::var_os(name),
79            std::env::current_exe().ok().as_deref(),
80        )
81    }
82
83    fn detect<F>(env: F, exe: Option<&Path>) -> Self
84    where
85        F: Fn(&str) -> Option<OsString>,
86    {
87        if env(NPX_MANAGED_ENV).is_some() {
88            return Self::Npx;
89        }
90        if env(NPM_MANAGED_ENV).is_some() {
91            return Self::Npm;
92        }
93        if env(HOMEBREW_MANAGED_ENV).is_some() {
94            return Self::Homebrew;
95        }
96        exe.map_or(Self::Direct, |exe| {
97            install_method_from_exe(exe, env!("CARGO_PKG_VERSION"))
98        })
99    }
100
101    /// The command a person would run to upgrade through this channel. Used
102    /// verbatim for the notice-only channels and as the basis for the
103    /// delegated upgrade of npm and Homebrew.
104    fn update_command(&self) -> Option<String> {
105        match self {
106            Self::Npm => Some("npm install -g @brokkai/mjolnir@latest".to_string()),
107            Self::Npx => Some("npx -y @brokkai/mjolnir@latest".to_string()),
108            Self::Homebrew => Some("brew upgrade --formula brokkai/tap/mjolnir".to_string()),
109            Self::Cargo { voice_worker: true } => {
110                Some("cargo install --locked brokk-mjolnir brokk-mj-voice-worker".to_string())
111            }
112            Self::Cargo {
113                voice_worker: false,
114            } => Some("cargo install --locked brokk-mjolnir".to_string()),
115            Self::Direct => None,
116        }
117    }
118
119    fn channel_name(&self) -> &'static str {
120        match self {
121            Self::Npm | Self::Npx => "npm",
122            Self::Homebrew => "Homebrew",
123            Self::Cargo { .. } => "crates.io",
124            Self::Direct => "GitHub Releases",
125        }
126    }
127}
128
129fn install_method_from_exe(exe_path: &Path, current_version: &str) -> InstallMethod {
130    if is_homebrew_executable(exe_path) {
131        return InstallMethod::Homebrew;
132    }
133    if is_npm_bundle_executable(exe_path) {
134        return InstallMethod::Npm;
135    }
136
137    let Some(install_root) = cargo_install_root(exe_path, current_version) else {
138        return InstallMethod::Direct;
139    };
140    InstallMethod::Cargo {
141        voice_worker: cargo_install_recorded(
142            &install_root,
143            "brokk-mj-voice-worker",
144            None,
145            VOICE_WORKER_NAME,
146        ),
147    }
148}
149
150fn is_homebrew_executable(exe_path: &Path) -> bool {
151    let components = path_text_components(exe_path);
152    components
153        .windows(2)
154        .any(|pair| pair == ["Cellar", "mjolnir"])
155}
156
157/// Recognizes an npm bundle binary even when the launcher's marker is
158/// missing: without this, an env-less npm install would be misread as a
159/// direct install and the self-replace path would write inside
160/// `node_modules`, corrupting npm's package database.
161fn is_npm_bundle_executable(exe_path: &Path) -> bool {
162    let components = path_text_components(exe_path);
163    components
164        .windows(2)
165        .any(|pair| pair == ["node_modules", "@brokkai"])
166}
167
168fn path_text_components(exe_path: &Path) -> Vec<&str> {
169    exe_path
170        .components()
171        .filter_map(|component| component.as_os_str().to_str())
172        .collect()
173}
174
175fn cargo_install_root(exe_path: &Path, current_version: &str) -> Option<PathBuf> {
176    let canonical_exe = exe_path.canonicalize().ok()?;
177    let bin_dir = canonical_exe.parent()?;
178    if bin_dir.file_name()? != "bin" {
179        return None;
180    }
181    let install_root = bin_dir.parent()?;
182    cargo_install_recorded(
183        install_root,
184        "brokk-mjolnir",
185        Some(current_version),
186        BIN_NAME,
187    )
188    .then(|| install_root.to_path_buf())
189}
190
191fn cargo_install_recorded(
192    install_root: &Path,
193    package: &str,
194    version: Option<&str>,
195    binary: &str,
196) -> bool {
197    cargo_json_install_recorded(install_root, package, version, binary)
198        || cargo_toml_install_recorded(install_root, package, version, binary)
199}
200
201fn cargo_json_install_recorded(
202    install_root: &Path,
203    package: &str,
204    version: Option<&str>,
205    binary: &str,
206) -> bool {
207    let Ok(raw) = std::fs::read_to_string(install_root.join(".crates2.json")) else {
208        return false;
209    };
210    let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&raw) else {
211        return false;
212    };
213    manifest
214        .get("installs")
215        .and_then(serde_json::Value::as_object)
216        .is_some_and(|installs| {
217            installs.iter().any(|(source, record)| {
218                cargo_source_matches(source, package, version)
219                    && record
220                        .get("bins")
221                        .and_then(serde_json::Value::as_array)
222                        .is_some_and(|bins| bins.iter().any(|name| name.as_str() == Some(binary)))
223            })
224        })
225}
226
227fn cargo_toml_install_recorded(
228    install_root: &Path,
229    package: &str,
230    version: Option<&str>,
231    binary: &str,
232) -> bool {
233    let Ok(raw) = std::fs::read_to_string(install_root.join(".crates.toml")) else {
234        return false;
235    };
236    let Ok(manifest) = raw.parse::<toml::Value>() else {
237        return false;
238    };
239    manifest
240        .get("v1")
241        .and_then(toml::Value::as_table)
242        .is_some_and(|installs| {
243            installs.iter().any(|(source, bins)| {
244                cargo_source_matches(source, package, version)
245                    && bins
246                        .as_array()
247                        .is_some_and(|bins| bins.iter().any(|name| name.as_str() == Some(binary)))
248            })
249        })
250}
251
252fn cargo_source_matches(source: &str, package: &str, version: Option<&str>) -> bool {
253    let Some(rest) = source
254        .strip_prefix(package)
255        .and_then(|rest| rest.strip_prefix(' '))
256    else {
257        return false;
258    };
259    let Some(recorded_version) = rest.split_whitespace().next() else {
260        return false;
261    };
262    version.is_none_or(|expected| recorded_version == expected)
263}
264
265/// An upgrade that the running process can perform itself: the release
266/// archive and its checksum sidecar, ready to download.
267#[derive(Debug, Clone, PartialEq, Eq)]
268struct UpdateInfo {
269    version: Version,
270    tag: String,
271    asset: ReleaseAsset,
272    checksum_asset: ReleaseAsset,
273}
274
275/// What a channel reports. `Managed` upgrades delegate to the package
276/// manager; `Direct` upgrades replace the running binary from the release
277/// archive.
278#[derive(Debug, Clone, PartialEq, Eq)]
279enum AvailableUpdate {
280    Managed {
281        version: Version,
282        method: InstallMethod,
283    },
284    Direct(UpdateInfo),
285}
286
287#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
288struct GitHubRelease {
289    tag_name: String,
290    #[serde(default)]
291    assets: Vec<ReleaseAsset>,
292}
293
294#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
295struct ReleaseAsset {
296    name: String,
297    browser_download_url: String,
298}
299
300#[derive(Debug, Deserialize)]
301struct NpmLatest {
302    version: String,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
306struct Platform {
307    os_family: &'static str,
308    arch: &'static str,
309    rust_target: String,
310}
311
312/// Fetches the newest release the running install's channel publishes, or
313/// `None` when the channel is not ahead of the running version.
314async fn latest_update(
315    sources: &UpdateSources,
316    method: &InstallMethod,
317) -> Result<Option<AvailableUpdate>> {
318    let current = parse_version(env!("CARGO_PKG_VERSION"))
319        .with_context(|| format!("parse current version {}", env!("CARGO_PKG_VERSION")))?;
320    if *method == InstallMethod::Direct {
321        let release = fetch_latest_release(sources)
322            .await
323            .context("fetch latest mj release")?;
324        return update_info_from_release(&release, &current, &current_platform()?)
325            .map(|update| update.map(AvailableUpdate::Direct));
326    }
327
328    let latest = fetch_latest_managed_version(sources, method).await?;
329    Ok((latest > current).then(|| AvailableUpdate::Managed {
330        version: latest,
331        method: method.clone(),
332    }))
333}
334
335async fn fetch_latest_release(sources: &UpdateSources) -> Result<GitHubRelease> {
336    let body = fetch_text(&sources.latest_release).await?;
337    serde_json::from_str(&body).context("parse release body")
338}
339
340async fn fetch_latest_managed_version(
341    sources: &UpdateSources,
342    method: &InstallMethod,
343) -> Result<Version> {
344    match method {
345        InstallMethod::Npm | InstallMethod::Npx => {
346            let body = fetch_text(&sources.npm_latest)
347                .await
348                .context("fetch latest npm package")?;
349            let latest: NpmLatest = serde_json::from_str(&body).context("parse npm metadata")?;
350            parse_version(&latest.version).context("parse latest npm version")
351        }
352        InstallMethod::Homebrew => {
353            let body = fetch_text(&sources.homebrew_formula)
354                .await
355                .context("fetch Homebrew formula")?;
356            parse_homebrew_formula_version(&body)
357        }
358        InstallMethod::Cargo { .. } => {
359            // crates.io installs are notice-only, but the notice still needs
360            // to know whether anything newer exists. The sparse index lists
361            // every published version, yanked ones included-and-skipped.
362            let body = fetch_text(&sources.cargo_index)
363                .await
364                .context("fetch crates.io index entry")?;
365            parse_cargo_index_version(&body)
366        }
367        InstallMethod::Direct => anyhow::bail!("direct installs use GitHub release metadata"),
368    }
369}
370
371const CARGO_INDEX_URL: &str = "https://index.crates.io/br/ok/brokk-mjolnir";
372
373async fn fetch_text(url: &str) -> Result<String> {
374    let client = reqwest::Client::builder()
375        .timeout(Duration::from_secs(5))
376        .user_agent(concat!("mj/", env!("CARGO_PKG_VERSION")))
377        .build()
378        .context("build http client")?;
379    let resp = client
380        .get(url)
381        .send()
382        .await
383        .with_context(|| format!("GET {url}"))?;
384    let status = resp.status();
385    if !status.is_success() {
386        anyhow::bail!("GET {url}: HTTP {status}");
387    }
388    resp.text().await.with_context(|| format!("read {url}"))
389}
390
391fn parse_homebrew_formula_version(formula: &str) -> Result<Version> {
392    let raw = formula
393        .lines()
394        .map(str::trim)
395        .find_map(|line| line.strip_prefix("version \"")?.strip_suffix('"'))
396        .ok_or_else(|| anyhow::anyhow!("Homebrew formula has no version"))?;
397    parse_version(raw).context("parse Homebrew formula version")
398}
399
400fn parse_cargo_index_version(index: &str) -> Result<Version> {
401    let mut latest: Option<Version> = None;
402    for line in index.lines().filter(|line| !line.trim().is_empty()) {
403        let entry: CargoIndexEntry =
404            serde_json::from_str(line).context("parse crates.io index entry")?;
405        if entry.yanked {
406            continue;
407        }
408        let version = parse_version(&entry.vers).context("parse crates.io package version")?;
409        if latest.as_ref().is_none_or(|current| version > *current) {
410            latest = Some(version);
411        }
412    }
413    latest.ok_or_else(|| anyhow::anyhow!("crates.io index has no published versions"))
414}
415
416#[derive(Debug, Deserialize)]
417struct CargoIndexEntry {
418    vers: String,
419    #[serde(default)]
420    yanked: bool,
421}
422
423fn update_info_from_release(
424    release: &GitHubRelease,
425    current: &Version,
426    platform: &Platform,
427) -> Result<Option<UpdateInfo>> {
428    let latest = parse_version(&release.tag_name)
429        .with_context(|| format!("parse release tag {}", release.tag_name))?;
430    if latest <= *current {
431        return Ok(None);
432    }
433
434    let asset = select_mj_asset(&release.assets, platform)
435        .with_context(|| format!("find mj asset for {}/{}", platform.os_family, platform.arch))?;
436    let checksum_name = format!("{}.sha256", asset.name);
437    let checksum_asset = release
438        .assets
439        .iter()
440        .find(|candidate| candidate.name == checksum_name)
441        .cloned()
442        .ok_or_else(|| {
443            anyhow::anyhow!(
444                "release {} is missing required checksum asset {}",
445                release.tag_name,
446                checksum_name
447            )
448        })?;
449
450    Ok(Some(UpdateInfo {
451        version: latest,
452        tag: release.tag_name.clone(),
453        asset,
454        checksum_asset,
455    }))
456}
457
458fn select_mj_asset(assets: &[ReleaseAsset], platform: &Platform) -> Result<ReleaseAsset> {
459    let target_suffix = format!(
460        "-{}{}",
461        platform.rust_target,
462        platform_archive_ext(platform)
463    );
464    if platform.os_family == "macos"
465        && let Some(asset) = assets.iter().find(|asset| {
466            is_mj_archive(&asset.name) && asset.name.ends_with("-universal-apple-darwin.tar.gz")
467        })
468    {
469        return Ok(asset.clone());
470    }
471    assets
472        .iter()
473        .find(|asset| is_mj_archive(&asset.name) && asset.name.ends_with(&target_suffix))
474        .cloned()
475        .ok_or_else(|| {
476            anyhow::anyhow!(
477                "no mj archive found for target {}; available assets: {}",
478                platform.rust_target,
479                assets
480                    .iter()
481                    .filter(|asset| !asset.name.ends_with(".sha256"))
482                    .map(|asset| asset.name.as_str())
483                    .collect::<Vec<_>>()
484                    .join(", ")
485            )
486        })
487}
488
489fn is_mj_archive(name: &str) -> bool {
490    name.starts_with("brokk-mjolnir-") && (name.ends_with(".tar.gz") || name.ends_with(".zip"))
491}
492
493fn platform_archive_ext(platform: &Platform) -> &'static str {
494    if platform.os_family == "windows" {
495        ".zip"
496    } else {
497        ".tar.gz"
498    }
499}
500
501fn current_platform() -> Result<Platform> {
502    let arch = match std::env::consts::ARCH {
503        "x86_64" => "x86_64",
504        "aarch64" | "arm64" => "aarch64",
505        other => anyhow::bail!("unsupported CPU architecture: {other}"),
506    };
507    let (os_family, rust_os) = match std::env::consts::OS {
508        "android" => ("android", "linux-android"),
509        "macos" => ("macos", "apple-darwin"),
510        "linux" => ("linux", "unknown-linux-gnu"),
511        "windows" => ("windows", "pc-windows-msvc"),
512        other => anyhow::bail!("unsupported OS: {other}"),
513    };
514
515    Ok(Platform {
516        os_family,
517        arch,
518        rust_target: format!("{arch}-{rust_os}"),
519    })
520}
521
522fn parse_version(raw: &str) -> Result<Version> {
523    Version::parse(raw.trim_start_matches('v')).with_context(|| format!("parse version {raw}"))
524}
525
526/// What the startup check decided. A successful upgrade never produces a
527/// value: the process re-execs into the new binary.
528#[derive(Debug, Clone, PartialEq, Eq)]
529pub enum StartupUpdateOutcome {
530    /// The check did not run (debug build, non-interactive, disabled, or it
531    /// failed without affecting the session).
532    Skipped,
533    UpToDate,
534    /// The channel only allows announcing the upgrade command; the user must
535    /// run it themselves.
536    Notified,
537    Declined,
538}
539
540/// Checks the running install's channel for a newer release, asks before
541/// changing anything, and on consent performs the upgrade:
542///
543/// - npm installs run `npm install -g @brokkai/mjolnir@latest` and Homebrew
544///   installs run `brew update` followed by
545///   `brew upgrade --formula brokkai/tap/mjolnir`; mj never writes into
546///   `node_modules` or the Cellar itself, because those trees belong to the
547///   package managers.
548/// - curl installs download the release archive, verify its SHA-256 sidecar,
549///   update the controller and every bundled application helper, and re-exec.
550/// - npx and cargo installs are notice-only.
551///
552/// Runs on every interactive startup before the daemon or dashboard starts.
553/// The version fetch is time-boxed, and interactive package-manager runs share
554/// the terminal like any foreground command, so this must never be called
555/// from a UI render path.
556pub async fn check_prompt_and_apply() -> StartupUpdateOutcome {
557    // The controller ships for Linux and macOS (Windows users run WSL2), so
558    // there is no Windows replacer to maintain; debug builds are developers,
559    // who upgrade themselves.
560    if cfg!(windows)
561        || cfg!(debug_assertions)
562        || !io::stdin().is_terminal()
563        || !io::stdout().is_terminal()
564        || std::env::var_os(NO_UPDATE_CHECK_ENV).is_some()
565    {
566        return StartupUpdateOutcome::Skipped;
567    }
568
569    let method = InstallMethod::current();
570
571    let update = match latest_update(&UpdateSources::default(), &method).await {
572        Ok(Some(update)) => update,
573        Ok(None) => return StartupUpdateOutcome::UpToDate,
574        Err(error) => {
575            // A broken check must never keep someone out of mj.
576            eprintln!("mj: update check failed: {error:#}");
577            return StartupUpdateOutcome::Skipped;
578        }
579    };
580
581    match update {
582        AvailableUpdate::Direct(update) => {
583            if !prompt_for_update(&update.version, &InstallMethod::Direct).unwrap_or(false) {
584                return StartupUpdateOutcome::Declined;
585            }
586            if let Err(error) = download_apply_and_restart(&update).await {
587                eprintln!("mj: upgrade failed: {error:#}");
588                eprintln!("mj: continuing with {}", env!("CARGO_PKG_VERSION"));
589            }
590            // The success path re-execs and never returns; reaching this
591            // line means the upgrade failed and the current process lives on.
592            StartupUpdateOutcome::Skipped
593        }
594        AvailableUpdate::Managed { version, method } => match method {
595            InstallMethod::Npm | InstallMethod::Homebrew => {
596                if !prompt_for_update(&version, &method).unwrap_or(false) {
597                    return StartupUpdateOutcome::Declined;
598                }
599                let upgraded =
600                    run_managed_upgrade(&version, &method).and_then(restart_current_process);
601                if let Err(error) = upgraded {
602                    eprintln!("mj: upgrade failed: {error:#}");
603                    eprintln!("mj: continuing with {}", env!("CARGO_PKG_VERSION"));
604                }
605                StartupUpdateOutcome::Skipped
606            }
607            InstallMethod::Npx | InstallMethod::Cargo { .. } => {
608                let notice = managed_update_notice(&version, &method, env!("CARGO_PKG_VERSION"))
609                    .expect("notice-only channels have an update command");
610                println!("{notice}");
611                StartupUpdateOutcome::Notified
612            }
613            InstallMethod::Direct => {
614                unreachable!("direct installs never report managed updates")
615            }
616        },
617    }
618}
619
620fn prompt_for_update(version: &Version, method: &InstallMethod) -> Result<bool> {
621    print!(
622        "mj {version} is available through {}; current version is {}. Upgrade now? [Y/n] ",
623        method.channel_name(),
624        env!("CARGO_PKG_VERSION")
625    );
626    io::stdout().flush().context("flush update prompt")?;
627
628    read_update_answer(&mut io::stdin().lock())
629}
630
631fn read_update_answer(input: &mut impl BufRead) -> Result<bool> {
632    let mut answer = String::new();
633    let bytes_read = input
634        .read_line(&mut answer)
635        .context("read update prompt answer")?;
636    Ok(bytes_read != 0 && prompt_answer_is_yes(&answer))
637}
638
639/// An empty answer accepts, matching the 1.x prompt's default.
640fn prompt_answer_is_yes(answer: &str) -> bool {
641    matches!(answer.trim(), "" | "y" | "Y" | "yes" | "YES")
642}
643
644fn managed_update_notice(
645    version: &Version,
646    method: &InstallMethod,
647    current_version: &str,
648) -> Option<String> {
649    Some(format!(
650        "mj {version} is available through {}; current version is {current_version}. Run: {}",
651        method.channel_name(),
652        method.update_command()?
653    ))
654}
655
656fn npm_upgrade_command() -> Command {
657    let mut command = Command::new("npm");
658    command.args(["install", "-g", "@brokkai/mjolnir@latest"]);
659    command
660}
661
662fn brew_update_command() -> Command {
663    let mut command = Command::new("brew");
664    command.arg("update");
665    command
666}
667
668fn brew_upgrade_command() -> Command {
669    let mut command = Command::new("brew");
670    // A bare `mjolnir` resolves to an unrelated Homebrew cask, so name the
671    // tap formula explicitly.
672    command.args(["upgrade", "--formula", "brokkai/tap/mjolnir"]);
673    command
674}
675
676/// Runs the channel's own upgrade command in the foreground with its live
677/// output on the terminal. `run_inherited` keeps stdin closed so neither
678/// package manager can stop to ask a question nobody is there to answer.
679fn run_managed_upgrade(version: &Version, method: &InstallMethod) -> Result<RestartTarget> {
680    // npm moves and unlinks the old package. Resolve this before the upgrade,
681    // while current_exe still identifies the installation's stable path.
682    let current_exe = std::env::current_exe().context("resolve current executable")?;
683    let restart = managed_restart_target(method, &current_exe)?;
684    match method {
685        InstallMethod::Npm => {
686            println!("mj: running npm install -g @brokkai/mjolnir@latest");
687            let status = mj_core::subprocess::run_inherited(&mut npm_upgrade_command())
688                .context("run npm install -g @brokkai/mjolnir@latest")?;
689            ensure!(
690                status.success(),
691                "npm install exited with {status}; npm usually explains why above"
692            );
693        }
694        InstallMethod::Homebrew => {
695            // The version check read the tap formula on GitHub, but the
696            // local brew only knows about it after its index refreshes;
697            // without `brew update` the upgrade would report "already
698            // up-to-date" on a fresh release.
699            println!("mj: running brew update");
700            let status = mj_core::subprocess::run_inherited(&mut brew_update_command())
701                .context("run brew update")?;
702            ensure!(status.success(), "brew update exited with {status}");
703            println!("mj: running brew upgrade --formula brokkai/tap/mjolnir");
704            let status = mj_core::subprocess::run_inherited(&mut brew_upgrade_command())
705                .context("run brew upgrade --formula brokkai/tap/mjolnir")?;
706            ensure!(status.success(), "brew upgrade exited with {status}");
707        }
708        other => bail!("{other:?} installs do not support delegated upgrades"),
709    }
710    println!("mj: upgraded to {version}; restarting");
711    Ok(restart)
712}
713
714/// How the process re-execs after a successful managed upgrade.
715#[derive(Debug, Clone, PartialEq, Eq)]
716enum RestartTarget {
717    /// The upgrade replaced the binary file at this exact path, so re-execing
718    /// it loads the new version (direct replacements and npm bundles, whose
719    /// paths survive `npm install -g`).
720    SameExe(PathBuf),
721    /// Homebrew moves the new release into a fresh Cellar directory and
722    /// repoints its wrappers, so re-execing this process's own Cellar path
723    /// would relaunch the old version. Resolving `mj` on `PATH` runs the
724    /// formula's wrapper, which execs the new libexec binary.
725    Wrapper,
726}
727
728fn managed_restart_target(method: &InstallMethod, current_exe: &Path) -> Result<RestartTarget> {
729    match method {
730        InstallMethod::Npm => Ok(RestartTarget::SameExe(current_exe.to_path_buf())),
731        InstallMethod::Homebrew => Ok(RestartTarget::Wrapper),
732        other => bail!("{other:?} installs do not support delegated upgrades"),
733    }
734}
735
736#[cfg(unix)]
737fn restart_current_process(target: RestartTarget) -> Result<()> {
738    use std::os::unix::process::CommandExt;
739
740    let args: Vec<OsString> = std::env::args_os().skip(1).collect();
741    let mut command = match target {
742        RestartTarget::SameExe(exe) => Command::new(exe),
743        RestartTarget::Wrapper => Command::new("mj"),
744    };
745    let error = command.args(args).exec();
746    Err(error).context("exec replacement mj")
747}
748
749#[cfg(not(unix))]
750fn restart_current_process(_target: RestartTarget) -> Result<()> {
751    bail!("automatic restart is only supported on Unix platforms")
752}
753
754async fn download_apply_and_restart(update: &UpdateInfo) -> Result<()> {
755    println!("mj: downloading {} ({})", update.tag, update.asset.name);
756    let archive = download_bytes(&update.asset.browser_download_url)
757        .await
758        .with_context(|| format!("download {}", update.asset.name))?;
759    verify_checksum(update, &archive).await?;
760
761    let current_exe = std::env::current_exe().context("resolve current executable")?;
762    let replacement = install_release_archive(&current_exe, &update.asset.name, &archive)
763        .context("install release bundle")?;
764
765    println!("mj: upgraded to {}; restarting", update.tag);
766    restart_current_process(RestartTarget::SameExe(replacement))
767}
768
769async fn download_bytes(url: &str) -> Result<Vec<u8>> {
770    let client = reqwest::Client::builder()
771        .timeout(Duration::from_secs(120))
772        .user_agent(concat!("mj/", env!("CARGO_PKG_VERSION")))
773        .build()
774        .context("build http client")?;
775    let resp = client
776        .get(url)
777        .send()
778        .await
779        .with_context(|| format!("GET {url}"))?;
780    let status = resp.status();
781    if !status.is_success() {
782        anyhow::bail!("GET {url}: HTTP {status}");
783    }
784    resp.bytes()
785        .await
786        .map(|bytes| bytes.to_vec())
787        .context("read response body")
788}
789
790async fn verify_checksum(update: &UpdateInfo, archive: &[u8]) -> Result<()> {
791    let body = download_bytes(&update.checksum_asset.browser_download_url)
792        .await
793        .with_context(|| format!("download {}", update.checksum_asset.name))?;
794    let body = String::from_utf8(body).context("checksum file is not utf-8")?;
795    let expected = body
796        .split_whitespace()
797        .next()
798        .ok_or_else(|| anyhow::anyhow!("empty checksum file {}", update.checksum_asset.name))?;
799    let actual = sha256_hex(archive);
800    if expected != actual {
801        bail!(
802            "checksum mismatch for {}: expected {expected}, got {actual}",
803            update.asset.name
804        );
805    }
806    Ok(())
807}
808
809fn sha256_hex(bytes: &[u8]) -> String {
810    let mut hasher = Sha256::new();
811    hasher.update(bytes);
812    lower_hex(hasher.finalize())
813}
814
815/// Extract the complete application bundle before changing any installed file.
816/// Companions can be retired in future releases, but mj itself is mandatory.
817fn install_release_archive(
818    current_exe: &Path,
819    archive_name: &str,
820    archive_bytes: &[u8],
821) -> Result<PathBuf> {
822    ensure!(
823        cfg!(unix),
824        "self-update replacement is only supported on Unix platforms"
825    );
826    let target_exe = current_exe
827        .canonicalize()
828        .with_context(|| format!("resolve executable target {}", current_exe.display()))?;
829    let parent = target_exe
830        .parent()
831        .ok_or_else(|| anyhow::anyhow!("executable has no parent: {}", target_exe.display()))?;
832    // Keep staging on the destination filesystem so each replacement is an
833    // atomic rename, including binaries that are currently running.
834    let staging = tempfile::Builder::new()
835        .prefix(".mj-self-update-")
836        .tempdir_in(parent)
837        .context("create update staging directory")?;
838    let mut binaries = stage_release_archive(archive_name, archive_bytes, staging.path())?;
839    let executable_name = if archive_name.ends_with(".zip") {
840        WINDOWS_BIN_NAME
841    } else {
842        BIN_NAME
843    };
844    ensure!(
845        staging.path().join(executable_name).is_file(),
846        "archive did not contain expected binary: {executable_name}"
847    );
848    // Replace the controller last, once every packaged companion is installed.
849    binaries.sort_by_key(|path| path.file_name() == Some(executable_name.as_ref()));
850    strip_quarantine(staging.path());
851    for binary in binaries {
852        let name = binary
853            .file_name()
854            .context("staged binary has no file name")?;
855        let target = if name == executable_name {
856            target_exe.clone()
857        } else {
858            parent.join(name)
859        };
860        std::fs::rename(&binary, &target)
861            .with_context(|| format!("install {}", target.display()))?;
862    }
863    Ok(target_exe)
864}
865
866fn stage_release_archive(
867    archive_name: &str,
868    archive_bytes: &[u8],
869    directory: &Path,
870) -> Result<Vec<PathBuf>> {
871    let mut binaries = Vec::new();
872    if archive_name.ends_with(".zip") {
873        let mut archive =
874            zip::ZipArchive::new(Cursor::new(archive_bytes)).context("open zip archive")?;
875        for index in 0..archive.len() {
876            let mut entry = archive.by_index(index).context("read zip entry")?;
877            let path = entry.enclosed_name().ok_or_else(|| {
878                anyhow::anyhow!("zip entry escapes destination: {}", entry.name())
879            })?;
880            let is_file = entry.is_file() && !entry.is_symlink();
881            if let Some(binary) = stage_archive_binary(directory, &path, is_file, &mut entry)? {
882                binaries.push(binary);
883            }
884        }
885    } else {
886        let mut archive = tar::Archive::new(GzDecoder::new(archive_bytes));
887        for entry in archive.entries().context("read tar entries")? {
888            let mut entry = entry.context("read tar entry")?;
889            let path = entry.path().context("read tar entry path")?.into_owned();
890            let is_file = entry.header().entry_type().is_file();
891            if let Some(binary) = stage_archive_binary(directory, &path, is_file, &mut entry)? {
892                binaries.push(binary);
893            }
894        }
895    }
896    Ok(binaries)
897}
898
899fn stage_archive_binary(
900    directory: &Path,
901    path: &Path,
902    is_file: bool,
903    mut contents: impl Read,
904) -> Result<Option<PathBuf>> {
905    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
906        return Ok(None);
907    };
908    let stem = name.strip_suffix(".exe").unwrap_or(name);
909    if !matches!(
910        stem,
911        BIN_NAME | "mj-desktop" | VOICE_WORKER_NAME | "mj-worker"
912    ) && !stem.starts_with("mj-worker-")
913    {
914        return Ok(None);
915    }
916    ensure!(
917        is_file,
918        "archive binary is not a regular file: {}",
919        path.display()
920    );
921    let target = directory.join(name);
922    let mut output = std::fs::File::create_new(&target)
923        .with_context(|| format!("stage {name}; each binary must appear only once"))?;
924    let size = io::copy(&mut contents, &mut output).with_context(|| format!("extract {name}"))?;
925    ensure!(size != 0, "archive contained an empty {name} binary");
926    #[cfg(unix)]
927    {
928        use std::os::unix::fs::PermissionsExt;
929        output
930            .set_permissions(std::fs::Permissions::from_mode(0o755))
931            .with_context(|| format!("chmod {name}"))?;
932    }
933    Ok(Some(target))
934}
935
936#[cfg(unix)]
937fn strip_quarantine(path: &Path) {
938    #[cfg(target_os = "macos")]
939    {
940        // Downloads inherit a quarantine attribute on macOS; Gatekeeper
941        // would refuse to exec the replacement without this.
942        let _ = Command::new("xattr")
943            .arg("-dr")
944            .arg("com.apple.quarantine")
945            .arg(path)
946            .status();
947    }
948    #[cfg(not(target_os = "macos"))]
949    {
950        let _ = path;
951    }
952}
953
954#[cfg(not(unix))]
955fn strip_quarantine(_path: &Path) {}
956
957#[cfg(test)]
958mod tests;