leviath_cli/commands/update.rs
1//! `lev update` - bring this copy of Leviath up to date, then everything that
2//! shipped with it.
3//!
4//! Three steps, in the order that matters: the binary, the bundled blueprints,
5//! and the config file. The binary first because the other two are decided by
6//! what the *new* binary ships, and a user who updates the agents against the
7//! old one has done half a job.
8//!
9//! All three run every time. The binary step is never a reason to skip the
10//! other two, and this is the whole point: `brew upgrade` and `scoop update`
11//! hand over a new binary and say nothing about the blueprints in the user's
12//! agents directory or the config beside them, so anyone who has ever updated
13//! that way is carrying blueprints from whenever they last ran `lev setup`. A
14//! binary that needs no update is not evidence that anything else is current,
15//! which is why "already up to date" is a sentence this command never says on
16//! its own.
17//!
18//! # Why the install method is detected rather than guessed
19//!
20//! Every channel installs the same `CARGO_PKG_VERSION`: the `-alpha` and
21//! `-beta` suffixes live in the tap manifests the release workflow bumps, not
22//! in `Cargo.toml`. So the version string says nothing about which channel this
23//! binary came from, and nothing about which installer put it there. What does
24//! say something is *where the file is*: a Homebrew Cellar path carries the
25//! formula name, and the formula name carries the channel; a Scoop `apps` path
26//! carries the package the same way; `~/.cargo/bin` means somebody compiled it.
27//!
28//! The hosted install script is the one method that records nothing. It writes
29//! a plain binary into an ordinary directory and keeps no receipt, so a copy
30//! that came from it is indistinguishable from any other loose binary. That is
31//! what [`UpdateArgs::channel`] is for, and why the script arm defaults to
32//! `stable` and says so rather than inferring a channel it cannot know.
33//!
34//! # Seams
35//!
36//! Running the upgrade and asking the user a question are both injected (see
37//! [`UpdateEnv`]), so the tests assert the command that *would* run without a
38//! single process being spawned - the same shape `lev mcp` uses for the browser
39//! and the daemon uses for seed commands.
40
41use std::path::{Path, PathBuf};
42use std::sync::Arc;
43
44use clap::Args;
45
46use crate::bundled::{AgentAction, BundledAgent, install_bundled, plan_agent_actions};
47use crate::config::Config;
48
49/// Where the hosted installer lives. One constant, because the invocation below
50/// is easy to get subtly wrong and there must be exactly one copy of it.
51const INSTALL_URL: &str = "https://leviath.dev/install.sh";
52
53/// `lev update --help`.
54pub const UPDATE_LONG_ABOUT: &str = "\
55Update Leviath, then offer to bring everything else up to date with it.
56
57The binary is updated with the installer that put it there, which is worked out
58from where the file is rather than guessed from the version string (every
59channel ships the same version number, so the string cannot tell you):
60
61 Homebrew a Cellar path names the formula, and the formula names the
62 channel: `brew upgrade leviath-beta`
63 Scoop the same, from the package under scoop/apps
64 cargo says to run `cargo install leviath-cli` and stops. Updating it
65 means a long compile, which is not something to start unasked
66 script re-runs the hosted installer for a channel. The install script
67 keeps no record of the channel it used, so this defaults to
68 stable - pass --channel to say otherwise
69
70The blueprints and the config are checked every time, whatever the binary step
71did. `brew upgrade` on its own leaves both behind, and a binary that was
72already current is not a reason to stop looking: an install can be months
73behind on its blueprints with a `lev` that needs no update at all.
74
75Nothing is written to your agents directory without a yes. The whole list is
76printed first, then one confirmation covers it; --install-agents is how a
77script says yes. --yes alone is not enough, because updating a binary and
78replacing the blueprints in your agents directory are different requests. A
79copy you edited is named as edited and asked about on its own, and no flag
80covers it: installing removes the directory and takes your edits with it.
81
82Config migrations are described line by line before anything is written, and
83then asked about.
84
85--check and --json report the plan and change nothing. --dry-run walks the
86whole flow, prompts and all, and prints what each step would do instead of
87doing it.";
88
89// ─── Channels ─────────────────────────────────────────────────────────────────
90
91/// A release channel.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
93#[value(rename_all = "lowercase")]
94pub enum Channel {
95 /// The weekly stable release. What crates.io and `brew install leviath` track.
96 Stable,
97 /// The promoted build a week ahead of stable.
98 Beta,
99 /// The nightly build.
100 Alpha,
101}
102
103impl Channel {
104 /// The name the install script and the docs use.
105 pub fn id(self) -> &'static str {
106 match self {
107 Self::Stable => "stable",
108 Self::Beta => "beta",
109 Self::Alpha => "alpha",
110 }
111 }
112
113 /// The Homebrew formula and Scoop package for this channel. They share a
114 /// naming scheme on purpose, so one function answers for both.
115 pub fn package(self) -> &'static str {
116 match self {
117 Self::Stable => "leviath",
118 Self::Beta => "leviath-beta",
119 Self::Alpha => "leviath-alpha",
120 }
121 }
122
123 /// The channel a package name carries, or `None` for a name this build does
124 /// not ship (someone's own formula, or one from a future channel).
125 pub fn from_package(name: &str) -> Option<Self> {
126 [Self::Stable, Self::Beta, Self::Alpha]
127 .into_iter()
128 .find(|c| c.package() == name)
129 }
130}
131
132// ─── Install-method detection ─────────────────────────────────────────────────
133
134/// How this copy of `lev` got onto the machine.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum InstallMethod {
137 /// Homebrew, under the named formula.
138 Homebrew {
139 /// The formula, which is also what carries the channel.
140 formula: String,
141 },
142 /// Scoop, under the named package.
143 Scoop {
144 /// The package, which carries the channel the same way a formula does.
145 package: String,
146 },
147 /// `cargo install`, so the binary was compiled locally.
148 Cargo,
149 /// The hosted install script, or something else that dropped a plain binary
150 /// where the install script puts one.
151 Script {
152 /// The channel to re-install. Never detected: see the module docs.
153 channel: Channel,
154 },
155 /// Somewhere no supported installer writes.
156 Unknown {
157 /// Where the binary actually is, so the report can say it.
158 path: PathBuf,
159 },
160}
161
162impl InstallMethod {
163 /// The short name used in `--json`.
164 pub fn id(&self) -> &'static str {
165 match self {
166 Self::Homebrew { .. } => "homebrew",
167 Self::Scoop { .. } => "scoop",
168 Self::Cargo => "cargo",
169 Self::Script { .. } => "script",
170 Self::Unknown { .. } => "unknown",
171 }
172 }
173
174 /// The channel this install tracks, where that is knowable.
175 ///
176 /// `cargo install leviath-cli` resolves crates.io, and each stable deploy
177 /// publishes there from the same commit the binaries were built at, so a
178 /// cargo install is a stable install by construction.
179 pub fn channel(&self) -> Option<Channel> {
180 match self {
181 Self::Homebrew { formula } => Channel::from_package(formula),
182 Self::Scoop { package } => Channel::from_package(package),
183 Self::Cargo => Some(Channel::Stable),
184 Self::Script { channel } => Some(*channel),
185 Self::Unknown { .. } => None,
186 }
187 }
188
189 /// The one-line description the report opens with.
190 pub fn describe(&self) -> String {
191 let channel = match self.channel() {
192 Some(c) => format!(", {} channel", c.id()),
193 // A formula this build does not ship, or a path nothing claims.
194 None => String::new(),
195 };
196 match self {
197 Self::Homebrew { formula } => format!("Homebrew (formula {formula}{channel})"),
198 Self::Scoop { package } => format!("Scoop (package {package}{channel})"),
199 Self::Cargo => format!("cargo install (crates.io{channel})"),
200 Self::Script { .. } => format!("the install script ({INSTALL_URL}{channel})"),
201 Self::Unknown { path } => {
202 format!("something else - the binary is at {}", path.display())
203 }
204 }
205 }
206}
207
208/// The component of `path` immediately after the first one equal to `marker`.
209///
210/// This is how both package managers record what they installed: Homebrew lays
211/// a binary out as `<prefix>/Cellar/<formula>/<version>/bin/lev`, and Scoop as
212/// `<root>/apps/<package>/current/lev.exe`. The name in that slot is the real
213/// answer to "which channel is this", and it is on disk rather than inferred.
214fn component_after(path: &Path, marker: &str) -> Option<String> {
215 let mut components = path
216 .components()
217 .map(|c| c.as_os_str().to_string_lossy().into_owned());
218 components.by_ref().find(|c| c == marker)?;
219 components.next()
220}
221
222/// Whether `path` has a component equal to `marker`, ignoring case.
223///
224/// Scoop's root is a user-chosen directory that is conventionally but not
225/// reliably lowercase, and Windows paths are case-insensitive anyway.
226fn has_component(path: &Path, marker: &str) -> bool {
227 path.components()
228 .any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(marker))
229}
230
231/// Homebrew prefixes that mean Homebrew and nothing else, for the case where
232/// the binary is the `bin/lev` symlink rather than the Cellar path behind it.
233///
234/// `/usr/local` is deliberately absent even though it is Homebrew's own prefix
235/// on Intel macOS: it is also where the install script and a hand-unpacked
236/// tarball put a binary, so treating everything under it as Homebrew would send
237/// a script install to `brew upgrade`. Under that prefix the Cellar component is
238/// the only evidence that counts.
239const UNAMBIGUOUS_BREW_PREFIXES: &[&str] = &["/opt/homebrew", "/home/linuxbrew/.linuxbrew"];
240
241/// Absolute directories the installers write to. The Linux installer hard-codes
242/// `/usr/local/bin`; `/usr/bin` is where the manual tarball instructions end up
243/// for anyone who moved it there instead.
244const SCRIPT_DESTINATIONS: &[&str] = &["/usr/local/bin", "/usr/bin"];
245
246/// Every directory a plain-binary install lands in, including the two that are
247/// home-relative: `~/.local/bin`, and the `%LOCALAPPDATA%\Leviath\bin` that
248/// `install.ps1` writes on Windows.
249///
250/// A loose binary in one of these is a script install. A loose binary anywhere
251/// else is not something to guess about, because re-running an installer aims
252/// at a fixed destination and would leave the copy actually on `PATH` untouched.
253fn script_destinations(home: Option<&Path>) -> Vec<PathBuf> {
254 let mut dirs: Vec<PathBuf> = SCRIPT_DESTINATIONS.iter().map(PathBuf::from).collect();
255 if let Some(home) = home {
256 dirs.push(home.join(".local").join("bin"));
257 dirs.push(
258 home.join("AppData")
259 .join("Local")
260 .join("Leviath")
261 .join("bin"),
262 );
263 }
264 dirs
265}
266
267/// Work out how `exe` was installed.
268///
269/// Pure over its inputs - the resolved executable path, the home directory, the
270/// answer `brew --prefix` gave (if it was asked and answered), and the channel
271/// the user named - so every arm is testable without a Homebrew, a Scoop or a
272/// second machine.
273pub fn detect(
274 exe: &Path,
275 home: Option<&Path>,
276 brew_prefix: Option<&Path>,
277 requested: Option<Channel>,
278) -> InstallMethod {
279 // The channel to fall back on where the path does not name one.
280 let channel = requested.unwrap_or(Channel::Stable);
281
282 // Homebrew, from the strongest evidence down. A Cellar path names the
283 // formula outright; a prefix only says "Homebrew put this here".
284 if let Some(formula) = component_after(exe, "Cellar") {
285 return InstallMethod::Homebrew { formula };
286 }
287 let under_brew = UNAMBIGUOUS_BREW_PREFIXES.iter().any(|p| exe.starts_with(p))
288 || brew_prefix.is_some_and(|p| exe.starts_with(p) && !is_ambiguous_prefix(p));
289 if under_brew {
290 return InstallMethod::Homebrew {
291 formula: channel.package().to_string(),
292 };
293 }
294
295 // Scoop, the same two ways round.
296 if has_component(exe, "scoop") {
297 let package = component_after(exe, "apps").unwrap_or_else(|| channel.package().to_string());
298 return InstallMethod::Scoop { package };
299 }
300
301 // A cargo install, which is the one method that cannot be updated in place.
302 let cargo_bin = home.map(|h| h.join(".cargo").join("bin"));
303 if cargo_bin.is_some_and(|dir| exe.starts_with(dir)) {
304 return InstallMethod::Cargo;
305 }
306
307 let parent = exe.parent();
308 let script_dir = script_destinations(home)
309 .iter()
310 .any(|d| parent == Some(d.as_path()));
311 match script_dir {
312 true => InstallMethod::Script { channel },
313 false => InstallMethod::Unknown {
314 path: exe.to_path_buf(),
315 },
316 }
317}
318
319/// Whether a prefix is too general to be evidence of anything on its own. See
320/// [`UNAMBIGUOUS_BREW_PREFIXES`] for why `/usr/local` is the case that matters.
321fn is_ambiguous_prefix(prefix: &Path) -> bool {
322 matches!(
323 prefix.to_string_lossy().trim_end_matches('/'),
324 "/usr/local" | "/usr" | "" | "/"
325 )
326}
327
328// ─── Config migrations ────────────────────────────────────────────────────────
329
330/// One config change `lev update` knows how to make on the user's behalf.
331///
332/// The mechanism exists so that a future incompatibility - a key that moved, a
333/// value whose meaning changed - is either fixed automatically or at least
334/// explained at the moment the user updates into it, rather than surfacing as a
335/// broken run days later. [`MIGRATIONS`] is empty today because no shipped
336/// version has changed a key's name or meaning; the tests drive the machinery
337/// with a sample so the wiring is proven rather than assumed.
338pub struct Migration {
339 /// A short stable name, shown in the report and in `--json`.
340 pub name: &'static str,
341 /// What it changes and why, in one line.
342 pub description: &'static str,
343 /// Whether this config needs it.
344 ///
345 /// Gets the parsed [`Config`] *and* the raw document, because the two see
346 /// different things: a key serde no longer reads vanishes from the parsed
347 /// value entirely, and a key that is still read but now means something
348 /// else is only visible there.
349 pub applies: fn(&Config, &toml::Table) -> bool,
350 /// Make the change, returning one line per thing it did.
351 pub apply: fn(&mut Config) -> Vec<String>,
352}
353
354/// The migrations this build knows about, oldest first.
355///
356/// Empty, and honestly so: nothing in a released `config.toml` has to change to
357/// work with this version. Adding one is adding an entry here.
358pub const MIGRATIONS: &[Migration] = &[];
359
360// ─── The plan ─────────────────────────────────────────────────────────────────
361
362/// What to do about the binary.
363#[derive(Debug, Clone, PartialEq, Eq)]
364pub enum BinaryStep {
365 /// Run these, argv-style, in order, stopping at the first failure.
366 ///
367 /// A sequence rather than one command because a package manager will not
368 /// see a release published minutes ago until its own index is refreshed,
369 /// so the refresh and the upgrade are two commands that only make sense
370 /// together.
371 Run(Vec<Vec<String>>),
372 /// There is nothing to run here. Tell the user this instead.
373 Advise(String),
374}
375
376/// Everything `lev update` intends to do, as plain data.
377///
378/// Built before anything happens and rendered before anything happens, so the
379/// report, the JSON and the actions can never disagree about what was planned.
380pub struct UpdatePlan {
381 /// How this copy was installed.
382 pub method: InstallMethod,
383 /// The binary step.
384 pub binary: BinaryStep,
385 /// Every bundled blueprint and what would happen to it.
386 pub agents: Vec<(&'static BundledAgent, AgentAction)>,
387 /// The migrations that apply to the config as it stands.
388 pub migrations: Vec<&'static Migration>,
389 /// What reading the config found.
390 pub config: ConfigState,
391}
392
393/// What the plan found when it read the config file.
394///
395/// One value rather than a `Config` and an error beside it, because those two
396/// only ever come in two of the four combinations and the other two would be
397/// arms nothing could reach.
398///
399/// The config is carried rather than re-read at write time so there is exactly
400/// one read: re-opening the file would add an error arm only a race could take,
401/// and applying a migration to a document nobody has looked at since the report
402/// was printed is exactly the surprise this command exists to avoid.
403pub enum ConfigState {
404 /// The config as it stands, for the migrations to be applied to. Boxed
405 /// because a `Config` is far larger than the message beside it.
406 Loaded(Box<Config>),
407 /// It could not be read, and this is why.
408 Unreadable(String),
409}
410
411/// One line naming every command a [`BinaryStep::Run`] will run, in order.
412///
413/// Joined with `&&` because that is both how the sequence behaves (each
414/// command only runs if the one before it succeeded) and what a user would
415/// paste into a shell to do it themselves.
416pub fn render_commands(commands: &[Vec<String>]) -> String {
417 commands
418 .iter()
419 .map(|argv| argv.join(" "))
420 .collect::<Vec<_>>()
421 .join(" && ")
422}
423
424/// The upgrade step for an install method: the commands to run, or the reason
425/// there are none.
426pub fn binary_step(method: &InstallMethod) -> BinaryStep {
427 match method {
428 // `brew update` first, every time. Homebrew upgrades against the tap
429 // metadata it already has, so a formula published minutes ago is
430 // invisible to `brew upgrade` on its own and the command cheerfully
431 // reports the installed version as the latest. That is what sent
432 // people to `brew update && brew upgrade leviath` by hand. The formula
433 // name carries the channel, so this is the same two steps for
434 // `leviath`, `leviath-alpha` and `leviath-beta`.
435 InstallMethod::Homebrew { formula } => BinaryStep::Run(vec![
436 vec!["brew".to_string(), "update".to_string()],
437 vec!["brew".to_string(), "upgrade".to_string(), formula.clone()],
438 ]),
439 // Scoop has the same shape: a bare `scoop update` refreshes the
440 // buckets, and `scoop update <app>` upgrades against whatever the
441 // buckets already said.
442 InstallMethod::Scoop { package } => BinaryStep::Run(vec![
443 vec!["scoop".to_string(), "update".to_string()],
444 vec!["scoop".to_string(), "update".to_string(), package.clone()],
445 ]),
446 // Deliberately not run. `cargo install` rebuilds the whole workspace
447 // from source, which is minutes of CPU nobody asked for by typing
448 // `lev update`.
449 InstallMethod::Cargo => BinaryStep::Advise(
450 "this copy was built by `cargo install`. Update it with \
451 `cargo install leviath-cli` - that is a full compile, so it is not \
452 something to start for you."
453 .to_string(),
454 ),
455 // One shell, one pipeline. `LEVIATH_CHANNEL=beta curl ... | sh` is the
456 // form to never generate: the assignment belongs to `curl`, the piped
457 // shell never sees it, and the installer silently takes stable.
458 InstallMethod::Script { channel } => BinaryStep::Run(vec![vec![
459 "sh".to_string(),
460 "-c".to_string(),
461 format!(
462 "curl -fsSL {INSTALL_URL} | sh -s -- --channel {}",
463 channel.id()
464 ),
465 ]]),
466 InstallMethod::Unknown { path } => BinaryStep::Advise(format!(
467 "`lev` is at {}, which is not where any installer Leviath ships puts it. \
468 Update it the way you installed it, or re-install with \
469 `curl -fsSL {INSTALL_URL} | sh`.",
470 path.display()
471 )),
472 }
473}
474
475/// The config as `lev update` needs to see it: parsed, and the document behind
476/// it.
477struct LoadedConfig {
478 config: Config,
479 raw: toml::Table,
480}
481
482/// Read the config file both ways.
483fn load_config(path: &Path) -> anyhow::Result<LoadedConfig> {
484 let config = Config::load_from_path_public(path)?;
485 let raw = match std::fs::read_to_string(path) {
486 // `expect`: `load_from_path_public` above parsed this same text as
487 // TOML, so a document that reaches here is a document that parses.
488 Ok(text) => toml::from_str::<toml::Table>(&text).expect("the config parsed a moment ago"),
489 // No file at all, which loads as the defaults and an empty document.
490 Err(_) => toml::Table::new(),
491 };
492 Ok(LoadedConfig { config, raw })
493}
494
495/// Work out everything the command would do, without doing any of it.
496pub fn plan(args: &UpdateArgs, env: &UpdateEnv) -> UpdatePlan {
497 let method = detect(
498 &env.exe,
499 env.home.as_deref(),
500 env.brew_prefix.as_deref(),
501 args.channel,
502 );
503 let binary = binary_step(&method);
504 let agents = plan_agent_actions(&env.agents_dir);
505
506 // A config that will not parse is reported, not fatal: the binary step is
507 // the part of this command that matters most and it does not need one.
508 let (migrations, config) = match load_config(&env.config_path) {
509 Ok(loaded) => (
510 env.migrations
511 .iter()
512 .filter(|m| (m.applies)(&loaded.config, &loaded.raw))
513 .collect(),
514 ConfigState::Loaded(Box::new(loaded.config)),
515 ),
516 Err(e) => (Vec::new(), ConfigState::Unreadable(e.to_string())),
517 };
518
519 UpdatePlan {
520 method,
521 binary,
522 agents,
523 migrations,
524 config,
525 }
526}
527
528// ─── Rendering ────────────────────────────────────────────────────────────────
529
530/// The blueprints this plan would change.
531fn changing(plan: &UpdatePlan) -> Vec<&(&'static BundledAgent, AgentAction)> {
532 plan.agents.iter().filter(|(_, a)| a.is_change()).collect()
533}
534
535/// Render the plan the way `lev update` prints it.
536///
537/// Pure, so the tests assert the text exactly - everything that varies between
538/// machines is already in the [`UpdatePlan`].
539pub fn format_plan(plan: &UpdatePlan, version: &str) -> String {
540 let mut out = format!(
541 "\nlev {version}, installed with {}\n\n",
542 plan.method.describe()
543 );
544
545 match &plan.binary {
546 BinaryStep::Run(commands) => {
547 out.push_str(&format!(" binary {}\n", render_commands(commands)));
548 }
549 BinaryStep::Advise(text) => out.push_str(&format!(" binary {text}\n")),
550 }
551
552 let changes = changing(plan);
553 match changes.is_empty() {
554 true => out.push_str(&format!(
555 " agents all {} bundled blueprints are up to date\n",
556 plan.agents.len()
557 )),
558 false => {
559 out.push_str(&format!(
560 " agents {} of {} would change\n",
561 changes.len(),
562 plan.agents.len()
563 ));
564 for (agent, action) in &changes {
565 out.push_str(&format!(
566 " {} - {}\n",
567 agent.name,
568 action.label(agent.version)
569 ));
570 }
571 }
572 }
573
574 match (&plan.config, plan.migrations.is_empty()) {
575 (ConfigState::Unreadable(e), _) => {
576 out.push_str(&format!(" config could not be read: {e}\n"))
577 }
578 (ConfigState::Loaded(_), true) => out.push_str(" config nothing to migrate\n"),
579 (ConfigState::Loaded(_), false) => {
580 out.push_str(&format!(
581 " config {} migration(s)\n",
582 plan.migrations.len()
583 ));
584 for migration in &plan.migrations {
585 out.push_str(&format!(
586 " {} - {}\n",
587 migration.name, migration.description
588 ));
589 }
590 }
591 }
592 out
593}
594
595/// The plan as JSON. Built by hand, like `lev tools` and `lev doctor`, so the
596/// shape is explicit and does not move when a type gains a field.
597pub fn plan_json(plan: &UpdatePlan, version: &str) -> serde_json::Value {
598 let binary = match &plan.binary {
599 // `commands` is a list of argv lists. The old `command` key held a
600 // single argv and is kept alongside it, holding the last command (the
601 // upgrade itself), so a script reading it still sees the step that
602 // does the work rather than the index refresh in front of it.
603 BinaryStep::Run(commands) => serde_json::json!({
604 "action": "run",
605 "commands": commands,
606 "command": commands.last(),
607 }),
608 BinaryStep::Advise(text) => serde_json::json!({ "action": "advise", "message": text }),
609 };
610 let agents: Vec<serde_json::Value> = plan
611 .agents
612 .iter()
613 .map(|(agent, action)| {
614 serde_json::json!({
615 "name": agent.name,
616 "version": agent.version,
617 "change": action.label(agent.version),
618 "changes": action.is_change(),
619 "preselected": action.preselect(),
620 })
621 })
622 .collect();
623 let migrations: Vec<serde_json::Value> = plan
624 .migrations
625 .iter()
626 .map(|m| serde_json::json!({ "name": m.name, "description": m.description }))
627 .collect();
628 serde_json::json!({
629 "version": version,
630 "install_method": plan.method.id(),
631 "channel": plan.method.channel().map(Channel::id),
632 "binary": binary,
633 "agents": agents,
634 "migrations": migrations,
635 "config_error": match &plan.config {
636 ConfigState::Unreadable(e) => serde_json::Value::String(e.clone()),
637 ConfigState::Loaded(_) => serde_json::Value::Null,
638 },
639 })
640}
641
642// ─── Arguments and seams ──────────────────────────────────────────────────────
643
644/// Arguments for `lev update`.
645#[derive(Args, Debug, Clone, Default)]
646pub struct UpdateArgs {
647 /// Report what would happen and change nothing.
648 #[arg(long)]
649 pub check: bool,
650
651 /// Answer yes to the binary upgrade and the config write without asking.
652 /// It deliberately does not install blueprints: see `--install-agents`.
653 #[arg(long)]
654 pub yes: bool,
655
656 /// Install the bundled blueprints without asking. A copy you edited
657 /// locally is still asked about on its own, since installing destroys it.
658 #[arg(long)]
659 pub install_agents: bool,
660
661 /// The channel to install, for the install-script method, which records
662 /// none. Ignored by Homebrew and Scoop, whose package name already says.
663 #[arg(long, value_name = "CHANNEL")]
664 pub channel: Option<Channel>,
665
666 /// Walk the whole flow, but print each action instead of performing it.
667 #[arg(long)]
668 pub dry_run: bool,
669
670 /// Print the plan as JSON and change nothing.
671 #[arg(long)]
672 pub json: bool,
673}
674
675/// Runs the upgrade command: argv in, success or the reason it failed out.
676///
677/// Injected rather than called directly so the tests assert *which* command
678/// would run without spawning anything. Mirrors the `BrowserOpener` and
679/// `SeedCommandRunner` seams.
680pub type CommandRunner = Arc<dyn Fn(&[String]) -> anyhow::Result<()> + Send + Sync>;
681
682/// Asks the user a yes/no question. Injected for the same reason: a unit test
683/// has no terminal, and which questions a flag answers on the user's behalf -
684/// and, for a blueprint they edited, which ones no flag answers - is something
685/// the tests have to be able to prove rather than describe.
686pub type Confirm = Arc<dyn Fn(&str) -> bool + Send + Sync>;
687
688/// The real I/O `lev update` depends on, injected so the command logic is
689/// testable without a package manager, a terminal, or the real home directory.
690pub struct UpdateEnv {
691 /// The running binary, symlinks already resolved. Resolving matters: a
692 /// Homebrew `bin/lev` is a symlink into the Cellar, and the Cellar path is
693 /// the one that names the formula.
694 pub exe: PathBuf,
695 /// The user's home directory, when one resolves.
696 pub home: Option<PathBuf>,
697 /// What `brew --prefix` answered, when it was asked and answered.
698 pub brew_prefix: Option<PathBuf>,
699 /// Where the bundled blueprints are installed.
700 pub agents_dir: PathBuf,
701 /// The config file to read and, with permission, rewrite.
702 pub config_path: PathBuf,
703 /// How to run the upgrade command.
704 pub runner: CommandRunner,
705 /// How to ask a yes/no question.
706 pub confirm: Confirm,
707 /// The migrations to consider, in order. Production passes [`MIGRATIONS`].
708 pub migrations: &'static [Migration],
709}
710
711// ─── Execution ────────────────────────────────────────────────────────────────
712
713/// Ask, unless `--yes` has already answered.
714fn agreed(args: &UpdateArgs, env: &UpdateEnv, question: &str) -> bool {
715 args.yes || (env.confirm)(question)
716}
717
718/// Step one: the binary.
719fn update_binary(args: &UpdateArgs, env: &UpdateEnv, plan: &UpdatePlan) -> anyhow::Result<()> {
720 let commands = match &plan.binary {
721 BinaryStep::Advise(text) => {
722 println!(" {text}");
723 return Ok(());
724 }
725 BinaryStep::Run(commands) => commands,
726 };
727 // Asked about as one question, because they are one action: refreshing a
728 // package index without then upgrading would be a strange thing to agree
729 // to on its own.
730 let shown = render_commands(commands);
731 if !agreed(args, env, &format!("Run `{shown}`?")) {
732 println!(" left the binary alone");
733 return Ok(());
734 }
735 if args.dry_run {
736 println!(" would run: {shown}");
737 return Ok(());
738 }
739 for argv in commands {
740 (env.runner)(argv)?;
741 }
742 Ok(())
743}
744
745/// Step two: the bundled blueprints.
746///
747/// Nothing here is a default. The whole list is printed first, and the agents
748/// directory is not written to until someone has said yes to it: `--yes` is
749/// deliberately not enough, because "update my binary without stopping to ask"
750/// and "replace the blueprints in my agents directory" are different requests
751/// and `lev setup` already treats them that way. `--install-agents` is how a
752/// script says the second one.
753///
754/// A copy the user edited is named as edited and asked about on its own, and no
755/// flag covers it: [`install_bundled`] removes the destination directory first,
756/// so a bulk yes would take their edits and any file they added with it.
757///
758/// A failed install is a warning, not an abort, for the same reason `lev setup`
759/// treats it that way: an updated binary plus most of the blueprints is a far
760/// better place to leave someone than a command that gave up in the middle.
761fn update_agents(args: &UpdateArgs, env: &UpdateEnv, plan: &UpdatePlan) {
762 let changes = changing(plan);
763 if changes.is_empty() {
764 println!(" every bundled blueprint is already up to date");
765 return;
766 }
767
768 // Seen before agreed to, always.
769 for (agent, action) in &changes {
770 println!(" {} - {}", agent.name, action.label(agent.version));
771 }
772 let clean = changes.iter().filter(|(_, a)| a.preselect()).count();
773 let edited = changes.len() - clean;
774 if edited > 0 {
775 println!(
776 " {edited} of these you have edited locally. Installing removes the directory \
777 first, so your edits and any file you added go with it - each is asked about \
778 on its own."
779 );
780 }
781
782 // One question for the whole clean set, rather than one per blueprint: the
783 // list above is already the detail, and seven prompts in a row is how a
784 // person stops reading them.
785 let install_clean = match clean {
786 0 => false,
787 n => args.install_agents || (env.confirm)(&format!("Install these {n} blueprint(s)?")),
788 };
789
790 for (agent, action) in changes {
791 let ok = match action.preselect() {
792 true => install_clean,
793 false => (env.confirm)(&format!(
794 "{} - {}. Overwrite your edited copy?",
795 agent.name,
796 action.label(agent.version)
797 )),
798 };
799 if !ok {
800 println!(" skipped {}", agent.name);
801 continue;
802 }
803 if args.dry_run {
804 println!(" would install {} {}", agent.name, agent.version);
805 continue;
806 }
807 match install_bundled(agent, &env.agents_dir) {
808 Ok(()) => println!(" installed {} {}", agent.name, agent.version),
809 Err(e) => println!(" could not install {}: {e}", agent.name),
810 }
811 }
812}
813
814/// Step three: the config.
815///
816/// Nothing is written before the user has seen, line by line, what changed.
817fn migrate_config(args: &UpdateArgs, env: &UpdateEnv, plan: &UpdatePlan) -> anyhow::Result<()> {
818 let config = match &plan.config {
819 ConfigState::Unreadable(e) => {
820 println!(" the config could not be read, so it was left alone: {e}");
821 return Ok(());
822 }
823 ConfigState::Loaded(config) => config,
824 };
825 if plan.migrations.is_empty() {
826 println!(" the config needs no changes");
827 return Ok(());
828 }
829
830 let mut config = config.as_ref().clone();
831 let mut changed = Vec::new();
832 for migration in &plan.migrations {
833 for line in (migration.apply)(&mut config) {
834 changed.push(format!("{}: {line}", migration.name));
835 }
836 }
837 for line in &changed {
838 println!(" - {line}");
839 }
840
841 let path = env.config_path.display();
842 if !agreed(args, env, &format!("Write these changes to {path}?")) {
843 println!(" config left as it is");
844 return Ok(());
845 }
846 if args.dry_run {
847 println!(" would write {path}");
848 return Ok(());
849 }
850 config.save_to_path_public(&env.config_path)?;
851 println!(" wrote {path}");
852 Ok(())
853}
854
855/// Run `lev update` against an injected environment.
856pub fn execute_with(args: &UpdateArgs, env: &UpdateEnv, version: &str) -> anyhow::Result<()> {
857 let plan = plan(args, env);
858
859 if args.json {
860 println!(
861 "{}",
862 serde_json::to_string_pretty(&plan_json(&plan, version))
863 .expect("a plan is plain data and always serializes")
864 );
865 return Ok(());
866 }
867
868 print!("{}", format_plan(&plan, version));
869 if args.check {
870 return Ok(());
871 }
872
873 println!("\nbinary");
874 update_binary(args, env, &plan)?;
875 println!("\nblueprints");
876 update_agents(args, env, &plan);
877 println!("\nconfig");
878 migrate_config(args, env, &plan)?;
879 Ok(())
880}
881
882#[cfg(test)]
883mod tests;