1use std::path::{Path, PathBuf};
47use std::pin::Pin;
48use std::time::Duration;
49
50use futures::Stream;
51use objectiveai_sdk::cli::command::update::{Request, ResponseItem, ResponseSkipReason};
52
53use crate::context::Context;
54use crate::error::Error;
55
56type ItemStream = Pin<Box<dyn Stream<Item = Result<ResponseItem, Error>> + Send>>;
57
58const RELEASES_API: &str =
59 "https://api.github.com/repos/ObjectiveAI/objectiveai/releases/latest";
60const METADATA_TIMEOUT: Duration = Duration::from_secs(10);
61const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600);
65
66const WIPE_KEEP: &[&str] = &["plugins", "tools", "config.json"];
69
70pub async fn execute(ctx: &Context, _request: Request) -> Result<ItemStream, Error> {
71 let (tx, rx) = tokio::sync::mpsc::channel::<Result<ResponseItem, Error>>(8);
72 let bin_dir = ctx.filesystem.bin_dir();
73 let states_root = ctx.filesystem.dir().join("state");
75 let github_authorization = ctx
78 .filesystem
79 .read_config_view(objectiveai_sdk::cli::command::GetScope::Final)
80 .await?
81 .api()
82 .get_github_authorization()
83 .map(String::from);
84
85 tokio::spawn(async move {
86 if let Err(e) = run(
87 &bin_dir,
88 &states_root,
89 github_authorization.as_deref(),
90 &tx,
91 )
92 .await
93 {
94 let _ = tx.send(Err(e)).await;
95 }
96 });
97
98 Ok(Box::pin(futures::stream::unfold(rx, |mut rx| async move {
99 rx.recv().await.map(|item| (item, rx))
100 })))
101}
102
103async fn run(
104 bin_dir: &Path,
105 states_root: &Path,
106 github_authorization: Option<&str>,
107 tx: &tokio::sync::mpsc::Sender<Result<ResponseItem, Error>>,
108) -> Result<(), Error> {
109 let current_exe = std::env::current_exe()
112 .map_err(|e| Error::Updater(format!("could not locate current binary: {e}")))?;
113 if looks_like_dev_tree(¤t_exe) {
114 let _ = tx
115 .send(Ok(ResponseItem::Skipped {
116 reason: ResponseSkipReason::DevTree,
117 }))
118 .await;
119 return Ok(());
120 }
121
122 let Some((os, arch, ext)) = platform_triple() else {
123 let _ = tx
124 .send(Ok(ResponseItem::Skipped {
125 reason: ResponseSkipReason::UnsupportedPlatform,
126 }))
127 .await;
128 return Ok(());
129 };
130
131 sweep_stale_old(¤t_exe);
134
135 let local = env!("CARGO_PKG_VERSION");
136 let local_ver = semver::Version::parse(local)
137 .map_err(|e| Error::Updater(format!("semver parse: {e}")))?;
138
139 let http = reqwest::Client::new();
141 let auth = github_authorization_header(github_authorization);
142
143 let release: Release = {
144 let mut req = http
145 .get(RELEASES_API)
146 .header("User-Agent", format!("objectiveai/{local}"))
147 .header("Accept", "application/vnd.github+json")
148 .timeout(METADATA_TIMEOUT);
149 if let Some(ref h) = auth {
150 req = req.header("Authorization", h);
151 }
152 let resp = req
153 .send()
154 .await
155 .map_err(|e| Error::Updater(format!("http: {e}")))?;
156 let status = resp.status();
157 if !status.is_success() {
158 return Err(Error::Updater(format!("github returned status {status}")));
159 }
160 let body = resp
161 .bytes()
162 .await
163 .map_err(|e| Error::Updater(format!("http: {e}")))?;
164 serde_json::from_slice(&body)
165 .map_err(|e| Error::Updater(format!("malformed release metadata: {e}")))?
166 };
167
168 let remote_str = release
172 .tag_name
173 .strip_prefix('v')
174 .unwrap_or(&release.tag_name);
175 let remote = semver::Version::parse(remote_str)
176 .map_err(|e| Error::Updater(format!("semver parse: {e}")))?;
177
178 let asset_name = format!("objectiveai-{remote_str}-{os}-{arch}.zip");
181
182 let _ = tx
183 .send(Ok(ResponseItem::Checking {
184 asset_name: asset_name.clone(),
185 current_version: local.to_string(),
186 }))
187 .await;
188
189 let Some(asset) = release.assets.iter().find(|a| a.name == asset_name) else {
191 let _ = tx
192 .send(Ok(ResponseItem::Skipped {
193 reason: ResponseSkipReason::IncompleteRelease,
194 }))
195 .await;
196 return Ok(());
197 };
198
199 if remote <= local_ver {
200 let _ = tx
201 .send(Ok(ResponseItem::UpToDate {
202 current_version: local_ver.to_string(),
203 remote_version: remote.to_string(),
204 }))
205 .await;
206 return Ok(());
207 }
208
209 let _ = tx
210 .send(Ok(ResponseItem::Found {
211 current_version: local_ver.to_string(),
212 remote_version: remote.to_string(),
213 asset_name: asset_name.clone(),
214 url: asset.browser_download_url.clone(),
215 }))
216 .await;
217
218 let zip_path =
221 std::env::temp_dir().join(format!("objectiveai-update-{}.zip", std::process::id()));
222 if let Err(e) =
223 download_to(&http, &asset.browser_download_url, auth.as_deref(), &zip_path, local).await
224 {
225 let _ = std::fs::remove_file(&zip_path);
226 return Err(e);
227 }
228
229 let _ = kill_lock_owners(bin_dir.join("locks"), "api").await;
233 kill_state_servers(states_root).await;
234
235 rename_running_cli_aside(¤t_exe);
239
240 wipe_bin_except(bin_dir, WIPE_KEEP);
242 std::fs::create_dir_all(bin_dir)
243 .map_err(|e| Error::Updater(format!("create bin dir: {e}")))?;
244
245 let unzip_result = unzip_into(&zip_path, bin_dir);
246 let _ = std::fs::remove_file(&zip_path);
247 unzip_result?;
248
249 sweep_stale_old(¤t_exe);
250
251 let _ = tx
252 .send(Ok(ResponseItem::Installed {
253 current_version: local_ver.to_string(),
254 remote_version: remote.to_string(),
255 }))
256 .await;
257
258 Ok(())
259}
260
261#[derive(serde::Deserialize)]
262struct Release {
263 tag_name: String,
264 assets: Vec<Asset>,
265}
266
267#[derive(serde::Deserialize)]
268struct Asset {
269 name: String,
270 browser_download_url: String,
271}
272
273fn platform_triple() -> Option<(&'static str, &'static str, &'static str)> {
274 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
275 {
276 Some(("linux", "x86_64", ""))
277 }
278 #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
279 {
280 Some(("linux", "aarch64", ""))
281 }
282 #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
283 {
284 Some(("macos", "x86_64", ""))
285 }
286 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
287 {
288 Some(("macos", "aarch64", ""))
289 }
290 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
291 {
292 Some(("windows", "x86_64", ".exe"))
293 }
294 #[cfg(all(target_os = "windows", target_arch = "aarch64"))]
295 {
296 Some(("windows", "aarch64", ".exe"))
297 }
298 #[cfg(not(any(
299 all(target_os = "linux", target_arch = "x86_64"),
300 all(target_os = "linux", target_arch = "aarch64"),
301 all(target_os = "macos", target_arch = "x86_64"),
302 all(target_os = "macos", target_arch = "aarch64"),
303 all(target_os = "windows", target_arch = "x86_64"),
304 all(target_os = "windows", target_arch = "aarch64"),
305 )))]
306 {
307 None
308 }
309}
310
311fn looks_like_dev_tree(current_exe: &Path) -> bool {
312 current_exe.components().any(|c| {
313 let s = c.as_os_str();
314 s == "target"
315 || s == "target-objectiveai-mcp-laboratory"
316 || s == "target-objectiveai-mcp-proxy"
317 || s == "target-objectiveai-viewer"
318 })
319}
320
321use crate::command::kill_helpers::kill_lock_owners;
323
324async fn kill_state_servers(states_root: &Path) {
328 let mut rd = match tokio::fs::read_dir(states_root).await {
329 Ok(rd) => rd,
330 Err(_) => return,
332 };
333 while let Ok(Some(entry)) = rd.next_entry().await {
334 if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) {
335 let locks = entry.path().join("locks");
336 let _ = kill_lock_owners(locks.clone(), "db").await;
337 let _ = kill_lock_owners(locks, "viewer").await;
338 }
339 }
340}
341
342#[cfg(windows)]
343fn rename_running_cli_aside(current_exe: &Path) {
344 let old = current_exe.with_extension("exe.old");
347 let _ = std::fs::remove_file(&old);
348 let _ = std::fs::rename(current_exe, &old);
349}
350
351#[cfg(not(windows))]
352fn rename_running_cli_aside(_current_exe: &Path) {
353 }
356
357fn wipe_bin_except(bin_dir: &Path, keep: &[&str]) {
361 let rd = match std::fs::read_dir(bin_dir) {
362 Ok(rd) => rd,
363 Err(_) => return,
364 };
365 for entry in rd.flatten() {
366 let name = entry.file_name();
367 if keep.iter().any(|k| std::ffi::OsStr::new(k) == name) {
368 continue;
369 }
370 let path = entry.path();
371 if path.is_dir() {
372 let _ = std::fs::remove_dir_all(&path);
373 } else {
374 let _ = std::fs::remove_file(&path);
375 }
376 }
377}
378
379fn unzip_into(zip_path: &Path, bin_dir: &Path) -> Result<(), Error> {
385 let file = std::fs::File::open(zip_path)
386 .map_err(|e| Error::Updater(format!("open zip: {e}")))?;
387 let mut archive =
388 zip::ZipArchive::new(file).map_err(|e| Error::Updater(format!("read zip: {e}")))?;
389 let pid = std::process::id();
390
391 for i in 0..archive.len() {
392 let mut entry = archive
393 .by_index(i)
394 .map_err(|e| Error::Updater(format!("read zip entry: {e}")))?;
395 if !entry.is_file() {
396 continue;
397 }
398 let Some(rel) = entry.enclosed_name() else {
399 continue;
400 };
401 let Some(file_name) = rel.file_name() else {
402 continue;
403 };
404 let dst = bin_dir.join(file_name);
405 let staged = staged_path(&dst, pid);
406
407 {
408 let mut out = std::fs::File::create(&staged)
409 .map_err(|e| Error::Updater(format!("unzip: {e}")))?;
410 std::io::copy(&mut entry, &mut out)
411 .map_err(|e| Error::Updater(format!("unzip: {e}")))?;
412 }
413
414 #[cfg(unix)]
415 {
416 use std::os::unix::fs::PermissionsExt;
417 std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
418 .map_err(|e| Error::Updater(format!("unzip chmod: {e}")))?;
419 }
420
421 if let Err(e) = self_replace(&dst, &staged) {
422 let _ = std::fs::remove_file(&staged);
423 return Err(e);
424 }
425 }
426
427 Ok(())
428}
429
430fn staged_path(target: &Path, pid: u32) -> PathBuf {
431 let mut p = target.to_path_buf();
432 let filename = p
433 .file_name()
434 .map(|s| s.to_string_lossy().into_owned())
435 .unwrap_or_else(|| "objectiveai".to_string());
436 p.set_file_name(format!("{filename}.new.{pid}"));
437 p
438}
439
440fn github_authorization_header(caller: Option<&str>) -> Option<String> {
441 caller.map(str::trim).filter(|s| !s.is_empty()).map(|s| {
442 let bare = s.strip_prefix("Bearer ").unwrap_or(s);
443 format!("Bearer {bare}")
444 })
445}
446
447async fn download_to(
448 client: &reqwest::Client,
449 url: &str,
450 auth: Option<&str>,
451 dst: &Path,
452 version: &str,
453) -> Result<(), Error> {
454 use futures::StreamExt as _;
455 use tokio::io::AsyncWriteExt as _;
456
457 let mut req = client
458 .get(url)
459 .header("User-Agent", format!("objectiveai/{version}"))
460 .timeout(DOWNLOAD_TIMEOUT);
461 if let Some(h) = auth {
462 req = req.header("Authorization", h);
463 }
464 let resp = req
465 .send()
466 .await
467 .map_err(|e| Error::Updater(format!("http: {e}")))?;
468 let status = resp.status();
469 if !status.is_success() {
470 return Err(Error::Updater(format!("github returned status {status}")));
471 }
472
473 let mut file = tokio::fs::File::create(dst)
474 .await
475 .map_err(|e| Error::Updater(format!("download: {e}")))?;
476 let mut stream = resp.bytes_stream();
477 while let Some(chunk) = stream.next().await {
478 let chunk = chunk.map_err(|e| Error::Updater(format!("http: {e}")))?;
479 file.write_all(&chunk)
480 .await
481 .map_err(|e| Error::Updater(format!("download: {e}")))?;
482 }
483 file.flush()
484 .await
485 .map_err(|e| Error::Updater(format!("download: {e}")))?;
486 Ok(())
487}
488
489#[cfg(unix)]
499fn self_replace(current: &Path, new: &Path) -> Result<(), Error> {
500 std::fs::rename(new, current).map_err(|e| Error::Updater(format!("swap: {e}")))
501}
502
503#[cfg(windows)]
504fn self_replace(current: &Path, new: &Path) -> Result<(), Error> {
505 let old = current.with_extension("exe.old");
506 let _ = std::fs::remove_file(&old);
507 if current.exists() {
508 std::fs::rename(current, &old).map_err(|e| Error::Updater(format!("swap: {e}")))?;
509 }
510 std::fs::rename(new, current).map_err(|e| {
511 let _ = std::fs::rename(&old, current);
514 Error::Updater(format!("swap: {e}"))
515 })
516}
517
518#[cfg(not(any(unix, windows)))]
519fn self_replace(_current: &Path, _new: &Path) -> Result<(), Error> {
520 Err(Error::Updater(
521 "self-replace not implemented on this platform".to_string(),
522 ))
523}
524
525fn sweep_stale_old(current: &Path) {
526 #[cfg(windows)]
527 {
528 let old = current.with_extension("exe.old");
529 let _ = std::fs::remove_file(old);
530 }
531 #[cfg(not(windows))]
532 {
533 let _ = current;
534 }
535}
536
537pub mod request_schema {
538 use objectiveai_sdk::cli::command::update as sdk;
539 use objectiveai_sdk::cli::command::update::request_schema::{Request, Response};
540
541 use crate::context::Context;
542 use crate::error::Error;
543
544 pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
545 Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Request)))
546 }
547}
548
549pub mod response_schema {
550 use objectiveai_sdk::cli::command::update as sdk;
551 use objectiveai_sdk::cli::command::update::response_schema::{Request, Response};
552
553 use crate::context::Context;
554 use crate::error::Error;
555
556 pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
557 Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Response)))
558 }
559}