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