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
334 let current_year = chrono::Utc::now().year();
335 let adv_file_path =
336 pkg_dir.join(format!("{prefix}-{current_year}-TEMP.sec.yaml"));
337
338 if adv_file_path.exists() {
339 return Err(anyhow!(
340 "A temporary advisory already exists for this package."
341 ));
342 }
343
344 println!(
345 "{} Adding security advisory for package: {}",
346 "::".bold().blue(),
347 package_name_str.cyan()
348 );
349 println!(
350 "For detailed documentation, visit: https://zillowe.qzz.io/docs/zds/zoi/guides/security-advisories\n"
351 );
352
353 let summary = get_input("Summary (short description)");
354 let severity = get_input("Severity (low, medium, high, critical)");
355 let affected_range =
356 get_input("Affected version range (e.g. >=1.0.0, <1.2.3)");
357 let fixed_in = get_input("Fixed in version");
358 let description = get_input("Detailed description");
359 let reference = get_input("Reference URL (optional)");
360
361 let content = format!(
362 r#"# Zoi Security Advisory
363# For schema details: https://zillowe.qzz.io/docs/zds/zoi/guides/security-advisories#advisory-schema
364
365id: "{prefix}-{current_year}-TEMP"
366package: "{package_name}"
367summary: "{summary}"
368severity: "{severity}"
369affected_range: "{affected_range}"
370fixed_in: "{fixed_in}"
371description: |
372 {description}
373references:
374 - "{reference}"
375"#,
376 );
377
378 fs::write(&adv_file_path, content)?;
379 println!(
380 "\n{} Temporary advisory created: {}",
381 "::".bold().green(),
382 adv_file_path.display().to_string().cyan()
383 );
384 println!(
385 "{} ID will be automatically assigned during 'zoi reg gen-meta' or in \
386 CI.",
387 "Note:".yellow()
388 );
389
390 Ok(())
391}
392
393pub fn generate_metadata(registry_root: &Path) -> Result<()> {
409 if !registry_root.join("repo.yaml").exists() {
410 return Err(anyhow!(
411 "Not a Zoi registry (missing repo.yaml). Run 'zoi reg init' first."
412 ));
413 }
414
415 println!("{} Generating registry metadata...", "::".bold().blue());
416
417 let repo_config_str = fs::read_to_string(registry_root.join("repo.yaml"))?;
418 let repo_config: types::RepoConfig =
419 serde_yaml::from_str(&repo_config_str)?;
420 let advisory_prefix = repo_config
421 .advisory_prefix
422 .clone()
423 .unwrap_or_else(|| "ZSA".to_string());
424
425 let advisories_json_path = registry_root.join("advisories.json");
426 let mut adv_registry: types::AdvisoryRegistry =
427 if advisories_json_path.exists() {
428 serde_json::from_str(&fs::read_to_string(&advisories_json_path)?)?
429 } else {
430 types::AdvisoryRegistry::default()
431 };
432
433 let current_year = chrono::Utc::now().year();
434 let current_year_u32 = u32::try_from(current_year).unwrap_or(0);
435 if adv_registry.year != current_year_u32 {
436 adv_registry.year = current_year_u32;
437 adv_registry.last_id = 0;
438 }
439
440 for entry in WalkDir::new(registry_root)
441 .into_iter()
442 .filter_map(Result::ok)
443 {
444 let file_name = entry.file_name().to_string_lossy();
445 if file_name.ends_with("-TEMP.sec.yaml") {
446 let path = entry.path();
447 let content_str = fs::read_to_string(path)?;
448 let mut content: serde_yaml::Value =
449 serde_yaml::from_str(&content_str)?;
450 let severity = content
451 .get("severity")
452 .and_then(|v| v.as_str())
453 .unwrap_or("low")
454 .to_lowercase();
455 let sev_char = match severity.as_str() {
456 "medium" => "B",
457 "high" => "C",
458 "critical" => "D",
459 _ => "A"
460 };
461
462 adv_registry.last_id += 1;
463 let final_id = format!(
464 "{advisory_prefix}-{current_year}-{sev_char}{:04}",
465 adv_registry.last_id
466 );
467
468 if let Some(mapping) = content.as_mapping_mut() {
469 mapping.insert(
470 serde_yaml::Value::String("id".to_string()),
471 serde_yaml::Value::String(final_id.clone())
472 );
473 }
474
475 let final_path =
476 path.with_file_name(format!("{final_id}.sec.yaml"));
477 fs::write(&final_path, serde_yaml::to_string(&content)?)?;
478 fs::remove_file(path)?;
479 println!("Assigned ID {} to {}", final_id.green(), path.display());
480 }
481 }
482
483 let mut advisories_map = std::collections::BTreeMap::new();
484 let mut max_id = adv_registry.last_id;
485
486 for entry in WalkDir::new(registry_root)
487 .into_iter()
488 .filter_map(Result::ok)
489 {
490 let file_name = entry.file_name().to_string_lossy();
491 if file_name.ends_with(".sec.yaml")
492 && !file_name.ends_with("-TEMP.sec.yaml")
493 {
494 let content_str = fs::read_to_string(entry.path())?;
495 let content: types::Advisory = serde_yaml::from_str(&content_str)?;
496 if let Some(last_part) = content.id.split('-').next_back() {
497 let id_num_str = if last_part.len() > 4 {
498 &last_part[1..]
499 } else {
500 last_part
501 };
502 if let Ok(id_num) = id_num_str.parse::<u32>() {
503 if id_num > max_id {
504 max_id = id_num;
505 }
506 let rel_path = entry.path().strip_prefix(registry_root)?;
507 advisories_map.insert(
508 content.id.clone(),
509 rel_path.to_string_lossy().to_string()
510 );
511 }
512 }
513 }
514 }
515
516 adv_registry.last_id = max_id;
517 adv_registry.advisories = advisories_map;
518 adv_registry.version = "2".to_string();
519 fs::write(
520 &advisories_json_path,
521 serde_json::to_string_pretty(&adv_registry)?
522 )?;
523
524 let mut packages_map = std::collections::BTreeMap::new();
525 let repo_types: HashMap<String, String> = repo_config
526 .repos
527 .iter()
528 .map(|r| (r.name.clone(), r.repo_type.clone()))
529 .collect();
530
531 for entry in WalkDir::new(registry_root)
532 .into_iter()
533 .filter_map(Result::ok)
534 {
535 if entry.file_type().is_file()
536 && entry.file_name().to_string_lossy().ends_with(".pkg.lua")
537 {
538 let path = entry.path();
539 let path_str = path.to_string_lossy();
540 if let Ok(pkg) =
541 zoi_lua::parser::parse_lua_package(&path_str, None, None, true)
542 {
543 let rel_path = path.strip_prefix(registry_root)?;
544 let mut repo_parts: Vec<_> = rel_path
545 .components()
546 .map(|c| c.as_os_str().to_string_lossy().to_string())
547 .collect();
548 repo_parts.pop();
549 repo_parts.pop();
550 let repo_path = repo_parts.join("/");
551
552 let major_repo =
553 repo_path.split('/').next().unwrap_or_default();
554 let repo_type = repo_types
555 .get(major_repo)
556 .cloned()
557 .unwrap_or_else(|| "unofficial".to_string());
558
559 let version = pkg
560 .version
561 .clone()
562 .or_else(|| {
563 pkg.versions
564 .as_ref()
565 .and_then(|v| v.get("stable").cloned())
566 })
567 .unwrap_or_else(|| "unknown".to_string());
568
569 let mut vulns = Vec::new();
570 let pkg_dir = path.parent().ok_or_else(|| {
571 anyhow!("Package path has no parent directory")
572 })?;
573 if let Ok(sec_entries) = fs::read_dir(pkg_dir) {
574 for sec_entry in sec_entries.flatten() {
575 if sec_entry
576 .file_name()
577 .to_string_lossy()
578 .ends_with(".sec.yaml")
579 {
580 let sec_content_str =
581 fs::read_to_string(sec_entry.path())?;
582 if let Ok(adv) =
583 serde_yaml::from_str::<types::Advisory>(
584 &sec_content_str
585 )
586 {
587 vulns.push(
588 zoi_core::types::MiniVulnerability {
589 id: adv.id,
590 severity: format!("{:?}", adv.severity)
591 .to_lowercase(),
592 affected_range: adv.affected_range,
593 fixed_in: adv.fixed_in,
594 summary: adv.summary
595 }
596 );
597 }
598 }
599 }
600 }
601
602 let packages_key =
603 format!("@{repo_path}/{name}", name = pkg.name);
604
605 let dependencies_v2 = pkg.dependencies.map(|deps| {
606 let mut runtime = Vec::new();
607 if let Some(r) = deps.runtime {
608 runtime = match r {
609 types::DependencyGroup::Simple(d) => d,
610 types::DependencyGroup::Complex(c) => {
611 let mut all = c.required;
612 all.extend(c.optional);
613 for opt in c.options {
614 all.extend(opt.depends);
615 }
616 all
617 }
618 };
619 }
620
621 let mut build = Vec::new();
622 if let Some(b) = deps.build {
623 match b {
624 types::BuildDependencies::Group(g) => {
625 let packages = match g {
626 types::DependencyGroup::Simple(d) => d,
627 types::DependencyGroup::Complex(c) => {
628 let mut all = c.required;
629 all.extend(c.optional);
630 for opt in c.options {
631 all.extend(opt.depends);
632 }
633 all
634 }
635 };
636 build.push(types::BuildDependencyV2 {
637 build_type: "source".to_string(),
638 packages
639 });
640 }
641 types::BuildDependencies::Typed(t) => {
642 for (bt, g) in t.types {
643 let packages = match g {
644 types::DependencyGroup::Simple(d) => d,
645 types::DependencyGroup::Complex(c) => {
646 let mut all = c.required;
647 all.extend(c.optional);
648 for opt in c.options {
649 all.extend(opt.depends);
650 }
651 all
652 }
653 };
654 build.push(types::BuildDependencyV2 {
655 build_type: bt,
656 packages
657 });
658 }
659 }
660 }
661 }
662
663 let mut test = Vec::new();
664 if let Some(t) = deps.test {
665 test = match t {
666 types::DependencyGroup::Simple(d) => d,
667 types::DependencyGroup::Complex(c) => {
668 let mut all = c.required;
669 all.extend(c.optional);
670 for opt in c.options {
671 all.extend(opt.depends);
672 }
673 all
674 }
675 };
676 }
677
678 types::DependenciesV2 {
679 runtime,
680 build,
681 test
682 }
683 });
684
685 packages_map.insert(
686 packages_key,
687 types::PurlPackageIndexV2 {
688 repo: repo_path,
689 repo_type,
690 version,
691 epoch: pkg.epoch,
692 revision: pkg.revision.clone(),
693 description: pkg.description,
694 scope: Some(pkg.scope),
695 scopes: pkg.scopes.clone(),
696 dependencies: dependencies_v2,
697 sub_packages: pkg.sub_packages.unwrap_or_default(),
698 main_sub_packages: pkg.main_subs.unwrap_or_default(),
699 vuln: vulns
700 }
701 );
702 }
703 }
704 }
705
706 let index = types::RegistryIndexV2 {
707 version: "2".to_string(),
708 packages: packages_map
709 };
710 fs::write(
711 registry_root.join("packages.json"),
712 serde_json::to_string_pretty(&index)?
713 )?;
714
715 println!("{}", "Metadata generation complete.".green());
716
717 Ok(())
718}
719
720pub fn check(registry_root: &Path) -> Result<()> {
728 if !registry_root.join("repo.yaml").exists() {
729 return Err(anyhow!(
730 "Not a Zoi registry (missing repo.yaml). Run 'zoi reg init' first."
731 ));
732 }
733
734 println!("{} Checking registry integrity...", "::".bold().blue());
735
736 let mut errors = 0;
737 let mut warnings = 0;
738
739 for entry in WalkDir::new(registry_root)
740 .into_iter()
741 .filter_map(Result::ok)
742 {
743 if entry.file_type().is_file()
744 && entry.file_name().to_string_lossy().ends_with(".pkg.lua")
745 {
746 println!(
747 " Checking {}...",
748 entry.path().display().to_string().cyan()
749 );
750 match pkg_doctor::run(entry.path(), None, None) {
751 Ok(report) => {
752 for error in &report.errors {
753 eprintln!(" {} {error}", "Error:".red().bold());
754 errors += 1;
755 }
756 for warning in &report.warnings {
757 println!(
758 " {} {warning}",
759 "Warning:".yellow().bold()
760 );
761 warnings += 1;
762 }
763 }
764 Err(e) => {
765 eprintln!(
766 " {} Failed to parse package: {e}",
767 "Error:".red().bold(),
768 );
769 errors += 1;
770 }
771 }
772 }
773 }
774
775 if errors > 0 {
776 return Err(anyhow!(
777 "Registry check failed with {errors} error(s) and {warnings} \
778 warning(s).",
779 ));
780 }
781
782 println!(
783 "{} Registry check passed with {} warning(s).",
784 "::".bold().green(),
785 warnings
786 );
787 Ok(())
788}