upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-10 12:52:11 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-10 13:03:35 +0000
commit5db638d7350c35649d9fa4faac981395667e0609 (patch)
tree0710f5071da180c372f7b29b1fb0043df1bb23c7
parent761344563507eb50726db96f7409a8f3d5928b98 (diff)
feat: update ngit account login for non-interactive mode
Update login flow to support non-interactive mode with --nsec flag. Refactor login logic to handle both interactive and non-interactive cases. Add better error handling and validation.
-rw-r--r--src/bin/ngit/sub_commands/login.rs56
-rw-r--r--src/lib/client.rs14
-rw-r--r--src/lib/login/fresh.rs179
3 files changed, 215 insertions, 34 deletions
diff --git a/src/bin/ngit/sub_commands/login.rs b/src/bin/ngit/sub_commands/login.rs
index ed2414a..9081e66 100644
--- a/src/bin/ngit/sub_commands/login.rs
+++ b/src/bin/ngit/sub_commands/login.rs
@@ -26,6 +26,24 @@ pub struct SubCommandArgs {
26} 26}
27 27
28pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { 28pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> {
29 // Early validation: check if we have required parameters in non-interactive
30 // mode
31 let signer_info = extract_signer_cli_arguments(args)?;
32 if Interactor::is_non_interactive() && signer_info.is_none() {
33 use ngit::cli_interactor::cli_error;
34 return Err(cli_error(
35 "requires --nsec or --interactive",
36 &[
37 ("--nsec <key>", "provide secret key (nsec or hex)"),
38 ("--interactive", "for nostr connect or bunker login"),
39 ],
40 &[
41 "ngit account login --nsec <your-nsec>",
42 "ngit account create",
43 ],
44 ));
45 }
46
29 let git_repo_result = Repo::discover().context("failed to find a git repository"); 47 let git_repo_result = Repo::discover().context("failed to find a git repository");
30 let git_repo = { git_repo_result.ok() }; 48 let git_repo = { git_repo_result.ok() };
31 49
@@ -42,7 +60,7 @@ pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> {
42 fresh_login_or_signup( 60 fresh_login_or_signup(
43 &git_repo.as_ref(), 61 &git_repo.as_ref(),
44 client.as_ref(), 62 client.as_ref(),
45 extract_signer_cli_arguments(args)?, 63 signer_info,
46 log_in_locally_only || command_args.local, 64 log_in_locally_only || command_args.local,
47 ) 65 )
48 .await?; 66 .await?;
@@ -56,6 +74,7 @@ pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> {
56} 74}
57 75
58/// return ( bool - logged out, bool - log in to local git locally) 76/// return ( bool - logged out, bool - log in to local git locally)
77#[allow(clippy::too_many_lines)]
59async fn logout(git_repo: Option<&Repo>, local_only: bool) -> Result<(bool, bool)> { 78async fn logout(git_repo: Option<&Repo>, local_only: bool) -> Result<(bool, bool)> {
60 for source in if local_only || std::env::var("NGITTEST").is_ok() { 79 for source in if local_only || std::env::var("NGITTEST").is_ok() {
61 vec![SignerInfoSource::GitLocal] 80 vec![SignerInfoSource::GitLocal]
@@ -74,6 +93,41 @@ async fn logout(git_repo: Option<&Repo>, local_only: bool) -> Result<(bool, bool
74 ) 93 )
75 .await 94 .await
76 { 95 {
96 // In non-interactive mode, automatically logout without prompting
97 if Interactor::is_non_interactive() {
98 for item in [
99 "nostr.nsec",
100 "nostr.npub",
101 "nostr.bunker-uri",
102 "nostr.bunker-app-key",
103 ] {
104 if let Err(_error) = remove_git_config_item(
105 if source == SignerInfoSource::GitLocal {
106 &git_repo
107 } else {
108 &None
109 },
110 item,
111 ) {
112 use ngit::cli_interactor::cli_error;
113 return Err(cli_error(
114 &format!(
115 "failed to edit {} git config item '{item}'",
116 if source == SignerInfoSource::GitGlobal {
117 "global"
118 } else {
119 "local"
120 },
121 ),
122 &[],
123 &["ngit account login --local --nsec <your-nsec>"],
124 ));
125 }
126 }
127 return Ok((true, local_only));
128 }
129
130 // Interactive mode: prompt user for what to do
77 match Interactor::default().choice( 131 match Interactor::default().choice(
78 PromptChoiceParms::default() 132 PromptChoiceParms::default()
79 .with_default(0) 133 .with_default(0)
diff --git a/src/lib/client.rs b/src/lib/client.rs
index 4643392..fcb7a40 100644
--- a/src/lib/client.rs
+++ b/src/lib/client.rs
@@ -1300,6 +1300,11 @@ pub async fn get_repo_ref_from_cache(
1300 Some(repo_coordinate.public_key), 1300 Some(repo_coordinate.public_key),
1301 ))?; 1301 ))?;
1302 1302
1303 // Use name/description/web from the latest event across all maintainers
1304 let latest_metadata = repo_events
1305 .last()
1306 .and_then(|e| RepoRef::try_from((e.clone(), None)).ok());
1307
1303 let mut events: HashMap<Nip19Coordinate, nostr::Event> = HashMap::new(); 1308 let mut events: HashMap<Nip19Coordinate, nostr::Event> = HashMap::new();
1304 for m in &maintainers { 1309 for m in &maintainers {
1305 if let Some(e) = repo_events.iter().find(|e| e.pubkey.eq(m)) { 1310 if let Some(e) = repo_events.iter().find(|e| e.pubkey.eq(m)) {
@@ -1364,6 +1369,15 @@ pub async fn get_repo_ref_from_cache(
1364 git_server, 1369 git_server,
1365 events, 1370 events,
1366 maintainers_without_annoucnement: Some(maintainers_without_annoucnement), 1371 maintainers_without_annoucnement: Some(maintainers_without_annoucnement),
1372 name: latest_metadata
1373 .as_ref()
1374 .map_or_else(|| repo_ref.name.clone(), |r| r.name.clone()),
1375 description: latest_metadata
1376 .as_ref()
1377 .map_or_else(|| repo_ref.description.clone(), |r| r.description.clone()),
1378 web: latest_metadata
1379 .as_ref()
1380 .map_or_else(|| repo_ref.web.clone(), |r| r.web.clone()),
1367 ..repo_ref 1381 ..repo_ref
1368 }) 1382 })
1369} 1383}
diff --git a/src/lib/login/fresh.rs b/src/lib/login/fresh.rs
index e01d4c3..886b0e4 100644
--- a/src/lib/login/fresh.rs
+++ b/src/lib/login/fresh.rs
@@ -25,7 +25,7 @@ use crate::{
25 Interactor, InteractorPrompt, Printer, PromptChoiceParms, PromptConfirmParms, 25 Interactor, InteractorPrompt, Printer, PromptChoiceParms, PromptConfirmParms,
26 PromptInputParms, PromptPasswordParms, 26 PromptInputParms, PromptPasswordParms,
27 }, 27 },
28 client::{Connect, nip05_query, send_events}, 28 client::{Connect, nip05_query, save_event_in_global_cache, send_events},
29 git::{Repo, RepoActions, remove_git_config_item, save_git_config_item}, 29 git::{Repo, RepoActions, remove_git_config_item, save_git_config_item},
30}; 30};
31 31
@@ -123,7 +123,7 @@ pub async fn get_fresh_nsec_signer() -> Result<
123 .input( 123 .input(
124 PromptInputParms::default() 124 PromptInputParms::default()
125 .with_prompt("nsec") 125 .with_prompt("nsec")
126 .optional() 126 .with_flag_name("--nsec")
127 .dont_report(), 127 .dont_report(),
128 ) 128 )
129 .context("failed to get nsec input from interactor")?; 129 .context("failed to get nsec input from interactor")?;
@@ -509,6 +509,26 @@ async fn save_to_git_config(
509 if let Err(error) = 509 if let Err(error) =
510 silently_save_to_git_config(git_repo, signer_info, global).context(err_msg.clone()) 510 silently_save_to_git_config(git_repo, signer_info, global).context(err_msg.clone())
511 { 511 {
512 // Check if this is a read-only file system error
513 let is_readonly_error = error
514 .chain()
515 .any(|e| e.to_string().contains("Read-only file system"));
516
517 if is_readonly_error && global {
518 // In non-interactive mode, provide a clear error with --local suggestion
519 if crate::cli_interactor::Interactor::is_non_interactive() {
520 use crate::cli_interactor::cli_error;
521 return Err(cli_error(
522 "failed to create account",
523 &[("cause", "global git config is read-only")],
524 &[
525 "ngit account create --local --nsec <your-nsec>",
526 "ngit account login --local --nsec <your-nsec>",
527 ],
528 ));
529 }
530 }
531
512 eprintln!("Error: {error:?}"); 532 eprintln!("Error: {error:?}");
513 match signer_info { 533 match signer_info {
514 SignerInfo::Nsec { 534 SignerInfo::Nsec {
@@ -678,6 +698,119 @@ fn silently_save_to_git_config(
678 Ok(()) 698 Ok(())
679} 699}
680 700
701/// Non-interactive signup function for creating a new account
702///
703/// # Arguments
704/// * `name` - Display name for the new account
705/// * `client` - Optional client for publishing metadata to relays
706/// * `save_local` - If true, save credentials to local git config only
707/// * `publish` - If true, publish metadata and relay list to relays
708///
709/// # Returns
710/// Returns a tuple of (signer, public_key, signer_info, keys) where keys can be
711/// used to display the nsec
712pub async fn signup_non_interactive(
713 name: String,
714 #[cfg(test)] client: Option<&MockConnect>,
715 #[cfg(not(test))] client: Option<&Client>,
716 save_local: bool,
717 publish: bool,
718) -> Result<(Arc<dyn NostrSigner>, PublicKey, SignerInfo, Keys)> {
719 // Generate new keypair
720 let keys = nostr::Keys::generate();
721 let nsec = keys.secret_key().to_bech32()?;
722 let public_key = keys.public_key();
723
724 let signer_info = SignerInfo::Nsec {
725 nsec,
726 password: None,
727 npub: Some(public_key.to_bech32()?),
728 };
729
730 // Save to git config
731 let git_repo = Repo::discover().ok();
732 if let Err(error) = silently_save_to_git_config(&git_repo.as_ref(), &signer_info, !save_local) {
733 let is_readonly = error
734 .chain()
735 .any(|e| e.to_string().contains("Read-only file system"));
736
737 if is_readonly && !save_local {
738 use crate::cli_interactor::cli_error;
739
740 let mut cmds: Vec<String> = match &signer_info {
741 SignerInfo::Nsec { nsec, npub, .. } => {
742 let mut v = vec![format!("git config --global nostr.nsec {nsec}")];
743 if let Some(npub) = npub {
744 v.push(format!("git config --global nostr.npub {npub}"));
745 }
746 v
747 }
748 SignerInfo::Bunker {
749 bunker_uri,
750 bunker_app_key,
751 npub,
752 } => {
753 let mut v = vec![
754 format!("git config --global nostr.bunker-uri {bunker_uri}"),
755 format!("git config --global nostr.bunker-app-key {bunker_app_key}"),
756 ];
757 if let Some(npub) = npub {
758 v.push(format!("git config --global nostr.npub {npub}"));
759 }
760 v
761 }
762 };
763 cmds.push("ngit account create --local --name <your-name>".to_string());
764
765 let cmd_refs: Vec<&str> = cmds.iter().map(String::as_str).collect();
766 return Err(cli_error(
767 "global git config is read-only. login to local repo or save git config manually",
768 &[("--local", "login scoped to this repositoriy")],
769 &cmd_refs,
770 ));
771 }
772
773 return Err(error);
774 }
775
776 let git_repo_path = if let Some(ref git_repo) = git_repo {
777 Some(git_repo.get_path()?)
778 } else {
779 None
780 };
781
782 // Build events, save to cache, and optionally publish to relays
783 if let Some(client) = client {
784 let profile = EventBuilder::metadata(&Metadata::new().name(name)).sign_with_keys(&keys)?;
785 let relay_list = EventBuilder::relay_list(
786 client
787 .get_relay_default_set()
788 .iter()
789 .map(|s| (RelayUrl::parse(s).unwrap(), None)),
790 )
791 .sign_with_keys(&keys)?;
792
793 // Save to global cache so subsequent commands don't need to fetch
794 save_event_in_global_cache(git_repo_path, &profile).await?;
795 save_event_in_global_cache(git_repo_path, &relay_list).await?;
796
797 if publish {
798 send_events(
799 client,
800 git_repo_path,
801 vec![profile, relay_list],
802 client.get_relay_default_set().clone(),
803 vec![],
804 true,
805 false,
806 )
807 .await?;
808 }
809 }
810
811 Ok((Arc::new(keys.clone()), public_key, signer_info, keys))
812}
813
681async fn signup( 814async fn signup(
682 #[cfg(test)] client: Option<&MockConnect>, 815 #[cfg(test)] client: Option<&MockConnect>,
683 #[cfg(not(test))] client: Option<&Client>, 816 #[cfg(not(test))] client: Option<&Client>,
@@ -714,42 +847,22 @@ async fn signup(
714 _ => break Ok(None), 847 _ => break Ok(None),
715 } 848 }
716 } 849 }
717 let keys = nostr::Keys::generate(); 850
718 let nsec = keys.secret_key().to_bech32()?; 851 // Call the non-interactive function
852 let (signer, public_key, signer_info, _keys) = signup_non_interactive(
853 name.clone(),
854 client,
855 false, // save_local = false (will be saved globally by caller)
856 true, // publish = true (always publish in interactive mode)
857 )
858 .await?;
859
719 show_prompt_success("user display name", &name); 860 show_prompt_success("user display name", &name);
720 let signer_info = SignerInfo::Nsec {
721 nsec,
722 password: None,
723 npub: Some(keys.public_key().to_bech32()?),
724 };
725 let public_key = keys.public_key();
726 if let Some(client) = client {
727 let profile =
728 EventBuilder::metadata(&Metadata::new().name(name)).sign_with_keys(&keys)?;
729 let relay_list = EventBuilder::relay_list(
730 client
731 .get_relay_default_set()
732 .iter()
733 .map(|s| (RelayUrl::parse(s).unwrap(), None)),
734 )
735 .sign_with_keys(&keys)?;
736 eprintln!("publishing user profile to relays");
737 send_events(
738 client,
739 None,
740 vec![profile, relay_list],
741 client.get_relay_default_set().clone(),
742 vec![],
743 true,
744 false,
745 )
746 .await?;
747 }
748 eprintln!( 861 eprintln!(
749 "to login to other nostr clients eg. gitworkshop.dev with this account run `ngit export-keys` at any time to reveal your nostr account secret" 862 "to login to other nostr clients eg. gitworkshop.dev with this account run `ngit export-keys` at any time to reveal your nostr account secret"
750 ); 863 );
751 break Ok(Some(( 864 break Ok(Some((
752 Arc::new(keys), 865 signer,
753 public_key, 866 public_key,
754 signer_info, 867 signer_info,
755 // TODO factor in source 868 // TODO factor in source