Skip to main content

zoi_cli/cmd/
install.rs

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
14/// The primary high-level orchestration for the `zoi install` command.
15///
16/// This function coordinates:
17/// - Context Resolution: Decides between global, user, or project-local scopes.
18/// - Dependency Resolution: Triggers the SAT solver to build the dependency graph.
19/// - Safety Checks: Validates policy compliance, security advisories, and file conflicts.
20/// - Transactional Execution: Executes the install plan within an atomic transaction.
21/// - Lockfile Updates: Synchronizes `zoi.lock` for project-local installations.
22pub 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    // --- Phase 1: Context & Scope Resolution ---
47    // We decide whether this is a global, user, or project-local installation
48    // and whether we are operating in 'frozen' mode from a lockfile.
49    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    // --- Phase 2: Dependency Resolution ---
233    // We trigger the SAT solver to build the complete dependency graph.
234    // If we're in frozen mode, we build it strictly from the lockfile.
235    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    // --- Phase 3: Safety & Compliance Checks ---
359    // Before touching the disk, we validate policy, signatures, and file conflicts.
360    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    // --- Phase 4: Transactional Execution ---
659    // We execute the install plan within an atomic transaction.
660    // Preparation happens in parallel, but installation follows the topological order.
661    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(&reg.handle);
1069                if reg_path.exists()
1070                    && let Ok(repo) = git2::Repository::open(&reg_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}