Skip to main content

zoi_package/
registry.rs

1use crate::doctor as pkg_doctor;
2use crate::init_lsp;
3use anyhow::{Result, anyhow};
4use chrono::Datelike;
5use colored::*;
6use std::collections::HashMap;
7use std::fs;
8use std::path::Path;
9use walkdir::WalkDir;
10use zoi_core::types;
11use zoi_lua;
12
13pub fn init(path: &Path) -> Result<()> {
14    println!(
15        "{} Initializing new Zoi registry at {}...",
16        "::".bold().blue(),
17        path.display()
18    );
19
20    fs::create_dir_all(path)?;
21
22    let dirs = ["core", "main", "community", "test", "archive"];
23    for dir in &dirs {
24        let dir_path = path.join(dir);
25        if !dir_path.exists() {
26            fs::create_dir_all(&dir_path)?;
27        }
28    }
29
30    init_lsp::setup_lsp_workspace(path)?;
31    println!(
32        "{} LSP support initialized. Created .luarc.json and type definitions.",
33        "::".bold().green()
34    );
35
36    let repo_yaml_path = path.join("repo.yaml");
37    if !repo_yaml_path.exists() {
38        let content = r#"# Zoi Registry Configuration
39# For detailed documentation, visit: https://zillowe.qzz.io/docs/zds/zoi/repositories#the-repoyaml-file
40
41name: "My-Registry"
42description: "A custom Zoi package registry"
43handle: "my-registry"
44advisory_prefix: "RSA" # Prefix for security advisories, e.g. RSA-2026-A0001
45
46# Git mirrors for this registry
47# For more info: https://zillowe.qzz.io/docs/zds/zoi/guides/mirroring
48git:
49  - type: main
50    url: "https://github.com/user/my-registry.git"
51
52# Pre-built package mirrors (optional)
53# pkg:
54#   - type: main
55#     url: "https://example.com/pkgs/{repo}/{os}/{arch}/{version}"
56
57# Trusted PGP keys for this registry
58# pgp:
59#   - name: maintainer-key
60#     key: "https://example.com/keys/maintainer.asc"
61
62# Repository tiers
63repos:
64  - name: core
65    type: official
66    active: true
67  - name: main
68    type: official
69    active: true
70  - name: community
71    type: community
72    active: false
73  - name: test
74    type: test
75    active: false
76  - name: archive
77    type: archive
78    active: false
79"#;
80        fs::write(repo_yaml_path, content)?;
81    }
82
83    let packages_json_path = path.join("packages.json");
84    if !packages_json_path.exists() {
85        let content = r#"{
86  "version": "2",
87  "packages": {}
88}"#;
89        fs::write(packages_json_path, content)?;
90    }
91
92    let advisories_json_path = path.join("advisories.json");
93    if !advisories_json_path.exists() {
94        let current_year = chrono::Utc::now().year();
95        let content = format!(
96            r#"{{
97  "version": "2",
98  "advisories": {{}},
99  "last_id": 0,
100  "year": {}
101}}"#,
102            current_year
103        );
104        fs::write(advisories_json_path, content)?;
105    }
106
107    println!("{}", "Registry initialized successfully.".green());
108    println!(
109        "{} Edit 'repo.yaml' to configure your registry mirrors and authorities.",
110        "Note:".yellow()
111    );
112    Ok(())
113}
114
115pub fn add_package(registry_root: &Path, name: Option<&str>, repo: Option<&str>) -> Result<()> {
116    if !registry_root.join("repo.yaml").exists() {
117        return Err(anyhow!(
118            "Not a Zoi registry (missing repo.yaml). Run 'zoi reg init' first."
119        ));
120    }
121
122    use std::io::{Write, stdin, stdout};
123    let get_input = |prompt: &str| -> String {
124        print!("{}: ", prompt);
125        let _ = stdout().flush();
126        let mut input = String::new();
127        let _ = stdin().read_line(&mut input);
128        input.trim().to_string()
129    };
130
131    let name = match name {
132        Some(n) => n.to_string(),
133        None => get_input("Package name"),
134    };
135
136    let repo = match repo {
137        Some(r) => r.to_string(),
138        None => get_input("Repository tier (e.g. community, main)"),
139    };
140
141    if name.is_empty() || repo.is_empty() {
142        return Err(anyhow!("Package name and repository tier are required."));
143    }
144
145    let pkg_dir = registry_root.join(&repo).join(&name);
146    fs::create_dir_all(&pkg_dir)?;
147
148    let pkg_lua_path = pkg_dir.join(format!("{}.pkg.lua", name));
149    if pkg_lua_path.exists() {
150        return Err(anyhow!(
151            "Package '{}' already exists in repo '{}'.",
152            name,
153            repo
154        ));
155    }
156
157    let content = format!(
158        r#"-- Zoi Package Definition: {name}
159-- For detailed documentation, visit: https://zillowe.qzz.io/docs/zds/zoi/creating-packages
160
161metadata({{
162  name = "{name}",
163  repo = "{repo}",
164  version = "1.0.0",
165  revision = "1",
166  description = "A short description of {name}.",
167  website = "https://example.com",
168  license = "Apache-2.0",
169  maintainer = {{ name = "Your Name", email = "you@example.com" }},
170  bins = {{ "{name}" }},
171  types = {{ "pre-compiled" }}, -- Supports "source", "pre-compiled"
172}})
173
174dependencies({{
175  build = {{
176    -- Build-time dependencies
177    -- For format info: https://zillowe.qzz.io/docs/zds/zoi/dependencies
178  }},
179  runtime = {{
180    -- Runtime dependencies
181  }}
182}})
183
184function prepare()
185  -- Fetch source or binaries
186  -- Example: UTILS.EXTRACT("https://example.com/release.tar.gz", "src")
187end
188
189function package()
190  -- Stage files for the package
191  -- Example: zcp("src/{name}", "${{pkgstore}}/bin/{name}")
192end
193
194-- function verify()
195--   -- Security verification
196--   -- return verifyHash("release.tar.gz", "sha256-...")
197-- end
198
199-- function test()
200--   -- Integration tests (run via zoi package test)
201--   -- local _, _, code = cmd(STAGING_DIR .. "/data/pkgstore/bin/{name} --version")
202--   -- return code == 0
203-- end
204
205function uninstall()
206  -- Cleanup outside the package store
207end
208"#,
209        name = name,
210        repo = repo
211    );
212
213    fs::write(pkg_lua_path, content)?;
214    println!(
215        "{} Package '{}' created in repo '{}'.",
216        "::".bold().green(),
217        name.cyan(),
218        repo.cyan()
219    );
220
221    Ok(())
222}
223
224pub fn add_advisory(
225    registry_root: &Path,
226    package_name: Option<&str>,
227    repo: Option<&str>,
228) -> Result<()> {
229    if !registry_root.join("repo.yaml").exists() {
230        return Err(anyhow!(
231            "Not a Zoi registry (missing repo.yaml). Run 'zoi reg init' first."
232        ));
233    }
234
235    use std::io::{Write, stdin, stdout};
236    let get_input = |prompt: &str| -> String {
237        print!("{}: ", prompt);
238        let _ = stdout().flush();
239        let mut input = String::new();
240        let _ = stdin().read_line(&mut input);
241        input.trim().to_string()
242    };
243
244    let package_name = match package_name {
245        Some(n) => n.to_string(),
246        None => get_input("Package name"),
247    };
248
249    let package_name_str = package_name.as_str();
250
251    let pkg_dir = if let Some(r) = repo {
252        let dir = registry_root.join(r).join(package_name_str);
253        if !dir.join(format!("{}.pkg.lua", package_name_str)).exists() {
254            return Err(anyhow!(
255                "Package '{}' not found in repo '{}'.",
256                package_name_str,
257                r
258            ));
259        }
260        dir
261    } else {
262        let mut found = None;
263        for entry in WalkDir::new(registry_root)
264            .into_iter()
265            .filter_map(|e| e.ok())
266        {
267            if entry.file_type().is_dir()
268                && entry.file_name().to_string_lossy() == package_name_str
269                && entry
270                    .path()
271                    .join(format!("{}.pkg.lua", package_name_str))
272                    .exists()
273            {
274                found = Some(entry.path().to_path_buf());
275                break;
276            }
277        }
278        found.ok_or_else(|| {
279            anyhow!(
280                "Package '{}' not found in registry. Try specifying --repo.",
281                package_name_str
282            )
283        })?
284    };
285
286    let repo_config_str = fs::read_to_string(registry_root.join("repo.yaml"))?;
287    let repo_config: types::RepoConfig = serde_yaml::from_str(&repo_config_str)?;
288    let prefix = repo_config
289        .advisory_prefix
290        .unwrap_or_else(|| "ZSA".to_string());
291
292    let current_year = chrono::Utc::now().year();
293    let adv_file_path = pkg_dir.join(format!("{}-{}-TEMP.sec.yaml", prefix, current_year));
294
295    if adv_file_path.exists() {
296        return Err(anyhow!(
297            "A temporary advisory already exists for this package."
298        ));
299    }
300
301    println!(
302        "{} Adding security advisory for package: {}",
303        "::".bold().blue(),
304        package_name_str.cyan()
305    );
306    println!(
307        "For detailed documentation, visit: https://zillowe.qzz.io/docs/zds/zoi/guides/security-advisories\n"
308    );
309
310    let summary = get_input("Summary (short description)");
311    let severity = get_input("Severity (low, medium, high, critical)");
312    let affected_range = get_input("Affected version range (e.g. >=1.0.0, <1.2.3)");
313    let fixed_in = get_input("Fixed in version");
314    let description = get_input("Detailed description");
315    let reference = get_input("Reference URL (optional)");
316
317    let content = format!(
318        r#"# Zoi Security Advisory
319# For schema details: https://zillowe.qzz.io/docs/zds/zoi/guides/security-advisories#advisory-schema
320
321id: "{prefix}-{year}-TEMP"
322package: "{package_name}"
323summary: "{summary}"
324severity: "{severity}"
325affected_range: "{affected_range}"
326fixed_in: "{fixed_in}"
327description: |
328  {description}
329references:
330  - "{reference}"
331"#,
332        prefix = prefix,
333        year = current_year,
334        package_name = package_name_str,
335        summary = summary,
336        severity = severity,
337        affected_range = affected_range,
338        fixed_in = fixed_in,
339        description = description,
340        reference = reference
341    );
342
343    fs::write(&adv_file_path, content)?;
344    println!(
345        "\n{} Temporary advisory created: {}",
346        "::".bold().green(),
347        adv_file_path.display().to_string().cyan()
348    );
349    println!(
350        "{} ID will be automatically assigned during 'zoi reg gen-meta' or in CI.",
351        "Note:".yellow()
352    );
353
354    Ok(())
355}
356
357/// Scans the entire registry to generate optimized JSON index files.
358///
359/// This function is the "Build Pipeline" for Zoi registries. It:
360/// - Chronologically assigns permanent IDs to new security advisories.
361/// - Parses every `.pkg.lua` file to extract static metadata.
362/// - Populates `packages.json` (the primary index) and `advisories.json`.
363/// - Enables clients to resolve packages and vulnerabilities without cloning
364///   the entire Git repository or parsing thousands of Lua scripts.
365pub fn generate_metadata(registry_root: &Path) -> Result<()> {
366    if !registry_root.join("repo.yaml").exists() {
367        return Err(anyhow!(
368            "Not a Zoi registry (missing repo.yaml). Run 'zoi reg init' first."
369        ));
370    }
371
372    println!("{} Generating registry metadata...", "::".bold().blue());
373
374    let repo_config_str = fs::read_to_string(registry_root.join("repo.yaml"))?;
375    let repo_config: types::RepoConfig = serde_yaml::from_str(&repo_config_str)?;
376    let advisory_prefix = repo_config
377        .advisory_prefix
378        .clone()
379        .unwrap_or_else(|| "ZSA".to_string());
380
381    let advisories_json_path = registry_root.join("advisories.json");
382    let mut adv_registry: types::AdvisoryRegistry = if advisories_json_path.exists() {
383        serde_json::from_str(&fs::read_to_string(&advisories_json_path)?)?
384    } else {
385        types::AdvisoryRegistry::default()
386    };
387
388    let current_year = chrono::Utc::now().year();
389    if adv_registry.year != current_year as u32 {
390        adv_registry.year = current_year as u32;
391        adv_registry.last_id = 0;
392    }
393
394    for entry in WalkDir::new(registry_root)
395        .into_iter()
396        .filter_map(|e| e.ok())
397    {
398        let file_name = entry.file_name().to_string_lossy();
399        if file_name.ends_with("-TEMP.sec.yaml") {
400            let path = entry.path();
401            let content_str = fs::read_to_string(path)?;
402            let mut content: serde_yaml::Value = serde_yaml::from_str(&content_str)?;
403            let severity = content
404                .get("severity")
405                .and_then(|v| v.as_str())
406                .unwrap_or("low")
407                .to_lowercase();
408            let sev_char = match severity.as_str() {
409                "low" => "A",
410                "medium" => "B",
411                "high" => "C",
412                "critical" => "D",
413                _ => "A",
414            };
415
416            adv_registry.last_id += 1;
417            let final_id = format!(
418                "{}-{}-{}{:04}",
419                advisory_prefix, current_year, sev_char, adv_registry.last_id
420            );
421
422            if let Some(mapping) = content.as_mapping_mut() {
423                mapping.insert(
424                    serde_yaml::Value::String("id".to_string()),
425                    serde_yaml::Value::String(final_id.clone()),
426                );
427            }
428
429            let final_path = path.with_file_name(format!("{}.sec.yaml", final_id));
430            fs::write(&final_path, serde_yaml::to_string(&content)?)?;
431            fs::remove_file(path)?;
432            println!("Assigned ID {} to {}", final_id.green(), path.display());
433        }
434    }
435
436    let mut advisories_map = std::collections::BTreeMap::new();
437    let mut max_id = adv_registry.last_id;
438
439    for entry in WalkDir::new(registry_root)
440        .into_iter()
441        .filter_map(|e| e.ok())
442    {
443        let file_name = entry.file_name().to_string_lossy();
444        if file_name.ends_with(".sec.yaml") && !file_name.ends_with("-TEMP.sec.yaml") {
445            let content_str = fs::read_to_string(entry.path())?;
446            let content: types::Advisory = serde_yaml::from_str(&content_str)?;
447            if let Some(last_part) = content.id.split('-').next_back() {
448                let id_num_str = if last_part.len() > 4 {
449                    &last_part[1..]
450                } else {
451                    last_part
452                };
453                if let Ok(id_num) = id_num_str.parse::<u32>() {
454                    if id_num > max_id {
455                        max_id = id_num;
456                    }
457                    let rel_path = entry.path().strip_prefix(registry_root)?;
458                    advisories_map
459                        .insert(content.id.clone(), rel_path.to_string_lossy().to_string());
460                }
461            }
462        }
463    }
464
465    adv_registry.last_id = max_id;
466    adv_registry.advisories = advisories_map;
467    adv_registry.version = "2".to_string();
468    fs::write(
469        &advisories_json_path,
470        serde_json::to_string_pretty(&adv_registry)?,
471    )?;
472
473    let mut packages_map = std::collections::BTreeMap::new();
474    let repo_types: HashMap<String, String> = repo_config
475        .repos
476        .iter()
477        .map(|r| (r.name.clone(), r.repo_type.clone()))
478        .collect();
479
480    for entry in WalkDir::new(registry_root)
481        .into_iter()
482        .filter_map(|e| e.ok())
483    {
484        if entry.file_type().is_file() && entry.file_name().to_string_lossy().ends_with(".pkg.lua")
485        {
486            let path = entry.path();
487            let path_str = path.to_string_lossy();
488            if let Ok(pkg) = zoi_lua::parser::parse_lua_package(&path_str, None, None, true) {
489                let rel_path = path.strip_prefix(registry_root)?;
490                let mut repo_parts: Vec<_> = rel_path
491                    .components()
492                    .map(|c| c.as_os_str().to_string_lossy().to_string())
493                    .collect();
494                repo_parts.pop();
495                repo_parts.pop();
496                let repo_path = repo_parts.join("/");
497
498                let major_repo = repo_path.split('/').next().unwrap_or_default();
499                let repo_type = repo_types
500                    .get(major_repo)
501                    .cloned()
502                    .unwrap_or_else(|| "unofficial".to_string());
503
504                let version = pkg
505                    .version
506                    .clone()
507                    .or_else(|| pkg.versions.as_ref().and_then(|v| v.get("stable").cloned()))
508                    .unwrap_or_else(|| "unknown".to_string());
509
510                let mut vulns = Vec::new();
511                let pkg_dir = path
512                    .parent()
513                    .ok_or_else(|| anyhow!("Package path has no parent directory"))?;
514                if let Ok(sec_entries) = fs::read_dir(pkg_dir) {
515                    for sec_entry in sec_entries.flatten() {
516                        if sec_entry
517                            .file_name()
518                            .to_string_lossy()
519                            .ends_with(".sec.yaml")
520                        {
521                            let sec_content_str = fs::read_to_string(sec_entry.path())?;
522                            if let Ok(adv) =
523                                serde_yaml::from_str::<types::Advisory>(&sec_content_str)
524                            {
525                                vulns.push(zoi_core::types::MiniVulnerability {
526                                    id: adv.id,
527                                    severity: format!("{:?}", adv.severity).to_lowercase(),
528                                    affected_range: adv.affected_range,
529                                    fixed_in: adv.fixed_in,
530                                    summary: adv.summary,
531                                });
532                            }
533                        }
534                    }
535                }
536
537                let packages_key = format!("@{}/{}", repo_path, pkg.name);
538
539                let dependencies_v2 = pkg.dependencies.map(|deps| {
540                    let mut runtime = Vec::new();
541                    if let Some(r) = deps.runtime {
542                        runtime = match r {
543                            types::DependencyGroup::Simple(d) => d,
544                            types::DependencyGroup::Complex(c) => {
545                                let mut all = c.required;
546                                all.extend(c.optional);
547                                for opt in c.options {
548                                    all.extend(opt.depends);
549                                }
550                                all
551                            }
552                        };
553                    }
554
555                    let mut build = Vec::new();
556                    if let Some(b) = deps.build {
557                        match b {
558                            types::BuildDependencies::Group(g) => {
559                                let packages = match g {
560                                    types::DependencyGroup::Simple(d) => d,
561                                    types::DependencyGroup::Complex(c) => {
562                                        let mut all = c.required;
563                                        all.extend(c.optional);
564                                        for opt in c.options {
565                                            all.extend(opt.depends);
566                                        }
567                                        all
568                                    }
569                                };
570                                build.push(types::BuildDependencyV2 {
571                                    build_type: "source".to_string(),
572                                    packages,
573                                });
574                            }
575                            types::BuildDependencies::Typed(t) => {
576                                for (bt, g) in t.types {
577                                    let packages = match g {
578                                        types::DependencyGroup::Simple(d) => d,
579                                        types::DependencyGroup::Complex(c) => {
580                                            let mut all = c.required;
581                                            all.extend(c.optional);
582                                            for opt in c.options {
583                                                all.extend(opt.depends);
584                                            }
585                                            all
586                                        }
587                                    };
588                                    build.push(types::BuildDependencyV2 {
589                                        build_type: bt,
590                                        packages,
591                                    });
592                                }
593                            }
594                        }
595                    }
596
597                    let mut test = Vec::new();
598                    if let Some(t) = deps.test {
599                        test = match t {
600                            types::DependencyGroup::Simple(d) => d,
601                            types::DependencyGroup::Complex(c) => {
602                                let mut all = c.required;
603                                all.extend(c.optional);
604                                for opt in c.options {
605                                    all.extend(opt.depends);
606                                }
607                                all
608                            }
609                        };
610                    }
611
612                    types::DependenciesV2 {
613                        runtime,
614                        build,
615                        test,
616                    }
617                });
618
619                packages_map.insert(
620                    packages_key,
621                    types::PurlPackageIndexV2 {
622                        repo: repo_path,
623                        repo_type,
624                        version,
625                        epoch: pkg.epoch,
626                        revision: pkg.revision.clone(),
627                        description: pkg.description,
628                        scope: Some(pkg.scope),
629                        scopes: pkg.scopes.clone(),
630                        dependencies: dependencies_v2,
631                        sub_packages: pkg.sub_packages.unwrap_or_default(),
632                        main_sub_packages: pkg.main_subs.unwrap_or_default(),
633                        vuln: vulns,
634                    },
635                );
636            }
637        }
638    }
639
640    let index = types::RegistryIndexV2 {
641        version: "2".to_string(),
642        packages: packages_map,
643    };
644    fs::write(
645        registry_root.join("packages.json"),
646        serde_json::to_string_pretty(&index)?,
647    )?;
648
649    println!("{}", "Metadata generation complete.".green());
650
651    Ok(())
652}
653
654pub fn check(registry_root: &Path) -> Result<()> {
655    if !registry_root.join("repo.yaml").exists() {
656        return Err(anyhow!(
657            "Not a Zoi registry (missing repo.yaml). Run 'zoi reg init' first."
658        ));
659    }
660
661    println!("{} Checking registry integrity...", "::".bold().blue());
662
663    let mut errors = 0;
664    let mut warnings = 0;
665
666    for entry in WalkDir::new(registry_root)
667        .into_iter()
668        .filter_map(|e| e.ok())
669    {
670        if entry.file_type().is_file() && entry.file_name().to_string_lossy().ends_with(".pkg.lua")
671        {
672            println!(
673                "  Checking {}...",
674                entry.path().display().to_string().cyan()
675            );
676            match pkg_doctor::run(entry.path(), None, None) {
677                Ok(report) => {
678                    for error in &report.errors {
679                        eprintln!("    {} {}", "Error:".red().bold(), error);
680                        errors += 1;
681                    }
682                    for warning in &report.warnings {
683                        println!("    {} {}", "Warning:".yellow().bold(), warning);
684                        warnings += 1;
685                    }
686                }
687                Err(e) => {
688                    eprintln!(
689                        "    {} Failed to parse package: {}",
690                        "Error:".red().bold(),
691                        e
692                    );
693                    errors += 1;
694                }
695            }
696        }
697    }
698
699    if errors > 0 {
700        return Err(anyhow!(
701            "Registry check failed with {} error(s) and {} warning(s).",
702            errors,
703            warnings
704        ));
705    }
706
707    println!(
708        "{} Registry check passed with {} warning(s).",
709        "::".bold().green(),
710        warnings
711    );
712    Ok(())
713}