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