1use crate::cmd::ux;
2use crate::pkg::{config, install, lock, resolve, transaction, types};
3use anyhow::{Result, anyhow};
4use colored::Colorize;
5use indicatif::MultiProgress;
6use mlua::LuaSerdeExt;
7use rayon::prelude::*;
8use serde_json::json;
9use std::collections::{HashMap, HashSet};
10use std::sync::Mutex;
11use std::sync::atomic::{AtomicUsize, Ordering};
12use zoi_project as project;
13
14pub fn run(
23 sources: &[String],
24 repo: Option<String>,
25 force: bool,
26 all_optional: bool,
27 yes: bool,
28 scope: Option<crate::cli::InstallScope>,
29 local: bool,
30 global: bool,
31 save: bool,
32 build_type: Option<String>,
33 dry_run: bool,
34 plugin_manager: Option<&crate::pkg::plugin::PluginManager>,
35 build: bool,
36 frozen: bool,
37 explain: bool,
38 plan_json: bool,
39 retry: u32,
40 verbose: bool,
41 purl: bool,
42 project_config: Option<project::config::ProjectConfig>,
43) -> Result<()> {
44 crate::pkg::install::util::set_download_retry_attempts(retry);
45
46 let mut scope_override = scope.map(|s| match s {
50 crate::cli::InstallScope::User => types::Scope::User,
51 crate::cli::InstallScope::System => types::Scope::System,
52 crate::cli::InstallScope::Project => types::Scope::Project,
53 });
54
55 if local {
56 scope_override = Some(types::Scope::Project);
57 } else if global {
58 scope_override = Some(types::Scope::User);
59 }
60
61 if scope_override.is_none() {
62 scope_override = Some(crate::pkg::utils::resolve_fallback_scope());
63 }
64
65 if frozen {
66 if repo.is_some() || !sources.is_empty() {
67 return Err(anyhow!(
68 "--frozen can only be used without explicit sources or --repo."
69 ));
70 }
71 if save {
72 return Err(anyhow!(
73 "--save cannot be used with --frozen because the lockfile must remain unchanged."
74 ));
75 }
76 if !std::path::Path::new("zoi.lua").exists() {
77 return Err(anyhow!(
78 "--frozen requires a local zoi.lua in the current project."
79 ));
80 }
81 if !std::path::Path::new("zoi.lock").exists() {
82 return Err(anyhow!(
83 "--frozen requires zoi.lock. Generate it first with a normal project install."
84 ));
85 }
86 if let Some(scope) = scope_override
87 && scope != types::Scope::Project
88 {
89 return Err(anyhow!(
90 "--frozen is only supported for project scope installs."
91 ));
92 }
93 scope_override = Some(types::Scope::Project);
94 crate::pkg::frozen::set_frozen(true);
95 }
96
97 let lockfile_exists = sources.is_empty()
98 && repo.is_none()
99 && std::path::Path::new("zoi.lock").exists()
100 && (std::path::Path::new("zoi.lua").exists() || std::path::Path::new("zoi.yaml").exists());
101
102 let mut sources_to_process: Vec<String> = sources.to_vec();
103 let mut is_project_install = false;
104 let mut frozen_packages = None;
105 if frozen {
106 let lockfile = project::lockfile::read_zoi_lock()?;
107 let locked_packages = project::lockfile::locked_packages(&lockfile);
108 sources_to_process = locked_packages
109 .iter()
110 .map(|entry| entry.source.clone())
111 .collect();
112 if sources_to_process.is_empty() {
113 return Err(anyhow!("zoi.lock is empty. Cannot continue with --frozen."));
114 }
115 frozen_packages = Some(locked_packages);
116 println!(
117 "{} --frozen enabled. Installing pinned lockfile sources only...",
118 "::".bold().blue()
119 );
120 is_project_install = true;
121 } else if sources.is_empty() && repo.is_none() {
122 if std::path::Path::new("zoi.lua").exists() || std::path::Path::new("zoi.yaml").exists() {
123 if let Ok(config) = project::config::load() {
124 let config_file = if std::path::Path::new("zoi.lua").exists() {
125 "zoi.lua"
126 } else {
127 "zoi.yaml"
128 };
129 if lockfile_exists {
130 println!(
131 "{} zoi.lock found. Installing from {} then verifying...",
132 "::".bold().blue(),
133 config_file
134 );
135 } else {
136 println!(
137 "{} Installing project packages from {}...",
138 "::".bold().blue(),
139 config_file
140 );
141 }
142 sources_to_process = config.pkgs.clone();
143 if scope_override.is_none() {
144 scope_override = Some(types::Scope::Project);
145 }
146 is_project_install = true;
147 }
148 } else if let Some(pm) = plugin_manager
149 && pm.trigger_project_install_hook()?
150 {
151 return Ok(());
152 }
153 }
154
155 if let Some(repo_spec) = repo {
156 if scope_override == Some(types::Scope::Project) {
157 return Err(anyhow!(
158 "Installing from a repository to a project scope is not supported."
159 ));
160 }
161 let repo_install_scope = scope_override.map(|s| match s {
162 types::Scope::User => crate::cli::SetupScope::User,
163 types::Scope::System => crate::cli::SetupScope::System,
164 types::Scope::Project => unreachable!(),
165 });
166
167 if dry_run {
168 println!(
169 "{} Dry-run: would install from repository '{}'",
170 "::".bold().yellow(),
171 repo_spec
172 );
173 return Ok(());
174 }
175
176 crate::pkg::repo_install::run(
177 &repo_spec,
178 force,
179 all_optional,
180 yes,
181 repo_install_scope,
182 plugin_manager,
183 )?;
184 return Ok(());
185 }
186
187 if sources_to_process.is_empty() {
188 return Ok(());
189 }
190
191 if purl {
192 let mut resolved_purls = Vec::new();
193 for source in &sources_to_process {
194 println!(
195 "{} Fetching PURL package '{}'...",
196 "::".bold().blue(),
197 source
198 );
199 let ident = crate::pkg::purl::fetch_and_store_purl_package(source)?;
200 resolved_purls.push(ident);
201 }
202 sources_to_process = resolved_purls;
203 }
204
205 let config = config::read_config().unwrap_or_default();
206 let jobs = config.jobs.unwrap_or(3);
207 rayon::ThreadPoolBuilder::new()
208 .num_threads(jobs)
209 .build_global()
210 .ok();
211
212 let failed_packages = Mutex::new(Vec::new());
213 let mut temp_files = Vec::new();
214 let mut final_sources = Vec::new();
215
216 for source in &sources_to_process {
217 if source.ends_with(".lock") {
218 install::lockfile::process_lockfile(
219 source,
220 &mut final_sources,
221 &mut temp_files,
222 scope_override.unwrap_or(types::Scope::User),
223 )?;
224 } else {
225 final_sources.push(source.to_string());
226 }
227 }
228
229 let successfully_installed_sources = Mutex::new(Vec::new());
230 let installed_manifests = Mutex::new(Vec::new());
231
232 let (mut graph, mut non_zoi_deps) = if let Some(locked_packages) = frozen_packages.as_ref() {
236 install::resolver::build_graph_from_locked_packages(
237 locked_packages,
238 scope_override,
239 false,
240 yes,
241 )?
242 } else {
243 install::resolver::resolve_dependency_graph(
244 &final_sources,
245 scope_override,
246 force,
247 yes,
248 all_optional,
249 build_type.as_deref(),
250 false,
251 project_config,
252 )?
253 };
254
255 let mut skipped_existing_count = 0usize;
256 if !force {
257 let mut to_remove = Vec::new();
258 for (pkg_id, node) in &graph.nodes {
259 let request_source = crate::pkg::local::package_source_string(
260 &node.registry_handle,
261 &node.pkg.repo,
262 &node.pkg.name,
263 node.sub_package.as_deref(),
264 &node.version,
265 );
266 let request = resolve::parse_source_string(&request_source)?;
267 let matches = crate::pkg::local::find_installed_manifests_matching(
268 &request,
269 scope_override.unwrap_or(node.pkg.scope),
270 )?;
271 if matches
272 .iter()
273 .any(|manifest| manifest.version == node.version)
274 {
275 println!(
276 "{} Package '{}' is already installed at version {}. Skipping.",
277 "::".bold().green(),
278 node.pkg.name.cyan(),
279 node.version.yellow()
280 );
281 to_remove.push(pkg_id.clone());
282 }
283 }
284 skipped_existing_count = to_remove.len();
285
286 for pkg_id in to_remove {
287 graph.nodes.remove(&pkg_id);
288 if let Some(children) = graph.adj.remove(&pkg_id)
289 && let Some(root_children) = graph.adj.get_mut("$root")
290 {
291 for child in children {
292 root_children.insert(child);
293 }
294 }
295 if let Some(root_children) = graph.adj.get_mut("$root") {
296 root_children.remove(&pkg_id);
297 }
298 }
299
300 let mut valid_non_zoi_deps = std::collections::HashSet::new();
301 for source in &sources_to_process {
302 if let Ok(dep) = crate::pkg::dependencies::parse_dependency_string(source)
303 && dep.manager != "zoi"
304 {
305 valid_non_zoi_deps.insert(source.clone());
306 }
307 }
308 for node in graph.nodes.values() {
309 for dep in &node.dependencies {
310 if let Ok(dep_req) = crate::pkg::dependencies::parse_dependency_string(dep)
311 && dep_req.manager != "zoi"
312 {
313 valid_non_zoi_deps.insert(dep.clone());
314 }
315 }
316 }
317 non_zoi_deps.retain(|dep| valid_non_zoi_deps.contains(dep));
318 }
319
320 if graph.nodes.is_empty() && non_zoi_deps.is_empty() {
321 println!("\nAll requested packages are already installed.");
322 return Ok(());
323 }
324
325 if !dry_run {
326 if let Some(pm) = plugin_manager {
327 pm.set_context(scope_override.unwrap_or_default())?;
328 }
329 for node in graph.nodes.values() {
330 if let Some(pm) = plugin_manager {
331 let pkg_val = pm
332 .lua
333 .to_value(&node.pkg)
334 .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
335 pm.trigger_hook("on_pre_install", Some(pkg_val))?;
336 }
337 }
338 }
339
340 let mut direct_packages = Vec::new();
341 let mut dependencies = Vec::new();
342
343 for node in graph.nodes.values() {
344 if matches!(node.reason, types::InstallReason::Direct) {
345 direct_packages.push(node);
346 } else {
347 dependencies.push(node);
348 }
349 }
350
351 direct_packages.sort_by(|a, b| a.pkg.name.cmp(&b.pkg.name));
352 dependencies.sort_by(|a, b| a.pkg.name.cmp(&b.pkg.name));
353
354 for node in graph.nodes.values() {
355 crate::utils::print_repo_warning(&node.pkg.repo);
356 }
357
358 println!("{} Looking for conflicts...", "::".bold().blue());
361 let packages_to_install: Vec<&types::Package> = graph.nodes.values().map(|n| &n.pkg).collect();
362
363 if !dry_run {
364 install::util::check_for_conflicts(&packages_to_install, yes)?;
365 for pkg in &packages_to_install {
366 if !install::util::display_updates(pkg, yes)? {
367 return Err(anyhow!("Installation aborted by user."));
368 }
369 }
370 install::util::check_policy_compliance(&graph)?;
371 install::util::check_scope_compliance(&graph)?;
372 install::util::check_for_vulnerabilities(&graph, yes)?;
373
374 let m_for_conflict_check = MultiProgress::new();
375 install::util::check_file_conflicts(&graph, yes, &m_for_conflict_check)?;
376 let _ = m_for_conflict_check.clear();
377 }
378
379 println!("{} Checking available disk space...", "::".bold().blue());
380 let install_plan =
381 install::plan::create_install_plan(&graph.nodes, build_type.as_deref(), build)?;
382
383 let mut total_download_size: u64 = 0;
384 let mut total_installed_size: u64 = 0;
385 let mut unique_downloads = HashSet::new();
386
387 for (id, node) in &graph.nodes {
388 match install_plan.get(id) {
389 Some(install::plan::InstallAction::DownloadAndInstall(details)) => {
390 if unique_downloads.insert(details.info.final_url.clone()) {
391 total_download_size += details.download_size;
392 }
393 total_installed_size += if details.installed_size > 0 {
394 details.installed_size
395 } else {
396 node.pkg.installed_size.unwrap_or(0)
397 };
398 }
399 Some(install::plan::InstallAction::BuildAndInstall) => {
400 total_installed_size += node.pkg.installed_size.unwrap_or(0);
401 }
402 _ => {}
403 }
404 }
405
406 let default_handle = config
407 .default_registry
408 .as_ref()
409 .map(|r| r.handle.as_str())
410 .unwrap_or("");
411 let active_repos = &config.repos;
412
413 let format_display_name = |registry: &str, repo: &str, name: &str, sub: Option<&str>| {
414 let base_name = if let Some(s) = sub {
415 format!("{}:{}", name, s)
416 } else {
417 name.to_string()
418 };
419
420 if registry == "local" && repo.starts_with("git/") {
421 let repo_name = &repo[4..];
422 return format!("#git@{}/{}", repo_name, base_name);
423 }
424
425 if registry == default_handle || registry == "local" || registry.is_empty() {
426 if active_repos.contains(&repo.to_string()) || repo.is_empty() {
427 base_name
428 } else {
429 format!("@{}/{}", repo, base_name)
430 }
431 } else {
432 format!("#{}@{}/{}", registry, repo, base_name)
433 }
434 };
435
436 println!(
437 "\n{} Packages ({})",
438 "::".bold().blue(),
439 direct_packages.len()
440 );
441 let direct_list: Vec<_> = direct_packages
442 .iter()
443 .map(|n| {
444 let display_name = format_display_name(
445 &n.registry_handle,
446 &n.pkg.repo,
447 &n.pkg.name,
448 n.sub_package.as_deref(),
449 );
450 let version_display = if n.revision != "1" {
451 format!("{}-{}", n.version, n.revision)
452 } else {
453 n.version.clone()
454 };
455 format!("{}@{}", display_name, version_display)
456 .cyan()
457 .to_string()
458 })
459 .collect();
460 println!(" {}", direct_list.join(" "));
461
462 if verbose {
463 println!("\n{} Package origins", "::".bold().blue());
464 let mut direct_entries: Vec<_> = graph
465 .nodes
466 .iter()
467 .filter(|(_, node)| matches!(node.reason, types::InstallReason::Direct))
468 .collect();
469 direct_entries.sort_by(|a, b| a.1.pkg.name.cmp(&b.1.pkg.name));
470 for (id, node) in direct_entries {
471 let action_name = match install_plan.get(id) {
472 Some(install::plan::InstallAction::DownloadAndInstall(_)) => "download",
473 Some(install::plan::InstallAction::InstallFromArchive(_)) => "archive",
474 Some(install::plan::InstallAction::BuildAndInstall) => "build",
475 None => "unknown",
476 };
477 let origin = ux::classify_source_origin(&node.source, action_name);
478 let display_name = format_display_name(
479 &node.registry_handle,
480 &node.pkg.repo,
481 &node.pkg.name,
482 node.sub_package.as_deref(),
483 );
484 let version_display = if node.revision != "1" {
485 format!("{}-{}", node.version, node.revision)
486 } else {
487 node.version.clone()
488 };
489 println!(
490 " - {}@{} -> {} ({})",
491 display_name.cyan(),
492 version_display,
493 origin.as_str(),
494 action_name
495 );
496 }
497 }
498
499 if !dependencies.is_empty() || !non_zoi_deps.is_empty() {
500 println!(
501 "\n{} Dependencies ({})",
502 "::".bold().blue(),
503 dependencies.len() + non_zoi_deps.len()
504 );
505 let mut dep_list = Vec::new();
506 for n in &dependencies {
507 let display_name = format_display_name(
508 &n.registry_handle,
509 &n.pkg.repo,
510 &n.pkg.name,
511 n.sub_package.as_deref(),
512 );
513 let version_display = if n.revision != "1" {
514 format!("{}-{}", n.version, n.revision)
515 } else {
516 n.version.clone()
517 };
518 dep_list.push(
519 format!("zoi:{}@{}", display_name, version_display)
520 .dimmed()
521 .to_string(),
522 );
523 }
524 for d in &non_zoi_deps {
525 dep_list.push(d.dimmed().to_string());
526 }
527 println!(" {}", dep_list.join(" "));
528 }
529
530 if total_download_size > 0 {
531 println!(
532 "\nTotal Download Size: {}",
533 crate::pkg::utils::format_bytes(total_download_size)
534 );
535 }
536 if total_installed_size > 0 {
537 println!(
538 "Total Installed Size: {}",
539 crate::pkg::utils::format_bytes(total_installed_size)
540 );
541 }
542
543 if verbose {
544 let preflight = ux::PreflightSummary::new("Install preflight")
545 .row(
546 "Scope",
547 format!("{:?}", scope_override.unwrap_or(types::Scope::User)),
548 )
549 .row("Frozen lockfile", frozen.to_string())
550 .row("Retry attempts", retry.to_string())
551 .row("Direct packages", direct_packages.len().to_string())
552 .row(
553 "Dependencies",
554 (dependencies.len() + non_zoi_deps.len()).to_string(),
555 )
556 .row(
557 "Download size",
558 crate::pkg::utils::format_bytes(total_download_size),
559 )
560 .row(
561 "Installed size",
562 crate::pkg::utils::format_bytes(total_installed_size),
563 );
564 ux::print_preflight(&preflight);
565 }
566
567 if explain {
568 let mut report = ux::ExplainReport::new("Install explanation");
569 let mut entries: Vec<_> = graph.nodes.iter().collect();
570 entries.sort_by(|a, b| a.1.pkg.name.cmp(&b.1.pkg.name));
571 for (id, node) in entries {
572 let action_name = match install_plan.get(id) {
573 Some(install::plan::InstallAction::DownloadAndInstall(_)) => "download",
574 Some(install::plan::InstallAction::InstallFromArchive(_)) => "archive",
575 Some(install::plan::InstallAction::BuildAndInstall) => "build",
576 None => "unknown",
577 };
578 let reason = match &node.reason {
579 types::InstallReason::Direct => "direct request".to_string(),
580 types::InstallReason::Dependency { parent } => {
581 format!("dependency of {}", parent)
582 }
583 };
584 let version_display = if node.revision != "1" {
585 format!("{} (rev {})", node.version, node.revision)
586 } else {
587 node.version.clone()
588 };
589 report = report.item(
590 format!("{}@{}", node.pkg.name, version_display),
591 format!("[{}]", reason),
592 vec![format!(
593 "via {} ({})",
594 action_name,
595 ux::classify_source_origin(&node.source, action_name).as_str()
596 )],
597 );
598 }
599 ux::print_explain(&report);
600 }
601
602 if plan_json {
603 let mut packages = Vec::new();
604 let mut entries: Vec<_> = graph.nodes.iter().collect();
605 entries.sort_by(|a, b| a.1.pkg.name.cmp(&b.1.pkg.name));
606 for (id, node) in entries {
607 let action_name = match install_plan.get(id) {
608 Some(install::plan::InstallAction::DownloadAndInstall(_)) => "download",
609 Some(install::plan::InstallAction::InstallFromArchive(_)) => "archive",
610 Some(install::plan::InstallAction::BuildAndInstall) => "build",
611 None => "unknown",
612 };
613 let reason = match &node.reason {
614 types::InstallReason::Direct => "direct".to_string(),
615 types::InstallReason::Dependency { parent } => format!("dependency:{}", parent),
616 };
617 packages.push(json!({
618 "id": id,
619 "name": node.pkg.name,
620 "version": node.version,
621 "revision": node.revision,
622 "sub_package": node.sub_package,
623 "repo": node.pkg.repo,
624 "registry": node.registry_handle,
625 "reason": reason,
626 "action": action_name,
627 "origin": ux::classify_source_origin(&node.source, action_name).as_str(),
628 "source": node.source,
629 }));
630 }
631
632 let plan = json!({
633 "dry_run": dry_run,
634 "frozen": frozen,
635 "retry_attempts": retry,
636 "scope": format!("{:?}", scope_override.unwrap_or(types::Scope::User)),
637 "totals": {
638 "direct_packages": direct_packages.len(),
639 "dependencies": dependencies.len() + non_zoi_deps.len(),
640 "download_bytes": total_download_size,
641 "installed_bytes": total_installed_size,
642 "skipped_existing": skipped_existing_count,
643 },
644 "packages": packages,
645 "non_zoi_dependencies": non_zoi_deps,
646 });
647 ux::emit_plan_json_v1("install", plan)?;
648 }
649
650 if dry_run {
651 println!(
652 "\n{} Dry-run: installation plan above would be executed.",
653 "::".bold().yellow()
654 );
655 return Ok(());
656 }
657
658 let install_path = crate::pkg::local::get_store_base_dir(scope_override.unwrap_or_default())?;
662 std::fs::create_dir_all(&install_path)?;
663
664 let available_space = fs2::available_space(&install_path).unwrap_or(u64::MAX);
665
666 if total_installed_size > available_space {
667 return Err(anyhow!(
668 "Not enough disk space. Required: {}, Available: {}",
669 crate::pkg::utils::format_bytes(total_installed_size),
670 crate::pkg::utils::format_bytes(available_space)
671 ));
672 }
673
674 if !crate::utils::ask_for_confirmation("\nProceed with installation?", yes) {
675 let _ = lock::release_lock();
676 return Ok(());
677 }
678
679 let stages = graph.toposort()?;
680 let transaction = Mutex::new(transaction::begin()?);
681 let transaction_id = transaction.lock().unwrap().id.clone();
682 let dependency_installed_count = AtomicUsize::new(0);
683
684 println!("\n{} Preparing packages...", "::".bold().blue());
685 let m_prep = MultiProgress::new();
686 let prepared_nodes = Mutex::new(HashMap::new());
687
688 stages
689 .par_iter()
690 .flatten()
691 .try_for_each(|pkg_id| -> Result<()> {
692 let node = graph.nodes.get(pkg_id).ok_or_else(|| {
693 anyhow!(
694 "Package node '{}' missing from graph during preparation",
695 pkg_id
696 )
697 })?;
698 let action = install_plan.get(pkg_id).ok_or_else(|| {
699 anyhow!(
700 "Install action missing for package '{}' during preparation",
701 pkg_id
702 )
703 })?;
704
705 let prepared = install::installer::prepare_node(
706 node,
707 action,
708 Some(&m_prep),
709 build_type.as_deref(),
710 verbose,
711 )?;
712
713 let mut lock = prepared_nodes
714 .lock()
715 .map_err(|e| anyhow!("Prepared nodes mutex poisoned during preparation: {}", e))?;
716 lock.insert(pkg_id.clone(), prepared);
717 Ok(())
718 })?;
719
720 if !dependencies.is_empty() || !non_zoi_deps.is_empty() {
721 println!("\n{} Installing dependencies...", "::".bold().blue());
722 let m_deps = MultiProgress::new();
723
724 if !non_zoi_deps.is_empty() {
725 let processed_deps = Mutex::new(HashSet::new());
726 let mut installed_deps_ext = Vec::new();
727 for dep_str in &non_zoi_deps {
728 let dep = match crate::pkg::dependencies::parse_dependency_string(dep_str) {
729 Ok(d) => d,
730 Err(e) => {
731 eprintln!("Error parsing dependency {}: {}", dep_str, e);
732 continue;
733 }
734 };
735
736 if let Err(e) = crate::pkg::install::dep_install::install_dependency(
737 &dep,
738 "direct",
739 scope_override.unwrap_or_default(),
740 yes,
741 all_optional,
742 &processed_deps,
743 &mut installed_deps_ext,
744 Some(&m_deps),
745 ) {
746 eprintln!("Failed to install dependency {}: {}", dep_str, e);
747 }
748 }
749 }
750
751 for stage in &stages {
752 stage.par_iter().try_for_each(|pkg_id| -> Result<()> {
753 let node = graph.nodes.get(pkg_id).ok_or_else(|| {
754 anyhow!(
755 "Package node '{}' missing from graph during installation",
756 pkg_id
757 )
758 })?;
759 if matches!(node.reason, types::InstallReason::Direct) {
760 return Ok(());
761 }
762
763 let prepared = {
764 let lock = prepared_nodes.lock().map_err(|e| {
765 anyhow!(
766 "Prepared nodes mutex poisoned during dependency install: {}",
767 e
768 )
769 })?;
770 lock.get(pkg_id)
771 .cloned()
772 .ok_or_else(|| anyhow!("Prepared node missing for: {}", pkg_id))?
773 };
774
775 match install::installer::install_prepared_node(
776 node,
777 &prepared,
778 Some(&m_deps),
779 yes,
780 true,
781 true,
782 verbose,
783 ) {
784 Ok(manifest) => {
785 dependency_installed_count.fetch_add(1, Ordering::Relaxed);
786 let mut tx_lock = transaction.lock().map_err(|e| {
787 anyhow!("Transaction mutex poisoned during installation: {}", e)
788 })?;
789 if let Err(e) = transaction::record_operation(
790 &mut tx_lock,
791 types::TransactionOperation::Install {
792 manifest: Box::new(manifest),
793 },
794 ) {
795 eprintln!("Failed to record transaction operation: {}", e);
796 return Err(anyhow!("Transaction recording failed: {}", e));
797 }
798 }
799 Err(e) => {
800 failed_packages
801 .lock()
802 .map_err(|e| {
803 anyhow!("Failed packages mutex poisoned during installation: {}", e)
804 })?
805 .push(node.pkg.name.clone());
806 eprintln!("Error installing {}: {}", node.pkg.name, e);
807 }
808 }
809 Ok(())
810 })?;
811 }
812 }
813
814 println!("\n{} Installing packages...", "::".bold().blue());
815 let m_pkg = MultiProgress::new();
816
817 let mut direct_package_ids = Vec::new();
818 for stage in &stages {
819 for pkg_id in stage {
820 if let Some(node) = graph.nodes.get(pkg_id)
821 && matches!(node.reason, types::InstallReason::Direct)
822 {
823 direct_package_ids.push(pkg_id.clone());
824 }
825 }
826 }
827
828 for stage in &stages {
829 let mut stage_direct_ids = Vec::new();
830 for pkg_id in stage {
831 if let Some(node) = graph.nodes.get(pkg_id)
832 && matches!(node.reason, types::InstallReason::Direct)
833 {
834 let name = if let Some(sub) = &node.sub_package {
835 format!("{}:{}", node.pkg.name, sub)
836 } else {
837 node.pkg.name.clone()
838 };
839 let version_display = if node.revision != "1" {
840 format!("{}-{}", node.version, node.revision)
841 } else {
842 node.version.clone()
843 };
844 println!("@{}:{}", name, version_display);
845 stage_direct_ids.push(pkg_id.clone());
846 }
847 }
848
849 if stage_direct_ids.is_empty() {
850 continue;
851 }
852
853 let res = stage_direct_ids
854 .par_iter()
855 .try_for_each(|pkg_id| -> Result<()> {
856 let node = graph.nodes.get(pkg_id).ok_or_else(|| {
857 anyhow!(
858 "Package node '{}' missing from graph during final installation",
859 pkg_id
860 )
861 })?;
862
863 let prepared = {
864 let lock = prepared_nodes.lock().map_err(|e| {
865 anyhow!(
866 "Prepared nodes mutex poisoned during package install: {}",
867 e
868 )
869 })?;
870 lock.get(pkg_id)
871 .cloned()
872 .ok_or_else(|| anyhow!("Prepared node missing for: {}", pkg_id))?
873 };
874
875 match install::installer::install_prepared_node(
876 node,
877 &prepared,
878 Some(&m_pkg),
879 yes,
880 true,
881 true,
882 verbose,
883 ) {
884 Ok(manifest) => {
885 installed_manifests
886 .lock()
887 .map_err(|e| anyhow!("Installed manifests mutex poisoned: {}", e))?
888 .push(manifest.clone());
889 let mut tx_lock = transaction.lock().map_err(|e| {
890 anyhow!(
891 "Transaction mutex poisoned during direct package installation: {}",
892 e
893 )
894 })?;
895 transaction::record_operation(
896 &mut tx_lock,
897 types::TransactionOperation::Install {
898 manifest: Box::new(manifest),
899 },
900 )?;
901 successfully_installed_sources
902 .lock()
903 .map_err(|e| {
904 anyhow!("Successfully installed sources mutex poisoned: {}", e)
905 })?
906 .push(node.source.clone());
907 Ok(())
908 }
909 Err(e) => {
910 failed_packages
911 .lock()
912 .map_err(|e| anyhow!("Failed packages mutex poisoned: {}", e))?
913 .push(node.pkg.name.clone());
914 eprintln!("Error installing {}: {}", node.pkg.name, e);
915 Err(e)
916 }
917 }
918 });
919
920 if res.is_err() {
921 break;
922 }
923 }
924
925 let direct_installed_count: usize = direct_package_ids.len();
926
927 let failed = failed_packages
928 .lock()
929 .map_err(|e| anyhow!("Failed packages mutex poisoned during finalization: {}", e))?;
930 if !failed.is_empty() {
931 println!("\n{} Rolling back changes...", "::".bold().yellow());
932 transaction::rollback(&transaction_id)?;
933 ux::print_transaction_summary(&ux::TransactionSummary {
934 command: "install".to_string(),
935 success: dependency_installed_count.load(Ordering::Relaxed) + direct_installed_count,
936 failed: failed.len(),
937 skipped: skipped_existing_count,
938 });
939 return Err(anyhow!("Installation failed for: {}", failed.join(", ")));
940 }
941
942 if let Ok(modified_files) = transaction::get_modified_files(&transaction_id) {
943 let modified_packages =
944 transaction::get_modified_packages(&transaction_id).unwrap_or_default();
945 let _ = crate::pkg::hooks::global::run_global_hooks(
946 crate::pkg::hooks::global::HookWhen::PostTransaction,
947 &modified_files,
948 &modified_packages,
949 "install",
950 scope_override.unwrap_or_default(),
951 );
952 }
953
954 if let Err(e) = transaction::commit(&transaction_id) {
955 eprintln!("Warning: Failed to commit transaction: {}", e);
956 }
957
958 let installed_manifests_vec = installed_manifests
959 .lock()
960 .map_err(|e| {
961 anyhow!(
962 "Installed manifests mutex poisoned during finalization: {}",
963 e
964 )
965 })?
966 .clone();
967 for manifest in &installed_manifests_vec {
968 if let Some(pm) = plugin_manager {
969 let pkg_val = pm
970 .lua
971 .to_value(manifest)
972 .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
973 pm.trigger_hook_nonfatal("on_post_install", Some(pkg_val));
974 }
975 }
976
977 let is_any_project_install = scope_override == Some(types::Scope::Project);
978
979 if is_any_project_install && !frozen {
980 println!("\nUpdating zoi.lock...");
981 let mut lockfile =
982 project::lockfile::read_zoi_lock().unwrap_or_else(|_| types::ZoiLockV2 {
983 version: "2".to_string(),
984 ..Default::default()
985 });
986
987 lockfile.installed_packages.clear();
988 lockfile.registries.clear();
989
990 let all_regs_config = crate::pkg::config::read_config().unwrap_or_default();
991 let mut all_configured_regs = all_regs_config.added_registries;
992 if let Some(default_reg) = all_regs_config.default_registry {
993 all_configured_regs.push(default_reg);
994 }
995
996 let store_dir = crate::pkg::local::get_store_base_dir(types::Scope::Project)?;
997 let db_dir = std::env::current_dir()?
998 .join(".zoi")
999 .join("pkgs")
1000 .join("db");
1001
1002 lockfile.packages_hash = Some(format!(
1003 "sha512-{}",
1004 crate::pkg::hash::calculate_dir_hash(&store_dir).unwrap_or_default()
1005 ));
1006 lockfile.registries_hash = Some(format!(
1007 "sha512-{}",
1008 crate::pkg::hash::calculate_dir_hash(&db_dir).unwrap_or_default()
1009 ));
1010
1011 let mut all_final_manifests = Vec::new();
1012
1013 let just_installed = installed_manifests.into_inner().map_err(|e| {
1014 anyhow!(
1015 "Installed manifests mutex poisoned during lockfile update: {}",
1016 e
1017 )
1018 })?;
1019 all_final_manifests.extend(just_installed);
1020
1021 for (pkg_id, node) in &graph.nodes {
1022 if all_final_manifests.iter().any(|m| {
1023 let m_id = if let Some(sub) = &m.sub_package {
1024 format!("{}@{}:{}", m.name, m.version, sub)
1025 } else {
1026 format!("{}@{}", m.name, m.version)
1027 };
1028 m_id == *pkg_id
1029 }) {
1030 continue;
1031 }
1032
1033 let request_source = crate::pkg::local::package_source_string(
1034 &node.registry_handle,
1035 &node.pkg.repo,
1036 &node.pkg.name,
1037 node.sub_package.as_deref(),
1038 &node.version,
1039 );
1040 if let Ok(request) = resolve::parse_source_string(&request_source)
1041 && let Ok(matches) = crate::pkg::local::find_installed_manifests_matching(
1042 &request,
1043 scope_override.unwrap_or(node.pkg.scope),
1044 )
1045 && let Some(m) = matches.first()
1046 {
1047 all_final_manifests.push(m.clone());
1048 }
1049 }
1050
1051 for manifest in &all_final_manifests {
1052 let packages_key = if let Some(sub) = &manifest.sub_package {
1053 format!(
1054 "@{}/{}:{}",
1055 manifest.repo.trim(),
1056 manifest.name.trim(),
1057 sub.trim()
1058 )
1059 } else {
1060 format!("@{}/{}", manifest.repo.trim(), manifest.name.trim())
1061 };
1062
1063 if let Some(reg) = all_configured_regs
1064 .iter()
1065 .find(|r| r.handle == manifest.registry_handle)
1066 {
1067 let mut revision = "unknown".to_string();
1068 let reg_path = db_dir.join(®.handle);
1069 if reg_path.exists()
1070 && let Ok(repo) = git2::Repository::open(®_path)
1071 && let Ok(head) = repo.head()
1072 && let Some(target) = head.target()
1073 {
1074 revision = target.to_string();
1075 }
1076
1077 lockfile.registries.insert(
1078 reg.handle.clone(),
1079 types::LockRegistryV2 {
1080 url: reg.url.clone(),
1081 revision,
1082 },
1083 );
1084 }
1085
1086 let package_dir = crate::pkg::local::get_package_dir(
1087 types::Scope::Project,
1088 &manifest.registry_handle,
1089 &manifest.repo,
1090 &manifest.name,
1091 )?;
1092 let version_dir = package_dir.join(&manifest.version);
1093 let integrity =
1094 crate::pkg::hash::calculate_dir_hash(&version_dir).unwrap_or_else(|e| {
1095 eprintln!(
1096 "Warning: could not calculate integrity for {}: {}",
1097 manifest.name, e
1098 );
1099 String::new()
1100 });
1101
1102 let why = if matches!(manifest.reason, types::InstallReason::Direct) {
1103 "direct".to_string()
1104 } else {
1105 "dependency".to_string()
1106 };
1107
1108 let detail = types::LockPackageDetailV2 {
1109 name: manifest.name.clone(),
1110 sub_package: manifest.sub_package.clone(),
1111 repo: manifest.repo.clone(),
1112 repo_type: manifest.repo_type.clone(),
1113 version: manifest.version.clone(),
1114 epoch: manifest.epoch,
1115 revision: manifest.revision.clone(),
1116 registry: manifest.registry_handle.clone(),
1117 why,
1118 description: manifest.description.clone(),
1119 package_type_install: format!("{:?}", manifest.package_type).to_lowercase(),
1120 install_method: manifest
1121 .install_method
1122 .clone()
1123 .unwrap_or_else(|| "pre-built".to_string()),
1124 installed_sub_packages: manifest
1125 .sub_package
1126 .clone()
1127 .map(|s| vec![s])
1128 .unwrap_or_default(),
1129 platform: manifest.platform.clone(),
1130 hash: format!("sha512-{}", integrity),
1131 dependencies: manifest.dependencies_v2.clone(),
1132 };
1133
1134 lockfile.installed_packages.insert(packages_key, detail);
1135 }
1136
1137 if let Err(e) = project::lockfile::write_zoi_lock(&mut lockfile) {
1138 eprintln!("Warning: Failed to write zoi.lock file: {}", e);
1139 }
1140 }
1141
1142 if save && scope_override == Some(types::Scope::Project) {
1143 let successfully_installed = successfully_installed_sources.into_inner().map_err(|e| {
1144 anyhow!(
1145 "Successfully installed sources mutex poisoned during finalization: {}",
1146 e
1147 )
1148 })?;
1149
1150 if !successfully_installed.is_empty() {
1151 if std::path::Path::new("zoi.lua").exists() {
1152 println!(
1153 "\n{} Project uses zoi.lua. Automatic saving is not supported for Lua configurations.",
1154 "Note:".yellow().bold()
1155 );
1156 println!(" Please add the following to your packages() block in zoi.lua:");
1157 for pkg in &successfully_installed {
1158 println!(" - \"{}\"", pkg);
1159 }
1160 } else if let Err(e) = project::config::add_packages_to_config(&successfully_installed)
1161 {
1162 eprintln!(
1163 "{}: Failed to save packages to zoi.yaml: {}",
1164 "Warning".yellow().bold(),
1165 e
1166 );
1167 }
1168 }
1169 }
1170
1171 println!("\n{} Installation complete!", "Success:".green().bold());
1172
1173 if is_project_install && lockfile_exists {
1174 println!();
1175 crate::project::verify::run()?;
1176 }
1177
1178 println!("\n{} Done", "::".bold().blue());
1179 println!(
1180 "Installed ({}) packages and ({}) dependencies.",
1181 direct_packages.len(),
1182 dependencies.len() + non_zoi_deps.len()
1183 );
1184 ux::print_transaction_summary(&ux::TransactionSummary {
1185 command: "install".to_string(),
1186 success: dependency_installed_count.load(Ordering::Relaxed) + direct_installed_count,
1187 failed: 0,
1188 skipped: skipped_existing_count,
1189 });
1190
1191 Ok(())
1192}