1#![forbid(unsafe_code)]
6
7use std::collections::{BTreeMap, HashSet};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use vanta_core::{Area, ExitCode, Platform, Request, StoreKey, VersionReq, VtaError, VtaResult};
11use vanta_install::Engine;
12use vanta_registry::Registry;
13use vanta_resolve::{artifact_for, Resolver};
14
15pub const VERSION: &str = env!("CARGO_PKG_VERSION");
17
18pub fn run(args: &[String]) -> VtaResult<ExitCode> {
20 let cmd = args.first().map(String::as_str).unwrap_or("help");
21 let rest: &[String] = args.get(1..).unwrap_or(&[]);
22 match cmd {
23 "--version" | "-V" | "version" => {
24 println!("vanta {VERSION}");
25 Ok(ExitCode::Ok)
26 }
27 "--help" | "-h" | "help" => {
28 print_help();
29 Ok(ExitCode::Ok)
30 }
31 "add" => cmd_add(rest),
32 "search" => cmd_search(rest),
33 "info" => cmd_info(rest),
34 "activate" => cmd_activate(rest),
35 "list" | "ls" => cmd_list(),
36 "which" => cmd_which(rest),
37 "doctor" => cmd_doctor(),
38 "sync" => cmd_sync(),
39 "generations" | "gen" => cmd_generations(),
40 "rollback" => cmd_rollback(rest),
41 "gc" => cmd_gc(),
42 "init" | "migrate" => cmd_import(has_flag(rest, "--force") || has_flag(rest, "-f")),
43 "exec" => cmd_exec(rest),
44 "x" => cmd_x(rest),
45 "remove" | "rm" => cmd_remove(rest),
46 "run" => cmd_run(rest),
47 "bundle" => cmd_bundle(rest),
48 "restore" => cmd_restore(rest),
49 "use" => cmd_add(rest),
50 "update" | "up" => cmd_sync(),
51 "outdated" => cmd_outdated(),
52 "cache" => cmd_cache(rest),
53 "config" => cmd_config(),
54 "completions" => cmd_completions(rest),
55 "trust" => cmd_trust(rest),
56 "registry" => cmd_registry(rest),
57 "shell" => cmd_shell(rest),
58 "self" => cmd_self(rest),
59 other => {
60 eprintln!("vanta: unknown command `{other}` (try `vanta help`)");
61 Ok(ExitCode::Usage)
62 }
63 }
64}
65
66fn cmd_add(rest: &[String]) -> VtaResult<ExitCode> {
68 let tools: Vec<&String> = rest.iter().filter(|a| !a.starts_with('-')).collect();
69 if tools.is_empty() {
70 eprintln!("usage: vanta add <tool>[@version] ...");
71 return Ok(ExitCode::Usage);
72 }
73
74 let registry = load_registry()?;
75 let resolver = Resolver::new(®istry);
76 let platform = Platform::current();
77
78 let mut resolutions = Vec::new();
80 for tool in &tools {
81 let request = Request::parse(tool)?;
82 resolutions.push(resolver.resolve(&request, &[platform])?);
83 }
84
85 let engine = Engine::open(home()?)?;
87 for resolution in &resolutions {
88 let artifact = artifact_for(resolution, &platform).ok_or_else(|| {
89 VtaError::new(
90 Area::Res,
91 5,
92 format!(
93 "no artifact for `{}` on {}",
94 resolution.tool,
95 platform.token()
96 ),
97 )
98 })?;
99 println!("installing {} {}", resolution.tool, resolution.version);
100 let key = engine.install_artifact(&resolution.tool, &resolution.version, artifact)?;
101 println!(" ✓ {} {} → {}", resolution.tool, resolution.version, key);
102 }
103 Ok(ExitCode::Ok)
104}
105
106fn cmd_search(rest: &[String]) -> VtaResult<ExitCode> {
108 let query = rest
109 .iter()
110 .find(|a| !a.starts_with('-'))
111 .cloned()
112 .unwrap_or_default();
113 let registry = load_registry()?;
114 for name in registry.search(&query) {
115 println!("{name}");
116 }
117 Ok(ExitCode::Ok)
118}
119
120fn cmd_info(rest: &[String]) -> VtaResult<ExitCode> {
122 let name = match rest.iter().find(|a| !a.starts_with('-')) {
123 Some(n) => n,
124 None => {
125 eprintln!("usage: vanta info <tool>");
126 return Ok(ExitCode::Usage);
127 }
128 };
129 let registry = load_registry()?;
130 let entry = registry
131 .tool(name)
132 .ok_or_else(|| VtaError::new(Area::Res, 3, format!("unknown tool `{name}`")))?;
133 println!("{name} (provider: {})", entry.provider.id);
134 if let Some(summary) = &entry.summary {
135 println!(" {summary}");
136 }
137 println!(" versions:");
138 for v in &entry.versions {
139 let chan = v.channel.as_deref().unwrap_or("");
140 println!(" {} {}", v.version, chan);
141 }
142 Ok(ExitCode::Ok)
143}
144
145fn cmd_activate(rest: &[String]) -> VtaResult<ExitCode> {
147 let shell = match rest.iter().find(|a| !a.starts_with('-')) {
148 Some(s) => s,
149 None => {
150 eprintln!("usage: vanta activate <bash|zsh|fish|pwsh>");
151 return Ok(ExitCode::Usage);
152 }
153 };
154 match vanta_env::activate_hook(shell) {
155 Some(hook) => {
156 print!("{hook}");
157 Ok(ExitCode::Ok)
158 }
159 None => {
160 eprintln!("vanta: unsupported shell `{shell}`");
161 Ok(ExitCode::Usage)
162 }
163 }
164}
165
166fn cmd_list() -> VtaResult<ExitCode> {
168 let engine = Engine::open(home()?)?;
169 match engine.state().current()? {
170 Some(id) => match engine.state().get_generation(id)? {
171 Some(gen) if !gen.tools.is_empty() => {
172 for (tool, key) in &gen.tools {
173 println!("{tool} ({key})");
174 }
175 }
176 _ => println!("(no tools installed)"),
177 },
178 None => println!("(no tools installed)"),
179 }
180 Ok(ExitCode::Ok)
181}
182
183fn cmd_which(rest: &[String]) -> VtaResult<ExitCode> {
185 let name = match rest.iter().find(|a| !a.starts_with('-')) {
186 Some(n) => n,
187 None => {
188 eprintln!("usage: vanta which <tool>");
189 return Ok(ExitCode::Usage);
190 }
191 };
192 let engine = Engine::open(home()?)?;
193 let id = engine
194 .state()
195 .current()?
196 .ok_or_else(|| VtaError::new(Area::Env, 2, format!("`{name}` is not active")))?;
197 let gen = engine
198 .state()
199 .get_generation(id)?
200 .ok_or_else(|| VtaError::new(Area::Env, 2, format!("`{name}` is not active")))?;
201 let (_, key) = gen
202 .tools
203 .iter()
204 .find(|(t, _)| t == name)
205 .ok_or_else(|| VtaError::new(Area::Env, 2, format!("`{name}` is not active")))?;
206 let store_key = StoreKey::new(key.clone())?;
207 println!("{}", engine.store().entry_path(&store_key).display());
208 Ok(ExitCode::Ok)
209}
210
211fn cmd_generations() -> VtaResult<ExitCode> {
213 let engine = Engine::open(home()?)?;
214 match engine.state().current()? {
215 None => println!("(no generations)"),
216 Some(current) => {
217 for id in 1..=current {
218 if let Some(gen) = engine.state().get_generation(id)? {
219 let mark = if id == current { "*" } else { " " };
220 println!("{mark} {id:04} {} [{}]", gen.command, gen.reason);
221 }
222 }
223 }
224 }
225 Ok(ExitCode::Ok)
226}
227
228fn cmd_rollback(rest: &[String]) -> VtaResult<ExitCode> {
230 let engine = Engine::open(home()?)?;
231 let current = engine
232 .state()
233 .current()?
234 .ok_or_else(|| VtaError::new(Area::Env, 2, "no generations to roll back".to_string()))?;
235 let target = match rest
236 .iter()
237 .find(|a| !a.starts_with('-'))
238 .and_then(|s| s.parse::<u64>().ok())
239 {
240 Some(n) => n,
241 None if current > 1 => current - 1,
242 None => {
243 return Err(VtaError::new(
244 Area::Env,
245 2,
246 "already at the earliest generation".to_string(),
247 ))
248 }
249 };
250 if engine.state().get_generation(target)?.is_none() {
251 return Err(VtaError::new(
252 Area::Env,
253 2,
254 format!("generation {target} not found"),
255 ));
256 }
257 engine.state().set_current(target)?;
258 println!("rolled back to generation {target:04}");
259 Ok(ExitCode::Ok)
260}
261
262fn cmd_gc() -> VtaResult<ExitCode> {
265 const RETAIN: u64 = 5;
266 let engine = Engine::open(home()?)?;
267 let mut roots: HashSet<StoreKey> = HashSet::new();
268 if let Some(current) = engine.state().current()? {
269 let start = current.saturating_sub(RETAIN - 1).max(1);
270 for id in start..=current {
271 if let Some(gen) = engine.state().get_generation(id)? {
272 for (_, key) in &gen.tools {
273 if let Ok(k) = StoreKey::new(key.clone()) {
274 roots.insert(k);
275 }
276 }
277 }
278 }
279 }
280 let removed = engine.store().gc(&roots)?;
281 println!(
282 "removed {removed} unreferenced store entr{}",
283 if removed == 1 { "y" } else { "ies" }
284 );
285 Ok(ExitCode::Ok)
286}
287
288fn cmd_doctor() -> VtaResult<ExitCode> {
290 let home = home()?;
291 let checks = vanta_diag::run(&home);
292 for c in &checks {
293 let mark = if c.ok { "✓" } else { "✗" };
294 println!("{mark} {} — {}", c.name, c.detail);
295 }
296 Ok(if vanta_diag::all_ok(&checks) {
297 ExitCode::Ok
298 } else {
299 ExitCode::Failure
300 })
301}
302
303fn cmd_sync() -> VtaResult<ExitCode> {
307 let manifest_path = find_manifest()?;
308 let manifest = vanta_config::load_file(&manifest_path)?;
309 if manifest.tools.is_empty() {
310 println!(
311 "nothing to sync ({} has no [tools])",
312 manifest_path.display()
313 );
314 return Ok(ExitCode::Ok);
315 }
316
317 let current = Platform::current();
320 let mut platforms: Vec<Platform> = manifest
321 .settings
322 .targets
323 .clone()
324 .unwrap_or_else(default_targets)
325 .iter()
326 .filter_map(|t| Platform::parse(t).ok())
327 .collect();
328 if !platforms.contains(¤t) {
329 platforms.push(current);
330 }
331
332 let registry = load_registry()?;
333 let resolver = Resolver::new(®istry);
334 let engine = Engine::open(home()?)?;
335 let mut lock = vanta_lock::Lock::new(
336 format!("vanta {VERSION}"),
337 platforms.iter().map(|p| p.token()).collect(),
338 );
339
340 for (tool, spec) in &manifest.tools {
341 let request_str = spec.version().to_string();
342 let request = Request {
343 tool: tool.clone(),
344 version: VersionReq::parse(&request_str),
345 };
346 let resolution = resolver.resolve(&request, &platforms)?;
347
348 let current_artifact = artifact_for(&resolution, ¤t).ok_or_else(|| {
350 VtaError::new(
351 Area::Res,
352 5,
353 format!("no artifact for `{tool}` on {}", current.token()),
354 )
355 })?;
356 println!("syncing {} {}", resolution.tool, resolution.version);
357 let key =
358 engine.install_artifact(&resolution.tool, &resolution.version, current_artifact)?;
359
360 let mut platform_map = BTreeMap::new();
361 for (plat, art) in &resolution.per_platform {
362 let store_key = if *plat == current {
365 key.as_str().to_string()
366 } else {
367 String::new()
368 };
369 platform_map.insert(
370 plat.token(),
371 vanta_lock::PlatformPin {
372 store_key,
373 url: art.url.clone(),
374 size: art.size,
375 sha256: art.checksum.value.clone(),
376 blake3: None,
377 signature: art.signature.clone(),
378 bin: art.bin.clone(),
379 },
380 );
381 }
382 lock.tools.push(vanta_lock::LockedTool {
383 name: tool.clone(),
384 request: request_str,
385 version: resolution.version.clone(),
386 provider: resolution.provider.clone(),
387 platform: platform_map,
388 });
389 }
390
391 let lock_path = manifest_path
392 .parent()
393 .unwrap_or(Path::new("."))
394 .join("vanta.lock");
395 lock.write_file(&lock_path)?;
396 println!(
397 "✓ wrote {} ({} targets)",
398 lock_path.display(),
399 platforms.len()
400 );
401 Ok(ExitCode::Ok)
402}
403
404fn default_targets() -> Vec<String> {
406 [
407 "macos/aarch64",
408 "macos/x86_64",
409 "linux/x86_64/gnu",
410 "linux/aarch64/gnu",
411 "windows/x86_64",
412 ]
413 .iter()
414 .map(|s| s.to_string())
415 .collect()
416}
417
418fn find_manifest() -> VtaResult<PathBuf> {
420 let mut dir = std::env::current_dir()
421 .map_err(|e| VtaError::new(Area::Cfg, 1, format!("cannot read current directory: {e}")))?;
422 loop {
423 let candidate = dir.join("vanta.toml");
424 if candidate.is_file() {
425 return Ok(candidate);
426 }
427 if !dir.pop() {
428 return Err(VtaError::new(
429 Area::Cfg,
430 1,
431 "no vanta.toml found in this directory or any parent".to_string(),
432 ));
433 }
434 }
435}
436
437fn cmd_exec(rest: &[String]) -> VtaResult<ExitCode> {
439 let cmdv: &[String] = match rest.iter().position(|a| a == "--") {
440 Some(i) => &rest[i + 1..],
441 None => rest,
442 };
443 if cmdv.is_empty() {
444 eprintln!("usage: vanta exec -- <command> [args]");
445 return Ok(ExitCode::Usage);
446 }
447 let status = Command::new(&cmdv[0])
448 .args(&cmdv[1..])
449 .env("PATH", env_path_with_bin()?)
450 .status()
451 .map_err(|e| VtaError::new(Area::Env, 1, format!("running {}: {e}", cmdv[0])))?;
452 Ok(status_exit(status))
453}
454
455fn cmd_x(rest: &[String]) -> VtaResult<ExitCode> {
457 let spec = match rest.iter().find(|a| !a.starts_with('-')) {
458 Some(s) => s.clone(),
459 None => {
460 eprintln!("usage: vanta x <tool>[@version] [args]");
461 return Ok(ExitCode::Usage);
462 }
463 };
464 let request = Request::parse(&spec)?;
465 let registry = load_registry()?;
466 let resolver = Resolver::new(®istry);
467 let platform = Platform::current();
468 let resolution = resolver.resolve(&request, &[platform])?;
469 let engine = Engine::open(home()?)?;
470 let artifact = artifact_for(&resolution, &platform).ok_or_else(|| {
471 VtaError::new(
472 Area::Res,
473 5,
474 format!("no artifact for `{}`", resolution.tool),
475 )
476 })?;
477 engine.install_artifact(&resolution.tool, &resolution.version, artifact)?;
478
479 let idx = rest.iter().position(|a| a == &spec).unwrap_or(0);
480 let args: &[String] = rest.get(idx + 1..).unwrap_or(&[]);
481 let tool_bin = home()?.join("bin").join(&resolution.tool);
482 let status = Command::new(&tool_bin)
483 .args(args)
484 .env("PATH", env_path_with_bin()?)
485 .status()
486 .map_err(|e| VtaError::new(Area::Env, 1, format!("running {}: {e}", resolution.tool)))?;
487 Ok(status_exit(status))
488}
489
490fn cmd_remove(rest: &[String]) -> VtaResult<ExitCode> {
492 let tool = match rest.iter().find(|a| !a.starts_with('-')) {
493 Some(t) => t,
494 None => {
495 eprintln!("usage: vanta remove <tool>");
496 return Ok(ExitCode::Usage);
497 }
498 };
499 let engine = Engine::open(home()?)?;
500 if engine.remove(tool)? {
501 println!("removed {tool}");
502 Ok(ExitCode::Ok)
503 } else {
504 Err(VtaError::new(
505 Area::Env,
506 2,
507 format!("`{tool}` is not installed"),
508 ))
509 }
510}
511
512fn cmd_run(rest: &[String]) -> VtaResult<ExitCode> {
514 let name = match rest.iter().find(|a| !a.starts_with('-')) {
515 Some(n) => n.clone(),
516 None => {
517 eprintln!("usage: vanta run <task|tool> [args]");
518 return Ok(ExitCode::Usage);
519 }
520 };
521 if let Ok(manifest_path) = find_manifest() {
523 if let Ok(manifest) = vanta_config::load_file(&manifest_path) {
524 if let Some(task) = manifest.tasks.get(&name) {
525 let cmd = match task {
526 vanta_config::model::Task::Command(s) => s.clone(),
527 vanta_config::model::Task::Detailed(d) => d.run.clone(),
528 };
529 let status = shell_command(&cmd)
530 .env("PATH", env_path_with_bin()?)
531 .status()
532 .map_err(|e| {
533 VtaError::new(Area::Env, 1, format!("running task `{name}`: {e}"))
534 })?;
535 return Ok(status_exit(status));
536 }
537 }
538 }
539 let idx = rest.iter().position(|a| a == &name).unwrap_or(0);
540 let args: &[String] = rest.get(idx + 1..).unwrap_or(&[]);
541 let tool_bin = home()?.join("bin").join(&name);
542 let status = Command::new(&tool_bin)
543 .args(args)
544 .env("PATH", env_path_with_bin()?)
545 .status()
546 .map_err(|e| VtaError::new(Area::Env, 1, format!("running `{name}`: {e}")))?;
547 Ok(status_exit(status))
548}
549
550fn cmd_bundle(rest: &[String]) -> VtaResult<ExitCode> {
552 let out = rest
553 .iter()
554 .position(|a| a == "--out")
555 .and_then(|i| rest.get(i + 1))
556 .cloned()
557 .unwrap_or_else(|| "vanta-bundle.vbundle".to_string());
558 let engine = Engine::open(home()?)?;
559 let n = engine.bundle_current(Path::new(&out))?;
560 println!("bundled {n} store entries → {out}");
561 Ok(ExitCode::Ok)
562}
563
564fn cmd_restore(rest: &[String]) -> VtaResult<ExitCode> {
566 let file = match rest.iter().find(|a| !a.starts_with('-')) {
567 Some(f) => f,
568 None => {
569 eprintln!("usage: vanta restore <file>");
570 return Ok(ExitCode::Usage);
571 }
572 };
573 let engine = Engine::open(home()?)?;
574 let n = engine.restore(Path::new(file))?;
575 println!("restored {n} store entries");
576 Ok(ExitCode::Ok)
577}
578
579#[allow(clippy::print_literal)] fn cmd_outdated() -> VtaResult<ExitCode> {
582 let manifest_path = find_manifest()?;
583 let manifest = vanta_config::load_file(&manifest_path)?;
584 let registry = load_registry()?;
585 let resolver = Resolver::new(®istry);
586 let platform = Platform::current();
587
588 let lock_path = manifest_path
589 .parent()
590 .unwrap_or(Path::new("."))
591 .join("vanta.lock");
592 let locked: BTreeMap<String, String> = if lock_path.exists() {
593 vanta_lock::Lock::load_file(&lock_path)
594 .map(|l| l.tools.into_iter().map(|t| (t.name, t.version)).collect())
595 .unwrap_or_default()
596 } else {
597 BTreeMap::new()
598 };
599
600 println!(
601 "{:<16} {:<12} {:<12} {}",
602 "tool", "current", "allowed", "latest"
603 );
604 for (tool, spec) in &manifest.tools {
605 let allowed = resolver
606 .resolve(
607 &Request {
608 tool: tool.clone(),
609 version: VersionReq::parse(spec.version()),
610 },
611 &[platform],
612 )
613 .map(|r| r.version)
614 .unwrap_or_else(|_| "-".to_string());
615 let latest = resolver
616 .resolve(
617 &Request {
618 tool: tool.clone(),
619 version: VersionReq::Latest,
620 },
621 &[platform],
622 )
623 .map(|r| r.version)
624 .unwrap_or_else(|_| "-".to_string());
625 let current = locked.get(tool).cloned().unwrap_or_else(|| "-".to_string());
626 println!("{tool:<16} {current:<12} {allowed:<12} {latest}");
627 }
628 Ok(ExitCode::Ok)
629}
630
631fn cmd_cache(rest: &[String]) -> VtaResult<ExitCode> {
633 let sub = rest
634 .iter()
635 .find(|a| !a.starts_with('-'))
636 .map(|s| s.as_str())
637 .unwrap_or("stats");
638 let downloads = home()?.join("cache").join("downloads");
639 match sub {
640 "prune" => {
641 let mut n = 0;
642 if let Ok(rd) = std::fs::read_dir(&downloads) {
643 for e in rd.flatten() {
644 if std::fs::remove_file(e.path()).is_ok() {
645 n += 1;
646 }
647 }
648 }
649 println!("pruned {n} cached downloads");
650 }
651 _ => {
652 let (mut files, mut bytes) = (0u64, 0u64);
653 if let Ok(rd) = std::fs::read_dir(&downloads) {
654 for e in rd.flatten() {
655 if let Ok(m) = e.metadata() {
656 if m.is_file() {
657 files += 1;
658 bytes += m.len();
659 }
660 }
661 }
662 }
663 println!("download cache: {files} files, {} KB", bytes / 1024);
664 }
665 }
666 Ok(ExitCode::Ok)
667}
668
669fn cmd_config() -> VtaResult<ExitCode> {
671 let path = home()?.join("config.toml");
672 println!("config: {}", path.display());
673 match std::fs::read_to_string(&path) {
674 Ok(contents) => {
675 println!("---");
676 print!("{contents}");
677 }
678 Err(_) => println!("(no global config; create it to set [tools]/[settings])"),
679 }
680 Ok(ExitCode::Ok)
681}
682
683fn cmd_completions(rest: &[String]) -> VtaResult<ExitCode> {
685 let shell = rest
686 .iter()
687 .find(|a| !a.starts_with('-'))
688 .map(|s| s.as_str())
689 .unwrap_or("bash");
690 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";
691 match shell {
692 "bash" => println!("complete -W \"{cmds}\" vanta vt"),
693 "zsh" => println!("#compdef vanta vt\n_values 'vanta command' {cmds}"),
694 "fish" => {
695 for c in cmds.split(' ') {
696 println!("complete -c vanta -a {c}");
697 }
698 }
699 other => {
700 eprintln!("vanta: no completions for `{other}`");
701 return Ok(ExitCode::Usage);
702 }
703 }
704 Ok(ExitCode::Ok)
705}
706
707fn cmd_trust(rest: &[String]) -> VtaResult<ExitCode> {
709 let trust_dir = home()?.join("trust");
710 if has_flag(rest, "--list") {
711 match std::fs::read_dir(&trust_dir) {
712 Ok(rd) => {
713 for e in rd.flatten() {
714 if let Ok(target) = std::fs::read_to_string(e.path()) {
715 println!("{} {}", e.file_name().to_string_lossy(), target);
716 }
717 }
718 }
719 Err(_) => println!("(nothing trusted yet)"),
720 }
721 return Ok(ExitCode::Ok);
722 }
723 let path = match rest.iter().find(|a| !a.starts_with('-')) {
724 Some(p) => PathBuf::from(p),
725 None => find_manifest()?,
726 };
727 let hash = vanta_security::sha256_file(&path)?;
728 std::fs::create_dir_all(&trust_dir)
729 .map_err(|e| VtaError::new(Area::Vrf, 3, format!("trust dir: {e}")))?;
730 std::fs::write(trust_dir.join(&hash), path.display().to_string())
731 .map_err(|e| VtaError::new(Area::Vrf, 3, format!("recording trust: {e}")))?;
732 println!("trusted {} ({hash})", path.display());
733 Ok(ExitCode::Ok)
734}
735
736fn cmd_registry(rest: &[String]) -> VtaResult<ExitCode> {
738 let nonflags: Vec<&String> = rest.iter().filter(|a| !a.starts_with('-')).collect();
739 let cfg = home()?.join("config.toml");
740 match nonflags.first().map(|s| s.as_str()) {
741 Some("add") => {
742 if nonflags.len() < 3 {
743 eprintln!("usage: vanta registry add <name> <url>");
744 return Ok(ExitCode::Usage);
745 }
746 let (name, url) = (nonflags[1], nonflags[2]);
747 let block = format!("\n[registries.{name}]\nurl = \"{url}\"\n");
748 if let Some(parent) = cfg.parent() {
749 let _ = std::fs::create_dir_all(parent);
750 }
751 let mut existing = std::fs::read_to_string(&cfg).unwrap_or_default();
752 existing.push_str(&block);
753 std::fs::write(&cfg, existing)
754 .map_err(|e| VtaError::new(Area::Cfg, 1, format!("writing config: {e}")))?;
755 println!("added registry {name} → {url}");
756 Ok(ExitCode::Ok)
757 }
758 _ => {
759 if cfg.exists() {
760 let manifest = vanta_config::load_file(&cfg)?;
761 if manifest.registries.is_empty() {
762 println!("(no registries configured; the official registry is used)");
763 } else {
764 for (name, reg) in &manifest.registries {
765 println!("{name} {}", reg.url);
766 }
767 }
768 } else {
769 println!("(no config; the official registry is used by default)");
770 }
771 Ok(ExitCode::Ok)
772 }
773 }
774}
775
776fn cmd_shell(rest: &[String]) -> VtaResult<ExitCode> {
779 let specs: Vec<&String> = rest.iter().filter(|a| !a.starts_with('-')).collect();
780 if specs.is_empty() {
781 eprintln!("usage: vanta shell <tool>[@version] ...");
782 return Ok(ExitCode::Usage);
783 }
784 let registry = load_registry()?;
785 let resolver = Resolver::new(®istry);
786 let platform = Platform::current();
787 let engine = Engine::open(home()?)?;
788 for spec in &specs {
789 let request = Request::parse(spec)?;
790 let resolution = resolver.resolve(&request, &[platform])?;
791 let artifact = artifact_for(&resolution, &platform).ok_or_else(|| {
792 VtaError::new(
793 Area::Res,
794 5,
795 format!("no artifact for `{}`", resolution.tool),
796 )
797 })?;
798 engine.install_artifact(&resolution.tool, &resolution.version, artifact)?;
799 }
800 let shell = std::env::var("SHELL").unwrap_or_else(|_| {
801 if cfg!(windows) {
802 "cmd".to_string()
803 } else {
804 "/bin/sh".to_string()
805 }
806 });
807 println!(
808 "entering vanta subshell ({}); type `exit` to leave",
809 specs.len()
810 );
811 let status = Command::new(shell)
812 .env("PATH", env_path_with_bin()?)
813 .status()
814 .map_err(|e| VtaError::new(Area::Env, 1, format!("starting subshell: {e}")))?;
815 Ok(status_exit(status))
816}
817
818fn cmd_self(rest: &[String]) -> VtaResult<ExitCode> {
820 match rest
821 .iter()
822 .find(|a| !a.starts_with('-'))
823 .map(|s| s.as_str())
824 {
825 Some("uninstall") => {
826 let h = home()?;
827 if !has_flag(rest, "--yes") {
828 eprintln!(
829 "this will permanently remove {} — re-run with --yes",
830 h.display()
831 );
832 return Ok(ExitCode::Usage);
833 }
834 std::fs::remove_dir_all(&h).map_err(|e| {
835 VtaError::new(Area::Sys, 2, format!("removing {}: {e}", h.display()))
836 })?;
837 println!("removed {}", h.display());
838 Ok(ExitCode::Ok)
839 }
840 Some("update") => {
841 println!(
842 "self-update is handled by the channel you installed from; \
843 see docs/32-release-engineering.md"
844 );
845 Ok(ExitCode::Ok)
846 }
847 _ => {
848 eprintln!("usage: vanta self <uninstall|update>");
849 Ok(ExitCode::Usage)
850 }
851 }
852}
853
854fn env_path_with_bin() -> VtaResult<String> {
855 let bin = home()?.join("bin");
856 let sep = if cfg!(windows) { ';' } else { ':' };
857 Ok(format!(
858 "{}{}{}",
859 bin.display(),
860 sep,
861 std::env::var("PATH").unwrap_or_default()
862 ))
863}
864
865fn shell_command(cmd: &str) -> Command {
866 if cfg!(windows) {
867 let mut c = Command::new("cmd");
868 c.arg("/C").arg(cmd);
869 c
870 } else {
871 let mut c = Command::new("sh");
872 c.arg("-c").arg(cmd);
873 c
874 }
875}
876
877fn status_exit(status: std::process::ExitStatus) -> ExitCode {
878 if status.success() {
879 ExitCode::Ok
880 } else {
881 ExitCode::Failure
882 }
883}
884
885fn cmd_import(force: bool) -> VtaResult<ExitCode> {
888 let cwd = std::env::current_dir()
889 .map_err(|e| VtaError::new(Area::Cfg, 1, format!("cannot read current directory: {e}")))?;
890 let imported = vanta_migrate::import_dir(&cwd);
891 if imported.is_empty() {
892 println!("no version files detected in {}", cwd.display());
893 return Ok(ExitCode::Ok);
894 }
895 let target = cwd.join("vanta.toml");
896 if target.exists() && !force {
897 eprintln!("vanta.toml already exists (use --force to overwrite)");
898 return Ok(ExitCode::Usage);
899 }
900 println!("detected:");
901 for i in &imported {
902 println!(" {} = \"{}\" (from {})", i.tool, i.version, i.source);
903 }
904 let body = vanta_migrate::to_manifest_toml(&imported);
905 std::fs::write(&target, body)
906 .map_err(|e| VtaError::new(Area::Cfg, 1, format!("writing {}: {e}", target.display())))?;
907 println!(
908 "✓ wrote {} — run `vanta sync` to install + lock",
909 target.display()
910 );
911 Ok(ExitCode::Ok)
912}
913
914fn has_flag(rest: &[String], flag: &str) -> bool {
915 rest.iter().any(|a| a == flag)
916}
917
918fn home() -> VtaResult<PathBuf> {
920 if let Ok(h) = std::env::var("VANTA_HOME") {
921 return Ok(PathBuf::from(h));
922 }
923 let base = std::env::var("HOME")
924 .or_else(|_| std::env::var("USERPROFILE"))
925 .map_err(|_| {
926 VtaError::new(
927 Area::Sys,
928 2,
929 "cannot determine home directory; set VANTA_HOME".to_string(),
930 )
931 })?;
932 Ok(PathBuf::from(base).join(".vanta"))
933}
934
935fn load_registry() -> VtaResult<Registry> {
938 match std::env::var("VANTA_REGISTRY") {
939 Ok(loc) if loc.starts_with("http://") || loc.starts_with("https://") => {
940 let tmp =
941 std::env::temp_dir().join(format!("vanta-registry-{}.toml", std::process::id()));
942 vanta_net::Downloader::new()?.download(&loc, &tmp)?;
943 let registry = Registry::load_file(&tmp);
944 let _ = std::fs::remove_file(&tmp);
945 registry
946 }
947 Ok(path) => Registry::load_file(Path::new(&path)),
948 Err(_) => Ok(Registry::builtin()),
949 }
950}
951
952fn print_help() {
953 println!(
954 "vanta — every developer tool, one command\n\
955 \n\
956 USAGE:\n vanta <command> [args]\n\
957 \n\
958 COMMON COMMANDS:\n\
959 \x20 add <tool>[@ver] resolve and install a tool\n\
960 \x20 search <query> search the registry\n\
961 \x20 info <tool> show a tool's versions\n\
962 \x20 remove <tool> remove a tool\n\
963 \x20 update [tool] update within constraints\n\
964 \x20 sync reconcile to vanta.toml + vanta.lock\n\
965 \x20 doctor diagnose the installation\n\
966 \n\
967 See docs/04-cli.md for the full reference."
968 );
969}
970
971#[cfg(test)]
972mod tests {
973 use super::*;
974
975 #[test]
976 fn version_ok() {
977 assert_eq!(run(&["--version".into()]).unwrap(), ExitCode::Ok);
978 }
979
980 #[test]
981 fn unknown_is_usage() {
982 assert_eq!(run(&["frobnicate".into()]).unwrap(), ExitCode::Usage);
983 }
984
985 #[test]
986 fn add_no_args_is_usage() {
987 assert_eq!(run(&["add".into()]).unwrap(), ExitCode::Usage);
988 }
989
990 #[test]
991 fn add_unknown_tool_resolves_to_error() {
992 let err = run(&["add".into(), "totally-unknown-tool".into()]).unwrap_err();
994 assert_eq!(err.area, Area::Res);
995 }
996
997 #[test]
998 fn search_succeeds() {
999 assert_eq!(
1000 run(&["search".into(), "node".into()]).unwrap(),
1001 ExitCode::Ok
1002 );
1003 }
1004}