1use std::path::{Path, PathBuf};
30use std::pin::Pin;
31use std::time::Duration;
32
33use futures::Stream;
34use objectiveai_sdk::cli::command::update::{Request, ResponseItem, ResponseSkipReason};
35
36use crate::context::Context;
37use crate::error::Error;
38
39type ItemStream = Pin<Box<dyn Stream<Item = Result<ResponseItem, Error>> + Send>>;
40
41const RELEASES_API: &str =
42 "https://api.github.com/repos/ObjectiveAI/objectiveai/releases/latest";
43const METADATA_TIMEOUT: Duration = Duration::from_secs(10);
44const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300);
48
49const PACKAGES: &[&str] = &["api", "viewer", "mcp", "db", "cli"];
53
54pub async fn execute(ctx: &Context, _request: Request) -> Result<ItemStream, Error> {
55 let (tx, rx) = tokio::sync::mpsc::channel::<Result<ResponseItem, Error>>(8);
56 let bin_dir = ctx.filesystem.bin_dir();
57 let github_authorization = ctx
60 .filesystem
61 .read_config_view(objectiveai_sdk::cli::command::GetScope::Final)
62 .await?
63 .api()
64 .get_github_authorization()
65 .map(String::from);
66
67 tokio::spawn(async move {
68 if let Err(e) = run(&bin_dir, github_authorization.as_deref(), &tx).await {
69 let _ = tx.send(Err(e)).await;
70 }
71 });
72
73 Ok(Box::pin(futures::stream::unfold(rx, |mut rx| async move {
74 rx.recv().await.map(|item| (item, rx))
75 })))
76}
77
78async fn run(
79 bin_dir: &Path,
80 github_authorization: Option<&str>,
81 tx: &tokio::sync::mpsc::Sender<Result<ResponseItem, Error>>,
82) -> Result<(), Error> {
83 let current_exe = std::env::current_exe()
86 .map_err(|e| Error::Updater(format!("could not locate current binary: {e}")))?;
87 if looks_like_dev_tree(¤t_exe) {
88 let _ = tx
89 .send(Ok(ResponseItem::Skipped {
90 reason: ResponseSkipReason::DevTree,
91 }))
92 .await;
93 return Ok(());
94 }
95
96 let Some((os, arch, ext)) = platform_triple() else {
97 let _ = tx
98 .send(Ok(ResponseItem::Skipped {
99 reason: ResponseSkipReason::UnsupportedPlatform,
100 }))
101 .await;
102 return Ok(());
103 };
104
105 sweep_stale_old(¤t_exe);
108
109 let expected: Vec<(&'static str, String)> = PACKAGES
113 .iter()
114 .map(|&pkg| {
115 let name = if pkg == "cli" {
116 format!("objectiveai-{os}-{arch}{ext}")
117 } else {
118 format!("objectiveai-{os}-{arch}-{pkg}{ext}")
119 };
120 (pkg, name)
121 })
122 .collect();
123
124 let local = env!("CARGO_PKG_VERSION");
125 let local_ver = semver::Version::parse(local)
126 .map_err(|e| Error::Updater(format!("semver parse: {e}")))?;
127
128 let _ = tx
129 .send(Ok(ResponseItem::Checking {
130 asset_name: format!("objectiveai-{os}-{arch}{ext}"),
131 current_version: local.to_string(),
132 }))
133 .await;
134
135 let http = reqwest::Client::new();
137 let auth = github_authorization_header(github_authorization);
138
139 let release: Release = {
140 let mut req = http
141 .get(RELEASES_API)
142 .header("User-Agent", format!("objectiveai/{local}"))
143 .header("Accept", "application/vnd.github+json")
144 .timeout(METADATA_TIMEOUT);
145 if let Some(ref h) = auth {
146 req = req.header("Authorization", h);
147 }
148 let resp = req
149 .send()
150 .await
151 .map_err(|e| Error::Updater(format!("http: {e}")))?;
152 let status = resp.status();
153 if !status.is_success() {
154 return Err(Error::Updater(format!("github returned status {status}")));
155 }
156 let body = resp
157 .bytes()
158 .await
159 .map_err(|e| Error::Updater(format!("http: {e}")))?;
160 serde_json::from_slice(&body)
161 .map_err(|e| Error::Updater(format!("malformed release metadata: {e}")))?
162 };
163
164 let assets_map: std::collections::HashMap<&str, &Asset> = release
166 .assets
167 .iter()
168 .map(|a| (a.name.as_str(), a))
169 .collect();
170
171 for (_, name) in &expected {
172 if !assets_map.contains_key(name.as_str()) {
173 let _ = tx
174 .send(Ok(ResponseItem::Skipped {
175 reason: ResponseSkipReason::IncompleteRelease,
176 }))
177 .await;
178 return Ok(());
179 }
180 }
181
182 let remote_str = release
184 .tag_name
185 .strip_prefix('v')
186 .unwrap_or(&release.tag_name);
187 let remote = semver::Version::parse(remote_str)
188 .map_err(|e| Error::Updater(format!("semver parse: {e}")))?;
189 if remote <= local_ver {
190 let _ = tx
191 .send(Ok(ResponseItem::UpToDate {
192 current_version: local_ver.to_string(),
193 remote_version: remote.to_string(),
194 }))
195 .await;
196 return Ok(());
197 }
198
199 std::fs::create_dir_all(bin_dir)
200 .map_err(|e| Error::Updater(format!("create bin dir: {e}")))?;
201
202 let targets: Vec<(&'static str, String, PathBuf)> = expected
203 .iter()
204 .map(|(pkg, name)| {
205 let path = if *pkg == "cli" {
206 bin_dir.join(format!("objectiveai{ext}"))
207 } else {
208 bin_dir.join(format!("objectiveai-{pkg}{ext}"))
209 };
210 (*pkg, name.clone(), path)
211 })
212 .collect();
213
214 let pid = std::process::id();
216 let mut staged: Vec<(&'static str, PathBuf, PathBuf)> = Vec::new();
217
218 for (pkg, name, target) in &targets {
219 let asset = assets_map
220 .get(name.as_str())
221 .expect("incomplete-release check above guarantees presence");
222 let stage = staged_path(target, pid);
223
224 let _ = tx
225 .send(Ok(ResponseItem::Found {
226 current_version: local_ver.to_string(),
227 remote_version: remote.to_string(),
228 asset_name: name.clone(),
229 url: asset.browser_download_url.clone(),
230 }))
231 .await;
232
233 if let Err(e) = download_to(
234 &http,
235 &asset.browser_download_url,
236 auth.as_deref(),
237 &stage,
238 local,
239 )
240 .await
241 {
242 for (_, sp, _) in &staged {
244 let _ = std::fs::remove_file(sp);
245 }
246 let _ = std::fs::remove_file(&stage);
247 return Err(e);
248 }
249
250 #[cfg(unix)]
251 {
252 use std::os::unix::fs::PermissionsExt;
253 std::fs::set_permissions(&stage, std::fs::Permissions::from_mode(0o755))
254 .map_err(|e| Error::Updater(format!("swap: {e}")))?;
255 }
256
257 staged.push((pkg, stage, target.clone()));
258 }
259
260 for (pkg, stage, target) in &staged {
265 match self_replace(target, stage) {
266 Ok(()) => {
267 sweep_stale_old(target);
268 let _ = tx
269 .send(Ok(ResponseItem::Installed {
270 current_version: local_ver.to_string(),
271 remote_version: remote.to_string(),
272 }))
273 .await;
274 }
275 Err(e) if *pkg == "cli" => return Err(e),
276 Err(e) => {
277 let _ = tx
278 .send(Err(Error::Updater(format!("{pkg}: swap failed: {e}"))))
279 .await;
280 }
281 }
282 }
283
284 Ok(())
285}
286
287#[derive(serde::Deserialize)]
288struct Release {
289 tag_name: String,
290 assets: Vec<Asset>,
291}
292
293#[derive(serde::Deserialize)]
294struct Asset {
295 name: String,
296 browser_download_url: String,
297}
298
299fn platform_triple() -> Option<(&'static str, &'static str, &'static str)> {
300 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
301 {
302 Some(("linux", "x86_64", ""))
303 }
304 #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
305 {
306 Some(("linux", "aarch64", ""))
307 }
308 #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
309 {
310 Some(("macos", "x86_64", ""))
311 }
312 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
313 {
314 Some(("macos", "aarch64", ""))
315 }
316 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
317 {
318 Some(("windows", "x86_64", ".exe"))
319 }
320 #[cfg(not(any(
321 all(target_os = "linux", target_arch = "x86_64"),
322 all(target_os = "linux", target_arch = "aarch64"),
323 all(target_os = "macos", target_arch = "x86_64"),
324 all(target_os = "macos", target_arch = "aarch64"),
325 all(target_os = "windows", target_arch = "x86_64"),
326 )))]
327 {
328 None
329 }
330}
331
332fn looks_like_dev_tree(current_exe: &Path) -> bool {
333 current_exe.components().any(|c| {
334 let s = c.as_os_str();
335 s == "target"
336 || s == "target-objectiveai-mcp-filesystem"
337 || s == "target-objectiveai-mcp-proxy"
338 || s == "target-objectiveai-viewer"
339 })
340}
341
342fn staged_path(target: &Path, pid: u32) -> PathBuf {
343 let mut p = target.to_path_buf();
344 let filename = p
345 .file_name()
346 .map(|s| s.to_string_lossy().into_owned())
347 .unwrap_or_else(|| "objectiveai".to_string());
348 p.set_file_name(format!("{filename}.new.{pid}"));
349 p
350}
351
352fn github_authorization_header(caller: Option<&str>) -> Option<String> {
353 caller.map(str::trim).filter(|s| !s.is_empty()).map(|s| {
354 let bare = s.strip_prefix("Bearer ").unwrap_or(s);
355 format!("Bearer {bare}")
356 })
357}
358
359async fn download_to(
360 client: &reqwest::Client,
361 url: &str,
362 auth: Option<&str>,
363 dst: &Path,
364 version: &str,
365) -> Result<(), Error> {
366 use futures::StreamExt as _;
367 use tokio::io::AsyncWriteExt as _;
368
369 let mut req = client
370 .get(url)
371 .header("User-Agent", format!("objectiveai/{version}"))
372 .timeout(DOWNLOAD_TIMEOUT);
373 if let Some(h) = auth {
374 req = req.header("Authorization", h);
375 }
376 let resp = req
377 .send()
378 .await
379 .map_err(|e| Error::Updater(format!("http: {e}")))?;
380 let status = resp.status();
381 if !status.is_success() {
382 return Err(Error::Updater(format!("github returned status {status}")));
383 }
384
385 let mut file = tokio::fs::File::create(dst)
386 .await
387 .map_err(|e| Error::Updater(format!("download: {e}")))?;
388 let mut stream = resp.bytes_stream();
389 while let Some(chunk) = stream.next().await {
390 let chunk = chunk.map_err(|e| Error::Updater(format!("http: {e}")))?;
391 file.write_all(&chunk)
392 .await
393 .map_err(|e| Error::Updater(format!("download: {e}")))?;
394 }
395 file.flush()
396 .await
397 .map_err(|e| Error::Updater(format!("download: {e}")))?;
398 Ok(())
399}
400
401#[cfg(unix)]
411fn self_replace(current: &Path, new: &Path) -> Result<(), Error> {
412 std::fs::rename(new, current).map_err(|e| Error::Updater(format!("swap: {e}")))
413}
414
415#[cfg(windows)]
416fn self_replace(current: &Path, new: &Path) -> Result<(), Error> {
417 let old = current.with_extension("exe.old");
418 let _ = std::fs::remove_file(&old);
419 if current.exists() {
420 std::fs::rename(current, &old).map_err(|e| Error::Updater(format!("swap: {e}")))?;
421 }
422 std::fs::rename(new, current).map_err(|e| {
423 let _ = std::fs::rename(&old, current);
426 Error::Updater(format!("swap: {e}"))
427 })
428}
429
430#[cfg(not(any(unix, windows)))]
431fn self_replace(_current: &Path, _new: &Path) -> Result<(), Error> {
432 Err(Error::Updater(
433 "self-replace not implemented on this platform".to_string(),
434 ))
435}
436
437fn sweep_stale_old(current: &Path) {
438 #[cfg(windows)]
439 {
440 let old = current.with_extension("exe.old");
441 let _ = std::fs::remove_file(old);
442 }
443 #[cfg(not(windows))]
444 {
445 let _ = current;
446 }
447}
448
449pub mod request_schema {
450 use objectiveai_sdk::cli::command::update as sdk;
451 use objectiveai_sdk::cli::command::update::request_schema::{Request, Response};
452
453 use crate::context::Context;
454 use crate::error::Error;
455
456 pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
457 Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Request)))
458 }
459}
460
461pub mod response_schema {
462 use objectiveai_sdk::cli::command::update as sdk;
463 use objectiveai_sdk::cli::command::update::response_schema::{Request, Response};
464
465 use crate::context::Context;
466 use crate::error::Error;
467
468 pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
469 Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Response)))
470 }
471}