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);
44// Per-asset download cap. Sized for the largest asset: `objectiveai-db`
45// bundles postgres (~163 MB), so a tight cap would time out the db leg
46// on slower links (the other binaries are far smaller).
47const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300);
48
49/// Swap order: cli last because it's the running binary. The
50/// sub-binaries don't gate each other — a swap failure on one is
51/// non-fatal (we emit a per-binary error event and keep going).
52const 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    // The GitHub credential lives in the on-disk json config only
58    // (`api config github-authorization set`), not the env Config.
59    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    // Dev tree refuses to self-update — overwriting a `cargo run`
84    // output would clobber an in-tree build.
85    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(&current_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    // Best-effort cleanup of any stale `.exe.old` from a prior Windows
106    // swap before we begin.
107    sweep_stale_old(&current_exe);
108
109    // Build the five expected asset names for this (os, arch). The cli
110    // is the bare `objectiveai-<os>-<arch>{ext}`; every other package
111    // appends `-<package>` after the arch.
112    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    // Fetch the latest release metadata.
136    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    // Index assets by name; check all 5 expected names are present.
165    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    // Compare versions. Tag is `v<X.Y.Z>` by repo convention.
183    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    // Download all 5 to staged paths next to their targets.
215    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            // Clean up any prior staged files on failure.
243            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    // Swap each staged binary into place. Order = PACKAGES order =
261    // api/viewer/mcp first, cli last. Per-binary failures on
262    // api/viewer/mcp emit a non-fatal Err event and continue; a cli
263    // failure is fatal (returns Err).
264    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/// Swap the staged binary into place.
402///
403/// **Unix**: `rename(new, current)` works because the running process
404/// holds the binary by inode.
405///
406/// **Windows**: the running exe's path is locked for writes, but
407/// renaming the file to a different name is allowed. Move current
408/// aside, drop the new file into place. For fresh-install targets
409/// (the binary didn't exist locally), skip the rename-aside step.
410#[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        // Best effort: restore the original so the user isn't left
424        // with a missing binary on PATH.
425        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}