railwayapp 4.57.5

Interact with Railway via CLI
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallMethod {
    Homebrew,
    Npm,
    Bun,
    Cargo,
    Shell,
    Scoop,
    Unknown,
}

impl InstallMethod {
    pub fn detect() -> Self {
        let exe_path = match std::env::current_exe() {
            Ok(path) => path,
            Err(_) => return InstallMethod::Unknown,
        };

        // Resolve symlinks so that e.g. /usr/local/bin/railway (Intel
        // Homebrew symlink) is followed to /usr/local/Cellar/… and
        // correctly classified as Homebrew rather than Shell.
        let exe_path = exe_path.canonicalize().unwrap_or(exe_path);

        let path_str = exe_path.to_string_lossy().to_lowercase();

        if path_str.contains("homebrew")
            || path_str.contains("cellar")
            || path_str.contains("linuxbrew")
        {
            return InstallMethod::Homebrew;
        }

        // Check for Bun global install (must be before npm since bun uses node_modules internally)
        if path_str.contains(".bun") {
            return InstallMethod::Bun;
        }

        // pnpm paths contain "npm" as a substring — check before npm.
        if path_str.contains("pnpm") {
            return InstallMethod::Unknown;
        }

        if path_str.contains("node_modules")
            || path_str.contains("npm")
            || path_str.contains(".npm")
        {
            return InstallMethod::Npm;
        }

        if path_str.contains(".cargo") && path_str.contains("bin") {
            return InstallMethod::Cargo;
        }

        if path_str.contains("scoop") {
            return InstallMethod::Scoop;
        }

        // Cargo's `CARGO_INSTALL_ROOT` can place binaries in standard paths
        // like /usr/local/bin or ~/.local/bin.  Check for the `.crates.toml`
        // marker *before* the shell-path heuristic so these are not
        // misclassified as Shell installs.
        if exe_path
            .parent()
            .and_then(|bin| bin.parent())
            .map(|root| root.join(".crates.toml").exists())
            .unwrap_or(false)
        {
            return InstallMethod::Cargo;
        }

        if path_str.contains("/usr/local/bin") || path_str.contains("/.local/bin") {
            return InstallMethod::Shell;
        }

        if path_str.contains("program files") || path_str.contains("programfiles") {
            return InstallMethod::Shell;
        }

        // Paths owned by system package managers — must be checked before
        // the catch-all so we don't misclassify them as Shell.
        const SYSTEM_PATHS: &[&str] = &[
            "/usr/bin",
            "/usr/sbin",
            "/nix/",
            "nix-profile",
            "/snap/",
            "/flatpak/",
        ];
        if SYSTEM_PATHS.iter().any(|p| path_str.contains(p)) {
            return InstallMethod::Unknown;
        }

        // Version managers install binaries under their own directory trees.
        // Exclude them so the catch-all doesn't misclassify a managed binary
        // as a shell install and attempt to self-replace it.
        const VERSION_MANAGER_PATHS: &[&str] = &[
            ".asdf/", ".mise/", ".rtx/", ".proto/", ".volta/", ".fnm/", ".nodenv/", ".rbenv/",
            ".pyenv/",
        ];
        if VERSION_MANAGER_PATHS.iter().any(|p| path_str.contains(p)) {
            return InstallMethod::Unknown;
        }

        // Catch-all: if the binary lives in any directory named "bin" and no
        // package manager, system path, or version manager was detected, it
        // was most likely installed via the shell installer (possibly with a
        // custom --bin-dir like ~/tools/bin or /opt/railway/bin).
        // Note: Cargo's CARGO_INSTALL_ROOT is already caught by the
        // `.crates.toml` check above, so no need to re-check here.
        if exe_path
            .parent()
            .and_then(|p| p.file_name())
            .map(|n| n == "bin")
            .unwrap_or(false)
        {
            return InstallMethod::Shell;
        }

        InstallMethod::Unknown
    }

