Skip to main content

zoi_cli/pkg/
repo_install.rs

1/// Implements "Direct Repository Installation" (`zoi install --repo`).
2///
3/// This allows Zoi to install a package directly from a Git repository
4/// that contains a `zoi.yaml` or `zoi.lua` project file. It:
5/// - Detects the Git provider (GitHub, GitLab, Codeberg).
6/// - Fetches the project configuration over HTTP (RAW URL).
7/// - Resolves and installs the specific package defined in the project.
8use crate::pkg::types;
9use anyhow::{Result, anyhow};
10use colored::*;
11use serde::Deserialize;
12use std::env;
13use std::fs;
14use zoi_core::types::SourceType;
15
16#[derive(Debug, Deserialize)]
17struct RepoFile {
18    package: String,
19}
20
21pub fn run(
22    repo_spec: &str,
23    force: bool,
24    all_optional: bool,
25    yes: bool,
26    scope: Option<crate::cli::SetupScope>,
27    plugin_manager: Option<&crate::pkg::plugin::PluginManager>,
28) -> Result<()> {
29    println!(
30        "Installing from git repository: {}",
31        repo_spec.cyan().bold()
32    );
33
34    let (provider, repo_path) = parse_repo_spec(repo_spec)?;
35
36    crate::pkg::utils::confirm_untrusted_source(&SourceType::GitRepo(repo_spec.to_string()), yes)?;
37
38    let repo_file_names = ["zoi.yaml"];
39    let mut repo_file_content: Option<String> = None;
40    let mut used_url = String::new();
41
42    for file_name in &repo_file_names {
43        if let Ok(url) = get_repo_file_url(&provider, &repo_path, file_name) {
44            println!("Attempting to fetch repo config from: {}", url);
45            let client = crate::pkg::utils::get_http_client().ok();
46            if let Some(c) = client
47                && let Ok(content_res) = c.get(&url).send()
48                && content_res.status().is_success()
49            {
50                repo_file_content = Some(content_res.text()?);
51                used_url = url;
52                break;
53            }
54        }
55    }
56
57    let repo_file_content = repo_file_content.ok_or_else(|| {
58        anyhow!("Could not find zoi.yaml in the repository on main/master branches.")
59    })?;
60    println!("Using repo config from: {}", used_url.cyan());
61
62    let repo_file: RepoFile = serde_yaml::from_str(&repo_file_content)?;
63
64    let package_source = &repo_file.package;
65
66    let scope_override = scope.map(|s| match s {
67        crate::cli::SetupScope::User => types::Scope::User,
68        crate::cli::SetupScope::System => types::Scope::System,
69    });
70
71    println!("Starting installation of package from git repo...");
72
73    let source_to_install = if package_source.starts_with("http") {
74        println!("Package source is a URL: {}", package_source.cyan());
75        let client = crate::pkg::utils::get_http_client()?;
76        let pkg_content = client.get(package_source).send()?.text()?;
77        let temp_path = env::temp_dir().join(format!(
78            "zoi-repo-install-{}.pkg.lua",
79            chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
80        ));
81        fs::write(&temp_path, pkg_content)?;
82        temp_path
83            .to_str()
84            .ok_or_else(|| anyhow!("Temporary path contains invalid UTF-8"))?
85            .to_string()
86    } else if package_source.ends_with(".pkg.lua")
87        || (package_source.contains('/') && !package_source.starts_with('@'))
88    {
89        println!(
90            "Package source is a path in the repo: {}",
91            package_source.cyan()
92        );
93        let pkg_url = get_repo_file_url(&provider, &repo_path, package_source)?;
94        let client = crate::pkg::utils::get_http_client()?;
95        let pkg_content = client.get(&pkg_url).send()?.text()?;
96        let temp_path = env::temp_dir().join(format!(
97            "zoi-repo-install-{}.pkg.lua",
98            chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
99        ));
100        fs::write(&temp_path, pkg_content)?;
101        temp_path
102            .to_str()
103            .ok_or_else(|| anyhow!("Temporary path contains invalid UTF-8"))?
104            .to_string()
105    } else {
106        println!(
107            "Package source is a package name: {}",
108            package_source.cyan()
109        );
110        package_source.to_string()
111    };
112
113    crate::cmd::install::run(
114        &[source_to_install],
115        None,
116        force,
117        all_optional,
118        yes,
119        scope_override.map(|s| match s {
120            types::Scope::User => crate::cli::InstallScope::User,
121            types::Scope::System => crate::cli::InstallScope::System,
122            types::Scope::Project => crate::cli::InstallScope::Project,
123        }),
124        false,
125        false,
126        false,
127        None,
128        false,
129        plugin_manager,
130        false,
131        false,
132        false,
133        false,
134        3,
135        false,
136        false,
137        None,
138    )?;
139
140    Ok(())
141}
142
143fn parse_repo_spec(spec: &str) -> Result<(String, String)> {
144    if let Some((provider_alias, path)) = spec.split_once(':') {
145        let provider = match provider_alias {
146            "gh" | "github" => "github",
147            "gl" | "gitlab" => "gitlab",
148            "cb" | "codeberg" => "codeberg",
149            _ => return Err(anyhow!("Unknown provider alias: {}", provider_alias)),
150        };
151        Ok((provider.to_string(), path.to_string()))
152    } else {
153        Ok(("github".to_string(), spec.to_string()))
154    }
155}
156
157fn get_repo_file_url(provider: &str, repo_path: &str, file_path: &str) -> Result<String> {
158    let branches = ["main", "master"];
159    let client = crate::pkg::utils::get_http_client()?;
160    for branch in &branches {
161        let url = match provider {
162            "github" => format!(
163                "https://raw.githubusercontent.com/{}/refs/heads/{}/{}",
164                repo_path, branch, file_path
165            ),
166            "gitlab" => format!(
167                "https://gitlab.com/{}/-/raw/{}/{}",
168                repo_path, branch, file_path
169            ),
170            "codeberg" => format!(
171                "https://codeberg.org/{}/raw/branch/{}/{}",
172                repo_path, branch, file_path
173            ),
174            _ => return Err(anyhow!("Unknown provider")),
175        };
176
177        let res = client.get(&url).send();
178        if let Ok(response) = res
179            && response.status().is_success()
180        {
181            return Ok(url);
182        }
183    }
184    Err(anyhow!(
185        "Could not find '{}' in repo '{}' on branches main or master.",
186        file_path,
187        repo_path
188    ))
189}