- 1
//! `vak setup` (`docs/design/46-stabilization-install-and-onboarding.md`). - 2
//! - 3
//! Install places bits; **setup** chooses and activates (D6). This module - 4
//! owns the terminal half of that contract. S1 lands `status`, the read - 5
//! side of the shared projection, so every surface can already agree on - 6
//! what is configured before any of them can change it. The guided flow - 7
//! itself is S2 (web) and S3 (terminal). - 8
- 9
use std::path::PathBuf; - 10
- 11
use vak_core::onboarding::{self, OnboardingState, ProbedFacts, StepFailure, StepState}; - 12
- 13
/// Probe the facts the library deliberately does not gather for itself: - 14
/// the install manifest, and the service manager. - 15
/// - 16
/// Both live behind process boundaries a status read should touch once, - 17
/// explicitly, rather than have a library shell out on every call. - 18
pub fn probe(prefix: Option<PathBuf>) -> ProbedFacts { - 19
ProbedFacts { - 20
install: probe_install(prefix), - 21
services: probe_services(), - 22
awaiting_activation: probe_awaiting_activation(), - 23
} - 24
} - 25
- 26
fn probe_install(prefix: Option<PathBuf>) -> Option<Result<String, StepFailure>> { - 27
let root = crate::install::layout::InstallRoot::resolve(prefix); - 28
if !root.is_installed() { - 29
// Running from a source tree. Normal, not a defect. - 30
return None; - 31
} - 32
let manifest = match crate::install::manifest::Manifest::read(&root) { - 33
Ok(m) => m, - 34
Err(e) => { - 35
// `Manifest::read` refuses pre-baseline state with the one - 36
// shared message, whose repair is a purge -- NOT a reinstall. - 37
// Handing back a generic "run reinstall" here would give the - 38
// reader a command that cannot work (AGENTS.md invariant 29). - 39
let failure = if e.contains(vak_core::baseline::BASELINE) { - 40
StepFailure::new( - 41
format!( - 42
"This machine has an install that predates the {} baseline.", - 43
vak_core::baseline::BASELINE - 44
), - 45
"Your project files are untouched; only vak's own state is removed.", - 46
"Run `vak self uninstall --purge`, then install and run setup.", - 47
) - 48
} else { - 49
StepFailure::new( - 50
"The install manifest could not be read.", - 51
"Your configuration, sessions, and secrets are untouched.", - 52
"Run `vak self reinstall` to replace the installed files.", - 53
) - 54
}; - 55
return Some(Err(failure.with_detail(e))); - 56
} - 57
}; - 58
let defects = manifest.verify(); - 59
if defects.is_empty() { - 60
return Some(Ok(format!( - 61
"{} verified at {}", - 62
manifest.version, - 63
root.prefix().display() - 64
))); - 65
} - 66
Some(Err(StepFailure::new( - 67
"Installed components do not match the manifest.", - 68
"Your configuration, sessions, and secrets are untouched.", - 69
"Run `vak self reinstall` to replace the installed files.", - 70
) - 71
.with_detail( - 72
defects - 73
.iter() - 74
.map(ToString::to_string) - 75
.collect::<Vec<_>>() - 76
.join("; "), - 77
))) - 78
} - 79
- 80
/// Bots configured in `bots.json` that the service manager has never been - 81
/// told about. Configuring a bot does not activate it (doc 46 D6), so this - 82
/// is a normal, deliberate state — and one the operator has to be able to - 83
/// see, or a created bot looks identical to a running one. - 84
fn probe_awaiting_activation() -> Vec<String> { - 85
let data_home = vak_config::paths::data_home(); - 86
let paths = vak_ops::services::Paths::default(); - 87
vak_ops::services::configured_bot_service_names_all(&data_home) - 88
.into_iter() - 89
.filter(|name| !vak_ops::services::unit_is_registered(name, &paths)) - 90
.collect() - 91
} - 92
- 93
/// What the service manager says about the units this installation - 94
/// expects. `None` when nothing is registered — an installation that - 95
/// never asked for durable services is complete without them. - 96
fn probe_services() -> Option<Vec<(String, bool)>> { - 97
let cfg = vak_ops::OpsConfig::detect(); - 98
let probed: Vec<(String, bool)> = [vak_ops::Service::Gateway, vak_ops::Service::Bridges] - 99
.into_iter() - 100
.filter_map(|service| { - 101
let state = vak_ops::status(service, &cfg); - 102
(state != vak_ops::State::NotInstalled).then(|| { - 103
( - 104
service.label().to_string(), - 105
state == vak_ops::State::Running, - 106
) - 107
}) - 108
}) - 109
.collect(); - 110
(!probed.is_empty()).then_some(probed) - 111
} - 112
- 113
pub fn run_status(cwd: PathBuf, prefix: Option<PathBuf>, json: bool) -> i32 { - 114
let core = match vak_core::Core::new_with_trust(cwd.clone(), vak_core::trust::is_trusted(&cwd)) - 115
{ - 116
Ok(c) => c, - 117
Err(e) => { - 118
eprintln!("error: {e}"); - 119
return 2; - 120
} - 121
}; - 122
let state = onboarding::derive(&core, &probe(prefix)); - 123
- 124
if json { - 125
match serde_json::to_string_pretty(&state) { - 126
Ok(text) => println!("{text}"), - 127
Err(e) => { - 128
eprintln!("error: {e}"); - 129
return 2; - 130
} - 131
} - 132
// JSON is for machines: the exit code carries the verdict so a - 133
// script does not have to parse the body to branch on it. - 134
return i32::from(!state.core_ready); - 135
} - 136
- 137
render(&state); - 138
i32::from(!state.core_ready) - 139
} - 140
- 141
fn render(state: &OnboardingState) { - 142
let width = state - 143
.steps() - 144
.iter() - 145
.map(|(label, _)| label.len()) - 146
.max() - 147
.unwrap_or(0); - 148
- 149
println!("setup:"); - 150
for (label, step) in state.steps() { - 151
match step { - 152
StepState::Satisfied { detail, provenance } => { - 153
let from = provenance - 154
.as_deref() - 155
.map(|p| format!(" ({p})")) - 156
.unwrap_or_default(); - 157
println!(" ✓ {label:<width$} {detail}{from}"); - 158
} - 159
StepState::NotApplicable { reason } => { - 160
println!(" · {label:<width$} {reason}"); - 161
} - 162
StepState::Incomplete(f) => { - 163
println!(" ✗ {label:<width$} {}", f.what); - 164
} - 165
} - 166
} - 167
- 168
println!(); - 169
println!( - 170
"core ready {}", - 171
if state.core_ready { "yes" } else { "no" } - 172
); - 173
println!( - 174
"unattended ready {}", - 175
if state.unattended_ready { "yes" } else { "no" } - 176
); - 177
- 178
// Failures repeat at the bottom in full, because the four fields are - 179
// the whole point: one line each is a summary, not a remedy. - 180
let incomplete = state.incomplete(); - 181
if incomplete.is_empty() { - 182
return; - 183
} - 184
println!(); - 185
for (label, f) in incomplete { - 186
println!("{label}"); - 187
println!(" {}", f.what); - 188
println!(" {}", f.preserved); - 189
println!(" → {}", f.repair); - 190
if let Some(detail) = &f.detail { - 191
println!(" details: {detail}"); - 192
} - 193
println!(); - 194
} - 195
} - 196
- 197
/// Apply the Shared capability seeds (`crate::setup_seed`). - 198
/// - 199
/// This is a **setup** action, never an install side effect (D6). Placing - 200
/// binaries used to do it, which meant a `--prefix` install silently got - 201
/// nothing and an update got nothing at all. Idempotent: an existing - 202
/// skill, plugin, or hook is never overwritten, so re-running after an - 203
/// upgrade adds what is new and leaves edited files alone. - 204
pub fn run_seed() -> i32 { - 205
let root = vak_config::paths::default_workspace(); - 206
println!("seeding Shared capabilities into {}", root.display()); - 207
if let Err(error) = vak_core::seed::seed_shared_capabilities() { - 208
eprintln!("error: {error}"); - 209
return 1; - 210
} - 211
let skills = root.join(".vak/skills"); - 212
let count = std::fs::read_dir(&skills) - 213
.map(|e| e.flatten().filter(|e| e.path().is_dir()).count()) - 214
.unwrap_or(0); - 215
println!("{count} shared skills available"); - 216
0 - 217
} - 218
- 219
/// `vak setup` — start the local setup server and hand over its URL. - 220
/// - 221
/// The wizard is the web admin console (doc 46 D7): it is present in every - 222
/// install — headless box, Linux server, macOS desktop — so it is the only - 223
/// surface that can carry one first-run experience everywhere. There is no - 224
/// second server and no second frontend; this binds the same secured - 225
/// router the desktop shell uses. - 226
/// - 227
/// It is **not** a durable service (D9). Nothing is registered with - 228
/// launchd or systemd, so `vak self install` still starts nothing and - 229
/// nothing unattended exists until the wizard's activation step says so. - 230
/// The process ends when the operator ends it. - 231
pub async fn run_wizard(cwd: PathBuf, open_browser: bool, print_url_only: bool) -> i32 { - 232
// The server runs against the invoking directory; the wizard's - 233
// workspace step is what actually chooses where work happens, and - 234
// durable services always resolve the canonical default independently - 235
// (AGENTS.md invariant 18) regardless of where this was run. - 236
let workspace = cwd; - 237
if let Err(e) = std::fs::create_dir_all(&workspace) { - 238
eprintln!("error: cannot create {}: {e}", workspace.display()); - 239
return 2; - 240
} - 241
- 242
let trusted = vak_core::trust::is_trusted(&workspace); - 243
let core = match vak_core::Core::new_with_trust(workspace.clone(), trusted) { - 244
Ok(c) => c, - 245
Err(e) => { - 246
eprintln!("error: {e}"); - 247
return 2; - 248
} - 249
}; - 250
- 251
// Loopback only. A setup server binds no external interface, so the - 252
// window in which an unconfigured install is reachable is this - 253
// machine, and only with the token below. - 254
let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0)).await { - 255
Ok(l) => l, - 256
Err(e) => { - 257
eprintln!("error: cannot bind a local port: {e}"); - 258
return 2; - 259
} - 260
}; - 261
let addr = match listener.local_addr() { - 262
Ok(a) => a, - 263
Err(e) => { - 264
eprintln!("error: {e}"); - 265
return 2; - 266
} - 267
}; - 268
let (app, token) = vak_server::secured_router(core); - 269
// The token reaches the operator on stdout and nowhere else: not a - 270
// file, not a log, not a service unit. - 271
let url = format!("http://{addr}/admin?token={token}#/setup"); - 272
- 273
println!("vak setup — {}", workspace.display()); - 274
println!(); - 275
println!(" {url}"); - 276
println!(); - 277
if print_url_only { - 278
println!( - 279
"open that in a browser (or tunnel to it: ssh -L {0}:127.0.0.1:{0} <host>)", - 280
addr.port() - 281
); - 282
} else if open_browser && open_in_browser(&url) { - 283
println!("opened in your browser"); - 284
} else { - 285
println!("open that URL to continue"); - 286
} - 287
println!("press Ctrl-C when you are finished"); - 288
- 289
if let Err(e) = vak_server::serve_router(listener, app).await { - 290
eprintln!("error: setup server stopped: {e}"); - 291
return 2; - 292
} - 293
0 - 294
} - 295
- 296
/// Best-effort browser launch. A headless box has none, which is normal — - 297
/// the URL was already printed, so failure here costs nothing. - 298
fn open_in_browser(url: &str) -> bool { - 299
#[cfg(target_os = "macos")] - 300
let mut command = std::process::Command::new("open"); - 301
#[cfg(not(target_os = "macos"))] - 302
let mut command = std::process::Command::new("xdg-open"); - 303
command - 304
.arg(url) - 305
.stdout(std::process::Stdio::null()) - 306
.stderr(std::process::Stdio::null()) - 307
.status() - 308
.map(|s| s.success()) - 309
.unwrap_or(false) - 310
} - 311
- 312
// ---- Terminal flow (docs/design/46 S3) ------------------------------------- - 313
- 314
/// One choice the operator has to make, and where it came from. - 315
/// - 316
/// `--non-interactive` exists so a scripted install can run setup without a - 317
/// person, and it **fails on any missing choice** rather than picking one - 318
/// (doc 46 S3). Inferring a default for an unanswered question is how a - 319
/// machine ends up configured in a way nobody chose. - 320
struct Answers { - 321
provider: Option<String>, - 322
model: Option<String>, - 323
posture: Option<String>, - 324
seed: bool, - 325
activate: bool, - 326
first_task: bool, - 327
} - 328
- 329
/// True when a person is on the other end. - 330
/// - 331
/// Menus are only printed when they can be answered: offering a numbered - 332
/// list to a pipe and then refusing buries the actual error in noise. - 333
fn interactive() -> bool { - 334
use std::io::IsTerminal as _; - 335
std::io::stdin().is_terminal() - 336
} - 337
- 338
/// Read a line from a real terminal, or refuse. - 339
/// - 340
/// Prompts are TTY-only. A piped stdin is not a person, so reading from it - 341
/// would turn "no answer" into whatever bytes happened to be there. - 342
fn ask(prompt: &str) -> Option<String> { - 343
use std::io::{IsTerminal as _, Write as _}; - 344
if !std::io::stdin().is_terminal() { - 345
return None; - 346
} - 347
print!("{prompt}"); - 348
let _ = std::io::stdout().flush(); - 349
let mut line = String::new(); - 350
std::io::stdin().read_line(&mut line).ok()?; - 351
Some(line.trim().to_string()) - 352
} - 353
- 354
/// Read a secret without echoing it, from a terminal or from stdin. - 355
/// - 356
/// Never from argv: a key in a command line is in the shell history, in - 357
/// `ps`, and in any process listing on the machine (doc 46 S3). - 358
fn ask_secret(prompt: &str) -> Option<String> { - 359
use std::io::{BufRead as _, IsTerminal as _, Write as _}; - 360
if !std::io::stdin().is_terminal() { - 361
// Piped: the credential is the piped content, which is how a - 362
// scripted install supplies one. - 363
let mut line = String::new(); - 364
return std::io::stdin() - 365
.lock() - 366
.read_line(&mut line) - 367
.ok() - 368
.filter(|read| *read > 0) - 369
.map(|_| line.trim().to_string()); - 370
} - 371
print!("{prompt}"); - 372
let _ = std::io::stdout().flush(); - 373
// No portable no-echo without another dependency; say so rather than - 374
// let someone believe the key was hidden when it was not. - 375
println!(); - 376
println!(" (the key will be visible as you type)"); - 377
let mut line = String::new(); - 378
std::io::stdin().read_line(&mut line).ok()?; - 379
Some(line.trim().to_string()) - 380
} - 381
- 382
pub async fn run_terminal(cwd: PathBuf, non_interactive: bool) -> i32 { - 383
let answers = Answers { - 384
provider: std::env::var("VAK_SETUP_PROVIDER").ok(), - 385
model: std::env::var("VAK_SETUP_MODEL").ok(), - 386
posture: std::env::var("VAK_SETUP_POSTURE").ok(), - 387
seed: std::env::var("VAK_SETUP_SEED").is_ok(), - 388
activate: std::env::var("VAK_SETUP_ACTIVATE").is_ok(), - 389
first_task: std::env::var("VAK_SETUP_FIRST_TASK").is_ok(), - 390
}; - 391
- 392
let trusted = vak_core::trust::is_trusted(&cwd); - 393
let core = match vak_core::Core::new_with_trust(cwd.clone(), trusted) { - 394
Ok(c) => c, - 395
Err(e) => { - 396
eprintln!("error: {e}"); - 397
return 2; - 398
} - 399
}; - 400
- 401
println!("vak setup — {}", cwd.display()); - 402
println!(); - 403
- 404
// --- workspace and trust ------------------------------------------- - 405
let asks = vak_core::trust::requested_privileges(&cwd); - 406
if !asks.is_empty() && !trusted { - 407
println!("This folder's own settings ask for:"); - 408
for item in &asks { - 409
println!(" · {item}"); - 410
} - 411
println!("Opening safely ignores them. Trusting lets them take effect."); - 412
match ask("Trust this folder? [y/N] ").as_deref() { - 413
Some(a) if a.eq_ignore_ascii_case("y") => { - 414
if let Err(e) = vak_core::trust::record(&cwd) { - 415
eprintln!("warning: could not record the decision: {e}"); - 416
} else { - 417
println!(" trusted"); - 418
} - 419
} - 420
Some(_) => println!(" opened safely — those settings stay ignored"), - 421
None if non_interactive => println!(" opened safely (non-interactive)"), - 422
None => { - 423
eprintln!("error: this folder needs a trust decision and stdin is not a terminal"); - 424
eprintln!(" re-run with --non-interactive to open it safely"); - 425
return 2; - 426
} - 427
} - 428
println!(); - 429
} - 430
- 431
// --- provider and route -------------------------------------------- - 432
if core.provider().is_err() || core.effective_model().trim().is_empty() { - 433
let provider = match answers.provider.clone().or_else(|| { - 434
if !interactive() { - 435
return None; - 436
} - 437
println!("Available services: {}", core.provider_names().join(", ")); - 438
println!("For hosted OpenAI GPT models with tools or reasoning, choose openai-responses; openai is the Chat Completions compatibility adapter."); - 439
ask("Which service should answer? ") - 440
}) { - 441
Some(p) if !p.is_empty() => p, - 442
_ => { - 443
eprintln!("error: no provider chosen"); - 444
eprintln!(" set VAK_SETUP_PROVIDER, or run without --non-interactive"); - 445
return 2; - 446
} - 447
}; - 448
if !core.provider_names().iter().any(|n| n == &provider) { - 449
eprintln!("error: unknown provider '{provider}'"); - 450
return 2; - 451
} - 452
- 453
if let Some(key) = ask_secret(&format!("Paste the {provider} API key (blank to skip): ")) - 454
&& !key.is_empty() - 455
&& let Err(e) = core.set_provider_key(&provider, &key) - 456
{ - 457
eprintln!("error: could not store the key: {e}"); - 458
return 2; - 459
} - 460
- 461
// A stored key is not success. The route is verified by asking the - 462
// provider what this key can actually reach (invariant 9). - 463
println!("asking {provider} which models your key can reach…"); - 464
let discovered = core.discover_models(&provider).await; - 465
let model = match (answers.model.clone(), &discovered) { - 466
(Some(m), _) => m, - 467
(None, Ok(models)) if !models.is_empty() && interactive() => { - 468
for (i, m) in models.iter().take(20).enumerate() { - 469
println!(" {:>2}. {m}", i + 1); - 470
} - 471
match ask("Model (number or exact id): ") { - 472
Some(a) if a.is_empty() => { - 473
eprintln!("error: no model chosen"); - 474
return 2; - 475
} - 476
Some(a) => a - 477
.parse::<usize>() - 478
.ok() - 479
.and_then(|n| models.get(n.saturating_sub(1)).cloned()) - 480
.unwrap_or(a), - 481
None => { - 482
eprintln!("error: no model chosen and stdin is not a terminal"); - 483
eprintln!(" set VAK_SETUP_MODEL to choose one explicitly"); - 484
return 2; - 485
} - 486
} - 487
} - 488
(None, Ok(_)) | (None, Err(_)) => { - 489
if let Err(e) = &discovered { - 490
println!(" could not list models: {e}"); - 491
} - 492
match ask("Enter an exact model id: ") { - 493
Some(a) if !a.is_empty() => a, - 494
_ => { - 495
eprintln!("error: no model chosen"); - 496
return 2; - 497
} - 498
} - 499
} - 500
}; - 501
- 502
// Provider and model are one atomic route (invariant 17). - 503
if let Err(e) = vak_config::persist_global_preferences( - 504
Some(&provider), - 505
Some(&model), - 506
None, - 507
None, - 508
None, - 509
None, - 510
) { - 511
eprintln!("error: could not save the route: {e}"); - 512
return 2; - 513
} - 514
core.apply_persisted_route(provider.clone(), model.clone()); - 515
println!(" route saved: {provider}/{model}"); - 516
println!(); - 517
} - 518
- 519
// --- safety posture ------------------------------------------------- - 520
let posture = answers.posture.clone().or_else(|| { - 521
if !interactive() { - 522
return None; - 523
} - 524
println!("How much should vak be allowed to do on its own?"); - 525
println!(" 1. Inspect only — reads and searches, changes nothing"); - 526
println!(" 2. Work with approval — edits here, asks before shell commands (recommended)"); - 527
println!(" 3. Unrestricted — full access to this machine, unsandboxed"); - 528
ask("Choose [1/2/3]: ").map(|a| match a.as_str() { - 529
"1" => "read-only".to_string(), - 530
"3" => "full-access".to_string(), - 531
_ => "workspace-write".to_string(), - 532
}) - 533
}); - 534
match posture { - 535
Some(mode) => { - 536
let parsed = match mode.as_str() { - 537
"read-only" => vak_config::PermissionMode::ReadOnly, - 538
"workspace-write" => vak_config::PermissionMode::WorkspaceWrite, - 539
"full-access" => vak_config::PermissionMode::FullAccess, - 540
other => { - 541
eprintln!("error: unknown posture '{other}'"); - 542
return 2; - 543
} - 544
}; - 545
if let Err(e) = - 546
vak_config::persist_global_preferences(None, None, None, Some(parsed), None, None) - 547
{ - 548
eprintln!("error: could not save the posture: {e}"); - 549
return 2; - 550
} - 551
core.set_permission_mode(parsed); - 552
println!(" posture: {parsed:?}"); - 553
} - 554
None => { - 555
eprintln!("error: no safety posture chosen and stdin is not a terminal"); - 556
eprintln!(" set VAK_SETUP_POSTURE to read-only|workspace-write|full-access"); - 557
return 2; - 558
} - 559
} - 560
println!(); - 561
- 562
// --- seeds ---------------------------------------------------------- - 563
let seed = answers.seed - 564
|| matches!(ask("Install the starter skills? [Y/n] ").as_deref(), Some(a) if !a.eq_ignore_ascii_case("n")); - 565
if seed { - 566
if let Err(error) = vak_core::seed::seed_shared_capabilities() { - 567
eprintln!("error: {error}"); - 568
return 2; - 569
} - 570
println!(" starter skills installed"); - 571
} - 572
- 573
// --- activation ----------------------------------------------------- - 574
let activate = answers.activate - 575
|| matches!(ask("Run vak in the background (durable services)? [y/N] ").as_deref(), Some(a) if a.eq_ignore_ascii_case("y")); - 576
if activate { - 577
println!(" registering services…"); - 578
let code = crate::install::run_services_sync(None, Vec::new()); - 579
if code != 0 { - 580
eprintln!("warning: some services did not register; `vak self status` has detail"); - 581
} - 582
} - 583
- 584
// --- first result --------------------------------------------------- - 585
// - 586
// Read-only is the strictest mode, so passing it as the run's override - 587
// can only ever cap — it cannot raise the ceiling whatever the posture - 588
// above was (doc 46 security invariant 5). The web wizard reaches the - 589
// same guarantee through `CorePool`; this reaches it through the - 590
// scoped override `exec` already takes. - 591
let first = answers.first_task - 592
|| matches!( - 593
ask("Run a safe, read-only starter task now? [Y/n] ").as_deref(), - 594
Some(a) if !a.eq_ignore_ascii_case("n") - 595
); - 596
if first { - 597
println!(); - 598
let code = crate::run_exec( - 599
cwd.clone(), - 600
FIRST_TASK_PROMPT.to_string(), - 601
None, - 602
None, - 603
None, - 604
// Matches `exec`'s own default rather than inventing a - 605
// second number the two could drift apart on. - 606
40, - 607
false, - 608
true, - 609
Some("read-only".to_string()), - 610
Vec::new(), - 611
false, - 612
None, - 613
false, - 614
false, - 615
None, - 616
Vec::new(), - 617
trusted, - 618
) - 619
.await; - 620
if code != 0 { - 621
eprintln!("the starter task did not finish; `vak doctor` has detail"); - 622
} - 623
} - 624
- 625
println!(); - 626
run_status(cwd, None, false) - 627
} - 628
- 629
/// The starter task, identical to the one the web wizard runs. - 630
const FIRST_TASK_PROMPT: &str = "Map this codebase and explain its architecture, key flows, \ - 631
and highest-risk areas. Do not modify files or run any destructive command."; - 632
- 633
/// `vak self state [--verify <snapshot>]`. - 634
/// - 635
/// The plumbing the upgrade gate drives (doc 46 VII.5). Without `--verify` - 636
/// it prints a snapshot of every durable file the registry declares; with - 637
/// it, it compares the current state against a snapshot taken before an - 638
/// update and reports every entry the contract forbids changing. - 639
/// - 640
/// The comparison rules live in `vak_core::state`, beside the registry - 641
/// they belong to, rather than in the script that calls this — one - 642
/// definition, so a shell and a library cannot drift apart about what an - 643
/// update is allowed to do. - 644
pub fn run_state(verify: Option<PathBuf>) -> i32 { - 645
let current = vak_core::state::snapshot(env!("CARGO_PKG_VERSION")); - 646
let Some(path) = verify else { - 647
match serde_json::to_string_pretty(¤t) { - 648
Ok(text) => { - 649
println!("{text}"); - 650
return 0; - 651
} - 652
Err(e) => { - 653
eprintln!("error: {e}"); - 654
return 2; - 655
} - 656
} - 657
}; - 658
- 659
let raw = match std::fs::read_to_string(&path) { - 660
Ok(raw) => raw, - 661
Err(e) => { - 662
eprintln!("error: cannot read {}: {e}", path.display()); - 663
return 2; - 664
} - 665
}; - 666
let before: vak_core::state::StateSnapshot = match serde_json::from_str(&raw) { - 667
Ok(s) => s, - 668
Err(e) => { - 669
eprintln!("error: {} is not a state snapshot: {e}", path.display()); - 670
return 2; - 671
} - 672
}; - 673
- 674
let violations = vak_core::state::verify_upgrade(&before, ¤t); - 675
println!("upgrade check: {} → {}", before.version, current.version); - 676
if violations.is_empty() { - 677
println!(" ✓ every declared entry survived the update as its rule requires"); - 678
return 0; - 679
} - 680
for violation in &violations { - 681
println!(" ✗ {violation}"); - 682
} - 683
eprintln!(); - 684
eprintln!( - 685
"{} entr(ies) changed in a way the update contract forbids \ - 686
(docs/design/46-stabilization-install-and-onboarding.md VII.3).", - 687
violations.len() - 688
); - 689
1 - 690
} - 691
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.