1use super::Program;
7use crate::model::Author;
8use anyhow::{Context, Result, bail};
9use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11
12const INSTALL_SH: &str = "https://github.com/singudotdev/kanbrain/releases/latest/download/install.sh";
15
16pub const INSTALL_PS1: &str = "https://github.com/singudotdev/kanbrain/releases/latest/download/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 mut working = Vec::new();
79 for (label, tasks) in super::every_brain(central)? {
80 working.extend(
81 tasks.iter().filter(|t| t.doing_by(Author::Agent)).map(|t| format!("{label}#{} {}", t.id, t.title)),
82 );
83 }
84 Ok(working)
85}
86
87pub fn check_no_agent_working(central: &Path) -> Result<()> {
89 let working = agents_working(central)?;
90 if !working.is_empty() {
91 bail!(
92 "agents are working on {}: update once they're done or parked, so their instructions and data don't \
93 change under them",
94 working.join(", ")
95 );
96 }
97 Ok(())
98}
99
100pub fn newer_release() -> Result<Option<String>> {
103 let mut curl = Command::new("curl");
104 curl.args(["-fsSI", "--max-time", "20", "--", LATEST]);
105 hide_console(&mut curl);
106 let out = curl.output().context("running curl, which asks for the latest release")?;
107 if !out.status.success() {
108 bail!("couldn't ask {LATEST} for the latest release");
109 }
110 let latest = redirected_version(&String::from_utf8_lossy(&out.stdout))
111 .context("the latest release's address has no version")?;
112 Ok(newer(&latest, env!("CARGO_PKG_VERSION")).then_some(latest))
113}
114
115fn up_to_date(newer: &Result<Option<String>>, program: Program) -> Option<String> {
118 matches!(newer, Ok(None))
119 .then(|| format!("{} {} is the latest release: nothing to update", program.name, env!("CARGO_PKG_VERSION")))
120}
121
122fn cargo_update(program: Program, release: Option<&str>) -> String {
125 format!(
126 "{} was installed with cargo: update it, and then every project's agent instructions, with\n\n {} && {} init \
127 --all",
128 program.name,
129 cargo_update_command(program, release),
130 program.name
131 )
132}
133
134pub fn cargo_update_command(program: Program, release: Option<&str>) -> String {
140 if program.name != "kanbrain" {
141 return "cargo install kanbrain".to_string();
142 }
143 let tag = release.map(|version| format!(" --tag v{version}")).unwrap_or_default();
144 format!("cargo install --locked --git https://github.com/singudotdev/kanbrain{tag} kanbrain-app")
145}
146
147fn redirected_version(headers: &str) -> Option<String> {
149 let location = headers.lines().find_map(|line| {
150 let (name, value) = line.split_once(':')?;
151 name.eq_ignore_ascii_case("location").then(|| value.trim())
152 })?;
153 location.rsplit_once("/tag/v").map(|(_, version)| version.to_string())
154}
155
156fn newer(a: &str, b: &str) -> bool {
158 let parts = |v: &str| -> Vec<u64> { v.split('.').map(|p| p.parse().unwrap_or(0)).collect() };
159 parts(a) > parts(b)
160}
161
162pub fn hide_console(command: &mut Command) {
164 #[cfg(windows)]
165 {
166 use std::os::windows::process::CommandExt;
167 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
168 command.creation_flags(CREATE_NO_WINDOW);
169 }
170 #[cfg(not(windows))]
171 let _ = command;
172}
173
174pub(crate) const INSTALLER_CHOICES: [&str; 4] =
178 ["KANBRAIN_UNINSTALL", "KANBRAIN_PURGE", "KANBRAIN_YES", "KANBRAIN_VERSION"];
179
180pub fn clear_installer_choices_ps1() -> String {
182 let vars: Vec<String> = INSTALLER_CHOICES.iter().map(|v| format!("Env:{v}")).collect();
183 format!("Remove-Item {} -ErrorAction SilentlyContinue", vars.join(", "))
184}
185
186pub fn powershell_string(text: &str) -> String {
191 let mut quoted = String::with_capacity(text.len() + 2);
192 quoted.push('\'');
193 for c in text.chars() {
194 if matches!(c, '\'' | '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}') {
195 quoted.push(c);
196 }
197 quoted.push(c);
198 }
199 quoted.push('\'');
200 quoted
201}
202
203fn windows_update_command(dir: &Path, app: &str) -> String {
206 format!(
207 "{}; $env:KANBRAIN_APP = {}; $env:KANBRAIN_INSTALL_DIR = {}; irm {INSTALL_PS1} | iex",
208 clear_installer_choices_ps1(),
209 powershell_string(app),
210 powershell_string(&dir.display().to_string())
211 )
212}
213
214fn installer_shell(dir: &Path, app: &str) -> Command {
217 let mut sh = Command::new("sh");
218 sh.args(["-s", "--", "--app", app, "--dir"]).arg(dir);
219 for var in INSTALLER_CHOICES {
220 sh.env_remove(var);
221 }
222 sh
223}
224
225pub fn run_installer(dir: &Path, app: &str, captured: bool) -> Result<()> {
229 let mut curl = Command::new("curl")
232 .args(["-fsSL", "--", INSTALL_SH])
233 .stdout(Stdio::piped())
234 .spawn()
235 .context("running curl, which downloads the installer")?;
236 let script = curl.stdout.take().context("reading curl's output")?;
237 let mut sh = installer_shell(dir, app);
238 sh.stdin(script);
239 let (installed, said) = if captured {
240 let out = sh.output().context("running the installer")?;
241 let said = String::from_utf8_lossy(&out.stderr).trim().lines().last().unwrap_or_default().to_string();
242 (out.status, said)
243 } else {
244 (sh.status().context("running the installer")?, "its messages are above".to_string())
245 };
246 let downloaded = curl.wait().context("waiting for curl")?;
247 if !downloaded.success() {
248 bail!("couldn't download the installer from {INSTALL_SH}");
249 }
250 if !installed.success() {
251 bail!("the installer failed ({said})");
252 }
253 Ok(())
254}
255
256#[deprecated(since = "0.6.3", note = "use `update_program`, which names the program running in what it says")]
258pub fn update(central: &Path) -> Result<()> {
259 update_program(central, Program { name: "kb" })
260}
261
262pub fn update_program(central: &Path, program: Program) -> Result<()> {
266 check_no_agent_working(central)?;
267 let newer = newer_release();
268 if let Some(said) = up_to_date(&newer, program) {
269 println!("{said}");
270 return Ok(());
271 }
272 let exe = std::env::current_exe().context("finding this program's file")?;
273 let Install::Installer { dir, app } = install_of(&exe)? else {
274 println!("{}", cargo_update(program, newer.ok().flatten().as_deref()));
275 return Ok(());
276 };
277 if cfg!(windows) {
278 println!("Close kanbrain, then run in PowerShell:\n\n {}", windows_update_command(&dir, app));
279 return Ok(());
280 }
281 run_installer(&dir, app, false)
282}
283
284#[cfg(test)]
285#[path = "../../tests/cli/update.rs"]
286mod tests;