use anyhow::{Result, bail};
use crate::config::{self, Config};
#[cfg(unix)]
use crate::privilege;
use crate::update_check::{self, ReleaseChannel, UpdateStatus};
use crate::{apps, colors, env, info, list, output, platform, shells, sys, version};
use shine_core::lifecycle::LifecycleOperation;
use shine_core::runtime::{
AppPlanRequest, PlanningInputVersions, ShellPlanRequest, SysManagedPlanRequest,
};
pub async fn handle_update(
config: &Config,
target: Option<&str>,
diff: bool,
verbose: bool,
refresh_release: bool,
run_generators: bool,
) -> Result<()> {
let compatibility = crate::preset_migration::active_compatibility_plan(target).await?;
let migration_required = crate::preset_migration::compatibility_required(&compatibility);
crate::preset_migration::print_compatibility(&compatibility);
if let Some(target) = target {
info::handle_update_target(config, target, run_generators).await?;
if migration_required {
bail!(
"{}",
crate::preset_migration::compatibility_failure_message(&compatibility)
);
}
return Ok(());
}
let config_updates = if verbose {
match Box::pin(list::handle_status_list(config, diff, run_generators)).await {
Ok(()) => {
println!();
Ok(true)
}
Err(error) => Err(error),
}
} else {
Box::pin(list::handle_update_list(config, diff, run_generators)).await
};
let (mut printed_update, config_update_error) = match config_updates {
Ok(printed) => (printed, None),
Err(error) => {
eprintln!(
"{}",
colors::yellow_stderr(&format!(
"warning: configuration update check failed: {error:#}"
))
);
(false, Some(error))
}
};
let current = version::semver();
if verbose {
println!("Checking for updates (current: {current})...");
}
let update_status = if refresh_release {
update_check::check_for_update_forced(config).await
} else {
update_check::check_for_update(config).await
};
match update_status {
Ok(UpdateStatus::UpToDate) => {
if verbose {
println!(
"{}",
colors::green(&format!("shine {current} is up to date."))
);
}
}
Ok(UpdateStatus::UpdateAvailable { latest }) => {
if printed_update && !verbose {
println!();
}
println!(
"{}",
colors::yellow(&format!(
"A newer version of shine is available: {current} -> {latest}."
))
);
println!("Run `shine self upgrade` to install it.");
printed_update = true;
}
Ok(UpdateStatus::UpdateRequired { latest }) => {
if printed_update && !verbose {
println!();
}
println!(
"{}",
colors::yellow(&format!(
"A newer patch release of shine is available: {current} -> {latest}."
))
);
println!("Run `shine self upgrade` to install it.");
printed_update = true;
}
Err(e) => {
eprintln!("{}", format_update_check_failure_warning(&e));
}
}
if !printed_update && config_update_error.is_none() && !migration_required {
println!("{}", colors::dim("Nothing to update."));
}
if let Some(error) = config_update_error {
return Err(error);
}
if migration_required {
bail!(
"{}",
crate::preset_migration::compatibility_failure_message(&compatibility)
);
}
Ok(())
}
fn format_update_check_failure_warning(err: &anyhow::Error) -> String {
colors::yellow_stderr(&format!("warning: skipped shine version check: {err}"))
}
pub async fn handle_self_upgrade(config: &Config, channel: Option<ReleaseChannel>) -> Result<()> {
let current = version::semver();
let selected_channel = channel.unwrap_or(ReleaseChannel::Stable);
let force_install = channel.is_some();
println!(
"Checking for {} upgrades (current: {current})...",
selected_channel.as_str()
);
match update_check::upgrade_to_release(config, selected_channel, force_install).await {
Ok(update_check::UpgradeResult::AlreadyUpToDate { channel, latest }) => {
println!(
"{}",
colors::green(&format!(
"shine {current} is up to date on the {} channel ({latest}).",
channel.as_str()
))
);
}
Ok(update_check::UpgradeResult::Upgraded {
channel,
previous: _,
previous_display,
release_tag,
installed_version,
installed_path,
}) => {
println!(
"{}",
colors::green(&format_self_upgrade_message(
channel,
&previous_display,
&installed_version,
&release_tag,
))
);
sync_self_install_dest(config, &installed_path).await;
}
Err(e) => {
update_check::invalidate_update_cache(config).await;
bail!("Upgrade failed: {e}");
}
}
Ok(())
}
fn format_self_upgrade_message(
channel: ReleaseChannel,
previous_display: &str,
installed_version: &str,
release_tag: &str,
) -> String {
match channel {
ReleaseChannel::Stable => {
format!("Upgraded shine from {previous_display} to {installed_version}.")
}
ReleaseChannel::Preview => {
if previous_display.contains("-preview") {
format!(
"Updated shine preview from {previous_display} to {installed_version} ({release_tag})."
)
} else {
format!(
"Installed shine preview {installed_version} over stable {previous_display} ({release_tag})."
)
}
}
}
}
pub async fn handle_config_upgrade(
config: &Config,
target: Option<&str>,
verbose: bool,
prune_stale: bool,
yes: bool,
) -> Result<()> {
let compatibility = crate::preset_migration::active_compatibility_plan(target).await?;
crate::preset_migration::print_compatibility(&compatibility);
if crate::preset_migration::compatibility_required(&compatibility) {
bail!(
"{}",
crate::preset_migration::compatibility_failure_message(&compatibility)
);
}
if let Some(target) = target {
return handle_config_target_upgrade(config, target, verbose, prune_stale, yes).await;
}
if verbose {
println!("{}", colors::bold("Upgrading installed configs"));
config::print_presets_note(config);
}
let mut sep = if verbose {
output::SectionSeparator::new()
} else {
output::SectionSeparator::with_preamble(colors::bold("Upgrading installed configs"))
};
let env_report = Box::pin(env::upgrade::handle_upgrade(config, false, verbose)).await?;
let os_id = sys::detect_os_id().await?;
let reviewed = crate::lifecycle_plan::review_upgrade_plans(
config,
[
crate::lifecycle_plan::LifecyclePlanRequest::shell(
ShellPlanRequest {
operation: LifecycleOperation::Upgrade,
target: None,
force: false,
purge: false,
input_versions: PlanningInputVersions::default(),
},
config,
),
crate::lifecycle_plan::LifecyclePlanRequest::app(
AppPlanRequest {
operation: LifecycleOperation::Upgrade,
target: None,
force: false,
purge: false,
prune_stale,
input_versions: PlanningInputVersions::default(),
},
config,
),
crate::lifecycle_plan::LifecyclePlanRequest::sys(
SysManagedPlanRequest {
operation: LifecycleOperation::Upgrade,
os_id,
target: None,
input_versions: PlanningInputVersions::default(),
},
config,
),
],
yes,
verbose,
)
.await?;
let mut prepared = crate::lifecycle_plan::prepare_plans(config, reviewed).await?;
let shell_prepared = prepared.remove(0);
let app_prepared = prepared.remove(0);
let sys_prepared = prepared.remove(0);
let (shell_report, shell_lifecycle) =
Box::pin(shells::handle_upgrade_installed_with_result_prepared(
config,
verbose,
shell_prepared,
&mut sep,
))
.await?;
let (app_report, app_lifecycle) = Box::pin(
apps::handle_upgrade_installed_with_output_with_result_prepared(
config,
prune_stale,
verbose,
app_prepared,
&mut sep,
),
)
.await?;
let (sys_report, _sys_lifecycle) = Box::pin(sys::handle_upgrade_managed_with_result_prepared(
config,
verbose,
sys_prepared,
&mut sep,
))
.await?;
let updated = env_report.updated
+ changed_shell_categories(&shell_lifecycle)
+ usize::from(shell_report.path_changed)
+ changed_app_categories(&app_lifecycle)
+ sys_report.updated;
let user_modified = env_report.user_modified + preserved_app_resources(&app_lifecycle);
let summary = config_upgrade_summary_parts(updated, user_modified, shell_report.link_conflicts);
if verbose || sep.has_printed() {
output::footer("Done", &summary);
} else {
println!("{}", colors::dim("Nothing to upgrade."));
}
for hint in &app_report.restart_hints {
println!(" {} {}", colors::symbol("!"), colors::yellow(hint));
}
let fatal_app_failures = app_upgrade_failure_count(&app_report);
if fatal_app_failures > 0 {
bail!("{} app configuration item(s) failed", fatal_app_failures);
}
if sys_report.failed > 0 {
bail!(
"{} managed system configuration item(s) failed",
sys_report.failed
);
}
Ok(())
}
async fn handle_config_target_upgrade(
config: &Config,
target: &str,
verbose: bool,
prune_stale: bool,
yes: bool,
) -> Result<()> {
use crate::shim::{PresetKind, resolve_preset_kind};
let target = target.trim();
if target.is_empty() {
bail!("upgrade target must not be empty");
}
let mut sep = if verbose {
println!("{}", colors::bold(&format!("Upgrading {target}")));
config::print_presets_note(config);
output::SectionSeparator::new()
} else {
output::SectionSeparator::with_preamble(colors::bold(&format!("Upgrading {target}")))
};
let (updated, user_modified, link_conflicts, failed, restart_hints) =
if let Some(item) = target.strip_prefix("sys/") {
if item.is_empty() || item.contains('/') {
bail!("invalid system target `{target}`; expected sys/<item>");
}
if prune_stale {
bail!("`--prune-stale` applies only to app targets");
}
let (report, lifecycle) =
Box::pin(sys::handle_upgrade_managed_target_with_result_approved(
config,
Some(item),
verbose,
yes,
&mut sep,
))
.await?;
(
lifecycle.summary().changed,
lifecycle.summary().preserved + lifecycle.summary().conflicts,
0,
report.failed,
Default::default(),
)
} else {
let normalized = if let Some(rest) = target.strip_prefix("app/") {
let category = rest.split('/').next().unwrap_or_default();
format!("app/{category}")
} else if let Some(rest) = target.strip_prefix("shell/") {
let category = rest.split('/').next().unwrap_or_default();
format!("shell/{category}")
} else {
target.to_string()
};
let (kind, category) = resolve_preset_kind(config, &normalized).await?;
match kind {
PresetKind::App => {
let (report, lifecycle) =
Box::pin(apps::handle_upgrade_installed_target_with_result_approved(
config,
Some(&category),
prune_stale,
verbose,
yes,
&mut sep,
))
.await?;
(
changed_app_categories(&lifecycle),
preserved_app_resources(&lifecycle),
0,
app_upgrade_failure_count(&report),
report.restart_hints,
)
}
PresetKind::Shell => {
if prune_stale {
bail!("`--prune-stale` applies only to app targets");
}
let (report, lifecycle) = Box::pin(
shells::handle_upgrade_installed_target_with_result_approved(
config,
Some(&category),
verbose,
yes,
&mut sep,
),
)
.await?;
(
changed_shell_categories(&lifecycle) + usize::from(report.path_changed),
0,
lifecycle.summary().conflicts,
0,
Default::default(),
)
}
}
};
let summary = config_upgrade_summary_parts(updated, user_modified, link_conflicts);
if verbose || sep.has_printed() {
output::footer("Done", &summary);
} else {
println!("{}", colors::dim("Nothing to upgrade."));
}
for hint in restart_hints {
println!(" {} {}", colors::symbol("!"), colors::yellow(&hint));
}
if failed > 0 {
bail!("{failed} managed configuration item(s) failed");
}
Ok(())
}
fn config_upgrade_summary_parts(
updated: usize,
user_modified: usize,
link_conflicts: usize,
) -> Vec<String> {
let mut parts = Vec::new();
output::push_count(&mut parts, updated, colors::green, "updated");
output::push_count(
&mut parts,
user_modified,
colors::yellow,
"user-modified (kept)",
);
output::push_count(&mut parts, link_conflicts, colors::yellow, "link conflicts");
parts
}
fn app_upgrade_failure_count(report: &apps::AppUpgradeReport) -> usize {
report.failed
}
fn changed_shell_categories(result: &shine_core::lifecycle::LifecycleResultV1) -> usize {
result
.outcomes
.iter()
.filter(|outcome| {
outcome.status == shine_core::lifecycle::LifecycleStatus::Changed
&& outcome.target.starts_with("shell/")
&& outcome.effects.iter().any(|effect| {
!matches!(effect, shine_core::lifecycle::LifecycleEffect::CacheWritten)
})
})
.filter_map(|outcome| outcome.target.split('/').nth(1))
.collect::<std::collections::BTreeSet<_>>()
.len()
}
fn is_app_auxiliary_resource(resource: Option<&str>) -> bool {
matches!(
resource,
Some(
"preset-cache"
| "purge"
| "hook:post-install"
| "hook:post-upgrade"
| "artifact:teardown"
)
)
}
fn changed_app_categories(result: &shine_core::lifecycle::LifecycleResultV1) -> usize {
result
.outcomes
.iter()
.filter(|outcome| {
outcome.status == shine_core::lifecycle::LifecycleStatus::Changed
&& outcome.target.starts_with("app/")
&& !is_app_auxiliary_resource(outcome.resource.as_deref())
})
.map(|outcome| outcome.target.as_str())
.collect::<std::collections::BTreeSet<_>>()
.len()
}
fn preserved_app_resources(result: &shine_core::lifecycle::LifecycleResultV1) -> usize {
result
.outcomes
.iter()
.filter(|outcome| {
matches!(
outcome.status,
shine_core::lifecycle::LifecycleStatus::Preserved
| shine_core::lifecycle::LifecycleStatus::Conflict
) && outcome.target.starts_with("app/")
&& !is_app_auxiliary_resource(outcome.resource.as_deref())
})
.count()
}
async fn sync_self_install_dest(config: &Config, src: &std::path::Path) {
let dest = match &config.self_install_dest {
Some(d) => d,
None => return,
};
match sync_self_install_dest_from(src, dest).await {
Ok(SelfInstallSync::Synced) => println!(
"{}",
colors::green(&format!("Synced system copy at {}", dest.display()))
),
Ok(SelfInstallSync::AlreadyCurrent) => {}
Err(e) if cfg!(windows) && has_io_error_kind(&e, std::io::ErrorKind::PermissionDenied) => {
let hint = format!(
"Installed copy at {} needs manual sync; rerun from an elevated terminal if needed.",
dest.display()
);
println!("{}", colors::yellow(&hint));
}
Err(e) => eprintln!(
"Warning: failed to sync system copy at {}: {e}",
dest.display()
),
}
}
enum SelfInstallSync {
Synced,
AlreadyCurrent,
}
async fn sync_self_install_dest_from(
src: &std::path::Path,
dest: &std::path::Path,
) -> Result<SelfInstallSync> {
if dest.exists() {
let canonical_src = src.canonicalize().unwrap_or_else(|_| src.to_path_buf());
let canonical_dest = dest.canonicalize().unwrap_or_else(|_| dest.to_path_buf());
if canonical_src == canonical_dest {
return Ok(SelfInstallSync::AlreadyCurrent);
}
}
install_binary_with_elevation(src, dest)
.await
.map(|()| SelfInstallSync::Synced)
}
fn has_io_error_kind(err: &anyhow::Error, kind: std::io::ErrorKind) -> bool {
err.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|io_err| io_err.kind() == kind)
})
}
pub async fn handle_self_install(
mut config: Config,
dest: Option<std::path::PathBuf>,
) -> Result<()> {
use anyhow::{Context as _, bail};
let src = std::env::current_exe().context("failed to resolve current executable path")?;
let dest = match dest {
Some(dest) => dest,
None => platform::default_self_install_dest()?,
};
if dest.exists() {
let canonical_src = src.canonicalize().unwrap_or_else(|_| src.clone());
let canonical_dest = dest.canonicalize().unwrap_or_else(|_| dest.clone());
if canonical_src == canonical_dest {
let example = if cfg!(windows) {
r"C:\path\to\new\shine.exe self install"
} else {
"sudo /path/to/new/shine self install"
};
bail!(
"source and destination are the same binary: {}. Run the newer binary by full path, e.g. `{example}`, to overwrite this copy.",
dest.display()
);
}
}
install_binary_with_elevation(&src, &dest)
.await
.with_context(|| self_install_failure_hint(&dest))?;
config.self_install_dest = Some(dest.clone());
config
.save()
.await
.context("failed to save self_install_dest to config")?;
println!(
"{}",
colors::green(&format!("installed to {}", dest.display()))
);
print_self_install_activation_hint(&dest);
Ok(())
}
fn self_install_failure_hint(dest: &std::path::Path) -> String {
format!("failed to copy to {}", dest.display())
}
fn print_self_install_activation_hint(dest: &std::path::Path) {
let Some(dir) = dest.parent() else {
return;
};
if platform::current_path_contains_dir(dir) {
println!(
"{}",
colors::dim("The install directory is already on PATH.")
);
} else {
println!(
"{}",
colors::yellow(&format!(
"Install directory is not on PATH: {}",
dir.display()
))
);
println!("{}", colors::dim(&platform::path_install_hint(dir)));
}
}
fn install_binary_atomically(src: &std::path::Path, dest: &std::path::Path) -> Result<()> {
use anyhow::Context as _;
let parent = dest
.parent()
.with_context(|| format!("destination has no parent: {}", dest.display()))?;
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create destination dir: {}", parent.display()))?;
let temp = parent.join(format!(".shine-self-install-{}", uuid::Uuid::new_v4()));
std::fs::copy(src, &temp).with_context(|| {
format!(
"failed to stage binary from {} to {}",
src.display(),
temp.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(src)
.map(|m| m.permissions().mode())
.unwrap_or(0o755);
std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(mode))
.with_context(|| format!("failed to set permissions on {}", temp.display()))?;
}
match std::fs::rename(&temp, dest) {
Ok(()) => Ok(()),
Err(err) => {
let _ = std::fs::remove_file(&temp);
Err(err)
.with_context(|| format!("failed to replace {} with staged binary", dest.display()))
}
}
}
async fn install_binary_with_elevation(
src: &std::path::Path,
dest: &std::path::Path,
) -> Result<()> {
match install_binary_atomically(src, dest) {
Ok(()) => Ok(()),
Err(e)
if !cfg!(windows)
&& has_io_error_kind(&e, std::io::ErrorKind::PermissionDenied)
&& !std::env::var("USER").is_ok_and(|user| user == "root") =>
{
let _lock = crate::admin_fs::admin_lock().await?;
install_binary_privileged(src, dest).await
}
Err(e) => Err(e),
}
}
#[cfg(unix)]
async fn install_binary_privileged(src: &std::path::Path, dest: &std::path::Path) -> Result<()> {
use anyhow::Context as _;
use std::os::unix::fs::PermissionsExt;
if !privilege::ensure_admin(1).await? {
anyhow::bail!("administrator permission was not granted");
}
let parent = dest
.parent()
.with_context(|| format!("destination has no parent: {}", dest.display()))?;
let mode = std::fs::metadata(src)
.map(|m| m.permissions().mode())
.unwrap_or(0o755);
let status = crate::admin_fs::sudo_command()
.arg("mkdir")
.arg("-p")
.arg(parent)
.status()
.await
.context("failed to create privileged destination directory")?;
if !status.success() {
anyhow::bail!("administrator permission was not granted");
}
let status = crate::admin_fs::sudo_command()
.args(["install", "-m", &format!("{mode:o}"), "--"])
.arg(src)
.arg(dest)
.status()
.await
.context("failed to install shine binary with administrator privileges")?;
if !status.success() {
anyhow::bail!("failed to install shine binary with administrator privileges");
}
Ok(())
}
#[cfg(not(unix))]
async fn install_binary_privileged(src: &std::path::Path, dest: &std::path::Path) -> Result<()> {
install_binary_atomically(src, dest)
}
#[cfg(test)]
mod tests {
use super::*;
async fn make_temp_dir() -> std::path::PathBuf {
crate::test_support::make_temp_dir("shine-self-install-test").await
}
fn config_in(dir: &std::path::Path) -> Config {
crate::test_support::test_config(dir)
}
#[test]
fn install_binary_atomically_overwrites_existing_dest() {
let dir = std::env::temp_dir().join(format!("shine-self-install-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let src = dir.join("new-shine");
let dest = dir.join("shine");
std::fs::write(&src, b"new").unwrap();
std::fs::write(&dest, b"old").unwrap();
install_binary_atomically(&src, &dest).unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"new");
std::fs::remove_dir_all(&dir).unwrap();
}
#[tokio::test]
async fn sync_self_install_dest_creates_missing_parent() {
let dir = std::env::temp_dir().join(format!("shine-self-sync-{}", uuid::Uuid::new_v4()));
let src = dir.join("new-shine");
let dest = dir.join("usr/local/bin/shine");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(&src, b"new").unwrap();
let outcome = sync_self_install_dest_from(&src, &dest).await.unwrap();
assert!(matches!(outcome, SelfInstallSync::Synced));
assert_eq!(std::fs::read(&dest).unwrap(), b"new");
std::fs::remove_dir_all(&dir).unwrap();
}
#[tokio::test]
async fn sync_self_install_dest_skips_current_exe_path() {
let dir = std::env::temp_dir().join(format!("shine-self-sync-{}", uuid::Uuid::new_v4()));
let src = dir.join("shine");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(&src, b"new").unwrap();
let outcome = sync_self_install_dest_from(&src, &src).await.unwrap();
assert!(matches!(outcome, SelfInstallSync::AlreadyCurrent));
assert_eq!(std::fs::read(&src).unwrap(), b"new");
std::fs::remove_dir_all(&dir).unwrap();
}
#[tokio::test]
async fn self_install_errors_when_source_is_destination() {
let dir = make_temp_dir().await;
let config = config_in(&dir);
let current = std::env::current_exe().unwrap();
let err = handle_self_install(config, Some(current))
.await
.unwrap_err();
assert!(
err.to_string()
.contains("source and destination are the same binary"),
"error should explain self-overwrite: {err:#}"
);
tokio::fs::remove_dir_all(&dir).await.unwrap();
}
#[test]
fn update_check_failure_warning_is_non_fatal_wording() {
let err = anyhow::anyhow!(
"GitHub stable release request failed: HTTP 403 Forbidden: API rate limit exceeded"
);
let warning = format_update_check_failure_warning(&err);
assert!(warning.contains("warning: skipped shine version check"));
assert!(warning.contains("HTTP 403 Forbidden"));
assert!(!warning.contains("Update check failed"));
}
#[test]
fn config_upgrade_summary_parts_includes_only_nonzero_counts() {
assert_eq!(
config_upgrade_summary_parts(2, 0, 0),
vec!["2 updated".to_string()]
);
}
#[test]
fn config_upgrade_summary_parts_empty_when_all_zero() {
assert!(config_upgrade_summary_parts(0, 0, 0).is_empty());
}
#[test]
fn config_upgrade_summary_parts_reports_actionable_counters() {
assert_eq!(
config_upgrade_summary_parts(1, 2, 3),
vec![
"1 updated".to_string(),
"2 user-modified (kept)".to_string(),
"3 link conflicts".to_string(),
]
);
}
#[test]
fn app_upgrade_failure_count_uses_the_authoritative_report_total() {
let report = apps::AppUpgradeReport {
failed: 3,
..Default::default()
};
assert_eq!(app_upgrade_failure_count(&report), 3);
}
#[test]
fn format_self_upgrade_message_handles_stable_channel() {
assert_eq!(
format_self_upgrade_message(ReleaseChannel::Stable, "0.21.3", "0.21.4", "v0.21.4",),
"Upgraded shine from 0.21.3 to 0.21.4."
);
}
#[test]
fn format_self_upgrade_message_handles_stable_to_preview_install() {
assert_eq!(
format_self_upgrade_message(
ReleaseChannel::Preview,
"0.21.3",
"1.0.0-preview",
"preview",
),
"Installed shine preview 1.0.0-preview over stable 0.21.3 (preview)."
);
}
#[test]
fn format_self_upgrade_message_handles_preview_to_preview_update() {
assert_eq!(
format_self_upgrade_message(
ReleaseChannel::Preview,
"1.0.0-preview",
"1.0.1-preview",
"preview",
),
"Updated shine preview from 1.0.0-preview to 1.0.1-preview (preview)."
);
}
}