- 1
//! Managed release lifecycle: install, reinstall, verify, status, update, - 2
//! uninstall (docs/design/32-release-engineering.md). - 3
//! - 4
//! Design rules this module holds to, each of which had a counterexample - 5
//! in the code it replaces: - 6
//! - 7
//! * One prefix resolution shared by every subcommand, so an install to a - 8
//! custom prefix stays inspectable and removable. - 9
//! * Every mutation is a transaction that rolls back, so a failure never - 10
//! leaves a half-installed prefix. - 11
//! * The manifest records a digest per component, so `status` reports - 12
//! drift instead of assuming its absence. - 13
//! * Version decisions are semantic, never lexical. - 14
//! * A downloaded artifact is verified before it is allowed near the - 15
//! install root. - 16
- 17
pub mod atomic; - 18
pub mod bundle; - 19
pub mod digest; - 20
pub mod feed; - 21
pub mod layout; - 22
pub mod manifest; - 23
pub mod transaction; - 24
- 25
#[cfg(not(test))] - 26
use std::io::{IsTerminal as _, Write as _}; - 27
use std::path::{Path, PathBuf}; - 28
- 29
use feed::{Decision, Feed}; - 30
use layout::InstallRoot; - 31
use manifest::{Component, Manifest}; - 32
use transaction::Transaction; - 33
- 34
/// What a release is made of. Only the CLI is required; the rest ride - 35
/// along when the build produced them, and their absence is normal - 36
/// rather than a defect. - 37
struct ComponentSpec { - 38
name: &'static str, - 39
required: bool, - 40
} - 41
- 42
const COMPONENTS: &[ComponentSpec] = &[ - 43
ComponentSpec { - 44
name: "vak", - 45
required: true, - 46
}, - 47
ComponentSpec { - 48
name: "vak-desktop", - 49
required: false, - 50
}, - 51
ComponentSpec { - 52
name: "vak-delivery-worker", - 53
required: false, - 54
}, - 55
]; - 56
- 57
fn confirm(prompt: &str, yes: bool) -> bool { - 58
#[cfg(test)] - 59
{ - 60
let _ = prompt; - 61
yes - 62
} - 63
#[cfg(not(test))] - 64
{ - 65
if yes || !std::io::stdin().is_terminal() { - 66
// Non-interactive without --yes is a refusal, not an assumption: - 67
// a script must say so explicitly before anything is replaced. - 68
return yes; - 69
} - 70
print!("{prompt} [y/N] "); - 71
let _ = std::io::stdout().flush(); - 72
let mut line = String::new(); - 73
let _ = std::io::stdin().read_line(&mut line); - 74
line.trim().eq_ignore_ascii_case("y") - 75
} - 76
} - 77
- 78
// ---------------------------------------------------------------- install - 79
- 80
/// Install the running binary and its siblings into `prefix`. - 81
pub fn run_install(prefix: Option<PathBuf>, force: bool) -> i32 { - 82
let root = InstallRoot::resolve(prefix); - 83
install_and_report(&root, force, None) - 84
} - 85
- 86
/// `source` is where the component binaries live, when that is not beside - 87
/// the running executable — `run_reinstall` passes a staged copy, because - 88
/// by the time it installs, the directory it was running from is gone. - 89
fn install_and_report(root: &InstallRoot, force: bool, source: Option<&Path>) -> i32 { - 90
match install_into(root, force, source) { - 91
Ok(m) => { - 92
println!( - 93
"installed {} ({}) → {}", - 94
m.version, - 95
m.git_sha, - 96
root.prefix().display() - 97
); - 98
for c in &m.components { - 99
println!(" {:<22} {}", c.name, c.path.display()); - 100
} - 101
let stale = m - 102
.cli_path() - 103
.ok() - 104
.map(|cli| report_stale_services(&cli)) - 105
.unwrap_or(false); - 106
report_next_steps(root, stale); - 107
0 - 108
} - 109
Err(e) => { - 110
eprintln!("error: {e}"); - 111
1 - 112
} - 113
} - 114
} - 115
- 116
/// Remove an existing install and place a fresh one. Distinct from - 117
/// `install` because installing over a tree leaves files from the old - 118
/// version that the new manifest does not describe. - 119
pub fn run_reinstall(prefix: Option<PathBuf>, yes: bool) -> i32 { - 120
let root = InstallRoot::resolve(prefix); - 121
if root.is_installed() - 122
&& !confirm( - 123
&format!("remove and reinstall {}?", root.prefix().display()), - 124
yes, - 125
) - 126
{ - 127
println!("aborted"); - 128
return 0; - 129
} - 130
- 131
// Take a copy of the build BEFORE clearing, when we are running from - 132
// inside the prefix we are about to delete. - 133
// - 134
// `vak self reinstall` is what a person on a broken install reaches - 135
// for, and the `vak` on their PATH is the installed one — so the - 136
// overwhelmingly common invocation is the one where the source of the - 137
// reinstall lives inside its own target. Clearing first deleted that - 138
// source, the reinstall then failed with "required component vak not - 139
// found", and the prefix was left EMPTY. A repair command that - 140
// destroys the thing it repairs is worse than no repair command. - 141
let staged = match stage_sources(&root) { - 142
Ok(staged) => staged, - 143
Err(e) => { - 144
eprintln!("error: {e}"); - 145
eprintln!("nothing was removed."); - 146
return 1; - 147
} - 148
}; - 149
- 150
if root.is_installed() { - 151
// Services keep running against the old inode until sync; the - 152
// data home is untouched, so this is not destructive to state. - 153
if let Err(e) = std::fs::remove_dir_all(root.prefix()) { - 154
eprintln!("error: clear {}: {e}", root.prefix().display()); - 155
return 1; - 156
} - 157
} - 158
- 159
let source = staged.as_ref().map(|d| d.path().to_path_buf()); - 160
let result = install_and_report(&root, true, source.as_deref()); - 161
// `staged` drops here, taking the temporary copy with it. - 162
drop(staged); - 163
result - 164
} - 165
- 166
/// A temporary copy of the component binaries, when the running executable - 167
/// lives inside `root`. - 168
/// - 169
/// `None` means the build is somewhere else (a dev tree, a release - 170
/// tarball) and can be installed from where it already is. - 171
fn stage_sources(root: &InstallRoot) -> Result<Option<tempfile::TempDir>, String> { - 172
let exe = std::env::current_exe().map_err(|e| format!("cannot locate running binary: {e}"))?; - 173
let prefix = root - 174
.prefix() - 175
.canonicalize() - 176
.unwrap_or_else(|_| root.prefix().to_path_buf()); - 177
let exe_real = exe.canonicalize().unwrap_or_else(|_| exe.clone()); - 178
if !exe_real.starts_with(&prefix) { - 179
return Ok(None); - 180
} - 181
- 182
let build_dir = exe_real - 183
.parent() - 184
.ok_or_else(|| "running binary has no parent directory".to_string())?; - 185
let staged = tempfile::tempdir().map_err(|e| format!("stage a copy of the build: {e}"))?; - 186
for spec in COMPONENTS { - 187
let source = build_dir.join(spec.name); - 188
if !source.exists() { - 189
if spec.required { - 190
return Err(format!( - 191
"required component {} not found at {}", - 192
spec.name, - 193
source.display() - 194
)); - 195
} - 196
continue; - 197
} - 198
let destination = staged.path().join(spec.name); - 199
std::fs::copy(&source, &destination) - 200
.map_err(|e| format!("stage {}: {e}", source.display()))?; - 201
#[cfg(unix)] - 202
{ - 203
use std::os::unix::fs::PermissionsExt as _; - 204
let _ = std::fs::set_permissions(&destination, std::fs::Permissions::from_mode(0o755)); - 205
} - 206
} - 207
Ok(Some(staged)) - 208
} - 209
- 210
fn install_into( - 211
root: &InstallRoot, - 212
force: bool, - 213
source: Option<&Path>, - 214
) -> Result<Manifest, String> { - 215
let exe = match source { - 216
Some(dir) => dir.join("vak"), - 217
None => { - 218
std::env::current_exe().map_err(|e| format!("cannot locate running binary: {e}"))? - 219
} - 220
}; - 221
let build_dir = exe - 222
.parent() - 223
.ok_or_else(|| "running binary has no parent directory".to_string())? - 224
.to_path_buf(); - 225
let build_dir = build_dir.as_path(); - 226
- 227
if root.is_installed() && !force { - 228
let existing = Manifest::read(root)?; - 229
if existing.version == manifest::build_version() - 230
&& existing.git_sha == manifest::build_git_sha() - 231
{ - 232
return Err(format!( - 233
"{} is already at {} ({}) — use `--force` to reinstall the same build", - 234
root.prefix().display(), - 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()
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.