88 Commits

Author SHA1 Message Date
Awiteb
ce0068be53 chore: Bump the version to v0.2.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-01 12:32:24 +00:00
Awiteb
4c6578ccb1 feat: Support relays and naddrs sets
This commit introduces support for 'sets', a feature that simplifies
referencing multiple relays or naddrs with a single word.

Sets are stored in the config directory under `n34` as `config.toml` in
`TOML` format. The config path follows the `$XDG_CONFIG_HOME` standard,
defaulting to `$HOME/.config` if unset.

The `n34 sets` command allows managing sets, adding, removing, updating,
or displaying them. When using the main `--relays` flag, a set name can
be provided to automatically expand its relays (an error occurs if the
set contains no relays). Similarly, any command accepting naddrs can
reference a set to extract its naddrs.

Sets can also be merged by updating a set while specifying another
set in `--repo` merges their naddrs, while `--set-relays` merges their
relays. Removal follows the same pattern.

Signed-off-by: Awiteb <a@4rs.nl>
2025-06-01 12:12:09 +00:00
Awiteb
d6d3f8a52f chore: Add more relays to the repo address
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-01 11:59:49 +00:00
Awiteb
84bfafa2e9 deps: Add serde@1.0.219, dirs@6.0.0 and toml@0.8.22
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-01 11:59:12 +00:00
Awiteb
24e8522268 chore: Move the write relays definition to the end of the commands
To make it clear, we can review exactly what was collected instead of
having it spread throughout the body of the command.

Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 19:20:42 +00:00
Awiteb
f670859b92 chore: Remove the vector parameter from add_{read,write}_relays
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 12:35:26 +00:00
Awiteb
bca86f115f perf(NostrClient::send_event_to): Join the event sending
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 12:00:10 +00:00
Awiteb
a9b9a7a02a chore: Remove unused import
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 11:04:58 +00:00
Awiteb
1abb8e3f36 feat: Events and naddrs can starts with nostr:
Suggested-by: DanConwayDev <DanConwayDev@protonmail.com>
Fixes: note1jwgfl095vuhduepxxpwhdqme0dnlhawzvcal0xnl0e6d82wx5jys5z92hj
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 10:48:33 +00:00
Awiteb
15d834dc0f chore: Deduplicate tags
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 08:45:20 +00:00
Awiteb
ea862dac5f chore: Add a newline after the naddr
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 08:29:36 +00:00
Awiteb
55a4868813 fix(utils-repo_naddr): Create a valid naddr string
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 08:29:13 +00:00
Awiteb
60978f43b5 chore: Add a header to nostr-address file
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 08:02:37 +00:00
Awiteb
37cf601c01 refactor: Support more than one naddr instead of one
This commit supports multiple naddrs for sending issues to and searching
for repositories by extracting the embedded relays, and appending to the
address file instead of overwriting its previous contents. A header has
also been added to the `nostr-address` file to clarify its purpose.

This enhancement accommodates maintainers who distribute issue tracking
across multiple repositories, preventing confusion if the primary
maintainer steps down. Now, issues and patches will reference all
relevant repositories, not just one.

Note that repositories won't automatically include all maintainers'
addresses unless explicitly specified. Maintainers may manage a
repository under a project-specific public key rather than individual
keys. Addresses are only added to issues and patches when explicitly
provided via the command or written to the address file. This prevents
unintended inclusions and ensures clarity.

Suggested-by: DanConwayDev <DanConwayDev@protonmail.com>
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 07:05:13 +00:00
Awiteb
87da5035da chore(todo): Some tasks
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 14:08:25 +00:00
Awiteb
ddea5028ee feat: Make the relays list optional
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 12:30:40 +00:00
Awiteb
de68d61c3f feat: Enter repository as nip5
For example, `4rs.nl/n34` and `_@4rs.nl/n34`

Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 11:00:37 +00:00
Awiteb
f7e837e3ab deps: Enable nip05 feature of nostr crate
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 10:57:15 +00:00
Awiteb
02070c2868 feat: A --quote-to flag to quote the replied to content in the editor
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 06:06:32 +00:00
Awiteb
998ef8f4b1 deps: Add chrono@0.4.41 to the dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 06:03:17 +00:00
Awiteb
6fdf0dbcf5 feat: Read the nostr-address file in reply command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-23 13:38:21 +00:00
Awiteb
15c0eaa5dc chore: Make commands module
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-23 13:15:37 +00:00
Awiteb
226909ef16 feat: Read the nostr-address file in issue new command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-23 12:38:26 +00:00
Awiteb
8ca88804dd feat: Read the nostr-address file in repo view command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-23 12:36:48 +00:00
Awiteb
55f9b9194e chore: Utils to work with the nostr-address file
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 20:45:01 +00:00
Awiteb
a26116702d chore: Errors to work with the nostr-address file
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 20:44:41 +00:00
Awiteb
35d376ca6c chore: Add nostr-address file
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 19:19:27 +00:00
Awiteb
57b48c7368 feat: Add --address-file flag to repo announce command
This flag creates a file in the current working directory and writes
the `naddr` into it. This allows n34 and other tools to recognize the
repository address automatically, eliminating the need for manual input
by the user.

Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 19:17:07 +00:00
Awiteb
b29937a04e chore(changelog): Update the body
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 15:15:49 +00:00
Awiteb
06374fe1fc feat: add --force-id flag to bypass case validation in repo announce
The new flag allows users to skip the kebkab case ID check when announcing
repositories.

Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 08:27:13 +00:00
Awiteb
2d5c0bf549 chore(git-cliff): Upper first after the trim
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 08:27:13 +00:00
Awiteb
0a5b4656c3 chore(taplo): Exclude target dir
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 08:27:13 +00:00
Awiteb
cad2c7faa3 chore: Update CONTRIBUTING.md
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 08:27:13 +00:00
Awiteb
19cfecb9ea chore(readme): Add Contributions & Changes section
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 06:11:28 +00:00
Awiteb
89d0710ce5 chore(justfile): Add a subject to the tag message
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 23:57:16 +00:00
Awiteb
c1afa9a93d chore: Bump the version to v0.1.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 22:41:37 +00:00
Awiteb
92165de6fd chore: Improve the changelog
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 22:07:24 +00:00
Awiteb
c5085b3af7 chore: Update the bugs report location
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 22:07:24 +00:00
Awiteb
ecee54c9a3 chore: Update README.md
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 22:07:23 +00:00
Awiteb
55aa48c1fe chore: Add CONTRIBUTING.md, AUTHORS and DCO files
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
0cc656b780 chore: Update the repository url
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
8bff000b33 chore: Format rustfmt.toml
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
5c201b0cec chore(justfile): A new recipe to release n34
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
c608f2120b chore: Add taplo to the packages
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
60ad1fe5bb chore: Improve issue new command code
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
b3b7ce7a4b chore(repo-announce): Ensure that the event author is not a duplicate maintainer
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
a137a3f9de chore: Rename nostr::nips::nip22::Comment to CommentTarget
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
724e2707c6 deps: Bump nostr and nostr_sdk to 0.42.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
9444fc1a8c feat: New reply command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:05 +00:00
Awiteb
a324ba2ff7 chore: Add comments helper functions
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:05 +00:00
Awiteb
56316d36d3 chore: Add events errors
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:05 +00:00
Awiteb
074245733e chore(nostr): A util to get the content from a string or editor
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:05 +00:00
Awiteb
661266c67b chore(nostr): A function to add user read relays to a vector
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:05 +00:00
Awiteb
a9b088d8a5 chore(NostrClient-add_relays): If the relays is empty do nothing
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:44:51 +00:00
Awiteb
1af731ef67 chore: Improve event fetchers functions
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-19 20:26:29 +00:00
Awiteb
4298ca5d2d chore: Use Coordinate instead of Nip19Coordinate in NostrClient::fetch_repo
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-19 20:26:29 +00:00
Awiteb
70ee4a59b2 perf: Connect to relays in a separate task
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 14:25:09 +00:00
Awiteb
bd086538fd deps: Add futures@0.3.31 to the dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 13:32:44 +00:00
Awiteb
54f1c7e0e6 feat: Add issue new command
Command to create a new issue on a repo

Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 13:03:09 +00:00
Awiteb
f1378a2eb7 chore: More errors for issue subcommand
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 13:02:04 +00:00
Awiteb
2627fe7980 chore: Function to returns the event author if found
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 13:01:20 +00:00
Awiteb
42508fc8b9 chore: Returns the relays that successfully received the event on NostrClient::send_event_to
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 13:01:05 +00:00
Awiteb
c69fbca2a0 chore(justfile): Add some fancy emojis
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 12:56:43 +00:00
Awiteb
552136400d chore: Improve the MSRV check
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 12:40:01 +00:00
Awiteb
ac7c1e6216 chore: Add MSRV
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 12:32:50 +00:00
Awiteb
5139f8ea11 chore: Improve logging
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 12:27:13 +00:00
Awiteb
6aac4b7a85 chore: Rename --labels flag of repo announce command to --label
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 12:25:06 +00:00
Awiteb
ce58f29022 deps: Add tempfile@3.19.1 to the dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 12:23:10 +00:00
Awiteb
d9fe376746 chore: Sort the relays then take 3 from them
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-07 20:20:36 +00:00
Awiteb
d9f8ce2418 chore: Util to add write relays to a vector
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-07 06:12:17 +00:00
Awiteb
577de41054 chore(repo-announce): Utils to creates the bech32
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-07 06:04:28 +00:00
Awiteb
3857753b7d chore: Log the event result inside send_event_to function
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-07 05:42:07 +00:00
Awiteb
93c8743dc7 chore: Add the relays for read and write
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-06 12:24:33 +00:00
Awiteb
c0a5e47793 feat(nip13): Support PoW
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-06 11:36:56 +00:00
Awiteb
a56acf5752 chore: Trim the strings in repo announce command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-06 11:30:27 +00:00
Awiteb
9b4dfb9374 chore: Returns Result public key in CliOptions::pubkey
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-06 11:22:46 +00:00
Awiteb
6fc59bfe59 chore: Send the repo announce to the author relays list
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-06 11:20:41 +00:00
Awiteb
b444aeba0a feat: Add repo announce command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-06 08:10:06 +00:00
Awiteb
273058fe3f chore: A method to send an event builder
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-05 19:56:41 +00:00
Awiteb
4dea4785b4 chore: A method to return the public key in CliOptions
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-05 19:56:41 +00:00
Awiteb
4314cebfbb chore: Take the ownership in the CommandRunner trait
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-05 19:56:41 +00:00
Awiteb
8a553f4de8 deps: Add convert_case@0.8.0 to the dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-05 19:56:41 +00:00
Awiteb
57434ac39b chore: Handle euc in the repo view command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-05 19:56:41 +00:00
Awiteb
99e46d7633 chore: Add a extension trait for Tags type
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-05 19:56:40 +00:00
Awiteb
fb77a8ca14 deps: Add easy_ext@1.0.2 to the dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-05 13:52:00 +00:00
Awiteb
d962732e52 feat: Setup the CLI and create repo view command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-03 15:58:45 +00:00
Awiteb
654aee620e chore: Add .taplo.toml file
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-03 15:23:41 +00:00
Awiteb
d37c6964f7 deps: Add nedded dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-03 15:22:46 +00:00
36 changed files with 5979 additions and 62 deletions

11
.taplo.toml Normal file
View File

@@ -0,0 +1,11 @@
exclude = ["target/**/*.toml", "Cargo.lock"]
[formatting]
align_entries = true
indent_tables = true
reorder_keys = false
[[rule]]
include = ["**/Cargo.toml"]
keys = ["dependencies"]
formatting.reorder_keys = true

1
AUTHORS Normal file
View File

@@ -0,0 +1 @@
Awiteb <a@4rs.nl> (nprofile1qqsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgpzfmhxue69uhkummnw3ezudrjwvhxumq3dg0ly)

View File

