[O] Better interactive config

This commit is contained in:
2026-05-08 00:52:37 +00:00
parent f0153ea396
commit 0bc4abf1a7
4 changed files with 577 additions and 56 deletions
+2 -2
View File
@@ -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 `<base-url>/-/user_settings/personal_access_tokens?name=git-sync&scopes=api`, create the token, then copy it.
- GitLab: open `<base-url>/-/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 `<base-url>/user/settings/applications`, create a token with repository access, then copy it.
- Forgejo: open `<base-url>/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:
+249 -23
View File
@@ -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<Vec<EndpointConfig>> {
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::<String>::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<thread::JoinHandle<()>>,
}
impl DemoWebhookServer {
fn start(listen: &str) -> Result<Self> {
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<WizardAction> {
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<WizardAction> {
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<bool> {
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<bool> {
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<ProfileTarget> {
let url = Input::<String>::with_theme(theme)
.with_prompt(prompt)
fn prompt_target_styled(
theme: &ColorfulTheme,
prompt: &str,
default: Option<String>,
) -> Result<ProfileTarget> {
let input = Input::<String>::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<String>
.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<String> {
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"),
+174 -8
View File
@@ -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")
);
}
+152 -23
View File
@@ -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<R, W>(
reader: &mut R,
writer: &mut W,
config: &mut Config,
existing: &[EndpointConfig],
) -> Result<Vec<EndpointConfig>>
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<R, W>(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<W>(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<R, W>(reader: &mut R, writer: &mut W) -> Result<WizardAction>
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<R, W>(reader: &mut R, writer: &mut W, config: &mut Config) -> Result<bool>
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::<usize>() {
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<R, W>(reader: &mut R, writer: &mut W, prompt: &str) -> Result<ProfileTarget>
fn prompt_target<R, W>(
reader: &mut R,
writer: &mut W,
prompt: &str,
default: Option<String>,
) -> Result<ProfileTarget>
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")