1#![forbid(unsafe_code)]
6
7use std::cell::RefCell;
8use std::collections::{BTreeMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use vanta_core::{Area, ExitCode, Platform, Request, StoreKey, VersionReq, VtaError, VtaResult};
12use vanta_install::Engine;
13use vanta_registry::Registry;
14use vanta_resolve::{artifact_for, Resolver};
15use vanta_ui::Progress;
16
17pub const VERSION: &str = env!("CARGO_PKG_VERSION");
19
20struct InstallUi {
26 label: String,
28 bar: RefCell<Option<Progress>>,
29}
30
31impl InstallUi {
32 fn new(label: String) -> InstallUi {
33 InstallUi {
34 label,
35 bar: RefCell::new(None),
36 }
37 }
38
39 fn finish_ok(&self, msg: &str) {
41 match self.bar.borrow().as_ref() {
42 Some(bar) => bar.finish_ok(msg),
43 None => vanta_ui::step(msg),
44 }
45 }
46}
47
48impl vanta_install::Reporter for InstallUi {
49 fn fetch_start(&self, total: Option<u64>) {
50 let bar = Progress::new_bar(&format!("downloading {}", self.label), total);
51 *self.bar.borrow_mut() = Some(bar);
52 }
53
54 fn fetch_inc(&self, n: u64) {
55 if let Some(bar) = self.bar.borrow().as_ref() {
56 bar.inc(n);
57 }
58 }
59
60 fn phase(&self, name: &str) {
61 let mut slot = self.bar.borrow_mut();
64 if let Some(old) = slot.as_ref() {
65 old.clear();
66 }
67 *slot = Some(Progress::new_spinner(&format!("{name} {}", self.label)));
68 }
69}
70
71fn install_with_ui(
74 engine: &Engine,
75 tool: &str,
76 version: &str,
77 artifact: &vanta_core::Artifact,
78) -> VtaResult<StoreKey> {
79 let ui = InstallUi::new(format!("{tool} {version}"));
80 let key = engine.install_artifact_reported(tool, version, artifact, &ui)?;
81 ui.finish_ok(&format!("{tool} {version} → {key}"));
82 Ok(key)
83}
84
85pub fn run(args: &[String]) -> VtaResult<ExitCode> {
87 let cmd = args.first().map(String::as_str).unwrap_or("help");
88 let rest: &[String] = args.get(1..).unwrap_or(&[]);
89 if wants_banner(cmd) {
94 vanta_ui::banner(VERSION);
95 }
96 match cmd {
97 "--version" | "-V" | "version" => {
98 println!("vanta {VERSION}");
99 Ok(ExitCode::Ok)
100 }
101 "--help" | "-h" | "help" => {
102 print_help();
103 Ok(ExitCode::Ok)
104 }
105 "add" | "install" => cmd_add(rest),
106 "search" => cmd_search(rest),
107 "info" => cmd_info(rest),
108 "activate" => cmd_activate(rest),
109 "list" | "ls" => cmd_list(),
110 "which" => cmd_which(rest),
111 "doctor" => cmd_doctor(),
112 "sync" => cmd_sync(),
113 "generations" | "gen" => cmd_generations(),
114 "rollback" => cmd_rollback(rest),
115 "gc" => cmd_gc(),
116 "init" | "migrate" => cmd_import(has_flag(rest, "--force") || has_flag(rest, "-f")),
117 "exec" => cmd_exec(rest),
118 "x" => cmd_x(rest),
119 "remove" | "rm" => cmd_remove(rest),
120 "run" => cmd_run(rest),
121 "bundle" => cmd_bundle(rest),
122 "restore" => cmd_restore(rest),
123 "use" => cmd_add(rest),
124 "update" | "up" => cmd_sync(),
125 "outdated" => cmd_outdated(),
126 "cache" => cmd_cache(rest),
127 "config" => cmd_config(),
128 "completions" => cmd_completions(rest),
129 "trust" => cmd_trust(rest),
130 "registry" => cmd_registry(rest),
131 "shell" => cmd_shell(rest),
132 "self" => cmd_self(rest),
133 other => {
134 eprintln!("vanta: unknown command `{other}` (try `vanta help`)");
135 Ok(ExitCode::Usage)
136 }
137 }
138}
139
140fn cmd_add(rest: &[String]) -> VtaResult<ExitCode> {
142 let tools: Vec<&String> = rest.iter().filter(|a| !a.starts_with('-')).collect();
143 if tools.is_empty() {
144 eprintln!("usage: vanta add <tool>[@version] ...");
145 return Ok(ExitCode::Usage);
146 }
147
148 let registry = load_registry()?;
149 let resolver = Resolver::new(®istry);
150 let platform = Platform::current();
151
152 let mut resolutions = Vec::new();
154 for tool in &tools {
155 let request = Request::parse(tool)?;
156 resolutions.push(resolver.resolve(&request, &[platform])?);
157 }
158
159 let engine = open_engine()?;
161 for resolution in &resolutions {
162 let artifact = artifact_for(resolution, &platform).ok_or_else(|| {
163 VtaError::new(
164 Area::Res,
165 5,
166 format!(
167 "no artifact for `{}` on {}",
168 resolution.tool,
169 platform.token()
170 ),
171 )
172 })?;
173 install_with_ui(&engine, &resolution.tool, &resolution.version, artifact)?;
174 }
175 Ok(ExitCode::Ok)
176}
177
178fn cmd_search(rest: &[String]) -> VtaResult<ExitCode> {
180 let query = rest
181 .iter()
182 .find(|a| !a.starts_with('-'))
183 .cloned()
184 .unwrap_or_default();
185 let registry = load_registry()?;
186 for name in registry.search(&query) {
187 println!("{name}");
188 }
189 Ok(ExitCode::Ok)
190}
191
192fn cmd_info(rest: &[String]) -> VtaResult<ExitCode> {
194 let name = match rest.iter().find(|a| !a.starts_with('-')) {
195 Some(n) => n,
196 None => {
197 eprintln!("usage: vanta info <tool>");
198 return Ok(ExitCode::Usage);
199 }
200 };
201 let registry = load_registry()?;
202 let entry = registry
203 .tool(name)
204 .ok_or_else(|| VtaError::new(Area::Res, 3, format!("unknown tool `{name}`")))?;
205 println!("{name} (provider: {})", entry.provider.id);
206 if let Some(summary) = &entry.summary {
207 println!(" {summary}");
208 }
209 println!(" versions:");
210 for v in &entry.versions {
211 let chan = v.channel.as_deref().unwrap_or("");
212 println!(" {} {}", v.version, chan);
213 }
214 Ok(ExitCode::Ok)
215}
216
217fn cmd_activate(rest: &[String]) -> VtaResult<ExitCode> {
219 let shell = match rest.iter().find(|a| !a.starts_with('-')) {
220 Some(s) => s,
221 None => {
222 eprintln!("usage: vanta activate <bash|zsh|fish|pwsh>");
223 return Ok(ExitCode::Usage);
224 }
225 };
226 match vanta_env::activate_hook(shell) {
227 Some(hook) => {
228 print!("{hook}");
229 Ok(ExitCode::Ok)
230 }
231 None => {
232 eprintln!("vanta: unsupported shell `{shell}`");
233 Ok(ExitCode::Usage)
234 }
235 }
236}
237
238fn cmd_list() -> VtaResult<ExitCode> {
240 let engine = Engine::open(home()?)?;
241 match engine.state().current()? {
242 Some(id) => match engine.state().get_generation(id)? {
243 Some(gen) if !gen.tools.is_empty() => {
244 for (tool, key) in &gen.tools {
245 println!("{tool} ({key})");
246 }
247 }
248 _ => println!("(no tools installed)"),
249 },
250 None => println!("(no tools installed)"),
251 }
252 Ok(ExitCode::Ok)
253}
254
255fn which_from_lock(home: &Path, name: &str) -> Option<PathBuf> {
260 let plat = Platform::current().token();
261 let mut dir = std::env::current_dir().ok();
262 while let Some(d) = dir {
263 let lock_path = d.join("vanta.lock");
264 if lock_path.is_file() {
265 if let Ok(lock) = vanta_lock::Lock::load_file(&lock_path) {
266 for tool in &lock.tools {
267 let pin = tool.platform.get(&plat);
268 let manages = tool.name == name
269 || pin.is_some_and(|p| {
270 p.bin
271 .iter()
272 .any(|b| b.rsplit(['/', '\\']).next() == Some(name))
273 });
274 if !manages {
275 continue;
276 }
277 let pin = pin.filter(|p| !p.store_key.is_empty())?;
278 let key = StoreKey::new(pin.store_key.clone()).ok()?;
279 let rel = pin
280 .bin
281 .iter()
282 .find(|b| b.rsplit(['/', '\\']).next() == Some(name))
283 .cloned()
284 .unwrap_or_else(|| name.to_string());
285 return Some(home.join("store").join(key.as_str()).join(rel));
286 }
287 }
288 }
289 dir = d.parent().map(Path::to_path_buf);
290 }
291 None
292}
293
294fn cmd_which(rest: &[String]) -> VtaResult<ExitCode> {
295 let name = match rest.iter().find(|a| !a.starts_with('-')) {
296 Some(n) => n,
297 None => {
298 eprintln!("usage: vanta which <tool>");
299 return Ok(ExitCode::Usage);
300 }
301 };
302 let home = home()?;
303 if let Some(path) = which_from_lock(&home, name) {
305 println!("{}", path.display());
306 return Ok(ExitCode::Ok);
307 }
308 let engine = Engine::open(&home)?;
309 let id = engine
310 .state()
311 .current()?
312 .ok_or_else(|| VtaError::new(Area::Env, 2, format!("`{name}` is not active")))?;
313 let gen = engine
314 .state()
315 .get_generation(id)?
316 .ok_or_else(|| VtaError::new(Area::Env, 2, format!("`{name}` is not active")))?;
317 let (_, key) = gen
318 .tools
319 .iter()
320 .find(|(t, _)| t == name)
321 .ok_or_else(|| VtaError::new(Area::Env, 2, format!("`{name}` is not active")))?;
322 let store_key = StoreKey::new(key.clone())?;
323 println!("{}", engine.store().entry_path(&store_key).display());
324 Ok(ExitCode::Ok)
325}
326
327fn cmd_generations() -> VtaResult<ExitCode> {
329 let engine = Engine::open(home()?)?;
330 match engine.state().current()? {
331 None => println!("(no generations)"),
332 Some(current) => {
333 for id in 1..=current {
334 if let Some(gen) = engine.state().get_generation(id)? {
335 let mark = if id == current { "*" } else { " " };
336 println!("{mark} {id:04} {} [{}]", gen.command, gen.reason);
337 }
338 }
339 }
340 }
341 Ok(ExitCode::Ok)
342}
343
344fn cmd_rollback(rest: &[String]) -> VtaResult<ExitCode> {
346 let engine = Engine::open(home()?)?;
347 let current = engine
348 .state()
349 .current()?
350 .ok_or_else(|| VtaError::new(Area::Env, 2, "no generations to roll back".to_string()))?;
351 let target = match rest
352 .iter()
353 .find(|a| !a.starts_with('-'))
354 .and_then(|s| s.parse::<u64>().ok())
355 {
356 Some(n) => n,
357 None if current > 1 => current - 1,
358 None => {
359 return Err(VtaError::new(
360 Area::Env,
361 2,
362 "already at the earliest generation".to_string(),
363 ))
364 }
365 };
366 if engine.state().get_generation(target)?.is_none() {
367 return Err(VtaError::new(
368 Area::Env,
369 2,
370 format!("generation {target} not found"),
371 ));
372 }
373 engine.state().set_current(target)?;
374 println!("rolled back to generation {target:04}");
375 Ok(ExitCode::Ok)
376}
377
378fn cmd_gc() -> VtaResult<ExitCode> {
381 const RETAIN: u64 = 5;
382 let engine = Engine::open(home()?)?;
383 let mut roots: HashSet<StoreKey> = HashSet::new();
384 if let Some(current) = engine.state().current()? {
385 let start = current.saturating_sub(RETAIN - 1).max(1);
386 for id in start..=current {
387 if let Some(gen) = engine.state().get_generation(id)? {
388 for (_, key) in &gen.tools {
389 if let Ok(k) = StoreKey::new(key.clone()) {
390 roots.insert(k);
391 }
392 }
393 }
394 }
395 }
396 let removed = engine.store().gc(&roots)?;
397 println!(
398 "removed {removed} unreferenced store entr{}",
399 if removed == 1 { "y" } else { "ies" }
400 );
401 Ok(ExitCode::Ok)
402}
403
404fn cmd_doctor() -> VtaResult<ExitCode> {
406 let home = home()?;
407 let checks = vanta_diag::run(&home);
408 for c in &checks {
409 let mark = if c.ok { "✓" } else { "✗" };
410 println!("{mark} {} — {}", c.name, c.detail);
411 }
412 Ok(if vanta_diag::all_ok(&checks) {
413 ExitCode::Ok
414 } else {
415 ExitCode::Failure
416 })
417}
418
419fn cmd_sync() -> VtaResult<ExitCode> {
423 let manifest_path = find_manifest()?;
424 if !ensure_manifest_trusted(&manifest_path)? {
428 return Ok(ExitCode::Usage);
429 }
430 let manifest = vanta_config::load_file(&manifest_path)?;
431 if manifest.tools.is_empty() {
432 println!(
433 "nothing to sync ({} has no [tools])",
434 manifest_path.display()
435 );
436 return Ok(ExitCode::Ok);
437 }
438
439 let current = Platform::current();
442 let mut platforms: Vec<Platform> = manifest
443 .settings
444 .targets
445 .clone()
446 .unwrap_or_else(default_targets)
447 .iter()
448 .filter_map(|t| Platform::parse(t).ok())
449 .collect();
450 if !platforms.contains(¤t) {
451 platforms.push(current);
452 }
453
454 let registry = load_registry()?;
455 let resolver = Resolver::new(®istry);
456 let engine = open_engine()?;
457 let mut lock = vanta_lock::Lock::new(
458 format!("vanta {VERSION}"),
459 platforms.iter().map(|p| p.token()).collect(),
460 );
461
462 let lock_path = manifest_path
465 .parent()
466 .unwrap_or(Path::new("."))
467 .join("vanta.lock");
468 let prev_lock = vanta_lock::Lock::load_file(&lock_path).ok();
469 let current_token = current.token();
470
471 for (tool, spec) in &manifest.tools {
472 let request_str = spec.version().to_string();
473 let request = Request {
474 tool: tool.clone(),
475 version: VersionReq::parse(&request_str),
476 };
477 let resolution = resolver.resolve(&request, &platforms)?;
478
479 let mut current_artifact = artifact_for(&resolution, ¤t)
481 .ok_or_else(|| {
482 VtaError::new(
483 Area::Res,
484 5,
485 format!("no artifact for `{tool}` on {}", current.token()),
486 )
487 })?
488 .clone();
489 if let Some(prev) = &prev_lock {
493 if let Some(pin) = prev
494 .tools
495 .iter()
496 .find(|t| t.name == *tool && t.version == resolution.version)
497 .and_then(|t| t.platform.get(¤t_token))
498 .filter(|p| !p.store_key.is_empty())
499 {
500 current_artifact.store_key = StoreKey::new(pin.store_key.clone()).ok();
501 }
502 }
503 let key = install_with_ui(
504 &engine,
505 &resolution.tool,
506 &resolution.version,
507 ¤t_artifact,
508 )?;
509
510 let mut platform_map = BTreeMap::new();
511 for (plat, art) in &resolution.per_platform {
512 let store_key = if *plat == current {
515 key.as_str().to_string()
516 } else {
517 String::new()
518 };
519 platform_map.insert(
520 plat.token(),
521 vanta_lock::PlatformPin {
522 store_key,
523 url: art.url.clone(),
524 size: art.size,
525 sha256: art.checksum.value.clone(),
526 blake3: None,
527 signature: art.signature.clone(),
528 bin: art.bin.clone(),
529 },
530 );
531 }
532 lock.tools.push(vanta_lock::LockedTool {
533 name: tool.clone(),
534 request: request_str,
535 version: resolution.version.clone(),
536 provider: resolution.provider.clone(),
537 platform: platform_map,
538 });
539 }
540
541 lock.write_file(&lock_path)?;
542 println!(
543 "✓ wrote {} ({} targets)",
544 lock_path.display(),
545 platforms.len()
546 );
547 Ok(ExitCode::Ok)
548}
549
550fn default_targets() -> Vec<String> {
552 [
553 "macos/aarch64",
554 "macos/x86_64",
555 "linux/x86_64/gnu",
556 "linux/aarch64/gnu",
557 "windows/x86_64",
558 ]
559 .iter()
560 .map(|s| s.to_string())
561 .collect()
562}
563
564fn find_manifest() -> VtaResult<PathBuf> {
566 let mut dir = std::env::current_dir()
567 .map_err(|e| VtaError::new(Area::Cfg, 1, format!("cannot read current directory: {e}")))?;
568 loop {
569 let candidate = dir.join("vanta.toml");
570 if candidate.is_file() {
571 return Ok(candidate);
572 }
573 if !dir.pop() {
574 return Err(VtaError::new(
575 Area::Cfg,
576 1,
577 "no vanta.toml found in this directory or any parent".to_string(),
578 ));
579 }
580 }
581}
582
583fn cmd_exec(rest: &[String]) -> VtaResult<ExitCode> {
585 let cmdv: &[String] = match rest.iter().position(|a| a == "--") {
586 Some(i) => &rest[i + 1..],
587 None => rest,
588 };
589 if cmdv.is_empty() {
590 eprintln!("usage: vanta exec -- <command> [args]");
591 return Ok(ExitCode::Usage);
592 }
593 run_header(&cmdv[0], &cmdv[1..]);
594 let status = Command::new(&cmdv[0])
595 .args(&cmdv[1..])
596 .env("PATH", env_path_with_bin()?)
597 .status()
598 .map_err(|e| VtaError::new(Area::Env, 1, format!("running {}: {e}", cmdv[0])))?;
599 Ok(status_exit(status))
600}
601
602fn cmd_x(rest: &[String]) -> VtaResult<ExitCode> {
604 let spec = match rest.iter().find(|a| !a.starts_with('-')) {
605 Some(s) => s.clone(),
606 None => {
607 eprintln!("usage: vanta x <tool>[@version] [args]");
608 return Ok(ExitCode::Usage);
609 }
610 };
611 let request = Request::parse(&spec)?;
612 let registry = load_registry()?;
613 let resolver = Resolver::new(®istry);
614 let platform = Platform::current();
615 let resolution = resolver.resolve(&request, &[platform])?;
616 let engine = open_engine()?;
617 let artifact = artifact_for(&resolution, &platform).ok_or_else(|| {
618 VtaError::new(
619 Area::Res,
620 5,
621 format!("no artifact for `{}`", resolution.tool),
622 )
623 })?;
624 install_with_ui(&engine, &resolution.tool, &resolution.version, artifact)?;
625
626 let idx = rest.iter().position(|a| a == &spec).unwrap_or(0);
627 let args: &[String] = rest.get(idx + 1..).unwrap_or(&[]);
628 let tool_bin = home()?.join("bin").join(&resolution.tool);
629 run_header(&resolution.tool, args);
630 let status = Command::new(&tool_bin)
631 .args(args)
632 .env("PATH", env_path_with_bin()?)
633 .status()
634 .map_err(|e| VtaError::new(Area::Env, 1, format!("running {}: {e}", resolution.tool)))?;
635 Ok(status_exit(status))
636}
637
638fn cmd_remove(rest: &[String]) -> VtaResult<ExitCode> {
640 let tool = match rest.iter().find(|a| !a.starts_with('-')) {
641 Some(t) => t,
642 None => {
643 eprintln!("usage: vanta remove <tool>");
644 return Ok(ExitCode::Usage);
645 }
646 };
647 let engine = Engine::open(home()?)?;
648 if engine.remove(tool)? {
649 println!("removed {tool}");
650 Ok(ExitCode::Ok)
651 } else {
652 Err(VtaError::new(
653 Area::Env,
654 2,
655 format!("`{tool}` is not installed"),
656 ))
657 }
658}
659
660fn cmd_run(rest: &[String]) -> VtaResult<ExitCode> {
662 let name = match rest.iter().find(|a| !a.starts_with('-')) {
663 Some(n) => n.clone(),
664 None => {
665 eprintln!("usage: vanta run <task|tool> [args]");
666 return Ok(ExitCode::Usage);
667 }
668 };
669 if let Ok(manifest_path) = find_manifest() {
671 if let Ok(manifest) = vanta_config::load_file(&manifest_path) {
672 if let Some(task) = manifest.tasks.get(&name) {
673 if !ensure_manifest_trusted(&manifest_path)? {
677 return Ok(ExitCode::Usage);
678 }
679 let cmd = match task {
680 vanta_config::model::Task::Command(s) => s.clone(),
681 vanta_config::model::Task::Detailed(d) => d.run.clone(),
682 };
683 vanta_ui::running(&cmd);
684 let status = shell_command(&cmd)
685 .env("PATH", env_path_with_bin()?)
686 .status()
687 .map_err(|e| {
688 VtaError::new(Area::Env, 1, format!("running task `{name}`: {e}"))
689 })?;
690 return Ok(status_exit(status));
691 }
692 }
693 }
694 let idx = rest.iter().position(|a| a == &name).unwrap_or(0);
695 let args: &[String] = rest.get(idx + 1..).unwrap_or(&[]);
696 let tool_bin = home()?.join("bin").join(&name);
697 run_header(&name, args);
698 let status = Command::new(&tool_bin)
699 .args(args)
700 .env("PATH", env_path_with_bin()?)
701 .status()
702 .map_err(|e| VtaError::new(Area::Env, 1, format!("running `{name}`: {e}")))?;
703 Ok(status_exit(status))
704}
705
706fn cmd_bundle(rest: &[String]) -> VtaResult<ExitCode> {
708 let out = rest
709 .iter()
710 .position(|a| a == "--out")
711 .and_then(|i| rest.get(i + 1))
712 .cloned()
713 .unwrap_or_else(|| "vanta-bundle.vbundle".to_string());
714 let engine = Engine::open(home()?)?;
715 let progress = Progress::new_spinner(&format!("bundling active generation → {out}"));
716 let n = match engine.bundle_current(Path::new(&out)) {
717 Ok(n) => n,
718 Err(e) => {
719 progress.finish_err("bundle failed");
720 return Err(e);
721 }
722 };
723 progress.finish_ok(&format!("bundled {n} store entries → {out}"));
724 Ok(ExitCode::Ok)
725}
726
727fn cmd_restore(rest: &[String]) -> VtaResult<ExitCode> {
729 let file = match rest.iter().find(|a| !a.starts_with('-')) {
730 Some(f) => f,
731 None => {
732 eprintln!("usage: vanta restore <file>");
733 return Ok(ExitCode::Usage);
734 }
735 };
736 let engine = Engine::open(home()?)?;
737 let progress = Progress::new_spinner(&format!("restoring bundle {file}"));
738 let n = match engine.restore(Path::new(file)) {
739 Ok(n) => n,
740 Err(e) => {
741 progress.finish_err("restore failed");
742 return Err(e);
743 }
744 };
745 progress.finish_ok(&format!("restored {n} store entries"));
746 Ok(ExitCode::Ok)
747}
748
749#[allow(clippy::print_literal)] fn cmd_outdated() -> VtaResult<ExitCode> {
752 let manifest_path = find_manifest()?;
753 let manifest = vanta_config::load_file(&manifest_path)?;
754 let registry = load_registry()?;
755 let resolver = Resolver::new(®istry);
756 let platform = Platform::current();
757
758 let lock_path = manifest_path
759 .parent()
760 .unwrap_or(Path::new("."))
761 .join("vanta.lock");
762 let locked: BTreeMap<String, String> = if lock_path.exists() {
763 vanta_lock::Lock::load_file(&lock_path)
764 .map(|l| l.tools.into_iter().map(|t| (t.name, t.version)).collect())
765 .unwrap_or_default()
766 } else {
767 BTreeMap::new()
768 };
769
770 println!(
771 "{:<16} {:<12} {:<12} {}",
772 "tool", "current", "allowed", "latest"
773 );
774 for (tool, spec) in &manifest.tools {
775 let allowed = resolver
776 .resolve(
777 &Request {
778 tool: tool.clone(),
779 version: VersionReq::parse(spec.version()),
780 },
781 &[platform],
782 )
783 .map(|r| r.version)
784 .unwrap_or_else(|_| "-".to_string());
785 let latest = resolver
786 .resolve(
787 &Request {
788 tool: tool.clone(),
789 version: VersionReq::Latest,
790 },
791 &[platform],
792 )
793 .map(|r| r.version)
794 .unwrap_or_else(|_| "-".to_string());
795 let current = locked.get(tool).cloned().unwrap_or_else(|| "-".to_string());
796 println!("{tool:<16} {current:<12} {allowed:<12} {latest}");
797 }
798 Ok(ExitCode::Ok)
799}
800
801fn cmd_cache(rest: &[String]) -> VtaResult<ExitCode> {
803 let sub = rest
804 .iter()
805 .find(|a| !a.starts_with('-'))
806 .map(|s| s.as_str())
807 .unwrap_or("stats");
808 let downloads = home()?.join("cache").join("downloads");
809 match sub {
810 "prune" => {
811 let mut n = 0;
812 if let Ok(rd) = std::fs::read_dir(&downloads) {
813 for e in rd.flatten() {
814 if std::fs::remove_file(e.path()).is_ok() {
815 n += 1;
816 }
817 }
818 }
819 println!("pruned {n} cached downloads");
820 }
821 _ => {
822 let (mut files, mut bytes) = (0u64, 0u64);
823 if let Ok(rd) = std::fs::read_dir(&downloads) {
824 for e in rd.flatten() {
825 if let Ok(m) = e.metadata() {
826 if m.is_file() {
827 files += 1;
828 bytes += m.len();
829 }
830 }
831 }
832 }
833 println!("download cache: {files} files, {} KB", bytes / 1024);
834 }
835 }
836 Ok(ExitCode::Ok)
837}
838
839fn cmd_config() -> VtaResult<ExitCode> {
841 let path = home()?.join("config.toml");
842 println!("config: {}", path.display());
843 match std::fs::read_to_string(&path) {
844 Ok(contents) => {
845 println!("---");
846 print!("{contents}");
847 }
848 Err(_) => println!("(no global config; create it to set [tools]/[settings])"),
849 }
850 Ok(ExitCode::Ok)
851}
852
853fn cmd_completions(rest: &[String]) -> VtaResult<ExitCode> {
855 let shell = rest
856 .iter()
857 .find(|a| !a.starts_with('-'))
858 .map(|s| s.as_str())
859 .unwrap_or("bash");
860 let cmds = "add remove update sync list which search info outdated init migrate doctor activate gc rollback generations run exec x bundle restore cache config completions use";
861 match shell {
862 "bash" => println!("complete -W \"{cmds}\" vanta vt"),
863 "zsh" => println!("#compdef vanta vt\n_values 'vanta command' {cmds}"),
864 "fish" => {
865 for c in cmds.split(' ') {
866 println!("complete -c vanta -a {c}");
867 }
868 }
869 other => {
870 eprintln!("vanta: no completions for `{other}`");
871 return Ok(ExitCode::Usage);
872 }
873 }
874 Ok(ExitCode::Ok)
875}
876
877fn cmd_trust(rest: &[String]) -> VtaResult<ExitCode> {
879 let trust_dir = home()?.join("trust");
880 if has_flag(rest, "--list") {
881 match std::fs::read_dir(&trust_dir) {
882 Ok(rd) => {
883 for e in rd.flatten() {
884 if let Ok(target) = std::fs::read_to_string(e.path()) {
885 println!("{} {}", e.file_name().to_string_lossy(), target);
886 }
887 }
888 }
889 Err(_) => println!("(nothing trusted yet)"),
890 }
891 return Ok(ExitCode::Ok);
892 }
893 let path = match rest.iter().find(|a| !a.starts_with('-')) {
894 Some(p) => PathBuf::from(p),
895 None => find_manifest()?,
896 };
897 let hash = vanta_security::sha256_file(&path)?;
898 std::fs::create_dir_all(&trust_dir)
899 .map_err(|e| VtaError::new(Area::Vrf, 3, format!("trust dir: {e}")))?;
900 std::fs::write(trust_dir.join(&hash), path.display().to_string())
901 .map_err(|e| VtaError::new(Area::Vrf, 3, format!("recording trust: {e}")))?;
902 println!("trusted {} ({hash})", path.display());
903 Ok(ExitCode::Ok)
904}
905
906fn manifest_is_trusted(trust_dir: &Path, hash: &str) -> bool {
908 trust_dir.join(hash).is_file()
909}
910
911fn ensure_manifest_trusted(manifest_path: &Path) -> VtaResult<bool> {
919 use std::io::IsTerminal;
920 let trust_dir = home()?.join("trust");
921 let hash = vanta_security::sha256_file(manifest_path)?;
922 if manifest_is_trusted(&trust_dir, &hash) {
923 return Ok(true);
924 }
925 let assume = matches!(
926 std::env::var("VANTA_ASSUME_TRUST").ok().as_deref(),
927 Some("1") | Some("true") | Some("yes")
928 );
929 let approved = if assume {
930 true
931 } else if std::io::stdin().is_terminal() {
932 eprint!(
933 "vanta: {} is not trusted. Trust it and continue? [y/N] ",
934 manifest_path.display()
935 );
936 use std::io::Write;
937 let _ = std::io::stderr().flush();
938 let mut line = String::new();
939 std::io::stdin().read_line(&mut line).ok();
940 matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
941 } else {
942 false
943 };
944 if approved {
945 std::fs::create_dir_all(&trust_dir)
946 .map_err(|e| VtaError::new(Area::Vrf, 3, format!("trust dir: {e}")))?;
947 let _ = std::fs::write(trust_dir.join(&hash), manifest_path.display().to_string());
948 Ok(true)
949 } else {
950 eprintln!(
951 "vanta: refusing to use untrusted manifest {} \
952 (run `vanta trust` to approve it)",
953 manifest_path.display()
954 );
955 Ok(false)
956 }
957}
958
959fn cmd_registry(rest: &[String]) -> VtaResult<ExitCode> {
961 let nonflags: Vec<&String> = rest.iter().filter(|a| !a.starts_with('-')).collect();
962 let cfg = home()?.join("config.toml");
963 match nonflags.first().map(|s| s.as_str()) {
964 Some("add") => {
965 if nonflags.len() < 3 {
966 eprintln!("usage: vanta registry add <name> <url>");
967 return Ok(ExitCode::Usage);
968 }
969 let (name, url) = (nonflags[1], nonflags[2]);
970 let block = format!("\n[registries.{name}]\nurl = \"{url}\"\n");
971 if let Some(parent) = cfg.parent() {
972 let _ = std::fs::create_dir_all(parent);
973 }
974 let mut existing = std::fs::read_to_string(&cfg).unwrap_or_default();
975 existing.push_str(&block);
976 std::fs::write(&cfg, existing)
977 .map_err(|e| VtaError::new(Area::Cfg, 1, format!("writing config: {e}")))?;
978 println!("added registry {name} → {url}");
979 Ok(ExitCode::Ok)
980 }
981 _ => {
982 if cfg.exists() {
983 let manifest = vanta_config::load_file(&cfg)?;
984 if manifest.registries.is_empty() {
985 println!("(no registries configured; the official registry is used)");
986 } else {
987 for (name, reg) in &manifest.registries {
988 println!("{name} {}", reg.url);
989 }
990 }
991 } else {
992 println!("(no config; the official registry is used by default)");
993 }
994 Ok(ExitCode::Ok)
995 }
996 }
997}
998
999fn cmd_shell(rest: &[String]) -> VtaResult<ExitCode> {
1002 let specs: Vec<&String> = rest.iter().filter(|a| !a.starts_with('-')).collect();
1003 if specs.is_empty() {
1004 eprintln!("usage: vanta shell <tool>[@version] ...");
1005 return Ok(ExitCode::Usage);
1006 }
1007 let registry = load_registry()?;
1008 let resolver = Resolver::new(®istry);
1009 let platform = Platform::current();
1010 let engine = open_engine()?;
1011 for spec in &specs {
1012 let request = Request::parse(spec)?;
1013 let resolution = resolver.resolve(&request, &[platform])?;
1014 let artifact = artifact_for(&resolution, &platform).ok_or_else(|| {
1015 VtaError::new(
1016 Area::Res,
1017 5,
1018 format!("no artifact for `{}`", resolution.tool),
1019 )
1020 })?;
1021 install_with_ui(&engine, &resolution.tool, &resolution.version, artifact)?;
1022 }
1023 let shell = std::env::var("SHELL").unwrap_or_else(|_| {
1024 if cfg!(windows) {
1025 "cmd".to_string()
1026 } else {
1027 "/bin/sh".to_string()
1028 }
1029 });
1030 vanta_ui::running(&format!(
1031 "{shell} (vanta subshell with {} tool(s); type `exit` to leave)",
1032 specs.len()
1033 ));
1034 let status = Command::new(shell)
1035 .env("PATH", env_path_with_bin()?)
1036 .status()
1037 .map_err(|e| VtaError::new(Area::Env, 1, format!("starting subshell: {e}")))?;
1038 Ok(status_exit(status))
1039}
1040
1041fn cmd_self(rest: &[String]) -> VtaResult<ExitCode> {
1043 match rest
1044 .iter()
1045 .find(|a| !a.starts_with('-'))
1046 .map(|s| s.as_str())
1047 {
1048 Some("uninstall") => {
1049 let h = home()?;
1050 if !has_flag(rest, "--yes") {
1051 eprintln!(
1052 "this will permanently remove {} — re-run with --yes",
1053 h.display()
1054 );
1055 return Ok(ExitCode::Usage);
1056 }
1057 std::fs::remove_dir_all(&h).map_err(|e| {
1058 VtaError::new(Area::Sys, 2, format!("removing {}: {e}", h.display()))
1059 })?;
1060 println!("removed {}", h.display());
1061 Ok(ExitCode::Ok)
1062 }
1063 Some("update") => {
1064 println!(
1065 "self-update is handled by the channel you installed from; \
1066 see docs/32-release-engineering.md"
1067 );
1068 Ok(ExitCode::Ok)
1069 }
1070 _ => {
1071 eprintln!("usage: vanta self <uninstall|update>");
1072 Ok(ExitCode::Usage)
1073 }
1074 }
1075}
1076
1077fn env_path_with_bin() -> VtaResult<String> {
1078 let bin = home()?.join("bin");
1079 let sep = if cfg!(windows) { ';' } else { ':' };
1080 Ok(format!(
1081 "{}{}{}",
1082 bin.display(),
1083 sep,
1084 std::env::var("PATH").unwrap_or_default()
1085 ))
1086}
1087
1088fn run_header(program: &str, args: &[String]) {
1091 let mut line = program.to_string();
1092 if !args.is_empty() {
1093 line.push(' ');
1094 line.push_str(&args.join(" "));
1095 }
1096 vanta_ui::running(&line);
1097}
1098
1099fn shell_command(cmd: &str) -> Command {
1100 if cfg!(windows) {
1101 let mut c = Command::new("cmd");
1102 c.arg("/C").arg(cmd);
1103 c
1104 } else {
1105 let mut c = Command::new("sh");
1106 c.arg("-c").arg(cmd);
1107 c
1108 }
1109}
1110
1111fn status_exit(status: std::process::ExitStatus) -> ExitCode {
1112 if status.success() {
1113 ExitCode::Ok
1114 } else {
1115 ExitCode::Failure
1116 }
1117}
1118
1119fn cmd_import(force: bool) -> VtaResult<ExitCode> {
1122 let cwd = std::env::current_dir()
1123 .map_err(|e| VtaError::new(Area::Cfg, 1, format!("cannot read current directory: {e}")))?;
1124 let imported = vanta_migrate::import_dir(&cwd);
1125 if imported.is_empty() {
1126 println!("no version files detected in {}", cwd.display());
1127 return Ok(ExitCode::Ok);
1128 }
1129 let target = cwd.join("vanta.toml");
1130 if target.exists() && !force {
1131 eprintln!("vanta.toml already exists (use --force to overwrite)");
1132 return Ok(ExitCode::Usage);
1133 }
1134 println!("detected:");
1135 for i in &imported {
1136 println!(" {} = \"{}\" (from {})", i.tool, i.version, i.source);
1137 }
1138 let body = vanta_migrate::to_manifest_toml(&imported);
1139 std::fs::write(&target, body)
1140 .map_err(|e| VtaError::new(Area::Cfg, 1, format!("writing {}: {e}", target.display())))?;
1141 println!(
1142 "✓ wrote {} — run `vanta sync` to install + lock",
1143 target.display()
1144 );
1145 Ok(ExitCode::Ok)
1146}
1147
1148fn has_flag(rest: &[String], flag: &str) -> bool {
1149 rest.iter().any(|a| a == flag)
1150}
1151
1152fn wants_banner(cmd: &str) -> bool {
1158 !matches!(
1159 cmd,
1160 "--version"
1161 | "-V"
1162 | "version"
1163 | "--help"
1164 | "-h"
1165 | "help"
1166 | "completions"
1167 | "activate"
1168 | "which"
1169 | "exec"
1170 )
1171}
1172
1173fn home() -> VtaResult<PathBuf> {
1175 if let Ok(h) = std::env::var("VANTA_HOME") {
1176 return Ok(PathBuf::from(h));
1177 }
1178 let base = std::env::var("HOME")
1179 .or_else(|_| std::env::var("USERPROFILE"))
1180 .map_err(|_| {
1181 VtaError::new(
1182 Area::Sys,
1183 2,
1184 "cannot determine home directory; set VANTA_HOME".to_string(),
1185 )
1186 })?;
1187 Ok(PathBuf::from(base).join(".vanta"))
1188}
1189
1190const REGISTRY_MAX_BYTES: u64 = 64 * 1024 * 1024; const DEFAULT_REGISTRY_URL: &str =
1199 "https://raw.githubusercontent.com/squaretick/vanta/main/registry/registry.toml";
1200
1201fn registry_insecure_optin() -> bool {
1207 matches!(
1208 std::env::var("VANTA_INSECURE_REGISTRY").ok().as_deref(),
1209 Some("1") | Some("true") | Some("yes")
1210 )
1211}
1212
1213fn load_registry() -> VtaResult<Registry> {
1227 let roots = vanta_security::trust::load_root_keys(&home()?.join("trust"));
1228 match std::env::var("VANTA_REGISTRY") {
1229 Ok(loc) if loc.starts_with("http://") || loc.starts_with("https://") => {
1230 fetch_signed_index(&loc, roots)
1231 }
1232 Ok(path) => {
1233 let mut registry = Registry::load_file(Path::new(&path))?;
1235 registry.index_verified = true;
1236 registry.trusted_root_keys = roots;
1237 Ok(registry)
1238 }
1239 Err(_) => {
1240 match fetch_signed_index(DEFAULT_REGISTRY_URL, roots) {
1245 Ok(registry) => Ok(registry),
1246 Err(e) => {
1247 eprintln!(
1248 "vanta: WARNING — could not load the official registry \
1249 ({DEFAULT_REGISTRY_URL}): {e}. Falling back to the empty \
1250 built-in index. Set $VANTA_REGISTRY to a reachable signed \
1251 https URL or a local file path to override."
1252 );
1253 Ok(Registry::builtin())
1254 }
1255 }
1256 }
1257 }
1258}
1259
1260fn fetch_signed_index(loc: &str, roots: Vec<String>) -> VtaResult<Registry> {
1264 let insecure = registry_insecure_optin();
1265 if loc.starts_with("http://") && !insecure {
1266 return Err(VtaError::new(
1267 Area::Reg,
1268 5,
1269 format!(
1270 "refusing plaintext http registry {loc} (https required; \
1271 set VANTA_INSECURE_REGISTRY=1 to override — DANGEROUS)"
1272 ),
1273 ));
1274 }
1275 let downloader = if insecure {
1276 vanta_net::Downloader::insecure()?
1277 } else {
1278 vanta_net::Downloader::new()?
1279 };
1280 let pid = std::process::id();
1281 let tmp = std::env::temp_dir().join(format!("vanta-registry-{pid}.toml"));
1282 let progress = Progress::new_bar("fetching registry index", None);
1285 let dl = downloader.download_capped_with_progress(
1286 loc,
1287 &tmp,
1288 Some(REGISTRY_MAX_BYTES),
1289 Some(&|n| progress.inc(n)),
1290 );
1291 if let Err(e) = dl {
1292 progress.finish_err("registry index download failed");
1293 return Err(e);
1294 }
1295 progress.finish_ok("fetched registry index");
1296 let index_bytes = std::fs::read(&tmp)
1297 .map_err(|e| VtaError::new(Area::Reg, 1, format!("reading index: {e}")))?;
1298
1299 let sig_url = format!("{loc}.minisig");
1301 let sig_tmp = std::env::temp_dir().join(format!("vanta-registry-{pid}.minisig"));
1302 let signature = downloader
1303 .download_capped(&sig_url, &sig_tmp, Some(1024 * 1024))
1304 .ok()
1305 .and_then(|_| std::fs::read_to_string(&sig_tmp).ok());
1306 let _ = std::fs::remove_file(&sig_tmp);
1307 let index_verified = signature
1308 .as_deref()
1309 .map(|s| vanta_security::trust::index_signed_by_root(&index_bytes, s, &roots))
1310 .unwrap_or(false);
1311
1312 if !index_verified && !insecure {
1313 let _ = std::fs::remove_file(&tmp);
1314 return Err(VtaError::new(
1315 Area::Reg,
1316 6,
1317 format!(
1318 "registry index {loc} is not signed by a pinned trust root \
1319 (expected detached signature at {loc}.minisig). Refusing to trust it. \
1320 Add a root to ~/.vanta/trust/roots.toml, or set \
1321 VANTA_INSECURE_REGISTRY=1 to override — DANGEROUS."
1322 ),
1323 ));
1324 }
1325 if insecure && !index_verified {
1326 eprintln!(
1327 "vanta: WARNING — using unverified registry {loc} (VANTA_INSECURE_REGISTRY). \
1328 Per-artifact signing keys will be treated as untrusted."
1329 );
1330 }
1331
1332 let src = String::from_utf8(index_bytes)
1333 .map_err(|e| VtaError::new(Area::Reg, 2, format!("index is not UTF-8: {e}")))?;
1334 let _ = std::fs::remove_file(&tmp);
1335 let mut registry = Registry::from_toml(&src)?;
1336 registry.index_verified = index_verified;
1337 registry.trusted_root_keys = roots;
1338 Ok(registry)
1339}
1340
1341fn install_policy() -> vanta_security::Policy {
1347 let mut policy = vanta_security::Policy::default();
1348 let mut verify: Option<String> = None;
1349 if let Ok(h) = home() {
1350 if let Ok(m) = vanta_config::load_file(&h.join("config.toml")) {
1351 verify = m.settings.verify;
1352 }
1353 }
1354 if let Ok(path) = find_manifest() {
1355 if let Ok(m) = vanta_config::load_file(&path) {
1356 if m.settings.verify.is_some() {
1357 verify = m.settings.verify;
1358 }
1359 }
1360 }
1361 if let Some(v) = verify {
1362 if matches!(
1363 v.to_ascii_lowercase().as_str(),
1364 "require" | "required" | "signature" | "strict"
1365 ) {
1366 policy.require_signature = true;
1367 }
1368 }
1369 policy
1370}
1371
1372fn open_engine() -> VtaResult<Engine> {
1374 Engine::open_with_policy(home()?, install_policy())
1375}
1376
1377fn print_help() {
1378 println!(
1379 "vanta — every developer tool, one command\n\
1380 \n\
1381 USAGE:\n vanta <command> [args]\n\
1382 \n\
1383 COMMON COMMANDS:\n\
1384 \x20 add <tool>[@ver] resolve and install a tool (alias: install)\n\
1385 \x20 search <query> search the registry\n\
1386 \x20 info <tool> show a tool's versions\n\
1387 \x20 remove <tool> remove a tool\n\
1388 \x20 update [tool] update within constraints\n\
1389 \x20 sync reconcile to vanta.toml + vanta.lock\n\
1390 \x20 doctor diagnose the installation\n\
1391 \n\
1392 REGISTRY:\n\
1393 \x20 By default vanta uses the official, minisign-signed registry\n\
1394 \x20 (verified against a pinned root key). Override the source with\n\
1395 \x20 $VANTA_REGISTRY — an https:// URL (must carry a <url>.minisig\n\
1396 \x20 signed by a pinned root) or a local file path (trusted as-is).\n\
1397 \n\
1398 See docs/04-cli.md for the full reference."
1399 );
1400}
1401
1402#[cfg(test)]
1403mod tests {
1404 use super::*;
1405
1406 #[test]
1407 fn version_ok() {
1408 assert_eq!(run(&["--version".into()]).unwrap(), ExitCode::Ok);
1409 }
1410
1411 #[test]
1412 fn unknown_is_usage() {
1413 assert_eq!(run(&["frobnicate".into()]).unwrap(), ExitCode::Usage);
1414 }
1415
1416 #[test]
1417 fn add_no_args_is_usage() {
1418 assert_eq!(run(&["add".into()]).unwrap(), ExitCode::Usage);
1419 }
1420
1421 fn use_empty_registry() {
1424 let p = std::env::temp_dir().join(format!("vanta-empty-reg-{}.toml", std::process::id()));
1425 std::fs::write(&p, "").unwrap();
1426 std::env::set_var("VANTA_REGISTRY", &p);
1427 }
1428
1429 #[test]
1430 fn add_unknown_tool_resolves_to_error() {
1431 use_empty_registry();
1433 let err = run(&["add".into(), "totally-unknown-tool".into()]).unwrap_err();
1434 assert_eq!(err.area, Area::Res);
1435 }
1436
1437 #[test]
1438 fn search_succeeds() {
1439 use_empty_registry();
1440 assert_eq!(
1441 run(&["search".into(), "node".into()]).unwrap(),
1442 ExitCode::Ok
1443 );
1444 }
1445
1446 #[test]
1449 fn trust_list_gates_untrusted_manifest() {
1450 let dir = std::env::temp_dir().join(format!("vanta-cli-trust-{}", std::process::id()));
1451 let _ = std::fs::remove_dir_all(&dir);
1452 std::fs::create_dir_all(&dir).unwrap();
1453 let hash = "a".repeat(64);
1454 assert!(!manifest_is_trusted(&dir, &hash));
1456 std::fs::write(dir.join(&hash), "manifest path").unwrap();
1458 assert!(manifest_is_trusted(&dir, &hash));
1459 assert!(!manifest_is_trusted(&dir, &"b".repeat(64)));
1461 let _ = std::fs::remove_dir_all(&dir);
1462 }
1463}