diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..6ae4a92 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,122 @@ +// 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 . + +/// CLI arguments parsers +pub mod parsers; +/// `repo` subcommands +mod repo; +/// CLI traits +mod traits; + +use std::fmt; + +use clap::{ArgGroup, Args, Parser}; +use clap_verbosity_flag::Verbosity; +use nostr::{RelayUrl, SecretKey}; + +pub use self::repo::RepoSubcommands; +pub use self::traits::CommandRunner; +use crate::error::N34Result; + +/// Header message, used in the help message +const HEADER: &str = r#"Copyright (C) 2025 Awiteb +License GNU GPL-3.0-or-later +This is free software: you are free to change and redistribute it. +There is NO WARRANTY, to the extent permitted by law. + +Git repository: https://git.4rs.nl/awiteb/n34"#; + +/// Footer message, used in the help message +const FOOTER: &str = r#"Please report bugs to ."#; + +/// The command-line interface options +#[derive(Args, Clone)] +#[clap( + group( + ArgGroup::new("auth") + .args(&["secret_key"]) + .required(true) + ) +)] +pub struct CliOptions { + /// Your Nostr secret key + #[arg(short, long)] + pub secret_key: Option, + /// Where your relays list. And repository relays if not included in naddr + #[arg(short, long, required = true)] + pub relays: Vec, +} + +#[derive(Parser, Debug)] +#[command(about, version, before_long_help = HEADER, after_long_help = FOOTER)] +/// A command-line interface for interacting with NIP-34 and other Nostr +/// code-related stuff. +pub struct Cli { + #[command(flatten)] + pub options: CliOptions, + /// Controls the verbosity level of output + #[command(flatten)] + pub verbosity: Verbosity, + /// The subcommand to execute + #[command(subcommand)] + pub command: Commands, +} + +/// N34 commands +#[derive(Parser, Debug)] +pub enum Commands { + /// Manage repositories + Repo { + #[command(subcommand)] + subcommands: RepoSubcommands, + }, + // /// Manage issues + // Issue { + // #[command(subcommand)] + // subcommands: IssueSubcommands, + // }, + // /// Manage patches + // Patch { + // #[command(subcommand)] + // subcommands: PatchSubcommands, + // }, +} + +impl Cli { + /// Executes the command + pub async fn run(self) -> N34Result<()> { + self.command.run(self.options).await + } +} + +impl CommandRunner for Commands { + async fn run(&self, options: CliOptions) -> N34Result<()> { + tracing::trace!("Options: {options:#?}"); + tracing::trace!("Handling: {self:#?}"); + match self { + Self::Repo { subcommands } => subcommands.run(options).await, + } + } +} + +impl fmt::Debug for CliOptions { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CliOptions") + .field("secret_key", &self.secret_key.as_ref().map(|_| "*******")) + .field("relays", &self.relays) + .finish() + } +} diff --git a/src/cli/parsers.rs b/src/cli/parsers.rs new file mode 100644 index 0000000..782c7fd --- /dev/null +++ b/src/cli/parsers.rs @@ -0,0 +1,39 @@ +// 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 nostr::{ + Kind, + nips::nip19::{FromBech32, Nip19Coordinate}, +}; + +/// Parses a Nostr naddr string into a Git repository announcement coordinate. +/// +/// # Errors +/// Returns an error if: +/// - The bech32 decoding fails +/// - The naddr doesn't represent a Git repository announcement (kind != 30617) +pub fn repo_naddr(naddr: &str) -> Result { + let naddr = Nip19Coordinate::from_bech32(naddr).map_err(|err| err.to_string())?; + if naddr.kind != Kind::GitRepoAnnouncement { + return Err("The naddr is not repo announcement address".to_owned()); + } + + if naddr.relays.is_empty() { + tracing::warn!("The repository naddr does not contain any relay hints"); + } + + Ok(naddr) +} diff --git a/src/cli/repo/mod.rs b/src/cli/repo/mod.rs new file mode 100644 index 0000000..abb9fae --- /dev/null +++ b/src/cli/repo/mod.rs @@ -0,0 +1,38 @@ +// 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 . + +/// `repo view` subcommand +mod view; + +use clap::Subcommand; + +use self::view::ViewArgs; +use super::{CliOptions, CommandRunner}; +use crate::error::N34Result; + +#[derive(Subcommand, Debug)] +pub enum RepoSubcommands { + /// View details of a nostr git repository + View(ViewArgs), +} + +impl CommandRunner for RepoSubcommands { + async fn run(&self, options: CliOptions) -> N34Result<()> { + match self { + Self::View(args) => args.run(options).await, + } + } +} diff --git a/src/cli/repo/view.rs b/src/cli/repo/view.rs new file mode 100644 index 0000000..610a934 --- /dev/null +++ b/src/cli/repo/view.rs @@ -0,0 +1,86 @@ +// 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::fmt; + +use clap::Args; +use nostr::nips::nip19::Nip19Coordinate; + +use crate::{ + cli::{CliOptions, CommandRunner, parsers}, + error::N34Result, + nostr_utils::NostrClient, +}; + +/// Arguments for the `repo view` command +#[derive(Args, Debug)] +pub struct ViewArgs { + /// Nostr repository address + #[arg(short, long, value_parser = parsers::repo_naddr)] + naddr: Nip19Coordinate, +} + +impl CommandRunner for ViewArgs { + async fn run(&self, options: CliOptions) -> N34Result<()> { + let client = NostrClient::init(&options).await; + if !self.naddr.relays.is_empty() { + client.add_read_relays(&self.naddr.relays).await; + } + + let repo = client.fetch_repo(&self.naddr).await?; + let mut msg = format!("ID: {}", repo.id); + + if let Some(name) = repo.name { + msg.push_str(&format!("\nName: {name}")); + } + if let Some(desc) = repo.description { + msg.push_str(&format!("\nDescription: {desc}")); + } + if !repo.web.is_empty() { + msg.push_str(&format!("\nWebpages:\n{}", format_list(repo.web))); + } + if !repo.clone.is_empty() { + msg.push_str(&format!("\nClone urls:\n{}", format_list(repo.clone))); + } + if !repo.relays.is_empty() { + msg.push_str(&format!("\nRelays:\n{}", format_list(repo.relays))); + } + if let Some(euc) = repo.euc { + msg.push_str(&format!("\nEarliest unique commit: {euc}")); + } + if !repo.maintainers.is_empty() { + msg.push_str(&format!( + "\nMaintainers:\n{}", + format_list(repo.maintainers) + )); + } + + println!("{msg}"); + Ok(()) + } +} + +/// Format a vector to print it +fn format_list(vector: Vec) -> String +where + T: fmt::Display, +{ + vector + .into_iter() + .map(|t| format!(" - {t}")) + .collect::>() + .join("\n") +} diff --git a/src/cli/traits.rs b/src/cli/traits.rs new file mode 100644 index 0000000..1d9d299 --- /dev/null +++ b/src/cli/traits.rs @@ -0,0 +1,24 @@ +// 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 super::CliOptions; +use crate::error::N34Result; + +/// A trait defining the interface for command runners in the CLI. +pub trait CommandRunner { + /// Executes the command and returns a Result indicating success or failure. + async fn run(&self, options: CliOptions) -> N34Result<()>; +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..ff68760 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,38 @@ +// 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::process::ExitCode; + +use nostr_sdk::client::Error as ClientError; + +pub type N34Result = Result; + +/// N34 errors +#[derive(Debug, thiserror::Error)] +pub enum N34Error { + #[error("Client Error: {0}")] + Client(#[from] ClientError), + #[error("Unable to locate the repository. The repository may not exists in the given relays")] + NotFoundRepo, +} + +impl N34Error { + /// Returns the exit code associated with this error + pub fn exit_code(&self) -> ExitCode { + // TODO: More specific exit code + ExitCode::FAILURE + } +} diff --git a/src/main.rs b/src/main.rs index b2ba02a..5213669 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,46 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -fn main() { - println!("Hello, world!"); +/// Command line interface module +mod cli; +/// N34 errors +mod error; +/// Nostr utils module +mod nostr_utils; + +use std::process::ExitCode; + +use clap::Parser; +use clap_verbosity_flag::Verbosity; + +use self::cli::Cli; + +/// Configures the logging level based on the provided verbosity. +/// +/// When verbosity is set to TRACE, includes file and line numbers in logs. +fn set_log_level(verbosity: Verbosity) { + let is_trace = verbosity + .tracing_level() + .is_some_and(|l| l == tracing::Level::TRACE); + + let subscriber = tracing_subscriber::fmt() + .with_file(is_trace) + .with_line_number(is_trace) + .without_time() + .with_max_level(verbosity) + .finish(); + tracing::subscriber::set_global_default(subscriber).ok(); +} + +#[tokio::main] +async fn main() -> ExitCode { + let cli = Cli::parse(); + set_log_level(cli.verbosity); + + if let Err(err) = cli.run().await { + tracing::error!("{err}"); + return err.exit_code(); + } + + ExitCode::SUCCESS } diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs new file mode 100644 index 0000000..f4ce60c --- /dev/null +++ b/src/nostr_utils/mod.rs @@ -0,0 +1,104 @@ +// 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 . + +pub mod utils; + +use std::time::Duration; + +use nostr::{ + event::Kind, + filter::Filter, + key::Keys, + nips::{nip19::Nip19Coordinate, nip34::GitRepositoryAnnouncement}, + types::RelayUrl, +}; +use nostr_sdk::Client; + +use crate::{ + cli::CliOptions, + error::{N34Error, N34Result}, +}; + +/// A client for interacting with the Nostr relays +pub struct NostrClient { + /// The underlying Nostr client implementation + client: Client, +} + +impl NostrClient { + /// Creates a new [`NostrClient`] with the given client and options. + const fn new(client: Client) -> Self { + Self { client } + } + + /// Initializes a new [`NostrClient`] instance and connects to the specified + /// relays. + pub async fn init(options: &CliOptions) -> Self { + let client = Self::new( + Client::builder() + .signer(Keys::new( + options + .secret_key + .as_ref() + .expect("This the only method for now") + .clone(), + )) + .build(), + ); + + client.add_read_relays(&options.relays).await; + client + } + + /// Add read relays and connect to them + pub async fn add_read_relays(&self, relays: &[RelayUrl]) { + for relay in relays { + self.client + .add_read_relay(relay) + .await + .expect("It's a valid relay url"); + if let Err(err) = self + .client + .try_connect_relay(relay, Duration::from_millis(1500)) + .await + { + tracing::error!("Failed to connect to relay '{relay}': {err}"); + } + } + } + + /// Try to fetch a repository and returns it + pub async fn fetch_repo( + &self, + repo_naddr: &Nip19Coordinate, + ) -> N34Result { + let filter = Filter::new() + .author(repo_naddr.public_key) + .kind(Kind::GitRepoAnnouncement) + .identifier(&repo_naddr.identifier); + let events = self + .client + .fetch_events(filter, Duration::from_secs(1)) + .await + .map_err(|_| N34Error::NotFoundRepo)?; + + + Ok(utils::event_into_repo( + events.first_owned().ok_or(N34Error::NotFoundRepo)?, + &repo_naddr.identifier, + )) + } +} diff --git a/src/nostr_utils/utils.rs b/src/nostr_utils/utils.rs new file mode 100644 index 0000000..8550a25 --- /dev/null +++ b/src/nostr_utils/utils.rs @@ -0,0 +1,80 @@ +// 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::{fmt, str::FromStr}; + +use nostr::{ + event::{Event, TagKind, TagStandard}, + nips::nip34::GitRepositoryAnnouncement, +}; + +/// Returns the value of the given tag +fn tag_value(tag: &TagStandard) -> String { + tag.clone().to_vec().remove(1) +} + +/// Gets all values from the tag. If any value fails to parse, returns an empty +/// vector. +fn tag_values(tag: &TagStandard) -> Vec +where + T: FromStr + fmt::Debug, + ::Err: fmt::Debug, +{ + tag.clone() + .to_vec() + .into_iter() + .skip(1) + .map(|t| { + let result = T::from_str(t.as_str()); + tracing::trace!("Parsing `{t}` result: `{result:?}`"); + result + }) + .collect::>() + .unwrap_or_default() +} + +/// Convert [`Event`] to [`GitRepositoryAnnouncement`] +pub fn event_into_repo(event: Event, repo_id: impl Into) -> GitRepositoryAnnouncement { + GitRepositoryAnnouncement { + id: repo_id.into(), + name: event.tags.find_standardized(TagKind::Name).map(tag_value), + description: event + .tags + .find_standardized(TagKind::Description) + .map(tag_value), + web: event + .tags + .find_standardized(TagKind::Web) + .map(tag_values) + .unwrap_or_default(), + clone: event + .tags + .find_standardized(TagKind::Clone) + .map(tag_values) + .unwrap_or_default(), + relays: event + .tags + .find_standardized(TagKind::Relays) + .map(tag_values) + .unwrap_or_default(), + euc: None, + maintainers: event + .tags + .find_standardized(TagKind::Maintainers) + .map(tag_values) + .unwrap_or_default(), + } +}