返回 CodeWhale
update.rs
根目录 / crates / tui / src / commands / groups / utility / update.rs
1 //! `/update` — check for and install a new CodeWhale release without leaving
2 //! the TUI.
3 //!
4 //! The updater itself is not reimplemented here. `codewhale update` (see
5 //! `crates/cli/src/update.rs`) already resolves the release, downloads the
6 //! platform-correct asset, verifies its SHA256, and atomically replaces the
7 //! running binary; this command finds that binary and runs it. Duplicating any
8 //! of that logic would give us two updaters to keep honest.
9 //!
10 //! Two deliberate limits:
11 //!
12 //! * **Package-managed installs get instructions, not an updater run.**
13 //! Overwriting a binary Homebrew, npm, or cargo owns leaves the manager's
14 //! metadata describing a version that is no longer on disk, and the next
15 //! upgrade silently reverts the user.
16 //! * **We do not relaunch.** This codebase has no self-exec/relaunch pattern,
17 //! and inventing one under a TUI holding the terminal is not a small change.
18 //! Telling the user to restart is the honest slice; that is also the only
19 //! possible answer on Windows, where the replaced image is the one running.
20
21 use std::path::{Path, PathBuf};
22 use std::process::Command;
23
24 use codewhale_command_contract::handler::CommandHandler;
25 use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
26 use codewhale_release::InstallMethod;
27
28 use crate::commands::CommandResult;
29
30 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
31 name: "update",
32 aliases: &["upgrade"],
33 usage: "/update [check|install]",
34 description_key: "cmd_update_description",
35 };
36
37 pub(in crate::commands) struct UpdateCmd;
38
39 impl RegisterCommand<CommandResult> for UpdateCmd {
40 fn info() -> &'static CommandInfo {
41 &COMMAND_INFO
42 }
43
44 fn handler() -> CommandHandler<CommandResult> {
45 CommandHandler::Pure(update)
46 }
47 }
48
49 /// What `/update` was asked to do.
50 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
51 pub(super) enum UpdateMode {
52 /// Explain what an install would do, and ask the updater whether one is
53 /// available. The default: `/update` never installs without being told to.
54 Check,
55 /// Actually run the updater.
56 Install,
57 }
58
59 fn parse_mode(arg: Option<&str>) -> Result<UpdateMode, String> {
60 match arg
61 .map(str::trim)
62 .unwrap_or("")
63 .to_ascii_lowercase()
64 .as_str()
65 {
66 "" | "check" | "status" => Ok(UpdateMode::Check),
67 "install" | "now" | "apply" | "yes" => Ok(UpdateMode::Install),
68 other => Err(format!(
69 "Unknown /update argument {other:?}. Usage: {}",
70 COMMAND_INFO.usage
71 )),
72 }
73 }
74
75 /// How this install can be updated, resolved before anything runs.
76 #[derive(Debug, Clone, PartialEq, Eq)]
77 pub(super) enum UpdaterPlan {
78 /// Run this `codewhale` binary's `update` subcommand.
79 Run(PathBuf),
80 /// A package manager owns the binary; print its command instead.
81 Managed(InstallMethod),
82 /// Self-update would be correct, but no `codewhale` CLI is reachable —
83 /// e.g. a bare `codewhale-tui` build with no sibling CLI.
84 NoUpdater { exe: PathBuf },
85 }
86
87 /// The primary binary name. `codew` carries the same CLI and update subcommand.
88 const UPDATER_BIN_STEM: &str = "codewhale";
89
90 /// Resolve the update path without touching the process environment, so the
91 /// decision is testable. `exists` answers whether a candidate path is a file.
92 pub(super) fn resolve_updater(
93 exe: Option<&Path>,
94 method: InstallMethod,
95 exists: &dyn Fn(&Path) -> bool,
96 ) -> UpdaterPlan {
97 if !method.supports_self_update() {
98 return UpdaterPlan::Managed(method);
99 }
100 // No resolvable executable path is not a reason to guess at one: report it
101 // as "no updater here" and let the user run the CLI themselves.
102 let Some(exe) = exe else {
103 return UpdaterPlan::NoUpdater {
104 exe: PathBuf::from(UPDATER_BIN_STEM),
105 };
106 };
107 if matches!(
108 exe.file_stem().and_then(|stem| stem.to_str()),
109 Some(UPDATER_BIN_STEM | "codew")
110 ) {
111 return UpdaterPlan::Run(exe.to_path_buf());
112 }
113 // A `codewhale-tui` build has no `update` subcommand, but the CLI that
114 // does is normally installed right next to it.
115 if let Some(dir) = exe.parent() {
116 let extension = exe.extension().and_then(|ext| ext.to_str());
117 let mut sibling = dir.join(UPDATER_BIN_STEM);
118 if let Some(extension) = extension {
119 sibling.set_extension(extension);
120 }
121 if exists(&sibling) {
122 return UpdaterPlan::Run(sibling);
123 }
124 }
125 UpdaterPlan::NoUpdater {
126 exe: exe.to_path_buf(),
127 }
128 }
129
130 /// Instructions for an install this command must not update in place.
131 pub(super) fn managed_install_message(method: InstallMethod) -> String {
132 format!(
133 "Codewhale was installed with {label}, which owns this binary.\n\
134 {migration_help}\n\n\
135 To keep this secondary {label} installation, run `{command}` in a shell.\n\
136 /update will not replace a binary owned by {label}; restart Codewhale after changing installs.",
137 label = method.label(),
138 migration_help = codewhale_release::install::GITHUB_MIGRATION_HELP,
139 command = method.update_command(),
140 )
141 }
142
143 /// Instructions when self-update is right but no updater binary is reachable.
144 pub(super) fn no_updater_message(exe: &Path) -> String {
145 format!(
146 "No `{UPDATER_BIN_STEM}` updater was found for this install ({exe}).\n\
147 The updater ships in the `{UPDATER_BIN_STEM}` CLI. Install or locate it, run \
148 `{UPDATER_BIN_STEM} update` in a shell, then restart Codewhale.",
149 exe = exe.display(),
150 )
151 }
152
153 /// What an install would do, stated before it is done.
154 fn install_preamble(updater: &Path) -> String {
155 format!(
156 "`/update install` will run `{updater} update`: it resolves the latest release, \
157 downloads the binary for this platform, verifies its SHA256 checksum, and atomically \
158 replaces {updater}.\n\
159 It does not restart Codewhale — you will need to do that yourself once it finishes. \
160 The UI is paused while the updater runs.",
161 updater = updater.display(),
162 )
163 }
164
165 fn update(arg: Option<&str>) -> CommandResult {
166 let mode = match parse_mode(arg) {
167 Ok(mode) => mode,
168 Err(message) => return CommandResult::error(message),
169 };
170
171 let exe = std::env::current_exe().ok();
172 let method = match exe.as_deref() {
173 Some(path) => InstallMethod::detect(path),
174 None => InstallMethod::Binary,
175 };
176
177 match resolve_updater(exe.as_deref(), method, &|path: &Path| path.is_file()) {
178 UpdaterPlan::Managed(method) => CommandResult::message(managed_install_message(method)),
179 UpdaterPlan::NoUpdater { exe } => CommandResult::message(no_updater_message(&exe)),
180 UpdaterPlan::Run(updater) => run_updater(&updater, mode),
181 }
182 }
183
184 fn run_updater(updater: &Path, mode: UpdateMode) -> CommandResult {
185 let mut command = Command::new(updater);
186 command.arg("update");
187 if mode == UpdateMode::Check {
188 command.arg("--check");
189 }
190
191 let output = match command.output() {
192 Ok(output) => output,
193 Err(err) => {
194 return CommandResult::error(format!(
195 "Failed to run `{} update`: {err}\nRun it in a shell instead, then restart Codewhale.",
196 updater.display()
197 ));
198 }
199 };
200
201 let transcript = updater_transcript(&output.stdout, &output.stderr);
202 if !output.status.success() {
203 return CommandResult::error(format!(
204 "`{updater} update` failed.\n{transcript}",
205 updater = updater.display()
206 ));
207 }
208
209 match mode {
210 UpdateMode::Check => {
211 CommandResult::message(format!("{}\n\n{transcript}", install_preamble(updater)))
212 }
213 UpdateMode::Install => CommandResult::message(format!(
214 "{transcript}\n\nRestart Codewhale to run the updated binary."
215 )),
216 }
217 }
218
219 /// Merge the updater's streams into one readable block, bounded so a pathological
220 /// run cannot flood the transcript.
221 fn updater_transcript(stdout: &[u8], stderr: &[u8]) -> String {
222 const MAX_CHARS: usize = 4_000;
223 let mut merged = String::new();
224 for stream in [stdout, stderr] {
225 let text = String::from_utf8_lossy(stream);
226 let text = text.trim();
227 if text.is_empty() {
228 continue;
229 }
230 if !merged.is_empty() {
231 merged.push('\n');
232 }
233 merged.push_str(text);
234 }
235 if merged.is_empty() {
236 return "(the updater printed nothing)".to_string();
237 }
238 if merged.chars().count() > MAX_CHARS {
239 let kept: String = merged.chars().take(MAX_CHARS).collect();
240 return format!("{kept}\n… output truncated.");
241 }
242 merged
243 }
244
245 #[cfg(test)]
246 mod tests {
247 use super::*;
248
249 #[test]
250 fn bare_and_explicit_modes_parse() {
251 assert_eq!(parse_mode(None), Ok(UpdateMode::Check));
252 assert_eq!(parse_mode(Some(" ")), Ok(UpdateMode::Check));
253 assert_eq!(parse_mode(Some("Check")), Ok(UpdateMode::Check));
254 assert_eq!(parse_mode(Some("install")), Ok(UpdateMode::Install));
255 assert_eq!(parse_mode(Some("NOW")), Ok(UpdateMode::Install));
256 assert!(parse_mode(Some("--force")).is_err());
257 }
258
259 #[test]
260 fn a_package_managed_install_gets_its_managers_command_not_an_updater_run() {
261 for method in [
262 InstallMethod::Npm,
263 InstallMethod::Homebrew,
264 InstallMethod::Cargo,
265 InstallMethod::Omarchy,
266 ] {
267 let plan = resolve_updater(
268 Some(Path::new("/opt/whatever/codewhale")),
269 method,
270 &|_: &Path| true,
271 );
272 assert_eq!(plan, UpdaterPlan::Managed(method), "{method:?}");
273
274 let message = managed_install_message(method);
275 assert!(message.contains(codewhale_release::install::GITHUB_MIGRATION_HELP));
276 assert!(message.contains("mktemp -d"));
277 assert!(
278 message.contains(method.update_command()),
279 "{method:?} message must name its own update command: {message}"
280 );
281 assert!(
282 message.contains("restart Codewhale"),
283 "{method:?} message must tell the user to restart: {message}"
284 );
285 }
286 }
287
288 #[test]
289 fn a_tui_only_build_without_a_sibling_cli_falls_back_to_instructions() {
290 let exe = Path::new("/usr/local/bin/codewhale-tui");
291 let plan = resolve_updater(Some(exe), InstallMethod::Binary, &|_: &Path| false);
292 assert_eq!(
293 plan,
294 UpdaterPlan::NoUpdater {
295 exe: exe.to_path_buf()
296 }
297 );
298
299 let message = no_updater_message(exe);
300 assert!(message.contains("codewhale update"), "{message}");
301 assert!(message.contains("restart Codewhale"), "{message}");
302 assert!(message.contains("codewhale-tui"), "{message}");
303 }
304
305 #[test]
306 fn a_tui_build_next_to_the_cli_runs_the_sibling_updater() {
307 let plan = resolve_updater(
308 Some(Path::new("/usr/local/bin/codewhale-tui")),
309 InstallMethod::Binary,
310 &|path: &Path| path == Path::new("/usr/local/bin/codewhale"),
311 );
312 assert_eq!(
313 plan,
314 UpdaterPlan::Run(PathBuf::from("/usr/local/bin/codewhale"))
315 );
316 }
317
318 #[test]
319 fn a_cli_install_runs_itself_including_the_windows_extension() {
320 assert_eq!(
321 resolve_updater(
322 Some(Path::new("/usr/local/bin/codewhale")),
323 InstallMethod::Binary,
324 &|_: &Path| false
325 ),
326 UpdaterPlan::Run(PathBuf::from("/usr/local/bin/codewhale"))
327 );
328 // Forward slashes so the case is meaningful on the host running the
329 // test: `\` is a plain filename character to a Unix `Path`, which
330 // would make this assert about nothing.
331 assert_eq!(
332 resolve_updater(
333 Some(Path::new("C:/tools/codewhale.exe")),
334 InstallMethod::Binary,
335 &|_: &Path| false
336 ),
337 UpdaterPlan::Run(PathBuf::from("C:/tools/codewhale.exe"))
338 );
339 }
340
341 #[test]
342 fn standalone_codew_uses_its_own_updater_without_a_companion() {
343 for exe in ["/usr/local/bin/codew", "C:/tools/codew.exe"] {
344 let exe = Path::new(exe);
345 assert_eq!(
346 resolve_updater(Some(exe), InstallMethod::Binary, &|_| {
347 panic!("the full codew CLI must not look for another installation")
348 }),
349 UpdaterPlan::Run(exe.to_path_buf())
350 );
351 }
352 }
353
354 #[test]
355 fn a_windows_tui_build_finds_the_sibling_cli_with_its_extension() {
356 assert_eq!(
357 resolve_updater(
358 Some(Path::new("C:/tools/codewhale-tui.exe")),
359 InstallMethod::Binary,
360 &|path: &Path| path == Path::new("C:/tools/codewhale.exe"),
361 ),
362 UpdaterPlan::Run(PathBuf::from("C:/tools/codewhale.exe"))
363 );
364 }
365
366 #[test]
367 fn an_unresolvable_executable_reports_no_updater_instead_of_guessing() {
368 assert_eq!(
369 resolve_updater(None, InstallMethod::Binary, &|_: &Path| true),
370 UpdaterPlan::NoUpdater {
371 exe: PathBuf::from("codewhale")
372 }
373 );
374 }
375
376 #[test]
377 fn the_check_preamble_states_what_install_would_do() {
378 let preamble = install_preamble(Path::new("/usr/local/bin/codewhale"));
379 assert!(
380 preamble.contains("/usr/local/bin/codewhale update"),
381 "{preamble}"
382 );
383 assert!(preamble.contains("SHA256"), "{preamble}");
384 assert!(preamble.contains("does not restart"), "{preamble}");
385 }
386
387 #[test]
388 fn updater_output_is_merged_and_bounded() {
389 assert_eq!(updater_transcript(b" out ", b""), "out");
390 assert_eq!(updater_transcript(b"out", b"err"), "out\nerr");
391 assert_eq!(
392 updater_transcript(b"", b" "),
393 "(the updater printed nothing)"
394 );
395
396 let flood = "x".repeat(9_000);
397 let bounded = updater_transcript(flood.as_bytes(), b"");
398 assert!(bounded.ends_with("… output truncated."));
399 assert!(bounded.chars().count() < flood.chars().count());
400 }
401
402 #[test]
403 fn handler_is_pure_and_argument_only() {
404 assert!(matches!(UpdateCmd::handler(), CommandHandler::Pure(_)));
405 assert_eq!(UpdateCmd::info().description_key, "cmd_update_description");
406 assert_eq!(UpdateCmd::info().aliases, &["upgrade"]);
407 }
408 }
409
409 lines RUST