180 Commits

Author SHA1 Message Date
Awiteb
9aa3b62de0 chore: Bump the version to v0.4.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-08 15:21:35 +00:00
Awiteb
a8015d618b chore: Use nushell as a script interpreter
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-08 15:20:20 +00:00
Awiteb
2bcdb29a88 chore: Add nushell to dev shell
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-08 14:29:22 +00:00
Awiteb
4a4c0dcd34 chore: Show the lifetime
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-08 14:29:22 +00:00
Awiteb
43ca689190 chore: installation using Nix
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:26:11 +00:00
Awiteb
14ab9b38e2 chore(cargo): Add package.documentation and package.homepage
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:22:40 +00:00
Awiteb
802da70056 chore(flake): Add packages.default output
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:22:40 +00:00
Awiteb
cff0b2f3b2 chore(flake): Update lock file
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:22:40 +00:00
Awiteb
9c55bf8af1 chore: use the existing signer to get the pubkey instead of a new one
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:22:40 +00:00
Awiteb
904d14064f feat: Sign using NIP-07
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:22:40 +00:00
Awiteb
ad215002e2 chore: backward compatibility with ngit revision patch hashtag
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-04 09:22:45 +00:00
Awiteb
67339a8a47 chore: verify subscriptions
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-04 06:28:32 +00:00
Awiteb
1d83e445a4 deps: Upgrade to nostr@0.43.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-03 15:24:05 +00:00
Awiteb
8bb4cf0da4 refactor: Move root and root-revision to constants
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-03 15:24:05 +00:00
Awiteb
e17e75e53b refactor: Move the trait extensions to traits.rs
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-03 15:24:04 +00:00
Awiteb
67e25da8c0 feat: New flag to patch apply and merge to mention patches
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-03 15:24:04 +00:00
Awiteb
bc8c6f3154 deps: Remove url from n34 dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-03 00:17:30 +00:00
Awiteb
0ba58dcbd0 tests: Move the patch tests to tests.rs
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-02 09:50:21 +00:00
Awiteb
78113c7402 docs: Fix status command docs
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-02 09:14:21 +00:00
Awiteb
bd3ba1bdbf docs: n34 book
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-01 20:32:48 +00:00
Awiteb
2630104e99 chore: Fix a typo in the description
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-01 20:17:42 +00:00
Awiteb
03d5c8020e feat: Keyring the secret key n34 config keyring --enable
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-27 00:55:53 +00:00
Awiteb
c3aef2e0ad chore: Add installation section to README.md
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-18 22:24:03 +00:00
Awiteb
4e0ecdceaf feat: Support signing using NIP-46 bunker
Suggested-by: DanConwayDev <DanConwayDev@protonmail.com>
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-18 21:43:50 +00:00
Awiteb
f0c20c3677 deps: Add keyring, nostr-connect, nostr-keyring and url to the dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-18 21:17:02 +00:00
Awiteb
e6943aefab chore(flake): Add dbus to the packages
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-10 12:05:14 +00:00
Awiteb
353358f0ed chore: Move the naddr parsers to parsers.rs
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-07 12:35:01 +00:00
Awiteb
c029ddf86c chore: Bump the version to v0.3.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 12:20:19 +00:00
Awiteb
fc7d7baeb6 chore: Bump the MSRV to 1.88.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 12:16:37 +00:00
Awiteb
8715cd5547 chore: Use cargo-msrv to check the msrv instead of rustup
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 12:15:53 +00:00
Awiteb
70e76f1699 chore: Update patch filename test case
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 12:03:31 +00:00
Awiteb
9e840be820 chore: Use new stabilized let-chains feature
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 09:34:32 +00:00
Awiteb
3fb7abe62a chore: Fix docs issues
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 08:48:34 +00:00
Awiteb
3814d1b4ec chore: Inlined format args
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 08:45:13 +00:00
Awiteb
1c11b8f6bb chore: Add rust-analyzer to flake.nix
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 08:41:28 +00:00
Awiteb
74d05033f8 chore: Update flake.lock
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 08:33:13 +00:00
Awiteb
9bbeda8de8 chore: Update rustfmt nightly version
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 08:32:59 +00:00
Awiteb
3510b59fbb feat: Improve exit codes and make them more specific
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-04 10:50:24 +00:00
Awiteb
05b4ae30f7 feat: New issue list commands to list the repo issues
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-02 22:07:03 +00:00
Awiteb
387dd32a37 feat: New patch list commands to list the repo patches
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-02 22:07:03 +00:00
Awiteb
5c5987e07b chore: Enable optimization
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-01 15:20:44 +00:00
Awiteb
9e1e5d7c42 chore: Inline small functions
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-01 15:20:30 +00:00
Awiteb
7e479f3c7a chore: issues utils
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-01 15:07:04 +00:00
Awiteb
4cc01668fd fix: Require a repo in repo view command
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-01 12:47:03 +00:00
Awiteb
7d700602ef fix: Fix a typo in EmptySetRelays error message
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-01 12:45:53 +00:00
DanConwayDev
7c589f1c6c fix: spelling in help content
reposotoies ~> repositories
2025-06-30 12:10:26 +00:00
Awiteb
f3a09d30ea chore: Update SignerRequired error message
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-30 11:33:23 +00:00
Awiteb
2874ba8457 remove: Remove --euc flag from patch send command and use the repo euc
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-30 11:17:05 +00:00
Awiteb
55e5d86c03 deps: Remove bitcoin_hashes and use nostr::hashes re-export
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-30 11:16:52 +00:00
Awiteb
6467bc3bb5 remove: Remove the --to flag from reply command and make it an argument
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-29 20:24:57 +00:00
Awiteb
da284a084c feat: View the repo maintainers as npub
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-29 20:20:58 +00:00
Awiteb
45ea7d25f2 remove: Remove the --repo option and make the repo an argument
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-29 20:17:14 +00:00
Awiteb
8b09cff5e3 feat: New patch subcommands apply,close,draft,merge and reopen to manage the patch status
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-28 13:47:31 +00:00
Awiteb
d6dd63f576 chore: Use bitcoin_hashes::Sha1 instead of bitcoin_hashes::sha1::Hash
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-28 12:51:43 +00:00
Awiteb
ae6cff1a1d chore(patch-fetch): Remove the repo coordinate from the filter
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-28 12:49:08 +00:00
Awiteb
a38a811cbf deps: Update nostr to 0.42.2
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-28 12:47:40 +00:00
Awiteb
93136fb767 deps: Add either@1.15.0 to the dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-28 12:46:49 +00:00
Awiteb
035e22a6ba chore: Fix typos
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-28 10:29:38 +00:00
Awiteb
043ef6fc96 chore: Add the repository to authorized issue status users
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-24 11:40:48 +00:00
Awiteb
54cae48123 chroe: Check if the naddrs exists for issue new and patch send
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-14 10:46:55 +00:00
Awiteb
1651fd5a47 fix: Not to return an error if nostr-address file does not exist
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-14 10:45:00 +00:00
Awiteb
ec08a1febc chore: Improve maintainers handling
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-14 10:36:01 +00:00
Awiteb
a9a2cb2b16 feat: New issue {reopen,close,resolve} commands to manage issue status
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-14 09:38:00 +00:00
Awiteb
63f6c1fb11 chore(cli): Improve run_command macro
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-11 16:54:33 +00:00
Awiteb
3e6fed8bc6 chore: Remove unnecessary ToString::to_string call
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-11 10:28:11 +00:00
Awiteb
431bfd2512 chore: Update CONTRIBUTING.md
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-11 10:18:03 +00:00
Awiteb
364356a573 feat: New patch fetch command to fetch patches
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-10 16:23:08 +00:00
Awiteb
2a3e2e09c0 chore: Improve single letter tags
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-10 08:53:22 +00:00
Awiteb
8870f8ced3 chore: Fix a typo
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-10 08:41:36 +00:00
Awiteb
da96985a40 feat: New issue view command to view an issue
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-09 22:09:55 +00:00
Awiteb
76b76bc8d6 chore(nostr_utils): Move the username logic to the utils
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-09 21:12:17 +00:00
Awiteb
983d2f209d chore(NostrClient): Limit the events to 1 in fetch_event function
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-09 10:29:17 +00:00
Awiteb
7ba98bbbe8 chore: Update README.md
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-09 10:01:35 +00:00
Awiteb
8f70778c26 chore: Improve unreachable panic message
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-08 20:35:49 +00:00
Awiteb
9069adbe22 chore: Add the signer to the client if found
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-08 20:35:49 +00:00
Awiteb
b1027b3b04 refactor: Improve commands running and make the signer optional for some commands
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-08 20:35:49 +00:00
Awiteb
5dc8e31eb3 feat: New config relays command to set the default fallbacks relays
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-07 21:12:34 +00:00
Awiteb
51bd239024 feat: New config pow command to set the default PoW difficulty
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-07 19:54:40 +00:00
Awiteb
53a2eb5bda chore: Remove toml prefix from CliConfig::{load,dump}_toml
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-07 19:01:53 +00:00
Awiteb
a6a61aedf1 refactor: Store the config in CliOptions instead of its path
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-07 19:00:29 +00:00
Awiteb
30b3056d39 test: Add a new patch parsing unit test
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-06 21:23:27 +00:00
Awiteb
8fe6755d3b chore: Run the tests in the CI
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-06 21:15:33 +00:00
Awiteb
812a06a276 feat: Add description tag to the patch
The tag value is the patch subject

Signed-off-by: Awiteb <a@4rs.nl>
2025-06-04 22:10:10 +00:00
Awiteb
494cced9cb feat: Add alt tag to the git issue
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-04 22:05:21 +00:00
Awiteb
16e6300c81 chore(cli): Return Future and make the function sync CommandRunner:run
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-04 22:04:48 +00:00
Awiteb
ef8d6c1c4f feat: New patch send command to send patches
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-04 12:45:36 +00:00
Awiteb
01f5fa60e6 chore(issue-new): Add naddrs relays to write relays
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-04 06:56:18 +00:00
Awiteb
7d9b968f16 chore(cli): Add author read relays to discovery relays
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-03 14:28:04 +00:00
Awiteb
57fc868b63 chore: Move NostrEvent to crate::cli::types
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-03 09:27:20 +00:00
Awiteb
08505151bd chore: More steps in Patch Guidelines
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-02 17:54:41 +00:00
Awiteb
864f3018f6 chore: Update README.md
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-01 12:46:10 +00:00
Awiteb
37128a8c1a chore(justfile): Improve the changelog body
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-01 12:45:25 +00:00
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
97 changed files with 11194 additions and 82 deletions

