Skip to main content

mj_controller/controller/
update.rs

1//! Install-aware startup update checks for `mj`.
2//!
3//! Mjolnir reaches users through several channels — the curl release
4//! installer, the npm package, the Homebrew tap, and crates.io — and each
5//! channel publishes its "latest" somewhere else. This module decides how
6//! the running binary was installed, asks that channel whether a newer
7//! release exists, and (in [`check_prompt_and_apply`]) asks the
8//! user before changing anything. npm and Homebrew upgrades delegate to the
9//! package manager; curl installs self-replace; cargo and npx installs only
10//! print the command, because a live `cargo install` rebuild or a nested
11//! `npx` run is not something to start from inside mj.
12
13use mj_core::hex::lower_hex;
14use std::ffi::OsString;
15use std::io::{self, BufRead, Cursor, IsTerminal, Read, Write};
16use std::path::{Path, PathBuf};
17use std::process::Command;
18use std::time::Duration;
19
20use anyhow::{Context, Result, bail, ensure};
21use chrono::Utc;
22use flate2::read::GzDecoder;
23use semver::Version;
24use serde::Deserialize;
25use sha2::{Digest, Sha256};
26
27use crate::github_watch::service::github_rate_limit_retry_at;
28
29const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/BrokkAi/mjolnir/releases/latest";
30const NPM_LATEST_URL: &str = "https://registry.npmjs.org/@brokkai%2Fmjolnir/latest";
31const HOMEBREW_FORMULA_URL: &str =
32    "https://raw.githubusercontent.com/BrokkAi/homebrew-tap/main/Formula/mjolnir.rb";
33const BIN_NAME: &str = "mj";
34const WINDOWS_BIN_NAME: &str = "mj.exe";
35const VOICE_WORKER_NAME: &str = "mj-voice-worker";
36const NPM_MANAGED_ENV: &str = "MJOLNIR_MANAGED_BY_NPM";
37const NPX_MANAGED_ENV: &str = "MJOLNIR_MANAGED_BY_NPX";
38const HOMEBREW_MANAGED_ENV: &str = "MJOLNIR_MANAGED_BY_HOMEBREW";
39const NO_UPDATE_CHECK_ENV: &str = "MJOLNIR_NO_UPDATE_CHECK";
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 --formula brokkai/tap/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 url = &sources.latest_release;
340    let client = http_client(Duration::from_secs(5))?;
341    let mut response = client
342        .get(url)
343        .send()
344        .await
345        .with_context(|| format!("GET {url}"))?;
346    if is_github_api_url(url)
347        && github_rate_limit_retry_at(response.status(), response.headers(), Utc::now()).is_some()
348    {
349        response = retry_with_github_credential(&client, url, response.status()).await?;
350    }
351    let body = success_text(url, response).await?;
352    serde_json::from_str(&body).context("parse release body")
353}
354
355/// Whether `url` is GitHub's API over HTTPS, the only place the user's GitHub
356/// credential may be sent.
357fn is_github_api_url(url: &str) -> bool {
358    reqwest::Url::parse(url)
359        .is_ok_and(|url| url.scheme() == "https" && url.host_str() == Some("api.github.com"))
360}
361
362/// Anonymous GitHub API calls share a limit of 60 an hour per IP address,
363/// which a shared NAT exhausts quickly. Only a lookup that limit refused
364/// borrows the credential the daemon uses (`GH_TOKEN`, `GITHUB_TOKEN`, or the
365/// GitHub CLI login): an ordinary launch never runs `gh`, and a stale token
366/// cannot break a check that works without one. The token goes in a header
367/// marked sensitive, never in a URL, an error, or a log line.
368async fn retry_with_github_credential(
369    client: &reqwest::Client,
370    url: &str,
371    anonymous_status: reqwest::StatusCode,
372) -> Result<reqwest::Response> {
373    let Some(credential) = super::backend::controller_github_credential().await else {
374        bail!(
375            "GitHub's anonymous rate limit is used up; run `gh auth login` or set GH_TOKEN \
376             so mj checks with your account (HTTP {anonymous_status})"
377        );
378    };
379    let source = credential.source;
380    let mut authorization =
381        reqwest::header::HeaderValue::from_str(&format!("Bearer {}", credential.token))
382            .with_context(|| format!("the GitHub token from {source} is not a valid header"))?;
383    authorization.set_sensitive(true);
384    let response = client
385        .get(url)
386        .header(reqwest::header::AUTHORIZATION, authorization)
387        .send()
388        .await
389        .with_context(|| format!("GET {url} with the GitHub token from {source}"))?;
390    let status = response.status();
391    if status == reqwest::StatusCode::UNAUTHORIZED {
392        bail!("GitHub rejected the token from {source}; refresh or unset it (HTTP {status})");
393    }
394    if github_rate_limit_retry_at(status, response.headers(), Utc::now()).is_some() {
395        bail!("GitHub's rate limit is used up for the token from {source} too (HTTP {status})");
396    }
397    Ok(response)
398}
399
400async fn fetch_latest_managed_version(
401    sources: &UpdateSources,
402    method: &InstallMethod,
403) -> Result<Version> {
404    match method {
405        InstallMethod::Npm | InstallMethod::Npx => {
406            let body = fetch_text(&sources.npm_latest)
407                .await
408                .context("fetch latest npm package")?;
409            let latest: NpmLatest = serde_json::from_str(&body).context("parse npm metadata")?;
410            parse_version(&latest.version).context("parse latest npm version")
411        }
412        InstallMethod::Homebrew => {
413            let body = fetch_text(&sources.homebrew_formula)
414                .await
415                .context("fetch Homebrew formula")?;
416            parse_homebrew_formula_version(&body)
417        }
418        InstallMethod::Cargo { .. } => {
419            // crates.io installs are notice-only, but the notice still needs
420            // to know whether anything newer exists. The sparse index lists
421            // every published version, yanked ones included-and-skipped.
422            let body = fetch_text(&sources.cargo_index)
423                .await
424                .context("fetch crates.io index entry")?;
425            parse_cargo_index_version(&body)
426        }
427        InstallMethod::Direct => anyhow::bail!("direct installs use GitHub release metadata"),
428    }
429}
430
431const CARGO_INDEX_URL: &str = "https://index.crates.io/br/ok/brokk-mjolnir";
432
433fn http_client(timeout: Duration) -> Result<reqwest::Client> {
434    reqwest::Client::builder()
435        .timeout(timeout)
436        .user_agent(concat!("mj/", env!("CARGO_PKG_VERSION")))
437        .build()
438        .context("build http client")
439}
440
441async fn fetch_text(url: &str) -> Result<String> {
442    let response = http_client(Duration::from_secs(5))?
443        .get(url)
444        .send()
445        .await
446        .with_context(|| format!("GET {url}"))?;
447    success_text(url, response).await
448}
449
450async fn success_text(url: &str, response: reqwest::Response) -> Result<String> {
451    let status = response.status();
452    ensure!(status.is_success(), "GET {url}: HTTP {status}");
453    response.text().await.with_context(|| format!("read {url}"))
454}
455
456fn parse_homebrew_formula_version(formula: &str) -> Result<Version> {
457    let raw = formula
458        .lines()
459        .map(str::trim)
460        .find_map(|line| line.strip_prefix("version \"")?.strip_suffix('"'))
461        .ok_or_else(|| anyhow::anyhow!("Homebrew formula has no version"))?;
462    parse_version(raw).context("parse Homebrew formula version")
463}
464
465fn parse_cargo_index_version(index: &str) -> Result<Version> {
466    let mut latest: Option<Version> = None;
467    for line in index.lines().filter(|line| !line.trim().is_empty()) {
468        let entry: CargoIndexEntry =
469            serde_json::from_str(line).context("parse crates.io index entry")?;
470        if entry.yanked {
471            continue;
472        }
473        let version = parse_version(&entry.vers).context("parse crates.io package version")?;
474        if latest.as_ref().is_none_or(|current| version > *current) {
475            latest = Some(version);
476        }
477    }
478    latest.ok_or_else(|| anyhow::anyhow!("crates.io index has no published versions"))
479}
480
481#[derive(Debug, Deserialize)]
482struct CargoIndexEntry {
483    vers: String,
484    #[serde(default)]
485    yanked: bool,
486}
487
488fn update_info_from_release(
489    release: &GitHubRelease,
490    current: &Version,
491    platform: &Platform,
492) -> Result<Option<UpdateInfo>> {
493    let latest = parse_version(&release.tag_name)
494        .with_context(|| format!("parse release tag {}", release.tag_name))?;
495    if latest <= *current {
496        return Ok(None);
497    }
498
499    let asset = select_mj_asset(&release.assets, platform)
500        .with_context(|| format!("find mj asset for {}/{}", platform.os_family, platform.arch))?;
501    let checksum_name = format!("{}.sha256", asset.name);
502    let checksum_asset = release
503        .assets
504        .iter()
505        .find(|candidate| candidate.name == checksum_name)
506        .cloned()
507        .ok_or_else(|| {
508            anyhow::anyhow!(
509                "release {} is missing required checksum asset {}",
510                release.tag_name,
511                checksum_name
512            )
513        })?;
514
515    Ok(Some(UpdateInfo {
516        version: latest,
517        tag: release.tag_name.clone(),
518        asset,
519        checksum_asset,
520    }))
521}
522
523fn select_mj_asset(assets: &[ReleaseAsset], platform: &Platform) -> Result<ReleaseAsset> {
524    let target_suffix = format!(
525        "-{}{}",
526        platform.rust_target,
527        platform_archive_ext(platform)
528    );
529    assets
530        .iter()
531        .find(|asset| is_mj_archive(&asset.name) && asset.name.ends_with(&target_suffix))
532        .cloned()
533        .ok_or_else(|| {
534            anyhow::anyhow!(
535                "no mj archive found for target {}; available assets: {}",
536                platform.rust_target,
537                assets
538                    .iter()
539                    .filter(|asset| !asset.name.ends_with(".sha256"))
540                    .map(|asset| asset.name.as_str())
541                    .collect::<Vec<_>>()
542                    .join(", ")
543            )
544        })
545}
546
547fn is_mj_archive(name: &str) -> bool {
548    name.starts_with("brokk-mjolnir-") && (name.ends_with(".tar.gz") || name.ends_with(".zip"))
549}
550
551fn platform_archive_ext(platform: &Platform) -> &'static str {
552    if platform.os_family == "windows" {
553        ".zip"
554    } else {
555        ".tar.gz"
556    }
557}
558
559fn current_platform() -> Result<Platform> {
560    let arch = match std::env::consts::ARCH {
561        "x86_64" => "x86_64",
562        "aarch64" | "arm64" => "aarch64",
563        other => anyhow::bail!("unsupported CPU architecture: {other}"),
564    };
565    let (os_family, rust_os) = match std::env::consts::OS {
566        "android" => ("android", "linux-android"),
567        "macos" => ("macos", "apple-darwin"),
568        "linux" => ("linux", "unknown-linux-gnu"),
569        "windows" => ("windows", "pc-windows-msvc"),
570        other => anyhow::bail!("unsupported OS: {other}"),
571    };
572
573    Ok(Platform {
574        os_family,
575        arch,
576        rust_target: format!("{arch}-{rust_os}"),
577    })
578}
579
580fn parse_version(raw: &str) -> Result<Version> {
581    Version::parse(raw.trim_start_matches('v')).with_context(|| format!("parse version {raw}"))
582}
583
584/// What the startup check decided. A successful upgrade never produces a
585/// value: the process re-execs into the new binary.
586#[derive(Debug, Clone, PartialEq, Eq)]
587pub enum StartupUpdateOutcome {
588    /// The check did not run (debug build, non-interactive, or disabled).
589    Skipped,
590    UpToDate,
591    /// The channel only allows announcing the upgrade command; the user must
592    /// run it themselves. Carries the announcement.
593    Notified(String),
594    Declined,
595    /// The check, the prompt, or the upgrade failed and this version keeps
596    /// running. Carries the short notice; the full error is in the log.
597    Failed(String),
598}
599
600/// What the dashboard's notice bar says about this launch's update. Nothing
601/// is printed before the dashboard starts: a line left on the terminal while
602/// startup continues reads as though mj stopped.
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub enum StartupUpdateNotice {
605    Available(String),
606    Failed(String),
607}
608
609impl StartupUpdateOutcome {
610    /// The bar has one line, so the notice leads with the innermost cause,
611    /// which says what went wrong and what to do; the request chain around it
612    /// goes to the log.
613    fn failed(what: &str, error: &anyhow::Error) -> Self {
614        tracing::warn!(error = format!("{error:#}"), "{what} failed");
615        Self::Failed(format!(
616            "{what} failed: {}. Still running mj {}.",
617            error.root_cause(),
618            env!("CARGO_PKG_VERSION")
619        ))
620    }
621
622    pub fn dashboard_notice(self) -> Option<StartupUpdateNotice> {
623        match self {
624            Self::Notified(notice) => Some(StartupUpdateNotice::Available(notice)),
625            Self::Failed(notice) => Some(StartupUpdateNotice::Failed(notice)),
626            Self::Skipped | Self::UpToDate | Self::Declined => None,
627        }
628    }
629}
630
631/// Checks the running install's channel for a newer release, asks before
632/// changing anything, and on consent performs the upgrade:
633///
634/// - npm installs run `npm install -g @brokkai/mjolnir@latest` and Homebrew
635///   installs run `brew update` followed by
636///   `brew upgrade --formula brokkai/tap/mjolnir`; mj never writes into
637///   `node_modules` or the Cellar itself, because those trees belong to the
638///   package managers.
639/// - curl installs download the release archive, verify its SHA-256 sidecar,
640///   update the controller and every bundled application helper, and re-exec.
641/// - npx and cargo installs are notice-only.
642///
643/// Runs on every interactive startup before the daemon or dashboard starts.
644/// The version fetch is time-boxed, and interactive package-manager runs share
645/// the terminal like any foreground command, so this must never be called
646/// from a UI render path.
647pub async fn check_prompt_and_apply() -> StartupUpdateOutcome {
648    // The controller ships for Linux and macOS (Windows users run WSL2), so
649    // there is no Windows replacer to maintain; debug builds are developers,
650    // who upgrade themselves.
651    if cfg!(windows)
652        || cfg!(debug_assertions)
653        || !io::stdin().is_terminal()
654        || !io::stdout().is_terminal()
655        || std::env::var_os(NO_UPDATE_CHECK_ENV).is_some()
656    {
657        return StartupUpdateOutcome::Skipped;
658    }
659
660    let method = InstallMethod::current();
661
662    let update = match latest_update(&UpdateSources::default(), &method).await {
663        Ok(Some(update)) => update,
664        Ok(None) => return StartupUpdateOutcome::UpToDate,
665        // A broken check must never keep someone out of mj.
666        Err(error) => return StartupUpdateOutcome::failed("Update check", &error),
667    };
668
669    match update {
670        AvailableUpdate::Direct(update) => {
671            match prompt_for_update(&update.version, &InstallMethod::Direct) {
672                Ok(true) => {}
673                Ok(false) => return StartupUpdateOutcome::Declined,
674                Err(error) => return StartupUpdateOutcome::failed("Upgrade prompt", &error),
675            }
676            // The success path re-execs and never returns; reaching the end
677            // means the upgrade failed and the current process lives on.
678            match download_apply_and_restart(&update).await {
679                Ok(()) => StartupUpdateOutcome::Skipped,
680                Err(error) => StartupUpdateOutcome::failed("Upgrade", &error),
681            }
682        }
683        AvailableUpdate::Managed { version, method } => match method {
684            InstallMethod::Npm | InstallMethod::Homebrew => {
685                match prompt_for_update(&version, &method) {
686                    Ok(true) => {}
687                    Ok(false) => return StartupUpdateOutcome::Declined,
688                    Err(error) => return StartupUpdateOutcome::failed("Upgrade prompt", &error),
689                }
690                let upgraded =
691                    run_managed_upgrade(&version, &method).and_then(restart_current_process);
692                match upgraded {
693                    Ok(()) => StartupUpdateOutcome::Skipped,
694                    Err(error) => StartupUpdateOutcome::failed("Upgrade", &error),
695                }
696            }
697            InstallMethod::Npx | InstallMethod::Cargo { .. } => StartupUpdateOutcome::Notified(
698                managed_update_notice(&version, &method, env!("CARGO_PKG_VERSION"))
699                    .expect("notice-only channels have an update command"),
700            ),
701            InstallMethod::Direct => {
702                unreachable!("direct installs never report managed updates")
703            }
704        },
705    }
706}
707
708fn prompt_for_update(version: &Version, method: &InstallMethod) -> Result<bool> {
709    print!(
710        "mj {version} is available through {}; current version is {}. Upgrade now? [Y/n] ",
711        method.channel_name(),
712        env!("CARGO_PKG_VERSION")
713    );
714    io::stdout().flush().context("flush update prompt")?;
715
716    read_update_answer(&mut io::stdin().lock())
717}
718
719fn read_update_answer(input: &mut impl BufRead) -> Result<bool> {
720    let mut answer = String::new();
721    let bytes_read = input
722        .read_line(&mut answer)
723        .context("read update prompt answer")?;
724    Ok(bytes_read != 0 && prompt_answer_is_yes(&answer))
725}
726
727/// An empty answer accepts, matching the 1.x prompt's default.
728fn prompt_answer_is_yes(answer: &str) -> bool {
729    matches!(answer.trim(), "" | "y" | "Y" | "yes" | "YES")
730}
731
732fn managed_update_notice(
733    version: &Version,
734    method: &InstallMethod,
735    current_version: &str,
736) -> Option<String> {
737    Some(format!(
738        "mj {version} is available through {}; current version is {current_version}. Run: {}",
739        method.channel_name(),
740        method.update_command()?
741    ))
742}
743
744fn npm_upgrade_command() -> Command {
745    let mut command = Command::new("npm");
746    command.args(["install", "-g", "@brokkai/mjolnir@latest"]);
747    command
748}
749
750fn brew_update_command() -> Command {
751    let mut command = Command::new("brew");
752    command.arg("update");
753    command
754}
755
756fn brew_upgrade_command() -> Command {
757    let mut command = Command::new("brew");
758    // A bare `mjolnir` resolves to an unrelated Homebrew cask, so name the
759    // tap formula explicitly.
760    command.args(["upgrade", "--formula", "brokkai/tap/mjolnir"]);
761    command
762}
763
764/// Runs the channel's own upgrade command in the foreground with its live
765/// output on the terminal. `run_inherited` keeps stdin closed so neither
766/// package manager can stop to ask a question nobody is there to answer.
767fn run_managed_upgrade(version: &Version, method: &InstallMethod) -> Result<RestartTarget> {
768    // npm moves and unlinks the old package. Resolve this before the upgrade,
769    // while current_exe still identifies the installation's stable path.
770    let current_exe = std::env::current_exe().context("resolve current executable")?;
771    let restart = managed_restart_target(method, &current_exe)?;
772    match method {
773        InstallMethod::Npm => {
774            println!("mj: running npm install -g @brokkai/mjolnir@latest");
775            let status = mj_core::subprocess::run_inherited(&mut npm_upgrade_command())
776                .context("run npm install -g @brokkai/mjolnir@latest")?;
777            ensure!(
778                status.success(),
779                "npm install exited with {status}; npm usually explains why above"
780            );
781        }
782        InstallMethod::Homebrew => {
783            // The version check read the tap formula on GitHub, but the
784            // local brew only knows about it after its index refreshes;
785            // without `brew update` the upgrade would report "already
786            // up-to-date" on a fresh release.
787            println!("mj: running brew update");
788            let status = mj_core::subprocess::run_inherited(&mut brew_update_command())
789                .context("run brew update")?;
790            ensure!(status.success(), "brew update exited with {status}");
791            println!("mj: running brew upgrade --formula brokkai/tap/mjolnir");
792            let status = mj_core::subprocess::run_inherited(&mut brew_upgrade_command())
793                .context("run brew upgrade --formula brokkai/tap/mjolnir")?;
794            ensure!(status.success(), "brew upgrade exited with {status}");
795        }
796        other => bail!("{other:?} installs do not support delegated upgrades"),
797    }
798    println!("mj: upgraded to {version}; restarting");
799    Ok(restart)
800}
801
802/// How the process re-execs after a successful managed upgrade.
803#[derive(Debug, Clone, PartialEq, Eq)]
804enum RestartTarget {
805    /// The upgrade replaced the binary file at this exact path, so re-execing
806    /// it loads the new version (direct replacements and npm bundles, whose
807    /// paths survive `npm install -g`).
808    SameExe(PathBuf),
809    /// Homebrew moves the new release into a fresh Cellar directory and
810    /// repoints its wrappers, so re-execing this process's own Cellar path
811    /// would relaunch the old version. Resolving `mj` on `PATH` runs the
812    /// formula's wrapper, which execs the new libexec binary.
813    Wrapper,
814}
815
816fn managed_restart_target(method: &InstallMethod, current_exe: &Path) -> Result<RestartTarget> {
817    match method {
818        InstallMethod::Npm => Ok(RestartTarget::SameExe(current_exe.to_path_buf())),
819        InstallMethod::Homebrew => Ok(RestartTarget::Wrapper),
820        other => bail!("{other:?} installs do not support delegated upgrades"),
821    }
822}
823
824#[cfg(unix)]
825fn restart_current_process(target: RestartTarget) -> Result<()> {
826    use std::os::unix::process::CommandExt;
827
828    let args: Vec<OsString> = std::env::args_os().skip(1).collect();
829    let mut command = match target {
830        RestartTarget::SameExe(exe) => Command::new(exe),
831        RestartTarget::Wrapper => Command::new("mj"),
832    };
833    let error = command.args(args).exec();
834    Err(error).context("exec replacement mj")
835}
836
837#[cfg(not(unix))]
838fn restart_current_process(_target: RestartTarget) -> Result<()> {
839    bail!("automatic restart is only supported on Unix platforms")
840}
841
842async fn download_apply_and_restart(update: &UpdateInfo) -> Result<()> {
843    println!("mj: downloading {} ({})", update.tag, update.asset.name);
844    let archive = download_bytes(&update.asset.browser_download_url)
845        .await
846        .with_context(|| format!("download {}", update.asset.name))?;
847    verify_checksum(update, &archive).await?;
848
849    let current_exe = std::env::current_exe().context("resolve current executable")?;
850    let replacement = install_release_archive(&current_exe, &update.asset.name, &archive)
851        .context("install release bundle")?;
852
853    println!("mj: upgraded to {}; restarting", update.tag);
854    restart_current_process(RestartTarget::SameExe(replacement))
855}
856
857async fn download_bytes(url: &str) -> Result<Vec<u8>> {
858    let resp = http_client(Duration::from_secs(120))?
859        .get(url)
860        .send()
861        .await
862        .with_context(|| format!("GET {url}"))?;
863    let status = resp.status();
864    if !status.is_success() {
865        anyhow::bail!("GET {url}: HTTP {status}");
866    }
867    resp.bytes()
868        .await
869        .map(|bytes| bytes.to_vec())
870        .context("read response body")
871}
872
873async fn verify_checksum(update: &UpdateInfo, archive: &[u8]) -> Result<()> {
874    let body = download_bytes(&update.checksum_asset.browser_download_url)
875        .await
876        .with_context(|| format!("download {}", update.checksum_asset.name))?;
877    let body = String::from_utf8(body).context("checksum file is not utf-8")?;
878    let expected = body
879        .split_whitespace()
880        .next()
881        .ok_or_else(|| anyhow::anyhow!("empty checksum file {}", update.checksum_asset.name))?;
882    let actual = sha256_hex(archive);
883    if expected != actual {
884        bail!(
885            "checksum mismatch for {}: expected {expected}, got {actual}",
886            update.asset.name
887        );
888    }
889    Ok(())
890}
891
892fn sha256_hex(bytes: &[u8]) -> String {
893    let mut hasher = Sha256::new();
894    hasher.update(bytes);
895    lower_hex(hasher.finalize())
896}
897
898/// Extract the complete application bundle before changing any installed file.
899/// Companions can be retired in future releases, but mj itself is mandatory.
900fn install_release_archive(
901    current_exe: &Path,
902    archive_name: &str,
903    archive_bytes: &[u8],
904) -> Result<PathBuf> {
905    ensure!(
906        cfg!(unix),
907        "self-update replacement is only supported on Unix platforms"
908    );
909    let target_exe = current_exe
910        .canonicalize()
911        .with_context(|| format!("resolve executable target {}", current_exe.display()))?;
912    let parent = target_exe
913        .parent()
914        .ok_or_else(|| anyhow::anyhow!("executable has no parent: {}", target_exe.display()))?;
915    // Keep staging on the destination filesystem so each replacement is an
916    // atomic rename, including binaries that are currently running.
917    let staging = tempfile::Builder::new()
918        .prefix(".mj-self-update-")
919        .tempdir_in(parent)
920        .context("create update staging directory")?;
921    let mut binaries = stage_release_archive(archive_name, archive_bytes, staging.path())?;
922    let executable_name = if archive_name.ends_with(".zip") {
923        WINDOWS_BIN_NAME
924    } else {
925        BIN_NAME
926    };
927    ensure!(
928        staging.path().join(executable_name).is_file(),
929        "archive did not contain expected binary: {executable_name}"
930    );
931    // Replace the controller last, once every packaged companion is installed.
932    binaries.sort_by_key(|path| path.file_name() == Some(executable_name.as_ref()));
933    strip_quarantine(staging.path());
934    for binary in binaries {
935        let name = binary
936            .file_name()
937            .context("staged binary has no file name")?;
938        let target = if name == executable_name {
939            target_exe.clone()
940        } else {
941            parent.join(name)
942        };
943        std::fs::rename(&binary, &target)
944            .with_context(|| format!("install {}", target.display()))?;
945    }
946    Ok(target_exe)
947}
948
949fn stage_release_archive(
950    archive_name: &str,
951    archive_bytes: &[u8],
952    directory: &Path,
953) -> Result<Vec<PathBuf>> {
954    let mut binaries = Vec::new();
955    if archive_name.ends_with(".zip") {
956        let mut archive =
957            zip::ZipArchive::new(Cursor::new(archive_bytes)).context("open zip archive")?;
958        for index in 0..archive.len() {
959            let mut entry = archive.by_index(index).context("read zip entry")?;
960            let path = entry.enclosed_name().ok_or_else(|| {
961                anyhow::anyhow!("zip entry escapes destination: {}", entry.name())
962            })?;
963            let is_file = entry.is_file() && !entry.is_symlink();
964            if let Some(binary) = stage_archive_binary(directory, &path, is_file, &mut entry)? {
965                binaries.push(binary);
966            }
967        }
968    } else {
969        let mut archive = tar::Archive::new(GzDecoder::new(archive_bytes));
970        for entry in archive.entries().context("read tar entries")? {
971            let mut entry = entry.context("read tar entry")?;
972            let path = entry.path().context("read tar entry path")?.into_owned();
973            let is_file = entry.header().entry_type().is_file();
974            if let Some(binary) = stage_archive_binary(directory, &path, is_file, &mut entry)? {
975                binaries.push(binary);
976            }
977        }
978    }
979    Ok(binaries)
980}
981
982fn stage_archive_binary(
983    directory: &Path,
984    path: &Path,
985    is_file: bool,
986    mut contents: impl Read,
987) -> Result<Option<PathBuf>> {
988    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
989        return Ok(None);
990    };
991    let stem = name.strip_suffix(".exe").unwrap_or(name);
992    if !matches!(
993        stem,
994        BIN_NAME | "mj-desktop" | VOICE_WORKER_NAME | "mj-worker"
995    ) && !stem.starts_with("mj-worker-")
996    {
997        return Ok(None);
998    }
999    ensure!(
1000        is_file,
1001        "archive binary is not a regular file: {}",
1002        path.display()
1003    );
1004    let target = directory.join(name);
1005    let mut output = std::fs::File::create_new(&target)
1006        .with_context(|| format!("stage {name}; each binary must appear only once"))?;
1007    let size = io::copy(&mut contents, &mut output).with_context(|| format!("extract {name}"))?;
1008    ensure!(size != 0, "archive contained an empty {name} binary");
1009    #[cfg(unix)]
1010    {
1011        use std::os::unix::fs::PermissionsExt;
1012        output
1013            .set_permissions(std::fs::Permissions::from_mode(0o755))
1014            .with_context(|| format!("chmod {name}"))?;
1015    }
1016    Ok(Some(target))
1017}
1018
1019#[cfg(unix)]
1020fn strip_quarantine(path: &Path) {
1021    #[cfg(target_os = "macos")]
1022    {
1023        // Downloads inherit a quarantine attribute on macOS; Gatekeeper
1024        // would refuse to exec the replacement without this.
1025        let _ = Command::new("xattr")
1026            .arg("-dr")
1027            .arg("com.apple.quarantine")
1028            .arg(path)
1029            .status();
1030    }
1031    #[cfg(not(target_os = "macos"))]
1032    {
1033        let _ = path;
1034    }
1035}
1036
1037#[cfg(not(unix))]
1038fn strip_quarantine(_path: &Path) {}
1039
1040#[cfg(test)]
1041mod tests;