- 235
existing.version, - 236
existing.git_sha - 237
)); - 238
} - 239
} - 240
- 241
let bin_dir = root.bin_dir(); - 242
let mut tx = Transaction::begin(root)?; - 243
let mut components = Vec::new(); - 244
- 245
for spec in COMPONENTS { - 246
// The CLI is the binary we are running; siblings come from the - 247
// same build directory. - 248
let source = if spec.name == "vak" { - 249
exe.clone() - 250
} else { - 251
build_dir.join(spec.name) - 252
}; - 253
if !source.exists() { - 254
if spec.required { - 255
return Err(format!( - 256
"required component {} not found at {}", - 257
spec.name, - 258
source.display() - 259
)); - 260
} - 261
continue; - 262
} - 263
let destination = bin_dir.join(spec.name); - 264
tx.stage_file(spec.name, &source, destination.clone(), true)?; - 265
components.push(Component { - 266
name: spec.name.to_string(), - 267
path: destination, - 268
sha256: digest::of_file(&source)?, - 269
required: spec.required, - 270
}); - 271
} - 272
- 273
// Before anything is placed. A bundle that cannot launch is not an - 274
// install, and refusing after the commit would leave binaries with no - 275
// manifest beside them — worse than either finishing or not starting. - 276
let desktop = components.iter().any(|c| c.name == "vak-desktop"); - 277
let frontend = bundle::locate_frontend_assets(); - 278
bundle::require_frontend(root, frontend.as_deref(), desktop)?; - 279
- 280
tx.commit()?; - 281
- 282
let version = manifest::build_version().to_string(); - 283
bundle::write_metadata(root, &version, frontend.as_deref(), desktop)?; - 284
- 285
if let Some(feed_assets) = bundle::locate_feed_assets() { - 286
let resources = root.resources_dir(); - 287
std::fs::create_dir_all(&resources) - 288
.map_err(|e| format!("create resources {}: {e}", resources.display()))?; - 289
let installed_feeds = resources.join("feeds"); - 290
if installed_feeds.exists() { - 291
std::fs::remove_dir_all(&installed_feeds).map_err(|e| { - 292
format!( - 293
"clear stale feed runtime {}: {e}", - 294
installed_feeds.display() - 295
) - 296
})?; - 297
} - 298
atomic::copy_dir(&feed_assets, &installed_feeds)?; - 299
} - 300
- 301
// Record the frontend as an installed asset, not just as a side effect. - 302
// - 303
// `verify` checks the manifest and nothing else, so anything the - 304
// manifest does not describe is, as far as every check in this product - 305
// is concerned, not installed. That is how a bundle could ship with a - 306
// blank window and still verify clean. The digest is over the whole - 307
// tree because the failure that happens is the shell arriving without - 308
// the assets it names, and `index.html` alone would not notice. - 309
if desktop && root.is_bundle() { - 310
let resources = root.resources_dir(); - 311
// The manifest itself lands in this directory (a bundle keeps - 312
// `install.json` under Contents/Resources), and it cannot be inside - 313
// the digest it is about to carry. - 314
let manifest_path = root.manifest_path(); - 315
components.push(Component { - 316
name: "desktop-frontend".to_string(), - 317
sha256: digest::of_tree_excluding(&resources, &[manifest_path.as_path()])?, - 318
path: resources, - 319
required: true, - 320
}); - 321
} - 322
- 323
let m = Manifest::new(version, root.prefix().to_path_buf(), components); - 324
m.write(root)?; - 325
- 326
// Verify what we just wrote rather than trusting that we wrote it. - 327
let defects = m.verify(); - 328
if !defects.is_empty() { - 329
return Err(format!( - 330
"install completed but does not verify: {}", - 331
defects - 332
.iter() - 333
.map(ToString::to_string) - 334
.collect::<Vec<_>>() - 335
.join("; ") - 336
)); - 337
} - 338
Ok(m) - 339
} - 340
- 341
/// Directories a `vak` symlink plausibly lives in, most-preferred first. - 342
/// - 343
/// `/usr/local/bin` is NOT the answer on Apple Silicon: Homebrew moved to - 344
/// `/opt/homebrew/bin` there, and `/usr/local/bin` is frequently absent - 345
/// from PATH entirely. Advising it unconditionally sent ARM Mac users to - 346
/// create a link their shell would never find. - 347
/// - 348
/// Shared with [`remove_dangling_cli_symlink`] so uninstall cleans up - 349
/// exactly the places install can send someone — the two used to disagree, - 350
/// which is how a `vak` that fails with "no such file" survived an - 351
/// uninstall. - 352
fn cli_link_dirs() -> Vec<PathBuf> { - 353
let mut dirs = Vec::new(); - 354
if let Some(home) = std::env::var_os("HOME") { - 355
dirs.push(PathBuf::from(home).join(".local/bin")); - 356
} - 357
dirs.push(PathBuf::from("/opt/homebrew/bin")); - 358
dirs.push(PathBuf::from("/usr/local/bin")); - 359
dirs - 360
} - 361
- 362
/// The best place to suggest linking the CLI: a directory that already - 363
/// exists AND is already on this user's PATH, so the advice works when - 364
/// followed rather than being technically true. - 365
fn suggested_link_dir() -> Option<PathBuf> { - 366
let path = std::env::var_os("PATH")?; - 367
let on_path: Vec<PathBuf> = std::env::split_paths(&path).collect(); - 368
cli_link_dirs() - 369
.into_iter() - 370
.find(|dir| dir.is_dir() && on_path.contains(dir)) - 371
} - 372
- 373
/// Which `vak` a shell would run, relative to the installed CLI. - 374
#[derive(Debug, PartialEq, Eq)] - 375
enum CliOnPath { - 376
ThisInstall, - 377
/// An earlier PATH entry holds a different `vak`. - 378
Shadowed(PathBuf), - 379
Absent, - 380
} - 381
- 382
/// Resolve `cli`'s file name the way a shell does — the first executable - 383
/// match in `path_var` wins — and compare canonical paths, so a symlink - 384
/// into the install (`~/.local/bin/vak`) counts as this install. Checking - 385
/// only whether the install's own directory is on PATH reported a linked - 386
/// CLI as missing. - 387
fn cli_on_path(path_var: Option<&std::ffi::OsStr>, cli: &Path) -> CliOnPath { - 388
let (Some(path_var), Some(name)) = (path_var, cli.file_name()) else { - 389
return CliOnPath::Absent; - 390
}; - 391
let target = std::fs::canonicalize(cli).unwrap_or_else(|_| cli.to_path_buf()); - 392
for dir in std::env::split_paths(path_var) { - 393
let candidate = dir.join(name); - 394
if !is_executable_file(&candidate) { - 395
continue; - 396
} - 397
return match std::fs::canonicalize(&candidate) { - 398
Ok(real) if real == target => CliOnPath::ThisInstall, - 399
_ => CliOnPath::Shadowed(candidate), - 400
}; - 401
} - 402
CliOnPath::Absent - 403
} - 404
- 405
fn is_executable_file(path: &Path) -> bool { - 406
let Ok(meta) = std::fs::metadata(path) else { - 407
return false; - 408
}; - 409
#[cfg(unix)] - 410
{ - 411
use std::os::unix::fs::PermissionsExt as _; - 412
meta.is_file() && meta.permissions().mode() & 0o111 != 0 - 413
} - 414
#[cfg(not(unix))] - 415
{ - 416
meta.is_file() - 417
} - 418
} - 419
- 420
fn report_next_steps(root: &InstallRoot, stale_services: bool) { - 421
let cli = root.bin_dir().join("vak"); - 422
let resolved = cli_on_path(std::env::var_os("PATH").as_deref(), &cli); - 423
match &resolved { - 424
CliOnPath::ThisInstall => {} - 425
// A link placed after the shadowing entry would change nothing, so - 426
// only the PATH order is offered. - 427
CliOnPath::Shadowed(other) => { - 428
println!(); - 429
println!( - 430
"`vak` on PATH is {}, not this install; put this one first:", - 431
other.display() - 432
); - 433
println!(" export PATH=\"{}:$PATH\"", root.bin_dir().display()); - 434
} - 435
CliOnPath::Absent => { - 436
println!(); - 437
println!("the CLI is not on PATH; either add it:"); - 438
println!(" export PATH=\"{}:$PATH\"", root.bin_dir().display()); - 439
println!("or link it:"); - 440
match suggested_link_dir() { - 441
Some(dir) => { - 442
let link = dir.join("vak"); - 443
// No `sudo` when the directory is already writable; asking - 444
// for root to write a directory the user owns teaches people - 445
// to sudo things that do not need it. - 446
let sudo = if is_writable_dir(&dir) { "" } else { "sudo " }; - 447
println!(" {sudo}ln -sf {} {}", cli.display(), link.display()); - 448
} - 449
None => { - 450
println!(" sudo ln -sf {} /usr/local/bin/vak", cli.display()); - 451
println!(" (then make sure /usr/local/bin is on your PATH)"); - 452
} - 453
} - 454
} - 455
} - 456
println!(); - 457
if stale_services { - 458
// The fresh-machine advice is wrong and actively misleading on a - 459
// machine that already has services: `setup` does not restart them. - 460
println!("next: vak self services-sync (restart them onto this build)"); - 461
} else { - 462
println!("next: vak setup (choose a workspace, connect a model, activate services)"); - 463
} - 464
} - 465
- 466
/// Every service unit this install is responsible for, with its live state. - 467
/// - 468
/// Shared by `self status` and the post-install report below, so the two - 469
/// can never disagree about what "stale" means. - 470
/// Each managed service of the installed build that is failing: its name, - 471
/// the status it last exited with, and where its log is. Empty when - 472
/// nothing is installed. - 473
pub(crate) fn failing_services() -> Vec<(String, i32)> { - 474
let Ok(manifest) = Manifest::read(&InstallRoot::resolve(None)) else { - 475
return Vec::new(); - 476
}; - 477
let Ok(cli) = manifest.cli_path() else { - 478
return Vec::new(); - 479
}; - 480
service_rows(&cli) - 481
.into_iter() - 482
.filter_map(|row| row.failed_exit.map(|status| (row.name, status))) - 483
.collect() - 484
} - 485
- 486
fn service_rows(cli: &Path) -> Vec<vak_ops::services::ServiceRow> { - 487
let data_home = vak_config::paths::data_home(); - 488
let names = vak_ops::services::default_service_names(cli); - 489
let mut specs: Vec<_> = vak_ops::services::resolve_specs(cli, &names) - 490
.into_iter() - 491
.flatten() - 492
.collect(); - 493
specs.extend(vak_ops::services::configured_bot_service_specs( - 494
cli, - 495
&data_home, - 496
&vak_ops::OpsConfig::detect().base_url(), - 497
)); - 498
vak_ops::services::status_specs( - 499
&specs, - 500
&vak_ops::services::Paths::default(), - 501
&vak_ops::services::SystemRunner, - 502
) - 503
} - 504
- 505
/// Services still running the build this install just replaced. - 506
/// - 507
/// `self install` swaps binaries on disk and deliberately does not restart - 508
/// anything — one writer, and killing a running agent mid-turn in order to - 509
/// place a file is not a trade an installer gets to make by itself. - 510
/// - 511
/// Saying nothing, however, is worse than either. That exact sequence has - 512
/// happened here: the app on disk was current, `self verify` was clean, the - 513
/// install printed "next: vak setup" — and every browser surface served the - 514
/// previous build for hours, because launchd was still running the old - 515
/// inode and nothing anywhere said so. An install that leaves the running - 516
/// system on the old build has not finished, and has to be the one to - 517
/// mention it. - 518
/// - 519
/// Returns true when something is stale, so the caller can make the closing - 520
/// line the command that fixes it rather than the one for a fresh machine. - 521
fn report_stale_services(cli: &Path) -> bool { - 522
let rows = service_rows(cli); - 523
let stale: Vec<_> = rows - 524
.iter() - 525
.filter(|r| r.unit_present && (!r.unit_points_at_installed || r.binary_stale)) - 526
.collect(); - 527
if stale.is_empty() { - 528
return false; - 529
} - 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
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.