1
.gitignore vendored
View File

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

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,131 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.0] - 2025-08-08
### Added
- Support signing using NIP-46 bunker - by Awiteb
- Keyring the secret key `n34 config keyring --enable` - by Awiteb
- New flag to `patch apply and merge` to mention patches - by Awiteb
- Sign using NIP-07 - by Awiteb
### Dependencies
- Add `keyring`, `nostr-connect`, `nostr-keyring` and `url` to the dependencies - by Awiteb
- Remove `url` from `n34` dependencies - by Awiteb
- Upgrade to `nostr@0.43.0` - by Awiteb
### Documentation
- N34 book - by Awiteb
- Fix status command docs - by Awiteb
### Refactor
- Move the trait extensions to `traits.rs` - by Awiteb
- Move `root` and `root-revision` to constants - by Awiteb
## [0.3.0] - 2025-07-05
### Added
- New `patch send` command to send patches - by Awiteb
- Add `alt` tag to the git issue - by Awiteb
- Add `description` tag to the patch - by Awiteb
- New `config pow` command to set the default PoW difficulty - by Awiteb
- New `config relays` command to set the default fallbacks relays - by Awiteb
- New `issue view` command to view an issue - by Awiteb
- New `patch fetch` command to fetch patches - by Awiteb
- New `issue {reopen,close,resolve}` commands to manage issue status - by Awiteb
- New `patch` subcommands apply,close,draft,merge and reopen to manage the patch status - by Awiteb
- View the repo maintainers as `npub` - by Awiteb
- New `patch list` commands to list the repo patches - by Awiteb
- New `issue list` commands to list the repo issues - by Awiteb
- Improve exit codes and make them more specific - by Awiteb
### Dependencies
- Add `either@1.15.0` to the dependencies - by Awiteb
- Update `nostr` to `0.42.2` - by Awiteb
- Remove `bitcoin_hashes` and use `nostr::hashes` re-export - by Awiteb
### Fixed
- Not to return an error if `nostr-address` file does not exist - by Awiteb
- Spelling in help content - by DanConwayDev
- Fix a typo in `EmptySetRelays` error message - by Awiteb
- Require a repo in `repo view` command - by Awiteb
### Refactor
- Store the config in `CliOptions` instead of its path - by Awiteb
- Improve commands running and make the signer optional for some commands - by Awiteb
### Removed
- Remove the `--repo` option and make the repo an argument - by Awiteb
- Remove the `--to` flag from `reply` command and make it an argument - by Awiteb
- Remove `--euc` flag from `patch send` command and use the repo euc - by Awiteb
## [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.4.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.4.0
[0.3.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.3.0
[0.2.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.2.0
[0.1.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.1.0
[0.0.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.0.0
<!-- generated by git-cliff -->

115
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,115 @@
# 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`
When using `n34`, there's no need to specify the address, it will automatically
check the `nostr-address` file. Simply submit your issues and patches without
worrying about this detail.
## 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`
- Use the `--cover-letter` flag to include a cover letter with your patch. Describe the issue youre addressing, whether its a one-line bug fix or a 5000-line new feature.
- Specify the base commit for your patch using the `--base` flag.
- First-time contributors: Review the [Submitting Patches guide](https://www.kernel.org/doc/html/latest/process/submitting-patches.html) before sending your patch.
- If you revise your patch, you should reference all previous revisions (or the
root patch if this is the first revision) and explain the changes made (i.e.,
the differences between this patch and the prior one).
#### 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/

2962
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,72 @@
[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 code in Nostr"
version = "0.4.0"
edition = "2024"
license = "GPL-3.0-or-later"
authors = ["Awiteb <a@4rs.nl>"]
readme = "README.md"
documentation = "https://n34.dev/commands.html"
homepage = "https://n34.dev"
repository = "https://git.4rs.nl/awiteb/n34.git"
keywords = ["nostr", "NIP-34", "CLI"]
categories = ["command-line-utilities"]
rust-version = "1.88.0"
[dependencies]
chrono = "0.4.41"
convert_case = "0.8.0"
dirs = "6.0.0"
easy-ext = "1.0.2"
either = "1.15.0"
futures = "0.3.31"
nostr-browser-signer-proxy = "0.43.0"
regex = "1.11.1"
tempfile = "3.20.0"
thiserror = "2.0.12"
toml = "0.9.4"
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
[dependencies.clap]
features = ["derive"]
version = "4.5.42"
[dependencies.clap-verbosity-flag]
default-features = false
features = ["tracing"]
version = "3.0.3"
[dependencies.nostr]
default-features = false
features = ["std"]
version = "0.43.0"
[dependencies.nostr-connect]
default-features = false
version = "0.43.0"
[dependencies.nostr-keyring]
default-features = false
version = "0.43.0"
[dependencies.nostr-sdk]
default-features = false
version = "0.43.0"
[dependencies.reqwest]
default-features = false
features = ["http2", "json", "rustls-tls"]
version = "0.12.22"
[dependencies.serde]
features = ["derive"]
version = "1.0.219"
[dependencies.tokio]
features = ["macros", "rt-multi-thread"]
version = "1.47.1"
[profile.release]
lto = "fat"
opt-level = 3

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.

135
README.md
View File

@@ -1,9 +1,142 @@
# n34
A CLI to interact with NIP-34 and other stuff related to codes in nostr
A CLI to interact with NIP-34 and other stuff related to code in Nostr
## About
`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.
## Documentation
Check the documentation at [n34.dev]
## Features
- [X] Repository announcements
- [ ] Repository state announcements
- [X] Patches (Send, fetch and list)
- [X] Issues (Send, view and list)
- [X] Replies
- [X] Issues and patches status
- [ ] Pull requests ([nostr-protocol/nips#1966])
- [X] Gossip Model ([NIP-65])
- [X] Proof of Work ([NIP-13])
- [X] `nostr:` URI scheme, in the issue/reply content ([NIP-21])
- [X] Signing using bunker ([NIP-46])
- [X] Signing using [NIP-07] proxy ([nostr-browser-signer-proxy])
- [ ] Code Snippets ([NIP-C0])
- [X] In device relays and repos bookmark (`sets` command)
## 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>
## Installation
You can install n34 either by cloning the repository and building it with Cargo,
or by using `cargo install` or Nix.
### Building from source
- Clone the repository:
```sh
git clone git://git.4rs.nl/awiteb/n34.git
cd n34
```
- Build the release version:
```sh
cargo build --release
```
The binary will be available at `target/release/n34`.
### Using cargo install
```sh
cargo install n34
```
The binary will be installed to your Cargo binary directory (typically `~/.cargo/bin/n34`).
Make sure `~/.cargo/bin` is in your `PATH` environment variable to run the binary from anywhere.
### Using `nix build` (+v0.4)
- Clone the repository.
- Run the `nix build` command.
The binary will be available at `result/bin/n34`.
### Adding it to your [home-manager] (+v0.4)
- Add it as an input to your `flake.nix`:
```nix
inputs = {
# Specify the version you want to install, or remove `?ref` for the unreleased
# version. You can also use any mirror; it doesn't have to be `git.4rs.nl`.
n34.url = "git+https://git.4rs.nl/awiteb/n34.git?ref=refs/tags/vx.y.x";
};
```
- Add it to your packages (ensure your home-manager `extraSpecialArgs` includes the `inputs`):
```nix
packages = [ inputs.n34.packages."${pkgs.system}".default ];
```
## Contributing
Contributions to `n34` are welcome! You can help by opening issues (such as bug
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
[NIP-46]: https://github.com/nostr-protocol/nips/blob/master/46.md
[NIP-07]: https://github.com/nostr-protocol/nips/blob/master/07.md
[nostr-protocol/nips#1966]: https://github.com/nostr-protocol/nips/pull/1966
[nostr-browser-signer-proxy]: https://crates.io/crates/nostr-browser-signer-proxy
[home-manager]: https://github.com/nix-community/home-manager
[n34.dev]: https://n34.dev

10
book.toml Normal file
View File

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

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

34
docs/SUMMARY.md Normal file
View File

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

66
docs/commands.md Normal file
View File

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

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

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

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

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

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

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

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

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

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

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

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

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

482
docs/index.html Normal file
View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

28
docs/reply.md Normal file
View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

12
flake.lock generated
View File

@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1745930157,
"narHash": "sha256-y3h3NLnzRSiUkYpnfvnS669zWZLoqqI6NprtLQ+5dck=",
"lastModified": 1754498491,
"narHash": "sha256-erbiH2agUTD0Z30xcVSFcDHzkRvkRXOQ3lb887bcVrs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "46e634be05ce9dc6d4db8e664515ba10b78151ae",
"rev": "c2ae88e026f9525daf89587f3cbee584b92b6134",
"type": "github"
},
"original": {
@@ -62,11 +62,11 @@
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1746067100,
"narHash": "sha256-6JeEbboDvRjLwB9kzCnmWj+f+ZnMtKOe5c2F1VBpaTs=",
"lastModified": 1754575663,
"narHash": "sha256-afOx8AG0KYtw7mlt6s6ahBBy7eEHZwws3iCRoiuRQS4=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "026e8fedefd6b167d92ed04b195c658d95ffc7a5",
"rev": "6db0fb0e9cec2e9729dc52bf4898e6c135bb8a0f",
"type": "github"
},
"original": {

View File

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

View File

@@ -4,46 +4,101 @@
# - Linux distribution
# - just (Of course) <https://github.com/casey/just>
# - cargo (For the build and tests) <https://doc.rust-lang.org/cargo/getting-started/installation.html>
# - mdbook (<https://rust-lang.github.io/mdBook>)
# - git-cliff (<https://git-cliff.org>)
# - taplo (<https://taplo.tamasfe.dev/>)
# - cargo-msrv (<https://github.com/foresterre/cargo-msrv>)
# - nushell (<https://nushell.sh>)
set quiet
set unstable
set shell := ["/usr/bin/env", "bash", "-c"]
set script-interpreter := ["/usr/bin/env", "bash", "-c"]
set script-interpreter := ["/usr/bin/env", "nu"]
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/'`
BOOK_DEST_DIR := "dest"
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
RUST_LOG=none taplo fmt --check --config "./.taplo.toml" || (echo "❌Toml files is not properly formatted" && exit 1)
echo "🧹 Running linter checks..."
cargo clippy -q -- -D warnings
echo "🧪 Running tests..."
cargo test -q
# 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..."
cargo-msrv verify
echo "✅ MSRV verification passed"
_done_ci:
echo "🎉 CI pipeline completed successfully"
# Update the changelog
[script]
change-log:
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'
echo 'The changes have been added to the changelog file and committed'
else
echo 'No changes have been added to the changelog'
fi
changelog:
def get_hash [] { open "./CHANGELOG.md" | hash sha256 }
let old_hash = get_hash
git-cliff out> "CHANGELOG.md"
if old_hash != get_hash {
git add "CHANGELOG.md"
git commit -m "chore(changelog): Update the changelog"
print "The changes have been added to the changelog file and committed"
} else {
print "No changes have been added to the changelog"
}
# Releases a new version of n34. Requires a clean file tree with no uncommitted changes.
[script]
release version:
let tag_msg = "Version {{ version }}" + (git-cliff --strip all --unreleased --body '{{ tag_change_body }}')
mut cargo_file = open "Cargo.toml"
$cargo_file.package.version = "{{ version }}"
$cargo_file | save -f "Cargo.toml"
RUST_LOG=none taplo fmt --config "./.taplo.toml"
{{ JUST_EXECUTABLE }} ci
git-cliff -t "v{{ version }}" out> "./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
# Deploy the book to Github Pages
deploy:
mdbook build --dest-dir {{ BOOK_DEST_DIR }}
cd {{ BOOK_DEST_DIR }}
git init .
git checkout -B gh-pages
touch ".nojekyll"
echo "n34.dev" > "CNAME"
git add .
git commit -m "Deploy the book to github pages"
git remote add origin "git@github.com:TheAwiteb/n34-book"
git push origin gh-pages -f
cd ..
rm -fr {{ BOOK_DEST_DIR }}
alias cl := change-log

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,46 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use clap::Args;
use nostr::nips::nip46::NostrConnectURI;
use crate::{
cli::{CliOptions, traits::CommandRunner},
error::{N34Error, N34Result},
};
#[derive(Args, Debug)]
pub struct BunkerArgs {
/// Nostr Connect URL for the bunker. Omit this to remove the current bunker
/// URL.
bunker_url: Option<NostrConnectURI>,
}
impl CommandRunner for BunkerArgs {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
if let Some(ref bunker_url) = self.bunker_url
&& !bunker_url.is_bunker()
{
return Err(N34Error::NotBunkerUrl);
}
options.config.bunker_url = self.bunker_url;
options.config.dump()
}
}

View File

@@ -0,0 +1,63 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use clap::{ArgGroup, Args};
use crate::{
cli::{Cli, CliOptions, traits::CommandRunner},
error::N34Result,
};
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("options")
.required(true)
)
)]
pub struct KeyringArgs {
/// Turns on secret key keyring. Requires entering the key once when
/// enabled.
#[arg(long, group = "options")]
enable: bool,
/// Turns off secret key keyring. Removes any existing key and prevents
/// storing new ones.
#[arg(long, group = "options")]
disable: bool,
/// Deletes current key and stores the next provided key.
#[arg(long, group = "options")]
reset: bool,
}
impl CommandRunner for KeyringArgs {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
let keyring = nostr_keyring::NostrKeyring::new(Cli::N34_KEYRING_SERVICE_NAME);
if self.enable {
options.config.keyring_secret_key = true;
} else if self.disable {
options.config.keyring_secret_key = false;
}
if self.reset || self.disable {
let _ = keyring.delete(Cli::USER_KEY_PAIR_ENTRY);
}
options.config.dump()
}
}

View File

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

View File

@@ -0,0 +1,58 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::net::SocketAddr;
use clap::{ArgGroup, Args};
use crate::{
cli::{CliOptions, options_state::DEFAULT_NIP07_PROXY_ADDR, traits::CommandRunner},
error::N34Result,
};
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("options")
.required(true)
)
)]
pub struct Nip07Args {
/// Enable NIP-07 as the default signer.
#[arg(long, group = "options")]
enable: bool,
/// Disable NIP-07 as the default signer.
#[arg(long, group = "options", group = "disable_options")]
disable: bool,
/// Set the default `ip:port` for the browser signer proxy.
#[arg(long, group = "disable_options")]
addr: Option<SocketAddr>,
}
impl CommandRunner for Nip07Args {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
if self.enable {
let addr = self.addr.unwrap_or(DEFAULT_NIP07_PROXY_ADDR);
options.config.nip07 = Some(addr)
} else {
options.config.nip07 = None
}
options.config.dump()
}
}

View File

@@ -0,0 +1,37 @@
// 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::{CliOptions, traits::CommandRunner},
error::N34Result,
};
#[derive(Args, Debug)]
pub struct PowArgs {
/// The new default PoW difficulty
difficulty: u8,
}
impl CommandRunner for PowArgs {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
options.config.pow = Some(self.difficulty);
options.config.dump()
}
}

View File

@@ -0,0 +1,53 @@
// 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::types::RelayUrl;
use crate::{
cli::{CliOptions, traits::CommandRunner},
error::N34Result,
};
#[derive(Args, Debug)]
pub struct RelaysArgs {
/// List of relay URLs to append to fallback relays. If empty, removes all
/// fallback relays.
relays: Vec<RelayUrl>,
/// Replace existing fallback relays instead of appending new ones.
#[arg(long = "override")]
override_relays: bool,
}
impl CommandRunner for RelaysArgs {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
if self.relays.is_empty() {
options.config.fallback_relays = None;
} else if self.override_relays {
options.config.fallback_relays = Some(self.relays);
} else {
let mut relays = options.config.fallback_relays.clone().unwrap_or_default();
relays.extend(self.relays);
relays.sort_unstable();
relays.dedup();
options.config.fallback_relays = Some(relays);
}
options.config.dump()
}
}

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 super::IssueStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct CloseArgs {
/// 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 open issue id to close it
issue_id: NostrEvent,
}
impl CommandRunner for CloseArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::issue_status_command(
options,
self.issue_id,
self.naddrs,
IssueStatus::Closed,
|issue_status| {
if issue_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't close an already closed issue".to_owned(),
));
}
if issue_status.is_resolved() {
return Err(N34Error::InvalidStatus(
"You can't close a resolved issue".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,46 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::num::NonZeroUsize;
use clap::Args;
use crate::{
cli::{CliOptions, common_commands, traits::CommandRunner, types::NaddrOrSet},
error::N34Result,
};
#[derive(Debug, Args)]
pub struct ListArgs {
/// 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")]
naddrs: Option<Vec<NaddrOrSet>>,
/// Maximum number of issues to list
#[arg(long, default_value = "15")]
limit: NonZeroUsize,
}
impl CommandRunner for ListArgs {
const NEED_SIGNER: bool = false;
async fn run(self, options: CliOptions) -> N34Result<()> {
common_commands::list_patches_and_issues(options, self.naddrs, false, self.limit.into())
.await
}
}

View File

@@ -0,0 +1,142 @@
// 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 close` subcommand
mod close;
/// `issue list` subcommand
mod list;
/// `issue new` subcommand
mod new;
/// `issue reopen` subcommand
mod reopen;
/// `issue resolve` subcommand
mod resolve;
/// `issue view` subcommand
mod view;
use std::fmt;
use clap::Subcommand;
use nostr::event::Kind;
use self::close::CloseArgs;
use self::list::ListArgs;
use self::new::NewArgs;
use self::reopen::ReopenArgs;
use self::resolve::ResolveArgs;
use self::view::ViewArgs;
use super::{CliOptions, CommandRunner};
use crate::error::{N34Error, N34Result};
/// Prefix used for git issue alt.
pub const ISSUE_ALT_PREFIX: &str = "git issue: ";
#[derive(Subcommand, Debug)]
pub enum IssueSubcommands {
/// Create a new repository issue
New(NewArgs),
/// View an issue by its ID
View(ViewArgs),
/// Reopens a closed issue.
Reopen(ReopenArgs),
/// Closes an open issue.
Close(CloseArgs),
/// Resolves an issue.
Resolve(ResolveArgs),
/// List the repositories issues.
List(ListArgs),
}
/// Possible states for a Git issue
#[derive(Debug)]
pub enum IssueStatus {
/// The issue is currently open
Open,
/// The issue has been resolved
Resolved,
/// The issue has been closed
Closed,
}
impl IssueStatus {
/// Maps the issue status to its corresponding Nostr kind.
#[inline]
pub fn kind(&self) -> Kind {
match self {
Self::Open => Kind::GitStatusOpen,
Self::Resolved => Kind::GitStatusApplied,
Self::Closed => Kind::GitStatusClosed,
}
}
/// Returns the string representation of the issue status.
pub const fn as_str(&self) -> &'static str {
match self {
Self::Open => "Open",
Self::Resolved => "Resolved",
Self::Closed => "Closed",
}
}
/// Check if the issue is open.
#[inline]
pub fn is_open(&self) -> bool {
matches!(self, Self::Open)
}
/// Check if the issue is resolved.
#[inline]
pub fn is_resolved(&self) -> bool {
matches!(self, Self::Resolved)
}
/// Check if the issue is closed.
#[inline]
pub fn is_closed(&self) -> bool {
matches!(self, Self::Closed)
}
}
impl From<&IssueStatus> for Kind {
fn from(status: &IssueStatus) -> Self {
status.kind()
}
}
impl fmt::Display for IssueStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl TryFrom<Kind> for IssueStatus {
type Error = N34Error;
fn try_from(kind: Kind) -> Result<Self, Self::Error> {
match kind {
Kind::GitStatusOpen => Ok(Self::Open),
Kind::GitStatusApplied => Ok(Self::Resolved),
Kind::GitStatusClosed => Ok(Self::Closed),
_ => Err(N34Error::InvalidIssueStatus(kind)),
}
}
}
impl CommandRunner for IssueSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::run_command!(self, options, & New View Reopen Close Resolve List)
}
}

View File

