Skip to main content

aster_cli/recipes/
github_recipe.rs

1use anyhow::{anyhow, Result};
2use aster::recipe::template_recipe::parse_recipe_content;
3use aster::recipe::RECIPE_FILE_EXTENSIONS;
4use console::style;
5use serde::{Deserialize, Serialize};
6
7use aster::recipe::read_recipe_file_content::RecipeFile;
8use std::env;
9use std::fs;
10use std::path::Path;
11use std::path::PathBuf;
12use std::process::Command;
13use std::process::Stdio;
14use tar::Archive;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct RecipeInfo {
18    pub name: String,
19    pub source: RecipeSource,
20    pub path: String,
21    pub title: Option<String>,
22    pub description: Option<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub enum RecipeSource {
27    Local,
28    GitHub,
29}
30
31pub const ASTER_RECIPE_GITHUB_REPO_CONFIG_KEY: &str = "ASTER_RECIPE_GITHUB_REPO";
32pub fn retrieve_recipe_from_github(
33    recipe_name: &str,
34    recipe_repo_full_name: &str,
35) -> Result<RecipeFile> {
36    println!(
37        "📦 Looking for recipe \"{}\" in github repo: {}",
38        recipe_name, recipe_repo_full_name
39    );
40    ensure_gh_authenticated()?;
41    let max_attempts = 2;
42    let mut last_err = None;
43
44    for attempt in 1..=max_attempts {
45        match clone_and_download_recipe(recipe_name, recipe_repo_full_name) {
46            Ok(download_dir) => match read_recipe_file(&download_dir) {
47                Ok((content, recipe_file_local_path)) => {
48                    return Ok(RecipeFile {
49                        content,
50                        parent_dir: download_dir.clone(),
51                        file_path: recipe_file_local_path,
52                    })
53                }
54                Err(err) => return Err(err),
55            },
56            Err(err) => {
57                last_err = Some(err);
58            }
59        }
60        if attempt < max_attempts {
61            clean_cloned_dirs(recipe_repo_full_name)?;
62        }
63    }
64    Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Unknown error occurred")))
65}
66
67fn clean_cloned_dirs(recipe_repo_full_name: &str) -> anyhow::Result<()> {
68    let local_repo_path = get_local_repo_path(&env::temp_dir(), recipe_repo_full_name)?;
69    if local_repo_path.exists() {
70        fs::remove_dir_all(&local_repo_path)?;
71    }
72    Ok(())
73}
74fn read_recipe_file(download_dir: &Path) -> Result<(String, PathBuf)> {
75    for ext in RECIPE_FILE_EXTENSIONS {
76        let candidate_file_path = download_dir.join(format!("recipe.{}", ext));
77        if candidate_file_path.exists() {
78            let content = fs::read_to_string(&candidate_file_path)?;
79            println!(
80                "⬇️  Retrieved recipe file: {}",
81                candidate_file_path
82                    .strip_prefix(download_dir)
83                    .unwrap()
84                    .display()
85            );
86            return Ok((content, candidate_file_path));
87        }
88    }
89
90    Err(anyhow::anyhow!(
91        "No recipe file found in {} (looked for extensions: {:?})",
92        download_dir.display(),
93        RECIPE_FILE_EXTENSIONS
94    ))
95}
96
97fn clone_and_download_recipe(recipe_name: &str, recipe_repo_full_name: &str) -> Result<PathBuf> {
98    let local_repo_path = ensure_repo_cloned(recipe_repo_full_name)?;
99    fetch_origin(&local_repo_path)?;
100    get_folder_from_github(&local_repo_path, recipe_name)
101}
102
103pub fn ensure_gh_authenticated() -> Result<()> {
104    // Check authentication status
105    let status = Command::new("gh")
106        .args(["auth", "status"])
107        .status()
108        .map_err(|_| {
109            anyhow::anyhow!("Failed to run `gh auth status`. Make sure you have `gh` installed.")
110        })?;
111
112    if status.success() {
113        return Ok(());
114    }
115    println!("GitHub CLI is not authenticated. Launching `gh auth login`...");
116    // Run `gh auth login` interactively
117    let login_status = Command::new("gh")
118        .args(["auth", "login", "--git-protocol", "https"])
119        .status()
120        .map_err(|_| anyhow::anyhow!("Failed to run `gh auth login`"))?;
121
122    if !login_status.success() {
123        Err(anyhow::anyhow!("Failed to authenticate using GitHub CLI."))
124    } else {
125        Ok(())
126    }
127}
128
129fn get_local_repo_path(
130    local_repo_parent_path: &Path,
131    recipe_repo_full_name: &str,
132) -> Result<PathBuf> {
133    let (_, repo_name) = recipe_repo_full_name
134        .split_once('/')
135        .ok_or_else(|| anyhow::anyhow!("Invalid repository name format"))?;
136    let local_repo_path = local_repo_parent_path.to_path_buf().join(repo_name);
137    Ok(local_repo_path)
138}
139
140fn ensure_repo_cloned(recipe_repo_full_name: &str) -> Result<PathBuf> {
141    let local_repo_parent_path = env::temp_dir();
142    if !local_repo_parent_path.exists() {
143        std::fs::create_dir_all(local_repo_parent_path.clone())?;
144    }
145    let local_repo_path = get_local_repo_path(&local_repo_parent_path, recipe_repo_full_name)?;
146
147    if local_repo_path.join(".git").exists() {
148        Ok(local_repo_path)
149    } else {
150        let error_message: String = format!("Failed to clone repo: {}", recipe_repo_full_name);
151        let status = Command::new("gh")
152            .args(["repo", "clone", recipe_repo_full_name])
153            .current_dir(local_repo_parent_path.clone())
154            .status()
155            .map_err(|_: std::io::Error| anyhow::anyhow!(error_message.clone()))?;
156
157        if status.success() {
158            Ok(local_repo_path)
159        } else {
160            Err(anyhow::anyhow!(error_message))
161        }
162    }
163}
164
165fn fetch_origin(local_repo_path: &Path) -> Result<()> {
166    let error_message: String = format!("Failed to fetch at {}", local_repo_path.to_str().unwrap());
167    let status = Command::new("git")
168        .args(["fetch", "origin"])
169        .current_dir(local_repo_path)
170        .status()
171        .map_err(|_| anyhow::anyhow!(error_message.clone()))?;
172
173    if status.success() {
174        Ok(())
175    } else {
176        Err(anyhow::anyhow!(error_message))
177    }
178}
179
180fn get_folder_from_github(local_repo_path: &Path, recipe_name: &str) -> Result<PathBuf> {
181    let ref_and_path = format!("origin/main:{}", recipe_name);
182    let output_dir = env::temp_dir().join(recipe_name);
183
184    if output_dir.exists() {
185        fs::remove_dir_all(&output_dir)?;
186    }
187    fs::create_dir_all(&output_dir)?;
188
189    let archive_output = Command::new("git")
190        .args(["archive", &ref_and_path])
191        .current_dir(local_repo_path)
192        .stdout(Stdio::piped())
193        .spawn()?;
194
195    let stdout = archive_output
196        .stdout
197        .ok_or_else(|| anyhow::anyhow!("Failed to capture stdout from git archive"))?;
198
199    let mut archive = Archive::new(stdout);
200    archive.unpack(&output_dir)?;
201    list_files(&output_dir)?;
202
203    Ok(output_dir)
204}
205
206fn list_files(dir: &Path) -> Result<()> {
207    println!("{}", style("Files downloaded from github:").bold());
208    for entry in fs::read_dir(dir)? {
209        let entry = entry?;
210        let path = entry.path();
211        if path.is_file() {
212            println!("  - {}", path.display());
213        }
214    }
215    Ok(())
216}
217
218/// Lists all available recipes from a GitHub repository
219pub fn list_github_recipes(repo: &str) -> Result<Vec<RecipeInfo>> {
220    discover_github_recipes(repo)
221}
222
223fn discover_github_recipes(repo: &str) -> Result<Vec<RecipeInfo>> {
224    use serde_json::Value;
225    use std::process::Command;
226
227    // Ensure GitHub CLI is authenticated
228    ensure_gh_authenticated()?;
229
230    // Get repository contents using GitHub CLI
231    let output = Command::new("gh")
232        .args(["api", &format!("repos/{}/contents", repo)])
233        .output()
234        .map_err(|e| anyhow!("Failed to fetch repository contents using 'gh api' command (executed when ASTER_RECIPE_GITHUB_REPO is configured). This requires GitHub CLI (gh) to be installed and authenticated. Error: {}", e))?;
235
236    if !output.status.success() {
237        let error_msg = String::from_utf8_lossy(&output.stderr);
238        return Err(anyhow!("GitHub API request failed: {}", error_msg));
239    }
240
241    let contents: Value = serde_json::from_slice(&output.stdout)
242        .map_err(|e| anyhow!("Failed to parse GitHub API response: {}", e))?;
243
244    let mut recipes = Vec::new();
245
246    if let Some(items) = contents.as_array() {
247        for item in items {
248            if let (Some(name), Some(item_type)) = (
249                item.get("name").and_then(|n| n.as_str()),
250                item.get("type").and_then(|t| t.as_str()),
251            ) {
252                if item_type == "dir" {
253                    // Check if this directory contains a recipe file
254                    if let Ok(recipe_info) = check_github_directory_for_recipe(repo, name) {
255                        recipes.push(recipe_info);
256                    }
257                }
258            }
259        }
260    }
261
262    Ok(recipes)
263}
264
265fn check_github_directory_for_recipe(repo: &str, dir_name: &str) -> Result<RecipeInfo> {
266    use serde_json::Value;
267    use std::process::Command;
268
269    // Check directory contents for recipe files
270    let output = Command::new("gh")
271        .args(["api", &format!("repos/{}/contents/{}", repo, dir_name)])
272        .output()
273        .map_err(|e| anyhow!("Failed to check directory contents: {}", e))?;
274
275    if !output.status.success() {
276        return Err(anyhow!("Failed to access directory: {}", dir_name));
277    }
278
279    let contents: Value = serde_json::from_slice(&output.stdout)
280        .map_err(|e| anyhow!("Failed to parse directory contents: {}", e))?;
281
282    if let Some(items) = contents.as_array() {
283        for item in items {
284            if let Some(name) = item.get("name").and_then(|n| n.as_str()) {
285                if RECIPE_FILE_EXTENSIONS
286                    .iter()
287                    .any(|ext| name == format!("recipe.{}", ext))
288                {
289                    // Found a recipe file, get its content
290                    return get_github_recipe_info(repo, dir_name, name);
291                }
292            }
293        }
294    }
295
296    Err(anyhow!("No recipe file found in directory: {}", dir_name))
297}
298
299fn get_github_recipe_info(repo: &str, dir_name: &str, recipe_filename: &str) -> Result<RecipeInfo> {
300    use serde_json::Value;
301    use std::process::Command;
302
303    // Get the recipe file content
304    let output = Command::new("gh")
305        .args([
306            "api",
307            &format!("repos/{}/contents/{}/{}", repo, dir_name, recipe_filename),
308        ])
309        .output()
310        .map_err(|e| anyhow!("Failed to get recipe file content: {}", e))?;
311
312    if !output.status.success() {
313        return Err(anyhow!(
314            "Failed to access recipe file: {}/{}",
315            dir_name,
316            recipe_filename
317        ));
318    }
319
320    let file_info: Value = serde_json::from_slice(&output.stdout)
321        .map_err(|e| anyhow!("Failed to parse file info: {}", e))?;
322
323    if let Some(content_b64) = file_info.get("content").and_then(|c| c.as_str()) {
324        // Decode base64 content
325        use base64::{engine::general_purpose, Engine as _};
326        let content_bytes = general_purpose::STANDARD
327            .decode(content_b64.replace('\n', ""))
328            .map_err(|e| anyhow!("Failed to decode base64 content: {}", e))?;
329
330        let content = String::from_utf8(content_bytes)
331            .map_err(|e| anyhow!("Failed to convert content to string: {}", e))?;
332
333        // Parse the recipe content
334        let (recipe, _) = parse_recipe_content(&content, Some(format!("{}/{}", repo, dir_name)))?;
335
336        return Ok(RecipeInfo {
337            name: dir_name.to_string(),
338            source: RecipeSource::GitHub,
339            path: format!("{}/{}", repo, dir_name),
340            title: Some(recipe.title),
341            description: Some(recipe.description),
342        });
343    }
344
345    Err(anyhow!("Failed to get recipe content from GitHub"))
346}