Skip to main content

cli/
self_install.rs

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
353/// After a successful self-upgrade, try to sync the new binary to the self-install destination.
354/// If the copy fails due to permissions, print a targeted hint instead of failing.
355async 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        // Unix already tried `sudo` automatically inside `install_binary_with_elevation`;
367        // reaching here means it was declined or unavailable non-interactively. Windows has
368        // no such auto-elevation path, so it still needs the manual hint.
369        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    // Remember where we installed so `shine self upgrade` can sync this copy automatically.
446    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
524/// Installs the binary, auto-elevating via `sudo` on Unix if the plain copy
525/// fails because the destination isn't user-writable (e.g. `/usr/local/bin`
526/// owned by root). Mirrors the auto-elevation already used for privileged
527/// app-config writes in `apps/file_ops.rs::install_bytes_admin`, so the user
528/// is prompted once instead of being told to manually re-run with `sudo`.
529async 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    // No auto-elevation path on Windows: privileged binary copies go through
591    // an elevated terminal instead. Fall back to the plain unprivileged copy
592    // so the caller's original error surfaces if it still fails.
593    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}