diff --git a/.claude/skills/amy-expert/SKILL.md b/.claude/skills/amy-expert/SKILL.md index c0139959cf..f6cbce8416 100644 --- a/.claude/skills/amy-expert/SKILL.md +++ b/.claude/skills/amy-expert/SKILL.md @@ -129,10 +129,12 @@ via `Output.emit`. The template is in `references/command-template.md`; copy it rather than re-deriving it. Wire-up checklist: -1. New file in `cli/commands/` with the `object` pattern. -2. Add a branch in `Commands.kt`. -3. Add a branch in `Main.kt`'s `dispatch` (or under `marmotDispatch` - / a new group dispatcher). +1. New file in `cli/commands/` with the `object` pattern. Sub-verb + `dispatch` functions use the shared `route(...)` helper in + `Router.kt` rather than a hand-rolled `when (tail[0])`. +2. Add a branch in `Main.kt`'s `dispatch` (top-level verbs call the + command object directly, e.g. `"relay" -> RelayCommands.dispatch(…)`; + `marmot` sub-verbs go through `marmotDispatch`'s `route` map). 4. Extend `printUsage()` in `Main.kt`. 5. Add the row to `cli/README.md`'s command table. 6. Update `cli/ROADMAP.md` β€” move the row from πŸ†• / πŸ“¦ to βœ…. @@ -173,6 +175,7 @@ cli/ β”œβ”€β”€ secrets/ # SecretStore backends (keychain / ncryptsec / plaintext) └── commands/ # one file (or group) per top-level verb β”œβ”€β”€ UseCommand.kt # `amy use NAME` + β”œβ”€β”€ Router.kt # `route(...)` shared sub-verb dispatcher β”œβ”€β”€ InitCommands.kt # init, whoami β”œβ”€β”€ CreateCommand.kt + LoginCommand.kt β”œβ”€β”€ RelayCommands.kt diff --git a/.claude/skills/amy-expert/references/command-template.md b/.claude/skills/amy-expert/references/command-template.md index b2adc99a75..4546de0367 100644 --- a/.claude/skills/amy-expert/references/command-template.md +++ b/.claude/skills/amy-expert/references/command-template.md @@ -18,8 +18,7 @@ object NotePublishCommand { val args = Args(rest) val text = args.positional(0, "text") - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val event = com.vitorpamplona.amethyst.commons.note @@ -33,13 +32,15 @@ object NotePublishCommand { "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, )) return 0 - } finally { - ctx.close() } } } ``` +`Context` is `AutoCloseable`; wrap it in `use { }` so it's closed +(RunState flushed, relays disconnected) on every exit path β€” never a +hand-rolled `try { } finally { ctx.close() }`. + `Output.emit(...)` handles the text-vs-JSON mode automatically. The result map IS the `--json` shape; the human-readable text default is derived from the same map by `Output.kt`'s renderer. @@ -51,39 +52,34 @@ When a feature has several verbs (`note publish`, `note show`, ```kotlin object NoteCommands { - suspend fun dispatch(dataDir: DataDir, tail: Array): Int { - if (tail.isEmpty()) return Output.error("bad_args", "note ") - val rest = tail.drop(1).toTypedArray() - return when (tail[0]) { - "publish" -> NotePublishCommand.run(dataDir, rest) - "show" -> NoteShowCommand.run(dataDir, rest) - "react" -> NoteReactCommand.run(dataDir, rest) - else -> Output.error("bad_args", "note ${tail[0]}") - } - } + suspend fun dispatch(dataDir: DataDir, tail: Array): Int = + route("note", tail, "note ", mapOf( + "publish" to { rest -> NotePublishCommand.run(dataDir, rest) }, + "show" to { rest -> NoteShowCommand.run(dataDir, rest) }, + "react" to { rest -> NoteReactCommand.run(dataDir, rest) }, + )) } ``` -Each verb gets its own file. Once a single file crosses ~200 lines, -split it β€” see `GroupCommands.kt` and its siblings as the reference. +The shared `route(name, tail, usage, routes)` helper (`Router.kt`) +handles the empty-input and unknown-verb `bad_args` branches, so the +`dispatch` body is just the verbβ†’handler map. Each verb gets its own +file. Once a single file crosses ~200 lines, split it β€” see +`GroupCommands.kt` and its siblings as the reference. ## Wire-up checklist For every new command: 1. File under `cli/commands/`. -2. Branch in `Commands.kt`: +2. Branch in `Main.kt`'s top-level `dispatch`, calling the command + object directly: ```kotlin - suspend fun note(dataDir: DataDir, tail: Array): Int = - NoteCommands.dispatch(dataDir, tail) + "note" -> NoteCommands.dispatch(dataDir, tail) ``` -3. Branch in `Main.kt`'s top-level `dispatch`: - ```kotlin - "note" -> Commands.note(dataDir, tail) - ``` -4. Line in `printUsage()` explaining the verb. -5. Row in `cli/README.md`'s command table. -6. Status flip in `cli/ROADMAP.md` (πŸ†• / πŸ“¦ β†’ βœ…). +3. Line in `printUsage()` explaining the verb. +4. Row in `cli/README.md`'s command table. +5. Status flip in `cli/ROADMAP.md` (πŸ†• / πŸ“¦ β†’ βœ…). ## What not to do @@ -95,7 +91,7 @@ For every new command: them to `error: …` (text mode) / `{"error":…}` (JSON mode) plus the right exit code. - No holding a connection open across invocations β€” every run opens - a fresh `Context` and closes it in `finally`. + a fresh `Context` inside `use { }` so it closes on every exit path. - No blocking reads for user input β€” take a flag. - No global flags that collide with subcommand flags. `--name` is reserved for subcommand use (group/profile name); the global diff --git a/cli/DEVELOPMENT.md b/cli/DEVELOPMENT.md index 00fd9a4bf9..b9f37b73b8 100644 --- a/cli/DEVELOPMENT.md +++ b/cli/DEVELOPMENT.md @@ -85,7 +85,7 @@ cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/ β”œβ”€β”€ stores/FileStores.kt # File-backed MLS / KP / message stores β”œβ”€β”€ secrets/ # SecretStore backends (keychain / ncryptsec / plaintext) └── commands/ - β”œβ”€β”€ Commands.kt # dispatcher tables + β”œβ”€β”€ Router.kt # `route(...)` shared sub-verb dispatcher β”œβ”€β”€ UseCommand.kt # `amy use NAME` β€” pin active account β”œβ”€β”€ InitCommands.kt # init, whoami β”œβ”€β”€ CreateCommand.kt # full bootstrap (β†’ commons/account/) @@ -111,18 +111,19 @@ host. Never add a Gradle dependency on `:amethyst` or `:desktopApp`. **`Context.kt` is the backbone.** Most commands follow this template: ```kotlin -val ctx = Context.open(dataDir) -try { +Context.open(dataDir).use { ctx -> // .use closes the Context on exit ctx.prepare() // restore MLS state + connect relays ctx.syncIncoming() // pull new gift-wraps + group events // ...call into commons/ or quartz/ to build an event... val ack = ctx.publish(event, targets) Output.emit(mapOf(...)) -} finally { - ctx.close() // flush RunState, disconnect -} + return 0 +} // close() flushes RunState + disconnects ``` +`Context` is `AutoCloseable`, so wrap it in `use { }` rather than a +hand-rolled `try { } finally { ctx.close() }`. + --- ## How to add a command @@ -179,20 +180,15 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output object NoteCommands { - suspend fun dispatch(dataDir: DataDir, tail: Array): Int { - if (tail.isEmpty()) return Output.error("bad_args", "note ") - val rest = tail.drop(1).toTypedArray() - return when (tail[0]) { - "publish" -> publish(dataDir, rest) - else -> Output.error("bad_args", "note ${tail[0]}") - } - } + suspend fun dispatch(dataDir: DataDir, tail: Array): Int = + route("note", tail, "note ", mapOf( + "publish" to { rest -> publish(dataDir, rest) }, + )) private suspend fun publish(dataDir: DataDir, rest: Array): Int { val args = Args(rest) val text = args.positional(0, "text") - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val event = com.vitorpamplona.amethyst.commons.note.buildTextNote(ctx.signer, text) val ack = ctx.publish(event, ctx.outboxRelays()) @@ -202,13 +198,15 @@ object NoteCommands { "published_to" to ack.filterValues { it }.keys.map { it.url }, )) return 0 - } finally { ctx.close() } + } } } ``` -Wire it into `Commands.kt`, add a top-level branch in `Main.kt`'s -`dispatch`, and extend `printUsage()`. Keep the command tour in +The shared `route(name, tail, usage, routes)` helper (in `Router.kt`) +handles the empty-input and unknown-verb `bad_args` cases, so each +`dispatch` is just the verbβ†’handler map. Add a top-level branch in +`Main.kt`'s `dispatch` and extend `printUsage()`. Keep the command tour in [README.md](./README.md) and the parity matrix in [ROADMAP.md](./ROADMAP.md) in sync. diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index b50689a36d..6ab7d37abb 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -34,6 +34,7 @@ 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.crypto.verify +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed @@ -128,12 +129,14 @@ class Context( * humans inspect these files. Verification always re-canonicalises, * so the stored bytes never feed back into a signature check. */ - val store: IEventStore by lazy { - FsEventStore( - root = dataDir.eventsDir.toPath(), - eventToJson = com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper::toJsonPretty, - ) - } + private val storeDelegate: Lazy = + lazy { + FsEventStore( + root = dataDir.eventsDir.toPath(), + eventToJson = JacksonMapper::toJsonPretty, + ) + } + val store: IEventStore by storeDelegate /** Fully-wired manager. Call [prepare] once before use to load persisted state. */ val marmot: MarmotManager = MarmotManager(signer, mlsStore, messageStore, keyPackageStore) @@ -611,25 +614,14 @@ class Context( } // Only close the store if it was actually opened β€” by-lazy // otherwise allocates the lock channel just to release it. - if (storeIsInitialized()) { + if (storeDelegate.isInitialized()) { try { - store.close() + storeDelegate.value.close() } catch (_: Exception) { } } } - private fun storeIsInitialized(): Boolean { - // Reflect on the lazy delegate to avoid forcing initialisation in close(). - return try { - val field = javaClass.getDeclaredField("store\$delegate").apply { isAccessible = true } - val delegate = field.get(this) as Lazy<*> - delegate.isInitialized() - } catch (_: Throwable) { - false - } - } - companion object { /** * Lookback applied to the gift-wrap `since` filter to compensate for diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 5ddad30bd8..e5f8cde7d2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -20,7 +20,28 @@ */ package com.vitorpamplona.amethyst.cli -import com.vitorpamplona.amethyst.cli.commands.Commands +import com.vitorpamplona.amethyst.cli.commands.AwaitCommands +import com.vitorpamplona.amethyst.cli.commands.CreateCommand +import com.vitorpamplona.amethyst.cli.commands.DebitCommands +import com.vitorpamplona.amethyst.cli.commands.DmCommands +import com.vitorpamplona.amethyst.cli.commands.FollowCommand +import com.vitorpamplona.amethyst.cli.commands.GroupCommands +import com.vitorpamplona.amethyst.cli.commands.InitCommands +import com.vitorpamplona.amethyst.cli.commands.KeyPackageCommands +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.NappletCommands +import com.vitorpamplona.amethyst.cli.commands.NotesCommands +import com.vitorpamplona.amethyst.cli.commands.NsiteCommands +import com.vitorpamplona.amethyst.cli.commands.OfferCommands +import com.vitorpamplona.amethyst.cli.commands.ProfileCommands +import com.vitorpamplona.amethyst.cli.commands.RelayCommands +import com.vitorpamplona.amethyst.cli.commands.SearchCommand +import com.vitorpamplona.amethyst.cli.commands.StoreCommands +import com.vitorpamplona.amethyst.cli.commands.UseCommand +import com.vitorpamplona.amethyst.cli.commands.ZapCommand +import com.vitorpamplona.amethyst.cli.commands.route import com.vitorpamplona.amethyst.cli.secrets.SecretStore import kotlinx.coroutines.runBlocking import kotlin.system.exitProcess @@ -118,86 +139,31 @@ private suspend fun dispatch(argv: Array): Int { // resolve "multiple accounts, ambiguous" cases) β€” so it skips // DataDir.resolve. Other commands fall through to the normal path. if (head == "use") { - return com.vitorpamplona.amethyst.cli.commands.UseCommand - .run(tail) + return UseCommand.run(tail) } val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag) val dataDir = DataDir.resolve(accountFlag = accountFlag, secrets = secrets) return when (head) { - "init" -> { - Commands.init(dataDir, Args(tail)) - } - - "create" -> { - Commands.create(dataDir, tail) - } - - "login" -> { - Commands.login(dataDir, tail) - } - - "whoami" -> { - Commands.whoami(dataDir) - } - - "relay" -> { - Commands.relay(dataDir, tail) - } - - "marmot" -> { - marmotDispatch(dataDir, tail) - } - - "dm" -> { - Commands.dm(dataDir, tail) - } - - "profile" -> { - Commands.profile(dataDir, tail) - } - - "notes" -> { - Commands.notes(dataDir, tail) - } - - "nsite" -> { - Commands.nsite(dataDir, tail) - } - - "napplet" -> { - Commands.napplet(dataDir, tail) - } - - "store" -> { - Commands.store(dataDir, tail) - } - - "follow" -> { - Commands.follow(dataDir, tail) - } - - "unfollow" -> { - Commands.unfollow(dataDir, tail) - } - - "search" -> { - Commands.search(dataDir, tail) - } - - "zap" -> { - Commands.zap(dataDir, tail) - } - - "offer" -> { - Commands.offer(dataDir, tail) - } - - "debit" -> { - Commands.debit(dataDir, tail) - } - + "init" -> InitCommands.init(dataDir, Args(tail)) + "create" -> CreateCommand.run(dataDir, tail) + "login" -> LoginCommand.run(dataDir, tail) + "whoami" -> InitCommands.whoami(dataDir) + "relay" -> RelayCommands.dispatch(dataDir, tail) + "marmot" -> marmotDispatch(dataDir, tail) + "dm" -> DmCommands.dispatch(dataDir, tail) + "profile" -> ProfileCommands.dispatch(dataDir, tail) + "notes" -> NotesCommands.dispatch(dataDir, tail) + "nsite" -> NsiteCommands.dispatch(dataDir, tail) + "napplet" -> NappletCommands.dispatch(dataDir, tail) + "store" -> StoreCommands.dispatch(dataDir, tail) + "follow" -> FollowCommand.follow(dataDir, tail) + "unfollow" -> FollowCommand.unfollow(dataDir, tail) + "search" -> SearchCommand.dispatch(dataDir, tail) + "zap" -> ZapCommand.dispatch(dataDir, tail) + "offer" -> OfferCommands.dispatch(dataDir, tail) + "debit" -> DebitCommands.dispatch(dataDir, tail) else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -209,41 +175,20 @@ private suspend fun dispatch(argv: Array): Int { 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) - } - - "reset" -> { - Commands.reset(dataDir, rest) - } - - else -> { - System.err.println("unknown marmot subcommand: $head") - printUsage() - 2 - } - } -} +): Int = + route( + name = "marmot", + tail = tail, + usage = "marmot ", + routes = + mapOf( + "key-package" to { rest -> KeyPackageCommands.dispatch(dataDir, rest) }, + "group" to { rest -> GroupCommands.dispatch(dataDir, rest) }, + "message" to { rest -> MessageCommands.dispatch(dataDir, rest) }, + "await" to { rest -> AwaitCommands.dispatch(dataDir, rest) }, + "reset" to { rest -> MarmotResetCommand.run(dataDir, rest) }, + ), + ) private enum class GlobalFlag( val long: String, 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 index eeea8b7458..0ad6e869b0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt @@ -42,20 +42,21 @@ object AwaitCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, - ): Int { - if (tail.isEmpty()) return Output.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 -> Output.error("bad_args", "await ${tail[0]}") - } - } + ): Int = + route( + "await", + tail, + "await ", + mapOf( + "key-package" to { rest -> awaitKeyPackage(dataDir, rest) }, + "group" to { rest -> awaitGroup(dataDir, rest) }, + "member" to { rest -> awaitMember(dataDir, rest) }, + "admin" to { rest -> awaitAdmin(dataDir, rest) }, + "message" to { rest -> awaitMessage(dataDir, rest) }, + "rename" to { rest -> awaitRename(dataDir, rest) }, + "epoch" to { rest -> awaitEpoch(dataDir, rest) }, + ), + ) private suspend fun awaitKeyPackage( dataDir: DataDir, @@ -64,8 +65,7 @@ object AwaitCommands { if (rest.isEmpty()) return Output.error("bad_args", "await key-package ") val args = Args(rest.drop(1).toTypedArray()) val timeoutSecs = args.longFlag("timeout", 30) - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val target = ctx.requireUserHex(rest[0]) val filter = ctx.marmot.subscriptionManager.keyPackageFilter(target) @@ -112,8 +112,6 @@ object AwaitCommands { delay(2_000) } throw AwaitTimeout("no KeyPackage for $target within ${timeoutSecs}s") - } finally { - ctx.close() } } @@ -124,8 +122,7 @@ object AwaitCommands { val args = Args(rest) val wantedName = args.flag("name") val timeoutSecs = args.longFlag("timeout", 30) - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val deadline = System.currentTimeMillis() + timeoutSecs * 1000 while (System.currentTimeMillis() < deadline) { @@ -148,8 +145,6 @@ object AwaitCommands { delay(1_500) } throw AwaitTimeout("no group with name=$wantedName within ${timeoutSecs}s") - } finally { - ctx.close() } } @@ -197,8 +192,7 @@ object AwaitCommands { val args = Args(rest.drop(1).toTypedArray()) val wantedName = args.requireFlag("name") val timeoutSecs = args.longFlag("timeout", 30) - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) val deadline = System.currentTimeMillis() + timeoutSecs * 1000 @@ -212,8 +206,6 @@ object AwaitCommands { delay(1_500) } throw AwaitTimeout("group $gid never renamed to $wantedName within ${timeoutSecs}s") - } finally { - ctx.close() } } @@ -225,8 +217,7 @@ object AwaitCommands { 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 { + Context.open(dataDir).use { ctx -> ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) val deadline = System.currentTimeMillis() + timeoutSecs * 1000 @@ -240,8 +231,6 @@ object AwaitCommands { delay(1_500) } throw AwaitTimeout("group $gid epoch never reached $min within ${timeoutSecs}s") - } finally { - ctx.close() } } @@ -253,8 +242,7 @@ object AwaitCommands { val args = Args(rest.drop(1).toTypedArray()) val needle = args.requireFlag("match") val timeoutSecs = args.longFlag("timeout", 30) - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) val deadline = System.currentTimeMillis() + timeoutSecs * 1000 @@ -285,8 +273,6 @@ object AwaitCommands { delay(1_500) } throw AwaitTimeout("no message matching '$needle' in $gid within ${timeoutSecs}s") - } finally { - ctx.close() } } @@ -304,8 +290,7 @@ object AwaitCommands { if (rest.size <= targetIdx) return Output.error("bad_args", usage) val args = Args(rest.drop(targetIdx + 1).toTypedArray()) val timeoutSecs = args.longFlag("timeout", 30) - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val deadline = System.currentTimeMillis() + timeoutSecs * 1000 while (System.currentTimeMillis() < deadline) { @@ -318,8 +303,6 @@ object AwaitCommands { 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 deleted file mode 100644 index 0859b4dc8f..0000000000 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ /dev/null @@ -1,138 +0,0 @@ -/* - * 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 create( - dataDir: DataDir, - tail: Array, - ): Int = CreateCommand.run(dataDir, tail) - - suspend fun login( - dataDir: DataDir, - tail: Array, - ): Int = LoginCommand.run(dataDir, tail) - - 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) - - suspend fun reset( - dataDir: DataDir, - tail: Array, - ): Int = MarmotResetCommand.run(dataDir, tail) - - suspend fun dm( - dataDir: DataDir, - tail: Array, - ): Int = DmCommands.dispatch(dataDir, tail) - - suspend fun profile( - dataDir: DataDir, - tail: Array, - ): Int = ProfileCommands.dispatch(dataDir, tail) - - suspend fun notes( - dataDir: DataDir, - tail: Array, - ): Int = NotesCommands.dispatch(dataDir, tail) - - suspend fun nsite( - dataDir: DataDir, - tail: Array, - ): Int = NsiteCommands.dispatch(dataDir, tail) - - suspend fun napplet( - dataDir: DataDir, - tail: Array, - ): Int = NappletCommands.dispatch(dataDir, tail) - - suspend fun store( - dataDir: DataDir, - tail: Array, - ): Int = StoreCommands.dispatch(dataDir, tail) - - suspend fun follow( - dataDir: DataDir, - tail: Array, - ): Int = FollowCommand.follow(dataDir, tail) - - suspend fun unfollow( - dataDir: DataDir, - tail: Array, - ): Int = FollowCommand.unfollow(dataDir, tail) - - suspend fun search( - dataDir: DataDir, - tail: Array, - ): Int = SearchCommand.dispatch(dataDir, tail) - - suspend fun zap( - dataDir: DataDir, - tail: Array, - ): Int = ZapCommand.dispatch(dataDir, tail) - - suspend fun offer( - dataDir: DataDir, - tail: Array, - ): Int = OfferCommands.dispatch(dataDir, tail) - - suspend fun debit( - dataDir: DataDir, - tail: Array, - ): Int = DebitCommands.dispatch(dataDir, tail) -} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt index ddae68bd8b..876e45e69d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt @@ -71,17 +71,14 @@ object CreateCommand { // store via verifyAndStore β€” so kind:10002 / 10050 / 10051 are // immediately readable by outboxRelays() / inboxRelays() / // keyPackageRelays() without a separate config file. - val ctx = Context.open(dataDir) val accepted = mutableMapOf>() - try { + Context.open(dataDir).use { ctx -> ctx.prepare() for (event in bootstrap.all()) { val ack = ctx.publish(event, DefaultNIP65RelaySet) accepted[event.kind.toString()] = ack.filterValues { it }.keys.map { it.url } } - } finally { - ctx.close() } Output.emit( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt index ba90f6f01c..f89d79c8b0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt @@ -47,16 +47,17 @@ object DebitCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, - ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "debit ") - val rest = tail.drop(1).toTypedArray() - return when (tail[0]) { - "info" -> info(rest) - "pay" -> pay(dataDir, rest) - "budget" -> budget(dataDir, rest) - else -> Output.error("bad_args", "debit ${tail[0]} (expected info|pay|budget)") - } - } + ): Int = + route( + "debit", + tail, + "debit ", + mapOf( + "info" to { rest -> info(rest) }, + "pay" to { rest -> pay(dataDir, rest) }, + "budget" to { rest -> budget(dataDir, rest) }, + ), + ) /** Local decode of an `ndebit` pointer β€” no network, no account needed. */ private fun info(rest: Array): Int { @@ -119,8 +120,7 @@ object DebitCommands { ?: return Output.error("bad_args", "not a valid ndebit pointer") if (debit.relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach") - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() return when (val outcome = settle(ctx, debit, timeoutMs, buildRequest)) { Settle.Timeout -> { @@ -130,8 +130,6 @@ object DebitCommands { Settle.BadReply -> Output.error("bad_response", "service reply was not a kind-21002 debit event") is Settle.Replied -> emitDebit(outcome, debit.pubKey) } - } finally { - ctx.close() } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt index 9cd003ae30..c387a1e72f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt @@ -61,17 +61,18 @@ object DmCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, - ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "dm …") - val rest = tail.drop(1).toTypedArray() - return when (tail[0]) { - "send" -> send(dataDir, rest) - "send-file" -> sendFile(dataDir, rest) - "list" -> list(dataDir, rest) - "await" -> await(dataDir, rest) - else -> Output.error("bad_args", "dm ${tail[0]}") - } - } + ): Int = + route( + "dm", + tail, + "dm …", + mapOf( + "send" to { rest -> send(dataDir, rest) }, + "send-file" to { rest -> sendFile(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + "await" to { rest -> await(dataDir, rest) }, + ), + ) private suspend fun send( dataDir: DataDir, @@ -82,14 +83,11 @@ object DmCommands { val args = Args(rest.drop(2).toTypedArray()) val allowFallback = args.bool("allow-fallback") - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val recipient = ctx.requireUserHex(rest[0]) val result = DmActions.buildTextDm(ctx.signer, recipient, text) return publishWraps(ctx, result, allowFallback) - } finally { - ctx.close() } } @@ -118,8 +116,7 @@ object DmCommands { val allowFallback = args.bool("allow-fallback") val recipientInput = rest[0] - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val recipient = ctx.requireUserHex(recipientInput) @@ -133,8 +130,6 @@ object DmCommands { } return publishWraps(ctx, result, allowFallback, extra = summary) - } finally { - ctx.close() } } @@ -315,8 +310,7 @@ object DmCommands { val limit = args.intFlag("limit", Int.MAX_VALUE) val timeoutSecs = args.longFlag("timeout", 8) - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val peerHex = peerInput?.let { ctx.requireUserHex(it) } @@ -357,8 +351,6 @@ object DmCommands { ), ) return 0 - } finally { - ctx.close() } } @@ -371,8 +363,7 @@ object DmCommands { val match = args.requireFlag("match") val timeoutSecs = args.longFlag("timeout", 30) - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val peerHex = ctx.requireUserHex(peerInput) @@ -408,8 +399,6 @@ object DmCommands { delay(2_000) } throw AwaitTimeout("no DM from $peerHex matching '$match' within ${timeoutSecs}s") - } finally { - ctx.close() } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt index ba928622ec..25bce1e8b8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt @@ -61,8 +61,7 @@ object FeedCommand { val until = args.flag("until")?.toLongOrNull() val timeoutSecs = args.longFlag("timeout", 8L) - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val (authors, mode) = @@ -131,8 +130,6 @@ object FeedCommand { ), ) return 0 - } finally { - ctx.close() } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt index c96234c8f4..85c6123390 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt @@ -68,8 +68,7 @@ object FollowCommand { val args = Args(rest.drop(1).toTypedArray()) val timeoutSecs = args.longFlag("timeout", 8L) - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val target = ctx.requireUserHex(userArg) val self = ctx.identity.pubKeyHex @@ -148,8 +147,6 @@ object FollowCommand { ), ) return 0 - } finally { - ctx.close() } } 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 index c47e862bdd..7622361451 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt @@ -46,8 +46,7 @@ object GroupAddMemberCommand { rest: Array, ): Int { if (rest.size < 2) return Output.error("bad_args", "group add [ ...]") - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() @@ -168,8 +167,6 @@ object GroupAddMemberCommand { ), ) 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 index 7c519cbb7a..fe8bf98aef 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt @@ -21,28 +21,28 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.DataDir -import com.vitorpamplona.amethyst.cli.Output object GroupCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, - ): Int { - if (tail.isEmpty()) return Output.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 -> Output.error("bad_args", "group ${tail[0]}") - } - } + ): Int = + route( + "group", + tail, + "group ", + mapOf( + "create" to { rest -> GroupCreateCommand.run(dataDir, rest) }, + "list" to { _ -> GroupReadCommands.list(dataDir) }, + "show" to { rest -> GroupReadCommands.show(dataDir, rest) }, + "members" to { rest -> GroupReadCommands.members(dataDir, rest) }, + "admins" to { rest -> GroupReadCommands.admins(dataDir, rest) }, + "add" to { rest -> GroupAddMemberCommand.run(dataDir, rest) }, + "rename" to { rest -> GroupMetadataCommands.rename(dataDir, rest) }, + "promote" to { rest -> GroupMetadataCommands.promote(dataDir, rest) }, + "demote" to { rest -> GroupMetadataCommands.demote(dataDir, rest) }, + "remove" to { rest -> GroupMembershipCommands.remove(dataDir, rest) }, + "leave" to { rest -> GroupMembershipCommands.leave(dataDir, rest) }, + ), + ) } 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 index b639971c8f..ee138c4626 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt @@ -35,8 +35,7 @@ object GroupCreateCommand { ): Int { val args = Args(rest) val name = args.flag("name", "")!! - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val gid = RandomInstance.bytes(32).toHexKey() @@ -65,8 +64,6 @@ object GroupCreateCommand { ), ) 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 index e79dc0135d..1239996981 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt @@ -30,8 +30,7 @@ object GroupMembershipCommands { rest: Array, ): Int { if (rest.size < 2) return Output.error("bad_args", "group remove ") - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) val target = ctx.requireUserHex(rest[1]) @@ -56,8 +55,6 @@ object GroupMembershipCommands { ), ) return 0 - } finally { - ctx.close() } } @@ -66,8 +63,7 @@ object GroupMembershipCommands { rest: Array, ): Int { if (rest.isEmpty()) return Output.error("bad_args", "group leave ") - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid) @@ -111,8 +107,6 @@ object GroupMembershipCommands { ), ) 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 index fe8940782e..841adf2c37 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt @@ -69,8 +69,7 @@ object GroupMetadataCommands { rawGid: HexKey, mutate: suspend (Context, MarmotGroupData) -> MarmotGroupData, ): Int { - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val gid = ctx.resolveGroupId(rawGid) ctx.syncIncoming() @@ -100,8 +99,6 @@ object GroupMetadataCommands { ), ) 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 index 925685ff93..46e06da5df 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt @@ -29,8 +29,7 @@ import com.vitorpamplona.amethyst.cli.Output */ object GroupReadCommands { suspend fun list(dataDir: DataDir): Int { - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() ctx.syncIncoming() val ids = ctx.marmot.activeGroupIds() @@ -46,8 +45,6 @@ object GroupReadCommands { } Output.emit(mapOf("groups" to items)) return 0 - } finally { - ctx.close() } } @@ -56,8 +53,7 @@ object GroupReadCommands { rest: Array, ): Int { if (rest.isEmpty()) return Output.error("bad_args", "group show ") - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() @@ -81,8 +77,6 @@ object GroupReadCommands { ), ) return 0 - } finally { - ctx.close() } } @@ -91,8 +85,7 @@ object GroupReadCommands { rest: Array, ): Int { if (rest.isEmpty()) return Output.error("bad_args", "group members ") - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() @@ -103,8 +96,6 @@ object GroupReadCommands { } Output.emit(mapOf("group_id" to gid, "members" to members)) return 0 - } finally { - ctx.close() } } @@ -113,8 +104,7 @@ object GroupReadCommands { rest: Array, ): Int { if (rest.isEmpty()) return Output.error("bad_args", "group admins ") - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() @@ -122,8 +112,6 @@ object GroupReadCommands { val m = ctx.marmot.groupMetadata(gid) Output.emit(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/KeyPackageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt index 843fd17653..ac35fca36a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt @@ -30,18 +30,19 @@ object KeyPackageCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, - ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "key-package …") - return when (tail[0]) { - "publish" -> publish(dataDir) - "check" -> check(dataDir, tail.drop(1).toTypedArray()) - else -> Output.error("bad_args", "key-package ${tail[0]}") - } - } + ): Int = + route( + "key-package", + tail, + "key-package …", + mapOf( + "publish" to { _ -> publish(dataDir) }, + "check" to { rest -> check(dataDir, rest) }, + ), + ) private suspend fun publish(dataDir: DataDir): Int { - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val relays = ctx.keyPackageRelays().ifEmpty { ctx.outboxRelays() }.ifEmpty { ctx.anyRelays() } if (relays.isEmpty()) return Output.error("no_relays", "configure relays first") @@ -57,8 +58,6 @@ object KeyPackageCommands { ), ) return 0 - } finally { - ctx.close() } } @@ -67,8 +66,7 @@ object KeyPackageCommands { rest: Array, ): Int { if (rest.isEmpty()) return Output.error("bad_args", "key-package check ") - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val targetHex = ctx.requireUserHex(rest[0]) // Per MIP-00: a user's KeyPackages live on the relays advertised @@ -109,8 +107,6 @@ object KeyPackageCommands { ), ) return 0 - } finally { - ctx.close() } } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MarmotResetCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MarmotResetCommand.kt index 8d1a118b5b..4d5fd9c73e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MarmotResetCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MarmotResetCommand.kt @@ -46,8 +46,7 @@ object MarmotResetCommand { rest: Array, ): Int { val confirmed = rest.any { it == "--yes" || it == "-y" } - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val groupIds = ctx.marmot @@ -77,8 +76,6 @@ object MarmotResetCommand { ), ) 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 index 0706e7dc10..fc62fb9efa 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt @@ -31,17 +31,18 @@ object MessageCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, - ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "message …") - val rest = tail.drop(1).toTypedArray() - return when (tail[0]) { - "send" -> send(dataDir, rest) - "list" -> list(dataDir, rest) - "react" -> react(dataDir, rest) - "delete" -> delete(dataDir, rest) - else -> Output.error("bad_args", "message ${tail[0]}") - } - } + ): Int = + route( + "message", + tail, + "message …", + mapOf( + "send" to { rest -> send(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + "react" to { rest -> react(dataDir, rest) }, + "delete" to { rest -> delete(dataDir, rest) }, + ), + ) private suspend fun send( dataDir: DataDir, @@ -49,8 +50,7 @@ object MessageCommands { ): Int { if (rest.size < 2) return Output.error("bad_args", "message send ") val text = rest[1] - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() @@ -70,8 +70,6 @@ object MessageCommands { ), ) return 0 - } finally { - ctx.close() } } @@ -82,8 +80,7 @@ object MessageCommands { if (rest.isEmpty()) return Output.error("bad_args", "message list ") val args = Args(rest.drop(1).toTypedArray()) val limit = args.intFlag("limit", Int.MAX_VALUE) - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() @@ -110,8 +107,6 @@ object MessageCommands { Output.emit(mapOf("group_id" to gid, "messages" to items)) return 0 - } finally { - ctx.close() } } @@ -122,8 +117,7 @@ object MessageCommands { if (rest.size < 3) return Output.error("bad_args", "message react ") val targetId = rest[1] val emoji = rest[2] - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() @@ -146,8 +140,6 @@ object MessageCommands { ), ) return 0 - } finally { - ctx.close() } } @@ -156,8 +148,7 @@ object MessageCommands { rest: Array, ): Int { if (rest.size < 2) return Output.error("bad_args", "message delete [target_event_id ...]") - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() @@ -184,8 +175,6 @@ object MessageCommands { ), ) return 0 - } finally { - ctx.close() } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt index d80c6c32c3..5c0a182e1a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt @@ -53,14 +53,15 @@ object NappletCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, - ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "napplet …") - val rest = tail.drop(1).toTypedArray() - return when (tail[0]) { - "fetch" -> fetch(dataDir, rest) - else -> Output.error("bad_args", "napplet ${tail[0]}") - } - } + ): Int = + route( + "napplet", + tail, + "napplet …", + mapOf( + "fetch" to { rest -> fetch(dataDir, rest) }, + ), + ) private suspend fun fetch( dataDir: DataDir, @@ -80,8 +81,7 @@ object NappletCommands { val extraServers = StaticSiteFetch.commaList(args.flag("server")) val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val relays = extraRelays @@ -143,8 +143,6 @@ object NappletCommands { outFile = outFile, maxInlineBytes = maxInlineBytes, ) - } finally { - ctx.close() } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NotesCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NotesCommands.kt index f3ecad460d..8c0415ae17 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NotesCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NotesCommands.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.DataDir -import com.vitorpamplona.amethyst.cli.Output /** * `amy notes ` β€” NIP-10 kind:1 short text notes. Sits alongside @@ -32,13 +31,14 @@ object NotesCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, - ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "notes …") - val rest = tail.drop(1).toTypedArray() - return when (tail[0]) { - "post" -> PostCommand.run(dataDir, rest) - "feed" -> FeedCommand.run(dataDir, rest) - else -> Output.error("bad_args", "notes ${tail[0]}") - } - } + ): Int = + route( + "notes", + tail, + "notes …", + mapOf( + "post" to { rest -> PostCommand.run(dataDir, rest) }, + "feed" to { rest -> FeedCommand.run(dataDir, rest) }, + ), + ) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt index 81cdeb560d..5ab090f414 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt @@ -52,14 +52,15 @@ object NsiteCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, - ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "nsite …") - val rest = tail.drop(1).toTypedArray() - return when (tail[0]) { - "fetch" -> fetch(dataDir, rest) - else -> Output.error("bad_args", "nsite ${tail[0]}") - } - } + ): Int = + route( + "nsite", + tail, + "nsite …", + mapOf( + "fetch" to { rest -> fetch(dataDir, rest) }, + ), + ) private suspend fun fetch( dataDir: DataDir, @@ -75,8 +76,7 @@ object NsiteCommands { val extraServers = StaticSiteFetch.commaList(args.flag("server")) val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val authorHex = ctx.requireUserHex(author) @@ -113,8 +113,6 @@ object NsiteCommands { outFile = outFile, maxInlineBytes = maxInlineBytes, ) - } finally { - ctx.close() } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt index 388e9291f8..408f49704a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt @@ -57,17 +57,18 @@ object OfferCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, - ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "offer ") - val rest = tail.drop(1).toTypedArray() - return when (tail[0]) { - "info" -> info(rest) - "discover" -> discover(dataDir, rest) - "request" -> request(dataDir, rest) - "pay" -> pay(dataDir, rest) - else -> Output.error("bad_args", "offer ${tail[0]} (expected info|discover|request|pay)") - } - } + ): Int = + route( + "offer", + tail, + "offer ", + mapOf( + "info" to { rest -> info(rest) }, + "discover" to { rest -> discover(dataDir, rest) }, + "request" to { rest -> request(dataDir, rest) }, + "pay" to { rest -> pay(dataDir, rest) }, + ), + ) /** * Resolve a profile's advertised offer from its NIP-05 `.well-known/nostr.json` `clink_offer` @@ -83,8 +84,7 @@ object OfferCommands { Nip05Id.parse(args.positional(0, "nip05").trim()) ?: return Output.error("bad_args", "not a valid NIP-05 address (e.g. bob@example.com)") - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val noffer = ctx.nip05Client.loadClinkOffer(id) if (noffer == null) { @@ -105,8 +105,6 @@ object OfferCommands { ), ) return 0 - } finally { - ctx.close() } } @@ -152,8 +150,7 @@ object OfferCommands { ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer ?: return Output.error("bad_args", ERR_NOT_A_NOFFER) - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() var hops = 0 while (hops <= MAX_FOLLOW_HOPS) { @@ -200,8 +197,6 @@ object OfferCommands { ) } return Output.error("offer_error", "too many redirects following moved offers (>$MAX_FOLLOW_HOPS)") - } finally { - ctx.close() } } @@ -232,8 +227,7 @@ object OfferCommands { if (offerRelays.isEmpty()) return Output.error("bad_pointer", "noffer carries no relay to reach") if (debit.relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach") - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() // 1. fetch a fresh BOLT-11 from the offer service. @@ -287,8 +281,6 @@ object OfferCommands { ) } } - } finally { - ctx.close() } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt index dd27a82940..3a46c37bf4 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt @@ -50,8 +50,7 @@ object PostCommand { ?.map { it.trim() } ?.filter { it.isNotEmpty() } ?: emptyList() - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val outbox = ctx.outboxRelays() val extraNormalized = @@ -78,8 +77,6 @@ object PostCommand { ), ) return 0 - } finally { - ctx.close() } } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt index 170e792189..a496cc1184 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt @@ -45,15 +45,16 @@ object ProfileCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, - ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "profile …") - val rest = tail.drop(1).toTypedArray() - return when (tail[0]) { - "show" -> show(dataDir, rest) - "edit" -> edit(dataDir, rest) - else -> Output.error("bad_args", "profile ${tail[0]}") - } - } + ): Int = + route( + "profile", + tail, + "profile …", + mapOf( + "show" to { rest -> show(dataDir, rest) }, + "edit" to { rest -> edit(dataDir, rest) }, + ), + ) private suspend fun show( dataDir: DataDir, @@ -62,8 +63,7 @@ object ProfileCommands { val args = Args(rest) val refresh = args.bool("refresh") val timeoutSecs = args.longFlag("timeout", 8L) - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val pubKey = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } @@ -118,8 +118,6 @@ object ProfileCommands { ), ) return 0 - } finally { - ctx.close() } } @@ -161,8 +159,7 @@ object ProfileCommands { ) } - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val targets = ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() } val latest = @@ -226,8 +223,6 @@ object ProfileCommands { ), ) 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 index aade5d8298..416d6a6c63 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -51,17 +51,17 @@ object RelayCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, - ): Int { - if (tail.isEmpty()) return Output.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 -> Output.error("bad_args", "relay $sub") - } - } + ): Int = + route( + "relay", + tail, + "relay …", + mapOf( + "add" to { rest -> add(dataDir, Args(rest)) }, + "list" to { _ -> list(dataDir) }, + "publish-lists" to { _ -> publishLists(dataDir) }, + ), + ) private suspend fun add( dataDir: DataDir, @@ -74,8 +74,7 @@ object RelayCommands { ?: return Output.error("bad_args", "invalid relay url: $rawUrl") val targets = if (type == "all") listOf("nip65", "inbox", "key_package") else listOf(type) - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> val addedTo = mutableListOf() val alreadyPresent = mutableListOf() for (t in targets) { @@ -89,8 +88,6 @@ object RelayCommands { ), ) return 0 - } finally { - ctx.close() } } @@ -138,8 +135,7 @@ object RelayCommands { } private suspend fun list(dataDir: DataDir): Int { - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> val self = ctx.identity.pubKeyHex Output.emit( mapOf( @@ -149,14 +145,11 @@ object RelayCommands { ), ) return 0 - } finally { - ctx.close() } } private suspend fun publishLists(dataDir: DataDir): Int { - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val self = ctx.identity.pubKeyHex val nip65Event = ctx.relaysOf(self) @@ -189,8 +182,6 @@ object RelayCommands { ), ) return 0 - } finally { - ctx.close() } } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Router.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Router.kt new file mode 100644 index 0000000000..4fefe297d5 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Router.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.commands + +import com.vitorpamplona.amethyst.cli.Output + +/** + * Shared sub-verb router used by every `*Commands.dispatch`. + * + * Maps the first token of [tail] to a handler over the remaining args. + * Empty input emits `bad_args: `; an unrecognised verb emits + * `bad_args: `. Handlers receive the args *after* the verb, + * mirroring the old hand-rolled `when (tail[0]) { … }` blocks. + */ +suspend fun route( + name: String, + tail: Array, + usage: String, + routes: Map) -> Int>, +): Int { + if (tail.isEmpty()) return Output.error("bad_args", usage) + val handler = routes[tail[0]] ?: return Output.error("bad_args", "$name ${tail[0]}") + return handler(tail.drop(1).toTypedArray()) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt index 0c52732a96..4e0bedbcbd 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt @@ -52,15 +52,16 @@ object SearchCommand { suspend fun dispatch( dataDir: DataDir, tail: Array, - ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "search [--limit N] [--timeout SECS]") - val rest = tail.drop(1).toTypedArray() - return when (tail[0]) { - "user" -> searchUsers(dataDir, rest) - "note" -> searchNotes(dataDir, rest) - else -> Output.error("bad_args", "search ${tail[0]} β€” expected user|note") - } - } + ): Int = + route( + "search", + tail, + "search [--limit N] [--timeout SECS]", + mapOf( + "user" to { rest -> searchUsers(dataDir, rest) }, + "note" to { rest -> searchNotes(dataDir, rest) }, + ), + ) private suspend fun searchUsers( dataDir: DataDir, @@ -144,8 +145,7 @@ object SearchCommand { timeoutMs: Long, render: (List) -> List>, ): Int { - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val relays = SearchActions.resolveSearchRelays( @@ -172,8 +172,6 @@ object SearchCommand { ), ) return 0 - } finally { - ctx.close() } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt index c1ff1b89eb..bab4c94a55 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt @@ -54,18 +54,19 @@ object StoreCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, - ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "store ") - val rest = tail.drop(1).toTypedArray() - return when (tail[0]) { - "stat" -> stat(dataDir) - "sweep-expired" -> sweepExpired(dataDir) - "scrub" -> scrub(dataDir) - "compact" -> compact(dataDir) - "reindex-fts" -> reindexFts(dataDir) - else -> Output.error("bad_args", "store ${tail[0]}") - } - } + ): Int = + route( + "store", + tail, + "store ", + mapOf( + "stat" to { _ -> stat(dataDir) }, + "sweep-expired" to { _ -> sweepExpired(dataDir) }, + "scrub" to { _ -> scrub(dataDir) }, + "compact" to { _ -> compact(dataDir) }, + "reindex-fts" to { _ -> reindexFts(dataDir) }, + ), + ) private fun stat(dataDir: DataDir): Int { val storeRoot = dataDir.eventsDir.toPath() diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt index 6ef33e4557..18eb321bf2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt @@ -94,8 +94,7 @@ object ZapCommand { ?: return Output.error("bad_args", "--with must be a valid ndebit pointer with a relay") } - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val recipient = ctx.requireUserHex(userArg) val metadata = @@ -117,8 +116,6 @@ object ZapCommand { emitZapResult(ctx, sats, lnAddress, comment, request, zapType, timeoutMs, settleWith) return 0 - } finally { - ctx.close() } } @@ -145,8 +142,7 @@ object ZapCommand { ?: return Output.error("bad_args", "--with must be a valid ndebit pointer with a relay") } - val ctx = Context.open(dataDir) - try { + Context.open(dataDir).use { ctx -> ctx.prepare() val zappedEvent = ctx.store.query(Filter(ids = listOf(eventId), limit = 1)).firstOrNull() @@ -197,8 +193,6 @@ object ZapCommand { emitSplitZapResult(ctx, sats, comment, zappedEvent.id, zapType, requests, timeoutMs, settleWith) return 0 - } finally { - ctx.close() } }