1use std::path::{Path, PathBuf};
35
36use agentmux_common::DataPaths;
37
38const AGENTMUX_VERSION: &str = env!("CARGO_PKG_VERSION");
39
40pub fn is_safe_version_component(v: &str) -> bool {
45 !v.is_empty()
46 && v.len() <= 64
47 && !v.contains("..")
48 && v.chars()
49 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
50}
51
52pub(crate) fn is_safe_provider_component(p: &str) -> bool {
54 !p.is_empty()
55 && p.len() <= 64
56 && p.chars()
57 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
58}
59
60pub fn shared_cli_dir(
63 paths: &DataPaths,
64 provider_id: &str,
65 pinned_version: &str,
66) -> Option<PathBuf> {
67 if !is_safe_provider_component(provider_id) || !is_safe_version_component(pinned_version) {
68 return None;
69 }
70 Some(
71 paths
72 .shared_dir
73 .join("cli")
74 .join(provider_id)
75 .join(pinned_version),
76 )
77}
78
79pub fn legacy_cli_dir(paths: &DataPaths, provider_id: &str) -> PathBuf {
82 paths
83 .home_dir
84 .join("instances")
85 .join(format!("v{AGENTMUX_VERSION}"))
86 .join("cli")
87 .join(provider_id)
88}
89
90pub fn install_dir(paths: &DataPaths, provider_id: &str, pinned_version: &str) -> PathBuf {
93 shared_cli_dir(paths, provider_id, pinned_version)
94 .unwrap_or_else(|| legacy_cli_dir(paths, provider_id))
95}
96
97pub fn npm_bin(dir: &Path, cli_command: &str) -> PathBuf {
100 let bin = dir.join("node_modules").join(".bin");
101 if cfg!(windows) {
102 bin.join(format!("{cli_command}.cmd"))
103 } else {
104 bin.join(cli_command)
105 }
106}
107
108pub const COMPLETE_MARKER: &str = ".agentmux-install-complete";
115
116pub fn is_complete(dir: &Path) -> bool {
118 dir.join(COMPLETE_MARKER).is_file()
119}
120
121pub fn mark_complete(dir: &Path, version: &str) -> std::io::Result<()> {
124 let tmp = dir.join(format!("{COMPLETE_MARKER}.tmp"));
125 std::fs::write(&tmp, version)?;
126 std::fs::rename(&tmp, dir.join(COMPLETE_MARKER))
127}
128
129pub fn is_valid_install(dir: &Path, cli_command: &str) -> bool {
133 is_complete(dir) && npm_bin(dir, cli_command).is_file()
134}
135
136pub fn clear_unless_valid(dir: &Path, cli_command: &str) -> std::io::Result<()> {
144 if dir.exists() && !is_valid_install(dir, cli_command) {
145 std::fs::remove_dir_all(dir)?;
146 }
147 Ok(())
148}
149
150pub fn find_installed(
154 paths: &DataPaths,
155 provider_id: &str,
156 pinned_version: &str,
157 cli_command: &str,
158) -> Option<PathBuf> {
159 let shared = shared_cli_dir(paths, provider_id, pinned_version)
160 .filter(|d| is_complete(d))
161 .inspect(|d| crate::backend::cli_prune::record_use(d, std::time::SystemTime::now()))
162 .map(|d| npm_bin(&d, cli_command))
163 .filter(|b| b.is_file());
164 shared.or_else(|| {
165 let legacy = npm_bin(&legacy_cli_dir(paths, provider_id), cli_command);
166 legacy.is_file().then_some(legacy)
167 })
168}
169
170pub fn find_installed_for_provider(paths: &DataPaths, provider_id: &str) -> Option<PathBuf> {
173 let provider = crate::backend::providers::get_provider(provider_id)?;
174 find_installed(
175 paths,
176 provider.id,
177 provider.pinned_version,
178 provider.cli_command,
179 )
180}
181
182pub struct InstallGuard {
186 _file: Option<std::fs::File>,
187}
188
189pub fn lock_install(
195 paths: &DataPaths,
196 provider_id: &str,
197 pinned_version: &str,
198) -> std::io::Result<InstallGuard> {
199 let Some(file) = open_lock_file(paths, provider_id, pinned_version)? else {
200 return Ok(InstallGuard { _file: None });
201 };
202 crate::registry::lock_exclusive(&file)?;
203 Ok(InstallGuard { _file: Some(file) })
204}
205
206pub fn try_lock_install(
210 paths: &DataPaths,
211 provider_id: &str,
212 pinned_version: &str,
213) -> std::io::Result<Option<InstallGuard>> {
214 let Some(file) = open_lock_file(paths, provider_id, pinned_version)? else {
215 return Ok(Some(InstallGuard { _file: None }));
216 };
217 if crate::registry::try_lock_exclusive(&file)? {
218 Ok(Some(InstallGuard { _file: Some(file) }))
219 } else {
220 Ok(None)
221 }
222}
223
224fn open_lock_file(
227 paths: &DataPaths,
228 provider_id: &str,
229 pinned_version: &str,
230) -> std::io::Result<Option<std::fs::File>> {
231 let Some(dir) = shared_cli_dir(paths, provider_id, pinned_version) else {
232 return Ok(None);
233 };
234 let parent = dir.parent().unwrap_or(&dir).to_path_buf();
235 std::fs::create_dir_all(&parent)?;
236 let lock_path = parent.join(format!("{pinned_version}.lock"));
237 let file = std::fs::OpenOptions::new()
238 .create(true)
239 .write(true)
240 .truncate(false)
241 .open(&lock_path)?;
242 Ok(Some(file))
243}
244
245#[derive(Debug, Clone)]
249pub struct NpmInstallRequest {
250 pub provider_id: String,
251 pub npm_package: String,
252 pub pinned_version: String,
253 pub cli_command: String,
254 pub background: bool,
257}
258
259#[derive(Debug)]
261pub struct NpmRun {
262 pub success: bool,
263 pub code: Option<i32>,
264 pub stdout: Vec<u8>,
265 pub stderr: Vec<u8>,
266}
267
268#[derive(Debug)]
269pub enum InstallOutcome {
270 Installed(PathBuf),
272 AlreadyInstalled(PathBuf),
275}
276
277#[derive(Debug)]
278pub enum InstallError {
279 Lock(std::io::Error),
280 Clear(PathBuf, std::io::Error),
281 Spawn(std::io::Error),
282 NpmFailed { code: Option<i32> },
283 ShimMissing { expected: PathBuf },
284 Mark(PathBuf, std::io::Error),
285}
286
287impl std::fmt::Display for InstallError {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 match self {
290 InstallError::Lock(e) => write!(f, "cannot take the CLI install lock: {e}"),
291 InstallError::Clear(d, e) => write!(
292 f,
293 "cannot clear an incomplete CLI install at {}: {e}",
294 d.display()
295 ),
296 InstallError::Spawn(e) => write!(f, "failed to run npm install: {e}"),
297 InstallError::NpmFailed { code } => {
298 write!(f, "npm install exited {}", code.unwrap_or(-1))
299 }
300 InstallError::ShimMissing { expected } => {
301 write!(f, "npm install left no CLI shim at {}", expected.display())
302 }
303 InstallError::Mark(d, e) => write!(
304 f,
305 "cannot mark the CLI install complete at {}: {e}",
306 d.display()
307 ),
308 }
309 }
310}
311
312pub fn install_pinned_cli(
325 paths: &DataPaths,
326 req: &NpmInstallRequest,
327 on_line: &dyn Fn(&'static str, &str),
328) -> Result<InstallOutcome, InstallError> {
329 install_pinned_cli_with(paths, req, on_line, run_npm_install)
330}
331
332pub fn install_pinned_cli_with(
334 paths: &DataPaths,
335 req: &NpmInstallRequest,
336 on_line: &dyn Fn(&'static str, &str),
337 run_npm: impl FnOnce(&Path, &str, bool) -> std::io::Result<NpmRun>,
338) -> Result<InstallOutcome, InstallError> {
339 let dir = install_dir(paths, &req.provider_id, &req.pinned_version);
340 let bin = npm_bin(&dir, &req.cli_command);
341 let _guard =
342 lock_install(paths, &req.provider_id, &req.pinned_version).map_err(InstallError::Lock)?;
343 let shared = shared_cli_dir(paths, &req.provider_id, &req.pinned_version);
344 if let Some(d) = shared.as_deref() {
345 if is_valid_install(d, &req.cli_command) {
346 return Ok(InstallOutcome::AlreadyInstalled(bin));
347 }
348 clear_unless_valid(d, &req.cli_command)
349 .map_err(|e| InstallError::Clear(d.to_path_buf(), e))?;
350 }
351 let package = match crate::backend::providers::get_provider(&req.provider_id) {
354 Some(p) if p.npm_package == req.npm_package => p.npm_install_specs(&req.pinned_version).join(" "),
355 _ => format!("{}@{}", req.npm_package, req.pinned_version),
356 };
357 tracing::info!(package = %package, prefix = %dir.display(), background = req.background, "running npm install");
358 let run = run_npm(&dir, &package, req.background).map_err(InstallError::Spawn)?;
359 tracing::info!(
360 exit_code = run.code.unwrap_or(-1),
361 stdout_bytes = run.stdout.len(),
362 stderr_bytes = run.stderr.len(),
363 "npm install output collected"
364 );
365 for (stream, bytes) in [("stderr", &run.stderr), ("stdout", &run.stdout)] {
367 for line in String::from_utf8_lossy(bytes).lines() {
368 if !line.trim().is_empty() {
369 on_line(stream, line);
370 }
371 }
372 }
373 if !run.success {
374 return Err(InstallError::NpmFailed { code: run.code });
375 }
376 if !bin.is_file() {
377 return Err(InstallError::ShimMissing { expected: bin });
378 }
379 if let Some(d) = shared.as_deref() {
380 mark_complete(d, &req.pinned_version)
381 .map_err(|e| InstallError::Mark(d.to_path_buf(), e))?;
382 }
383 Ok(InstallOutcome::Installed(bin))
384}
385
386pub(crate) fn npm_registry_args(package: &str) -> Vec<String> {
393 if package.starts_with("@agentmuxai/") {
394 vec!["--@agentmuxai:registry=https://registry.npmjs.org/".to_string()]
395 } else {
396 Vec::new()
397 }
398}
399
400fn run_npm_install(dir: &Path, package: &str, background: bool) -> std::io::Result<NpmRun> {
407 let out = {
408 #[cfg(windows)]
409 {
410 use std::os::windows::process::CommandExt;
416 const BELOW_NORMAL_PRIORITY_CLASS: u32 = 0x0000_4000;
419 let mut flags = agentmux_common::win32::CREATE_NO_WINDOW;
420 if background {
421 flags |= BELOW_NORMAL_PRIORITY_CLASS;
422 }
423 let prefix = dir.to_string_lossy().replace('/', "\\");
424 let registry = npm_registry_args(package).join(" ");
425 let cmdline = format!(
426 "npm install --loglevel=http --no-audit --no-fund --no-progress {registry} --prefix \"{prefix}\" {package}"
427 );
428 let mut c = std::process::Command::new("cmd");
429 crate::backend::pane_env::sanitize_external_std_command(&mut c);
432 c.arg("/C")
433 .raw_arg(&cmdline)
434 .creation_flags(flags)
435 .env("CI", "true")
436 .env("FORCE_COLOR", "0")
437 .output()?
438 }
439 #[cfg(not(windows))]
440 {
441 let mut c = std::process::Command::new("npm");
442 crate::backend::pane_env::sanitize_external_std_command(&mut c);
443 c.args([
444 "install",
445 "--loglevel=http",
446 "--no-audit",
447 "--no-fund",
448 "--no-progress",
449 ])
450 .args(npm_registry_args(package))
451 .arg("--prefix")
452 .arg(dir)
453 .args(package.split_whitespace())
454 .env("CI", "true")
455 .env("FORCE_COLOR", "0");
456 if background {
457 use std::os::unix::process::CommandExt;
458 unsafe {
461 c.pre_exec(|| {
462 let _ = libc::setpriority(libc::PRIO_PROCESS, 0, 10);
463 Ok(())
464 });
465 }
466 }
467 c.output()?
468 }
469 };
470 Ok(NpmRun {
471 success: out.status.success(),
472 code: out.status.code(),
473 stdout: out.stdout,
474 stderr: out.stderr,
475 })
476}
477
478#[derive(Debug)]
482pub enum EnsureError {
483 Failed(String),
485 StillInstalling,
489}
490
491pub async fn ensure_installed_with<F>(
498 paths: DataPaths,
499 req: NpmInstallRequest,
500 wait: std::time::Duration,
501 install: F,
502) -> Result<PathBuf, EnsureError>
503where
504 F: FnOnce(&DataPaths, &NpmInstallRequest) -> Result<InstallOutcome, InstallError>
505 + Send
506 + 'static,
507{
508 let task = tokio::task::spawn_blocking(move || install(&paths, &req));
509 match tokio::time::timeout(wait, task).await {
510 Ok(Ok(Ok(InstallOutcome::Installed(bin) | InstallOutcome::AlreadyInstalled(bin)))) => {
511 Ok(bin)
512 }
513 Ok(Ok(Err(e))) => Err(EnsureError::Failed(e.to_string())),
514 Ok(Err(join)) => Err(EnsureError::Failed(format!(
515 "the install task failed: {join}"
516 ))),
517 Err(_elapsed) => Err(EnsureError::StillInstalling),
518 }
519}
520
521pub async fn ensure_installed(
523 paths: DataPaths,
524 req: NpmInstallRequest,
525 wait: std::time::Duration,
526) -> Result<PathBuf, EnsureError> {
527 ensure_installed_with(paths, req, wait, |p, r| {
528 install_pinned_cli(p, r, &|_, _| {})
529 })
530 .await
531}
532
533pub const WARM_INSTALL_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
538
539pub fn providers_needing_install(
543 paths: &DataPaths,
544 provider_ids: &[String],
545) -> Vec<NpmInstallRequest> {
546 let mut seen = std::collections::HashSet::new();
547 let mut out = Vec::new();
548 for id in provider_ids {
549 let Some(p) = crate::backend::providers::get_provider(id) else {
550 continue;
551 };
552 if p.npm_package.is_empty() || !seen.insert(p.id) {
553 continue;
554 }
555 if find_installed(paths, p.id, p.pinned_version, p.cli_command).is_some() {
556 continue;
557 }
558 out.push(NpmInstallRequest {
559 provider_id: p.id.to_string(),
560 npm_package: p.npm_package.to_string(),
561 pinned_version: p.pinned_version.to_string(),
562 cli_command: p.cli_command.to_string(),
563 background: true,
564 });
565 }
566 out
567}
568
569pub fn warm_used_providers(
577 paths: DataPaths,
578 provider_ids: Vec<String>,
579) -> std::thread::JoinHandle<()> {
580 std::thread::Builder::new()
581 .name("cli-install-warm".into())
582 .spawn(move || {
583 std::thread::sleep(WARM_INSTALL_DELAY);
584 let todo = providers_needing_install(&paths, &provider_ids);
585 if todo.is_empty() {
586 tracing::info!(
587 providers = provider_ids.len(),
588 "cli warm-up: every used provider's pinned CLI is installed"
589 );
590 return;
591 }
592 for req in todo {
593 let started = std::time::Instant::now();
594 let quiet = |_: &'static str, _: &str| {};
595 match install_pinned_cli(&paths, &req, &quiet) {
596 Ok(outcome) => tracing::info!(
597 provider = %req.provider_id,
598 pinned_version = %req.pinned_version,
599 duration_ms = started.elapsed().as_millis() as u64,
600 outcome = ?outcome,
601 "cli warm-up: installed"
602 ),
603 Err(e) => tracing::info!(
604 provider = %req.provider_id,
605 pinned_version = %req.pinned_version,
606 error = %e,
607 "cli warm-up: install failed; the first open will retry and report it"
608 ),
609 }
610 }
611 })
612 .expect("spawn cli-install-warm thread")
613}
614
615#[cfg(test)]
616mod tests {
617
618 #[test]
619 fn agentmux_packages_install_from_the_public_registry_others_use_npmrc() {
620 assert_eq!(
623 super::npm_registry_args("@agentmuxai/muxcode@0.8.0"),
624 vec!["--@agentmuxai:registry=https://registry.npmjs.org/".to_string()]
625 );
626 assert!(super::npm_registry_args("@anthropic-ai/claude-code@2.1.288").is_empty());
627 assert!(super::npm_registry_args("openclaw").is_empty());
628 }
629
630 use super::*;
631
632 fn paths_in(root: &Path) -> DataPaths {
633 let version_dir = root
634 .join("channels")
635 .join("c")
636 .join("versions")
637 .join("1.0.0");
638 DataPaths {
639 home_dir: version_dir.join("data"),
640 instance_dir: root.join("channels").join("c"),
641 channel: "c".to_string(),
642 data_dir: version_dir.join("data"),
643 config_dir: root.join("channels").join("c").join("config"),
644 logs_dir: version_dir.join("logs"),
645 cef_cache_dir: version_dir.join("cef-cache"),
646 agents_dir: root.join("channels").join("c").join("agents"),
647 instance_runtime_dir: version_dir.join("runtime"),
648 shared_dir: root.join("shared"),
649 mode: agentmux_common::RuntimeMode::Portable,
650 }
651 }
652
653 #[test]
654 fn shared_dir_is_keyed_by_provider_and_pinned_version_not_agentmux_version() {
655 let tmp = tempfile::tempdir().unwrap();
656 let p = paths_in(tmp.path());
657 let d = shared_cli_dir(&p, "claude", "2.1.280").unwrap();
658 assert_eq!(
659 d,
660 tmp.path()
661 .join("shared")
662 .join("cli")
663 .join("claude")
664 .join("2.1.280")
665 );
666 assert!(!d.to_string_lossy().contains(AGENTMUX_VERSION) || AGENTMUX_VERSION == "2.1.280");
667 }
668
669 #[test]
670 fn unsafe_components_have_no_shared_dir_and_fall_back_to_legacy() {
671 let tmp = tempfile::tempdir().unwrap();
672 let p = paths_in(tmp.path());
673 for bad in ["", "..", "../x", "1.0/2", "1.0\\2", "a b"] {
674 assert!(shared_cli_dir(&p, "claude", bad).is_none(), "{bad:?}");
675 assert_eq!(install_dir(&p, "claude", bad), legacy_cli_dir(&p, "claude"));
676 }
677 assert!(shared_cli_dir(&p, "../claude", "1.0.0").is_none());
678 assert!(is_safe_version_component("2.1.280"));
679 assert!(is_safe_version_component("1.0.0-beta.1+build_7"));
680 }
681
682 fn touch_bin(dir: &Path, cli: &str) -> PathBuf {
683 let b = npm_bin(dir, cli);
684 std::fs::create_dir_all(b.parent().unwrap()).unwrap();
685 std::fs::write(&b, "").unwrap();
686 b
687 }
688
689 #[test]
690 fn finds_the_shared_install_first_then_the_legacy_one() {
691 let tmp = tempfile::tempdir().unwrap();
692 let p = paths_in(tmp.path());
693 assert!(find_installed(&p, "claude", "2.1.280", "claude").is_none());
694
695 let legacy = touch_bin(&legacy_cli_dir(&p, "claude"), "claude");
696 assert_eq!(
697 find_installed(&p, "claude", "2.1.280", "claude"),
698 Some(legacy.clone())
699 );
700
701 let shared_dir = shared_cli_dir(&p, "claude", "2.1.280").unwrap();
702 let shared = touch_bin(&shared_dir, "claude");
703 assert_eq!(
705 find_installed(&p, "claude", "2.1.280", "claude"),
706 Some(legacy.clone())
707 );
708 mark_complete(&shared_dir, "2.1.280").unwrap();
709 assert_eq!(
710 find_installed(&p, "claude", "2.1.280", "claude"),
711 Some(shared)
712 );
713
714 assert_eq!(
716 find_installed(&p, "claude", "2.1.281", "claude"),
717 Some(legacy)
718 );
719 }
720
721 #[test]
722 fn an_upgraded_agentmux_version_reuses_the_shared_install() {
723 let tmp = tempfile::tempdir().unwrap();
724 let old = paths_in(tmp.path());
725 let shared_dir = shared_cli_dir(&old, "claude", "2.1.280").unwrap();
726 let shared = touch_bin(&shared_dir, "claude");
727 mark_complete(&shared_dir, "2.1.280").unwrap();
728
729 let mut new = paths_in(tmp.path());
731 new.home_dir = tmp
732 .path()
733 .join("channels")
734 .join("c")
735 .join("versions")
736 .join("9.9.9")
737 .join("data");
738 assert_eq!(
739 find_installed(&new, "claude", "2.1.280", "claude"),
740 Some(shared)
741 );
742 }
743
744 #[test]
745 fn the_install_lock_serializes_two_holders() {
746 let tmp = tempfile::tempdir().unwrap();
747 let p = paths_in(tmp.path());
748 let first = lock_install(&p, "claude", "2.1.280").unwrap();
749 assert!(tmp
750 .path()
751 .join("shared")
752 .join("cli")
753 .join("claude")
754 .join("2.1.280.lock")
755 .is_file());
756
757 let p2 = paths_in(tmp.path());
758 let (tx, rx) = std::sync::mpsc::channel();
759 let waiter = std::thread::spawn(move || {
760 let _second = lock_install(&p2, "claude", "2.1.280").unwrap();
761 tx.send(()).unwrap();
762 });
763 assert!(rx
765 .recv_timeout(std::time::Duration::from_millis(300))
766 .is_err());
767 drop(first);
768 rx.recv_timeout(std::time::Duration::from_secs(5))
769 .expect("second holder acquires after release");
770 waiter.join().unwrap();
771 }
772
773 #[test]
774 fn the_legacy_dir_needs_no_lock() {
775 let tmp = tempfile::tempdir().unwrap();
776 let p = paths_in(tmp.path());
777 let g = lock_install(&p, "claude", "").unwrap();
778 assert!(g._file.is_none());
779 }
780
781 #[test]
785 fn a_shim_without_the_completion_marker_is_not_an_install() {
786 let tmp = tempfile::tempdir().unwrap();
787 let p = paths_in(tmp.path());
788 let dir = shared_cli_dir(&p, "claude", "2.1.280").unwrap();
789 touch_bin(&dir, "claude");
790 assert!(!is_complete(&dir));
791 assert!(find_installed(&p, "claude", "2.1.280", "claude").is_none());
792 }
793
794 #[test]
795 fn clear_unless_valid_removes_leftovers_but_keeps_a_finished_install() {
796 let tmp = tempfile::tempdir().unwrap();
797 let p = paths_in(tmp.path());
798 let dir = shared_cli_dir(&p, "claude", "2.1.280").unwrap();
799 touch_bin(&dir, "claude");
800 clear_unless_valid(&dir, "claude").unwrap();
801 assert!(!dir.exists(), "a partial install is cleared");
802
803 touch_bin(&dir, "claude");
804 mark_complete(&dir, "2.1.280").unwrap();
805 clear_unless_valid(&dir, "claude").unwrap();
806 assert!(
807 is_valid_install(&dir, "claude"),
808 "a finished install is kept"
809 );
810 clear_unless_valid(&tmp.path().join("missing"), "claude").unwrap();
811 }
812
813 #[test]
817 fn a_completed_install_that_lost_its_shim_is_cleared_marker_and_all() {
818 let tmp = tempfile::tempdir().unwrap();
819 let p = paths_in(tmp.path());
820 let dir = shared_cli_dir(&p, "claude", "2.1.280").unwrap();
821 let shim = touch_bin(&dir, "claude");
822 mark_complete(&dir, "2.1.280").unwrap();
823 std::fs::remove_file(&shim).unwrap();
824 assert!(is_complete(&dir) && !is_valid_install(&dir, "claude"));
825
826 clear_unless_valid(&dir, "claude").unwrap();
827 assert!(!dir.exists(), "stale marker and leftovers are gone");
828
829 touch_bin(&dir, "claude");
831 assert!(find_installed(&p, "claude", "2.1.280", "claude").is_none());
832 }
833
834 #[test]
835 fn try_lock_install_reports_a_held_lock_instead_of_waiting() {
836 let tmp = tempfile::tempdir().unwrap();
837 let p = paths_in(tmp.path());
838 let held = lock_install(&p, "claude", "2.1.280").unwrap();
839 let p2 = paths_in(tmp.path());
840 let busy = std::thread::spawn(move || {
843 try_lock_install(&p2, "claude", "2.1.280")
844 .unwrap()
845 .is_none()
846 })
847 .join()
848 .unwrap();
849 assert!(
850 busy,
851 "try_lock_install must report the held lock, not block"
852 );
853 drop(held);
854 let p3 = paths_in(tmp.path());
855 let got = std::thread::spawn(move || {
856 try_lock_install(&p3, "claude", "2.1.280")
857 .unwrap()
858 .is_some()
859 })
860 .join()
861 .unwrap();
862 assert!(got, "acquired once released");
863 assert!(
864 try_lock_install(&p, "claude", "").unwrap().is_some(),
865 "no lock needed without a shared dir"
866 );
867 }
868
869 fn claude_req(background: bool) -> NpmInstallRequest {
870 NpmInstallRequest {
871 provider_id: "claude".into(),
872 npm_package: "@anthropic-ai/claude-code".into(),
873 pinned_version: "2.1.280".into(),
874 cli_command: "claude".into(),
875 background,
876 }
877 }
878
879 fn fake_npm(
881 write_shim: bool,
882 success: bool,
883 ) -> impl FnOnce(&Path, &str, bool) -> std::io::Result<NpmRun> {
884 move |dir: &Path, _pkg: &str, _bg: bool| {
885 if write_shim {
886 let bin = npm_bin(dir, "claude");
887 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
888 std::fs::write(&bin, "").unwrap();
889 }
890 Ok(NpmRun {
891 success,
892 code: Some(if success { 0 } else { 1 }),
893 stdout: b"added 3 packages\n".to_vec(),
894 stderr: b"npm http fetch\n\n".to_vec(),
895 })
896 }
897 }
898
899 #[test]
900 fn install_runs_npm_verifies_the_shim_and_marks_it_complete() {
901 let _tmp = tempfile::tempdir().unwrap();
902 let paths = paths_in(_tmp.path());
903 let lines = std::sync::Mutex::new(Vec::new());
904 let on_line = |s: &'static str, l: &str| lines.lock().unwrap().push(format!("{s}:{l}"));
905 let out =
906 install_pinned_cli_with(&paths, &claude_req(false), &on_line, fake_npm(true, true))
907 .unwrap();
908 let dir = shared_cli_dir(&paths, "claude", "2.1.280").unwrap();
909 assert!(matches!(out, InstallOutcome::Installed(ref b) if *b == npm_bin(&dir, "claude")));
910 assert!(is_valid_install(&dir, "claude"));
911 assert_eq!(
912 *lines.lock().unwrap(),
913 vec!["stderr:npm http fetch", "stdout:added 3 packages"]
914 );
915 }
916
917 #[test]
918 fn a_finished_install_is_reused_without_running_npm() {
919 let _tmp = tempfile::tempdir().unwrap();
920 let paths = paths_in(_tmp.path());
921 install_pinned_cli_with(&paths, &claude_req(true), &|_, _| {}, fake_npm(true, true))
922 .unwrap();
923 let out = install_pinned_cli_with(
924 &paths,
925 &claude_req(false),
926 &|_, _| {},
927 |_: &Path, _: &str, _: bool| -> std::io::Result<NpmRun> {
928 panic!("npm must not run again")
929 },
930 )
931 .unwrap();
932 assert!(matches!(out, InstallOutcome::AlreadyInstalled(_)));
933 }
934
935 #[test]
936 fn a_failed_npm_leaves_no_marker_and_the_next_attempt_starts_clean() {
937 let _tmp = tempfile::tempdir().unwrap();
938 let paths = paths_in(_tmp.path());
939 let dir = shared_cli_dir(&paths, "claude", "2.1.280").unwrap();
940 let err = install_pinned_cli_with(
942 &paths,
943 &claude_req(false),
944 &|_, _| {},
945 fake_npm(true, false),
946 )
947 .unwrap_err();
948 assert!(matches!(err, InstallError::NpmFailed { code: Some(1) }));
949 assert!(!is_complete(&dir));
950 assert!(find_installed(&paths, "claude", "2.1.280", "claude").is_none());
951 let retry = install_pinned_cli_with(
953 &paths,
954 &claude_req(false),
955 &|_, _| {},
956 |d: &Path, p: &str, b: bool| {
957 assert!(
958 !npm_bin(d, "claude").exists(),
959 "leftover shim must be cleared first"
960 );
961 fake_npm(true, true)(d, p, b)
962 },
963 );
964 assert!(matches!(retry, Ok(InstallOutcome::Installed(_))));
965 }
966
967 #[test]
968 fn npm_success_without_a_shim_is_an_error_and_unmarked() {
969 let _tmp = tempfile::tempdir().unwrap();
970 let paths = paths_in(_tmp.path());
971 let err = install_pinned_cli_with(
972 &paths,
973 &claude_req(false),
974 &|_, _| {},
975 fake_npm(false, true),
976 )
977 .unwrap_err();
978 assert!(matches!(err, InstallError::ShimMissing { .. }));
979 assert!(!is_complete(
980 &shared_cli_dir(&paths, "claude", "2.1.280").unwrap()
981 ));
982 }
983
984 #[test]
985 fn the_install_holds_the_lock_until_npm_returns() {
986 let _tmp = tempfile::tempdir().unwrap();
987 let paths = paths_in(_tmp.path());
988 let p2 = paths.clone();
989 install_pinned_cli_with(
990 &paths,
991 &claude_req(false),
992 &|_, _| {},
993 move |d: &Path, pk: &str, b: bool| {
994 assert!(try_lock_install(&p2, "claude", "2.1.280")
996 .unwrap()
997 .is_none());
998 fake_npm(true, true)(d, pk, b)
999 },
1000 )
1001 .unwrap();
1002 assert!(try_lock_install(&paths, "claude", "2.1.280")
1003 .unwrap()
1004 .is_some());
1005 }
1006
1007 #[test]
1008 fn warm_up_picks_used_npm_providers_with_no_install_once_each() {
1009 let _tmp = tempfile::tempdir().unwrap();
1010 let paths = paths_in(_tmp.path());
1011 let ids: Vec<String> = ["claude", "claude", "codex", "not-a-provider"]
1012 .iter()
1013 .map(|s| s.to_string())
1014 .collect();
1015 let todo: Vec<String> = providers_needing_install(&paths, &ids)
1016 .into_iter()
1017 .map(|r| r.provider_id)
1018 .collect();
1019 assert_eq!(todo, vec!["claude", "codex"]);
1020 assert!(providers_needing_install(&paths, &ids)
1021 .iter()
1022 .all(|r| r.background));
1023
1024 let live_claude = crate::backend::providers::get_provider("claude").unwrap();
1030 let live_req = NpmInstallRequest {
1031 provider_id: "claude".into(),
1032 npm_package: live_claude.npm_package.into(),
1033 pinned_version: live_claude.pinned_version.into(),
1034 cli_command: live_claude.cli_command.into(),
1035 background: true,
1036 };
1037 install_pinned_cli_with(&paths, &live_req, &|_, _| {}, fake_npm(true, true)).unwrap();
1038 let todo: Vec<String> = providers_needing_install(&paths, &ids)
1039 .into_iter()
1040 .map(|r| r.provider_id)
1041 .collect();
1042 assert_eq!(todo, vec!["codex"]);
1043 }
1044
1045 fn with_previous_pin_installed() -> (tempfile::TempDir, DataPaths, String, PathBuf) {
1055 let tmp = tempfile::tempdir().unwrap();
1056 let paths = paths_in(tmp.path());
1057 let live = crate::backend::providers::get_provider("claude").unwrap();
1058 let previous = "1.0.0-previous".to_string();
1059 assert_ne!(previous, live.pinned_version);
1060 let req = NpmInstallRequest {
1061 provider_id: "claude".into(),
1062 npm_package: live.npm_package.into(),
1063 pinned_version: previous.clone(),
1064 cli_command: live.cli_command.into(),
1065 background: true,
1066 };
1067 install_pinned_cli_with(&paths, &req, &|_, _| {}, fake_npm(true, true)).unwrap();
1068 let old_bin = npm_bin(
1069 &shared_cli_dir(&paths, "claude", &previous).unwrap(),
1070 "claude",
1071 );
1072 assert!(old_bin.is_file());
1073 (tmp, paths, previous, old_bin)
1074 }
1075
1076 fn live_claude_req(background: bool) -> NpmInstallRequest {
1077 let live = crate::backend::providers::get_provider("claude").unwrap();
1078 NpmInstallRequest {
1079 provider_id: "claude".into(),
1080 npm_package: live.npm_package.into(),
1081 pinned_version: live.pinned_version.into(),
1082 cli_command: live.cli_command.into(),
1083 background,
1084 }
1085 }
1086
1087 #[test]
1088 fn after_a_pin_bump_the_previous_install_is_not_mistaken_for_the_new_pin() {
1089 let (_tmp, paths, _prev, _old_bin) = with_previous_pin_installed();
1090 assert_eq!(
1091 find_installed_for_provider(&paths, "claude"),
1092 None,
1093 "an open must resolve the NEW pin (installing it), not run whatever was there"
1094 );
1095 let todo = providers_needing_install(&paths, &["claude".to_string()]);
1097 assert_eq!(todo.len(), 1);
1098 let live = crate::backend::providers::get_provider("claude").unwrap();
1099 assert_eq!(todo[0].pinned_version, live.pinned_version);
1100 }
1101
1102 #[test]
1103 fn installing_the_new_pin_leaves_the_previous_one_runnable_for_panes_that_hold_its_path() {
1104 let (_tmp, paths, previous, old_bin) = with_previous_pin_installed();
1107 install_pinned_cli_with(
1108 &paths,
1109 &live_claude_req(false),
1110 &|_, _| {},
1111 fake_npm(true, true),
1112 )
1113 .unwrap();
1114
1115 let new_bin = find_installed_for_provider(&paths, "claude").expect("new pin resolves");
1116 assert_ne!(new_bin, old_bin);
1117 assert!(is_valid_install(
1118 &shared_cli_dir(&paths, "claude", &previous).unwrap(),
1119 "claude"
1120 ));
1121 assert!(old_bin.is_file(), "the previous pin's shim is untouched");
1122 assert_eq!(find_installed_for_provider(&paths, "claude"), Some(new_bin));
1124 }
1125
1126 #[test]
1127 fn a_failed_install_of_the_new_pin_does_not_fall_back_to_the_previous_one() {
1128 let (_tmp, paths, previous, old_bin) = with_previous_pin_installed();
1133 let out = install_pinned_cli_with(
1134 &paths,
1135 &live_claude_req(false),
1136 &|_, _| {},
1137 fake_npm(false, false),
1138 );
1139 assert!(
1140 matches!(out, Err(InstallError::NpmFailed { .. })),
1141 "{out:?}"
1142 );
1143 assert_eq!(find_installed_for_provider(&paths, "claude"), None);
1144 assert!(old_bin.is_file());
1145 assert!(is_valid_install(
1146 &shared_cli_dir(&paths, "claude", &previous).unwrap(),
1147 "claude"
1148 ));
1149 let live = crate::backend::providers::get_provider("claude").unwrap();
1151 assert!(!shared_cli_dir(&paths, "claude", live.pinned_version)
1152 .unwrap()
1153 .exists());
1154 }
1155
1156 fn live_req() -> NpmInstallRequest {
1159 live_claude_req(false)
1160 }
1161
1162 #[tokio::test]
1163 async fn ensure_installed_installs_the_missing_pin_and_returns_the_shim() {
1164 let (_tmp, paths, _prev, _old) = with_previous_pin_installed();
1165 let live = crate::backend::providers::get_provider("claude").unwrap();
1166 let want = npm_bin(
1167 &shared_cli_dir(&paths, "claude", live.pinned_version).unwrap(),
1168 "claude",
1169 );
1170 let bin = ensure_installed_with(
1171 paths.clone(),
1172 live_req(),
1173 std::time::Duration::from_secs(30),
1174 |p, r| install_pinned_cli_with(p, r, &|_, _| {}, fake_npm(true, true)),
1175 )
1176 .await
1177 .expect("installs");
1178 assert_eq!(bin, want);
1179 assert!(bin.is_file());
1180 assert_eq!(find_installed_for_provider(&paths, "claude"), Some(bin));
1181 }
1182
1183 #[tokio::test]
1184 async fn ensure_installed_reuses_an_install_that_finished_meanwhile() {
1185 let (_tmp, paths, _prev, _old) = with_previous_pin_installed();
1186 install_pinned_cli_with(
1187 &paths,
1188 &live_claude_req(true),
1189 &|_, _| {},
1190 fake_npm(true, true),
1191 )
1192 .unwrap();
1193 let bin = ensure_installed_with(
1194 paths,
1195 live_req(),
1196 std::time::Duration::from_secs(30),
1197 |p, r| {
1198 install_pinned_cli_with(
1199 p,
1200 r,
1201 &|_, _| {},
1202 |_: &Path, _: &str, _: bool| -> std::io::Result<NpmRun> {
1203 panic!("npm must not run again")
1204 },
1205 )
1206 },
1207 )
1208 .await
1209 .expect("reuses");
1210 assert!(bin.is_file());
1211 }
1212
1213 #[tokio::test]
1214 async fn ensure_installed_reports_a_failed_install_with_its_reason() {
1215 let (_tmp, paths, _prev, _old) = with_previous_pin_installed();
1216 let err = ensure_installed_with(
1217 paths.clone(),
1218 live_req(),
1219 std::time::Duration::from_secs(30),
1220 |p, r| install_pinned_cli_with(p, r, &|_, _| {}, fake_npm(false, false)),
1221 )
1222 .await
1223 .unwrap_err();
1224 assert!(
1225 matches!(&err, EnsureError::Failed(m) if m.contains("npm install exited 1")),
1226 "{err:?}"
1227 );
1228 assert_eq!(find_installed_for_provider(&paths, "claude"), None);
1229 }
1230
1231 #[tokio::test]
1232 async fn ensure_installed_stops_waiting_but_does_not_cancel_a_slow_install() {
1233 let (_tmp, paths, _prev, _old) = with_previous_pin_installed();
1234 let finished = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1235 let flag = finished.clone();
1236 let err = ensure_installed_with(
1237 paths.clone(),
1238 live_req(),
1239 std::time::Duration::from_millis(50),
1240 move |p, r| {
1241 std::thread::sleep(std::time::Duration::from_millis(400));
1242 let out = install_pinned_cli_with(p, r, &|_, _| {}, fake_npm(true, true));
1243 flag.store(true, std::sync::atomic::Ordering::SeqCst);
1244 out
1245 },
1246 )
1247 .await
1248 .unwrap_err();
1249 assert!(matches!(err, EnsureError::StillInstalling), "{err:?}");
1250 for _ in 0..100 {
1252 if finished.load(std::sync::atomic::Ordering::SeqCst) {
1253 break;
1254 }
1255 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1256 }
1257 assert!(
1258 finished.load(std::sync::atomic::Ordering::SeqCst),
1259 "the install must keep running"
1260 );
1261 assert!(
1262 find_installed_for_provider(&paths, "claude").is_some(),
1263 "and a later open finds it"
1264 );
1265 }
1266}