@@ -0,0 +1,152 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use clap::{ArgGroup, Args};
use nostr::event::{EventBuilder, Tag};
use crate::{
cli::{
CliOptions,
CommandRunner,
traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::NaddrOrSet,
},
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 naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
self.naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let user_pubk = client.pubkey().await?;
let coordinates = naddrs.clone().into_coordinates();
client.add_relays(&naddrs.extract_relays()).await;
let repos = client.fetch_repos(coordinates.as_slice()).await?;
let maintainers = repos.extract_maintainers();
client.add_relays(&repos.extract_relays()).await;
let relays_list = client.user_relays_list(user_pubk).await?;
client
.add_relays(&utils::add_read_relays(relays_list.as_ref()))
.await;
let (subject, content) = self.issue_content()?;
let content_details = client.parse_content(&content).await;
let event =
EventBuilder::new_git_issue(coordinates.as_slice(), content, subject, self.label)?
.dedup_tags()
.pow(options.pow.unwrap_or_default())
.tags(maintainers.iter().map(|p| Tag::public_key(*p)))
.tags(content_details.clone().into_tags())
.build(user_pubk);
let event_id = event.id.expect("There is an id");
let write_relays = [
relays,
naddrs.extract_relays(),
utils::add_write_relays(relays_list.as_ref()),
client
.fetch_repos(&naddrs.into_coordinates())
.await?
.extract_relays(),
// Include read relays for each maintainer (if found)
client.read_relays_from_users(&maintainers).await,
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(())
}
}

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 super::IssueStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct ReopenArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@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 closed issue id to reopen it
issue_id: NostrEvent,
}
impl CommandRunner for ReopenArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::issue_status_command(
options,
self.issue_id,
self.naddrs,
IssueStatus::Open,
|issue_status| {
if issue_status.is_open() {
return Err(N34Error::InvalidStatus(
"You can't reopen an open issue".to_owned(),
));
}
if issue_status.is_resolved() {
return Err(N34Error::InvalidStatus(
"You can't open a resolved issue".to_owned(),
));
}
Ok(())
},
)
.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 super::IssueStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct ResolveArgs {
/// 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 issue id to resolve it
issue_id: NostrEvent,
}
impl CommandRunner for ResolveArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::issue_status_command(
options,
self.issue_id,
self.naddrs,
IssueStatus::Resolved,
|issue_status| {
if issue_status.is_resolved() {
return Err(N34Error::InvalidStatus(
"You can't resolve an resolved issue".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,93 @@
// 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::{event::Kind, filter::Filter};
use crate::{
cli::{
CliOptions,
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
nostr_utils::{
NostrClient,
traits::{GitIssueUtils, NaddrsUtils, ReposUtils},
utils,
},
};
#[derive(Debug, Args)]
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>>,
/// The issue id to view it
issue_id: NostrEvent,
}
impl CommandRunner for ViewArgs {
const NEED_SIGNER: bool = false;
async fn run(self, options: CliOptions) -> N34Result<()> {
let naddrs = utils::naddrs_or_file(
self.naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
client.add_relays(&naddrs.extract_relays()).await;
client.add_relays(&self.issue_id.relays).await;
client
.add_relays(
&client
.fetch_repos(&naddrs.into_coordinates())
.await?
.extract_relays(),
)
.await;
let issue = client
.fetch_event(
Filter::new()
.id(self.issue_id.event_id)
.kind(Kind::GitIssue),
)
.await?
.ok_or(N34Error::CanNotFoundIssue)?;
let issue_subject = utils::smart_wrap(issue.extract_issue_subject(), 70);
let issue_author = client.get_username(issue.pubkey).await;
let mut issue_labels = utils::smart_wrap(&issue.extract_issue_labels(), 70);
if issue_labels.is_empty() {
issue_labels = "\n".to_owned();
} else {
issue_labels = format!("{issue_labels}\n\n")
}
println!(
"{issue_subject} - [by {issue_author}]\n{issue_labels}{}",
utils::smart_wrap(&issue.content, 80)
);
Ok(())
}
}

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

@@ -0,0 +1,202 @@
// 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>.
/// `config` subcommands
pub mod config;
/// `issue` subcommands
pub mod issue;
/// `patch` subcommands
pub mod patch;
/// `reply` command
pub mod reply;
/// `repo` subcommands
pub mod repo;
/// `sets` subcommands
pub mod sets;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use clap::{Args, Parser};
use nostr::key::{Keys, SecretKey};
use nostr::nips::nip46::NostrConnectURI;
use nostr::signer::{IntoNostrSigner, NostrSigner};
use nostr_connect::client::NostrConnect;
use self::config::ConfigSubcommands;
use self::issue::IssueSubcommands;
use self::patch::PatchSubcommands;
use self::reply::ReplyArgs;
use self::repo::RepoSubcommands;
use self::sets::SetsSubcommands;
use super::CliConfig;
use super::options_state::OptionsState;
use super::types::RelayOrSet;
use super::{parsers, traits::CommandRunner};
use crate::cli::Cli;
use crate::cli::types::EchoAuthUrl;
use crate::error::{N34Error, N34Result};
/// Default path used when no path is provided via command line arguments.
///
/// This is a workaround since Clap doesn't support lazy evaluation of defaults.
pub const DEFAULT_FALLBACK_PATH: &str = "I_DO_NOT_KNOW_WHY_CLAP_DO_NOT_SUPPORT_LAZY_DEFAULT";
/// How long to wait for bunker response (3 minutes).
const BUNKER_TIMEOUT: Duration = Duration::from_secs(60 * 3);
/// The command-line interface options
#[derive(Args)]
pub struct CliOptions {
/// Your Nostr secret key
#[arg(short, long, group = "signer")]
pub secret_key: Option<SecretKey>,
/// NIP-46 bunker url used for signing events
#[arg(short, long, group = "signer", value_parser = parsers::parse_bunker_url)]
pub bunker_url: Option<NostrConnectURI>,
/// Enables signing events using the browser's NIP-07 extension. Listens on
/// `127.0.0.1:51034`.
#[arg(short = '7', long, group = "signer")]
pub nip07: bool,
/// Fallbacks relay to write and read from it. Multiple relays can be
/// passed.
#[arg(short, long)]
pub relays: Vec<RelayOrSet>,
/// Proof of Work difficulty when creatring events
#[arg(long, value_name = "DIFFICULTY")]
pub pow: Option<u8>,
/// Config path [default: `$XDG_CONFIG_HOME` or `$HOME/.config`]
#[arg(long, value_name = "PATH", default_value = DEFAULT_FALLBACK_PATH,
hide_default_value = true, value_parser = parsers::parse_config_path
)]
pub config: CliConfig,
/// The state of options. Some values that are used by them but should not
/// be entered via the CLI
#[arg(skip)]
pub state: OptionsState,
}
/// N34 commands
#[derive(Parser, Debug)]
pub enum Commands {
/// Manage repositories 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,
},
/// Manage configuration
Config {
#[command(subcommand)]
subcommands: ConfigSubcommands,
},
/// Reply to issues and patches.
Reply(ReplyArgs),
}
impl CliOptions {
/// Returns the signer
pub async fn signer(&self) -> N34Result<Option<Arc<dyn NostrSigner + 'static>>> {
if self.nip07 {
self.state.browser_signer_proxy.start().await?;
println!(
"Browser signer proxy started at: {}",
self.state.browser_signer_proxy.url()
);
// FIXME: Use `BrowserSignerProxy::is_session_active` after it release
// nostr@0.44.0
tokio::time::sleep(Duration::from_secs(10)).await;
return Ok(Some(
self.state.browser_signer_proxy.clone().into_nostr_signer(),
));
}
if let Some(sk) = &self.secret_key {
return Ok(Some(Keys::new(sk.clone()).into_nostr_signer()));
}
if let Some(ref bunker_url) = self.bunker_url {
let mut nostrconnect = NostrConnect::new(
bunker_url.clone(),
Cli::n34_keypair()?,
BUNKER_TIMEOUT,
None,
)
.expect("It's a bunker url and not a client");
nostrconnect.auth_url_handler(EchoAuthUrl);
return Ok(Some(nostrconnect.into_nostr_signer()));
}
Ok(None)
}
/// Returns an error if there are no relays.
pub fn ensure_relays(&self) -> N34Result<()> {
if self.relays.is_empty() {
return Err(N34Error::EmptyRelays);
}
Ok(())
}
/// Returns an error if there are no signers
pub fn ensure_signer(&self) -> N34Result<()> {
if !self.config.keyring_secret_key && self.secret_key.is_none() && self.bunker_url.is_none()
{
return Err(N34Error::SignerRequired);
}
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("bunker_url", &self.bunker_url.as_ref().map(|_| "*******"))
.field("relays", &self.relays)
.field("pow", &self.pow)
.field("config", &self.config)
.finish()
}
}
impl CommandRunner for Commands {
async fn run(self, options: CliOptions) -> N34Result<()> {
tracing::trace!("Options: {options:#?}");
tracing::trace!("Handling: {self:#?}");
crate::run_command!(self, options, Repo Issue Sets Patch Config & Reply)
}
}

View File

@@ -0,0 +1,82 @@
// 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::hashes::sha1::Hash as Sha1Hash;
use super::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::{CommandRunner, VecNostrEventExt},
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct ApplyArgs {
/// 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 open patch id to apply it. Must be orignal root patch or
/// revision root
patch_id: NostrEvent,
/// Patches that have been applied. Use this when only some patches have
/// been applied, not all.
#[arg(long = "patches", value_name = "PATCH-EVENT-ID")]
applied_patches: Vec<NostrEvent>,
/// The applied commits
#[arg(num_args = 1..)]
applied_commits: Vec<Sha1Hash>,
}
impl CommandRunner for ApplyArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
options,
self.patch_id,
self.naddrs,
PatchStatus::MergedApplied,
Some(either::Either::Right(self.applied_commits)),
self.applied_patches.into_event_ids(),
|patch_status| {
if patch_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't apply an already applied patch".to_owned(),
));
}
if patch_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't apply a closed patch".to_owned(),
));
}
if patch_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't apply a drafted patch".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,67 @@
// 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 super::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct CloseArgs {
/// 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 open/drafted patch id to close it. Must be orignal root patch
patch_id: NostrEvent,
}
impl CommandRunner for CloseArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
options,
self.patch_id,
self.naddrs,
PatchStatus::Closed,
None,
Vec::new(),
|patch_status| {
if patch_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't close an already closed patch".to_owned(),
));
}
if patch_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't close a merged/applied patch".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,74 @@
// 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 super::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct DraftArgs {
/// 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 open patch id to draft it. Must be orignal root patch
patch_id: NostrEvent,
}
impl CommandRunner for DraftArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
options,
self.patch_id,
self.naddrs,
PatchStatus::Draft,
None,
Vec::new(),
|patch_status| {
if patch_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't draft an already drafted patch".to_owned(),
));
}
if patch_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't draft a closed patch".to_owned(),
));
}
if patch_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't draft a merged/applied patch".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,137 @@
// 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, PathBuf},
str::FromStr,
};
use clap::Args;
use nostr::{
event::{Kind, TagKind},
filter::Filter,
nips::nip19::ToBech32,
};
use crate::{
cli::{
CliOptions,
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
nostr_utils::{
NostrClient,
traits::{NaddrsUtils, ReposUtils},
utils,
},
};
#[derive(Debug, Args)]
pub struct FetchArgs {
/// 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>>,
/// Output directory for the patches. Default to the current directory
#[arg(short, long, value_name = "PATH")]
output: Option<PathBuf>,
/// The patch id to fetch it
patch_id: NostrEvent,
}
impl CommandRunner for FetchArgs {
const NEED_SIGNER: bool = false;
async fn run(self, options: CliOptions) -> N34Result<()> {
let naddrs = utils::naddrs_or_file(
self.naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let output_path = self.output.unwrap_or_default();
client
.add_relays(
&[
naddrs.extract_relays(),
self.patch_id.relays,
client
.fetch_repos(&naddrs.into_coordinates())
.await?
.extract_relays(),
]
.concat(),
)
.await;
let root_patch = client
.fetch_event(
Filter::new()
.id(self.patch_id.event_id)
.kind(Kind::GitPatch),
)
.await?
.ok_or(N34Error::CanNotFoundPatch)?;
if !root_patch
.tags
.iter()
.any(|t| t.kind() == TagKind::t() && t.content().is_some_and(|c| c == "root"))
{
return Err(N34Error::NotRootPatch);
}
let root_author = root_patch.pubkey;
let root_patch = super::GitPatch::from_str(&root_patch.content)
.map_err(|err| N34Error::InvalidEvent(format!("Failed to parse the patch: {err}")))?;
tracing::info!("Found the root patch: `{}`", root_patch.subject);
let mut patches = client
.fetch_patch_series(self.patch_id.event_id, root_author)
.await?
.into_iter()
.map(|p| {
let patch = super::GitPatch::from_str(&p.content).map_err(|err| {
N34Error::InvalidEvent(format!(
"Failed to parse the patch `{}`: {err}",
p.id.to_bech32().expect("Infallible")
))
})?;
N34Result::Ok((patch.filename(&output_path)?, patch))
})
.collect::<N34Result<Vec<_>>>()?;
patches.push((root_patch.filename(&output_path)?, root_patch));
patches.sort_unstable_by_key(|p| p.0.clone());
patches.dedup_by_key(|p| p.0.clone());
if output_path.as_path() != Path::new("") && !output_path.exists() {
fs::create_dir_all(&output_path)?;
}
for (patch_path, patch) in patches {
tracing::info!("Writeing `{}` in `{}`", patch.subject, patch_path.display());
fs::write(patch_path, patch.inner)?;
}
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>.
use std::num::NonZeroUsize;
use clap::Args;
use crate::{
cli::{CliOptions, common_commands, traits::CommandRunner, types::NaddrOrSet},
error::N34Result,
};
#[derive(Debug, Args)]
pub struct ListArgs {
/// 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")]
naddrs: Option<Vec<NaddrOrSet>>,
/// Maximum number of patches to list
#[arg(long, default_value = "15")]
limit: NonZeroUsize,
}
impl CommandRunner for ListArgs {
const NEED_SIGNER: bool = false;
async fn run(self, options: CliOptions) -> N34Result<()> {
common_commands::list_patches_and_issues(options, self.naddrs, true, self.limit.into())
.await
}
}

View File

@@ -0,0 +1,82 @@
// 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::hashes::sha1::Hash as Sha1Hash;
use super::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::{CommandRunner, VecNostrEventExt},
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct MergeArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@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 open patch id to merge it. Must be orignal root patch or
/// revision root
patch_id: NostrEvent,
/// Patches that have been merged. Use this when only some patches have been
/// merged, not all.
#[arg(long = "patches", value_name = "PATCH-EVENT-ID")]
merged_patches: Vec<NostrEvent>,
/// The merge commit id
merge_commit: Sha1Hash,
}
impl CommandRunner for MergeArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
options,
self.patch_id,
self.naddrs,
PatchStatus::MergedApplied,
Some(either::Either::Left(self.merge_commit)),
self.merged_patches.into_event_ids(),
|patch_status| {
if patch_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't merge an already merged patch".to_owned(),
));
}
if patch_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't merge a closed patch".to_owned(),
));
}
if patch_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't merge a drafted patch".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,299 @@
// 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>.
/// `patch apply` suubcommand
mod apply;
/// `patch close` subcommand
mod close;
/// `patch draft` subcommand
mod draft;
/// `patch fetch` subcommand
mod fetch;
/// `patch list` subcommand
mod list;
/// `patch merge` subcommand
mod merge;
/// `patch reopen` subcommand
mod reopen;
/// `patch send` subcommand
mod send;
#[cfg(test)]
mod tests;
use std::{
fmt,
path::{Path, PathBuf},
str::FromStr,
sync::LazyLock,
};
use clap::Subcommand;
use nostr::event::Kind;
use regex::Regex;
use self::apply::ApplyArgs;
use self::close::CloseArgs;
use self::draft::DraftArgs;
use self::fetch::FetchArgs;
use self::list::ListArgs;
use self::merge::MergeArgs;
use self::reopen::ReopenArgs;
use self::send::SendArgs;
use super::{CliOptions, CommandRunner};
use crate::error::{N34Error, N34Result};
/// Regular expression for extracting the patch subject.
static SUBJECT_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^Subject: (.*(?:\n .*)*)").unwrap());
/// Regular expression for extracting the patch body.
static BODY_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\n\n((?:.|\n)*?)(?:\n--[ -]|\z)").unwrap());
/// Regular expiration for extracting the patch version and number
static PATCH_VERSION_NUMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\[PATCH\s+(?:v(?<version>\d+)\s*)?(?<number>\d+)/(?:\d+)").unwrap()
});
/// Content of the hashtag representing the root patch.
pub const ROOT_HASHTAG_CONTENT: &str = "root";
/// Content of the hashtag representing the root revision patch.
pub const REVISION_ROOT_HASHTAG_CONTENT: &str = "root-revision";
/// The content of the hashtag used by `ngit-cli` to represent a root revision
/// patch before the commit 6ae42e67d9da36f6c2e1356acba30a3a62112bc7. This was a
/// typo.
pub const LEGACY_NGIT_REVISION_ROOT_HASHTAG_CONTENT: &str = "revision-root";
#[derive(Subcommand, Debug)]
pub enum PatchSubcommands {
/// Send one or more patches to a repository.
Send(SendArgs),
/// Fetches a patch by its id.
Fetch(FetchArgs),
/// Closes an open or drafted patch.
Close(CloseArgs),
/// Converts an open patch to draft state.
Draft(DraftArgs),
/// Reopens a closed or drafted patch.
Reopen(ReopenArgs),
/// Set an open patch status to applied.
Apply(ApplyArgs),
/// Set an open patch status to merged.
Merge(MergeArgs),
/// List the repositories patches.
List(ListArgs),
}
/// Represents a git patch
#[derive(Clone, Debug)]
pub struct GitPatch {
/// Full content of the patch file
pub inner: String,
/// Short description of the patch changes
pub subject: String,
/// Detailed explanation of the patch changes
pub body: String,
}
#[derive(Debug)]
pub enum PatchStatus {
/// The patch is currently open
Open,
/// The patch has been merged/applied
MergedApplied,
/// The patch has been closed
Closed,
/// A patch that has been drafted but not yet applied.
Draft,
}
impl PatchStatus {
/// Maps the issue status to its corresponding Nostr kind.
#[inline]
pub fn kind(&self) -> Kind {
match self {
Self::Open => Kind::GitStatusOpen,
Self::MergedApplied => Kind::GitStatusApplied,
Self::Closed => Kind::GitStatusClosed,
Self::Draft => Kind::GitStatusDraft,
}
}
/// Returns the string representation of the patch status.
pub const fn as_str(&self) -> &'static str {
match self {
Self::Open => "Open",
Self::MergedApplied => "Merged/Applied",
Self::Closed => "Closed",
Self::Draft => "Draft",
}
}
/// Check if the patch is open.
#[inline]
pub fn is_open(&self) -> bool {
matches!(self, Self::Open)
}
/// Check if the patch is merged/applied.
#[inline]
pub fn is_merged_or_applied(&self) -> bool {
matches!(self, Self::MergedApplied)
}
/// Check if the patch is closed.
#[inline]
pub fn is_closed(&self) -> bool {
matches!(self, Self::Closed)
}
/// Check if the patch is drafted
#[inline]
pub fn is_drafted(&self) -> bool {
matches!(self, Self::Draft)
}
}
impl From<&PatchStatus> for Kind {
fn from(status: &PatchStatus) -> Self {
status.kind()
}
}
impl fmt::Display for PatchStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl TryFrom<Kind> for PatchStatus {
type Error = N34Error;
fn try_from(kind: Kind) -> Result<Self, Self::Error> {
match kind {
Kind::GitStatusOpen => Ok(Self::Open),
Kind::GitStatusApplied => Ok(Self::MergedApplied),
Kind::GitStatusClosed => Ok(Self::Closed),
Kind::GitStatusDraft => Ok(Self::Draft),
_ => Err(N34Error::InvalidPatchStatus(kind)),
}
}
}
impl GitPatch {
/// Returns the patch file name from the subject
pub fn filename(&self, parent: impl AsRef<Path>) -> N34Result<PathBuf> {
let (patch_version, patch_number) = if self.subject.contains("[PATCH]") {
(String::new(), "1")
} else {
patch_version_and_subject(&self.subject)?
};
let patch_name = if patch_number == "0" {
"cover-letter".to_owned()
} else {
patch_file_name(&self.subject)?
};
Ok(parent
.as_ref()
.join(format!("{patch_version}{patch_number:0>4}-{patch_name}").replace("--", "-"))
.with_extension("patch"))
}
}
impl CommandRunner for PatchSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::run_command!(self, options, & Send Fetch Close Reopen Draft Apply Merge List)
}
}
impl FromStr for GitPatch {
type Err = String;
fn from_str(patch_content: &str) -> Result<Self, Self::Err> {
// Regex for subject (handles multi-line subjects)
let subject = SUBJECT_RE
.captures(patch_content)
.and_then(|cap| cap.get(1))
.ok_or("No subject found")?
.as_str()
.trim()
.replace('\n', "")
.to_string();
// Regex for body
let body = BODY_RE
.captures(patch_content)
.and_then(|cap| cap.get(1))
.ok_or("No body found")?
.as_str()
.trim()
.to_string();
Ok(Self {
inner: patch_content.to_owned(),
subject,
body,
})
}
}
/// Extracts the version prefix and patch number from a patch subject string.
///
/// The version prefix is formatted as "v{version}-" if present, or an empty
/// string. The patch number is mandatory and will cause an error if not found.
fn patch_version_and_subject(subject: &str) -> N34Result<(String, &str)> {
let captures = PATCH_VERSION_NUMBER_RE.captures(subject).ok_or_else(|| {
N34Error::InvalidEvent(format!("Can not parse the patch subject `{subject}`"))
})?;
Ok((
captures
.name("version")
.map(|m| format!("v{}-", m.as_str()))
.unwrap_or_default(),
captures
.name("number")
.map(|m| m.as_str())
.expect("It's not optional, regex will fail if it's not found"),
))
}
/// Extracts a clean file name from the patch subject by removing version info
/// and special characters. Converts to lowercase and ensures the name only
/// contains alphanumeric, '.', '-', or '_' characters.
fn patch_file_name(subject: &str) -> N34Result<String> {
Ok(subject
.split_once("]")
.ok_or_else(|| {
N34Error::InvalidEvent(format!(
"Invalid patch subject. No `[PATCH ...]`: `{subject}`",
))
})?
.1
.trim()
.to_lowercase()
.replace(
|c: char| !c.is_ascii_alphanumeric() && !['.', '-', '_'].contains(&c),
"-",
)
.chars()
.take(60)
.collect::<String>()
.trim_matches('-')
.trim()
.replace("--", "-"))
}

