- 530
println!(); - 531
println!( - 532
"{} service{} still running the build this replaced:", - 533
stale.len(), - 534
if stale.len() == 1 { " is" } else { "s are" } - 535
); - 536
for r in &stale { - 537
let why = if !r.unit_points_at_installed { - 538
"execs outside the managed prefix" - 539
} else { - 540
"still on the previous binary" - 541
}; - 542
let state = match r.running_pid { - 543
Some(pid) => format!("pid {pid}"), - 544
None => "down".to_string(), - 545
}; - 546
println!(" {:<28} {state} — {why}", r.name); - 547
} - 548
println!(); - 549
println!("Until they restart, every surface they serve is the OLD build."); - 550
true - 551
} - 552
- 553
/// Whether this process could create a file in `dir`. - 554
fn is_writable_dir(dir: &Path) -> bool { - 555
let probe = dir.join(".vak-write-probe"); - 556
match std::fs::File::create(&probe) { - 557
Ok(_) => { - 558
let _ = std::fs::remove_file(&probe); - 559
true - 560
} - 561
Err(_) => false, - 562
} - 563
} - 564
- 565
// ----------------------------------------------------------------- verify - 566
- 567
/// Check an install against its manifest. - 568
pub fn run_verify(prefix: Option<PathBuf>) -> i32 { - 569
let root = InstallRoot::resolve(prefix); - 570
let m = match Manifest::read(&root) { - 571
Ok(m) => m, - 572
Err(e) => { - 573
eprintln!("error: {e}"); - 574
return 2; - 575
} - 576
}; - 577
let defects = m.verify(); - 578
println!("prefix {}", root.prefix().display()); - 579
println!("version {} ({})", m.version, m.git_sha); - 580
for c in &m.components { - 581
let mark = if defects.iter().any(|d| defect_names(d) == c.name) { - 582
"✗" - 583
} else if c.path.exists() { - 584
"✓" - 585
} else { - 586
"·" - 587
}; - 588
println!(" {mark} {:<22} {}", c.name, c.path.display()); - 589
} - 590
if defects.is_empty() { - 591
println!("install verifies clean"); - 592
0 - 593
} else { - 594
for d in &defects { - 595
eprintln!("defect: {d}"); - 596
} - 597
eprintln!("repair with: vak self reinstall"); - 598
1 - 599
} - 600
} - 601
- 602
fn defect_names(d: &manifest::Defect) -> &str { - 603
match d { - 604
manifest::Defect::Missing { name, .. } - 605
| manifest::Defect::Corrupt { name, .. } - 606
| manifest::Defect::Unreadable { name, .. } => name, - 607
} - 608
} - 609
- 610
// ----------------------------------------------------------------- status - 611
- 612
pub fn run_status(prefix: Option<PathBuf>) -> i32 { - 613
let root = InstallRoot::resolve(prefix); - 614
let build = manifest::build_version(); - 615
println!("build {} ({})", build, manifest::build_git_sha()); - 616
- 617
let m = match Manifest::read(&root) { - 618
Ok(m) => m, - 619
Err(e) => { - 620
println!("manifest — ({e})"); - 621
return 1; - 622
} - 623
}; - 624
println!( - 625
"prefix {}{}", - 626
root.prefix().display(), - 627
if root.is_bundle() { - 628
" (app bundle)" - 629
} else { - 630
"" - 631
} - 632
); - 633
println!("manifest {} installed {}", m.version, m.installed_at); - 634
- 635
let mut drifted = false; - 636
let defects = m.verify(); - 637
for d in &defects { - 638
eprintln!("drift: {d}"); - 639
drifted = true; - 640
} - 641
- 642
let cli = match m.cli_path() { - 643
Ok(p) => p, - 644
Err(e) => { - 645
eprintln!("drift: {e}"); - 646
return 1; - 647
} - 648
}; - 649
let rows = service_rows(&cli); - 650
for r in &rows { - 651
let state = match r.running_pid { - 652
Some(pid) => format!("running (pid {pid})"), - 653
None => "down".to_string(), - 654
}; - 655
// "never synced" and "synced against the wrong binary" both leave - 656
// unit_points_at_installed false, but they need different - 657
// instructions — and a fresh install is always the former. - 658
let flag = if !r.unit_present { - 659
"not registered — run `self services-sync`".to_string() - 660
} else if !r.unit_points_at_installed { - 661
"✗ execs outside the managed prefix — run `self services-sync`".to_string() - 662
} else if let Some(status) = r.failed_exit { - 663
format!( - 664
"✗ failing: last exited with status {status} — see its log in {}", - 665
vak_config::paths::logs_dir().display() - 666
) - 667
} else if r.binary_stale { - 668
"⚠ stale process — run `self services-sync`".to_string() - 669
} else { - 670
"✓".to_string() - 671
}; - 672
println!( - 673
"service {} {state} · {} · {flag}", - 674
r.name, - 675
r.unit_path.display() - 676
); - 677
if !r.unit_points_at_installed || r.binary_stale || r.failed_exit.is_some() { - 678
drifted = true; - 679
} - 680
} - 681
- 682
// Compare the manifest against the binary it describes, not against - 683
// whichever build happens to be running this command — otherwise a - 684
// dev build always reports drift against a good install. - 685
if let Ok(installed_version) = installed_cli_version(&cli) - 686
&& installed_version != m.version - 687
{ - 688
eprintln!( - 689
"drift: manifest says {} but the installed binary reports {installed_version}", - 690
m.version - 691
); - 692
drifted = true; - 693
} - 694
if let Some(note) = untagged_build_note(&m.version, &m.git_sha) { - 695
println!("note {note}"); - 696
} - 697
if m.version != build { - 698
println!( - 699
"note running build {build} differs from the install ({}) — expected when running from a source tree", - 700
m.version - 701
); - 702
} - 703
- 704
if drifted { 1 } else { 0 } - 705
} - 706
- 707
/// When git can tell (this runs inside a clone that has the installed - 708
/// commit), whether the install is the tagged release it claims to be. An - 709
/// abandoned release once left an installed build whose version existed in - 710
/// no tag, and nothing said so. - 711
fn untagged_build_note(version: &str, sha: &str) -> Option<String> { - 712
if sha.is_empty() || sha == "unknown" { - 713
return None; - 714
} - 715
let git = |args: &[&str]| { - 716
std::process::Command::new("git") - 717
.args(args) - 718
.output() - 719
.ok() - 720
.filter(|out| out.status.success()) - 721
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string()) - 722
}; - 723
let commit = git(&[ - 724
"rev-parse", - 725
"--verify", - 726
"--quiet", - 727
&format!("{sha}^{{commit}}"), - 728
])?; - 729
let tagged = git(&[ - 730
"rev-parse", - 731
"--verify", - 732
"--quiet", - 733
&format!("refs/tags/v{version}^{{commit}}"), - 734
]); - 735
match tagged { - 736
Some(tag_commit) if tag_commit == commit => None, - 737
Some(_) => Some(format!( - 738
"installed {version} was built from {sha}, not from tag v{version}: a development build" - 739
)), - 740
None => Some(format!( - 741
"installed {version} has no tag v{version}: a development build, not a release" - 742
)), - 743
} - 744
} - 745
- 746
/// Ask the installed binary what version it is. Used only to detect - 747
/// manifest drift; a failure here is not itself a defect. - 748
fn installed_cli_version(cli: &Path) -> Result<String, String> { - 749
let out = std::process::Command::new(cli) - 750
.arg("--version") - 751
.output() - 752
.map_err(|e| format!("exec {}: {e}", cli.display()))?; - 753
let text = String::from_utf8_lossy(&out.stdout); - 754
text.split_whitespace() - 755
.find_map(|t| feed::parse_version(t).ok()) - 756
.map(|v| v.to_string()) - 757
.ok_or_else(|| format!("no version in `{} --version` output", cli.display())) - 758
} - 759
- 760
// ---------------------------------------------------------- services-sync - 761
- 762
pub fn run_services_sync(prefix: Option<PathBuf>, names: Vec<String>) -> i32 { - 763
let root = InstallRoot::resolve(prefix); - 764
let cli = match Manifest::read(&root).and_then(|m| m.cli_path()) { - 765
Ok(p) => p, - 766
Err(e) => { - 767
eprintln!("error: {e}"); - 768
return 1; - 769
} - 770
}; - 771
// Activation is where a durable bearer token is first needed: the - 772
// bridge units registered below authenticate with it. - 773
if let Err(e) = vak_core::gateway_token::ensure_gateway_token() { - 774
eprintln!("warning: could not pin the gateway token: {e}"); - 775
} - 776
let data_home = vak_config::paths::data_home(); - 777
let default_workspace = vak_config::paths::default_workspace(); - 778
if let Err(error) = std::fs::create_dir_all(&default_workspace) { - 779
eprintln!( - 780
"error: could not create default workspace {}: {error}", - 781
default_workspace.display() - 782
); - 783
return 1; - 784
} - 785
- 786
// Shared skills, plugins, and the global hook table — the capabilities a - 787
// workspace is expected to have before anyone asks for one. - 788
// - 789
// This used to run ONLY from `vak setup` and `POST /onboarding/seed`, so - 790
// the documented build-and-install path (`scripts/build.sh`, which calls - 791
// `self install` then `services-sync`) produced a live, service-managed - 792
// deployment with zero skills, zero plugins, and no hook table — and - 793
// nothing said so. `doctor` reported "0 skills · 0 hooks" as though that - 794
// were a normal steady state rather than an install that never finished. - 795
// - 796
// Idempotent by construction: a skill already on disk is left alone, and - 797
// the hook table is only written when empty. Safe to run on every sync, - 798
// which is what makes it safe to put here rather than behind a flag. - 799
if let Err(error) = vak_core::seed::seed_shared_capabilities() { - 800
eprintln!("error: {error}"); - 801
return 1; - 802
} - 803
- 804
let requested: Vec<&str> = if names.is_empty() { - 805
vak_ops::services::default_service_names(&cli) - 806
} else { - 807
names.iter().map(String::as_str).collect() - 808
}; - 809
- 810
let outcomes = vak_ops::services::services_sync( - 811
&cli, - 812
&requested, - 813
&vak_ops::services::Paths::default(), - 814
&vak_ops::services::SystemRunner, - 815
); - 816
let mut failed = false; - 817
for o in outcomes { - 818
match o.action { - 819
vak_ops::services::SyncAction::Failed(e) => { - 820
failed = true; - 821
println!("✗ {}: {e}", o.name); - 822
} - 823
action => println!("✓ {}: {action:?}", o.name), - 824
} - 825
} - 826
- 827
// Per-bot bridge units (docs/design/34, multi-bot-per-channel) aren't in - 828
// the static SERVICES table above — there's no fixed count of them, so - 829
// they're reconciled separately against bots.json every time services - 830
// are synced (install, update, and manual `self services-sync` alike). - 831
// Without this, a bot created before the binary that first understood - 832
// multi-bot units would never get its unit spawned until the next admin - 833
// console edit touched it. - 834
let bot_outcomes = vak_ops::services::sync_bots( - 835
&cli, - 836
&data_home, - 837
&vak_ops::OpsConfig::detect().base_url(), - 838
&vak_ops::services::Paths::default(), - 839
&vak_ops::services::SystemRunner, - 840
); - 841
for o in bot_outcomes { - 842
match o.action { - 843
vak_ops::services::SyncAction::Failed(e) => { - 844
failed = true; - 845
println!("✗ {}: {e}", o.name); - 846
} - 847
action => println!("✓ {}: {action:?}", o.name), - 848
} - 849
} - 850
- 851
if failed { 1 } else { 0 } - 852
} - 853
- 854
// ----------------------------------------------------------------- update - 855
- 856
/// Pull a newer release from a feed and replace every component at once. - 857
/// - 858
/// The fetch uses a blocking HTTP client, and these subcommands dispatch - 859
/// from inside the CLI's tokio runtime — constructing or dropping a - 860
/// blocking client there panics ("Cannot drop a runtime in a context - 861
/// where blocking is not allowed"). The whole transfer therefore runs on - 862
/// a dedicated thread, the same confinement `update_check` uses. - 863
pub fn run_update(prefix: Option<PathBuf>, url: &str, yes: bool, dry_run: bool) -> i32 { - 864
let root = InstallRoot::resolve(prefix); - 865
let outcome = { - 866
let root = root.clone(); - 867
let url = url.to_string(); - 868
match std::thread::spawn(move || update(&root, &url, yes, dry_run)).join() { - 869
Ok(r) => r, - 870
Err(_) => Err("update thread panicked".to_string()), - 871
} - 872
}; - 873
match outcome { - 874
Ok(Some(version)) => { - 875
println!("updated → {version}"); - 876
println!("reloading services…"); - 877
run_services_sync(Some(root.prefix().to_path_buf()), Vec::new()) - 878
} - 879
Ok(None) => 0, - 880
Err(e) => { - 881
eprintln!("error: {e}"); - 882
1 - 883
} - 884
} - 885
} - 886
- 887
fn update( - 888
root: &InstallRoot, - 889
url: &str, - 890
yes: bool, - 891
dry_run: bool, - 892
) -> Result<Option<String>, String> { - 893
let mut installed = Manifest::read(root)?; - 894
- 895
let client = reqwest::blocking::Client::builder() - 896
.timeout(std::time::Duration::from_secs(60)) - 897
.build() - 898
.map_err(|e| format!("http client: {e}"))?; - 899
- 900
let body = client - 901
.get(url) - 902
.send() - 903
.and_then(reqwest::blocking::Response::error_for_status) - 904
.map_err(|e| format!("release feed unreachable at {url}: {e}"))? - 905
.bytes() - 906
.map_err(|e| format!("release feed body: {e}"))?; - 907
let feed = Feed::parse(&body)?; - 908
- 909
// Compare against what is installed, not against the running build. - 910
match feed.decide(&installed.version)? { - 911
Decision::UpToDate { installed, offered } => { - 912
println!("up to date ({installed} installed, {offered} offered)"); - 913
if !dry_run { - 914
vak_core::seed::seed_shared_capabilities() - 915
.map_err(|error| format!("capability update failed: {error}"))?; - 916
} - 917
return Ok(None); - 918
} - 919
Decision::Upgrade { from, to } => { - 920
println!("update available: {from} → {to}"); - 921
if let Some(notes) = &feed.notes_url { - 922
println!("notes: {notes}"); - 923
} - 924
if dry_run { - 925
return Ok(None); - 926
} - 927
if !confirm(&format!("update {from} → {to}?"), yes) { - 928
println!("aborted"); - 929
return Ok(None); - 930
} - 931
} - 932
} - 933
- 934
let key = feed::platform_key(); - 935
let artifacts = feed.artifacts_for(&key)?; - 936
- 937
// A release feed ships executables and nothing else (scripts/release.sh: - 938
// REQUIRED/OPTIONAL are all binaries). Inside a macOS bundle the desktop - 939
// app's UI is a *separate tree* under Contents/Resources, so replacing - 940
// `vak-desktop` here would leave a new binary driving the previous - 941
// version's frontend — the same binary/bundle skew that shipped a blank - 942
// admin console twice, and one `verify` cannot see, because the files it - 943
// digests did not change. - 944
// - 945
// Refusing is the honest answer. An update that knowingly produces a - 946
// mismatched app is worse than one that says it cannot do this. - 947
if root.is_bundle() && artifacts.iter().any(|a| a.name == "vak-desktop") { - 948
return Err(format!( - 949
"this install is a macOS application bundle at {}, and the release feed carries \ - 950
executables only — updating `vak-desktop` here would leave it driving the \ - 951
previous version's frontend.\nInstall the {} disk image instead, or update a \ - 952
non-bundle prefix with `--prefix`.", - 953
root.prefix().display(), - 954
feed.version()?, - 955
)); - 956
} - 957
- 958
// Download and verify every artifact before touching the install. - 959
let mut tx = Transaction::begin(root)?; - 960
let mut next_components = Vec::new(); - 961
for artifact in &artifacts { - 962
println!(" fetching {}…", artifact.name); - 963
let bytes = client - 964
.get(&artifact.url) - 965
.send() - 966
.and_then(reqwest::blocking::Response::error_for_status) - 967
.map_err(|e| format!("download {}: {e}", artifact.name))? - 968
.bytes() - 969
.map_err(|e| format!("read {}: {e}", artifact.name))?; - 970
let actual = digest::of_bytes(&bytes); - 971
if !digest::matches(&artifact.sha256, &actual) { - 972
return Err(format!( - 973
"{} failed integrity check — expected {}, got {actual}. Nothing was installed.", - 974
artifact.name, - 975
artifact.sha256.trim() - 976
)); - 977
} - 978
let destination = installed - 979
.component(&artifact.name) - 980
.map(|c| c.path.clone()) - 981
.unwrap_or_else(|| root.bin_dir().join(&artifact.name)); - 982
tx.stage_bytes(&artifact.name, &bytes, destination.clone(), true)?; - 983
next_components.push(Component { - 984
name: artifact.name.clone(), - 985
path: destination, - 986
sha256: actual, - 987
required: artifact.required, - 988
}); - 989
} - 990
- 991
if tx.is_empty() { - 992
return Err(format!("release {} offers nothing for {key}", feed.version)); - 993
} - 994
tx.commit()?; - 995
- 996
// Capability seeds live in the canonical Shared workspace rather than - 997
// inside the executable prefix. Reconcile them after every real update, - 998
// including releases that add no new binary component. The seed manifest - 999
// advances untouched shipped content and preserves user edits. - 1000
vak_core::seed::seed_shared_capabilities() - 1001
.map_err(|error| format!("capability update failed: {error}"))?; - 1002
- 1003
// Carry forward components the feed did not ship, so the manifest - 1004
// keeps describing the whole install rather than only what moved. - 1005
for existing in installed.components.clone() { - 1006
if !next_components.iter().any(|c| c.name == existing.name) { - 1007
next_components.push(existing); - 1008
} - 1009
} - 1010
installed.version = feed.version()?.to_string(); - 1011
installed.installed_at = manifest::now_rfc3339(); - 1012
installed.components = next_components; - 1013
installed.write(root)?; - 1014
- 1015
// `desktop: false` — the refusal above guarantees this update carried - 1016
// no `vak-desktop`, so there is no frontend requirement to enforce and - 1017
// nothing under Resources to replace. All this call still does is - 1018
// restamp Info.plist with the new version. - 1019
bundle::write_metadata(root, &installed.version, None, false)?; - 1020
Ok(Some(installed.version.clone())) - 1021
} - 1022
- 1023
// -------------------------------------------------------------- uninstall - 1024
- 1025
pub fn run_uninstall(prefix: Option<PathBuf>, yes: bool, purge: bool) -> i32 { - 1026
let root = InstallRoot::resolve(prefix); - 1027
if !confirm( - 1028
&format!( - 1029
"stop services and remove the managed install at {}?", - 1030
root.prefix().display() - 1031
), - 1032
yes, - 1033
) { - 1034
println!("aborted"); - 1035
return 0; - 1036
} - 1037
- 1038
// Only tear down units that actually exec from THIS prefix. - 1039
// - 1040
// Service teardown used to run against every known unit name with the - 1041
// real `Paths::default()`, whatever `--prefix` said — so uninstalling a - 1042
// throwaway prefix unregistered the operator's real services. Running - 1043
// the test suite on a machine with vak installed stopped its gateway, - 1044
// tray, and bridges, silently. A unit that execs a different install - 1045
// belongs to that install. - 1046
let paths = vak_ops::services::Paths::default(); - 1047
let owned: Vec<&str> = vak_ops::services::SERVICES - 1048
.iter() - 1049
.map(|d| d.name) - 1050
.filter(|name| vak_ops::services::unit_belongs_to_prefix(name, root.prefix(), &paths)) - 1051
.collect(); - 1052
if !owned.is_empty() - 1053
&& let Err(e) = - 1054
vak_ops::services::services_uninstall(&owned, &paths, &vak_ops::services::SystemRunner) - 1055
{ - 1056
eprintln!("warning: service teardown incomplete: {e}"); - 1057
} - 1058
- 1059
// Per-bot bridge units are generated from bots.json rather than living - 1060
// in the static table, and are filtered the same way. - 1061
let data_home = vak_config::paths::data_home(); - 1062
let owned_bots: Vec<String> = vak_ops::services::configured_bot_service_names_all(&data_home) - 1063
.into_iter() - 1064
.filter(|name| vak_ops::services::unit_belongs_to_prefix(name, root.prefix(), &paths)) - 1065
.collect(); - 1066
if !owned_bots.is_empty() { - 1067
let refs: Vec<&str> = owned_bots.iter().map(String::as_str).collect(); - 1068
if let Err(e) = - 1069
vak_ops::services::services_uninstall(&refs, &paths, &vak_ops::services::SystemRunner) - 1070
{ - 1071
eprintln!("warning: bridge teardown incomplete: {e}"); - 1072
} - 1073
} - 1074
- 1075
// Report components installed elsewhere, which removing this prefix - 1076
// will not reach. - 1077
if let Ok(m) = Manifest::read(&root) { - 1078
for c in &m.components { - 1079
if !c.path.starts_with(root.prefix()) { - 1080
eprintln!( - 1081
"note: {} lives outside the prefix and was left in place: {}", - 1082
c.name, - 1083
c.path.display() - 1084
); - 1085
} - 1086
} - 1087
} - 1088
- 1089
match std::fs::remove_dir_all(root.prefix()) { - 1090
Ok(()) => println!("removed {}", root.prefix().display()), - 1091
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - 1092
println!("nothing installed at {}", root.prefix().display()); - 1093
} - 1094
Err(e) => eprintln!("warning: remove {}: {e}", root.prefix().display()), - 1095
} - 1096
- 1097
// The symlink `report_next_steps` tells people to create. Leaving it - 1098
// is how an uninstall leaves a dangling `vak` on PATH that fails with - 1099
// a confusing "no such file" months later. - 1100
remove_dangling_cli_symlink(root.prefix()); - 1101
- 1102
if purge { - 1103
return purge_state(yes); - 1104
} - 1105
println!(); - 1106
println!("configuration and data were kept; `--purge` removes them too"); - 1107
0 - 1108
} - 1109
- 1110
/// Remove any `vak` symlink that points into the prefix we just deleted. - 1111
/// - 1112
/// Only ever a symlink, and only when it resolves into this prefix: a real - 1113
/// binary someone else installed there is theirs, not ours to remove. - 1114
/// - 1115
/// Every directory `report_next_steps` can send someone to is checked, not - 1116
/// just `/usr/local/bin`. That single hardcoded path was the bug: on Apple - 1117
/// Silicon the link lands in `/opt/homebrew/bin`, so an uninstall left a - 1118
/// dangling `vak` on PATH that failed with "no such file or directory" — - 1119
/// which reads like a broken install rather than a removed one. - 1120
fn remove_dangling_cli_symlink(prefix: &Path) { - 1121
for dir in cli_link_dirs() { - 1122
let link = dir.join("vak"); - 1123
let Ok(meta) = std::fs::symlink_metadata(&link) else { - 1124
continue; - 1125
}; - 1126
if !meta.file_type().is_symlink() { - 1127
continue; - 1128
} - 1129
let Ok(target) = std::fs::read_link(&link) else { - 1130
continue; - 1131
}; - 1132
if !target.starts_with(prefix) { - 1133
continue; - 1134
} - 1135
match std::fs::remove_file(&link) { - 1136
Ok(()) => println!("removed the {} symlink", link.display()), - 1137
Err(e) => eprintln!( - 1138
"note: {} points into the removed prefix; remove it with: sudo rm {} ({e})", - 1139
link.display(), - 1140
link.display() - 1141
), - 1142
} - 1143
} - 1144
} - 1145
- 1146
/// Everything the state registry says a purge removes. - 1147
/// - 1148
/// Derived from `vak_core::state`, not from a list kept here, and framed - 1149
/// as a **preserve rule** rather than a delete list: when a new durable - 1150
/// file appears, the safe default is to leave it alone, and the registry - 1151
/// test fails the build until someone declares what should happen to it - 1152
/// (doc 46 D3, Part VII.2). - 1153
fn purge_state(yes: bool) -> i32 { - 1154
use vak_core::state::{OnPurge, Root}; - 1155
- 1156
let mut targets: Vec<(PathBuf, &'static str)> = Vec::new(); - 1157
for root in Root::ALL { - 1158
let base = vak_core::state::root_path(root); - 1159
for entry in vak_core::state::entries_for(root) { - 1160
if entry.on_purge != OnPurge::Remove { - 1161
continue; - 1162
} - 1163
let path = if entry.path.is_empty() { - 1164
base.clone() - 1165
} else { - 1166
base.join(entry.path) - 1167
}; - 1168
if path.exists() { - 1169
targets.push((path, entry.path)); - 1170
} - 1171
} - 1172
} - 1173
- 1174
if targets.is_empty() { - 1175
println!("nothing to purge — no declared state is present"); - 1176
return 0; - 1177
} - 1178
- 1179
// Name what dies. A confirmation that says "delete everything?" is one - 1180
// people answer without reading. - 1181
println!(); - 1182
println!("--purge will permanently delete:"); - 1183
for (path, _) in &targets { - 1184
println!(" {}", path.display()); - 1185
} - 1186
println!(); - 1187
println!("this includes your Shared configuration, your stored keys, every session"); - 1188
println!("ledger, and every installed skill and plugin."); - 1189
println!("project `.vak` directories inside your own repositories are NOT touched."); - 1190
if !confirm("permanently delete all of that?", yes) { - 1191
println!("kept everything; nothing was deleted"); - 1192
return 0; - 1193
} - 1194
- 1195
let mut failed = false; - 1196
for (path, declared) in &targets { - 1197
// A symlinked root would make `remove_dir_all` follow the link and - 1198
// delete somebody else's directory. Refuse rather than guess. - 1199
match std::fs::symlink_metadata(path) { - 1200
Ok(meta) if meta.file_type().is_symlink() => { - 1201
eprintln!( - 1202
"refused: {} is a symlink; remove it by hand if you meant to", - 1203
path.display() - 1204
); - 1205
failed = true; - 1206
continue; - 1207
} - 1208
_ => {} - 1209
} - 1210
let outcome = if path.is_dir() { - 1211
std::fs::remove_dir_all(path) - 1212
} else { - 1213
std::fs::remove_file(path) - 1214
}; - 1215
match outcome { - 1216
Ok(()) => println!(" removed {declared}"), - 1217
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - 1218
Err(e) => { - 1219
eprintln!(" warning: {}: {e}", path.display()); - 1220
failed = true; - 1221
} - 1222
} - 1223
} - 1224
- 1225
println!(); - 1226
println!("purged. the next install is a genuine first run."); - 1227
i32::from(failed) - 1228
} - 1229
- 1230
#[cfg(test)] - 1231
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 1232
mod tests { - 1233
use super::*; - 1234
- 1235
#[test] - 1236
fn confirm_refuses_rather_than_assumes_when_not_interactive() { - 1237
// A piped stdin with no --yes must not be read as consent. - 1238
assert!(!confirm("do the thing?", false)); - 1239
assert!(confirm("do the thing?", true)); - 1240
} - 1241
- 1242
#[test] - 1243
fn every_optional_component_is_declared_optional() { - 1244
let required: Vec<&str> = COMPONENTS - 1245
.iter() - 1246
.filter(|c| c.required) - 1247
.map(|c| c.name) - 1248
.collect(); - 1249
assert_eq!( - 1250
required, - 1251
vec!["vak"], - 1252
"only the CLI is required; the rest ride along when built" - 1253
); - 1254
} - 1255
- 1256
#[cfg(unix)] - 1257
fn executable(path: &Path) { - 1258
use std::os::unix::fs::PermissionsExt as _; - 1259
std::fs::write(path, "#!/bin/sh\n").unwrap(); - 1260
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); - 1261
} - 1262
- 1263
#[cfg(unix)] - 1264
#[test] - 1265
fn cli_on_path_follows_a_symlink_into_the_install() { - 1266
let tmp = tempfile::tempdir().unwrap(); - 1267
let bin = tmp.path().join("App/Contents/MacOS"); - 1268
let link_dir = tmp.path().join("local-bin"); - 1269
std::fs::create_dir_all(&bin).unwrap(); - 1270
std::fs::create_dir_all(&link_dir).unwrap(); - 1271
let cli = bin.join("vak"); - 1272
executable(&cli); - 1273
std::os::unix::fs::symlink(&cli, link_dir.join("vak")).unwrap(); - 1274
- 1275
let path = std::env::join_paths([&link_dir]).unwrap(); - 1276
assert_eq!(cli_on_path(Some(&path), &cli), CliOnPath::ThisInstall); - 1277
let path = std::env::join_paths([&bin]).unwrap(); - 1278
assert_eq!(cli_on_path(Some(&path), &cli), CliOnPath::ThisInstall); - 1279
} - 1280
- 1281
#[cfg(unix)] - 1282
#[test] - 1283
fn cli_on_path_reports_an_earlier_different_vak_as_shadowing() { - 1284
let tmp = tempfile::tempdir().unwrap(); - 1285
let bin = tmp.path().join("install"); - 1286
let stale = tmp.path().join("cargo-bin"); - 1287
let not_exec = tmp.path().join("plain"); - 1288
for dir in [&bin, &stale, ¬_exec] { - 1289
std::fs::create_dir_all(dir).unwrap(); - 1290
} - 1291
let cli = bin.join("vak"); - 1292
executable(&cli); - 1293
executable(&stale.join("vak")); - 1294
std::fs::write(not_exec.join("vak"), "not a program").unwrap(); - 1295
- 1296
// A non-executable `vak` is skipped, as a shell would skip it. - 1297
let path = std::env::join_paths([¬_exec, &bin]).unwrap(); - 1298
assert_eq!(cli_on_path(Some(&path), &cli), CliOnPath::ThisInstall); - 1299
let path = std::env::join_paths([&stale, &bin]).unwrap(); - 1300
assert_eq!( - 1301
cli_on_path(Some(&path), &cli), - 1302
CliOnPath::Shadowed(stale.join("vak")) - 1303
); - 1304
} - 1305
- 1306
#[test] - 1307
fn cli_on_path_is_absent_without_a_match() { - 1308
let tmp = tempfile::tempdir().unwrap(); - 1309
let cli = tmp.path().join("vak"); - 1310
let empty = tempfile::tempdir().unwrap(); - 1311
let path = std::env::join_paths([empty.path()]).unwrap(); - 1312
assert_eq!(cli_on_path(Some(&path), &cli), CliOnPath::Absent); - 1313
assert_eq!(cli_on_path(None, &cli), CliOnPath::Absent); - 1314
} - 1315
} - 1316
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.