27 Commits

Author SHA1 Message Date
Awiteb
9aa3b62de0 chore: Bump the version to v0.4.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-08 15:21:35 +00:00
Awiteb
a8015d618b chore: Use nushell as a script interpreter
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-08 15:20:20 +00:00
Awiteb
2bcdb29a88 chore: Add nushell to dev shell
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-08 14:29:22 +00:00
Awiteb
4a4c0dcd34 chore: Show the lifetime
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-08 14:29:22 +00:00
Awiteb
43ca689190 chore: installation using Nix
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:26:11 +00:00
Awiteb
14ab9b38e2 chore(cargo): Add package.documentation and package.homepage
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:22:40 +00:00
Awiteb
802da70056 chore(flake): Add packages.default output
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:22:40 +00:00
Awiteb
cff0b2f3b2 chore(flake): Update lock file
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:22:40 +00:00
Awiteb
9c55bf8af1 chore: use the existing signer to get the pubkey instead of a new one
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:22:40 +00:00
Awiteb
904d14064f feat: Sign using NIP-07
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:22:40 +00:00
Awiteb
ad215002e2 chore: backward compatibility with ngit revision patch hashtag
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-04 09:22:45 +00:00
Awiteb
67339a8a47 chore: verify subscriptions
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-04 06:28:32 +00:00
Awiteb
1d83e445a4 deps: Upgrade to nostr@0.43.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-03 15:24:05 +00:00
Awiteb
8bb4cf0da4 refactor: Move root and root-revision to constants
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-03 15:24:05 +00:00
Awiteb
e17e75e53b refactor: Move the trait extensions to traits.rs
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-03 15:24:04 +00:00
Awiteb
67e25da8c0 feat: New flag to patch apply and merge to mention patches
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-03 15:24:04 +00:00
Awiteb
bc8c6f3154 deps: Remove url from n34 dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-03 00:17:30 +00:00
Awiteb
0ba58dcbd0 tests: Move the patch tests to tests.rs
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-02 09:50:21 +00:00
Awiteb
78113c7402 docs: Fix status command docs
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-02 09:14:21 +00:00
Awiteb
bd3ba1bdbf docs: n34 book
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-01 20:32:48 +00:00
Awiteb
2630104e99 chore: Fix a typo in the description
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-01 20:17:42 +00:00
Awiteb
03d5c8020e feat: Keyring the secret key n34 config keyring --enable
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-27 00:55:53 +00:00
Awiteb
c3aef2e0ad chore: Add installation section to README.md
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-18 22:24:03 +00:00
Awiteb
4e0ecdceaf feat: Support signing using NIP-46 bunker
Suggested-by: DanConwayDev <DanConwayDev@protonmail.com>
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-18 21:43:50 +00:00
Awiteb
f0c20c3677 deps: Add keyring, nostr-connect, nostr-keyring and url to the dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-18 21:17:02 +00:00
Awiteb
e6943aefab chore(flake): Add dbus to the packages
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-10 12:05:14 +00:00
Awiteb
353358f0ed chore: Move the naddr parsers to parsers.rs
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-07 12:35:01 +00:00
80 changed files with 3188 additions and 935 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/target
/book

View File

