1#![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_forge::{config::ReleaseSourceConfig, ReleaseProvider};
32
33use crate::options::{CheckOutcome, ProgressEvent, RunOptions};
34use crate::updater::Updater;
35
36pub 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 feature: Some(Feature::Update),
46 ..CommandSpec::DEFAULT
47 };
48 &SPEC
49 }
50
51 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 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#[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 Check(CheckOpts),
103 Run(RunOpts),
105}
106
107#[derive(Debug, clap::Args)]
108struct CheckOpts {}
109
110#[derive(Debug, clap::Args)]
111#[allow(clippy::struct_excessive_bools)] struct RunOpts {
113 #[arg(long, value_name = "VERSION")]
116 target: Option<semver::Version>,
117 #[arg(long)]
120 force: bool,
121 #[arg(long)]
123 include_prereleases: bool,
124 #[arg(long)]
127 dry_run: bool,
128 #[arg(long)]
130 progress: bool,
131}
132
133async 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
188pub(crate) async 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_forge::lookup(config.source_type()).ok_or_else(|| {
203 miette!(
204 "update: no provider registered for source_type={:?}; \
205 rtb-forge may have been compiled without that backend feature",
206 config.source_type(),
207 )
208 })?;
209 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 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_forge::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 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 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}