diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts new file mode 100644 index 000000000..30b9a910f --- /dev/null +++ b/cli/build.gradle.kts @@ -0,0 +1,36 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.jetbrainsKotlinJvm) + application +} + +kotlin { + jvmToolchain(21) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } +} + +sourceSets { + main { + kotlin.srcDir("src/main/kotlin") + resources.srcDir("src/main/resources") + } +} + +dependencies { + implementation(project(":quartz")) + implementation(project(":commons")) + + implementation(libs.kotlinx.coroutines.core) + implementation(libs.okhttp) + implementation(libs.okhttpCoroutines) + implementation(libs.jackson.module.kotlin) + implementation(libs.slf4j.nop) +} + +application { + mainClass.set("com.vitorpamplona.amethyst.cli.MainKt") + applicationName = "amy" +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Args.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Args.kt new file mode 100644 index 000000000..69db556fd --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Args.kt @@ -0,0 +1,103 @@ +/* + * 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 + +/** + * Minimal argv parser. Splits flags (--key value or --key=value) from positional args. + * Boolean flags are those whose next token starts with "--" or is absent. + * + * The parser is intentionally tiny — this CLI is driven by shell scripts, not humans, + * so we don't need subcommand groups, short flags, or help text generation. + */ +class Args( + argv: Array, +) { + val flags: Map + val booleans: Set + val positional: List + + init { + val f = mutableMapOf() + val b = mutableSetOf() + val p = mutableListOf() + var i = 0 + while (i < argv.size) { + val a = argv[i] + if (a.startsWith("--")) { + val eq = a.indexOf('=') + if (eq >= 0) { + f[a.substring(2, eq)] = a.substring(eq + 1) + i++ + } else { + val key = a.substring(2) + val next = argv.getOrNull(i + 1) + if (next == null || next.startsWith("--")) { + b.add(key) + i++ + } else { + f[key] = next + i += 2 + } + } + } else { + p.add(a) + i++ + } + } + flags = f + booleans = b + positional = p + } + + fun flag( + name: String, + default: String? = null, + ): String? = flags[name] ?: default + + fun intFlag( + name: String, + default: Int, + ): Int = flags[name]?.toIntOrNull() ?: default + + fun longFlag( + name: String, + default: Long, + ): Long = flags[name]?.toLongOrNull() ?: default + + fun requireFlag(name: String): String = + flags[name] ?: run { + System.err.println("missing required flag: --$name") + throw IllegalArgumentException("missing flag $name") + } + + fun bool(name: String): Boolean = name in booleans + + fun positional( + index: Int, + name: String, + ): String = + positional.getOrNull(index) ?: run { + System.err.println("missing positional arg: $name (index $index)") + throw IllegalArgumentException("missing positional $name") + } + + fun positionalOrNull(index: Int): String? = positional.getOrNull(index) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt new file mode 100644 index 000000000..03b1dab0e --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -0,0 +1,161 @@ +/* + * 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 + +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import java.io.File + +/** Persisted identity (hex keys + cached bech32 forms for convenience). */ +data class Identity( + val privKeyHex: String, + val pubKeyHex: String, + val nsec: String, + val npub: String, +) { + fun keyPair(): KeyPair = KeyPair(privKey = privKeyHex.hexToByteArray(), pubKey = pubKeyHex.hexToByteArray()) + + companion object { + fun create(): Identity { + val kp = KeyPair() + val priv = kp.privKey!! + val pub = kp.pubKey + return Identity( + privKeyHex = priv.toHexKey(), + pubKeyHex = pub.toHexKey(), + nsec = priv.toNsec(), + npub = pub.toNpub(), + ) + } + + fun fromNsec(nsec: String): Identity { + val priv = nsec.bechToBytes() + val kp = KeyPair(privKey = priv) + return Identity( + privKeyHex = priv.toHexKey(), + pubKeyHex = kp.pubKey.toHexKey(), + nsec = priv.toNsec(), + npub = kp.pubKey.toNpub(), + ) + } + } +} + +/** + * On-disk relay configuration, bucketed by purpose (mirrors `wn relays add --type`). + * + * - `nip65`: advertised read/write (kind:10002) + * - `inbox`: DM inbox / gift-wrap delivery (kind:10050) + * - `keyPackage`: where this account's KeyPackages (kind:30443) live + */ +data class RelayConfig( + val nip65: MutableList = mutableListOf(), + val inbox: MutableList = mutableListOf(), + val keyPackage: MutableList = mutableListOf(), +) { + fun all(): Set = (nip65 + inbox + keyPackage).toSet() + + fun add( + type: String, + url: String, + ): Boolean { + val list = + when (type) { + "nip65" -> nip65 + "inbox" -> inbox + "key_package", "keyPackage" -> keyPackage + else -> throw IllegalArgumentException("unknown relay type: $type") + } + if (list.contains(url)) return false + list.add(url) + return true + } + + fun normalized(kind: String): Set { + val src = + when (kind) { + "nip65" -> nip65 + "inbox" -> inbox + "key_package" -> keyPackage + "all" -> all().toList() + else -> throw IllegalArgumentException("unknown relay selector: $kind") + } + return src.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + } +} + +/** Opaque per-run state (subscription cursors, etc). Stored alongside identity. */ +data class RunState( + var giftWrapSince: Long? = null, + val groupSince: MutableMap = mutableMapOf(), +) + +/** + * Root of the on-disk layout. Any absolute path chosen by `--data-dir` (or + * `$AMETHYST_CLI_DATA`) — defaults to `./amethyst-cli-data`. + */ +class DataDir( + val root: File, +) { + val identityFile = File(root, "identity.json") + val relaysFile = File(root, "relays.json") + val stateFile = File(root, "state.json") + val groupsDir = File(root, "groups") + val keyPackageBundleFile = File(root, "keypackages.bundle") + + init { + root.mkdirs() + groupsDir.mkdirs() + } + + fun loadIdentityOrNull(): Identity? = if (identityFile.exists()) Json.mapper.readValue(identityFile.readText()) else null + + fun saveIdentity(id: Identity) { + identityFile.writeText(Json.mapper.writeValueAsString(id)) + } + + fun loadRelays(): RelayConfig = if (relaysFile.exists()) Json.mapper.readValue(relaysFile.readText()) else RelayConfig() + + fun saveRelays(r: RelayConfig) { + relaysFile.writeText(Json.mapper.writeValueAsString(r)) + } + + fun loadRunState(): RunState = if (stateFile.exists()) Json.mapper.readValue(stateFile.readText()) else RunState() + + fun saveRunState(s: RunState) { + stateFile.writeText(Json.mapper.writeValueAsString(s)) + } + + companion object { + fun resolve(flag: String?): DataDir { + val envPath = System.getenv("AMETHYST_CLI_DATA") + val path = flag ?: envPath ?: "./amethyst-cli-data" + return DataDir(File(path).absoluteFile) + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt new file mode 100644 index 000000000..1616217aa --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -0,0 +1,319 @@ +/* + * 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 + +import com.vitorpamplona.amethyst.cli.stores.FileKeyPackageBundleStore +import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore +import com.vitorpamplona.amethyst.cli.stores.FileMlsGroupStateStore +import com.vitorpamplona.amethyst.commons.marmot.MarmotManager +import com.vitorpamplona.quartz.marmot.MarmotFilters +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient + +/** + * Per-invocation wiring. Each CLI run constructs a Context, does its work, + * and then closes it — no daemon. + * + * Responsibilities: + * - load identity + relay + run-state from the data-dir, + * - wire up a [NostrClient] pointing at those relays, + * - wire up the [MarmotManager] pipeline with file-backed stores, + * - expose helpers that every command needs (sync, publish-and-confirm, + * process-incoming, etc). + * + * Closing flushes run-state to disk and disconnects the client. + */ +class Context( + val dataDir: DataDir, + val identity: Identity, + val relays: RelayConfig, + val state: RunState, +) : AutoCloseable { + val signer = NostrSignerInternal(identity.keyPair()) + + private val okhttp = OkHttpClient.Builder().build() + + val client: NostrClient = + NostrClient( + websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp }, + ) + + private val mlsStore = FileMlsGroupStateStore(dataDir.groupsDir) + private val keyPackageStore = FileKeyPackageBundleStore(dataDir.keyPackageBundleFile) + private val messageStore = FileMarmotMessageStore(dataDir.groupsDir) + + /** Fully-wired manager. Call [prepare] once before use to load persisted state. */ + val marmot: MarmotManager = MarmotManager(signer, mlsStore, messageStore, keyPackageStore) + + private var prepared = false + + /** + * Hydrate MarmotManager from disk (groups + KeyPackage bundles) and + * connect to relays. Safe to call multiple times — subsequent calls are + * no-ops. + */ + suspend fun prepare() { + if (prepared) return + marmot.restoreAll() + client.connect() + prepared = true + } + + fun outboxRelays(): Set = relays.normalized("nip65") + + fun inboxRelays(): Set = relays.normalized("inbox") + + fun keyPackageRelays(): Set = relays.normalized("key_package") + + fun anyRelays(): Set = relays.normalized("all") + + /** + * Publish an event to the given relays and wait for OK confirmations. + * + * Returns the set of relays that ACK'd `true`. Does not throw on rejection — + * callers inspect the map and decide. + */ + suspend fun publish( + event: Event, + relayList: Set, + timeoutSecs: Long = 15, + ): Map { + if (relayList.isEmpty()) return emptyMap() + return client.publishAndConfirmDetailed(event, relayList, timeoutSecs) + } + + /** + * Subscribe to the given filters across the given relays, drain all events + * until either every relay has sent EOSE or the timeout elapses, and + * return them. Used for one-shot catch-up queries — not live subscriptions. + */ + suspend fun drain( + filters: Map>, + timeoutMs: Long = 8_000, + ): List> { + if (filters.isEmpty()) return emptyList() + val eventChannel = Channel>(UNLIMITED) + val doneChannel = Channel(UNLIMITED) + val remaining = filters.keys.toMutableSet() + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eventChannel.trySend(relay to event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + doneChannel.trySend(relay) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + doneChannel.trySend(relay) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + doneChannel.trySend(relay) + } + } + val collected = mutableListOf>() + try { + client.subscribe(subId, filters, listener) + withTimeoutOrNull(timeoutMs) { + while (remaining.isNotEmpty()) { + select { + eventChannel.onReceive { collected.add(it) } + doneChannel.onReceive { r -> remaining.remove(r) } + } + } + // Drain any events that landed after EOSE but before cancel + while (true) { + val r = eventChannel.tryReceive() + if (!r.isSuccess) break + collected.add(r.getOrThrow()) + } + } + } finally { + client.unsubscribe(subId) + eventChannel.close() + doneChannel.close() + } + return collected + } + + /** + * Pull down everything needed to bring local Marmot state current: + * - kind:1059 gift wraps on inbox relays → try to unwrap Welcomes + * - kind:445 group events per active group → feed into inbound processor + * + * Incrementally advances the `since` cursors in [state] so the next run + * only asks relays for newer events. + */ + suspend fun syncIncoming(timeoutMs: Long = 8_000) { + val inbox = inboxRelays().ifEmpty { anyRelays() } + val gwSince = state.giftWrapSince + val gwFilter = + if (gwSince != null) { + MarmotFilters.giftWrapsForUserSince(identity.pubKeyHex, gwSince) + } else { + MarmotFilters.giftWrapsForUser(identity.pubKeyHex) + } + + val activeGroupIds = marmot.subscriptionManager.activeGroupIdsSnapshot().toList() + val perGroupFilters: Map = + activeGroupIds.associateWith { gid -> + val since = state.groupSince[gid] + if (since != null) { + MarmotFilters.groupEventsByGroupIdSince(gid, since) + } else { + MarmotFilters.groupEventsByGroupId(gid) + } + } + + // Group filters go to each group's configured relays, not the user's + // inbox — kind:445 is delivered to the group's relay set advertised in + // its MIP-01 metadata (falls back to our outbox if the group never + // stamped any). + val filterMap = mutableMapOf>() + for (r in inbox) filterMap.getOrPut(r) { mutableListOf() }.add(gwFilter) + for ((gid, filter) in perGroupFilters) { + val groupRelays = marmotGroupRelays(gid).ifEmpty { outboxRelays() } + for (r in groupRelays) filterMap.getOrPut(r) { mutableListOf() }.add(filter) + } + if (filterMap.isEmpty()) return + + val events = drain(filterMap, timeoutMs) + val now = System.currentTimeMillis() / 1000 + + var maxGwSeen = gwSince ?: 0L + val maxGroupSeen = perGroupFilters.keys.associateWith { state.groupSince[it] ?: 0L }.toMutableMap() + + for ((relay, event) in events) { + when (event.kind) { + GiftWrapEvent.KIND -> { + // kind:1059 — try to unwrap. If it's a Welcome, let the + // inbound processor apply it; anything else is ignored. + val gw = event as? GiftWrapEvent ?: continue + try { + val inner = gw.unwrapOrNull(signer) ?: continue + if (inner.kind == WelcomeEvent.KIND && inner is WelcomeEvent) { + val hint = inner.nostrGroupId() + val res = marmot.processWelcome(inner, hint) + System.err.println("[cli] Welcome via $relay → $res") + } + } catch (e: Exception) { + System.err.println("[cli] failed to unwrap giftwrap ${event.id.take(8)}: ${e.message}") + } + if (event.createdAt > maxGwSeen) maxGwSeen = event.createdAt + } + + GroupEvent.KIND -> { + val ge = event as? GroupEvent ?: continue + val gid = ge.groupId() ?: continue + try { + val res = marmot.processGroupEvent(ge) + // Mirror DecryptAndIndexProcessor on Amethyst: application + // messages get persisted at decrypt-time, because MLS ratchet + // advancement makes the ciphertext un-re-decryptable later. + if (res is com.vitorpamplona.quartz.marmot.GroupEventResult.ApplicationMessage) { + marmot.persistDecryptedMessage(res.groupId, res.innerEventJson) + } + System.err.println("[cli] GroupEvent ${event.id.take(8)} → ${res::class.simpleName}") + } catch (e: Exception) { + System.err.println("[cli] processGroupEvent failed: ${e.message}") + } + val prev = maxGroupSeen[gid] ?: 0L + if (event.createdAt > prev) maxGroupSeen[gid] = event.createdAt + } + } + } + + state.giftWrapSince = if (maxGwSeen > 0) maxGwSeen else now + for ((gid, seen) in maxGroupSeen) { + state.groupSince[gid] = if (seen > 0) seen else now + } + } + + fun marmotGroupRelays(nostrGroupId: HexKey): Set { + val m = marmot.groupMetadata(nostrGroupId) ?: return emptySet() + return m.relays + .mapNotNull { + com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + .normalizeOrNull(it) + }.toSet() + } + + override fun close() { + dataDir.saveRunState(state) + try { + client.close() + } catch (_: Exception) { + } + } + + companion object { + /** Build a Context but require an identity to already exist — most commands can't run without one. */ + fun open(dataDir: DataDir): Context { + val identity = + dataDir.loadIdentityOrNull() + ?: run { + System.err.println("No identity found at ${dataDir.identityFile}. Run `amethyst-cli init` first.") + throw IllegalStateException("no identity") + } + return Context( + dataDir = dataDir, + identity = identity, + relays = dataDir.loadRelays(), + state = dataDir.loadRunState(), + ) + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Json.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Json.kt new file mode 100644 index 000000000..9efc18647 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Json.kt @@ -0,0 +1,42 @@ +/* + * 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 + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper + +object Json { + val mapper: ObjectMapper = jacksonObjectMapper() + + fun writeLine(obj: Any) { + println(mapper.writeValueAsString(obj)) + } + + fun error( + code: String, + detail: String? = null, + ): Int { + val payload = mutableMapOf("error" to code) + if (detail != null) payload["detail"] = detail + System.err.println(mapper.writeValueAsString(payload)) + return 1 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt new file mode 100644 index 000000000..b34978a0c --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -0,0 +1,210 @@ +/* + * 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 + +import com.vitorpamplona.amethyst.cli.commands.Commands +import kotlinx.coroutines.runBlocking +import kotlin.system.exitProcess + +/** + * amy — non-interactive command-line interface to Amethyst. + * + * Today this covers the Marmot/MLS surface (`amy marmot …`) plus identity + * and relay configuration at the root level. The layout is intentionally + * extensible — future verbs (`amy dm`, `amy feed`, `amy profile`) slot in + * as new top-level subcommands. + * + * Usage: amy --data-dir PATH SUBCOMMAND ARGS + * + * Exit codes: + * 0 — success + * 1 — runtime error (printed as JSON on stderr: {"error": "...", "detail": "..."}) + * 2 — invalid arguments + * 124 — await timeout + * + * Every command that succeeds prints exactly one JSON object to stdout. + * Diagnostic logs go to stderr and are safe to discard. + */ +fun main(argv: Array) { + val code = + try { + runBlocking { dispatch(argv) } + } catch (e: IllegalArgumentException) { + Json.error("bad_args", e.message) + 2 + } catch (e: AwaitTimeout) { + Json.error("timeout", e.message) + 124 + } catch (e: Exception) { + Json.error("runtime", "${e::class.simpleName}: ${e.message}") + 1 + } + exitProcess(code) +} + +class AwaitTimeout( + message: String, +) : RuntimeException(message) + +private suspend fun dispatch(argv: Array): Int { + if (argv.isEmpty() || argv[0] == "--help" || argv[0] == "-h") { + printUsage() + return 0 + } + + // Pull --data-dir out of argv before subcommand parsing so subcommands see + // only their own args. + val filteredArgs = mutableListOf() + var dataDirFlag: String? = null + var i = 0 + while (i < argv.size) { + when (val a = argv[i]) { + "--data-dir" -> { + dataDirFlag = argv.getOrNull(i + 1) + i += 2 + } + + else -> { + if (a.startsWith("--data-dir=")) { + dataDirFlag = a.removePrefix("--data-dir=") + i++ + } else { + filteredArgs.add(a) + i++ + } + } + } + } + if (filteredArgs.isEmpty()) { + printUsage() + return 2 + } + + val dataDir = DataDir.resolve(dataDirFlag) + val head = filteredArgs[0] + val tail = filteredArgs.drop(1).toTypedArray() + + return when (head) { + "init" -> { + Commands.init(dataDir, Args(tail)) + } + + "whoami" -> { + Commands.whoami(dataDir) + } + + "relay" -> { + Commands.relay(dataDir, tail) + } + + "marmot" -> { + marmotDispatch(dataDir, tail) + } + + else -> { + System.err.println("unknown subcommand: $head") + printUsage() + 2 + } + } +} + +private suspend fun marmotDispatch( + dataDir: DataDir, + tail: Array, +): Int { + if (tail.isEmpty()) { + printUsage() + return 2 + } + val head = tail[0] + val rest = tail.drop(1).toTypedArray() + return when (head) { + "key-package" -> { + Commands.keyPackage(dataDir, rest) + } + + "group" -> { + Commands.group(dataDir, rest) + } + + "message" -> { + Commands.message(dataDir, rest) + } + + "await" -> { + Commands.await(dataDir, rest) + } + + else -> { + System.err.println("unknown marmot subcommand: $head") + printUsage() + 2 + } + } +} + +private fun printUsage() { + System.err.println( + """ + |amy — Amethyst command-line interface + | + |Usage: + | amy [--data-dir PATH] [args...] + | + |Identity: + | init [--nsec NSEC] create or import identity + | whoami print current identity + | + |Relays: + | relay add URL [--type T] T=nip65|inbox|key_package|all (default all) + | relay list print configured relays + | relay publish-lists publish kind:10002 + kind:10050 + | + |Marmot (MLS group messaging): + | marmot key-package publish publish a fresh KeyPackage + | marmot key-package check NPUB fetch NPUB's KeyPackage from relays + | + | marmot group create [--name NAME] create an empty group (self-only) + | marmot group list list joined groups + | marmot group show GID print full group details + | marmot group members GID print members + | marmot group admins GID print admins + | marmot group add GID NPUB [NPUB...] fetch KPs and invite + | marmot group rename GID NAME commit a rename + | marmot group promote GID NPUB add admin + | marmot group demote GID NPUB remove admin + | marmot group remove GID NPUB remove member + | marmot group leave GID self-remove + | + | marmot message send GID TEXT publish kind:9 inner event into the group + | marmot message list GID [--limit N] dump decrypted inner events + | + | marmot await key-package NPUB (all await verbs take --timeout SECS, default 30; + | marmot await group --name NAME exit 124 on timeout) + | marmot await member GID NPUB + | marmot await admin GID NPUB + | marmot await message GID --match TEXT + | marmot await rename GID --name NAME + | marmot await epoch GID --min N + """.trimMargin(), + ) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt new file mode 100644 index 000000000..c5e87939a --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt @@ -0,0 +1,295 @@ +/* + * 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.AwaitTimeout +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.util.Npubs +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import kotlinx.coroutines.delay + +/** + * `await` subcommands. Each polls until a condition is met or the timeout elapses; + * on timeout we throw [AwaitTimeout] which maps to exit code 124. + */ +object AwaitCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Json.error("bad_args", "await ") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "key-package" -> awaitKeyPackage(dataDir, rest) + "group" -> awaitGroup(dataDir, rest) + "member" -> awaitMember(dataDir, rest) + "admin" -> awaitAdmin(dataDir, rest) + "message" -> awaitMessage(dataDir, rest) + "rename" -> awaitRename(dataDir, rest) + "epoch" -> awaitEpoch(dataDir, rest) + else -> Json.error("bad_args", "await ${tail[0]}") + } + } + + private suspend fun awaitKeyPackage( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "await key-package ") + val target = Npubs.resolveToHex(rest[0]) + val args = Args(rest.drop(1).toTypedArray()) + val timeoutSecs = args.longFlag("timeout", 30) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val filter = ctx.marmot.subscriptionManager.keyPackageFilter(target) + val deadline = System.currentTimeMillis() + timeoutSecs * 1000 + while (System.currentTimeMillis() < deadline) { + val relays = ctx.anyRelays() + if (relays.isNotEmpty()) { + val event = + ctx.client.fetchFirst( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = 3_000, + ) + if (event is KeyPackageEvent) { + Json.writeLine(mapOf("event_id" to event.id, "author" to event.pubKey)) + return 0 + } + } + delay(2_000) + } + throw AwaitTimeout("no KeyPackage for $target within ${timeoutSecs}s") + } finally { + ctx.close() + } + } + + private suspend fun awaitGroup( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val wantedName = args.flag("name") + val timeoutSecs = args.longFlag("timeout", 30) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val deadline = System.currentTimeMillis() + timeoutSecs * 1000 + while (System.currentTimeMillis() < deadline) { + ctx.syncIncoming(timeoutMs = 3_000) + val match = + ctx.marmot.activeGroupIds().firstOrNull { gid -> + wantedName == null || ctx.marmot.groupMetadata(gid)?.name == wantedName + } + if (match != null) { + Json.writeLine( + mapOf( + "group_id" to match, + "name" to (ctx.marmot.groupMetadata(match)?.name ?: ""), + "epoch" to ctx.marmot.groupEpoch(match), + ), + ) + return 0 + } + delay(1_500) + } + throw AwaitTimeout("no group with name=$wantedName within ${timeoutSecs}s") + } finally { + ctx.close() + } + } + + private suspend fun awaitMember( + dataDir: DataDir, + rest: Array, + ): Int = + pollCondition(dataDir, rest, "await member ", targetIdx = 1) { ctx, rawArgs -> + val gid = rawArgs[0] + val target = Npubs.resolveToHex(rawArgs[1]) + if (!ctx.marmot.isMember(gid)) { + null + } else if (ctx.marmot.memberPubkeys(gid).any { it.pubkey == target }) { + mapOf("group_id" to gid, "pubkey" to target, "epoch" to ctx.marmot.groupEpoch(gid)) + } else { + null + } + } + + private suspend fun awaitAdmin( + dataDir: DataDir, + rest: Array, + ): Int = + pollCondition(dataDir, rest, "await admin ", targetIdx = 1) { ctx, rawArgs -> + val gid = rawArgs[0] + val target = Npubs.resolveToHex(rawArgs[1]) + if (!ctx.marmot.isMember(gid)) { + null + } else if (ctx.marmot + .groupMetadata(gid) + ?.adminPubkeys + ?.contains(target) == true + ) { + mapOf("group_id" to gid, "pubkey" to target, "epoch" to ctx.marmot.groupEpoch(gid)) + } else { + null + } + } + + private suspend fun awaitRename( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "await rename --name ") + val gid = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + val wantedName = args.requireFlag("name") + val timeoutSecs = args.longFlag("timeout", 30) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val deadline = System.currentTimeMillis() + timeoutSecs * 1000 + while (System.currentTimeMillis() < deadline) { + ctx.syncIncoming(timeoutMs = 3_000) + val name = ctx.marmot.groupMetadata(gid)?.name + if (name == wantedName) { + Json.writeLine(mapOf("group_id" to gid, "name" to name, "epoch" to ctx.marmot.groupEpoch(gid))) + return 0 + } + delay(1_500) + } + throw AwaitTimeout("group $gid never renamed to $wantedName within ${timeoutSecs}s") + } finally { + ctx.close() + } + } + + private suspend fun awaitEpoch( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "await epoch --min N") + val gid = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + val min = args.longFlag("min", 1) + val timeoutSecs = args.longFlag("timeout", 30) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val deadline = System.currentTimeMillis() + timeoutSecs * 1000 + while (System.currentTimeMillis() < deadline) { + ctx.syncIncoming(timeoutMs = 3_000) + val epoch = ctx.marmot.groupEpoch(gid) + if (epoch != null && epoch >= min) { + Json.writeLine(mapOf("group_id" to gid, "epoch" to epoch)) + return 0 + } + delay(1_500) + } + throw AwaitTimeout("group $gid epoch never reached $min within ${timeoutSecs}s") + } finally { + ctx.close() + } + } + + private suspend fun awaitMessage( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "await message --match STRING") + val gid = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + val needle = args.requireFlag("match") + val timeoutSecs = args.longFlag("timeout", 30) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val deadline = System.currentTimeMillis() + timeoutSecs * 1000 + while (System.currentTimeMillis() < deadline) { + ctx.syncIncoming(timeoutMs = 3_000) + val msgs = ctx.marmot.loadStoredMessages(gid) + for (line in msgs.asReversed()) { + val obj = + try { + Json.mapper.readValue>(line) + } catch (_: Exception) { + null + } ?: continue + val content = obj["content"]?.toString() ?: continue + if (content.contains(needle)) { + Json.writeLine( + mapOf( + "group_id" to gid, + "id" to obj["id"], + "author" to obj["pubkey"], + "content" to content, + "kind" to obj["kind"], + ), + ) + return 0 + } + } + delay(1_500) + } + throw AwaitTimeout("no message matching '$needle' in $gid within ${timeoutSecs}s") + } finally { + ctx.close() + } + } + + /** + * Generic poll loop used by [awaitMember] / [awaitAdmin] — both take the + * same ` ` positional shape and differ only in the predicate. + */ + private suspend fun pollCondition( + dataDir: DataDir, + rest: Array, + usage: String, + targetIdx: Int, + check: suspend (Context, Array) -> Map?, + ): Int { + if (rest.size <= targetIdx) return Json.error("bad_args", usage) + val args = Args(rest.drop(targetIdx + 1).toTypedArray()) + val timeoutSecs = args.longFlag("timeout", 30) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val deadline = System.currentTimeMillis() + timeoutSecs * 1000 + while (System.currentTimeMillis() < deadline) { + ctx.syncIncoming(timeoutMs = 3_000) + val hit = check(ctx, rest) + if (hit != null) { + Json.writeLine(hit) + return 0 + } + delay(1_500) + } + throw AwaitTimeout("condition never satisfied within ${timeoutSecs}s ($usage)") + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt new file mode 100644 index 000000000..042b4f387 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -0,0 +1,63 @@ +/* + * 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.DataDir + +/** + * Tiny dispatcher over the per-verb command groups. Each top-level subcommand + * (`init`, `relay`, `group`, `message`, …) gets its own file so no single + * file is too big to edit safely. + */ +object Commands { + suspend fun init( + dataDir: DataDir, + args: Args, + ): Int = InitCommands.init(dataDir, args) + + suspend fun whoami(dataDir: DataDir): Int = InitCommands.whoami(dataDir) + + suspend fun relay( + dataDir: DataDir, + tail: Array, + ): Int = RelayCommands.dispatch(dataDir, tail) + + suspend fun keyPackage( + dataDir: DataDir, + tail: Array, + ): Int = KeyPackageCommands.dispatch(dataDir, tail) + + suspend fun group( + dataDir: DataDir, + tail: Array, + ): Int = GroupCommands.dispatch(dataDir, tail) + + suspend fun message( + dataDir: DataDir, + tail: Array, + ): Int = MessageCommands.dispatch(dataDir, tail) + + suspend fun await( + dataDir: DataDir, + tail: Array, + ): Int = AwaitCommands.dispatch(dataDir, tail) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt new file mode 100644 index 000000000..8c4114283 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt @@ -0,0 +1,113 @@ +/* + * 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.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.util.Npubs +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * `group add [ ...]` — fetch each invitee's + * KeyPackage from the union of (our relays + any known KeyPackage relays + * for them) and run the full add-member flow for each one: build commit, + * publish commit to the group's relays, then wrap + publish the Welcome + * gift wrap. + */ +object GroupAddMemberCommand { + @OptIn(ExperimentalEncodingApi::class) + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Json.error("bad_args", "group add [ ...]") + val gid = rest[0] + val invitees = rest.drop(1).map { Npubs.resolveToHex(it) } + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + + val groupRelays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } + val report = mutableListOf>() + + for (pub in invitees) { + val filter = ctx.marmot.subscriptionManager.keyPackageFilter(pub) + val relays = ctx.anyRelays() + val filters = relays.associateWith { listOf(filter) } + val kpEvent = ctx.client.fetchFirst(filters = filters, timeoutMs = 10_000) + if (kpEvent == null || kpEvent !is KeyPackageEvent) { + report.add(mapOf("pubkey" to pub, "status" to "no_key_package")) + continue + } + val kpBytes = Base64.decode(kpEvent.keyPackageBase64()) + + val (commitEvent, welcomeDelivery) = + ctx.marmot.addMember( + nostrGroupId = gid, + memberPubKey = pub, + keyPackageBytes = kpBytes, + keyPackageEventId = kpEvent.id, + relays = groupRelays.toList(), + ) + + // Order matters: commit first (so invitee doesn't join at a future epoch), + // then welcome. + val commitAck = ctx.publish(commitEvent.signedEvent, groupRelays) + val welcomeAck = + if (welcomeDelivery != null) { + val inbox = ctx.inboxRelays().ifEmpty { ctx.outboxRelays() } + ctx.publish(welcomeDelivery.giftWrapEvent, inbox) + } else { + emptyMap() + } + + report.add( + mapOf( + "pubkey" to pub, + "status" to "invited", + "key_package_event_id" to kpEvent.id, + "commit_event_id" to commitEvent.signedEvent.id, + "welcome_event_id" to welcomeDelivery?.giftWrapEvent?.id, + "commit_accepted_by" to commitAck.filterValues { it }.keys.map { it.url }, + "welcome_accepted_by" to welcomeAck.filterValues { it }.keys.map { it.url }, + ), + ) + } + + Json.writeLine( + mapOf( + "group_id" to gid, + "epoch" to ctx.marmot.groupEpoch(gid), + "results" to report, + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt new file mode 100644 index 000000000..863962b2b --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt @@ -0,0 +1,48 @@ +/* + * 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.DataDir +import com.vitorpamplona.amethyst.cli.Json + +object GroupCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Json.error("bad_args", "group ") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "create" -> GroupCreateCommand.run(dataDir, rest) + "list" -> GroupReadCommands.list(dataDir) + "show" -> GroupReadCommands.show(dataDir, rest) + "members" -> GroupReadCommands.members(dataDir, rest) + "admins" -> GroupReadCommands.admins(dataDir, rest) + "add" -> GroupAddMemberCommand.run(dataDir, rest) + "rename" -> GroupMetadataCommands.rename(dataDir, rest) + "promote" -> GroupMetadataCommands.promote(dataDir, rest) + "demote" -> GroupMetadataCommands.demote(dataDir, rest) + "remove" -> GroupMembershipCommands.remove(dataDir, rest) + "leave" -> GroupMembershipCommands.leave(dataDir, rest) + else -> Json.error("bad_args", "group ${tail[0]}") + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt new file mode 100644 index 000000000..12d64b33e --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt @@ -0,0 +1,76 @@ +/* + * 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.Json +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.RandomInstance + +object GroupCreateCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val name = args.flag("name", "")!! + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val gid = RandomInstance.bytes(32).toHexKey() + + ctx.marmot.createGroup(gid) + + // Stamp initial metadata: name + our outbox relays + self as admin. + // Mirrors CreateGroupScreen.proceedWithCreate() on the Amethyst side. + val outboxUrls = ctx.outboxRelays().map { it.url } + val metadata = + MarmotGroupData( + nostrGroupId = gid, + name = name, + description = "", + adminPubkeys = listOf(ctx.identity.pubKeyHex), + relays = outboxUrls, + ) + val commit = ctx.marmot.updateGroupMetadata(gid, metadata) + + // Group relays == what the metadata carries, which on first commit is our outbox. + val targets = ctx.outboxRelays() + val ack = ctx.publish(commit.signedEvent, targets) + + Json.writeLine( + mapOf( + "group_id" to gid, + "name" to name, + "epoch" to ctx.marmot.groupEpoch(gid), + "commit_event_id" to commit.signedEvent.id, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt new file mode 100644 index 000000000..8c5b8d95b --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt @@ -0,0 +1,92 @@ +/* + * 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.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.util.Npubs + +object GroupMembershipCommands { + suspend fun remove( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Json.error("bad_args", "group remove ") + val gid = rest[0] + val target = Npubs.resolveToHex(rest[1]) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + + val member = + ctx.marmot.memberPubkeys(gid).firstOrNull { it.pubkey == target } + ?: return Json.error("not_in_group", target) + + val outbound = + ctx.marmot.removeMember(nostrGroupId = gid, targetLeafIndex = member.leafIndex) + val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } + val ack = ctx.publish(outbound.signedEvent, targets) + Json.writeLine( + mapOf( + "group_id" to gid, + "removed" to target, + "leaf_index" to member.leafIndex, + "epoch" to ctx.marmot.groupEpoch(gid), + "commit_event_id" to outbound.signedEvent.id, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + suspend fun leave( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "group leave ") + val gid = rest[0] + val ctx = Context.open(dataDir) + try { + ctx.prepare() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + + val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } + val outbound = ctx.marmot.leaveGroup(gid) + val ack = ctx.publish(outbound.signedEvent, targets) + Json.writeLine( + mapOf( + "group_id" to gid, + "proposal_event_id" to outbound.signedEvent.id, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt new file mode 100644 index 000000000..44d7b08a8 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt @@ -0,0 +1,108 @@ +/* + * 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.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.util.Npubs +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Metadata-only commits: rename, promote/demote. Each loads current metadata, + * edits the right field, publishes a GCE commit to the group relays. + */ +object GroupMetadataCommands { + suspend fun rename( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Json.error("bad_args", "group rename ") + return edit(dataDir, rest[0]) { it.copy(name = rest[1]) } + } + + suspend fun promote( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Json.error("bad_args", "group promote ") + val newAdmin = Npubs.resolveToHex(rest[1]) + return edit(dataDir, rest[0]) { cur -> + val admins = cur.adminPubkeys.toMutableList() + if (newAdmin !in admins) admins.add(newAdmin) + cur.copy(adminPubkeys = admins) + } + } + + suspend fun demote( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Json.error("bad_args", "group demote ") + val target = Npubs.resolveToHex(rest[1]) + return edit(dataDir, rest[0]) { cur -> + val admins = cur.adminPubkeys.filter { it != target } + cur.copy(adminPubkeys = admins) + } + } + + private suspend fun edit( + dataDir: DataDir, + gid: HexKey, + mutate: (MarmotGroupData) -> MarmotGroupData, + ): Int { + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + val cur = + ctx.marmot.groupMetadata(gid) + ?: MarmotGroupData( + nostrGroupId = gid, + name = "", + description = "", + adminPubkeys = listOf(ctx.identity.pubKeyHex), + relays = ctx.outboxRelays().map { it.url }, + ) + val updated = mutate(cur) + + val commit = ctx.marmot.updateGroupMetadata(gid, updated) + val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } + val ack = ctx.publish(commit.signedEvent, targets) + + Json.writeLine( + mapOf( + "group_id" to gid, + "name" to updated.name, + "admins" to updated.adminPubkeys, + "epoch" to ctx.marmot.groupEpoch(gid), + "commit_event_id" to commit.signedEvent.id, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt new file mode 100644 index 000000000..9ac6b78f7 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt @@ -0,0 +1,128 @@ +/* + * 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.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Json + +/** + * Read-only queries. None of these publish; they all sync-then-report. + */ +object GroupReadCommands { + suspend fun list(dataDir: DataDir): Int { + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + val ids = ctx.marmot.activeGroupIds() + val items = + ids.map { id -> + val m = ctx.marmot.groupMetadata(id) + mapOf( + "group_id" to id, + "name" to (m?.name ?: ""), + "members" to ctx.marmot.memberCount(id), + "epoch" to ctx.marmot.groupEpoch(id), + ) + } + Json.writeLine(mapOf("groups" to items)) + return 0 + } finally { + ctx.close() + } + } + + suspend fun show( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "group show ") + val gid = rest[0] + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + val meta = ctx.marmot.groupMetadata(gid) + val members = + ctx.marmot.memberPubkeys(gid).map { + mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex) + } + Json.writeLine( + mapOf( + "group_id" to gid, + "name" to (meta?.name ?: ""), + "description" to (meta?.description ?: ""), + "epoch" to ctx.marmot.groupEpoch(gid), + "admins" to (meta?.adminPubkeys ?: emptyList()), + "relays" to (meta?.relays ?: emptyList()), + "members" to members, + "is_admin" to (meta?.adminPubkeys?.contains(ctx.identity.pubKeyHex) == true), + ), + ) + return 0 + } finally { + ctx.close() + } + } + + suspend fun members( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "group members ") + val gid = rest[0] + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + val members = + ctx.marmot.memberPubkeys(gid).map { + mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex) + } + Json.writeLine(mapOf("group_id" to gid, "members" to members)) + return 0 + } finally { + ctx.close() + } + } + + suspend fun admins( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "group admins ") + val gid = rest[0] + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + val m = ctx.marmot.groupMetadata(gid) + Json.writeLine(mapOf("group_id" to gid, "admins" to (m?.adminPubkeys ?: emptyList()))) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt new file mode 100644 index 000000000..2c8cf973e --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt @@ -0,0 +1,67 @@ +/* + * 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.DataDir +import com.vitorpamplona.amethyst.cli.Identity +import com.vitorpamplona.amethyst.cli.Json + +object InitCommands { + suspend fun init( + dataDir: DataDir, + args: Args, + ): Int { + val existing = dataDir.loadIdentityOrNull() + val id = + existing ?: run { + val nsec = args.flag("nsec") + val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create() + dataDir.saveIdentity(created) + created + } + Json.writeLine( + mapOf( + "npub" to id.npub, + "hex" to id.pubKeyHex, + "nsec" to id.nsec, + "existing" to (existing != null), + "data_dir" to dataDir.root.absolutePath, + ), + ) + return 0 + } + + suspend fun whoami(dataDir: DataDir): Int { + val id = dataDir.loadIdentityOrNull() + if (id == null) { + return Json.error("no_identity", "No identity at ${dataDir.identityFile}. Run `init` first.") + } + Json.writeLine( + mapOf( + "npub" to id.npub, + "hex" to id.pubKeyHex, + "data_dir" to dataDir.root.absolutePath, + ), + ) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt new file mode 100644 index 000000000..8524a4d73 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt @@ -0,0 +1,97 @@ +/* + * 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.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.util.Npubs +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst + +object KeyPackageCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Json.error("bad_args", "key-package …") + return when (tail[0]) { + "publish" -> publish(dataDir) + "check" -> check(dataDir, tail.drop(1).toTypedArray()) + else -> Json.error("bad_args", "key-package ${tail[0]}") + } + } + + private suspend fun publish(dataDir: DataDir): Int { + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val relays = ctx.keyPackageRelays().ifEmpty { ctx.outboxRelays() }.ifEmpty { ctx.anyRelays() } + if (relays.isEmpty()) return Json.error("no_relays", "configure relays first") + + val event = ctx.marmot.generateKeyPackageEvent(relays.toList()) + val ack = ctx.publish(event, relays) + Json.writeLine( + mapOf( + "event_id" to event.id, + "kind" to event.kind, + "accepted_by" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + private suspend fun check( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "key-package check ") + val targetHex = Npubs.resolveToHex(rest[0]) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val filter = ctx.marmot.subscriptionManager.keyPackageFilter(targetHex) + val relays = ctx.anyRelays() + if (relays.isEmpty()) return Json.error("no_relays", "configure relays first") + val filtersByRelay = relays.associateWith { listOf(filter) } + val event = ctx.client.fetchFirst(filters = filtersByRelay, timeoutMs = 10_000) + if (event == null || event !is KeyPackageEvent) { + return Json.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)") + } + Json.writeLine( + mapOf( + "event_id" to event.id, + "author" to event.pubKey, + "kind" to event.kind, + "created_at" to event.createdAt, + "has_content" to event.content.isNotBlank(), + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt new file mode 100644 index 000000000..101d5eca2 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt @@ -0,0 +1,123 @@ +/* + * 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.Json +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate + +object MessageCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Json.error("bad_args", "message …") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "send" -> send(dataDir, rest) + "list" -> list(dataDir, rest) + else -> Json.error("bad_args", "message ${tail[0]}") + } + } + + private suspend fun send( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Json.error("bad_args", "message send ") + val gid = rest[0] + val text = rest[1] + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + + val template = + eventTemplate(kind = 9, description = text) + val innerEvent = ctx.signer.sign(template) + val outbound = ctx.marmot.buildGroupMessage(gid, innerEvent) + + // Persist our own outbound inner event so `message list` includes it + // alongside remote messages. + ctx.marmot.persistDecryptedMessage(gid, innerEvent.toJson()) + + val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } + val ack = ctx.publish(outbound.signedEvent, targets) + + Json.writeLine( + mapOf( + "group_id" to gid, + "inner_event_id" to innerEvent.id, + "outer_event_id" to outbound.signedEvent.id, + "kind" to innerEvent.kind, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "message list ") + val gid = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + val limit = args.intFlag("limit", Int.MAX_VALUE) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + + val raw = ctx.marmot.loadStoredMessages(gid) + val items = + raw + .map { line -> + try { + @Suppress("UNCHECKED_CAST") + val obj = Json.mapper.readValue>(line) + mapOf( + "id" to obj["id"], + "author" to obj["pubkey"], + "kind" to obj["kind"], + "content" to obj["content"], + "created_at" to obj["created_at"], + ) + } catch (_: Exception) { + mapOf("raw" to line) + } + }.takeLast(limit) + + Json.writeLine(mapOf("group_id" to gid, "messages" to items)) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt new file mode 100644 index 000000000..7a34df1c0 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -0,0 +1,114 @@ +/* + * 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.Json +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType + +object RelayCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Json.error("bad_args", "relay …") + val sub = tail[0] + val rest = tail.drop(1).toTypedArray() + return when (sub) { + "add" -> add(dataDir, Args(rest)) + "list" -> list(dataDir) + "publish-lists" -> publishLists(dataDir) + else -> Json.error("bad_args", "relay $sub") + } + } + + private fun add( + dataDir: DataDir, + args: Args, + ): Int { + val url = args.positional(0, "url") + val type = args.flag("type", "all") ?: "all" + val cfg = dataDir.loadRelays() + val addedTo = mutableListOf() + val targets = if (type == "all") listOf("nip65", "inbox", "key_package") else listOf(type) + for (t in targets) { + if (cfg.add(t, url)) addedTo.add(t) + } + dataDir.saveRelays(cfg) + Json.writeLine( + mapOf( + "url" to url, + "added_to" to addedTo, + "already_present" to (targets - addedTo.toSet()), + ), + ) + return 0 + } + + private fun list(dataDir: DataDir): Int { + val cfg = dataDir.loadRelays() + Json.writeLine( + mapOf( + "nip65" to cfg.nip65, + "inbox" to cfg.inbox, + "key_package" to cfg.keyPackage, + ), + ) + return 0 + } + + private suspend fun publishLists(dataDir: DataDir): Int { + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val nip65Relays = ctx.relays.normalized("nip65").toList() + val inboxRelays = ctx.relays.normalized("inbox").toList() + + val nip65Infos = nip65Relays.map { AdvertisedRelayInfo(it, AdvertisedRelayType.BOTH) } + val nip65Event = AdvertisedRelayListEvent.create(nip65Infos, ctx.signer) + val inboxEvent = ChatMessageRelayListEvent.create(inboxRelays, ctx.signer) + + val targets = ctx.anyRelays() + val nip65Result = ctx.publish(nip65Event, targets) + val inboxResult = ctx.publish(inboxEvent, targets) + + Json.writeLine( + mapOf( + "nip65_event_id" to nip65Event.id, + "inbox_event_id" to inboxEvent.id, + "accepted_by" to + mapOf( + "nip65" to nip65Result.filterValues { it }.keys.map { it.url }, + "inbox" to inboxResult.filterValues { it }.keys.map { it.url }, + ), + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt new file mode 100644 index 000000000..6aa7f0907 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt @@ -0,0 +1,142 @@ +/* + * 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.stores + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore +import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import java.io.File + +/** + * Test-harness file stores. **Unencrypted** — the production interfaces + * document that implementations MUST encrypt at rest, but this CLI is a + * throwaway interop driver running against scratch keys and local scratch + * state. Do not point it at real account material. + */ + +class FileMlsGroupStateStore( + private val dir: File, +) : MlsGroupStateStore { + init { + dir.mkdirs() + } + + private fun stateFile(id: String) = File(dir, "$id.state") + + private fun retainedFile(id: String) = File(dir, "$id.retained") + + override suspend fun save( + nostrGroupId: String, + state: ByteArray, + ) { + stateFile(nostrGroupId).writeBytes(state) + } + + override suspend fun load(nostrGroupId: String): ByteArray? = stateFile(nostrGroupId).takeIf { it.exists() }?.readBytes() + + override suspend fun delete(nostrGroupId: String) { + stateFile(nostrGroupId).delete() + retainedFile(nostrGroupId).delete() + } + + override suspend fun listGroups(): List = + dir + .listFiles { f -> f.name.endsWith(".state") } + ?.map { it.name.removeSuffix(".state") } + ?: emptyList() + + override suspend fun saveRetainedEpochs( + nostrGroupId: String, + retainedSecrets: List, + ) { + // Layout: [u32 count][(u32 len, bytes) …] — tiny framing so readers + // can recover independent byte arrays without TLS plumbing. + val f = retainedFile(nostrGroupId) + f.outputStream().use { out -> + val buf = java.nio.ByteBuffer.allocate(4) + buf.putInt(retainedSecrets.size) + out.write(buf.array()) + for (secret in retainedSecrets) { + buf.clear() + buf.putInt(secret.size) + out.write(buf.array()) + out.write(secret) + } + } + } + + override suspend fun loadRetainedEpochs(nostrGroupId: String): List { + val f = retainedFile(nostrGroupId) + if (!f.exists()) return emptyList() + val bytes = f.readBytes() + if (bytes.size < 4) return emptyList() + val buf = java.nio.ByteBuffer.wrap(bytes) + val count = buf.int + val result = ArrayList(count) + repeat(count) { + val len = buf.int + val arr = ByteArray(len) + buf.get(arr) + result.add(arr) + } + return result + } +} + +class FileKeyPackageBundleStore( + private val file: File, +) : KeyPackageBundleStore { + override suspend fun save(snapshot: ByteArray) { + file.parentFile?.mkdirs() + file.writeBytes(snapshot) + } + + override suspend fun load(): ByteArray? = file.takeIf { it.exists() }?.readBytes() + + override suspend fun delete() { + file.delete() + } +} + +class FileMarmotMessageStore( + private val dir: File, +) : MarmotMessageStore { + init { + dir.mkdirs() + } + + private fun file(id: String) = File(dir, "$id.messages") + + override suspend fun appendMessage( + nostrGroupId: String, + innerEventJson: String, + ) { + // Each line is one inner event JSON. The store doc tolerates duplicates + // so we don't bother deduping here — readers can do it. + file(nostrGroupId).appendText(innerEventJson.replace("\n", " ") + "\n") + } + + override suspend fun loadMessages(nostrGroupId: String): List = file(nostrGroupId).takeIf { it.exists() }?.readLines()?.filter { it.isNotBlank() } ?: emptyList() + + override suspend fun delete(nostrGroupId: String) { + file(nostrGroupId).delete() + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/util/Npubs.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/util/Npubs.kt new file mode 100644 index 000000000..3796fc1d7 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/util/Npubs.kt @@ -0,0 +1,39 @@ +/* + * 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.util + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes + +/** Accept either an `npub1…` or a raw 64-hex pubkey; always return hex. */ +object Npubs { + fun resolveToHex(input: String): String { + val trimmed = input.trim() + // Already hex? + if (trimmed.length == 64 && trimmed.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) { + return trimmed.lowercase() + } + if (trimmed.startsWith("npub1") || trimmed.startsWith("npub")) { + return trimmed.bechToBytes().toHexKey() + } + throw IllegalArgumentException("expected npub or 64-hex pubkey, got: $input") + } +} diff --git a/settings.gradle b/settings.gradle index 720f73879..ab5462e42 100644 --- a/settings.gradle +++ b/settings.gradle @@ -37,3 +37,4 @@ include ':quartz' include ':commons' include ':ammolite' include ':desktopApp' +include ':cli'