kanbrain_core/cli/
update.rs1use 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
13const INSTALL_SH: &str = "https://github.com/singudotdev/kanbrain/releases/latest/download/install.sh";
16
17pub const INSTALL_PS1: &str = "https://github.com/singudotdev/kanbrain/releases/latest/download/install.ps1";
19
20const LATEST: &str = "https://github.com/singudotdev/kanbrain/releases/latest";
23
24const BUNDLE_INSTALL_DIR: &str = "../Resources/install-dir";
27
28fn 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
39fn 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#[derive(Debug, PartialEq, Eq)]
49pub enum Install {
50 Cargo,
53 Installer {
55 dir: PathBuf,
57 app: &'static str,
59 },
60}
61
62pub 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
75pub 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 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
96pub 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
109pub 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
124fn 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
131fn 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
140fn 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
146pub 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
158pub(crate) const INSTALLER_CHOICES: [&str; 4] =
162 ["KANBRAIN_UNINSTALL", "KANBRAIN_PURGE", "KANBRAIN_YES", "KANBRAIN_VERSION"];
163
164pub 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
170fn 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
181pub fn run_installer(dir: &Path, app: &str, captured: bool) -> Result<()> {
185 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
212pub 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;