From c40a5553c335c889390cb54f5fad85e29af7d502 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 10 Feb 2026 12:51:52 +0000 Subject: feat: add non-interactive mode support to CLI interactor Add CliError type for styled error output and cli_error() helper function. Update Interactor to support non-interactive mode with default values. Add prompt methods that respect non-interactive mode and provide better error messages when required values are missing. --- src/lib/cli_interactor.rs | 148 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 1 deletion(-) (limited to 'src/lib') diff --git a/src/lib/cli_interactor.rs b/src/lib/cli_interactor.rs index e944bf9..881b988 100644 --- a/src/lib/cli_interactor.rs +++ b/src/lib/cli_interactor.rs @@ -1,4 +1,7 @@ -use anyhow::{Context, Result}; +use std::fmt; + +use anyhow::{Context, Result, bail}; +use console::Style; use dialoguer::{ Confirm, Input, Password, theme::{ColorfulTheme, Theme}, @@ -7,9 +10,93 @@ use indicatif::TermLike; #[cfg(test)] use mockall::*; +/// Sentinel error type indicating the error has already been printed to stderr. +/// +/// When this propagates up to `main()`, it signals "already printed styled +/// output to stderr, don't double-print". This is the same pattern clap uses +/// internally. +#[derive(Debug)] +pub struct CliError; + +impl fmt::Display for CliError { + fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Empty display — the error message was already printed to stderr + Ok(()) + } +} + +impl std::error::Error for CliError {} + +/// Print a styled CLI error to stderr and return an `anyhow::Error` wrapping +/// [`CliError`]. +/// +/// - `message`: the main error text (printed after the red `error:` prefix) +/// - `details`: flag/description pairs shown as gray indented lines (for +/// multiple missing fields). Descriptions are aligned to the longest flag. +/// - `suggestions`: command suggestions shown in yellow +/// +/// This function does NOT call `process::exit()`. It prints to stderr and +/// returns an error that the caller should propagate with `?` or `return Err`. +pub fn cli_error(message: &str, details: &[(&str, &str)], suggestions: &[&str]) -> anyhow::Error { + let dim = Style::new().for_stderr().color256(247); + + eprint!( + "{} {}", + console::style("error:").for_stderr().red(), + message + ); + if details.is_empty() { + eprintln!(); + } else { + let max_flag_len = details + .iter() + .map(|(flag, _)| flag.len()) + .max() + .unwrap_or(0); + eprintln!(); + for (flag, desc) in details { + eprintln!( + " {:width$} {}", + dim.apply_to(flag), + dim.apply_to(desc), + width = max_flag_len + ); + } + } + + if !suggestions.is_empty() { + eprintln!(); + for cmd in suggestions { + eprintln!( + "{}", + console::style(format!(" {cmd}")).for_stderr().yellow(), + ); + } + } + + CliError.into() +} + #[derive(Default)] pub struct Interactor { theme: ColorfulTheme, + non_interactive: bool, +} + +impl Interactor { + pub fn new(non_interactive: bool) -> Self { + Self { + theme: ColorfulTheme::default(), + non_interactive, + } + } + + /// Returns true if running in non-interactive mode (the default). + /// Interactive mode is only enabled when NGIT_INTERACTIVE_MODE env var is + /// set (via -i flag). + pub fn is_non_interactive() -> bool { + std::env::var("NGIT_INTERACTIVE_MODE").is_err() + } } #[cfg_attr(test, automock)] @@ -22,6 +109,21 @@ pub trait InteractorPrompt { } impl InteractorPrompt for Interactor { fn input(&self, parms: PromptInputParms) -> Result { + if self.non_interactive || Self::is_non_interactive() { + if parms.optional || !parms.default.is_empty() { + return Ok(parms.default); + } + let flag_hint = parms + .flag_name + .as_ref() + .map(|f| format!(" (provide {} or use -i/-d)", f)) + .unwrap_or_else(|| " (use -i for interactive mode or -d for defaults)".to_string()); + bail!( + "interactive input required but running in non-interactive mode: {}{}", + parms.prompt, + flag_hint + ); + } let mut input = Input::with_theme(&self.theme) .with_prompt(parms.prompt) .allow_empty(parms.optional) @@ -32,6 +134,12 @@ impl InteractorPrompt for Interactor { Ok(input.interact_text()?) } fn password(&self, parms: PromptPasswordParms) -> Result { + if self.non_interactive || Self::is_non_interactive() { + bail!( + "password input required but running in non-interactive mode: {}", + parms.prompt + ); + } let mut p = Password::with_theme(&self.theme) .with_prompt(parms.prompt) .report(parms.report); @@ -42,6 +150,9 @@ impl InteractorPrompt for Interactor { Ok(pass) } fn confirm(&self, params: PromptConfirmParms) -> Result { + if self.non_interactive || Self::is_non_interactive() { + return Ok(params.default); + } let confirm: bool = Confirm::with_theme(&self.theme) .with_prompt(params.prompt) .default(params.default) @@ -49,6 +160,15 @@ impl InteractorPrompt for Interactor { Ok(confirm) } fn choice(&self, parms: PromptChoiceParms) -> Result { + if self.non_interactive || Self::is_non_interactive() { + if let Some(default) = parms.default { + return Ok(default); + } + bail!( + "interactive choice required but running in non-interactive mode: {}", + parms.prompt + ); + } let mut choice = dialoguer::Select::with_theme(&self.theme) .with_prompt(parms.prompt) .report(parms.report) @@ -61,6 +181,17 @@ impl InteractorPrompt for Interactor { choice.interact().context("failed to get choice") } fn multi_choice(&self, parms: PromptMultiChoiceParms) -> Result> { + if self.non_interactive || Self::is_non_interactive() { + if let Some(defaults) = &parms.defaults { + return Ok(defaults + .iter() + .enumerate() + .filter(|(_, &selected)| selected) + .map(|(i, _)| i) + .collect()); + } + return Ok(vec![]); // Empty selection if no defaults + } // the colorful theme is not very clear so falling back to default let mut choice = dialoguer::MultiSelect::default() .with_prompt(parms.prompt) @@ -73,11 +204,20 @@ impl InteractorPrompt for Interactor { } } +/// Parameters for interactive input prompts. +/// +/// Supports both interactive and non-interactive modes: +/// - Interactive mode (NGIT_INTERACTIVE_MODE set): prompts user +/// - Non-interactive mode (default): returns default value or errors +/// +/// The `flag_name` field improves error messages by telling users +/// which CLI flag would provide the missing value. pub struct PromptInputParms { pub prompt: String, pub default: String, pub report: bool, pub optional: bool, + pub flag_name: Option, } impl Default for PromptInputParms { @@ -87,6 +227,7 @@ impl Default for PromptInputParms { default: String::new(), optional: false, report: true, + flag_name: None, } } } @@ -109,6 +250,11 @@ impl PromptInputParms { self.report = false; self } + + pub fn with_flag_name>(mut self, flag_name: S) -> Self { + self.flag_name = Some(flag_name.into()); + self + } } pub struct PromptPasswordParms { -- cgit v1.2.3 From 5db638d7350c35649d9fa4faac981395667e0609 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 10 Feb 2026 12:52:11 +0000 Subject: 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. --- src/bin/ngit/sub_commands/login.rs | 56 +++++++++++- src/lib/client.rs | 14 +++ src/lib/login/fresh.rs | 179 ++++++++++++++++++++++++++++++------- 3 files changed, 215 insertions(+), 34 deletions(-) (limited to 'src/lib') 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 { } pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { + // Early validation: check if we have required parameters in non-interactive + // mode + let signer_info = extract_signer_cli_arguments(args)?; + if Interactor::is_non_interactive() && signer_info.is_none() { + use ngit::cli_interactor::cli_error; + return Err(cli_error( + "requires --nsec or --interactive", + &[ + ("--nsec ", "provide secret key (nsec or hex)"), + ("--interactive", "for nostr connect or bunker login"), + ], + &[ + "ngit account login --nsec ", + "ngit account create", + ], + )); + } + let git_repo_result = Repo::discover().context("failed to find a git repository"); let git_repo = { git_repo_result.ok() }; @@ -42,7 +60,7 @@ pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { fresh_login_or_signup( &git_repo.as_ref(), client.as_ref(), - extract_signer_cli_arguments(args)?, + signer_info, log_in_locally_only || command_args.local, ) .await?; @@ -56,6 +74,7 @@ pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { } /// return ( bool - logged out, bool - log in to local git locally) +#[allow(clippy::too_many_lines)] async fn logout(git_repo: Option<&Repo>, local_only: bool) -> Result<(bool, bool)> { for source in if local_only || std::env::var("NGITTEST").is_ok() { vec![SignerInfoSource::GitLocal] @@ -74,6 +93,41 @@ async fn logout(git_repo: Option<&Repo>, local_only: bool) -> Result<(bool, bool ) .await { + // In non-interactive mode, automatically logout without prompting + if Interactor::is_non_interactive() { + for item in [ + "nostr.nsec", + "nostr.npub", + "nostr.bunker-uri", + "nostr.bunker-app-key", + ] { + if let Err(_error) = remove_git_config_item( + if source == SignerInfoSource::GitLocal { + &git_repo + } else { + &None + }, + item, + ) { + use ngit::cli_interactor::cli_error; + return Err(cli_error( + &format!( + "failed to edit {} git config item '{item}'", + if source == SignerInfoSource::GitGlobal { + "global" + } else { + "local" + }, + ), + &[], + &["ngit account login --local --nsec "], + )); + } + } + return Ok((true, local_only)); + } + + // Interactive mode: prompt user for what to do match Interactor::default().choice( PromptChoiceParms::default() .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( Some(repo_coordinate.public_key), ))?; + // Use name/description/web from the latest event across all maintainers + let latest_metadata = repo_events + .last() + .and_then(|e| RepoRef::try_from((e.clone(), None)).ok()); + let mut events: HashMap = HashMap::new(); for m in &maintainers { 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( git_server, events, maintainers_without_annoucnement: Some(maintainers_without_annoucnement), + name: latest_metadata + .as_ref() + .map_or_else(|| repo_ref.name.clone(), |r| r.name.clone()), + description: latest_metadata + .as_ref() + .map_or_else(|| repo_ref.description.clone(), |r| r.description.clone()), + web: latest_metadata + .as_ref() + .map_or_else(|| repo_ref.web.clone(), |r| r.web.clone()), ..repo_ref }) } 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::{ Interactor, InteractorPrompt, Printer, PromptChoiceParms, PromptConfirmParms, PromptInputParms, PromptPasswordParms, }, - client::{Connect, nip05_query, send_events}, + client::{Connect, nip05_query, save_event_in_global_cache, send_events}, git::{Repo, RepoActions, remove_git_config_item, save_git_config_item}, }; @@ -123,7 +123,7 @@ pub async fn get_fresh_nsec_signer() -> Result< .input( PromptInputParms::default() .with_prompt("nsec") - .optional() + .with_flag_name("--nsec") .dont_report(), ) .context("failed to get nsec input from interactor")?; @@ -509,6 +509,26 @@ async fn save_to_git_config( if let Err(error) = silently_save_to_git_config(git_repo, signer_info, global).context(err_msg.clone()) { + // Check if this is a read-only file system error + let is_readonly_error = error + .chain() + .any(|e| e.to_string().contains("Read-only file system")); + + if is_readonly_error && global { + // In non-interactive mode, provide a clear error with --local suggestion + if crate::cli_interactor::Interactor::is_non_interactive() { + use crate::cli_interactor::cli_error; + return Err(cli_error( + "failed to create account", + &[("cause", "global git config is read-only")], + &[ + "ngit account create --local --nsec ", + "ngit account login --local --nsec ", + ], + )); + } + } + eprintln!("Error: {error:?}"); match signer_info { SignerInfo::Nsec { @@ -678,6 +698,119 @@ fn silently_save_to_git_config( Ok(()) } +/// Non-interactive signup function for creating a new account +/// +/// # Arguments +/// * `name` - Display name for the new account +/// * `client` - Optional client for publishing metadata to relays +/// * `save_local` - If true, save credentials to local git config only +/// * `publish` - If true, publish metadata and relay list to relays +/// +/// # Returns +/// Returns a tuple of (signer, public_key, signer_info, keys) where keys can be +/// used to display the nsec +pub async fn signup_non_interactive( + name: String, + #[cfg(test)] client: Option<&MockConnect>, + #[cfg(not(test))] client: Option<&Client>, + save_local: bool, + publish: bool, +) -> Result<(Arc, PublicKey, SignerInfo, Keys)> { + // Generate new keypair + let keys = nostr::Keys::generate(); + let nsec = keys.secret_key().to_bech32()?; + let public_key = keys.public_key(); + + let signer_info = SignerInfo::Nsec { + nsec, + password: None, + npub: Some(public_key.to_bech32()?), + }; + + // Save to git config + let git_repo = Repo::discover().ok(); + if let Err(error) = silently_save_to_git_config(&git_repo.as_ref(), &signer_info, !save_local) { + let is_readonly = error + .chain() + .any(|e| e.to_string().contains("Read-only file system")); + + if is_readonly && !save_local { + use crate::cli_interactor::cli_error; + + let mut cmds: Vec = match &signer_info { + SignerInfo::Nsec { nsec, npub, .. } => { + let mut v = vec![format!("git config --global nostr.nsec {nsec}")]; + if let Some(npub) = npub { + v.push(format!("git config --global nostr.npub {npub}")); + } + v + } + SignerInfo::Bunker { + bunker_uri, + bunker_app_key, + npub, + } => { + let mut v = vec![ + format!("git config --global nostr.bunker-uri {bunker_uri}"), + format!("git config --global nostr.bunker-app-key {bunker_app_key}"), + ]; + if let Some(npub) = npub { + v.push(format!("git config --global nostr.npub {npub}")); + } + v + } + }; + cmds.push("ngit account create --local --name ".to_string()); + + let cmd_refs: Vec<&str> = cmds.iter().map(String::as_str).collect(); + return Err(cli_error( + "global git config is read-only. login to local repo or save git config manually", + &[("--local", "login scoped to this repositoriy")], + &cmd_refs, + )); + } + + return Err(error); + } + + let git_repo_path = if let Some(ref git_repo) = git_repo { + Some(git_repo.get_path()?) + } else { + None + }; + + // Build events, save to cache, and optionally publish to relays + if let Some(client) = client { + let profile = EventBuilder::metadata(&Metadata::new().name(name)).sign_with_keys(&keys)?; + let relay_list = EventBuilder::relay_list( + client + .get_relay_default_set() + .iter() + .map(|s| (RelayUrl::parse(s).unwrap(), None)), + ) + .sign_with_keys(&keys)?; + + // Save to global cache so subsequent commands don't need to fetch + save_event_in_global_cache(git_repo_path, &profile).await?; + save_event_in_global_cache(git_repo_path, &relay_list).await?; + + if publish { + send_events( + client, + git_repo_path, + vec![profile, relay_list], + client.get_relay_default_set().clone(), + vec![], + true, + false, + ) + .await?; + } + } + + Ok((Arc::new(keys.clone()), public_key, signer_info, keys)) +} + async fn signup( #[cfg(test)] client: Option<&MockConnect>, #[cfg(not(test))] client: Option<&Client>, @@ -714,42 +847,22 @@ async fn signup( _ => break Ok(None), } } - let keys = nostr::Keys::generate(); - let nsec = keys.secret_key().to_bech32()?; + + // Call the non-interactive function + let (signer, public_key, signer_info, _keys) = signup_non_interactive( + name.clone(), + client, + false, // save_local = false (will be saved globally by caller) + true, // publish = true (always publish in interactive mode) + ) + .await?; + show_prompt_success("user display name", &name); - let signer_info = SignerInfo::Nsec { - nsec, - password: None, - npub: Some(keys.public_key().to_bech32()?), - }; - let public_key = keys.public_key(); - if let Some(client) = client { - let profile = - EventBuilder::metadata(&Metadata::new().name(name)).sign_with_keys(&keys)?; - let relay_list = EventBuilder::relay_list( - client - .get_relay_default_set() - .iter() - .map(|s| (RelayUrl::parse(s).unwrap(), None)), - ) - .sign_with_keys(&keys)?; - eprintln!("publishing user profile to relays"); - send_events( - client, - None, - vec![profile, relay_list], - client.get_relay_default_set().clone(), - vec![], - true, - false, - ) - .await?; - } eprintln!( "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" ); break Ok(Some(( - Arc::new(keys), + signer, public_key, signer_info, // TODO factor in source -- cgit v1.2.3