mi6_cli/commands/
upgrade.rs

1//! Self-upgrade command for mi6.
2//!
3//! Detects installation method and uses the appropriate upgrade strategy:
4//! - Homebrew: `brew upgrade mi6`
5//! - Cargo (registry): `cargo install mi6`
6//! - Cargo (path): `cargo install --path <original-path>`
7//! - Standalone: Uses `self_update` crate to download from GitHub releases
8//!
9//! Supports switching between installation methods with automatic rollback on failure.
10
11use std::path::PathBuf;
12use std::process::Command;
13
14use anyhow::{Context, Result, bail};
15
16use crate::args::UpgradeMethod;
17use crate::display::{bold_green, bold_white, confirm_stdout};
18
19use super::update_check::check_for_update_with_method;
20
21/// How mi6 was installed
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum InstallMethod {
24    /// Installed via Homebrew
25    Homebrew,
26    /// Installed via `cargo install mi6` (from crates.io registry)
27    CargoRegistry,
28    /// Installed via `cargo install --path <path>` (from local source)
29    CargoPath(PathBuf),
30    /// Standalone binary (e.g., downloaded from GitHub releases or install.sh)
31    Standalone,
32}
33
34impl InstallMethod {
35    /// Detect the installation method based on the executable path and cargo metadata
36    pub fn detect() -> Result<Self> {
37        let exe_path = std::env::current_exe().context("failed to get current executable path")?;
38        let path_str = exe_path.to_string_lossy();
39
40        if path_str.contains("/Cellar/") || path_str.contains("/homebrew/") {
41            return Ok(Self::Homebrew);
42        }
43
44        if path_str.contains("/.cargo/bin/") {
45            // Use `cargo install --list` to determine if installed from registry or path
46            if let Some(method) = Self::detect_cargo_source()? {
47                return Ok(method);
48            }
49            // Fallback to registry if we can't determine
50            return Ok(Self::CargoRegistry);
51        }
52
53        Ok(Self::Standalone)
54    }
55
56    /// Use `cargo install --list` to determine the install source
57    ///
58    /// Output format:
59    /// - Registry install: `mi6 v0.2.0:`
60    /// - Path install: `mi6 v0.2.0 (/path/to/crate):`
61    fn detect_cargo_source() -> Result<Option<Self>> {
62        let output = Command::new("cargo")
63            .args(["install", "--list"])
64            .output()
65            .context("failed to run cargo install --list")?;
66
67        if !output.status.success() {
68            return Ok(None);
69        }
70
71        let stdout = String::from_utf8_lossy(&output.stdout);
72
73        // Find the line starting with "mi6 v"
74        for line in stdout.lines() {
75            if !line.starts_with("mi6 v") {
76                continue;
77            }
78
79            // Check if there's a path in parentheses: "mi6 v0.2.0 (/path/to/crate):"
80            if let Some(start) = line.find(" (")
81                && let Some(end) = line.rfind("):")
82            {
83                let path_str = &line[start + 2..end];
84                return Ok(Some(Self::CargoPath(PathBuf::from(path_str))));
85            }
86
87            // No path means registry install: "mi6 v0.2.0:"
88            return Ok(Some(Self::CargoRegistry));
89        }
90
91        Ok(None)
92    }
93
94    /// Get a human-readable name for the install method
95    pub fn name(&self) -> &'static str {
96        match self {
97            Self::Homebrew => "Homebrew",
98            Self::CargoRegistry => "Cargo (crates.io)",
99            Self::CargoPath(_) => "Cargo source code",
100            Self::Standalone => "Standalone binary",
101        }
102    }
103
104    /// Convert to UpgradeMethod (for comparison purposes)
105    pub fn to_upgrade_method(&self) -> UpgradeMethod {
106        match self {
107            Self::Homebrew => UpgradeMethod::Brew,
108            Self::CargoRegistry => UpgradeMethod::CargoCrates,
109            Self::CargoPath(_) => UpgradeMethod::CargoSource,
110            Self::Standalone => UpgradeMethod::Github,
111        }
112    }
113
114    /// Create from UpgradeMethod with optional path for CargoSource
115    pub fn from_upgrade_method(method: UpgradeMethod, path: Option<PathBuf>) -> Result<Self> {
116        match method {
117            UpgradeMethod::Brew => Ok(Self::Homebrew),
118            UpgradeMethod::CargoCrates => Ok(Self::CargoRegistry),
119            UpgradeMethod::Github => Ok(Self::Standalone),
120            UpgradeMethod::CargoSource => {
121                let source_path = path.ok_or_else(|| {
122                    anyhow::anyhow!(
123                        "--source-path is required when using --method cargo_source\n\
124                         Example: mi6 upgrade --method cargo_source --source-path /path/to/mi6"
125                    )
126                })?;
127                Ok(Self::CargoPath(source_path))
128            }
129        }
130    }
131
132    /// Check if prerequisites for this installation method are met
133    pub fn check_prerequisites(&self) -> Result<()> {
134        match self {
135            Self::Homebrew => {
136                let output = Command::new("brew")
137                    .arg("--version")
138                    .output()
139                    .context("brew is not installed or not in PATH")?;
140                if !output.status.success() {
141                    bail!("brew is not working properly");
142                }
143                Ok(())
144            }
145            Self::CargoRegistry | Self::CargoPath(_) => {
146                let output = Command::new("cargo")
147                    .arg("--version")
148                    .output()
149                    .context("cargo is not installed or not in PATH")?;
150                if !output.status.success() {
151                    bail!("cargo is not working properly");
152                }
153                // For CargoPath, check that the source directory exists and is a valid Rust project
154                if let Self::CargoPath(path) = self {
155                    if !path.exists() {
156                        bail!(
157                            "Source directory does not exist: {}\n\
158                             Please provide a valid path to the mi6 source.",
159                            path.display()
160                        );
161                    }
162                    let cargo_toml = path.join("Cargo.toml");
163                    if !cargo_toml.exists() {
164                        bail!(
165                            "Not a valid Rust project (no Cargo.toml): {}\n\
166                             Please provide a path to the mi6 source directory.",
167                            path.display()
168                        );
169                    }
170                }
171                Ok(())
172            }
173            Self::Standalone => {
174                // No prerequisites for standalone - just needs network access
175                Ok(())
176            }
177        }
178    }
179}
180
181/// Options for the upgrade command
182pub struct UpgradeOptions {
183    /// Target version (only for Cargo registry installs)
184    pub version: Option<String>,
185    /// Skip confirmation prompt
186    pub yes: bool,
187    /// Check for updates without installing
188    pub dry_run: bool,
189    /// Override installation method
190    pub method: Option<UpgradeMethod>,
191    /// Source path for cargo_source method
192    pub source_path: Option<PathBuf>,
193}
194
195/// Run the upgrade command
196pub fn run_upgrade(options: UpgradeOptions) -> Result<()> {
197    let current_version = env!("CARGO_PKG_VERSION");
198    let current_method = InstallMethod::detect()?;
199
200    println!("mi6 v{}", current_version);
201    println!();
202    println!(
203        "{} {}",
204        bold_green("Installation method:"),
205        current_method.name()
206    );
207    if let InstallMethod::CargoPath(path) = &current_method {
208        println!("{} {}", bold_green("   Source code path:"), path.display());
209    }
210    println!();
211
212    // Determine target method (either specified or current)
213    let target_method = if let Some(method) = options.method {
214        // If switching to cargo_source and no path provided, try to use current path
215        let path = options.source_path.clone().or_else(|| {
216            if let InstallMethod::CargoPath(p) = &current_method {
217                Some(p.clone())
218            } else {
219                None
220            }
221        });
222        // Canonicalize the path to avoid issues with relative paths
223        let path = path.map(|p| p.canonicalize().unwrap_or(p));
224        InstallMethod::from_upgrade_method(method, path)?
225    } else {
226        current_method.clone()
227    };
228
229    let is_switching = current_method.to_upgrade_method() != target_method.to_upgrade_method();
230
231    // Warn if --version is used with non-registry cargo install methods
232    if options.version.is_some() && !matches!(target_method, InstallMethod::CargoRegistry) {
233        println!(
234            "Warning: --version is only supported for Cargo registry installs (ignored for {})",
235            target_method.name()
236        );
237        println!();
238    }
239
240    // Handle dry-run mode
241    if options.dry_run {
242        if is_switching {
243            println!(
244                "Would switch from {} to {}",
245                current_method.name(),
246                target_method.name()
247            );
248            println!();
249
250            // Check prerequisites even in dry-run to warn early
251            print!("Checking prerequisites for {}... ", target_method.name());
252            match target_method.check_prerequisites() {
253                Ok(()) => println!("OK"),
254                Err(e) => {
255                    println!("FAILED");
256                    println!();
257                    println!("Warning: Switch would fail: {}", e);
258                    println!();
259                }
260            }
261        }
262        return check_for_updates(current_version, &target_method);
263    }
264
265    // If switching methods, go through the switch flow
266    if is_switching {
267        return switch_method(&current_method, &target_method, &options);
268    }
269
270    // Check if already on latest version (skip if user specified a specific version)
271    // Also skip for standalone installs - upgrade_standalone() does its own check,
272    // so doing it here would result in duplicate GitHub API calls
273    if options.version.is_none() && !matches!(target_method, InstallMethod::Standalone) {
274        println!("Checking for updates...");
275        match check_for_update_with_method(current_version, &target_method) {
276            Ok(None) => {
277                // For git source installs, show commit hash even when on latest
278                if matches!(target_method, InstallMethod::CargoPath(_)) {
279                    if let Some(current_hash) =
280                        option_env!("MI6_GIT_COMMIT").filter(|s| !s.is_empty())
281                    {
282                        println!(
283                            "{} {} ({})",
284                            bold_green("  Installed version:"),
285                            bold_white(current_hash),
286                            bold_white(&format!("v{}", current_version))
287                        );
288                        println!(
289                            "{} {} ({})",
290                            bold_green("     Latest version:"),
291                            bold_white(current_hash),
292                            bold_white(&format!("v{}", current_version))
293                        );
294                        println!();
295                        println!(
296                            "Already on latest version {} ({})",
297                            bold_white(current_hash),
298                            bold_white(&format!("v{}", current_version))
299                        );
300                    } else {
301                        println!(
302                            "Already on latest version ({}).",
303                            bold_white(&format!("v{}", current_version))
304                        );
305                    }
306                } else {
307                    println!(
308                        "Already on latest version ({}).",
309                        bold_white(&format!("v{}", current_version))
310                    );
311                }
312                return Ok(());
313            }
314            Ok(Some(update_info)) => {
315                // For git source installs, show commit hashes
316                if let Some(ref current_hash) = update_info.current_git_hash {
317                    let latest_hash = update_info
318                        .latest_version
319                        .strip_prefix("git:")
320                        .unwrap_or(&update_info.latest_version);
321                    println!(
322                        "{} {} ({})",
323                        bold_green("  Installed version:"),
324                        bold_white(current_hash),
325                        bold_white(&format!("v{}", current_version))
326                    );
327                    println!(
328                        "{} {} ({})",
329                        bold_green("     Latest version:"),
330                        bold_white(latest_hash),
331                        bold_white(&format!("v{}", current_version))
332                    );
333                } else {
334                    println!(
335                        "{} {}",
336                        bold_green("  Installed version:"),
337                        bold_white(&format!("v{}", current_version))
338                    );
339                    println!(
340                        "{} {}",
341                        bold_green("     Latest version:"),
342                        bold_white(&format!("v{}", update_info.latest_version))
343                    );
344                }
345                println!();
346            }
347            Err(e) => {
348                // Log warning but continue with upgrade anyway
349                println!("Check failed: {}", e);
350                println!();
351                println!("Continuing with upgrade...");
352                println!();
353            }
354        }
355    }
356
357    // Same method upgrade
358    let completed = match &target_method {
359        InstallMethod::Homebrew => upgrade_homebrew(options.yes)?,
360        InstallMethod::CargoRegistry => {
361            upgrade_cargo_registry(options.version.as_deref(), options.yes)?
362        }
363        InstallMethod::CargoPath(path) => upgrade_cargo_path(path, options.yes)?,
364        InstallMethod::Standalone => upgrade_standalone(current_version, options.yes)?,
365    };
366
367    if completed {
368        println!();
369        println!("Done! Run `mi6 --version` to verify.");
370    }
371
372    Ok(())
373}
374
375/// Switch from one installation method to another.
376///
377/// This function ensures the mi6 binary is always available during the switch
378/// by installing the new method BEFORE uninstalling the old one. This is critical
379/// for live sessions that have hooks calling `mi6 ingest event`.
380///
381/// For cargo-to-cargo switches (same binary location), the install atomically
382/// overwrites so no separate uninstall is needed.
383fn switch_method(from: &InstallMethod, to: &InstallMethod, options: &UpgradeOptions) -> Result<()> {
384    println!(
385        "Switching installation method: {} -> {}",
386        from.name(),
387        to.name()
388    );
389    println!();
390
391    // Step 1: Check prerequisites for the new method BEFORE any changes
392    println!("Checking prerequisites for {}...", to.name());
393    to.check_prerequisites()
394        .with_context(|| format!("Cannot switch to {}: prerequisites not met", to.name()))?;
395    println!("Prerequisites OK.");
396    println!();
397
398    // Determine if this is a cargo-to-cargo switch (same binary location, atomic overwrite)
399    let is_cargo_to_cargo = matches!(
400        (from, to),
401        (
402            InstallMethod::CargoRegistry | InstallMethod::CargoPath(_),
403            InstallMethod::CargoRegistry | InstallMethod::CargoPath(_)
404        )
405    );
406
407    // Step 2: Confirm with user
408    if !options.yes {
409        println!("This will:");
410        if is_cargo_to_cargo {
411            println!("  Install mi6 via {} (overwrites existing)", to.name());
412        } else {
413            println!("  1. Install mi6 via {}", to.name());
414            println!("  2. Uninstall mi6 via {} (binary only)", from.name());
415        }
416        println!();
417        println!("Note: Your mi6 data (database, config, themes) will be preserved.");
418        println!();
419        if !confirm_stdout("Proceed with method switch?")? {
420            println!("Aborted.");
421            return Ok(());
422        }
423        println!();
424    }
425
426    if is_cargo_to_cargo {
427        // Cargo-to-cargo: atomic overwrite, use --force to ensure replacement
428        println!("Installing via {} (atomic overwrite)...", to.name());
429        install_binary_forced(to, options.version.as_deref())
430            .with_context(|| format!("Failed to install via {}", to.name()))?;
431    } else {
432        // Different locations: install new first, then uninstall old
433        // This ensures mi6 is always available for live sessions
434
435        // Step 3: Install new method first (old binary still available)
436        println!("Installing via {}...", to.name());
437        if let Err(install_err) = install_binary(to, options.version.as_deref()) {
438            // Install failed, but old binary is still there - no rollback needed
439            bail!(
440                "Failed to install via {}: {}\n\n\
441                 Your current installation ({}) is unchanged.",
442                to.name(),
443                install_err,
444                from.name()
445            );
446        }
447        println!("Install complete.");
448        println!();
449
450        // Step 4: Now safe to uninstall old method (new binary is ready)
451        println!("Removing old installation ({})...", from.name());
452        if let Err(uninstall_err) = uninstall_binary(from) {
453            // New binary is installed, but old one couldn't be removed
454            // This is not critical - warn user but don't fail
455            eprintln!();
456            eprintln!(
457                "WARNING: Could not remove old {} installation: {}",
458                from.name(),
459                uninstall_err
460            );
461            eprintln!();
462            eprintln!("You may have two mi6 binaries installed.");
463            eprintln!("Run `which mi6` to verify you're using the correct binary.");
464            eprintln!();
465        } else {
466            println!("Old installation removed.");
467            println!();
468        }
469    }
470
471    println!(
472        "Done! Successfully switched from {} to {}.",
473        from.name(),
474        to.name()
475    );
476    println!("Run `mi6 --version` to verify.");
477
478    Ok(())
479}
480
481/// Uninstall the mi6 binary using the specified method (binary only, preserves data).
482fn uninstall_binary(method: &InstallMethod) -> Result<()> {
483    match method {
484        InstallMethod::Homebrew => {
485            let status = Command::new("brew")
486                .args(["uninstall", "mi6"])
487                .status()
488                .context("failed to run brew uninstall")?;
489            if !status.success() {
490                bail!("brew uninstall failed with exit code: {:?}", status.code());
491            }
492        }
493        InstallMethod::CargoRegistry | InstallMethod::CargoPath(_) => {
494            let status = Command::new("cargo")
495                .args(["uninstall", "mi6"])
496                .status()
497                .context("failed to run cargo uninstall")?;
498            if !status.success() {
499                bail!("cargo uninstall failed with exit code: {:?}", status.code());
500            }
501        }
502        InstallMethod::Standalone => {
503            let exe_path =
504                std::env::current_exe().context("failed to get current executable path")?;
505            std::fs::remove_file(&exe_path)
506                .with_context(|| format!("failed to delete {}", exe_path.display()))?;
507        }
508    }
509    Ok(())
510}
511
512/// Install the mi6 binary using the specified method.
513fn install_binary(method: &InstallMethod, version: Option<&str>) -> Result<()> {
514    install_binary_impl(method, version, false)
515}
516
517/// Install the mi6 binary with --force flag (for cargo-to-cargo switches).
518fn install_binary_forced(method: &InstallMethod, version: Option<&str>) -> Result<()> {
519    install_binary_impl(method, version, true)
520}
521
522/// Internal implementation of binary installation.
523fn install_binary_impl(method: &InstallMethod, version: Option<&str>, force: bool) -> Result<()> {
524    match method {
525        InstallMethod::Homebrew => {
526            let status = Command::new("brew")
527                .args(["install", "mi6"])
528                .status()
529                .context("failed to run brew install")?;
530            if !status.success() {
531                bail!("brew install failed with exit code: {:?}", status.code());
532            }
533        }
534        InstallMethod::CargoRegistry => {
535            let mut args = vec!["install", "mi6"];
536            if force {
537                args.push("--force");
538            }
539            if let Some(v) = version {
540                args.extend(["--version", v]);
541            }
542            let status = Command::new("cargo")
543                .args(&args)
544                .status()
545                .context("failed to run cargo install")?;
546            if !status.success() {
547                bail!("cargo install failed with exit code: {:?}", status.code());
548            }
549        }
550        InstallMethod::CargoPath(path) => {
551            let path_str = path.to_string_lossy();
552            let mut args = vec!["install", "--path", path_str.as_ref()];
553            if force {
554                args.push("--force");
555            }
556            let status = Command::new("cargo")
557                .args(&args)
558                .status()
559                .context("failed to run cargo install --path")?;
560            if !status.success() {
561                bail!(
562                    "cargo install --path failed with exit code: {:?}",
563                    status.code()
564                );
565            }
566        }
567        InstallMethod::Standalone => {
568            // When switching TO standalone, we must install to a DIFFERENT location
569            // than the current binary. By default, self_update replaces current_exe(),
570            // which would be the brew/cargo location - then uninstall would delete it!
571            //
572            // Install to /usr/local/bin (standard location for standalone binaries)
573            let install_dir = PathBuf::from("/usr/local/bin");
574
575            // Ensure the directory exists and is writable
576            if !install_dir.exists() {
577                std::fs::create_dir_all(&install_dir).with_context(|| {
578                    format!(
579                        "Cannot create install directory: {}\n\
580                         You may need to run with sudo or choose a different installation method.",
581                        install_dir.display()
582                    )
583                })?;
584            }
585
586            // Use self_update to download from GitHub releases
587            // Use "0.0.0" as current version to force download even if versions match.
588            let status = self_update::backends::github::Update::configure()
589                .repo_owner("paradigmxyz")
590                .repo_name("mi6")
591                .bin_name("mi6")
592                .bin_install_path(&install_dir)
593                .current_version("0.0.0") // Force download
594                .show_download_progress(true)
595                .build()
596                .context("failed to configure self-updater")?
597                .update()
598                .context("failed to download from GitHub releases")?;
599
600            // Verify the download actually happened
601            if !status.updated() {
602                bail!(
603                    "GitHub download did not produce a binary. \
604                     This may indicate a problem with the release assets."
605                );
606            }
607
608            // Remind user about PATH if /usr/local/bin might not be in it
609            let installed_path = install_dir.join("mi6");
610            println!();
611            println!("Installed to: {}", installed_path.display());
612            println!("Ensure /usr/local/bin is in your PATH.");
613        }
614    }
615    Ok(())
616}
617
618/// Check for available updates without installing (dry-run mode)
619fn check_for_updates(current_version: &str, method: &InstallMethod) -> Result<()> {
620    match method {
621        InstallMethod::Standalone => {
622            println!("Checking GitHub for updates...");
623            println!();
624
625            let update = self_update::backends::github::Update::configure()
626                .repo_owner("paradigmxyz")
627                .repo_name("mi6")
628                .bin_name("mi6")
629                .current_version(current_version)
630                .build()
631                .context("failed to configure self-updater")?;
632
633            let latest = update
634                .get_latest_release()
635                .context("failed to check for updates")?;
636
637            let latest_version = latest.version.trim_start_matches('v');
638
639            if latest_version == current_version {
640                println!("Already at the latest version (v{}).", current_version);
641            } else {
642                println!(
643                    "Update available: v{} -> v{}",
644                    current_version, latest_version
645                );
646                println!();
647                println!("Run `mi6 upgrade` to install.");
648            }
649        }
650        InstallMethod::Homebrew => {
651            println!("To check for Homebrew updates, run:");
652            println!("  brew outdated mi6");
653        }
654        InstallMethod::CargoRegistry => {
655            println!("To check for Cargo updates, run:");
656            println!("  cargo search mi6");
657        }
658        InstallMethod::CargoPath(path) => {
659            println!("Installed from source at: {}", path.display());
660            println!();
661            println!("To check for updates, pull latest changes and reinstall:");
662            println!("  mi6 upgrade");
663        }
664    }
665
666    Ok(())
667}
668
669fn upgrade_homebrew(skip_confirm: bool) -> Result<bool> {
670    if !skip_confirm {
671        println!("This will run: brew upgrade mi6");
672        if !confirm_stdout("Proceed?")? {
673            println!("Aborted.");
674            return Ok(false);
675        }
676        println!();
677    }
678
679    println!("Running: brew upgrade mi6");
680    println!();
681
682    let status = Command::new("brew")
683        .args(["upgrade", "mi6"])
684        .status()
685        .context("failed to run brew upgrade")?;
686
687    if !status.success() {
688        // Homebrew returns non-zero if package is already at latest version
689        println!();
690        println!("Note: brew upgrade exited with non-zero status.");
691        println!("This usually means mi6 is already at the latest version.");
692        println!("If not, try: brew reinstall mi6");
693    }
694
695    Ok(true)
696}
697
698fn upgrade_cargo_registry(version: Option<&str>, skip_confirm: bool) -> Result<bool> {
699    let mut args = vec!["install", "mi6"];
700    if let Some(v) = version {
701        args.extend(["--version", v]);
702    }
703
704    if !skip_confirm {
705        println!("This will run: cargo {}", args.join(" "));
706        if !confirm_stdout("Proceed?")? {
707            println!("Aborted.");
708            return Ok(false);
709        }
710        println!();
711    }
712
713    println!("Running: cargo {}", args.join(" "));
714    println!();
715
716    let status = Command::new("cargo")
717        .args(&args)
718        .status()
719        .context("failed to run cargo install")?;
720
721    if !status.success() {
722        anyhow::bail!("cargo install failed with exit code: {:?}", status.code());
723    }
724
725    Ok(true)
726}
727
728fn upgrade_cargo_path(path: &std::path::Path, skip_confirm: bool) -> Result<bool> {
729    // Verify the path still exists
730    if !path.exists() {
731        anyhow::bail!(
732            "Source directory no longer exists: {}\n\
733             You may need to reinstall mi6 or update the source location.",
734            path.display()
735        );
736    }
737
738    // Find the workspace root (look for Cargo.toml with [workspace])
739    let workspace_root = find_workspace_root(path)?;
740
741    // Determine the git pull target - if we're in a worktree, pull from main worktree instead
742    let pull_target = workspace_root.as_ref().and_then(|root| {
743        let git_dir = root.join(".git");
744        if git_dir.exists() {
745            Some(find_main_worktree(root).unwrap_or_else(|| root.clone()))
746        } else {
747            None
748        }
749    });
750
751    let path_str = path.to_string_lossy();
752    let args = vec!["install", "--path", path_str.as_ref()];
753
754    if !skip_confirm {
755        println!("{}", bold_green("Upgrade process:"));
756        if let Some(ref target) = pull_target {
757            println!(
758                "{} Pull latest changes ({})",
759                bold_green("1."),
760                bold_white(&format!("git pull -C {}", target.display()))
761            );
762            println!(
763                "{} Run cargo install ({})",
764                bold_green("2."),
765                bold_white(&format!("cargo {}", args.join(" ")))
766            );
767        } else {
768            println!(
769                "Run cargo install ({})",
770                bold_white(&format!("cargo {}", args.join(" ")))
771            );
772        }
773        println!();
774        if !confirm_stdout("Proceed?")? {
775            println!("Aborted.");
776            return Ok(false);
777        }
778        println!();
779    }
780
781    // Pull latest changes if we have a git directory
782    if let Some(ref target) = pull_target {
783        println!("Pulling latest changes...");
784        let target_str = target.to_string_lossy();
785
786        let status = Command::new("git")
787            .args(["-C", target_str.as_ref(), "pull"])
788            .status()
789            .context("failed to run git pull")?;
790
791        if !status.success() {
792            anyhow::bail!("git pull failed. Please resolve any conflicts or issues and try again.");
793        }
794        println!();
795    }
796
797    println!("Running: cargo {}", args.join(" "));
798    println!();
799
800    let status = Command::new("cargo")
801        .args(&args)
802        .status()
803        .context("failed to run cargo install --path")?;
804
805    if !status.success() {
806        anyhow::bail!("cargo install failed with exit code: {:?}", status.code());
807    }
808
809    Ok(true)
810}
811
812/// Find the workspace root by looking for Cargo.toml with [workspace] section
813fn find_workspace_root(crate_path: &std::path::Path) -> Result<Option<PathBuf>> {
814    let mut current = crate_path.to_path_buf();
815
816    // Walk up the directory tree looking for workspace Cargo.toml
817    for _ in 0..10 {
818        let cargo_toml = current.join("Cargo.toml");
819        if cargo_toml.exists() {
820            let content = std::fs::read_to_string(&cargo_toml)?;
821            if content.contains("[workspace]") {
822                return Ok(Some(current));
823            }
824        }
825
826        if !current.pop() {
827            break;
828        }
829    }
830
831    Ok(None)
832}
833
834/// Find the main worktree directory from any git directory (including worktrees).
835///
836/// If the given path is within a git worktree, this returns the main worktree
837/// (the original repo directory). If it's already in the main worktree, returns None.
838fn find_main_worktree(git_path: &std::path::Path) -> Option<PathBuf> {
839    let path_str = git_path.to_string_lossy();
840
841    // Get the common git directory (shared across all worktrees)
842    let output = Command::new("git")
843        .args([
844            "-C",
845            &path_str,
846            "rev-parse",
847            "--path-format=absolute",
848            "--git-common-dir",
849        ])
850        .output()
851        .ok()?;
852
853    if !output.status.success() {
854        return None;
855    }
856
857    let common_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
858    let common_path = PathBuf::from(&common_dir);
859
860    // The main worktree is the parent of the common .git directory
861    // (e.g., /path/to/repo/.git -> /path/to/repo)
862    let main_worktree = common_path.parent()?.to_path_buf();
863
864    // Check if we're already in the main worktree by comparing to current path
865    // Get the git dir for the given path
866    let git_dir_output = Command::new("git")
867        .args([
868            "-C",
869            &path_str,
870            "rev-parse",
871            "--path-format=absolute",
872            "--git-dir",
873        ])
874        .output()
875        .ok()?;
876
877    if !git_dir_output.status.success() {
878        return None;
879    }
880
881    let git_dir = String::from_utf8_lossy(&git_dir_output.stdout)
882        .trim()
883        .to_string();
884
885    // If git-dir equals git-common-dir, we're in the main worktree
886    if git_dir == common_dir {
887        return None;
888    }
889
890    Some(main_worktree)
891}
892
893fn upgrade_standalone(current_version: &str, skip_confirm: bool) -> Result<bool> {
894    println!("Checking GitHub for updates...");
895    println!();
896
897    let update = self_update::backends::github::Update::configure()
898        .repo_owner("paradigmxyz")
899        .repo_name("mi6")
900        .bin_name("mi6")
901        .current_version(current_version)
902        .build()
903        .context("failed to configure self-updater")?;
904
905    // Check for update first
906    let latest = update
907        .get_latest_release()
908        .context("failed to check for updates")?;
909
910    let latest_version = latest.version.trim_start_matches('v');
911
912    if latest_version == current_version {
913        println!("Already at the latest version (v{}).", current_version);
914        return Ok(true);
915    }
916
917    println!(
918        "New version available: v{} -> v{}",
919        current_version, latest_version
920    );
921
922    if !skip_confirm && !confirm_stdout("Proceed with update?")? {
923        println!("Aborted.");
924        return Ok(false);
925    }
926    println!();
927
928    println!("Downloading and installing v{}...", latest_version);
929
930    // Create a new update with show_progress for the actual download
931    let status = self_update::backends::github::Update::configure()
932        .repo_owner("paradigmxyz")
933        .repo_name("mi6")
934        .bin_name("mi6")
935        .current_version(current_version)
936        .show_download_progress(true)
937        .build()
938        .context("failed to configure self-updater")?
939        .update()
940        .context("failed to perform update")?;
941
942    println!();
943    println!("Updated to v{}!", status.version());
944
945    Ok(true)
946}