From 4c6578ccb1d285cccbc80c0c2baa26793d3a32f5 Mon Sep 17 00:00:00 2001 From: Awiteb Date: Sun, 1 Jun 2025 12:00:49 +0000 Subject: [PATCH] feat: Support relays and naddrs sets This commit introduces support for 'sets', a feature that simplifies referencing multiple relays or naddrs with a single word. Sets are stored in the config directory under `n34` as `config.toml` in `TOML` format. The config path follows the `$XDG_CONFIG_HOME` standard, defaulting to `$HOME/.config` if unset. The `n34 sets` command allows managing sets, adding, removing, updating, or displaying them. When using the main `--relays` flag, a set name can be provided to automatically expand its relays (an error occurs if the set contains no relays). Similarly, any command accepting naddrs can reference a set to extract its naddrs. Sets can also be merged by updating a set while specifying another set in `--repo` merges their naddrs, while `--set-relays` merges their relays. Removal follows the same pattern. Signed-off-by: Awiteb --- CHANGELOG.md | 14 ++ src/cli/commands/issue/new.rs | 33 ++-- src/cli/commands/mod.rs | 30 ++- src/cli/commands/reply.rs | 26 +-- src/cli/commands/repo/announce.rs | 12 +- src/cli/commands/repo/view.rs | 27 ++- src/cli/commands/sets/mod.rs | 57 ++++++ src/cli/commands/sets/new.rs | 59 ++++++ src/cli/commands/sets/remove.rs | 65 +++++++ src/cli/commands/sets/show.rs | 85 +++++++++ src/cli/commands/sets/update.rs | 72 +++++++ src/cli/config.rs | 303 ++++++++++++++++++++++++++++++ src/cli/defaults.rs | 28 +++ src/cli/mod.rs | 11 +- src/cli/parsers.rs | 61 ++---- src/cli/types.rs | 194 +++++++++++++++++++ src/error.rs | 17 ++ src/main.rs | 9 +- src/nostr_utils/mod.rs | 4 +- 19 files changed, 1010 insertions(+), 97 deletions(-) create mode 100644 src/cli/commands/sets/mod.rs create mode 100644 src/cli/commands/sets/new.rs create mode 100644 src/cli/commands/sets/remove.rs create mode 100644 src/cli/commands/sets/show.rs create mode 100644 src/cli/commands/sets/update.rs create mode 100644 src/cli/config.rs create mode 100644 src/cli/defaults.rs create mode 100644 src/cli/types.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c57ddd..467f24b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Read the `nostr-address` file in `issue new` command - by Awiteb - Read the `nostr-address` file in `reply` command - by Awiteb - A `--quote-to` flag to quote the replied to content in the editor - by Awiteb +- Enter repository as nip5 - by Awiteb +- Make the relays list optional - by Awiteb +- Events and naddrs can starts with `nostr:` - by Awiteb +- Support relays and naddrs sets - by Awiteb ### Dependencies - Add `chrono@0.4.41` to the dependencies - by Awiteb +- Enable `nip05` feature of `nostr` crate - by Awiteb +- Add `serde@1.0.219`, `dirs@6.0.0` and `toml@0.8.22` - by Awiteb + +### Fixed + +- Create a valid naddr string - by Awiteb + +### Refactor + +- Support more than one naddr instead of one - by Awiteb ## [0.1.0] - 2025-05-21 diff --git a/src/cli/commands/issue/new.rs b/src/cli/commands/issue/new.rs index e68783c..cb4db9b 100644 --- a/src/cli/commands/issue/new.rs +++ b/src/cli/commands/issue/new.rs @@ -17,13 +17,15 @@ use clap::{ArgGroup, Args}; use futures::future; -use nostr::{ - event::{EventBuilder, Tag}, - nips::nip19::Nip19Coordinate, -}; +use nostr::event::{EventBuilder, Tag}; use crate::{ - cli::{CliOptions, CommandRunner, parsers}, + cli::{ + CliConfig, + CliOptions, + CommandRunner, + types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt}, + }, error::N34Result, nostr_utils::{ NostrClient, @@ -47,12 +49,12 @@ use crate::{ ) )] pub struct NewArgs { - /// Repository address in `naddr` format or `/repo_id`. e.g. - /// `4rs.nl/n34` and `_@4rs.nl/n34` + /// Repository address in `naddr` format (`naddr1...`), NIP-05 format + /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`. /// - /// If not provided, `n34` will look for the `nostr-address` file. - #[arg(value_name = "NADDR-OR-NIP05", long = "repo", value_parser = parsers::repo_naddr)] - naddrs: Option>, + /// If omitted, looks for a `nostr-address` file. + #[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")] + naddrs: Option>, /// Markdown content for the issue. Cannot be used together with the /// `--editor` flag. #[arg(short, long)] @@ -95,9 +97,14 @@ impl NewArgs { impl CommandRunner for NewArgs { async fn run(self, options: CliOptions) -> N34Result<()> { - let client = NostrClient::init(&options).await; + let config = CliConfig::load_toml(&options.config_path)?; + let naddrs = utils::naddrs_or_file( + self.naddrs.flat_naddrs(&config.sets)?, + &utils::nostr_address_path()?, + )?; + let relays = options.relays.clone().flat_relays(&config.sets)?; + let client = NostrClient::init(&options, &relays).await; let user_pubk = options.pubkey().await?; - let naddrs = utils::naddrs_or_file(self.naddrs.clone(), &utils::nostr_address_path()?)?; let mut naddrs_iter = naddrs.clone().into_iter(); client.add_relays(&naddrs.extract_relays()).await; @@ -128,7 +135,7 @@ impl CommandRunner for NewArgs { let event_id = event.id.expect("There is an id"); let write_relays = [ - options.relays, + relays, utils::add_write_relays(relays_list.as_ref()), client .fetch_repos(&naddrs.into_coordinates()) diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index cbebb77..7616164 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -14,27 +14,34 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . - /// `issue` subcommands mod issue; /// 'reply` command mod reply; /// `repo` subcommands mod repo; - +/// `sets` subcommands +mod sets; use std::fmt; +use std::path::PathBuf; use clap::{ArgGroup, Args, Parser}; use nostr::key::{Keys, PublicKey, SecretKey}; -use nostr::types::RelayUrl; use self::issue::IssueSubcommands; use self::reply::ReplyArgs; use self::repo::RepoSubcommands; +use self::sets::SetsSubcommands; use super::traits::CommandRunner; +use super::types::RelayOrSet; use crate::error::{N34Error, N34Result}; +/// Default path used when no path is provided via command line arguments. +/// +/// This is a workaround since Clap doesn't support lazy evaluation of defaults. +pub const DEFAULT_FALLBACK_PATH: &str = "I_DO_NOT_KNOW_WHY_CLAP_DO_NOT_SUPPORT_LAZY_DEFAULT"; + // TODO: Make the `signer` group optional /// The command-line interface options #[derive(Args, Clone)] @@ -48,19 +55,29 @@ use crate::error::{N34Error, N34Result}; pub struct CliOptions { /// Your Nostr secret key #[arg(short, long)] - pub secret_key: Option, + pub secret_key: Option, /// Fallbacks relay to write and read from it. Multiple relays can be /// passed. #[arg(short, long)] - pub relays: Vec, + pub relays: Vec, /// Proof of Work difficulty when creatring events #[arg(long, default_value_t = 0)] - pub pow: u8, + pub pow: u8, + /// Config path [default: `$XDG_CONFIG_HOME` or `$HOME/.config`] + #[arg(long, value_name = "PATH", default_value = DEFAULT_FALLBACK_PATH, + hide_default_value = true + )] + pub config_path: PathBuf, } /// N34 commands #[derive(Parser, Debug)] pub enum Commands { + /// Manage reposotoies and relays sets + Sets { + #[command(subcommand)] + subcommands: SetsSubcommands, + }, /// Manage repositories Repo { #[command(subcommand)] @@ -89,6 +106,7 @@ impl CommandRunner for Commands { Self::Repo { subcommands } => subcommands.run(options).await, Self::Issue { subcommands } => subcommands.run(options).await, Commands::Reply(args) => args.run(options).await, + Commands::Sets { subcommands } => subcommands.run(options).await, } } } diff --git a/src/cli/commands/reply.rs b/src/cli/commands/reply.rs index 3c32eba..4e13f7b 100644 --- a/src/cli/commands/reply.rs +++ b/src/cli/commands/reply.rs @@ -23,14 +23,17 @@ use nostr::{ filter::Filter, nips::{ nip01::{Coordinate, Metadata}, - nip19::{self, FromBech32, Nip19Coordinate, ToBech32}, + nip19::{self, FromBech32, ToBech32}, }, types::RelayUrl, }; use super::{CliOptions, CommandRunner}; use crate::{ - cli::parsers, + cli::{ + CliConfig, + types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt}, + }, error::{N34Error, N34Result}, nostr_utils::{ NostrClient, @@ -101,13 +104,12 @@ pub struct ReplyArgs { /// Quote the replied-to event in the editor #[arg(long)] quote_to: bool, - /// Repository address in `naddr` format or `/repo_id`. e.g. - /// `4rs.nl/n34` and `_@4rs.nl/n34` + /// Repository address in `naddr` format (`naddr1...`), NIP-05 format + /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`. /// - /// If not provided, `n34` will look for the `nostr-address` file and if not - /// found, will get it from the root event if found. - #[arg(value_name = "NADDR-OR-NIP05", long = "repo", value_parser = parsers::repo_naddr)] - naddrs: Option>, + /// If omitted, looks for a `nostr-address` file. + #[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")] + naddrs: Option>, /// The comment (cannot be used with --editor) #[arg(short, long)] comment: Option, @@ -119,10 +121,12 @@ pub struct ReplyArgs { impl CommandRunner for ReplyArgs { async fn run(self, options: CliOptions) -> N34Result<()> { let nostr_address_path = utils::nostr_address_path()?; - let client = NostrClient::init(&options).await; + let config = CliConfig::load_toml(&options.config_path)?; + let relays = options.relays.clone().flat_relays(&config.sets)?; + let client = NostrClient::init(&options, &relays).await; let user_pubk = options.pubkey().await?; - let repo_naddrs = if let Some(naddrs) = self.naddrs { + let repo_naddrs = if let Some(naddrs) = self.naddrs.flat_naddrs(&config.sets)? { client.add_relays(&naddrs.extract_relays()).await; Some(naddrs) } else if fs::exists(&nostr_address_path).is_ok() { @@ -176,7 +180,7 @@ impl CommandRunner for ReplyArgs { let author_read_relays = utils::add_read_relays(client.user_relays_list(user_pubk).await?.as_ref()); let write_relays = [ - options.relays, + relays, utils::add_write_relays(relays_list.as_ref()), // Merge repository announcement relays into write relays repos.extract_relays(), diff --git a/src/cli/commands/repo/announce.rs b/src/cli/commands/repo/announce.rs index 73c91fe..fe29a81 100644 --- a/src/cli/commands/repo/announce.rs +++ b/src/cli/commands/repo/announce.rs @@ -21,7 +21,7 @@ use futures::future; use nostr::{event::EventBuilder, key::PublicKey, types::Url}; use crate::{ - cli::{CliOptions, CommandRunner, NOSTR_ADDRESS_FILE}, + cli::{CliConfig, CliOptions, CommandRunner, NOSTR_ADDRESS_FILE, types::RelayOrSetVecExt}, error::N34Result, nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils}, }; @@ -81,7 +81,9 @@ impl CommandRunner for AnnounceArgs { async fn run(mut self, options: CliOptions) -> N34Result<()> { options.ensure_relays()?; - let client = NostrClient::init(&options).await; + let config = CliConfig::load_toml(&options.config_path)?; + let relays = options.relays.clone().flat_relays(&config.sets)?; + let client = NostrClient::init(&options, &relays).await; let user_pubk = options.pubkey().await?; let relays_list = client.user_relays_list(user_pubk).await?; @@ -89,14 +91,14 @@ impl CommandRunner for AnnounceArgs { self.maintainers.insert(0, user_pubk); } - let naddr = utils::repo_naddr(&self.repo_id, user_pubk, &options.relays)?; + let naddr = utils::repo_naddr(&self.repo_id, user_pubk, &relays)?; let event = EventBuilder::new_git_repo( self.repo_id, self.name.map(utils::str_trim), self.description.map(utils::str_trim), self.web, self.clone, - options.relays.clone(), + relays.clone(), self.maintainers.clone(), self.label.into_iter().map(utils::str_trim).collect(), self.force_id, @@ -125,7 +127,7 @@ impl CommandRunner for AnnounceArgs { } let write_relays = [ - options.relays.clone(), + relays, utils::add_write_relays(relays_list.as_ref()), // Include read relays for each maintainer (if found) future::join_all( diff --git a/src/cli/commands/repo/view.rs b/src/cli/commands/repo/view.rs index ff6f679..f8c4723 100644 --- a/src/cli/commands/repo/view.rs +++ b/src/cli/commands/repo/view.rs @@ -17,10 +17,14 @@ use std::fmt; use clap::Args; -use nostr::nips::nip19::Nip19Coordinate; use crate::{ - cli::{CliOptions, CommandRunner, parsers}, + cli::{ + CliConfig, + CliOptions, + CommandRunner, + types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt}, + }, error::N34Result, nostr_utils::{NostrClient, traits::NaddrsUtils, utils}, }; @@ -28,20 +32,25 @@ use crate::{ /// Arguments for the `repo view` command #[derive(Args, Debug)] pub struct ViewArgs { - /// Repository address in `naddr` format or `/repo_id`. e.g. - /// `4rs.nl/n34` and `_@4rs.nl/n34` + /// Repository address in `naddr` format (`naddr1...`), NIP-05 format + /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`. /// - /// If not provided, `n34` will look for the `nostr-address` file. - #[arg(value_name = "NADDR-OR-NIP05", long = "repo", value_parser = parsers::repo_naddr)] - naddrs: Option>, + /// If omitted, looks for a `nostr-address` file. + #[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")] + naddrs: Option>, } impl CommandRunner for ViewArgs { async fn run(self, options: CliOptions) -> N34Result<()> { // FIXME: The signer is not required here - let naddrs = utils::naddrs_or_file(self.naddrs, &utils::nostr_address_path()?)?; - let client = NostrClient::init(&options).await; + let config = CliConfig::load_toml(&options.config_path)?; + let naddrs = utils::naddrs_or_file( + self.naddrs.flat_naddrs(&config.sets)?, + &utils::nostr_address_path()?, + )?; + let relays = options.relays.clone().flat_relays(&config.sets)?; + let client = NostrClient::init(&options, &relays).await; client.add_relays(&naddrs.extract_relays()).await; let repos = client.fetch_repos(&naddrs.into_coordinates()).await?; diff --git a/src/cli/commands/sets/mod.rs b/src/cli/commands/sets/mod.rs new file mode 100644 index 0000000..11465bd --- /dev/null +++ b/src/cli/commands/sets/mod.rs @@ -0,0 +1,57 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +/// `sets new` command +mod new; +/// `sets remove` command +mod remove; +/// `sets show` commands +mod show; +/// `sets update` command +mod update; + +use clap::Subcommand; + +use self::new::NewArgs; +use self::remove::RemoveArgs; +use self::show::ShowArgs; +use self::update::UpdateArgs; +use super::{CliOptions, CommandRunner}; +use crate::error::N34Result; + +#[derive(Subcommand, Debug)] +pub enum SetsSubcommands { + /// Remove a set, or specific repos and relays within it + Remove(RemoveArgs), + /// Create a new set + New(NewArgs), + /// Modify an existing set + Update(UpdateArgs), + /// Show a single set or all the stored sets + Show(ShowArgs), +} + + +impl CommandRunner for SetsSubcommands { + async fn run(self, options: CliOptions) -> N34Result<()> { + match self { + Self::Remove(args) => args.run(options).await, + Self::New(args) => args.run(options).await, + Self::Update(args) => args.run(options).await, + Self::Show(args) => args.run(options).await, + } + } +} diff --git a/src/cli/commands/sets/new.rs b/src/cli/commands/sets/new.rs new file mode 100644 index 0000000..240e6cc --- /dev/null +++ b/src/cli/commands/sets/new.rs @@ -0,0 +1,59 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use clap::Args; + +use crate::{ + cli::{ + CliConfig, + CliOptions, + ConfigError, + MutRepoRelaySetsExt, + traits::CommandRunner, + types::{NaddrOrSet, NaddrOrSetVecExt, RelayOrSet, RelayOrSetVecExt}, + }, + error::N34Result, +}; + +#[derive(Args, Debug)] +pub struct NewArgs { + /// Unique name for the set + name: String, + /// Optional relay to add it to the set, either as URL or set name to + /// extract its relays. [aliases: `--sr`] + #[arg(long = "set-relay", alias("sr"))] + relays: Vec, + /// Repository address in `naddr` format (`naddr1...`), NIP-05 format + /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`. + #[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")] + naddrs: Vec, +} + +impl CommandRunner for NewArgs { + // FIXME: The signer is not required here + async fn run(self, options: CliOptions) -> N34Result<()> { + let mut config = CliConfig::load_toml(&options.config_path)?; + let naddrs = self.naddrs.flat_naddrs(&config.sets)?; + let relays = self.relays.flat_relays(&config.sets)?; + + if relays.is_empty() && naddrs.is_empty() { + return Err(ConfigError::NewEmptySet.into()); + } + + config.sets.push_set(self.name, naddrs, relays)?; + config.dump_toml(&options.config_path) + } +} diff --git a/src/cli/commands/sets/remove.rs b/src/cli/commands/sets/remove.rs new file mode 100644 index 0000000..3da6422 --- /dev/null +++ b/src/cli/commands/sets/remove.rs @@ -0,0 +1,65 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use clap::Args; + +use crate::{ + cli::{ + CliConfig, + CliOptions, + MutRepoRelaySetsExt, + traits::CommandRunner, + types::{NaddrOrSet, NaddrOrSetVecExt, RelayOrSet, RelayOrSetVecExt}, + }, + error::N34Result, +}; + +#[derive(Args, Debug)] +pub struct RemoveArgs { + /// Set name to delete + name: String, + /// Specific relay to remove it from the set, either as URL or set name to + /// extract its relays. [aliases: `--sr`] + #[arg(long = "set-relay", alias("sr"))] + relays: Vec, + + /// Repository address in `naddr` format (`naddr1...`), NIP-05 format + /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`. + #[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")] + naddrs: Vec, +} + +impl CommandRunner for RemoveArgs { + // FIXME: The signer is not required here + async fn run(self, options: CliOptions) -> N34Result<()> { + let mut config = CliConfig::load_toml(&options.config_path)?; + let naddrs = self.naddrs.flat_naddrs(&config.sets)?; + let relays = self.relays.flat_relays(&config.sets)?; + + if relays.is_empty() && naddrs.is_empty() { + config.sets.remove_set(self.name)?; + } else { + if !relays.is_empty() { + config.sets.remove_relays(&self.name, relays.into_iter())?; + } + if !naddrs.is_empty() { + config.sets.remove_naddrs(self.name, naddrs.into_iter())?; + } + } + + config.dump_toml(&options.config_path) + } +} diff --git a/src/cli/commands/sets/show.rs b/src/cli/commands/sets/show.rs new file mode 100644 index 0000000..9abbae8 --- /dev/null +++ b/src/cli/commands/sets/show.rs @@ -0,0 +1,85 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use clap::Args; +use nostr::nips::nip19::ToBech32; + +use crate::{ + cli::{CliConfig, CliOptions, RepoRelaySet, RepoRelaySetsExt, traits::CommandRunner}, + error::N34Result, +}; + +#[derive(Args, Debug)] +pub struct ShowArgs { + /// Name of the set to display. If not provided, lists all available sets. + name: Option, +} + +impl CommandRunner for ShowArgs { + // FIXME: The signer is not required here + async fn run(self, options: CliOptions) -> N34Result<()> { + let config = CliConfig::load_toml(&options.config_path)?; + + if let Some(name) = self.name { + println!("{}", format_set(config.sets.as_slice().get_set(&name)?)); + } else { + println!( + "{}", + config + .sets + .iter() + .map(format_set) + .collect::>() + .join("\n----------\n") + ); + } + + Ok(()) + } +} + +/// Format a set to view it to the user +fn format_set(set: &RepoRelaySet) -> String { + let naddrs = if set.naddrs.is_empty() { + "Nothing".to_owned() + } else { + format!( + "\n- {}", + set.naddrs + .iter() + .map(|naddr| naddr.to_bech32().expect("We did decoded before")) + .collect::>() + .join("\n- ") + ) + }; + let relays = if set.relays.is_empty() { + "Nothing".to_owned() + } else { + format!( + "\n- {}", + set.relays + .iter() + .map(|relay| relay.to_string()) + .collect::>() + .join("\n- ") + ) + }; + + format!( + "Name: {}\nّّRepositories: {naddrs}\nRelays: {relays}", + set.name + ) +} diff --git a/src/cli/commands/sets/update.rs b/src/cli/commands/sets/update.rs new file mode 100644 index 0000000..4bfb36c --- /dev/null +++ b/src/cli/commands/sets/update.rs @@ -0,0 +1,72 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::collections::HashSet; + +use clap::Args; + +use crate::{ + cli::{ + CliConfig, + CliOptions, + MutRepoRelaySetsExt, + traits::CommandRunner, + types::{NaddrOrSet, NaddrOrSetVecExt, RelayOrSet, RelayOrSetVecExt}, + }, + error::N34Result, +}; + +#[derive(Args, Debug)] +pub struct UpdateArgs { + /// Name of the set to update + name: String, + /// Add relay to the set, either as URL or set name to extract its relays. + /// [aliases: `--sr`] + #[arg(long = "set-relay", alias("sr"))] + relays: Vec, + /// Repository address in `naddr` format (`naddr1...`), NIP-05 format + /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`. + #[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")] + naddrs: Vec, + /// Replace existing relays/repositories instead of adding to them + #[arg(long = "override")] + override_set: bool, +} + +impl CommandRunner for UpdateArgs { + // FIXME: The signer is not required here + async fn run(self, options: CliOptions) -> N34Result<()> { + let mut config = CliConfig::load_toml(&options.config_path)?; + let naddrs = self.naddrs.flat_naddrs(&config.sets)?; + let relays = self.relays.flat_relays(&config.sets)?; + + let set = config.sets.get_mut_set(&self.name)?; + + if self.override_set { + if !relays.is_empty() { + set.relays = HashSet::from_iter(relays); + } + if !naddrs.is_empty() { + set.naddrs = HashSet::from_iter(naddrs) + } + } else { + set.relays.extend(relays); + set.naddrs.extend(naddrs); + } + + config.dump_toml(&options.config_path) + } +} diff --git a/src/cli/config.rs b/src/cli/config.rs new file mode 100644 index 0000000..6cff549 --- /dev/null +++ b/src/cli/config.rs @@ -0,0 +1,303 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::{collections::HashSet, fs, path::Path}; + +use nostr::{ + nips::nip19::{FromBech32, Nip19Coordinate, ToBech32}, + types::RelayUrl, +}; +use serde::{Deserialize, Serialize, Serializer}; + +use crate::error::{N34Error, N34Result}; + +/// Errors that can occur when working with configuration files. +#[derive(thiserror::Error, Debug)] +pub enum ConfigError { + #[error( + "Could not determine the default config path: both `$XDG_CONFIG_HOME` and `$HOME` \ + environment variables are missing or unset." + )] + CanNotFindConfigPath, + #[error("Couldn't read the config file: {0}")] + ReadFile(std::io::Error), + #[error("Couldn't write in the config file: {0}")] + WriteFile(std::io::Error), + #[error("Couldn't serialize the config. This is a bug, please report it: {0}")] + Serialize(toml::ser::Error), + #[error("Failed to parse the config file: {0}")] + ParseFile(toml::de::Error), + #[error("Duplicate configuration set name detected: '{0}'. Each set must have a unique name.")] + SetDuplicateName(String), + #[error("No set with the given name `{0}`")] + SetNotFound(String), + #[error("You can't create an new empty set.")] + NewEmptySet, +} + +/// Configuration for the command-line interface. +#[derive(serde::Serialize, serde::Deserialize, Clone, Default, Debug)] +pub struct CliConfig { + /// Groups of repositories and relays. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sets: Vec, +} + +/// A named group of repositories and relays. +#[derive(serde::Serialize, serde::Deserialize, Default, Clone, Debug)] +pub struct RepoRelaySet { + /// Unique identifier for this group. + pub name: String, + /// Repository addresses in this group. + #[serde( + default, + skip_serializing_if = "HashSet::is_empty", + serialize_with = "ser_naddrs", + deserialize_with = "de_naddrs" + )] + pub naddrs: HashSet, + /// Relay URLs in this group. + #[serde(default, skip_serializing_if = "HashSet::is_empty")] + pub relays: HashSet, +} + +impl CliConfig { + /// Reads and parse a TOML config file from the given path, creating it if + /// missing. + pub fn load_toml(file_path: &Path) -> N34Result { + tracing::info!(path = %file_path.display(), "Loading configuration from file"); + // Make sure the file is exist + if let Some(parent) = file_path.parent() { + if !parent.exists() { + fs::create_dir_all(parent)?; + } + } + let _ = fs::File::create_new(file_path); + + let mut config: Self = + toml::from_str(&fs::read_to_string(file_path).map_err(ConfigError::ReadFile)?) + .map_err(ConfigError::ParseFile)?; + + config.post_sets()?; + + Ok(config) + } + + /// Dump the config as toml in a file + pub fn dump_toml(mut self, file_path: &Path) -> N34Result<()> { + tracing::debug!(config = ?self, "Writing configuration to {}", file_path.display()); + self.post_sets()?; + + fs::write( + file_path, + toml::to_string_pretty(&self).map_err(ConfigError::Serialize)?, + ) + .map_err(ConfigError::WriteFile)?; + + Ok(()) + } + + /// Performs post-processing validation on the sets after loading or before + /// dumping. + fn post_sets(&mut self) -> N34Result<()> { + self.sets.as_slice().ensure_names()?; + self.sets.dedup_naddrs(); + + Ok(()) + } +} + +impl RepoRelaySet { + /// Create a new [`RepoRelaySet`] + pub fn new( + name: impl Into, + naddrs: impl IntoIterator, + relays: impl IntoIterator, + ) -> Self { + Self { + name: name.into(), + naddrs: HashSet::from_iter(naddrs), + relays: HashSet::from_iter(relays), + } + } + + /// Removes duplicate repository addresses by comparing their coordinates, + /// ignoring embedded relays. + pub fn dedup_naddrs(&mut self) { + let mut seen = HashSet::new(); + self.naddrs.retain(|n| seen.insert(n.coordinate.clone())); + } +} + +#[easy_ext::ext(MutRepoRelaySetsExt)] +impl Vec { + /// Removes duplicate repository addresses from each set. + /// + /// Relays are automatically deduplicated by the HashSet, but + /// repository addresses may appear duplicated if relays are sorted + /// differently or when relay counts vary. This compares addresses by + /// their coordinates, ignoring any embedded relay details. + pub fn dedup_naddrs(&mut self) { + self.iter_mut().for_each(RepoRelaySet::dedup_naddrs); + } + + /// Finds and returns a mutable reference a set with the given name. Returns + /// an error if no set with this name exists. + pub fn get_mut_set(&mut self, name: impl AsRef) -> N34Result<&mut RepoRelaySet> { + let name = name.as_ref(); + let set = self + .iter_mut() + .find(|set| set.name == name) + .ok_or_else(|| N34Error::from(ConfigError::SetNotFound(name.to_owned())))?; + + tracing::trace!( + name = %name, set = ?set, + "Successfully located a set with the giving name" + ); + + Ok(set) + } + + /// Creates and pushes a new set with the given name. + /// + /// Returns an error if a set with the same name already exists. + pub fn push_set( + &mut self, + name: impl Into, + repos: impl IntoIterator, + relays: impl IntoIterator, + ) -> N34Result<()> { + let set_name: String = name.into(); + tracing::trace!(sets = ?self, "Pushing set '{set_name}' to sets collection"); + + if self.as_slice().exists(&set_name) { + return Err(ConfigError::SetDuplicateName(set_name).into()); + } + + self.push(RepoRelaySet::new(set_name, repos, relays)); + + Ok(()) + } + + /// Removes the set with the given name if it exists. Returns an error if + /// the set is not found. + pub fn remove_set(&mut self, name: impl Into) -> N34Result<()> { + let set_name: String = name.into(); + tracing::trace!(set_name, sets = ?self, "Removing set '{set_name}' from sets collection"); + + if !self.as_slice().exists(&set_name) { + return Err(ConfigError::SetNotFound(set_name).into()); + } + + self.retain(|s| s.name != set_name); + + Ok(()) + } + + /// Removes the given relays from the specified set. + pub fn remove_relays( + &mut self, + name: impl Into, + relays: impl Iterator, + ) -> N34Result<()> { + let relays = Vec::from_iter(relays); + let set = self.get_mut_set(name.into())?; + + set.relays.retain(|r| !relays.contains(r)); + + Ok(()) + } + + /// Removes the given naddrs from the specified set. + pub fn remove_naddrs( + &mut self, + name: impl Into, + naddrs: impl Iterator, + ) -> N34Result<()> { + let coordinates = Vec::from_iter(naddrs.map(|n| n.coordinate)); + let set = self.get_mut_set(name.into())?; + + set.naddrs.retain(|n| !coordinates.contains(&n.coordinate)); + + Ok(()) + } +} + +#[easy_ext::ext(RepoRelaySetsExt)] +impl &[RepoRelaySet] { + /// Checks for duplicate set names. Returns an error if any duplicates are + /// found. + pub fn ensure_names(&self) -> N34Result<()> { + let mut names = Vec::with_capacity(self.len()); + names.extend(self.iter().map(|s| s.name.to_owned())); + + names.sort_unstable(); + + if let Some(duplicate) = duplicate_in_sorted(&names) { + return Err(ConfigError::SetDuplicateName(duplicate.clone()).into()); + } + Ok(()) + } + + /// Check if a set with the given name exists. + pub fn exists(&self, set_name: &str) -> bool { + self.iter().any(|set| set.name == set_name) + } + + /// Finds and returns a reference a set with the given name. Returns an + /// error if no set with this name exists. + pub fn get_set(&self, name: impl AsRef) -> N34Result<&RepoRelaySet> { + let name = name.as_ref(); + let set = self + .iter() + .find(|set| set.name == name) + .ok_or_else(|| N34Error::from(ConfigError::SetNotFound(name.to_owned())))?; + tracing::trace!( + name = %name, set = ?set, + "Successfully located a set with the giving name" + ); + Ok(set) + } +} + +/// Helper function that checks for duplicates in a sorted slice +fn duplicate_in_sorted(items: &[T]) -> Option<&T> { + items.windows(2).find(|w| w[0] == w[1]).map(|w| &w[0]) +} + +fn ser_naddrs(naddr: &HashSet, serializer: S) -> Result +where + S: Serializer, +{ + let str_naddrs = naddr + .iter() + .map(|n| n.to_bech32().map_err(|err| err.to_string())) + .collect::, _>>() + .map_err(serde::ser::Error::custom)?; + + str_naddrs.serialize(serializer) +} + +fn de_naddrs<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Vec::::deserialize(deserializer)? + .into_iter() + .map(|naddr| Nip19Coordinate::from_bech32(&naddr)) + .collect::, _>>() + .map_err(serde::de::Error::custom) +} diff --git a/src/cli/defaults.rs b/src/cli/defaults.rs new file mode 100644 index 0000000..e6ba0bf --- /dev/null +++ b/src/cli/defaults.rs @@ -0,0 +1,28 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::path::PathBuf; + +use crate::{cli::ConfigError, error::N34Result}; + + +/// Default config path +pub fn config_path() -> N34Result { + Ok(dirs::config_dir() + .ok_or(ConfigError::CanNotFindConfigPath)? + .join("n34") + .join("config.toml")) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0b4ad35..e1c6318 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -15,16 +15,23 @@ // along with this program. If not, see . /// Commands module -mod commands; +pub mod commands; +/// The CLI config +pub mod config; +/// Default lazy values for CLI arguments +pub mod defaults; /// CLI arguments parsers pub mod parsers; /// CLI traits -mod traits; +pub mod traits; +/// Common helper types used throughout the CLI. +pub mod types; use clap::Parser; use clap_verbosity_flag::Verbosity; pub use self::commands::*; +pub use self::config::*; use self::traits::CommandRunner; use crate::error::N34Result; diff --git a/src/cli/parsers.rs b/src/cli/parsers.rs index 1db4f6f..0d4d819 100644 --- a/src/cli/parsers.rs +++ b/src/cli/parsers.rs @@ -18,35 +18,16 @@ use std::{fs, path::Path}; use nostr::{ Kind, - nips::{ - self, - nip01::Coordinate, - nip19::{FromBech32, Nip19Coordinate}, - }, + nips::nip19::{FromBech32, Nip19Coordinate}, }; -use tokio::runtime::Handle; -use crate::error::{N34Error, N34Result}; +use super::Cli; +use crate::{ + cli::DEFAULT_FALLBACK_PATH, + error::{N34Error, N34Result}, +}; -fn parse_nip5_repo(nip5: &str, repo_id: &str) -> Result { - let (username, domain) = nip5.split_once("@").unwrap_or(("_", nip5)); - - let nip5_profile = tokio::task::block_in_place(|| { - Handle::current().block_on(async { - nips::nip05::profile(format!("{username}@{domain}"), None) - .await - .map_err(|err| err.to_string()) - }) - })?; - - Ok(Nip19Coordinate::new( - Coordinate::new(Kind::GitRepoAnnouncement, nip5_profile.public_key).identifier(repo_id), - nip5_profile.relays, - ) - .expect("The relays is `RelayUrl`")) -} - -fn parse_repo_naddr(repo_naddr: &str) -> Result { +pub fn parse_repo_naddr(repo_naddr: &str) -> Result { let naddr = Nip19Coordinate::from_bech32(repo_naddr).map_err(|err| err.to_string())?; if naddr.relays.is_empty() { tracing::warn!("The repository naddr does not contain any relay hints"); @@ -74,27 +55,11 @@ pub fn parse_nostr_address_file(file_path: &Path) -> N34Result Result { - let repo = repo.trim().trim_start_matches("nostr:"); - - if repo.contains("/") { - let (nip5, repo_id) = repo.split_once("/").expect("There is a `/`"); - parse_nip5_repo(nip5, repo_id) - } else if repo.starts_with("naddr1") { - parse_repo_naddr(repo) - } else { - Err( - "Invalid repository address format. It can be A NIP-05 identifier with repository ID \ - in format `/` or A valid naddr1 string (NIP-19)" - .to_owned(), - ) +/// Post parse cli arguments +pub fn post_parse_cli(mut cli: Cli) -> N34Result { + if let Some(DEFAULT_FALLBACK_PATH) = cli.options.config_path.to_str() { + cli.options.config_path = super::defaults::config_path()?; } + + Ok(cli) } diff --git a/src/cli/types.rs b/src/cli/types.rs new file mode 100644 index 0000000..4259b03 --- /dev/null +++ b/src/cli/types.rs @@ -0,0 +1,194 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::str::FromStr; + +use nostr::{ + event::Kind, + nips::{self, nip01::Coordinate, nip19::Nip19Coordinate}, + types::RelayUrl, +}; +use tokio::runtime::Handle; + +use super::{RepoRelaySetsExt, parsers}; +use crate::{ + cli::RepoRelaySet, + error::{N34Error, N34Result}, +}; + +/// Either a NIP-19 coordinate (naddr) or a named set. +#[derive(Debug, Clone)] +pub enum NaddrOrSet { + /// NIP-19 coordinate. + Naddr(Nip19Coordinate), + /// Name of a set (may not exist). + Set(String), +} + +/// Either relay URL or a named set. +#[derive(Debug, Clone)] +pub enum RelayOrSet { + /// Relay URL. + Relay(RelayUrl), + /// Name of a set (may not exist). + Set(String), +} + +impl NaddrOrSet { + /// Returns the naddr if `Naddr` or try to get the relays from the set. + /// Returns error if the set naddrs are empty or the set not found. + pub fn get_naddrs(self, sets: &[RepoRelaySet]) -> N34Result> { + match self { + Self::Naddr(nip19_coordinate) => Ok(vec![nip19_coordinate]), + Self::Set(name) => { + let set = sets + .get_set(&name) + .map_err(|_| N34Error::InvalidNaddrArg(name.clone()))?; + if set.naddrs.is_empty() { + Err(N34Error::EmptySetNaddrs(name)) + } else { + Ok(Vec::from_iter(set.naddrs.clone())) + } + } + } + } +} + + +impl RelayOrSet { + /// Returns the relay if `Relay` or try to get the relays from the set. + /// Returns error if the set relays are empty or the set not found + pub fn get_relays(self, sets: &[RepoRelaySet]) -> N34Result> { + match self { + Self::Relay(relay) => Ok(vec![relay]), + Self::Set(name) => { + let set = sets.get_set(&name)?; + if set.relays.is_empty() { + Err(N34Error::EmptySetRelays(name)) + } else { + Ok(Vec::from_iter(set.relays.clone())) + } + } + } + } +} + +impl FromStr for NaddrOrSet { + type Err = String; + + /// Parses a Git repository address which can be either: + /// - A bech32-encoded naddr (e.g. "naddr1...") for Git repository + /// announcements (kind 30617) + /// - A NIP-05 identifier with repository ID (e.g. "4rs.nl/n34" or + /// "_@4rs.nl/n34") + /// - A set name. + /// + /// Returns an error for invalid formats, failed bech32 decoding, wrong + /// event kind. + fn from_str(naddr_or_set: &str) -> Result { + let naddr_or_set = naddr_or_set.trim(); + + if naddr_or_set.contains("/") { + let (nip5, repo_id) = naddr_or_set.split_once("/").expect("There is a `/`"); + parse_nip5_repo(nip5, repo_id) + } else if naddr_or_set.starts_with("naddr1") || naddr_or_set.starts_with("nostr:naddr1") { + parsers::parse_repo_naddr(naddr_or_set.trim_start_matches("nostr:")).map(Self::Naddr) + } else { + Ok(Self::Set(naddr_or_set.to_owned())) + } + } +} + +impl FromStr for RelayOrSet { + type Err = String; + + /// Parse a string into a relay URL or a set name. + /// If the string is a valid URL (e.g., "wss://example.com"), it's treated + /// as a relay URL. Otherwise, it's treated as a set name, and its + /// associated relays will be merged. + fn from_str(relay_or_set: &str) -> Result { + let relay_or_set = relay_or_set.trim(); + + if relay_or_set.starts_with("wss://") { + RelayUrl::from_str(relay_or_set) + .map_err(|err| err.to_string()) + .map(Self::Relay) + } else { + Ok(Self::Set(relay_or_set.to_owned())) + } + } +} + +fn parse_nip5_repo(nip5: &str, repo_id: &str) -> Result { + let (username, domain) = nip5.split_once("@").unwrap_or(("_", nip5)); + + let nip5_profile = tokio::task::block_in_place(|| { + Handle::current().block_on(async { + nips::nip05::profile(format!("{username}@{domain}"), None) + .await + .map_err(|err| err.to_string()) + }) + })?; + + Ok(NaddrOrSet::Naddr( + Nip19Coordinate::new( + Coordinate::new(Kind::GitRepoAnnouncement, nip5_profile.public_key).identifier(repo_id), + nip5_profile.relays, + ) + .expect("The relays is `RelayUrl`"), + )) +} + +#[easy_ext::ext(NaddrOrSetVecExt)] +impl Vec { + /// Converts this vector of [`NaddrOrSet`] into a flat vector of + /// [`Nip19Coordinates`] using the given sets. + pub fn flat_naddrs(self, sets: &[RepoRelaySet]) -> N34Result> { + self.into_iter() + .map(|n| n.get_naddrs(sets)) + .try_fold(Vec::new(), |mut acc, item| { + acc.extend(item?); + Ok(acc) + }) + } +} + +#[easy_ext::ext(RelayOrSetVecExt)] +impl Vec { + /// Converts this vector of [`RelayOrSet`] into a flat vector of + /// [`RelayUrl`] using the given sets. + pub fn flat_relays(self, sets: &[RepoRelaySet]) -> N34Result> { + self.into_iter() + .map(|n| n.get_relays(sets)) + .try_fold(Vec::new(), |mut acc, item| { + acc.extend(item?); + Ok(acc) + }) + } +} + + +#[easy_ext::ext(OptionNaddrOrSetVecExt)] +impl Option> { + /// Converts this vector of [`NaddrOrSet`] into a flat vector of + /// [`Nip19Coordinates`] using the given sets. + pub fn flat_naddrs(&self, sets: &[RepoRelaySet]) -> N34Result>> { + // Clones self here to simplify command code + self.clone() + .map(|naddrs| naddrs.flat_naddrs(sets)) + .transpose() + } +} diff --git a/src/error.rs b/src/error.rs index b64f389..7c22a5a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,6 +19,8 @@ use std::process::ExitCode; use nostr::event::builder::Error as EventBuilderError; use nostr_sdk::client::Error as ClientError; +use crate::cli::ConfigError; + pub type N34Result = Result; /// N34 errors @@ -26,6 +28,8 @@ pub type N34Result = Result; pub enum N34Error { #[error("IO: {0}")] Io(#[from] std::io::Error), + #[error("{0}")] + Config(#[from] ConfigError), #[error("No editor specified in the `EDITOR` environment variable")] EditorNotFound, #[error("The file you edited is empty. Please save your changes before exiting the editor.")] @@ -62,6 +66,19 @@ pub enum N34Error { InvalidNostrAddressFileContent(String), #[error("This command requires at least one relay, but none were provided")] EmptyRelays, + #[error( + "Invalid repository address. Expected one of these formats:\n- NIP-05 identifier with \ + repository ID: `/`\n- Valid NIP-19 naddr string (starts with \ + 'naddr1...')\n- Existing set name (merges all repositories in set)\nError: No set named \ + '{0}' exists." + )] + InvalidNaddrArg(String), + #[error( + "The set '{0}' doesn't contain any addresses. Use 'sets update' to add addresses to it." + )] + EmptySetNaddrs(String), + #[error("The set '{0}' doesn't contain any relays. Use 'sets update' to add addresses to it.")] + EmptySetRelays(String), } impl N34Error { diff --git a/src/main.rs b/src/main.rs index cc4a410..1d0c1e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,7 +61,14 @@ fn set_log_level(verbosity: Verbosity) { #[tokio::main] async fn main() -> ExitCode { - let cli = Cli::parse(); + let cli = match cli::parsers::post_parse_cli(Cli::parse()) { + Ok(cli) => cli, + Err(err) => { + eprintln!("{err}"); + return ExitCode::FAILURE; + } + }; + set_log_level(cli.verbosity); if let Err(err) = cli.run().await { diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs index e0592cb..6794913 100644 --- a/src/nostr_utils/mod.rs +++ b/src/nostr_utils/mod.rs @@ -102,7 +102,7 @@ impl NostrClient { /// Initializes a new [`NostrClient`] instance and connects to the specified /// relays. - pub async fn init(options: &CliOptions) -> Self { + pub async fn init(options: &CliOptions, relays: &[RelayUrl]) -> Self { let client = Self::new( Client::builder() .signer(Keys::new( @@ -115,7 +115,7 @@ impl NostrClient { .build(), ); - client.add_relays(&options.relays).await; + client.add_relays(relays).await; client }