chore: Make commands module

Signed-off-by: Awiteb <a@4rs.nl>
This commit is contained in:
Awiteb
2025-05-23 13:15:37 +00:00
parent 226909ef16
commit 15c0eaa5dc
8 changed files with 119 additions and 90 deletions

View File

@@ -0,0 +1,38 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
/// `issue new` subcommand
mod new;
use clap::Subcommand;
use self::new::NewArgs;
use super::{CliOptions, CommandRunner};
use crate::error::N34Result;
#[derive(Subcommand, Debug)]
pub enum IssueSubcommands {
/// Create a new repository issue
New(NewArgs),
}
impl CommandRunner for IssueSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
match self {
Self::New(args) => args.run(options).await,
}
}
}

View File

@@ -0,0 +1,122 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
use clap::{ArgGroup, Args};
use nostr::{event::EventBuilder, nips::nip19::Nip19Coordinate};
use crate::{
cli::{CliOptions, CommandRunner, parsers},
error::N34Result,
nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils},
};
/// Arguments for the `issue new` command
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("issue-content")
.args(["content", "editor"])
.required(true)
),
group(
ArgGroup::new("issue-subject")
.args(["editor", "subject"])
)
)]
pub struct NewArgs {
/// Repository address in `naddr` format.
///
/// If not provided, `n34` will look for a `nostr-address` file.
#[arg(short, long, value_parser = parsers::repo_naddr)]
naddr: Option<Nip19Coordinate>,
/// Markdown content for the issue. Cannot be used together with the
/// `--editor` flag.
#[arg(short, long)]
content: Option<String>,
/// Opens the user's default editor to write issue content. The first line
/// will be used as the issue subject.
#[arg(short, long)]
editor: bool,
/// The issue subject. Cannot be used together with the `--editor` flag.
#[arg(long)]
subject: Option<String>,
/// Labels for the issue. Can be specified as arguments (-l bug) or hashtags
/// in content (#bug).
#[arg(short, long)]
label: Vec<String>,
}
impl NewArgs {
/// Returns the subject and the content of the issue. (subject, content)
pub fn issue_content(&self) -> N34Result<(Option<String>, String)> {
if let Some(content) = self.content.as_ref() {
if let Some(subject) = self.subject.as_ref() {
return Ok((Some(subject.trim().to_owned()), content.trim().to_owned()));
}
return Ok((None, content.trim().to_owned()));
}
// If the `self.content` is `None` then the `self.editor` is `true`
let file_content = utils::read_editor(".md")?;
if file_content.contains('\n') {
Ok(file_content
.split_once('\n')
.map(|(s, c)| (Some(s.trim().to_owned()), c.trim().to_owned()))
.expect("There is a new line"))
} else {
tracing::info!("File content contains only issue body without a subject line");
Ok((None, file_content))
}
}
}
impl CommandRunner for NewArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let client = NostrClient::init(&options).await;
let user_pubk = options.pubkey().await?;
let naddr = utils::naddr_or_file(self.naddr.clone(), &utils::nostr_address_path()?)?;
client.add_relays(&options.relays).await;
client.add_relays(&naddr.relays).await;
let relays_list = client.user_relays_list(user_pubk).await?;
let mut write_relays =
utils::add_write_relays(options.relays.clone(), relays_list.as_ref());
write_relays.extend(client.fetch_repo(&naddr.coordinate).await?.relays);
let (subject, content) = self.issue_content()?;
let content_details = client.parse_content(&content).await;
write_relays.extend(content_details.write_relays.clone());
let event =
EventBuilder::new_git_issue(naddr.coordinate.clone(), content, subject, self.label)?
.tags(content_details.into_tags())
.pow(options.pow)
.build(user_pubk);
let event_id = event.id.expect("There is an id");
tracing::trace!(relays = ?write_relays, "Write relays list");
let success = client
.send_event_to(event, relays_list.as_ref(), &write_relays)
.await?;
let nevent = utils::new_nevent(event_id, &success)?;
println!("Issue created: {nevent}");
Ok(())
}
}

111
src/cli/commands/mod.rs Normal file
View File

@@ -0,0 +1,111 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
/// `issue` subcommands
mod issue;
/// 'reply` command
mod reply;
/// `repo` subcommands
mod repo;
use std::fmt;
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 super::traits::CommandRunner;
use crate::error::N34Result;
/// 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<SecretKey>,
/// Where your relays list. And repository relays if not included in naddr
#[arg(short, long, required = true)]
pub relays: Vec<RelayUrl>,
/// Proof of Work difficulty when creatring events
#[arg(long, default_value_t = 0)]
pub pow: u8,
}
/// 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,
// },
/// Reply to issues and patches.
Reply(ReplyArgs),
}
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,
Self::Issue { subcommands } => subcommands.run(options).await,
Commands::Reply(args) => args.run(options).await,
}
}
}
impl CliOptions {
/// Gets the public key of the user.
pub async fn pubkey(&self) -> N34Result<PublicKey> {
if let Some(sk) = &self.secret_key {
return Ok(Keys::new(sk.clone()).public_key());
}
unreachable!("There is no other method until now")
}
}
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()
}
}

