diff --git a/Cargo.lock b/Cargo.lock index c7e0bbe..7c276f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,6 +145,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arboard" version = "3.6.1" @@ -1267,6 +1276,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.8.0" @@ -1285,6 +1300,35 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1435,6 +1479,33 @@ dependencies = [ "syn", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "darling" version = "0.20.11" @@ -1504,6 +1575,16 @@ dependencies = [ "syn", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1514,6 +1595,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1685,6 +1777,31 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "signature", + "subtle", + "zeroize", +] + [[package]] name = "ego-tree" version = "0.10.0" @@ -1703,6 +1820,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -1831,6 +1957,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1842,6 +1974,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1862,6 +2004,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -2711,6 +2854,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + [[package]] name = "indoc" version = "2.0.7" @@ -3114,6 +3270,7 @@ dependencies = [ "rmcp", "rust-embed", "scraper", + "self_update", "serde", "serde_json", "serde_yaml", @@ -3942,6 +4099,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -3956,7 +4123,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.39.4", "serde", "time", ] @@ -4114,6 +4281,15 @@ dependencies = [ "prost", ] +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.39.4" @@ -4431,8 +4607,10 @@ checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", "futures-util", + "h2 0.4.14", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -4657,6 +4835,7 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki 0.103.13", "subtle", @@ -4910,6 +5089,43 @@ dependencies = [ "smallvec", ] +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + +[[package]] +name = "self_update" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e79722b5a505d4ddc77527455a97244e9e8c4c07533ff44cf4421cce7bb6d17" +dependencies = [ + "either", + "flate2", + "http 1.4.0", + "indicatif", + "log", + "quick-xml 0.38.4", + "regex", + "reqwest 0.13.3", + "self-replace", + "semver", + "serde", + "serde_json", + "tar", + "tempfile", + "ureq", + "urlencoding", + "zip", + "zipsign-api", +] + [[package]] name = "semver" version = "1.0.28" @@ -5170,6 +5386,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest", "rand_core 0.6.4", ] @@ -5251,6 +5468,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sse-stream" version = "0.2.3" @@ -5440,6 +5678,17 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -6031,6 +6280,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "universal-hash" version = "0.5.1" @@ -6068,6 +6323,40 @@ version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "cookie_store", + "encoding_rs", + "flate2", + "log", + "percent-encoding", + "rustls 0.23.40", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http 1.4.0", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -6092,6 +6381,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6387,7 +6682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.39.4", "quote", ] @@ -6429,6 +6724,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "8.0.2" @@ -6951,6 +7255,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xml5ever" version = "0.18.1" @@ -7097,8 +7411,52 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "time", + "zopfli", +] + +[[package]] +name = "zipsign-api" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dba6063ff82cdbd9a765add16d369abe81e520f836054e997c2db217ceca40c0" +dependencies = [ + "base64", + "ed25519-dalek", + "thiserror 2.0.18", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 46e3c4c..e7f8a5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,14 @@ clap_complete_nushell = "4.5.9" open = "5" rand = { version = "0.10.0", features = ["default"] } url = "2.5.8" +self_update = { version = "0.44", default-features = false, features = [ + "reqwest", + "rustls", + "archive-tar", + "compression-flate2", + "archive-zip", + "compression-zip-deflate", +] } [dependencies.reqwest] version = "0.13.3" diff --git a/README.md b/README.md index d7d1db0..4250c12 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,29 @@ To use a binary from the releases page on Linux/MacOS, do the following: 3. Extract the binary with `tar -C /usr/local/bin -xzf loki-.tar.gz` (Note: This may require `sudo`) 4. Now you can run `loki`! +## Updating +Loki can update itself in place to the latest GitHub release. Run `loki --update` +for the newest release, or `loki --update v0.4.0` for a specific version: + +```shell +loki --update +loki --update v0.4.0 +``` + +The same is available from within the REPL via `.update` and `.update v0.4.0`. + +If Loki was installed with a package manager, prefer that package manager so its +records stay in sync with the binary on disk; i.e. `brew upgrade loki` for Homebrew, +or `cargo install --locked loki-ai` for Cargo. + +When Loki detects a package-manager install it prints a warning and asks for +confirmation. In a non-interactive shell (no TTY), pass `--force` to update +anyway: + +```shell +loki --update --force +``` + ## Getting Started After installation, you can generate the configuration files and directories by simply running: diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f6aa29b..31f46c7 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -137,6 +137,12 @@ pub struct Cli { /// Generate static shell completion scripts #[arg(long, value_name = "SHELL", value_enum)] pub completions: Option, + /// Update Loki to the latest release, or to a specific version + #[arg(long, value_name = "VERSION")] + pub update: Option>, + /// With --update, update even if Loki was installed via a package manager + #[arg(long, requires = "update")] + pub force: bool, } impl Cli { @@ -396,4 +402,28 @@ mod tests { let cli = parse(&["--macro", "my-macro"]); assert_eq!(cli.macro_name, Some("my-macro".to_string())); } + + #[test] + fn parse_update_flag_no_value() { + let cli = parse(&["--update"]); + assert_eq!(cli.update, Some(None)); + } + + #[test] + fn parse_update_flag_with_version() { + let cli = parse(&["--update", "v0.4.0"]); + assert_eq!(cli.update, Some(Some("v0.4.0".to_string()))); + } + + #[test] + fn parse_update_with_force() { + let cli = parse(&["--update", "--force"]); + assert_eq!(cli.update, Some(None)); + assert!(cli.force); + } + + #[test] + fn parse_force_without_update_fails() { + assert!(Cli::try_parse_from(["loki", "--force"]).is_err()); + } } diff --git a/src/client/stream.rs b/src/client/stream.rs index 41ba911..3d6f1f8 100644 --- a/src/client/stream.rs +++ b/src/client/stream.rs @@ -4,7 +4,7 @@ use crate::utils::AbortSignal; use anyhow::{Context, Result, anyhow, bail}; use eventsource_stream::Eventsource; use futures_util::{Stream, StreamExt}; -use reqwest::{header, RequestBuilder}; +use reqwest::{RequestBuilder, header}; use serde_json::Value; use tokio::sync::mpsc::UnboundedSender; diff --git a/src/config/mod.rs b/src/config/mod.rs index 0474c62..a004e6a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -12,6 +12,7 @@ mod role; mod session; pub(crate) mod todo; mod tool_scope; +mod update; pub use self::agent::{Agent, AgentVariables, complete_agent_variables, list_agents}; #[allow(unused_imports)] @@ -25,6 +26,7 @@ pub use self::role::{ CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE, }; use self::session::Session; +pub use self::update::run_self_update; use crate::client::{ ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS, ProviderModels, create_client_config, list_client_types, diff --git a/src/config/update.rs b/src/config/update.rs new file mode 100644 index 0000000..f098026 --- /dev/null +++ b/src/config/update.rs @@ -0,0 +1,248 @@ +use std::fs::OpenOptions; +use anyhow::{Context, Result, bail}; +use inquire::Confirm; +use is_terminal::IsTerminal; +use std::path::Path; +use std::{env, fs, io, process}; +use dunce::canonicalize; +use self_update::backends::github::Update; +use self_update::Status; +use crate::utils::warning_text; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InstallSource { + Cargo, + Homebrew, + Manual, +} + +impl InstallSource { + fn is_package_managed(self) -> bool { + matches!(self, InstallSource::Cargo | InstallSource::Homebrew) + } + + fn label(self) -> &'static str { + match self { + InstallSource::Cargo => "Cargo", + InstallSource::Homebrew => "Homebrew", + InstallSource::Manual => "manually-installed", + } + } +} + +fn classify_install_path(path: &Path) -> InstallSource { + let components: Vec<&str> = path + .components() + .filter_map(|c| c.as_os_str().to_str()) + .collect(); + + if components + .windows(2) + .any(|w| w[0] == ".cargo" && w[1] == "bin") + { + return InstallSource::Cargo; + } + + if components.contains(&"Cellar") { + return InstallSource::Homebrew; + } + let path_str = path.to_string_lossy(); + if path_str.starts_with("/opt/homebrew/") || path_str.starts_with("/home/linuxbrew/.linuxbrew/") + { + return InstallSource::Homebrew; + } + + InstallSource::Manual +} + +fn normalize_version(requested: Option) -> Option { + let raw = requested?; + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("latest") { + return None; + } + match trimmed.chars().next() { + Some('v' | 'V') => Some(trimmed.to_string()), + Some(c) if c.is_ascii_digit() => Some(format!("v{trimmed}")), + _ => Some(trimmed.to_string()), + } +} + +fn is_dir_writable(dir: &Path) -> bool { + let probe = dir.join(format!(".loki-update-write-test-{}", process::id())); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe) + { + Ok(_) => { + let _ = fs::remove_file(&probe); + true + } + Err(_) => false, + } +} + +pub fn run_self_update(requested: Option, force: bool) -> Result<()> { + let target_tag = normalize_version(requested); + + let exe_path = env::current_exe() + .context("Could not determine the path of the running loki executable")?; + let resolved = canonicalize(&exe_path).unwrap_or_else(|_| exe_path.clone()); + let source = classify_install_path(&resolved); + + if source.is_package_managed() { + let body = match source { + InstallSource::Homebrew => format!( + "Loki appears to be installed via Homebrew ({}).\n\ + Updating in place replaces the binary inside Homebrew's Cellar; `brew` will\n\ + then report a version that no longer matches the file on disk, and a later\n\ + `brew upgrade`/`brew reinstall` may overwrite it or fail.\n\ + The clean way to update is: brew upgrade loki", + exe_path.display() + ), + InstallSource::Cargo => format!( + "Loki appears to be installed via `cargo install` ({}).\n\ + Updating in place leaves Cargo's records out of sync with the binary on disk.\n\ + The clean way to update is: cargo install --locked loki-ai", + exe_path.display() + ), + InstallSource::Manual => unreachable!("Manual installs are not package-managed"), + }; + println!("{} {body}", warning_text("WARNING:")); + + if force { + println!("--force specified; updating anyway."); + } else if io::stdin().is_terminal() { + let proceed = Confirm::new("Update anyway?") + .with_default(false) + .prompt()?; + if !proceed { + println!("Update cancelled."); + return Ok(()); + } + } else { + bail!( + "Refusing to update a {} install. Re-run with --force to override.", + source.label() + ); + } + } + + if let Some(parent) = exe_path.parent() + && !is_dir_writable(parent) + { + bail!( + "No write permission for '{}'. Re-run with elevated permissions (e.g. sudo), \ + or update Loki through your package manager.", + parent.display() + ); + } + + let interactive = io::stdin().is_terminal(); + let mut builder = Update::configure(); + builder + .repo_owner("Dark-Alex-17") + .repo_name("loki") + .bin_name("loki") + .current_version(env!("CARGO_PKG_VERSION")) + .no_confirm(true) + .show_download_progress(interactive); + if let Some(tag) = &target_tag { + builder.target_version_tag(tag.as_str()); + } + let status = builder + .build() + .context("Failed to configure the self-update")? + .update() + .context("Self-update failed")?; + + match status { + Status::UpToDate(version) => { + println!("Loki is already up to date (v{version})."); + } + Status::Updated(version) => { + println!("Loki updated to v{version}. Restart loki to use the new version."); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn classify_cargo_install() { + assert_eq!( + classify_install_path(&PathBuf::from("/home/u/.cargo/bin/loki")), + InstallSource::Cargo + ); + } + + #[test] + fn classify_homebrew_opt_prefix() { + assert_eq!( + classify_install_path(&PathBuf::from("/opt/homebrew/bin/loki")), + InstallSource::Homebrew + ); + } + + #[test] + fn classify_homebrew_cellar() { + assert_eq!( + classify_install_path(&PathBuf::from("/usr/local/Cellar/loki/0.3.0/bin/loki")), + InstallSource::Homebrew + ); + } + + #[test] + fn classify_homebrew_linuxbrew() { + assert_eq!( + classify_install_path(&PathBuf::from("/home/linuxbrew/.linuxbrew/bin/loki")), + InstallSource::Homebrew + ); + } + + #[test] + fn classify_manual_usr_local_bin() { + assert_eq!( + classify_install_path(&PathBuf::from("/usr/local/bin/loki")), + InstallSource::Manual + ); + } + + #[test] + fn classify_manual_local_bin() { + assert_eq!( + classify_install_path(&PathBuf::from("/home/u/.local/bin/loki")), + InstallSource::Manual + ); + } + + #[test] + fn normalize_version_latest_and_empty_are_none() { + assert_eq!(normalize_version(None), None); + assert_eq!(normalize_version(Some(String::new())), None); + assert_eq!(normalize_version(Some(" ".to_string())), None); + assert_eq!(normalize_version(Some("latest".to_string())), None); + assert_eq!(normalize_version(Some("LATEST".to_string())), None); + } + + #[test] + fn normalize_version_prepends_v_for_bare_semver() { + assert_eq!( + normalize_version(Some("0.4.0".to_string())), + Some("v0.4.0".to_string()) + ); + assert_eq!( + normalize_version(Some("v0.4.0".to_string())), + Some("v0.4.0".to_string()) + ); + assert_eq!( + normalize_version(Some(" v0.4.0 ".to_string())), + Some("v0.4.0".to_string()) + ); + } +} diff --git a/src/main.rs b/src/main.rs index d6c84c7..1206371 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,6 +83,13 @@ async fn main() -> Result<()> { let log_path = setup_logger()?; + if let Some(version) = &cli.update { + let version = version.clone(); + let force = cli.force; + return tokio::task::spawn_blocking(move || config::run_self_update(version, force)) + .await?; + } + install_builtins()?; if let Some(client_arg) = &cli.authenticate { diff --git a/src/mcp/sse_transport.rs b/src/mcp/sse_transport.rs index db683bb..c5c7af5 100644 --- a/src/mcp/sse_transport.rs +++ b/src/mcp/sse_transport.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result, anyhow}; use eventsource_stream::{EventStream, Eventsource}; use fmt::{Display, Formatter}; use futures_util::StreamExt; +use futures_util::stream::BoxStream; use mpsc::error::SendError; use mpsc::{OwnedPermit, Receiver, Sender, channel}; use reqwest::Client; @@ -13,13 +14,11 @@ use std::fmt; use std::future::Future; use std::pin::Pin; use std::task::Poll; -use futures_util::stream::BoxStream; use tokio::sync::mpsc; use tokio::time::Duration; use url::Url; -type SseEventStream = - EventStream>>; +type SseEventStream = EventStream>>; const CHANNEL_BUF: usize = 64; diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 35ad815..eb563be 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -33,6 +33,7 @@ use reedline::{ use reedline::{MenuBuilder, Signal}; use std::sync::LazyLock; use std::{env, process, sync::Arc}; +use tokio::task; const MENU_NAME: &str = "completion_menu"; @@ -45,7 +46,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {" 4. Continue with the next pending item now. Call tools immediately." }; -static REPL_COMMANDS: LazyLock<[ReplCommand; 41]> = LazyLock::new(|| { +static REPL_COMMANDS: LazyLock<[ReplCommand; 42]> = LazyLock::new(|| { [ ReplCommand::new(".help", "Show this help guide", AssertState::pass()), ReplCommand::new(".info", "Show system info", AssertState::pass()), @@ -222,6 +223,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 41]> = LazyLock::new(|| { "Reinstall bundled agents, macros, functions, or MCP config (overwrites local changes)", AssertState::pass(), ), + ReplCommand::new( + ".update", + "Update Loki to the latest release (or a specified version)", + AssertState::pass(), + ), ReplCommand::new(".exit", "Exit REPL", AssertState::pass()), ] }); @@ -542,6 +548,14 @@ pub async fn run_repl_command( }, _ => println!("Usage: .install <{}>", AssetCategory::NAMES.join("|")), }, + ".update" => { + if ctx.macro_flag { + bail!("Cannot perform this operation because you are in a macro") + } + let version = args.map(|s| s.trim().to_string()); + task::spawn_blocking(move || config::run_self_update(version, false)) + .await??; + } ".rag" => { ctx.use_rag(args, abort_signal.clone()).await?; } @@ -1241,8 +1255,8 @@ mod tests { } #[test] - fn repl_commands_has_41_entries() { - assert_eq!(REPL_COMMANDS.len(), 41); + fn repl_commands_has_42_entries() { + assert_eq!(REPL_COMMANDS.len(), 42); } #[test]