Skip to main content

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