EnglishEspañolPortuguêsالعربيةעבריתفارسیTürkçe
Nostr npubs archive Nostr relays archive
EnglishEspañolPortuguêsالعربيةעבריתفارسیTürkçe
nostr-rs-relay.dev.fedibtc.com
nostr-rs-relay.dev.fedibtc.com

nostr-rs-relay.dev.fedibtc.com

wss://nostr-rs-relay.dev.fedibtc.com

Last Notes

2026-04-04 22:44:49 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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::{iter, str::FromStr, sync::Arc};
use either::Either;
use futures::future;
use nostr::{
event::{Event, EventBuilder, EventId, Kind, Tag, TagKind},
filter::Filter,
hashes::sha1::Hash as Sha1Hash,
nips::{nip10::Marker, nip19::ToBech32},
types::RelayUrl,
};
use super::{
issue::IssueStatus,
patch::PatchStatus,
types::{NaddrOrSet, NostrEvent},
};
use crate::{
cli::traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
nostr_utils::{NostrClient, traits::NaddrsUtils, utils},
};
use crate::{
cli::{CliOptions, patch::GitPatch},
error::{N34Error, N34Result},
nostr_utils::traits::{GitIssueUtils, GitPatchUtils, ReposUtils},
};
/// Updates the issue's status to `new_status` after validating it with
/// `check_fn`.
pub async fn issue_status_command(
options: CliOptions,
issue_id: NostrEvent,
naddrs: Option<Vec<NaddrOrSet>>,
new_status: IssueStatus,
check_fn: impl FnOnce(&IssueStatus) -> N34Result<()>,
) -> N34Result<()> {
let naddrs = utils::naddrs_or_file(
naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let user_pubk = client.pubkey().await?;
client
.add_relays(&[naddrs.extract_relays(), issue_id.relays].concat())
.await;
let owners = naddrs.extract_owners();
let coordinates = naddrs.clone().into_coordinates();
let repos = client.fetch_repos(&coordinates).await?;
let maintainers = repos.extract_maintainers();
let relay_hint = repos.extract_relays().first().cloned();
client.add_relays(&repos.extract_relays()).await;
let issue_event = client
.fetch_event(Filter::new().id(issue_id.event_id))
.await?
.ok_or(N34Error::CanNotFoundIssue)?;
let issue_status = client
.fetch_issue_status(
issue_id.event_id,
[maintainers.as_slice(), &[issue_event.pubkey], &owners].concat(),
)
.await?;
check_fn(&issue_status)?;
let status_event = EventBuilder::new(new_status.kind(), "")
.pow(options.pow.unwrap_or_default())
.tag(utils::event_reply_tag(
&issue_id.event_id,
relay_hint.as_ref(),
Marker::Root,
))
.tag(Tag::public_key(issue_event.pubkey))
.tags(maintainers.iter().map(|p| Tag::public_key(*p)))
.tags(owners.iter().map(|p| Tag::public_key(*p)))
.tags(
coordinates
.into_iter()
.map(|c| Tag::coordinate(c, relay_hint.clone())),
)
.dedup_tags()
.build(user_pubk);
let event_id = status_event.id.expect("There is an id");
let user_relays_list = client.user_relays_list(user_pubk).await?;
let write_relays = [
relays,
naddrs.extract_relays(),
repos.extract_relays(),
utils::add_write_relays(user_relays_list.as_ref()),
client.read_relays_from_user(issue_event.pubkey).await,
client
.read_relays_from_users(&[maintainers, owners].concat())
.await,
]
.concat();
let success = client
.send_event_to(status_event, user_relays_list.as_ref(), &write_relays)
.await?;
let nevent = utils::new_nevent(event_id, &success)?;
println!("Issue status created: {nevent}");
Ok(())
}
/// Updates the patch's status to `new_status` after validating it with
/// `check_fn`.
pub async fn patch_status_command(
options: CliOptions,
patch_id: NostrEvent,
naddrs: Option<Vec<NaddrOrSet>>,
new_status: PatchStatus,
merge_or_applied_commits: Option<Either<Sha1Hash, Vec<Sha1Hash>>>,
merge_or_applied_patches: Vec<EventId>,
check_fn: impl FnOnce(&PatchStatus) -> N34Result<()>,
) -> N34Result<()> {
let naddrs = utils::naddrs_or_file(
naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let user_pubk = client.pubkey().await?;
client
.add_relays(&[naddrs.extract_relays(), patch_id.relays].concat())
.await;
let owners = naddrs.extract_owners();
let coordinates = naddrs.clone().into_coordinates();
let repos = client.fetch_repos(&coordinates).await?;
let maintainers = repos.extract_maintainers();
let relay_hint = repos.extract_relays().first().cloned();
client.add_relays(&repos.extract_relays()).await;
let patch_event = client.fetch_patch(patch_id.event_id).await?;
if patch_event.is_revision_patch() && !new_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"Invalid action for patch revision. Only 'apply' or 'merge' are allowed, 'open', \
'close', and 'draft' are not supported."
.to_owned(),
));
}
let (root_patch, root_revision) = get_patch_root_revision(&patch_event)?;
let patch_status = client
.fetch_patch_status(
root_patch,
root_revision,
[maintainers.as_slice(), &[patch_event.pubkey], &owners].concat(),
)
.await?;
check_fn(&patch_status)?;
let mut status_builder = EventBuilder::new(new_status.kind(), "")
.pow(options.pow.unwrap_or_default())
.tag(utils::event_reply_tag(
&root_patch,
relay_hint.as_ref(),
Marker::Root,
))
.tag(Tag::public_key(patch_event.pubkey))
.tags(maintainers.iter().map(|p| Tag::public_key(*p)))
.tags(owners.iter().map(|p| Tag::public_key(*p)))
.tags(
coordinates
.into_iter()
.map(|c| Tag::coordinate(c, relay_hint.clone())),
);
if new_status.is_merged_or_applied() {
if let Some(merge_commit) = merge_or_applied_commits
.as_ref()
.and_then(|e| e.as_ref().left())
{
let commit = merge_commit.to_string();
status_builder = status_builder
.tag(Tag::custom(
TagKind::custom("merge-commit"),
iter::once(&commit),
))
.tag(Tag::reference(commit));
} else if let Some(applied_commits) = merge_or_applied_commits.and_then(|e| e.right()) {
let commits = applied_commits
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
status_builder = status_builder
.tag(Tag::custom(TagKind::custom("applied-as-commits"), &commits))
.tags(commits.into_iter().map(Tag::reference));
};
if let Some(root_revision) = root_revision {
status_builder = status_builder.tag(utils::event_reply_tag(
&root_revision,
relay_hint.as_ref(),
Marker::Reply,
));
}
if !merge_or_applied_patches.is_empty() {
status_builder = status_builder.tags(
build_patches_quote(client.clone(), relay_hint.clone(), merge_or_applied_patches)
.await,
);
}
}
let status_event = status_builder.dedup_tags().build(user_pubk);
let event_id = status_event.id.expect("There is an id");
let user_relays_list = client.user_relays_list(user_pubk).await?;
let write_relays = [
relays,
naddrs.extract_relays(),
repos.extract_relays(),
utils::add_write_relays(user_relays_list.as_ref()),
client.read_relays_from_user(patch_event.pubkey).await,
client
.read_relays_from_users(&[maintainers, owners].concat())
.await,
]
.concat();
let success = client
.send_event_to(status_event, user_relays_list.as_ref(), &write_relays)
.await?;
let nevent = utils::new_nevent(event_id, &success)?;
println!("Patch status created: {nevent}");
Ok(())
}
/// Fetch and display patches and issues for given repositories.
/// If `list_patches` is true, lists patches instead of issues.
/// `limit` controls the maximum number of items to fetch.
pub async fn list_patches_and_issues(
options: CliOptions,
naddrs: Option<Vec<NaddrOrSet>>,
list_patches: bool,
limit: usize,
) -> N34Result<()> {
let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
client.add_relays(&naddrs.extract_relays()).await;
let coordinates = naddrs.clone().into_coordinates();
let repos = client.fetch_repos(&coordinates).await?;
let authorized_pubkeys = [naddrs.extract_owners(), repos.extract_maintainers()].concat();
client.add_relays(&repos.extract_relays()).await;
// This helps discover issues and their status.
client
.add_relays(&client.read_relays_from_users(&authorized_pubkeys).await)
.await;
let kind = if list_patches {
Kind::GitPatch
} else {
Kind::GitIssue
};
let mut filter = Filter::new()
.coordinates(coordinates.iter())
.kind(kind)
.limit(limit);
if list_patches {
filter = filter.hashtag("root");
}
let arc_client = Arc::new(client);
// Events are sorted by kind in ascending order:
// 1630 (Open), 1631 (Resolved/Applied), 1632 (Closed), 1633 (Draft)
let events = utils::sort_by_key(
future::join_all(
arc_client
.fetch_events(filter)
.await?
.into_iter()
.take(limit)
.map(|event| {
let c = arc_client.clone();
let keys = authorized_pubkeys.clone();
async move {
let status = if list_patches {
let (root, root_revision) = get_patch_root_revision(&event)?;
c.fetch_patch_status(
root,
root_revision,
[keys.as_slice(), &[event.pubkey]].concat(),
)
.await
.map(Either::Left)?
} else {
c.fetch_issue_status(
event.id,
[keys.as_slice(), &[event.pubkey]].concat(),
)
.await
.map(Either::Right)?
};
N34Result::Ok((event, status))
}
}),
)
.await
.into_iter()
.filter_map(|r| r.ok()),
|(_, status)| status.as_ref().either_into::<Kind>(),
);
let lines = events
.map(|(event, status)| format_patch_and_issue(&event, status))
.collect::<Vec<String>>();
let max_width = lines
.iter()
.map(|s| s.split_once('\n').map_or(85, |(l, _)| l.chars().count()))
.max()
.unwrap_or(85)
.max(67); // length of the event id
println!("{}", lines.join(&format!("{}\n", "-".repeat(max_width))));
Ok(())
}
/// Returns a tuple of (root_id, patch_id) if this is a valid root or revision
/// patch.
fn get_patch_root_revision(patch_event: &Event) -> N34Result<(EventId, Option<EventId>)> {
if patch_event.is_revision_patch() {
Ok((
patch_event.root_patch_from_revision()?,
Some(patch_event.id),
))
} else if patch_event.is_root_patch() {
Ok((patch_event.id, None))
} else {
Err(N34Error::NotRootPatch)
}
}
/// Formats an event as either a patch or an issue. For patches, extracts the
/// subject line from the Git patch format. For issues, combines the subject
/// with labels. The output includes status and formatted ID.
fn format_patch_and_issue(event: &Event, status: Either<PatchStatus, IssueStatus>) -> String {
let subject = if status.is_left() {
GitPatch::from_str(&event.content)
.map(|p| p.subject)
.unwrap_or_else(|_| {
event
.content
.lines()
.find(|line| line.trim().starts_with("Subject: "))
.unwrap_or_default()
.trim()
.trim_start_matches("Subject: ")
.to_owned()
})
} else {
let labels = event.extract_issue_labels();
let subject = event.extract_issue_subject();
if labels.is_empty() {
subject.to_owned()
} else {
format!(r#""{subject}" {labels}"#)
}
};
format!(
"({status}) {}\nID: {}\n",
utils::smart_wrap(&subject, 85),
event.id.to_bech32().expect("Infallible")
)
}
/// Generates a list of tags for quoting patches in merge/applied status events.
async fn build_patches_quote(
client: NostrClient,
relay_hint: Option<RelayUrl>,
patches: Vec<EventId>,
) -> Vec<Tag> {
let client = Arc::new(client);
let relay_hint = Arc::new(relay_hint);
future::join_all(patches.into_iter().map(|eid| {
let task_relay = Arc::clone(&relay_hint);
let task_client = Arc::clone(&client);
async move {
Tag::custom(
TagKind::q(),
[
eid.to_hex(),
task_relay
.as_ref()
.as_ref()
.map(|r| r.to_string())
.unwrap_or_default(),
task_client
.event_author(eid)
.await
.ok()
.flatten()
.map(|p| p.to_hex())
.unwrap_or_default(),
],
)
}
}))
.await
}
2026-04-04 22:45:22 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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 super::traits::CommandRunner;
/// Returns whether the command runner type `T` requires relays.
pub fn get_relays_state<T: CommandRunner>(_v: &T) -> bool {
T::NEED_RELAYS
}
/// Returns whether the command runner type `T` requires a signer.
pub fn get_signer_state<T: CommandRunner>(_v: &T) -> bool {
T::NEED_SIGNER
}
/// Executes a command with required setup checks. The first parameter is the
/// command to match on (often `self`), followed by options. Optional
/// subcommands come next, and commands with arguments (after `&`) are listed
/// last.
#[macro_export]
macro_rules! run_command {
($command:ident, $options:ident, $($subcommands:ident)* & $($commands:ident)*) => {
match $command {
$(
Self::$subcommands { subcommands } => subcommands.run($options).await,
)*
$(
Self::$commands ( args ) => {
if $crate::cli::macros::get_relays_state(&args) {
$options.ensure_relays()?;
}
if $crate::cli::macros::get_signer_state(&args) {
$options.ensure_signer()?;
}
args.run($options).await
},
)*
}
};
}
2026-04-04 22:45:44 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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::{
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
time::Duration,
};
use nostr_browser_signer_proxy::{BrowserSignerProxy, BrowserSignerProxyOptions};
/// The default socket address used for the NIP-07 signer proxy, set to
/// localhost on port 51034.
pub const DEFAULT_NIP07_PROXY_ADDR: SocketAddr =
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 51034));
/// How long to wait for the proxy response (3 minutes).
pub const BROWSER_SIGNER_PROXY_TIMEOUT: Duration = Duration::from_secs(60 * 3);
/// Represents the state used for CLI options.
pub struct OptionsState {
/// The browser signer proxy, will be used if `--nip07` is enabled
pub browser_signer_proxy: BrowserSignerProxy,
}
impl Default for OptionsState {
fn default() -> Self {
Self {
browser_signer_proxy: default_browser_signer_proxy(),
}
}
}
/// Build the default browser signer proxy
#[inline]
fn default_browser_signer_proxy() -> BrowserSignerProxy {
BrowserSignerProxy::new(
BrowserSignerProxyOptions::default()
.timeout(BROWSER_SIGNER_PROXY_TIMEOUT)
.ip_addr(DEFAULT_NIP07_PROXY_ADDR.ip())
.port(DEFAULT_NIP07_PROXY_ADDR.port()),
)
}
2026-04-04 22:45:33 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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>.
/// Commands module
pub mod commands;
/// Common commands used by multiply commands
pub mod common_commands;
/// The CLI config
pub mod config;
/// Default lazy values for CLI arguments
pub mod defaults;
/// Macros for CLI application.
pub mod macros;
/// Represents the state used for CLI options.
pub mod options_state;
/// CLI arguments parsers
pub mod parsers;
/// CLI traits
pub mod traits;
/// Common helper types used throughout the CLI.
pub mod types;
use clap::Parser;
use clap_verbosity_flag::Verbosity;
use nostr::key::Keys;
use nostr::key::SecretKey;
use nostr_browser_signer_proxy::BrowserSignerProxy;
use nostr_browser_signer_proxy::BrowserSignerProxyOptions;
use nostr_keyring::KeyringError;
use nostr_keyring::NostrKeyring;
use types::RelayOrSet;
pub use self::commands::*;
pub use self::config::*;
use self::traits::CommandRunner;
use crate::cli::options_state::BROWSER_SIGNER_PROXY_TIMEOUT;
use crate::error::N34Error;
use crate::error::N34Result;
use crate::nostr_utils::traits::NostrKeyringErrorUtils;
/// Header message, used in the help message
const HEADER: &str = r#"Copyright (C) 2025 Awiteb <[email protected]>
License GNU GPL-3.0-or-later <https://gnu.org/licenses/gpl-3.0.html>
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.git"#;
/// Footer message, used in the help message
const FOOTER: &str = r#"Please report bugs to <naddr1qqpkuve5qgsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgrqsqqqauesksc39>."#;
/// Name of the file storing the repository address
pub const NOSTR_ADDRESS_FILE: &str = "nostr-address";
#[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: commands::CliOptions,
/// Controls the verbosity level of output
#[command(flatten)]
pub verbosity: Verbosity,
/// The subcommand to execute
#[command(subcommand)]
pub command: commands::Commands,
}
impl Cli {
/// Keyring service name of n34
pub const N34_KEYRING_SERVICE_NAME: &str = "n34";
/// Keyring entry name of the n34 keypair
pub const N34_KEY_PAIR_ENTRY: &str = "n34_keypair";
/// Keyring entry name of the user secret key
pub const USER_KEY_PAIR_ENTRY: &str = "user_keypair";
/// Executes the command
pub async fn run(self) -> N34Result<()> {
self.command.run(self.options).await
}
/// Gets the n34 keypair from the keyring or generates and stores a new one
/// if none exists.
pub fn n34_keypair() -> N34Result<Keys> {
let keyring = NostrKeyring::new(Self::N34_KEYRING_SERVICE_NAME);
match keyring.get(Self::N34_KEY_PAIR_ENTRY) {
Ok(keys) => Ok(keys),
Err(nostr_keyring::Error::Keyring(KeyringError::NoEntry)) => {
let new_keys = Keys::generate();
keyring.set(Self::N34_KEY_PAIR_ENTRY, &new_keys)?;
Ok(new_keys)
}
Err(err) => Err(N34Error::Keyring(err)),
}
}
/// Retrieves the user's keypair from the keyring. If no key exists and one
/// is provided, stores and returns it. If no key exists and none is
/// provided, returns an error.
pub fn user_keypair(secret_key: Option<SecretKey>) -> N34Result<Keys> {
let keyring = NostrKeyring::new(Self::N34_KEYRING_SERVICE_NAME);
let keyring_key = keyring.get(Self::USER_KEY_PAIR_ENTRY);
if let Err(ref err) = keyring_key
&& err.is_keyring_no_entry()
&& let Some(secret_key) = secret_key
{
let keypair = Keys::new(secret_key);
keyring.set(Self::USER_KEY_PAIR_ENTRY, &keypair)?;
return Ok(keypair);
}
keyring_key.map_err(|err| {
if err.is_keyring_no_entry() {
N34Error::SecretKeyKeyringWithoutEntry
} else {
N34Error::Keyring(err)
}
})
}
}
/// Processes the CLI configuration by applying fallback values from config if
/// needed. Returns the processed Cli configuration if successful.
pub fn post_cli(mut cli: Cli) -> N34Result<Cli> {
cli.options.pow = cli.options.pow.or(cli.options.config.pow);
if cli.options.relays.is_empty()
&& let Some(relays) = &cli.options.config.fallback_relays
{
cli.options.relays = relays.iter().cloned().map(RelayOrSet::Relay).collect();
}
// Automatically sets the signer based on the configuration if no signer
// is provided.
if !cli.options.nip07
&& cli.options.bunker_url.is_none()
&& (cli.options.secret_key.is_none() || cli.options.config.keyring_secret_key)
{
if let Some(addr) = cli.options.config.nip07 {
cli.options.nip07 = true;
cli.options.state.browser_signer_proxy = BrowserSignerProxy::new(
BrowserSignerProxyOptions::default()
.timeout(BROWSER_SIGNER_PROXY_TIMEOUT)
.ip_addr(addr.ip())
.port(addr.port()),
);
} else if let Some(bunker_url) = &cli.options.config.bunker_url {
cli.options.bunker_url = Some(bunker_url.clone());
} else if cli.options.config.keyring_secret_key {
cli.options.secret_key = Some(
Cli::user_keypair(cli.options.secret_key)?
.secret_key()
.clone(),
);
}
}
Ok(cli)
}
2026-04-04 22:45:11 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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::path::PathBuf;
use crate::{cli::ConfigError, error::N34Result};
/// Default config path
pub fn config_path() -> N34Result<PathBuf> {
Ok(dirs::config_dir()
.ok_or(ConfigError::CanNotFindConfigPath)?
.join("n34")
.join("config.toml"))
}
2026-04-04 22:45:00 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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::{collections::HashSet, fs, net::SocketAddr, path::PathBuf};
use nostr::{
nips::{nip19::Nip19Coordinate, nip46::NostrConnectURI},
types::RelayUrl,
};
use crate::{
cli::traits::{MutRepoRelaySetsExt, RepoRelaySetsExt},
error::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 {
/// Path to the configuration file (not serialized)
#[serde(skip)]
path: PathBuf,
/// Groups of repositories and relays.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sets: Vec<RepoRelaySet>,
/// The default PoW difficulty
#[serde(skip_serializing_if = "Option::is_none")]
pub pow: Option<u8>,
/// List of fallback relays used if no fallback relays was provided.
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback_relays: Option<Vec<RelayUrl>>,
/// Default Nostr bunker URL used for signing events.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "super::parsers::de_bunker_url",
serialize_with = "super::parsers::ser_bunker_url"
)]
pub bunker_url: Option<NostrConnectURI>,
/// Whether to use the system keyring to store the secret key.
#[serde(default)]
pub keyring_secret_key: bool,
/// Signs events using the browser's NIP-07 extension.
#[serde(default)]
pub nip07: Option<SocketAddr>,
}
/// 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 = "super::parsers::ser_naddrs",
deserialize_with = "super::parsers::de_naddrs"
)]
pub naddrs: HashSet<Nip19Coordinate>,
/// Relay URLs in this group.
#[serde(default, skip_serializing_if = "HashSet::is_empty")]
pub relays: HashSet<RelayUrl>,
}
impl CliConfig {
/// Reads and parse a TOML config file from the given path, creating it if
/// missing.
pub fn load(file_path: PathBuf) -> N34Result<Self> {
tracing::info!(path = %file_path.display(), "Loading configuration from file");
// Make sure the file is exist
if let Some(parent) = file_path.parent()
&& !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.path = file_path;
config.post_sets()?;
Ok(config)
}
/// Dump the config as toml in a file
pub fn dump(mut self) -> N34Result<()> {
tracing::debug!(config = ?self, "Writing configuration to {}", self.path.display());
self.post_sets()?;
fs::write(
&self.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<String>,
naddrs: impl IntoIterator<Item = Nip19Coordinate>,
relays: impl IntoIterator<Item = RelayUrl>,
) -> 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()));
}
}
2026-04-04 22:44:38 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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::collections::HashSet;
use clap::Args;
use crate::{
cli::{
CliOptions,
traits::{CommandRunner, MutRepoRelaySetsExt, NaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, RelayOrSet},
},
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<RelayOrSet>,
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `[email protected]/n34`), or a set name like `kernel`.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Vec<NaddrOrSet>,
/// Replace existing relays/repositories instead of adding to them
#[arg(long = "override")]
override_set: bool,
}
impl CommandRunner for UpdateArgs {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
let naddrs = self.naddrs.flat_naddrs(&options.config.sets)?;
let relays = self.relays.flat_relays(&options.config.sets)?;
let set = options.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);
}
options.config.dump()
}
}
2026-04-04 22:44:28 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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::Args;
use nostr::nips::nip19::ToBech32;
use crate::{
cli::{
CliOptions,
RepoRelaySet,
traits::{CommandRunner, RepoRelaySetsExt},
},
error::N34Result,
};
#[derive(Args, Debug)]
pub struct ShowArgs {
/// Name of the set to display. If not provided, lists all available sets.
name: Option<String>,
}
impl CommandRunner for ShowArgs {
const NEED_SIGNER: bool = false;
async fn run(self, options: CliOptions) -> N34Result<()> {
if let Some(name) = self.name {
println!(
"{}",
format_set(options.config.sets.as_slice().get_set(&name)?)
);
} else {
println!(
"{}",
options
.config
.sets
.iter()
.map(format_set)
.collect::<Vec<_>>()
.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::<Vec<_>>()
.join("\n- ")
)
};
let relays = if set.relays.is_empty() {
"Nothing".to_owned()
} else {
format!(
"\n- {}",
set.relays
.iter()
.map(|relay| relay.to_string())
.collect::<Vec<_>>()
.join("\n- ")
)
};
format!(
"Name: {}\nّّRepositories: {naddrs}\nRelays: {relays}",
set.name
)
}
2026-04-04 22:44:17 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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::Args;
use crate::{
cli::{
CliOptions,
traits::{CommandRunner, MutRepoRelaySetsExt, NaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, RelayOrSet},
},
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<RelayOrSet>,
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `[email protected]/n34`), or a set name like `kernel`.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Vec<NaddrOrSet>,
}
impl CommandRunner for RemoveArgs {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
let naddrs = self.naddrs.flat_naddrs(&options.config.sets)?;
let relays = self.relays.flat_relays(&options.config.sets)?;
if relays.is_empty() && naddrs.is_empty() {
options.config.sets.remove_set(self.name)?;
} else {
if !relays.is_empty() {
options
.config
.sets
.remove_relays(&self.name, relays.into_iter())?;
}
if !naddrs.is_empty() {
options
.config
.sets
.remove_naddrs(self.name, naddrs.into_iter())?;
}
}
options.config.dump()
}
}
2026-04-04 22:44:07 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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::Args;
use crate::{
cli::{
CliOptions,
ConfigError,
traits::{CommandRunner, MutRepoRelaySetsExt, NaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, RelayOrSet},
},
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<RelayOrSet>,
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `[email protected]/n34`), or a set name like `kernel`.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Vec<NaddrOrSet>,
}
impl CommandRunner for NewArgs {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
let naddrs = self.naddrs.flat_naddrs(&options.config.sets)?;
let relays = self.relays.flat_relays(&options.config.sets)?;
if relays.is_empty() && naddrs.is_empty() {
return Err(ConfigError::NewEmptySet.into());
}
options.config.sets.push_set(self.name, naddrs, relays)?;
options.config.dump()
}
}
2026-04-04 22:43:56 UTC
- reply
npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8 by npub1nxa…a6q8
Wisp
2026-04-04 22:43:56 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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>.
/// `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<()> {
crate::run_command!(self, options, & Remove New Update Show)
}
}
2026-04-04 22:43:52 UTC
- reply
npub10mtatsat7ph6rsq0w8u8npt8d86x4jfr2nqjnvld2439q6f8ugqq0x27hf by npub10mt…27hf
So the relays rebroadcast but we still need outbox in clients. Make it make sense.
2026-04-04 22:43:45 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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::ToBech32;
use crate::{
cli::{
CliOptions,
CommandRunner,
traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::NaddrOrSet,
},
error::N34Result,
nostr_utils::{NostrClient, traits::NaddrsUtils, utils},
};
/// Arguments for the `repo view` command
#[derive(Args, Debug)]
pub struct ViewArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `[email protected]/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET")]
naddrs: Option<Vec<NaddrOrSet>>,
}
impl CommandRunner for ViewArgs {
const NEED_SIGNER: bool = false;
async fn run(self, options: CliOptions) -> N34Result<()> {
let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
self.naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?)?;
let relays = options.relays.clone().flat_relays(&options.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?;
let mut repos_details: Vec<String> = Vec::new();
for repo in repos {
let mut repo_details = format!("ID: {}", repo.id);
if let Some(name) = repo.name {
repo_details.push_str(&format!("\nName: {name}"));
}
if let Some(desc) = repo.description {
repo_details.push_str(&format!("\nDescription: {desc}"));
}
if !repo.web.is_empty() {
repo_details.push_str(&format!("\nWebpages:\n{}", format_list(repo.web)));
}
if !repo.clone.is_empty() {
repo_details.push_str(&format!("\nClone urls:\n{}", format_list(repo.clone)));
}
if !repo.relays.is_empty() {
repo_details.push_str(&format!("\nRelays:\n{}", format_list(repo.relays)));
}
if let Some(euc) = repo.euc {
repo_details.push_str(&format!("\nEarliest unique commit: {euc}"));
}
if !repo.maintainers.is_empty() {
repo_details.push_str(&format!(
"\nMaintainers:\n{}",
format_list(
repo.maintainers
.iter()
.map(|p| p.to_bech32().expect("Infallible"))
)
));
}
repos_details.push(repo_details);
}
println!("{}", repos_details.join("\n----------\n"));
Ok(())
}
}
/// Format a vector to print it
fn format_list<I, T>(iterator: I) -> String
where
I: IntoIterator<Item = T>,
T: fmt::Display,
{
iterator
.into_iter()
.map(|t| format!(" - {t}"))
.collect::<Vec<String>>()
.join("\n")
}
2026-04-04 22:43:34 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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),
/// Broadcast and update a git repository
Announce(AnnounceArgs),
}
impl CommandRunner for RepoSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::run_command!(self, options, & View Announce)
}
}
2026-04-04 22:43:24 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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, io::Write};
use clap::Args;
use futures::future;
use nostr::{event::EventBuilder, key::PublicKey, types::Url};
use crate::{
cli::{CliOptions, CommandRunner, NOSTR_ADDRESS_FILE, traits::RelayOrSetVecExt},
error::N34Result,
nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils},
};
/// Header written to new `nostr-address` files. Contains two trailing newline
/// for formatting.
const NOSTR_ADDRESS_FILE_HEADER: &str = r##"# This file contains NIP-19 `naddr` entities for repositories that accept this
# project's issues and patches.
#
# The file acts as a **read-only reference** for retrieving repository relays
# when embedded in an `naddr` and mentions those repositories when opening
# patches or issues. Modifications here will not affect in the relays, as the
# file is **explicitly untracked**. Its goal is to simplify contributions by
# removing the need for manual address entry.
#
# Each entry must start with "naddr". Embedded relays are **strongly recommended**
# to assist client-side discovery.
#
# Empty lines are ignored. Lines starting with "#" are treated as comments.
"##;
/// 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 {
const NEED_RELAYS: bool = true;
async fn run(mut self, options: CliOptions) -> N34Result<()> {
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let user_pubk = client.pubkey().await?;
let relays_list = client.user_relays_list(user_pubk).await?;
client
.add_relays(&utils::add_read_relays(relays_list.as_ref()))
.await;
if !self.maintainers.contains(&user_pubk) {
self.maintainers.insert(0, user_pubk);
}
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,
relays.clone(),
self.maintainers.clone(),
self.label.into_iter().map(utils::str_trim).collect(),
self.force_id,
)?
.dedup_tags()
.pow(options.pow.unwrap_or_default())
.build(user_pubk);
if self.address_file {
let address_path = std::env::current_dir()?.join(NOSTR_ADDRESS_FILE);
if !address_path.exists() {
tracing::info!(
"Creating new address file: '{NOSTR_ADDRESS_FILE}' at path '{}' with default \
header",
address_path.display()
);
fs::write(&address_path, NOSTR_ADDRESS_FILE_HEADER)?;
}
let mut file = fs::OpenOptions::new().append(true).open(&address_path)?;
tracing::info!("Appending naddr '{naddr}' to address file: '{NOSTR_ADDRESS_FILE}'");
file.write_all(format!("{naddr}\n").as_bytes())?;
tracing::info!("Successfully wrote naddr to address file");
}
let write_relays = [
relays,
utils::add_write_relays(relays_list.as_ref()),
// Include read relays for each maintainer (if found)
future::join_all(
self.maintainers
.iter()
.map(|pkey| client.read_relays_from_user(*pkey)),
)
.await
.into_iter()
.flatten()
.collect(),
]
.concat();
let nevent = utils::new_nevent(event.id.expect("There is an id"), &write_relays)?;
client
.send_event_to(event, relays_list.as_ref(), &write_relays)
.await?;
println!("Event: {nevent}",);
println!("Repo Address: {naddr}",);
Ok(())
}
}
2026-04-04 22:43:13 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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::{ArgGroup, Args};
use futures::future;
use nostr::{
event::{Event, EventBuilder, Kind},
filter::Filter,
nips::nip01::Coordinate,
types::RelayUrl,
};
use super::{CliOptions, CommandRunner};
use crate::{
cli::{
traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
nostr_utils::{
NostrClient,
traits::{NaddrsUtils, ReposUtils},
utils,
},
};
/// The max date "9999-01-01 at 00:00 UTC"
const MAX_DATE: i64 = 253370764800;
/// Arguments for the `reply` command
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("comment-content")
.args(["comment", "editor"])
.required(true)
),
group(
ArgGroup::new("quote-reply-to")
.args(["comment", "quote_to"])
)
)]
pub struct ReplyArgs {
/// The issue, patch, or comment to reply to
#[arg(value_name = "nevent1-or-note1")]
to: NostrEvent,
/// Quote the replied-to event in the editor
#[arg(long)]
quote_to: bool,
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `[email protected]/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Option<Vec<NaddrOrSet>>,
/// 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 nostr_address_path = utils::nostr_address_path()?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let user_pubk = client.pubkey().await?;
let repo_naddrs = if let Some(naddrs) = self.naddrs.flat_naddrs(&options.config.sets)? {
client.add_relays(&naddrs.extract_relays()).await;
Some(naddrs)
} else if fs::exists(&nostr_address_path).is_ok() {
let naddrs = utils::naddrs_or_file(None, &nostr_address_path)?;
client.add_relays(&naddrs.extract_relays()).await;
Some(naddrs)
} else {
None
};
client.add_relays(&self.to.relays).await;
let relays_list = client.user_relays_list(user_pubk).await?;
let author_read_relays =
utils::add_read_relays(client.user_relays_list(user_pubk).await?.as_ref());
client.add_relays(&author_read_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 repos_coordinate = if let Some(naddrs) = repo_naddrs {
naddrs.into_coordinates()
} else if let Some(ref root_event) = root {
coordinates_from_root(root_event)?
} else {
return Err(N34Error::NotFoundRepo);
};
let repos = client.fetch_repos(&repos_coordinate).await?;
let maintainers = repos.extract_maintainers();
let quoted_content = if self.quote_to {
Some(quote_reply_to_content(&client, &reply_to).await)
} else {
None
};
let content = utils::get_content(self.comment.as_ref(), quoted_content.as_ref(), ".txt")?;
let content_details = client.parse_content(&content).await;
let event = EventBuilder::comment(
content,
&reply_to,
root.as_ref(),
repos.first().and_then(|r| r.relays.first()).cloned(),
)
.dedup_tags()
.pow(options.pow.unwrap_or_default())
.tags(content_details.clone().into_tags())
.build(user_pubk);
let event_id = event.id.expect("There is an id");
let write_relays = [
relays,
utils::add_write_relays(relays_list.as_ref()),
// Merge repository announcement relays into write relays
repos.extract_relays(),
// Include read relays for each repository maintainer (if found)
client.read_relays_from_users(&maintainers).await,
// read relays of the root event and the reply to event
{
let (r1, r2) = future::join(
client.read_relays_from_user(reply_to.pubkey),
event_author_read_relays(&client, root.as_ref()),
)
.await;
[r1, r2].concat()
},
content_details.write_relays.into_iter().collect(),
]
.concat();
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(())
}
}
/// Creates a quoted reply string in the format "On yyyy-mm-dd at hh:mm UTC,
/// {author} wrote:" followed by the event content. Uses display name if
/// available, otherwise falls back to a shortened npub string. Dates are
/// formatted in UTC.
async fn quote_reply_to_content(client: &NostrClient, quoted_event: &Event) -> String {
let author_name = client.get_username(quoted_event.pubkey).await;
let fdate = chrono::DateTime::from_timestamp(
quoted_event
.created_at
.as_u64()
.try_into()
.unwrap_or(MAX_DATE),
0,
)
.map(|datetime| datetime.format("On %F at %R UTC, ").to_string())
.unwrap_or_default();
format!(
"{fdate}{author_name} wrote:\n> {}",
quoted_event.content.trim().replace("\n", "\n> ")
)
}
/// Gets the repository coordinate from a root Nostr event's tags.
/// The event must contain a coordinate tag with GitRepoAnnouncement kind.
fn coordinates_from_root(root: &Event) -> N34Result<Vec<Coordinate>> {
let coordinates: Vec<Coordinate> = root
.tags
.coordinates()
.filter(|c| c.kind == Kind::GitRepoAnnouncement)
.cloned()
.collect();
if coordinates.is_empty() {
return Err(N34Error::InvalidEvent(
"The Git issue/patch does not specify a target repository".to_owned(),
));
}
Ok(coordinates)
}
/// Returns the event author read relays if found, otherwise an empty vector
async fn event_author_read_relays(client: &NostrClient, event: Option<&Event>) -> Vec<RelayUrl> {
if let Some(root_event) = event {
client.read_relays_from_user(root_event.pubkey).await
} else {
Vec::new()
}
}
2026-04-04 22:43:02 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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 super::*;
#[test]
fn patch_normal() {
let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
From: Awiteb <[email protected]>
Date: Tue, 27 May 2025 19:20:42 +0000
Subject: [PATCH] chore: a to abc
Abc patch
---
src/nostr_utils/mod.rs | 1 +
1files changed, 3 insertions(+), 1 deletions(-)
diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs
index 4120f5a..e68783c 100644
--- a/src/nostr_utils/mod.rs
+++ b/src/nostr_utils/mod.rs
@@ -103,31 +103,9 @@ impl CommandRunner for NewArgs {
- a
+ abc
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(patch.subject, "[PATCH] chore: a to abc");
assert_eq!(patch.body, "Abc patch");
}
#[test]
fn patch_normal_with_patch_in_content() {
let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
From: Awiteb <[email protected]>
Date: Tue, 27 May 2025 19:20:42 +0000
Subject: [PATCH] chore: Subject in subject
A good test patch
---
src/nostr_utils/mod.rs | 1 +
1files changed, 3 insertions(+), 1 deletions(-)
diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs
index 4120f5a..e68783c 100644
--- a/src/nostr_utils/mod.rs
+++ b/src/nostr_utils/mod.rs
@@ -103,31 +103,9 @@ impl CommandRunner for NewArgs {
From: Awiteb <[email protected]>
Date: Tue, 27 May 2025 19:20:42 +0000
Subject: [PATCH] chore: What a subject
hi
---
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(patch.subject, "[PATCH] chore: Subject in subject");
assert_eq!(patch.body, "A good test patch");
}
#[test]
fn patch_multiline_subject() {
let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
From: Awiteb <[email protected]>
Date: Tue, 27 May 2025 19:20:42 +0000
Subject: [PATCH] chore: Some long subject yes so long one Some long subject yes
so long one
Abc patch
---
src/nostr_utils/mod.rs | 1 +
1files changed, 3 insertions(+), 1 deletions(-)
diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs
index 4120f5a..e68783c 100644
--- a/src/nostr_utils/mod.rs
+++ b/src/nostr_utils/mod.rs
@@ -103,31 +103,9 @@ impl CommandRunner for NewArgs {
- a
+ abc
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(
patch.subject,
"[PATCH] chore: Some long subject yes so long one Some long subject yes so long one"
);
assert_eq!(patch.body, "Abc patch");
}
#[test]
fn patch_multiline_body() {
let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
From: Awiteb <[email protected]>
Date: Tue, 27 May 2025 19:20:42 +0000
Subject: [PATCH] chore: a to abc
Lorem ipsum dolor sit amet. 33 laborum galisum aut fugiat dicta vel accusamus
aliquam vel quisquam fuga in incidunt voluptas a aliquid neque ab iure pariatur.
Et molestiae vero a consectetur laborum et accusantium sequi. Et ratione
atque et molestiae dolorem in asperiores amet id dolor corporis in adipisci
aspernatur.
---
src/nostr_utils/mod.rs | 1 +
1files changed, 3 insertions(+), 1 deletions(-)
diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs
index 4120f5a..e68783c 100644
--- a/src/nostr_utils/mod.rs
+++ b/src/nostr_utils/mod.rs
@@ -103,31 +103,9 @@ impl CommandRunner for NewArgs {
- a
+ abc
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(patch.subject, "[PATCH] chore: a to abc");
assert_eq!(
patch.body,
"Lorem ipsum dolor sit amet. 33 laborum galisum aut fugiat dicta vel accusamus
aliquam vel quisquam fuga in incidunt voluptas a aliquid neque ab iure pariatur.
Et molestiae vero a consectetur laborum et accusantium sequi. Et ratione
atque et molestiae dolorem in asperiores amet id dolor corporis in adipisci
aspernatur."
);
}
#[test]
fn patch_cover_letter() {
let patch_content = r#"From 864f3018f62ab2e1265edb670d5493dafe7d2cb2 Mon Sep 17 00:00:00 2001
From: Awiteb <[email protected]>
Date: Tue, 3 Jun 2025 08:41:12 +0000
Subject: [PATCH v2 0/7] feat: Some test just a test
Cover body
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(patch.subject, "[PATCH v2 0/7] feat: Some test just a test");
assert_eq!(
patch.body,
"Cover body
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a"
);
}
#[test]
fn patch_multiline_cover_subject() {
let patch_content = r#"From 864f3018f62ab2e1265edb670d5493dafe7d2cb2 Mon Sep 17 00:00:00 2001
From: Awiteb <[email protected]>
Date: Tue, 3 Jun 2025 08:41:12 +0000
Subject: [PATCH v2 0/7] feat: Some test just a test some test just a test some
test just a test
Cover body
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(
patch.subject,
"[PATCH v2 0/7] feat: Some test just a test some test just a test some test just a test"
);
assert_eq!(
patch.body,
"Cover body
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a"
);
}
#[test]
fn patch_multiline_cover_body() {
let patch_content = r#"From 864f3018f62ab2e1265edb670d5493dafe7d2cb2 Mon Sep 17 00:00:00 2001
From: Awiteb <[email protected]>
Date: Tue, 3 Jun 2025 08:41:12 +0000
Subject: [PATCH v2 0/7] feat: Some test just a test some test just a test some
test just a test
Lorem ipsum dolor sit amet. 33 laborum galisum aut fugiat dicta vel accusamus
aliquam vel quisquam fuga in incidunt voluptas a aliquid neque ab iure pariatur.
Et molestiae vero a consectetur laborum et accusantium sequi. Et ratione
atque et molestiae dolorem in asperiores amet id dolor corporis in adipisci
aspernatur.
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(
patch.subject,
"[PATCH v2 0/7] feat: Some test just a test some test just a test some test just a test"
);
assert_eq!(
patch.body,
"Lorem ipsum dolor sit amet. 33 laborum galisum aut fugiat dicta vel accusamus
aliquam vel quisquam fuga in incidunt voluptas a aliquid neque ab iure pariatur.
Et molestiae vero a consectetur laborum et accusantium sequi. Et ratione
atque et molestiae dolorem in asperiores amet id dolor corporis in adipisci
aspernatur.
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a"
);
}
#[test]
fn normal_patch_filename() {
let mut patch = GitPatch {
inner: String::new(),
subject: String::new(),
body: String::new(),
};
patch.subject = "[PATCH v2 0/3] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("v2-0000-cover-letter.patch")
);
patch.subject = "[PATCH 0/3] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("0000-cover-letter.patch")
);
patch.subject = "[PATCH v2 1/3] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("v2-0001-feat-some-test-just-a-test.patch")
);
patch.subject = "[PATCH v42 1/3] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("v42-0001-feat-some-test-just-a-test.patch")
);
patch.subject = "[PATCH v42 23/30] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("v42-0023-feat-some-test-just-a-test.patch")
);
patch.subject = "[PATCH 1/3] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("0001-feat-some-test-just-a-test.patch")
);
patch.subject = "[PATCH 32/50] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("0032-feat-some-test-just-a-test.patch")
);
patch.subject = "[PATCH v100 32/50] feat: some long subject some long subject some long \
subject some long subject"
.to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("v100-0032-feat-some-long-subject-some-long-subject-some-long-subject.patch")
);
}
#[test]
fn patch_filename_without_patch() {
let mut patch = GitPatch {
inner: String::new(),
subject: "[RFC v5 1/2] Something".to_owned(),
body: String::new(),
};
assert!(patch.filename("").is_err());
patch.subject = "Something".to_owned();
assert!(patch.filename("").is_err());
}
#[test]
fn patch_filename_without_number() {
let mut patch = GitPatch {
inner: String::new(),
subject: "[PATCH v5 /2] Something".to_owned(),
body: String::new(),
};
assert!(patch.filename("").is_err());
patch.subject = "[PATCH v5 2/] Something".to_owned();
assert!(patch.filename("").is_err());
}
#[test]
fn patch_filename_without_version() {
let patch = GitPatch {
inner: String::new(),
subject: "[PATCH 1/2] Something".to_owned(),
body: String::new(),
};
assert!(patch.filename("").is_ok());
}
2026-04-04 22:42:40 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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::Args;
use super::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct ReopenArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `[email protected]/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Option<Vec<NaddrOrSet>>,
/// The closed/drafted patch id to reopen it. Must be orignal root patch
patch_id: NostrEvent,
}
impl CommandRunner for ReopenArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
options,
self.patch_id,
self.naddrs,
PatchStatus::Open,
None,
Vec::new(),
|patch_status| {
if patch_status.is_open() {
return Err(N34Error::InvalidStatus(
"You can't open an already open patch".to_owned(),
));
}
if patch_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't open a merged/applied patch".to_owned(),
));
}
Ok(())
},
)
.await
}
}
2026-04-04 22:42:51 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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, str::FromStr};
use clap::Args;
use futures::future;
use nostr::{
event::{EventBuilder, EventId, Kind, Tag, TagKind, Tags, UnsignedEvent},
hashes::sha1::Hash as Sha1Hash,
key::PublicKey,
nips::{nip01::Coordinate, nip10::Marker},
types::RelayUrl,
};
use super::GitPatch;
use crate::{
cli::{
CliOptions,
patch::{REVISION_ROOT_HASHTAG_CONTENT, ROOT_HASHTAG_CONTENT},
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, NostrEvent},
},
error::N34Result,
nostr_utils::{
NostrClient,
traits::{NaddrsUtils, ReposUtils},
utils,
},
};
/// Prefix used for git patch alt.
const PATCH_ALT_PREFIX: &str = "git patch: ";
#[derive(Args, Debug)]
pub struct SendArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `[email protected]/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Option<Vec<NaddrOrSet>>,
/// List of patch files to send (space separated).
///
/// For p-tagging users, include them in the cover letter with
/// `nostr:npub1...`.
#[arg(value_name = "PATCH-PATH", required = true, value_parser = parse_patch_path)]
patches: Vec<GitPatch>,
/// Original patch ID if this is a revision of it
#[arg(long, value_name = "EVENT-ID")]
original_patch: Option<NostrEvent>,
}
impl CommandRunner for SendArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
self.naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?)?;
let repo_coordinates = naddrs.clone().into_coordinates();
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let user_pubk = client.pubkey().await?;
client.add_relays(&naddrs.extract_relays()).await;
if let Some(original_patch) = &self.original_patch {
client.add_relays(&original_patch.relays).await;
}
let relays_list = client.user_relays_list(user_pubk).await?;
client
.add_relays(&utils::add_read_relays(relays_list.as_ref()))
.await;
let repos = client.fetch_repos(&repo_coordinates).await?;
let euc = repos.extract_euc();
let maintainers = repos.extract_maintainers();
client.add_relays(&repos.extract_relays()).await;
let (events, events_write_relays) = make_patch_series(
&client,
self.patches,
self.original_patch.as_ref().map(|e| e.event_id),
repos.extract_relays().first().cloned(),
repo_coordinates,
euc,
user_pubk,
)
.await?;
let write_relays = [
relays,
repos.extract_relays(),
events_write_relays,
naddrs.extract_relays(),
self.original_patch.map(|e| e.relays).unwrap_or_default(),
utils::add_write_relays(relays_list.as_ref()),
client.read_relays_from_users(&maintainers).await,
]
.concat();
tracing::trace!(write_relays = ?write_relays, "Write relays of the patches");
let nevents = future::join_all(events.into_iter().map(|mut event| {
async {
let event_id = event.id();
let subject = event
.tags
.find(TagKind::Alt)
.and_then(Tag::content)
.expect("There is an alt")
.replace(PATCH_ALT_PREFIX, "");
client
.send_event_to(event, relays_list.as_ref(), &write_relays)
.await
.map(|r| Ok((subject, utils::new_nevent(event_id, &r)?)))?
}
}))
.await
.into_iter()
.collect::<N34Result<Vec<_>>>()?;
for (subject, nevent) in nevents {
println!("Created '{subject}': {nevent}");
}
Ok(())
}
}
fn parse_patch_path(patch_path: &str) -> Result<GitPatch, String> {
tracing::debug!("Parsing patch file `{patch_path}`");
let patch_content = fs::read_to_string(patch_path)
.map_err(|err| format!("Failed to read patch file `{patch_path}`: {err}"))?;
GitPatch::from_str(&patch_content)
}
async fn make_patch_series(
client: &NostrClient,
patches: Vec<GitPatch>,
original_patch: Option<EventId>,
relay_hint: Option<RelayUrl>,
repo_coordinates: Vec<Coordinate>,
euc: Option<&Sha1Hash>,
author_pkey: PublicKey,
) -> N34Result<(Vec<UnsignedEvent>, Vec<RelayUrl>)> {
let mut write_relays = Vec::new();
let mut patch_series = Vec::new();
let mut patches = patches.into_iter();
let root_patch = patches.next().expect("Patches can't be empty");
let (root_event, root_relays) = make_patch(
client,
root_patch,
None,
original_patch,
relay_hint.as_ref(),
&repo_coordinates,
euc,
author_pkey,
)
.await;
write_relays.extend(root_relays);
let root_id = *root_event.id.as_ref().expect("There is an id");
let mut previous_patch = root_id;
patch_series.push(root_event);
for patch in patches {
let (patch_event, patch_relays) = make_patch(
client,
patch,
Some(root_id),
Some(previous_patch),
relay_hint.as_ref(),
&repo_coordinates,
euc,
author_pkey,
)
.await;
previous_patch = patch_event.id.expect("there is an id");
write_relays.extend(patch_relays);
patch_series.push(patch_event);
}
Ok((patch_series, write_relays))
}
#[allow(clippy::too_many_arguments)]
async fn make_patch(
client: &NostrClient,
patch: GitPatch,
root: Option<EventId>,
reply_to: Option<EventId>,
write_relay: Option<&RelayUrl>,
repo_coordinates: &[Coordinate],
euc: Option<&Sha1Hash>,
author_pkey: PublicKey,
) -> (UnsignedEvent, Vec<RelayUrl>) {
let content_details = client.parse_content(&patch.body).await;
let content_relays = content_details.write_relays.clone();
// NIP-34 compliance requires referencing the previous patch using `NIP-10 e
// reply`. However, this fails for the second patch when
// `EventBuilder::dedup_tags` is enabled because:
// 1. The tag is treated as a duplicate based on its content (the root ID).
// 2. The second patch would reply to the root twice:
// - First with the 'root' marker
// - Then with the 'reply' marker
// The `EventBuilder::dedup_tags` function then removes the 'reply' marker as a
// duplicate.
let mut safe_dedup_tags = Tags::new();
safe_dedup_tags.push(Tag::alt(format!("{PATCH_ALT_PREFIX}{}", patch.subject)));
safe_dedup_tags.push(Tag::description(patch.subject));
safe_dedup_tags.extend(content_details.into_tags());
safe_dedup_tags.extend(
repo_coordinates
.iter()
.map(|c| Tag::coordinate(c.clone(), None)),
);
safe_dedup_tags.extend(
repo_coordinates
.iter()
.map(|c| Tag::public_key(c.public_key)),
);
if let Some(euc) = euc {
safe_dedup_tags.push(Tag::reference(euc.to_string()));
}
safe_dedup_tags.dedup();
let mut event_builder = EventBuilder::new(Kind::GitPatch, patch.inner).tags(safe_dedup_tags);
// If the root is None, this indicates we're handling the root event
if let Some(root_id) = root {
event_builder =
event_builder.tag(utils::event_reply_tag(&root_id, write_relay, Marker::Root));
} else {
event_builder = event_builder.tag(Tag::hashtag(ROOT_HASHTAG_CONTENT));
}
// Handles the case where there is a patch to reply to but no root. This
// indicates we are processing a revision, as the root revision should reply
// directly to the original patch.
if let Some(reply_to_id) = reply_to {
if root.is_none() {
event_builder = event_builder.tags([
utils::event_reply_tag(&reply_to_id, write_relay, Marker::Reply),
Tag::hashtag(REVISION_ROOT_HASHTAG_CONTENT),
]);
} else {
event_builder = event_builder.tag(utils::event_reply_tag(
&reply_to_id,
write_relay,
Marker::Reply,
));
}
}
(
event_builder.build(author_pkey),
content_relays.into_iter().collect(),
)
}
2026-04-04 22:42:18 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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::Args;
use nostr::hashes::sha1::Hash as Sha1Hash;
use super::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::{CommandRunner, VecNostrEventExt},
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct MergeArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `[email protected]/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open patch id to merge it. Must be orignal root patch or
/// revision root
patch_id: NostrEvent,
/// Patches that have been merged. Use this when only some patches have been
/// merged, not all.
#[arg(long = "patches", value_name = "PATCH-EVENT-ID")]
merged_patches: Vec<NostrEvent>,
/// The merge commit id
merge_commit: Sha1Hash,
}
impl CommandRunner for MergeArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
options,
self.patch_id,
self.naddrs,
PatchStatus::MergedApplied,
Some(either::Either::Left(self.merge_commit)),
self.merged_patches.into_event_ids(),
|patch_status| {
if patch_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't merge an already merged patch".to_owned(),
));
}
if patch_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't merge a closed patch".to_owned(),
));
}
if patch_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't merge a drafted patch".to_owned(),
));
}
Ok(())
},
)
.await
}
}
2026-04-04 22:42:29 UTC npub13pvycyyuyczv0l0chj75tzjaypq3j8xnz5a02fmjkaw0mhmcqe7sdegxya by npub13pv…gxya
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <[email protected]>
//
// 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>.
/// `patch apply` suubcommand
mod apply;
/// `patch close` subcommand
mod close;
/// `patch draft` subcommand
mod draft;
/// `patch fetch` subcommand
mod fetch;
/// `patch list` subcommand
mod list;
/// `patch merge` subcommand
mod merge;
/// `patch reopen` subcommand
mod reopen;
/// `patch send` subcommand
mod send;
#[cfg(test)]
mod tests;
use std::{
fmt,
path::{Path, PathBuf},
str::FromStr,
sync::LazyLock,
};
use clap::Subcommand;
use nostr::event::Kind;
use regex::Regex;
use self::apply::ApplyArgs;
use self::close::CloseArgs;
use self::draft::DraftArgs;
use self::fetch::FetchArgs;
use self::list::ListArgs;
use self::merge::MergeArgs;
use self::reopen::ReopenArgs;
use self::send::SendArgs;
use super::{CliOptions, CommandRunner};
use crate::error::{N34Error, N34Result};
/// Regular expression for extracting the patch subject.
static SUBJECT_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^Subject: (.*(?:\n .*)*)").unwrap());
/// Regular expression for extracting the patch body.
static BODY_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\n\n((?:.|\n)*?)(?:\n--[ -]|\z)").unwrap());
/// Regular expiration for extracting the patch version and number
static PATCH_VERSION_NUMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\[PATCH\s+(?:v(?<version>\d+)\s*)?(?<number>\d+)/(?:\d+)").unwrap()
});
/// Content of the hashtag representing the root patch.
pub const ROOT_HASHTAG_CONTENT: &str = "root";
/// Content of the hashtag representing the root revision patch.
pub const REVISION_ROOT_HASHTAG_CONTENT: &str = "root-revision";
/// The content of the hashtag used by `ngit-cli` to represent a root revision
/// patch before the commit 6ae42e67d9da36f6c2e1356acba30a3a62112bc7. This was a
/// typo.
pub const LEGACY_NGIT_REVISION_ROOT_HASHTAG_CONTENT: &str = "revision-root";
#[derive(Subcommand, Debug)]
pub enum PatchSubcommands {
/// Send one or more patches to a repository.
Send(SendArgs),
/// Fetches a patch by its id.
Fetch(FetchArgs),
/// Closes an open or drafted patch.
Close(CloseArgs),
/// Converts an open patch to draft state.
Draft(DraftArgs),
/// Reopens a closed or drafted patch.
Reopen(ReopenArgs),
/// Set an open patch status to applied.
Apply(ApplyArgs),
/// Set an open patch status to merged.
Merge(MergeArgs),
/// List the repositories patches.
List(ListArgs),
}
/// Represents a git patch
#[derive(Clone, Debug)]
pub struct GitPatch {
/// Full content of the patch file
pub inner: String,
/// Short description of the patch changes
pub subject: String,
/// Detailed explanation of the patch changes
pub body: String,
}
#[derive(Debug)]
pub enum PatchStatus {
/// The patch is currently open
Open,
/// The patch has been merged/applied
MergedApplied,
/// The patch has been closed
Closed,
/// A patch that has been drafted but not yet applied.
Draft,
}
impl PatchStatus {
/// Maps the issue status to its corresponding Nostr kind.
#[inline]
pub fn kind(&self) -> Kind {
match self {
Self::Open => Kind::GitStatusOpen,
Self::MergedApplied => Kind::GitStatusApplied,
Self::Closed => Kind::GitStatusClosed,
Self::Draft => Kind::GitStatusDraft,
}
}
/// Returns the string representation of the patch status.
pub const fn as_str(&self) -> &'static str {
match self {
Self::Open => "Open",
Self::MergedApplied => "Merged/Applied",
Self::Closed => "Closed",
Self::Draft => "Draft",
}
}
/// Check if the patch is open.
#[inline]
pub fn is_open(&self) -> bool {
matches!(self, Self::Open)
}
/// Check if the patch is merged/applied.
#[inline]
pub fn is_merged_or_applied(&self) -> bool {
matches!(self, Self::MergedApplied)
}
/// Check if the patch is closed.
#[inline]
pub fn is_closed(&self) -> bool {
matches!(self, Self::Closed)
}
/// Check if the patch is drafted
#[inline]
pub fn is_drafted(&self) -> bool {
matches!(self, Self::Draft)
}
}
impl From<&PatchStatus> for Kind {
fn from(status: &PatchStatus) -> Self {
status.kind()
}
}
impl fmt::Display for PatchStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl TryFrom<Kind> for PatchStatus {
type Error = N34Error;
fn try_from(kind: Kind) -> Result<Self, Self::Error> {
match kind {
Kind::GitStatusOpen => Ok(Self::Open),
Kind::GitStatusApplied => Ok(Self::MergedApplied),
Kind::GitStatusClosed => Ok(Self::Closed),
Kind::GitStatusDraft => Ok(Self::Draft),
_ => Err(N34Error::InvalidPatchStatus(kind)),
}
}
}
impl GitPatch {
/// Returns the patch file name from the subject
pub fn filename(&self, parent: impl AsRef<Path>) -> N34Result<PathBuf> {
let (patch_version, patch_number) = if self.subject.contains("[PATCH]") {
(String::new(), "1")
} else {
patch_version_and_subject(&self.subject)?
};
let patch_name = if patch_number == "0" {
"cover-letter".to_owned()
} else {
patch_file_name(&self.subject)?
};
Ok(parent
.as_ref()
.join(format!("{patch_version}{patch_number:0>4}-{patch_name}").replace("--", "-"))
.with_extension("patch"))
}
}
impl CommandRunner for PatchSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::run_command!(self, options, & Send Fetch Close Reopen Draft Apply Merge List)
}
}
impl FromStr for GitPatch {
type Err = String;
fn from_str(patch_content: &str) -> Result<Self, Self::Err> {
// Regex for subject (handles multi-line subjects)
let subject = SUBJECT_RE
.captures(patch_content)
.and_then(|cap| cap.get(1))
.ok_or("No subject found")?
.as_str()
.trim()
.replace('\n', "")
.to_string();
// Regex for body
let body = BODY_RE
.captures(patch_content)
.and_then(|cap| cap.get(1))
.ok_or("No body found")?
.as_str()
.trim()
.to_string();
Ok(Self {
inner: patch_content.to_owned(),
subject,
body,
})
}
}
/// Extracts the version prefix and patch number from a patch subject string.
///
/// The version prefix is formatted as "v{version}-" if present, or an empty
/// string. The patch number is mandatory and will cause an error if not found.
fn patch_version_and_subject(subject: &str) -> N34Result<(String, &str)> {
let captures = PATCH_VERSION_NUMBER_RE.captures(subject).ok_or_else(|| {
N34Error::InvalidEvent(format!("Can not parse the patch subject `{subject}`"))
})?;
Ok((
captures
.name("version")
.map(|m| format!("v{}-", m.as_str()))
.unwrap_or_default(),
captures
.name("number")
.map(|m| m.as_str())
.expect("It's not optional, regex will fail if it's not found"),
))
}
/// Extracts a clean file name from the patch subject by removing version info
/// and special characters. Converts to lowercase and ensures the name only
/// contains alphanumeric, '.', '-', or '_' characters.
fn patch_file_name(subject: &str) -> N34Result<String> {
Ok(subject
.split_once("]")
.ok_or_else(|| {
N34Error::InvalidEvent(format!(
"Invalid patch subject. No `[PATCH ...]`: `{subject}`",
))
})?
.1
.trim()
.to_lowercase()
.replace(
|c: char| !c.is_ascii_alphanumeric() && !['.', '-', '_'].contains(&c),
"-",
)
.chars()
.take(60)
.collect::<String>()
.trim_matches('-')
.trim()
.replace("--", "-"))
}
2026-04-04 22:42:07 UTC
- reply
npub10mtatsat7ph6rsq0w8u8npt8d86x4jfr2nqjnvld2439q6f8ugqq0x27hf by npub10mt…27hf
https://i.nostr.build/zozlKEkblUkGBTu0.gif
Open in
Open in Your default app native
Open in Nostur ios
Open in Yakihonne android
Open in Yakihonne ios
Open in Jumble web
Open in YakiHonne web
Open in chachi web
Open in Nosotros web
Open in Lumilumi web
Open in Coracle web
Open in relay.tools web
Open in Nostter web
Open in Your default web app web
the source code for this service is free and open
this website is part of nostr.net