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 6048f6882b..fa900daf0b 100644 --- a/cli/README.md +++ b/cli/README.md @@ -374,6 +374,8 @@ 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 @@ -588,7 +590,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. @@ -654,11 +667,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/ROADMAP.md b/cli/ROADMAP.md index 8fc7609b0b..a6fdb62a10 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -43,6 +43,8 @@ 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/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index d2cc32a51d..48f19276c4 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") @@ -231,12 +252,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) + } } /** @@ -391,6 +416,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 e75e8459df..f4bb055ff1 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -120,6 +120,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 @@ -225,9 +233,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) } /** * Shared Nostr event store for this run, opened via [StoreFactory] @@ -240,7 +251,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 @@ -359,7 +370,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. @@ -949,7 +962,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() @@ -978,12 +992,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( @@ -992,5 +1015,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 b1efba3e7f..3a3db047f1 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -45,6 +45,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 @@ -61,6 +62,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 @@ -137,6 +139,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() @@ -179,6 +191,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. @@ -206,13 +226,33 @@ 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)) "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) @@ -341,7 +381,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 ...`). @@ -349,6 +394,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. @@ -395,6 +443,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/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/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/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/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() + } + } +} 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/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/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/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/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 } 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!! 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 -> {} } } }