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