View File

@@ -0,0 +1,68 @@
// 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 super::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct ReopenArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@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 closed/drafted patch id to reopen it. Must be orignal root patch
patch_id: NostrEvent,
}
impl CommandRunner for ReopenArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
options,
self.patch_id,
self.naddrs,
PatchStatus::Open,
None,
Vec::new(),
|patch_status| {
if patch_status.is_open() {
return Err(N34Error::InvalidStatus(
"You can't open an already open patch".to_owned(),
));
}
if patch_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't open a merged/applied patch".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,269 @@
// 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::Args;
use futures::future;
use nostr::{
event::{EventBuilder, EventId, Kind, Tag, TagKind, Tags, UnsignedEvent},
hashes::sha1::Hash as Sha1Hash,
key::PublicKey,
nips::{nip01::Coordinate, nip10::Marker},
types::RelayUrl,
};
use super::GitPatch;
use crate::{
cli::{
CliOptions,
patch::{REVISION_ROOT_HASHTAG_CONTENT, ROOT_HASHTAG_CONTENT},
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, NostrEvent},
},
error::N34Result,
nostr_utils::{
NostrClient,
traits::{NaddrsUtils, ReposUtils},
utils,
},
};
/// Prefix used for git patch alt.
const PATCH_ALT_PREFIX: &str = "git patch: ";
#[derive(Args, Debug)]
pub struct SendArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@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>>,
/// List of patch files to send (space separated).
///
/// For p-tagging users, include them in the cover letter with
/// `nostr:npub1...`.
#[arg(value_name = "PATCH-PATH", required = true, value_parser = parse_patch_path)]
patches: Vec<GitPatch>,
/// Original patch ID if this is a revision of it
#[arg(long, value_name = "EVENT-ID")]
original_patch: Option<NostrEvent>,
}
impl CommandRunner for SendArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
self.naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?)?;
let repo_coordinates = naddrs.clone().into_coordinates();
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let user_pubk = client.pubkey().await?;
client.add_relays(&naddrs.extract_relays()).await;
if let Some(original_patch) = &self.original_patch {
client.add_relays(&original_patch.relays).await;
}
let relays_list = client.user_relays_list(user_pubk).await?;
client
.add_relays(&utils::add_read_relays(relays_list.as_ref()))
.await;
let repos = client.fetch_repos(&repo_coordinates).await?;
let euc = repos.extract_euc();
let maintainers = repos.extract_maintainers();
client.add_relays(&repos.extract_relays()).await;
let (events, events_write_relays) = make_patch_series(
&client,
self.patches,
self.original_patch.as_ref().map(|e| e.event_id),
repos.extract_relays().first().cloned(),
repo_coordinates,
euc,
user_pubk,
)
.await?;
let write_relays = [
relays,
repos.extract_relays(),
events_write_relays,
naddrs.extract_relays(),
self.original_patch.map(|e| e.relays).unwrap_or_default(),
utils::add_write_relays(relays_list.as_ref()),
client.read_relays_from_users(&maintainers).await,
]
.concat();
tracing::trace!(write_relays = ?write_relays, "Write relays of the patches");
let nevents = future::join_all(events.into_iter().map(|mut event| {
async {
let event_id = event.id();
let subject = event
.tags
.find(TagKind::Alt)
.and_then(Tag::content)
.expect("There is an alt")
.replace(PATCH_ALT_PREFIX, "");
client
.send_event_to(event, relays_list.as_ref(), &write_relays)
.await
.map(|r| Ok((subject, utils::new_nevent(event_id, &r)?)))?
}
}))
.await
.into_iter()
.collect::<N34Result<Vec<_>>>()?;
for (subject, nevent) in nevents {
println!("Created '{subject}': {nevent}");
}
Ok(())
}
}
fn parse_patch_path(patch_path: &str) -> Result<GitPatch, String> {
tracing::debug!("Parsing patch file `{patch_path}`");
let patch_content = fs::read_to_string(patch_path)
.map_err(|err| format!("Failed to read patch file `{patch_path}`: {err}"))?;
GitPatch::from_str(&patch_content)
}
async fn make_patch_series(
client: &NostrClient,
patches: Vec<GitPatch>,
original_patch: Option<EventId>,
relay_hint: Option<RelayUrl>,
repo_coordinates: Vec<Coordinate>,
euc: Option<&Sha1Hash>,
author_pkey: PublicKey,
) -> N34Result<(Vec<UnsignedEvent>, Vec<RelayUrl>)> {
let mut write_relays = Vec::new();
let mut patch_series = Vec::new();
let mut patches = patches.into_iter();
let root_patch = patches.next().expect("Patches can't be empty");
let (root_event, root_relays) = make_patch(
client,
root_patch,
None,
original_patch,
relay_hint.as_ref(),
&repo_coordinates,
euc,
author_pkey,
)
.await;
write_relays.extend(root_relays);
let root_id = *root_event.id.as_ref().expect("There is an id");
let mut previous_patch = root_id;
patch_series.push(root_event);
for patch in patches {
let (patch_event, patch_relays) = make_patch(
client,
patch,
Some(root_id),
Some(previous_patch),
relay_hint.as_ref(),
&repo_coordinates,
euc,
author_pkey,
)
.await;
previous_patch = patch_event.id.expect("there is an id");
write_relays.extend(patch_relays);
patch_series.push(patch_event);
}
Ok((patch_series, write_relays))
}
#[allow(clippy::too_many_arguments)]
async fn make_patch(
client: &NostrClient,
patch: GitPatch,
root: Option<EventId>,
reply_to: Option<EventId>,
write_relay: Option<&RelayUrl>,
repo_coordinates: &[Coordinate],
euc: Option<&Sha1Hash>,
author_pkey: PublicKey,
) -> (UnsignedEvent, Vec<RelayUrl>) {
let content_details = client.parse_content(&patch.body).await;
let content_relays = content_details.write_relays.clone();
// NIP-34 compliance requires referencing the previous patch using `NIP-10 e
// reply`. However, this fails for the second patch when
// `EventBuilder::dedup_tags` is enabled because:
// 1. The tag is treated as a duplicate based on its content (the root ID).
// 2. The second patch would reply to the root twice:
// - First with the 'root' marker
// - Then with the 'reply' marker
// The `EventBuilder::dedup_tags` function then removes the 'reply' marker as a
// duplicate.
let mut safe_dedup_tags = Tags::new();
safe_dedup_tags.push(Tag::alt(format!("{PATCH_ALT_PREFIX}{}", patch.subject)));
safe_dedup_tags.push(Tag::description(patch.subject));
safe_dedup_tags.extend(content_details.into_tags());
safe_dedup_tags.extend(
repo_coordinates
.iter()
.map(|c| Tag::coordinate(c.clone(), None)),
);
safe_dedup_tags.extend(
repo_coordinates
.iter()
.map(|c| Tag::public_key(c.public_key)),
);
if let Some(euc) = euc {
safe_dedup_tags.push(Tag::reference(euc.to_string()));
}
safe_dedup_tags.dedup();
let mut event_builder = EventBuilder::new(Kind::GitPatch, patch.inner).tags(safe_dedup_tags);
// If the root is None, this indicates we're handling the root event
if let Some(root_id) = root {
event_builder =
event_builder.tag(utils::event_reply_tag(&root_id, write_relay, Marker::Root));
} else {
event_builder = event_builder.tag(Tag::hashtag(ROOT_HASHTAG_CONTENT));
}
// Handles the case where there is a patch to reply to but no root. This
// indicates we are processing a revision, as the root revision should reply
// directly to the original patch.
if let Some(reply_to_id) = reply_to {
if root.is_none() {
event_builder = event_builder.tags([
utils::event_reply_tag(&reply_to_id, write_relay, Marker::Reply),
Tag::hashtag(REVISION_ROOT_HASHTAG_CONTENT),
]);
} else {
event_builder = event_builder.tag(utils::event_reply_tag(
&reply_to_id,
write_relay,
Marker::Reply,
));
}
}
(
event_builder.build(author_pkey),
content_relays.into_iter().collect(),
)
}

View File

