use crate::model::{Author, Status};
use crate::projects;
use crate::store::Store;
use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const INSTALL_SH: &str = "https://github.com/singudotdev/kanbrain/releases/latest/download/install.sh";
pub const INSTALL_PS1: &str = "https://github.com/singudotdev/kanbrain/releases/latest/download/install.ps1";
const LATEST: &str = "https://github.com/singudotdev/kanbrain/releases/latest";
const BUNDLE_INSTALL_DIR: &str = "../Resources/install-dir";
fn installed_app(dir: &Path) -> &'static str {
let has = |name: &str| dir.join(format!("{name}{}", std::env::consts::EXE_SUFFIX)).is_file();
match (has("kb"), has("kanbrain")) {
(true, true) => "both",
(false, true) => "gui",
_ => "cli",
}
}
fn installed_by_cargo(dir: &Path, cargo_home: Option<PathBuf>) -> bool {
let Some(home) = cargo_home.or_else(|| dirs::home_dir().map(|home| home.join(".cargo"))) else { return false };
let canonical = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
canonical(dir) == canonical(&home.join("bin"))
}
#[derive(Debug, PartialEq, Eq)]
pub enum Install {
Cargo,
Installer {
dir: PathBuf,
app: &'static str,
},
}
pub fn install_of(exe: &Path) -> Result<Install> {
let dir = exe.parent().context("finding this program's folder")?;
if installed_by_cargo(dir, std::env::var_os("CARGO_HOME").map(PathBuf::from)) {
return Ok(Install::Cargo);
}
let dir = match std::fs::read_to_string(dir.join(BUNDLE_INSTALL_DIR)) {
Ok(text) => PathBuf::from(text.trim_end()),
Err(_) => dir.to_path_buf(),
};
Ok(Install::Installer { app: installed_app(&dir), dir })
}
pub fn agents_working(central: &Path) -> Result<Vec<String>> {
let repos = projects::load(central)?;
let brains = std::iter::once((String::new(), central.to_path_buf()))
.chain(repos.iter().map(|repo| (format!("{} ", repo.label(&repos)), repo.dir.clone())));
let mut working = Vec::new();
for (label, dir) in brains.filter(|(_, dir)| dir.is_dir()) {
let Ok((tasks, _)) = Store::at(dir).and_then(|store| store.load()) else { continue };
working.extend(
tasks
.iter()
.filter(|t| t.is_task() && t.status == Status::Doing && t.started_by() == Author::Agent)
.map(|t| format!("{label}#{} {}", t.id, t.title)),
);
}
Ok(working)
}
pub fn check_no_agent_working(central: &Path) -> Result<()> {
let working = agents_working(central)?;
if !working.is_empty() {
bail!(
"agents are working on {}: update once they're done or parked, so their instructions and data don't \
change under them",
working.join(", ")
);
}
Ok(())
}
pub fn newer_release() -> Result<Option<String>> {
let mut curl = Command::new("curl");
curl.args(["-fsSI", "--max-time", "20", "--", LATEST]);
hide_console(&mut curl);
let out = curl.output().context("running curl, which asks for the latest release")?;
if !out.status.success() {
bail!("couldn't ask {LATEST} for the latest release");
}
let latest = redirected_version(&String::from_utf8_lossy(&out.stdout))
.context("the latest release's address has no version")?;
Ok(newer(&latest, env!("CARGO_PKG_VERSION")).then_some(latest))
}
fn up_to_date(newer: &Result<Option<String>>) -> Option<String> {
matches!(newer, Ok(None))
.then(|| format!("kb {} is the latest release: nothing to update", env!("CARGO_PKG_VERSION")))
}
fn redirected_version(headers: &str) -> Option<String> {
let location = headers.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("location").then(|| value.trim())
})?;
location.rsplit_once("/tag/v").map(|(_, version)| version.to_string())
}
fn newer(a: &str, b: &str) -> bool {
let parts = |v: &str| -> Vec<u64> { v.split('.').map(|p| p.parse().unwrap_or(0)).collect() };
parts(a) > parts(b)
}
pub fn hide_console(command: &mut Command) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
let _ = command;
}
pub(crate) const INSTALLER_CHOICES: [&str; 4] =
["KANBRAIN_UNINSTALL", "KANBRAIN_PURGE", "KANBRAIN_YES", "KANBRAIN_VERSION"];
pub fn clear_installer_choices_ps1() -> String {
let vars: Vec<String> = INSTALLER_CHOICES.iter().map(|v| format!("Env:{v}")).collect();
format!("Remove-Item {} -ErrorAction SilentlyContinue", vars.join(", "))
}
fn installer_shell(dir: &Path, app: &str) -> Command {
let mut sh = Command::new("sh");
sh.args(["-s", "--", "--app", app, "--dir"]).arg(dir);
for var in INSTALLER_CHOICES {
sh.env_remove(var);
}
sh
}
pub fn run_installer(dir: &Path, app: &str, captured: bool) -> Result<()> {
let mut curl = Command::new("curl")
.args(["-fsSL", "--", INSTALL_SH])
.stdout(Stdio::piped())
.spawn()
.context("running curl, which downloads the installer")?;
let script = curl.stdout.take().context("reading curl's output")?;
let mut sh = installer_shell(dir, app);
sh.stdin(script);
let (installed, said) = if captured {
let out = sh.output().context("running the installer")?;
let said = String::from_utf8_lossy(&out.stderr).trim().lines().last().unwrap_or_default().to_string();
(out.status, said)
} else {
(sh.status().context("running the installer")?, "its messages are above".to_string())
};
let downloaded = curl.wait().context("waiting for curl")?;
if !downloaded.success() {
bail!("couldn't download the installer from {INSTALL_SH}");
}
if !installed.success() {
bail!("the installer failed ({said})");
}
Ok(())
}
pub fn update(central: &Path) -> Result<()> {
check_no_agent_working(central)?;
if let Some(said) = up_to_date(&newer_release()) {
println!("{said}");
return Ok(());
}
let exe = std::env::current_exe().context("finding this program's file")?;
let Install::Installer { dir, app } = install_of(&exe)? else {
println!(
"kb was installed with cargo: update it, and then every project's agent instructions, with\n\n \
cargo install kanbrain && kb init --all"
);
return Ok(());
};
if cfg!(windows) {
println!(
"Close kanbrain, then run in PowerShell:\n\n {}; $env:KANBRAIN_APP = \"{app}\"; \
$env:KANBRAIN_INSTALL_DIR = \"{}\"; irm {INSTALL_PS1} | iex",
clear_installer_choices_ps1(),
dir.display()
);
return Ok(());
}
run_installer(&dir, app, false)
}
#[cfg(test)]
#[path = "../../tests/cli/update.rs"]
mod tests;