mod config; mod git; mod interactive; mod logging; mod parallel; mod provider; mod state; mod sync; mod webhook; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use clap::{Args, Parser, Subcommand}; use crate::config::{Config, default_config_path}; use crate::sync::{SyncOptions, sync_all}; use crate::webhook::{ ServeOptions, WebhookInstallOptions, WebhookUninstallOptions, WebhookUpdateOptions, install_webhooks, serve, uninstall_webhooks, update_webhooks, }; #[derive(Parser, Debug)] #[command(name = "refray")] #[command(about = "Mirror repositories between Git hosting providers")] struct Cli { #[arg(long, global = true, value_name = "PATH")] config: Option, #[command(subcommand)] command: Command, } #[derive(Subcommand, Debug)] enum Command { /// Run the interactive configuration wizard Config, /// Sync configured mirror groups once Sync(SyncCommand), /// Run the webhook receiver Serve(ServeCommand), /// Install or uninstall repository webhooks #[command(subcommand)] Webhook(WebhookCommand), } #[derive(Args, Debug)] struct SyncCommand { #[arg(long, value_name = "NAME")] group: Option, #[arg(long)] dry_run: bool, /// Do not create repositories that are missing from an endpoint. #[arg(long)] no_create: bool, /// Sync only repositories that failed during the previous non-dry-run sync. #[arg(long)] retry_failed: bool, } #[derive(Args, Debug)] struct ServeCommand { #[arg(long, default_value = "127.0.0.1:8787", value_name = "HOST:PORT")] listen: String, } #[derive(Subcommand, Debug)] enum WebhookCommand { Install(WebhookInstallCommand), Uninstall(WebhookUninstallCommand), Update(WebhookUpdateCommand), } #[derive(Args, Debug)] struct WebhookInstallCommand { #[arg(long)] dry_run: bool, } #[derive(Args, Debug)] struct WebhookUninstallCommand { #[arg(value_name = "URL", value_parser = parse_webhook_url)] url: Option, #[arg(long)] dry_run: bool, } #[derive(Args, Debug)] struct WebhookUpdateCommand { #[arg(value_name = "URL")] url: String, #[arg(long)] dry_run: bool, } fn main() -> Result<()> { let cli = Cli::parse(); let config_path = cli.config.unwrap_or_else(default_config_path); match cli.command { Command::Config => { let outcome = interactive::run_config_wizard(&config_path)?; if outcome.run_full_sync_now { sync_all( &outcome.config, SyncOptions { jobs: outcome.config.jobs, ..SyncOptions::default() }, ) } else { Ok(()) } } Command::Sync(command) => { let config = load_config(&config_path)?; sync_all( &config, SyncOptions { group: command.group, dry_run: command.dry_run, create_missing_override: command.no_create.then_some(false), retry_failed: command.retry_failed, jobs: config.jobs, ..SyncOptions::default() }, ) } Command::Serve(command) => { let config = load_config(&config_path)?; let full_sync_interval_minutes = config .webhook .as_ref() .and_then(|webhook| webhook.full_sync_interval_minutes); let reachability_url = config.webhook.as_ref().map(|webhook| webhook.url.clone()); let reachability_check_interval_minutes = config .webhook .as_ref() .and_then(|webhook| webhook.reachability_check_interval_minutes); let secret = resolve_config_webhook_secret(&config)?; let workers = config.jobs; serve( config, ServeOptions { listen: command.listen, secret, workers, work_dir: None, full_sync_interval_minutes, reachability_url, reachability_check_interval_minutes, }, ) } Command::Webhook(WebhookCommand::Install(command)) => { let config = load_config(&config_path)?; let secret = resolve_config_webhook_secret(&config)?; let url = resolve_config_webhook_url(&config)?; install_webhooks( &config, WebhookInstallOptions { url, secret, dry_run: command.dry_run, work_dir: None, jobs: config.jobs, }, ) } Command::Webhook(WebhookCommand::Uninstall(command)) => { let config = load_config(&config_path)?; let url = resolve_uninstall_webhook_url(&config, command.url)?; uninstall_webhooks( &config, WebhookUninstallOptions { url, dry_run: command.dry_run, work_dir: None, jobs: config.jobs, }, ) } Command::Webhook(WebhookCommand::Update(command)) => { let mut config = load_config(&config_path)?; let old_url = config .webhook .as_ref() .map(|webhook| webhook.url.clone()) .ok_or_else(|| { anyhow::anyhow!("configure [webhook] before running webhook update") })?; let secret = resolve_config_webhook_secret(&config)?; update_webhooks( &config, WebhookUpdateOptions { old_url, new_url: command.url.clone(), secret, dry_run: command.dry_run, work_dir: None, jobs: config.jobs, }, )?; if !command.dry_run { set_config_webhook_url(&mut config, command.url); config.save(&config_path)?; } Ok(()) } } } fn load_config(path: &Path) -> Result { if !path.exists() { anyhow::bail!( "config not found at {}. Run `refray config` first to create one.", path.display() ); } Config::load(path).with_context(|| format!("failed to load config at {}", path.display())) } fn resolve_config_webhook_secret(config: &Config) -> Result { config .webhook .as_ref() .map(|webhook| webhook.secret()) .transpose()? .ok_or_else(|| { anyhow::anyhow!("configure [webhook].secret before running serve or webhook commands") }) } fn resolve_config_webhook_url(config: &Config) -> Result { config .webhook .as_ref() .map(|webhook| webhook.url.clone()) .ok_or_else(|| anyhow::anyhow!("configure [webhook].url before running webhook commands")) } fn resolve_uninstall_webhook_url(config: &Config, url: Option) -> Result { match url { Some(url) => Ok(url), None => resolve_config_webhook_url(config), } } fn parse_webhook_url(value: &str) -> std::result::Result { if value.trim().is_empty() { return Err("A value is required".to_string()); } let url = url::Url::parse(value).map_err(|error| format!("Invalid URL: {error}"))?; match url.scheme() { "http" | "https" => Ok(value.to_string()), _ => Err("URL must start with http:// or https://".to_string()), } } fn set_config_webhook_url(config: &mut Config, url: String) { let Some(webhook) = &mut config.webhook else { unreachable!("caller verifies webhook config exists before saving update") }; webhook.url = url; } #[cfg(test)] #[path = "../tests/unit/cli.rs"] mod tests;