Skip to main content

rtb_update/
command.rs

1//! `update` CLI subcommand — `check | run`.
2//!
3//! Wires the [`crate::Updater`] library API to the user-facing CLI.
4//! Subcommands:
5//!
6//! - `update check` — print whether a newer version is available.
7//! - `update run [--target X.Y.Z] [--force] [--include-prereleases] [--dry-run]`
8//!   — execute the full self-update flow.
9//!
10//! No subcommand defaults to `check` (cheapest, most common).
11//!
12//! # Lint exception
13//!
14//! `linkme::distributed_slice` emits `#[link_section]` which Rust
15//! 1.95+ flags under `unsafe_code`. Allowed at module level — no
16//! hand-rolled `unsafe` blocks anywhere in the module.
17
18#![allow(unsafe_code)]
19
20use std::ffi::OsString;
21use std::sync::Arc;
22
23use async_trait::async_trait;
24use clap::{Parser, Subcommand};
25use linkme::distributed_slice;
26use miette::{miette, IntoDiagnostic};
27use rtb_app::app::App;
28use rtb_app::command::{Command, CommandSpec, BUILTIN_COMMANDS};
29use rtb_app::features::Feature;
30use rtb_app::metadata::ReleaseSource;
31use rtb_vcs::{config::ReleaseSourceConfig, ReleaseProvider};
32
33use crate::options::{CheckOutcome, ProgressEvent, RunOptions};
34use crate::updater::Updater;
35
36/// The `update` subcommand.
37pub struct UpdateCmd;
38
39#[async_trait]
40impl Command for UpdateCmd {
41    fn spec(&self) -> &CommandSpec {
42        static SPEC: CommandSpec = CommandSpec {
43            name: "update",
44            about: "Update the tool to the latest available version",
45            aliases: &[],
46            feature: Some(Feature::Update),
47        };
48        &SPEC
49    }
50
51    /// `update` owns its inner clap subtree (`check / run`).
52    fn subcommand_passthrough(&self) -> bool {
53        true
54    }
55
56    async fn run(&self, app: App) -> miette::Result<()> {
57        let mut args: Vec<OsString> = std::env::args_os().collect();
58        if args.len() >= 2 {
59            args.drain(..2);
60        }
61        args.insert(0, OsString::from("update"));
62        let cli = match UpdateCli::try_parse_from(args) {
63            Ok(c) => c,
64            Err(e) => {
65                use clap::error::ErrorKind;
66                if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
67                    print!("{e}");
68                    return Ok(());
69                }
70                return Err(miette!("{e}"));
71            }
72        };
73
74        // Default subcommand is `check` — cheapest, most-common path.
75        let sub = cli.command.unwrap_or_else(|| UpdateSub::Check(CheckOpts {}));
76        match sub {
77            UpdateSub::Check(_) => run_check(&app).await,
78            UpdateSub::Run(opts) => run_run(&app, opts).await,
79        }
80    }
81}
82
83#[distributed_slice(BUILTIN_COMMANDS)]
84fn __register_update() -> Box<dyn Command> {
85    Box::new(UpdateCmd)
86}
87
88// ---------------------------------------------------------------------
89// clap surface
90// ---------------------------------------------------------------------
91
92#[derive(Debug, Parser)]
93#[command(name = "update", about = "Self-update from the configured release source")]
94struct UpdateCli {
95    #[command(subcommand)]
96    command: Option<UpdateSub>,
97}
98
99#[derive(Debug, Subcommand)]
100enum UpdateSub {
101    /// Print whether a newer version is available; no download.
102    Check(CheckOpts),
103    /// Run the full self-update flow (download + verify + swap).
104    Run(RunOpts),
105}
106
107#[derive(Debug, clap::Args)]
108struct CheckOpts {}
109
110#[derive(Debug, clap::Args)]
111#[allow(clippy::struct_excessive_bools)] // CLI flags, not state.
112struct RunOpts {
113    /// Pin to a specific version (semver, no `v` prefix).
114    /// Downgrades require `--force`.
115    #[arg(long, value_name = "VERSION")]
116    target: Option<semver::Version>,
117    /// Re-install even when already up to date. Repairs corrupted
118    /// binaries and bypasses the downgrade check.
119    #[arg(long)]
120    force: bool,
121    /// Allow prereleases when picking "latest".
122    #[arg(long)]
123    include_prereleases: bool,
124    /// Verify + stage but do not swap. Leaves the staged binary
125    /// in the cache dir and prints its path.
126    #[arg(long)]
127    dry_run: bool,
128    /// Print progress events to stderr as the flow runs.
129    #[arg(long)]
130    progress: bool,
131}
132
133// ---------------------------------------------------------------------
134// Subcommand bodies
135// ---------------------------------------------------------------------
136
137async fn run_check(app: &App) -> miette::Result<()> {
138    let provider = build_provider(app).await?;
139    let updater = Updater::builder().app(app).provider(provider).build();
140    match updater.check().await.into_diagnostic()? {
141        CheckOutcome::UpToDate { current } => {
142            println!("up to date — running version {current}");
143        }
144        CheckOutcome::Newer { current, latest, .. } => {
145            println!("new version available: {current} -> {latest}");
146            println!("run `{} update run` to install", app.metadata.name);
147        }
148        CheckOutcome::Older { current, latest } => {
149            println!(
150                "running newer than the upstream report: \
151                 current {current} > latest {latest} (likely tool-author misconfiguration)",
152            );
153        }
154    }
155    Ok(())
156}
157
158async fn run_run(app: &App, opts: RunOpts) -> miette::Result<()> {
159    let provider = build_provider(app).await?;
160    let progress = if opts.progress { Some(progress_sink()) } else { None };
161    let updater = Updater::builder().app(app).provider(provider).build();
162    let outcome = updater
163        .run(RunOptions {
164            target: opts.target,
165            force: opts.force,
166            include_prereleases: opts.include_prereleases,
167            dry_run: opts.dry_run,
168            progress,
169        })
170        .await
171        .into_diagnostic()?;
172
173    if outcome.swapped {
174        println!("updated: {} -> {}", outcome.from_version, outcome.to_version);
175    } else if let Some(staged) = outcome.staged_at {
176        println!(
177            "dry run: staged {} -> {} at {}",
178            outcome.from_version,
179            outcome.to_version,
180            staged.display(),
181        );
182    } else {
183        println!("already at {}", outcome.to_version);
184    }
185    Ok(())
186}
187
188// ---------------------------------------------------------------------
189// Helpers
190// ---------------------------------------------------------------------
191
192/// Build a [`ReleaseProvider`] from `app.metadata.release_source`.
193/// Matches each [`ReleaseSource`] variant to the corresponding
194/// [`ReleaseSourceConfig`] and dispatches via [`rtb_vcs::lookup`].
195async fn build_provider(app: &App) -> miette::Result<Arc<dyn ReleaseProvider>> {
196    let source = app
197        .metadata
198        .release_source
199        .as_ref()
200        .ok_or_else(|| miette!("update: no `release_source` configured on ToolMetadata"))?;
201    let config = release_source_to_config(source)?;
202    let factory = rtb_vcs::lookup(config.source_type()).ok_or_else(|| {
203        miette!(
204            "update: no provider registered for source_type={:?}; \
205             rtb-vcs may have been compiled without that backend feature",
206            config.source_type(),
207        )
208    })?;
209    // Resolve PAT auth from `ToolMetadata::release_credential`. When
210    // unset, the provider runs unauthenticated (rate-limited but
211    // correct for public releases). When set, walk the precedence
212    // chain (env > keychain > literal > fallback_env) via the
213    // platform-default resolver — stops at the first hit.
214    let token = if let Some(cred) = app.metadata.release_credential.as_ref() {
215        let resolver = rtb_credentials::Resolver::with_platform_default();
216        match resolver.resolve(cred).await {
217            Ok(secret) => Some(secret),
218            // `NotFound` is non-fatal — the user may simply not have
219            // configured the credential yet for a public-only repo.
220            // Other errors (CI-rejected literal, keychain failure)
221            // are real and should surface.
222            Err(rtb_credentials::CredentialError::NotFound { .. }) => None,
223            Err(e) => return Err(miette!("update: credential resolve: {e}")),
224        }
225    } else {
226        None
227    };
228    factory(&config, token).into_diagnostic()
229}
230
231fn release_source_to_config(source: &ReleaseSource) -> miette::Result<ReleaseSourceConfig> {
232    use rtb_vcs::config::{
233        BitbucketParams, CodebergParams, DirectParams, GiteaParams, GithubParams, GitlabParams,
234    };
235    match source {
236        ReleaseSource::Github { owner, repo, host } => {
237            Ok(ReleaseSourceConfig::Github(GithubParams {
238                host: host.clone(),
239                owner: owner.clone(),
240                repo: repo.clone(),
241                private: false,
242                timeout_seconds: 30,
243                allow_insecure_base_url: false,
244            }))
245        }
246        ReleaseSource::Gitlab { project, host } => {
247            // The rtb-app v0.1 ReleaseSource collapses owner/repo into a
248            // single `project` slug (e.g. `myorg/group/project`).
249            // rtb-vcs's GitlabParams splits them — derive owner/repo by
250            // splitting on the last `/`.
251            let (owner, repo) = project.rsplit_once('/').ok_or_else(|| {
252                miette!(
253                    "update: gitlab `project` must include the owner (`<owner>/<repo>`); \
254                     got {project:?}",
255                )
256            })?;
257            Ok(ReleaseSourceConfig::Gitlab(GitlabParams {
258                host: host.clone(),
259                owner: owner.to_string(),
260                repo: repo.to_string(),
261                private: false,
262                timeout_seconds: 30,
263                allow_insecure_base_url: false,
264            }))
265        }
266        ReleaseSource::Bitbucket { workspace, repo_slug, host } => {
267            Ok(ReleaseSourceConfig::Bitbucket(BitbucketParams {
268                host: host.clone(),
269                workspace: workspace.clone(),
270                repo_slug: repo_slug.clone(),
271                username: None,
272                private: false,
273                timeout_seconds: 30,
274                allow_insecure_base_url: false,
275            }))
276        }
277        ReleaseSource::Gitea { owner, repo, host } => Ok(ReleaseSourceConfig::Gitea(GiteaParams {
278            host: host.clone(),
279            owner: owner.clone(),
280            repo: repo.clone(),
281            private: false,
282            timeout_seconds: 30,
283            allow_insecure_base_url: false,
284        })),
285        ReleaseSource::Codeberg { owner, repo } => {
286            Ok(ReleaseSourceConfig::Codeberg(CodebergParams {
287                owner: owner.clone(),
288                repo: repo.clone(),
289                private: false,
290                timeout_seconds: 30,
291                allow_insecure_base_url: false,
292            }))
293        }
294        ReleaseSource::Direct { url_template } => Ok(ReleaseSourceConfig::Direct(DirectParams {
295            version_url: url_template.clone(),
296            asset_url_template: url_template.clone(),
297            pinned_version: None,
298            timeout_seconds: 30,
299            allow_insecure_base_url: false,
300        })),
301        // `ReleaseSource` is `#[non_exhaustive]`; defensive arm for
302        // any future variant added without updating this mapper.
303        other => {
304            Err(miette!("update: release source {other:?} not yet wired through the update CLI"))
305        }
306    }
307}
308
309fn progress_sink() -> crate::ProgressSink {
310    Arc::new(|event: ProgressEvent| match event {
311        ProgressEvent::Checking => eprintln!("update: checking…"),
312        ProgressEvent::Downloading { bytes_done, bytes_total } => {
313            if bytes_total > 0 {
314                eprintln!("update: downloading {bytes_done}/{bytes_total}");
315            } else {
316                eprintln!("update: downloading {bytes_done} bytes");
317            }
318        }
319        ProgressEvent::Verifying => eprintln!("update: verifying signature…"),
320        ProgressEvent::SelfTesting => eprintln!("update: self-testing staged binary…"),
321        ProgressEvent::Swapping => eprintln!("update: swapping running binary…"),
322        ProgressEvent::Done { version } => eprintln!("update: done — now at {version}"),
323    })
324}