From 560b95c2ece19c622d88b6834e61141e1f77c697 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 21:32:41 +0000 Subject: [PATCH 1/5] fix: resolve Kotlin compiler warnings in quartz and quic - Replace unused Unit/null expressions in statement-position when branches with empty blocks (CommandSerializer, QuicConnection, QuicConnectionParser, Http3FrameReader, WtPeerStreamDemux). - Suppress DEPRECATION on KindNames.names, which intentionally registers the deprecated GitReplyEvent and TorrentCommentEvent kinds for display. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018nqdy4VTLKidUWzGTJPja9 --- .../com/vitorpamplona/quartz/kinds/KindNames.kt | 1 + .../relay/commands/toRelay/CommandSerializer.kt | 4 +--- .../quic/connection/QuicConnection.kt | 14 ++++---------- .../quic/connection/QuicConnectionParser.kt | 4 +--- .../vitorpamplona/quic/http3/Http3FrameReader.kt | 4 +--- .../quic/webtransport/WtPeerStreamDemux.kt | 4 +--- 6 files changed, 9 insertions(+), 22 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 774dd47b32..f12a7339d7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -326,6 +326,7 @@ data class KindName( * platform concern layered on top, never a fork of this data. */ object KindNames { + @Suppress("DEPRECATION") // registry intentionally names deprecated kinds (GitReply, TorrentComment) for display val names: Map = mapOf( AcceptedBadgeSetEvent.KIND to KindName("Accepted Badge Set", "58"), diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt index 97e57f0729..3f1a6438b9 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt @@ -83,9 +83,7 @@ class CommandSerializer : StdSerializer(Command::class.java) { gen.writeString(cmd.subId) } - else -> { - null - } + else -> {} } gen.writeEndArray() diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt index f57101547a..891869d11e 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -2523,9 +2523,7 @@ class QuicConnection( PathValidator.RecordResult.Stored, PathValidator.RecordResult.Duplicate, PathValidator.RecordResult.AlreadyRetired, - -> { - Unit - } + -> {} PathValidator.RecordResult.PoolFull -> { // Peer over-issued past its own advertised @@ -2573,11 +2571,9 @@ class QuicConnection( // same path before the next outbound packet (which would // otherwise stamp a now-retired CID). when (val rotation = pathValidator.forceRotateToHigherSequence()) { - null -> { - Unit - } - // active CID is still valid; nothing to do. + null -> {} + PathValidator.ForcedRotationResult.NoSpareCid -> { // Watermark forced retirement of the active CID but // the pool is empty — we have nothing valid to use. @@ -2613,9 +2609,7 @@ class QuicConnection( when (val outcome = pathValidator.applyPathResponse(payload)) { PathValidator.ValidationOutcome.NotValidating, PathValidator.ValidationOutcome.PayloadMismatch, - -> { - Unit - } + -> {} is PathValidator.ValidationOutcome.Validated -> { // Bug-7 fix: a valid PATH_RESPONSE proves the peer diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt index e424c274d5..6816ebbd5e 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt @@ -852,9 +852,7 @@ private fun dispatchFrames( // peer knows it just violated the spec instead of // having its bytes silently dropped. when (stream.receive.insert(frame.offset, frame.data, frame.fin)) { - com.vitorpamplona.quic.stream.ReceiveBuffer.InsertResult.OK -> { - Unit - } + com.vitorpamplona.quic.stream.ReceiveBuffer.InsertResult.OK -> {} com.vitorpamplona.quic.stream.ReceiveBuffer.InsertResult.OFFSET_PAST_FIN -> { conn.markClosedExternally( diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameReader.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameReader.kt index f00816f0d1..8f27a84503 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameReader.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameReader.kt @@ -165,9 +165,7 @@ class Http3FrameReader( ) } when (context) { - StreamContext.UNCHECKED -> { - Unit - } + StreamContext.UNCHECKED -> {} StreamContext.CONTROL -> { // §7.2.4: SETTINGS MUST be the first frame on the diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtPeerStreamDemux.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtPeerStreamDemux.kt index 345298a8fd..9538fd5f20 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtPeerStreamDemux.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtPeerStreamDemux.kt @@ -491,9 +491,7 @@ class WtPeerStreamDemux( } // no new requests; we don't enforce yet - else -> { - Unit - } + else -> {} } } } From 01d61b085156905ea4c7d13b5ebe8ebf549cf249 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 21:39:04 +0000 Subject: [PATCH 2/5] fix: resolve remaining Kotlin compiler warnings in commons and cli - Suppress DEPRECATION on REASONABLE_SIGN_KINDS, which intentionally lists the deprecated TorrentCommentEvent kind. - Replace deprecated readLine() with readlnOrNull() in SecureKeyStorage. - Drop unnecessary !! non-null assertions in KeyCommands and NostrConnect where the receiver is already smart-cast to non-null. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018nqdy4VTLKidUWzGTJPja9 --- .../com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt | 2 +- .../com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt | 2 +- .../commons/napplet/signers/NostrSignerPermissionLedger.kt | 1 + .../amethyst/commons/keystorage/SecureKeyStorage.kt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt index 22079d8634..549608c252 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt @@ -77,7 +77,7 @@ object KeyCommands { Output.emit(mapOf("valid" to false)) return 0 } - val npub = hex!!.hexToByteArray().toNpub() + val npub = hex.hexToByteArray().toNpub() Output.emit(mapOf("valid" to true, "pubkey" to hex, "npub" to npub)) return 0 } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt index 08ffe57337..a24ded7865 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt @@ -81,7 +81,7 @@ object NostrConnect { } } if (secret == null) return null - return Offer(clientPubkey, relays, secret!!, name) + return Offer(clientPubkey, relays, secret, name) } private fun buildOffer( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt index fd213ba1bd..621aaf3149 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt @@ -185,6 +185,7 @@ class NostrSignerPermissionLedger( * Deliberately conservative: when a kind's blast radius is unclear, it is left out so the user * is asked rather than surprised. */ + @Suppress("DEPRECATION") // TorrentCommentEvent is deprecated (NIP-22) but still a reasonable sign kind val REASONABLE_SIGN_KINDS: Set = setOf( TextNoteEvent.KIND, // 1 — short text notes & replies diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt index 00772efcec..72c1985045 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt @@ -324,7 +324,7 @@ actual class SecureKeyStorage private actual constructor() { } else { // Fallback for non-interactive environments (testing, etc.) print("Enter master password: ") - readLine() ?: throw SecureStorageException("Password required for fallback storage") + readlnOrNull() ?: throw SecureStorageException("Password required for fallback storage") } } return fallbackPassword!! From c8c4111e0c0ab91261a3fce892b158ac95382577 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:11:52 +0000 Subject: [PATCH 3/5] feat(cli): add `amy logoff` to clear an account's local data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `amy logoff [--yes] [--keep-events]`, the CLI counterpart to logging out: it removes everything an account left on the machine. - the identity file and any backend-held secret (keychain / ncryptsec / plaintext), via DataDir.deleteIdentity - the rest of the per-account directory ~/.amy// (run-state cursors, aliases, cashu counters, all Marmot/MLS state) - the ~/.amy/current pin, when it points at this account - the account's events in the SHARED ~/.amy/shared/events-store/ The event store is shared across accounts, so logoff does not wipe it wholesale — it deletes only the events that involve this account: those it authored plus those addressed to it via a #p tag (gift wraps, nutzaps, reactions, mentions). Other accounts' cached events are left untouched. `--keep-events` skips the shared-cache purge entirely. The public key is read straight from identity.json (never unlocking the private key), so logoff needs no passphrase and pops no keychain prompt. Destructive and irreversible, so it follows the `marmot reset` precedent: `--yes` is required to execute; without it the command prints a dry run of what would be deleted and exits 2. Thin-assembly only — event deletion is quartz's FsEventStore.delete; this just resolves the account, counts, and wires the filesystem teardown. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PH3rqz5KaA7CYFPAtxgoz1 --- cli/README.md | 1 + cli/ROADMAP.md | 1 + .../com/vitorpamplona/amethyst/cli/Main.kt | 5 + .../amethyst/cli/commands/LogoffCommand.kt | 167 ++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LogoffCommand.kt diff --git a/cli/README.md b/cli/README.md index 13da5dd96d..a198bcaaea 100644 --- a/cli/README.md +++ b/cli/README.md @@ -374,6 +374,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `amy login KEY [--password X]` | Import an existing identity (`nsec`/`ncryptsec`/mnemonic/`npub`/`nprofile`/hex/NIP-05). | | `amy whoami` | Print the active account's name + npub. | | `amy use NAME` / `--clear` / no-arg | Pin / clear / inspect the active account. | +| `amy logoff [--yes] [--keep-events]` | Log off an account: delete its key + backend secret, the whole `~/.amy//` directory (run-state, aliases, cashu counters, Marmot state), the `current` pin if it points here, and the account's events (authored + `#p`-addressed) in the shared store. `--keep-events` leaves the shared cache alone. Destructive and irreversible — requires `--yes`; without it, prints a dry run and exits 2. | ### Social diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 6e5a7cd979..b27e9fcdb5 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -43,6 +43,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · |---|---|---| | Identity create / import (`nsec`, `ncryptsec`, mnemonic, `npub`, `nprofile`, hex, NIP-05) | ✅ | `LoginCommand` + Quartz NIP-05 / NIP-06 / NIP-49 | | Account bootstrap (nine events) | ✅ | `commons/account/AccountBootstrapEvents.kt` | +| Account logoff (`amy logoff`) — delete key + per-account state + the account's events in the shared store | ✅ | `LogoffCommand`. `--yes`-gated; `--keep-events` skips the shared-cache purge. | | Relay config — every relay-list bucket (nip65 10002 via `outbox`/`inbox`/`nip65` nouns with spec read/write merge, dm 10050, key-package 10051, search 10007, private-outbox 10013, blocked 10006, trusted 10089, proxy 10087, indexer 10086, broadcast 10088, favorite 10012) — noun-first `relay add/remove/set/clear/list` + fan-out `relay add/remove` + publish | ✅ | `RelayCommands`. Mirrors the Android relay-settings screen. Local relays (device pref) + relay sets (30002) intentionally out of scope. | | MLS KeyPackage publish + fetch | ✅ | `commons/marmot/MarmotManager` | | Marmot group create / add / rename / promote / demote / remove / leave | ✅ | `commons/marmot/` | 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 fc85fa7494..446f26ee28 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.cli.commands.KeyCommands import com.vitorpamplona.amethyst.cli.commands.KeyPackageCommands import com.vitorpamplona.amethyst.cli.commands.KindCommand import com.vitorpamplona.amethyst.cli.commands.LoginCommand +import com.vitorpamplona.amethyst.cli.commands.LogoffCommand import com.vitorpamplona.amethyst.cli.commands.MarmotResetCommand import com.vitorpamplona.amethyst.cli.commands.MessageCommands import com.vitorpamplona.amethyst.cli.commands.NamecoinCommand @@ -203,6 +204,7 @@ private suspend fun dispatch(argv: Array): Int { "init" -> InitCommands.init(dataDir, Args(tail)) "create" -> CreateCommand.run(dataDir, tail) "login" -> LoginCommand.run(dataDir, tail) + "logoff" -> LogoffCommand.run(dataDir, tail) "whoami" -> InitCommands.whoami(dataDir) "relay" -> RelayCommands.dispatch(dataDir, tail) "marmot" -> marmotDispatch(dataDir, tail) @@ -380,6 +382,9 @@ private fun printUsage() { | create [--name NAME] provision a full Amethyst-style account + publish bootstrap events | login KEY [--password X] import (nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05|bunker://) | whoami print current identity + | logoff [--yes] [--keep-events] log off: delete this account's key, per-account state, + | and its events in the shared store (--keep-events skips the + | cache purge). Requires --yes; without it, prints a dry run. | |Remote signing (NIP-46): | bunker [--relay URL[,URL…]] run a remote signer for this (local-key) account; prints a diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LogoffCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LogoffCommand.kt new file mode 100644 index 0000000000..3312c8f61b --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LogoffCommand.kt @@ -0,0 +1,167 @@ +/* + * 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.Output +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore +import java.io.File + +/** + * `amy logoff [--yes] [--keep-events]` — log off an account and clear its + * local data. + * + * "Logging off" a CLI with no server session means removing everything the + * account left on this machine: + * - the identity file and any backend-held secret (keychain / ncryptsec / + * plaintext) — via [DataDir.deleteIdentity], + * - the rest of the per-account directory `~/.amy//` (run-state + * cursors, aliases, cashu counters, all MLS/Marmot state), + * - the active-account pin at `~/.amy/current`, if it points here, + * - and the account's events in the SHARED store at + * `~/.amy/shared/events-store/`. + * + * The event store is shared across every account on the machine, so this + * does NOT wipe it wholesale — it deletes only the events that involve this + * account: those it authored (`authors`) plus those addressed to it via a + * `#p` tag (inbound gift wraps, nutzaps, reactions, mentions…). Other + * accounts' cached events are untouched. Pass `--keep-events` to leave the + * shared cache alone and only remove the identity + per-account state. + * + * The account is selected the normal way (the `--account` flag, the + * `current` pin, or the sole account) — when more than one account exists + * and none is pinned, [DataDir.resolve] already errors out asking the caller + * to disambiguate, so logoff never guesses which account to destroy. + * + * Reads the public key straight from `identity.json` (never unlocking the + * private key), so it needs no passphrase and pops no keychain prompt. + * + * Requires `--yes` to execute, because it is destructive and cannot be + * undone — the private key is gone with the identity file. Without `--yes` + * the command reports what it would delete and exits with code 2. + */ +object LogoffCommand { + suspend fun run( + dataDir: DataDir, + tail: Array, + ): Int { + val confirmed = tail.any { it == "--yes" || it == "-y" } + val keepEvents = tail.any { it == "--keep-events" } + + // Read the on-disk identity metadata only — no SecretStore round-trip, + // so we never prompt for a passphrase or trip a keychain dialog just + // to log off. + val idFile = + dataDir.loadIdentityFileOrNull() + ?: return Output.error( + "no_account", + "no identity at ${dataDir.identityFile.absolutePath}; nothing to log off", + ) + val pubkey = idFile.pubKeyHex + + val marker = File(DataDir.DEFAULT_ROOT, DataDir.CURRENT_MARKER_NAME) + val isPinned = marker.isFile && marker.readText().trim() == dataDir.accountName + + // Everything the account touched in the shared store: authored by it, + // or addressed to it via a #p tag (gift wraps, nutzaps, reactions…). + val involvedFilters = + listOf( + Filter(authors = listOf(pubkey)), + Filter(tags = mapOf("p" to listOf(pubkey))), + ) + + if (!confirmed) { + val eventCount = if (keepEvents) 0 else withStore(dataDir) { it.count(involvedFilters) } + Output.emit( + mapOf( + "dry_run" to true, + "account" to dataDir.accountName, + "npub" to idFile.npub, + "pubkey" to pubkey, + "account_dir" to dataDir.root.absolutePath, + "pinned" to isPinned, + "events_to_purge" to eventCount, + "keep_events" to keepEvents, + "detail" to "pass --yes to permanently delete this account's key, local state" + + (if (keepEvents) "" else ", and cached events"), + ), + ) + return 2 + } + + // 1. Purge the account's events from the shared store. + var purged = 0 + if (!keepEvents) { + withStore(dataDir) { store -> + val before = store.count(involvedFilters) + store.delete(involvedFilters) + purged = (before - store.count(involvedFilters)).coerceAtLeast(0) + } + } + + // 2. Remove the identity file and any backend-held secret. + dataDir.deleteIdentity() + + // 3. Wipe the rest of the per-account directory (run-state, aliases, + // cashu counters, Marmot/MLS state). The shared events-store lives + // outside this directory, so it is not affected. + val dirFullyRemoved = dataDir.root.deleteRecursively() + + // 4. Drop the active-account pin if it pointed at this account. + val clearedPin = isPinned && marker.delete() + + Output.emit( + mapOf( + "logoff" to true, + "account" to dataDir.accountName, + "npub" to idFile.npub, + "events_purged" to purged, + "removed_dir" to dataDir.root.absolutePath, + "dir_fully_removed" to dirFullyRemoved, + "cleared_pin" to clearedPin, + ), + ) + return 0 + } + + /** + * Open the shared [FsEventStore] directly — logoff needs the store but no + * identity, signer, or relays, so it skips [com.vitorpamplona.amethyst.cli.Context.open] + * (which requires a bootstrapped identity). Mirrors `StoreCommands.withStore`. + */ + private inline fun withStore( + dataDir: DataDir, + body: (FsEventStore) -> T, + ): T { + val store = + FsEventStore( + root = dataDir.eventsDir.toPath(), + eventToJson = JacksonMapper::toJsonPretty, + ) + try { + return body(store) + } finally { + store.close() + } + } +} From 85d6f5a162020f15d0b51399f797f108f997021a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:32:06 +0000 Subject: [PATCH 4/5] feat(cli): add `amy status` overview command A cross-account, read-only snapshot of everything amy holds under `~/.amy/`, built for the returning user: which accounts exist, which one is pinned as current, each signer type (local keychain/ncryptsec/ plaintext, NIP-46 bunker, or read-only) and whether it can still sign, the per-account local footprint (aliases, Marmot groups, published KeyPackage bundle, Cashu wallet, sync cursors), and the shared event store's size. Like `use`, it dispatches before account resolution so it works with zero, one, or many accounts. Strictly metadata-only: it never unlocks a private key (no keychain prompt / NIP-49 passphrase) and never touches the network. Factors the on-disk event-store walk into a shared `StoreStats` helper reused by both `status` and `store stat`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01K3GJb11JkvopP61ETWAVyy --- cli/README.md | 1 + cli/ROADMAP.md | 1 + .../com/vitorpamplona/amethyst/cli/Main.kt | 12 ++ .../vitorpamplona/amethyst/cli/StoreStats.kt | 121 +++++++++++++ .../amethyst/cli/commands/StatusCommand.kt | 167 ++++++++++++++++++ .../amethyst/cli/commands/StoreCommands.kt | 90 +--------- 6 files changed, 310 insertions(+), 82 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreStats.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusCommand.kt diff --git a/cli/README.md b/cli/README.md index a198bcaaea..221b660d0f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -374,6 +374,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `amy login KEY [--password X]` | Import an existing identity (`nsec`/`ncryptsec`/mnemonic/`npub`/`nprofile`/hex/NIP-05). | | `amy whoami` | Print the active account's name + npub. | | `amy use NAME` / `--clear` / no-arg | Pin / clear / inspect the active account. | +| `amy status` | Read-only overview of everything under `~/.amy/`: every account, which one is current, each signer type (local keychain/ncryptsec/plaintext, NIP-46 bunker, or read-only) and whether it can sign, the local Marmot / Cashu / alias / sync-cursor footprint per account, and the shared event store's size. Built for the returning user. No keychain prompt, no network. | | `amy logoff [--yes] [--keep-events]` | Log off an account: delete its key + backend secret, the whole `~/.amy//` directory (run-state, aliases, cashu counters, Marmot state), the `current` pin if it points here, and the account's events (authored + `#p`-addressed) in the shared store. `--keep-events` leaves the shared cache alone. Destructive and irreversible — requires `--yes`; without it, prints a dry run and exits 2. | ### Social diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index b27e9fcdb5..24fd1ff4b7 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -44,6 +44,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | Identity create / import (`nsec`, `ncryptsec`, mnemonic, `npub`, `nprofile`, hex, NIP-05) | ✅ | `LoginCommand` + Quartz NIP-05 / NIP-06 / NIP-49 | | Account bootstrap (nine events) | ✅ | `commons/account/AccountBootstrapEvents.kt` | | Account logoff (`amy logoff`) — delete key + per-account state + the account's events in the shared store | ✅ | `LogoffCommand`. `--yes`-gated; `--keep-events` skips the shared-cache purge. | +| Status overview (`amy status`) — every account, current pin, signer type + can-sign, per-account Marmot/Cashu/alias/cursor footprint, shared event-store size | ✅ | `StatusCommand`. Cross-account, read-only, metadata-only (no keychain prompt, no network). Store stats via shared `StoreStats`. | | Relay config — every relay-list bucket (nip65 10002 via `outbox`/`inbox`/`nip65` nouns with spec read/write merge, dm 10050, key-package 10051, search 10007, private-outbox 10013, blocked 10006, trusted 10089, proxy 10087, indexer 10086, broadcast 10088, favorite 10012) — noun-first `relay add/remove/set/clear/list` + fan-out `relay add/remove` + publish | ✅ | `RelayCommands`. Mirrors the Android relay-settings screen. Local relays (device pref) + relay sets (30002) intentionally out of scope. | | MLS KeyPackage publish + fetch | ✅ | `commons/marmot/MarmotManager` | | Marmot group create / add / rename / promote / demote / remove / leave | ✅ | `commons/marmot/` | 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 446f26ee28..57ca289f19 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.cli.commands.PublishCommand import com.vitorpamplona.amethyst.cli.commands.RelayCommands import com.vitorpamplona.amethyst.cli.commands.SearchCommand import com.vitorpamplona.amethyst.cli.commands.ServeCommand +import com.vitorpamplona.amethyst.cli.commands.StatusCommand import com.vitorpamplona.amethyst.cli.commands.StoreCommands import com.vitorpamplona.amethyst.cli.commands.SubscribeCommand import com.vitorpamplona.amethyst.cli.commands.SyncCommand @@ -170,6 +171,14 @@ private suspend fun dispatch(argv: Array): Int { return UseCommand.run(tail) } + // `status` is a cross-account, read-only overview of everything on + // disk under ~/.amy/. Like `use`, it must work regardless of how many + // accounts exist (zero, one, or many), so it dispatches before account + // resolution rather than through the single-account DataDir path. + if (head == "status") { + return StatusCommand.run(tail) + } + // Stateless local primitives (nak-style army-knife verbs). They operate // purely on their arguments — no identity, no relays, no `~/.amy/` — so // they dispatch before account resolution and work with zero state. @@ -336,6 +345,9 @@ private fun printUsage() { | use NAME pin NAME as the active account | use --clear remove the pin | use print current pin + available accounts + | status read-only overview of every account, signer + | type, local Marmot/Cashu state, and the shared + | event store (no keychain prompt, no network) | |Output: | Default: human-readable text on stdout. diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreStats.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreStats.kt new file mode 100644 index 0000000000..234d66c167 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreStats.kt @@ -0,0 +1,121 @@ +/* + * 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 java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.TimeUnit +import kotlin.io.path.exists + +/** + * Read-only introspection of a file-backed Nostr event store on disk. + * + * Pure filesystem walk — no relay traffic, no writer lock, no [Context]. + * Shared by `amy store stat` (full detail) and `amy status` (a compact + * roll-up alongside the account overview). + */ +data class StoreStats( + val events: Long, + /** Per-kind event counts derived from `idx/kind//`, sorted by kind string. */ + val byKind: Map, + val diskBytes: Long, + /** Oldest / newest event file mtime, in unix seconds. Null on an empty store. */ + val oldestAt: Long?, + val newestAt: Long?, + val root: Path, +) { + val distinctKinds: Int get() = byKind.size + + companion object { + /** Compute stats for the store rooted at [storeRoot]. Missing dir → all-zero. */ + fun of(storeRoot: Path): StoreStats { + if (!storeRoot.exists()) { + return StoreStats(0, emptyMap(), 0L, null, null, storeRoot.toAbsolutePath()) + } + + val eventsRoot = storeRoot.resolve("events") + var count = 0L + var oldest: Long? = null + var newest: Long? = null + if (Files.isDirectory(eventsRoot)) { + Files.walk(eventsRoot).use { stream -> + for (p in stream) { + if (!Files.isRegularFile(p)) continue + if (!p.fileName.toString().endsWith(".json")) continue + count++ + val mt = + try { + Files.getLastModifiedTime(p).to(TimeUnit.SECONDS) + } catch (_: IOException) { + continue + } + val o = oldest + if (o == null || mt < o) oldest = mt + val n = newest + if (n == null || mt > n) newest = mt + } + } + } + + // Histogram from idx/kind// — for a healthy store this is + // exactly one entry per (kind, event), so summing matches `count`. + // Mismatch points at index drift; run `amy store scrub` to fix. + val kindRoot = storeRoot.resolve("idx/kind") + val byKind = sortedMapOf() + if (Files.isDirectory(kindRoot)) { + Files.list(kindRoot).use { stream -> + for (kindDir in stream) { + if (!Files.isDirectory(kindDir)) continue + val n = Files.list(kindDir).use { it.count() } + byKind[kindDir.fileName.toString()] = n + } + } + } + + return StoreStats( + events = count, + byKind = byKind, + diskBytes = walkSize(storeRoot), + oldestAt = oldest, + newestAt = newest, + root = storeRoot.toAbsolutePath(), + ) + } + + private fun walkSize(root: Path): Long { + if (!Files.exists(root)) return 0L + var total = 0L + Files.walk(root).use { stream -> + for (p in stream) { + if (!Files.isRegularFile(p)) continue + total += + try { + Files.size(p) + } catch (_: IOException) { + 0L + } + } + } + return total + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusCommand.kt new file mode 100644 index 0000000000..624bc1e9d1 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusCommand.kt @@ -0,0 +1,167 @@ +/* + * 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.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.RunState +import com.vitorpamplona.amethyst.cli.StoreStats +import com.vitorpamplona.amethyst.cli.secrets.IdentityFile +import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret +import java.io.File + +/** + * `amy status` — a single at-a-glance overview of everything amy is + * holding on disk under `~/.amy/`. Built for the returning user: "I + * haven't run this in months — what accounts do I have, which one is + * active, can they still sign, and how big is the local database?" + * + * Cross-account by design, so it dispatches *before* account resolution + * (like `use`) and never fails on "zero accounts" or "ambiguous account". + * It is strictly read-only and metadata-only: it parses the on-disk + * `identity.json` / `state.json` / `aliases.json` and walks the shared + * event store, but it never unlocks a private key (no keychain prompt, + * no NIP-49 passphrase) and never touches the network. + * + * Per account it reports the npub, how the key is stored (local keychain + * / ncryptsec / plaintext, a NIP-46 bunker, or read-only), whether it can + * sign, and the local footprint that account has accumulated: aliases, + * Marmot groups, a published KeyPackage bundle, a Cashu wallet, and the + * sync cursors that tell catch-up commands where they left off. + */ +object StatusCommand { + fun run(tail: Array): Int { + // `status` takes no positional args; tolerate an accidental one + // rather than erroring — it's a read-only inspection command. + val rootBase = DataDir.DEFAULT_ROOT + + val currentPin = + File(rootBase, DataDir.CURRENT_MARKER_NAME) + .takeIf { it.isFile } + ?.readText() + ?.trim() + ?.ifEmpty { null } + + val accountNames = DataDir.listAccounts(rootBase) + val accounts = accountNames.map { accountRow(File(rootBase, it), it, it == currentPin) } + + // The event store is shared across every account. + val store = StoreStats.of(File(rootBase, "shared/events-store").toPath()) + + Output.emit( + mapOf( + "root" to rootBase.absolutePath, + "current" to currentPin, + "account_count" to accounts.size, + "accounts" to accounts, + "store" to + mapOf( + "events" to store.events, + "distinct_kinds" to store.distinctKinds, + "disk_bytes" to store.diskBytes, + "oldest_at" to store.oldestAt, + "newest_at" to store.newestAt, + "root" to store.root.toString(), + ), + ), + ) + return 0 + } + + private fun accountRow( + accountRoot: File, + name: String, + isCurrent: Boolean, + ): Map { + val identity = readIdentity(File(accountRoot, "identity.json")) + val signer = classifySigner(identity) + + val marmotGroups = + File(accountRoot, "marmot/groups") + .listFiles { f -> f.name.endsWith(".state") } + ?.size ?: 0 + val hasKeyPackage = File(accountRoot, "marmot/keypackages.bundle").isFile + val hasCashuWallet = File(accountRoot, "cashu.json").isFile + val aliasCount = readAliases(File(accountRoot, "aliases.json")).size + val runState = readRunState(File(accountRoot, "state.json")) + + // LinkedHashMap so the text renderer prints fields in this order. + val row = LinkedHashMap() + row["name"] = name + row["current"] = isCurrent + row["npub"] = identity?.npub + row["hex"] = identity?.pubKeyHex + row["signer"] = signer.kind + row["key_storage"] = signer.storage + row["can_sign"] = signer.canSign + if (signer.bunkerRelays != null) row["bunker_relays"] = signer.bunkerRelays + row["aliases"] = aliasCount + row["marmot_groups"] = marmotGroups + row["key_package_published"] = hasKeyPackage + row["cashu_wallet"] = hasCashuWallet + row["dm_cursor_at"] = runState.giftWrapSince + row["marmot_group_cursors"] = runState.groupSince.size + return row + } + + /** + * How this account can sign, derived purely from the on-disk + * [IdentityFile] — never resolves the secret itself. + * - `local` — an on-device private key ([storage] says where). + * - `bunker` — a NIP-46 remote signer ([bunkerRelays] lists it). + * - `read-only` — imported from an npub/nprofile/NIP-05; cannot sign. + */ + private data class SignerInfo( + val kind: String, + val storage: String?, + val canSign: Boolean, + val bunkerRelays: List?, + ) + + private fun classifySigner(identity: IdentityFile?): SignerInfo { + if (identity == null) return SignerInfo("unknown", null, false, null) + identity.bunker?.let { bunker -> + return SignerInfo("bunker", secretStorageLabel(identity.secret), true, bunker.relays) + } + val storage = secretStorageLabel(identity.secret) + return when { + identity.secret != null -> SignerInfo("local", storage, true, null) + // Pre-secret-store data-dirs kept the key inline; still signable. + identity.privKeyHex != null || identity.nsec != null -> SignerInfo("local", "legacy-plaintext", true, null) + else -> SignerInfo("read-only", null, false, null) + } + } + + private fun secretStorageLabel(secret: IdentitySecret?): String? = + when (secret) { + is IdentitySecret.Keychain -> "keychain:${secret.backend}" + is IdentitySecret.Ncryptsec -> "ncryptsec" + is IdentitySecret.Plaintext -> "plaintext" + null -> null + } + + private fun readIdentity(file: File): IdentityFile? = if (file.isFile) runCatching { Output.mapper.readValue(file.readText()) }.getOrNull() else null + + private fun readAliases(file: File): Map = if (file.isFile) runCatching { Output.mapper.readValue>(file.readText()) }.getOrElse { emptyMap() } else emptyMap() + + private fun readRunState(file: File): RunState = if (file.isFile) runCatching { Output.mapper.readValue(file.readText()) }.getOrElse { RunState() } else RunState() +} 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 bab4c94a55..2ec53cf68c 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 @@ -22,14 +22,12 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.StoreStats import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore -import java.io.IOException import java.nio.file.Files import java.nio.file.Path -import java.util.concurrent.TimeUnit -import kotlin.io.path.exists /** * `amy store ` — direct introspection @@ -69,70 +67,15 @@ object StoreCommands { ) private fun stat(dataDir: DataDir): Int { - val storeRoot = dataDir.eventsDir.toPath() - if (!storeRoot.exists()) { - Output.emit( - mapOf( - "events" to 0, - "by_kind" to emptyMap(), - "disk_bytes" to 0L, - "oldest_at" to null, - "newest_at" to null, - "root" to storeRoot.toAbsolutePath().toString(), - ), - ) - return 0 - } - - val eventsRoot = storeRoot.resolve("events") - var count = 0L - var oldest: Long? = null - var newest: Long? = null - if (Files.isDirectory(eventsRoot)) { - Files.walk(eventsRoot).use { stream -> - for (p in stream) { - if (!Files.isRegularFile(p)) continue - if (!p.fileName.toString().endsWith(".json")) continue - count++ - val mt = - try { - Files.getLastModifiedTime(p).to(TimeUnit.SECONDS) - } catch (_: IOException) { - continue - } - val o = oldest - if (o == null || mt < o) oldest = mt - val n = newest - if (n == null || mt > n) newest = mt - } - } - } - - // Histogram from idx/kind// — for a healthy store this is - // exactly one entry per (kind, event), so summing matches `count`. - // Mismatch points at index drift; run `amy store scrub` to fix. - val kindRoot = storeRoot.resolve("idx/kind") - val byKind = sortedMapOf() - if (Files.isDirectory(kindRoot)) { - Files.list(kindRoot).use { stream -> - for (kindDir in stream) { - if (!Files.isDirectory(kindDir)) continue - val n = Files.list(kindDir).use { it.count() } - byKind[kindDir.fileName.toString()] = n - } - } - } - - val diskBytes = walkSize(storeRoot) - + val stats = StoreStats.of(dataDir.eventsDir.toPath()) Output.emit( mapOf( - "events" to count, - "by_kind" to byKind, - "disk_bytes" to diskBytes, - "oldest_at" to oldest, - "newest_at" to newest, - "root" to storeRoot.toAbsolutePath().toString(), + "events" to stats.events, + "by_kind" to stats.byKind, + "disk_bytes" to stats.diskBytes, + "oldest_at" to stats.oldestAt, + "newest_at" to stats.newestAt, + "root" to stats.root.toString(), ), ) return 0 @@ -220,23 +163,6 @@ object StoreCommands { } } - private fun walkSize(root: Path): Long { - if (!Files.exists(root)) return 0L - var total = 0L - Files.walk(root).use { stream -> - for (p in stream) { - if (!Files.isRegularFile(p)) continue - total += - try { - Files.size(p) - } catch (_: IOException) { - 0L - } - } - } - return total - } - private fun countEntries(dir: Path): Long { if (!Files.isDirectory(dir)) return 0L return Files.list(dir).use { it.count() } From 439b85a8e131dde679e00b04b1d6ad0dcc4e7d95 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:58:22 +0000 Subject: [PATCH 5/5] feat(cli): let read-only verbs run without an account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit amy gated every non-primitive verb behind a chosen account: `DataDir.resolve` threw when `~/.amy/` had no unambiguous account, and every networked command called `Context.open`, which requires an identity — even though queries only read relays and the shared store and never sign. `store` maintenance and the local `offer`/`debit info` decoders were caught by the same gate despite touching no account state. Reads now work anonymously; only signing needs an account: - `DataDir.resolveOptional` hands back an accountless dir (`hasAccount = false`) pointing only at the shared event store when there is no unambiguous account, instead of throwing. - `Context.openOrAnonymous` uses the resolved account when present, else an ephemeral key-less `Identity.anonymous()` — can read, can't sign. Marmot stores are now lazy and run-state isn't persisted for anonymous runs, so an accountless read leaves `~/.amy/shared/` clean. - `Context.open` (signing path) re-asserts the requirement with the "which account?" hint, so ambiguous/no-account signing verbs still exit 2. - Main resolves optionally for every verb except the identity-lifecycle ones (`init`/`create`/`login`/`logoff`/`whoami`), which still need a concrete account. `offer info` / `debit info` join the stateless primitive block. - Read subverbs (fetch, subscribe, count, publish, outbox, search, sync, store, profile/git/podcast/podcast20 reads, nsite/napplet fetch·serve·list, blossom download·check) switch to `openOrAnonymous`. No `--json` shapes change. Docs updated (help text, README, DEVELOPMENT). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TRaoqGod5LUeSwq4GHRSNF --- cli/DEVELOPMENT.md | 9 ++ cli/README.md | 22 +++- .../com/vitorpamplona/amethyst/cli/Config.kt | 100 ++++++++++++++++-- .../com/vitorpamplona/amethyst/cli/Context.kt | 58 ++++++++-- .../com/vitorpamplona/amethyst/cli/Main.kt | 38 ++++++- .../amethyst/cli/commands/BlossomCommands.kt | 6 +- .../amethyst/cli/commands/CountCommand.kt | 2 +- .../amethyst/cli/commands/DebitCommands.kt | 2 +- .../amethyst/cli/commands/FeedCommand.kt | 5 +- .../amethyst/cli/commands/FetchCommand.kt | 4 +- .../amethyst/cli/commands/GitCommands.kt | 6 +- .../amethyst/cli/commands/NappletCommands.kt | 6 +- .../amethyst/cli/commands/NsiteCommands.kt | 6 +- .../amethyst/cli/commands/OfferCommands.kt | 2 +- .../amethyst/cli/commands/OutboxCommand.kt | 2 +- .../cli/commands/Podcast20Commands.kt | 4 +- .../amethyst/cli/commands/PodcastCommands.kt | 4 +- .../amethyst/cli/commands/ProfileCommands.kt | 4 +- .../amethyst/cli/commands/PublishCommand.kt | 2 +- .../amethyst/cli/commands/SearchCommand.kt | 2 +- .../amethyst/cli/commands/SubscribeCommand.kt | 2 +- .../amethyst/cli/commands/SyncCommand.kt | 2 +- 22 files changed, 243 insertions(+), 45 deletions(-) diff --git a/cli/DEVELOPMENT.md b/cli/DEVELOPMENT.md index 6b9eb42376..5f9203cd89 100644 --- a/cli/DEVELOPMENT.md +++ b/cli/DEVELOPMENT.md @@ -38,6 +38,15 @@ What every caller — user, script, agent, CI — can rely on: copy to move. Tests isolate by overriding `$HOME` for the amy subprocess (`HOME=/tmp/run.123 amy --account alice …`) — same convention `git`, `gpg`, and `npm` use. +- **An account is only required to _sign_.** Read-only verbs (relay + queries, the shared `store`, `offer`/`debit info`, and the stateless + primitives) run against an empty `~/.amy/` — `DataDir.resolveOptional` + hands them an accountless dir (its `hasAccount = false`) pointing only at + the shared event store, and `Context.openOrAnonymous` gives them an + ephemeral key-less identity (they read fine, they just can't + authenticate). Signing verbs go through `Context.open`, which re-asserts + the account requirement — `init`/`create`/`login`/`logoff`/`whoami` + resolve strictly, since they operate on the account dir itself. Only the `--json` shape and the exit codes are public API. The default text format is allowed to change between releases. The five design diff --git a/cli/README.md b/cli/README.md index a198bcaaea..29f233ff9c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -554,7 +554,18 @@ matches that: 1. If `~/.amy/current` is set, use it. 2. Else if exactly one account exists, use it (silent auto-pick). -3. Else error and list the candidates so you can disambiguate. +3. Else — for a **read-only** verb, run **anonymously**; for a **signing** + verb, error and list the candidates so you can disambiguate. + +**No account? Reads still work.** Verbs that only query relays or the shared +event store — `fetch`, `subscribe`, `count`, `publish` (broadcasts a +pre-signed event), `outbox`, `search`, `sync`, `store …`, the read halves of +`profile`/`notes`/`git`/`podcast`/`podcast20`, `nsite`/`napplet` fetch/serve/ +list, `blossom download`/`check`, `offer`/`debit info`, and every stateless +primitive — run against an empty `~/.amy/` with a throwaway key. They read +fine; they just can't authenticate. Only verbs that **sign or encrypt with +your key** (post, edit, follow, dm, marmot, zap, relay-list edits, blossom +upload/list/delete, cashu, …) require an account — and say so. `amy use NAME` writes `~/.amy/current`; `amy use --clear` removes it. For one-off override, prepend `--account NAME` to any command. @@ -620,11 +631,12 @@ Inside the amy process there's no test mode — it just sees a fresh ## Troubleshooting -- **`no account at ~/.amy`** — you haven't created one yet. Run +- **`no account configured` / `multiple accounts in ~/.amy (alice, bob)`** — + only **signing** verbs raise these; reads run anonymously instead (see + "No account? Reads still work" above). Create one with `amy --account NAME init` (bare keypair) or `amy --account NAME create` - (full Amethyst-style bootstrap). -- **`multiple accounts in ~/.amy (alice, bob)`** — pin one with - `amy use NAME` or pass `--account NAME` per command. + (full Amethyst-style bootstrap), or pin/select one with `amy use NAME` / + `--account NAME`. - **`current pins 'X' but ~/.amy/X doesn't exist`** — the active-account marker is stale. Rewrite with `amy use OTHER` or `amy use --clear`. - **`no_dm_relays`** — recipient hasn't published a kind:10050 inbox. diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index dd7c0fa984..8a8779b8f7 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -143,6 +143,16 @@ data class Identity( npub = pubHex.hexToByteArray().toNpub(), ) + /** + * Ephemeral, key-less identity for anonymous read-only runs (no + * account on disk). It mints a throwaway public key so the + * relay-list fallbacks (`outboxRelays()` etc.) resolve to the + * built-in defaults, and it carries no private key, so any attempt + * to sign/encrypt fails loudly — "you can read, you just can't + * auth". Used by [com.vitorpamplona.amethyst.cli.Context.openOrAnonymous]. + */ + fun anonymous(): Identity = fromPublicKeyHex(KeyPair().pubKey.toHexKey()) + /** * Rebuild an in-memory identity after a load. Accepts the public * parts that live on disk and a private key resolved from the @@ -204,6 +214,17 @@ class DataDir( val eventsDir: File, val accountName: String, val secrets: SecretStore, + /** + * Whether this points at a concrete account. `false` for the + * accountless directory [resolveOptional] hands back when `~/.amy/` + * has no unambiguous account — [root] then points at the shared + * sibling and only [eventsDir] (the cross-account event store) is + * meaningful. Read-only verbs run anonymously against it; signing + * verbs get [noAccountDetail] via `Context.open`. + */ + val hasAccount: Boolean = true, + /** Human-readable reason there is no account, for the signing-verb error. */ + val noAccountDetail: String? = null, ) { val identityFile = File(root, "identity.json") val stateFile = File(root, "state.json") @@ -215,12 +236,16 @@ class DataDir( init { SecureFileIO.secureMkdirs(root) - SecureFileIO.secureMkdirs(groupsDir) - // Tighten perms on any data already on disk from an older, unhardened CLI. - SecureFileIO.tighten(identityFile) - SecureFileIO.tighten(stateFile) - SecureFileIO.tighten(marmotDir) - SecureFileIO.tighten(keyPackageBundleFile) + // The accountless dir only ever exposes the shared event store; don't + // seed per-account marmot dirs / tighten identity files under it. + if (hasAccount) { + SecureFileIO.secureMkdirs(groupsDir) + // Tighten perms on any data already on disk from an older, unhardened CLI. + SecureFileIO.tighten(identityFile) + SecureFileIO.tighten(stateFile) + SecureFileIO.tighten(marmotDir) + SecureFileIO.tighten(keyPackageBundleFile) + } } /** @@ -375,6 +400,69 @@ class DataDir( ) } + /** + * Like [resolve], but never throws when there is no account: read-only + * verbs can run without one. When `--account` is given it is honoured; + * otherwise the pin / sole-account are used if unambiguous. Failing + * that, returns an *accountless* [DataDir] (`hasAccount = false`) whose + * [root] is the shared sibling and whose [eventsDir] is still the + * cross-account event store — enough for anonymous relay queries and + * `store` maintenance. The reason no account was chosen is carried in + * [DataDir.noAccountDetail] so a signing verb can surface it. + */ + fun resolveOptional( + accountFlag: String?, + secrets: SecretStore, + ): DataDir { + val rootBase = DEFAULT_ROOT + val sharedEvents = File(rootBase, "$SHARED_DIR_NAME/events-store").absoluteFile + if (accountFlag != null) { + val name = validateName(accountFlag) + return DataDir(File(rootBase, name).absoluteFile, sharedEvents, name, secrets) + } + val picked = pickAccountOptional(rootBase) + return if (picked.name != null) { + DataDir(File(rootBase, picked.name).absoluteFile, sharedEvents, picked.name, secrets) + } else { + DataDir( + root = File(rootBase, SHARED_DIR_NAME).absoluteFile, + eventsDir = sharedEvents, + accountName = SHARED_DIR_NAME, + secrets = secrets, + hasAccount = false, + noAccountDetail = picked.detail, + ) + } + } + + /** Result of [pickAccountOptional]: an account [name], or null plus a [detail] reason. */ + private data class OptionalPick( + val name: String?, + val detail: String?, + ) + + /** Non-throwing sibling of [pickAccount]: null [name] with a [detail] when 0 / ambiguous. */ + private fun pickAccountOptional(rootBase: File): OptionalPick { + val current = File(rootBase, CURRENT_MARKER_NAME) + if (current.isFile) { + val pinned = current.readText().trim() + if (pinned.isNotEmpty() && File(rootBase, pinned).isDirectory) { + return OptionalPick(pinned, null) + } + } + val accounts = listAccounts(rootBase) + return when (accounts.size) { + 0 -> OptionalPick(null, "no account configured (create one with `amy --account init`)") + 1 -> OptionalPick(accounts.single(), null) + else -> + OptionalPick( + null, + "multiple accounts in ${rootBase.absolutePath} (${accounts.joinToString(", ")}); " + + "pick one with --account or `amy use `", + ) + } + } + /** * Auto-select an account when `--name` was not given. Honours * `/current` first (explicit pin from `amy use`), then 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 dd0bac444e..88238e643b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -115,6 +115,14 @@ class Context( val dataDir: DataDir, val identity: Identity, val state: RunState, + /** + * Anonymous read-only run: no account on disk, [identity] is an ephemeral + * key-less identity (see [Identity.anonymous]). Marmot state is not + * restored and run-state is not persisted — the run only reads relays and + * the shared event store. Signing verbs never take this path; they go + * through [Companion.open], which requires a real account. + */ + val anonymous: Boolean = false, ) : AutoCloseable { private val okhttp = OkHttpClient.Builder().socketFactory(TcpNoDelaySocketFactory).build() @@ -158,9 +166,12 @@ class Context( .OkHttpNip05Fetcher { _ -> okhttp }, ) - private val mlsStore = FileMlsGroupStateStore(dataDir.groupsDir) - private val keyPackageStore = FileKeyPackageBundleStore(dataDir.keyPackageBundleFile) - private val messageStore = FileMarmotMessageStore(dataDir.groupsDir) + // Lazy so an anonymous read (no account dir) never materialises the + // per-account marmot stores — constructing them would `mkdir` group dirs + // under the shared root. Real accounts build them on first marmot use. + private val mlsStore by lazy { FileMlsGroupStateStore(dataDir.groupsDir) } + private val keyPackageStore by lazy { FileKeyPackageBundleStore(dataDir.keyPackageBundleFile) } + private val messageStore by lazy { FileMarmotMessageStore(dataDir.groupsDir) } /** * Filesystem-backed Nostr event store, rooted at [DataDir.eventsDir]. @@ -183,7 +194,7 @@ class Context( 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) + val marmot: MarmotManager by lazy { MarmotManager(signer, mlsStore, messageStore, keyPackageStore) } // ------------------------------------------------------------------ // Cashu (NIP-60 / NIP-61) — shared wallet code from commons @@ -302,7 +313,9 @@ class Context( */ suspend fun prepare() { if (prepared) return - marmot.restoreAll() + // Anonymous runs have no account and therefore no marmot state to + // restore (and touching `marmot` would allocate the per-account stores). + if (!anonymous) marmot.restoreAll() client.connect() // A bunker account must open its NIP-46 response subscription and run // the connect handshake before any signing/encryption call. @@ -852,7 +865,8 @@ class Context( } override fun close() { - dataDir.saveRunState(state) + // Nothing to persist for an anonymous run (no account dir to write into). + if (!anonymous) dataDir.saveRunState(state) (signer as? NostrSignerRemote)?.let { try { it.closeSubscription() @@ -881,12 +895,21 @@ class Context( */ private const val GIFT_WRAP_LOOKBACK_SECS: Long = 2L * 24 * 60 * 60 - /** Build a Context but require an identity to already exist — most commands can't run without one. */ + /** + * Build a Context but require an account with a usable identity — + * signing verbs can't run without one. Throws [IllegalArgumentException] + * (→ exit 2) when no account was resolvable, carrying the "which + * account?" hint from [DataDir.resolveOptional]; throws + * [IllegalStateException] when the account exists but has no identity. + */ fun open(dataDir: DataDir): Context { + require(dataDir.hasAccount) { + dataDir.noAccountDetail ?: "no account selected; pass --account or run `amy use `" + } val identity = dataDir.loadIdentityOrNull() ?: run { - System.err.println("No identity found at ${dataDir.identityFile}. Run `amethyst-cli init` first.") + System.err.println("No identity found at ${dataDir.identityFile}. Run `amy --account ${dataDir.accountName} init` first.") throw IllegalStateException("no identity") } return Context( @@ -895,5 +918,24 @@ class Context( state = dataDir.loadRunState(), ) } + + /** + * Context for read-only verbs: use the resolved account when one is + * present, otherwise run anonymously (ephemeral key-less identity, no + * persisted state). Lets `fetch`/`subscribe`/`count`/`publish`/`outbox`/ + * … query relays and the shared store with no account on disk — they + * read fine, they just can't sign. + */ + fun openOrAnonymous(dataDir: DataDir): Context = + if (dataDir.hasAccount && dataDir.identityExists()) { + open(dataDir) + } else { + Context( + dataDir = dataDir, + identity = Identity.anonymous(), + state = RunState(), + anonymous = true, + ) + } } } 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 446f26ee28..0974d7d2b3 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -129,6 +129,16 @@ class AwaitTimeout( message: String, ) : RuntimeException(message) +/** + * Verbs that create, select, or delete the account/identity on disk. They + * write to (or read) the per-account directory directly rather than through + * `Context.open`, so they need a concrete account and must resolve strictly — + * an accountless run has nowhere to put a new identity. Every other verb + * resolves via [DataDir.resolveOptional] and either runs anonymously (reads) + * or re-asserts the requirement inside `Context.open` (signing). + */ +private val STRICT_ACCOUNT_VERBS = setOf("init", "create", "login", "logoff", "whoami") + private suspend fun dispatch(argv: Array): Int { if (argv.isEmpty() || argv[0] == "--help" || argv[0] == "-h") { printUsage() @@ -197,8 +207,27 @@ private suspend fun dispatch(argv: Array): Int { return CashuMintCommands.dispatch(tail.drop(1).toTypedArray()) } + // `offer info NOFFER` / `debit info NDEBIT` decode a CLINK pointer locally — + // no network, no account. The rest of `offer`/`debit` operates on the account. + if (head == "offer" && tail.firstOrNull() == "info") { + return OfferCommands.info(tail.drop(1).toTypedArray()) + } + if (head == "debit" && tail.firstOrNull() == "info") { + return DebitCommands.info(tail.drop(1).toTypedArray()) + } + val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag) - val dataDir = DataDir.resolve(accountFlag = accountFlag, secrets = secrets) + // Identity-lifecycle verbs create / select / delete the account itself, so + // they need a concrete account and resolve strictly (helpful ambiguity + // errors). Everything else resolves optionally: read-only verbs then run + // anonymously when there is no account, while signing verbs re-assert the + // requirement through `Context.open`. + val dataDir = + if (head in STRICT_ACCOUNT_VERBS) { + DataDir.resolve(accountFlag = accountFlag, secrets = secrets) + } else { + DataDir.resolveOptional(accountFlag = accountFlag, secrets = secrets) + } return when (head) { "init" -> InitCommands.init(dataDir, Args(tail)) @@ -328,7 +357,12 @@ private fun printUsage() { | 1. --account X if given. | 2. ~/.amy/current marker (set by `amy use X`). | 3. Sole subdirectory of ~/.amy/ other than shared/. - | 4. Error — disambiguate with --account or `amy use`. + | 4. Read-only verbs (fetch, subscribe, count, publish, outbox, + | search, sync, store, profile/git/podcast reads, nsite/napplet + | fetch, decode/encode/… primitives, offer/debit info) run + | ANONYMOUSLY — they query relays and the shared store with no + | account, they just can't sign. Signing verbs error here: + | disambiguate with --account or `amy use`. | | Test harnesses isolate by overriding ${'$'}HOME for the amy | subprocess (`HOME=/tmp/run.123 amy --account alice ...`). diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt index f7ed5a8288..56d1847c30 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt @@ -85,7 +85,8 @@ object BlossomCommands { .map { it.trim() } .filter { it.isNotEmpty() } - Context.open(dataDir).use { _ -> + // Read-only HEAD probe — no auth, so it runs anonymously without an account. + Context.openOrAnonymous(dataDir).use { _ -> val http = OkHttpClient() val results = hashes.map { hash -> @@ -188,7 +189,8 @@ object BlossomCommands { val server = args.flag("server") val url = if (server != null && !target.startsWith("http")) BlossomServerUrl.blob(server, target) else target - Context.open(dataDir).use { ctx -> + // Public download — no auth, so it runs anonymously without an account. + Context.openOrAnonymous(dataDir).use { ctx -> val bytes = BlossomClient().download(url) ?: return Output.error("not_found", "server returned no blob for $url") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt index 3d2c4e004e..f912f83253 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt @@ -47,7 +47,7 @@ object CountCommand { val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 15L) * 1000 val filter = RawEventSupport.buildFilter(args) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val relays = RawEventSupport.queryTargets(ctx, args) if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`") 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 f89d79c8b0..c6b668d42c 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 @@ -60,7 +60,7 @@ object DebitCommands { ) /** Local decode of an `ndebit` pointer — no network, no account needed. */ - private fun info(rest: Array): Int { + internal fun info(rest: Array): Int { val args = Args(rest) val debit = ClinkPointerParser.parse(args.positional(0, "ndebit").trim()) as? NDebit 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 25bce1e8b8..cf207405f0 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,7 +61,10 @@ object FeedCommand { val until = args.flag("until")?.toLongOrNull() val timeoutSecs = args.longFlag("timeout", 8L) - Context.open(dataDir).use { ctx -> + // Read-only: runs anonymously when there is no account. `--author` / + // `--following` still work; the bare "self" feed just has no self to + // resolve without an account. + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val (authors, mode) = diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt index 9126779780..9118f8f925 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt @@ -94,7 +94,7 @@ object FetchCommand { val filter = RawEventSupport.buildFilter(args).copy(limit = effectiveLimit) val paginate = args.bool("paginate") || args.bool("all") - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val relays = RawEventSupport.queryTargets(ctx, args) if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`") @@ -148,7 +148,7 @@ object FetchCommand { timeoutMs: Long, ): Int { val code = codeArg.removePrefix("nostr:") - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() var filter: Filter diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt index 9b14eeac03..6c4d0c8054 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt @@ -113,7 +113,9 @@ object GitCommands { rest: Array, ): Int { val args = Args(rest) - Context.open(dataDir).use { ctx -> + // Read-only: runs anonymously when there is no account (defaults to + // the anonymous key, so pass a USER to list someone's repos). + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex val relays = RawEventSupport.queryTargets(ctx, args) @@ -143,7 +145,7 @@ object GitCommands { return Output.error("bad_args", "not a git repository address (expected kind ${GitRepositoryEvent.KIND}, got ${addr.kind})") } - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord") Output.emit(repoSummary(repo) + mapOf("event_id" to repo.id, "content" to repo.content)) 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 457d1915c8..97d49b5b4e 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 @@ -79,7 +79,7 @@ object NappletCommands { val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) val timeoutSecs = args.longFlag("timeout", 8L) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val authorHex = ctx.requireUserHex(author) val relays = @@ -134,7 +134,7 @@ object NappletCommands { val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) val timeoutSecs = args.longFlag("timeout", 8L) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val authorHex = ctx.requireUserHex(author) val relays = @@ -193,7 +193,7 @@ object NappletCommands { val extraServers = StaticSiteFetch.commaList(args.flag("server")) val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val relays = extraRelays 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 e00b767220..4780dec65e 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 @@ -79,7 +79,7 @@ object NsiteCommands { val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) val timeoutSecs = args.longFlag("timeout", 8L) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val authorHex = ctx.requireUserHex(author) val relays = @@ -145,7 +145,7 @@ object NsiteCommands { val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) val timeoutSecs = args.longFlag("timeout", 8L) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val authorHex = ctx.requireUserHex(author) val relays = @@ -203,7 +203,7 @@ object NsiteCommands { val extraServers = StaticSiteFetch.commaList(args.flag("server")) val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val authorHex = ctx.requireUserHex(author) 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 408f49704a..3fdcfc362d 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 @@ -109,7 +109,7 @@ object OfferCommands { } /** Local decode of a `noffer` pointer — no network, no account needed. */ - private fun info(rest: Array): Int { + internal fun info(rest: Array): Int { val args = Args(rest) val offer = ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt index 8545803da8..9d3459d08e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt @@ -44,7 +44,7 @@ object OutboxCommand { val refresh = args.bool("refresh") val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 8L) * 1000 - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val pubkey = ctx.requireUserHex(user) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt index 42fdfc1dc9..4e320e43b6 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt @@ -219,7 +219,9 @@ object Podcast20Commands { ): Int { val args = Args(rest) val limit = args.intFlag("limit", 50) - Context.open(dataDir).use { ctx -> + // Read-only: runs anonymously when there is no account (pass a USER to + // list someone else's episodes). + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex val relays = RawEventSupport.queryTargets(ctx, args) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt index 098b83194d..9c91826ec1 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt @@ -136,7 +136,9 @@ object PodcastCommands { ): Int { val args = Args(rest) val limit = args.intFlag("limit", 50) - Context.open(dataDir).use { ctx -> + // Read-only: runs anonymously when there is no account (pass a USER to + // list someone else's podcasts). + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex val relays = RawEventSupport.queryTargets(ctx, args) 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 a496cc1184..d70ae89b25 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 @@ -63,7 +63,9 @@ object ProfileCommands { val args = Args(rest) val refresh = args.bool("refresh") val timeoutSecs = args.longFlag("timeout", 8L) - Context.open(dataDir).use { ctx -> + // Read-only: runs anonymously when there is no account (an explicit + // USER is then required, since there is no "own" profile to default to). + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val pubKey = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt index 37da518132..c47b725a27 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt @@ -55,7 +55,7 @@ object PublishCommand { return Output.error("invalid_event", "event id/signature does not verify — refusing to publish") } - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val targets = RawEventSupport.publishTargets(ctx, args) if (targets.isEmpty()) { 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 4e0bedbcbd..1dae1c9b52 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 @@ -145,7 +145,7 @@ object SearchCommand { timeoutMs: Long, render: (List) -> List>, ): Int { - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val relays = SearchActions.resolveSearchRelays( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt index a00a9ce86a..0d58a3ee6f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt @@ -53,7 +53,7 @@ object SubscribeCommand { val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } val filter = RawEventSupport.buildFilter(args) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val relays = RawEventSupport.queryTargets(ctx, args) if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt index 9f878186d0..545b35cb50 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt @@ -95,7 +95,7 @@ object SyncCommand { val down = args.bool("down") || !up val filter = RawEventSupport.buildFilter(args) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val localEvents = ctx.store.query(filter) val localById = localEvents.associateBy { it.id }