返回 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 where managers use distinct prefixes. Omarchy's
12 //! AUR packages live in the ordinary `/usr/bin` prefix, so that case also
13 //! consults the local pacman ownership database. No network access is needed.
14
15 use std::path::Path;
16 #[cfg(target_os = "linux")]
17 use std::process::{Command, Stdio};
18
19 /// Environment variable that overrides install-method detection.
20 ///
21 /// Accepts `npm`, `homebrew` (or `brew`), `cargo`, `omarchy`, and `binary`.
22 /// Anything else is ignored and detection falls back to automatic detection.
23 /// Packagers who relocate the binary somewhere the heuristics cannot read —
24 /// and users debugging a wrong guess — set this. Recognized managed paths
25 /// take precedence; `binary` cannot authorize overwriting a package-owned file.
26 pub const INSTALL_METHOD_ENV: &str = "CODEWHALE_INSTALL_METHOD";
27
28 /// Shared migration instructions for every runtime update surface. The new
29 /// directory avoids guessing ownership or overwriting a mixed installation.
30 pub const GITHUB_MIGRATION_HELP: &str = r#"Install the official GitHub release into a fresh user directory (macOS/Linux):
31 mkdir -p "$HOME/.local"
32 codewhale_install_dir="$(mktemp -d "$HOME/.local/codewhale-release.XXXXXX")"
33 curl -fsSL https://codewhale.net/install.sh | CODEWHALE_INSTALL_DIR="$codewhale_install_dir" sh
34 "$codewhale_install_dir/codewhale" --version
35 export PATH="$codewhale_install_dir:$PATH"
36 hash -r
37 command -v codewhale codew
38 Future updates: "$codewhale_install_dir/codewhale" update
39 Keep the chosen PATH directory in your shell profile after verifying it.
40 Windows: https://github.com/Hmbown/CodeWhale/releases/latest
41 PATH and migration: https://github.com/Hmbown/CodeWhale/blob/main/docs/INSTALL.md"#;
42
43 /// The package manager (if any) that owns the running executable.
44 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45 pub enum InstallMethod {
46 /// Global npm install — the `codewhale` package under `node_modules`.
47 Npm,
48 /// Homebrew — a binary under a `Cellar` or `linuxbrew` prefix.
49 Homebrew,
50 /// `cargo install` — a binary under `~/.cargo/bin`.
51 Cargo,
52 /// An AUR/pacman package installed on Omarchy.
53 Omarchy,
54 /// A release binary the user placed on disk themselves. The default, and
55 /// the only case where in-place self-update is correct.
56 Binary,
57 }
58
59 impl InstallMethod {
60 /// Detect from an executable path, honouring [`INSTALL_METHOD_ENV`].
61 ///
62 /// Pass the *resolved* path — `std::env::current_exe()` already follows
63 /// symlinks on the platforms we ship, which is what puts a globally
64 /// npm-installed binary inside `node_modules` and a Homebrew one inside
65 /// `Cellar` rather than in the manager's flat `bin` shim directory.
66 #[must_use]
67 pub fn detect(exe: &Path) -> Self {
68 let detected = Self::detect_with_omarchy_probe(exe, omarchy_package_owns);
69 // An override can identify a relocated package, but cannot authorize
70 // replacing a file that a known package manager owns.
71 if !detected.supports_self_update() {
72 return detected;
73 }
74 if let Some(forced) = std::env::var(INSTALL_METHOD_ENV)
75 .ok()
76 .and_then(|raw| Self::from_token(&raw))
77 {
78 return forced;
79 }
80 detected
81 }
82
83 fn detect_with_omarchy_probe(exe: &Path, owns_package: impl FnOnce(&Path) -> bool) -> Self {
84 let path_method = Self::from_path(exe);
85 if path_method != Self::Binary {
86 return path_method;
87 }
88 if owns_package(exe) {
89 Self::Omarchy
90 } else {
91 Self::Binary
92 }
93 }
94
95 /// Path-only detection, with no environment lookup. Split out from
96 /// [`detect`](Self::detect) so tests can exercise the heuristics without
97 /// mutating process-global state.
98 #[must_use]
99 pub fn from_path(exe: &Path) -> Self {
100 let components: Vec<String> = exe
101 .components()
102 .filter_map(|c| c.as_os_str().to_str())
103 .map(str::to_ascii_lowercase)
104 .collect();
105
106 let has = |name: &str| components.iter().any(|c| c == name);
107
108 // npm is checked first: a `node_modules` install *inside* a Homebrew
109 // or Termux prefix is still npm's to update.
110 if has("node_modules") {
111 return Self::Npm;
112 }
113 if has("cellar") || has(".linuxbrew") || has("linuxbrew") {
114 return Self::Homebrew;
115 }
116 // `.cargo/bin/codewhale` — require the pair so an unrelated `bin`
117 // directory does not read as a Cargo install.
118 if components
119 .windows(2)
120 .any(|pair| pair[0] == ".cargo" && pair[1] == "bin")
121 {
122 return Self::Cargo;
123 }
124 Self::Binary
125 }
126
127 fn from_token(raw: &str) -> Option<Self> {
128 match raw.trim().to_ascii_lowercase().as_str() {
129 "npm" => Some(Self::Npm),
130 "homebrew" | "brew" => Some(Self::Homebrew),
131 "cargo" => Some(Self::Cargo),
132 "omarchy" => Some(Self::Omarchy),
133 "binary" | "release" => Some(Self::Binary),
134 _ => None,
135 }
136 }
137
138 /// The exact shell command that updates this install.
139 ///
140 /// Homebrew's primary formula is `codewhale`. Existing Cellar paths
141 /// under the legacy `deepseek-tui` name still detect as Homebrew; those
142 /// installs can keep using `brew upgrade deepseek-tui` during the
143 /// overlap window, but new notices name the Codewhale formula.
144 #[must_use]
145 pub fn update_command(self) -> &'static str {
146 match self {
147 Self::Npm => "npm install -g codewhale@latest",
148 Self::Homebrew => "brew upgrade codewhale",
149 Self::Cargo => "cargo install codewhale-cli --locked --force",
150 Self::Omarchy => "omarchy update",
151 Self::Binary => "codewhale update",
152 }
153 }
154
155 /// Whether `codewhale update` may replace this binary in place.
156 ///
157 /// False for every package-managed install: see the module docs for why
158 /// overwriting a managed binary is worse than doing nothing.
159 #[must_use]
160 pub fn supports_self_update(self) -> bool {
161 matches!(self, Self::Binary)
162 }
163
164 /// Short human label, for messages that name the owner of the install.
165 #[must_use]
166 pub fn label(self) -> &'static str {
167 match self {
168 Self::Npm => "npm",
169 Self::Homebrew => "Homebrew",
170 Self::Cargo => "cargo",
171 Self::Omarchy => "Omarchy",
172 Self::Binary => "release binary",
173 }
174 }
175 }
176
177 #[cfg(target_os = "linux")]
178 fn omarchy_package_owns(exe: &Path) -> bool {
179 if !Path::new("/usr/share/omarchy/version").is_file() {
180 return false;
181 }
182
183 Command::new("pacman")
184 .args(["-Qo"])
185 .arg(exe)
186 .stdin(Stdio::null())
187 .stdout(Stdio::null())
188 .stderr(Stdio::null())
189 .status()
190 .is_ok_and(|status| status.success())
191 }
192
193 #[cfg(not(target_os = "linux"))]
194 fn omarchy_package_owns(_exe: &Path) -> bool {
195 false
196 }
197
198 /// Detect the install method for the currently running executable.
199 ///
200 /// Returns [`InstallMethod::Binary`] when the executable path cannot be
201 /// resolved — the conservative answer, because it is the one that tells the
202 /// user to run our own updater rather than a package manager command that may
203 /// not apply to them.
204 #[must_use]
205 pub fn current_install_method() -> InstallMethod {
206 match std::env::current_exe() {
207 Ok(exe) => InstallMethod::detect(&exe),
208 Err(_) => InstallMethod::Binary,
209 }
210 }
211
212 #[cfg(test)]
213 mod tests {
214 use std::path::PathBuf;
215
216 use super::*;
217
218 #[test]
219 fn npm_global_install_is_detected_from_node_modules() {
220 let exe = PathBuf::from("/usr/local/lib/node_modules/codewhale/bin/codewhale");
221 assert_eq!(InstallMethod::from_path(&exe), InstallMethod::Npm);
222 assert_eq!(
223 InstallMethod::Npm.update_command(),
224 "npm install -g codewhale@latest"
225 );
226 assert!(!InstallMethod::Npm.supports_self_update());
227 }
228
229 #[test]
230 fn homebrew_install_is_detected_from_cellar_on_both_prefixes() {
231 for exe in [
232 "/opt/homebrew/Cellar/codewhale/0.9.8/bin/codewhale",
233 "/usr/local/Cellar/codewhale/0.9.8/bin/codewhale",
234 "/home/linuxbrew/.linuxbrew/Cellar/codewhale/0.9.8/bin/codewhale",
235 "/opt/homebrew/Cellar/deepseek-tui/0.9.4/bin/codewhale",
236 "/usr/local/Cellar/deepseek-tui/0.9.4/bin/codewhale",
237 "/home/linuxbrew/.linuxbrew/Cellar/deepseek-tui/0.9.4/bin/codewhale",
238 ] {
239 assert_eq!(
240 InstallMethod::from_path(&PathBuf::from(exe)),
241 InstallMethod::Homebrew,
242 "{exe} should read as Homebrew"
243 );
244 }
245 assert_eq!(
246 InstallMethod::Homebrew.update_command(),
247 "brew upgrade codewhale"
248 );
249 assert!(!InstallMethod::Homebrew.supports_self_update());
250 }
251
252 #[test]
253 fn cargo_install_requires_the_cargo_bin_pair() {
254 assert_eq!(
255 InstallMethod::from_path(&PathBuf::from("/home/u/.cargo/bin/codewhale")),
256 InstallMethod::Cargo
257 );
258 // A bare `bin` directory is not a Cargo install.
259 assert_eq!(
260 InstallMethod::from_path(&PathBuf::from("/home/u/bin/codewhale")),
261 InstallMethod::Binary
262 );
263 assert!(!InstallMethod::Cargo.supports_self_update());
264 }
265
266 #[test]
267 fn npm_wins_over_an_enclosing_manager_prefix() {
268 // npm installed under a Homebrew-managed node prefix is still npm's.
269 let exe = PathBuf::from("/opt/homebrew/lib/node_modules/codewhale/bin/codewhale");
270 assert_eq!(InstallMethod::from_path(&exe), InstallMethod::Npm);
271 }
272
273 #[test]
274 fn omarchy_probe_only_claims_package_owned_plain_paths() {
275 let managed = PathBuf::from("/usr/bin/codewhale");
276 assert_eq!(
277 InstallMethod::detect_with_omarchy_probe(&managed, |_| true),
278 InstallMethod::Omarchy
279 );
280 assert_eq!(
281 InstallMethod::detect_with_omarchy_probe(&managed, |_| false),
282 InstallMethod::Binary
283 );
284
285 let npm = PathBuf::from("/usr/lib/node_modules/codewhale/bin/codewhale");
286 assert_eq!(
287 InstallMethod::detect_with_omarchy_probe(&npm, |_| {
288 panic!("a path-owned install must not query pacman")
289 }),
290 InstallMethod::Npm
291 );
292 }
293
294 #[test]
295 fn termux_and_plain_release_binaries_self_update() {
296 for exe in [
297 "/data/data/com.termux/files/usr/bin/codewhale",
298 "/usr/local/bin/codewhale",
299 "/home/u/Downloads/codewhale",
300 ] {
301 let method = InstallMethod::from_path(&PathBuf::from(exe));
302 assert_eq!(method, InstallMethod::Binary, "{exe} should self-update");
303 assert!(method.supports_self_update());
304 assert_eq!(method.update_command(), "codewhale update");
305 }
306 }
307
308 #[test]
309 fn env_tokens_map_to_methods_and_junk_is_ignored() {
310 assert_eq!(InstallMethod::from_token("npm"), Some(InstallMethod::Npm));
311 assert_eq!(
312 InstallMethod::from_token(" BREW "),
313 Some(InstallMethod::Homebrew)
314 );
315 assert_eq!(
316 InstallMethod::from_token("homebrew"),
317 Some(InstallMethod::Homebrew)
318 );
319 assert_eq!(
320 InstallMethod::from_token("cargo"),
321 Some(InstallMethod::Cargo)
322 );
323 assert_eq!(
324 InstallMethod::from_token("omarchy"),
325 Some(InstallMethod::Omarchy)
326 );
327 assert_eq!(
328 InstallMethod::from_token("binary"),
329 Some(InstallMethod::Binary)
330 );
331 assert_eq!(InstallMethod::from_token("apt"), None);
332 }
333
334 #[test]
335 fn omarchy_install_is_package_managed() {
336 assert_eq!(InstallMethod::Omarchy.update_command(), "omarchy update");
337 assert_eq!(InstallMethod::Omarchy.label(), "Omarchy");
338 assert!(!InstallMethod::Omarchy.supports_self_update());
339 }
340 }
341
341 lines RUST