返回 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/codewhale-hq/CodeWhale/releases/latest
41 PATH and migration: https://github.com/codewhale-hq/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 /// `std::env::current_exe()` follows symlinks on Linux but not on macOS,
63 /// where a Homebrew launch reports `/opt/homebrew/bin/codewhale` instead
64 /// of the `Cellar` file it points at. When the path as given looks
65 /// self-updatable, the symlink target is checked too, and a package
66 /// manager that owns the real file wins.
67 #[must_use]
68 pub fn detect(exe: &Path) -> Self {
69 let mut detected = Self::detect_with_omarchy_probe(exe, omarchy_package_owns);
70 if detected.supports_self_update()
71 && let Ok(real) = std::fs::canonicalize(exe)
72 && real != exe
73 {
74 let owner = Self::detect_with_omarchy_probe(&real, omarchy_package_owns);
75 if !owner.supports_self_update() {
76 detected = owner;
77 }
78 }
79 // An override can identify a relocated package, but cannot authorize
80 // replacing a file that a known package manager owns.
81 if !detected.supports_self_update() {
82 return detected;
83 }
84 if let Some(forced) = std::env::var(INSTALL_METHOD_ENV)
85 .ok()
86 .and_then(|raw| Self::from_token(&raw))
87 {
88 return forced;
89 }
90 detected
91 }
92
93 fn detect_with_omarchy_probe(exe: &Path, owns_package: impl FnOnce(&Path) -> bool) -> Self {
94 let path_method = Self::from_path(exe);
95 if path_method != Self::Binary {
96 return path_method;
97 }
98 if owns_package(exe) {
99 Self::Omarchy
100 } else {
101 Self::Binary
102 }
103 }
104
105 /// Path-only detection, with no environment lookup. Split out from
106 /// [`detect`](Self::detect) so tests can exercise the heuristics without
107 /// mutating process-global state.
108 #[must_use]
109 pub fn from_path(exe: &Path) -> Self {
110 let components: Vec<String> = exe
111 .components()
112 .filter_map(|c| c.as_os_str().to_str())
113 .map(str::to_ascii_lowercase)
114 .collect();
115
116 let has = |name: &str| components.iter().any(|c| c == name);
117
118 // npm is checked first: a `node_modules` install *inside* a Homebrew
119 // or Termux prefix is still npm's to update.
120 if has("node_modules") {
121 return Self::Npm;
122 }
123 if has("cellar") || has(".linuxbrew") || has("linuxbrew") {
124 return Self::Homebrew;
125 }
126 // `.cargo/bin/codewhale` — require the pair so an unrelated `bin`
127 // directory does not read as a Cargo install.
128 if components
129 .windows(2)
130 .any(|pair| pair[0] == ".cargo" && pair[1] == "bin")
131 {
132 return Self::Cargo;
133 }
134 Self::Binary
135 }
136
137 fn from_token(raw: &str) -> Option<Self> {
138 match raw.trim().to_ascii_lowercase().as_str() {
139 "npm" => Some(Self::Npm),
140 "homebrew" | "brew" => Some(Self::Homebrew),
141 "cargo" => Some(Self::Cargo),
142 "omarchy" => Some(Self::Omarchy),
143 "binary" | "release" => Some(Self::Binary),
144 _ => None,
145 }
146 }
147
148 /// The exact shell command that updates this install.
149 ///
150 /// Homebrew's primary formula is `codewhale`. Existing Cellar paths
151 /// under the legacy `deepseek-tui` name still detect as Homebrew; those
152 /// installs can keep using `brew upgrade deepseek-tui` during the
153 /// overlap window, but new notices name the Codewhale formula.
154 #[must_use]
155 pub fn update_command(self) -> &'static str {
156 match self {
157 Self::Npm => "npm install -g codewhale@latest",
158 Self::Homebrew => "brew upgrade codewhale",
159 Self::Cargo => "cargo install codewhale-cli --locked --force",
160 Self::Omarchy => "omarchy update",
161 Self::Binary => "codewhale update",
162 }
163 }
164
165 /// Whether `codewhale update` may replace this binary in place.
166 ///
167 /// False for every package-managed install: see the module docs for why
168 /// overwriting a managed binary is worse than doing nothing.
169 #[must_use]
170 pub fn supports_self_update(self) -> bool {
171 matches!(self, Self::Binary)
172 }
173
174 /// Short human label, for messages that name the owner of the install.
175 #[must_use]
176 pub fn label(self) -> &'static str {
177 match self {
178 Self::Npm => "npm",
179 Self::Homebrew => "Homebrew",
180 Self::Cargo => "cargo",
181 Self::Omarchy => "Omarchy",
182 Self::Binary => "release binary",
183 }
184 }
185 }
186
187 #[cfg(target_os = "linux")]
188 fn omarchy_package_owns(exe: &Path) -> bool {
189 if !Path::new("/usr/share/omarchy/version").is_file() {
190 return false;
191 }
192
193 Command::new("pacman")
194 .args(["-Qo"])
195 .arg(exe)
196 .stdin(Stdio::null())
197 .stdout(Stdio::null())
198 .stderr(Stdio::null())
199 .status()
200 .is_ok_and(|status| status.success())
201 }
202
203 #[cfg(not(target_os = "linux"))]
204 fn omarchy_package_owns(_exe: &Path) -> bool {
205 false
206 }
207
208 /// Detect the install method for the currently running executable.
209 ///
210 /// Returns [`InstallMethod::Binary`] when the executable path cannot be
211 /// resolved — the conservative answer, because it is the one that tells the
212 /// user to run our own updater rather than a package manager command that may
213 /// not apply to them.
214 #[must_use]
215 pub fn current_install_method() -> InstallMethod {
216 match std::env::current_exe() {
217 Ok(exe) => InstallMethod::detect(&exe),
218 Err(_) => InstallMethod::Binary,
219 }
220 }
221
222 #[cfg(test)]
223 mod tests {
224 use std::path::PathBuf;
225
226 use super::*;
227
228 #[test]
229 fn npm_global_install_is_detected_from_node_modules() {
230 let exe = PathBuf::from("/usr/local/lib/node_modules/codewhale/bin/codewhale");
231 assert_eq!(InstallMethod::from_path(&exe), InstallMethod::Npm);
232 assert_eq!(
233 InstallMethod::Npm.update_command(),
234 "npm install -g codewhale@latest"
235 );
236 assert!(!InstallMethod::Npm.supports_self_update());
237 }
238
239 #[test]
240 fn homebrew_install_is_detected_from_cellar_on_both_prefixes() {
241 for exe in [
242 "/opt/homebrew/Cellar/codewhale/0.9.8/bin/codewhale",
243 "/usr/local/Cellar/codewhale/0.9.8/bin/codewhale",
244 "/home/linuxbrew/.linuxbrew/Cellar/codewhale/0.9.8/bin/codewhale",
245 "/opt/homebrew/Cellar/deepseek-tui/0.9.4/bin/codewhale",
246 "/usr/local/Cellar/deepseek-tui/0.9.4/bin/codewhale",
247 "/home/linuxbrew/.linuxbrew/Cellar/deepseek-tui/0.9.4/bin/codewhale",
248 ] {
249 assert_eq!(
250 InstallMethod::from_path(&PathBuf::from(exe)),
251 InstallMethod::Homebrew,
252 "{exe} should read as Homebrew"
253 );
254 }
255 assert_eq!(
256 InstallMethod::Homebrew.update_command(),
257 "brew upgrade codewhale"
258 );
259 assert!(!InstallMethod::Homebrew.supports_self_update());
260 }
261
262 /// macOS reports the launcher symlink from `current_exe()`, so detection
263 /// must follow it into `Cellar` before allowing an in-place update.
264 #[cfg(unix)]
265 #[test]
266 fn homebrew_symlink_launcher_is_detected_through_its_target() {
267 let root = tempfile::tempdir().unwrap();
268 let cellar_bin = root.path().join("Cellar/codewhale/0.10.0/bin");
269 std::fs::create_dir_all(&cellar_bin).unwrap();
270 let real = cellar_bin.join("codewhale");
271 std::fs::write(&real, b"binary").unwrap();
272 let bin = root.path().join("bin");
273 std::fs::create_dir_all(&bin).unwrap();
274 let launcher = bin.join("codewhale");
275 std::os::unix::fs::symlink(&real, &launcher).unwrap();
276
277 assert_eq!(InstallMethod::from_path(&launcher), InstallMethod::Binary);
278 assert_eq!(InstallMethod::detect(&launcher), InstallMethod::Homebrew);
279
280 // A plain release binary with no package owner stays self-updatable.
281 let plain = bin.join("codew");
282 std::fs::write(&plain, b"binary").unwrap();
283 assert!(InstallMethod::detect(&plain).supports_self_update());
284 }
285
286 #[test]
287 fn cargo_install_requires_the_cargo_bin_pair() {
288 assert_eq!(
289 InstallMethod::from_path(&PathBuf::from("/home/u/.cargo/bin/codewhale")),
290 InstallMethod::Cargo
291 );
292 // A bare `bin` directory is not a Cargo install.
293 assert_eq!(
294 InstallMethod::from_path(&PathBuf::from("/home/u/bin/codewhale")),
295 InstallMethod::Binary
296 );
297 assert!(!InstallMethod::Cargo.supports_self_update());
298 }
299
300 #[test]
301 fn npm_wins_over_an_enclosing_manager_prefix() {
302 // npm installed under a Homebrew-managed node prefix is still npm's.
303 let exe = PathBuf::from("/opt/homebrew/lib/node_modules/codewhale/bin/codewhale");
304 assert_eq!(InstallMethod::from_path(&exe), InstallMethod::Npm);
305 }
306
307 #[test]
308 fn omarchy_probe_only_claims_package_owned_plain_paths() {
309 let managed = PathBuf::from("/usr/bin/codewhale");
310 assert_eq!(
311 InstallMethod::detect_with_omarchy_probe(&managed, |_| true),
312 InstallMethod::Omarchy
313 );
314 assert_eq!(
315 InstallMethod::detect_with_omarchy_probe(&managed, |_| false),
316 InstallMethod::Binary
317 );
318
319 let npm = PathBuf::from("/usr/lib/node_modules/codewhale/bin/codewhale");
320 assert_eq!(
321 InstallMethod::detect_with_omarchy_probe(&npm, |_| {
322 panic!("a path-owned install must not query pacman")
323 }),
324 InstallMethod::Npm
325 );
326 }
327
328 #[test]
329 fn termux_and_plain_release_binaries_self_update() {
330 for exe in [
331 "/data/data/com.termux/files/usr/bin/codewhale",
332 "/usr/local/bin/codewhale",
333 "/home/u/Downloads/codewhale",
334 ] {
335 let method = InstallMethod::from_path(&PathBuf::from(exe));
336 assert_eq!(method, InstallMethod::Binary, "{exe} should self-update");
337 assert!(method.supports_self_update());
338 assert_eq!(method.update_command(), "codewhale update");
339 }
340 }
341
342 #[test]
343 fn env_tokens_map_to_methods_and_junk_is_ignored() {
344 assert_eq!(InstallMethod::from_token("npm"), Some(InstallMethod::Npm));
345 assert_eq!(
346 InstallMethod::from_token(" BREW "),
347 Some(InstallMethod::Homebrew)
348 );
349 assert_eq!(
350 InstallMethod::from_token("homebrew"),
351 Some(InstallMethod::Homebrew)
352 );
353 assert_eq!(
354 InstallMethod::from_token("cargo"),
355 Some(InstallMethod::Cargo)
356 );
357 assert_eq!(
358 InstallMethod::from_token("omarchy"),
359 Some(InstallMethod::Omarchy)
360 );
361 assert_eq!(
362 InstallMethod::from_token("binary"),
363 Some(InstallMethod::Binary)
364 );
365 assert_eq!(InstallMethod::from_token("apt"), None);
366 }
367
368 #[test]
369 fn omarchy_install_is_package_managed() {
370 assert_eq!(InstallMethod::Omarchy.update_command(), "omarchy update");
371 assert_eq!(InstallMethod::Omarchy.label(), "Omarchy");
372 assert!(!InstallMethod::Omarchy.supports_self_update());
373 }
374 }
375
375 lines RUST