    pub fn name(&self) -> &'static str {
        match self {
            InstallMethod::Homebrew => "Homebrew",
            InstallMethod::Npm => "npm",
            InstallMethod::Bun => "Bun",
            InstallMethod::Cargo => "Cargo",
            InstallMethod::Shell => "Shell script",
            InstallMethod::Scoop => "Scoop",
            InstallMethod::Unknown => "Unknown",
        }
    }

    pub fn upgrade_command(&self) -> Option<String> {
        if let Some((program, args)) = self.package_manager_command() {
            return Some(format!("{} {}", program, args.join(" ")));
        }
        match self {
            InstallMethod::Shell => Some("bash <(curl -fsSL cli.new)".to_string()),
            _ => None,
        }
    }

    pub fn can_auto_upgrade(&self) -> bool {
        matches!(
            self,
            InstallMethod::Homebrew
                | InstallMethod::Npm
                | InstallMethod::Bun
                | InstallMethod::Cargo
                | InstallMethod::Scoop
        )
    }

    /// Whether this install method supports direct binary self-update
    /// (download from GitHub Releases and replace in place).
    /// Only Shell installs on platforms with published release assets qualify.
    /// Unknown means we don't know where the binary came from, so
    /// self-updating it could conflict with an undetected package manager.
    pub fn can_self_update(&self) -> bool {
        matches!(self, InstallMethod::Shell) && is_self_update_platform()
    }

    /// Whether the current process can write to the directory containing the
    /// binary.  Returns `false` for paths like `/usr/local/bin` that were
    /// installed with `sudo` and are not writable by the current user.
    pub fn can_write_binary(&self) -> bool {
        let exe_path = match std::env::current_exe() {
            Ok(p) => p,
            Err(_) => return false,
        };
        let dir = match exe_path.parent() {
            Some(d) => d,
            None => return false,
        };

        // Try creating a temp file in the same directory — the most reliable
        // cross-platform writability check (accounts for ACLs, mount flags…).
        let probe = dir.join(".railway-write-probe");
        let writable = std::fs::File::create(&probe).is_ok();
        let _ = std::fs::remove_file(&probe);
        writable
    }

    /// Whether this install method supports auto-running the package manager
    /// in the background.  Homebrew and Cargo are excluded because they can
    /// take several minutes and would keep a detached process alive far longer
    /// than is acceptable for a transparent background update.
    ///
    /// Also checks that the package manager's global install directory is
    /// writable by the current user, so we don't spawn a doomed `npm update -g`
    /// (installed via `sudo`) that fails immediately on every invocation.
    pub fn can_auto_run_package_manager(&self) -> bool {
        if !matches!(
            self,
            InstallMethod::Npm | InstallMethod::Bun | InstallMethod::Scoop
        ) {
            return false;
        }

        // Probe writability of the directory containing the binary — if we
        // can't write there, the package manager update will fail anyway.
        self.can_write_binary()
    }

    /// Human-readable description of the auto-update strategy for this install method.
    /// Reflects the actual runtime behaviour by checking platform support and
    /// binary writability, so `autoupdate status` never overpromises.
    pub fn update_strategy(&self) -> &'static str {
        match self {
            InstallMethod::Shell if self.can_self_update() && self.can_write_binary() => {
                "Background download + auto-swap"
            }
            InstallMethod::Shell if self.can_self_update() => {
                "Notification only (binary not writable)"
            }
            InstallMethod::Shell => "Notification only (unsupported platform)",
            InstallMethod::Npm | InstallMethod::Bun | InstallMethod::Scoop
                if self.can_auto_run_package_manager() =>
            {
                "Auto-run package manager"
            }
            InstallMethod::Npm | InstallMethod::Bun | InstallMethod::Scoop => {
                "Notification only (binary not writable)"
            }
            InstallMethod::Homebrew | InstallMethod::Cargo | InstallMethod::Unknown => {
                "Notification only (manual upgrade)"
            }
        }
    }

    /// Returns the program and arguments to run the package manager upgrade.
    pub fn package_manager_command(&self) -> Option<(&'static str, Vec<&'static str>)> {
        match self {
            InstallMethod::Homebrew => Some(("brew", vec!["upgrade", "railway"])),
            InstallMethod::Npm => Some(("npm", vec!["update", "-g", "@railway/cli"])),
            InstallMethod::Bun => Some(("bun", vec!["update", "-g", "@railway/cli"])),
            InstallMethod::Cargo => Some(("cargo", vec!["install", "railwayapp", "--locked"])),
            InstallMethod::Scoop => Some(("scoop", vec!["update", "railway"])),
            InstallMethod::Shell | InstallMethod::Unknown => None,
        }
    }
}

/// Returns `true` when the release pipeline publishes a binary for the
/// current OS, i.e. self-update can actually download an asset.
/// FreeBSD is recognized by the install script but no release asset is
/// published, so it must not enter the self-update path.
fn is_self_update_platform() -> bool {
    matches!(std::env::consts::OS, "macos" | "linux" | "windows")
}