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 if plan_json {
509 m.set_draw_target(indicatif::ProgressDrawTarget::hidden());
510 }
511
512 if !non_zoi_deps.is_empty() {
514 let processed_deps = Mutex::new(HashSet::new());
515 let mut installed_deps_ext = Vec::new();
516 for dep_str in non_zoi_deps {
517 let dep =
518 crate::pkg::dependencies::parse_dependency_string(dep_str)?;
519 crate::pkg::install::dep_install::install_dependency(
520 &dep,
521 "update",
522 scope,
523 yes,
524 false,
525 &processed_deps,
526 &mut installed_deps_ext,
527 Some(&m)
528 )?;
529 }
530 }
531
532 for stage in stages {
533 for pkg_id in stage {
534 let node = graph.nodes.get(&pkg_id).ok_or_else(|| {
535 anyhow!("Package node '{pkg_id}' missing from graph")
536 })?;
537 if let Some(action) = install_plan.get(&pkg_id) {
538 match install::installer::install_node(
539 node,
540 action,
541 Some(&m),
542 None,
543 yes,
544 true,
545 true,
546 verbose,
547 false
548 ) {
549 Ok(m) => {
550 if m.name == target_pkg_name {
551 new_manifest_option = Some(m);
552 }
553 }
554 Err(e) => {
555 eprintln!("\nError: Update failed. Rolling back...");
556 transaction::rollback(&transaction.id)?;
557 return Err(anyhow!("Update failed: {e}"));
558 }
559 }
560 }
561 }
562 }
563
564 if let Some(new_manifest) = new_manifest_option {
565 transaction::record_operation(
567 &mut transaction,
568 types::TransactionOperation::Upgrade {
569 old_manifest: Box::new(old_manifest_ref.clone()),
570 new_manifest: Box::new(new_manifest.clone())
571 }
572 )?;
573
574 if let Ok(modified_files) =
575 transaction::get_modified_files(&transaction.id)
576 {
577 let modified_packages =
578 transaction::get_modified_packages(&transaction.id)
579 .unwrap_or_default();
580 let _ = crate::pkg::hooks::global::run_global_hooks(
581 crate::pkg::hooks::global::HookWhen::PostTransaction,
582 &modified_files,
583 &modified_packages,
584 "upgrade",
585 scope
586 );
587 }
588
589 transaction::commit(&transaction.id)?;
590
591 if let Some(backup_files) = &old_manifest_ref.backup {
592 println!("Restoring configuration files...");
593 let old_version_dir = local::get_package_version_dir(
594 old_manifest_ref.scope,
595 &old_manifest_ref.registry_handle,
596 &old_manifest_ref.repo,
597 &old_manifest_ref.name,
598 &old_manifest_ref.version
599 )?;
600 let new_version_dir = local::get_package_version_dir(
601 new_manifest.scope,
602 &new_manifest.registry_handle,
603 &new_manifest.repo,
604 &new_manifest.name,
605 &new_manifest.version
606 )?;
607
608 crate::pkg::merge::handle_backup_files(
609 &old_version_dir,
610 &new_version_dir,
611 backup_files,
612 old_manifest_ref.scope
613 )?;
614 }
615
616 cleanup_old_versions(
618 &new_manifest.name,
619 scope,
620 &new_manifest.repo,
621 &new_manifest.registry_handle
622 )?;
623
624 if let Ok(conn) = db::open_connection("local") {
625 let _ = db::update_package(
626 &conn,
627 new_pkg_ref,
628 &new_manifest.registry_handle,
629 Some(new_manifest.scope),
630 new_manifest.sub_package.as_deref(),
631 Some(&types::InstallReason::Direct)
632 );
633 }
634
635 if let Some(hooks) = &new_pkg_ref.hooks {
636 crate::pkg::hooks::run_hooks(
637 hooks,
638 crate::pkg::hooks::HookType::PostUpgrade,
639 new_manifest.scope
640 )?;
641 }
642 }
643
644 Ok(())
645}
646
647fn run_update_all_logic(
649 yes: bool,
650 dry_run: bool,
651 explain: bool,
652 plan_json: bool,
653 verbose: bool,
654 interactive: bool
655) -> Result<()> {
656 #[derive(Clone)]
657 struct UpdateCandidate {
658 source: String,
659 new_pkg: types::Package,
660 new_version: String,
661 old_manifest: types::InstallManifest,
662 old_advisories: usize,
663 new_advisories: usize,
664 download_size: u64,
665 new_installed_size: u64
666 }
667
668 let installed_packages = local::get_installed_packages()?;
669 let mut pinned_sources = Vec::new();
670 let mut skipped_sources = Vec::new();
671 let mut up_to_date_sources = Vec::new();
672 let mut candidates: Vec<UpdateCandidate> = Vec::new();
673
674 if !plan_json {
678 println!("{} Checking for upgrades...", "::".bold().blue());
679 }
680 let pb = ProgressBar::new(installed_packages.len() as u64);
681 if plan_json {
682 pb.set_draw_target(indicatif::ProgressDrawTarget::hidden());
683 }
684 pb.set_style(
685 ProgressStyle::default_bar()
686 .template(
687 "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] \
688 {pos}/{len} ({msg})"
689 )?
690 .progress_chars("#>-")
691 );
692 pb.set_message("Checking packages...");
693
694 for manifest in installed_packages {
695 let source = if let Some(sub) = &manifest.sub_package {
696 format!(
697 "#{}@{}/{}:{}",
698 manifest.registry_handle, manifest.repo, manifest.name, sub
699 )
700 } else {
701 format!(
702 "#{}@{}/{}",
703 manifest.registry_handle, manifest.repo, manifest.name
704 )
705 };
706
707 if pin::is_pinned(&source).unwrap_or(false)
708 || pin::is_pinned(&manifest.name).unwrap_or(false)
709 {
710 pinned_sources.push(source);
711 pb.inc(1);
712 continue;
713 }
714
715 let (new_pkg, new_version, _, _, _registry_handle, _, _) =
716 match resolve::resolve_package_and_version(
717 &source,
718 Some(manifest.scope),
719 true,
720 false
721 ) {
722 Ok(result) => result,
723 Err(e) => {
724 skipped_sources.push(format!("{source} ({e})"));
725 pb.inc(1);
726 continue;
727 }
728 };
729
730 if manifest.version == new_version
731 && manifest.revision == new_pkg.revision
732 {
733 up_to_date_sources.push(source);
734 pb.inc(1);
735 continue;
736 }
737
738 let (old_adv, new_adv) = advisory_counts(
739 &manifest.registry_handle,
740 &manifest.name,
741 manifest.sub_package.as_deref(),
742 &manifest.version,
743 &new_version
744 )
745 .unwrap_or((0, 0));
746
747 let (download_size, new_installed_size) =
748 install::util::get_package_sizes(
749 &new_pkg,
750 &manifest.registry_handle,
751 &new_version
752 );
753
754 candidates.push(UpdateCandidate {
755 source,
756 new_pkg,
757 new_version,
758 old_manifest: manifest,
759 old_advisories: old_adv,
760 new_advisories: new_adv,
761 download_size,
762 new_installed_size
763 });
764 pb.inc(1);
765 }
766 pb.finish_and_clear();
767
768 if candidates.is_empty() {
769 if !plan_json {
770 println!("\nAll packages are up to date.");
771 }
772 return Ok(());
773 }
774
775 if interactive && !dry_run {
776 let items: Vec<String> = candidates
777 .iter()
778 .map(|c| {
779 format!(
780 "{} {} -> {}",
781 c.source, c.old_manifest.version, c.new_version
782 )
783 })
784 .collect();
785 let selected = MultiSelect::with_theme(&ColorfulTheme::default())
786 .with_prompt("Select packages to update")
787 .items(&items)
788 .interact()
789 .map_err(|e| anyhow!("Interactive selection failed: {e}"))?;
790
791 if selected.is_empty() {
792 if !plan_json {
793 println!("No packages selected.");
794 }
795 return Ok(());
796 }
797
798 let selected_set: HashSet<usize> = selected.into_iter().collect();
799 candidates = candidates
800 .into_iter()
801 .enumerate()
802 .filter(|(idx, _)| selected_set.contains(idx))
803 .map(|(_, c)| c)
804 .collect();
805 }
806
807 if !plan_json {
808 println!("{} Resolving dependencies...", "::".bold().blue());
809 println!("{} Looking for conflicts...", "::".bold().blue());
810 println!("{} Checking available disk space...", "::".bold().blue());
811
812 if !pinned_sources.is_empty() {
813 println!("\n{} Pinned (skipped)", "::".bold().blue());
814 for s in &pinned_sources {
815 println!(" - {}", s.yellow());
816 }
817 }
818
819 println!("\n{} Packages ({})", "::".bold().blue(), candidates.len());
820 }
821 let config = config::read_config().unwrap_or_default();
822
823 for candidate in &candidates {
824 let delta =
825 candidate.new_advisories as i64 - candidate.old_advisories as i64;
826 let advisory_suffix = match delta.cmp(&0) {
827 std::cmp::Ordering::Greater => {
828 format!(" (advisories +{delta})").red().to_string()
829 }
830 std::cmp::Ordering::Less => {
831 format!(" (advisories {delta})").green().to_string()
832 }
833 std::cmp::Ordering::Equal => String::new()
834 };
835
836 let display_name = ux::format_display_name(
837 &candidate.old_manifest.registry_handle,
838 &candidate.old_manifest.repo,
839 &candidate.old_manifest.name,
840 candidate.old_manifest.sub_package.as_deref(),
841 &config
842 );
843 let old_display = if candidate.old_manifest.revision == "1" {
844 candidate.old_manifest.version.clone()
845 } else {
846 format!(
847 "{}-{}",
848 candidate.old_manifest.version, candidate.old_manifest.revision
849 )
850 };
851 let new_display = if candidate.new_pkg.revision == "1" {
852 candidate.new_version.clone()
853 } else {
854 format!("{}-{}", candidate.new_version, candidate.new_pkg.revision)
855 };
856
857 if !plan_json {
858 println!(
859 " {} -> {}{}",
860 format!("{display_name}@{old_display}").cyan(),
861 format!("{display_name}@{new_display}").cyan(),
862 advisory_suffix
863 );
864 }
865 }
866
867 let total_download_size: u64 =
868 candidates.iter().map(|c| c.download_size).sum();
869 let total_installed_size_diff: i64 = candidates
870 .iter()
871 .map(|c| {
872 c.new_installed_size as i64
873 - c.old_manifest.installed_size.unwrap_or(0) as i64
874 })
875 .sum();
876
877 let down_str = zoi_core::utils::format_bytes(total_download_size);
878 let net_str = zoi_core::utils::format_size_diff(total_installed_size_diff);
879
880 if !plan_json {
881 println!("\nTotal Download Size: {down_str:>10}");
882 println!("Net Upgrade Size: {net_str:>10}");
883 }
884
885 if verbose && !plan_json {
886 let preflight = ux::PreflightSummary::new("Update preflight")
887 .row("Candidates", candidates.len().to_string())
888 .row("Download size", &down_str)
889 .row("Net size", &net_str);
890 ux::print_preflight(&preflight);
891 }
892
893 if explain && !plan_json {
894 let mut report = ux::ExplainReport::new("Update explanation");
895 for candidate in &candidates {
896 report = report.item(
897 candidate.source.clone(),
898 format!(
899 "selected because {} -> {}",
900 candidate.old_manifest.version, candidate.new_version
901 ),
902 Vec::new()
903 );
904 }
905 ux::print_explain(&report);
906 }
907
908 if plan_json {
909 let packages: Vec<_> = candidates
910 .iter()
911 .map(|c| {
912 json!({
913 "source": c.source,
914 "name": c.new_pkg.name,
915 "sub_package": c.old_manifest.sub_package,
916 "from_version": c.old_manifest.version,
917 "to_version": c.new_version,
918 "download_bytes": c.download_size,
919 "net_size_bytes": c.new_installed_size as i64 - c.old_manifest.installed_size.unwrap_or(0) as i64,
920 "advisories_old": c.old_advisories,
921 "advisories_new": c.new_advisories,
922 })
923 })
924 .collect();
925
926 let plan = json!({
927 "dry_run": dry_run,
928 "interactive": interactive,
929 "totals": {
930 "candidates": candidates.len(),
931 "pinned_skipped": pinned_sources.len(),
932 "other_skipped": skipped_sources.len(),
933 "up_to_date": up_to_date_sources.len(),
934 "download_bytes": total_download_size,
935 "net_size_bytes": total_installed_size_diff,
936 },
937 "pinned": pinned_sources,
938 "skipped": skipped_sources,
939 "packages": packages,
940 });
941 ux::emit_plan_json_v1("update", plan)?;
942 return Ok(());
943 }
944
945 if dry_run {
946 println!(
947 "\n{} Dry-run: upgrade plan above would be executed.",
948 "::".bold().yellow()
949 );
950 return Ok(());
951 }
952
953 if !crate::utils::ask_for_confirmation(
954 "Do you want to upgrade these packages?",
955 yes
956 ) {
957 return Ok(());
958 }
959
960 let transaction = Mutex::new(transaction::begin()?);
962 let transaction_id = transaction.lock().expect("mutex poisoned").id.clone();
963 let failed_updates = Mutex::new(Vec::new());
964 let successful_upgrades = Mutex::new(Vec::new());
965
966 let m = MultiProgress::new();
967
968 candidates
969 .par_iter()
970 .try_for_each(|candidate| -> Result<()> {
971 println!(
972 "\n{} Upgrading {} to {}...",
973 "::".bold().blue(),
974 candidate.source.cyan(),
975 candidate.new_version.green()
976 );
977
978 if let Some(hooks) = &candidate.new_pkg.hooks {
979 hooks::run_hooks(
980 hooks,
981 hooks::HookType::PreUpgrade,
982 candidate.old_manifest.scope
983 )?;
984 }
985
986 let (graph, _) = match install::resolver::resolve_dependency_graph(
987 std::slice::from_ref(&candidate.source),
988 Some(candidate.old_manifest.scope),
989 true,
990 yes,
991 false,
992 None,
993 false,
994 None
995 ) {
996 Ok(res) => res,
997 Err(e) => {
998 eprintln!(
999 "{}: Failed to resolve dependencies for '{}': {}",
1000 "Error".red().bold(),
1001 candidate.source,
1002 e
1003 );
1004 failed_updates
1005 .lock()
1006 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1007 .push(candidate.source.clone());
1008 return Ok(());
1009 }
1010 };
1011
1012 if let Err(e) =
1013 zoi_install::preflight::check_policy_compliance(&graph)
1014 {
1015 eprintln!(
1016 "{}: Policy check failed for '{}': {}",
1017 "Error".red().bold(),
1018 candidate.source,
1019 e
1020 );
1021 failed_updates
1022 .lock()
1023 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1024 .push(candidate.source.clone());
1025 return Ok(());
1026 }
1027
1028 if let Err(e) =
1029 zoi_install::preflight::check_scope_compliance(&graph)
1030 {
1031 eprintln!(
1032 "{}: Scope check failed for '{}': {}",
1033 "Error".red().bold(),
1034 candidate.source,
1035 e
1036 );
1037 failed_updates
1038 .lock()
1039 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1040 .push(candidate.source.clone());
1041 return Ok(());
1042 }
1043
1044 if let Err(e) =
1045 zoi_install::preflight::check_zoios_compliance(&graph)
1046 {
1047 eprintln!(
1048 "{}: ZoiOS check failed for '{}': {}",
1049 "Error".red().bold(),
1050 candidate.source,
1051 e
1052 );
1053 failed_updates
1054 .lock()
1055 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1056 .push(candidate.source.clone());
1057 return Ok(());
1058 }
1059
1060 if let Err(e) =
1061 zoi_install::preflight::check_for_vulnerabilities(&graph, yes)
1062 {
1063 eprintln!(
1064 "{}: Security check failed for '{}': {}",
1065 "Error".red().bold(),
1066 candidate.source,
1067 e
1068 );
1069 failed_updates
1070 .lock()
1071 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1072 .push(candidate.source.clone());
1073 return Ok(());
1074 }
1075
1076 let install_plan = match install::plan::create_install_plan(
1077 &graph.nodes,
1078 None,
1079 false
1080 ) {
1081 Ok(plan) => plan,
1082 Err(e) => {
1083 eprintln!(
1084 "{}: Failed to create install plan for '{}': {}",
1085 "Error".red().bold(),
1086 candidate.source,
1087 e
1088 );
1089 failed_updates
1090 .lock()
1091 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1092 .push(candidate.source.clone());
1093 return Ok(());
1094 }
1095 };
1096
1097 let stages = match graph.toposort() {
1098 Ok(s) => s,
1099 Err(e) => {
1100 eprintln!(
1101 "{}: Failed to sort dependency graph for '{}': {}",
1102 "Error".red().bold(),
1103 candidate.source,
1104 e
1105 );
1106 failed_updates
1107 .lock()
1108 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1109 .push(candidate.source.clone());
1110 return Ok(());
1111 }
1112 };
1113
1114 let mut new_manifest_option: Option<types::InstallManifest> = None;
1115 for stage in stages {
1116 for pkg_id in stage {
1117 let node = graph.nodes.get(&pkg_id).ok_or_else(|| {
1118 anyhow!(
1119 "Package node '{pkg_id}' missing from graph \
1120 during update"
1121 )
1122 })?;
1123 if let Some(action) = install_plan.get(&pkg_id) {
1124 match install::installer::install_node(
1125 node,
1126 action,
1127 Some(&m),
1128 None,
1129 yes,
1130 true,
1131 true,
1132 false,
1133 false
1134 ) {
1135 Ok(m) => {
1136 if m.name == candidate.new_pkg.name
1137 && m.sub_package
1138 == candidate.old_manifest.sub_package
1139 {
1140 new_manifest_option = Some(m);
1141 }
1142 }
1143 Err(e) => {
1144 eprintln!(
1145 "{}: Failed to upgrade '{}': {}",
1146 "Error".red().bold(),
1147 candidate.source,
1148 e
1149 );
1150 failed_updates
1151 .lock()
1152 .map_err(|e| {
1153 anyhow!("mutex poisoned: {e}")
1154 })?
1155 .push(candidate.source.clone());
1156 return Ok(());
1157 }
1158 }
1159 }
1160 }
1161 }
1162
1163 if let Some(new_manifest) = new_manifest_option {
1164 if let Err(e) = transaction::record_operation(
1165 &mut *transaction
1166 .lock()
1167 .map_err(|e| anyhow!("mutex poisoned: {e}"))?,
1168 types::TransactionOperation::Upgrade {
1169 old_manifest: Box::new(candidate.old_manifest.clone()),
1170 new_manifest: Box::new(new_manifest.clone())
1171 }
1172 ) {
1173 eprintln!(
1174 "Error: Failed to record transaction for {}: {}",
1175 candidate.source, e
1176 );
1177 failed_updates
1178 .lock()
1179 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1180 .push(candidate.source.clone());
1181 } else {
1182 successful_upgrades
1183 .lock()
1184 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1185 .push((
1186 candidate.old_manifest.clone(),
1187 new_manifest.clone(),
1188 candidate.new_pkg.clone()
1189 ));
1190 }
1191 } else {
1192 eprintln!(
1193 "Failed to get new manifest for {}",
1194 candidate.source
1195 );
1196 failed_updates
1197 .lock()
1198 .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1199 .push(candidate.source.clone());
1200 }
1201 Ok(())
1202 })?;
1203
1204 let failed = failed_updates
1205 .into_inner()
1206 .map_err(|e| anyhow!("mutex poisoned: {e}"))?;
1207 if !failed.is_empty() {
1208 eprintln!(
1209 "\nError: Some packages failed to upgrade. Rolling back all \
1210 changes..."
1211 );
1212 for pkg in &failed {
1213 eprintln!(" - {pkg}");
1214 }
1215 transaction::rollback(&transaction_id)?;
1216 ux::print_transaction_summary(&ux::TransactionSummary {
1217 command: "update".to_string(),
1218 success: 0,
1219 failed: failed.len(),
1220 skipped: pinned_sources.len()
1221 + skipped_sources.len()
1222 + up_to_date_sources.len()
1223 });
1224 return Err(anyhow!("Update failed for some packages."));
1225 }
1226
1227 if let Ok(modified_files) = transaction::get_modified_files(&transaction_id)
1228 {
1229 let modified_packages =
1230 transaction::get_modified_packages(&transaction_id)
1231 .unwrap_or_default();
1232 let upgrades_lock = successful_upgrades
1233 .lock()
1234 .map_err(|e| anyhow!("mutex poisoned: {e}"))?;
1235 let first_scope = upgrades_lock
1236 .first()
1237 .map_or(types::Scope::User, |(old, _, _)| old.scope);
1238 let _ = crate::pkg::hooks::global::run_global_hooks(
1239 crate::pkg::hooks::global::HookWhen::PostTransaction,
1240 &modified_files,
1241 &modified_packages,
1242 "upgrade",
1243 first_scope
1244 );
1245 }
1246 transaction::commit(&transaction_id)?;
1247
1248 if !plan_json {
1249 println!("\n{}", "Success:".green());
1250 }
1251 let successful_upgrades = successful_upgrades
1252 .into_inner()
1253 .map_err(|e| anyhow!("mutex poisoned: {e}"))?;
1254 for (old_manifest, new_manifest, new_pkg) in &successful_upgrades {
1255 if let Some(backup_files) = &old_manifest.backup {
1256 if !plan_json {
1257 println!(
1258 "Restoring configuration for {}...",
1259 old_manifest.name.cyan()
1260 );
1261 }
1262 let old_version_dir = local::get_package_version_dir(
1263 old_manifest.scope,
1264 &old_manifest.registry_handle,
1265 &old_manifest.repo,
1266 &old_manifest.name,
1267 &old_manifest.version
1268 )?;
1269 let new_version_dir = local::get_package_version_dir(
1270 new_manifest.scope,
1271 &new_manifest.registry_handle,
1272 &new_manifest.repo,
1273 &new_manifest.name,
1274 &new_manifest.version
1275 )?;
1276 handle_backup_files(
1277 &old_version_dir,
1278 &new_version_dir,
1279 backup_files,
1280 old_manifest.scope
1281 )?;
1282 }
1283
1284 if let Err(e) = cleanup_old_versions(
1285 &new_manifest.name,
1286 new_manifest.scope,
1287 &new_manifest.repo,
1288 &new_manifest.registry_handle
1289 ) {
1290 eprintln!(
1291 "Failed to clean up old versions for {}: {}",
1292 new_manifest.name, e
1293 );
1294 }
1295
1296 if let Ok(conn) = db::open_connection("local") {
1297 let _ = db::update_package(
1298 &conn,
1299 new_pkg,
1300 &new_manifest.registry_handle,
1301 Some(new_manifest.scope),
1302 new_manifest.sub_package.as_deref(),
1303 Some(&old_manifest.reason)
1304 );
1305 }
1306
1307 if let Some(hooks) = &new_pkg.hooks
1308 && let Err(e) = hooks::run_hooks(
1309 hooks,
1310 hooks::HookType::PostUpgrade,
1311 new_manifest.scope
1312 )
1313 {
1314 eprintln!(
1315 "{}: Post-upgrade hook failed for '{}': {}",
1316 "Error".red().bold(),
1317 new_manifest.name,
1318 e
1319 );
1320 }
1321 }
1322
1323 if !plan_json {
1324 ux::print_transaction_summary(&ux::TransactionSummary {
1325 command: "update".to_string(),
1326 success: successful_upgrades.len(),
1327 failed: 0,
1328 skipped: pinned_sources.len()
1329 + skipped_sources.len()
1330 + up_to_date_sources.len()
1331 });
1332 }
1333 Ok(())
1334}
1335
1336fn advisory_counts(
1339 registry_handle: &str,
1340 package: &str,
1341 sub_package: Option<&str>,
1342 old_version: &str,
1343 new_version: &str
1344) -> Result<(usize, usize)> {
1345 let advisories =
1346 db::get_advisories_for_package(registry_handle, package, sub_package)?;
1347 let old_ver = Version::parse(old_version).map_err(|e| {
1348 anyhow!("failed to parse old version '{old_version}': {e}")
1349 })?;
1350 let new_ver = Version::parse(new_version).map_err(|e| {
1351 anyhow!("failed to parse new version '{new_version}': {e}")
1352 })?;
1353
1354 let mut old_count = 0usize;
1355 let mut new_count = 0usize;
1356 for adv in advisories {
1357 if let Ok(req) = semver::VersionReq::parse(&adv.affected_range) {
1358 if req.matches(&old_ver) {
1359 old_count += 1;
1360 }
1361 if req.matches(&new_ver) {
1362 new_count += 1;
1363 }
1364 }
1365 }
1366 Ok((old_count, new_count))
1367}
1368
1369fn cleanup_old_versions(
1372 package_name: &str,
1373 scope: types::Scope,
1374 repo: &str,
1375 registry_handle: &str
1376) -> Result<()> {
1377 let config = config::read_config()?;
1378 let rollback_enabled = config.rollback_enabled;
1379 let package_dir =
1380 local::get_package_dir(scope, registry_handle, repo, package_name)?;
1381
1382 let mut versions = Vec::new();
1383 if let Ok(entries) = fs::read_dir(&package_dir) {
1384 for entry in entries.flatten() {
1385 let path = entry.path();
1386 if path.is_dir()
1387 && let Some(version_str) =
1388 path.file_name().and_then(|s| s.to_str())
1389 && version_str != "latest"
1390 && let Ok(version) = Version::parse(version_str)
1391 {
1392 versions.push(version);
1393 }
1394 }
1395 }
1396
1397 if versions.is_empty() {
1398 return Ok(());
1399 }
1400 versions.sort();
1401
1402 let versions_to_keep = if rollback_enabled { 3 } else { 1 };
1403 if versions.len() > versions_to_keep {
1404 let num_to_delete = versions.len() - versions_to_keep;
1405 println!("Cleaning up old versions...");
1406 let to_delete = versions
1407 .get(..num_to_delete)
1408 .ok_or_else(|| anyhow!("Version slice out of bounds"))?;
1409 for version in to_delete {
1410 let version_dir_to_delete = package_dir.join(version.to_string());
1411 println!(" - Removing {}", version_dir_to_delete.display());
1412 let _ = fs::remove_dir_all(version_dir_to_delete);
1413 }
1414 }
1415 Ok(())
1416}