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