179
src/cli/commands/reply.rs Normal file
View File

@@ -0,0 +1,179 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
use std::str::FromStr;
use clap::{ArgGroup, Args};
use nostr::{
event::{EventBuilder, EventId, Kind, Tag},
filter::Filter,
nips::nip19::{self, FromBech32, Nip19Coordinate},
types::RelayUrl,
};
use super::{CliOptions, CommandRunner};
use crate::{
cli::parsers,
error::{N34Error, N34Result},
nostr_utils::{NostrClient, utils},
};
/// Parses and represents a Nostr `nevent1` or `note1`.
#[derive(Debug, Clone)]
struct NostrEvent {
/// Unique identifier for the event.
event_id: EventId,
/// List of relay URLs associated with the event. Empty if parsing a
/// `note1`.
relays: Vec<RelayUrl>,
}
impl NostrEvent {
/// Create a new [`NostrEvent`] instance
fn new(event_id: EventId, relays: Vec<RelayUrl>) -> Self {
Self { event_id, relays }
}
}
impl FromStr for NostrEvent {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.trim().starts_with("nevent1") {
let event = nip19::Nip19Event::from_bech32(s).map_err(|e| e.to_string())?;
Ok(Self::new(event.event_id, event.relays))
} else if s.trim().starts_with("note1") {
Ok(Self::new(
EventId::from_bech32(s).map_err(|e| e.to_string())?,
Vec::new(),
))
} else {
Err("Invalid event id, must starts with `note1` or `nevent1`".to_owned())
}
}
}
/// Arguments for the `reply` command
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("comment-content")
.args(["comment", "editor"])
.required(true)
)
)]
pub struct ReplyArgs {
/// The issue, patch, or comment to reply to
#[arg(long)]
to: NostrEvent,
/// Repository address
#[arg(short, long, value_parser = parsers::repo_naddr)]
naddr: Option<Nip19Coordinate>,
/// The comment (cannot be used with --editor)
#[arg(short, long)]
comment: Option<String>,
/// Open editor to write comment (cannot be used with --content)
#[arg(short, long)]
editor: bool,
}
impl CommandRunner for ReplyArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let client = NostrClient::init(&options).await;
let user_pubk = options.pubkey().await?;
let relays_list = client.user_relays_list(user_pubk).await?;
let mut write_relays =
utils::add_write_relays(options.relays.clone(), relays_list.as_ref());
client.add_relays(&options.relays).await;
client.add_relays(&self.to.relays).await;
if let Some(ref naddr) = self.naddr {
client.add_relays(&naddr.relays).await;
}
let reply_to = client
.fetch_event(Filter::new().id(self.to.event_id))
.await?
.ok_or(N34Error::EventNotFound)?;
let root = client.find_root(reply_to.clone()).await?;
let repo_naddr = if let Some(naddr) = self.naddr {
naddr.coordinate
} else if let Some(ref root_event) = root {
root_event
.tags
.coordinates()
.find(|c| c.kind == Kind::GitRepoAnnouncement)
.ok_or_else(|| {
N34Error::InvalidEvent(
"The Git issue/patch does not specify a target repository".to_owned(),
)
})?
.clone()
} else {
return Err(N34Error::NotFoundRepo);
};
let repo = client.fetch_repo(&repo_naddr).await?;
write_relays.extend(repo.relays.clone());
write_relays = client
.read_relays_from_user(write_relays, reply_to.pubkey)
.await;
if let Some(root_event) = &root {
write_relays = client
.read_relays_from_user(write_relays, root_event.pubkey)
.await;
}
let content = utils::get_content(self.comment.as_ref(), ".txt")?;
let content_details = client.parse_content(&content).await;
write_relays.extend(content_details.write_relays.clone());
let event = EventBuilder::comment(
content,
&reply_to,
root.as_ref(),
repo.relays.first().cloned(),
)
.tag(Tag::public_key(repo_naddr.public_key))
.tags(content_details.into_tags())
.pow(options.pow)
.build(user_pubk);
let event_id = event.id.expect("There is an id");
let author_read_relays = utils::add_read_relays(Vec::new(), relays_list.as_ref());
tracing::trace!(relays = ?write_relays, "Write relays list");
let (success, ..) = futures::join!(
client.send_event_to(event, relays_list.as_ref(), &write_relays),
client.broadcast(&reply_to, &author_read_relays),
async {
if let Some(root_event) = root {
let _ = client.broadcast(&root_event, &author_read_relays).await;
}
},
);
let nevent = utils::new_nevent(event_id, &success?)?;
println!("Comment created: {nevent}");
Ok(())
}
}