@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.0] - 2025-08-08
### Added
- Support signing using NIP-46 bunker - by Awiteb
- Keyring the secret key `n34 config keyring --enable` - by Awiteb
- New flag to `patch apply and merge` to mention patches - by Awiteb
- Sign using NIP-07 - by Awiteb
### Dependencies
- Add `keyring`, `nostr-connect`, `nostr-keyring` and `url` to the dependencies - by Awiteb
- Remove `url` from `n34` dependencies - by Awiteb
- Upgrade to `nostr@0.43.0` - by Awiteb
### Documentation
- N34 book - by Awiteb
- Fix status command docs - by Awiteb
### Refactor
- Move the trait extensions to `traits.rs` - by Awiteb
- Move `root` and `root-revision` to constants - by Awiteb
## [0.3.0] - 2025-07-05
### Added
@@ -101,6 +126,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Initialize the project - by Awiteb
[0.4.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.4.0
[0.3.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.3.0
[0.2.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.2.0
[0.1.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.1.0

651
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,71 @@
[package]
name = "n34"
description = "A CLI to interact with NIP-34 and other stuff related to codes in nostr"
version = "0.3.0"
edition = "2024"
license = "GPL-3.0-or-later"
authors = ["Awiteb <a@4rs.nl>"]
readme = "README.md"
repository = "https://git.4rs.nl/awiteb/n34.git"
keywords = ["nostr", "NIP-34", "CLI"]
categories = ["command-line-utilities"]
rust-version = "1.88.0"
name = "n34"
description = "A CLI to interact with NIP-34 and other stuff related to code in Nostr"
version = "0.4.0"
edition = "2024"
license = "GPL-3.0-or-later"
authors = ["Awiteb <a@4rs.nl>"]
readme = "README.md"
documentation = "https://n34.dev/commands.html"
homepage = "https://n34.dev"
repository = "https://git.4rs.nl/awiteb/n34.git"
keywords = ["nostr", "NIP-34", "CLI"]
categories = ["command-line-utilities"]
rust-version = "1.88.0"
[dependencies]
chrono = "0.4.41"
clap = { version = "4.5.37", features = ["derive"] }
clap-verbosity-flag = { version = "3.0.2", default-features = false, features = [
"tracing",
] }
convert_case = "0.8.0"
dirs = "6.0.0"
easy-ext = "1.0.2"
either = "1.15.0"
futures = "0.3.31"
nostr = { version = "0.42.2", features = ["parser", "nip05"] }
nostr-sdk = "0.42.0"
regex = "1.11.1"
serde = { version = "1.0.219", features = ["derive"] }
tempfile = "3.19.1"
thiserror = "2.0.12"
tokio = { version = "1.44.2", features = ["macros", "rt-multi-thread"] }
toml = "0.8.22"
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
chrono = "0.4.41"
convert_case = "0.8.0"
dirs = "6.0.0"
easy-ext = "1.0.2"
either = "1.15.0"
futures = "0.3.31"
nostr-browser-signer-proxy = "0.43.0"
regex = "1.11.1"
tempfile = "3.20.0"
thiserror = "2.0.12"
toml = "0.9.4"
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
[dependencies.clap]
features = ["derive"]
version = "4.5.42"
[dependencies.clap-verbosity-flag]
default-features = false
features = ["tracing"]
version = "3.0.3"
[dependencies.nostr]
default-features = false
features = ["std"]
version = "0.43.0"
[dependencies.nostr-connect]
default-features = false
version = "0.43.0"
[dependencies.nostr-keyring]
default-features = false
version = "0.43.0"
[dependencies.nostr-sdk]
default-features = false
version = "0.43.0"
[dependencies.reqwest]
default-features = false
features = ["http2", "json", "rustls-tls"]
version = "0.12.22"
[dependencies.serde]
features = ["derive"]
version = "1.0.219"
[dependencies.tokio]
features = ["macros", "rt-multi-thread"]
version = "1.47.1"
[profile.release]
lto = "fat"

View File

@@ -1,9 +1,6 @@
!!! warning "Work In Progress"
This project is still work in progress and not completed yet.
# n34
A CLI to interact with NIP-34 and other stuff related to codes in nostr
A CLI to interact with NIP-34 and other stuff related to code in Nostr
## About
@@ -16,6 +13,10 @@ The primary goal of `n34` is to implement [NIP-34] (`git` stuff), but its
flexible design allows for additional use cases beyond Git workflows. For more
details, see the following section.
## Documentation
Check the documentation at [n34.dev]
## Features
- [X] Repository announcements
@@ -24,9 +25,12 @@ details, see the following section.
- [X] Issues (Send, view and list)
- [X] Replies
- [X] Issues and patches status
- [ ] Pull requests ([nostr-protocol/nips#1966])
- [X] Gossip Model ([NIP-65])
- [X] Proof of Work ([NIP-13])
- [X] `nostr:` URI scheme, in the issue/reply content ([NIP-21])
- [X] Signing using bunker ([NIP-46])
- [X] Signing using [NIP-07] proxy ([nostr-browser-signer-proxy])
- [ ] Code Snippets ([NIP-C0])
- [X] In device relays and repos bookmark (`sets` command)
@@ -54,6 +58,59 @@ into a future-proof foundation for decentralized collaboration.
- <https://nostr.org>
- <https://nostr.how/en/what-is-nostr>
## Installation
You can install n34 either by cloning the repository and building it with Cargo,
or by using `cargo install` or Nix.
### Building from source
- Clone the repository:
```sh
git clone git://git.4rs.nl/awiteb/n34.git
cd n34
```
- Build the release version:
```sh
cargo build --release
```
The binary will be available at `target/release/n34`.
### Using cargo install
```sh
cargo install n34
```
The binary will be installed to your Cargo binary directory (typically `~/.cargo/bin/n34`).
Make sure `~/.cargo/bin` is in your `PATH` environment variable to run the binary from anywhere.
### Using `nix build` (+v0.4)
- Clone the repository.
- Run the `nix build` command.
The binary will be available at `result/bin/n34`.
### Adding it to your [home-manager] (+v0.4)
- Add it as an input to your `flake.nix`:
```nix
inputs = {
# Specify the version you want to install, or remove `?ref` for the unreleased
# version. You can also use any mirror; it doesn't have to be `git.4rs.nl`.
n34.url = "git+https://git.4rs.nl/awiteb/n34.git?ref=refs/tags/vx.y.x";
};
```
- Add it to your packages (ensure your home-manager `extraSpecialArgs` includes the `inputs`):
```nix
packages = [ inputs.n34.packages."${pkgs.system}".default ];
```
## Contributing
Contributions to `n34` are welcome! You can help by opening issues (such as bug
@@ -77,3 +134,9 @@ refer to the [LICENSE](LICENSE) file for more details.
[NIP-13]: https://github.com/nostr-protocol/nips/blob/master/13.md
[NIP-21]: https://github.com/nostr-protocol/nips/blob/master/21.md
[NIP-C0]: https://github.com/nostr-protocol/nips/blob/master/C0.md
[NIP-46]: https://github.com/nostr-protocol/nips/blob/master/46.md
[NIP-07]: https://github.com/nostr-protocol/nips/blob/master/07.md
[nostr-protocol/nips#1966]: https://github.com/nostr-protocol/nips/pull/1966
[nostr-browser-signer-proxy]: https://crates.io/crates/nostr-browser-signer-proxy
[home-manager]: https://github.com/nix-community/home-manager
[n34.dev]: https://n34.dev

10
book.toml Normal file
View File

@@ -0,0 +1,10 @@
[book]
authors = ["Awiteb"]
language = "en"
multilingual = false
src = "docs"
title = "n34 documentation"
[output.html]
git-repository-icon = "fa-git"
git-repository-url = "https://git.4rs.nl/awiteb/n34.git"

34
docs/SUMMARY.md Normal file
View File

@@ -0,0 +1,34 @@
# n34 documentation
- [Command Line Usage](commands.md)
- [Managing Repository and Relay Sets](sets/README.md)
- [Create a Set](sets/new.md)
- [Modify a Set](sets/update.md)
- [Show Sets](sets/show.md)
- [Remove a Set](sets/remove.md)
- [Manage Configuration](config/README.md)
- [Default PoW Difficulty](config/pow.md)
- [Fallback Relays](config/relays.md)
- [NIP-07 Browser Signer Proxy](config/nip07.md)
- [NIP-46 Bunker](config/bunker.md)
- [Secret Key Keyring](config/keyring.md)
- [Manage Repositories](repo/README.md)
- [Broadcast and Update a Git Repository](repo/announce.md)
- [View Git Repository Details](repo/view.md)
- [Issue Management](issue/README.md)
- [Create an Issue](issue/new.md)
- [View an Issue By ID](issue/view.md)
- [Reopen a Closed Issue](issue/reopen.md)
- [Closes an Open Issue](issue/close.md)
- [Resolves an Issue](issue/resolve.md)
- [List Repositories Issues](issue/list.md)
- [Patch Management](patch/README.md)
- [Send Patches to a Repository](patch/send.md)
- [Fetch a Patch By ID](patch/fetch.md)
- [Reopens a Closed or Drafted Patch](patch/reopen.md)
- [Closes an Open or Drafted Patch](patch/close.md)
- [Draft an Open Patch](patch/draft.md)
- [Apply an Open Patch](patch/apply.md)
- [Merge an Open Patch](patch/merge.md)
- [List Repositories Patches](patch/list.md)
- [Reply to Issues and Patches](reply.md)

66
docs/commands.md Normal file
View File

@@ -0,0 +1,66 @@
# Command-Line Usage
## Options
The `n34` command-line tool accepts the following options:
- `-s`, `--secret-key`: Your Nostr secret key (in `nsec` format), used for
signing events.
- `-b`, `--bunker-url`: The URL of a NIP-46 bunker service used for remote
signing of events.
- `-7`, `--nip07`: Enables signing events using the browser's NIP-07
extension. Listens on `127.0.0.1:51034`. You can configure the address with `n34
config nip07`
- `-r`, `--relays`: A relay to read from and write to. This option can be
specified multiple times to connect to several relays.
- `--pow`: Sets the Proof of Work difficulty required when creating events.
- `--config`: Specifies a custom path to the configuration file (Default:
`$HOME/.config/n34/config.toml`).
- `-v`, `--verbose...`: Increases the logging verbosity. Can be used multiple
times for more detail (e.g., `-v`, `-vv`).
**Note:** The `--secret-key` and `--bunker-url` options are mutually exclusive.
You must provide exactly one signing method.
## Multiple Repositories
Commands that interact with a repository, such as submitting an issue or a
patch, can accept multiple repository addresses (`naddr`). This feature is
useful for projects with multiple maintainers who each have their own repository
fork.
> **Important:** When you provide multiple repositories, `n34` does not
create a separate issue or patch for each one. Instead, it creates a single
event that references all of the specified repositories.
## The `nostr-address` File
The `nostr-address` file is a plain text file that stores a list of project
repository addresses. This allows the `n34` to find and use them
without requiring you to enter the addresses manually.
### Format
- Each line must contain a single addressable event coordinate `naddr` which is
the repository address.
- Lines beginning with a `#` are treated as comments and are ignored.
- Empty lines are also ignored.
## Passing repositories
By default, `n34` will look for a `nostr-address` file to extract repositories
from it. This is why repositories are not required for commands like `patch
send` and `issue new`. You can also pass repositories using the `--repo`
option or the `<NADDR-NIP05-OR-SET>` argument for commands that accept them. The
supported formats for manual input are:
- A [NIP-19] addressable event coordinate `naddr`.
- A [NIP-05] identifier and repository name, in the format
`<nip05>/<repo-name>`.
- A set name that contains repository addresses.
You do not need to specify relays for these commands if your `naddr` or `NIP-05`
identifier already includes relays; `n34` will automatically extract them.
[NIP-19]: https://github.com/nostr-protocol/nips/blob/master/19.md
[NIP-05]: https://github.com/nostr-protocol/nips/blob/master/05.md

7
docs/config/README.md Normal file
View File

@@ -0,0 +1,7 @@
# Manage Configuration
Configuration allows you to set default values for various command parameters,
such as fallback relays, Proof of Work (PoW) difficulty, a default bunker URL,
and your Nostr private key (keyring). This avoids the need to enter your private key,
bunker URL, relays, or PoW difficulty for every command, making `n34` more
convenient to use.

27
docs/config/bunker.md Normal file
View File

@@ -0,0 +1,27 @@
# NIP-46 Bunker
> `n34 config bunker` command
**Usage:**
```
Sets a URL of NIP-46 bunker server used for signing events
Usage: n34 config bunker [BUNKER_URL]
Arguments:
[BUNKER_URL] Nostr Connect URL for the bunker. Omit this to remove the current bunker URL
```
This command configures `n34` to use a remote signer ([NIP-46]), known as a
bunker, for all cryptographic operations.
When `n34` communicates with the bunker, it uses a persistent, locally-generated
keypair. You should add this keypair's public key to your bunker's list of
authorized applications. This allows `n34` to operate securely without needing
direct access to your main private key.
Once configured, actions such as fetching your public key or signing events are
delegated to the bunker. To remove the bunker configuration, run the command
again without providing a URL.
[NIP-46]: https://github.com/nostr-protocol/nips/blob/master/46.md

27
docs/config/keyring.md Normal file
View File

@@ -0,0 +1,27 @@
# Secret Key Keyring
> `n34 config keyring` command
**Usage:**
```
Manages the secret key keyring, including enabling, disabling, or resetting it.
Usage: n34 config keyring <--enable|--disable|--reset>
Options:
--enable Enables the secret key keyring. You will be prompted for your key one last time to store it.
--disable Disables the secret key keyring. This removes the stored key and prevents new ones from being saved.
--reset Resets the keyring. This deletes the current key, allowing a new one to be stored on the next use.
```
To avoid entering your private key for every command, you can enable the keyring
to store it securely. First, run `n34 config keyring --enable`. The next time
you run an `n34` command that requires your private key, it will be saved
to your system's keyring. You will not need to enter it again for subsequent
commands.
To replace the stored key with a new one, use the `--reset` flag. To stop using
the keyring and remove the stored key, use the `--disable` flag.
`n34` uses your operating system's native secret management system. For example,
it uses `keyutils` on Linux and `Keychain` on macOS.

22
docs/config/nip07.md Normal file
View File

@@ -0,0 +1,22 @@
# NIP-07 Browser Signer Proxy
> `n34 config nip07`
**Usage:**
```
Manage the NIP-07 browser signer proxy by enabling or disabling it and configuring the `ip:port` address.
Usage: n34 config nip07 [OPTIONS] <--enable|--disable>
Options:
--enable Enable NIP-07 as the default signer
--disable Disable NIP-07 as the default signer
--addr <ADDR> Set the `ip:port` for the browser signer proxy (default: 127.0.0.1:51034)
```
Use [NIP-07] (Browser Extension Signer) as your default signer. This is achieved
by running a proxy at the specified `ADDR`, which defaults to `127.0.0.1:51034`.
The proxy forwards `n34` requests to the browser signer and relays the responses
back.
[NIP-07]: https://github.com/nostr-protocol/nips/blob/master/07.md

20
docs/config/pow.md Normal file
View File

@@ -0,0 +1,20 @@
# Default PoW Difficulty
> `n34 config pow`
**Usage:**
```
Sets the default PoW difficulty (0 if not specified)
Usage: n34 config pow <DIFFICULTY>
Arguments:
<DIFFICULTY> The new default PoW difficulty
```
This command configures the default Proof of Work (PoW) difficulty for newly
created events. This setting is applied to most generated events, but it
intentionally skips patch events. Because patches can be numerous, calculating
PoW for each one would significantly slow down operations.
If you want to disable the PoW just make it 0.

21
docs/config/relays.md Normal file
View File

@@ -0,0 +1,21 @@
# Fallback Relays
> `n34 config relays` command
**Usage:**
```
Sets the default fallback relays if none provided. Use this relays for read and write
Usage: n34 config relays [OPTIONS] [RELAYS]...
Arguments:
[RELAYS]... List of relay URLs to append to fallback relays. If empty, removes all fallback relays
Options:
--override Replace existing fallback relays instead of appending new ones
```
This command configures the default fallback relays, which `n34` uses to read
from and write to. To add relays, provide their URLs as arguments to append
them to the current list. Use the `--override` flag to replace the existing list
entirely. To clear all fallback relays, run the command without any arguments.

482
docs/index.html Normal file
View File

@@ -0,0 +1,482 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta property="og:title" content="n34 - CLI for NIP-34 and Nostr Code Collaboration">
<meta property="og:description" content="An open source CLI for sending and receiving Git issues, patches and comments over the Nostr protocol.">
<meta property="og:url" content="https://n34.dev">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="n34 - CLI for NIP-34 and Nostr Code Collaboration">
<meta name="twitter:description" content="An open source CLI for sending and receiving Git issues, patches and comments over the Nostr protocol.">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>n34 - CLI for NIP-34 and Nostr Code Collaboration</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: radial-gradient(125% 125% at 50% 90%, #000000 40%, #072607 100%);
color: #ffffff;
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 60px 20px;
}
.hero {
text-align: center;
margin-bottom: 80px;
}
.hero h1 {
font-size: 4rem;
font-weight: 300;
margin-bottom: 20px;
letter-spacing: 2px;
background: linear-gradient(135deg, #ffffff, #cccccc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero p {
font-size: 1.2rem;
color: #cccccc;
margin-bottom: 40px;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.buttons {
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
display: inline-block;
padding: 14px 28px;
background: transparent;
border: 2px solid #ffffff;
color: #ffffff;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
font-size: 1rem;
transition: all 0.3s ease;
min-width: 120px;
text-align: center;
}
.btn:hover {
background: #ffffff;
color: #0a0a0a;
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(255, 255, 255, 0.2);
}
.section {
margin-bottom: 60px;
}
.section h2 {
font-size: 2.2rem;
margin-bottom: 30px;
color: #ffffff;
font-weight: 400;
}
.section p {
font-size: 1.1rem;
color: #e0e0e0;
margin-bottom: 20px;
text-align: justify;
}
a {
color: #cccccc;
text-decoration: underline;
font-size: 1rem;
transition: color 0.3s ease;
}
a:hover {
color: #ffffff;
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 30px;
margin-bottom: 60px;
}
.feature {
background: #1a1a1a;
padding: 30px;
border-radius: 12px;
border: 1px solid #333;
transition: transform 0.3s ease, border-color 0.3s ease;
}
.feature:hover {
transform: translateY(-5px);
border-color: #555;
}
.feature h3 {
font-size: 1.3rem;
margin-bottom: 15px;
color: #ffffff;
display: flex;
align-items: center;
gap: 10px;
}
.feature-icon {
font-size: 1.5rem;
}
.feature p {
color: #cccccc;
font-size: 1rem;
text-align: left;
}
.feature-list {
background: #111;
padding: 40px;
border-radius: 12px;
border: 1px solid #333;
margin-bottom: 60px;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.feature-column ul {
list-style: none;
padding: 0;
}
.feature-column li {
display: flex;
align-items: center;
margin-bottom: 12px;
font-size: 1rem;
padding: 8px 0;
}
.feature-check {
display: inline-block;
width: 20px;
height: 20px;
margin-right: 12px;
border-radius: 3px;
text-align: center;
line-height: 20px;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
}
.feature-check.completed {
background: #22c55e;
color: #ffffff;
}
.feature-check.pending {
background: #374151;
color: #9ca3af;
border: 1px solid #4b5563;
}
.feature-check.completed::after {
content: "✓";
}
.feature-check.pending::after {
content: "○";
}
.feature-text {
color: #e0e0e0;
}
.feature-text.pending {
color: #9ca3af;
}
.install-section {
background: #111;
padding: 40px;
border-radius: 12px;
border: 1px solid #333;
margin-bottom: 60px;
}
.install-section h2 {
margin-bottom: 20px;
text-align: center;
}
.code-block {
background: #000;
padding: 20px;
border-radius: 8px;
font-family: "Courier New", monospace;
font-size: 1rem;
border: 1px solid #333;
overflow-x: auto;
margin: 15px 0;
}
.code-block p {
color: #00ff00;
}
.code-block p::before {
content: "$ ";
color: #888;
}
.code-block span::before {
content: "$ ";
color: #888;
}
.code-block span {
color: #777;
}
.code-block pre {
color: #00ff00;
}
.links {
margin-top: 30px;
}
.links h3 {
font-size: 1.3rem;
margin-bottom: 15px;
color: #ffffff;
}
.links ul {
list-style: none;
}
.links li {
margin-bottom: 8px;
}
.links a {
color: #cccccc;
text-decoration: none;
font-size: 1rem;
transition: color 0.3s ease;
}
.links a:hover {
color: #ffffff;
}
.links a:before {
content: "→ ";
margin-right: 8px;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
background: #1a4d1a;
color: #4ade80;
border-radius: 16px;
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 20px;
}
.footer {
text-align: center;
padding: 40px 0;
border-top: 1px solid #333;
margin-top: 80px;
color: #888;
}
@media (max-width: 768px) {
.hero h1 {
font-size: 2.5rem;
}
.hero p {
font-size: 1.1rem;
}
.buttons {
flex-direction: column;
align-items: center;
}
.btn {
width: 200px;
}
.section h2 {
font-size: 1.8rem;
}
.features {
grid-template-columns: 1fr;
}
.feature-grid {
grid-template-columns: 1fr;
}
.install-section,
.feature-list {
padding: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="hero">
<div class="status-badge">✨ Looking for feedback</div>
<h1>n34</h1>
<p>
n34 is an open source command-line interface (CLI) tool for
sending and receiving Git issues, patches and comments over the
Nostr protocol. It supports creating, replying to, and managing
issues and patches, making Git collaboration decentralized and
censorship-resistant.
</p>
<div class="buttons">
<a href="https://n34.dev/commands.html" class="btn">Documentation</a>
<a href="https://git.4rs.nl/awiteb/n34.git" class="btn">Git Repository</a>
</div>
</div>
<div class="section">
<h2>Key Features</h2>
<div class="features">
<div class="feature">
<h3><span class="feature-icon">🔄</span>Complete Git Workflow</h3>
<p>Handle the full development lifecycle with patches, issues, replies, and status tracking, all through the decentralized Nostr protocol.</p>
</div>
<div class="feature">
<h3><span class="feature-icon">🔗</span><a href="https://github.com/nostr-protocol/nips/blob/master/34.md" target="_blank" rel="noreferrer">NIP-34</a> Compliant</h3>
<p>Fully implements the <a href="https://github.com/nostr-protocol/nips/blob/master/34.md" target="_blank" rel="noreferrer">NIP-34</a> specification for Git repositories on Nostr, ensuring compatibility with the decentralized ecosystem.</p>
</div>
<div class="feature">
<h3><span class="feature-icon">🛠️</span>Developer Friendly</h3>
<p>Intuitive CLI interface designed for developers who want to integrate Git workflows with Nostr seamlessly.</p>
</div>
<div class="feature">
<h3><span class="feature-icon">🔐</span>Self-Sovereign</h3>
<p>No accounts, no passwords, no centralized servers. You control your identity and data through cryptographic keys.</p>
</div>
</div>
</div>
<div class="feature-list">
<h2>Feature Roadmap</h2>
<p style="text-align: center; margin-bottom: 30px; color: #cccccc;">Current implementation status and upcoming features</p>
<div class="feature-grid">
<div class="feature-column">
<ul>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Repository announcements</span>
</li>
<li>
<span class="feature-check pending"></span>
<span class="feature-text pending">Repository state announcements</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Patches (Send, fetch and list)</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Issues (Send, view and list)</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Replies</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Issues and patches status</span>
</li>
<li>
<span class="feature-check pending"></span>
<span class="feature-text pending">Pull requests (<a href="https://github.com/nostr-protocol/nips/pull/1966" target="_blank" rel="noreferrer">nostr-protocol/nips#1966</a>)</span>
</li>
</ul>
</div>
<div class="feature-column">
<ul>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Gossip Model (<a href="https://github.com/nostr-protocol/nips/blob/master/65.md" target="_blank" rel="noreferrer">NIP-65</a>)</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Proof of Work (<a href="https://github.com/nostr-protocol/nips/blob/master/13.md" target="_blank" rel="noreferrer">NIP-13</a>)</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">nostr: URI scheme (<a href="https://github.com/nostr-protocol/nips/blob/master/21.md" target="_blank" rel="noreferrer">NIP-21</a>)</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Signing using bunker (<a href="https://github.com/nostr-protocol/nips/blob/master/46.md" target="_blank" rel="noreferrer">NIP-46</a>)</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text pending">Signing using <a href="https://github.com/nostr-protocol/nips/blob/master/07.md" target="_blank" rel="noreferrer">NIP-07</a> proxy (<a href="https://crates.io/crates/nostr-browser-signer-proxy" target="_blank" rel="noreferrer">nostr-browser-signer-proxy</a>)</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Secret key keyring</span>
</li>
<li>
<span class="feature-check pending"></span>
<span class="feature-text pending">Code Snippets (<a href="https://github.com/nostr-protocol/nips/blob/master/C0.md" target="_blank" rel="noreferrer">NIP-C0</a>)</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">In device relays and repos bookmark</span>
</li>
</ul>
</div>
</div>
</div>
<div class="install-section">
<h2>Quick Start</h2>
<p style="text-align: center; margin-bottom: 20px; color: #cccccc;">Get started with n34 in seconds</p>
<div class="code-block">
<p>cargo install n34</p>
<span># The execautable will be in ~/.cargo/bin/n34</span>
</div>
<p style="text-align: center; margin: 20px 0; color: #888;">or</p>
<div class="code-block">
<p>git clone https://git.4rs.nl/awiteb/n34.git</p>
<p>cd n34 && cargo build --release</p>
<span># The execautable will be in target/release/n34</span>
</div>
<p style="text-align: center; margin: 20px 0; color: #888;">NixOS (Version 0.4.0 or later)</p>
<div class="code-block">
<p>git clone https://git.4rs.nl/awiteb/n34.git</p>
<p>cd n34 && nix build</p>
<span># The execautable will be in result/bin/n34</span>
</div>
<p style="text-align: center; margin: 20px 0; color: #888;">home-manager</p>
<p style="text-align: center; margin-bottom: 20px; color: #cccccc;">Add this to your <code>flake.nix</code> inputs
<br>
Specify the version you want to install, or remove <code>?ref</code> for the unreleased version. You can also use any mirror; it doesn't have to be <code>git.4rs.nl</code>
</p>
<div class="code-block">
<pre>
inputs = {
n34.url = "git+https://git.4rs.nl/awiteb/n34.git?ref=refs/tags/vx.y.x";
};</pre>
</div>
<p style="text-align: center; margin-bottom: 20px; color: #cccccc;">And this in your home packages</p>
<div class="code-block">
<pre>packages = [ inputs.n34.packages."${pkgs.system}".default ];</pre>
</div>
<p style="text-align: center; margin-top: 20px; color: #cccccc;">Once installed, run <code style="background: #333; padding: 2px 6px; border-radius: 4px;">n34 --help</code> to see available commands.</p>
</div>
<div class="section">
<h2>Why Nostr?</h2>
<p>
Nostr is fundamentally different from traditional platforms because it's not an application or service, it's a decentralized protocol. This means any tool or app can integrate with it, enabling open, permissionless collaboration
without relying on centralized gatekeepers. Unlike proprietary systems, Nostr doesn't require emails, passwords, or accounts. You interact directly through relays, whether you self-host your own or use public ones, ensuring no
single point of failure or control.
</p>
<p>
What makes Nostr uniquely resilient is its design, the protocol itself is just a set of rules, not a company or product that can disappear. Your Git issues, patches, and comments persist as long as relays choose to store them,
immune to the whims of corporate shutdowns or policy changes. Nostr is infrastructure in its purest form, an idea that outlives any temporary implementation. n34 taps into a future-proof foundation for decentralized collaboration.
</p>
<div class="links">
<h3>More about Nostr</h3>
<ul>
<li><a href="https://nostr.com">nostr.com</a></li>
<li><a href="https://nostr.org">nostr.org</a></li>
<li><a href="https://nostr.how/en/what-is-nostr">nostr.how/en/what-is-nostr</a></li>
</ul>
</div>
</div>
<div class="footer">
<p>Built with ❤️ for the decentralized future</p>
</div>
</div>
</body>
</html>

15
docs/issue/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Issue Management
Using `n34`, you can manage Git issues stored in Nostr relays, adhering to
the [NIP-34] standard. In Nostr, events are immutable, meaning their IDs are
derived from the SHA-256 hash of their timestamp, content, author, and tags.
As a result, issues cannot be edited directly. However, with `n34`, you can
create new issues, view existing ones, or update their status—such as closing,
resolving, or reopening them.
[NIP-34] introduces support for drafting issues, though this feature is not
currently implemented in `n34` due to the lack of a clear use case for drafting
issues. The inclusion of this functionality may stem from its shared use in both
issues and patches, suggesting it was primarily designed for patch management.
[NIP-34]: https://github.com/nostr-protocol/nips/blob/master/34.md

19
docs/issue/close.md Normal file
View File

@@ -0,0 +1,19 @@
# Closes an Open Issue
> `n34 issue close` command
**Usage:**
```
Closes an open issue
Usage: n34 issue close [OPTIONS] <ISSUE_ID>
Arguments:
<ISSUE_ID> The open issue id to close it
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
```
Issue a kind `1632` (Close status) for the specified issue. The issue have to
be open.

19
docs/issue/list.md Normal file
View File

@@ -0,0 +1,19 @@
# List Repositories Issues
> `n34 issue list` command
**Usage:**
```
List the repositories issues
Usage: n34 issue list [OPTIONS] [NADDR-NIP05-OR-SET]...
Arguments:
[NADDR-NIP05-OR-SET]... Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
Options:
--limit <LIMIT> Maximum number of issues to list [default: 15]
```
List the repositories issues. By default `n34` will look for `nostr-address`
file and extract the repositories from it.

29
docs/issue/new.md Normal file
View File

@@ -0,0 +1,29 @@
# Create an Issue
> `n34 issue new` Command
**Usage:**
```
Create a new repository issue
Usage: n34 issue new [OPTIONS] <--content <CONTENT>|--editor>
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
-c, --content <CONTENT> Markdown content for the issue. Cannot be used together with the `--editor` flag
-e, --editor Opens the user's default editor to write issue content. The first line will be used as the issue subject
--subject <SUBJECT> The issue subject. Cannot be used together with the `--editor` flag
-l, --label <LABEL> Labels for the issue. Can be specified as arguments (-l bug) or hashtags in content (#bug)
```
Use the `n34 issue new` command to create a new issue in a repository. This
command supports the [NIP-21] (`nostr:` URI scheme) and hashtags within the
issue content. When you mention public keys in the content, they will be
included in the event tags. Additionally, using hashtags like `#bug` in the
issue body will automatically apply them as labels.
You must choose between the `--content` and `--editor` options. With
`--content`, you provide the issue content directly in the command. With
`--editor`, your default `$EDITOR` will open, allowing you to write the issue
content. The first line of the editor's output will be used as the issue
subject.

19
docs/issue/reopen.md Normal file
View File

@@ -0,0 +1,19 @@
# Reopen a Closed Issue
> `n34 issue reopen` command
**Usage:**
```
Reopens a closed issue
Usage: n34 issue reopen [OPTIONS] <ISSUE_ID>
Arguments:
<ISSUE_ID> The ID of the closed issue to reopen
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
```
Issue a kind `1630` (Open status) for the specified issue. The issue have to
be closed.

18
docs/issue/resolve.md Normal file
View File

@@ -0,0 +1,18 @@
# Resolves an Issue
> `n34 issue resolve` command
**Usage:**
```
Resolves an issue
Usage: n34 issue resolve [OPTIONS] <ISSUE_ID>
Arguments:
<ISSUE_ID> The issue id to resolve it
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
```
Issue a kind `1631` (Resolved status) event for the specified issue.

19
docs/issue/view.md Normal file
View File

@@ -0,0 +1,19 @@
# View an Issue By ID
> `n34 issue view` command
**Usage:**
```
View an issue by its ID
Usage: n34 issue view [OPTIONS] <ISSUE_ID>
Arguments:
<ISSUE_ID> The issue id to view it
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
```
Simply provide the issue ID in `note` or `nevent` format to retrieve and display
the issue details.

21
docs/patch/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Patch Management
In `n34`, patch management is designed to give you complete control. You can
manually generate patch files using `git-format-patch` and then broadcast them
to Nostr relays. This ensures that you have full authority over the content
and structure of your patches, allowing for precise customization as per your
requirements.
Similarly, when fetching patches, `n34` provides them to you without
automatically applying, merging, or checking them. This empowers you to review
the patches at your own pace and decide whether to merge or apply them as
needed. You retain full control over the entire process, ensuring a tailored
approach to patch management.
## Patch Status Management
You can assign a status to original patches, but revision patches do not have
a specific status assigned to them. Instead, they inherit the status of the
original patch. However, if the original patch is marked as `Applied/Merged`,
the revision patch must be explicitly tagged to claim the same status. If not
tagged, the revision patch status will be `Closed`.

31
docs/patch/apply.md Normal file
View File

@@ -0,0 +1,31 @@
# Apply an Open Patch
> `n34 patch apply` command
**Usage:**
```
Set an open patch status to applied
Usage: n34 patch apply [OPTIONS] <PATCH_ID> [APPLIED_COMMITS]...
Arguments:
<PATCH_ID> The open patch id to apply it. Must be orignal root patch or revision root
[APPLIED_COMMITS]... The applied commits
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--patches <PATCH-EVENT-ID> Patches that have been applied. Use this when only some patches have been applied, not all
```
Creates a kind `1631` event (Applied/Merged status) for the specified patch. The
patch must be in open status.
You can specify either an original patch or revision patch ID, but the status
event will only reference the original patch. Revision patches will be mentioned
in the event.
The `APPLIED_COMMITS` field serves to inform clients about the status of
specific commits, whether they have been applied or not. If you need to retrieve
the list of commits from a specific point (such as the tip of the master branch)
up to the `HEAD`, you can use the following Git command: `git log --pretty=%H
'origin/master..HEAD'`.

20
docs/patch/close.md Normal file
View File

@@ -0,0 +1,20 @@
# Closes an Open or Drafted Patch
> `n34 patch close` command
**Usage:**
```
Closes an open or drafted patch
Usage: n34 patch close [OPTIONS] <PATCH_ID>
Arguments:
<PATCH_ID> The open/drafted patch id to close it. Must be orignal root patch
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
```
Issue a kind `1632` (Close status) for the specified patch. The patch have to
be open or drafted.

19
docs/patch/draft.md Normal file
View File

@@ -0,0 +1,19 @@
# Draft an Open Patch
> `n34 patch draft` command
**Usage:**
```
Converts an open patch to draft state
Usage: n34 patch draft [OPTIONS] <PATCH_ID>
Arguments:
<PATCH_ID> The open patch id to draft it. Must be orignal root patch
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
```
Issue a kind `1633` (Draft status) for the specified patch. The patch have to
be open.

21
docs/patch/fetch.md Normal file
View File

@@ -0,0 +1,21 @@
# Fetch a Patch By ID
> `n34 patch fetch` command
**Usage:**
```
Fetches a patch by its id
Usage: n34 patch fetch [OPTIONS] <PATCH_ID>
Arguments:
<PATCH_ID> The patch id to fetch it
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
-o, --output <PATH> Output directory for the patches. Default to the current directory
```
Fetches patches using their original patch ID. All fetched patches will be saved
to the specified output directory (current directory by default). You can then
apply or merge these patches into your branch as needed.

20
docs/patch/list.md Normal file
View File

@@ -0,0 +1,20 @@
# List Repositories Patches
> `n34 patch list` command
**Usage:**
```
List the repositories patches
Usage: n34 patch list [OPTIONS] [NADDR-NIP05-OR-SET]...
Arguments:
[NADDR-NIP05-OR-SET]... Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
Options:
--limit <LIMIT> Maximum number of patches to list [default: 15]
```
List the repositories patches. By default `n34` will look for `nostr-address`
file and extract the repositories from it.

29
docs/patch/merge.md Normal file
View File

@@ -0,0 +1,29 @@
# Merge an Open Patch
> `n34 patch merge` command
**Usage:**
```
Set an open patch status to merged
Usage: n34 patch merge [OPTIONS] <PATCH_ID> <MERGE_COMMIT>
Arguments:
<PATCH_ID> The open patch id to merge it. Must be orignal root patch or revision root
<MERGE_COMMIT> The merge commit id
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--patches <PATCH-EVENT-ID> Patches that have been merged. Use this when only some patches have been merged, not all
```
Creates a kind `1631` event (Applied/Merged status) for the specified patch. The
patch must be in open status.
You can specify either an original patch or revision patch ID, but the status
event will only reference the original patch. Revision patches will be mentioned
in the event.
You can get the `MERGE_COMMIT` commit using `git rev-parse HEAD` command if
the merge commit in the `HEAD` or use `HEAD~n` where the `n` is the number of
commits the merge commit before the HEAD

20
docs/patch/reopen.md Normal file
View File

@@ -0,0 +1,20 @@
# Reopens a Closed or Drafted Patch
> `n34 patch reopen` command
**Usage:**
```
Reopens a closed or drafted patch
Usage: n34 patch reopen [OPTIONS] <PATCH_ID>
Arguments:
<PATCH_ID> The closed/drafted patch id to reopen it. Must be orignal root patch
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
```
Issue a kind `1632` (Close status) for the specified patch. The patch have to
be closed or drafted.

22
docs/patch/send.md Normal file
View File

@@ -0,0 +1,22 @@
# Send Patches to a Repository
> `n34 patch send` command
**Usage:**
```
Send one or more patches to a repository
Usage: n34 patch send [OPTIONS] <PATCH-PATH>...
Arguments:
<PATCH-PATH>... List of patch files to send (space separated)
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--original-patch <EVENT-ID> Original patch ID if this is a revision of it
```
Send your generated patches to the repositories specified using the `--repo`
option or retrieved from the `nostr-address` file. When submitting a revision
of an existing patch, include the original patch ID to ensure its correctly
referenced in your revision patch event.

28
docs/reply.md Normal file
View File

@@ -0,0 +1,28 @@
# Reply to Issues and Patches
> `n34 reply` command
**Usage:**
```
Reply to issues and patches
Usage: n34 reply [OPTIONS] <--comment <COMMENT>|--editor> <nevent1-or-note1>
Arguments:
<nevent1-or-note1> The issue, patch, or comment to reply to
Options:
--quote-to Quote the replied-to event in the editor
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
-c, --comment <COMMENT> The comment (cannot be used with --editor)
-e, --editor Open editor to write comment (cannot be used with --content)
```
Craft replies ([NIP-22] Comment) to issues, patches, or comments with ease
using the `n34 reply` command. You can either input your reply directly with
the `--comment` option or open an editor for a more detailed response using
`--editor`. Additionally, when using `--editor`, the `--quote-to` option
allows you to include the original content in your editor, enabling precise and
context-aware replies.
[NIP-22]: https://github.com/nostr-protocol/nips/blob/master/22.md

4
docs/repo/README.md Normal file
View File

@@ -0,0 +1,4 @@
# Manage Repositories
In `n34` you can manage your repositories. This includes announcing new ones,
viewing existing ones, and announcing state updates (coming soon).

32
docs/repo/announce.md Normal file
View File

@@ -0,0 +1,32 @@
# Broadcast and Update a Git Repository
> `n34 repo announce` command
**Usage:**
```
Broadcast and update a git repository
Usage: n34 repo announce [OPTIONS] --id <REPO_ID>
Options:
--id <REPO_ID> Unique identifier for the repository in kebab-case
-n, --name <NAME> A name for the repository
-d, --description <DESCRIPTION> A description for the repository
-w, --web <WEB> Webpage URLs for the repository (if provided by the git server)
-c, --clone <CLONE> URLs for cloning the repository
-m, --maintainers <MAINTAINERS> Additional maintainers of the repository (besides yourself)
-l, --label <LABEL> Labels to categorize the repository. Can be specified multiple times
--force-id Skip kebab-case validation for the repository ID
--address-file If set, creates a `nostr-address` file to enable automatic address discovery by n34
```
This command generates an announcement event to publish your project. It can be
used to announce a new repository or update an existing one.
When updating, you must resubmit all repository fields, not just the fields
you wish to change. The command uses this information to build and publish a
completely new announcement event that will replace the old one.
It is recommended to use the `--address-file` flag. This option creates
a `nostr-address` file that enables `n34` to automatically discover the
repository's address, simplifying the workflow for contributors.

20
docs/repo/view.md Normal file
View File

@@ -0,0 +1,20 @@
# View Git Repository Details
> `n34 repo view` command
**Usage:**
```
View details of a nostr git repository
Usage: n34 repo view [NADDR-NIP05-OR-SET]...
Arguments:
[NADDR-NIP05-OR-SET]... Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
```
This command prints repository details to standard output. If no arguments
are provided, it looks for a `nostr-address` file in the current directory
and displays the details for the address specified within it. See [passing
repositories] for details on accepted formats.
[passing repositories]: /commands.html#passing-repositories

11
docs/sets/README.md Normal file
View File

@@ -0,0 +1,11 @@
# Managing Repository and Relay Sets
Sets are a convenience feature for contributing to projects that do not have a
`nostr-address` file. Instead of manually specifying the project's repositories
and relays for every command, you can define them once as a named "set". You can
then reference this set by its name in commands. This allows you to use the set
as a shortcut for a list of relays (`--relays <set_name>`) or as the project's
address in commands like `issue` and `patch`.
Sets are defined in your configuration file. To use a specific configuration
file, pass its path using the `--config` option.

25
docs/sets/new.md Normal file
View File

@@ -0,0 +1,25 @@
# Create a Set
> `n34 sets new` command
**Usage:**
```
Create a new set
Usage: n34 sets new [OPTIONS] <NAME>
Arguments:
<NAME> Unique name for the set
Options:
--set-relay <RELAYS> Optional relay to add it to the set, either as URL or set name to extract its relays. [aliases: `--sr`]
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
```
Each set requires a unique name, provided as the final argument to the command.
Use the `--set-relays`/`--sr` option to specify the relays for the new set;
this can be a relay URL or the name of an existing set whose relays you wish to
use. To add repositories, use the `--repo` option. Check [passing repositories]
format.
[passing repositories]: /commands.html#passing-repositories

24
docs/sets/remove.md Normal file
View File

@@ -0,0 +1,24 @@
# Remove a Set
> `n34 sets remove` command
**Usage:**
```
Remove a set, or specific repos and relays within it
Usage: n34 sets remove [OPTIONS] <NAME>
Arguments:
<NAME> Set name to delete
Options:
--set-relay <RELAYS> Specific relay to remove it from the set, either as URL or set name to extract its relays. [aliases: `--sr`]
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
```
Removes an entire set, or specific repositories and relays from it.
Without options, this command deletes the entire set.
See the [passing repositories] section for more details on supported formats.
[passing repositories]: /commands.html#passing-repositories

17
docs/sets/show.md Normal file
View File

@@ -0,0 +1,17 @@
# Show a Set
> `n34 sets show` command
**Usage:**
```
Show a single set or all the stored sets
Usage: n34 sets show [NAME]
Arguments:
[NAME] Name of the set to display. If not provided, lists all available sets
```
Prints all set names to standard output, each followed by its associated repos
and relays. To view the details for a specific set, provide its name as an
argument.

25
docs/sets/update.md Normal file
View File

@@ -0,0 +1,25 @@
# Modify a Set
> `n34 sets update` command
**Usage:**
```
Modify an existing set
Usage: n34 sets update [OPTIONS] <NAME>
Arguments:
<NAME> Name of the set to update
Options:
--set-relay <RELAYS> Add relay to the set, either as URL or set name to extract its relays. [aliases: `--sr`]
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--override Replace existing relays/repositories instead of adding to them
```
Use this command to update an existing set by its name. By default, providing
relays via `--set-relay` or repositories via `--repo` will add them to the set's
existing entries. To replace the current relays and repositories with the new
values, use the `--override` flag.
[passing repositories]: /commands.html#passing-repositories

12
flake.lock generated
View File

@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1751271578,
"narHash": "sha256-P/SQmKDu06x8yv7i0s8bvnnuJYkxVGBWLWHaU+tt4YY=",
"lastModified": 1754498491,
"narHash": "sha256-erbiH2agUTD0Z30xcVSFcDHzkRvkRXOQ3lb887bcVrs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3016b4b15d13f3089db8a41ef937b13a9e33a8df",
"rev": "c2ae88e026f9525daf89587f3cbee584b92b6134",
"type": "github"
},
"original": {
@@ -62,11 +62,11 @@
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1751683029,
"narHash": "sha256-dYO5X5jK8bpQOeRAo8R5aUt6M/+Ji1cZgstZI7SQ2IA=",
"lastModified": 1754575663,
"narHash": "sha256-afOx8AG0KYtw7mlt6s6ahBBy7eEHZwws3iCRoiuRQS4=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "9e5e62a33a929a67a5427fb7324a6f583dced0b2",
"rev": "6db0fb0e9cec2e9729dc52bf4898e6c135bb8a0f",
"type": "github"
},
"original": {

View File

@@ -23,19 +23,46 @@
devShells.default = mkShell {
packages = [
cargo-msrv
dbus
git-cliff
just
mdbook
nushell
pkg-config
taplo
];
nativeBuildInputs = [
(lib.hiPrio rust-bin.nightly."2025-07-05".rustfmt)
(lib.hiPrio rust-bin.nightly."2025-08-07".rustfmt)
rust-bin.stable.latest.default
rust-analyzer
];
buildInputs = [ ];
};
packages.default =
let
manifest = (pkgs.lib.importTOML ./Cargo.toml).package;
in
with pkgs;
rustPlatform.buildRustPackage {
pname = manifest.name;
version = manifest.version;
cargoLock.lockFile = ./Cargo.lock;
src = lib.cleanSource ./.;
nativeBuildInputs = [
pkg-config
];
buildInputs = [
dbus
];
meta = {
inherit (manifest) description homepage;
license = lib.licenses.gpl3Plus;
};
};
}
);
}

View File

@@ -4,15 +4,20 @@
# - Linux distribution
# - just (Of course) <https://github.com/casey/just>
# - cargo (For the build and tests) <https://doc.rust-lang.org/cargo/getting-started/installation.html>
# - mdbook (<https://rust-lang.github.io/mdBook>)
# - git-cliff (<https://git-cliff.org>)
# - taplo (<https://taplo.tamasfe.dev/>)
# - cargo-msrv (<https://github.com/foresterre/cargo-msrv>)
# - nushell (<https://nushell.sh>)
set quiet
set unstable
set shell := ["/usr/bin/env", "bash", "-c"]
set script-interpreter := ["/usr/bin/env", "bash"]
set script-interpreter := ["/usr/bin/env", "nu"]
JUST_EXECUTABLE := "just -u -f " + justfile()
header := "Available tasks:\n"
# Get the MSRV from the Cargo.toml
BOOK_DEST_DIR := "dest"
tag_change_body := '''{% for group, commits in commits | group_by(attribute="group") %}
{{ group | upper_first }}
@@ -32,7 +37,7 @@ ci: && msrv _done_ci
cargo build -q
echo "🔍 Checking code formatting..."
cargo fmt -q -- --check
RUST_LOG=none taplo fmt --check --config ./.taplo.toml || (echo "❌Toml files is not properly formatted" && exit 1)
RUST_LOG=none taplo fmt --check --config "./.taplo.toml" || (echo "❌Toml files is not properly formatted" && exit 1)
echo "🧹 Running linter checks..."
cargo clippy -q -- -D warnings
echo "🧪 Running tests..."
@@ -50,28 +55,50 @@ _done_ci:
# Update the changelog
[script]
changelog:
OLD_HASH=$(sha256sum CHANGELOG.md | head -c 64)
git-cliff > CHANGELOG.md
NEW_HASH=$(sha256sum CHANGELOG.md | head -c 64)
if [[ $OLD_HASH != $NEW_HASH ]]; then
git add CHANGELOG.md
git commit -m 'chore(changelog): Update the changelog'
echo 'The changes have been added to the changelog file and committed'
else
echo 'No changes have been added to the changelog'
fi
def get_hash [] { open "./CHANGELOG.md" | hash sha256 }
let old_hash = get_hash
git-cliff out> "CHANGELOG.md"
if old_hash != get_hash {
git add "CHANGELOG.md"
git commit -m "chore(changelog): Update the changelog"
print "The changes have been added to the changelog file and committed"
} else {
print "No changes have been added to the changelog"
}
# Releases a new version of n34. Requires a clean file tree with no uncommitted changes.
[script]
release version:
set -e
TAG_MSG="Version {{ version }}$(git-cliff --strip all --unreleased --body '{{ tag_change_body }}')"
sed -i "s/^version\s*= \".*\"/version = \"{{ version }}\"/" ./Cargo.toml
taplo fmt --config ./.taplo.toml ./Cargo.toml
let tag_msg = "Version {{ version }}" + (git-cliff --strip all --unreleased --body '{{ tag_change_body }}')
mut cargo_file = open "Cargo.toml"
$cargo_file.package.version = "{{ version }}"
$cargo_file | save -f "Cargo.toml"
RUST_LOG=none taplo fmt --config "./.taplo.toml"
{{ JUST_EXECUTABLE }} ci
git-cliff -t "v{{ version }}" > CHANGELOG.md
git-cliff -t "v{{ version }}" out> "./CHANGELOG.md"
git add .
git commit -m 'chore: Bump the version to `v{{ version }}`'
git tag -s -m "$TAG_MSG" "v{{ version }}"
git commit -m "chore: Bump the version to `v{{ version }}`"
git tag -s -m $tag_msg "v{{ version }}"
git push origin master --tags
cargo publish
# Deploy the book to Github Pages
deploy:
mdbook build --dest-dir {{ BOOK_DEST_DIR }}
cd {{ BOOK_DEST_DIR }}
git init .
git checkout -B gh-pages
touch ".nojekyll"
echo "n34.dev" > "CNAME"
git add .
git commit -m "Deploy the book to github pages"
git remote add origin "git@github.com:TheAwiteb/n34-book"
git push origin gh-pages -f
cd ..
rm -fr {{ BOOK_DEST_DIR }}

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>.
use clap::Args;
use nostr::nips::nip46::NostrConnectURI;
use crate::{
cli::{CliOptions, traits::CommandRunner},
error::{N34Error, N34Result},
};
#[derive(Args, Debug)]
pub struct BunkerArgs {
/// Nostr Connect URL for the bunker. Omit this to remove the current bunker
/// URL.
bunker_url: Option<NostrConnectURI>,
}
impl CommandRunner for BunkerArgs {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
if let Some(ref bunker_url) = self.bunker_url
&& !bunker_url.is_bunker()
{
return Err(N34Error::NotBunkerUrl);
}
options.config.bunker_url = self.bunker_url;
options.config.dump()
}
}

View File

@@ -0,0 +1,63 @@
// 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 crate::{
cli::{Cli, CliOptions, traits::CommandRunner},
error::N34Result,
};
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("options")
.required(true)
)
)]
pub struct KeyringArgs {
/// Turns on secret key keyring. Requires entering the key once when
/// enabled.
#[arg(long, group = "options")]
enable: bool,
/// Turns off secret key keyring. Removes any existing key and prevents
/// storing new ones.
#[arg(long, group = "options")]
disable: bool,
/// Deletes current key and stores the next provided key.
#[arg(long, group = "options")]
reset: bool,
}
impl CommandRunner for KeyringArgs {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
let keyring = nostr_keyring::NostrKeyring::new(Cli::N34_KEYRING_SERVICE_NAME);
if self.enable {
options.config.keyring_secret_key = true;
} else if self.disable {
options.config.keyring_secret_key = false;
}
if self.reset || self.disable {
let _ = keyring.delete(Cli::USER_KEY_PAIR_ENTRY);
}
options.config.dump()
}
}

View File

@@ -14,6 +14,12 @@
// 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>.
/// `config bunker` subcommand
mod bunker;
/// `config keyring` subcommand
mod keyring;
/// `config nip07` subcommand
mod nip07;
/// `config pow` subcommand
mod pow;
/// `config relays` subcommand
@@ -21,6 +27,9 @@ mod relays;
use clap::Subcommand;
use self::bunker::BunkerArgs;
use self::keyring::KeyringArgs;
use self::nip07::Nip07Args;
use self::pow::PowArgs;
use self::relays::RelaysArgs;
use super::CliOptions;
@@ -34,10 +43,18 @@ pub enum ConfigSubcommands {
/// Sets the default fallback relays if none provided. Use this relays for
/// read and write.
Relays(RelaysArgs),
/// Sets a URL of NIP-46 bunker server used for signing events.
Bunker(BunkerArgs),
/// Managing the secret key keyring, including enabling, disabling, or
/// resetting it.
Keyring(KeyringArgs),
/// Controls the NIP-07 browser signer proxy, turning it on or off, and
/// configures the `ip:port` address.
Nip07(Nip07Args),
}
impl CommandRunner for ConfigSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::run_command!(self, options, & Pow Relays)
crate::run_command!(self, options, & Pow Relays Bunker Keyring Nip07)
}
}

View File

@@ -0,0 +1,58 @@
// 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::net::SocketAddr;
use clap::{ArgGroup, Args};
use crate::{
cli::{CliOptions, options_state::DEFAULT_NIP07_PROXY_ADDR, traits::CommandRunner},
error::N34Result,
};
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("options")
.required(true)
)
)]
pub struct Nip07Args {
/// Enable NIP-07 as the default signer.
#[arg(long, group = "options")]
enable: bool,
/// Disable NIP-07 as the default signer.
#[arg(long, group = "options", group = "disable_options")]
disable: bool,
/// Set the default `ip:port` for the browser signer proxy.
#[arg(long, group = "disable_options")]
addr: Option<SocketAddr>,
}
impl CommandRunner for Nip07Args {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
if self.enable {
let addr = self.addr.unwrap_or(DEFAULT_NIP07_PROXY_ADDR);
options.config.nip07 = Some(addr)
} else {
options.config.nip07 = None
}
options.config.dump()
}
}

View File

@@ -54,7 +54,7 @@ pub enum IssueSubcommands {
Reopen(ReopenArgs),
/// Closes an open issue.
Close(CloseArgs),
/// Resolves an open issue.
/// Resolves an issue.
Resolve(ResolveArgs),
/// List the repositories issues.
List(ListArgs),

View File

@@ -22,7 +22,8 @@ use crate::{
cli::{
CliOptions,
CommandRunner,
types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::NaddrOrSet,
},
error::N34Result,
nostr_utils::{
@@ -101,7 +102,7 @@ impl CommandRunner for NewArgs {
)?)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let user_pubk = options.pubkey().await?;
let user_pubk = client.pubkey().await?;
let coordinates = naddrs.clone().into_coordinates();
client.add_relays(&naddrs.extract_relays()).await;
let repos = client.fetch_repos(coordinates.as_slice()).await?;

View File

@@ -34,7 +34,7 @@ pub struct ResolveArgs {
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open issue id to resolve it
/// The issue id to resolve it
issue_id: NostrEvent,
}

View File

@@ -20,8 +20,8 @@ use nostr::{event::Kind, filter::Filter};
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
nostr_utils::{

View File

@@ -28,9 +28,14 @@ pub mod repo;
pub mod sets;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use clap::{ArgGroup, Args, Parser};
use nostr::key::{Keys, PublicKey, SecretKey};
use clap::{Args, Parser};
use nostr::key::{Keys, SecretKey};
use nostr::nips::nip46::NostrConnectURI;
use nostr::signer::{IntoNostrSigner, NostrSigner};
use nostr_connect::client::NostrConnect;
use self::config::ConfigSubcommands;
use self::issue::IssueSubcommands;
@@ -39,8 +44,11 @@ use self::reply::ReplyArgs;
use self::repo::RepoSubcommands;
use self::sets::SetsSubcommands;
use super::CliConfig;
use super::options_state::OptionsState;
use super::types::RelayOrSet;
use super::{parsers, traits::CommandRunner};
use crate::cli::Cli;
use crate::cli::types::EchoAuthUrl;
use crate::error::{N34Error, N34Result};
/// Default path used when no path is provided via command line arguments.
@@ -48,19 +56,22 @@ use crate::error::{N34Error, N34Result};
/// This is a workaround since Clap doesn't support lazy evaluation of defaults.
pub const DEFAULT_FALLBACK_PATH: &str = "I_DO_NOT_KNOW_WHY_CLAP_DO_NOT_SUPPORT_LAZY_DEFAULT";
/// How long to wait for bunker response (3 minutes).
const BUNKER_TIMEOUT: Duration = Duration::from_secs(60 * 3);
/// The command-line interface options
#[derive(Args, Clone)]
#[clap(
group(
ArgGroup::new("signer")
.args(&["secret_key"])
.required(false)
)
)]
#[derive(Args)]
pub struct CliOptions {
/// Your Nostr secret key
#[arg(short, long)]
#[arg(short, long, group = "signer")]
pub secret_key: Option<SecretKey>,
/// NIP-46 bunker url used for signing events
#[arg(short, long, group = "signer", value_parser = parsers::parse_bunker_url)]
pub bunker_url: Option<NostrConnectURI>,
/// Enables signing events using the browser's NIP-07 extension. Listens on
/// `127.0.0.1:51034`.
#[arg(short = '7', long, group = "signer")]
pub nip07: bool,
/// Fallbacks relay to write and read from it. Multiple relays can be
/// passed.
#[arg(short, long)]
@@ -73,6 +84,10 @@ pub struct CliOptions {
hide_default_value = true, value_parser = parsers::parse_config_path
)]
pub config: CliConfig,
/// The state of options. Some values that are used by them but should not
/// be entered via the CLI
#[arg(skip)]
pub state: OptionsState,
}
/// N34 commands
@@ -109,24 +124,42 @@ pub enum Commands {
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!(
"This method should only be called when a signer is required. If this panic occurs, \
it indicates a bug where the command failed to properly require a signer (which is \
the default behavior)"
)
}
/// Returns the signer
pub fn signer(&self) -> Option<impl nostr::signer::IntoNostrSigner> {
if let Some(sk) = &self.secret_key {
return Some(Keys::new(sk.clone()));
pub async fn signer(&self) -> N34Result<Option<Arc<dyn NostrSigner + 'static>>> {
if self.nip07 {
self.state.browser_signer_proxy.start().await?;
println!(
"Browser signer proxy started at: {}",
self.state.browser_signer_proxy.url()
);
// FIXME: Use `BrowserSignerProxy::is_session_active` after it release
// nostr@0.44.0
tokio::time::sleep(Duration::from_secs(10)).await;
return Ok(Some(
self.state.browser_signer_proxy.clone().into_nostr_signer(),
));
}
None
if let Some(sk) = &self.secret_key {
return Ok(Some(Keys::new(sk.clone()).into_nostr_signer()));
}
if let Some(ref bunker_url) = self.bunker_url {
let mut nostrconnect = NostrConnect::new(
bunker_url.clone(),
Cli::n34_keypair()?,
BUNKER_TIMEOUT,
None,
)
.expect("It's a bunker url and not a client");
nostrconnect.auth_url_handler(EchoAuthUrl);
return Ok(Some(nostrconnect.into_nostr_signer()));
}
Ok(None)
}
/// Returns an error if there are no relays.
@@ -139,11 +172,11 @@ impl CliOptions {
/// Returns an error if there are no signers
pub fn ensure_signer(&self) -> N34Result<()> {
if self.secret_key.is_none() {
Err(N34Error::SignerRequired)
} else {
Ok(())
if !self.config.keyring_secret_key && self.secret_key.is_none() && self.bunker_url.is_none()
{
return Err(N34Error::SignerRequired);
}
Ok(())
}
}
@@ -151,6 +184,7 @@ 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("bunker_url", &self.bunker_url.as_ref().map(|_| "*******"))
.field("relays", &self.relays)
.field("pow", &self.pow)
.field("config", &self.config)

View File

@@ -21,7 +21,7 @@ use super::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
traits::{CommandRunner, VecNostrEventExt},
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
@@ -38,6 +38,10 @@ pub struct ApplyArgs {
/// The open patch id to apply it. Must be orignal root patch or
/// revision root
patch_id: NostrEvent,
/// Patches that have been applied. Use this when only some patches have
/// been applied, not all.
#[arg(long = "patches", value_name = "PATCH-EVENT-ID")]
applied_patches: Vec<NostrEvent>,
/// The applied commits
#[arg(num_args = 1..)]
applied_commits: Vec<Sha1Hash>,
@@ -51,6 +55,7 @@ impl CommandRunner for ApplyArgs {
self.naddrs,
PatchStatus::MergedApplied,
Some(either::Either::Right(self.applied_commits)),
self.applied_patches.into_event_ids(),
|patch_status| {
if patch_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(

View File

@@ -46,6 +46,7 @@ impl CommandRunner for CloseArgs {
self.naddrs,
PatchStatus::Closed,
None,
Vec::new(),
|patch_status| {
if patch_status.is_closed() {
return Err(N34Error::InvalidStatus(

View File

@@ -46,6 +46,7 @@ impl CommandRunner for DraftArgs {
self.naddrs,
PatchStatus::Draft,
None,
Vec::new(),
|patch_status| {
if patch_status.is_drafted() {
return Err(N34Error::InvalidStatus(

View File

@@ -30,8 +30,8 @@ use nostr::{
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
nostr_utils::{

View File

@@ -21,7 +21,7 @@ use super::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
traits::{CommandRunner, VecNostrEventExt},
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
@@ -34,12 +34,16 @@ pub struct MergeArgs {
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Option<Vec<NaddrOrSet>>,
naddrs: Option<Vec<NaddrOrSet>>,
/// The open patch id to merge it. Must be orignal root patch or
/// revision root
patch_id: NostrEvent,
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,
merge_commit: Sha1Hash,
}
impl CommandRunner for MergeArgs {
@@ -50,6 +54,7 @@ impl CommandRunner for MergeArgs {
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(

View File

@@ -30,6 +30,8 @@ mod merge;
mod reopen;
/// `patch send` subcommand
mod send;
#[cfg(test)]
mod tests;
use std::{
fmt,
@@ -66,6 +68,15 @@ 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.
@@ -74,7 +85,7 @@ pub enum PatchSubcommands {
Fetch(FetchArgs),
/// Closes an open or drafted patch.
Close(CloseArgs),
/// Converts the closed or open patch to draft state.
/// Converts an open patch to draft state.
Draft(DraftArgs),
/// Reopens a closed or drafted patch.
Reopen(ReopenArgs),
@@ -286,346 +297,3 @@ fn patch_file_name(subject: &str) -> N34Result<String> {
.trim()
.replace("--", "-"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn patch_normal() {
let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
From: Awiteb <a@4rs.nl>
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 <a@4rs.nl>
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 <a@4rs.nl>
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 <a@4rs.nl>
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 <a@4rs.nl>
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 <a@4rs.nl>
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 <a@4rs.nl>
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 <a@4rs.nl>
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());
}
}

View File

@@ -46,6 +46,7 @@ impl CommandRunner for ReopenArgs {
self.naddrs,
PatchStatus::Open,
None,
Vec::new(),
|patch_status| {
if patch_status.is_open() {
return Err(N34Error::InvalidStatus(

View File

@@ -30,8 +30,9 @@ use super::GitPatch;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
patch::{REVISION_ROOT_HASHTAG_CONTENT, ROOT_HASHTAG_CONTENT},
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, NostrEvent},
},
error::N34Result,
nostr_utils::{
@@ -72,8 +73,8 @@ impl CommandRunner for SendArgs {
let repo_coordinates = naddrs.clone().into_coordinates();
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let user_pubk = options.pubkey().await?;
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 {
@@ -241,14 +242,17 @@ async fn make_patch(
event_builder =
event_builder.tag(utils::event_reply_tag(&root_id, write_relay, Marker::Root));
} else {
event_builder = event_builder.tag(Tag::hashtag("root"));
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("root-revision"),
Tag::hashtag(REVISION_ROOT_HASHTAG_CONTENT),
]);
} else {
event_builder = event_builder.tag(utils::event_reply_tag(

View File

@@ -0,0 +1,351 @@
// 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 super::*;
#[test]
fn patch_normal() {
let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
From: Awiteb <a@4rs.nl>
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 <a@4rs.nl>
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 <a@4rs.nl>
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 <a@4rs.nl>
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 <a@4rs.nl>
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 <a@4rs.nl>
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 <a@4rs.nl>
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 <a@4rs.nl>
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());
}

View File

@@ -27,7 +27,10 @@ use nostr::{
use super::{CliOptions, CommandRunner};
use crate::{
cli::types::{NaddrOrSet, NostrEvent, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
cli::{
traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
nostr_utils::{
NostrClient,
@@ -78,8 +81,7 @@ impl CommandRunner for ReplyArgs {
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 = options.pubkey().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)

View File

@@ -21,7 +21,7 @@ use futures::future;
use nostr::{event::EventBuilder, key::PublicKey, types::Url};
use crate::{
cli::{CliOptions, CommandRunner, NOSTR_ADDRESS_FILE, types::RelayOrSetVecExt},
cli::{CliOptions, CommandRunner, NOSTR_ADDRESS_FILE, traits::RelayOrSetVecExt},
error::N34Result,
nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils},
};
@@ -83,7 +83,7 @@ impl CommandRunner for AnnounceArgs {
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 = options.pubkey().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()))

View File

@@ -30,9 +30,7 @@ use crate::error::N34Result;
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.
/// Broadcast and update a git repository
Announce(AnnounceArgs),
}

View File

@@ -23,7 +23,8 @@ use crate::{
cli::{
CliOptions,
CommandRunner,
types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::NaddrOrSet,
},
error::N34Result,
nostr_utils::{NostrClient, traits::NaddrsUtils, utils},

View File

@@ -20,9 +20,8 @@ use crate::{
cli::{
CliOptions,
ConfigError,
MutRepoRelaySetsExt,
traits::CommandRunner,
types::{NaddrOrSet, NaddrOrSetVecExt, RelayOrSet, RelayOrSetVecExt},
traits::{CommandRunner, MutRepoRelaySetsExt, NaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, RelayOrSet},
},
error::N34Result,
};

View File

@@ -19,9 +19,8 @@ use clap::Args;
use crate::{
cli::{
CliOptions,
MutRepoRelaySetsExt,
traits::CommandRunner,
types::{NaddrOrSet, NaddrOrSetVecExt, RelayOrSet, RelayOrSetVecExt},
traits::{CommandRunner, MutRepoRelaySetsExt, NaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, RelayOrSet},
},
error::N34Result,
};

View File

@@ -18,7 +18,11 @@ use clap::Args;
use nostr::nips::nip19::ToBech32;
use crate::{
cli::{CliOptions, RepoRelaySet, RepoRelaySetsExt, traits::CommandRunner},
cli::{
CliOptions,
RepoRelaySet,
traits::{CommandRunner, RepoRelaySetsExt},
},
error::N34Result,
};

View File

@@ -21,9 +21,8 @@ use clap::Args;
use crate::{
cli::{
CliOptions,
MutRepoRelaySetsExt,
traits::CommandRunner,
types::{NaddrOrSet, NaddrOrSetVecExt, RelayOrSet, RelayOrSetVecExt},
traits::{CommandRunner, MutRepoRelaySetsExt, NaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, RelayOrSet},
},
error::N34Result,
};

View File

@@ -23,6 +23,7 @@ use nostr::{
filter::Filter,
hashes::sha1::Hash as Sha1Hash,
nips::{nip10::Marker, nip19::ToBech32},
types::RelayUrl,
};
use super::{
@@ -31,7 +32,7 @@ use super::{
types::{NaddrOrSet, NostrEvent},
};
use crate::{
cli::types::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
cli::traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
nostr_utils::{NostrClient, traits::NaddrsUtils, utils},
};
use crate::{
@@ -49,13 +50,13 @@ pub async fn issue_status_command(
new_status: IssueStatus,
check_fn: impl FnOnce(&IssueStatus) -> N34Result<()>,
) -> N34Result<()> {
let user_pkey = options.pubkey().await?;
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;
@@ -97,10 +98,10 @@ pub async fn issue_status_command(
.map(|c| Tag::coordinate(c, relay_hint.clone())),
)
.dedup_tags()
.build(user_pkey);
.build(user_pubk);
let event_id = status_event.id.expect("There is an id");
let user_relays_list = client.user_relays_list(user_pkey).await?;
let user_relays_list = client.user_relays_list(user_pubk).await?;
let write_relays = [
relays,
naddrs.extract_relays(),
@@ -130,15 +131,16 @@ pub async fn patch_status_command(
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 user_pkey = options.pubkey().await?;
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;
@@ -209,28 +211,26 @@ pub async fn patch_status_command(
.tags(commits.into_iter().map(Tag::reference));
};
let root = if let Some(root_revision) = root_revision {
if let Some(root_revision) = root_revision {
status_builder = status_builder.tag(utils::event_reply_tag(
&root_revision,
relay_hint.as_ref(),
Marker::Reply,
));
root_revision
} else {
root_patch
};
let patches = client.fetch_patch_series(root, patch_event.pubkey).await?;
status_builder = status_builder.tags(
patches
.into_iter()
.map(|p| utils::event_reply_tag(&p.id, relay_hint.as_ref(), Marker::Mention)),
);
}
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_pkey);
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_pkey).await?;
let user_relays_list = client.user_relays_list(user_pubk).await?;
let write_relays = [
relays,
naddrs.extract_relays(),
@@ -279,7 +279,6 @@ pub async fn list_patches_and_issues(
.add_relays(&client.read_relays_from_users(&authorized_pubkeys).await)
.await;
let kind = if list_patches {
Kind::GitPatch
} else {
@@ -352,7 +351,6 @@ pub async fn list_patches_and_issues(
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>)> {
@@ -401,3 +399,40 @@ fn format_patch_and_issue(event: &Event, status: Either<PatchStatus, IssueStatus
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
}

View File

@@ -14,15 +14,17 @@
// 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, path::PathBuf};
use std::{collections::HashSet, fs, net::SocketAddr, path::PathBuf};
use nostr::{
nips::nip19::{FromBech32, Nip19Coordinate, ToBech32},
nips::{nip19::Nip19Coordinate, nip46::NostrConnectURI},
types::RelayUrl,
};
use serde::{Deserialize, Serialize, Serializer};
use crate::error::{N34Error, N34Result};
use crate::{
cli::traits::{MutRepoRelaySetsExt, RepoRelaySetsExt},
error::N34Result,
};
/// Errors that can occur when working with configuration files.
#[derive(thiserror::Error, Debug)]
@@ -53,16 +55,30 @@ pub enum ConfigError {
pub struct CliConfig {
/// Path to the configuration file (not serialized)
#[serde(skip)]
path: PathBuf,
path: PathBuf,
/// Groups of repositories and relays.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sets: Vec<RepoRelaySet>,
pub sets: Vec<RepoRelaySet>,
/// The default PoW difficulty
#[serde(skip_serializing_if = "Option::is_none")]
pub pow: Option<u8>,
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>>,
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.
@@ -74,8 +90,8 @@ pub struct RepoRelaySet {
#[serde(
default,
skip_serializing_if = "HashSet::is_empty",
serialize_with = "ser_naddrs",
deserialize_with = "de_naddrs"
serialize_with = "super::parsers::ser_naddrs",
deserialize_with = "super::parsers::de_naddrs"
)]
pub naddrs: HashSet<Nip19Coordinate>,
/// Relay URLs in this group.
@@ -152,163 +168,3 @@ impl RepoRelaySet {
self.naddrs.retain(|n| seen.insert(n.coordinate.clone()));
}
}
#[easy_ext::ext(MutRepoRelaySetsExt)]
impl Vec<RepoRelaySet> {
/// Removes duplicate repository addresses from each set.
///
/// Relays are automatically deduplicated by the HashSet, but
/// repository addresses may appear duplicated if relays are sorted
/// differently or when relay counts vary. This compares addresses by
/// their coordinates, ignoring any embedded relay details.
pub fn dedup_naddrs(&mut self) {
self.iter_mut().for_each(RepoRelaySet::dedup_naddrs);
}
/// Finds and returns a mutable reference a set with the given name. Returns
/// an error if no set with this name exists.
pub fn get_mut_set(&mut self, name: impl AsRef<str>) -> N34Result<&mut RepoRelaySet> {
let name = name.as_ref();
let set = self
.iter_mut()
.find(|set| set.name == name)
.ok_or_else(|| N34Error::from(ConfigError::SetNotFound(name.to_owned())))?;
tracing::trace!(
name = %name, set = ?set,
"Successfully located a set with the giving name"
);
Ok(set)
}
/// Creates and pushes a new set with the given name.
///
/// Returns an error if a set with the same name already exists.
pub fn push_set(
&mut self,
name: impl Into<String>,
repos: impl IntoIterator<Item = Nip19Coordinate>,
relays: impl IntoIterator<Item = RelayUrl>,
) -> N34Result<()> {
let set_name: String = name.into();
tracing::trace!(sets = ?self, "Pushing set '{set_name}' to sets collection");
if self.as_slice().exists(&set_name) {
return Err(ConfigError::SetDuplicateName(set_name).into());
}
self.push(RepoRelaySet::new(set_name, repos, relays));
Ok(())
}
/// Removes the set with the given name if it exists. Returns an error if
/// the set is not found.
pub fn remove_set(&mut self, name: impl Into<String>) -> N34Result<()> {
let set_name: String = name.into();
tracing::trace!(set_name, sets = ?self, "Removing set '{set_name}' from sets collection");
if !self.as_slice().exists(&set_name) {
return Err(ConfigError::SetNotFound(set_name).into());
}
self.retain(|s| s.name != set_name);
Ok(())
}
/// Removes the given relays from the specified set.
pub fn remove_relays(
&mut self,
name: impl Into<String>,
relays: impl Iterator<Item = RelayUrl>,
) -> N34Result<()> {
let relays = Vec::from_iter(relays);
let set = self.get_mut_set(name.into())?;
set.relays.retain(|r| !relays.contains(r));
Ok(())
}
/// Removes the given naddrs from the specified set.
pub fn remove_naddrs(
&mut self,
name: impl Into<String>,
naddrs: impl Iterator<Item = Nip19Coordinate>,
) -> N34Result<()> {
let coordinates = Vec::from_iter(naddrs.map(|n| n.coordinate));
let set = self.get_mut_set(name.into())?;
set.naddrs.retain(|n| !coordinates.contains(&n.coordinate));
Ok(())
}
}
#[easy_ext::ext(RepoRelaySetsExt)]
impl &[RepoRelaySet] {
/// Checks for duplicate set names. Returns an error if any duplicates are
/// found.
pub fn ensure_names(&self) -> N34Result<()> {
let mut names = Vec::with_capacity(self.len());
names.extend(self.iter().map(|s| s.name.to_owned()));
names.sort_unstable();
if let Some(duplicate) = duplicate_in_sorted(&names) {
return Err(ConfigError::SetDuplicateName(duplicate.clone()).into());
}
Ok(())
}
/// Check if a set with the given name exists.
pub fn exists(&self, set_name: &str) -> bool {
self.iter().any(|set| set.name == set_name)
}
/// Finds and returns a reference a set with the given name. Returns an
/// error if no set with this name exists.
pub fn get_set(&self, name: impl AsRef<str>) -> N34Result<&RepoRelaySet> {
let name = name.as_ref();
let set = self
.iter()
.find(|set| set.name == name)
.ok_or_else(|| N34Error::from(ConfigError::SetNotFound(name.to_owned())))?;
tracing::trace!(
name = %name, set = ?set,
"Successfully located a set with the giving name"
);
Ok(set)
}
}
/// Helper function that checks for duplicates in a sorted slice
fn duplicate_in_sorted<T: PartialEq + Clone>(items: &[T]) -> Option<&T> {
items.windows(2).find(|w| w[0] == w[1]).map(|w| &w[0])
}
fn ser_naddrs<S>(naddr: &HashSet<Nip19Coordinate>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let str_naddrs = naddr
.iter()
.map(|n| n.to_bech32().map_err(|err| err.to_string()))
.collect::<Result<Vec<_>, _>>()
.map_err(serde::ser::Error::custom)?;
str_naddrs.serialize(serializer)
}
fn de_naddrs<'de, D>(deserializer: D) -> Result<HashSet<Nip19Coordinate>, D::Error>
where
D: serde::Deserializer<'de>,
{
Vec::<String>::deserialize(deserializer)?
.into_iter()
.map(|naddr| Nip19Coordinate::from_bech32(&naddr))
.collect::<Result<HashSet<_>, _>>()
.map_err(serde::de::Error::custom)
}

View File

@@ -24,6 +24,8 @@ pub mod config;
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
@@ -31,14 +33,24 @@ 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 <a@4rs.nl>
@@ -51,6 +63,7 @@ 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";
@@ -71,10 +84,58 @@ pub struct Cli {
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
@@ -88,5 +149,30 @@ pub fn post_cli(mut cli: Cli) -> N34Result<Cli> {
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)
}

56
src/cli/options_state.rs Normal file
View File

@@ -0,0 +1,56 @@
// 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::{
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()),
)
}

View File

@@ -15,14 +15,19 @@
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::{
collections::HashSet,
fs,
path::{Path, PathBuf},
};
use nostr::{
Kind,
nips::nip19::{FromBech32, Nip19Coordinate},
nips::{
nip19::{FromBech32, Nip19Coordinate, ToBech32},
nip46::NostrConnectURI,
},
};
use serde::{Deserialize, Serialize, Serializer};
use super::CliConfig;
use crate::{
@@ -69,3 +74,61 @@ pub fn parse_config_path(config_path: &str) -> N34Result<CliConfig> {
CliConfig::load(path)
}
/// Parses a bunker URL and checks if it's a valid Nostr Connect URI.
/// Returns an error if the URL is not a valid bunker URL.
pub fn parse_bunker_url(bunker_url: &str) -> N34Result<NostrConnectURI> {
match NostrConnectURI::parse(bunker_url) {
Ok(url) if url.is_bunker() => Ok(url),
_ => Err(N34Error::NotBunkerUrl),
}
}
/// Serializes a set of NIP-19 coordinates as a list of bech32 strings.
pub fn ser_naddrs<S>(naddr: &HashSet<Nip19Coordinate>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let str_naddrs = naddr
.iter()
.map(|n| n.to_bech32().map_err(|err| err.to_string()))
.collect::<Result<Vec<_>, _>>()
.map_err(serde::ser::Error::custom)?;
str_naddrs.serialize(serializer)
}
/// Deserializes a list of bech32 strings into a set of NIP-19 coordinates.
pub fn de_naddrs<'de, D>(deserializer: D) -> Result<HashSet<Nip19Coordinate>, D::Error>
where
D: serde::Deserializer<'de>,
{
Vec::<String>::deserialize(deserializer)?
.into_iter()
.map(|naddr| Nip19Coordinate::from_bech32(&naddr))
.collect::<Result<HashSet<_>, _>>()
.map_err(serde::de::Error::custom)
}
pub fn ser_bunker_url<S>(
bunker_url: &Option<NostrConnectURI>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
bunker_url
.as_ref()
.map(|u| u.to_string())
.serialize(serializer)
}
pub fn de_bunker_url<'de, D>(deserializer: D) -> Result<Option<NostrConnectURI>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(deserializer)?
.map(|u| parse_bunker_url(&u))
.transpose()
.map_err(serde::de::Error::custom)
}

View File

@@ -14,8 +14,17 @@
// 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 nostr::{event::EventId, nips::nip19::Nip19Coordinate, types::RelayUrl};
use super::CliOptions;
use crate::error::N34Result;
use crate::{
cli::{
ConfigError,
RepoRelaySet,
types::{NaddrOrSet, NostrEvent, RelayOrSet},
},
error::{N34Error, N34Result},
};
/// A trait defining the interface for command runners in the CLI.
pub trait CommandRunner {
@@ -29,3 +38,189 @@ pub trait CommandRunner {
/// Executes the command and returns a Result indicating success or failure.
fn run(self, options: CliOptions) -> impl Future<Output = N34Result<()>> + Send;
}
#[easy_ext::ext(VecNostrEventExt)]
impl Vec<NostrEvent> {
/// Extracts `EventId` from each `NostrEvent` and collects them into a
/// `Vec<EventId>`.
pub fn into_event_ids(self) -> Vec<EventId> {
self.into_iter().map(|e| e.event_id).collect()
}
}
#[easy_ext::ext(NaddrOrSetVecExt)]
impl Vec<NaddrOrSet> {
/// Converts this vector of [`NaddrOrSet`] into a flat vector of
/// [`Nip19Coordinate`] using the given sets.
pub fn flat_naddrs(self, sets: &[RepoRelaySet]) -> N34Result<Vec<Nip19Coordinate>> {
self.into_iter()
.map(|n| n.get_naddrs(sets))
.try_fold(Vec::new(), |mut acc, item| {
acc.extend(item?);
Ok(acc)
})
}
}
#[easy_ext::ext(RelayOrSetVecExt)]
impl Vec<RelayOrSet> {
/// Converts this vector of [`RelayOrSet`] into a flat vector of
/// [`RelayUrl`] using the given sets.
pub fn flat_relays(self, sets: &[RepoRelaySet]) -> N34Result<Vec<RelayUrl>> {
self.into_iter()
.map(|n| n.get_relays(sets))
.try_fold(Vec::new(), |mut acc, item| {
acc.extend(item?);
Ok(acc)
})
}
}
#[easy_ext::ext(OptionNaddrOrSetVecExt)]
impl Option<Vec<NaddrOrSet>> {
/// Converts this vector of [`NaddrOrSet`] into a flat vector of
/// [`Nip19Coordinate`] using the given sets.
pub fn flat_naddrs(&self, sets: &[RepoRelaySet]) -> N34Result<Option<Vec<Nip19Coordinate>>> {
// Clones self here to simplify command code
self.clone()
.map(|naddrs| naddrs.flat_naddrs(sets))
.transpose()
}
}
#[easy_ext::ext(MutRepoRelaySetsExt)]
impl Vec<RepoRelaySet> {
/// Removes duplicate repository addresses from each set.
///
/// Relays are automatically deduplicated by the HashSet, but
/// repository addresses may appear duplicated if relays are sorted
/// differently or when relay counts vary. This compares addresses by
/// their coordinates, ignoring any embedded relay details.
pub fn dedup_naddrs(&mut self) {
self.iter_mut().for_each(RepoRelaySet::dedup_naddrs);
}
/// Finds and returns a mutable reference a set with the given name. Returns
/// an error if no set with this name exists.
pub fn get_mut_set(&mut self, name: impl AsRef<str>) -> N34Result<&mut RepoRelaySet> {
let name = name.as_ref();
let set = self
.iter_mut()
.find(|set| set.name == name)
.ok_or_else(|| N34Error::from(ConfigError::SetNotFound(name.to_owned())))?;
tracing::trace!(
name = %name, set = ?set,
"Successfully located a set with the giving name"
);
Ok(set)
}
/// Creates and pushes a new set with the given name.
///
/// Returns an error if a set with the same name already exists.
pub fn push_set(
&mut self,
name: impl Into<String>,
repos: impl IntoIterator<Item = Nip19Coordinate>,
relays: impl IntoIterator<Item = RelayUrl>,
) -> N34Result<()> {
let set_name: String = name.into();
tracing::trace!(sets = ?self, "Pushing set '{set_name}' to sets collection");
if self.as_slice().exists(&set_name) {
return Err(ConfigError::SetDuplicateName(set_name).into());
}
self.push(RepoRelaySet::new(set_name, repos, relays));
Ok(())
}
/// Removes the set with the given name if it exists. Returns an error if
/// the set is not found.
pub fn remove_set(&mut self, name: impl Into<String>) -> N34Result<()> {
let set_name: String = name.into();
tracing::trace!(set_name, sets = ?self, "Removing set '{set_name}' from sets collection");
if !self.as_slice().exists(&set_name) {
return Err(ConfigError::SetNotFound(set_name).into());
}
self.retain(|s| s.name != set_name);
Ok(())
}
/// Removes the given relays from the specified set.
pub fn remove_relays(
&mut self,
name: impl Into<String>,
relays: impl Iterator<Item = RelayUrl>,
) -> N34Result<()> {
let relays = Vec::from_iter(relays);
let set = self.get_mut_set(name.into())?;
set.relays.retain(|r| !relays.contains(r));
Ok(())
}
/// Removes the given naddrs from the specified set.
pub fn remove_naddrs(
&mut self,
name: impl Into<String>,
naddrs: impl Iterator<Item = Nip19Coordinate>,
) -> N34Result<()> {
let coordinates = Vec::from_iter(naddrs.map(|n| n.coordinate));
let set = self.get_mut_set(name.into())?;
set.naddrs.retain(|n| !coordinates.contains(&n.coordinate));
Ok(())
}
}
#[easy_ext::ext(RepoRelaySetsExt)]
impl &[RepoRelaySet] {
/// Checks for duplicate set names. Returns an error if any duplicates are
/// found.
pub fn ensure_names(&self) -> N34Result<()> {
let mut names = Vec::with_capacity(self.len());
names.extend(self.iter().map(|s| s.name.to_owned()));
names.sort_unstable();
if let Some(duplicate) = duplicate_in_sorted(&names) {
return Err(ConfigError::SetDuplicateName(duplicate.clone()).into());
}
Ok(())
}
/// Check if a set with the given name exists.
pub fn exists(&self, set_name: &str) -> bool {
self.iter().any(|set| set.name == set_name)
}
/// Finds and returns a reference a set with the given name. Returns an
/// error if no set with this name exists.
pub fn get_set(&self, name: impl AsRef<str>) -> N34Result<&RepoRelaySet> {
let name = name.as_ref();
let set = self
.iter()
.find(|set| set.name == name)
.ok_or_else(|| N34Error::from(ConfigError::SetNotFound(name.to_owned())))?;
tracing::trace!(
name = %name, set = ?set,
"Successfully located a set with the giving name"
);
Ok(set)
}
}
/// Helper function that checks for duplicates in a sorted slice
fn duplicate_in_sorted<T: PartialEq + Clone>(items: &[T]) -> Option<&T> {
items.windows(2).find(|w| w[0] == w[1]).map(|w| &w[0])
}

View File

@@ -19,17 +19,19 @@ use std::str::FromStr;
use nostr::{
event::{EventId, Kind},
nips::{
self,
nip01::Coordinate,
nip05::{Nip05Address, Nip05Profile},
nip19::{self, FromBech32, Nip19Coordinate},
},
types::RelayUrl,
util::BoxedFuture,
};
use nostr_connect::client::AuthUrlHandler;
use tokio::runtime::Handle;
use super::{RepoRelaySetsExt, parsers};
use super::parsers;
use crate::{
cli::RepoRelaySet,
cli::{RepoRelaySet, traits::RepoRelaySetsExt},
error::{N34Error, N34Result},
};
@@ -61,6 +63,21 @@ pub struct NostrEvent {
pub relays: Vec<RelayUrl>,
}
#[derive(Debug)]
pub struct EchoAuthUrl;
impl AuthUrlHandler for EchoAuthUrl {
fn on_auth_url(
&self,
auth_url: nostr::Url,
) -> BoxedFuture<'_, Result<(), Box<dyn std::error::Error>>> {
Box::pin(async move {
println!("The bunker requires authentication. Please open this URL: {auth_url}");
Ok(())
})
}
}
impl NaddrOrSet {
/// Returns the naddr if `Naddr` or try to get the relays from the set.
/// Returns error if the set naddrs are empty or the set not found.
@@ -174,63 +191,28 @@ impl FromStr for NostrEvent {
}
}
#[easy_ext::ext(NaddrOrSetVecExt)]
impl Vec<NaddrOrSet> {
/// Converts this vector of [`NaddrOrSet`] into a flat vector of
/// [`Nip19Coordinate`] using the given sets.
pub fn flat_naddrs(self, sets: &[RepoRelaySet]) -> N34Result<Vec<Nip19Coordinate>> {
self.into_iter()
.map(|n| n.get_naddrs(sets))
.try_fold(Vec::new(), |mut acc, item| {
acc.extend(item?);
Ok(acc)
})
}
}
#[easy_ext::ext(RelayOrSetVecExt)]
impl Vec<RelayOrSet> {
/// Converts this vector of [`RelayOrSet`] into a flat vector of
/// [`RelayUrl`] using the given sets.
pub fn flat_relays(self, sets: &[RepoRelaySet]) -> N34Result<Vec<RelayUrl>> {
self.into_iter()
.map(|n| n.get_relays(sets))
.try_fold(Vec::new(), |mut acc, item| {
acc.extend(item?);
Ok(acc)
})
}
}
#[easy_ext::ext(OptionNaddrOrSetVecExt)]
impl Option<Vec<NaddrOrSet>> {
/// Converts this vector of [`NaddrOrSet`] into a flat vector of
/// [`Nip19Coordinate`] using the given sets.
pub fn flat_naddrs(&self, sets: &[RepoRelaySet]) -> N34Result<Option<Vec<Nip19Coordinate>>> {
// Clones self here to simplify command code
self.clone()
.map(|naddrs| naddrs.flat_naddrs(sets))
.transpose()
}
}
fn parse_nip5_repo(nip5: &str, repo_id: &str) -> Result<NaddrOrSet, String> {
let (username, domain) = nip5.split_once("@").unwrap_or(("_", nip5));
let nip5_profile = tokio::task::block_in_place(|| {
let nip5_address =
Nip05Address::parse(&format!("{username}@{domain}")).map_err(|err| err.to_string())?;
let nip5_json = tokio::task::block_in_place(|| {
Handle::current().block_on(async {
nips::nip05::profile(format!("{username}@{domain}"), None)
reqwest::get(nip5_address.url().as_str())
.await
.map_err(|err| err.to_string())?
.text()
.await
.map_err(|err| err.to_string())
})
})?;
Ok(NaddrOrSet::Naddr(
Nip19Coordinate::new(
Coordinate::new(Kind::GitRepoAnnouncement, nip5_profile.public_key).identifier(repo_id),
nip5_profile.relays,
)
.expect("The relays is `RelayUrl`"),
))
let nip5_profile =
Nip05Profile::from_raw_json(&nip5_address, &nip5_json).map_err(|err| err.to_string())?;
Ok(NaddrOrSet::Naddr(Nip19Coordinate::new(
Coordinate::new(Kind::GitRepoAnnouncement, nip5_profile.public_key).identifier(repo_id),
nip5_profile.relays,
)))
}

View File

@@ -14,9 +14,12 @@
// 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::process::ExitCode;
use std::{net::AddrParseError, process::ExitCode};
use nostr::event::{Kind, builder::Error as EventBuilderError};
use nostr::{
event::{Kind, builder::Error as EventBuilderError},
signer::SignerError,
};
use nostr_sdk::client::Error as ClientError;
use crate::cli::ConfigError;
@@ -42,6 +45,14 @@ pub type N34Result<T> = Result<T, N34Error>;
pub enum N34Error {
#[error("IO: {0}")]
Io(#[from] std::io::Error),
#[error("Signer Error: {0}")]
SignerError(#[from] SignerError),
#[error("Invalid Browser Signer Proxy Address: {0}")]
Addr(#[from] AddrParseError),
#[error("Browser Signer Proxy Error: {0}")]
BrowserSignerProxy(#[from] nostr_browser_signer_proxy::Error),
#[error("Keyring error: {0}")]
Keyring(#[from] nostr_keyring::Error),
#[error("{0}")]
Config(#[from] ConfigError),
#[error("No editor specified in the `EDITOR` environment variable")]
@@ -85,7 +96,8 @@ pub enum N34Error {
#[error("One naddr is required for this command")]
EmptyNaddrs,
#[error(
"This command requires a signer to sign events. Use `--secret-key` to provide a signer"
"This command requires a signer to sign events. Use `--secret-key`, `--nip07` or \
`--bunker-url` to provide a signer"
)]
SignerRequired,
#[error(
@@ -124,6 +136,13 @@ pub enum N34Error {
RevisionRootNotFound,
#[error("Invalid status for the issue/patch: {0}")]
InvalidStatus(String),
#[error("Not valid bunker URL")]
NotBunkerUrl,
#[error(
"No secret key found in the keyring. Please use the secret key at least once while \
keyring is enabled to store it"
)]
SecretKeyKeyringWithoutEntry,
}
impl N34Error {

View File

@@ -35,7 +35,7 @@ use nostr::{
parser::NostrParser,
types::RelayUrl,
};
use nostr_sdk::Client;
use nostr_sdk::{Client, ClientOptions};
use traits::TokenUtils;
use crate::{
@@ -62,9 +62,10 @@ pub struct ContentDetails {
}
/// A client for interacting with the Nostr relays
#[derive(Clone)]
pub struct NostrClient {
/// The underlying Nostr client implementation
client: Client,
pub client: Client,
}
impl ContentDetails {
@@ -110,9 +111,10 @@ impl NostrClient {
/// Initializes a new [`NostrClient`] instance and connects to the specified
/// relays.
pub async fn init(options: &CliOptions, relays: &[RelayUrl]) -> Self {
let mut client_builder = Client::builder();
let mut client_builder =
Client::builder().opts(ClientOptions::new().verify_subscriptions(true));
if let Some(signer) = options.signer() {
if let Ok(Some(signer)) = options.signer().await {
client_builder = client_builder.signer(signer);
}
@@ -122,6 +124,16 @@ impl NostrClient {
client
}
//// Returns the users public key
pub async fn pubkey(&self) -> N34Result<PublicKey> {
self.client
.signer()
.await?
.get_public_key()
.await
.map_err(N34Error::SignerError)
}
/// Add relays and connect to them
pub async fn add_relays(&self, relays: &[RelayUrl]) {
if relays.is_empty() {
@@ -323,7 +335,6 @@ impl NostrClient {
return Ok(PatchStatus::Closed);
}
Ok(root_status)
}

View File

@@ -28,8 +28,14 @@ use nostr::{
parser::Token,
types::{RelayUrl, Url},
};
use nostr_keyring::KeyringError;
use crate::cli::issue::ISSUE_ALT_PREFIX;
use crate::cli::patch::{
LEGACY_NGIT_REVISION_ROOT_HASHTAG_CONTENT,
REVISION_ROOT_HASHTAG_CONTENT,
ROOT_HASHTAG_CONTENT,
};
use crate::error::{N34Error, N34Result};
@@ -245,17 +251,20 @@ impl Event {
&& self
.tags
.filter(TagKind::t())
.any(|t| t.content() == Some("root"))
.any(|t| t.content() == Some(ROOT_HASHTAG_CONTENT))
}
/// Returns whether the patch is patch-revision or not
#[inline]
pub fn is_revision_patch(&self) -> bool {
self.kind == Kind::GitPatch
&& self
.tags
.filter(TagKind::t())
.any(|t| t.content() == Some("root-revision"))
&& self.tags.filter(TagKind::t()).any(|t| {
[
Some(REVISION_ROOT_HASHTAG_CONTENT),
Some(LEGACY_NGIT_REVISION_ROOT_HASHTAG_CONTENT),
]
.contains(&t.content())
})
}
/// Gets the root patch ID from a patch-revision event by finding the `e`
@@ -300,3 +309,12 @@ impl Event {
.join(", ")
}
}
#[easy_ext::ext(NostrKeyringErrorUtils)]
impl nostr_keyring::Error {
/// Checks if the error indicates a missing keyring entry.
#[inline]
pub fn is_keyring_no_entry(&self) -> bool {
matches!(self, nostr_keyring::Error::Keyring(KeyringError::NoEntry))
}
}

View File

@@ -151,9 +151,8 @@ pub fn repo_naddr(
) -> N34Result<String> {
Nip19Coordinate::new(
Coordinate::new(Kind::GitRepoAnnouncement, pubk).identifier(repo_id),
dedup(relays.iter().cloned()).iter().take(3),
dedup(relays.iter().cloned()).into_iter().take(3),
)
.expect("Valid relays")
.to_bech32()
.map_err(N34Error::from)
}