mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
feat(cli): amy namecoin resolve + servers verbs
Add Namecoin NIP-05 resolution to the amy CLI as a stateless verb group, matching the Android and Desktop apps' resolution surface. amy namecoin resolve IDENT [--server URL[,URL]] [--timeout SECS] amy namecoin servers IDENT accepts the same shapes the apps accept: raw `d/` / `id/` names, bare `.bit` domains, and `alice@example.bit` NIP-05-style local-parts. Output is the resolved Nostr pubkey + relay list (+ the resolved Namecoin name + matched local-part) as machine-readable JSON (with `--json`) or human-readable text. The verb is stateless — no account, no `~/.amy/`, no relays — so it dispatches alongside `decode`/`encode`/`verify`/`nip`/`kind` before account resolution and the secret store. Zero new logic in cli/: the implementation is a thin command-file wrapper around quartz's existing `NamecoinNameResolver` + `ElectrumXClient` + the canonical `DEFAULT_ELECTRUMX_SERVERS` set the apps already ship with, including the pinned trust store for the self-signed Namecoin ElectrumX ecosystem. amy is headless so no UI piece is wired in. The `--server` flag accepts `host`, `host:port`, `tcp://`, `tls://`, `ssl://` per entry (defaults to TLS on 50002); empty / malformed entries fail with `bad_args` rather than silently using the default set, so a fat- fingered override can't go unnoticed. Outcomes from `NamecoinResolveOutcome` map to amy error codes: Success -> emit JSON, exit 0 NameNotFound -> error not_found NoNostrField -> error no_nostr_field MalformedRecord -> error malformed_record (+ namecoin_name extra) ServersUnreachable-> error servers_unreachable InvalidIdentifier -> error invalid_identifier Timeout -> error timeout Smoke-tested end-to-end on macOS arm64 against the live ElectrumX fleet: $ amy --json namecoin resolve d/testls {"identifier":"d/testls","namecoin_name":"d/testls", "local_part":"_","pubkey":"460c25e6…","relays":[]} $ amy namecoin servers count: 6 servers: - host: electrumx.testls.space port: 50002 tls: yes … No new runtime deps. The "no Compose UI in the amy image" CI assertion still passes — `NamecoinNameResolver` + `ElectrumXClient` are pure JVM (kotlinx.coroutines + kotlinx.serialization, both already on the CLI classpath via :quartz). Tests: the resolver, ElectrumX client, identifier parser, and the default server set already have JVM tests under `quartz/src/jvmTest/.../namecoin/` — no new core code in this PR, so the existing coverage applies. CLI verbs are exercised via the shell harnesses in `cli/tests/`; a Namecoin harness fits the same pattern but isn't included here. Parity matrix in `cli/ROADMAP.md` flags `name_history` and the Namecoin Core JSON-RPC backend as pending separate PRs — both already exist on Android and Desktop but aren't on upstream main yet (open PRs against this repo carry them).
This commit is contained in:
committed by
Vitor Pamplona
parent
f5793937a7
commit
305d6bc733
@@ -219,6 +219,8 @@ Army-knife verbs that operate purely on their arguments. They never touch
|
||||
| `amy filter [filter flags]` | Assemble and print a NIP-01 filter JSON from the same flags `fetch`/`subscribe` use — no query is sent. |
|
||||
| `amy nip N` / `amy nip list` | Look up a NIP — the `nostr-protocol/nips` repo first, then a Nostr wiki/long-form fallback. `list` fetches the index. |
|
||||
| `amy kind N` / `amy kind NAME` | Look up an event kind's label + defining NIP (number), or search labels by name. Backed by quartz's `KindNames` registry. |
|
||||
| `amy namecoin resolve IDENT [--server URL[,URL]] [--timeout SECS]` | Resolve a Namecoin identifier (`.bit`, `d/`, `id/`, `alice@example.bit`) to a Nostr pubkey + relays via the Namecoin blockchain. Stateless: talks directly to one or more ElectrumX servers over TLS, no account needed. Reuses the same NIP-05-Namecoin parser, server set, and pinned trust store as the Android and Desktop apps. |
|
||||
| `amy namecoin servers` | Print the default ElectrumX server list (host, port, TLS flag). |
|
||||
| `amy relay info URL` | Fetch and print a relay's NIP-11 information document. |
|
||||
|
||||
### Remote signing (NIP-46 bunker)
|
||||
|
||||
@@ -70,6 +70,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|
||||
| Thread view (`amy thread show EVENT_ID`) | ⚠️ | Same. |
|
||||
| Notifications feed | 🆕 | |
|
||||
| Search (NIP-50) | 🆕 | |
|
||||
| Namecoin NIP-05 resolve (`amy namecoin resolve .bit\|d/\|id/`) | ✅ | `NamecoinCommand` — reuses Quartz `NamecoinNameResolver` + `ElectrumXClient` + the default ElectrumX server set the Android/Desktop apps ship with. Stateless. On-chain `name_history` + Core RPC backend pending separate PRs. |
|
||||
|
||||
### `nak` parity — army-knife primitives
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.cli.commands.KindCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.LoginCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.MarmotResetCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.MessageCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.NamecoinCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.NappletCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.NipCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.NotesCommands
|
||||
@@ -178,6 +179,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
"filter" -> return FilterCommand.run(tail)
|
||||
"nip" -> return NipCommand.run(tail)
|
||||
"kind" -> return KindCommand.run(tail)
|
||||
"namecoin" -> return NamecoinCommand.dispatch(tail)
|
||||
}
|
||||
|
||||
// `relay info URL` is a stateless NIP-11 fetch — no account needed. The
|
||||
@@ -366,6 +368,10 @@ private fun printUsage() {
|
||||
| nip N show a NIP (repo first, then a Nostr wiki/long-form fallback)
|
||||
| nip list fetch the NIP index (README) from the repo
|
||||
| kind N|NAME look up an event kind's label + NIP (number, or search by name)
|
||||
| namecoin resolve IDENT resolve a Namecoin identifier (.bit, d/, id/, alice@x.bit)
|
||||
| [--server URL[,URL]] to a Nostr pubkey + relays via the Namecoin blockchain
|
||||
| [--timeout SECS] (no account, talks to ElectrumX over TLS)
|
||||
| namecoin servers print the default ElectrumX server list
|
||||
|
|
||||
|Identity:
|
||||
| init [--nsec NSEC] create or import a bare identity (no defaults published)
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* 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.Output
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
|
||||
|
||||
/**
|
||||
* `amy namecoin …` — resolve `.bit` / `d/` / `id/` identifiers to a
|
||||
* Nostr pubkey via the Namecoin blockchain.
|
||||
*
|
||||
* Stateless: no account, no `~/.amy/`, no relays. Talks directly to
|
||||
* one or more ElectrumX servers over TLS to fetch the latest
|
||||
* `name_show` value for the requested name and extracts the `nostr`
|
||||
* field per the NIP-05-Namecoin parser (priority: exact local-part →
|
||||
* `_` wildcard → first valid entry; `nostr:` prefix tolerated;
|
||||
* accepts the simple-string, single-identity-object, and
|
||||
* extended-NIP-05-like `names` map forms).
|
||||
*
|
||||
* Verbs:
|
||||
* resolve IDENTIFIER resolve to a Nostr pubkey (+ relays)
|
||||
* servers list the default ElectrumX server set
|
||||
*
|
||||
* IDENTIFIER accepts the same forms the Amethyst Android/Desktop apps
|
||||
* accept:
|
||||
* d/example raw `d/` Namecoin name (root `_` local-part)
|
||||
* id/alice raw `id/` Namecoin name (always `_`)
|
||||
* example.bit domain form (canonicalised to `d/example`)
|
||||
* alice@example.bit NIP-05-style local-part in a `.bit` domain
|
||||
*
|
||||
* Flags (resolve):
|
||||
* --server URL[,URL] override the ElectrumX server list (host:port,
|
||||
* one or more; default: the same hard-coded
|
||||
* mainnet set the apps ship with)
|
||||
* --timeout SECS overall lookup timeout (default 20)
|
||||
*
|
||||
* Exit codes follow amy convention:
|
||||
* 0 success (Output.emit was called)
|
||||
* 1 Output.error was called (bad_args, network, name not found, …)
|
||||
*
|
||||
* The JSON shape on success (resolve):
|
||||
* {
|
||||
* "identifier": "alice@example.bit",
|
||||
* "namecoin_name": "d/example",
|
||||
* "local_part": "alice",
|
||||
* "pubkey": "<64 hex>",
|
||||
* "relays": ["wss://relay.damus.io/", …]
|
||||
* }
|
||||
*
|
||||
* The JSON shape on success (servers):
|
||||
* {
|
||||
* "count": 7,
|
||||
* "servers": [
|
||||
* { "host": "electrumx.testls.space", "port": 50002, "tls": true },
|
||||
* …
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
object NamecoinCommand {
|
||||
private val DEFAULT_TIMEOUT_SECS = 20L
|
||||
|
||||
suspend fun dispatch(rest: Array<String>): Int =
|
||||
route(
|
||||
name = "namecoin",
|
||||
tail = rest,
|
||||
usage = "namecoin <resolve|servers> …",
|
||||
routes =
|
||||
mapOf(
|
||||
"resolve" to { tail -> resolve(tail) },
|
||||
"servers" to { tail -> servers(tail) },
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun resolve(rest: Array<String>): Int {
|
||||
val args = Args(rest)
|
||||
val identifier = args.positional(0, "identifier").trim()
|
||||
if (identifier.isEmpty()) {
|
||||
return Output.error("bad_args", "namecoin resolve <identifier>")
|
||||
}
|
||||
if (!NamecoinNameResolver.isNamecoinIdentifier(identifier)) {
|
||||
return Output.error(
|
||||
"bad_args",
|
||||
"not a Namecoin identifier (expected .bit, d/, or id/): $identifier",
|
||||
)
|
||||
}
|
||||
|
||||
val serverFlag = args.flag("server")
|
||||
val servers = parseServerFlag(serverFlag)
|
||||
if (servers != null && servers.isEmpty()) {
|
||||
return Output.error("bad_args", "--server: no valid host:port entries in '$serverFlag'")
|
||||
}
|
||||
|
||||
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: DEFAULT_TIMEOUT_SECS
|
||||
if (timeoutSecs <= 0) {
|
||||
return Output.error("bad_args", "--timeout must be positive (was $timeoutSecs)")
|
||||
}
|
||||
val timeoutMs = timeoutSecs * 1000
|
||||
|
||||
val resolver =
|
||||
NamecoinNameResolver(
|
||||
electrumxClient = ElectrumXClient(),
|
||||
lookupTimeoutMs = timeoutMs,
|
||||
serverListProvider = { servers ?: DEFAULT_ELECTRUMX_SERVERS },
|
||||
)
|
||||
|
||||
return when (val outcome = resolver.resolveDetailed(identifier)) {
|
||||
is NamecoinResolveOutcome.Success -> {
|
||||
val r = outcome.result
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"identifier" to identifier,
|
||||
"namecoin_name" to r.namecoinName,
|
||||
"local_part" to r.localPart,
|
||||
"pubkey" to r.pubkey,
|
||||
"relays" to r.relays,
|
||||
),
|
||||
)
|
||||
0
|
||||
}
|
||||
is NamecoinResolveOutcome.NameNotFound ->
|
||||
Output.error("not_found", "Namecoin name does not exist: ${outcome.name}")
|
||||
is NamecoinResolveOutcome.NoNostrField ->
|
||||
Output.error(
|
||||
"no_nostr_field",
|
||||
"Namecoin name has no Nostr field: ${outcome.name}",
|
||||
)
|
||||
is NamecoinResolveOutcome.MalformedRecord ->
|
||||
Output.error(
|
||||
"malformed_record",
|
||||
outcome.error,
|
||||
extra = mapOf("namecoin_name" to outcome.name),
|
||||
)
|
||||
is NamecoinResolveOutcome.ServersUnreachable ->
|
||||
Output.error("servers_unreachable", outcome.message)
|
||||
is NamecoinResolveOutcome.InvalidIdentifier ->
|
||||
Output.error("invalid_identifier", outcome.identifier)
|
||||
NamecoinResolveOutcome.Timeout ->
|
||||
Output.error("timeout", "lookup timed out after ${timeoutSecs}s")
|
||||
}
|
||||
}
|
||||
|
||||
private fun servers(rest: Array<String>): Int {
|
||||
// No positional/flags today — but reject unknown ones so future
|
||||
// additions don't silently break.
|
||||
val args = Args(rest)
|
||||
if (args.positionalOrNull(0) != null) {
|
||||
return Output.error("bad_args", "namecoin servers takes no arguments")
|
||||
}
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"count" to DEFAULT_ELECTRUMX_SERVERS.size,
|
||||
"servers" to
|
||||
DEFAULT_ELECTRUMX_SERVERS.map { srv ->
|
||||
mapOf(
|
||||
"host" to srv.host,
|
||||
"port" to srv.port,
|
||||
"tls" to srv.useSsl,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `--server URL[,URL]` flag.
|
||||
*
|
||||
* Accepted forms per entry:
|
||||
* host → host:50002 TLS
|
||||
* host:port → TLS
|
||||
* tcp://host:port → plaintext
|
||||
* tls://host:port → TLS
|
||||
* ssl://host:port → TLS (alias for tls://)
|
||||
*
|
||||
* Returns null when the flag is absent (caller falls back to the
|
||||
* default server list). Returns an empty list when the flag is
|
||||
* present but produced zero valid entries — the caller treats that
|
||||
* as a hard `bad_args` rather than silently using defaults, so a
|
||||
* fat-fingered `--server foo:bar` is impossible to overlook.
|
||||
*/
|
||||
private fun parseServerFlag(raw: String?): List<ElectrumxServer>? {
|
||||
if (raw == null) return null
|
||||
return raw
|
||||
.split(',')
|
||||
.mapNotNull { entry ->
|
||||
val trimmed = entry.trim()
|
||||
if (trimmed.isEmpty()) return@mapNotNull null
|
||||
val (scheme, rest) =
|
||||
when {
|
||||
trimmed.startsWith("tcp://") -> "tcp" to trimmed.removePrefix("tcp://")
|
||||
trimmed.startsWith("tls://") -> "tls" to trimmed.removePrefix("tls://")
|
||||
trimmed.startsWith("ssl://") -> "tls" to trimmed.removePrefix("ssl://")
|
||||
else -> "tls" to trimmed
|
||||
}
|
||||
val (host, portStr) =
|
||||
if (':' in rest) {
|
||||
rest.substringBeforeLast(':') to rest.substringAfterLast(':')
|
||||
} else {
|
||||
rest to "50002"
|
||||
}
|
||||
if (host.isEmpty()) return@mapNotNull null
|
||||
val port = portStr.toIntOrNull() ?: return@mapNotNull null
|
||||
if (port !in 1..65535) return@mapNotNull null
|
||||
ElectrumxServer(host = host, port = port, useSsl = scheme == "tls")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user