@@ -0,0 +1,351 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use super::*;
#[test]
fn patch_normal() {
let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
From: Awiteb <a@4rs.nl>
Date: Tue, 27 May 2025 19:20:42 +0000
Subject: [PATCH] chore: a to abc
Abc patch
---
src/nostr_utils/mod.rs | 1 +
1files changed, 3 insertions(+), 1 deletions(-)
diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs
index 4120f5a..e68783c 100644
--- a/src/nostr_utils/mod.rs
+++ b/src/nostr_utils/mod.rs
@@ -103,31 +103,9 @@ impl CommandRunner for NewArgs {
- a
+ abc
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(patch.subject, "[PATCH] chore: a to abc");
assert_eq!(patch.body, "Abc patch");
}
#[test]
fn patch_normal_with_patch_in_content() {
let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
From: Awiteb <a@4rs.nl>
Date: Tue, 27 May 2025 19:20:42 +0000
Subject: [PATCH] chore: Subject in subject
A good test patch
---
src/nostr_utils/mod.rs | 1 +
1files changed, 3 insertions(+), 1 deletions(-)
diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs
index 4120f5a..e68783c 100644
--- a/src/nostr_utils/mod.rs
+++ b/src/nostr_utils/mod.rs
@@ -103,31 +103,9 @@ impl CommandRunner for NewArgs {
From: Awiteb <a@4rs.nl>
Date: Tue, 27 May 2025 19:20:42 +0000
Subject: [PATCH] chore: What a subject
hi
---
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(patch.subject, "[PATCH] chore: Subject in subject");
assert_eq!(patch.body, "A good test patch");
}
#[test]
fn patch_multiline_subject() {
let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
From: Awiteb <a@4rs.nl>
Date: Tue, 27 May 2025 19:20:42 +0000
Subject: [PATCH] chore: Some long subject yes so long one Some long subject yes
so long one
Abc patch
---
src/nostr_utils/mod.rs | 1 +
1files changed, 3 insertions(+), 1 deletions(-)
diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs
index 4120f5a..e68783c 100644
--- a/src/nostr_utils/mod.rs
+++ b/src/nostr_utils/mod.rs
@@ -103,31 +103,9 @@ impl CommandRunner for NewArgs {
- a
+ abc
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(
patch.subject,
"[PATCH] chore: Some long subject yes so long one Some long subject yes so long one"
);
assert_eq!(patch.body, "Abc patch");
}
#[test]
fn patch_multiline_body() {
let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
From: Awiteb <a@4rs.nl>
Date: Tue, 27 May 2025 19:20:42 +0000
Subject: [PATCH] chore: a to abc
Lorem ipsum dolor sit amet. 33 laborum galisum aut fugiat dicta vel accusamus
aliquam vel quisquam fuga in incidunt voluptas a aliquid neque ab iure pariatur.
Et molestiae vero a consectetur laborum et accusantium sequi. Et ratione
atque et molestiae dolorem in asperiores amet id dolor corporis in adipisci
aspernatur.
---
src/nostr_utils/mod.rs | 1 +
1files changed, 3 insertions(+), 1 deletions(-)
diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs
index 4120f5a..e68783c 100644
--- a/src/nostr_utils/mod.rs
+++ b/src/nostr_utils/mod.rs
@@ -103,31 +103,9 @@ impl CommandRunner for NewArgs {
- a
+ abc
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(patch.subject, "[PATCH] chore: a to abc");
assert_eq!(
patch.body,
"Lorem ipsum dolor sit amet. 33 laborum galisum aut fugiat dicta vel accusamus
aliquam vel quisquam fuga in incidunt voluptas a aliquid neque ab iure pariatur.
Et molestiae vero a consectetur laborum et accusantium sequi. Et ratione
atque et molestiae dolorem in asperiores amet id dolor corporis in adipisci
aspernatur."
);
}
#[test]
fn patch_cover_letter() {
let patch_content = r#"From 864f3018f62ab2e1265edb670d5493dafe7d2cb2 Mon Sep 17 00:00:00 2001
From: Awiteb <a@4rs.nl>
Date: Tue, 3 Jun 2025 08:41:12 +0000
Subject: [PATCH v2 0/7] feat: Some test just a test
Cover body
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(patch.subject, "[PATCH v2 0/7] feat: Some test just a test");
assert_eq!(
patch.body,
"Cover body
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a"
);
}
#[test]
fn patch_multiline_cover_subject() {
let patch_content = r#"From 864f3018f62ab2e1265edb670d5493dafe7d2cb2 Mon Sep 17 00:00:00 2001
From: Awiteb <a@4rs.nl>
Date: Tue, 3 Jun 2025 08:41:12 +0000
Subject: [PATCH v2 0/7] feat: Some test just a test some test just a test some
test just a test
Cover body
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(
patch.subject,
"[PATCH v2 0/7] feat: Some test just a test some test just a test some test just a test"
);
assert_eq!(
patch.body,
"Cover body
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a"
);
}
#[test]
fn patch_multiline_cover_body() {
let patch_content = r#"From 864f3018f62ab2e1265edb670d5493dafe7d2cb2 Mon Sep 17 00:00:00 2001
From: Awiteb <a@4rs.nl>
Date: Tue, 3 Jun 2025 08:41:12 +0000
Subject: [PATCH v2 0/7] feat: Some test just a test some test just a test some
test just a test
Lorem ipsum dolor sit amet. 33 laborum galisum aut fugiat dicta vel accusamus
aliquam vel quisquam fuga in incidunt voluptas a aliquid neque ab iure pariatur.
Et molestiae vero a consectetur laborum et accusantium sequi. Et ratione
atque et molestiae dolorem in asperiores amet id dolor corporis in adipisci
aspernatur.
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(
patch.subject,
"[PATCH v2 0/7] feat: Some test just a test some test just a test some test just a test"
);
assert_eq!(
patch.body,
"Lorem ipsum dolor sit amet. 33 laborum galisum aut fugiat dicta vel accusamus
aliquam vel quisquam fuga in incidunt voluptas a aliquid neque ab iure pariatur.
Et molestiae vero a consectetur laborum et accusantium sequi. Et ratione
atque et molestiae dolorem in asperiores amet id dolor corporis in adipisci
aspernatur.
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a"
);
}
#[test]
fn normal_patch_filename() {
let mut patch = GitPatch {
inner: String::new(),
subject: String::new(),
body: String::new(),
};
patch.subject = "[PATCH v2 0/3] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("v2-0000-cover-letter.patch")
);
patch.subject = "[PATCH 0/3] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("0000-cover-letter.patch")
);
patch.subject = "[PATCH v2 1/3] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("v2-0001-feat-some-test-just-a-test.patch")
);
patch.subject = "[PATCH v42 1/3] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("v42-0001-feat-some-test-just-a-test.patch")
);
patch.subject = "[PATCH v42 23/30] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("v42-0023-feat-some-test-just-a-test.patch")
);
patch.subject = "[PATCH 1/3] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("0001-feat-some-test-just-a-test.patch")
);
patch.subject = "[PATCH 32/50] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("0032-feat-some-test-just-a-test.patch")
);
patch.subject = "[PATCH v100 32/50] feat: some long subject some long subject some long \
subject some long subject"
.to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("v100-0032-feat-some-long-subject-some-long-subject-some-long-subject.patch")
);
}
#[test]
fn patch_filename_without_patch() {
let mut patch = GitPatch {
inner: String::new(),
subject: "[RFC v5 1/2] Something".to_owned(),
body: String::new(),
};
assert!(patch.filename("").is_err());
patch.subject = "Something".to_owned();
assert!(patch.filename("").is_err());
}
#[test]
fn patch_filename_without_number() {
let mut patch = GitPatch {
inner: String::new(),
subject: "[PATCH v5 /2] Something".to_owned(),
body: String::new(),
};
assert!(patch.filename("").is_err());
patch.subject = "[PATCH v5 2/] Something".to_owned();
assert!(patch.filename("").is_err());
}
#[test]
fn patch_filename_without_version() {
let patch = GitPatch {
inner: String::new(),
subject: "[PATCH 1/2] Something".to_owned(),
body: String::new(),
};
assert!(patch.filename("").is_ok());
}

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

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

View File

@@ -0,0 +1,157 @@
// 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::{CliOptions, CommandRunner, NOSTR_ADDRESS_FILE, traits::RelayOrSetVecExt},
error::N34Result,
nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils},
};
/// Header written to new `nostr-address` files. Contains two trailing newline
/// for formatting.
const NOSTR_ADDRESS_FILE_HEADER: &str = r##"# This file contains NIP-19 `naddr` entities for repositories that accept this
# project's issues and patches.
#
# The file acts as a **read-only reference** for retrieving repository relays
# when embedded in an `naddr` and mentions those repositories when opening
# patches or issues. Modifications here will not affect in the relays, as the
# file is **explicitly untracked**. Its goal is to simplify contributions by
# removing the need for manual address entry.
#
# Each entry must start with "naddr". Embedded relays are **strongly recommended**
# to assist client-side discovery.
#
# Empty lines are ignored. Lines starting with "#" are treated as comments.
"##;
/// Arguments for the `repo announce` command
#[derive(Args, Debug)]
pub struct AnnounceArgs {
/// Unique identifier for the repository in kebab-case.
#[arg(long = "id")]
repo_id: String,
/// A name for the repository.
#[arg(short, long)]
name: Option<String>,
/// A description for the repository.
#[arg(short, long)]
description: Option<String>,
/// Webpage URLs for the repository (if provided by the git server).
#[arg(short, long)]
web: Vec<Url>,
/// URLs for cloning the repository.
#[arg(short, long)]
clone: Vec<Url>,
/// Additional maintainers of the repository (besides yourself).
#[arg(short, long)]
maintainers: Vec<PublicKey>,
/// Labels to categorize the repository. Can be specified multiple times.
#[arg(short, long)]
label: Vec<String>,
/// Skip kebab-case validation for the repository ID
#[arg(long)]
force_id: bool,
/// If set, creates a `nostr-address` file to enable automatic address
/// discovery by n34
#[arg(long)]
address_file: bool,
}
impl CommandRunner for AnnounceArgs {
const NEED_RELAYS: bool = true;
async fn run(mut self, options: CliOptions) -> N34Result<()> {
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let user_pubk = client.pubkey().await?;
let relays_list = client.user_relays_list(user_pubk).await?;
client
.add_relays(&utils::add_read_relays(relays_list.as_ref()))
.await;
if !self.maintainers.contains(&user_pubk) {
self.maintainers.insert(0, user_pubk);
}
let naddr = utils::repo_naddr(&self.repo_id, user_pubk, &relays)?;
let event = EventBuilder::new_git_repo(
self.repo_id,
self.name.map(utils::str_trim),
self.description.map(utils::str_trim),
self.web,
self.clone,
relays.clone(),
self.maintainers.clone(),
self.label.into_iter().map(utils::str_trim).collect(),
self.force_id,
)?
.dedup_tags()
.pow(options.pow.unwrap_or_default())
.build(user_pubk);
if self.address_file {
let address_path = std::env::current_dir()?.join(NOSTR_ADDRESS_FILE);
if !address_path.exists() {
tracing::info!(
"Creating new address file: '{NOSTR_ADDRESS_FILE}' at path '{}' with default \
header",
address_path.display()
);
fs::write(&address_path, NOSTR_ADDRESS_FILE_HEADER)?;
}
let mut file = fs::OpenOptions::new().append(true).open(&address_path)?;
tracing::info!("Appending naddr '{naddr}' to address file: '{NOSTR_ADDRESS_FILE}'");
file.write_all(format!("{naddr}\n").as_bytes())?;
tracing::info!("Successfully wrote naddr to address file");
}
let write_relays = [
relays,
utils::add_write_relays(relays_list.as_ref()),
// Include read relays for each maintainer (if found)
future::join_all(
self.maintainers
.iter()
.map(|pkey| client.read_relays_from_user(*pkey)),
)
.await
.into_iter()
.flatten()
.collect(),
]
.concat();
let nevent = utils::new_nevent(event.id.expect("There is an id"), &write_relays)?;
client
.send_event_to(event, relays_list.as_ref(), &write_relays)
.await?;
println!("Event: {nevent}",);
println!("Repo Address: {naddr}",);
Ok(())
}
}

View File

@@ -0,0 +1,41 @@
// 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),
/// Broadcast and update a git repository
Announce(AnnounceArgs),
}
impl CommandRunner for RepoSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::run_command!(self, options, & View Announce)
}
}

View File

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

View File

@@ -0,0 +1,52 @@
// 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<()> {
crate::run_command!(self, options, & Remove New Update Show)
}
}

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>.
use clap::Args;
use crate::{
cli::{
CliOptions,
ConfigError,
traits::{CommandRunner, MutRepoRelaySetsExt, NaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, RelayOrSet},
},
error::N34Result,
};
#[derive(Args, Debug)]
pub struct NewArgs {
/// Unique name for the set
name: String,
/// Optional relay to add it to the set, either as URL or set name to
/// extract its relays. [aliases: `--sr`]
#[arg(long = "set-relay", alias("sr"))]
relays: Vec<RelayOrSet>,
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@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 {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
let naddrs = self.naddrs.flat_naddrs(&options.config.sets)?;
let relays = self.relays.flat_relays(&options.config.sets)?;
if relays.is_empty() && naddrs.is_empty() {
return Err(ConfigError::NewEmptySet.into());
}
options.config.sets.push_set(self.name, naddrs, relays)?;
options.config.dump()
}
}

View File

@@ -0,0 +1,69 @@
// 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::{
CliOptions,
traits::{CommandRunner, MutRepoRelaySetsExt, NaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, RelayOrSet},
},
error::N34Result,
};
#[derive(Args, Debug)]
pub struct RemoveArgs {
/// Set name to delete
name: String,
/// Specific relay to remove it from the set, either as URL or set name to
/// extract its relays. [aliases: `--sr`]
#[arg(long = "set-relay", alias("sr"))]
relays: Vec<RelayOrSet>,
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@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 {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
let naddrs = self.naddrs.flat_naddrs(&options.config.sets)?;
let relays = self.relays.flat_relays(&options.config.sets)?;
if relays.is_empty() && naddrs.is_empty() {
options.config.sets.remove_set(self.name)?;
} else {
if !relays.is_empty() {
options
.config
.sets
.remove_relays(&self.name, relays.into_iter())?;
}
if !naddrs.is_empty() {
options
.config
.sets
.remove_naddrs(self.name, naddrs.into_iter())?;
}
}
options.config.dump()
}
}

View File

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

View File

@@ -0,0 +1,70 @@
// 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::{
CliOptions,
traits::{CommandRunner, MutRepoRelaySetsExt, NaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, RelayOrSet},
},
error::N34Result,
};
#[derive(Args, Debug)]
pub struct UpdateArgs {
/// Name of the set to update
name: String,
/// Add relay to the set, either as URL or set name to extract its relays.
/// [aliases: `--sr`]
#[arg(long = "set-relay", alias("sr"))]
relays: Vec<RelayOrSet>,
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@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 {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
let naddrs = self.naddrs.flat_naddrs(&options.config.sets)?;
let relays = self.relays.flat_relays(&options.config.sets)?;
let set = options.config.sets.get_mut_set(&self.name)?;
if self.override_set {
if !relays.is_empty() {
set.relays = HashSet::from_iter(relays);
}
if !naddrs.is_empty() {
set.naddrs = HashSet::from_iter(naddrs)
}
} else {
set.relays.extend(relays);
set.naddrs.extend(naddrs);
}
options.config.dump()
}
}

438
src/cli/common_commands.rs Normal file
View File

