diff --git a/README.md b/README.md index e8b5c99..452100a 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Example wizard flow: PAT quick setup: - GitHub: open `https://github.com/settings/tokens`, create a classic PAT with `repo` permissions, then copy the token. -- GitLab: open `/-/user_settings/personal_access_tokens?name=git-sync&scopes=api`, create the token, then copy it. +- GitLab: open `/-/user_settings/personal_access_tokens?name=git-sync&scopes=api,write_repository`, select `api` and `write_repository`, create the token, then copy it. - Gitea: open `/user/settings/applications`, create a token with repository access, then copy it. - Forgejo: open `/user/settings/applications`, create a token with repository access, then copy it. @@ -109,7 +109,7 @@ Use cron or another scheduler for automatic execution: Webhook mode reduces the window for divergent commits by syncing a repository immediately after a provider sends a push event. It is still conservative: if two endpoints receive independent commits before webhook sync catches up, the normal divergence rules still apply. -The interactive wizard can configure webhooks for you. It asks for the public URL, checks that the URL is reachable from the current machine, creates a webhook secret, and can enable periodic full syncs while `git-sync serve` is running. +The interactive wizard can configure webhooks for you. During setup it starts a temporary test listener on `127.0.0.1:8787`, asks for the public URL, checks that the URL is reachable from the current machine, creates a webhook secret, and can enable periodic full syncs while `git-sync serve` is running. Example config: diff --git a/src/interactive.rs b/src/interactive.rs index 68642f5..ecabe83 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -2,12 +2,15 @@ use std::fmt::Display; use std::fs::File; use std::io::Read; use std::path::Path; +use std::sync::mpsc; +use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, anyhow}; use console::{Term, style}; use dialoguer::{Confirm, Input, Password, Select, theme::ColorfulTheme}; use reqwest::blocking::Client; +use tiny_http::{Request, Response, Server, StatusCode}; use url::Url; use crate::config::{ @@ -17,6 +20,8 @@ use crate::config::{ use crate::provider::ProviderClient; use crate::webhook::check_webhook_url_reachable; +const DEFAULT_WEBHOOK_LISTEN: &str = "127.0.0.1:8787"; + #[derive(Clone, Debug)] struct ProfileTarget { base_url: String, @@ -40,7 +45,7 @@ pub fn run_config_wizard(path: &Path) -> Result<()> { println!(); println!("{}", style("git-sync configuration wizard").cyan().bold()); let description = if existing_config { - "Review, add, or delete sync groups." + "Review, add, edit, or delete sync groups." } else { "Enter profile or organization URLs, then git-sync will build the mirror group." }; @@ -60,6 +65,11 @@ pub fn run_config_wizard(path: &Path) -> Result<()> { add_sync_group_styled(&mut config, &theme)?; print_sync_groups(&config); } + WizardAction::EditSyncGroup => { + if edit_sync_group_styled(&mut config, &theme)? { + print_sync_groups(&config); + } + } WizardAction::DeleteSyncGroup => { if delete_sync_group_styled(&mut config, &theme)? { print_sync_groups(&config); @@ -81,19 +91,61 @@ pub fn run_config_wizard(path: &Path) -> Result<()> { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum WizardAction { AddSyncGroup, + EditSyncGroup, DeleteSyncGroup, Done, } fn add_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<()> { + let endpoints = prompt_sync_group_endpoints_styled(config, theme, &[])?; + config.upsert_mirror(MirrorConfig { + name: next_mirror_name(config), + endpoints, + create_missing: true, + visibility: Visibility::Private, + allow_force: false, + }); + prompt_webhook_setup_styled(config, theme)?; + + Ok(()) +} + +fn prompt_sync_group_endpoints_styled( + config: &mut Config, + theme: &ColorfulTheme, + existing: &[EndpointConfig], +) -> Result> { let mut endpoints = Vec::new(); - let first = prompt_target_styled(theme, "Profile/org URL")?; + let first = prompt_target_styled( + theme, + "Profile/org URL", + endpoint_profile_url(config, existing.first()), + )?; endpoints.push(ensure_credentials_styled(config, first, theme)?); - let second = prompt_target_styled(theme, "Profile/org URL to sync with")?; + let second = prompt_target_styled( + theme, + "Profile/org URL to sync with", + endpoint_profile_url(config, existing.get(1)), + )?; endpoints.push(ensure_credentials_styled(config, second, theme)?); + for (index, endpoint) in existing.iter().enumerate().skip(2) { + let Some(default_url) = endpoint_profile_url(config, Some(endpoint)) else { + continue; + }; + if !Confirm::with_theme(theme) + .with_prompt(format!("Keep endpoint {}?", index + 1)) + .default(true) + .interact()? + { + continue; + } + let next = prompt_target_styled(theme, "Additional profile/org URL", Some(default_url))?; + endpoints.push(ensure_credentials_styled(config, next, theme)?); + } + loop { let prompt = if endpoints.len() == 2 { "Add a third endpoint for 3-way sync?" @@ -107,20 +159,11 @@ fn add_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<( { break; } - let next = prompt_target_styled(theme, "Additional profile/org URL")?; + let next = prompt_target_styled(theme, "Additional profile/org URL", None)?; endpoints.push(ensure_credentials_styled(config, next, theme)?); } - config.upsert_mirror(MirrorConfig { - name: next_mirror_name(config), - endpoints, - create_missing: true, - visibility: Visibility::Private, - allow_force: false, - }); - prompt_webhook_setup_styled(config, theme)?; - - Ok(()) + Ok(endpoints) } fn prompt_webhook_setup_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<()> { @@ -152,6 +195,29 @@ fn prompt_webhook_setup_styled(config: &mut Config, theme: &ColorfulTheme) -> Re { return Ok(()); } + print_webhook_url_instructions(); + let demo_server = match DemoWebhookServer::start(DEFAULT_WEBHOOK_LISTEN) { + Ok(server) => { + println!( + " {} Temporary test listener running on {}", + style("-").cyan(), + style(server.listen()).bold() + ); + Some(server) + } + Err(error) => { + println!( + " {} Could not start temporary test listener on {}: {error:#}", + style("-").yellow(), + style(DEFAULT_WEBHOOK_LISTEN).bold() + ); + println!( + " {} If git-sync serve is already running there, you can continue.", + style("-").yellow() + ); + None + } + }; let url = Input::::with_theme(theme) .with_prompt("Webhook URL reachable by GitHub/GitLab/Gitea") .validate_with(|value: &String| validate_url(value)) @@ -177,6 +243,7 @@ fn prompt_webhook_setup_styled(config: &mut Config, theme: &ColorfulTheme) -> Re } } } + drop(demo_server); let full_sync_interval_minutes = if Confirm::with_theme(theme) .with_prompt("Run periodic full sync while the webhook server is running?") .default(true) @@ -201,8 +268,111 @@ fn prompt_webhook_setup_styled(config: &mut Config, theme: &ColorfulTheme) -> Re Ok(()) } +fn print_webhook_url_instructions() { + println!(); + println!( + "{} {}", + style("Webhook URL").cyan().bold(), + style("must be reachable from every Git provider").dim() + ); + println!( + " {} Start the receiver with: {}", + style("-").cyan(), + style(format!("git-sync serve --listen {DEFAULT_WEBHOOK_LISTEN}")).bold() + ); + println!( + " {} The receiver accepts: {} and {}", + style("-").cyan(), + style("POST /").bold(), + style("POST /webhook").bold() + ); + println!( + " {} If running locally, expose it with a tunnel, for example: {}", + style("-").cyan(), + style(format!( + "cloudflared tunnel --url http://{DEFAULT_WEBHOOK_LISTEN}" + )) + .bold() + ); + println!( + " {} Then enter the public URL, usually ending in {}", + style("-").cyan(), + style("/webhook").bold() + ); + println!( + " {} The wizard starts a temporary listener on {} so you can test the tunnel now.", + style("-").cyan(), + style(DEFAULT_WEBHOOK_LISTEN).bold() + ); +} + +struct DemoWebhookServer { + listen: String, + stop: mpsc::Sender<()>, + handle: Option>, +} + +impl DemoWebhookServer { + fn start(listen: &str) -> Result { + let server = Server::http(listen) + .map_err(|error| anyhow!("failed to listen on {listen}: {error}"))?; + let listen = server.server_addr().to_string(); + let (stop, stop_receiver) = mpsc::channel(); + let handle = thread::spawn(move || run_demo_webhook_server(server, stop_receiver)); + Ok(Self { + listen, + stop, + handle: Some(handle), + }) + } + + fn listen(&self) -> &str { + &self.listen + } +} + +impl Drop for DemoWebhookServer { + fn drop(&mut self) { + let _ = self.stop.send(()); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +fn run_demo_webhook_server(server: Server, stop: mpsc::Receiver<()>) { + loop { + if stop.try_recv().is_ok() { + break; + } + match server.recv_timeout(Duration::from_millis(100)) { + Ok(Some(request)) => respond_demo_webhook_request(request), + Ok(None) => {} + Err(_) => break, + } + } +} + +fn respond_demo_webhook_request(request: Request) { + let path = request.url().split('?').next().unwrap_or(request.url()); + let (status, body) = if path == "/" || path == "/webhook" { + ( + StatusCode(200), + "git-sync webhook setup listener\nThis temporary server only confirms that your public URL reaches this machine.\nAfter saving config, run git-sync serve for real webhooks.\n", + ) + } else { + (StatusCode(404), "not found\n") + }; + let _ = request.respond(Response::from_string(body).with_status_code(status)); +} + fn prompt_wizard_action_styled(theme: &ColorfulTheme) -> Result { - let options = ["Add another sync group", "Delete an existing group", "Done"]; + let options = [ + "Add another sync group", + "Edit an existing group", + "Delete an existing group", + "Done", + ]; let index = Select::with_theme(theme) .with_prompt("What would you like to do?") .items(options) @@ -211,11 +381,46 @@ fn prompt_wizard_action_styled(theme: &ColorfulTheme) -> Result { Ok(match index { 0 => WizardAction::AddSyncGroup, - 1 => WizardAction::DeleteSyncGroup, + 1 => WizardAction::EditSyncGroup, + 2 => WizardAction::DeleteSyncGroup, _ => WizardAction::Done, }) } +fn edit_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result { + if config.mirrors.is_empty() { + println!("{}", style("No sync groups to edit.").yellow()); + return Ok(false); + } + + let mut options = numbered_sync_group_options(config); + options.push("Back".to_string()); + let index = Select::with_theme(theme) + .with_prompt("Edit sync group") + .items(&options) + .default(0) + .interact()?; + if index == config.mirrors.len() { + return Ok(false); + } + + println!( + "{} {}", + style("Editing").cyan().bold(), + style(format!("sync group {}", index + 1)).cyan() + ); + let existing = config.mirrors[index].endpoints.clone(); + let endpoints = prompt_sync_group_endpoints_styled(config, theme, &existing)?; + config.mirrors[index].endpoints = endpoints; + prompt_webhook_setup_styled(config, theme)?; + println!( + "{} {}", + style("updated").green().bold(), + style(format!("sync group {}", index + 1)).cyan() + ); + Ok(true) +} + fn delete_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result { if config.mirrors.is_empty() { println!("{}", style("No sync groups to delete.").yellow()); @@ -242,9 +447,18 @@ fn delete_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Resul Ok(true) } -fn prompt_target_styled(theme: &ColorfulTheme, prompt: &str) -> Result { - let url = Input::::with_theme(theme) - .with_prompt(prompt) +fn prompt_target_styled( + theme: &ColorfulTheme, + prompt: &str, + default: Option, +) -> Result { + let input = Input::::with_theme(theme).with_prompt(prompt); + let input = if let Some(default) = default { + input.default(default) + } else { + input + }; + let url = input .validate_with(|value: &String| validate_required(value)) .interact_text()?; let parsed = parse_profile_url(&url)?; @@ -546,9 +760,9 @@ fn pat_instruction_lines(provider: &ProviderKind, base_url: &str) -> Vec .to_string(), ], ProviderKind::Gitlab => vec![ - "Create a personal access token with API permissions.".to_string(), + "Create a personal access token with API and repository write permissions.".to_string(), format!("Open: {url}"), - "Select api, create the token, then paste it here.".to_string(), + "Select api and write_repository, create the token, then paste it here.".to_string(), ], ProviderKind::Gitea => vec![ "Create a personal access token with repository permissions.".to_string(), @@ -755,6 +969,16 @@ fn endpoint_url(site: &SiteConfig, endpoint: &EndpointConfig) -> String { format!("{}/{}", trim_url_scheme(&site.base_url), endpoint.namespace) } +fn endpoint_profile_url(config: &Config, endpoint: Option<&EndpointConfig>) -> Option { + let endpoint = endpoint?; + let site = config.site(&endpoint.site)?; + Some(format!( + "{}/{}", + trim_url_end(&site.base_url), + endpoint.namespace + )) +} + fn trim_url_scheme(value: &str) -> String { value .trim_start_matches("https://") @@ -852,7 +1076,9 @@ fn token_creation_url(provider: &ProviderKind, base_url: &str) -> String { match provider { ProviderKind::Github => format!("{base}/settings/tokens"), ProviderKind::Gitlab => { - format!("{base}/-/user_settings/personal_access_tokens?name=git-sync&scopes=api") + format!( + "{base}/-/user_settings/personal_access_tokens?name=git-sync&scopes=api,write_repository" + ) } ProviderKind::Gitea => format!("{base}/user/settings/applications"), ProviderKind::Forgejo => format!("{base}/user/settings/applications"), diff --git a/tests/unit/interactive.rs b/tests/unit/interactive.rs index c71683a..8e92c4a 100644 --- a/tests/unit/interactive.rs +++ b/tests/unit/interactive.rs @@ -12,7 +12,7 @@ fn wizard_builds_sync_group_from_profile_urls() { "", "n", "n", - "3", + "4", ] .join("\n") + "\n"; @@ -48,6 +48,7 @@ fn wizard_builds_sync_group_from_profile_urls() { let output = String::from_utf8(output).unwrap(); assert!(output.contains("1. github.com/hykilpikonna <-> gitea.example.test/azalea")); assert!(output.contains("Add another sync group")); + assert!(output.contains("Edit an existing group")); assert!(output.contains("Delete an existing group")); assert!(output.contains("Done")); } @@ -67,7 +68,7 @@ fn wizard_can_build_three_way_sync() { "", "n", "n", - "3", + "4", ] .join("\n") + "\n"; @@ -95,7 +96,7 @@ fn wizard_can_enable_webhooks() { "https://mirror.example.test/webhook", "y", "30", - "3", + "4", ] .join("\n") + "\n"; @@ -113,6 +114,12 @@ fn wizard_can_enable_webhooks() { webhook.secret, TokenConfig::Value("test-webhook-secret".to_string()) ); + + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("git-sync serve --listen 127.0.0.1:8787")); + assert!(output.contains("cloudflared tunnel --url http://127.0.0.1:8787")); + assert!(output.contains("POST / and POST /webhook")); + assert!(output.contains("temporary listener on 127.0.0.1:8787")); } #[test] @@ -136,7 +143,7 @@ fn wizard_reuses_existing_credentials_for_same_instance() { "", "n", "n", - "3", + "4", ] .join("\n") + "\n"; @@ -191,7 +198,7 @@ fn wizard_starts_existing_config_at_sync_group_menu() { }], webhook: None, }; - let mut reader = Cursor::new(b"3\n".as_slice()); + let mut reader = Cursor::new(b"4\n".as_slice()); let mut output = Vec::new(); let updated = run_config_wizard_with_io(config, &mut reader, &mut output).unwrap(); @@ -203,6 +210,151 @@ fn wizard_starts_existing_config_at_sync_group_menu() { assert!(!output.contains("Profile/org URL:")); } +#[test] +fn wizard_edits_existing_sync_group_from_menu() { + let config = Config { + sites: vec![ + SiteConfig { + name: "github".to_string(), + provider: ProviderKind::Github, + base_url: "https://github.com".to_string(), + api_url: None, + token: TokenConfig::Value("existing-gh".to_string()), + git_username: None, + }, + SiteConfig { + name: "gitea".to_string(), + provider: ProviderKind::Gitea, + base_url: "https://gitea.example.test".to_string(), + api_url: None, + token: TokenConfig::Value("existing-gt".to_string()), + git_username: None, + }, + SiteConfig { + name: "gitlab".to_string(), + provider: ProviderKind::Gitlab, + base_url: "https://gitlab.com".to_string(), + api_url: None, + token: TokenConfig::Value("existing-gl".to_string()), + git_username: None, + }, + ], + mirrors: vec![MirrorConfig { + name: "sync-1".to_string(), + endpoints: vec![ + EndpointConfig { + site: "github".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + EndpointConfig { + site: "gitea".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + ], + create_missing: false, + visibility: Visibility::Public, + allow_force: true, + }], + webhook: None, + }; + let input = [ + "2", + "1", + "https://github.com/bob", + "", + "https://gitlab.com/bob", + "", + "n", + "n", + "4", + ] + .join("\n") + + "\n"; + let mut reader = Cursor::new(input.as_bytes()); + let mut output = Vec::new(); + + let updated = run_config_wizard_with_io(config, &mut reader, &mut output).unwrap(); + + assert_eq!(updated.mirrors.len(), 1); + let mirror = &updated.mirrors[0]; + assert_eq!(mirror.name, "sync-1"); + assert_eq!(mirror.endpoints.len(), 2); + assert_eq!(mirror.endpoints[0].site, "github"); + assert_eq!(mirror.endpoints[0].namespace, "bob"); + assert_eq!(mirror.endpoints[1].site, "gitlab"); + assert_eq!(mirror.endpoints[1].namespace, "bob"); + assert!(!mirror.create_missing); + assert_eq!(mirror.visibility, Visibility::Public); + assert!(mirror.allow_force); + + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("Edit sync group")); + assert!(output.contains("updated sync group 1")); + assert!(output.contains("github.com/bob <-> gitlab.com/bob")); +} + +#[test] +fn wizard_prefills_existing_sync_group_when_editing() { + let config = Config { + sites: vec![ + SiteConfig { + name: "github".to_string(), + provider: ProviderKind::Github, + base_url: "https://github.com".to_string(), + api_url: None, + token: TokenConfig::Value("existing-gh".to_string()), + git_username: None, + }, + SiteConfig { + name: "gitea".to_string(), + provider: ProviderKind::Gitea, + base_url: "https://gitea.example.test".to_string(), + api_url: None, + token: TokenConfig::Value("existing-gt".to_string()), + git_username: None, + }, + ], + mirrors: vec![MirrorConfig { + name: "sync-1".to_string(), + endpoints: vec![ + EndpointConfig { + site: "github".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + EndpointConfig { + site: "gitea".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + ], + create_missing: true, + visibility: Visibility::Private, + allow_force: false, + }], + webhook: None, + }; + let input = ["2", "1", "", "", "", "", "n", "n", "4"].join("\n") + "\n"; + let mut reader = Cursor::new(input.as_bytes()); + let mut output = Vec::new(); + + let updated = run_config_wizard_with_io(config, &mut reader, &mut output).unwrap(); + + let mirror = &updated.mirrors[0]; + assert_eq!(mirror.endpoints.len(), 2); + assert_eq!(mirror.endpoints[0].site, "github"); + assert_eq!(mirror.endpoints[0].namespace, "alice"); + assert_eq!(mirror.endpoints[1].site, "gitea"); + assert_eq!(mirror.endpoints[1].namespace, "alice"); + + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("Profile/org URL [https://github.com/alice]:")); + assert!(output.contains("Profile/org URL to sync with [https://gitea.example.test/alice]:")); + assert!(output.contains("updated sync group 1")); +} + #[test] fn wizard_deletes_existing_sync_group_from_menu() { let config = Config { @@ -244,7 +396,7 @@ fn wizard_deletes_existing_sync_group_from_menu() { }], webhook: None, }; - let input = ["2", "1", "3"].join("\n") + "\n"; + let input = ["3", "1", "4"].join("\n") + "\n"; let mut reader = Cursor::new(input.as_bytes()); let mut output = Vec::new(); @@ -299,7 +451,7 @@ fn wizard_can_go_back_from_delete_menu() { }], webhook: None, }; - let input = ["2", "2", "3"].join("\n") + "\n"; + let input = ["3", "2", "4"].join("\n") + "\n"; let mut reader = Cursor::new(input.as_bytes()); let mut output = Vec::new(); @@ -373,7 +525,7 @@ fn token_creation_urls_are_provider_specific() { ); assert_eq!( token_creation_url(&ProviderKind::Gitlab, "https://gitlab.example.test"), - "https://gitlab.example.test/-/user_settings/personal_access_tokens?name=git-sync&scopes=api" + "https://gitlab.example.test/-/user_settings/personal_access_tokens?name=git-sync&scopes=api,write_repository" ); assert_eq!( token_creation_url(&ProviderKind::Gitea, "gitea.example.test"), @@ -384,3 +536,17 @@ fn token_creation_urls_are_provider_specific() { "https://forgejo.example.test/user/settings/applications" ); } + +#[test] +fn demo_webhook_server_answers_reachability_checks() { + let server = DemoWebhookServer::start("127.0.0.1:0").unwrap(); + let response = reqwest::blocking::get(format!("http://{}/webhook", server.listen())).unwrap(); + + assert!(response.status().is_success()); + assert!( + response + .text() + .unwrap() + .contains("git-sync webhook setup listener") + ); +} diff --git a/tests/unit/interactive_test_io.rs b/tests/unit/interactive_test_io.rs index 7ce5c62..1c36292 100644 --- a/tests/unit/interactive_test_io.rs +++ b/tests/unit/interactive_test_io.rs @@ -25,6 +25,11 @@ where add_sync_group(reader, writer, &mut config)?; write_sync_groups(&config, writer)?; } + WizardAction::EditSyncGroup => { + if edit_sync_group(reader, writer, &mut config)? { + write_sync_groups(&config, writer)?; + } + } WizardAction::DeleteSyncGroup => { if delete_sync_group(reader, writer, &mut config)? { write_sync_groups(&config, writer)?; @@ -41,22 +46,7 @@ where R: BufRead, W: Write, { - let mut endpoints = Vec::new(); - let first = prompt_target(reader, writer, "Profile/org URL")?; - endpoints.push(ensure_credentials(config, first, reader, writer)?); - let second = prompt_target(reader, writer, "Profile/org URL to sync with")?; - endpoints.push(ensure_credentials(config, second, reader, writer)?); - - while prompt_bool( - reader, - writer, - "Add a third endpoint for 3-way sync?", - false, - )? { - let next = prompt_target(reader, writer, "Additional profile/org URL")?; - endpoints.push(ensure_credentials(config, next, reader, writer)?); - } - + let endpoints = prompt_sync_group_endpoints(reader, writer, config, &[])?; config.upsert_mirror(MirrorConfig { name: next_mirror_name(config), endpoints, @@ -68,6 +58,69 @@ where Ok(()) } +fn prompt_sync_group_endpoints( + reader: &mut R, + writer: &mut W, + config: &mut Config, + existing: &[EndpointConfig], +) -> Result> +where + R: BufRead, + W: Write, +{ + let mut endpoints = Vec::new(); + let first = prompt_target( + reader, + writer, + "Profile/org URL", + endpoint_profile_url(config, existing.first()), + )?; + endpoints.push(ensure_credentials(config, first, reader, writer)?); + let second = prompt_target( + reader, + writer, + "Profile/org URL to sync with", + endpoint_profile_url(config, existing.get(1)), + )?; + endpoints.push(ensure_credentials(config, second, reader, writer)?); + + for (index, endpoint) in existing.iter().enumerate().skip(2) { + let Some(default_url) = endpoint_profile_url(config, Some(endpoint)) else { + continue; + }; + if !prompt_bool( + reader, + writer, + &format!("Keep endpoint {}?", index + 1), + true, + )? { + continue; + } + let next = prompt_target( + reader, + writer, + "Additional profile/org URL", + Some(default_url), + )?; + endpoints.push(ensure_credentials(config, next, reader, writer)?); + } + + loop { + let prompt = if endpoints.len() == 2 { + "Add a third endpoint for 3-way sync?" + } else { + "Add another endpoint to this sync group?" + }; + if !prompt_bool(reader, writer, prompt, false)? { + break; + } + let next = prompt_target(reader, writer, "Additional profile/org URL", None)?; + endpoints.push(ensure_credentials(config, next, reader, writer)?); + } + + Ok(endpoints) +} + fn prompt_webhook_setup(reader: &mut R, writer: &mut W, config: &mut Config) -> Result<()> where R: BufRead, @@ -88,6 +141,7 @@ where if !prompt_bool(reader, writer, "Install webhook?", true)? { return Ok(()); } + write_webhook_url_instructions(writer)?; let url = prompt_required(reader, writer, "Webhook URL reachable by providers")?; if let Err(error) = validate_url(&url) { bail!(error); @@ -116,6 +170,34 @@ where Ok(()) } +fn write_webhook_url_instructions(writer: &mut W) -> Result<()> +where + W: Write, +{ + writeln!( + writer, + "Webhook URL must be reachable from every Git provider." + )?; + writeln!( + writer, + "Start the receiver with: git-sync serve --listen 127.0.0.1:8787" + )?; + writeln!(writer, "The receiver accepts: POST / and POST /webhook")?; + writeln!( + writer, + "If running locally, expose it with a tunnel, for example: cloudflared tunnel --url http://127.0.0.1:8787" + )?; + writeln!( + writer, + "Then enter the public URL, usually ending in /webhook." + )?; + writeln!( + writer, + "During the real wizard, git-sync starts a temporary listener on 127.0.0.1:8787 so you can test the tunnel now." + )?; + Ok(()) +} + fn prompt_wizard_action(reader: &mut R, writer: &mut W) -> Result where R: BufRead, @@ -124,18 +206,57 @@ where loop { writeln!(writer, "What would you like to do?")?; writeln!(writer, " 1. Add another sync group")?; - writeln!(writer, " 2. Delete an existing group")?; - writeln!(writer, " 3. Done")?; + writeln!(writer, " 2. Edit an existing group")?; + writeln!(writer, " 3. Delete an existing group")?; + writeln!(writer, " 4. Done")?; write!(writer, "Choose an option: ")?; writer.flush()?; let value = read_line(reader)?.trim().to_ascii_lowercase(); match value.as_str() { "1" | "add" | "add another sync group" => return Ok(WizardAction::AddSyncGroup), - "2" | "delete" | "delete an existing group" => { + "2" | "edit" | "edit an existing group" => return Ok(WizardAction::EditSyncGroup), + "3" | "delete" | "delete an existing group" => { return Ok(WizardAction::DeleteSyncGroup); } - "3" | "done" | "finish" => return Ok(WizardAction::Done), - _ => writeln!(writer, "Enter 1, 2, or 3.")?, + "4" | "done" | "finish" => return Ok(WizardAction::Done), + _ => writeln!(writer, "Enter 1, 2, 3, or 4.")?, + } + } +} + +fn edit_sync_group(reader: &mut R, writer: &mut W, config: &mut Config) -> Result +where + R: BufRead, + W: Write, +{ + if config.mirrors.is_empty() { + writeln!(writer, "No sync groups to edit.")?; + return Ok(false); + } + + loop { + writeln!(writer, "Edit sync group")?; + for (index, option) in sync_group_summaries(config).iter().enumerate() { + writeln!(writer, " {}. {}", index + 1, option)?; + } + writeln!(writer, " {}. Back", config.mirrors.len() + 1)?; + write!(writer, "Choose a sync group: ")?; + writer.flush()?; + let value = read_line(reader)?.trim().to_ascii_lowercase(); + if value == "b" || value == "back" { + return Ok(false); + } + match value.parse::() { + Ok(index) if (1..=config.mirrors.len()).contains(&index) => { + let existing = config.mirrors[index - 1].endpoints.clone(); + let endpoints = prompt_sync_group_endpoints(reader, writer, config, &existing)?; + config.mirrors[index - 1].endpoints = endpoints; + prompt_webhook_setup(reader, writer, config)?; + writeln!(writer, "updated sync group {index}")?; + return Ok(true); + } + Ok(index) if index == config.mirrors.len() + 1 => return Ok(false), + _ => writeln!(writer, "Enter a sync group number, or choose Back.")?, } } } @@ -175,12 +296,20 @@ where } } -fn prompt_target(reader: &mut R, writer: &mut W, prompt: &str) -> Result +fn prompt_target( + reader: &mut R, + writer: &mut W, + prompt: &str, + default: Option, +) -> Result where R: BufRead, W: Write, { - let url = prompt_required(reader, writer, prompt)?; + let url = match default { + Some(default) => prompt_with_default(reader, writer, prompt, &default)?, + None => prompt_required(reader, writer, prompt)?, + }; let parsed = parse_profile_url(&url)?; let provider = known_provider_from_host(&parsed.host).unwrap_or_else(|| { prompt_provider(reader, writer, &parsed.base_url).expect("provider prompt failed")