diff --git a/cli/README.md b/cli/README.md index f60de8e029..4024f787aa 100644 --- a/cli/README.md +++ b/cli/README.md @@ -326,6 +326,8 @@ the default is the repo's advertised relays, else your outbox. | `amy git state REPO\|IDENTIFIER [--head BRANCH] [--branch name=commit[,…]] [--tag name=commit[,…]]` | Publish a kind:30618 repository state (branch/tag tips + HEAD). | | `amy git list [USER]` | List a user's repo announcements (defaults to self). | | `amy git show NADDR\|kind:pubkey:id` | Print one repo announcement (cache-first). | +| `amy git grasp list [USER]` | List a user's kind:10317 GRASP hosting-server list (defaults to self). | +| `amy git grasp set URL[,URL]` | Publish your GRASP hosting-server list (preference order — where PR tips get pushed). | **Issues, patches & pull requests** diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index e6506fa3bf..df1b2db5ae 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -109,7 +109,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `nip` | `amy nip` | ✅ | repo-first lookup + Nostr fallback (NipText kind:30817, wiki:30818, long-form:30023); `nip list`. | | `kind` | `amy kind` | ✅ | quartz `KindNames` registry (kind → English label + NIP) covering **every** event kind quartz defines (280 entries); number lookup + name search. | | `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). | -| `git` | `amy git` | ✅ (events) | NIP-34: repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), status open/applied/closed/draft (1630-1633); `issues`/`patches`/`prs`/`thread` reads derive status. clone/push (git-packfile transport) out of scope. | +| `git` | `amy git` | ✅ (events) | NIP-34: repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status. clone/push (git-packfile transport) out of scope. | | `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. | | `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. | | `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index b6e2b4b877..ff8e5a3c09 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -632,6 +632,7 @@ private fun printUsage() { | [--tag n=c[,…]] | git list [USER] list a user's repo announcements (default self) | git show NADDR|kind:pubkey:id print one repo announcement + | git grasp list [USER] | set URL[,URL] GRASP hosting-server list (kind 10317) | git issue REPO --subject S [BODY] publish a kind:1621 issue against a repo | [--hashtag T[,T]] (BODY from arg or stdin) | git patch REPO [--file P] [--root] publish a kind:1617 patch (format-patch/stdin) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt index 9079a7ceb2..f30008c9a9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt @@ -59,6 +59,7 @@ object GitCommands { | [--tag name=commit[,…]] | git list [USER] list a user's repo announcements (default self) | git show NADDR|kind:pubkey:id print one repo announcement + | git grasp list [USER] | set URL[,URL] a user's GRASP hosting-server list (kind 10317) | |Issues / patches / pull requests: | git issue REPO --subject S [BODY] publish a kind:1621 issue (BODY arg or stdin) @@ -89,12 +90,13 @@ object GitCommands { route( "git", tail, - "git ", + "git ", mapOf( "announce" to { rest -> announce(dataDir, rest) }, "state" to { rest -> state(dataDir, rest) }, "list" to { rest -> list(dataDir, rest) }, "show" to { rest -> show(dataDir, rest) }, + "grasp" to { rest -> GitGraspCommands.dispatch(dataDir, rest) }, "issue" to { rest -> issue(dataDir, rest) }, "issues" to { rest -> GitReadCommands.issues(dataDir, rest) }, "patch" to { rest -> GitPatchCommands.patch(dataDir, rest) }, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitGraspCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitGraspCommands.kt new file mode 100644 index 0000000000..e330c7e621 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitGraspCommands.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent + +/** + * `amy git grasp ` — a user's NIP-34 GRASP (Git-over-Nostr hosting) + * server list (kind 10317). Functions like NIP-65's relay list: it declares, + * in preference order, where PR tip branches (`refs/nostr/`) get pushed + * so maintainers know where to fetch them. `ngit` and `nak git` read this to + * pick a push host; amy publishes and reads the list (the git push itself is + * out of scope — see cli/ROADMAP.md). + */ +object GitGraspCommands { + val USAGE: String = + """ + |amy git grasp — NIP-34 GRASP server list (kind 10317) + | + | git grasp list [USER] list a user's grasp servers (default self) + | git grasp set URL[,URL] [--relay URL[,URL]] publish your grasp server list (preference order) + """.trimMargin() + + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "git grasp", + tail, + "git grasp ", + mapOf( + "list" to { rest -> list(dataDir, rest) }, + "set" to { rest -> set(dataDir, rest) }, + ), + help = USAGE, + ) + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + Context.openOrAnonymous(dataDir).use { ctx -> + ctx.prepare() + val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + args.rejectUnknown("relay") + val filter = Filter(kinds = listOf(UserGraspListEvent.KIND), authors = listOf(author), limit = 1) + var event = ctx.store.query(filter).firstOrNull() as? UserGraspListEvent + if (event == null) { + val relays = RawEventSupport.queryTargets(ctx, args) + ctx.drain(relays.associateWith { listOf(filter) }) + event = ctx.store.query(filter).firstOrNull() as? UserGraspListEvent + } + val grasps = event?.grasps().orEmpty() + Output.emit(mapOf("pubkey" to author, "count" to grasps.size, "grasps" to grasps)) + return 0 + } + } + + private suspend fun set( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val grasps = + args + .positional(0, "grasp-server-urls") + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + if (grasps.isEmpty()) return Output.error("bad_args", "git grasp set requires URL[,URL] (a comma-separated grasp server list)") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val template = UserGraspListEvent.build(grasps) + val signed = ctx.signer.sign(template) + val targets = RawEventSupport.publishTargets(ctx, args) + args.rejectUnknown() + val ack = ctx.publish(signed, targets) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "grasps" to grasps, + ) + RawEventSupport.ackFields(ack), + ) + return 0 + } + } +} diff --git a/cli/tests/git/git-nip34-headless.sh b/cli/tests/git/git-nip34-headless.sh index 5797cfaf13..400ebe1248 100755 --- a/cli/tests/git/git-nip34-headless.sh +++ b/cli/tests/git/git-nip34-headless.sh @@ -131,6 +131,13 @@ info "state: $STATE" assert_eq "$(echo "$STATE" | jq -r '.branches')" "2" state.branches "two branch refs" assert_eq "$(echo "$STATE" | jq -r '.head')" "main" state.head "HEAD=main" +# GRASP server list (10317) round-trip. +GRASP="$(M git grasp set "wss://grasp.example.com,wss://grasp2.example.com" --relay "$RELAY_URL")" +assert_eq "$(echo "$GRASP" | jq -r '.kind')" "10317" grasp.kind "grasp list is kind 10317" +GRASP_READ="$(M git grasp list --relay "$RELAY_URL")" +assert_eq "$(echo "$GRASP_READ" | jq -r '.count')" "2" grasp.count "two grasp servers read back" +assert_eq "$(echo "$GRASP_READ" | jq -r '.grasps[0]')" "wss://grasp.example.com" grasp.order "preference order preserved" + # ============================================================================= # Issue (1621) + patch (1617) + PR (1618) + PR update (1619) # =============================================================================