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 std::ffi::OsString;
14use std::io::{self, BufRead, Cursor, IsTerminal, Read, Write};
15use std::path::{Path, PathBuf};
16use std::process::Command;
17use std::time::Duration;
18
19use anyhow::{Context, Result, bail, ensure};
20use flate2::read::GzDecoder;
21use semver::Version;
22use serde::Deserialize;
23use sha2::{Digest, Sha256};
24
25const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/BrokkAi/mjolnir/releases/latest";
26const NPM_LATEST_URL: &str = "https://registry.npmjs.org/@brokkai%2Fmjolnir/latest";
27const HOMEBREW_FORMULA_URL: &str =
28    "https://raw.githubusercontent.com/BrokkAi/homebrew-tap/main/Formula/mjolnir.rb";
29const BIN_NAME: &str = "mj";
30const WINDOWS_BIN_NAME: &str = "mj.exe";
31const VOICE_WORKER_NAME: &str = "mj-voice-worker";
32const NPM_MANAGED_ENV: &str = "MJOLNIR_MANAGED_BY_NPM";
33const NPX_MANAGED_ENV: &str = "MJOLNIR_MANAGED_BY_NPX";
34const HOMEBREW_MANAGED_ENV: &str = "MJOLNIR_MANAGED_BY_HOMEBREW";
35const NO_UPDATE_CHECK_ENV: &str = "MJOLNIR_NO_UPDATE_CHECK";
36
37/// The endpoints a channel consults, grouped so loopback tests can point
38/// every fetch at a local server instead of the real registries.
39#[derive(Debug, Clone)]
40struct UpdateSources {
41    latest_release: String,
42    npm_latest: String,
43    homebrew_formula: String,
44    cargo_index: String,
45}
46
47impl Default for UpdateSources {
48    fn default() -> Self {
49        Self {
50            latest_release: LATEST_RELEASE_URL.to_string(),
51            npm_latest: NPM_LATEST_URL.to_string(),
52            homebrew_formula: HOMEBREW_FORMULA_URL.to_string(),
53            cargo_index: CARGO_INDEX_URL.to_string(),
54        }
55    }
56}
57
58/// How the running `mj` binary was installed. Decides both where the latest
59/// version is published and who is allowed to replace the binary: package
60/// managers own their trees, so upgrades there run the manager's own
61/// command, while a curl install may only be replaced in place.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum InstallMethod {
64    Npm,
65    Npx,
66    Homebrew,
67    Cargo { voice_worker: bool },
68    Direct,
69}
70
71impl InstallMethod {
72    /// Detects the install method from the launcher-declared environment
73    /// markers, falling back to executable-path forensics. The markers are
74    /// set by `npm/launcher/mj.js` and the Homebrew formula's wrapper script.
75    fn current() -> Self {
76        Self::detect(
77            |name| std::env::var_os(name),
78            std::env::current_exe().ok().as_deref(),
79        )
80    }
81
82    fn detect<F>(env: F, exe: Option<&Path>) -> Self
83    where
84        F: Fn(&str) -> Option<OsString>,
85    {
86        if env(NPX_MANAGED_ENV).is_some() {
87            return Self::Npx;
88        }
89        if env(NPM_MANAGED_ENV).is_some() {
90            return Self::Npm;
91        }
92        if env(HOMEBREW_MANAGED_ENV).is_some() {
93            return Self::Homebrew;
94        }
95        exe.map_or(Self::Direct, |exe| {
96            install_method_from_exe(exe, env!("CARGO_PKG_VERSION"))
97        })
98    }
99
100    /// The command a person would run to upgrade through this channel. Used
101    /// verbatim for the notice-only channels and as the basis for the
102    /// delegated upgrade of npm and Homebrew.
103    fn update_command(&self) -> Option<String> {
104        match self {
105            Self::Npm => Some("npm install -g @brokkai/mjolnir@latest".to_string()),
106            Self::Npx => Some("npx -y @brokkai/mjolnir@latest".to_string()),
107            Self::Homebrew => Some("brew upgrade mjolnir".to_string()),
108            Self::Cargo { voice_worker: true } => {
109                Some("cargo install --locked brokk-mjolnir brokk-mj-voice-worker".to_string())
110            }
111            Self::Cargo {
112                voice_worker: false,
113            } => Some("cargo install --locked brokk-mjolnir".to_string()),
114            Self::Direct => None,
115        }
116    }
117
118    fn channel_name(&self) -> &'static str {
119        match self {
120            Self::Npm | Self::Npx => "npm",
121            Self::Homebrew => "Homebrew",
122            Self::Cargo { .. } => "crates.io",
123            Self::Direct => "GitHub Releases",
124        }
125    }
126}
127
128fn install_method_from_exe(exe_path: &Path, current_version: &str) -> InstallMethod {
129    if is_homebrew_executable(exe_path) {
130        return InstallMethod::Homebrew;
131    }
132    if is_npm_bundle_executable(exe_path) {
133        return InstallMethod::Npm;
134    }
135
136    let Some(install_root) = cargo_install_root(exe_path, current_version) else {
137        return InstallMethod::Direct;
138    };
139    InstallMethod::Cargo {
140        voice_worker: cargo_install_recorded(
141            &install_root,
142            "brokk-mj-voice-worker",
143            None,
144            VOICE_WORKER_NAME,
145        ),
146    }
147}
148
149fn is_homebrew_executable(exe_path: &Path) -> bool {
150    let components = path_text_components(exe_path);
151    components
152        .windows(2)
153        .any(|pair| pair == ["Cellar", "mjolnir"])
154}
155
156/// Recognizes an npm bundle binary even when the launcher's marker is
157/// missing: without this, an env-less npm install would be misread as a
158/// direct install and the self-replace path would write inside
159/// `node_modules`, corrupting npm's package database.
160fn is_npm_bundle_executable(exe_path: &Path) -> bool {
161    let components = path_text_components(exe_path);
162    components
163        .windows(2)
164        .any(|pair| pair == ["node_modules", "@brokkai"])
165}
166
167fn path_text_components(exe_path: &Path) -> Vec<&str> {
168    exe_path
169        .components()
170        .filter_map(|component| component.as_os_str().to_str())
171        .collect()
172}
173
174fn cargo_install_root(exe_path: &Path, current_version: &str) -> Option<PathBuf> {
175    let canonical_exe = exe_path.canonicalize().ok()?;
176    let bin_dir = canonical_exe.parent()?;
177    if bin_dir.file_name()? != "bin" {
178        return None;
179    }
180    let install_root = bin_dir.parent()?;
181    cargo_install_recorded(
182        install_root,
183        "brokk-mjolnir",
184        Some(current_version),
185        BIN_NAME,
186    )
187    .then(|| install_root.to_path_buf())
188}
189
190fn cargo_install_recorded(
191    install_root: &Path,
192    package: &str,
193    version: Option<&str>,
194    binary: &str,
195) -> bool {
196    cargo_json_install_recorded(install_root, package, version, binary)
197        || cargo_toml_install_recorded(install_root, package, version, binary)
198}
199
200fn cargo_json_install_recorded(
201    install_root: &Path,
202    package: &str,
203    version: Option<&str>,
204    binary: &str,
205) -> bool {
206    let Ok(raw) = std::fs::read_to_string(install_root.join(".crates2.json")) else {
207        return false;
208    };
209    let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&raw) else {
210        return false;
211    };
212    manifest
213        .get("installs")
214        .and_then(serde_json::Value::as_object)
215        .is_some_and(|installs| {
216            installs.iter().any(|(source, record)| {
217                cargo_source_matches(source, package, version)
218                    && record
219                        .get("bins")
220                        .and_then(serde_json::Value::as_array)
221                        .is_some_and(|bins| bins.iter().any(|name| name.as_str() == Some(binary)))
222            })
223        })
224}
225
226fn cargo_toml_install_recorded(
227    install_root: &Path,
228    package: &str,
229    version: Option<&str>,
230    binary: &str,
231) -> bool {
232    let Ok(raw) = std::fs::read_to_string(install_root.join(".crates.toml")) else {
233        return false;
234    };
235    let Ok(manifest) = raw.parse::<toml::Value>() else {
236        return false;
237    };
238    manifest
239        .get("v1")
240        .and_then(toml::Value::as_table)
241        .is_some_and(|installs| {
242            installs.iter().any(|(source, bins)| {
243                cargo_source_matches(source, package, version)
244                    && bins
245                        .as_array()
246                        .is_some_and(|bins| bins.iter().any(|name| name.as_str() == Some(binary)))
247            })
248        })
249}
250
251fn cargo_source_matches(source: &str, package: &str, version: Option<&str>) -> bool {
252    let Some(rest) = source
253        .strip_prefix(package)
254        .and_then(|rest| rest.strip_prefix(' '))
255    else {
256        return false;
257    };
258    let Some(recorded_version) = rest.split_whitespace().next() else {
259        return false;
260    };
261    version.is_none_or(|expected| recorded_version == expected)
262}
263
264/// An upgrade that the running process can perform itself: the release
265/// archive and its checksum sidecar, ready to download.
266#[derive(Debug, Clone, PartialEq, Eq)]
267struct UpdateInfo {
268    version: Version,
269    tag: String,
270    asset: ReleaseAsset,
271    checksum_asset: ReleaseAsset,
272}
273
274/// What a channel reports. `Managed` upgrades delegate to the package
275/// manager; `Direct` upgrades replace the running binary from the release
276/// archive.
277#[derive(Debug, Clone, PartialEq, Eq)]
278enum AvailableUpdate {
279    Managed {
280        version: Version,
281        method: InstallMethod,
282    },
283    Direct(UpdateInfo),
284}
285
286#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
287struct GitHubRelease {
288    tag_name: String,
289    #[serde(default)]
290    assets: Vec<ReleaseAsset>,
291}
292
293#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
294struct ReleaseAsset {
295    name: String,
296    browser_download_url: String,
297}
298
299#[derive(Debug, Deserialize)]
300struct NpmLatest {
301    version: String,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq)]
305struct Platform {
306    os_family: &'static str,
307    arch: &'static str,
308    rust_target: String,
309}
310
311/// Fetches the newest release the running install's channel publishes, or
312/// `None` when the channel is not ahead of the running version.
313async fn latest_update(
314    sources: &UpdateSources,
315    method: &InstallMethod,
316) -> Result<Option<AvailableUpdate>> {
317    let current = parse_version(env!("CARGO_PKG_VERSION"))
318        .with_context(|| format!("parse current version {}", env!("CARGO_PKG_VERSION")))?;
319    if *method == InstallMethod::Direct {
320        let release = fetch_latest_release(sources)
321            .await
322            .context("fetch latest mj release")?;
323        return update_info_from_release(&release, &current, &current_platform()?)
324            .map(|update| update.map(AvailableUpdate::Direct));
325    }
326
327    let latest = fetch_latest_managed_version(sources, method).await?;
328    Ok((latest > current).then(|| AvailableUpdate::Managed {
329        version: latest,
330        method: method.clone(),
331    }))
332}
333
334async fn fetch_latest_release(sources: &UpdateSources) -> Result<GitHubRelease> {
335    let body = fetch_text(&sources.latest_release).await?;
336    serde_json::from_str(&body).context("parse release body")
337}
338
339async fn fetch_latest_managed_version(
340    sources: &UpdateSources,
341    method: &InstallMethod,
342) -> Result<Version> {
343    match method {
344        InstallMethod::Npm | InstallMethod::Npx => {
345            let body = fetch_text(&sources.npm_latest)
346                .await
347                .context("fetch latest npm package")?;
348            let latest: NpmLatest = serde_json::from_str(&body).context("parse npm metadata")?;
349            parse_version(&latest.version).context("parse latest npm version")
350        }
351        InstallMethod::Homebrew => {
352            let body = fetch_text(&sources.homebrew_formula)
353                .await
354                .context("fetch Homebrew formula")?;
355            parse_homebrew_formula_version(&body)
356        }
357        InstallMethod::Cargo { .. } => {
358            // crates.io installs are notice-only, but the notice still needs
359            // to know whether anything newer exists. The sparse index lists
360            // every published version, yanked ones included-and-skipped.
361            let body = fetch_text(&sources.cargo_index)
362                .await
363                .context("fetch crates.io index entry")?;
364            parse_cargo_index_version(&body)
365        }
366        InstallMethod::Direct => anyhow::bail!("direct installs use GitHub release metadata"),
367    }
368}
369
370const CARGO_INDEX_URL: &str = "https://index.crates.io/br/ok/brokk-mjolnir";
371
372async fn fetch_text(url: &str) -> Result<String> {
373    let client = reqwest::Client::builder()
374        .timeout(Duration::from_secs(5))
375        .user_agent(concat!("mj/", env!("CARGO_PKG_VERSION")))
376        .build()
377        .context("build http client")?;
378    let resp = client
379        .get(url)
380        .send()
381        .await
382        .with_context(|| format!("GET {url}"))?;
383    let status = resp.status();
384    if !status.is_success() {
385        anyhow::bail!("GET {url}: HTTP {status}");
386    }
387    resp.text().await.with_context(|| format!("read {url}"))
388}
389
390fn parse_homebrew_formula_version(formula: &str) -> Result<Version> {
391    let raw = formula
392        .lines()
393        .map(str::trim)
394        .find_map(|line| line.strip_prefix("version \"")?.strip_suffix('"'))
395        .ok_or_else(|| anyhow::anyhow!("Homebrew formula has no version"))?;
396    parse_version(raw).context("parse Homebrew formula version")
397}
398
399fn parse_cargo_index_version(index: &str) -> Result<Version> {
400    let mut latest: Option<Version> = None;
401    for line in index.lines().filter(|line| !line.trim().is_empty()) {
402        let entry: CargoIndexEntry =
403            serde_json::from_str(line).context("parse crates.io index entry")?;
404        if entry.yanked {
405            continue;
406        }
407        let version = parse_version(&entry.vers).context("parse crates.io package version")?;
408        if latest.as_ref().is_none_or(|current| version > *current) {
409            latest = Some(version);
410        }
411    }
412    latest.ok_or_else(|| anyhow::anyhow!("crates.io index has no published versions"))
413}
414
415#[derive(Debug, Deserialize)]
416struct CargoIndexEntry {
417    vers: String,
418    #[serde(default)]
419    yanked: bool,
420}
421
422fn update_info_from_release(
423    release: &GitHubRelease,
424    current: &Version,
425    platform: &Platform,
426) -> Result<Option<UpdateInfo>> {
427    let latest = parse_version(&release.tag_name)
428        .with_context(|| format!("parse release tag {}", release.tag_name))?;
429    if latest <= *current {
430        return Ok(None);
431    }
432
433    let asset = select_mj_asset(&release.assets, platform)
434        .with_context(|| format!("find mj asset for {}/{}", platform.os_family, platform.arch))?;
435    let checksum_name = format!("{}.sha256", asset.name);
436    let checksum_asset = release
437        .assets
438        .iter()
439        .find(|candidate| candidate.name == checksum_name)
440        .cloned()
441        .ok_or_else(|| {
442            anyhow::anyhow!(
443                "release {} is missing required checksum asset {}",
444                release.tag_name,
445                checksum_name
446            )
447        })?;
448
449    Ok(Some(UpdateInfo {
450        version: latest,
451        tag: release.tag_name.clone(),
452        asset,
453        checksum_asset,
454    }))
455}
456
457fn select_mj_asset(assets: &[ReleaseAsset], platform: &Platform) -> Result<ReleaseAsset> {
458    let target_suffix = format!(
459        "-{}{}",
460        platform.rust_target,
461        platform_archive_ext(platform)
462    );
463    if platform.os_family == "macos"
464        && let Some(asset) = assets.iter().find(|asset| {
465            is_mj_archive(&asset.name) && asset.name.ends_with("-universal-apple-darwin.tar.gz")
466        })
467    {
468        return Ok(asset.clone());
469    }
470    assets
471        .iter()
472        .find(|asset| is_mj_archive(&asset.name) && asset.name.ends_with(&target_suffix))
473        .cloned()
474        .ok_or_else(|| {
475            anyhow::anyhow!(
476                "no mj archive found for target {}; available assets: {}",
477                platform.rust_target,
478                assets
479                    .iter()
480                    .filter(|asset| !asset.name.ends_with(".sha256"))
481                    .map(|asset| asset.name.as_str())
482                    .collect::<Vec<_>>()
483                    .join(", ")
484            )
485        })
486}
487
488fn is_mj_archive(name: &str) -> bool {
489    name.starts_with("brokk-mjolnir-") && (name.ends_with(".tar.gz") || name.ends_with(".zip"))
490}
491
492fn platform_archive_ext(platform: &Platform) -> &'static str {
493    if platform.os_family == "windows" {
494        ".zip"
495    } else {
496        ".tar.gz"
497    }
498}
499
500fn current_platform() -> Result<Platform> {
501    let arch = match std::env::consts::ARCH {
502        "x86_64" => "x86_64",
503        "aarch64" | "arm64" => "aarch64",
504        other => anyhow::bail!("unsupported CPU architecture: {other}"),
505    };
506    let (os_family, rust_os) = match std::env::consts::OS {
507        "android" => ("android", "linux-android"),
508        "macos" => ("macos", "apple-darwin"),
509        "linux" => ("linux", "unknown-linux-gnu"),
510        "windows" => ("windows", "pc-windows-msvc"),
511        other => anyhow::bail!("unsupported OS: {other}"),
512    };
513
514    Ok(Platform {
515        os_family,
516        arch,
517        rust_target: format!("{arch}-{rust_os}"),
518    })
519}
520
521fn parse_version(raw: &str) -> Result<Version> {
522    Version::parse(raw.trim_start_matches('v')).with_context(|| format!("parse version {raw}"))
523}
524
525/// What the startup check decided. A successful upgrade never produces a
526/// value: the process re-execs into the new binary.
527#[derive(Debug, Clone, PartialEq, Eq)]
528pub enum StartupUpdateOutcome {
529    /// The check did not run (debug build, non-interactive, disabled, or it
530    /// failed without affecting the session).
531    Skipped,
532    UpToDate,
533    /// The channel only allows announcing the upgrade command; the user must
534    /// run it themselves.
535    Notified,
536    Declined,
537}
538
539/// Checks the running install's channel for a newer release, asks before
540/// changing anything, and on consent performs the upgrade:
541///
542/// - npm installs run `npm install -g @brokkai/mjolnir@latest` and Homebrew
543///   installs run `brew update` followed by `brew upgrade mjolnir`; mj never
544///   writes into `node_modules` or the Cellar itself, because those trees
545///   belong to the package managers.
546/// - curl installs download the release archive, verify its SHA-256 sidecar,
547///   update the controller and every bundled application helper, and re-exec.
548/// - npx and cargo installs are notice-only.
549///
550/// Runs on every interactive startup before the daemon or dashboard starts.
551/// The version fetch is time-boxed, and interactive package-manager runs share
552/// the terminal like any foreground command, so this must never be called
553/// from a UI render path.
554pub async fn check_prompt_and_apply() -> StartupUpdateOutcome {
555    // The controller ships for Linux and macOS (Windows users run WSL2), so
556    // there is no Windows replacer to maintain; debug builds are developers,
557    // who upgrade themselves.
558    if cfg!(windows)
559        || cfg!(debug_assertions)
560        || !io::stdin().is_terminal()
561        || !io::stdout().is_terminal()
562        || std::env::var_os(NO_UPDATE_CHECK_ENV).is_some()
563    {
564        return StartupUpdateOutcome::Skipped;
565    }
566
567    let method = InstallMethod::current();
568
569    let update = match latest_update(&UpdateSources::default(), &method).await {
570        Ok(Some(update)) => update,
571        Ok(None) => return StartupUpdateOutcome::UpToDate,
572        Err(error) => {
573            // A broken check must never keep someone out of mj.
574            eprintln!("mj: update check failed: {error:#}");
575            return StartupUpdateOutcome::Skipped;
576        }
577    };
578
579    match update {
580        AvailableUpdate::Direct(update) => {
581            if !prompt_for_update(&update.version, &InstallMethod::Direct).unwrap_or(false) {
582                return StartupUpdateOutcome::Declined;
583            }
584            if let Err(error) = download_apply_and_restart(&update).await {
585                eprintln!("mj: upgrade failed: {error:#}");
586                eprintln!("mj: continuing with {}", env!("CARGO_PKG_VERSION"));
587            }
588            // The success path re-execs and never returns; reaching this
589            // line means the upgrade failed and the current process lives on.
590            StartupUpdateOutcome::Skipped
591        }
592        AvailableUpdate::Managed { version, method } => match method {
593            InstallMethod::Npm | InstallMethod::Homebrew => {
594                if !prompt_for_update(&version, &method).unwrap_or(false) {
595                    return StartupUpdateOutcome::Declined;
596                }
597                let upgraded =
598                    run_managed_upgrade(&version, &method).and_then(restart_current_process);
599                if let Err(error) = upgraded {
600                    eprintln!("mj: upgrade failed: {error:#}");
601                    eprintln!("mj: continuing with {}", env!("CARGO_PKG_VERSION"));
602                }
603                StartupUpdateOutcome::Skipped
604            }
605            InstallMethod::Npx | InstallMethod::Cargo { .. } => {
606                let notice = managed_update_notice(&version, &method, env!("CARGO_PKG_VERSION"))
607                    .expect("notice-only channels have an update command");
608                println!("{notice}");
609                StartupUpdateOutcome::Notified
610            }
611            InstallMethod::Direct => {
612                unreachable!("direct installs never report managed updates")
613            }
614        },
615    }
616}
617
618fn prompt_for_update(version: &Version, method: &InstallMethod) -> Result<bool> {
619    print!(
620        "mj {version} is available through {}; current version is {}. Upgrade now? [Y/n] ",
621        method.channel_name(),
622        env!("CARGO_PKG_VERSION")
623    );
624    io::stdout().flush().context("flush update prompt")?;
625
626    read_update_answer(&mut io::stdin().lock())
627}
628
629fn read_update_answer(input: &mut impl BufRead) -> Result<bool> {
630    let mut answer = String::new();
631    let bytes_read = input
632        .read_line(&mut answer)
633        .context("read update prompt answer")?;
634    Ok(bytes_read != 0 && prompt_answer_is_yes(&answer))
635}
636
637/// An empty answer accepts, matching the 1.x prompt's default.
638fn prompt_answer_is_yes(answer: &str) -> bool {
639    matches!(answer.trim(), "" | "y" | "Y" | "yes" | "YES")
640}
641
642fn managed_update_notice(
643    version: &Version,
644    method: &InstallMethod,
645    current_version: &str,
646) -> Option<String> {
647    Some(format!(
648        "mj {version} is available through {}; current version is {current_version}. Run: {}",
649        method.channel_name(),
650        method.update_command()?
651    ))
652}
653
654fn npm_upgrade_command() -> Command {
655    let mut command = Command::new("npm");
656    command.args(["install", "-g", "@brokkai/mjolnir@latest"]);
657    command
658}
659
660fn brew_update_command() -> Command {
661    let mut command = Command::new("brew");
662    command.arg("update");
663    command
664}
665
666fn brew_upgrade_command() -> Command {
667    let mut command = Command::new("brew");
668    command.args(["upgrade", "mjolnir"]);
669    command
670}
671
672/// Runs the channel's own upgrade command in the foreground with its live
673/// output on the terminal. `run_inherited` keeps stdin closed so neither
674/// package manager can stop to ask a question nobody is there to answer.
675fn run_managed_upgrade(version: &Version, method: &InstallMethod) -> Result<RestartTarget> {
676    // npm moves and unlinks the old package. Resolve this before the upgrade,
677    // while current_exe still identifies the installation's stable path.
678    let current_exe = std::env::current_exe().context("resolve current executable")?;
679    let restart = managed_restart_target(method, &current_exe)?;
680    match method {
681        InstallMethod::Npm => {
682            println!("mj: running npm install -g @brokkai/mjolnir@latest");
683            let status = mj_core::subprocess::run_inherited(&mut npm_upgrade_command())
684                .context("run npm install -g @brokkai/mjolnir@latest")?;
685            ensure!(
686                status.success(),
687                "npm install exited with {status}; npm usually explains why above"
688            );
689        }
690        InstallMethod::Homebrew => {
691            // The version check read the tap formula on GitHub, but the
692            // local brew only knows about it after its index refreshes;
693            // without `brew update` the upgrade would report "already
694            // up-to-date" on a fresh release.
695            println!("mj: running brew update");
696            let status = mj_core::subprocess::run_inherited(&mut brew_update_command())
697                .context("run brew update")?;
698            ensure!(status.success(), "brew update exited with {status}");
699            println!("mj: running brew upgrade mjolnir");
700            let status = mj_core::subprocess::run_inherited(&mut brew_upgrade_command())
701                .context("run brew upgrade mjolnir")?;
702            ensure!(status.success(), "brew upgrade exited with {status}");
703        }
704        other => bail!("{other:?} installs do not support delegated upgrades"),
705    }
706    println!("mj: upgraded to {version}; restarting");
707    Ok(restart)
708}
709
710/// How the process re-execs after a successful managed upgrade.
711#[derive(Debug, Clone, PartialEq, Eq)]
712enum RestartTarget {
713    /// The upgrade replaced the binary file at this exact path, so re-execing
714    /// it loads the new version (direct replacements and npm bundles, whose
715    /// paths survive `npm install -g`).
716    SameExe(PathBuf),
717    /// Homebrew moves the new release into a fresh Cellar directory and
718    /// repoints its wrappers, so re-execing this process's own Cellar path
719    /// would relaunch the old version. Resolving `mj` on `PATH` runs the
720    /// formula's wrapper, which execs the new libexec binary.
721    Wrapper,
722}
723
724fn managed_restart_target(method: &InstallMethod, current_exe: &Path) -> Result<RestartTarget> {
725    match method {
726        InstallMethod::Npm => Ok(RestartTarget::SameExe(current_exe.to_path_buf())),
727        InstallMethod::Homebrew => Ok(RestartTarget::Wrapper),
728        other => bail!("{other:?} installs do not support delegated upgrades"),
729    }
730}
731
732#[cfg(unix)]
733fn restart_current_process(target: RestartTarget) -> Result<()> {
734    use std::os::unix::process::CommandExt;
735
736    let args: Vec<OsString> = std::env::args_os().skip(1).collect();
737    let mut command = match target {
738        RestartTarget::SameExe(exe) => Command::new(exe),
739        RestartTarget::Wrapper => Command::new("mj"),
740    };
741    let error = command.args(args).exec();
742    Err(error).context("exec replacement mj")
743}
744
745#[cfg(not(unix))]
746fn restart_current_process(_target: RestartTarget) -> Result<()> {
747    bail!("automatic restart is only supported on Unix platforms")
748}
749
750async fn download_apply_and_restart(update: &UpdateInfo) -> Result<()> {
751    println!("mj: downloading {} ({})", update.tag, update.asset.name);
752    let archive = download_bytes(&update.asset.browser_download_url)
753        .await
754        .with_context(|| format!("download {}", update.asset.name))?;
755    verify_checksum(update, &archive).await?;
756
757    let current_exe = std::env::current_exe().context("resolve current executable")?;
758    let replacement = install_release_archive(&current_exe, &update.asset.name, &archive)
759        .context("install release bundle")?;
760
761    println!("mj: upgraded to {}; restarting", update.tag);
762    restart_current_process(RestartTarget::SameExe(replacement))
763}
764
765async fn download_bytes(url: &str) -> Result<Vec<u8>> {
766    let client = reqwest::Client::builder()
767        .timeout(Duration::from_secs(120))
768        .user_agent(concat!("mj/", env!("CARGO_PKG_VERSION")))
769        .build()
770        .context("build http client")?;
771    let resp = client
772        .get(url)
773        .send()
774        .await
775        .with_context(|| format!("GET {url}"))?;
776    let status = resp.status();
777    if !status.is_success() {
778        anyhow::bail!("GET {url}: HTTP {status}");
779    }
780    resp.bytes()
781        .await
782        .map(|bytes| bytes.to_vec())
783        .context("read response body")
784}
785
786async fn verify_checksum(update: &UpdateInfo, archive: &[u8]) -> Result<()> {
787    let body = download_bytes(&update.checksum_asset.browser_download_url)
788        .await
789        .with_context(|| format!("download {}", update.checksum_asset.name))?;
790    let body = String::from_utf8(body).context("checksum file is not utf-8")?;
791    let expected = body
792        .split_whitespace()
793        .next()
794        .ok_or_else(|| anyhow::anyhow!("empty checksum file {}", update.checksum_asset.name))?;
795    let actual = sha256_hex(archive);
796    if expected != actual {
797        bail!(
798            "checksum mismatch for {}: expected {expected}, got {actual}",
799            update.asset.name
800        );
801    }
802    Ok(())
803}
804
805fn sha256_hex(bytes: &[u8]) -> String {
806    let mut hasher = Sha256::new();
807    hasher.update(bytes);
808    format!("{:x}", hasher.finalize())
809}
810
811/// Extract the complete application bundle before changing any installed file.
812/// Companions can be retired in future releases, but mj itself is mandatory.
813fn install_release_archive(
814    current_exe: &Path,
815    archive_name: &str,
816    archive_bytes: &[u8],
817) -> Result<PathBuf> {
818    ensure!(
819        cfg!(unix),
820        "self-update replacement is only supported on Unix platforms"
821    );
822    let target_exe = current_exe
823        .canonicalize()
824        .with_context(|| format!("resolve executable target {}", current_exe.display()))?;
825    let parent = target_exe
826        .parent()
827        .ok_or_else(|| anyhow::anyhow!("executable has no parent: {}", target_exe.display()))?;
828    // Keep staging on the destination filesystem so each replacement is an
829    // atomic rename, including binaries that are currently running.
830    let staging = tempfile::Builder::new()
831        .prefix(".mj-self-update-")
832        .tempdir_in(parent)
833        .context("create update staging directory")?;
834    let mut binaries = stage_release_archive(archive_name, archive_bytes, staging.path())?;
835    let executable_name = if archive_name.ends_with(".zip") {
836        WINDOWS_BIN_NAME
837    } else {
838        BIN_NAME
839    };
840    ensure!(
841        staging.path().join(executable_name).is_file(),
842        "archive did not contain expected binary: {executable_name}"
843    );
844    // Replace the controller last, once every packaged companion is installed.
845    binaries.sort_by_key(|path| path.file_name() == Some(executable_name.as_ref()));
846    strip_quarantine(staging.path());
847    for binary in binaries {
848        let name = binary
849            .file_name()
850            .context("staged binary has no file name")?;
851        let target = if name == executable_name {
852            target_exe.clone()
853        } else {
854            parent.join(name)
855        };
856        std::fs::rename(&binary, &target)
857            .with_context(|| format!("install {}", target.display()))?;
858    }
859    Ok(target_exe)
860}
861
862fn stage_release_archive(
863    archive_name: &str,
864    archive_bytes: &[u8],
865    directory: &Path,
866) -> Result<Vec<PathBuf>> {
867    let mut binaries = Vec::new();
868    if archive_name.ends_with(".zip") {
869        let mut archive =
870            zip::ZipArchive::new(Cursor::new(archive_bytes)).context("open zip archive")?;
871        for index in 0..archive.len() {
872            let mut entry = archive.by_index(index).context("read zip entry")?;
873            let path = entry.enclosed_name().ok_or_else(|| {
874                anyhow::anyhow!("zip entry escapes destination: {}", entry.name())
875            })?;
876            let is_file = entry.is_file() && !entry.is_symlink();
877            if let Some(binary) = stage_archive_binary(directory, &path, is_file, &mut entry)? {
878                binaries.push(binary);
879            }
880        }
881    } else {
882        let mut archive = tar::Archive::new(GzDecoder::new(archive_bytes));
883        for entry in archive.entries().context("read tar entries")? {
884            let mut entry = entry.context("read tar entry")?;
885            let path = entry.path().context("read tar entry path")?.into_owned();
886            let is_file = entry.header().entry_type().is_file();
887            if let Some(binary) = stage_archive_binary(directory, &path, is_file, &mut entry)? {
888                binaries.push(binary);
889            }
890        }
891    }
892    Ok(binaries)
893}
894
895fn stage_archive_binary(
896    directory: &Path,
897    path: &Path,
898    is_file: bool,
899    mut contents: impl Read,
900) -> Result<Option<PathBuf>> {
901    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
902        return Ok(None);
903    };
904    let stem = name.strip_suffix(".exe").unwrap_or(name);
905    if !matches!(
906        stem,
907        BIN_NAME | "mj-desktop" | VOICE_WORKER_NAME | "mj-worker"
908    ) && !stem.starts_with("mj-worker-")
909    {
910        return Ok(None);
911    }
912    ensure!(
913        is_file,
914        "archive binary is not a regular file: {}",
915        path.display()
916    );
917    let target = directory.join(name);
918    let mut output = std::fs::File::create_new(&target)
919        .with_context(|| format!("stage {name}; each binary must appear only once"))?;
920    let size = io::copy(&mut contents, &mut output).with_context(|| format!("extract {name}"))?;
921    ensure!(size != 0, "archive contained an empty {name} binary");
922    #[cfg(unix)]
923    {
924        use std::os::unix::fs::PermissionsExt;
925        output
926            .set_permissions(std::fs::Permissions::from_mode(0o755))
927            .with_context(|| format!("chmod {name}"))?;
928    }
929    Ok(Some(target))
930}
931
932#[cfg(unix)]
933fn strip_quarantine(path: &Path) {
934    #[cfg(target_os = "macos")]
935    {
936        // Downloads inherit a quarantine attribute on macOS; Gatekeeper
937        // would refuse to exec the replacement without this.
938        let _ = Command::new("xattr")
939            .arg("-dr")
940            .arg("com.apple.quarantine")
941            .arg(path)
942            .status();
943    }
944    #[cfg(not(target_os = "macos"))]
945    {
946        let _ = path;
947    }
948}
949
950#[cfg(not(unix))]
951fn strip_quarantine(_path: &Path) {}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956
957    fn exe(path: &str) -> Option<&Path> {
958        Some(Path::new(path))
959    }
960
961    fn detect_with_env(markers: &[&str], exe_path: Option<&Path>) -> InstallMethod {
962        InstallMethod::detect(
963            |name| markers.contains(&name).then(|| OsString::from("true")),
964            exe_path,
965        )
966    }
967
968    #[test]
969    fn launcher_markers_override_path_forensics() {
970        let npm_bundle = "/usr/lib/node_modules/@brokkai/mjolnir-linux-x64-gnu/bin/mj";
971        assert_eq!(
972            detect_with_env(&[NPM_MANAGED_ENV], exe("/home/user/.local/bin/mj")),
973            InstallMethod::Npm
974        );
975        assert_eq!(
976            detect_with_env(&[NPX_MANAGED_ENV, NPM_MANAGED_ENV], exe(npm_bundle)),
977            InstallMethod::Npx
978        );
979        assert_eq!(
980            detect_with_env(&[HOMEBREW_MANAGED_ENV], exe(npm_bundle)),
981            InstallMethod::Homebrew
982        );
983    }
984
985    #[test]
986    fn marker_free_detection_reads_the_executable_path() {
987        assert_eq!(
988            InstallMethod::detect(
989                |_| None,
990                exe("/opt/homebrew/Cellar/mjolnir/2.4.0/libexec/mj")
991            ),
992            InstallMethod::Homebrew
993        );
994        assert_eq!(
995            InstallMethod::detect(
996                |_| None,
997                exe("/home/linuxbrew/.linuxbrew/Cellar/mjolnir/2.4.0/libexec/mj")
998            ),
999            InstallMethod::Homebrew
1000        );
1001        assert_eq!(
1002            InstallMethod::detect(
1003                |_| None,
1004                exe(
1005                    "/usr/lib/node_modules/@brokkai/mjolnir/node_modules/@brokkai/mjolnir-linux-x64-gnu/bin/mj"
1006                )
1007            ),
1008            InstallMethod::Npm
1009        );
1010        assert_eq!(
1011            InstallMethod::detect(|_| None, exe("/home/user/.local/bin/mj")),
1012            InstallMethod::Direct
1013        );
1014        assert_eq!(InstallMethod::detect(|_| None, None), InstallMethod::Direct);
1015    }
1016
1017    #[test]
1018    fn cargo_detection_requires_a_matching_install_record() {
1019        let root = tempfile::tempdir().expect("tempdir");
1020        let bin_dir = root.path().join("bin");
1021        std::fs::create_dir(&bin_dir).expect("bin dir");
1022        let executable = bin_dir.join(BIN_NAME);
1023        std::fs::write(&executable, b"mj").expect("executable");
1024        let recorded = |body: &str| {
1025            std::fs::write(root.path().join(".crates.toml"), body).expect("manifest");
1026            InstallMethod::detect(|_| None, Some(executable.as_path()))
1027        };
1028        // The record must name the running version, so the fixture derives it
1029        // from the crate instead of hardcoding one: CI builds this tree at
1030        // whatever version master carries, not the version this branch began
1031        // from.
1032        let version = env!("CARGO_PKG_VERSION");
1033        let registry = "registry+https://github.com/rust-lang/crates.io-index";
1034        let record = |package: &str, version: &str, binary: &str| {
1035            format!("\"{package} {version} ({registry})\" = [\"{binary}\"]\n")
1036        };
1037
1038        assert_eq!(
1039            recorded(&format!(
1040                "[v1]\n{}{}",
1041                record("brokk-mjolnir", version, "mj"),
1042                record("brokk-mj-voice-worker", version, "mj-voice-worker"),
1043            )),
1044            InstallMethod::Cargo { voice_worker: true }
1045        );
1046        assert_eq!(
1047            recorded(&format!("[v1]\n{}", record("brokk-mjolnir", version, "mj"),)),
1048            InstallMethod::Cargo {
1049                voice_worker: false
1050            }
1051        );
1052        // A record for a different installed version is not this install.
1053        assert_eq!(
1054            recorded(&format!("[v1]\n{}", record("brokk-mjolnir", "0.0.0", "mj"),)),
1055            InstallMethod::Direct
1056        );
1057    }
1058
1059    #[test]
1060    fn managed_install_methods_provide_their_own_update_commands() {
1061        assert_eq!(
1062            InstallMethod::Npm.update_command().as_deref(),
1063            Some("npm install -g @brokkai/mjolnir@latest")
1064        );
1065        assert_eq!(
1066            InstallMethod::Npx.update_command().as_deref(),
1067            Some("npx -y @brokkai/mjolnir@latest")
1068        );
1069        assert_eq!(
1070            InstallMethod::Homebrew.update_command().as_deref(),
1071            Some("brew upgrade mjolnir")
1072        );
1073        assert_eq!(
1074            InstallMethod::Cargo { voice_worker: true }
1075                .update_command()
1076                .as_deref(),
1077            Some("cargo install --locked brokk-mjolnir brokk-mj-voice-worker")
1078        );
1079        assert_eq!(InstallMethod::Direct.update_command(), None);
1080    }
1081
1082    #[test]
1083    fn channels_name_their_distribution_source() {
1084        assert_eq!(InstallMethod::Npm.channel_name(), "npm");
1085        assert_eq!(InstallMethod::Homebrew.channel_name(), "Homebrew");
1086        assert_eq!(
1087            InstallMethod::Cargo {
1088                voice_worker: false
1089            }
1090            .channel_name(),
1091            "crates.io"
1092        );
1093        assert_eq!(InstallMethod::Direct.channel_name(), "GitHub Releases");
1094    }
1095
1096    #[test]
1097    fn parses_homebrew_formula_version() {
1098        let formula = r#"
1099class Mjolnir < Formula
1100  desc "Session control plane for ACP coding agents"
1101  version "2.5.0"
1102end
1103"#;
1104        assert_eq!(
1105            parse_homebrew_formula_version(formula).expect("version"),
1106            Version::parse("2.5.0").expect("semver")
1107        );
1108        assert!(parse_homebrew_formula_version("class Mjolnir < Formula\nend").is_err());
1109    }
1110
1111    #[test]
1112    fn cargo_index_uses_latest_non_yanked_version() {
1113        let index = concat!(
1114            r#"{"vers":"2.4.0","yanked":false}"#,
1115            "\n",
1116            r#"{"vers":"2.5.0","yanked":true}"#,
1117            "\n",
1118            r#"{"vers":"2.4.2","yanked":false}"#,
1119            "\n",
1120        );
1121        assert_eq!(
1122            parse_cargo_index_version(index).expect("version"),
1123            Version::parse("2.4.2").expect("semver")
1124        );
1125    }
1126
1127    #[test]
1128    fn parse_version_tolerates_release_tags() {
1129        assert_eq!(
1130            parse_version("v2.5.0").expect("version"),
1131            Version::parse("2.5.0").expect("semver")
1132        );
1133    }
1134
1135    fn asset(name: &str) -> ReleaseAsset {
1136        ReleaseAsset {
1137            name: name.to_string(),
1138            browser_download_url: format!("https://example.com/{name}"),
1139        }
1140    }
1141
1142    fn linux_x64() -> Platform {
1143        Platform {
1144            os_family: "linux",
1145            arch: "x86_64",
1146            rust_target: "x86_64-unknown-linux-gnu".to_string(),
1147        }
1148    }
1149
1150    fn mac_arm() -> Platform {
1151        Platform {
1152            os_family: "macos",
1153            arch: "aarch64",
1154            rust_target: "aarch64-apple-darwin".to_string(),
1155        }
1156    }
1157
1158    #[test]
1159    fn release_newer_than_current_returns_update_info() {
1160        let release = GitHubRelease {
1161            tag_name: "v2.5.0".to_string(),
1162            assets: vec![
1163                asset("brokk-mjolnir-v2.5.0-x86_64-unknown-linux-gnu.tar.gz"),
1164                asset("brokk-mjolnir-v2.5.0-x86_64-unknown-linux-gnu.tar.gz.sha256"),
1165            ],
1166        };
1167
1168        let update = update_info_from_release(
1169            &release,
1170            &Version::parse("2.4.0").expect("version"),
1171            &linux_x64(),
1172        )
1173        .expect("update info")
1174        .expect("update");
1175
1176        assert_eq!(update.version, Version::parse("2.5.0").expect("version"));
1177        assert_eq!(
1178            update.asset.name,
1179            "brokk-mjolnir-v2.5.0-x86_64-unknown-linux-gnu.tar.gz"
1180        );
1181        assert_eq!(
1182            update.checksum_asset.name,
1183            "brokk-mjolnir-v2.5.0-x86_64-unknown-linux-gnu.tar.gz.sha256"
1184        );
1185    }
1186
1187    #[test]
1188    fn release_not_newer_returns_none() {
1189        let release = GitHubRelease {
1190            tag_name: "v2.4.0".to_string(),
1191            assets: vec![asset(
1192                "brokk-mjolnir-v2.4.0-x86_64-unknown-linux-gnu.tar.gz",
1193            )],
1194        };
1195
1196        let update = update_info_from_release(
1197            &release,
1198            &Version::parse("2.4.0").expect("version"),
1199            &linux_x64(),
1200        )
1201        .expect("update info");
1202
1203        assert!(update.is_none());
1204    }
1205
1206    #[test]
1207    fn release_newer_than_current_requires_checksum_asset() {
1208        let release = GitHubRelease {
1209            tag_name: "v2.5.0".to_string(),
1210            assets: vec![asset(
1211                "brokk-mjolnir-v2.5.0-x86_64-unknown-linux-gnu.tar.gz",
1212            )],
1213        };
1214
1215        let error = update_info_from_release(
1216            &release,
1217            &Version::parse("2.4.0").expect("version"),
1218            &linux_x64(),
1219        )
1220        .expect_err("missing checksum should fail");
1221
1222        assert!(error
1223            .to_string()
1224            .contains("missing required checksum asset brokk-mjolnir-v2.5.0-x86_64-unknown-linux-gnu.tar.gz.sha256"));
1225    }
1226
1227    #[test]
1228    fn macos_prefers_universal_asset() {
1229        let assets = vec![
1230            asset("brokk-mjolnir-v2.5.0-aarch64-apple-darwin.tar.gz"),
1231            asset("brokk-mjolnir-v2.5.0-universal-apple-darwin.tar.gz"),
1232        ];
1233
1234        let selected = select_mj_asset(&assets, &mac_arm()).expect("select");
1235
1236        assert_eq!(
1237            selected.name,
1238            "brokk-mjolnir-v2.5.0-universal-apple-darwin.tar.gz"
1239        );
1240    }
1241
1242    #[test]
1243    fn linux_selects_target_asset() {
1244        let assets = vec![
1245            asset("brokk-mjolnir-v2.5.0-aarch64-unknown-linux-gnu.tar.gz"),
1246            asset("brokk-mjolnir-v2.5.0-x86_64-unknown-linux-gnu.tar.gz"),
1247        ];
1248
1249        let selected = select_mj_asset(&assets, &linux_x64()).expect("select");
1250
1251        assert_eq!(
1252            selected.name,
1253            "brokk-mjolnir-v2.5.0-x86_64-unknown-linux-gnu.tar.gz"
1254        );
1255    }
1256
1257    #[test]
1258    fn empty_prompt_answer_accepts_the_upgrade() {
1259        assert!(prompt_answer_is_yes(""));
1260        assert!(prompt_answer_is_yes("\n"));
1261        assert!(prompt_answer_is_yes(" y \n"));
1262        assert!(prompt_answer_is_yes("Y"));
1263        assert!(prompt_answer_is_yes("yes"));
1264        assert!(prompt_answer_is_yes("YES"));
1265        assert!(!prompt_answer_is_yes("n"));
1266        assert!(!prompt_answer_is_yes("N"));
1267        assert!(!prompt_answer_is_yes("no"));
1268        assert!(!prompt_answer_is_yes("later"));
1269    }
1270
1271    #[test]
1272    fn prompt_eof_declines_but_enter_accepts() {
1273        assert!(!read_update_answer(&mut Cursor::new(b"")).expect("read EOF"));
1274        assert!(read_update_answer(&mut Cursor::new(b"\n")).expect("read Enter"));
1275        assert!(read_update_answer(&mut Cursor::new(b"yes\n")).expect("read yes"));
1276        assert!(!read_update_answer(&mut Cursor::new(b"n\n")).expect("read no"));
1277    }
1278
1279    #[test]
1280    fn managed_update_notice_names_channel_version_and_command() {
1281        assert_eq!(
1282            managed_update_notice(
1283                &Version::parse("2.5.0").expect("version"),
1284                &InstallMethod::Homebrew,
1285                "2.4.0",
1286            )
1287            .as_deref(),
1288            Some(
1289                "mj 2.5.0 is available through Homebrew; current version is 2.4.0. Run: brew upgrade mjolnir"
1290            )
1291        );
1292        assert_eq!(
1293            managed_update_notice(
1294                &Version::parse("2.5.0").expect("version"),
1295                &InstallMethod::Cargo {
1296                    voice_worker: false
1297                },
1298                "2.4.0",
1299            )
1300            .as_deref(),
1301            Some(
1302                "mj 2.5.0 is available through crates.io; current version is 2.4.0. Run: cargo install --locked brokk-mjolnir"
1303            )
1304        );
1305    }
1306
1307    #[test]
1308    fn delegated_upgrades_run_the_package_managers_own_commands() {
1309        let npm = npm_upgrade_command();
1310        assert_eq!(npm.get_program(), "npm");
1311        let npm_args: Vec<String> = npm
1312            .get_args()
1313            .map(|argument| argument.to_string_lossy().into_owned())
1314            .collect();
1315        assert_eq!(npm_args, ["install", "-g", "@brokkai/mjolnir@latest"]);
1316
1317        let brew_update = brew_update_command();
1318        assert_eq!(brew_update.get_program(), "brew");
1319        assert_eq!(brew_update.get_args().count(), 1);
1320        assert_eq!(brew_update.get_args().next().unwrap(), "update");
1321
1322        let brew_upgrade = brew_upgrade_command();
1323        assert_eq!(brew_upgrade.get_program(), "brew");
1324        let brew_args: Vec<String> = brew_upgrade
1325            .get_args()
1326            .map(|argument| argument.to_string_lossy().into_owned())
1327            .collect();
1328        assert_eq!(brew_args, ["upgrade", "mjolnir"]);
1329    }
1330
1331    #[test]
1332    fn managed_restart_retargets_homebrew_to_its_wrapper() {
1333        // npm must save its installation path before the old bundle is removed.
1334        // Homebrew must re-resolve the wrapper or the
1335        // restart would relaunch the old Cellar version.
1336        let exe = Path::new("/opt/homebrew/Cellar/mjolnir/2.4.0/libexec/mj");
1337        assert_eq!(
1338            managed_restart_target(&InstallMethod::Npm, exe).expect("target"),
1339            RestartTarget::SameExe(exe.to_path_buf())
1340        );
1341        assert_eq!(
1342            managed_restart_target(&InstallMethod::Homebrew, exe).expect("target"),
1343            RestartTarget::Wrapper
1344        );
1345        assert!(managed_restart_target(&InstallMethod::Npx, exe).is_err());
1346    }
1347
1348    #[cfg(target_os = "linux")]
1349    #[test]
1350    fn npm_upgrade_restarts_after_the_running_package_is_removed() {
1351        const FIXTURE_ENV: &str = "MJ_UPDATE_RESTART_FIXTURE";
1352        if std::env::var_os(FIXTURE_ENV).is_some() {
1353            let target = run_managed_upgrade(
1354                &Version::parse("9.9.9").expect("version"),
1355                &InstallMethod::Npm,
1356            )
1357            .expect("fake npm upgrade");
1358            restart_current_process(target).expect("restart updated binary");
1359            unreachable!("exec does not return on success");
1360        }
1361
1362        let root = tempfile::tempdir().expect("fixture directory");
1363        let package_bin = root.path().join("package/bin");
1364        let manager_bin = root.path().join("manager");
1365        std::fs::create_dir_all(&package_bin).expect("package bin");
1366        std::fs::create_dir(&manager_bin).expect("manager bin");
1367        let executable = package_bin.join("mj");
1368        std::fs::copy(std::env::current_exe().expect("test binary"), &executable)
1369            .expect("copy test executable into package");
1370        let replacement = root.path().join("replacement");
1371        std::fs::write(&replacement, "#!/bin/sh\necho UPDATED_MJ_RESTARTED\n")
1372            .expect("replacement script");
1373        let npm = manager_bin.join("npm");
1374        std::fs::write(
1375            &npm,
1376            r#"#!/bin/sh
1377set -eu
1378mv "$MJ_UPDATE_RESTART_FIXTURE/package" "$MJ_UPDATE_RESTART_FIXTURE/retired"
1379mkdir -p "$MJ_UPDATE_RESTART_FIXTURE/package/bin"
1380cp "$MJ_UPDATE_RESTART_FIXTURE/replacement" "$MJ_UPDATE_RESTART_FIXTURE/package/bin/mj"
1381rm "$MJ_UPDATE_RESTART_FIXTURE/retired/bin/mj"
1382"#,
1383        )
1384        .expect("fake npm script");
1385        use std::os::unix::fs::PermissionsExt;
1386        for script in [&npm, &replacement] {
1387            std::fs::set_permissions(script, std::fs::Permissions::from_mode(0o755))
1388                .expect("executable script");
1389        }
1390        let mut paths = vec![manager_bin];
1391        paths.extend(std::env::split_paths(
1392            &std::env::var_os("PATH").unwrap_or_default(),
1393        ));
1394        let mut child = Command::new(executable);
1395        child.args([
1396            "--exact",
1397            "controller::update::tests::npm_upgrade_restarts_after_the_running_package_is_removed",
1398            "--nocapture",
1399        ]);
1400        child.env(FIXTURE_ENV, root.path());
1401        child.env("PATH", std::env::join_paths(paths).expect("fixture PATH"));
1402        let output = mj_core::subprocess::run_with_input(&mut child, b"")
1403            .expect("run copied test executable");
1404        assert!(
1405            output.status.success(),
1406            "{}",
1407            String::from_utf8_lossy(&output.stderr)
1408        );
1409        assert!(String::from_utf8_lossy(&output.stdout).contains("UPDATED_MJ_RESTARTED"));
1410    }
1411
1412    #[cfg(unix)]
1413    fn release_tar(entries: &[(&str, &[u8])]) -> Vec<u8> {
1414        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1415        let mut archive = tar::Builder::new(gz);
1416        for (name, bytes) in entries {
1417            let mut header = tar::Header::new_gnu();
1418            header.set_size(bytes.len() as u64);
1419            header.set_mode(0o755);
1420            header.set_cksum();
1421            archive
1422                .append_data(&mut header, name, *bytes)
1423                .expect("tar entry");
1424        }
1425        archive
1426            .into_inner()
1427            .expect("tar archive")
1428            .finish()
1429            .expect("gzip archive")
1430    }
1431
1432    #[cfg(unix)]
1433    #[test]
1434    fn release_upgrade_replaces_every_packaged_binary_and_preserves_unrelated_files() {
1435        let root = tempfile::tempdir().expect("install directory");
1436        let executable = root.path().join("custom-mj-name");
1437        std::fs::write(&executable, b"old controller").expect("old controller");
1438        std::fs::write(root.path().join("README.md"), b"user notes").expect("unrelated file");
1439        let new_binary = vec![b'n'; 128 * 1024];
1440        let entries: Vec<(&str, &[u8])> = [
1441            "release/mj",
1442            "release/mj-desktop",
1443            "release/mj-voice-worker",
1444            "release/mj-worker",
1445            "release/mj-worker-x86_64-unknown-linux-musl",
1446            "release/mj-worker-aarch64-unknown-linux-musl",
1447        ]
1448        .into_iter()
1449        .map(|name| (name, new_binary.as_slice()))
1450        .collect();
1451        for (name, _) in &entries[1..] {
1452            std::fs::write(
1453                root.path().join(Path::new(name).file_name().unwrap()),
1454                b"old helper",
1455            )
1456            .expect("old helper");
1457        }
1458        let mut archive_entries = entries.clone();
1459        archive_entries.push(("release/README.md", b"release notes"));
1460        let archive = release_tar(&archive_entries);
1461        let installed = install_release_archive(&executable, "release.tar.gz", &archive)
1462            .expect("install complete release");
1463        assert_eq!(
1464            installed,
1465            executable.canonicalize().expect("resolved controller")
1466        );
1467        assert_eq!(std::fs::read(&executable).unwrap(), new_binary);
1468        use std::os::unix::fs::PermissionsExt;
1469        for (name, _) in &entries[1..] {
1470            let helper = root.path().join(Path::new(name).file_name().unwrap());
1471            assert_eq!(std::fs::read(&helper).unwrap(), new_binary);
1472            assert_eq!(
1473                std::fs::metadata(&helper).unwrap().permissions().mode() & 0o777,
1474                0o755
1475            );
1476        }
1477        assert_eq!(
1478            std::fs::read(root.path().join("README.md")).unwrap(),
1479            b"user notes"
1480        );
1481        assert_eq!(
1482            std::fs::read_dir(root.path()).unwrap().count(),
1483            entries.len() + 1
1484        );
1485    }
1486
1487    #[cfg(unix)]
1488    #[test]
1489    fn malformed_release_leaves_installed_binaries_unchanged() {
1490        let root = tempfile::tempdir().expect("install directory");
1491        let executable = root.path().join("mj");
1492        let worker = root.path().join("mj-worker");
1493        for archive in [
1494            release_tar(&[("mj", b"new controller"), ("mj-worker", b"")]),
1495            release_tar(&[("mj", b"new controller"), ("mj", b"duplicate")]),
1496            release_tar(&[("mj-worker", b"new worker")]),
1497        ] {
1498            std::fs::write(&executable, b"old controller").unwrap();
1499            std::fs::write(&worker, b"old worker").unwrap();
1500            assert!(install_release_archive(&executable, "release.tar.gz", &archive).is_err());
1501            assert_eq!(std::fs::read(&executable).unwrap(), b"old controller");
1502            assert_eq!(std::fs::read(&worker).unwrap(), b"old worker");
1503            assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 2);
1504        }
1505    }
1506
1507    #[cfg(unix)]
1508    #[test]
1509    fn release_upgrade_allows_retired_companions() {
1510        let root = tempfile::tempdir().expect("install directory");
1511        let executable = root.path().join("mj");
1512        std::fs::write(&executable, b"old controller").unwrap();
1513        let archive = release_tar(&[("mj", b"new controller")]);
1514        install_release_archive(&executable, "release.tar.gz", &archive)
1515            .expect("main-only release");
1516        assert_eq!(std::fs::read(&executable).unwrap(), b"new controller");
1517    }
1518
1519    #[test]
1520    fn zip_release_stages_application_binaries_without_extracting_documents() {
1521        let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
1522        let options = zip::write::SimpleFileOptions::default();
1523        for name in [
1524            "release/mj.exe",
1525            "release/mj-worker.exe",
1526            "release/mj-desktop.exe",
1527            "release/LICENSE",
1528        ] {
1529            archive.start_file(name, options).expect("zip entry");
1530            archive.write_all(b"binary contents").expect("zip contents");
1531        }
1532        let bytes = archive.finish().expect("zip archive").into_inner();
1533        let root = tempfile::tempdir().expect("staging directory");
1534        let binaries =
1535            stage_release_archive("release.zip", &bytes, root.path()).expect("stage zip");
1536        assert_eq!(binaries.len(), 3);
1537        for name in ["mj.exe", "mj-worker.exe", "mj-desktop.exe"] {
1538            assert_eq!(
1539                std::fs::read(root.path().join(name)).unwrap(),
1540                b"binary contents"
1541            );
1542        }
1543        assert!(!root.path().join("LICENSE").exists());
1544    }
1545
1546    /// Serves canned bodies per path prefix from a loopback port and returns
1547    /// sources pointed at it, so channel fetches never leave the machine.
1548    async fn serve_update_sources(
1549        routes: Vec<(&'static str, &'static str)>,
1550    ) -> (UpdateSources, std::net::SocketAddr) {
1551        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1552
1553        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1554            .await
1555            .expect("bind loopback listener");
1556        let addr = listener.local_addr().expect("listener address");
1557        tokio::spawn(async move {
1558            while let Ok((mut socket, _)) = listener.accept().await {
1559                let mut buffer = vec![0u8; 4096];
1560                let read = socket.read(&mut buffer).await.unwrap_or(0);
1561                let request = String::from_utf8_lossy(&buffer[..read]).to_string();
1562                let path = request.split_whitespace().nth(1).unwrap_or_default();
1563                let matched = routes
1564                    .iter()
1565                    .find(|(route, _)| path.starts_with(route))
1566                    .map(|(_, body)| *body);
1567                let (status, body) = match matched {
1568                    Some(body) => ("200 OK", body),
1569                    None => ("404 Not Found", ""),
1570                };
1571                let response = format!(
1572                    "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1573                    body.len()
1574                );
1575                let _ = socket.write_all(response.as_bytes()).await;
1576                let _ = socket.shutdown().await;
1577            }
1578        });
1579
1580        let base = format!("http://{addr}");
1581        (
1582            UpdateSources {
1583                latest_release: format!("{base}/release"),
1584                npm_latest: format!("{base}/npm"),
1585                homebrew_formula: format!("{base}/formula"),
1586                cargo_index: format!("{base}/index"),
1587            },
1588            addr,
1589        )
1590    }
1591
1592    #[tokio::test]
1593    async fn npm_registry_update_is_offered_when_newer() {
1594        let (sources, _) = serve_update_sources(vec![("/npm", r#"{"version":"9.9.9"}"#)]).await;
1595
1596        let update = latest_update(&sources, &InstallMethod::Npm)
1597            .await
1598            .expect("update check");
1599
1600        assert_eq!(
1601            update,
1602            Some(AvailableUpdate::Managed {
1603                version: Version::parse("9.9.9").expect("version"),
1604                method: InstallMethod::Npm,
1605            })
1606        );
1607    }
1608
1609    #[tokio::test]
1610    async fn up_to_date_channel_offers_nothing() {
1611        let (sources, _) = serve_update_sources(vec![("/npm", r#"{"version":"0.0.1"}"#)]).await;
1612
1613        let update = latest_update(&sources, &InstallMethod::Npm)
1614            .await
1615            .expect("update check");
1616
1617        assert_eq!(update, None);
1618    }
1619
1620    #[tokio::test]
1621    async fn homebrew_formula_update_is_offered_when_newer() {
1622        let formula = "class Mjolnir < Formula\n  version \"9.9.9\"\nend\n";
1623        let (sources, _) = serve_update_sources(vec![("/formula", formula)]).await;
1624
1625        let update = latest_update(&sources, &InstallMethod::Homebrew)
1626            .await
1627            .expect("update check");
1628
1629        assert!(matches!(update, Some(AvailableUpdate::Managed { .. })));
1630    }
1631
1632    #[tokio::test]
1633    async fn failed_channel_fetch_is_reported_as_an_error() {
1634        // No route matches /npm, so the stub answers 404.
1635        let (sources, _) = serve_update_sources(vec![("/formula", "unused")]).await;
1636
1637        let error = latest_update(&sources, &InstallMethod::Npm)
1638            .await
1639            .expect_err("404 should fail the check");
1640
1641        assert!(format!("{error:#}").contains("404"));
1642    }
1643
1644    #[tokio::test]
1645    async fn direct_installs_read_the_release_endpoint() {
1646        let release = concat!(
1647            r#"{"tag_name":"v9.9.9","assets":["#,
1648            r#"{"name":"brokk-mjolnir-v9.9.9-x86_64-unknown-linux-gnu.tar.gz","#,
1649            r#""browser_download_url":"https://example.com/mj.tar.gz"},"#,
1650            r#"{"name":"brokk-mjolnir-v9.9.9-x86_64-unknown-linux-gnu.tar.gz.sha256","#,
1651            r#""browser_download_url":"https://example.com/mj.tar.gz.sha256"}]}"#,
1652        );
1653        let (sources, _) = serve_update_sources(vec![("/release", release)]).await;
1654
1655        // Asset selection is platform-shaped, and the stub only carries the
1656        // Linux x86_64 archive, so this end-to-end trip is Linux-only; the
1657        // pure selection tests above cover the other platforms.
1658        let Ok(update) = latest_update(&sources, &InstallMethod::Direct).await else {
1659            return;
1660        };
1661        if std::env::consts::OS != "linux" && std::env::consts::ARCH != "x86_64" {
1662            return;
1663        }
1664
1665        match update.expect("update") {
1666            AvailableUpdate::Direct(info) => {
1667                assert_eq!(info.version, Version::parse("9.9.9").expect("version"));
1668                assert_eq!(info.tag, "v9.9.9");
1669            }
1670            other => panic!("expected a direct update, got {other:?}"),
1671        }
1672    }
1673}