diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-05-22 16:20:25 +0100 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-05-22 17:09:47 +0100 |
| commit | 4dc5d0c9fb170981cf4fade5558d7cc8da404aa3 (patch) | |
| tree | 9f87365d5c479831a0fe7bb2db60a4440c166639 /src/bin | |
| parent | b6a161e1107d836d410d225d6700eeab38f12023 (diff) | |
feat(init): overhaul & simplify with ngit-relays
introduce ngit-relays as a way of setting git servers and relays at
the same time using a standard for specific repo locations:
https://<domain-port-path>/<npub>/<identifer>.git
add simple and advanced modes.
prompt less. eg always set remote origin to nostr url.
automatically push main or master branch.
Diffstat (limited to 'src/bin')
| -rw-r--r-- | src/bin/ngit/sub_commands/init.rs | 1113 |
1 files changed, 728 insertions, 385 deletions
diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs index bdecbe3..83e434f 100644 --- a/src/bin/ngit/sub_commands/init.rs +++ b/src/bin/ngit/sub_commands/init.rs | |||
| @@ -1,30 +1,31 @@ | |||
| 1 | use std::collections::{HashMap, HashSet}; | 1 | use std::{ |
| 2 | collections::HashMap, process::{Command, Stdio}, str::FromStr, thread, time::Duration | ||
| 3 | }; | ||
| 2 | 4 | ||
| 3 | use anyhow::{Context, Result}; | 5 | use anyhow::{Context, Result, bail}; |
| 4 | use console::{Style, Term}; | 6 | use console::Style; |
| 7 | use dialoguer::theme::{ColorfulTheme, Theme}; | ||
| 5 | use ngit::{ | 8 | use ngit::{ |
| 6 | cli_interactor::PromptConfirmParms, | 9 | cli_interactor::{PromptChoiceParms, PromptConfirmParms, PromptMultiChoiceParms}, |
| 7 | client::Params, | 10 | client::{send_events, Params}, |
| 8 | git::nostr_url::{NostrUrlDecoded, save_nip05_to_git_config_cache}, | 11 | git::nostr_url::{CloneUrl, NostrUrlDecoded}, repo_ref::{extract_pks, save_repo_config_to_yaml}, |
| 9 | }; | 12 | }; |
| 10 | use nostr::{ | 13 | use nostr::{ |
| 11 | FromBech32, PublicKey, ToBech32, | ||
| 12 | nips::{ | 14 | nips::{ |
| 13 | nip01::Coordinate, | 15 | nip01::Coordinate, |
| 14 | nip05::{self}, | ||
| 15 | nip19::Nip19Coordinate, | 16 | nip19::Nip19Coordinate, |
| 16 | }, | 17 | }, FromBech32, PublicKey, ToBech32 |
| 17 | }; | 18 | }; |
| 18 | use nostr_sdk::{Kind, RelayUrl}; | 19 | use nostr_sdk::{Kind, RelayUrl, Url}; |
| 19 | 20 | ||
| 20 | use crate::{ | 21 | use crate::{ |
| 21 | cli::{Cli, extract_signer_cli_arguments}, | 22 | cli::{Cli, extract_signer_cli_arguments}, |
| 22 | cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, | 23 | cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, |
| 23 | client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache, send_events}, | 24 | client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache}, |
| 24 | git::{Repo, RepoActions, nostr_url::convert_clone_url_to_https}, | 25 | git::{Repo, RepoActions, nostr_url::convert_clone_url_to_https}, |
| 25 | login, | 26 | login, |
| 26 | repo_ref::{ | 27 | repo_ref::{ |
| 27 | RepoRef, extract_pks, get_repo_config_from_yaml, save_repo_config_to_yaml, | 28 | RepoRef, get_repo_config_from_yaml, |
| 28 | try_and_get_repo_coordinates_when_remote_unknown, | 29 | try_and_get_repo_coordinates_when_remote_unknown, |
| 29 | }, | 30 | }, |
| 30 | }; | 31 | }; |
| @@ -107,43 +108,6 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 107 | )?, | 108 | )?, |
| 108 | }; | 109 | }; |
| 109 | 110 | ||
| 110 | let identifier = match &args.identifier { | ||
| 111 | Some(t) => t.clone(), | ||
| 112 | None => Interactor::default().input( | ||
| 113 | PromptInputParms::default() | ||
| 114 | .with_prompt( | ||
| 115 | "repo identifier (typically the short name with hypens instead of spaces)", | ||
| 116 | ) | ||
| 117 | .with_default(if let Some(repo_ref) = &repo_ref { | ||
| 118 | repo_ref.identifier.clone() | ||
| 119 | } else if let Some(repo_coordinate) = &repo_coordinate { | ||
| 120 | repo_coordinate.identifier.clone() | ||
| 121 | } else { | ||
| 122 | let fallback = name | ||
| 123 | .clone() | ||
| 124 | .replace(' ', "-") | ||
| 125 | .chars() | ||
| 126 | .map(|c| { | ||
| 127 | if c.is_ascii_alphanumeric() || c.eq(&'/') { | ||
| 128 | c | ||
| 129 | } else { | ||
| 130 | '-' | ||
| 131 | } | ||
| 132 | }) | ||
| 133 | .collect(); | ||
| 134 | if let Ok(config) = &repo_config_result { | ||
| 135 | if let Some(identifier) = &config.identifier { | ||
| 136 | identifier.to_string() | ||
| 137 | } else { | ||
| 138 | fallback | ||
| 139 | } | ||
| 140 | } else { | ||
| 141 | fallback | ||
| 142 | } | ||
| 143 | }), | ||
| 144 | )?, | ||
| 145 | }; | ||
| 146 | |||
| 147 | let description = match &args.description { | 111 | let description = match &args.description { |
| 148 | Some(t) => t.clone(), | 112 | Some(t) => t.clone(), |
| 149 | None => Interactor::default().input( | 113 | None => Interactor::default().input( |
| @@ -158,163 +122,89 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 158 | )?, | 122 | )?, |
| 159 | }; | 123 | }; |
| 160 | 124 | ||
| 161 | let maintainers: Vec<PublicKey> = { | 125 | // this is important so init can be completed done without prompts |
| 162 | let mut dont_ask_for_maintainers = !args.other_maintainers.is_empty(); | 126 | let has_server_and_relay_flags = !args.clone_url.is_empty() && !args.relays.is_empty(); |
| 163 | let mut maintainers_string = if !args.other_maintainers.is_empty() { | 127 | |
| 164 | [args.other_maintainers.clone()].concat().join(" ") | 128 | let simple_mode = if has_server_and_relay_flags { |
| 165 | } else if repo_ref.is_none() && repo_config_result.is_err() { | 129 | false |
| 166 | user_ref.public_key.to_bech32()? | 130 | } else { |
| 167 | } else { | 131 | Interactor::default().choice( |
| 168 | let maintainers = if let Ok(config) = &repo_config_result { | 132 | PromptChoiceParms::default() |
| 169 | config.maintainers.clone() | 133 | .with_prompt("config mode") |
| 170 | } else if let Some(repo_ref) = &repo_ref { | 134 | .with_choices(vec![ |
| 171 | repo_ref | 135 | "simple - all you need".to_string(), |
| 172 | .maintainers | 136 | "advanced - all the dials and switches".to_string(), |
| 173 | .clone() | 137 | ]) |
| 174 | .iter() | 138 | .with_default(0), |
| 175 | .map(|k| k.to_bech32().unwrap()) | 139 | )? == 0 |
| 176 | .collect() | 140 | }; |
| 177 | } else { | 141 | |
| 178 | //unreachable | 142 | let identifier_default = if let Some(repo_ref) = &repo_ref { |
| 179 | vec![user_ref.public_key.to_bech32()?] | 143 | repo_ref.identifier.clone() |
| 180 | }; | 144 | } else if let Some(repo_coordinate) = &repo_coordinate { |
| 181 | // add current user if not present | 145 | repo_coordinate.identifier.clone() |
| 182 | if maintainers.iter().any(|m| { | 146 | } else { |
| 183 | if let Ok(m_pubkey) = PublicKey::from_bech32(m) { | 147 | let fallback = name |
| 184 | user_ref.public_key.eq(&m_pubkey) | 148 | .clone() |
| 149 | .replace(' ', "-") | ||
| 150 | .chars() | ||
| 151 | .map(|c| { | ||
| 152 | if c.is_ascii_alphanumeric() || c.eq(&'/') { | ||
| 153 | c | ||
| 185 | } else { | 154 | } else { |
| 186 | false | 155 | '-' |
| 187 | } | 156 | } |
| 188 | }) { | 157 | }) |
| 189 | maintainers.join(" ") | 158 | .collect(); |
| 159 | if let Ok(config) = &repo_config_result { | ||
| 160 | if let Some(identifier) = &config.identifier { | ||
| 161 | identifier.to_string() | ||
| 190 | } else { | 162 | } else { |
| 191 | [maintainers, vec![user_ref.public_key.to_bech32()?]] | 163 | fallback |
| 192 | .concat() | ||
| 193 | .join(" ") | ||
| 194 | } | 164 | } |
| 195 | }; | 165 | } else { |
| 196 | 'outer: loop { | 166 | fallback |
| 197 | if !dont_ask_for_maintainers && user_ref.public_key.to_bech32()?.eq(&maintainers_string) | 167 | } |
| 198 | { | 168 | }; |
| 199 | if Interactor::default().confirm( | ||
| 200 | PromptConfirmParms::default() | ||
| 201 | .with_prompt("are you the only maintainer?") | ||
| 202 | .with_default(true), | ||
| 203 | )? { | ||
| 204 | dont_ask_for_maintainers = true; | ||
| 205 | } else { | ||
| 206 | let mut ask_about_state = false; | ||
| 207 | if !Interactor::default().confirm( | ||
| 208 | PromptConfirmParms::default() | ||
| 209 | .with_prompt("are the other maintainers on nostr?") | ||
| 210 | .with_default(true), | ||
| 211 | )? { | ||
| 212 | dont_ask_for_maintainers = true; | ||
| 213 | ask_about_state = true; | ||
| 214 | } else if !Interactor::default().confirm( | ||
| 215 | PromptConfirmParms::default() | ||
| 216 | .with_prompt( | ||
| 217 | "are you going to ask them to use ngit with this repository?", | ||
| 218 | ) | ||
| 219 | .with_default(true), | ||
| 220 | )? { | ||
| 221 | ask_about_state = true; | ||
| 222 | } | ||
| 223 | 169 | ||
| 224 | if ask_about_state { | 170 | let identifier = match &args.identifier { |
| 225 | println!( | 171 | Some(t) => t.clone(), |
| 226 | "nostr can reduce the trust placed in git servers by storing the state of git branches and tags. You can also use nostr-permissioned git servers. If you have other maintainers not using git via nostr, the verifiable state can fall behind the git server." | 172 | None => { |
| 227 | ); | 173 | if simple_mode { |
| 228 | if Interactor::default().confirm( | 174 | identifier_default |
| 229 | PromptConfirmParms::default() | 175 | } else { |
| 230 | .with_prompt("opt-out of storing git state on nostr and relay on git server for now? you will still receive PRs and issues via nostr") | 176 | Interactor::default().input( |
| 231 | .with_default(true), | 177 | PromptInputParms::default() |
| 232 | )? { | 178 | .with_prompt( |
| 233 | git_repo.save_git_config_item("nostr.nostate", "true", false)?; | 179 | "repo identifier (typically the short name with hypens instead of spaces)", |
| 234 | } | 180 | ) |
| 235 | } | 181 | .with_default(identifier_default), |
| 236 | } | 182 | )? |
| 237 | } | ||
| 238 | if !dont_ask_for_maintainers { | ||
| 239 | maintainers_string = Interactor::default().input( | ||
| 240 | PromptInputParms::default() | ||
| 241 | .with_prompt("maintainers - space seperated list of npubs") | ||
| 242 | .with_default(maintainers_string), | ||
| 243 | )?; | ||
| 244 | } | ||
| 245 | let mut maintainers: Vec<PublicKey> = vec![]; | ||
| 246 | for m in maintainers_string.split(' ') { | ||
| 247 | if let Ok(m_pubkey) = PublicKey::from_bech32(m) { | ||
| 248 | maintainers.push(m_pubkey); | ||
| 249 | } else { | ||
| 250 | println!("not a valid set of space seperated npubs"); | ||
| 251 | dont_ask_for_maintainers = false; | ||
| 252 | continue 'outer; | ||
| 253 | } | ||
| 254 | } | ||
| 255 | // add current user incase removed | ||
| 256 | if !maintainers.iter().any(|m| user_ref.public_key.eq(m)) { | ||
| 257 | maintainers.push(user_ref.public_key); | ||
| 258 | } | 183 | } |
| 259 | break maintainers; | ||
| 260 | } | 184 | } |
| 261 | }; | 185 | }; |
| 262 | 186 | ||
| 263 | let git_server = if args.clone_url.is_empty() { | 187 | let mut git_server_defaults: Vec<String> = if !args.clone_url.is_empty() { |
| 264 | let no_state = if let Ok(Some(s)) = git_repo.get_git_config_item("nostr.nostate", None) { | 188 | args.clone_url.clone() |
| 265 | s == "true" | 189 | } else if let Some(repo_ref) = &repo_ref { |
| 266 | } else { | 190 | // TODO dont default to git servers of other maintainers (?) |
| 267 | false | 191 | repo_ref.git_server.clone() |
| 268 | }; | 192 | } else if let Ok(url) = git_repo.get_origin_url() { |
| 269 | if no_state { | 193 | if let Ok(fetch_url) = convert_clone_url_to_https(&url) { |
| 270 | println!( | 194 | vec![fetch_url] |
| 271 | "you have opted out of storing git state on nostr, so a git server must be used for the state of authoritative branches, tags and related git objects. you can run `ngit init` again to change this later." | 195 | } else if url.starts_with("nostr://") { |
| 272 | ); | 196 | // nostr added as origin remote before repo announcement sent |
| 197 | vec![] | ||
| 273 | } else { | 198 | } else { |
| 274 | println!( | 199 | // local repo or custom protocol |
| 275 | "your repository state will be stored on nostr, but a git server is still required to store the git objects associated with this state." | 200 | vec![url] |
| 276 | ); | ||
| 277 | } | 201 | } |
| 278 | println!( | ||
| 279 | "you can change this git server at any time and even configure multiple servers for redundancy. In this case, the git plugin will push to all of them when using the nostr remote." | ||
| 280 | ); | ||
| 281 | println!("only maintainers need write access as PRs are sent over nostr."); | ||
| 282 | println!( | ||
| 283 | "a lightweight git server implementation for use with nostr, requiring no signup, is in development. several providers have shown interest in hosting it. for now use github, codeberg, or self-hosted song, forge, etc." | ||
| 284 | ); | ||
| 285 | Interactor::default() | ||
| 286 | .input( | ||
| 287 | PromptInputParms::default() | ||
| 288 | .with_prompt("git server remote url(s) (space seperated)") | ||
| 289 | .with_default(if let Some(repo_ref) = &repo_ref { | ||
| 290 | repo_ref.git_server.clone().join(" ") | ||
| 291 | } else if let Ok(url) = git_repo.get_origin_url() { | ||
| 292 | if let Ok(fetch_url) = convert_clone_url_to_https(&url) { | ||
| 293 | fetch_url | ||
| 294 | } else if url.starts_with("nostr://") { | ||
| 295 | // nostr added as origin remote before repo announcement sent | ||
| 296 | String::new() | ||
| 297 | } else { | ||
| 298 | // local repo or custom protocol | ||
| 299 | url | ||
| 300 | } | ||
| 301 | } else { | ||
| 302 | String::new() | ||
| 303 | }), | ||
| 304 | )? | ||
| 305 | .split(' ') | ||
| 306 | .map(std::string::ToString::to_string) | ||
| 307 | .collect() | ||
| 308 | } else { | 202 | } else { |
| 309 | args.clone_url.clone() | 203 | vec![] |
| 310 | }; | 204 | }; |
| 311 | 205 | ||
| 312 | // TODO: when NIP-66 is functional, use this to reccommend relays and filter out | 206 | let mut relay_defaults = if args.relays.is_empty() { |
| 313 | // relays that won't accept contributors events. NIP-11 'limitations' | 207 | if let Ok(config) = &repo_config_result { |
| 314 | // isn't widely used enough to be usedful. | ||
| 315 | |||
| 316 | let relays: Vec<RelayUrl> = { | ||
| 317 | let mut default = if let Ok(config) = &repo_config_result { | ||
| 318 | config.relays.clone() | 208 | config.relays.clone() |
| 319 | } else if let Some(repo_ref) = &repo_ref { | 209 | } else if let Some(repo_ref) = &repo_ref { |
| 320 | repo_ref | 210 | repo_ref |
| @@ -327,82 +217,333 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 327 | } else { | 217 | } else { |
| 328 | user_ref.relays.read().clone() | 218 | user_ref.relays.read().clone() |
| 329 | } | 219 | } |
| 330 | .join(" "); | 220 | } else { |
| 331 | 'outer: loop { | 221 | args.relays.clone() |
| 332 | let relays: Vec<String> = if args.relays.is_empty() { | 222 | }; |
| 333 | Interactor::default() | 223 | |
| 334 | .input( | 224 | |
| 335 | PromptInputParms::default() | 225 | let selected_ngit_relays = if has_server_and_relay_flags { |
| 336 | .with_prompt("relays") | 226 | // ignore so a script running `ngit init` can contiue without prompts |
| 337 | .with_default(default), | 227 | vec![] |
| 338 | )? | 228 | } else { |
| 339 | .split(' ') | 229 | let mut options: Vec<String> = guess_at_existing_ngit_relays( |
| 340 | .map(std::string::ToString::to_string) | 230 | repo_ref.as_ref(), |
| 231 | &args.relays, | ||
| 232 | &args.clone_url, | ||
| 233 | &identifier, | ||
| 234 | ); | ||
| 235 | let mut selections: Vec<bool> = vec![true; options.len()]; // Initialize selections based on existing options | ||
| 236 | let empty = options.is_empty(); | ||
| 237 | let fallbacks = vec!["relay.ngit.dev".to_string(), "gitnostr.com".to_string()]; | ||
| 238 | for fallback in fallbacks { | ||
| 239 | // Check if any option contains the fallback as a substring | ||
| 240 | if !options.iter().any(|option| option.contains(&fallback)) { | ||
| 241 | options.push(fallback.clone()); // Add fallback if not found | ||
| 242 | selections.push(empty); // mark as selected if no existing ngit relay otherwise not | ||
| 243 | } | ||
| 244 | } | ||
| 245 | let selected = multi_select_with_custom_value( | ||
| 246 | "ngit-relays (ideally use between 2-4)", | ||
| 247 | "ngit-relay", | ||
| 248 | options, | ||
| 249 | selections, | ||
| 250 | normalize_ngit_relay_url, | ||
| 251 | )?; | ||
| 252 | show_multi_input_prompt_success("ngit-relays", &selected); | ||
| 253 | selected | ||
| 254 | }; | ||
| 255 | |||
| 256 | // ensure ngit relays are added as git server, relay and blossom entries | ||
| 257 | for ngit_relay in &selected_ngit_relays { | ||
| 258 | if args.clone_url.is_empty() { | ||
| 259 | let clone_url = format_ngit_relay_url_as_clone_url(ngit_relay, &user_ref.public_key, &identifier)?; | ||
| 260 | if !git_server_defaults.contains(&clone_url) { | ||
| 261 | git_server_defaults.push(clone_url); | ||
| 262 | } | ||
| 263 | } | ||
| 264 | if args.clone_url.is_empty() { | ||
| 265 | let relay_url = format_ngit_relay_url_as_relay_url(ngit_relay)?; | ||
| 266 | if !relay_defaults.contains(&relay_url) { | ||
| 267 | relay_defaults.push(relay_url); | ||
| 268 | } | ||
| 269 | } | ||
| 270 | // TODO blossom | ||
| 271 | } | ||
| 272 | |||
| 273 | let no_state = if let Ok(Some(s)) = git_repo.get_git_config_item("nostr.nostate", None) { | ||
| 274 | s == "true" | ||
| 275 | } else { | ||
| 276 | false | ||
| 277 | }; | ||
| 278 | if no_state && Interactor::default().confirm( | ||
| 279 | PromptConfirmParms::default() | ||
| 280 | .with_prompt("store state on nostr? required for nostr-permissioned git servers") | ||
| 281 | .with_default(true), | ||
| 282 | )?{ | ||
| 283 | // TODO check if ngit-relays in use and if so turn this off: | ||
| 284 | if git_repo.get_git_config_item("nostr.nostate",Some(true)).unwrap_or(None).is_some() { | ||
| 285 | git_repo.remove_git_config_item("nostr.nostate", true)?; | ||
| 286 | } else { | ||
| 287 | git_repo.remove_git_config_item("nostr.nostate", false)?; | ||
| 288 | } | ||
| 289 | } | ||
| 290 | |||
| 291 | let git_server = if args.clone_url.is_empty() { | ||
| 292 | let ngit_relay_git_servers: Vec<String> = git_server_defaults.iter().filter(|s| selected_ngit_relays.iter().any(|r|s.contains(r))).cloned().collect(); | ||
| 293 | let mut additional_server_options: Vec<String> = git_server_defaults.iter().filter(|s| ngit_relay_git_servers.iter().any(|r|s.eq(&r))).cloned().collect(); | ||
| 294 | |||
| 295 | if simple_mode && !selected_ngit_relays.is_empty() { | ||
| 296 | if additional_server_options.is_empty() { | ||
| 297 | // additional git servers were listed | ||
| 298 | let selected = loop { | ||
| 299 | let selections: Vec<bool> = vec![true; additional_server_options.len()]; | ||
| 300 | let selected = multi_select_with_custom_value( | ||
| 301 | "additional git server(s) on top of ngit-relays", | ||
| 302 | "git server remote url", | ||
| 303 | additional_server_options, | ||
| 304 | selections, | ||
| 305 | |s| { | ||
| 306 | CloneUrl::from_str(s) | ||
| 307 | .map(|_| s.to_string()) | ||
| 308 | .context(format!("Invalid git server URL format: {s}")) | ||
| 309 | }, | ||
| 310 | )?; | ||
| 311 | |||
| 312 | if !selected.is_empty() || Interactor::default().choice( | ||
| 313 | PromptChoiceParms::default() | ||
| 314 | .with_prompt("if you or another maintainer start pushing directly to these, nostr will be out of date") | ||
| 315 | .dont_report() | ||
| 316 | .with_choices(vec![ | ||
| 317 | "I'll always push to the nostr remote".to_string(), | ||
| 318 | "change setup".to_string(), | ||
| 319 | ]) | ||
| 320 | .with_default(0), | ||
| 321 | )? == 1 { | ||
| 322 | additional_server_options = selected; | ||
| 323 | continue | ||
| 324 | } | ||
| 325 | break selected | ||
| 326 | }; | ||
| 327 | show_multi_input_prompt_success("git servers", &selected); | ||
| 328 | let mut combined = ngit_relay_git_servers; | ||
| 329 | combined.extend(selected); | ||
| 330 | combined | ||
| 331 | } else { | ||
| 332 | git_server_defaults | ||
| 333 | } | ||
| 334 | } else { | ||
| 335 | // show all git servers | ||
| 336 | let selections: Vec<bool> = vec![true; git_server_defaults.len()]; | ||
| 337 | |||
| 338 | let selected = multi_select_with_custom_value( | ||
| 339 | "git server remote url(s)", | ||
| 340 | "git server remote url", | ||
| 341 | git_server_defaults, | ||
| 342 | selections, | ||
| 343 | |s| { | ||
| 344 | CloneUrl::from_str(s) | ||
| 345 | .map(|_| s.to_string()) | ||
| 346 | .context(format!("Invalid git server URL format: {s}")) | ||
| 347 | }, | ||
| 348 | )?; | ||
| 349 | show_multi_input_prompt_success("git servers", &selected); | ||
| 350 | selected | ||
| 351 | } | ||
| 352 | } else { | ||
| 353 | git_server_defaults | ||
| 354 | }; | ||
| 355 | |||
| 356 | let relays: Vec<RelayUrl> = { | ||
| 357 | if simple_mode { | ||
| 358 | let formatted_selected_ngit_relays: Vec<String> = selected_ngit_relays.iter() | ||
| 359 | .filter_map(|r| format_ngit_relay_url_as_relay_url(r).ok()) | ||
| 360 | .collect(); | ||
| 361 | let mut options: Vec<String> = relay_defaults.iter() | ||
| 362 | .filter(|s| !formatted_selected_ngit_relays.iter().any(|r| s.as_str() == r)) | ||
| 363 | .cloned() | ||
| 364 | .collect(); | ||
| 365 | |||
| 366 | let mut selections: Vec<bool> = vec![true; options.len()]; | ||
| 367 | |||
| 368 | // add fallback relays as options | ||
| 369 | for relay in client.get_fallback_relays().clone() { | ||
| 370 | if !options.iter().any(|r|r.contains(&relay)) && !formatted_selected_ngit_relays.iter().any(|r|relay.contains(r)) { | ||
| 371 | options.push(relay); | ||
| 372 | selections.push(selections.is_empty()); | ||
| 373 | } | ||
| 374 | } | ||
| 375 | |||
| 376 | let selected = multi_select_with_custom_value( | ||
| 377 | "additional nostr relays on top of nostr-relays - 1 or 2 public relays are reccomended", | ||
| 378 | "nostr relay", | ||
| 379 | options, | ||
| 380 | selections, | ||
| 381 | |s| { | ||
| 382 | parse_relay_url(s) | ||
| 383 | .map(|_| s.to_string()) | ||
| 384 | .context(format!("Invalid relay URL format: {s}")) | ||
| 385 | }, | ||
| 386 | )?; | ||
| 387 | show_multi_input_prompt_success("additional nostr relays", &selected); | ||
| 388 | selected.iter() | ||
| 389 | .filter_map(|r| parse_relay_url(r).ok()) | ||
| 390 | .collect() | ||
| 391 | } else { | ||
| 392 | |||
| 393 | let selections: Vec<bool> = vec![true; relay_defaults.len()]; | ||
| 394 | if args.relays.is_empty() { | ||
| 395 | let selected = multi_select_with_custom_value( | ||
| 396 | "nostr relays", | ||
| 397 | "nostr relay", | ||
| 398 | relay_defaults, | ||
| 399 | selections, | ||
| 400 | |s| { | ||
| 401 | parse_relay_url(s) | ||
| 402 | .map(|_| s.to_string()) | ||
| 403 | .context(format!("Invalid relay URL format: {s}")) | ||
| 404 | }, | ||
| 405 | )?; | ||
| 406 | show_multi_input_prompt_success("nostr relays", &selected); | ||
| 407 | selected.iter() | ||
| 408 | .filter_map(|r| parse_relay_url(r).ok()) | ||
| 341 | .collect() | 409 | .collect() |
| 342 | } else { | 410 | } else { |
| 343 | args.relays.clone() | 411 | relay_defaults |
| 344 | }; | 412 | .iter() |
| 345 | let mut relay_urls = vec![]; | 413 | .filter_map(|r| parse_relay_url(r).ok()) |
| 346 | for r in &relays { | 414 | .collect() |
| 347 | if let Ok(r) = RelayUrl::parse(r) { | 415 | } |
| 348 | relay_urls.push(r); | 416 | } |
| 349 | } else { | 417 | }; |
| 350 | eprintln!("{r} is not a valid relay url"); | 418 | |
| 351 | default = relays.join(" "); | 419 | let default_maintainers = { |
| 352 | continue 'outer; | 420 | let mut maintainers = vec![user_ref.public_key]; |
| 421 | if args.other_maintainers.is_empty() { | ||
| 422 | if let Some(repo_ref) = &repo_ref { | ||
| 423 | for m in &repo_ref.maintainers { | ||
| 424 | if !maintainers.contains(m) { | ||
| 425 | maintainers.push(*m); | ||
| 426 | } | ||
| 427 | } | ||
| 428 | } | ||
| 429 | } else { | ||
| 430 | for m in &args.other_maintainers { | ||
| 431 | if let Ok(pubkey) = PublicKey::from_bech32(m).context("invalid npub") { | ||
| 432 | if !maintainers.contains(&pubkey) { | ||
| 433 | maintainers.push(pubkey); | ||
| 434 | } | ||
| 353 | } | 435 | } |
| 354 | } | 436 | } |
| 355 | break relay_urls; | ||
| 356 | } | 437 | } |
| 438 | maintainers | ||
| 357 | }; | 439 | }; |
| 358 | 440 | ||
| 359 | let web: Vec<String> = if args.web.is_empty() { | 441 | let maintainers: Vec<PublicKey> = if args.other_maintainers.is_empty() { |
| 360 | let gitworkshop_url = NostrUrlDecoded { | 442 | if default_maintainers.len() == 1 |
| 361 | original_string: String::new(), | 443 | && Interactor::default().choice( |
| 362 | coordinate: Nip19Coordinate { | 444 | PromptChoiceParms::default() |
| 363 | coordinate: Coordinate { | 445 | .with_prompt("add other maintainers now?") |
| 364 | public_key: user_ref.public_key, | 446 | .dont_report() |
| 365 | kind: Kind::GitRepoAnnouncement, | 447 | .with_choices(vec![ |
| 366 | identifier: identifier.clone(), | 448 | "maybe later".to_string(), |
| 367 | }, | 449 | "add maintainers".to_string(), |
| 368 | relays: if let Some(relay) = relays.first() { | 450 | ]) |
| 369 | vec![relay.clone()] | 451 | .with_default(0), |
| 370 | } else { | 452 | )? == 0 |
| 371 | vec![] | 453 | { |
| 454 | default_maintainers | ||
| 455 | } else { | ||
| 456 | let selections: Vec<bool> = vec![true; default_maintainers.len()]; | ||
| 457 | |||
| 458 | let selected = multi_select_with_custom_value( | ||
| 459 | "maintainers", | ||
| 460 | "maintainer npub", | ||
| 461 | default_maintainers | ||
| 462 | .iter() | ||
| 463 | .filter_map(|m| m.to_bech32().ok()) | ||
| 464 | .collect(), | ||
| 465 | selections, | ||
| 466 | |s| { | ||
| 467 | extract_npub(s) | ||
| 468 | .map(|_| s.to_string()) | ||
| 469 | .context(format!("Invalid npub: {s}")) | ||
| 372 | }, | 470 | }, |
| 373 | }, | 471 | )?; |
| 374 | protocol: None, | 472 | show_multi_input_prompt_success("maintainers", &selected); |
| 375 | user: None, | 473 | selected.iter() |
| 376 | nip05: None, | 474 | .filter_map(|npub| PublicKey::parse(npub).ok()) |
| 475 | .collect() | ||
| 377 | } | 476 | } |
| 477 | } else { | ||
| 478 | default_maintainers | ||
| 479 | }; | ||
| 480 | |||
| 481 | if selected_ngit_relays.is_empty() && git_server.iter().any(|s| s.contains("github.com") || s.contains("codeberg.org")) && Interactor::default().confirm( | ||
| 482 | PromptConfirmParms::default() | ||
| 483 | .with_prompt("you have listed github / codeberg. Are you or other maintainers planning on pushing directly to github / codeberg rather than using your shiny new nostr clone url which will do this for you?") | ||
| 484 | .with_default(false), | ||
| 485 | )? { | ||
| 486 | println!("This means people using the nostr URL won't get your latest branch updates."); | ||
| 487 | if Interactor::default().confirm( | ||
| 488 | PromptConfirmParms::default() | ||
| 489 | .with_prompt("opt-out of storing git state on nostr and relay on github for now? you will still receive PRs and issues via nostr") | ||
| 490 | .with_default(true), | ||
| 491 | )? { | ||
| 492 | git_repo.save_git_config_item("nostr.nostate", "true", false)?; | ||
| 493 | } | ||
| 494 | } | ||
| 495 | |||
| 496 | let gitworkshop_url = NostrUrlDecoded { | ||
| 497 | original_string: String::new(), | ||
| 498 | coordinate: Nip19Coordinate { | ||
| 499 | coordinate: Coordinate { | ||
| 500 | public_key: user_ref.public_key, | ||
| 501 | kind: Kind::GitRepoAnnouncement, | ||
| 502 | identifier: identifier.clone(), | ||
| 503 | }, | ||
| 504 | relays: if let Some(relay) = relays.first() { | ||
| 505 | vec![relay.clone()] | ||
| 506 | } else { | ||
| 507 | vec![] | ||
| 508 | }, | ||
| 509 | }, | ||
| 510 | protocol: None, | ||
| 511 | user: None, | ||
| 512 | nip05: None, | ||
| 513 | } | ||
| 378 | .to_string() | 514 | .to_string() |
| 379 | .replace("nostr://", "https://gitworkshop.dev/"); | 515 | .replace("nostr://", "https://gitworkshop.dev/"); |
| 380 | Interactor::default() | 516 | |
| 381 | .input( | 517 | let web: Vec<String> = if args.web.is_empty() { |
| 518 | let web_default = if let Some(repo_ref) = &repo_ref { | ||
| 519 | if repo_ref | ||
| 520 | .web | ||
| 521 | .clone() | ||
| 522 | .join(" ") | ||
| 523 | // replace legacy gitworkshop.dev url format with new one | ||
| 524 | .contains(format!("https://gitworkshop.dev/repo/{}", &identifier).as_str()) | ||
| 525 | { | ||
| 526 | gitworkshop_url.clone() | ||
| 527 | } else { | ||
| 528 | repo_ref.web.clone().join(" ") | ||
| 529 | } | ||
| 530 | } else { | ||
| 531 | gitworkshop_url.clone() | ||
| 532 | }; | ||
| 533 | |||
| 534 | if simple_mode { | ||
| 535 | web_default | ||
| 536 | } else { | ||
| 537 | Interactor::default().input( | ||
| 382 | PromptInputParms::default() | 538 | PromptInputParms::default() |
| 383 | .with_prompt("repo website") | 539 | .with_prompt("repo website") |
| 384 | .optional() | 540 | .optional() |
| 385 | .with_default(if let Some(repo_ref) = &repo_ref { | 541 | .with_default(web_default), |
| 386 | if repo_ref | ||
| 387 | .web | ||
| 388 | .clone() | ||
| 389 | .join(" ") | ||
| 390 | // replace legacy gitworkshop.dev url format with new one | ||
| 391 | .contains( | ||
| 392 | format!("https://gitworkshop.dev/repo/{}", &identifier).as_str(), | ||
| 393 | ) | ||
| 394 | { | ||
| 395 | gitworkshop_url | ||
| 396 | } else { | ||
| 397 | repo_ref.web.clone().join(" ") | ||
| 398 | } | ||
| 399 | } else { | ||
| 400 | gitworkshop_url | ||
| 401 | }), | ||
| 402 | )? | 542 | )? |
| 403 | .split(' ') | 543 | } |
| 404 | .map(std::string::ToString::to_string) | 544 | .split(' ') |
| 405 | .collect() | 545 | .map(std::string::ToString::to_string) |
| 546 | .collect() | ||
| 406 | } else { | 547 | } else { |
| 407 | args.web.clone() | 548 | args.web.clone() |
| 408 | }; | 549 | }; |
| @@ -415,32 +556,36 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 415 | } else { | 556 | } else { |
| 416 | root_commit.to_string() | 557 | root_commit.to_string() |
| 417 | }; | 558 | }; |
| 418 | println!( | 559 | if simple_mode { |
| 419 | "the earliest unique commit helps with discoverability. It defaults to the root commit. Only change this if your repo has completely forked off an has formed its own identity." | 560 | earliest_unique_commit |
| 420 | ); | 561 | } else { |
| 421 | loop { | 562 | println!( |
| 422 | earliest_unique_commit = Interactor::default().input( | 563 | "the earliest unique commit helps with discoverability. It defaults to the root commit. Only change this if your repo has completely forked off an has formed its own identity." |
| 423 | PromptInputParms::default() | 564 | ); |
| 424 | .with_prompt("earliest unique commit (to help with discoverability)") | 565 | loop { |
| 425 | .with_default(earliest_unique_commit.clone()), | 566 | earliest_unique_commit = Interactor::default().input( |
| 426 | )?; | 567 | PromptInputParms::default() |
| 427 | if let Ok(exists) = git_repo.does_commit_exist(&earliest_unique_commit) { | 568 | .with_prompt("earliest unique commit (to help with discoverability)") |
| 428 | if exists { | 569 | .with_default(earliest_unique_commit.clone()), |
| 429 | break earliest_unique_commit; | 570 | )?; |
| 571 | if let Ok(exists) = git_repo.does_commit_exist(&earliest_unique_commit) { | ||
| 572 | if exists { | ||
| 573 | break earliest_unique_commit; | ||
| 574 | } | ||
| 575 | println!("commit does not exist on current repository"); | ||
| 576 | } else { | ||
| 577 | println!("commit id not formatted correctly"); | ||
| 578 | } | ||
| 579 | if earliest_unique_commit.len().ne(&40) { | ||
| 580 | println!("commit id must be 40 characters long"); | ||
| 430 | } | 581 | } |
| 431 | println!("commit does not exist on current repository"); | ||
| 432 | } else { | ||
| 433 | println!("commit id not formatted correctly"); | ||
| 434 | } | ||
| 435 | if earliest_unique_commit.len().ne(&40) { | ||
| 436 | println!("commit id must be 40 characters long"); | ||
| 437 | } | 582 | } |
| 438 | } | 583 | } |
| 439 | }; | 584 | }; |
| 440 | 585 | ||
| 441 | println!("publishing repostory reference..."); | 586 | println!("publishing repostory reference..."); |
| 442 | 587 | ||
| 443 | let mut repo_ref = RepoRef { | 588 | let repo_ref = RepoRef { |
| 444 | identifier: identifier.clone(), | 589 | identifier: identifier.clone(), |
| 445 | name, | 590 | name, |
| 446 | description, | 591 | description, |
| @@ -483,70 +628,34 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 483 | false, | 628 | false, |
| 484 | )?; | 629 | )?; |
| 485 | 630 | ||
| 486 | // if nip05 valid, set nostr git url to use that format | 631 | // set origin remote |
| 487 | let hint_for_nip05_address = { | 632 | let nostr_url = repo_ref.to_nostr_git_url(&Some(&git_repo)).to_string(); |
| 488 | if let Some(nip05) = user_ref.metadata.nip05 { | ||
| 489 | let term = Term::stdout(); | ||
| 490 | term.write_line(&format!("fetching nip05 details for {nip05}..."))?; | ||
| 491 | if let Ok(nprofile) = nip05::profile(nip05.clone(), None).await { | ||
| 492 | let _ = term.clear_last_lines(1); | ||
| 493 | let _ = | ||
| 494 | save_nip05_to_git_config_cache(&nip05, &nprofile.public_key, &Some(&git_repo)); | ||
| 495 | // Normalize URLs before doing the intersection. | ||
| 496 | let repo_relays: HashSet<RelayUrl> = relays | ||
| 497 | .iter() | ||
| 498 | .map(|r| RelayUrl::parse(r.as_str_without_trailing_slash()).unwrap()) | ||
| 499 | .collect(); | ||
| 500 | let nip05_relays: HashSet<RelayUrl> = nprofile | ||
| 501 | .relays | ||
| 502 | .iter() | ||
| 503 | .map(|r| RelayUrl::parse(r.as_str_without_trailing_slash()).unwrap()) | ||
| 504 | .collect(); | ||
| 505 | let mut inter = repo_relays.intersection(&nip05_relays); | ||
| 506 | |||
| 507 | repo_ref.set_nostr_git_url(NostrUrlDecoded { | ||
| 508 | original_string: String::new(), | ||
| 509 | nip05: Some(nip05.clone()), | ||
| 510 | coordinate: Nip19Coordinate { | ||
| 511 | coordinate: Coordinate { | ||
| 512 | kind: Kind::GitRepoAnnouncement, | ||
| 513 | public_key: user_ref.public_key, | ||
| 514 | identifier: repo_ref.identifier.clone(), | ||
| 515 | }, | ||
| 516 | relays: if inter.next().is_some() || relays.is_empty() { | ||
| 517 | vec![] | ||
| 518 | } else { | ||
| 519 | vec![relays.first().unwrap().clone()] | ||
| 520 | }, | ||
| 521 | }, | ||
| 522 | protocol: None, | ||
| 523 | user: None, | ||
| 524 | }); | ||
| 525 | if inter.next().is_some() { | ||
| 526 | "note: point your NIP-05 relays to one of the repo relays for a cleaner nostr:// remote URL.".to_string() | ||
| 527 | } else { | ||
| 528 | String::new() | ||
| 529 | } | ||
| 530 | } else { | ||
| 531 | "note: could not validate your nip05 address {nip05} which could be used for a shorter nostr:// remote URL.".to_string() | ||
| 532 | } | ||
| 533 | } else { | ||
| 534 | String::new() | ||
| 535 | } | ||
| 536 | }; | ||
| 537 | 633 | ||
| 538 | prompt_to_set_nostr_url_as_origin(&repo_ref, &git_repo).await?; | 634 | if git_repo.git_repo.find_remote("origin").is_ok() { |
| 635 | git_repo.git_repo.remote_set_url("origin", &nostr_url)?; | ||
| 636 | } else { | ||
| 637 | git_repo.git_repo.remote("origin",&nostr_url)?; | ||
| 638 | } | ||
| 639 | thread::sleep(Duration::new(1, 0)); // wait for annoucment event to be receieved and processed by ngit-relays | ||
| 539 | 640 | ||
| 540 | if !hint_for_nip05_address.is_empty() { | 641 | if std::env::var("NGITTEST").is_err() { // ignore during tests as git-remote-nostr isn't installed during ngit binary tests |
| 541 | println!("{hint_for_nip05_address}"); | 642 | if let Err(err) = push_main_or_master_branch(&git_repo) { |
| 643 | println!("your repository announcement was published to nostr but git push exited with an error: {err}"); | ||
| 644 | } | ||
| 542 | } | 645 | } |
| 543 | 646 | ||
| 544 | // TODO: if no state event exists and there is currently a remote called | 647 | // println!( |
| 545 | // "origin", automtically push rather than waiting for the next commit | 648 | // "any remote branches beginning with `pr/` are open PRs from contributors. they can submit these by simply pushing a branch with this `pr/` prefix." |
| 649 | // ); | ||
| 650 | println!("share your repository: {gitworkshop_url}" ); | ||
| 651 | println!("clone url: {nostr_url}"); | ||
| 652 | |||
| 546 | 653 | ||
| 547 | // no longer create a new maintainers.yaml file - its too confusing for users | 654 | // no longer create a new maintainers.yaml file - its too confusing for users |
| 548 | // as it falls out of sync with data in nostr event . update if it already | 655 | // as it falls out of sync with data in nostr event . update if it already |
| 549 | // exists | 656 | // exists |
| 657 | |||
| 658 | |||
| 550 | let relays = relays | 659 | let relays = relays |
| 551 | .iter() | 660 | .iter() |
| 552 | .map(std::string::ToString::to_string) | 661 | .map(std::string::ToString::to_string) |
| @@ -584,74 +693,308 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 584 | Ok(()) | 693 | Ok(()) |
| 585 | } | 694 | } |
| 586 | 695 | ||
| 587 | async fn prompt_to_set_nostr_url_as_origin(repo_ref: &RepoRef, git_repo: &Repo) -> Result<()> { | 696 | fn multi_select_with_custom_value<F>( |
| 588 | println!( | 697 | prompt: &str, |
| 589 | "starting from your next commit, when you `git push` to a remote that uses your nostr url, it will store your repository state on nostr and update the state of the git server(s) you just listed." | 698 | custom_choice_prompt: &str, |
| 590 | ); | 699 | mut choices: Vec<String>, |
| 591 | println!( | 700 | mut defaults: Vec<bool>, |
| 592 | "in addition, any remote branches beginning with `pr/` are open PRs from contributors. they can submit these by simply pushing a branch with this `pr/` prefix." | 701 | validate_choice: F, |
| 593 | ); | 702 | ) -> Result<Vec<String>> |
| 594 | 703 | where | |
| 595 | if let Ok(origin_remote) = git_repo.git_repo.find_remote("origin") { | 704 | F: Fn(&str) -> Result<String>, |
| 596 | if let Some(origin_url) = origin_remote.url() { | 705 | { |
| 597 | if let Ok(nostr_url) = | 706 | let mut selected_choices = vec![]; |
| 598 | NostrUrlDecoded::parse_and_resolve(origin_url, &Some(git_repo)).await | 707 | |
| 599 | { | 708 | // Loop to allow users to add more choices |
| 600 | if nostr_url.coordinate.identifier == repo_ref.identifier { | 709 | loop { |
| 601 | if nostr_url.coordinate.public_key == repo_ref.trusted_maintainer { | 710 | // Add 'add another' option at the end of the choices |
| 602 | return Ok(()); | 711 | let mut current_choices = choices.clone(); |
| 712 | current_choices.push(if current_choices.is_empty() { | ||
| 713 | "add".to_string() | ||
| 714 | } else { | ||
| 715 | "add another".to_string() | ||
| 716 | }); | ||
| 717 | |||
| 718 | // Create default selections based on the provided defaults | ||
| 719 | let mut current_defaults = defaults.clone(); | ||
| 720 | current_defaults.push(current_choices.len() == 1); // 'add another' should not be selected by default | ||
| 721 | |||
| 722 | // Prompt for selections | ||
| 723 | let selected_indices: Vec<usize> = Interactor::default().multi_choice( | ||
| 724 | PromptMultiChoiceParms::default() | ||
| 725 | .with_prompt(prompt) | ||
| 726 | .dont_report() | ||
| 727 | .with_choices(current_choices.clone()) | ||
| 728 | .with_defaults(current_defaults), | ||
| 729 | )?; | ||
| 730 | |||
| 731 | // Collect selected choices | ||
| 732 | selected_choices.clear(); // Clear previous selections to update | ||
| 733 | for &index in &selected_indices { | ||
| 734 | if index < choices.len() { | ||
| 735 | // Exclude 'add another' option | ||
| 736 | selected_choices.push(choices[index].clone()); | ||
| 737 | } | ||
| 738 | } | ||
| 739 | |||
| 740 | // Check if 'add another' was selected | ||
| 741 | if selected_indices.contains(&(choices.len())) { | ||
| 742 | // Last index is 'add another' | ||
| 743 | let mut new_choice: String; | ||
| 744 | loop { | ||
| 745 | new_choice = Interactor::default().input( | ||
| 746 | PromptInputParms::default() | ||
| 747 | .with_prompt(custom_choice_prompt) | ||
| 748 | .dont_report() | ||
| 749 | .optional(), | ||
| 750 | )?; | ||
| 751 | |||
| 752 | if new_choice.is_empty() { | ||
| 753 | break; | ||
| 754 | } | ||
| 755 | // Validate the new choice | ||
| 756 | match validate_choice(&new_choice) { | ||
| 757 | Ok(valid_choice) => { | ||
| 758 | new_choice = valid_choice; // Use the fixed version of the input | ||
| 759 | break; // Valid choice, exit the loop | ||
| 760 | } | ||
| 761 | Err(err) => { | ||
| 762 | // Inform the user about the validation error | ||
| 763 | println!("Error: {err}"); | ||
| 603 | } | 764 | } |
| 604 | // origin is set to a different trusted maintainer | ||
| 605 | println!( | ||
| 606 | "warning: currently git remote 'origin' is set to a different trusted maintainer with the same identifier" | ||
| 607 | ); | ||
| 608 | ask_to_set_origin_remote(repo_ref, git_repo)?; | ||
| 609 | } else { | ||
| 610 | // origin is linked to a different identifier | ||
| 611 | println!( | ||
| 612 | "warning: currently git remote 'origin' is set to a different repository identifier" | ||
| 613 | ); | ||
| 614 | ask_to_set_origin_remote(repo_ref, git_repo)?; | ||
| 615 | } | 765 | } |
| 616 | } else { | 766 | } |
| 617 | // remote is non-nostr url | 767 | |
| 618 | ask_to_set_origin_remote(repo_ref, git_repo)?; | 768 | // Add the new choice to the choices vector |
| 769 | if !new_choice.is_empty() { | ||
| 770 | choices.push(new_choice.clone()); // Add new choice to the end of the list | ||
| 771 | selected_choices.push(new_choice); // Automatically select the new choice | ||
| 772 | defaults.push(true); // Set the new choice as selected by default | ||
| 619 | } | 773 | } |
| 620 | } else { | 774 | } else { |
| 621 | // no origin remote | 775 | // Exit the loop if 'add another' was not selected |
| 622 | ask_to_create_new_origin_remote(repo_ref, git_repo)?; | 776 | break; |
| 623 | } | 777 | } |
| 624 | } | 778 | } |
| 625 | println!("contributors can clone your repository by installing ngit and using this clone url:"); | ||
| 626 | println!("{}", repo_ref.to_nostr_git_url(&Some(git_repo))); | ||
| 627 | 779 | ||
| 628 | Ok(()) | 780 | Ok(selected_choices) |
| 629 | } | 781 | } |
| 630 | 782 | ||
| 631 | fn ask_to_set_origin_remote(repo_ref: &RepoRef, git_repo: &Repo) -> Result<()> { | 783 | fn guess_at_existing_ngit_relays( |
| 632 | if Interactor::default().confirm( | 784 | repo_ref: Option<&RepoRef>, |
| 633 | PromptConfirmParms::default() | 785 | args_relays: &[String], |
| 634 | .with_default(true) | 786 | args_clone_url: &[String], |
| 635 | .with_prompt("set remote \"origin\" to the nostr url of your repository?"), | 787 | identifier: &str, |
| 636 | )? { | 788 | ) -> Vec<String> { |
| 637 | git_repo.git_repo.remote_set_url( | 789 | // Collect clone URLs from arguments or repo_ref |
| 638 | "origin", | 790 | let clone_urls: Vec<String> = if !args_clone_url.is_empty() { |
| 639 | &repo_ref.to_nostr_git_url(&Some(git_repo)).to_string(), | 791 | args_clone_url.to_vec() |
| 640 | )?; | 792 | } else if let Some(repo) = repo_ref { |
| 793 | repo.git_server.clone() | ||
| 794 | } else { | ||
| 795 | Vec::new() | ||
| 796 | }; | ||
| 797 | |||
| 798 | // Collect relays from arguments or repo_ref | ||
| 799 | let relays: Vec<RelayUrl> = if !args_relays.is_empty() { | ||
| 800 | args_relays | ||
| 801 | .iter() | ||
| 802 | .filter_map(|r| RelayUrl::parse(r).ok()) | ||
| 803 | .collect() | ||
| 804 | } else if let Some(repo) = repo_ref { | ||
| 805 | repo.relays.clone() | ||
| 806 | } else { | ||
| 807 | Vec::new() | ||
| 808 | }; | ||
| 809 | |||
| 810 | let mut existing_ngit_relays = Vec::new(); | ||
| 811 | for url in &clone_urls { | ||
| 812 | if let Ok(npub) = extract_npub(url) { | ||
| 813 | let postfix = format!("/{npub}/{identifier}.git"); | ||
| 814 | if url.contains(&postfix) { | ||
| 815 | if let Ok(ngit_relay_url) = normalize_ngit_relay_url(url) { | ||
| 816 | let is_also_relay = relays.iter() | ||
| 817 | .any(|r| normalize_ngit_relay_url(&r.to_string()).is_ok_and(|r| r.eq(&ngit_relay_url))); | ||
| 818 | if !existing_ngit_relays.contains(&ngit_relay_url) && is_also_relay { | ||
| 819 | existing_ngit_relays.push(ngit_relay_url); | ||
| 820 | |||
| 821 | } | ||
| 822 | } | ||
| 823 | } | ||
| 824 | } | ||
| 641 | } | 825 | } |
| 642 | Ok(()) | 826 | existing_ngit_relays |
| 643 | } | 827 | } |
| 644 | 828 | ||
| 645 | fn ask_to_create_new_origin_remote(repo_ref: &RepoRef, git_repo: &Repo) -> Result<()> { | 829 | fn normalize_ngit_relay_url(url: &str) -> Result<String> { |
| 646 | if Interactor::default().confirm( | 830 | // Parse the URL and handle errors |
| 647 | PromptConfirmParms::default() | 831 | let mut parsed = Url::parse(url) |
| 648 | .with_default(true) | 832 | .or_else(|_| Url::parse(&format!("https://{url}"))) |
| 649 | .with_prompt("set remote \"origin\" to the nostr url of your repository?"), | 833 | .context(format!("{url} not a valid ngit relay URL"))?; |
| 650 | )? { | 834 | if parsed.host_str().is_none() { |
| 651 | git_repo.git_repo.remote( | 835 | // so sub.domain.org gets identifier as host in "sub.domain.org" |
| 652 | "origin", | 836 | parsed = Url::parse(&format!("https://{url}"))?; |
| 653 | &repo_ref.to_nostr_git_url(&Some(git_repo)).to_string(), | 837 | } |
| 654 | )?; | 838 | |
| 839 | // Extract the scheme, host, port, and path | ||
| 840 | let scheme = parsed.scheme(); | ||
| 841 | let host = parsed.host_str().context(format!( | ||
| 842 | "{url} not a ngit relay url reference: missing host in URL {parsed}" | ||
| 843 | ))?; | ||
| 844 | let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default(); | ||
| 845 | let path = parsed.path(); | ||
| 846 | |||
| 847 | // Normalize the URL based on the scheme and path | ||
| 848 | let mut normalized_url = match scheme { | ||
| 849 | "ws" | "http" => format!("http://{host}{port}{path}"), | ||
| 850 | _ => format!("{host}{port}{path}"), | ||
| 851 | }; | ||
| 852 | |||
| 853 | // If the normalized URL contains "npub1", remove "npub1" and everything after | ||
| 854 | // it | ||
| 855 | if let Some(pos) = normalized_url.find("npub1") { | ||
| 856 | normalized_url.truncate(pos); // Keep everything before "npub1" | ||
| 857 | } | ||
| 858 | // Return the normalized URL | ||
| 859 | Ok(normalized_url.trim_end_matches('/').to_string()) | ||
| 860 | } | ||
| 861 | |||
| 862 | fn format_ngit_relay_url_as_clone_url(url:&str, public_key:&PublicKey, identifier: &str) -> Result<String> { | ||
| 863 | let ngit_relay_url = normalize_ngit_relay_url(url)?; | ||
| 864 | if ngit_relay_url.contains("http://") { | ||
| 865 | return Ok(format!("{ngit_relay_url}/{}/{identifier}.git", public_key.to_bech32()?)) | ||
| 866 | } | ||
| 867 | Ok(format!("https://{ngit_relay_url}/{}/{identifier}.git", public_key.to_bech32()?)) | ||
| 868 | } | ||
| 869 | |||
| 870 | fn format_ngit_relay_url_as_relay_url(url:&str) -> Result<String> { | ||
| 871 | let ngit_relay_url = normalize_ngit_relay_url(url)?; | ||
| 872 | if ngit_relay_url.contains("http://") { | ||
| 873 | return Ok(ngit_relay_url.replace("http://", "ws://")) | ||
| 874 | } | ||
| 875 | Ok(format!("wss://{ngit_relay_url}")) | ||
| 876 | } | ||
| 877 | |||
| 878 | fn extract_npub(s: &str) -> Result<&str> { | ||
| 879 | // Find the starting index of "npub1" | ||
| 880 | if let Some(start) = s.find("npub1") { | ||
| 881 | let mut end = start + 5; // Start after "npub1" | ||
| 882 | |||
| 883 | // Move the end index to include valid characters (0-9, a-z) | ||
| 884 | while end < s.len() && s[end..=end].chars().all(|c| c.is_ascii_alphanumeric()) { | ||
| 885 | end += 1; | ||
| 886 | } | ||
| 887 | // Extract the npub substring | ||
| 888 | let npub = &s[start..end]; | ||
| 889 | // Attempt to create a PublicKey from the extracted npub | ||
| 890 | PublicKey::from_bech32(npub).context("invalid npub")?; | ||
| 891 | Ok(npub) | ||
| 892 | } else { | ||
| 893 | bail!("No npub found") | ||
| 894 | } | ||
| 895 | } | ||
| 896 | |||
| 897 | fn parse_relay_url(s: &str) -> Result<RelayUrl> { | ||
| 898 | // Attempt to parse the original string | ||
| 899 | match RelayUrl::parse(s) { | ||
| 900 | Ok(url) => Ok(url), | ||
| 901 | Err(original_err) => { | ||
| 902 | // If parsing fails, prefix with "wss://" and try again | ||
| 903 | let prefixed = format!("wss://{s}"); | ||
| 904 | RelayUrl::parse(&prefixed).map_err(|_| original_err) | ||
| 905 | } | ||
| 906 | } | ||
| 907 | .context(format!("failed to parse relay url: {s}")) | ||
| 908 | } | ||
| 909 | |||
| 910 | pub fn show_multi_input_prompt_success(label: &str, values: &[String]) { | ||
| 911 | let values_str: Vec<&str> = values.iter().map(std::string::String::as_str).collect(); | ||
| 912 | eprintln!("{}", { | ||
| 913 | let mut s = String::new(); | ||
| 914 | let _ = ColorfulTheme::default().format_multi_select_prompt_selection(&mut s, label, &values_str); | ||
| 915 | s | ||
| 916 | }); | ||
| 917 | } | ||
| 918 | |||
| 919 | fn push_main_or_master_branch(git_repo: &Repo) -> Result<()> { | ||
| 920 | let main_branch_name = { | ||
| 921 | let local_branches = git_repo | ||
| 922 | .get_local_branch_names() | ||
| 923 | .context("failed to find any local branches")?; | ||
| 924 | if local_branches.contains(&"main".to_string()) { | ||
| 925 | "main" | ||
| 926 | } else if local_branches.contains(&"master".to_string()) { | ||
| 927 | "master" | ||
| 928 | } else { | ||
| 929 | bail!("set remote origin to nostr url and tried to push main or master branch but they dont exist yet") | ||
| 930 | } | ||
| 931 | }; | ||
| 932 | |||
| 933 | println!("set remote origin to nostr url and pushing {main_branch_name} branch."); | ||
| 934 | |||
| 935 | let command = "git"; | ||
| 936 | let args = ["push", "origin", "-u", main_branch_name]; | ||
| 937 | |||
| 938 | // Spawn the process | ||
| 939 | let mut child = Command::new(command) | ||
| 940 | .args(args) | ||
| 941 | .stdout(Stdio::inherit()) // Redirect stdout to the console | ||
| 942 | .stderr(Stdio::inherit()) // Redirect stderr to the console | ||
| 943 | .spawn() | ||
| 944 | .context("Failed to start git push process")?; | ||
| 945 | |||
| 946 | // Wait for the process to finish | ||
| 947 | let exit_status = child.wait().context("Failed to start git push process")?; | ||
| 948 | |||
| 949 | // Check the exit status | ||
| 950 | if exit_status.success() { | ||
| 951 | Ok(()) | ||
| 952 | } else { | ||
| 953 | bail!("git push process exited with an error: {}", exit_status); | ||
| 954 | } | ||
| 955 | } | ||
| 956 | |||
| 957 | |||
| 958 | #[cfg(test)] | ||
| 959 | mod tests { | ||
| 960 | use anyhow::Result; | ||
| 961 | |||
| 962 | use super::*; | ||
| 963 | |||
| 964 | #[test] | ||
| 965 | fn normalize_ngit_relay_url_all_checks() -> Result<()> { | ||
| 966 | let test_cases = vec![ | ||
| 967 | ("https://sub.domain.org", "sub.domain.org"), | ||
| 968 | ("wss://sub.domain.org", "sub.domain.org"), | ||
| 969 | ("sub.domain.org", "sub.domain.org"), | ||
| 970 | ("http://sub.domain.org", "http://sub.domain.org"), | ||
| 971 | ("ws://sub.domain.org", "http://sub.domain.org"), | ||
| 972 | ("http://localhost", "http://localhost"), | ||
| 973 | ("localhost", "localhost"), | ||
| 974 | ("https://sub.domain.org:8080", "sub.domain.org:8080"), | ||
| 975 | ("http://sub.domain.org:8080", "http://sub.domain.org:8080"), | ||
| 976 | ("sub.domain.org:8080", "sub.domain.org:8080"), | ||
| 977 | ("https://sub.domain.org/path/to", "sub.domain.org/path/to"), | ||
| 978 | ( | ||
| 979 | "https://sub.domain.org:8080/path/to", | ||
| 980 | "sub.domain.org:8080/path/to", | ||
| 981 | ), | ||
| 982 | ( | ||
| 983 | "https://sub.domain.org/npub143675782648/to.git", | ||
| 984 | "sub.domain.org", | ||
| 985 | ), | ||
| 986 | ( | ||
| 987 | "https://sub.domain.org/path/npub143675782648/to.git", | ||
| 988 | "sub.domain.org/path", | ||
| 989 | ), | ||
| 990 | ("https://sub.domain.org/", "sub.domain.org"), | ||
| 991 | ("http://sub.domain.org/", "http://sub.domain.org"), | ||
| 992 | ]; | ||
| 993 | |||
| 994 | for (input, expected) in test_cases { | ||
| 995 | let normalized = normalize_ngit_relay_url(input)?; | ||
| 996 | assert_eq!(normalized, expected); | ||
| 997 | } | ||
| 998 | Ok(()) | ||
| 655 | } | 999 | } |
| 656 | Ok(()) | ||
| 657 | } | 1000 | } |