feat: Loki can now update itself via .update and --update commands
This commit is contained in:
@@ -137,6 +137,12 @@ pub struct Cli {
|
||||
/// Generate static shell completion scripts
|
||||
#[arg(long, value_name = "SHELL", value_enum)]
|
||||
pub completions: Option<ShellCompletion>,
|
||||
/// Update Loki to the latest release, or to a specific version
|
||||
#[arg(long, value_name = "VERSION")]
|
||||
pub update: Option<Option<String>>,
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>) -> Option<String> {
|
||||
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<String>, 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<BoxStream<'static, reqwest::Result<bytes::Bytes>>>;
|
||||
type SseEventStream = EventStream<BoxStream<'static, reqwest::Result<bytes::Bytes>>>;
|
||||
|
||||
const CHANNEL_BUF: usize = 64;
|
||||
|
||||
|
||||
+17
-3
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user