diff --git a/cli/README.md b/cli/README.md index 29f233ff9c..e11e1b838c 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 0974d7d2b3..e267c5abe3 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 @@ -180,6 +181,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. @@ -370,6 +379,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() }