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