返回 CodeWhale
install.rs
根目录 / crates / release / src / 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
15 use 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.
23 pub 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)]
27 pub 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
39 impl 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 still points at the legacy `deepseek-tui` formula: no
102 /// `codewhale` formula is published yet (see `docs/INSTALL.md`), and
103 /// naming one that does not exist would hand the user a command that
104 /// fails.
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 deepseek-tui",
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]
143 pub 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)]
151 mod 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/deepseek-tui/0.9.4/bin/codewhale",
171 "/usr/local/Cellar/deepseek-tui/0.9.4/bin/codewhale",
172 "/home/linuxbrew/.linuxbrew/Cellar/deepseek-tui/0.9.4/bin/codewhale",
173 ] {
174 assert_eq!(
175 InstallMethod::from_path(&PathBuf::from(exe)),
176 InstallMethod::Homebrew,
177 "{exe} should read as Homebrew"
178 );
179 }
180 // The formula is still the legacy name; see `docs/INSTALL.md`.
181 assert_eq!(
182 InstallMethod::Homebrew.update_command(),
183 "brew upgrade deepseek-tui"
184 );
185 assert!(!InstallMethod::Homebrew.supports_self_update());
186 }
187
188 #[test]
189 fn cargo_install_requires_the_cargo_bin_pair() {
190 assert_eq!(
191 InstallMethod::from_path(&PathBuf::from("/home/u/.cargo/bin/codewhale")),
192 InstallMethod::Cargo
193 );
194 // A bare `bin` directory is not a Cargo install.
195 assert_eq!(
196 InstallMethod::from_path(&PathBuf::from("/home/u/bin/codewhale")),
197 InstallMethod::Binary
198 );
199 assert!(!InstallMethod::Cargo.supports_self_update());
200 }
201
202 #[test]
203 fn npm_wins_over_an_enclosing_manager_prefix() {
204 // npm installed under a Homebrew-managed node prefix is still npm's.
205 let exe = PathBuf::from("/opt/homebrew/lib/node_modules/codewhale/bin/codewhale");
206 assert_eq!(InstallMethod::from_path(&exe), InstallMethod::Npm);
207 }
208
209 #[test]
210 fn termux_and_plain_release_binaries_self_update() {
211 for exe in [
212 "/data/data/com.termux/files/usr/bin/codewhale",
213 "/usr/local/bin/codewhale",
214 "/home/u/Downloads/codewhale",
215 ] {
216 let method = InstallMethod::from_path(&PathBuf::from(exe));
217 assert_eq!(method, InstallMethod::Binary, "{exe} should self-update");
218 assert!(method.supports_self_update());
219 assert_eq!(method.update_command(), "codewhale update");
220 }
221 }
222
223 #[test]
224 fn env_tokens_map_to_methods_and_junk_is_ignored() {
225 assert_eq!(InstallMethod::from_token("npm"), Some(InstallMethod::Npm));
226 assert_eq!(
227 InstallMethod::from_token(" BREW "),
228 Some(InstallMethod::Homebrew)
229 );
230 assert_eq!(
231 InstallMethod::from_token("homebrew"),
232 Some(InstallMethod::Homebrew)
233 );
234 assert_eq!(
235 InstallMethod::from_token("cargo"),
236 Some(InstallMethod::Cargo)
237 );
238 assert_eq!(
239 InstallMethod::from_token("binary"),
240 Some(InstallMethod::Binary)
241 );
242 assert_eq!(InstallMethod::from_token("apt"), None);
243 }
244 }
245
245 lines RUST