@@ -0,0 +1,438 @@
// 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::{iter, str::FromStr, sync::Arc};
use either::Either;
use futures::future;
use nostr::{
event::{Event, EventBuilder, EventId, Kind, Tag, TagKind},
filter::Filter,
hashes::sha1::Hash as Sha1Hash,
nips::{nip10::Marker, nip19::ToBech32},
types::RelayUrl,
};
use super::{
issue::IssueStatus,
patch::PatchStatus,
types::{NaddrOrSet, NostrEvent},
};
use crate::{
cli::traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
nostr_utils::{NostrClient, traits::NaddrsUtils, utils},
};
use crate::{
cli::{CliOptions, patch::GitPatch},
error::{N34Error, N34Result},
nostr_utils::traits::{GitIssueUtils, GitPatchUtils, ReposUtils},
};
/// Updates the issue's status to `new_status` after validating it with
/// `check_fn`.
pub async fn issue_status_command(
options: CliOptions,
issue_id: NostrEvent,
naddrs: Option<Vec<NaddrOrSet>>,
new_status: IssueStatus,
check_fn: impl FnOnce(&IssueStatus) -> N34Result<()>,
) -> N34Result<()> {
let naddrs = utils::naddrs_or_file(
naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let user_pubk = client.pubkey().await?;
client
.add_relays(&[naddrs.extract_relays(), issue_id.relays].concat())
.await;
let owners = naddrs.extract_owners();
let coordinates = naddrs.clone().into_coordinates();
let repos = client.fetch_repos(&coordinates).await?;
let maintainers = repos.extract_maintainers();
let relay_hint = repos.extract_relays().first().cloned();
client.add_relays(&repos.extract_relays()).await;
let issue_event = client
.fetch_event(Filter::new().id(issue_id.event_id))
.await?
.ok_or(N34Error::CanNotFoundIssue)?;
let issue_status = client
.fetch_issue_status(
issue_id.event_id,
[maintainers.as_slice(), &[issue_event.pubkey], &owners].concat(),
)
.await?;
check_fn(&issue_status)?;
let status_event = EventBuilder::new(new_status.kind(), "")
.pow(options.pow.unwrap_or_default())
.tag(utils::event_reply_tag(
&issue_id.event_id,
relay_hint.as_ref(),
Marker::Root,
))
.tag(Tag::public_key(issue_event.pubkey))
.tags(maintainers.iter().map(|p| Tag::public_key(*p)))
.tags(owners.iter().map(|p| Tag::public_key(*p)))
.tags(
coordinates
.into_iter()
.map(|c| Tag::coordinate(c, relay_hint.clone())),
)
.dedup_tags()
.build(user_pubk);
let event_id = status_event.id.expect("There is an id");
let user_relays_list = client.user_relays_list(user_pubk).await?;
let write_relays = [
relays,
naddrs.extract_relays(),
repos.extract_relays(),
utils::add_write_relays(user_relays_list.as_ref()),
client.read_relays_from_user(issue_event.pubkey).await,
client
.read_relays_from_users(&[maintainers, owners].concat())
.await,
]
.concat();
let success = client
.send_event_to(status_event, user_relays_list.as_ref(), &write_relays)
.await?;
let nevent = utils::new_nevent(event_id, &success)?;
println!("Issue status created: {nevent}");
Ok(())
}
/// Updates the patch's status to `new_status` after validating it with
/// `check_fn`.
pub async fn patch_status_command(
options: CliOptions,
patch_id: NostrEvent,
naddrs: Option<Vec<NaddrOrSet>>,
new_status: PatchStatus,
merge_or_applied_commits: Option<Either<Sha1Hash, Vec<Sha1Hash>>>,
merge_or_applied_patches: Vec<EventId>,
check_fn: impl FnOnce(&PatchStatus) -> N34Result<()>,
) -> N34Result<()> {
let naddrs = utils::naddrs_or_file(
naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let user_pubk = client.pubkey().await?;
client
.add_relays(&[naddrs.extract_relays(), patch_id.relays].concat())
.await;
let owners = naddrs.extract_owners();
let coordinates = naddrs.clone().into_coordinates();
let repos = client.fetch_repos(&coordinates).await?;
let maintainers = repos.extract_maintainers();
let relay_hint = repos.extract_relays().first().cloned();
client.add_relays(&repos.extract_relays()).await;
let patch_event = client.fetch_patch(patch_id.event_id).await?;
if patch_event.is_revision_patch() && !new_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"Invalid action for patch revision. Only 'apply' or 'merge' are allowed, 'open', \
'close', and 'draft' are not supported."
.to_owned(),
));
}
let (root_patch, root_revision) = get_patch_root_revision(&patch_event)?;
let patch_status = client
.fetch_patch_status(
root_patch,
root_revision,
[maintainers.as_slice(), &[patch_event.pubkey], &owners].concat(),
)
.await?;
check_fn(&patch_status)?;
let mut status_builder = EventBuilder::new(new_status.kind(), "")
.pow(options.pow.unwrap_or_default())
.tag(utils::event_reply_tag(
&root_patch,
relay_hint.as_ref(),
Marker::Root,
))
.tag(Tag::public_key(patch_event.pubkey))
.tags(maintainers.iter().map(|p| Tag::public_key(*p)))
.tags(owners.iter().map(|p| Tag::public_key(*p)))
.tags(
coordinates
.into_iter()
.map(|c| Tag::coordinate(c, relay_hint.clone())),
);
if new_status.is_merged_or_applied() {
if let Some(merge_commit) = merge_or_applied_commits
.as_ref()
.and_then(|e| e.as_ref().left())
{
let commit = merge_commit.to_string();
status_builder = status_builder
.tag(Tag::custom(
TagKind::custom("merge-commit"),
iter::once(&commit),
))
.tag(Tag::reference(commit));
} else if let Some(applied_commits) = merge_or_applied_commits.and_then(|e| e.right()) {
let commits = applied_commits
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
status_builder = status_builder
.tag(Tag::custom(TagKind::custom("applied-as-commits"), &commits))
.tags(commits.into_iter().map(Tag::reference));
};
if let Some(root_revision) = root_revision {
status_builder = status_builder.tag(utils::event_reply_tag(
&root_revision,
relay_hint.as_ref(),
Marker::Reply,
));
}
if !merge_or_applied_patches.is_empty() {
status_builder = status_builder.tags(
build_patches_quote(client.clone(), relay_hint.clone(), merge_or_applied_patches)
.await,
);
}
}
let status_event = status_builder.dedup_tags().build(user_pubk);
let event_id = status_event.id.expect("There is an id");
let user_relays_list = client.user_relays_list(user_pubk).await?;
let write_relays = [
relays,
naddrs.extract_relays(),
repos.extract_relays(),
utils::add_write_relays(user_relays_list.as_ref()),
client.read_relays_from_user(patch_event.pubkey).await,
client
.read_relays_from_users(&[maintainers, owners].concat())
.await,
]
.concat();
let success = client
.send_event_to(status_event, user_relays_list.as_ref(), &write_relays)
.await?;
let nevent = utils::new_nevent(event_id, &success)?;
println!("Patch status created: {nevent}");
Ok(())
}
/// Fetch and display patches and issues for given repositories.
/// If `list_patches` is true, lists patches instead of issues.
/// `limit` controls the maximum number of items to fetch.
pub async fn list_patches_and_issues(
options: CliOptions,
naddrs: Option<Vec<NaddrOrSet>>,
list_patches: bool,
limit: usize,
) -> N34Result<()> {
let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
client.add_relays(&naddrs.extract_relays()).await;
let coordinates = naddrs.clone().into_coordinates();
let repos = client.fetch_repos(&coordinates).await?;
let authorized_pubkeys = [naddrs.extract_owners(), repos.extract_maintainers()].concat();
client.add_relays(&repos.extract_relays()).await;
// This helps discover issues and their status.
client
.add_relays(&client.read_relays_from_users(&authorized_pubkeys).await)
.await;
let kind = if list_patches {
Kind::GitPatch
} else {
Kind::GitIssue
};
let mut filter = Filter::new()
.coordinates(coordinates.iter())
.kind(kind)
.limit(limit);
if list_patches {
filter = filter.hashtag("root");
}
let arc_client = Arc::new(client);
// Events are sorted by kind in ascending order:
// 1630 (Open), 1631 (Resolved/Applied), 1632 (Closed), 1633 (Draft)
let events = utils::sort_by_key(
future::join_all(
arc_client
.fetch_events(filter)
.await?
.into_iter()
.take(limit)
.map(|event| {
let c = arc_client.clone();
let keys = authorized_pubkeys.clone();
async move {
let status = if list_patches {
let (root, root_revision) = get_patch_root_revision(&event)?;
c.fetch_patch_status(
root,
root_revision,
[keys.as_slice(), &[event.pubkey]].concat(),
)
.await
.map(Either::Left)?
} else {
c.fetch_issue_status(
event.id,
[keys.as_slice(), &[event.pubkey]].concat(),
)
.await
.map(Either::Right)?
};
N34Result::Ok((event, status))
}
}),
)
.await
.into_iter()
.filter_map(|r| r.ok()),
|(_, status)| status.as_ref().either_into::<Kind>(),
);
let lines = events
.map(|(event, status)| format_patch_and_issue(&event, status))
.collect::<Vec<String>>();
let max_width = lines
.iter()
.map(|s| s.split_once('\n').map_or(85, |(l, _)| l.chars().count()))
.max()
.unwrap_or(85)
.max(67); // length of the event id
println!("{}", lines.join(&format!("{}\n", "-".repeat(max_width))));
Ok(())
}
/// Returns a tuple of (root_id, patch_id) if this is a valid root or revision
/// patch.
fn get_patch_root_revision(patch_event: &Event) -> N34Result<(EventId, Option<EventId>)> {
if patch_event.is_revision_patch() {
Ok((
patch_event.root_patch_from_revision()?,
Some(patch_event.id),
))
} else if patch_event.is_root_patch() {
Ok((patch_event.id, None))
} else {
Err(N34Error::NotRootPatch)
}
}
/// Formats an event as either a patch or an issue. For patches, extracts the
/// subject line from the Git patch format. For issues, combines the subject
/// with labels. The output includes status and formatted ID.
fn format_patch_and_issue(event: &Event, status: Either<PatchStatus, IssueStatus>) -> String {
let subject = if status.is_left() {
GitPatch::from_str(&event.content)
.map(|p| p.subject)
.unwrap_or_else(|_| {
event
.content
.lines()
.find(|line| line.trim().starts_with("Subject: "))
.unwrap_or_default()
.trim()
.trim_start_matches("Subject: ")
.to_owned()
})
} else {
let labels = event.extract_issue_labels();
let subject = event.extract_issue_subject();
if labels.is_empty() {
subject.to_owned()
} else {
format!(r#""{subject}" {labels}"#)
}
};
format!(
"({status}) {}\nID: {}\n",
utils::smart_wrap(&subject, 85),
event.id.to_bech32().expect("Infallible")
)
}
/// Generates a list of tags for quoting patches in merge/applied status events.
async fn build_patches_quote(
client: NostrClient,
relay_hint: Option<RelayUrl>,
patches: Vec<EventId>,
) -> Vec<Tag> {
let client = Arc::new(client);
let relay_hint = Arc::new(relay_hint);
future::join_all(patches.into_iter().map(|eid| {
let task_relay = Arc::clone(&relay_hint);
let task_client = Arc::clone(&client);
async move {
Tag::custom(
TagKind::q(),
[
eid.to_hex(),
task_relay
.as_ref()
.as_ref()
.map(|r| r.to_string())
.unwrap_or_default(),
task_client
.event_author(eid)
.await
.ok()
.flatten()
.map(|p| p.to_hex())
.unwrap_or_default(),
],
)
}
}))
.await
}

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

@@ -0,0 +1,170 @@
// 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, net::SocketAddr, path::PathBuf};
use nostr::{
nips::{nip19::Nip19Coordinate, nip46::NostrConnectURI},
types::RelayUrl,
};
use crate::{
cli::traits::{MutRepoRelaySetsExt, RepoRelaySetsExt},
error::N34Result,
};
/// Errors that can occur when working with configuration files.
#[derive(thiserror::Error, Debug)]
pub enum ConfigError {
#[error(
"Could not determine the default config path: both `$XDG_CONFIG_HOME` and `$HOME` \
environment variables are missing or unset."
)]
CanNotFindConfigPath,
#[error("Couldn't read the config file: {0}")]
ReadFile(std::io::Error),
#[error("Couldn't write in the config file: {0}")]
WriteFile(std::io::Error),
#[error("Couldn't serialize the config. This is a bug, please report it: {0}")]
Serialize(toml::ser::Error),
#[error("Failed to parse the config file: {0}")]
ParseFile(toml::de::Error),
#[error("Duplicate configuration set name detected: '{0}'. Each set must have a unique name.")]
SetDuplicateName(String),
#[error("No set with the given name `{0}`")]
SetNotFound(String),
#[error("You can't create an new empty set.")]
NewEmptySet,
}
/// Configuration for the command-line interface.
#[derive(serde::Serialize, serde::Deserialize, Clone, Default, Debug)]
pub struct CliConfig {
/// Path to the configuration file (not serialized)
#[serde(skip)]
path: PathBuf,
/// Groups of repositories and relays.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sets: Vec<RepoRelaySet>,
/// The default PoW difficulty
#[serde(skip_serializing_if = "Option::is_none")]
pub pow: Option<u8>,
/// List of fallback relays used if no fallback relays was provided.
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback_relays: Option<Vec<RelayUrl>>,
/// Default Nostr bunker URL used for signing events.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "super::parsers::de_bunker_url",
serialize_with = "super::parsers::ser_bunker_url"
)]
pub bunker_url: Option<NostrConnectURI>,
/// Whether to use the system keyring to store the secret key.
#[serde(default)]
pub keyring_secret_key: bool,
/// Signs events using the browser's NIP-07 extension.
#[serde(default)]
pub nip07: Option<SocketAddr>,
}
/// A named group of repositories and relays.
#[derive(serde::Serialize, serde::Deserialize, Default, Clone, Debug)]
pub struct RepoRelaySet {
/// Unique identifier for this group.
pub name: String,
/// Repository addresses in this group.
#[serde(
default,
skip_serializing_if = "HashSet::is_empty",
serialize_with = "super::parsers::ser_naddrs",
deserialize_with = "super::parsers::de_naddrs"
)]
pub naddrs: HashSet<Nip19Coordinate>,
/// Relay URLs in this group.
#[serde(default, skip_serializing_if = "HashSet::is_empty")]
pub relays: HashSet<RelayUrl>,
}
impl CliConfig {
/// Reads and parse a TOML config file from the given path, creating it if
/// missing.
pub fn load(file_path: PathBuf) -> N34Result<Self> {
tracing::info!(path = %file_path.display(), "Loading configuration from file");
// Make sure the file is exist
if let Some(parent) = file_path.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)?;
}
let _ = fs::File::create_new(&file_path);
let mut config: Self =
toml::from_str(&fs::read_to_string(&file_path).map_err(ConfigError::ReadFile)?)
.map_err(ConfigError::ParseFile)?;
config.path = file_path;
config.post_sets()?;
Ok(config)
}
/// Dump the config as toml in a file
pub fn dump(mut self) -> N34Result<()> {
tracing::debug!(config = ?self, "Writing configuration to {}", self.path.display());
self.post_sets()?;
fs::write(
&self.path,
toml::to_string_pretty(&self).map_err(ConfigError::Serialize)?,
)
.map_err(ConfigError::WriteFile)?;
Ok(())
}
/// Performs post-processing validation on the sets after loading or before
/// dumping.
fn post_sets(&mut self) -> N34Result<()> {
self.sets.as_slice().ensure_names()?;
self.sets.dedup_naddrs();
Ok(())
}
}
impl RepoRelaySet {
/// Create a new [`RepoRelaySet`]
pub fn new(
name: impl Into<String>,
naddrs: impl IntoIterator<Item = Nip19Coordinate>,
relays: impl IntoIterator<Item = RelayUrl>,
) -> Self {
Self {
name: name.into(),
naddrs: HashSet::from_iter(naddrs),
relays: HashSet::from_iter(relays),
}
}
/// Removes duplicate repository addresses by comparing their coordinates,
/// ignoring embedded relays.
pub fn dedup_naddrs(&mut self) {
let mut seen = HashSet::new();
self.naddrs.retain(|n| seen.insert(n.coordinate.clone()));
}
}

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"))
}