View File

@@ -0,0 +1,105 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
use std::fs;
use clap::Args;
use nostr::{event::EventBuilder, key::PublicKey, types::Url};
use crate::{
cli::{CliOptions, CommandRunner, NOSTR_ADDRESS_FILE},
error::N34Result,
nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils},
};
/// Arguments for the `repo announce` command
#[derive(Args, Debug)]
pub struct AnnounceArgs {
/// Unique identifier for the repository in kebab-case.
#[arg(long = "id")]
repo_id: String,
/// A name for the repository.
#[arg(short, long)]
name: Option<String>,
/// A description for the repository.
#[arg(short, long)]
description: Option<String>,
/// Webpage URLs for the repository (if provided by the git server).
#[arg(short, long)]
web: Vec<Url>,
/// URLs for cloning the repository.
#[arg(short, long)]
clone: Vec<Url>,
/// Additional maintainers of the repository (besides yourself).
#[arg(short, long)]
maintainers: Vec<PublicKey>,
/// Labels to categorize the repository. Can be specified multiple times.
#[arg(short, long)]
label: Vec<String>,
/// Skip kebab-case validation for the repository ID
#[arg(long)]
force_id: bool,
/// If set, creates a `nostr-address` file to enable automatic address
/// discovery by n34
#[arg(long)]
address_file: bool,
}
impl CommandRunner for AnnounceArgs {
async fn run(mut self, options: CliOptions) -> N34Result<()> {
let client = NostrClient::init(&options).await;
let user_pubk = options.pubkey().await?;
let relays_list = client.user_relays_list(user_pubk).await?;
let write_relays = utils::add_write_relays(options.relays.clone(), relays_list.as_ref());
if !self.maintainers.contains(&user_pubk) {
self.maintainers.insert(0, user_pubk);
}
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(),
self.maintainers,
self.label.into_iter().map(utils::str_trim).collect(),
self.force_id,
)?
.pow(options.pow)
.build(user_pubk);
let nevent = utils::new_nevent(event.id.expect("There is an id"), &write_relays)?;
let naddr = utils::repo_naddr(user_pubk, &options.relays)?;
if self.address_file {
let path = std::env::current_dir()?.join(NOSTR_ADDRESS_FILE);
tracing::info!("Create the `nostr-address` file at `{}`", path.display());
fs::write(path, &naddr)?;
}
client
.send_event_to(event, relays_list.as_ref(), &write_relays)
.await?;
println!("Event: {nevent}",);
println!("Repo Address: {naddr}",);
Ok(())
}
}

View File

@@ -0,0 +1,46 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
/// `repo announce` subcommand
mod announce;
/// `repo view` subcommand
mod view;
use clap::Subcommand;
use self::announce::AnnounceArgs;
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),
/// Publish information about a git repository to Nostr for collaboration
/// and feedback. Can also be used to update an existing repository's
/// details.
Announce(AnnounceArgs),
}
impl CommandRunner for RepoSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
match self {
Self::View(args) => args.run(options).await,
Self::Announce(args) => args.run(options).await,
}
}
}

View File

@@ -0,0 +1,87 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
use std::fmt;
use clap::Args;
use nostr::nips::nip19::Nip19Coordinate;
use crate::{
cli::{CliOptions, CommandRunner, parsers},
error::N34Result,
nostr_utils::{NostrClient, utils},
};
/// Arguments for the `repo view` command
#[derive(Args, Debug)]
pub struct ViewArgs {
/// Repository address in `naddr` format.
///
/// If not provided, `n34` will look for a `nostr-address` file.
#[arg(short, long, value_parser = parsers::repo_naddr)]
naddr: Option<Nip19Coordinate>,
}
impl CommandRunner for ViewArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let naddr = utils::naddr_or_file(self.naddr, &utils::nostr_address_path()?)?;
let client = NostrClient::init(&options).await;
client.add_relays(&naddr.relays).await;
let repo = client.fetch_repo(&naddr.coordinate).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<T>(vector: Vec<T>) -> String
where
T: fmt::Display,
{
vector
.into_iter()
.map(|t| format!(" - {t}"))
.collect::<Vec<String>>()
.join("\n")
}