mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
feat(cli): add nak-style event + publish primitives
Second batch of nak parity: - `amy event --kind N [--content …] [--tags JSON] [--created-at TS]` builds and signs an arbitrary event with the active account. Prints the signed event by default; `--publish` / `--relay` broadcasts it. - `amy publish [EVENT-JSON] [--relay …]` broadcasts a pre-made, signed event (verified before broadcast; reads stdin when no arg). Both reuse quartz EventTemplate/NostrSigner.sign and the existing Context.publish path. New RawEventSupport holds the shared arg/stdin + relay-target helpers for the raw-event verbs. Verified offline via an event -> verify round-trip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
This commit is contained in:
@@ -214,6 +214,14 @@ Army-knife verbs that operate purely on their arguments. They never touch
|
||||
| `amy key generate` | Mint a fresh keypair (`nsec` + `npub` + hex). Does not persist — use `init`/`login` for that. |
|
||||
| `amy key public NSEC\|HEX` | Derive the public key from a secret key. |
|
||||
|
||||
### Raw events
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy event --kind N [--content TEXT] [--tags JSON] [--created-at TS]` | Build + sign an arbitrary event with the active account. Prints the signed event. `--tags` is a JSON array-of-arrays, e.g. `'[["t","nostr"],["e","<id>"]]'`. |
|
||||
| `amy event … --publish` / `--relay URL[,URL…]` | As above, then broadcast (to the outbox, or to the given relays). |
|
||||
| `amy publish [EVENT-JSON] [--relay URL[,URL…]]` | Broadcast a pre-made signed event (verified first). Reads stdin when the argument is omitted or `-`. |
|
||||
|
||||
### Identity
|
||||
|
||||
| Command | What it does |
|
||||
|
||||
+2
-2
@@ -82,8 +82,8 @@ vs streaming `subscribe`). Stateless verbs run with no account or network.
|
||||
| `encode` | `amy encode` | ✅ | npub/nsec/note/nevent/nprofile/naddr. |
|
||||
| `verify` / `validate` | `amy verify` | ✅ | id-hash + signature, reported separately. |
|
||||
| `key` | `amy key generate\|public` | ✅ in part · 🆕 | generate + derive done; NIP-49 encrypt/decrypt pending. |
|
||||
| `event` | `amy event` | 🆕 | build/sign an arbitrary event, optional `--publish`. |
|
||||
| `publish` | `amy publish` | 🆕 | broadcast a pre-made event JSON. |
|
||||
| `event` | `amy event` | ✅ | build/sign an arbitrary event, optional `--publish`/`--relay`. |
|
||||
| `publish` | `amy publish` | ✅ | broadcast a pre-made event JSON (verified first). |
|
||||
| `req` (one-shot) | `amy fetch` | 🆕 | filter → collect-until-EOSE. |
|
||||
| `req` (stream) | `amy subscribe` | 🆕 | filter → live stream to stdout. |
|
||||
| `count` | `amy count` | 🆕 | NIP-45. |
|
||||
|
||||
@@ -216,6 +216,14 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
Commands.debit(dataDir, tail)
|
||||
}
|
||||
|
||||
"event" -> {
|
||||
Commands.event(dataDir, tail)
|
||||
}
|
||||
|
||||
"publish" -> {
|
||||
Commands.publish(dataDir, tail)
|
||||
}
|
||||
|
||||
else -> {
|
||||
System.err.println("unknown subcommand: $head")
|
||||
printUsage()
|
||||
@@ -398,6 +406,14 @@ private fun printUsage() {
|
||||
| [--since TS] [--until TS]
|
||||
| [--timeout SECS]
|
||||
|
|
||||
|Raw events (build / sign / broadcast):
|
||||
| event --kind N [--content TEXT] build + sign an arbitrary event with the active
|
||||
| [--tags JSON] [--created-at TS] account. Prints the signed event; add --publish
|
||||
| [--publish] [--relay URL[,URL…]] (or --relay) to broadcast. --tags takes a JSON
|
||||
| array-of-arrays, e.g. '[["t","nostr"]]'.
|
||||
| publish [EVENT-JSON] [--relay URL[,URL…]] broadcast a pre-made signed event (verified
|
||||
| first; reads stdin when the arg is omitted/`-`)
|
||||
|
|
||||
|Static websites (NIP-5A kind:15128/35128):
|
||||
| nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and
|
||||
| [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin
|
||||
|
||||
@@ -135,4 +135,14 @@ object Commands {
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = DebitCommands.dispatch(dataDir, tail)
|
||||
|
||||
suspend fun event(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = EventCommand.run(dataDir, tail)
|
||||
|
||||
suspend fun publish(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = PublishCommand.run(dataDir, tail)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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.fasterxml.jackson.module.kotlin.readValue
|
||||
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.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* `amy event --kind N [--content TEXT] [--tags JSON] [--created-at TS]
|
||||
* [--publish] [--relay URL[,URL…]]` — build and sign an arbitrary
|
||||
* Nostr event (nak's `event`).
|
||||
*
|
||||
* Signs with the active account. By default it only prints the signed event
|
||||
* (no relay traffic). Pass `--publish` to broadcast to the account's outbox,
|
||||
* or `--relay` to broadcast to a specific set (implies publish).
|
||||
*
|
||||
* `--tags` takes a JSON array-of-arrays, e.g.
|
||||
* --tags '[["p","<hex>"],["t","nostr"],["e","<id>","","root"]]'
|
||||
*
|
||||
* Thin assembly only: event construction + signing live in quartz
|
||||
* (`EventTemplate` / `NostrSigner.sign`); this file parses flags.
|
||||
*/
|
||||
object EventCommand {
|
||||
suspend fun run(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val kind =
|
||||
args.flag("kind")?.toIntOrNull()
|
||||
?: return Output.error("bad_args", "event requires --kind N")
|
||||
val content = args.flag("content", "") ?: ""
|
||||
val createdAt = args.flag("created-at")?.toLongOrNull() ?: TimeUtils.now()
|
||||
|
||||
val tags: Array<Array<String>> =
|
||||
try {
|
||||
args
|
||||
.flag("tags")
|
||||
?.let { Output.mapper.readValue<List<List<String>>>(it) }
|
||||
?.map { it.toTypedArray() }
|
||||
?.toTypedArray()
|
||||
?: emptyArray()
|
||||
} catch (e: Exception) {
|
||||
return Output.error("bad_args", "--tags must be a JSON array of string arrays: ${e.message}")
|
||||
}
|
||||
|
||||
// Publish when explicitly asked (--publish) or when a relay set is given.
|
||||
val wantPublish = args.bool("publish") || args.flag("relay") != null
|
||||
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val signed: Event = ctx.signer.sign(createdAt, kind, tags, content)
|
||||
val eventNode = Output.mapper.readTree(signed.toJson())
|
||||
|
||||
if (!wantPublish) {
|
||||
Output.emit(mapOf("event" to eventNode, "published" to false))
|
||||
return 0
|
||||
}
|
||||
|
||||
val targets = RawEventSupport.publishTargets(ctx, args)
|
||||
if (targets.isEmpty()) {
|
||||
return Output.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`")
|
||||
}
|
||||
val ack = ctx.publish(signed, targets)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event" to eventNode,
|
||||
"published" to true,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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.crypto.verify
|
||||
|
||||
/**
|
||||
* `amy publish [EVENT-JSON] [--relay URL[,URL…]]` — broadcast a pre-made,
|
||||
* already-signed event (nak's `publish`). The event JSON comes from the
|
||||
* positional argument or from stdin when omitted or `-`.
|
||||
*
|
||||
* The event is verified before broadcast — a bad id/signature is rejected
|
||||
* rather than published. Targets default to the account's outbox when
|
||||
* `--relay` is not given.
|
||||
*/
|
||||
object PublishCommand {
|
||||
suspend fun run(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val json = RawEventSupport.readArgOrStdin(args)
|
||||
if (json.isEmpty()) return Output.error("bad_args", "no event JSON on the argument or stdin")
|
||||
|
||||
val event =
|
||||
try {
|
||||
Event.fromJson(json)
|
||||
} catch (e: Exception) {
|
||||
return Output.error("bad_args", "could not parse event JSON: ${e.message}")
|
||||
}
|
||||
if (!event.verify()) {
|
||||
return Output.error("invalid_event", "event id/signature does not verify — refusing to publish")
|
||||
}
|
||||
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val targets = RawEventSupport.publishTargets(ctx, args)
|
||||
if (targets.isEmpty()) {
|
||||
return Output.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`")
|
||||
}
|
||||
val ack = ctx.publish(event, targets)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to event.id,
|
||||
"kind" to event.kind,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
|
||||
/**
|
||||
* Shared helpers for the nak-style raw-event verbs (`event`, `publish`,
|
||||
* `fetch`, `subscribe`, `count`). Kept tiny — parsing/normalisation only,
|
||||
* no protocol logic.
|
||||
*/
|
||||
object RawEventSupport {
|
||||
/**
|
||||
* Read a blob from the first positional argument, or from stdin when the
|
||||
* argument is omitted or `-`. Used by verbs that take event/filter JSON.
|
||||
*/
|
||||
fun readArgOrStdin(args: Args): String {
|
||||
val arg = args.positionalOrNull(0)
|
||||
return if (arg == null || arg == "-") {
|
||||
System.`in`
|
||||
.readBytes()
|
||||
.decodeToString()
|
||||
.trim()
|
||||
} else {
|
||||
arg.trim()
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a `--relay a,b,c` flag into normalized relay URLs (silently drops un-normalizable entries). */
|
||||
fun relayFlag(args: Args): Set<NormalizedRelayUrl> =
|
||||
args
|
||||
.flag("relay")
|
||||
?.split(',')
|
||||
?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) }
|
||||
?.toSet()
|
||||
.orEmpty()
|
||||
|
||||
/**
|
||||
* Resolve where to publish: the explicit `--relay` set when given,
|
||||
* otherwise the account's NIP-65 outbox. Empty only when neither is
|
||||
* available (caller turns that into a `no_relays` error).
|
||||
*/
|
||||
suspend fun publishTargets(
|
||||
ctx: Context,
|
||||
args: Args,
|
||||
): Set<NormalizedRelayUrl> = relayFlag(args).ifEmpty { ctx.outboxRelays() }
|
||||
}
|
||||
Reference in New Issue
Block a user