@@ -5,11 +5,62 @@ 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.2.0] - 2025-06-01
### Added
- Add `--force-id` flag to bypass case validation in `repo announce` - by Awiteb
- Add `--address-file` flag to `repo announce` command - by Awiteb
- Read the `nostr-address` file in `repo view` command - by Awiteb
- Read the `nostr-address` file in `issue new` command - by Awiteb
- Read the `nostr-address` file in `reply` command - by Awiteb
- A `--quote-to` flag to quote the replied to content in the editor - by Awiteb
- Enter repository as nip5 - by Awiteb
- Make the relays list optional - by Awiteb
- Events and naddrs can starts with `nostr:` - by Awiteb
- Support relays and naddrs sets - by Awiteb
### Dependencies
- Add `chrono@0.4.41` to the dependencies - by Awiteb
- Enable `nip05` feature of `nostr` crate - by Awiteb
- Add `serde@1.0.219`, `dirs@6.0.0` and `toml@0.8.22` - by Awiteb
### Fixed
- Create a valid naddr string - by Awiteb
### Refactor
- Support more than one naddr instead of one - by Awiteb
## [0.1.0] - 2025-05-21
### Added
- Setup the CLI and create `repo view` command - by Awiteb
- Add `repo announce` command - by Awiteb
- Support PoW - by Awiteb
- Add `issue new` command - by Awiteb
- New `reply` command - by Awiteb
### Dependencies
- Add nedded dependencies - by Awiteb
- Add `easy_ext@1.0.2` to the dependencies - by Awiteb
- Add `convert_case@0.8.0` to the dependencies - by Awiteb
- Add `tempfile@3.19.1` to the dependencies - by Awiteb
- Add `futures@0.3.31` to the dependencies - by Awiteb
- Bump `nostr` and `nostr_sdk` to `0.42.0` - by Awiteb
## [0.0.0] - 2025-05-01
### Added
- Feat: Initialize the project
- Initialize the project - by Awiteb
[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
[0.0.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.0.0
<!-- generated by git-cliff -->

104
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,104 @@
# Contributing to `n34`
For basic information about the `n34` project, please read the
[README.md](README.md). The project is licensed under **GPL-3.0**, and by
contributing, your work will also be licensed under the same terms.
Before submitting changes, please read the [Developer Certificate of Origin](DCO).
All patches must include a `Signed-off-by: NAME <EMAIL>` line to acknowledge
your agreement with the DCO.
Ensure your Git name and email are correctly configured. While you dont need to
use your real details, avoid leaving them as the default values. To verify your
current settings, run:
```bash
git config user.name
git config user.email
```
If theyre incorrect or unset, update them using:
```bash
git config --global user.name "Your Name"
git config --global user.email "your.email@example.com"
```
We welcome all contributions, whether it be bug reports, fixes, feature
submissions, feature requests, or improving documentation or testing. Enjoy
collaborating!
## Git Repository
The repository is hosted at <https://git.4rs.nl/awiteb/n34.git>, with `master`
as the active development branch.
## Nostr Repository Address
You can submit issues and patches via any
Nostr-compatible client using the address:
`naddr1qqpkuve5qgsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgrqsqqqaueq
yf8wumn8ghj7mn0wd68yt35wfejumnvqyxhwumn8ghj7mn0wvhxcmmvqy28wumn8ghj7mn0wd68ytn00
p68ytnyv4mqwuj6xc`
## Contribution Workflow
Before submitting changes, open an issue to discuss your proposed contribution.
Clearly indicate that you intend to work on it and wait for a maintainer's
response. If the issue remains open, you may proceed with submitting your patch.
### Reporting Issues
When opening an issue, include:
- Detailed steps to reproduce the problem
- Relevant error messages or logs (use the `-vvvv` flag for verbose output)
- Expected vs. actual behavior
Please label your issue appropriately (e.g., `bug`, `feature`, `question`) to
help categorize it.
### Your Patch
Ensure your patch submission tool notifies the maintainers and sends the patch
to their read relays, most tools handle this automatically.
#### Patch Guidelines
- Keep patches small: Focused changes are easier to review and merge.
- Run `just ci` before submitting your patch.
- Update the change log with your patch. Run `just changelog` or `git-cliff > CHANGELOG.md`
- Add your name to the [AUTHORS](AUTHORS) file if this is your first contribution. (alphabetical order)
- Use [Conventional Commits]: Start the patch subject with one of these types:
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation updates
- `refactor`: Code restructuring without behavioral changes
- `deprecate`: Marking code as deprecated
- `remove`: Removing deprecated code
- `security`: Security-related changes
- `perf`: Performance improvements
- `test`: Test additions or corrections
- For all other changes, use `chore`.
- Add `!` to the subject if your patch contains a breacking change, e.g.
`remove!: text` and `fix(reply)!: text`
#### Code Style
When writing code, make sure to folow this:
- Using Rust's official formatting tool, `rustfmt`, to format your code.
- Writing clear and concise code with meaningful variable and function names.
- Adding comments to explain complex logic or algorithms.
#### Cover Letter Description
Your patch description should provide a clear and concise summary of the changes you
have made. It should also include any relevant context or background information
that will help the project maintainers understand the purpose of the changes.
Make sure to reference the issue[^1] that your patch is addressing, and note any breaking
changes that your patch introduces.
[^1]: When referencing, avoid URLs or `nevent` formats with relays. Instead, use only the note ID in `note1` bech32 format.
[Conventional Commits]: https://www.conventionalcommits.org/en/v1.0.0/

2705
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,32 @@
[package]
name = "n34"
description = "A CLI to interact with NIP-34 and other stuff related to codes in nostr"
version = "0.0.0"
edition = "2024"
license = "GPL-3.0-or-later"
authors = ["Awiteb <a@4rs.nl>"]
readme = "README.md"
repository = "https://git.4rs.nl/awiteb/n34"
keywords = ["nostr", "NIP-34", "CLI"]
categories = ["command-line-utilities"]
name = "n34"
description = "A CLI to interact with NIP-34 and other stuff related to codes in nostr"
version = "0.2.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.85.1"
[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"
futures = "0.3.31"
nostr = { version = "0.42.0", features = ["parser", "nip05"] }
nostr-sdk = "0.42.0"
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"

34
DCO Normal file
View File

@@ -0,0 +1,34 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.

View File

@@ -1,9 +1,79 @@
!!! 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
## About
`n34` is a 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.
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.
## Features
- [X] Repository announcements
- [ ] Repository state announcements
- [ ] Patches
- [X] Issues
- [X] Replies
- [X] Status
- [X] Gossip Model ([NIP-65])
- [X] Proof of Work ([NIP-13])
- [X] `nostr:` URI scheme, in the issue/reply content ([NIP-21])
- [ ] Code Snippets ([NIP-C0])
- [ ] In device relays and repos bookmark
## Why Nostr?
Nostr is fundamentally different from traditional platforms because its not
an application or service, its 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 doesnt
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.
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.
### More about Nostr
- <https://nostr.com>
- <https://nostr.org>
- <https://nostr.how/en/what-is-nostr>
## Contributing
Contributions to `n34` are welcome! You can help by opening issues (such as bug
reports or feature requests) or submitting patches. **All contributions must be
submitted through Nostr**. For more details on the process, please refer to the
[CONTRIBUTING.md](CONTRIBUTING.md) file. Your support is greatly appreciated!
## Contributions & Changes
You can find the changelog at [CHANGELOG.md](CHANGELOG.md) and the list of
contributors at [AUTHORS](AUTHORS) file.
## License
n34 is licensed under the GPL-3.0 License. This means that you are free to use,
modify, and distribute the software under the terms of this license. Please
refer to the [LICENSE](LICENSE) file for more details.
[NIP-34]: https://github.com/nostr-protocol/nips/blob/master/34.md
[NIP-65]: https://github.com/nostr-protocol/nips/blob/master/65.md
[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

View File

@@ -17,12 +17,13 @@ body = """
{% if version -%}
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else -%}
## [Unreleased]
## Unreleased
{% endif -%}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first | upper_first | trim }}\
- {{ commit.message | split(pat="\n") | first | split(pat=":") | slice(start=1) | join(sep=":") | trim | upper_first }} - \
by {{ commit.author.name}}\
{% endfor %}
{% endfor %}\n
"""
@@ -31,12 +32,7 @@ body = """
footer = """
{% for release in releases -%}
{% if release.version -%}
{% if release.previous.version -%}
[{{ release.version | trim_start_matches(pat="v") }}]: \
https://git.4rs.nl/awiteb/n34/compare/{{ release.previous.version }}..{{ release.version }}
{% endif -%}
{% else -%}
[unreleased]: https://git.4rs.nl/awiteb/n34/compare/{{ release.previous.version }}..HEAD
[{{ release.version | trim_start_matches(pat="v") }}]: https://git.4rs.nl/awiteb/n34.git/tag/?h={{ release.version }}
{% endif -%}
{% endfor %}
<!-- generated by git-cliff -->
@@ -44,6 +40,11 @@ footer = """
# Remove leading and trailing whitespaces from the changelog's body.
trim = true
postprocessors = [
# Replace the placeholder `<REPO>` with a URL.
{ pattern = '\$REPO', replace = "https://git.4rs.nl/awiteb/n34.git" },
]
[git]
# Parse commits according to the conventional commits specification.
# See https://www.conventionalcommits.org
@@ -54,14 +55,14 @@ filter_unconventional = false
# Assigns commits to groups.
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
commit_parsers = [
{ message = "^.*!:.*$", group = "Breaking Change" },
{ message = "^feat", group = "Added" },
{ message = "^deprecate", group = "Deprecated" },
{ message = "^remove", group = "Removed" },
{ message = "^deps", group = "Dependencies" },
{ message = "^refactor", group = "Refactor" },
{ message = "^fix", group = "Fixed" },
{ message = "^docs", group = "Documentation" },
{ message = '^.*!:.*$', group = "Breaking Change" },
{ message = '^feat', group = "Added" },
{ message = '^deprecate', group = "Deprecated" },
{ message = '^remove', group = "Removed" },
{ message = '^deps', group = "Dependencies" },
{ message = '^refactor', group = "Refactor" },
{ message = '^fix', group = "Fixed" },
{ message = '^docs', group = "Documentation" },
]
# Prevent commits that are breaking from being excluded by commit parsers.
filter_commits = false

View File

@@ -22,9 +22,10 @@
{
devShells.default = mkShell {
packages = [
just
cargo-msrv
git-cliff
just
taplo
];
nativeBuildInputs = [

View File

@@ -8,42 +8,69 @@
set quiet
set unstable
set shell := ["/usr/bin/env", "bash", "-c"]
set script-interpreter := ["/usr/bin/env", "bash", "-c"]
set script-interpreter := ["/usr/bin/env", "bash"]
JUST_EXECUTABLE := "just -u -f " + justfile()
header := "Available tasks:\n"
# Get the MSRV from the Cargo.toml
msrv := `cat Cargo.toml | grep "rust-version" | sed 's/.*"\(.*\)".*/\1/'`
tag_change_body := '''{% for group, commits in commits | group_by(attribute="group") %}
{{ group | upper_first }}
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first | split(pat=":") | slice(start=1) | join(sep=":") | upper_first | trim }} - (by {{ commit.author.name}}){% endfor %}{% endfor %}'''
export TZ := "UTC"
_default:
@{{JUST_EXECUTABLE}} --list-heading "{{header}}" --list
# Run the CI
ci: && msrv
ci: && msrv _done_ci
echo "🔨 Building n34..."
cargo build -q
cargo fmt -- --check
cargo clippy -- -D warnings
echo "🔍 Checking code formatting..."
cargo fmt -q -- --check
taplo fmt --check --config ./.taplo.toml
echo "🧹 Running linter checks..."
cargo clippy -q -- -D warnings
taplo check --config ./.taplo.toml
# Check that the current MSRV is correct
msrv:
rustup toolchain install {{msrv}}
echo "Checking MSRV ({{msrv}})"
cargo +{{msrv}} check -q
echo "MSRV is correct"
echo "🔧 Verifying MSRV ({{msrv}})..."
rustup -q run --install {{msrv}} cargo check -q
echo "✅ MSRV verification passed"
_done_ci:
echo "🎉 CI pipeline completed successfully"
# Update the changelog
[script]
change-log:
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
TZ=UTC git add CHANGELOG.md
TZ=UTC git commit -m 'chore(changelog): Update the changelog'
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
alias cl := change-log
# Releases a new version of n34. Requires a clean file tree with no uncommitted changes.
[script]
release version:
set -e
TAG_MSG="Version {{ version }}\n\n$(git-cliff --strip all --unreleased --body '{{ tag_change_body }}')\n"
sed -i "s/^version\s*= \".*\"/version = \"{{ version }}\"/" ./Cargo.toml
taplo fmt --config ./.taplo.toml ./Cargo.toml
{{ JUST_EXECUTABLE }} ci
git-cliff -t "v{{ version }}" > CHANGELOG.md
git add .
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

15
nostr-address Normal file
View File

@@ -0,0 +1,15 @@
# This file contains NIP-19 `naddr` entities for repositories that accept this
# project's issues and patches.
#
# The file acts as a **read-only reference** for retrieving repository relays
# when embedded in an `naddr` and mentions those repositories when opening
# patches or issues. Modifications here will not affect in the relays, as the
# file is **explicitly untracked**. Its goal is to simplify contributions by
# removing the need for manual address entry.
#
# Each entry must start with "naddr". Embedded relays are **strongly recommended**
# to assist client-side discovery.
#
# Empty lines are ignored. Lines starting with "#" are treated as comments.
naddr1qqpkuve5qgsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgrqsqqqaueqyf8wumn8ghj7mn0wd68yt35wfejumnvqyt8wumn8ghj7un9d3shjtnswf5k6ctv9ehx2aqpp4mhxue69uhkummn9ekx7mqppamhxue69uhkummnw3ezumt0d5q3gamnwvaz7tmjv4kxz7fwv3sk6atn9e5k7qg5waehxw309ahx7um5wghx77r5wghxgetkxpx8xj

View File

@@ -1,22 +1,22 @@
unstable_features = true
style_edition = "2024"
style_edition = "2024"
blank_lines_upper_bound = 2
combine_control_expr = false
wrap_comments = true
condense_wildcard_suffixes = true
edition = "2024"
enum_discrim_align_threshold = 20
force_multiline_blocks = true
format_code_in_doc_comments = true
format_generated_files = false
format_macro_matchers = true
format_strings = true
imports_layout = "HorizontalVertical"
newline_style = "Unix"
normalize_comments = true
reorder_impl_items = true
group_imports = "StdExternalCrate"
blank_lines_upper_bound = 2
combine_control_expr = false
wrap_comments = true
condense_wildcard_suffixes = true
edition = "2024"
enum_discrim_align_threshold = 20
force_multiline_blocks = true
format_code_in_doc_comments = true
format_generated_files = false
format_macro_matchers = true
format_strings = true
imports_layout = "HorizontalVertical"
newline_style = "Unix"
normalize_comments = true
reorder_impl_items = true
group_imports = "StdExternalCrate"
single_line_let_else_max_width = 0
struct_field_align_threshold = 20
use_try_shorthand = true
struct_field_align_threshold = 20
use_try_shorthand = true

View File

@@ -0,0 +1,38 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
/// `issue new` subcommand
mod new;
use clap::Subcommand;
use self::new::NewArgs;
use super::{CliOptions, CommandRunner};
use crate::error::N34Result;
#[derive(Subcommand, Debug)]
pub enum IssueSubcommands {
/// Create a new repository issue
New(NewArgs),
}
impl CommandRunner for IssueSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
match self {
Self::New(args) => args.run(options).await,
}
}
}

View File

@@ -0,0 +1,164 @@
// 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 futures::future;
use nostr::event::{EventBuilder, Tag};
use crate::{
cli::{
CliConfig,
CliOptions,
CommandRunner,
types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
},
error::N34Result,
nostr_utils::{
NostrClient,
traits::{NaddrsUtils, NewGitRepositoryAnnouncement, ReposUtils},
utils,
},
};
/// Arguments for the `issue new` command
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("issue-content")
.args(["content", "editor"])
.required(true)
),
group(
ArgGroup::new("issue-subject")
.args(["editor", "subject"])
)
)]
pub struct NewArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Option<Vec<NaddrOrSet>>,
/// Markdown content for the issue. Cannot be used together with the
/// `--editor` flag.
#[arg(short, long)]
content: Option<String>,
/// Opens the user's default editor to write issue content. The first line
/// will be used as the issue subject.
#[arg(short, long)]
editor: bool,
/// The issue subject. Cannot be used together with the `--editor` flag.
#[arg(long)]
subject: Option<String>,
/// Labels for the issue. Can be specified as arguments (-l bug) or hashtags
/// in content (#bug).
#[arg(short, long)]
label: Vec<String>,
}
impl NewArgs {
/// Returns the subject and the content of the issue. (subject, content)
pub fn issue_content(&self) -> N34Result<(Option<String>, String)> {
if let Some(content) = self.content.as_ref() {
if let Some(subject) = self.subject.as_ref() {
return Ok((Some(subject.trim().to_owned()), content.trim().to_owned()));
}
return Ok((None, content.trim().to_owned()));
}
// If the `self.content` is `None` then the `self.editor` is `true`
let file_content = utils::read_editor(None, ".md")?;
if file_content.contains('\n') {
Ok(file_content
.split_once('\n')
.map(|(s, c)| (Some(s.trim().to_owned()), c.trim().to_owned()))
.expect("There is a new line"))
} else {
tracing::info!("File content contains only issue body without a subject line");
Ok((None, file_content))
}
}
}
impl CommandRunner for NewArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let config = CliConfig::load_toml(&options.config_path)?;
let naddrs = utils::naddrs_or_file(
self.naddrs.flat_naddrs(&config.sets)?,
&utils::nostr_address_path()?,
)?;
let relays = options.relays.clone().flat_relays(&config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let user_pubk = options.pubkey().await?;
let mut naddrs_iter = naddrs.clone().into_iter();
client.add_relays(&naddrs.extract_relays()).await;
let relays_list = client.user_relays_list(user_pubk).await?;
let (subject, content) = self.issue_content()?;
let content_details = client.parse_content(&content).await;
let event = EventBuilder::new_git_issue(
naddrs_iter
.next()
.expect("There is at least one address")
.coordinate
.clone(),
content,
subject,
self.label,
)?
.dedup_tags()
.pow(options.pow)
.tags(content_details.clone().into_tags())
// p-tag the reset of the reposotoies owners
.tags(naddrs_iter.clone().map(|n| Tag::public_key(n.public_key)))
// a-tag the reset of the reposotoies
.tags(naddrs_iter.clone().map(|n| Tag::coordinate(n.coordinate, n.relays.first().cloned())))
.build(user_pubk);
let event_id = event.id.expect("There is an id");
let write_relays = [
relays,
utils::add_write_relays(relays_list.as_ref()),
client
.fetch_repos(&naddrs.into_coordinates())
.await?
.extract_relays(),
// Include read relays for each repository owner (if found)
future::join_all(naddrs_iter.map(|c| client.read_relays_from_user(c.public_key)))
.await
.into_iter()
.flatten()
.collect(),
content_details.write_relays.clone().into_iter().collect(),
]
.concat();
tracing::trace!(relays = ?write_relays, "Write relays list");
let success = client
.send_event_to(event, relays_list.as_ref(), &write_relays)
.await?;
let nevent = utils::new_nevent(event_id, &success)?;
println!("Issue created: {nevent}");
Ok(())
}
}

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

@@ -0,0 +1,141 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
/// `issue` subcommands
mod issue;
/// 'reply` command
mod reply;
/// `repo` subcommands
mod repo;
/// `sets` subcommands
mod sets;
use std::fmt;
use std::path::PathBuf;
use clap::{ArgGroup, Args, Parser};
use nostr::key::{Keys, PublicKey, SecretKey};
use self::issue::IssueSubcommands;
use self::reply::ReplyArgs;
use self::repo::RepoSubcommands;
use self::sets::SetsSubcommands;
use super::traits::CommandRunner;
use super::types::RelayOrSet;
use crate::error::{N34Error, N34Result};
/// Default path used when no path is provided via command line arguments.
///
/// This is a workaround since Clap doesn't support lazy evaluation of defaults.
pub const DEFAULT_FALLBACK_PATH: &str = "I_DO_NOT_KNOW_WHY_CLAP_DO_NOT_SUPPORT_LAZY_DEFAULT";
// TODO: Make the `signer` group optional
/// The command-line interface options
#[derive(Args, Clone)]
#[clap(
group(
ArgGroup::new("signer")
.args(&["secret_key"])
.required(true)
)
)]
pub struct CliOptions {
/// Your Nostr secret key
#[arg(short, long)]
pub secret_key: Option<SecretKey>,
/// Fallbacks relay to write and read from it. Multiple relays can be
/// passed.
#[arg(short, long)]
pub relays: Vec<RelayOrSet>,
/// Proof of Work difficulty when creatring events
#[arg(long, default_value_t = 0)]
pub pow: u8,
/// Config path [default: `$XDG_CONFIG_HOME` or `$HOME/.config`]
#[arg(long, value_name = "PATH", default_value = DEFAULT_FALLBACK_PATH,
hide_default_value = true
)]
pub config_path: PathBuf,
}
/// N34 commands
#[derive(Parser, Debug)]
pub enum Commands {
/// Manage reposotoies and relays sets
Sets {
#[command(subcommand)]
subcommands: SetsSubcommands,
},
/// Manage repositories
Repo {
#[command(subcommand)]
subcommands: RepoSubcommands,
},
/// Manage issues
Issue {
#[command(subcommand)]
subcommands: IssueSubcommands,
},
// /// Manage patches
// Patch {
// #[command(subcommand)]
// subcommands: PatchSubcommands,
// },
/// Reply to issues and patches.
Reply(ReplyArgs),
}
impl CommandRunner for Commands {
async fn run(self, options: CliOptions) -> N34Result<()> {
tracing::trace!("Options: {options:#?}");
tracing::trace!("Handling: {self:#?}");
match self {
Self::Repo { subcommands } => subcommands.run(options).await,
Self::Issue { subcommands } => subcommands.run(options).await,
Commands::Reply(args) => args.run(options).await,
Commands::Sets { subcommands } => subcommands.run(options).await,
}
}
}
// TODO: Implement `ensure_signer` function with similar functionality to
// `ensure_relays`
impl CliOptions {
/// Gets the public key of the user.
pub async fn pubkey(&self) -> N34Result<PublicKey> {
if let Some(sk) = &self.secret_key {
return Ok(Keys::new(sk.clone()).public_key());
}
unreachable!("There is no other method until now")
}
/// Returns an error if there are no relays.
pub fn ensure_relays(&self) -> N34Result<()> {
if self.relays.is_empty() {
return Err(N34Error::EmptyRelays);
}
Ok(())
}
}
impl fmt::Debug for CliOptions {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CliOptions")
.field("secret_key", &self.secret_key.as_ref().map(|_| "*******"))
.field("relays", &self.relays)
.finish()
}
}

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

@@ -0,0 +1,296 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::{fs, str::FromStr};
use clap::{ArgGroup, Args};
use futures::future;
use nostr::{
event::{Event, EventBuilder, EventId, Kind},
filter::Filter,
nips::{
nip01::{Coordinate, Metadata},
nip19::{self, FromBech32, ToBech32},
},
types::RelayUrl,
};
use super::{CliOptions, CommandRunner};
use crate::{
cli::{
CliConfig,
types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
},
error::{N34Error, N34Result},
nostr_utils::{
NostrClient,
traits::{NaddrsUtils, ReposUtils},
utils,
},
};
/// Length of a Nostr npub (public key) in characters.
const NPUB_LEN: usize = 63;
/// The max date "9999-01-01 at 00:00 UTC"
const MAX_DATE: i64 = 253370764800;
/// Parses and represents a Nostr `nevent1` or `note1`.
#[derive(Debug, Clone)]
struct NostrEvent {
/// Unique identifier for the event.
event_id: EventId,
/// List of relay URLs associated with the event. Empty if parsing a
/// `note1`.
relays: Vec<RelayUrl>,
}
impl NostrEvent {
/// Create a new [`NostrEvent`] instance
fn new(event_id: EventId, relays: Vec<RelayUrl>) -> Self {
Self { event_id, relays }
}
}
impl FromStr for NostrEvent {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let str_event = s.trim().trim_start_matches("nostr:");
if str_event.starts_with("nevent1") {
let event = nip19::Nip19Event::from_bech32(str_event).map_err(|e| e.to_string())?;
Ok(Self::new(event.event_id, event.relays))
} else if str_event.starts_with("note1") {
Ok(Self::new(
EventId::from_bech32(str_event).map_err(|e| e.to_string())?,
Vec::new(),
))
} else {
Err("Invalid event id, must starts with `note1` or `nevent1`".to_owned())
}
}
}
/// Arguments for the `reply` command
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("comment-content")
.args(["comment", "editor"])
.required(true)
),
group(
ArgGroup::new("quote-reply-to")
.args(["comment", "quote_to"])
)
)]
pub struct ReplyArgs {
/// The issue, patch, or comment to reply to
#[arg(long, value_name = "nevent1-or-note1")]
to: NostrEvent,
/// Quote the replied-to event in the editor
#[arg(long)]
quote_to: bool,
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Option<Vec<NaddrOrSet>>,
/// The comment (cannot be used with --editor)
#[arg(short, long)]
comment: Option<String>,
/// Open editor to write comment (cannot be used with --content)
#[arg(short, long)]
editor: bool,
}
impl CommandRunner for ReplyArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let nostr_address_path = utils::nostr_address_path()?;
let config = CliConfig::load_toml(&options.config_path)?;
let relays = options.relays.clone().flat_relays(&config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let user_pubk = options.pubkey().await?;
let repo_naddrs = if let Some(naddrs) = self.naddrs.flat_naddrs(&config.sets)? {
client.add_relays(&naddrs.extract_relays()).await;
Some(naddrs)
} else if fs::exists(&nostr_address_path).is_ok() {
let naddrs = utils::naddrs_or_file(None, &nostr_address_path)?;
client.add_relays(&naddrs.extract_relays()).await;
Some(naddrs)
} else {
None
};
client.add_relays(&self.to.relays).await;
let reply_to = client
.fetch_event(Filter::new().id(self.to.event_id))
.await?
.ok_or(N34Error::EventNotFound)?;
let root = client.find_root(reply_to.clone()).await?;
let repos_coordinate = if let Some(naddrs) = repo_naddrs {
naddrs.into_coordinates()
} else if let Some(ref root_event) = root {
coordinates_from_root(root_event)?
} else {
return Err(N34Error::NotFoundRepo);
};
let repos = client.fetch_repos(&repos_coordinate).await?;
let quoted_content = if self.quote_to {
Some(quote_reply_to_content(&client, &reply_to).await)
} else {
None
};
let content = utils::get_content(self.comment.as_ref(), quoted_content.as_ref(), ".txt")?;
let content_details = client.parse_content(&content).await;
let event = EventBuilder::comment(
content,
&reply_to,
root.as_ref(),
repos.first().and_then(|r| r.relays.first()).cloned(),
)
.dedup_tags()
.pow(options.pow)
.tags(content_details.clone().into_tags())
.build(user_pubk);
let event_id = event.id.expect("There is an id");
let relays_list = client.user_relays_list(user_pubk).await?;
let author_read_relays =
utils::add_read_relays(client.user_relays_list(user_pubk).await?.as_ref());
let write_relays = [
relays,
utils::add_write_relays(relays_list.as_ref()),
// Merge repository announcement relays into write relays
repos.extract_relays(),
// Include read relays for each repository owner (if found)
future::join_all(
repos_coordinate
.iter()
.map(|c| client.read_relays_from_user(c.public_key)),
)
.await
.into_iter()
.flatten()
.collect(),
// read relays of the root event and the reply to event
{
let (r1, r2) = future::join(
client.read_relays_from_user(reply_to.pubkey),
event_author_read_relays(&client, root.as_ref()),
)
.await;
[r1, r2].concat()
},
content_details.write_relays.into_iter().collect(),
]
.concat();
tracing::trace!(relays = ?write_relays, "Write relays list");
let (success, ..) = futures::join!(
client.send_event_to(event, relays_list.as_ref(), &write_relays),
client.broadcast(&reply_to, &author_read_relays),
async {
if let Some(root_event) = root {
let _ = client.broadcast(&root_event, &author_read_relays).await;
}
},
);
let nevent = utils::new_nevent(event_id, &success?)?;
println!("Comment created: {nevent}");
Ok(())
}
}
/// Creates a quoted reply string in the format "On yyyy-mm-dd at hh:mm UTC,
/// <author> wrote:" followed by the event content. Uses display name if
/// available, otherwise falls back to a shortened npub string. Dates are
/// formatted in UTC.
async fn quote_reply_to_content(client: &NostrClient, quoted_event: &Event) -> String {
let author_name = client
.fetch_event(
Filter::new()
.kind(Kind::Metadata)
.author(quoted_event.pubkey),
)
.await
.ok()
.flatten()
.and_then(|e| Metadata::try_from(&e).ok())
.and_then(|m| m.display_name.or(m.name))
.unwrap_or_else(|| {
let pubkey = quoted_event
.pubkey
.to_bech32()
.expect("The error is `Infallible`");
format!("{}...{}", &pubkey[..8], &pubkey[NPUB_LEN - 8..])
});
let fdate = chrono::DateTime::from_timestamp(
quoted_event
.created_at
.as_u64()
.try_into()
.unwrap_or(MAX_DATE),
0,
)
.map(|datetime| datetime.format("On %F at %R UTC, ").to_string())
.unwrap_or_default();
format!(
"{fdate}{author_name} wrote:\n> {}",
quoted_event.content.trim().replace("\n", "\n> ")
)
}
/// Gets the repository coordinate from a root Nostr event's tags.
/// The event must contain a coordinate tag with GitRepoAnnouncement kind.
fn coordinates_from_root(root: &Event) -> N34Result<Vec<Coordinate>> {
let coordinates: Vec<Coordinate> = root
.tags
.coordinates()
.filter(|c| c.kind == Kind::GitRepoAnnouncement)
.cloned()
.collect();
if coordinates.is_empty() {
return Err(N34Error::InvalidEvent(
"The Git issue/patch does not specify a target repository".to_owned(),
));
}
Ok(coordinates)
}
/// Returns the event author read relays if found, otherwise an empty vector
async fn event_author_read_relays(client: &NostrClient, event: Option<&Event>) -> Vec<RelayUrl> {
if let Some(root_event) = event {
client.read_relays_from_user(root_event.pubkey).await
} else {
Vec::new()
}
}

View File

@@ -0,0 +1,155 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::{fs, io::Write};
use clap::Args;
use futures::future;
use nostr::{event::EventBuilder, key::PublicKey, types::Url};
use crate::{
cli::{CliConfig, CliOptions, CommandRunner, NOSTR_ADDRESS_FILE, types::RelayOrSetVecExt},
error::N34Result,
nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils},
};
/// Header written to new `nostr-address` files. Contains two trailing newline
/// for formatting.
const NOSTR_ADDRESS_FILE_HEADER: &str = r##"# This file contains NIP-19 `naddr` entities for repositories that accept this
# project's issues and patches.
#
# The file acts as a **read-only reference** for retrieving repository relays
# when embedded in an `naddr` and mentions those repositories when opening
# patches or issues. Modifications here will not affect in the relays, as the
# file is **explicitly untracked**. Its goal is to simplify contributions by
# removing the need for manual address entry.
#
# Each entry must start with "naddr". Embedded relays are **strongly recommended**
# to assist client-side discovery.
#
# Empty lines are ignored. Lines starting with "#" are treated as comments.
"##;
/// Arguments for the `repo announce` command
#[derive(Args, Debug)]
pub struct AnnounceArgs {
/// Unique identifier for the repository in kebab-case.
#[arg(long = "id")]
repo_id: String,
/// A name for the repository.
#[arg(short, long)]
name: Option<String>,
/// A description for the repository.
#[arg(short, long)]
description: Option<String>,
/// Webpage URLs for the repository (if provided by the git server).
#[arg(short, long)]
web: Vec<Url>,
/// URLs for cloning the repository.
#[arg(short, long)]
clone: Vec<Url>,
/// Additional maintainers of the repository (besides yourself).
#[arg(short, long)]
maintainers: Vec<PublicKey>,
/// Labels to categorize the repository. Can be specified multiple times.
#[arg(short, long)]
label: Vec<String>,
/// Skip kebab-case validation for the repository ID
#[arg(long)]
force_id: bool,
/// If set, creates a `nostr-address` file to enable automatic address
/// discovery by n34
#[arg(long)]
address_file: bool,
}
impl CommandRunner for AnnounceArgs {
async fn run(mut self, options: CliOptions) -> N34Result<()> {
options.ensure_relays()?;
let config = CliConfig::load_toml(&options.config_path)?;
let relays = options.relays.clone().flat_relays(&config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let user_pubk = options.pubkey().await?;
let relays_list = client.user_relays_list(user_pubk).await?;
if !self.maintainers.contains(&user_pubk) {
self.maintainers.insert(0, user_pubk);
}
let naddr = utils::repo_naddr(&self.repo_id, user_pubk, &relays)?;
let event = EventBuilder::new_git_repo(
self.repo_id,
self.name.map(utils::str_trim),
self.description.map(utils::str_trim),
self.web,
self.clone,
relays.clone(),
self.maintainers.clone(),
self.label.into_iter().map(utils::str_trim).collect(),
self.force_id,
)?
.dedup_tags()
.pow(options.pow)
.build(user_pubk);
if self.address_file {
let address_path = std::env::current_dir()?.join(NOSTR_ADDRESS_FILE);
if !address_path.exists() {
tracing::info!(
"Creating new address file: '{NOSTR_ADDRESS_FILE}' at path '{}' with default \
header",
address_path.display()
);
fs::write(&address_path, NOSTR_ADDRESS_FILE_HEADER)?;
}
let mut file = fs::OpenOptions::new().append(true).open(&address_path)?;
tracing::info!("Appending naddr '{naddr}' to address file: '{NOSTR_ADDRESS_FILE}'");
file.write_all(format!("{naddr}\n").as_bytes())?;
tracing::info!("Successfully wrote naddr to address file");
}
let write_relays = [
relays,
utils::add_write_relays(relays_list.as_ref()),
// Include read relays for each maintainer (if found)
future::join_all(
self.maintainers
.iter()
.map(|pkey| client.read_relays_from_user(*pkey)),
)
.await
.into_iter()
.flatten()
.collect(),
]
.concat();
let nevent = utils::new_nevent(event.id.expect("There is an id"), &write_relays)?;
client
.send_event_to(event, relays_list.as_ref(), &write_relays)
.await?;
println!("Event: {nevent}",);
println!("Repo Address: {naddr}",);
Ok(())
}
}

View File

@@ -0,0 +1,46 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
/// `repo announce` subcommand
mod announce;
/// `repo view` subcommand
mod view;
use clap::Subcommand;
use self::announce::AnnounceArgs;
use self::view::ViewArgs;
use super::{CliOptions, CommandRunner};
use crate::error::N34Result;
#[derive(Subcommand, Debug)]
pub enum RepoSubcommands {
/// View details of a nostr git repository
View(ViewArgs),
/// Publish information about a git repository to Nostr for collaboration
/// and feedback. Can also be used to update an existing repository's
/// details.
Announce(AnnounceArgs),
}
impl CommandRunner for RepoSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
match self {
Self::View(args) => args.run(options).await,
Self::Announce(args) => args.run(options).await,
}
}
}

View File

@@ -0,0 +1,104 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::fmt;
use clap::Args;
use crate::{
cli::{
CliConfig,
CliOptions,
CommandRunner,
types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
},
error::N34Result,
nostr_utils::{NostrClient, traits::NaddrsUtils, utils},
};
/// Arguments for the `repo view` command
#[derive(Args, Debug)]
pub struct ViewArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Option<Vec<NaddrOrSet>>,
}
impl CommandRunner for ViewArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
// FIXME: The signer is not required here
let config = CliConfig::load_toml(&options.config_path)?;
let naddrs = utils::naddrs_or_file(
self.naddrs.flat_naddrs(&config.sets)?,
&utils::nostr_address_path()?,
)?;
let relays = options.relays.clone().flat_relays(&config.sets)?;
let client = NostrClient::init(&options, &relays).await;
client.add_relays(&naddrs.extract_relays()).await;
let repos = client.fetch_repos(&naddrs.into_coordinates()).await?;
let mut repos_details: Vec<String> = Vec::new();
for repo in repos {
let mut repo_details = format!("ID: {}", repo.id);
if let Some(name) = repo.name {
repo_details.push_str(&format!("\nName: {name}"));
}
if let Some(desc) = repo.description {
repo_details.push_str(&format!("\nDescription: {desc}"));
}
if !repo.web.is_empty() {
repo_details.push_str(&format!("\nWebpages:\n{}", format_list(repo.web)));
}
if !repo.clone.is_empty() {
repo_details.push_str(&format!("\nClone urls:\n{}", format_list(repo.clone)));
}
if !repo.relays.is_empty() {
repo_details.push_str(&format!("\nRelays:\n{}", format_list(repo.relays)));
}
if let Some(euc) = repo.euc {
repo_details.push_str(&format!("\nEarliest unique commit: {euc}"));
}
if !repo.maintainers.is_empty() {
repo_details.push_str(&format!(
"\nMaintainers:\n{}",
format_list(repo.maintainers)
));
}
repos_details.push(repo_details);
}
println!("{}", repos_details.join("\n----------\n"));
Ok(())
}
}
/// Format a vector to print it
fn format_list<T>(vector: Vec<T>) -> String
where
T: fmt::Display,
{
vector
.into_iter()
.map(|t| format!(" - {t}"))
.collect::<Vec<String>>()
.join("\n")
}

View File

@@ -0,0 +1,57 @@
// 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>.
/// `sets new` command
mod new;
/// `sets remove` command
mod remove;
/// `sets show` commands
mod show;
/// `sets update` command
mod update;
use clap::Subcommand;
use self::new::NewArgs;
use self::remove::RemoveArgs;
use self::show::ShowArgs;
use self::update::UpdateArgs;
use super::{CliOptions, CommandRunner};
use crate::error::N34Result;
#[derive(Subcommand, Debug)]
pub enum SetsSubcommands {
/// Remove a set, or specific repos and relays within it
Remove(RemoveArgs),
/// Create a new set
New(NewArgs),
/// Modify an existing set
Update(UpdateArgs),
/// Show a single set or all the stored sets
Show(ShowArgs),
}
impl CommandRunner for SetsSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
match self {
Self::Remove(args) => args.run(options).await,
Self::New(args) => args.run(options).await,
Self::Update(args) => args.run(options).await,
Self::Show(args) => args.run(options).await,
}
}
}

View File

@@ -0,0 +1,59 @@
// 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 crate::{
cli::{
CliConfig,
CliOptions,
ConfigError,
MutRepoRelaySetsExt,
traits::CommandRunner,
types::{NaddrOrSet, NaddrOrSetVecExt, RelayOrSet, RelayOrSetVecExt},
},
error::N34Result,
};
#[derive(Args, Debug)]
pub struct NewArgs {
/// Unique name for the set
name: String,
/// Optional relay to add it to the set, either as URL or set name to
/// extract its relays. [aliases: `--sr`]
#[arg(long = "set-relay", alias("sr"))]
relays: Vec<RelayOrSet>,
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Vec<NaddrOrSet>,
}
impl CommandRunner for NewArgs {
// FIXME: The signer is not required here
async fn run(self, options: CliOptions) -> N34Result<()> {
let mut config = CliConfig::load_toml(&options.config_path)?;
let naddrs = self.naddrs.flat_naddrs(&config.sets)?;
let relays = self.relays.flat_relays(&config.sets)?;
if relays.is_empty() && naddrs.is_empty() {
return Err(ConfigError::NewEmptySet.into());
}
config.sets.push_set(self.name, naddrs, relays)?;
config.dump_toml(&options.config_path)
}
}

View File

@@ -0,0 +1,65 @@
// 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 crate::{
cli::{
CliConfig,
CliOptions,
MutRepoRelaySetsExt,
traits::CommandRunner,
types::{NaddrOrSet, NaddrOrSetVecExt, RelayOrSet, RelayOrSetVecExt},
},
error::N34Result,
};
#[derive(Args, Debug)]
pub struct RemoveArgs {
/// Set name to delete
name: String,
/// Specific relay to remove it from the set, either as URL or set name to
/// extract its relays. [aliases: `--sr`]
#[arg(long = "set-relay", alias("sr"))]
relays: Vec<RelayOrSet>,
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Vec<NaddrOrSet>,
}
impl CommandRunner for RemoveArgs {
// FIXME: The signer is not required here
async fn run(self, options: CliOptions) -> N34Result<()> {
let mut config = CliConfig::load_toml(&options.config_path)?;
let naddrs = self.naddrs.flat_naddrs(&config.sets)?;
let relays = self.relays.flat_relays(&config.sets)?;
if relays.is_empty() && naddrs.is_empty() {
config.sets.remove_set(self.name)?;
} else {
if !relays.is_empty() {
config.sets.remove_relays(&self.name, relays.into_iter())?;
}
if !naddrs.is_empty() {
config.sets.remove_naddrs(self.name, naddrs.into_iter())?;
}
}
config.dump_toml(&options.config_path)
}
}

View File

@@ -0,0 +1,85 @@
// 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::nip19::ToBech32;
use crate::{
cli::{CliConfig, CliOptions, RepoRelaySet, RepoRelaySetsExt, traits::CommandRunner},
error::N34Result,
};
#[derive(Args, Debug)]
pub struct ShowArgs {
/// Name of the set to display. If not provided, lists all available sets.
name: Option<String>,
}
impl CommandRunner for ShowArgs {
// FIXME: The signer is not required here
async fn run(self, options: CliOptions) -> N34Result<()> {
let config = CliConfig::load_toml(&options.config_path)?;
if let Some(name) = self.name {
println!("{}", format_set(config.sets.as_slice().get_set(&name)?));
} else {
println!(
"{}",
config
.sets
.iter()
.map(format_set)
.collect::<Vec<_>>()
.join("\n----------\n")
);
}
Ok(())
}
}
/// Format a set to view it to the user
fn format_set(set: &RepoRelaySet) -> String {
let naddrs = if set.naddrs.is_empty() {
"Nothing".to_owned()
} else {
format!(
"\n- {}",
set.naddrs
.iter()
.map(|naddr| naddr.to_bech32().expect("We did decoded before"))
.collect::<Vec<_>>()
.join("\n- ")
)
};
let relays = if set.relays.is_empty() {
"Nothing".to_owned()
} else {
format!(
"\n- {}",
set.relays
.iter()
.map(|relay| relay.to_string())
.collect::<Vec<_>>()
.join("\n- ")
)
};
format!(
"Name: {}\nّّRepositories: {naddrs}\nRelays: {relays}",
set.name
)
}

View File

@@ -0,0 +1,72 @@
// 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::collections::HashSet;
use clap::Args;
use crate::{
cli::{
CliConfig,
CliOptions,
MutRepoRelaySetsExt,
traits::CommandRunner,
types::{NaddrOrSet, NaddrOrSetVecExt, RelayOrSet, RelayOrSetVecExt},
},
error::N34Result,
};
#[derive(Args, Debug)]
pub struct UpdateArgs {
/// Name of the set to update
name: String,
/// Add relay to the set, either as URL or set name to extract its relays.
/// [aliases: `--sr`]
#[arg(long = "set-relay", alias("sr"))]
relays: Vec<RelayOrSet>,
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Vec<NaddrOrSet>,
/// Replace existing relays/repositories instead of adding to them
#[arg(long = "override")]
override_set: bool,
}
impl CommandRunner for UpdateArgs {
// FIXME: The signer is not required here
async fn run(self, options: CliOptions) -> N34Result<()> {
let mut config = CliConfig::load_toml(&options.config_path)?;
let naddrs = self.naddrs.flat_naddrs(&config.sets)?;
let relays = self.relays.flat_relays(&config.sets)?;
let set = config.sets.get_mut_set(&self.name)?;
if self.override_set {
if !relays.is_empty() {
set.relays = HashSet::from_iter(relays);
}
if !naddrs.is_empty() {
set.naddrs = HashSet::from_iter(naddrs)
}
} else {
set.relays.extend(relays);
set.naddrs.extend(naddrs);
}
config.dump_toml(&options.config_path)
}
}

303
src/cli/config.rs Normal file
View File

@@ -0,0 +1,303 @@
// 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::{collections::HashSet, fs, path::Path};
use nostr::{
nips::nip19::{FromBech32, Nip19Coordinate, ToBech32},
types::RelayUrl,
};
use serde::{Deserialize, Serialize, Serializer};
use crate::error::{N34Error, N34Result};
/// Errors that can occur when working with configuration files.
#[derive(thiserror::Error, Debug)]
pub enum ConfigError {
#[error(
"Could not determine the default config path: both `$XDG_CONFIG_HOME` and `$HOME` \
environment variables are missing or unset."
)]
CanNotFindConfigPath,
#[error("Couldn't read the config file: {0}")]
ReadFile(std::io::Error),
#[error("Couldn't write in the config file: {0}")]
WriteFile(std::io::Error),
#[error("Couldn't serialize the config. This is a bug, please report it: {0}")]
Serialize(toml::ser::Error),
#[error("Failed to parse the config file: {0}")]
ParseFile(toml::de::Error),
#[error("Duplicate configuration set name detected: '{0}'. Each set must have a unique name.")]
SetDuplicateName(String),
#[error("No set with the given name `{0}`")]
SetNotFound(String),
#[error("You can't create an new empty set.")]
NewEmptySet,
}
/// Configuration for the command-line interface.
#[derive(serde::Serialize, serde::Deserialize, Clone, Default, Debug)]
pub struct CliConfig {
/// Groups of repositories and relays.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sets: Vec<RepoRelaySet>,
}
/// A named group of repositories and relays.
#[derive(serde::Serialize, serde::Deserialize, Default, Clone, Debug)]
pub struct RepoRelaySet {
/// Unique identifier for this group.
pub name: String,
/// Repository addresses in this group.
#[serde(
default,
skip_serializing_if = "HashSet::is_empty",
serialize_with = "ser_naddrs",
deserialize_with = "de_naddrs"
)]
pub naddrs: HashSet<Nip19Coordinate>,
/// Relay URLs in this group.
#[serde(default, skip_serializing_if = "HashSet::is_empty")]
pub relays: HashSet<RelayUrl>,
}
impl CliConfig {
/// Reads and parse a TOML config file from the given path, creating it if
/// missing.
pub fn load_toml(file_path: &Path) -> N34Result<Self> {
tracing::info!(path = %file_path.display(), "Loading configuration from file");
// Make sure the file is exist
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
let _ = fs::File::create_new(file_path);
let mut config: Self =
toml::from_str(&fs::read_to_string(file_path).map_err(ConfigError::ReadFile)?)
.map_err(ConfigError::ParseFile)?;
config.post_sets()?;
Ok(config)
}
/// Dump the config as toml in a file
pub fn dump_toml(mut self, file_path: &Path) -> N34Result<()> {
tracing::debug!(config = ?self, "Writing configuration to {}", file_path.display());
self.post_sets()?;
fs::write(
file_path,
toml::to_string_pretty(&self).map_err(ConfigError::Serialize)?,
)
.map_err(ConfigError::WriteFile)?;
Ok(())
}
/// Performs post-processing validation on the sets after loading or before
/// dumping.
fn post_sets(&mut self) -> N34Result<()> {
self.sets.as_slice().ensure_names()?;
self.sets.dedup_naddrs();
Ok(())
}
}
impl RepoRelaySet {
/// Create a new [`RepoRelaySet`]
pub fn new(
name: impl Into<String>,
naddrs: impl IntoIterator<Item = Nip19Coordinate>,
relays: impl IntoIterator<Item = RelayUrl>,
) -> Self {
Self {
name: name.into(),
naddrs: HashSet::from_iter(naddrs),
relays: HashSet::from_iter(relays),
}
}
/// Removes duplicate repository addresses by comparing their coordinates,
/// ignoring embedded relays.
pub fn dedup_naddrs(&mut self) {
let mut seen = HashSet::new();
self.naddrs.retain(|n| seen.insert(n.coordinate.clone()));
}
}
#[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)
}

28
src/cli/defaults.rs Normal file
View File

@@ -0,0 +1,28 @@
// 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::path::PathBuf;
use crate::{cli::ConfigError, error::N34Result};
/// Default config path
pub fn config_path() -> N34Result<PathBuf> {
Ok(dirs::config_dir()
.ok_or(ConfigError::CanNotFindConfigPath)?
.join("n34")
.join("config.toml"))
}

73
src/cli/mod.rs Normal file
View File

@@ -0,0 +1,73 @@
// 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>.
/// Commands module
pub mod commands;
/// The CLI config
pub mod config;
/// Default lazy values for CLI arguments
pub mod defaults;
/// CLI arguments parsers
pub mod parsers;
/// CLI traits
pub mod traits;
/// Common helper types used throughout the CLI.
pub mod types;
use clap::Parser;
use clap_verbosity_flag::Verbosity;
pub use self::commands::*;
pub use self::config::*;
use self::traits::CommandRunner;
use crate::error::N34Result;
/// Header message, used in the help message
const HEADER: &str = r#"Copyright (C) 2025 Awiteb <a@4rs.nl>
License GNU GPL-3.0-or-later <https://gnu.org/licenses/gpl-3.0.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Git repository: https://git.4rs.nl/awiteb/n34.git"#;
/// Footer message, used in the help message
const FOOTER: &str = r#"Please report bugs to <naddr1qqpkuve5qgsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgrqsqqqauesksc39>."#;
/// Name of the file storing the repository address
pub const NOSTR_ADDRESS_FILE: &str = "nostr-address";
#[derive(Parser, Debug)]
#[command(about, version, before_long_help = HEADER, after_long_help = FOOTER)]
/// A command-line interface for interacting with NIP-34 and other Nostr
/// code-related stuff.
pub struct Cli {
#[command(flatten)]
pub options: commands::CliOptions,
/// Controls the verbosity level of output
#[command(flatten)]
pub verbosity: Verbosity,
/// The subcommand to execute
#[command(subcommand)]
pub command: commands::Commands,
}
impl Cli {
/// Executes the command
pub async fn run(self) -> N34Result<()> {
self.command.run(self.options).await
}
}

65
src/cli/parsers.rs Normal file
View File

@@ -0,0 +1,65 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::{fs, path::Path};
use nostr::{
Kind,
nips::nip19::{FromBech32, Nip19Coordinate},
};
use super::Cli;
use crate::{
cli::DEFAULT_FALLBACK_PATH,
error::{N34Error, N34Result},
};
pub fn parse_repo_naddr(repo_naddr: &str) -> Result<Nip19Coordinate, String> {
let naddr = Nip19Coordinate::from_bech32(repo_naddr).map_err(|err| err.to_string())?;
if naddr.relays.is_empty() {
tracing::warn!("The repository naddr does not contain any relay hints");
}
(naddr.kind == Kind::GitRepoAnnouncement)
.then_some(naddr)
.ok_or_else(|| "Invalid naddr: must be of kind 30617 (GitRepoAnnouncement)".to_owned())
}
/// Parses a nostr-address file into a NIP-19 coordinates. Expects the file to
/// contain a repository announcements.
pub fn parse_nostr_address_file(file_path: &Path) -> N34Result<Vec<Nip19Coordinate>> {
let addresses = fs::read_to_string(file_path)
.map_err(N34Error::CanNotReadNostrAddressFile)?
.split("\n")
.filter_map(|line| {
(!line.starts_with("#") && !line.trim().is_empty())
.then_some(parse_repo_naddr(line).map_err(N34Error::InvalidNostrAddressFileContent))
})
.collect::<N34Result<Vec<Nip19Coordinate>>>()?;
if addresses.is_empty() {
return Err(N34Error::EmptyNostrAddressFile);
}
Ok(addresses)
}
/// Post parse cli arguments
pub fn post_parse_cli(mut cli: Cli) -> N34Result<Cli> {
if let Some(DEFAULT_FALLBACK_PATH) = cli.options.config_path.to_str() {
cli.options.config_path = super::defaults::config_path()?;
}
Ok(cli)
}

24
src/cli/traits.rs Normal file
View File

@@ -0,0 +1,24 @@
// 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::CliOptions;
use crate::error::N34Result;
/// A trait defining the interface for command runners in the CLI.
pub trait CommandRunner {
/// Executes the command and returns a Result indicating success or failure.
async fn run(self, options: CliOptions) -> N34Result<()>;
}

194
src/cli/types.rs Normal file
View File

@@ -0,0 +1,194 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::str::FromStr;
use nostr::{
event::Kind,
nips::{self, nip01::Coordinate, nip19::Nip19Coordinate},
types::RelayUrl,
};
use tokio::runtime::Handle;
use super::{RepoRelaySetsExt, parsers};
use crate::{
cli::RepoRelaySet,
error::{N34Error, N34Result},
};
/// Either a NIP-19 coordinate (naddr) or a named set.
#[derive(Debug, Clone)]
pub enum NaddrOrSet {
/// NIP-19 coordinate.
Naddr(Nip19Coordinate),
/// Name of a set (may not exist).
Set(String),
}
/// Either relay URL or a named set.
#[derive(Debug, Clone)]
pub enum RelayOrSet {
/// Relay URL.
Relay(RelayUrl),
/// Name of a set (may not exist).
Set(String),
}
impl NaddrOrSet {
/// Returns the naddr if `Naddr` or try to get the relays from the set.
/// Returns error if the set naddrs are empty or the set not found.
pub fn get_naddrs(self, sets: &[RepoRelaySet]) -> N34Result<Vec<Nip19Coordinate>> {
match self {
Self::Naddr(nip19_coordinate) => Ok(vec![nip19_coordinate]),
Self::Set(name) => {
let set = sets
.get_set(&name)
.map_err(|_| N34Error::InvalidNaddrArg(name.clone()))?;
if set.naddrs.is_empty() {
Err(N34Error::EmptySetNaddrs(name))
} else {
Ok(Vec::from_iter(set.naddrs.clone()))
}
}
}
}
}
impl RelayOrSet {
/// Returns the relay if `Relay` or try to get the relays from the set.
/// Returns error if the set relays are empty or the set not found
pub fn get_relays(self, sets: &[RepoRelaySet]) -> N34Result<Vec<RelayUrl>> {
match self {
Self::Relay(relay) => Ok(vec![relay]),
Self::Set(name) => {
let set = sets.get_set(&name)?;
if set.relays.is_empty() {
Err(N34Error::EmptySetRelays(name))
} else {
Ok(Vec::from_iter(set.relays.clone()))
}
}
}
}
}
impl FromStr for NaddrOrSet {
type Err = String;
/// Parses a Git repository address which can be either:
/// - A bech32-encoded naddr (e.g. "naddr1...") for Git repository
/// announcements (kind 30617)
/// - A NIP-05 identifier with repository ID (e.g. "4rs.nl/n34" or
/// "_@4rs.nl/n34")
/// - A set name.
///
/// Returns an error for invalid formats, failed bech32 decoding, wrong
/// event kind.
fn from_str(naddr_or_set: &str) -> Result<Self, Self::Err> {
let naddr_or_set = naddr_or_set.trim();
if naddr_or_set.contains("/") {
let (nip5, repo_id) = naddr_or_set.split_once("/").expect("There is a `/`");
parse_nip5_repo(nip5, repo_id)
} else if naddr_or_set.starts_with("naddr1") || naddr_or_set.starts_with("nostr:naddr1") {
parsers::parse_repo_naddr(naddr_or_set.trim_start_matches("nostr:")).map(Self::Naddr)
} else {
Ok(Self::Set(naddr_or_set.to_owned()))
}
}
}
impl FromStr for RelayOrSet {
type Err = String;
/// Parse a string into a relay URL or a set name.
/// If the string is a valid URL (e.g., "wss://example.com"), it's treated
/// as a relay URL. Otherwise, it's treated as a set name, and its
/// associated relays will be merged.
fn from_str(relay_or_set: &str) -> Result<Self, Self::Err> {
let relay_or_set = relay_or_set.trim();
if relay_or_set.starts_with("wss://") {
RelayUrl::from_str(relay_or_set)
.map_err(|err| err.to_string())
.map(Self::Relay)
} else {
Ok(Self::Set(relay_or_set.to_owned()))
}
}
}
fn parse_nip5_repo(nip5: &str, repo_id: &str) -> Result<NaddrOrSet, String> {
let (username, domain) = nip5.split_once("@").unwrap_or(("_", nip5));
let nip5_profile = tokio::task::block_in_place(|| {
Handle::current().block_on(async {
nips::nip05::profile(format!("{username}@{domain}"), None)
.await
.map_err(|err| err.to_string())
})
})?;
Ok(NaddrOrSet::Naddr(
Nip19Coordinate::new(
Coordinate::new(Kind::GitRepoAnnouncement, nip5_profile.public_key).identifier(repo_id),
nip5_profile.relays,
)
.expect("The relays is `RelayUrl`"),
))
}
#[easy_ext::ext(NaddrOrSetVecExt)]
impl Vec<NaddrOrSet> {
/// Converts this vector of [`NaddrOrSet`] into a flat vector of
/// [`Nip19Coordinates`] 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
/// [`Nip19Coordinates`] 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()
}
}

90
src/error.rs Normal file
View File

@@ -0,0 +1,90 @@
// 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::process::ExitCode;
use nostr::event::builder::Error as EventBuilderError;
use nostr_sdk::client::Error as ClientError;
use crate::cli::ConfigError;
pub type N34Result<T> = Result<T, N34Error>;
/// N34 errors
#[derive(Debug, thiserror::Error)]
pub enum N34Error {
#[error("IO: {0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Config(#[from] ConfigError),
#[error("No editor specified in the `EDITOR` environment variable")]
EditorNotFound,
#[error("The file you edited is empty. Please save your changes before exiting the editor.")]
EmptyEditorFile,
#[error("Client Error: {0}")]
Client(#[from] ClientError),
#[error("Unable to locate the repository. The repository may not exists in the given relays")]
NotFoundRepo,
#[error("Failed building an event: {0}")]
EventBuilder(#[from] EventBuilderError),
#[error("Invalid repository id, it can't be empty and must be kebab-case")]
InvalidRepoId,
#[error("Invalid event: {0}")]
InvalidEvent(String),
#[error("Bech32 error: {0}")]
Bech32(#[from] nostr::nips::nip19::Error),
#[error("Event error: {0}")]
Event(#[from] nostr::event::Error),
#[error("Event not found in the specified relays")]
EventNotFound,
#[error(
"Can't reply to this event. Only Git issues, patches, and their comments can be replied \
to."
)]
CanNotReplyToEvent,
#[error("No repository address given and couldn't read `nostr-address` file: {0}")]
CanNotReadNostrAddressFile(std::io::Error),
#[error(
"The `nostr-address` file is empty. Please add a valid Nostr repository address (naddr) \
to the file or provide it manually as a flag."
)]
EmptyNostrAddressFile,
#[error("Invalid `nostr-address` file content: {0}")]
InvalidNostrAddressFileContent(String),
#[error("This command requires at least one relay, but none were provided")]
EmptyRelays,
#[error(
"Invalid repository address. Expected one of these formats:\n- NIP-05 identifier with \
repository ID: `<user@domain.com>/<repo_id>`\n- Valid NIP-19 naddr string (starts with \
'naddr1...')\n- Existing set name (merges all repositories in set)\nError: No set named \
'{0}' exists."
)]
InvalidNaddrArg(String),
#[error(
"The set '{0}' doesn't contain any addresses. Use 'sets update' to add addresses to it."
)]
EmptySetNaddrs(String),
#[error("The set '{0}' doesn't contain any relays. Use 'sets update' to add addresses to it.")]
EmptySetRelays(String),
}
impl N34Error {
/// Returns the exit code associated with this error
pub fn exit_code(&self) -> ExitCode {
// TODO: More specific exit code
ExitCode::FAILURE
}
}

View File

@@ -14,6 +14,67 @@
// 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>.
fn main() {
println!("Hello, world!");
/// Command line interface module
mod cli;
/// N34 errors
mod error;
/// Nostr utils module
mod nostr_utils;
use std::{
process::ExitCode,
sync::atomic::{AtomicBool, Ordering},
};
use clap::Parser;
use clap_verbosity_flag::Verbosity;
use tracing::Level;
use tracing_subscriber::{Layer, filter, layer::SubscriberExt};
use self::cli::Cli;
/// Whether the editor is currently open. Prevents logging while the editor is
/// open.
static EDITOR_OPEN: AtomicBool = AtomicBool::new(false);
/// Configures the logging level based on the provided verbosity.
///
/// When verbosity is set to TRACE, includes file and line numbers in logs.
fn set_log_level(verbosity: Verbosity) {
let is_trace = verbosity
.tracing_level()
.is_some_and(|l| l == tracing::Level::TRACE);
let logs_filter = filter::dynamic_filter_fn(move |m, _| {
// Disable all logs while editor is open
verbosity.tracing_level().unwrap_or(Level::ERROR) >= *m.level()
&& !EDITOR_OPEN.load(Ordering::Relaxed)
});
let logs_layer = tracing_subscriber::fmt::layer()
.with_file(is_trace)
.with_line_number(is_trace)
.without_time();
let subscriber = tracing_subscriber::registry().with(logs_layer.with_filter(logs_filter));
tracing::subscriber::set_global_default(subscriber).ok();
}
#[tokio::main]
async fn main() -> ExitCode {
let cli = match cli::parsers::post_parse_cli(Cli::parse()) {
Ok(cli) => cli,
Err(err) => {
eprintln!("{err}");
return ExitCode::FAILURE;
}
};
set_log_level(cli.verbosity);
if let Err(err) = cli.run().await {
tracing::error!("{err}");
return err.exit_code();
}
ExitCode::SUCCESS
}

330
src/nostr_utils/mod.rs Normal file
View File

@@ -0,0 +1,330 @@
// 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>.
/// Extension traits for nostr types.
pub mod traits;
/// Utility functions for nostr.
pub mod utils;
use std::{collections::HashSet, time::Duration};
use futures::future;
use nostr::{
event::{Event, EventId, Kind, Tag, TagStandard, Tags, UnsignedEvent},
filter::Filter,
key::{Keys, PublicKey},
nips::{nip01::Coordinate, nip22, nip34::GitRepositoryAnnouncement},
parser::NostrParser,
types::RelayUrl,
};
use nostr_sdk::Client;
use traits::TokenUtils;
use crate::{
cli::CliOptions,
error::{N34Error, N34Result},
};
/// Timeout duration for the clinet.
const CLIENT_TIMEOUT: Duration = Duration::from_millis(1500);
/// Parsed content details
#[derive(Clone)]
pub struct ContentDetails {
/// Public keys of users mentioned in the content.
pub p_tagged: HashSet<PublicKey>,
/// Event IDs and optional relay URLs for quoted events.
pub quotes: HashSet<(EventId, Option<RelayUrl>)>,
/// Hashtags found in the content.
pub hashtags: HashSet<String>,
/// Relays where mentioned users and quoted authors are read.
pub write_relays: HashSet<RelayUrl>,
}
/// A client for interacting with the Nostr relays
pub struct NostrClient {
/// The underlying Nostr client implementation
client: Client,
}
impl ContentDetails {
/// Create a new [`ContentDetails`] instance
pub fn new(
users: impl IntoIterator<Item = PublicKey>,
quotes: impl IntoIterator<Item = (EventId, Option<RelayUrl>)>,
hashtags: impl IntoIterator<Item = String>,
write_relays: impl IntoIterator<Item = RelayUrl>,
) -> Self {
Self {
p_tagged: HashSet::from_iter(users),
quotes: HashSet::from_iter(quotes),
hashtags: HashSet::from_iter(hashtags),
write_relays: HashSet::from_iter(write_relays),
}
}
/// Converts the instance into a list of tags including hashtags, p-tagged
/// users, and quoted events.
pub fn into_tags(self) -> Tags {
let mut tags = Tags::new();
tags.extend(self.hashtags.into_iter().map(Tag::hashtag));
tags.extend(self.p_tagged.into_iter().map(Tag::public_key));
tags.extend(self.quotes.into_iter().map(|(event_id, relay_url)| {
// TODO: Add the author public key if we know it
Tag::from_standardized(TagStandard::Quote {
event_id,
relay_url,
public_key: None,
})
}));
tags
}
}
impl NostrClient {
/// Creates a new [`NostrClient`] with the given client and options.
const fn new(client: Client) -> Self {
Self { client }
}
/// Initializes a new [`NostrClient`] instance and connects to the specified
/// relays.
pub async fn init(options: &CliOptions, relays: &[RelayUrl]) -> Self {
let client = Self::new(
Client::builder()
.signer(Keys::new(
options
.secret_key
.as_ref()
.expect("This the only method for now")
.clone(),
))
.build(),
);
client.add_relays(relays).await;
client
}
/// Add relays and connect to them
pub async fn add_relays(&self, relays: &[RelayUrl]) {
if relays.is_empty() {
return;
}
let mut tasks = Vec::new();
for relay in relays {
let relay = relay.clone();
let client = self.client.clone();
tasks.push(tokio::spawn(async move {
client
.add_relay(&relay)
.await
.expect("It's a valid relay url");
if let Err(err) = client.try_connect_relay(&relay, CLIENT_TIMEOUT).await {
tracing::error!("Failed to connect to relay '{relay}': {err}");
}
}));
}
future::join_all(tasks).await;
}
/// Add a relay hint and connect to it
pub async fn add_relay_hint(&self, hint: Option<RelayUrl>) {
if let Some(relay) = hint {
self.add_relays(&[relay]).await
}
}
/// broadcast an event to the given relays
pub async fn broadcast(&self, event: &Event, relays: &[RelayUrl]) -> N34Result<()> {
self.client.send_event_to(relays, event).await?;
Ok(())
}
/// Broadcasts an unsigned event to given relays, optionally broadcast the
/// relays list event. Returns URLs of relays that successfully received
/// the event.
pub async fn send_event_to(
&self,
mut event: UnsignedEvent,
relays_list: Option<&Event>,
relays: &[RelayUrl],
) -> N34Result<Vec<RelayUrl>> {
self.add_relays(relays).await;
let event_id = event.id();
let (result, ..) = futures::join!(
async {
N34Result::Ok(
self.client
.send_event_to(relays, &event.sign(&self.client.signer().await?).await?)
.await?,
)
},
async {
if let Some(event) = relays_list {
let _ = self.client.send_event_to(relays, event).await;
}
}
);
let result = result?;
for relay in &result.success {
tracing::info!(event_id = %event_id, relay = %relay, "Event sent successfully");
}
for (relay, reason) in &result.failed {
tracing::warn!(event_id = %event_id, relay = %relay, reason = %reason, "Failed to send event");
}
Ok(result.success.into_iter().collect())
}
/// Fetches the first event matching the given filter, or None if no event
/// is found.
pub async fn fetch_event(&self, filter: Filter) -> N34Result<Option<Event>> {
Ok(self
.client
.fetch_events(filter, CLIENT_TIMEOUT)
.await?
.first_owned())
}
/// Try to fetch the reposotoies and returns them
pub async fn fetch_repos(
&self,
repo_naddrs: &[Coordinate],
) -> N34Result<Vec<GitRepositoryAnnouncement>> {
future::join_all(repo_naddrs.iter().map(|c| {
async {
self.fetch_event(
Filter::new()
.author(c.public_key)
.identifier(&c.identifier)
.kind(Kind::GitRepoAnnouncement),
)
.await?
.map(|e| utils::event_into_repo(e, &c.identifier))
.ok_or(N34Error::NotFoundRepo)
}
}))
.await
.into_iter()
.collect()
}
/// Finds the root issue or patch for a given event. If the event is already
/// a root (issue/patch), returns it directly. For comments, follows
/// parent/root references until finding the root or failing. Returns
/// None if no root can be found.
pub async fn find_root(&self, mut event: Event) -> N34Result<Option<Event>> {
if !matches!(event.kind, Kind::GitIssue | Kind::GitPatch | Kind::Comment) {
return Err(N34Error::CanNotReplyToEvent);
}
loop {
if matches!(event.kind, Kind::GitIssue | Kind::GitPatch) {
return Ok(Some(event));
}
if let Some(nip22::CommentTarget::Event { id, relay_hint, .. }) =
nip22::extract_root(&event)
{
self.add_relay_hint(relay_hint.cloned()).await;
let root_event = self.fetch_event(Filter::new().id(*id)).await?;
if let Some(ref root_event) = root_event {
if !matches!(root_event.kind, Kind::GitIssue | Kind::GitPatch) {
return Err(N34Error::CanNotReplyToEvent);
}
}
return Ok(root_event);
} else if let Some(nip22::CommentTarget::Event { id, relay_hint, .. }) =
nip22::extract_parent(&event)
{
self.add_relay_hint(relay_hint.cloned()).await;
if let Ok(Some(parent_event)) = self.fetch_event(Filter::new().id(*id)).await {
event = parent_event;
continue;
}
}
// Break if: no root/parent tags found, parent/root event fetch failed
break;
}
Ok(None)
}
/// Fetches the relay list (kind 10002) for the given user. Returns None if
/// no relays are found.
pub async fn user_relays_list(&self, user: PublicKey) -> N34Result<Option<Event>> {
self.fetch_event(Filter::new().author(user).kind(Kind::RelayList))
.await
}
/// Gets the author of the specified event, if found.
pub async fn event_author(&self, event_id: EventId) -> N34Result<Option<PublicKey>> {
Ok(self
.fetch_event(Filter::new().id(event_id))
.await?
.map(|e| e.pubkey))
}
/// Returns the read relays of the given user if found, otherwise empty
/// vector
pub async fn read_relays_from_user(&self, user: PublicKey) -> Vec<RelayUrl> {
utils::add_read_relays(self.user_relays_list(user).await.ok().flatten().as_ref())
}
/// Parse the given content and returns the details that inside it
pub async fn parse_content(&self, content: &str) -> ContentDetails {
let mut write_relays = Vec::new();
let tokens = NostrParser::new().parse(content).collect::<Vec<_>>();
let mut p_tagged_users = tokens
.iter()
.filter_map(TokenUtils::extract_public_key)
.collect::<Vec<_>>();
let quotes = tokens
.iter()
.filter_map(TokenUtils::extract_event_id)
.collect::<Vec<_>>();
let hashtags = tokens
.iter()
.filter_map(TokenUtils::extract_hashtag)
.collect::<Vec<_>>();
for (user, relays) in &p_tagged_users {
self.add_relays(relays).await;
write_relays.extend(self.read_relays_from_user(*user).await);
}
for (event_id, relays) in &quotes {
self.add_relays(relays).await;
// Add the event author to the p-tagged users
if let Ok(Some(author)) = self.event_author(*event_id).await {
p_tagged_users.push((author, Vec::new()));
write_relays.extend(self.read_relays_from_user(author).await);
}
}
ContentDetails::new(
p_tagged_users.into_iter().map(|(p, _)| p),
quotes.into_iter().map(|(e, r)| (e, r.first().cloned())),
hashtags,
write_relays,
)
}
}

191
src/nostr_utils/traits.rs Normal file
View File

@@ -0,0 +1,191 @@
// 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 convert_case::{Case, Casing};
use nostr::{
event::{EventBuilder, EventId, Tag, TagKind, TagStandard, Tags},
key::PublicKey,
nips::{
nip01::Coordinate,
nip19::Nip19Coordinate,
nip21::Nip21,
nip34::{GitIssue, GitRepositoryAnnouncement},
},
parser::Token,
types::{RelayUrl, Url},
};
use crate::error::{N34Error, N34Result};
/// A trait to add helper instance function to [`Tags`] type
#[easy_ext::ext(TagsExt)]
impl Tags {
/// Search for the given tag and map it value to a function
pub fn map_tag<T>(&self, kind: TagKind, f: impl FnOnce(&TagStandard) -> T) -> Option<T> {
self.find_standardized(kind).map(f)
}
/// Search for the given tag and map it value to a function. If the tag not
/// found return the default `T`
pub fn dmap_tag<T>(&self, kind: TagKind, f: impl FnOnce(&TagStandard) -> T) -> T
where
T: Default,
{
self.map_tag(kind, f).unwrap_or_default()
}
/// Finds the first standard tag of the given kind with the specified
/// marker, then applies the function to the tag and returns the result.
pub fn map_marker<T>(
&self,
kind: TagKind,
marker: &str,
f: impl FnOnce(&TagStandard) -> T,
) -> Option<T> {
self.filter_standardized(kind)
.find(|t| (*t).clone().to_vec().last().is_some_and(|m| m == marker))
.map(f)
}
}
/// Trait for building [`GitRepositoryAnnouncement`] events
#[easy_ext::ext(NewGitRepositoryAnnouncement)]
impl EventBuilder {
/// Creates a new [`GitRepositoryAnnouncement`] event builder with the given
/// repository details.
#[allow(clippy::too_many_arguments)]
pub fn new_git_repo(
repo_id: String,
name: Option<String>,
description: Option<String>,
web: Vec<Url>,
clone: Vec<Url>,
relays: Vec<RelayUrl>,
maintainers: Vec<PublicKey>,
labels: Vec<String>,
force_id: bool,
) -> N34Result<EventBuilder> {
let repo_id = repo_id.trim();
let kebab_repo_id = repo_id.to_case(Case::Kebab);
if repo_id.is_empty() || (!force_id && repo_id != kebab_repo_id) {
if repo_id != kebab_repo_id {
tracing::error!(
"The repo id should be `{kebab_repo_id}` (kebab-case). Use `--force-id` to \
override this check"
);
}
return Err(N34Error::InvalidRepoId);
}
Ok(
EventBuilder::git_repository_announcement(GitRepositoryAnnouncement {
id: repo_id.to_owned(),
name,
description,
web,
clone,
relays,
euc: None,
maintainers,
})?
.dedup_tags()
.tags(labels.into_iter().map(Tag::hashtag)),
)
}
/// Creates a new [`GitIssue`] event builder with the given
/// issue details.
pub fn new_git_issue(
repository: Coordinate,
content: String,
subject: Option<String>,
labels: Vec<String>,
) -> N34Result<EventBuilder> {
EventBuilder::git_issue(GitIssue {
repository,
content,
subject,
labels: labels.into_iter().map(|l| l.trim().to_owned()).collect(),
})
.map_err(N34Error::from)
}
}
/// Helper functions for [`Token`] type
#[easy_ext::ext(TokenUtils)]
impl Token<'_> {
/// Returns `Some((public_key, relays))` from the givin token if it's npub1
/// or nprofile1
pub fn extract_public_key(&self) -> Option<(PublicKey, Vec<RelayUrl>)> {
match self {
Token::Nostr(nip21) => {
match nip21 {
Nip21::Pubkey(pkey) => Some((*pkey, Vec::new())),
Nip21::Profile(profile) => Some((profile.public_key, profile.relays.clone())),
_ => None,
}
}
_ => None,
}
}
/// Returns `Some((note_id, relays))` from the givin token if it's note1 or
/// nevent1
pub fn extract_event_id(&self) -> Option<(EventId, Vec<RelayUrl>)> {
match self {
Token::Nostr(nip21) => {
match nip21 {
Nip21::EventId(event_id) => Some((*event_id, Vec::new())),
Nip21::Event(event) => Some((event.event_id, event.relays.clone())),
_ => None,
}
}
_ => None,
}
}
/// Returns `Some(hashtag)` from the givin token if it's a hashtag
pub fn extract_hashtag(&self) -> Option<String> {
match self {
Token::Hashtag(tag) => Some(tag.trim().to_owned()),
_ => None,
}
}
}
/// Utility functions for working with lists of NIP-19 coordinates
#[easy_ext::ext(NaddrsUtils)]
impl Vec<Nip19Coordinate> {
/// Converts these coordinate addresses to basic coordinates
pub fn into_coordinates(self) -> Vec<Coordinate> {
self.into_iter().map(|n| n.coordinate).collect()
}
/// Extracts all relay URLs from these coordinates
pub fn extract_relays(&self) -> Vec<RelayUrl> {
self.iter().flat_map(|n| n.relays.clone()).collect()
}
}
/// Utility functions for working with lists of repository announcement
#[easy_ext::ext(ReposUtils)]
impl Vec<GitRepositoryAnnouncement> {
/// Extracts all relay URLs from these reposotoies
pub fn extract_relays(&self) -> Vec<RelayUrl> {
self.iter().flat_map(|n| n.relays.clone()).collect()
}
}

239
src/nostr_utils/utils.rs Normal file
View File

@@ -0,0 +1,239 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::{
fmt,
fs,
path::{Path, PathBuf},
str::FromStr,
sync::atomic::Ordering,
};
use nostr::{
event::{Event, EventId, Kind, TagKind, TagStandard},
filter::{Alphabet, SingleLetterTag},
key::PublicKey,
nips::{
nip01::Coordinate,
nip19::{Nip19Coordinate, Nip19Event, ToBech32},
nip34::GitRepositoryAnnouncement,
nip65::{self, RelayMetadata},
},
types::RelayUrl,
};
use super::traits::TagsExt;
use crate::{
cli::{NOSTR_ADDRESS_FILE, parsers},
error::{N34Error, N34Result},
};
/// Returns the value of the given tag
fn tag_value(tag: &TagStandard) -> String {
tag.clone().to_vec().remove(1)
}
/// Parses the tag value into type `T` if possible.
fn parse_value<T: FromStr>(tag: &TagStandard) -> Option<T> {
tag_value(tag).parse().ok()
}
/// Gets all values from the tag. If any value fails to parse, returns an empty
/// vector.
fn tag_values<T>(tag: &TagStandard) -> Vec<T>
where
T: FromStr + fmt::Debug,
<T as FromStr>::Err: fmt::Debug,
{
tag.clone()
.to_vec()
.into_iter()
.skip(1)
.map(|t| {
let result = T::from_str(t.as_str());
tracing::trace!("Parsing `{t}` result: `{result:?}`");
result
})
.collect::<Result<_, _>>()
.unwrap_or_default()
}
/// Convert [`Event`] to [`GitRepositoryAnnouncement`]
pub fn event_into_repo(event: Event, repo_id: impl Into<String>) -> GitRepositoryAnnouncement {
let tags = &event.tags;
GitRepositoryAnnouncement {
id: repo_id.into(),
name: tags.map_tag(TagKind::Name, tag_value),
description: tags.map_tag(TagKind::Description, tag_value),
euc: tags
.map_marker(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::R)),
"euc",
parse_value,
)
.flatten(),
web: tags.dmap_tag(TagKind::Web, tag_values),
clone: tags.dmap_tag(TagKind::Clone, tag_values),
relays: tags.dmap_tag(TagKind::Relays, tag_values),
maintainers: tags.dmap_tag(TagKind::Maintainers, tag_values),
}
}
/// Returns a new string with leading and trailing whitespace removed.
pub fn str_trim(s: String) -> String {
s.trim().to_owned()
}
/// Returns a vector with duplicate elements removed.
pub fn dedup<I, T>(iter: I) -> Vec<T>
where
T: std::cmp::Ord,
I: Iterator<Item = T>,
{
let mut vector: Vec<T> = iter.collect();
vector.sort_unstable();
vector.dedup();
vector
}
/// Creates a new NIP-19 nevent string from an event ID and up to 3 unique relay
/// URLs.
pub fn new_nevent(event_id: EventId, relays: &[RelayUrl]) -> N34Result<String> {
Nip19Event::new(event_id)
.relays(
dedup(relays.iter().cloned())
.into_iter()
.take(3)
.collect::<Vec<_>>(),
)
.to_bech32()
.map_err(N34Error::from)
}
/// Creates a NIP-19 naddr string for a git repository announcement and up to 3
/// unique relay URLs.
pub fn repo_naddr(
repo_id: impl Into<String>,
pubk: PublicKey,
relays: &[RelayUrl],
) -> N34Result<String> {
Nip19Coordinate::new(
Coordinate::new(Kind::GitRepoAnnouncement, pubk).identifier(repo_id),
dedup(relays.iter().cloned()).iter().take(3),
)
.expect("Valid relays")
.to_bech32()
.map_err(N34Error::from)
}
/// Extracts write relay URLs from an event if present, otherwise returns an
/// empty vector.
pub fn add_write_relays(event: Option<&Event>) -> Vec<RelayUrl> {
let mut vector = Vec::new();
if let Some(event) = event {
vector.extend(
nip65::extract_owned_relay_list(event.clone())
.filter_map(|(r, m)| m.is_none_or(|m| m == RelayMetadata::Write).then_some(r)),
);
}
vector
}
/// Extracts read relay URLs from an event if present, otherwise returns an
/// empty vector.
pub fn add_read_relays(event: Option<&Event>) -> Vec<RelayUrl> {
let mut vector = Vec::new();
if let Some(event) = event {
vector.extend(
nip65::extract_owned_relay_list(event.clone())
.filter_map(|(r, m)| m.is_none_or(|m| m == RelayMetadata::Read).then_some(r)),
);
}
vector
}
/// Opens the user's default editor ($EDITOR) to edit a temporary file with
/// given suffix, then reads and returns the file contents. The temporary file
/// is automatically deleted.
pub fn read_editor(file_pre_content: Option<&str>, file_suffix: &str) -> N34Result<String> {
let Ok(editor) = std::env::var("EDITOR") else {
return Err(N34Error::EditorNotFound);
};
let temp_path = tempfile::NamedTempFile::with_suffix(file_suffix)?.into_temp_path();
if let Some(pre_content) = file_pre_content {
fs::write(&temp_path, pre_content)?;
}
// Disable the logs to not show up in a terminal text editor
crate::EDITOR_OPEN.store(true, Ordering::Relaxed);
let exit_status = std::process::Command::new(editor)
.arg(temp_path.to_str().expect("The path is valid utf8"))
.spawn()?
.wait()?;
crate::EDITOR_OPEN.store(false, Ordering::Relaxed);
if !exit_status.success() {
if let Some(code) = exit_status.code() {
tracing::warn!("The editor exit with `{code}` status")
}
}
let content = fs::read_to_string(&temp_path)
.map_err(N34Error::from)?
.trim()
.to_owned();
if content.is_empty() {
return Err(N34Error::EmptyEditorFile);
}
Ok(content)
}
/// Returns the given content if it's `Option::Some` or call [`read_editor`]
pub fn get_content(
content: Option<impl AsRef<str>>,
quoted_content: Option<impl AsRef<str>>,
file_suffix: &str,
) -> N34Result<String> {
if let Some(content) = content {
return Ok(content.as_ref().trim().to_owned());
}
read_editor(
quoted_content.map(|s| s.as_ref().to_owned()).as_deref(),
file_suffix,
)
}
/// Path to the `nostr-address` file in current directory.
pub fn nostr_address_path() -> std::io::Result<PathBuf> {
std::env::current_dir().map(|p| p.join(NOSTR_ADDRESS_FILE))
}
/// If the given coordinate is Some, return it. Otherwise, try to read and parse
/// the first non-comment line from the `nostr-address` file.
pub fn naddrs_or_file(
naddrs: Option<Vec<Nip19Coordinate>>,
address_file_path: &Path,
) -> N34Result<Vec<Nip19Coordinate>> {
if let Some(naddrs) = naddrs {
return Ok(naddrs);
}
parsers::parse_nostr_address_file(address_file_path)
}