1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::sync::Mutex;
4
5use anyhow::{Result, anyhow};
6use colored::Colorize;
7use dialoguer::MultiSelect;
8use dialoguer::theme::ColorfulTheme;
9use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
10use rayon::prelude::*;
11use semver::Version;
12use serde_json::json;
13
14use crate::cmd::{utils as cmd_utils, ux};
15use crate::pkg::merge::handle_backup_files;
16use crate::pkg::{
17 config, db, hooks, install, local, pin, resolve, transaction, types
18};
19
20#[derive(Clone, Copy, PartialEq, Eq)]
25enum UpdateOutcome {
26 Updated,
28 Skipped
30}
31
32pub fn run(
49 all: bool,
50 package_names: &[String],
51 yes: bool,
52 dry_run: bool,
53 explain: bool,
54 plan_json: bool,
55 verbose: bool,
56 interactive: bool
57) -> Result<()> {
58 if all {
59 return run_update_all_logic(
60 yes,
61 dry_run,
62 explain,
63 plan_json,
64 verbose,
65 interactive
66 );
67 }
68
69 if plan_json && !dry_run {
70 return Err(anyhow!("--plan-json requires --dry-run"));
71 }
72
73 let expanded_package_names =
74 cmd_utils::expand_split_packages(package_names, "Updating")?;
75
76 let mut succeeded = 0usize;
77 let mut skipped = 0usize;
78 let mut failed = 0usize;
79
80 for (i, package_name) in expanded_package_names.iter().enumerate() {
81 if i > 0 {
82 println!();
83 }
84 match run_update_single_logic(
85 package_name,
86 yes,
87 dry_run,
88 explain,
89 plan_json,
90 verbose
91 ) {
92 Ok(UpdateOutcome::Updated) => succeeded += 1,
93 Ok(UpdateOutcome::Skipped) => skipped += 1,
94 Err(e) => {
95 eprintln!(
96 "{}: Failed to update '{}': {}",
97 "Error".red().bold(),
98 package_name,
99 e
100 );
101 failed += 1;
102 }
103 }
104 }
105
106 if !package_names.is_empty() && !dry_run && !plan_json {
107 ux::print_transaction_summary(&ux::TransactionSummary {
108 command: "update".to_string(),
109 success: succeeded,
110 failed,
111 skipped
112 });
113 }
114 Ok(())
115}
116
117fn run_update_single_logic(
122 package_name: &str,
123 yes: bool,
124 dry_run: bool,
125 explain: bool,
126 plan_json: bool,
127 verbose: bool
128) -> Result<UpdateOutcome> {
129 if !plan_json {
130 println!(
131 "{} Resolving dependencies for '{}'...",
132 "::".bold().blue(),
133 package_name.cyan()
134 );
135 }
136
137 let (new_pkg, new_version, _, _, registry_handle, _, _) =
138 resolve::resolve_package_and_version(package_name, None, true, yes)?;
139
140 if pin::is_pinned(package_name)? {
141 if !plan_json {
142 println!(
143 "Package '{}' is pinned. Skipping update.",
144 package_name.yellow()
145 );
146 }
147 return Ok(UpdateOutcome::Skipped);
148 }
149
150 let installed_source = if let Some(sub) = &new_pkg.sub_package {
151 format!(
152 "#{}@{}/{}:{}",
153 registry_handle.as_deref().unwrap_or("local"),
154 new_pkg.repo,
155 new_pkg.name,
156 sub
157 )
158 } else {
159 format!(
160 "#{}@{}/{}",
161 registry_handle.as_deref().unwrap_or("local"),
162 new_pkg.repo,
163 new_pkg.name
164 )
165 };
166 let installed_request = resolve::parse_source_string(&installed_source)?;
167 let mut candidates = Vec::new();
168 candidates.extend(local::find_installed_manifests_matching(
169 &installed_request,
170 types::Scope::User
171 )?);
172 candidates.extend(local::find_installed_manifests_matching(
173 &installed_request,
174 types::Scope::System
175 )?);
176
177 let old_manifest = crate::cmd::installed_select::choose_installed_manifest(
178 package_name,
179 &candidates,
180 yes
181 )
182 .map_err(|e| {
183 if candidates.is_empty() {
184 anyhow!(
185 "Package '{package_name}' is not installed. Use 'zoi install' \
186 instead."
187 )
188 } else {
189 e
190 }
191 })?;
192
193 if old_manifest.version == new_version
194 && old_manifest.revision == new_pkg.revision
195 {
196 if !plan_json {
197 println!("\nPackage '{package_name}' is already up to date.");
198 }
199 return Ok(UpdateOutcome::Skipped);
200 }
201
202 if !plan_json {
203 println!("{} Looking for conflicts...", "::".bold().blue());
204 }
205
206 let (graph, non_zoi_deps) = install::resolver::resolve_dependency_graph(
207 &[package_name.to_string()],
208 Some(old_manifest.scope),
209 true,
210 yes,
211 false,
212 None,
213 true,
214 None
215 )?;
216
217 if !plan_json {
218 println!("{} Checking available disk space...", "::".bold().blue());
219 }
220
221 if !dry_run {
222 let pkgs_to_check: Vec<&types::Package> =
223 graph.nodes.values().map(|n| &n.pkg).collect();
224 install::preflight::check_for_conflicts(&pkgs_to_check, yes)?;
225 for pkg in &pkgs_to_check {
226 if !install::util::display_updates(pkg, yes)? {
227 return Err(anyhow!("Update aborted by user."));
228 }
229 }
230 install::preflight::check_policy_compliance(&graph)?;
231 install::preflight::check_scope_compliance(&graph)?;
232 install::preflight::check_zoios_compliance(&graph)?;
233 install::preflight::check_for_vulnerabilities(&graph, yes)?;
234
235 let m_for_conflict_check = MultiProgress::new();
236 install::preflight::check_file_conflicts(
237 &graph,
238 yes,
239 &m_for_conflict_check
240 )?;
241 let _ = m_for_conflict_check.clear();
242 }
243
244 let install_plan =
245 install::plan::create_install_plan(&graph.nodes, None, false)?;
246
247 let mut total_download_size: u64 = 0;
248 let mut total_installed_size_diff: i64 = 0;
249 let mut packages_to_upgrade = Vec::new();
250 let config = config::read_config().unwrap_or_default();
251
252 for (pkg_id, node) in &graph.nodes {
253 let is_requested = node.pkg.name == new_pkg.name
254 && node.sub_package == old_manifest.sub_package;
255
256 let request_source = local::package_source_string(
257 &node.registry_handle,
258 &node.pkg.repo,
259 &node.pkg.name,
260 node.sub_package.as_deref(),
261 &node.version
262 );
263 let request = resolve::parse_source_string(&request_source)?;
264 let installed = local::find_installed_manifests_matching(
265 &request,
266 old_manifest.scope
267 )?;
268
269 let (current_version_display, needs_update) = if installed.is_empty() {
270 if is_requested {
271 let display = if old_manifest.revision == "1" {
272 old_manifest.version.clone()
273 } else {
274 format!(
275 "{}-{}",
276 old_manifest.version, old_manifest.revision
277 )
278 };
279 (Some(display), true)
280 } else {
281 (None, true)
282 }
283 } else {
284 let m = installed
285 .first()
286 .ok_or_else(|| anyhow!("installed list unexpectedly empty"))?;
287 let display = if m.revision == "1" {
288 m.version.clone()
289 } else {
290 format!("{}-{}", m.version, m.revision)
291 };
292 (
293 Some(display),
294 m.version != node.version || m.revision != node.revision
295 )
296 };
297
298 if needs_update {
299 let (down_size, inst_size) = install::util::get_package_sizes(
300 &node.pkg,
301 &node.registry_handle,
302 &node.version
303 );
304 total_download_size += down_size;
305
306 let old_size = installed
307 .first()
308 .and_then(|m| m.installed_size)
309 .unwrap_or(0);
310 total_installed_size_diff += inst_size as i64 - old_size as i64;
311
312 let display_name = ux::format_display_name(
313 &node.registry_handle,
314 &node.pkg.repo,
315 &node.pkg.name,
316 node.sub_package.as_deref(),
317 &config
318 );
319
320 let new_version_display = if node.revision == "1" {
321 node.version.clone()
322 } else {
323 format!("{}-{}", node.version, node.revision)
324 };
325
326 packages_to_upgrade.push((
327 display_name,
328 current_version_display,
329 new_version_display,
330 pkg_id.clone(),
331 !is_requested
332 ));
333 }
334 }
335
336 if !plan_json {
337 println!(
338 "\n{} Packages ({})\n",
339 "::".bold().blue(),
340 packages_to_upgrade.len()
341 );
342 }
343
344 for (name, old_ver, new_ver, pkg_id, is_dep) in &packages_to_upgrade {
345 let transition = if let Some(old) = old_ver {
346 format!(
347 "{} -> {}",
348 format!("{name}@{old}").cyan(),
349 format!("{name}@{new_ver}").cyan()
350 )
351 } else {
352 format!("{} (new dependency)", format!("{name}@{new_ver}").cyan())
353 };
354 if !plan_json {
355 println!(" {transition}");
356 }
357
358 if *is_dep {
359 let node = graph.nodes.get(pkg_id).ok_or_else(|| {
360 anyhow!("Package node '{pkg_id}' missing from graph")
361 })?;
362 let pkg_dir = local::get_package_dir(
363 old_manifest.scope,
364 &node.registry_handle,
365 &node.pkg.repo,
366 &node.pkg.name
367 )?;
368 if let Ok(dependents) = local::get_dependents(&pkg_dir) {
369 let external_dependents: Vec<_> = dependents
370 .into_iter()
371 .filter(|dep_id| !graph.nodes.contains_key(dep_id))
372 .collect();
373
374 if !external_dependents.is_empty() && !plan_json {
375 println!(
376 " {} Updating {} may affect: {}",
377 "Warning:".bold().yellow(),
378 node.pkg.name.cyan(),
379 external_dependents.join(", ").dimmed()
380 );
381 }
382 }
383 }
384 }
385
386 let down_str = zoi_core::utils::format_bytes(total_download_size);
387 let net_str = zoi_core::utils::format_size_diff(total_installed_size_diff);
388
389 if !plan_json {
390 println!("\nTotal Download Size: {down_str:>10}");
391 println!("Net Upgrade Size: {net_str:>10}");
392 }
393
394 if verbose && !plan_json {
395 let preflight = ux::PreflightSummary::new("Update preflight")
396 .row("Candidates", packages_to_upgrade.len().to_string())
397 .row("Scope", format!("{:?}", old_manifest.scope))
398 .row("Download size", &down_str)
399 .row("Net size", &net_str);
400 ux::print_preflight(&preflight);
401 }
402
403 if explain && !plan_json {
404 let mut report = ux::ExplainReport::new("Update explanation");
405 report = report.item(
406 new_pkg.name.clone(),
407 format!(
408 "selected because newer version {} is available over \
409 installed {}",
410 new_version, old_manifest.version
411 ),
412 Vec::new()
413 );
414 if let Ok((old_adv, new_adv)) = advisory_counts(
415 &old_manifest.registry_handle,
416 &new_pkg.name,
417 old_manifest.sub_package.as_deref(),
418 &old_manifest.version,
419 &new_version
420 ) {
421 report = report.item(
422 "advisories",
423 format!(
424 "old={}, new={}, delta={}",
425 old_adv,
426 new_adv,
427 (new_adv as i64 - old_adv as i64)
428 ),
429 Vec::new()
430 );
431 }
432 ux::print_explain(&report);
433 }
434
435 if plan_json {
436 let plan = json!({
437 "dry_run": dry_run,
438 "package": {
439 "name": new_pkg.name,
440 "sub_package": old_manifest.sub_package,
441 "registry": old_manifest.registry_handle,
442 "repo": old_manifest.repo,
443 "scope": format!("{:?}", old_manifest.scope),
444 "from_version": old_manifest.version,
445 "to_version": new_version,
446 "download_bytes": total_download_size,
447 "net_size_bytes": total_installed_size_diff,
448 }
449 });
450 ux::emit_plan_json_v1("update", plan)?;
451 return Ok(UpdateOutcome::Skipped);
452 }
453
454 if dry_run {
455 println!(
456 "\n{} Dry-run: update plan above would be executed.",
457 "::".bold().yellow()
458 );
459 return Ok(UpdateOutcome::Skipped);
460 }
461
462 println!();
463 let prompt = if packages_to_upgrade.len() > 1 {
464 "Do you want to upgrade these packages?".to_string()
465 } else {
466 format!("Update from {} to {}?", old_manifest.version, new_version)
467 };
468
469 if !crate::utils::ask_for_confirmation(&prompt, yes) {
470 return Ok(UpdateOutcome::Skipped);
471 }
472
473 perform_transaction(
474 &graph,
475 &install_plan,
476 &non_zoi_deps,
477 old_manifest.scope,
478 yes,
479 verbose,
480 &new_pkg.name,
481 &old_manifest,
482 &new_pkg,
483 plan_json
484 )?;
485 Ok(UpdateOutcome::Updated)
486}
487
488fn perform_transaction(
493 graph: &install::resolver::DependencyGraph,
494 install_plan: &HashMap<String, install::plan::InstallAction>,
495 non_zoi_deps: &[String],
496 scope: types::Scope,
497 yes: bool,
498 verbose: bool,
499 target_pkg_name: &str,
500 old_manifest_ref: &types::InstallManifest,
501 new_pkg_ref: &types::Package,
502 plan_json: bool
503) -> Result<()> {
504 let mut transaction = transaction::begin()?;
505 let stages = graph.toposort()?;
506 let mut new_manifest_option: Option<types::InstallManifest> = None;
507 let m = MultiProgress::new();
508
509 if old_manifest_ref.scope == types::Scope::System {
513 retain_kernels_before_upgrade();
514 }
515 if plan_json {
516 m.set_draw_target(indicatif::ProgressDrawTarget::hidden());
517 }
518
519 if !non_zoi_deps.is_empty() {
521 let processed_deps = Mutex::new(HashSet::new());
522 let mut installed_deps_ext = Vec::new();
523 for dep_str in non_zoi_deps {
524 let dep =
525 crate::pkg::dependencies::parse_dependency_string(dep_str)?;
526 crate::pkg::install::dep_install::install_dependency(
527 &dep,
528 "update",
529 scope,
530 yes,
531 false,
532 &processed_deps,
533 &mut installed_deps_ext,
534 Some(&m)
535 )?;
536 }
537 }
538
539 for stage in stages {
540 for pkg_id in stage {
541 let node = graph.nodes.get(&pkg_id).ok_or_else(|| {
542 anyhow!("Package node '{pkg_id}' missing from graph")
543 })?;
544 if let Some(action) = install_plan.get(&pkg_id) {
545 match install::installer::install_node(
546 node,
547 action,
548 Some(&m),
549 None,
550 yes,
551 true,
552 true,
553 verbose,
554 false
555 ) {
556 Ok(m) => {
557 if m.name == target_pkg_name {
558 new_manifest_option = Some(m);
559 }
560 }
561 Err(e) => {
562 eprintln!("\nError: Update failed. Rolling back...");
563 transaction::rollback(&transaction.id)?;
564 return Err(anyhow!("Update failed: {e}"));
565 }
566 }
567 }
568 }
569 }
570
571 if let Some(new_manifest) = new_manifest_option {
572 transaction::record_operation(
574 &mut transaction,
575 types::TransactionOperation::Upgrade {
576 old_manifest: Box::new(old_manifest_ref.clone()),
577 new_manifest: Box::new(new_manifest.clone())
578 }
579 )?;
580
581 if let Ok(modified_files) =
582 transaction::get_modified_files(&transaction.id)
583 {
584 let modified_packages =
585 transaction::get_modified_packages(&transaction.id)
586 .unwrap_or_default();
587 let _ = crate::pkg::hooks::global::run_global_hooks(
588 crate::pkg::hooks::global::HookWhen::PostTransaction,
589 &modified_files,
590 &modified_packages,
591 "upgrade",
592 scope
593 );
594
595 if let Ok(changed) =
599 zoi_core::alternatives::reconcile(&modified_packages)
600 {
601 crate::cmd::alt::announce_changes(&changed);
602 }
603
604 if scope == zoi_core::types::Scope::System {
608 let _ =
609 zoi_system::kernel::sync_after_transaction(&modified_files);
610 }
611 }
612
613 transaction::commit(&transaction.id)?;
614
615 if let Some(backup_files) = &old_manifest_ref.backup {
616 println!("Restoring configuration files...");
617 let old_version_dir = local::get_package_version_dir(
618 old_manifest_ref.scope,
619 &old_manifest_ref.registry_handle,
620 &old_manifest_ref.repo,
621 &old_manifest_ref.name,
622 &old_manifest_ref.version
623 )?;
624 let new_version_dir = local::get_package_version_dir(
625 new_manifest.scope,
626 &new_manifest.registry_handle,
627 &new_manifest.repo,
628 &new_manifest.name,
629 &new_manifest.version
630 )?;
631
632 crate::pkg::merge::handle_backup_files(
633 &old_version_dir,
634 &new_version_dir,
635 backup_files,
636 old_manifest_ref.scope
637 )?;
638 }
639
640 cleanup_old_versions(
642 &new_manifest.name,
643 scope,
644 &new_manifest.repo,
645 &new_manifest.registry_handle
646 )?;
647
648 if let Ok(conn) = db::open_connection("local") {
649 let _ = db::update_package(
650 &conn,
651 new_pkg_ref,
652 &new_manifest.registry_handle,
653 Some(new_manifest.scope),
654 new_manifest.sub_package.as_deref(),
655 Some(&types::InstallReason::Direct)
656 );
657 }
658
659 if let Some(hooks) = &new_pkg_ref.hooks {
660 crate::pkg::hooks::run_hooks(
661 hooks,
662 crate::pkg::hooks::HookType::PostUpgrade,
663 new_manifest.scope
664 )?;
665 }
666 }
667
668 Ok(())
669}
670
671fn run_update_all_logic(
673 yes: bool,
674 dry_run: bool,
675 explain: bool,
676 plan_json: bool,
677 verbose: bool,
678 interactive: bool
679) -> Result<()> {
680 #[derive(Clone)]
681 struct UpdateCandidate {
682 source: String,
683 new_pkg: types::Package,
684 new_version: String,
685 old_manifest: types::InstallManifest,
686 old_advisories: usize,
687 new_advisories: usize,
688 download_size: u64,
689 new_installed_size: u64
690 }
691
692 let installed_packages = local::get_installed_packages()?;
693 let mut pinned_sources = Vec::new();
694 let mut skipped_sources = Vec::new();
695 let mut up_to_date_sources = Vec::new();
696 let mut candidates: Vec<UpdateCandidate> = Vec::new();
697
698 if !plan_json {
702 println!("{} Checking for upgrades...", "::".bold().blue());
703 }
704 let pb = ProgressBar::new(installed_packages.len() as u64);
705 if plan_json {
706 pb.set_draw_target(indicatif::ProgressDrawTarget::hidden());
707 }
708 pb.set_style(
709 ProgressStyle::default_bar()
710 .template(
711 "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] \
712 {pos}/{len} ({msg})"
713 )?
714 .progress_chars("#>-")
715 );
716 pb.set_message("Checking packages...");
717
718 for manifest in installed_packages {
719 let source = if let Some(sub) = &manifest.sub_package {
720 format!(
721 "#{}@{}/{}:{}",
722 manifest.registry_handle, manifest.repo, manifest.name, sub
723 )
724 } else {
725 format!(
726 "#{}@{}/{}",
727 manifest.registry_handle, manifest.repo, manifest.name
728 )
729 };
730
731 if pin::is_pinned(&source).unwrap_or(false)
732 || pin::is_pinned(&manifest.name).unwrap_or(false)
733 {
734 pinned_sources.push(source);
735 pb.inc(1);
736 continue;
737 }
738
739 let (new_pkg, new_version, _, _, _registry_handle, _, _) =
740 match resolve::resolve_package_and_version(
741 &source,
742 Some(manifest.scope),
743 true,
744 false
745 ) {
746 Ok(result) => result,
747 Err(e) => {
748 skipped_sources.push(format!("{source} ({e})"));
749 pb.inc(1);
750 continue;
751 }
752 };
753
754 if manifest.version == new_version
755 && manifest.revision == new_pkg.revision
756 {
757 up_to_date_sources.push(source);
758 pb.inc(1);
759 continue;
760 }
761
762 let (old_adv, new_adv) = advisory_counts(
763 &manifest.registry_handle,
764 &manifest.name,
765 manifest.sub_package.as_deref(),
766 &manifest.version,
767 &new_version
768 )
769 .unwrap_or((0, 0));
770
771 let (download_size, new_installed_size) =
772 install::util::get_package_sizes(
773 &new_pkg,
774 &manifest.registry_handle,
775 &new_version
776 );
777
778 candidates.push(UpdateCandidate {
779 source,
780 new_pkg,
781 new_version,
782 old_manifest: manifest,
783 old_advisories: old_adv,
784 new_advisories: new_adv,
785 download_size,
786 new_installed_size
787 });
788 pb.inc(1);
789 }
790 pb.finish_and_clear();
791
792 if candidates.is_empty() {
793 if !plan_json {
794 println!("\nAll packages are up to date.");
795 }
796 return Ok(());
797 }
798
799 if interactive && !dry_run {
800 let items: Vec<String> = candidates
801 .iter()
802 .map(|c| {
803 format!(
804 "{} {} -> {}",
805 c.source, c.old_manifest.version, c.new_version
806 )
807 })
808 .collect();
809 let selected = MultiSelect::with_theme(&ColorfulTheme::default())
810 .with_prompt("Select packages to update")
811 .items(&items)
812 .interact()
813 .map_err(|e| anyhow!("Interactive selection failed: {e}"))?;
814
815 if selected.is_empty() {
816 if !plan_json {
817 println!("No packages selected.");
818 }
819 return Ok(());
820 }
821
822 let selected_set: HashSet<usize> = selected.into_iter().collect();
823 candidates = candidates
824 .into_iter()
825 .enumerate()
826 .filter(|(idx, _)| selected_set.contains(idx))
827 .map(|(_, c)| c)
828 .collect();
829 }
830
831 if !plan_json {
832 println!("{} Resolving dependencies...", "::".bold().blue());
833 println!("{} Looking for conflicts...", "::".bold().blue());
834 println!("{} Checking available disk space...", "::".bold().blue());
835
836 if !pinned_sources.is_empty() {
837 println!("\n{} Pinned (skipped)", "::".bold().blue());
838 for s in &pinned_sources {
839 println!(" - {}", s.yellow());
840 }
841 }
842
843 println!("\n{} Packages ({})", "::".bold().blue(), candidates.len());
844 }
845 let config = config::read_config().unwrap_or_default();
846
847 for candidate in &candidates {
848 let delta =
849 candidate.new_advisories as i64 - candidate.old_advisories as i64;
850 let advisory_suffix = match delta.cmp(&0) {
851 std::cmp::Ordering::Greater => {
852 format!(" (advisories +{delta})").red().to_string()
853 }
854 std::cmp::Ordering::Less => {
855 format!(" (advisories {delta})").green().to_string()
856 }
857 std::cmp::Ordering::Equal => String::new()
858 };
859
860 let display_name = ux::format_display_name(
861 &candidate.old_manifest.registry_handle,
862 &candidate.old_manifest.repo,
863 &candidate.old_manifest.name,
864 candidate.old_manifest.sub_package.as_deref(),
865 &config
866 );
867 let old_display = if candidate.old_manifest.revision == "1" {
868 candidate.old_manifest.version.clone()
869 } else {
870 format!(
871 "{}-{}",
872 candidate.old_manifest.version, candidate.old_manifest.revision
873 )
874 };
875 let new_display = if candidate.new_pkg.revision == "1" {
876 candidate.new_version.clone()
877 } else {
878 format!("{}-{}", candidate.new_version, candidate.new_pkg.revision)
879 };
880
881 if !plan_json {
882 println!(
883 " {} -> {}{}",
884 format!("{display_name}@{old_display}").cyan(),
885 format!("{display_name}@{new_display}").cyan(),
886 advisory_suffix
887 );
888 }
889 }
890
891 let total_download_size: u64 =
892 candidates.iter().map(|c| c.download_size).sum();
893 let total_installed_size_diff: i64 = candidates
894 .iter()
895 .map(|c| {
896 c.new_installed_size as i64
897 - c.old_manifest.installed_size.unwrap_or(0) as i64
898 })
899 .sum();
900
901 let down_str = zoi_core::utils::format_bytes(total_download_size);
902 let net_str = zoi_core::utils::format_size_diff(total_installed_size_diff);
903
904 if !plan_json {
905 println!("\nTotal Download Size: {down_str:>10}");
906 println!("Net Upgrade Size: {net_str:>10}");
907 }
908
909 if verbose && !plan_json {
910 let preflight = ux::PreflightSummary::new("Update preflight")
911 .row("Candidates", candidates.len().to_string())
912 .row("Download size", &down_str)
913 .row("Net size", &net_str);
914 ux::print_preflight(&preflight);
915 }
916
917 if explain && !plan_json {
918 let mut report = ux::ExplainReport::new("Update explanation");
919 for candidate in &candidates {
920 report = report.item(
921 candidate.source.clone(),
922 format!(
923 "selected because {} -> {}",
924 candidate.old_manifest.version, candidate.new_version
925 ),
926 Vec::new()
927 );
928 }
929 ux::print_explain(&report);
930 }
931
932 if plan_json {
933 let packages: Vec<_> = candidates
934 .iter()
935 .map(|c| {
936 json!({
937 "source": c.source,
938 "name": c.new_pkg.name,
939 "sub_package": c.old_manifest.sub_package,
940 "from_version": c.old_manifest.version,
941 "to_version": c.new_version,
942 "download_bytes": c.download_size,
943 "net_size_bytes": c.new_installed_size as i64 - c.old_manifest.installed_size.unwrap_or(0) as i64,
944 "advisories_old": c.old_advisories,
945 "advisories_new": c.new_advisories,
946 })
947 })
948 .collect();
949
950 let plan = json!({
951 "dry_run": dry_run,
952 "interactive": interactive,
953 "totals": {
954 "candidates": candidates.len(),
955 "pinned_skipped": pinned_sources.len(),
956 "other_skipped": skipped_sources.len(),
957 "up_to_date": up_to_date_sources.len(),
958 "download_bytes": total_download_size,
959 "net_size_bytes": total_installed_size_diff,
960 },
961 "pinned": pinned_sources,
962 "skipped": skipped_sources,
963 "packages": packages,
964 });
965 ux::emit_plan_json_v1("update", plan)?;
966 return Ok(());
967 }
968
969 if dry_run {
970 println!(
971 "\n{} Dry-run: upgrade plan above would be executed.",
972 "::".bold().yellow()
973 );
974 return Ok(());
975 }
976
977 if !crate::utils::ask_for_confirmation(
978 "Do you want to upgrade these packages?",
979 yes
980 ) {
981 return Ok(());
982 }
983
984 let transaction = Mutex::new(transaction::begin()?);
986 let transaction_id = transaction.lock().expect("mutex poisoned").id.clone();
987 let failed_updates = Mutex::new(Vec::new());
988 let successful_upgrades = Mutex::new(Vec::new());
989
990 let m = MultiProgress::new();
991
992 candidates
993 .par_iter()
994 .try_for_each(|candidate| -> Result<()> {
995 println!(
996 "\n{} Upgrading {} to {}...",
997 "::".bold().blue(),
998 candidate.source.cyan(),
999 candidate.new_version.green()
1000 );
1001
1002 if let Some(hooks) = &candidate.new_pkg.hooks {
1003 hooks::run_hooks(
1004 hooks,
1005 hooks::HookType::PreUpgrade,
1006 candidate.old_manifest.scope
1007 )?;
1008 }
1009
1010 let (graph, _) = match install::resolver::resolve_dependency_graph(
1011 std::slice::from_ref(&candidate.source),
1012 Some(candidate.old_manifest.scope),
1013 true,
1014 yes,
1015 false,
1016 None,
1017 false,
1018 None
1019 ) {
1020 Ok(res) => res,
1021 Err(e) => {
1022 eprintln!(
1023 "{}: Failed to resolve dependencies for '{}': {}",
1024 "Error".red().bold(),
1025 candidate.source,
1026 e
1027 );
1028 failed_updates
1029 .lock()
1030 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1031 .push(candidate.source.clone());
1032 return Ok(());
1033 }
1034 };
1035
1036 if let Err(e) =
1037 zoi_install::preflight::check_policy_compliance(&graph)
1038 {
1039 eprintln!(
1040 "{}: Policy check failed for '{}': {}",
1041 "Error".red().bold(),
1042 candidate.source,
1043 e
1044 );
1045 failed_updates
1046 .lock()
1047 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1048 .push(candidate.source.clone());
1049 return Ok(());
1050 }
1051
1052 if let Err(e) =
1053 zoi_install::preflight::check_scope_compliance(&graph)
1054 {
1055 eprintln!(
1056 "{}: Scope check failed for '{}': {}",
1057 "Error".red().bold(),
1058 candidate.source,
1059 e
1060 );
1061 failed_updates
1062 .lock()
1063 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1064 .push(candidate.source.clone());
1065 return Ok(());
1066 }
1067
1068 if let Err(e) =
1069 zoi_install::preflight::check_zoios_compliance(&graph)
1070 {
1071 eprintln!(
1072 "{}: ZoiOS check failed for '{}': {}",
1073 "Error".red().bold(),
1074 candidate.source,
1075 e
1076 );
1077 failed_updates
1078 .lock()
1079 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1080 .push(candidate.source.clone());
1081 return Ok(());
1082 }
1083
1084 if let Err(e) =
1085 zoi_install::preflight::check_for_vulnerabilities(&graph, yes)
1086 {
1087 eprintln!(
1088 "{}: Security check failed for '{}': {}",
1089 "Error".red().bold(),
1090 candidate.source,
1091 e
1092 );
1093 failed_updates
1094 .lock()
1095 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1096 .push(candidate.source.clone());
1097 return Ok(());
1098 }
1099
1100 let install_plan = match install::plan::create_install_plan(
1101 &graph.nodes,
1102 None,
1103 false
1104 ) {
1105 Ok(plan) => plan,
1106 Err(e) => {
1107 eprintln!(
1108 "{}: Failed to create install plan for '{}': {}",
1109 "Error".red().bold(),
1110 candidate.source,
1111 e
1112 );
1113 failed_updates
1114 .lock()
1115 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1116 .push(candidate.source.clone());
1117 return Ok(());
1118 }
1119 };
1120
1121 let stages = match graph.toposort() {
1122 Ok(s) => s,
1123 Err(e) => {
1124 eprintln!(
1125 "{}: Failed to sort dependency graph for '{}': {}",
1126 "Error".red().bold(),
1127 candidate.source,
1128 e
1129 );
1130 failed_updates
1131 .lock()
1132 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1133 .push(candidate.source.clone());
1134 return Ok(());
1135 }
1136 };
1137
1138 if candidate.old_manifest.scope == types::Scope::System {
1144 retain_kernels_before_upgrade();
1145 }
1146
1147 let mut new_manifest_option: Option<types::InstallManifest> = None;
1148 for stage in stages {
1149 for pkg_id in stage {
1150 let node = graph.nodes.get(&pkg_id).ok_or_else(|| {
1151 anyhow!(
1152 "Package node '{pkg_id}' missing from graph \
1153 during update"
1154 )
1155 })?;
1156 if let Some(action) = install_plan.get(&pkg_id) {
1157 match install::installer::install_node(
1158 node,
1159 action,
1160 Some(&m),
1161 None,
1162 yes,
1163 true,
1164 true,
1165 false,
1166 false
1167 ) {
1168 Ok(m) => {
1169 if m.name == candidate.new_pkg.name
1170 && m.sub_package
1171 == candidate.old_manifest.sub_package
1172 {
1173 new_manifest_option = Some(m);
1174 }
1175 }
1176 Err(e) => {
1177 eprintln!(
1178 "{}: Failed to upgrade '{}': {}",
1179 "Error".red().bold(),
1180 candidate.source,
1181 e
1182 );
1183 failed_updates
1184 .lock()
1185 .map_err(|e| {
1186 anyhow!("mutex poisoned: {e}")
1187 })?
1188 .push(candidate.source.clone());
1189 return Ok(());
1190 }
1191 }
1192 }
1193 }
1194 }
1195
1196 if let Some(new_manifest) = new_manifest_option {
1197 if let Err(e) = transaction::record_operation(
1198 &mut *transaction
1199 .lock()
1200 .map_err(|e| anyhow!("mutex poisoned: {e}"))?,
1201 types::TransactionOperation::Upgrade {
1202 old_manifest: Box::new(candidate.old_manifest.clone()),
1203 new_manifest: Box::new(new_manifest.clone())
1204 }
1205 ) {
1206 eprintln!(
1207 "Error: Failed to record transaction for {}: {}",
1208 candidate.source, e
1209 );
1210 failed_updates
1211 .lock()
1212 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1213 .push(candidate.source.clone());
1214 } else {
1215 successful_upgrades
1216 .lock()
1217 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1218 .push((
1219 candidate.old_manifest.clone(),
1220 new_manifest.clone(),
1221 candidate.new_pkg.clone()
1222 ));
1223 }
1224 } else {
1225 eprintln!(
1226 "Failed to get new manifest for {}",
1227 candidate.source
1228 );
1229 failed_updates
1230 .lock()
1231 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1232 .push(candidate.source.clone());
1233 }
1234 Ok(())
1235 })?;
1236
1237 let failed = failed_updates
1238 .into_inner()
1239 .map_err(|e| anyhow!("mutex poisoned: {e}"))?;
1240 if !failed.is_empty() {
1241 eprintln!(
1242 "\nError: Some packages failed to upgrade. Rolling back all \
1243 changes..."
1244 );
1245 for pkg in &failed {
1246 eprintln!(" - {pkg}");
1247 }
1248 transaction::rollback(&transaction_id)?;
1249 ux::print_transaction_summary(&ux::TransactionSummary {
1250 command: "update".to_string(),
1251 success: 0,
1252 failed: failed.len(),
1253 skipped: pinned_sources.len()
1254 + skipped_sources.len()
1255 + up_to_date_sources.len()
1256 });
1257 return Err(anyhow!("Update failed for some packages."));
1258 }
1259
1260 if let Ok(modified_files) = transaction::get_modified_files(&transaction_id)
1261 {
1262 let modified_packages =
1263 transaction::get_modified_packages(&transaction_id)
1264 .unwrap_or_default();
1265 let upgrades_lock = successful_upgrades
1266 .lock()
1267 .map_err(|e| anyhow!("mutex poisoned: {e}"))?;
1268 let first_scope = upgrades_lock
1269 .first()
1270 .map_or(types::Scope::User, |(old, _, _)| old.scope);
1271 let _ = crate::pkg::hooks::global::run_global_hooks(
1272 crate::pkg::hooks::global::HookWhen::PostTransaction,
1273 &modified_files,
1274 &modified_packages,
1275 "upgrade",
1276 first_scope
1277 );
1278
1279 if first_scope == types::Scope::System {
1283 let _ = zoi_system::kernel::sync_after_transaction(&modified_files);
1284 }
1285
1286 if let Ok(changed) =
1287 zoi_core::alternatives::reconcile(&modified_packages)
1288 {
1289 crate::cmd::alt::announce_changes(&changed);
1290 }
1291 }
1292 transaction::commit(&transaction_id)?;
1293
1294 if !plan_json {
1295 println!("\n{}", "Success:".green());
1296 }
1297 let successful_upgrades = successful_upgrades
1298 .into_inner()
1299 .map_err(|e| anyhow!("mutex poisoned: {e}"))?;
1300 for (old_manifest, new_manifest, new_pkg) in &successful_upgrades {
1301 if let Some(backup_files) = &old_manifest.backup {
1302 if !plan_json {
1303 println!(
1304 "Restoring configuration for {}...",
1305 old_manifest.name.cyan()
1306 );
1307 }
1308 let old_version_dir = local::get_package_version_dir(
1309 old_manifest.scope,
1310 &old_manifest.registry_handle,
1311 &old_manifest.repo,
1312 &old_manifest.name,
1313 &old_manifest.version
1314 )?;
1315 let new_version_dir = local::get_package_version_dir(
1316 new_manifest.scope,
1317 &new_manifest.registry_handle,
1318 &new_manifest.repo,
1319 &new_manifest.name,
1320 &new_manifest.version
1321 )?;
1322 handle_backup_files(
1323 &old_version_dir,
1324 &new_version_dir,
1325 backup_files,
1326 old_manifest.scope
1327 )?;
1328 }
1329
1330 if let Err(e) = cleanup_old_versions(
1331 &new_manifest.name,
1332 new_manifest.scope,
1333 &new_manifest.repo,
1334 &new_manifest.registry_handle
1335 ) {
1336 eprintln!(
1337 "Failed to clean up old versions for {}: {}",
1338 new_manifest.name, e
1339 );
1340 }
1341
1342 if let Ok(conn) = db::open_connection("local") {
1343 let _ = db::update_package(
1344 &conn,
1345 new_pkg,
1346 &new_manifest.registry_handle,
1347 Some(new_manifest.scope),
1348 new_manifest.sub_package.as_deref(),
1349 Some(&old_manifest.reason)
1350 );
1351 }
1352
1353 if let Some(hooks) = &new_pkg.hooks
1354 && let Err(e) = hooks::run_hooks(
1355 hooks,
1356 hooks::HookType::PostUpgrade,
1357 new_manifest.scope
1358 )
1359 {
1360 eprintln!(
1361 "{}: Post-upgrade hook failed for '{}': {}",
1362 "Error".red().bold(),
1363 new_manifest.name,
1364 e
1365 );
1366 }
1367 }
1368
1369 if !plan_json {
1370 ux::print_transaction_summary(&ux::TransactionSummary {
1371 command: "update".to_string(),
1372 success: successful_upgrades.len(),
1373 failed: 0,
1374 skipped: pinned_sources.len()
1375 + skipped_sources.len()
1376 + up_to_date_sources.len()
1377 });
1378 }
1379 Ok(())
1380}
1381
1382fn advisory_counts(
1385 registry_handle: &str,
1386 package: &str,
1387 sub_package: Option<&str>,
1388 old_version: &str,
1389 new_version: &str
1390) -> Result<(usize, usize)> {
1391 let advisories =
1392 db::get_advisories_for_package(registry_handle, package, sub_package)?;
1393 let old_ver = Version::parse(old_version).map_err(|e| {
1394 anyhow!("failed to parse old version '{old_version}': {e}")
1395 })?;
1396 let new_ver = Version::parse(new_version).map_err(|e| {
1397 anyhow!("failed to parse new version '{new_version}': {e}")
1398 })?;
1399
1400 let mut old_count = 0usize;
1401 let mut new_count = 0usize;
1402 for adv in advisories {
1403 if let Ok(req) = semver::VersionReq::parse(&adv.affected_range) {
1404 if req.matches(&old_ver) {
1405 old_count += 1;
1406 }
1407 if req.matches(&new_ver) {
1408 new_count += 1;
1409 }
1410 }
1411 }
1412 Ok((old_count, new_count))
1413}
1414
1415fn retain_kernels_before_upgrade() {
1429 let limit =
1430 zoi_core::config::read_config().map_or(3, |c| c.kernel_retention);
1431
1432 if limit == 0 {
1433 return;
1434 }
1435
1436 match zoi_system::kernel::retain_current_kernels(limit) {
1437 Ok(retained) if !retained.is_empty() => {
1438 println!(
1439 "{} Retaining kernel(s) as boot fallback: {}",
1440 "::".bold().blue(),
1441 retained.join(", ")
1442 );
1443 }
1444 Ok(_) => {}
1445 Err(e) => eprintln!(
1446 "{}: Failed to retain the current kernel: {e}. The previous \
1447 kernel may not be bootable after this update.",
1448 "Warning".yellow().bold()
1449 )
1450 }
1451}
1452
1453fn cleanup_old_versions(
1458 package_name: &str,
1459 scope: types::Scope,
1460 repo: &str,
1461 registry_handle: &str
1462) -> Result<()> {
1463 let config = config::read_config()?;
1464 let rollback_enabled = config.rollback_enabled;
1465 let package_dir =
1466 local::get_package_dir(scope, registry_handle, repo, package_name)?;
1467
1468 let mut versions = Vec::new();
1469 if let Ok(entries) = fs::read_dir(&package_dir) {
1470 for entry in entries.flatten() {
1471 let path = entry.path();
1472 if path.is_dir()
1473 && let Some(version_str) =
1474 path.file_name().and_then(|s| s.to_str())
1475 && version_str != "latest"
1476 && let Ok(version) = Version::parse(version_str)
1477 {
1478 versions.push(version);
1479 }
1480 }
1481 }
1482
1483 if versions.is_empty() {
1484 return Ok(());
1485 }
1486 versions.sort();
1487
1488 let versions_to_keep = if rollback_enabled { 3 } else { 1 };
1489 if versions.len() > versions_to_keep {
1490 let num_to_delete = versions.len() - versions_to_keep;
1491 println!("Cleaning up old versions...");
1492 let to_delete = versions
1493 .get(..num_to_delete)
1494 .ok_or_else(|| anyhow!("Version slice out of bounds"))?;
1495 for version in to_delete {
1496 let version_dir_to_delete = package_dir.join(version.to_string());
1497 println!(" - Removing {}", version_dir_to_delete.display());
1498 let _ = fs::remove_dir_all(version_dir_to_delete);
1499 }
1500 }
1501 Ok(())
1502}