1use anyhow::{Result, bail};
2
3use crate::config::{self, Config};
4#[cfg(unix)]
5use crate::privilege;
6use crate::update_check::{self, ReleaseChannel, UpdateStatus};
7use crate::{apps, colors, env, info, install_core, list, output, platform, shells, sys, version};
8
9pub async fn handle_update(
10 config: &Config,
11 target: Option<&str>,
12 diff: bool,
13 verbose: bool,
14 refresh_release: bool,
15) -> Result<()> {
16 if let Some(target) = target {
17 return info::handle_update_target(config, target).await;
18 }
19
20 let mut printed_update = if verbose {
21 Box::pin(list::handle_status_list(config, diff)).await?;
22 println!();
23 true
24 } else {
25 Box::pin(list::handle_update_list(config, diff)).await?
26 };
27
28 let current = version::semver();
29 if verbose {
30 println!("Checking for updates (current: {current})...");
31 }
32
33 let update_status = if refresh_release {
34 update_check::check_for_update_forced(config).await
35 } else {
36 update_check::check_for_update(config).await
37 };
38
39 match update_status {
40 Ok(UpdateStatus::UpToDate) => {
41 if verbose {
42 println!(
43 "{}",
44 colors::green(&format!("shine {current} is up to date."))
45 );
46 }
47 }
48 Ok(UpdateStatus::UpdateAvailable { latest }) => {
49 if printed_update && !verbose {
50 println!();
51 }
52 println!(
53 "{}",
54 colors::yellow(&format!(
55 "A newer version of shine is available: {current} -> {latest}."
56 ))
57 );
58 println!("Run `shine self upgrade` to install it.");
59 printed_update = true;
60 }
61 Ok(UpdateStatus::UpdateRequired { latest }) => {
62 if printed_update && !verbose {
63 println!();
64 }
65 println!(
66 "{}",
67 colors::yellow(&format!(
68 "A newer patch release of shine is available: {current} -> {latest}."
69 ))
70 );
71 println!("Run `shine self upgrade` to install it.");
72 printed_update = true;
73 }
74 Err(e) => {
75 eprintln!("{}", format_update_check_failure_warning(&e));
76 }
77 }
78
79 if !printed_update {
80 println!("{}", colors::dim("Nothing to update."));
81 }
82
83 Ok(())
84}
85
86fn format_update_check_failure_warning(err: &anyhow::Error) -> String {
87 colors::yellow_stderr(&format!("warning: skipped shine version check: {err}"))
88}
89
90pub async fn handle_self_upgrade(config: &Config, channel: Option<ReleaseChannel>) -> Result<()> {
91 let current = version::semver();
92 let selected_channel = channel.unwrap_or(ReleaseChannel::Stable);
93 let force_install = channel.is_some();
94 println!(
95 "Checking for {} upgrades (current: {current})...",
96 selected_channel.as_str()
97 );
98
99 match update_check::upgrade_to_release(config, selected_channel, force_install).await {
100 Ok(update_check::UpgradeResult::AlreadyUpToDate { channel, latest }) => {
101 println!(
102 "{}",
103 colors::green(&format!(
104 "shine {current} is up to date on the {} channel ({latest}).",
105 channel.as_str()
106 ))
107 );
108 }
109 Ok(update_check::UpgradeResult::Upgraded {
110 channel,
111 previous: _,
112 previous_display,
113 release_tag,
114 installed_version,
115 installed_path,
116 }) => {
117 println!(
118 "{}",
119 colors::green(&format_self_upgrade_message(
120 channel,
121 &previous_display,
122 &installed_version,
123 &release_tag,
124 ))
125 );
126 sync_self_install_dest(config, &installed_path).await;
127 }
128 Err(e) => {
129 update_check::invalidate_update_cache(config).await;
130 bail!("Upgrade failed: {e}");
131 }
132 }
133
134 Ok(())
135}
136
137fn format_self_upgrade_message(
138 channel: ReleaseChannel,
139 previous_display: &str,
140 installed_version: &str,
141 release_tag: &str,
142) -> String {
143 match channel {
144 ReleaseChannel::Stable => {
145 format!("Upgraded shine from {previous_display} to {installed_version}.")
146 }
147 ReleaseChannel::Preview => {
148 if previous_display.contains("-preview") {
149 format!(
150 "Updated shine preview from {previous_display} to {installed_version} ({release_tag})."
151 )
152 } else {
153 format!(
154 "Installed shine preview {installed_version} over stable {previous_display} ({release_tag})."
155 )
156 }
157 }
158 }
159}
160
161pub async fn handle_config_upgrade(
162 config: &Config,
163 target: Option<&str>,
164 verbose: bool,
165 prune_stale: bool,
166) -> Result<()> {
167 if let Some(target) = target {
168 return handle_config_target_upgrade(config, target, verbose, prune_stale).await;
169 }
170 if verbose {
171 println!("{}", colors::bold("Upgrading installed configs"));
172 config::print_presets_note(config);
173 }
174
175 let mut sep = if verbose {
176 output::SectionSeparator::new()
177 } else {
178 output::SectionSeparator::with_preamble(colors::bold("Upgrading installed configs"))
179 };
180
181 let env_report = Box::pin(env::upgrade::handle_upgrade(config, false, verbose)).await?;
182 let shell_report =
183 Box::pin(shells::handle_upgrade_installed(config, verbose, &mut sep)).await?;
184 let app_report = Box::pin(apps::handle_upgrade_installed_with_output(
185 config,
186 prune_stale,
187 verbose,
188 &mut sep,
189 ))
190 .await?;
191 let sys_report = Box::pin(sys::handle_upgrade_managed(config, verbose, &mut sep)).await?;
192
193 let updated = env_report.updated
194 + shell_report.snapshots_updated
195 + shell_report.templates_updated
196 + shell_report.links_created
197 + shell_report.links_updated
198 + usize::from(shell_report.path_changed)
199 + app_report.updated
200 + sys_report.updated;
201 let user_modified = env_report.user_modified + app_report.user_modified;
202
203 let summary = config_upgrade_summary_parts(updated, user_modified, shell_report.link_conflicts);
204 if verbose || sep.has_printed() {
205 output::footer("Done", &summary);
206 } else {
207 println!("{}", colors::dim("Nothing to upgrade."));
208 }
209 for hint in &app_report.restart_hints {
210 println!(" {} {}", colors::symbol("!"), colors::yellow(hint));
211 }
212
213 if app_report.failed > 0 {
214 bail!(
215 "{} generated app configuration item(s) failed",
216 app_report.failed
217 );
218 }
219
220 if sys_report.failed > 0 {
221 bail!(
222 "{} managed system configuration item(s) failed",
223 sys_report.failed
224 );
225 }
226
227 Ok(())
228}
229
230async fn handle_config_target_upgrade(
231 config: &Config,
232 target: &str,
233 verbose: bool,
234 prune_stale: bool,
235) -> Result<()> {
236 use crate::shim::{PresetKind, resolve_preset_kind};
237
238 let target = target.trim();
239 if target.is_empty() {
240 bail!("upgrade target must not be empty");
241 }
242
243 let mut sep = if verbose {
244 println!("{}", colors::bold(&format!("Upgrading {target}")));
245 config::print_presets_note(config);
246 output::SectionSeparator::new()
247 } else {
248 output::SectionSeparator::with_preamble(colors::bold(&format!("Upgrading {target}")))
249 };
250
251 let (updated, user_modified, link_conflicts, failed, restart_hints) =
252 if let Some(item) = target.strip_prefix("sys/") {
253 if item.is_empty() || item.contains('/') {
254 bail!("invalid system target `{target}`; expected sys/<item>");
255 }
256 if prune_stale {
257 bail!("`--prune-stale` applies only to app targets");
258 }
259 let report = Box::pin(sys::handle_upgrade_managed_target(
260 config,
261 Some(item),
262 verbose,
263 &mut sep,
264 ))
265 .await?;
266 (report.updated, 0, 0, report.failed, Default::default())
267 } else {
268 let normalized = if let Some(rest) = target.strip_prefix("app/") {
269 let category = rest.split('/').next().unwrap_or_default();
270 format!("app/{category}")
271 } else if let Some(rest) = target.strip_prefix("shell/") {
272 let category = rest.split('/').next().unwrap_or_default();
273 format!("shell/{category}")
274 } else {
275 target.to_string()
276 };
277 let (kind, category) = resolve_preset_kind(config, &normalized).await?;
278 match kind {
279 PresetKind::App => {
280 let report = Box::pin(apps::handle_upgrade_installed_target(
281 config,
282 Some(&category),
283 prune_stale,
284 verbose,
285 &mut sep,
286 ))
287 .await?;
288 (
289 report.updated,
290 report.user_modified,
291 0,
292 report.failed,
293 report.restart_hints,
294 )
295 }
296 PresetKind::Shell => {
297 if prune_stale {
298 bail!("`--prune-stale` applies only to app targets");
299 }
300 let report = Box::pin(shells::handle_upgrade_installed_target(
301 config,
302 Some(&category),
303 verbose,
304 &mut sep,
305 ))
306 .await?;
307 (
308 report.templates_updated
309 + report.links_created
310 + report.links_updated
311 + usize::from(report.path_changed),
312 0,
313 report.link_conflicts,
314 0,
315 Default::default(),
316 )
317 }
318 }
319 };
320
321 let summary = config_upgrade_summary_parts(updated, user_modified, link_conflicts);
322 if verbose || sep.has_printed() {
323 output::footer("Done", &summary);
324 } else {
325 println!("{}", colors::dim("Nothing to upgrade."));
326 }
327 for hint in restart_hints {
328 println!(" {} {}", colors::symbol("!"), colors::yellow(&hint));
329 }
330 if failed > 0 {
331 bail!("{failed} managed configuration item(s) failed");
332 }
333 Ok(())
334}
335
336fn config_upgrade_summary_parts(
337 updated: usize,
338 user_modified: usize,
339 link_conflicts: usize,
340) -> Vec<String> {
341 let mut parts = Vec::new();
342 output::push_count(&mut parts, updated, colors::green, "updated");
343 output::push_count(
344 &mut parts,
345 user_modified,
346 colors::yellow,
347 "user-modified (kept)",
348 );
349 output::push_count(&mut parts, link_conflicts, colors::yellow, "link conflicts");
350 parts
351}
352
353async fn sync_self_install_dest(config: &Config, src: &std::path::Path) {
356 let dest = match &config.self_install_dest {
357 Some(d) => d,
358 None => return,
359 };
360 match sync_self_install_dest_from(src, dest).await {
361 Ok(SelfInstallSync::Synced) => println!(
362 "{}",
363 colors::green(&format!("Synced system copy at {}", dest.display()))
364 ),
365 Ok(SelfInstallSync::AlreadyCurrent) => {}
366 Err(e) if cfg!(windows) && has_io_error_kind(&e, std::io::ErrorKind::PermissionDenied) => {
370 let hint = format!(
371 "Installed copy at {} needs manual sync; rerun from an elevated terminal if needed.",
372 dest.display()
373 );
374 println!("{}", colors::yellow(&hint));
375 }
376 Err(e) => eprintln!(
377 "Warning: failed to sync system copy at {}: {e}",
378 dest.display()
379 ),
380 }
381}
382
383enum SelfInstallSync {
384 Synced,
385 AlreadyCurrent,
386}
387
388async fn sync_self_install_dest_from(
389 src: &std::path::Path,
390 dest: &std::path::Path,
391) -> Result<SelfInstallSync> {
392 if dest.exists() {
393 let canonical_src = src.canonicalize().unwrap_or_else(|_| src.to_path_buf());
394 let canonical_dest = dest.canonicalize().unwrap_or_else(|_| dest.to_path_buf());
395 if canonical_src == canonical_dest {
396 return Ok(SelfInstallSync::AlreadyCurrent);
397 }
398 }
399
400 install_binary_with_elevation(src, dest)
401 .await
402 .map(|()| SelfInstallSync::Synced)
403}
404
405fn has_io_error_kind(err: &anyhow::Error, kind: std::io::ErrorKind) -> bool {
406 err.chain().any(|cause| {
407 cause
408 .downcast_ref::<std::io::Error>()
409 .is_some_and(|io_err| io_err.kind() == kind)
410 })
411}
412
413pub async fn handle_self_install(
414 mut config: Config,
415 dest: Option<std::path::PathBuf>,
416) -> Result<()> {
417 use anyhow::{Context as _, bail};
418
419 let src = std::env::current_exe().context("failed to resolve current executable path")?;
420 let dest = match dest {
421 Some(dest) => dest,
422 None => platform::default_self_install_dest()?,
423 };
424
425 if dest.exists() {
426 let canonical_src = src.canonicalize().unwrap_or_else(|_| src.clone());
427 let canonical_dest = dest.canonicalize().unwrap_or_else(|_| dest.clone());
428 if canonical_src == canonical_dest {
429 let example = if cfg!(windows) {
430 r"C:\path\to\new\shine.exe self install"
431 } else {
432 "sudo /path/to/new/shine self install"
433 };
434 bail!(
435 "source and destination are the same binary: {}. Run the newer binary by full path, e.g. `{example}`, to overwrite this copy.",
436 dest.display()
437 );
438 }
439 }
440
441 install_binary_with_elevation(&src, &dest)
442 .await
443 .with_context(|| self_install_failure_hint(&dest))?;
444
445 config.self_install_dest = Some(dest.clone());
447 config
448 .save()
449 .await
450 .context("failed to save self_install_dest to config")?;
451
452 println!(
453 "{}",
454 colors::green(&format!("installed to {}", dest.display()))
455 );
456 print_self_install_activation_hint(&dest);
457
458 Ok(())
459}
460
461fn self_install_failure_hint(dest: &std::path::Path) -> String {
462 format!("failed to copy to {}", dest.display())
463}
464
465fn print_self_install_activation_hint(dest: &std::path::Path) {
466 let Some(dir) = dest.parent() else {
467 return;
468 };
469 if platform::current_path_contains_dir(dir) {
470 println!(
471 "{}",
472 colors::dim("The install directory is already on PATH.")
473 );
474 } else {
475 println!(
476 "{}",
477 colors::yellow(&format!(
478 "Install directory is not on PATH: {}",
479 dir.display()
480 ))
481 );
482 println!("{}", colors::dim(&platform::path_install_hint(dir)));
483 }
484}
485
486fn install_binary_atomically(src: &std::path::Path, dest: &std::path::Path) -> Result<()> {
487 use anyhow::Context as _;
488
489 let parent = dest
490 .parent()
491 .with_context(|| format!("destination has no parent: {}", dest.display()))?;
492 std::fs::create_dir_all(parent)
493 .with_context(|| format!("failed to create destination dir: {}", parent.display()))?;
494
495 let temp = parent.join(format!(".shine-self-install-{}", uuid::Uuid::new_v4()));
496 std::fs::copy(src, &temp).with_context(|| {
497 format!(
498 "failed to stage binary from {} to {}",
499 src.display(),
500 temp.display()
501 )
502 })?;
503
504 #[cfg(unix)]
505 {
506 use std::os::unix::fs::PermissionsExt;
507 let mode = std::fs::metadata(src)
508 .map(|m| m.permissions().mode())
509 .unwrap_or(0o755);
510 std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(mode))
511 .with_context(|| format!("failed to set permissions on {}", temp.display()))?;
512 }
513
514 match std::fs::rename(&temp, dest) {
515 Ok(()) => Ok(()),
516 Err(err) => {
517 let _ = std::fs::remove_file(&temp);
518 Err(err)
519 .with_context(|| format!("failed to replace {} with staged binary", dest.display()))
520 }
521 }
522}
523
524async fn install_binary_with_elevation(
530 src: &std::path::Path,
531 dest: &std::path::Path,
532) -> Result<()> {
533 match install_binary_atomically(src, dest) {
534 Ok(()) => Ok(()),
535 Err(e)
536 if !cfg!(windows)
537 && has_io_error_kind(&e, std::io::ErrorKind::PermissionDenied)
538 && !std::env::var("USER").is_ok_and(|user| user == "root") =>
539 {
540 let _lock = install_core::file_ops::admin_lock().await?;
541 install_binary_privileged(src, dest).await
542 }
543 Err(e) => Err(e),
544 }
545}
546
547#[cfg(unix)]
548async fn install_binary_privileged(src: &std::path::Path, dest: &std::path::Path) -> Result<()> {
549 use anyhow::Context as _;
550 use std::os::unix::fs::PermissionsExt;
551
552 if !privilege::ensure_admin(1).await? {
553 anyhow::bail!("administrator permission was not granted");
554 }
555
556 let parent = dest
557 .parent()
558 .with_context(|| format!("destination has no parent: {}", dest.display()))?;
559 let mode = std::fs::metadata(src)
560 .map(|m| m.permissions().mode())
561 .unwrap_or(0o755);
562
563 let status = install_core::file_ops::sudo_command()
564 .arg("mkdir")
565 .arg("-p")
566 .arg(parent)
567 .status()
568 .await
569 .context("failed to create privileged destination directory")?;
570 if !status.success() {
571 anyhow::bail!("administrator permission was not granted");
572 }
573
574 let status = install_core::file_ops::sudo_command()
575 .args(["install", "-m", &format!("{mode:o}"), "--"])
576 .arg(src)
577 .arg(dest)
578 .status()
579 .await
580 .context("failed to install shine binary with administrator privileges")?;
581 if !status.success() {
582 anyhow::bail!("failed to install shine binary with administrator privileges");
583 }
584
585 Ok(())
586}
587
588#[cfg(not(unix))]
589async fn install_binary_privileged(src: &std::path::Path, dest: &std::path::Path) -> Result<()> {
590 install_binary_atomically(src, dest)
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 async fn make_temp_dir() -> std::path::PathBuf {
601 crate::test_support::make_temp_dir("shine-self-install-test").await
602 }
603
604 fn config_in(dir: &std::path::Path) -> Config {
605 crate::test_support::test_config(dir)
606 }
607
608 #[test]
609 fn install_binary_atomically_overwrites_existing_dest() {
610 let dir = std::env::temp_dir().join(format!("shine-self-install-{}", uuid::Uuid::new_v4()));
611 std::fs::create_dir_all(&dir).unwrap();
612 let src = dir.join("new-shine");
613 let dest = dir.join("shine");
614
615 std::fs::write(&src, b"new").unwrap();
616 std::fs::write(&dest, b"old").unwrap();
617
618 install_binary_atomically(&src, &dest).unwrap();
619
620 assert_eq!(std::fs::read(&dest).unwrap(), b"new");
621 std::fs::remove_dir_all(&dir).unwrap();
622 }
623
624 #[tokio::test]
625 async fn sync_self_install_dest_creates_missing_parent() {
626 let dir = std::env::temp_dir().join(format!("shine-self-sync-{}", uuid::Uuid::new_v4()));
627 let src = dir.join("new-shine");
628 let dest = dir.join("usr/local/bin/shine");
629
630 std::fs::create_dir_all(&dir).unwrap();
631 std::fs::write(&src, b"new").unwrap();
632
633 let outcome = sync_self_install_dest_from(&src, &dest).await.unwrap();
634
635 assert!(matches!(outcome, SelfInstallSync::Synced));
636 assert_eq!(std::fs::read(&dest).unwrap(), b"new");
637 std::fs::remove_dir_all(&dir).unwrap();
638 }
639
640 #[tokio::test]
641 async fn sync_self_install_dest_skips_current_exe_path() {
642 let dir = std::env::temp_dir().join(format!("shine-self-sync-{}", uuid::Uuid::new_v4()));
643 let src = dir.join("shine");
644
645 std::fs::create_dir_all(&dir).unwrap();
646 std::fs::write(&src, b"new").unwrap();
647
648 let outcome = sync_self_install_dest_from(&src, &src).await.unwrap();
649
650 assert!(matches!(outcome, SelfInstallSync::AlreadyCurrent));
651 assert_eq!(std::fs::read(&src).unwrap(), b"new");
652 std::fs::remove_dir_all(&dir).unwrap();
653 }
654
655 #[tokio::test]
656 async fn self_install_errors_when_source_is_destination() {
657 let dir = make_temp_dir().await;
658 let config = config_in(&dir);
659 let current = std::env::current_exe().unwrap();
660
661 let err = handle_self_install(config, Some(current))
662 .await
663 .unwrap_err();
664 assert!(
665 err.to_string()
666 .contains("source and destination are the same binary"),
667 "error should explain self-overwrite: {err:#}"
668 );
669
670 tokio::fs::remove_dir_all(&dir).await.unwrap();
671 }
672
673 #[test]
674 fn update_check_failure_warning_is_non_fatal_wording() {
675 let err = anyhow::anyhow!(
676 "GitHub stable release request failed: HTTP 403 Forbidden: API rate limit exceeded"
677 );
678 let warning = format_update_check_failure_warning(&err);
679
680 assert!(warning.contains("warning: skipped shine version check"));
681 assert!(warning.contains("HTTP 403 Forbidden"));
682 assert!(!warning.contains("Update check failed"));
683 }
684
685 #[test]
686 fn config_upgrade_summary_parts_includes_only_nonzero_counts() {
687 assert_eq!(
688 config_upgrade_summary_parts(2, 0, 0),
689 vec!["2 updated".to_string()]
690 );
691 }
692
693 #[test]
694 fn config_upgrade_summary_parts_empty_when_all_zero() {
695 assert!(config_upgrade_summary_parts(0, 0, 0).is_empty());
696 }
697
698 #[test]
699 fn config_upgrade_summary_parts_reports_actionable_counters() {
700 assert_eq!(
701 config_upgrade_summary_parts(1, 2, 3),
702 vec![
703 "1 updated".to_string(),
704 "2 user-modified (kept)".to_string(),
705 "3 link conflicts".to_string(),
706 ]
707 );
708 }
709
710 #[test]
711 fn format_self_upgrade_message_handles_stable_channel() {
712 assert_eq!(
713 format_self_upgrade_message(ReleaseChannel::Stable, "0.21.3", "0.21.4", "v0.21.4",),
714 "Upgraded shine from 0.21.3 to 0.21.4."
715 );
716 }
717
718 #[test]
719 fn format_self_upgrade_message_handles_stable_to_preview_install() {
720 assert_eq!(
721 format_self_upgrade_message(
722 ReleaseChannel::Preview,
723 "0.21.3",
724 "1.0.0-preview",
725 "preview",
726 ),
727 "Installed shine preview 1.0.0-preview over stable 0.21.3 (preview)."
728 );
729 }
730
731 #[test]
732 fn format_self_upgrade_message_handles_preview_to_preview_update() {
733 assert_eq!(
734 format_self_upgrade_message(
735 ReleaseChannel::Preview,
736 "1.0.0-preview",
737 "1.0.1-preview",
738 "preview",
739 ),
740 "Updated shine preview from 1.0.0-preview to 1.0.1-preview (preview)."
741 );
742 }
743}