Skip to main content

kanbrain_core/cli/
update.rs

1//! `kb update`, and the app's update: installs the latest release over this one, by running the installer the
2//! README gives, which downloads it, checks its checksum and replaces the binaries, then runs the new version's
3//! `init --all`, so none of that is repeated here. Never while an agent works on a task: `init --all` would rewrite
4//! its instructions and a migration its data mid-session.
5
6use crate::model::{Author, Status};
7use crate::projects;
8use crate::store::Store;
9use anyhow::{Context, Result, bail};
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12
13/// The installer for Linux and macOS, as the latest release has it: `main`'s may already expect a release that
14/// isn't out.
15const INSTALL_SH: &str = "https://github.com/singudotdev/kanbrain/releases/latest/download/install.sh";
16
17/// The installer for Windows, likewise.
18pub const INSTALL_PS1: &str = "https://github.com/singudotdev/kanbrain/releases/latest/download/install.ps1";
19
20/// Redirects to the latest release's page, whose address ends in its tag. A redirect, not GitHub's API, which
21/// limits how often an address may ask.
22const LATEST: &str = "https://github.com/singudotdev/kanbrain/releases/latest";
23
24/// Where the installer's macOS app bundle says the binaries are, relative to its executable: the bundle's is a
25/// link to the app in the install folder, which is the one to replace.
26const BUNDLE_INSTALL_DIR: &str = "../Resources/install-dir";
27
28/// Which apps are installed in `dir`, as the installer's `--app` names them: the one running is, and
29/// the other is updated along with it when it's there too.
30fn installed_app(dir: &Path) -> &'static str {
31    let has = |name: &str| dir.join(format!("{name}{}", std::env::consts::EXE_SUFFIX)).is_file();
32    match (has("kb"), has("kanbrain")) {
33        (true, true) => "both",
34        (false, true) => "gui",
35        _ => "cli",
36    }
37}
38
39/// Whether `dir` is cargo's folder of installed programs (`$CARGO_HOME/bin`, else `~/.cargo/bin`): a program
40/// there came from `cargo install`, which keeps its own record of what it installed.
41fn installed_by_cargo(dir: &Path, cargo_home: Option<PathBuf>) -> bool {
42    let Some(home) = cargo_home.or_else(|| dirs::home_dir().map(|home| home.join(".cargo"))) else { return false };
43    let canonical = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
44    canonical(dir) == canonical(&home.join("bin"))
45}
46
47/// How the running program was installed, which says how to update it.
48#[derive(Debug, PartialEq, Eq)]
49pub enum Install {
50    /// With `cargo install`, which keeps its own record of what it installed: the installer would replace the
51    /// file behind cargo's back.
52    Cargo,
53    /// By the installer, into `dir`, with `app` (its `--app`) there.
54    Installer {
55        /// The install folder.
56        dir: PathBuf,
57        /// `cli`, `gui` or `both`.
58        app: &'static str,
59    },
60}
61
62/// How the program at `exe` was installed.
63pub fn install_of(exe: &Path) -> Result<Install> {
64    let dir = exe.parent().context("finding this program's folder")?;
65    if installed_by_cargo(dir, std::env::var_os("CARGO_HOME").map(PathBuf::from)) {
66        return Ok(Install::Cargo);
67    }
68    let dir = match std::fs::read_to_string(dir.join(BUNDLE_INSTALL_DIR)) {
69        Ok(text) => PathBuf::from(text.trim_end()),
70        Err(_) => dir.to_path_buf(),
71    };
72    Ok(Install::Installer { app: installed_app(&dir), dir })
73}
74
75/// The tasks agents are working on, in doing, in every registered repository and the central brain, as
76/// `shop/api #3 title` (`#3 title` in the central brain), as `kb status` names them: an update waits for them. A
77/// brain that can't be read, such as one a newer kanbrain wrote, has none to wait for.
78pub fn agents_working(central: &Path) -> Result<Vec<String>> {
79    let repos = projects::load(central)?;
80    let brains = std::iter::once((String::new(), central.to_path_buf()))
81        .chain(repos.iter().map(|repo| (format!("{} ", repo.label(&repos)), repo.dir.clone())));
82    let mut working = Vec::new();
83    // A deleted repository's folder isn't there to read, and opening its store would make it again.
84    for (label, dir) in brains.filter(|(_, dir)| dir.is_dir()) {
85        let Ok((tasks, _)) = Store::at(dir).and_then(|store| store.load()) else { continue };
86        working.extend(
87            tasks
88                .iter()
89                .filter(|t| t.is_task() && t.status == Status::Doing && t.started_by() == Author::Agent)
90                .map(|t| format!("{label}#{} {}", t.id, t.title)),
91        );
92    }
93    Ok(working)
94}
95
96/// Refuses while [`agents_working`] finds any, naming them.
97pub fn check_no_agent_working(central: &Path) -> Result<()> {
98    let working = agents_working(central)?;
99    if !working.is_empty() {
100        bail!(
101            "agents are working on {}: update once they're done or parked, so their instructions and data don't \
102             change under them",
103            working.join(", ")
104        );
105    }
106    Ok(())
107}
108
109/// The latest release's version, `X.Y.Z`, when it's newer than this one. Asks GitHub, through curl, for where its
110/// latest release is.
111pub fn newer_release() -> Result<Option<String>> {
112    let mut curl = Command::new("curl");
113    curl.args(["-fsSI", "--max-time", "20", "--", LATEST]);
114    hide_console(&mut curl);
115    let out = curl.output().context("running curl, which asks for the latest release")?;
116    if !out.status.success() {
117        bail!("couldn't ask {LATEST} for the latest release");
118    }
119    let latest = redirected_version(&String::from_utf8_lossy(&out.stdout))
120        .context("the latest release's address has no version")?;
121    Ok(newer(&latest, env!("CARGO_PKG_VERSION")).then_some(latest))
122}
123
124/// What `update` says instead of reinstalling, when the latest release is this one (or older, a build ahead of
125/// it); `None` to install: a newer release, or none found (the installer then says why it can't download).
126fn up_to_date(newer: &Result<Option<String>>) -> Option<String> {
127    matches!(newer, Ok(None))
128        .then(|| format!("kb {} is the latest release: nothing to update", env!("CARGO_PKG_VERSION")))
129}
130
131/// The version at the end of the address a response's headers redirect to, `…/tag/vX.Y.Z`.
132fn redirected_version(headers: &str) -> Option<String> {
133    let location = headers.lines().find_map(|line| {
134        let (name, value) = line.split_once(':')?;
135        name.eq_ignore_ascii_case("location").then(|| value.trim())
136    })?;
137    location.rsplit_once("/tag/v").map(|(_, version)| version.to_string())
138}
139
140/// Whether version `a` comes after `b`, both `X.Y.Z`; a part that isn't a number counts as 0.
141fn newer(a: &str, b: &str) -> bool {
142    let parts = |v: &str| -> Vec<u64> { v.split('.').map(|p| p.parse().unwrap_or(0)).collect() };
143    parts(a) > parts(b)
144}
145
146/// Keeps a console window from flashing up when the app, which has none, runs a program on Windows.
147pub fn hide_console(command: &mut Command) {
148    #[cfg(windows)]
149    {
150        use std::os::windows::process::CommandExt;
151        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
152        command.creation_flags(CREATE_NO_WINDOW);
153    }
154    #[cfg(not(windows))]
155    let _ = command;
156}
157
158/// The installer's variables that say to do something else than install the latest release: left set in a shell
159/// from an uninstall or a pinned install, they'd have an update uninstall, purge without asking, or reinstall
160/// that version instead. An update runs the installer without them.
161pub(crate) const INSTALLER_CHOICES: [&str; 4] =
162    ["KANBRAIN_UNINSTALL", "KANBRAIN_PURGE", "KANBRAIN_YES", "KANBRAIN_VERSION"];
163
164/// PowerShell that clears [`INSTALLER_CHOICES`] in its session, to run before the installer there.
165pub fn clear_installer_choices_ps1() -> String {
166    let vars: Vec<String> = INSTALLER_CHOICES.iter().map(|v| format!("Env:{v}")).collect();
167    format!("Remove-Item {} -ErrorAction SilentlyContinue", vars.join(", "))
168}
169
170/// The shell that runs the installer script given on its stdin, for `app` into `dir`, without
171/// [`INSTALLER_CHOICES`].
172fn installer_shell(dir: &Path, app: &str) -> Command {
173    let mut sh = Command::new("sh");
174    sh.args(["-s", "--", "--app", app, "--dir"]).arg(dir);
175    for var in INSTALLER_CHOICES {
176        sh.env_remove(var);
177    }
178    sh
179}
180
181/// Runs the installer for `app` into `dir`, on Linux and macOS: the binaries are replaced by renaming, so the
182/// running ones carry on. `captured`, its messages are kept out of sight and the last of them make up the error
183/// when it fails, as the app wants; otherwise they're printed.
184pub fn run_installer(dir: &Path, app: &str, captured: bool) -> Result<()> {
185    // The script goes from curl to sh through a pipe, and the folder as an argument of its own, so
186    // nothing here is ever read by a shell as code.
187    let mut curl = Command::new("curl")
188        .args(["-fsSL", "--", INSTALL_SH])
189        .stdout(Stdio::piped())
190        .spawn()
191        .context("running curl, which downloads the installer")?;
192    let script = curl.stdout.take().context("reading curl's output")?;
193    let mut sh = installer_shell(dir, app);
194    sh.stdin(script);
195    let (installed, said) = if captured {
196        let out = sh.output().context("running the installer")?;
197        let said = String::from_utf8_lossy(&out.stderr).trim().lines().last().unwrap_or_default().to_string();
198        (out.status, said)
199    } else {
200        (sh.status().context("running the installer")?, "its messages are above".to_string())
201    };
202    let downloaded = curl.wait().context("waiting for curl")?;
203    if !downloaded.success() {
204        bail!("couldn't download the installer from {INSTALL_SH}");
205    }
206    if !installed.success() {
207        bail!("the installer failed ({said})");
208    }
209    Ok(())
210}
211
212/// Runs the installer for the apps installed where this program is, into the same folder, unless an agent is
213/// working. On Windows it prints the PowerShell command instead, since a running program's file can't be replaced
214/// there. Installed with `cargo install`, it says to update the same way instead.
215pub fn update(central: &Path) -> Result<()> {
216    check_no_agent_working(central)?;
217    if let Some(said) = up_to_date(&newer_release()) {
218        println!("{said}");
219        return Ok(());
220    }
221    let exe = std::env::current_exe().context("finding this program's file")?;
222    let Install::Installer { dir, app } = install_of(&exe)? else {
223        println!(
224            "kb was installed with cargo: update it, and then every project's agent instructions, with\n\n  \
225             cargo install kanbrain && kb init --all"
226        );
227        return Ok(());
228    };
229    if cfg!(windows) {
230        println!(
231            "Close kanbrain, then run in PowerShell:\n\n  {}; $env:KANBRAIN_APP = \"{app}\"; \
232             $env:KANBRAIN_INSTALL_DIR = \"{}\"; irm {INSTALL_PS1} | iex",
233            clear_installer_choices_ps1(),
234            dir.display()
235        );
236        return Ok(());
237    }
238    run_installer(&dir, app, false)
239}
240
241#[cfg(test)]
242#[path = "../../tests/cli/update.rs"]
243mod tests;