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://raw.githubusercontent.com/singudotdev/kanbrain/main/install.sh";
15
16pub const INSTALL_PS1: &str = "https://raw.githubusercontent.com/singudotdev/kanbrain/main/install.ps1";
18
19const LATEST: &str = "https://github.com/singudotdev/kanbrain/releases/latest";
22
23const BUNDLE_INSTALL_DIR: &str = "../Resources/install-dir";
26
27fn installed_app(dir: &Path) -> &'static str {
30 let has = |name: &str| dir.join(format!("{name}{}", std::env::consts::EXE_SUFFIX)).is_file();
31 match (has("kb"), has("kanbrain")) {
32 (true, true) => "both",
33 (false, true) => "gui",
34 _ => "cli",
35 }
36}
37
38fn installed_by_cargo(dir: &Path, cargo_home: Option<PathBuf>) -> bool {
41 let Some(home) = cargo_home.or_else(|| dirs::home_dir().map(|home| home.join(".cargo"))) else { return false };
42 let canonical = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
43 canonical(dir) == canonical(&home.join("bin"))
44}
45
46#[derive(Debug, PartialEq, Eq)]
48pub enum Install {
49 Cargo,
52 Installer {
54 dir: PathBuf,
56 app: &'static str,
58 },
59}
60
61pub fn install_of(exe: &Path) -> Result<Install> {
63 let dir = exe.parent().context("finding this program's folder")?;
64 if installed_by_cargo(dir, std::env::var_os("CARGO_HOME").map(PathBuf::from)) {
65 return Ok(Install::Cargo);
66 }
67 let dir = match std::fs::read_to_string(dir.join(BUNDLE_INSTALL_DIR)) {
68 Ok(text) => PathBuf::from(text.trim_end()),
69 Err(_) => dir.to_path_buf(),
70 };
71 Ok(Install::Installer { app: installed_app(&dir), dir })
72}
73
74pub fn agents_working(central: &Path) -> Result<Vec<String>> {
78 let repos = projects::load(central)?;
79 let brains = std::iter::once((String::new(), central.to_path_buf()))
80 .chain(repos.iter().map(|repo| (repo.label(&repos), repo.dir.clone())));
81 let mut working = Vec::new();
82 for (label, dir) in brains {
83 let Ok((tasks, _)) = Store::at(dir).and_then(|store| store.load()) else { continue };
84 working.extend(
85 tasks
86 .iter()
87 .filter(|t| t.is_task() && t.status == Status::Doing && t.started_by() == Author::Agent)
88 .map(|t| format!("{label}#{} {}", t.id, t.title)),
89 );
90 }
91 Ok(working)
92}
93
94pub fn check_no_agent_working(central: &Path) -> Result<()> {
96 let working = agents_working(central)?;
97 if !working.is_empty() {
98 bail!(
99 "agents are working on {}: update once they're done or parked, so their instructions and data don't \
100 change under them",
101 working.join(", ")
102 );
103 }
104 Ok(())
105}
106
107pub fn newer_release() -> Result<Option<String>> {
110 let mut curl = Command::new("curl");
111 curl.args(["-fsSI", "--max-time", "20", "--", LATEST]);
112 hide_console(&mut curl);
113 let out = curl.output().context("running curl, which asks for the latest release")?;
114 if !out.status.success() {
115 bail!("couldn't ask {LATEST} for the latest release");
116 }
117 let latest = redirected_version(&String::from_utf8_lossy(&out.stdout))
118 .context("the latest release's address has no version")?;
119 Ok(newer(&latest, env!("CARGO_PKG_VERSION")).then_some(latest))
120}
121
122fn redirected_version(headers: &str) -> Option<String> {
124 let location = headers.lines().find_map(|line| {
125 let (name, value) = line.split_once(':')?;
126 name.eq_ignore_ascii_case("location").then(|| value.trim())
127 })?;
128 location.rsplit_once("/tag/v").map(|(_, version)| version.to_string())
129}
130
131fn newer(a: &str, b: &str) -> bool {
133 let parts = |v: &str| -> Vec<u64> { v.split('.').map(|p| p.parse().unwrap_or(0)).collect() };
134 parts(a) > parts(b)
135}
136
137pub fn hide_console(command: &mut Command) {
139 #[cfg(windows)]
140 {
141 use std::os::windows::process::CommandExt;
142 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
143 command.creation_flags(CREATE_NO_WINDOW);
144 }
145 #[cfg(not(windows))]
146 let _ = command;
147}
148
149pub fn run_installer(dir: &Path, app: &str, captured: bool) -> Result<()> {
153 let mut curl = Command::new("curl")
156 .args(["-fsSL", "--", INSTALL_SH])
157 .stdout(Stdio::piped())
158 .spawn()
159 .context("running curl, which downloads the installer")?;
160 let script = curl.stdout.take().context("reading curl's output")?;
161 let mut sh = Command::new("sh");
162 sh.args(["-s", "--", "--app", app, "--dir"]).arg(dir).stdin(script);
163 let (installed, said) = if captured {
164 let out = sh.output().context("running the installer")?;
165 let said = String::from_utf8_lossy(&out.stderr).trim().lines().last().unwrap_or_default().to_string();
166 (out.status, said)
167 } else {
168 (sh.status().context("running the installer")?, "its messages are above".to_string())
169 };
170 let downloaded = curl.wait().context("waiting for curl")?;
171 if !downloaded.success() {
172 bail!("couldn't download the installer from {INSTALL_SH}");
173 }
174 if !installed.success() {
175 bail!("the installer failed ({said})");
176 }
177 Ok(())
178}
179
180pub fn update(central: &Path) -> Result<()> {
184 check_no_agent_working(central)?;
185 let exe = std::env::current_exe().context("finding this program's file")?;
186 let Install::Installer { dir, app } = install_of(&exe)? else {
187 println!(
188 "kb was installed with cargo: update it, and then every project's agent instructions, with\n\n \
189 cargo install kanbrain && kb init --all"
190 );
191 return Ok(());
192 };
193 if cfg!(windows) {
194 println!(
195 "Close kanbrain, then run in PowerShell:\n\n $env:KANBRAIN_APP = \"{app}\"; \
196 $env:KANBRAIN_INSTALL_DIR = \"{}\"; irm {INSTALL_PS1} | iex",
197 dir.display()
198 );
199 return Ok(());
200 }
201 run_installer(&dir, app, false)
202}
203
204#[cfg(test)]
205#[path = "../../tests/cli/update.rs"]
206mod tests;