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, 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
37const STAMP_MAX_AGE_MS: u64 = 24 * 60 * 60 * 1000;
40
41#[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#[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 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 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
160fn 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#[derive(Debug, Clone, PartialEq, Eq)]
271struct UpdateInfo {
272 version: Version,
273 tag: String,
274 asset: ReleaseAsset,
275 checksum_asset: ReleaseAsset,
276}
277
278#[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
315async 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, ¤t, ¤t_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 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#[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
547fn 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#[derive(Debug, Clone, PartialEq, Eq)]
575pub enum StartupUpdateOutcome {
576 Skipped,
579 UpToDate,
580 Notified,
583 Declined,
584}
585
586pub async fn check_prompt_and_apply() -> StartupUpdateOutcome {
602 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 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 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
688fn 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
723fn run_managed_upgrade(version: &Version, method: &InstallMethod) -> Result<RestartTarget> {
727 let current_exe = std::env::current_exe().context("resolve current executable")?;
730 let restart = managed_restart_target(method, ¤t_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 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#[derive(Debug, Clone, PartialEq, Eq)]
763enum RestartTarget {
764 SameExe(PathBuf),
768 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(¤t_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
862fn 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 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 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 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 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 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)); }
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 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 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 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 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}