54
src/cli/macros.rs Normal file
View File

@@ -0,0 +1,54 @@
// 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::traits::CommandRunner;
/// Returns whether the command runner type `T` requires relays.
pub fn get_relays_state<T: CommandRunner>(_v: &T) -> bool {
T::NEED_RELAYS
}
/// Returns whether the command runner type `T` requires a signer.
pub fn get_signer_state<T: CommandRunner>(_v: &T) -> bool {
T::NEED_SIGNER
}
/// Executes a command with required setup checks. The first parameter is the
/// command to match on (often `self`), followed by options. Optional
/// subcommands come next, and commands with arguments (after `&`) are listed
/// last.
#[macro_export]
macro_rules! run_command {
($command:ident, $options:ident, $($subcommands:ident)* & $($commands:ident)*) => {
match $command {
$(
Self::$subcommands { subcommands } => subcommands.run($options).await,
)*
$(
Self::$commands ( args ) => {
if $crate::cli::macros::get_relays_state(&args) {
$options.ensure_relays()?;
}
if $crate::cli::macros::get_signer_state(&args) {
$options.ensure_signer()?;
}
args.run($options).await
},
)*
}
};
}

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

@@ -0,0 +1,178 @@
// 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;
/// Common commands used by multiply commands
pub mod common_commands;
/// The CLI config
pub mod config;
/// Default lazy values for CLI arguments
pub mod defaults;
/// Macros for CLI application.
pub mod macros;
/// Represents the state used for CLI options.
pub mod options_state;
/// CLI arguments parsers
pub mod parsers;
/// CLI traits
pub mod traits;
/// Common helper types used throughout the CLI.
pub mod types;
use clap::Parser;
use clap_verbosity_flag::Verbosity;
use nostr::key::Keys;
use nostr::key::SecretKey;
use nostr_browser_signer_proxy::BrowserSignerProxy;
use nostr_browser_signer_proxy::BrowserSignerProxyOptions;
use nostr_keyring::KeyringError;
use nostr_keyring::NostrKeyring;
use types::RelayOrSet;
pub use self::commands::*;
pub use self::config::*;
use self::traits::CommandRunner;
use crate::cli::options_state::BROWSER_SIGNER_PROXY_TIMEOUT;
use crate::error::N34Error;
use crate::error::N34Result;
use crate::nostr_utils::traits::NostrKeyringErrorUtils;
/// Header message, used in the help message
const HEADER: &str = r#"Copyright (C) 2025 Awiteb <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 {
/// Keyring service name of n34
pub const N34_KEYRING_SERVICE_NAME: &str = "n34";
/// Keyring entry name of the n34 keypair
pub const N34_KEY_PAIR_ENTRY: &str = "n34_keypair";
/// Keyring entry name of the user secret key
pub const USER_KEY_PAIR_ENTRY: &str = "user_keypair";
/// Executes the command
pub async fn run(self) -> N34Result<()> {
self.command.run(self.options).await
}
/// Gets the n34 keypair from the keyring or generates and stores a new one
/// if none exists.
pub fn n34_keypair() -> N34Result<Keys> {
let keyring = NostrKeyring::new(Self::N34_KEYRING_SERVICE_NAME);
match keyring.get(Self::N34_KEY_PAIR_ENTRY) {
Ok(keys) => Ok(keys),
Err(nostr_keyring::Error::Keyring(KeyringError::NoEntry)) => {
let new_keys = Keys::generate();
keyring.set(Self::N34_KEY_PAIR_ENTRY, &new_keys)?;
Ok(new_keys)
}
Err(err) => Err(N34Error::Keyring(err)),
}
}
/// Retrieves the user's keypair from the keyring. If no key exists and one
/// is provided, stores and returns it. If no key exists and none is
/// provided, returns an error.
pub fn user_keypair(secret_key: Option<SecretKey>) -> N34Result<Keys> {
let keyring = NostrKeyring::new(Self::N34_KEYRING_SERVICE_NAME);
let keyring_key = keyring.get(Self::USER_KEY_PAIR_ENTRY);
if let Err(ref err) = keyring_key
&& err.is_keyring_no_entry()
&& let Some(secret_key) = secret_key
{
let keypair = Keys::new(secret_key);
keyring.set(Self::USER_KEY_PAIR_ENTRY, &keypair)?;
return Ok(keypair);
}
keyring_key.map_err(|err| {
if err.is_keyring_no_entry() {
N34Error::SecretKeyKeyringWithoutEntry
} else {
N34Error::Keyring(err)
}
})
}
}
/// Processes the CLI configuration by applying fallback values from config if
/// needed. Returns the processed Cli configuration if successful.
pub fn post_cli(mut cli: Cli) -> N34Result<Cli> {
cli.options.pow = cli.options.pow.or(cli.options.config.pow);
if cli.options.relays.is_empty()
&& let Some(relays) = &cli.options.config.fallback_relays
{
cli.options.relays = relays.iter().cloned().map(RelayOrSet::Relay).collect();
}
// Automatically sets the signer based on the configuration if no signer
// is provided.
if !cli.options.nip07
&& cli.options.bunker_url.is_none()
&& (cli.options.secret_key.is_none() || cli.options.config.keyring_secret_key)
{
if let Some(addr) = cli.options.config.nip07 {
cli.options.nip07 = true;
cli.options.state.browser_signer_proxy = BrowserSignerProxy::new(
BrowserSignerProxyOptions::default()
.timeout(BROWSER_SIGNER_PROXY_TIMEOUT)
.ip_addr(addr.ip())
.port(addr.port()),
);
} else if let Some(bunker_url) = &cli.options.config.bunker_url {
cli.options.bunker_url = Some(bunker_url.clone());
} else if cli.options.config.keyring_secret_key {
cli.options.secret_key = Some(
Cli::user_keypair(cli.options.secret_key)?
.secret_key()
.clone(),
);
}
}
Ok(cli)
}

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

@@ -0,0 +1,56 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::{
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
time::Duration,
};
use nostr_browser_signer_proxy::{BrowserSignerProxy, BrowserSignerProxyOptions};
/// The default socket address used for the NIP-07 signer proxy, set to
/// localhost on port 51034.
pub const DEFAULT_NIP07_PROXY_ADDR: SocketAddr =
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 51034));
/// How long to wait for the proxy response (3 minutes).
pub const BROWSER_SIGNER_PROXY_TIMEOUT: Duration = Duration::from_secs(60 * 3);
/// Represents the state used for CLI options.
pub struct OptionsState {
/// The browser signer proxy, will be used if `--nip07` is enabled
pub browser_signer_proxy: BrowserSignerProxy,
}
impl Default for OptionsState {
fn default() -> Self {
Self {
browser_signer_proxy: default_browser_signer_proxy(),
}
}
}
/// Build the default browser signer proxy
#[inline]
fn default_browser_signer_proxy() -> BrowserSignerProxy {
BrowserSignerProxy::new(
BrowserSignerProxyOptions::default()
.timeout(BROWSER_SIGNER_PROXY_TIMEOUT)
.ip_addr(DEFAULT_NIP07_PROXY_ADDR.ip())
.port(DEFAULT_NIP07_PROXY_ADDR.port()),
)
}

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

@@ -0,0 +1,134 @@
// 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, PathBuf},
};
use nostr::{
Kind,
nips::{
nip19::{FromBech32, Nip19Coordinate, ToBech32},
nip46::NostrConnectURI,
},
};
use serde::{Deserialize, Serialize, Serializer};
use super::CliConfig;
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)
}
/// Loads CLI configuration from given path. Uses default config path if input
/// matches fallback path.
pub fn parse_config_path(config_path: &str) -> N34Result<CliConfig> {
let mut path = PathBuf::from(config_path.trim());
if config_path == DEFAULT_FALLBACK_PATH {
path = super::defaults::config_path()?;
};
CliConfig::load(path)
}
/// Parses a bunker URL and checks if it's a valid Nostr Connect URI.
/// Returns an error if the URL is not a valid bunker URL.
pub fn parse_bunker_url(bunker_url: &str) -> N34Result<NostrConnectURI> {
match NostrConnectURI::parse(bunker_url) {
Ok(url) if url.is_bunker() => Ok(url),
_ => Err(N34Error::NotBunkerUrl),
}
}
/// Serializes a set of NIP-19 coordinates as a list of bech32 strings.
pub fn ser_naddrs<S>(naddr: &HashSet<Nip19Coordinate>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let str_naddrs = naddr
.iter()
.map(|n| n.to_bech32().map_err(|err| err.to_string()))
.collect::<Result<Vec<_>, _>>()
.map_err(serde::ser::Error::custom)?;
str_naddrs.serialize(serializer)
}
/// Deserializes a list of bech32 strings into a set of NIP-19 coordinates.
pub fn de_naddrs<'de, D>(deserializer: D) -> Result<HashSet<Nip19Coordinate>, D::Error>
where
D: serde::Deserializer<'de>,
{
Vec::<String>::deserialize(deserializer)?
.into_iter()
.map(|naddr| Nip19Coordinate::from_bech32(&naddr))
.collect::<Result<HashSet<_>, _>>()
.map_err(serde::de::Error::custom)
}
pub fn ser_bunker_url<S>(
bunker_url: &Option<NostrConnectURI>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
bunker_url
.as_ref()
.map(|u| u.to_string())
.serialize(serializer)
}
pub fn de_bunker_url<'de, D>(deserializer: D) -> Result<Option<NostrConnectURI>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(deserializer)?
.map(|u| parse_bunker_url(&u))
.transpose()
.map_err(serde::de::Error::custom)
}

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

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

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

@@ -0,0 +1,218 @@
// 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::{EventId, Kind},
nips::{
nip01::Coordinate,
nip05::{Nip05Address, Nip05Profile},
nip19::{self, FromBech32, Nip19Coordinate},
},
types::RelayUrl,
util::BoxedFuture,
};
use nostr_connect::client::AuthUrlHandler;
use tokio::runtime::Handle;
use super::parsers;
use crate::{
cli::{RepoRelaySet, traits::RepoRelaySetsExt},
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),
}
/// Parses and represents a Nostr `nevent1` or `note1`.
#[derive(Debug, Clone)]
pub struct NostrEvent {
/// Unique identifier for the event.
pub event_id: EventId,
/// List of relay URLs associated with the event. Empty if parsing a
/// `note1`.
pub relays: Vec<RelayUrl>,
}
#[derive(Debug)]
pub struct EchoAuthUrl;
impl AuthUrlHandler for EchoAuthUrl {
fn on_auth_url(
&self,
auth_url: nostr::Url,
) -> BoxedFuture<'_, Result<(), Box<dyn std::error::Error>>> {
Box::pin(async move {
println!("The bunker requires authentication. Please open this URL: {auth_url}");
Ok(())
})
}
}
impl NaddrOrSet {
/// Returns the naddr if `Naddr` or try to get the relays from the set.
/// Returns error if the set naddrs are empty or the set not found.
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)
.map_err(|_| N34Error::InvalidRelaysArg(name.clone()))?;
if set.relays.is_empty() {
Err(N34Error::EmptySetRelays(name))
} else {
Ok(Vec::from_iter(set.relays.clone()))
}
}
}
}
}
impl NostrEvent {
/// Create a new [`NostrEvent`] instance
fn new(event_id: EventId, relays: Vec<RelayUrl>) -> Self {
Self { event_id, relays }
}
}
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()))
}
}
}
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())
}
}
}
fn parse_nip5_repo(nip5: &str, repo_id: &str) -> Result<NaddrOrSet, String> {
let (username, domain) = nip5.split_once("@").unwrap_or(("_", nip5));
let nip5_address =
Nip05Address::parse(&format!("{username}@{domain}")).map_err(|err| err.to_string())?;
let nip5_json = tokio::task::block_in_place(|| {
Handle::current().block_on(async {
reqwest::get(nip5_address.url().as_str())
.await
.map_err(|err| err.to_string())?
.text()
.await
.map_err(|err| err.to_string())
})
})?;
let nip5_profile =
Nip05Profile::from_raw_json(&nip5_address, &nip5_json).map_err(|err| err.to_string())?;
Ok(NaddrOrSet::Naddr(Nip19Coordinate::new(
Coordinate::new(Kind::GitRepoAnnouncement, nip5_profile.public_key).identifier(repo_id),
nip5_profile.relays,
)))
}

169
src/error.rs Normal file
View File

