Skip to main content

codewhale_release/
install.rs

1//! How *this* binary was installed, and therefore which command updates it.
2//!
3//! `codewhale update` replaces the running executable in place. That is the
4//! right thing for a binary the user downloaded from GitHub Releases, and the
5//! wrong thing for one a package manager owns: overwriting Homebrew's Cellar
6//! binary or npm's `node_modules` payload leaves the manager's metadata
7//! describing a version that is no longer on disk, and the next
8//! `brew upgrade` / `npm install -g` silently reverts the user.
9//!
10//! So before we tell anyone to run anything, we work out who owns the file.
11//! Detection is path-based (plus an escape-hatch env var) because the install
12//! method is a property of *where the binary lives*, which is knowable
13//! offline, in a test, and without asking a package manager anything.
14
15use std::path::Path;
16
17/// Environment variable that overrides install-method detection.
18///
19/// Accepts `npm`, `homebrew` (or `brew`), `cargo`, and `binary`. Anything else
20/// is ignored and detection falls back to the path heuristics. Packagers who
21/// relocate the binary somewhere the heuristics cannot read — and users
22/// debugging a wrong guess — set this.
23pub const INSTALL_METHOD_ENV: &str = "CODEWHALE_INSTALL_METHOD";
24
25/// The package manager (if any) that owns the running executable.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum InstallMethod {
28    /// Global npm install — the `codewhale` package under `node_modules`.
29    Npm,
30    /// Homebrew — a binary under a `Cellar` or `linuxbrew` prefix.
31    Homebrew,
32    /// `cargo install` — a binary under `~/.cargo/bin`.
33    Cargo,
34    /// A release binary the user placed on disk themselves. The default, and
35    /// the only case where in-place self-update is correct.
36    Binary,
37}
38
39impl InstallMethod {
40    /// Detect from an executable path, honouring [`INSTALL_METHOD_ENV`].
41    ///
42    /// Pass the *resolved* path — `std::env::current_exe()` already follows
43    /// symlinks on the platforms we ship, which is what puts a globally
44    /// npm-installed binary inside `node_modules` and a Homebrew one inside
45    /// `Cellar` rather than in the manager's flat `bin` shim directory.
46    #[must_use]
47    pub fn detect(exe: &Path) -> Self {
48        if let Some(forced) = std::env::var(INSTALL_METHOD_ENV)
49            .ok()
50            .and_then(|raw| Self::from_token(&raw))
51        {
52            return forced;
53        }
54        Self::from_path(exe)
55    }
56
57    /// Path-only detection, with no environment lookup. Split out from
58    /// [`detect`](Self::detect) so tests can exercise the heuristics without
59    /// mutating process-global state.
60    #[must_use]
61    pub fn from_path(exe: &Path) -> Self {
62        let components: Vec<String> = exe
63            .components()
64            .filter_map(|c| c.as_os_str().to_str())
65            .map(str::to_ascii_lowercase)
66            .collect();
67
68        let has = |name: &str| components.iter().any(|c| c == name);
69
70        // npm is checked first: a `node_modules` install *inside* a Homebrew
71        // or Termux prefix is still npm's to update.
72        if has("node_modules") {
73            return Self::Npm;
74        }
75        if has("cellar") || has(".linuxbrew") || has("linuxbrew") {
76            return Self::Homebrew;
77        }
78        // `.cargo/bin/codewhale` — require the pair so an unrelated `bin`
79        // directory does not read as a Cargo install.
80        if components
81            .windows(2)
82            .any(|pair| pair[0] == ".cargo" && pair[1] == "bin")
83        {
84            return Self::Cargo;
85        }
86        Self::Binary
87    }
88
89    fn from_token(raw: &str) -> Option<Self> {
90        match raw.trim().to_ascii_lowercase().as_str() {
91            "npm" => Some(Self::Npm),
92            "homebrew" | "brew" => Some(Self::Homebrew),
93            "cargo" => Some(Self::Cargo),
94            "binary" | "release" => Some(Self::Binary),
95            _ => None,
96        }
97    }
98
99    /// The exact shell command that updates this install.
100    ///
101    /// Homebrew's primary formula is `codewhale`. Existing Cellar paths
102    /// under the legacy `deepseek-tui` name still detect as Homebrew; those
103    /// installs can keep using `brew upgrade deepseek-tui` during the
104    /// overlap window, but new notices name the Codewhale formula.
105    #[must_use]
106    pub fn update_command(self) -> &'static str {
107        match self {
108            Self::Npm => "npm install -g codewhale@latest",
109            Self::Homebrew => "brew upgrade codewhale",
110            Self::Cargo => "cargo install codewhale-cli --locked --force",
111            Self::Binary => "codewhale update",
112        }
113    }
114
115    /// Whether `codewhale update` may replace this binary in place.
116    ///
117    /// False for every package-managed install: see the module docs for why
118    /// overwriting a managed binary is worse than doing nothing.
119    #[must_use]
120    pub fn supports_self_update(self) -> bool {
121        matches!(self, Self::Binary)
122    }
123
124    /// Short human label, for messages that name the owner of the install.
125    #[must_use]
126    pub fn label(self) -> &'static str {
127        match self {
128            Self::Npm => "npm",
129            Self::Homebrew => "Homebrew",
130            Self::Cargo => "cargo",
131            Self::Binary => "release binary",
132        }
133    }
134}
135
136/// Detect the install method for the currently running executable.
137///
138/// Returns [`InstallMethod::Binary`] when the executable path cannot be
139/// resolved — the conservative answer, because it is the one that tells the
140/// user to run our own updater rather than a package manager command that may
141/// not apply to them.
142#[must_use]
143pub fn current_install_method() -> InstallMethod {
144    match std::env::current_exe() {
145        Ok(exe) => InstallMethod::detect(&exe),
146        Err(_) => InstallMethod::Binary,
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use std::path::PathBuf;
153
154    use super::*;
155
156    #[test]
157    fn npm_global_install_is_detected_from_node_modules() {
158        let exe = PathBuf::from("/usr/local/lib/node_modules/codewhale/bin/codewhale");
159        assert_eq!(InstallMethod::from_path(&exe), InstallMethod::Npm);
160        assert_eq!(
161            InstallMethod::Npm.update_command(),
162            "npm install -g codewhale@latest"
163        );
164        assert!(!InstallMethod::Npm.supports_self_update());
165    }
166
167    #[test]
168    fn homebrew_install_is_detected_from_cellar_on_both_prefixes() {
169        for exe in [
170            "/opt/homebrew/Cellar/codewhale/0.9.8/bin/codewhale",
171            "/usr/local/Cellar/codewhale/0.9.8/bin/codewhale",
172            "/home/linuxbrew/.linuxbrew/Cellar/codewhale/0.9.8/bin/codewhale",
173            "/opt/homebrew/Cellar/deepseek-tui/0.9.4/bin/codewhale",
174            "/usr/local/Cellar/deepseek-tui/0.9.4/bin/codewhale",
175            "/home/linuxbrew/.linuxbrew/Cellar/deepseek-tui/0.9.4/bin/codewhale",
176        ] {
177            assert_eq!(
178                InstallMethod::from_path(&PathBuf::from(exe)),
179                InstallMethod::Homebrew,
180                "{exe} should read as Homebrew"
181            );
182        }
183        assert_eq!(
184            InstallMethod::Homebrew.update_command(),
185            "brew upgrade codewhale"
186        );
187        assert!(!InstallMethod::Homebrew.supports_self_update());
188    }
189
190    #[test]
191    fn cargo_install_requires_the_cargo_bin_pair() {
192        assert_eq!(
193            InstallMethod::from_path(&PathBuf::from("/home/u/.cargo/bin/codewhale")),
194            InstallMethod::Cargo
195        );
196        // A bare `bin` directory is not a Cargo install.
197        assert_eq!(
198            InstallMethod::from_path(&PathBuf::from("/home/u/bin/codewhale")),
199            InstallMethod::Binary
200        );
201        assert!(!InstallMethod::Cargo.supports_self_update());
202    }
203
204    #[test]
205    fn npm_wins_over_an_enclosing_manager_prefix() {
206        // npm installed under a Homebrew-managed node prefix is still npm's.
207        let exe = PathBuf::from("/opt/homebrew/lib/node_modules/codewhale/bin/codewhale");
208        assert_eq!(InstallMethod::from_path(&exe), InstallMethod::Npm);
209    }
210
211    #[test]
212    fn termux_and_plain_release_binaries_self_update() {
213        for exe in [
214            "/data/data/com.termux/files/usr/bin/codewhale",
215            "/usr/local/bin/codewhale",
216            "/home/u/Downloads/codewhale",
217        ] {
218            let method = InstallMethod::from_path(&PathBuf::from(exe));
219            assert_eq!(method, InstallMethod::Binary, "{exe} should self-update");
220            assert!(method.supports_self_update());
221            assert_eq!(method.update_command(), "codewhale update");
222        }
223    }
224
225    #[test]
226    fn env_tokens_map_to_methods_and_junk_is_ignored() {
227        assert_eq!(InstallMethod::from_token("npm"), Some(InstallMethod::Npm));
228        assert_eq!(
229            InstallMethod::from_token("  BREW "),
230            Some(InstallMethod::Homebrew)
231        );
232        assert_eq!(
233            InstallMethod::from_token("homebrew"),
234            Some(InstallMethod::Homebrew)
235        );
236        assert_eq!(
237            InstallMethod::from_token("cargo"),
238            Some(InstallMethod::Cargo)
239        );
240        assert_eq!(
241            InstallMethod::from_token("binary"),
242            Some(InstallMethod::Binary)
243        );
244        assert_eq!(InstallMethod::from_token("apt"), None);
245    }
246}