Skip to main content

objectiveai_cli/command/
update.rs

1//! `update` — bare-naked streaming handler.
2//!
3//! Refreshes all five shipped binaries (cli, api, viewer, mcp, db)
4//! from the latest GitHub release, emitting one [`ResponseItem`] per
5//! (asset, stage) pair as the run progresses.
6//!
7//! Ported from `src/updater.rs`. The legacy emitted
8//! `Notification::Updater` envelopes through a `cli::output::Handle`;
9//! this version sends `ResponseItem` events through an
10//! `mpsc::channel` whose receiver is surfaced as the leaf's
11//! streaming return value.
12//!
13//! Layout on disk (resolved via `ctx.filesystem.bin_dir()` — every
14//! binary is machine-wide, shared across states):
15//!
16//! ```text
17//! <bin_dir>/objectiveai{.exe}        ← cli
18//! <bin_dir>/objectiveai-api{.exe}
19//! <bin_dir>/objectiveai-viewer{.exe}
20//! <bin_dir>/objectiveai-mcp{.exe}
21//! <bin_dir>/objectiveai-db{.exe}
22//! ```
23//!
24//! Release-completeness rule: if the latest release is missing any of
25//! the five expected `(os, arch)` assets, the run emits
26//! [`ResponseSkipReason::IncompleteRelease`] and exits without
27//! touching disk. Either all five advance together or none do.
28
29use 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
46/// Swap order: cli last because it's the running binary. The
47/// sub-binaries don't gate each other — a swap failure on one is
48/// non-fatal (we emit a per-binary error event and keep going).
49const 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    // The GitHub credential lives in the on-disk json config only
55    // (`api config github-authorization set`), not the env Config.
56    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    // Dev tree refuses to self-update — overwriting a `cargo run`
81    // output would clobber an in-tree build.
82    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(&current_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    // Best-effort cleanup of any stale `.exe.old` from a prior Windows
103    // swap before we begin.
104    sweep_stale_old(&current_exe);
105
106    // Build the four expected asset names for this (os, arch). The cli
107    // is the bare `objectiveai-<os>-<arch>{ext}`; every other package
108    // appends `-<package>` after the arch.
109    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    // Fetch the latest release metadata.
133    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    // Index assets by name; check all 4 expected names are present.
162    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    // Compare versions. Tag is `v<X.Y.Z>` by repo convention.
180    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    // Download all 4 to staged paths next to their targets.
212    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            // Clean up any prior staged files on failure.
240            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    // Swap each staged binary into place. Order = PACKAGES order =
258    // api/viewer/mcp first, cli last. Per-binary failures on
259    // api/viewer/mcp emit a non-fatal Err event and continue; a cli
260    // failure is fatal (returns Err).
261    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/// Swap the staged binary into place.
399///
400/// **Unix**: `rename(new, current)` works because the running process
401/// holds the binary by inode.
402///
403/// **Windows**: the running exe's path is locked for writes, but
404/// renaming the file to a different name is allowed. Move current
405/// aside, drop the new file into place. For fresh-install targets
406/// (the binary didn't exist locally), skip the rename-aside step.
407#[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        // Best effort: restore the original so the user isn't left
421        // with a missing binary on PATH.
422        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}