@@ -0,0 +1,169 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::{net::AddrParseError, process::ExitCode};
use nostr::{
event::{Kind, builder::Error as EventBuilderError},
signer::SignerError,
};
use nostr_sdk::client::Error as ClientError;
use crate::cli::ConfigError;
/// The input data was incorrect in some way. This should only be used for
/// users data and not system file.
const DATA_ERROR: u8 = 65;
/// An internal software error has been detected. This should be limited to
/// non-operating system related errors.
const SOFTWARE_ERROR: u8 = 70;
/// An error occurred while doing I/O on some file.
const IO_ERROR: u8 = 74;
/// Something was found in an unconfigured or misconfigured state.
const CONFIG_ERROR: u8 = 78;
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("Signer Error: {0}")]
SignerError(#[from] SignerError),
#[error("Invalid Browser Signer Proxy Address: {0}")]
Addr(#[from] AddrParseError),
#[error("Browser Signer Proxy Error: {0}")]
BrowserSignerProxy(#[from] nostr_browser_signer_proxy::Error),
#[error("Keyring error: {0}")]
Keyring(#[from] nostr_keyring::Error),
#[error("{0}")]
Config(#[from] ConfigError),
#[error("No editor specified in the `EDITOR` environment variable")]
EditorNotFound,
#[error("The file you edited is empty. Please save your changes before exiting the editor.")]
EmptyEditorFile,
#[error("The editor `{0}` exit with unsuccessful exit code `{1}`")]
EditorErr(String, i32),
#[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("One naddr is required for this command")]
EmptyNaddrs,
#[error(
"This command requires a signer to sign events. Use `--secret-key`, `--nip07` or \
`--bunker-url` to provide a signer"
)]
SignerRequired,
#[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(
"Invalid relays. Expected a relay url or a set name that contains some relays\nError: No \
set named '{0}' exists."
)]
InvalidRelaysArg(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 relays to it.")]
EmptySetRelays(String),
#[error(
"Issue not found, make sure it is in the relays and make sure that the ID is an issue ID"
)]
CanNotFoundIssue,
#[error(
"Patch not found, make sure it is in the relays and make sure that the ID is an patch ID"
)]
CanNotFoundPatch,
#[error(r#"The given patch id is not a root patch. It must contains `["t", "root"]` tag"#)]
NotRootPatch,
#[error("This status kind can't be set for an issue: {0}")]
InvalidIssueStatus(Kind),
#[error("This status kind can't be set for a patch: {0}")]
InvalidPatchStatus(Kind),
#[error("Can't find the root patch of the given patch-revision")]
RevisionRootNotFound,
#[error("Invalid status for the issue/patch: {0}")]
InvalidStatus(String),
#[error("Not valid bunker URL")]
NotBunkerUrl,
#[error(
"No secret key found in the keyring. Please use the secret key at least once while \
keyring is enabled to store it"
)]
SecretKeyKeyringWithoutEntry,
}
impl N34Error {
/// Returns the exit code associated with this error
pub fn exit_code(&self) -> ExitCode {
match self {
Self::Io(_) | Self::CanNotReadNostrAddressFile(_) => ExitCode::from(IO_ERROR),
Self::Config(_) => ExitCode::from(CONFIG_ERROR),
Self::EditorErr(..) => ExitCode::from(SOFTWARE_ERROR),
Self::InvalidRepoId
| Self::EmptyNostrAddressFile
| Self::InvalidNostrAddressFileContent(_)
| Self::EmptyRelays
| Self::EmptyNaddrs
| Self::SignerRequired
| Self::InvalidNaddrArg(_)
| Self::InvalidRelaysArg(_)
| Self::EmptySetNaddrs(_)
| Self::EmptySetRelays(_)
| Self::NotRootPatch => ExitCode::from(DATA_ERROR),
_ => 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
pub mod cli;
/// N34 errors
pub mod error;
/// Nostr utils module
pub 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::post_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
}

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

@@ -0,0 +1,479 @@
// 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, TagKind, TagStandard, Tags, UnsignedEvent},
filter::Filter,
key::PublicKey,
nips::{
nip01::{Coordinate, Metadata},
nip19::ToBech32,
nip22,
nip34::GitRepositoryAnnouncement,
},
parser::NostrParser,
types::RelayUrl,
};
use nostr_sdk::{Client, ClientOptions};
use traits::TokenUtils;
use crate::{
cli::{CliOptions, issue::IssueStatus, patch::PatchStatus},
error::{N34Error, N34Result},
};
/// Timeout duration for the client.
const CLIENT_TIMEOUT: Duration = Duration::from_millis(1500);
/// Length of a Nostr npub (public key) in characters.
const NPUB_LEN: usize = 63;
/// 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
#[derive(Clone)]
pub struct NostrClient {
/// The underlying Nostr client implementation
pub 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 mut client_builder =
Client::builder().opts(ClientOptions::new().verify_subscriptions(true));
if let Ok(Some(signer)) = options.signer().await {
client_builder = client_builder.signer(signer);
}
let client = Self::new(client_builder.build());
client.add_relays(relays).await;
client
}
//// Returns the users public key
pub async fn pubkey(&self) -> N34Result<PublicKey> {
self.client
.signer()
.await?
.get_public_key()
.await
.map_err(N34Error::SignerError)
}
/// Add relays and connect to them
pub async fn add_relays(&self, relays: &[RelayUrl]) {
if relays.is_empty() {
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.limit(1), CLIENT_TIMEOUT)
.await?
.first_owned())
}
/// Fetches the events matching the given filter
pub async fn fetch_events(&self, filter: Filter) -> N34Result<Vec<Event>> {
// Multiply timeout by 5 to account for multiple events being fetched
Ok(self
.client
.fetch_events(filter, CLIENT_TIMEOUT * 5)
.await?
.to_vec())
}
/// Try to fetch the repositories 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()
}
/// Fetch the patch by the given id. None if not found
pub async fn fetch_patch(&self, patch_id: EventId) -> N34Result<Event> {
self.fetch_event(Filter::new().id(patch_id).kind(Kind::GitPatch))
.await?
.ok_or(N34Error::CanNotFoundPatch)
}
/// Returns the username for a given public key. If no username is found,
/// falls back to a shortened version of the public key.
pub async fn get_username(&self, user: PublicKey) -> String {
self.fetch_event(Filter::new().kind(Kind::Metadata).author(user))
.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 = user.to_bech32().expect("The error is `Infallible`");
format!("{}...{}", &pubkey[..8], &pubkey[NPUB_LEN - 8..])
})
}
/// Get the latest status of an issue by its ID, only considering status
/// events from authorized_pubkeys. If no valid status event is found,
/// defaults to Open.
pub async fn fetch_issue_status(
&self,
issue_id: EventId,
authorized_pubkeys: Vec<PublicKey>,
) -> N34Result<IssueStatus> {
self.fetch_events(
Filter::new()
.event(issue_id)
.kinds([
Kind::GitStatusOpen,
Kind::GitStatusApplied,
Kind::GitStatusClosed,
])
.authors(utils::dedup(authorized_pubkeys.into_iter())),
)
.await?
.into_iter()
.max_by_key(|e| e.created_at)
.map(|status| IssueStatus::try_from(status.kind))
.unwrap_or_else(|| Ok(IssueStatus::Open))
}
/// Gets the status of a patch. If it's a revision patch, checks if it's
/// closed when the root patch is already merged/applied but doesn't
/// reference this revision. Defaults to Open status if no status event
/// is found.
pub async fn fetch_patch_status(
&self,
root_patch: EventId,
root_revision: Option<EventId>,
authorized_pubkeys: Vec<PublicKey>,
) -> N34Result<PatchStatus> {
let (root_status, event_tags) = self
.fetch_events(
Filter::new()
.event(root_patch)
.kinds([
Kind::GitStatusOpen,
Kind::GitStatusApplied,
Kind::GitStatusClosed,
Kind::GitStatusDraft,
])
.authors(utils::dedup(authorized_pubkeys.into_iter())),
)
.await?
.into_iter()
.max_by_key(|e| e.created_at)
.map(|status| N34Result::Ok((PatchStatus::try_from(status.kind)?, status.tags)))
.unwrap_or_else(|| Ok((PatchStatus::Open, Tags::new())))?;
if let Some(revision_id) = root_revision
&& root_status.is_merged_or_applied()
&& !event_tags
.filter(TagKind::e())
.any(|t| t.is_reply() && t.content().is_some_and(|c| c == revision_id.to_hex()))
{
return Ok(PatchStatus::Closed);
}
Ok(root_status)
}
pub async fn fetch_patch_series(
&self,
root_patch_id: EventId,
root_patch_author: PublicKey,
) -> N34Result<Vec<Event>> {
Ok(self
.fetch_events(
Filter::new()
.kind(Kind::GitPatch)
.author(root_patch_author)
.event(root_patch_id),
)
.await?
.into_iter()
.filter(|e| {
e.tags.iter().any(|t| {
t.is_root() && t.content().is_some_and(|c| c == root_patch_id.to_hex())
})
})
.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
&& !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())
}
/// Returns the read relays of the given users if found, otherwise empty
/// vector
pub async fn read_relays_from_users(&self, users: &[PublicKey]) -> Vec<RelayUrl> {
self.fetch_events(
Filter::new()
.kind(nostr::event::Kind::RelayList)
.authors(utils::dedup(users.iter().copied())),
)
.await
.unwrap_or_default()
.into_iter()
.flat_map(|e| utils::add_read_relays(Some(&e)))
.collect()
}
/// 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,
)
}
}

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

@@ -0,0 +1,320 @@
// 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::hashes::sha1::Hash as Sha1Hash;
use nostr::{
event::{Event, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard, Tags},
key::PublicKey,
nips::{
nip01::Coordinate,
nip19::Nip19Coordinate,
nip21::Nip21,
nip34::{GitIssue, GitRepositoryAnnouncement},
},
parser::Token,
types::{RelayUrl, Url},
};
use nostr_keyring::KeyringError;
use crate::cli::issue::ISSUE_ALT_PREFIX;
use crate::cli::patch::{
LEGACY_NGIT_REVISION_ROOT_HASHTAG_CONTENT,
REVISION_ROOT_HASHTAG_CONTENT,
ROOT_HASHTAG_CONTENT,
};
use crate::error::{N34Error, N34Result};
/// 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
#[inline]
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`
#[inline]
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.
#[inline]
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(
coordinates: &[Coordinate],
content: String,
subject: Option<String>,
labels: Vec<String>,
) -> N34Result<EventBuilder> {
let mut coordinates = coordinates.iter();
let first_coordinate = coordinates.next().ok_or(N34Error::EmptyNaddrs)?;
let mut event_builder = EventBuilder::git_issue(GitIssue {
repository: first_coordinate.clone(),
content,
subject: subject.clone(),
labels: labels.into_iter().map(|l| l.trim().to_owned()).collect(),
})
.map_err(N34Error::from)?
.tags(
coordinates
.clone()
.map(|c| Tag::coordinate(c.clone(), None)),
)
.tags(coordinates.map(|c| Tag::public_key(c.public_key)));
if let Some(issue_subject) = subject {
event_builder =
event_builder.tag(Tag::alt(format!("{ISSUE_ALT_PREFIX}{issue_subject}")))
}
Ok(event_builder)
}
}
/// 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
#[inline]
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
#[inline]
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
#[inline]
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
#[inline]
pub fn into_coordinates(self) -> Vec<Coordinate> {
self.into_iter().map(|n| n.coordinate).collect()
}
/// Returns all repository owners' public keys from these coordinates.
#[inline]
pub fn extract_owners(&self) -> Vec<PublicKey> {
self.iter().map(|n| n.public_key).collect()
}
/// Extracts all relay URLs from these coordinates
#[inline]
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 repositories
#[inline]
pub fn extract_relays(&self) -> Vec<RelayUrl> {
self.iter().flat_map(|n| n.relays.clone()).collect()
}
/// Extract all the maintainers from these repositories
#[inline]
pub fn extract_maintainers(&self) -> Vec<PublicKey> {
self.iter().flat_map(|r| r.maintainers.clone()).collect()
}
/// Gets the first EUC hash from the reposotoies if it exists.
#[inline]
pub fn extract_euc(&self) -> Option<&Sha1Hash> {
self.iter().find_map(|r| r.euc.as_ref())
}
}
/// Utility functions for working with patch events
#[easy_ext::ext(GitPatchUtils)]
impl Event {
/// Returns whether the patch is a root or not
#[inline]
pub fn is_root_patch(&self) -> bool {
self.kind == Kind::GitPatch
&& self
.tags
.filter(TagKind::t())
.any(|t| t.content() == Some(ROOT_HASHTAG_CONTENT))
}
/// Returns whether the patch is patch-revision or not
#[inline]
pub fn is_revision_patch(&self) -> bool {
self.kind == Kind::GitPatch
&& self.tags.filter(TagKind::t()).any(|t| {
[
Some(REVISION_ROOT_HASHTAG_CONTENT),
Some(LEGACY_NGIT_REVISION_ROOT_HASHTAG_CONTENT),
]
.contains(&t.content())
})
}
/// Gets the root patch ID from a patch-revision event by finding the `e`
/// tag that replies to it. Fails if no such tag is found or if the tag
/// contains an invalid event ID.
pub fn root_patch_from_revision(&self) -> N34Result<EventId> {
self.tags
.iter()
.find(|tag| tag.is_reply())
.ok_or_else(|| {
N34Error::InvalidEvent(
"A patch revision without `e`-reply to the root patch".to_owned(),
)
})?
.content()
.ok_or_else(|| N34Error::InvalidEvent("`e` tag without an event".to_owned()))?
.parse()
.map_err(|err| N34Error::InvalidEvent(format!("Invalid event ID in `e` tag: {err}")))
}
}
/// Utility functions for working with issue events
#[easy_ext::ext(GitIssueUtils)]
impl Event {
/// Gets the subject line of the issue or "N/A" if none exists
#[inline]
pub fn extract_issue_subject(&self) -> &str {
self.tags
.find(TagKind::Subject)
.and_then(|t| t.content())
.unwrap_or("N/A")
}
/// Gets all issue labels formatted as comma-separated hashtags (e.g. "#bug,
/// #feature")
#[inline]
pub fn extract_issue_labels(&self) -> String {
self.tags
.filter(TagKind::t())
.filter_map(|t| t.content().map(|l| format!("#{l}")))
.collect::<Vec<_>>()
.join(", ")
}
}
#[easy_ext::ext(NostrKeyringErrorUtils)]
impl nostr_keyring::Error {
/// Checks if the error indicates a missing keyring entry.
#[inline]
pub fn is_keyring_no_entry(&self) -> bool {
matches!(self, nostr_keyring::Error::Keyring(KeyringError::NoEntry))
}
}

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

@@ -0,0 +1,311 @@
// 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, Tag, TagKind, TagStandard},
filter::Alphabet,
key::PublicKey,
nips::{
nip01::Coordinate,
nip10::Marker,
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
#[inline]
fn tag_value(tag: &TagStandard) -> String {
tag.clone().to_vec().remove(1)
}
/// Parses the tag value into type `T` if possible.
#[inline]
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.
#[inline]
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::single_letter(Alphabet::R, false),
"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
}
/// Sorts items from the iterator using the given key function.
/// The sorting is unstable, but faster than stable sorting.
pub fn sort_by_key<I, T, K>(iterator: I, key: impl FnMut(&T) -> K) -> impl Iterator<Item = T>
where
I: IntoIterator<Item = T>,
K: Ord,
{
let mut vector = Vec::<T>::from_iter(iterator);
vector.sort_unstable_by_key(key);
vector.into_iter()
}
/// Creates a new NIP-19 nevent string from an event ID and up to 3 unique relay
/// URLs.
#[inline]
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.
#[inline]
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()).into_iter().take(3),
)
.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()
&& let Some(code) = exit_status.code()
{
return Err(N34Error::EditorErr(editor, code));
}
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.
#[inline]
pub fn nostr_address_path() -> std::io::Result<PathBuf> {
std::env::current_dir().map(|p| p.join(NOSTR_ADDRESS_FILE))
}
/// Returns the given coordinates if Some, otherwise attempts to read and parse
/// coordinates from the specified file. Returns an empty vector if the file
/// doesn't exist.
pub fn naddrs_or_file(
naddrs: Option<Vec<Nip19Coordinate>>,
address_file_path: &Path,
) -> N34Result<Vec<Nip19Coordinate>> {
if let Some(naddrs) = naddrs {
return Ok(naddrs);
}
if address_file_path.exists() {
parsers::parse_nostr_address_file(address_file_path)
} else {
Ok(Vec::new())
}
}
/// Generate a reply tag for an event with the given ID, relay URL (if any), and
/// marker.
#[inline]
pub fn event_reply_tag(reply_to: &EventId, relay: Option<&RelayUrl>, marker: Marker) -> Tag {
Tag::custom(
TagKind::e(),
[
reply_to.to_hex(),
relay.map(|r| r.to_string()).unwrap_or_default(),
marker.to_string(),
],
)
}
/// Wraps text into lines no longer than max_width, breaking only at whitespace.
pub fn smart_wrap(text: &str, max_width: usize) -> String {
text.lines()
.map(|line| {
if !line.trim().is_empty() {
line.split(" ")
.fold((String::new(), 0), |(result, last_newline), word| {
let result_len = result.chars().count();
if result_len == 0 {
(word.to_owned(), 0)
} else if (result_len - last_newline) + word.chars().count() > max_width {
(format!("{result}\n{word}"), result_len + 1)
} else {
(format!("{result} {word}"), last_newline)
}
})
.0
} else {
String::new()
}
})
.collect::<Vec<_>>()
.join("\n")
}
/// Returns an error if the given naddrs is empty otherwise returned it
pub fn check_empty_naddrs(naddrs: Vec<Nip19Coordinate>) -> N34Result<Vec<Nip19Coordinate>> {
if naddrs.is_empty() {
return Err(N34Error::EmptyNaddrs);
}
Ok(naddrs)
}