From 1da2c32df3bf1de8c456d002a377ea252f982bc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:25:08 +0000 Subject: [PATCH 1/3] feat(cli): redesign `amy status` around who is signed in and what they saved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old output was a generic key/value dump: 12 fields per account, most of them `no` or `0`, plus a `store` block that was wrong on the default backend — it always walked the FS store path (`shared/events-store`), so a SQLite install (the default since AMY_STORE landed) reported `events: 0` no matter how full the database was. `status` now answers two questions and drops everything else: alice (current) Alice Jones · alice@example.com npub1hje47kz5qeneyqrxc9nzgmz06ml6l9lguqv0qtsz4rkwqkmf636qvg4sz3 local key, in the login keychain saved: 128 events (newest 2h ago) · 3 contacts · 2 Marmot groups WHO: the profile name/NIP-05 amy holds locally (read from the account's own kind:0 in the store — new), the npub, and one plain-English sentence for the signer instead of three fields (`signer` + `key_storage` + `can_sign`). Plaintext key storage is called out in yellow. WHAT'S SAVED: the account's own events in the store and when the newest one landed (new), contacts, Marmot groups, key package, Concord communities (new — never reported before), Cashu wallet, DM cursor. The rule that keeps it short is "absent is silent": anything an account doesn't have is omitted rather than printed as `no`/`0`, so a fresh account is four lines and says `saved: nothing yet`. Two accuracy fixes fall out of that: the self-alias `init` writes is no longer counted as a saved contact, and the Cashu wallet is detected from a real kind:17375 in the store rather than from `cashu.json`, which only ever held NUT-13 counters. A directory whose `identity.json` won't parse now says so instead of suggesting `init`, which would mint a new key over it. Event-store size, backend and kind histogram move out entirely — that is `amy store stat`, which had its own (correct, backend-aware) version all along. Mechanics: - `Output.emit(result) { color -> … }`, an internal overload for a command with a purpose-built human rendering. JSON mode is untouched. - `StoreFactory.openExistingShared(root)` opens the cross-account store only if it already exists, so this read-only command never leaves an empty database behind — covered by a test. - `StoreCommands.fsStat` now calls `StoreStats.of`, which it had duplicated line for line; `status` was `StoreStats`' only caller and no longer needs it. Same output, ~50 fewer lines. - Split into StatusCommand (dispatch) / StatusReport (data + JSON contract) / StatusText (rendering) to stay under the module's file-size convention. JSON contract change (per DEVELOPMENT.md principle 5): `store` and `account_count` are gone; `hex` is now `pubkey` per the documented convention; per-account footprint fields move under `saved`; adds `profile_name`, `nip05`, `saved.events`, `saved.newest_event_at`, `saved.concord_communities`. No in-tree consumer read the old shape. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AtKhnNBSr9CWnZyjTWu7dL --- cli/README.md | 2 +- cli/ROADMAP.md | 2 +- .../com/vitorpamplona/amethyst/cli/Config.kt | 15 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 10 +- .../com/vitorpamplona/amethyst/cli/Output.kt | 30 +- .../amethyst/cli/StoreFactory.kt | 25 ++ .../vitorpamplona/amethyst/cli/StoreStats.kt | 6 +- .../amethyst/cli/commands/StatusCommand.kt | 155 +++------- .../amethyst/cli/commands/StatusReport.kt | 268 ++++++++++++++++++ .../amethyst/cli/commands/StatusText.kt | 222 +++++++++++++++ .../amethyst/cli/commands/StoreCommands.kt | 96 +------ .../vitorpamplona/amethyst/cli/CliResult.kt | 31 +- .../amethyst/cli/StatusCommandTest.kt | 154 ++++++++++ 13 files changed, 803 insertions(+), 213 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusReport.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusText.kt create mode 100644 cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/StatusCommandTest.kt diff --git a/cli/README.md b/cli/README.md index 7266b7df65..72713a575b 100644 --- a/cli/README.md +++ b/cli/README.md @@ -472,7 +472,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 status` | Who is signed in and what each account has saved. Per account: the profile name/NIP-05 amy holds locally, the npub, how it signs (local key — keychain/passphrase/plaintext — NIP-46 bunker, or read-only), and its local footprint: own events in the store, contacts, Marmot groups, a published key package, Concord communities, a Cashu wallet, DM sync cursor. Whatever an account doesn't have is left out, so a fresh one is four lines. Built for the returning user: no keychain prompt, no network. (Event-store size and backend: `amy store stat`.) | | `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 d0fab242bd..46705f1811 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -44,7 +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`. | +| Status overview (`amy status`) — who is signed in (profile name, npub, signer + can-sign) and what each account has saved (own events, contacts, Marmot groups, key package, Concord communities, Cashu wallet, DM cursor) | ✅ | `StatusCommand` + `StatusText`. Cross-account and read-only: no keychain prompt, no network, and it never creates the event store it reads. Store size/backend is `amy store stat`. | | 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 3bafbc5f1d..ba408f8158 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -418,7 +418,7 @@ class DataDir( val rootBase = DEFAULT_ROOT val name = if (accountFlag != null) validateName(accountFlag) else pickAccount(rootBase) val accountRoot = File(rootBase, name).absoluteFile - val sharedEvents = File(rootBase, "$SHARED_DIR_NAME/events-store").absoluteFile + val sharedEvents = sharedEventsDir(rootBase) return DataDir( root = accountRoot, eventsDir = sharedEvents, @@ -442,7 +442,7 @@ class DataDir( secrets: SecretStore, ): DataDir { val rootBase = DEFAULT_ROOT - val sharedEvents = File(rootBase, "$SHARED_DIR_NAME/events-store").absoluteFile + val sharedEvents = sharedEventsDir(rootBase) if (accountFlag != null) { val name = validateName(accountFlag) return DataDir(File(rootBase, name).absoluteFile, sharedEvents, name, secrets) @@ -531,6 +531,17 @@ class DataDir( } } + /** + * The cross-account event store under [rootBase], as the two backends + * lay it out (see [com.vitorpamplona.amethyst.cli.StoreFactory]). These + * mirror the per-instance [eventsDir] / [eventsDbFile] but take the + * root directly, so a cross-account command like `status` can reach the + * shared store without resolving — or creating — an account directory. + */ + fun sharedEventsDir(rootBase: File): File = File(rootBase, "$SHARED_DIR_NAME/events-store").absoluteFile + + fun sharedEventsDbFile(rootBase: File): File = File(rootBase, "$SHARED_DIR_NAME/events.db").absoluteFile + /** Subdirectories of `/` that look like accounts (excludes `shared/`). */ fun listAccounts(rootBase: File): List = rootBase 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 0ddc3f5762..1375852f28 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -221,8 +221,8 @@ 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 + // `status` is the cross-account, read-only "who am I and what have I + // got" overview. 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") { @@ -462,9 +462,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) + | status who is signed in and what each account has + | saved locally (no keychain prompt, no network; + | store size lives in `store stat`) | |Output: | Default: human-readable text on stdout. diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt index d01fa94dfb..5d5ed3695c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt @@ -60,6 +60,29 @@ object Output { } } + /** + * Emit a result that has earned a purpose-built human rendering. + * [result] is still the `--json` shape (the public contract); [text] is + * asked for the terminal form only in TEXT mode, and is handed the + * already-resolved [Ansi] so it paints under the same TTY/`NO_COLOR` + * rules as everything else. + * + * Reach for this only when the generic key/value render genuinely buries + * the answer — an overview that has to group and prioritise, rather than + * list. `amy status` is the reference case. Everything else should stay + * on the plain [emit], so the default text shape keeps being derived from + * one place. + */ + internal fun emit( + result: Any?, + text: (Ansi) -> String, + ) { + when (mode) { + Mode.JSON -> println(mapper.writeValueAsString(result)) + Mode.TEXT -> println(text(Ansi.forStream(isStderr = false))) + } + } + /** * Report a failure and return the exit code the process should end with. * The code string picks the exit code — `bad_args` → 2, `timeout` → 124, @@ -341,7 +364,12 @@ object Output { return "$iso ${color.dim("($rel)")}" } - private fun relativeTime(secondsAgo: Long): String { + /** + * `2h ago` / `3d ago` / `5m from now` for a delta in seconds. Internal + * so a command with its own text renderer (see the [emit] overload) + * words elapsed time exactly the way the generic renderer does. + */ + internal fun relativeTime(secondsAgo: Long): String { val s = if (secondsAgo < 0) -secondsAgo else secondsAgo val suffix = if (secondsAgo < 0) "from now" else "ago" val unit = diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreFactory.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreFactory.kt index 8c75bbf25f..c34d5f14df 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreFactory.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreFactory.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import java.io.File import kotlin.io.path.Path /** On-disk backend for the shared event store. */ @@ -86,4 +87,28 @@ object StoreFactory { eventToJson = JacksonMapper::toJsonPretty, ) } + + /** + * Open the shared, cross-account store under [rootBase] **only if it is + * already there** — returns null otherwise instead of creating it. The + * account-scoped [open] happily materialises an empty database, which is + * right for a command that is about to write; a read-only inspector like + * `amy status` must not leave a store behind on a machine that has none. + * It also takes the root rather than a [DataDir] so it never has to pick + * an account. Caller owns [IEventStore.close]. + */ + fun openExistingShared(rootBase: File): IEventStore? = + when (backend()) { + StoreBackend.SQLITE -> + DataDir + .sharedEventsDbFile(rootBase) + .takeIf { it.isFile } + ?.let { EventStore(dbName = it.absolutePath, relay = null) } + + StoreBackend.FS -> + DataDir + .sharedEventsDir(rootBase) + .takeIf { it.isDirectory } + ?.let { FsEventStore(root = Path(it.absolutePath), eventToJson = JacksonMapper::toJsonPretty) } + } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreStats.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreStats.kt index 234d66c167..51db57045c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreStats.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreStats.kt @@ -30,8 +30,10 @@ 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). + * Backs `amy store stat` on the FS backend. Filesystem-only by nature: the + * per-kind histogram reads `idx/kind/` and the age range reads file mtimes, + * neither of which the SQLite backend has, so `store stat` answers that one + * from the database instead. */ data class StoreStats( val events: Long, 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 index 8efe54e25a..3508e641a4 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusCommand.kt @@ -20,43 +20,45 @@ */ 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 com.vitorpamplona.amethyst.cli.StoreFactory 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?" + * `amy status` — who is signed in, and what each of them has saved. + * + * Two questions, in that order. **Who**: every account under `~/.amy/`, + * which one commands run as, the name/NIP-05 on its profile, and how (or + * whether) it can sign. **What's saved**: the local footprint that account + * has accumulated — its own events in the shared store, address-book + * aliases, Marmot groups, a published KeyPackage, Concord communities, a + * Cashu wallet, and how far the DM catch-up cursor has run. Anything an + * account does *not* have is left out rather than printed as a zero, so a + * fresh account reads as one short block instead of a wall of `no`. + * + * Deliberately out of scope: event-store internals (backend, disk bytes, + * kind histogram) — that is `amy store stat`, and duplicating a partial, + * FS-only view of it here is what made the old output so noisy. * * 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. + * Strictly read-only: [StatusReport] parses the on-disk JSON and reads — + * never creates — the shared event store. 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. + * [StatusReport] holds the data model and the `--json` contract; + * [StatusText] holds the terminal rendering. */ object StatusCommand { val USAGE: String = """ - |amy status — read-only overview of every account, signer type, local - |Marmot/Cashu state, and the shared event store (no keychain prompt, - |no network). Takes no arguments. + |amy status — who is signed in and what each account has saved + |locally. Read-only: no keychain prompt, no network. Takes no + |arguments. For event-store size and backend, see `amy store stat`. """.trimMargin() - fun run(tail: Array): Int { + suspend fun run(tail: Array): Int { if (tail.firstOrNull() == "--help" || tail.firstOrNull() == "-h") { System.err.println(USAGE) return 0 @@ -72,107 +74,26 @@ object StatusCommand { ?.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()) + // One store handle for every account — it's shared. Null when the + // machine has no store yet; a store we can't open (locked, corrupt) + // degrades to the same thing, so the on-disk half still prints. + val store = runCatching { StoreFactory.openExistingShared(rootBase) }.getOrNull() + val accounts = + try { + DataDir.listAccounts(rootBase).map { name -> + StatusReport.of(File(rootBase, name), name, name == currentPin, store) + } + } finally { + runCatching { store?.close() } + } 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(), - ), + "accounts" to accounts.map { it.toJson() }, ), - ) + ) { color -> StatusText.render(rootBase, accounts, color) } 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/StatusReport.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusReport.kt new file mode 100644 index 0000000000..07030fadf1 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusReport.kt @@ -0,0 +1,268 @@ +/* + * 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.Output +import com.vitorpamplona.amethyst.cli.RunState +import com.vitorpamplona.amethyst.cli.secrets.IdentityFile +import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret +import com.vitorpamplona.amethyst.cli.stores.ConcordStore +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent +import java.io.File + +/** + * What [StatusCommand] gathers about one account, and how it reads it off + * disk. Kept apart from the command so the command stays what every other + * one in `commands/` is: parse, call, emit. + * + * Everything here is read-only and prompt-free by construction — it parses + * the account's own JSON files and asks the (already-open) shared event + * store about the account's pubkey. It never resolves a private key, so it + * cannot pop a keychain dialog or ask for a NIP-49 passphrase. + */ +internal object StatusReport { + /** One account's answer to "who is this" and "what have they got". */ + data class AccountReport( + val name: String, + val isCurrent: Boolean, + val npub: String?, + val pubkey: String?, + val profileName: String?, + val nip05: String?, + val signer: SignerInfo, + val saved: Saved, + ) { + /** The `--json` projection — this shape is the public contract. */ + fun toJson(): Map { + // LinkedHashMap so `--json` key order matches the text block. + val row = LinkedHashMap() + row["name"] = name + row["current"] = isCurrent + row["npub"] = npub + row["pubkey"] = pubkey + row["profile_name"] = profileName + row["nip05"] = nip05 + row["signer"] = signer.kind + row["key_storage"] = signer.storage + row["can_sign"] = signer.canSign + if (signer.bunkerRelays != null) row["bunker_relays"] = signer.bunkerRelays + row["saved"] = saved.toJson() + return row + } + } + + /** + * What this account has accumulated on this machine. [events] and + * [cashuWallet] come from the shared event store; everything else from + * the account's own directory. + */ + data class Saved( + val events: Int, + val newestEventAt: Long?, + val contacts: Int, + val marmotGroups: Int, + val keyPackage: Boolean, + val concordCommunities: Int, + val cashuWallet: Boolean, + val dmCursorAt: Long?, + val marmotGroupCursors: Int, + ) { + /** True when this account has nothing but its key — the fresh-`init` state. */ + val isEmpty: Boolean + get() = + events == 0 && contacts == 0 && marmotGroups == 0 && !keyPackage && + concordCommunities == 0 && !cashuWallet && dmCursorAt == null + + fun toJson(): Map = + linkedMapOf( + "events" to events, + "newest_event_at" to newestEventAt, + "contacts" to contacts, + "marmot_groups" to marmotGroups, + "key_package" to keyPackage, + "concord_communities" to concordCommunities, + "cashu_wallet" to cashuWallet, + "dm_cursor_at" to dmCursorAt, + "marmot_group_cursors" to marmotGroupCursors, + ) + } + + /** + * 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. + * - `none` — no `identity.json` at all; the directory is a shell. + * - `unreadable` — the file is there but won't parse. Kept distinct from + * `none` because the fixes are opposite: `none` wants `init`, and + * running `init` over a file amy merely failed to READ would mint a + * new key and strand the old one. + */ + data class SignerInfo( + val kind: String, + val storage: String?, + val canSign: Boolean, + val bunkerRelays: List?, + ) + + /** + * Read one account rooted at [accountRoot]. [store] is the shared event + * store, already open, or null when this machine has none — in which + * case the store-backed fields simply come back empty. + */ + suspend fun of( + accountRoot: File, + name: String, + isCurrent: Boolean, + store: IEventStore?, + ): AccountReport { + val identityFile = File(accountRoot, "identity.json") + val identity = readIdentity(identityFile) + val pubkey = identity?.pubKeyHex + val runState = readRunState(File(accountRoot, "state.json")) + + // Everything the store can answer about this pubkey, in one hop. + val own = if (pubkey != null && store != null) ownEvents(store, pubkey) else OwnEvents() + + return AccountReport( + name = name, + isCurrent = isCurrent, + npub = identity?.npub, + pubkey = pubkey, + profileName = own.profile?.bestName()?.takeIf { it.isNotBlank() }, + nip05 = own.profile?.nip05?.takeIf { it.isNotBlank() }, + signer = classifySigner(identity, identityFile.isFile), + saved = + Saved( + events = own.count, + newestEventAt = own.newestAt, + contacts = contactCount(File(accountRoot, "aliases.json"), identity?.npub), + marmotGroups = + File(accountRoot, "marmot/groups") + .listFiles { f -> f.name.endsWith(".state") } + ?.size ?: 0, + keyPackage = File(accountRoot, "marmot/keypackages.bundle").isFile, + concordCommunities = ConcordStore(File(accountRoot, "concord.json")).load().size, + cashuWallet = own.hasWallet, + dmCursorAt = runState.giftWrapSince, + marmotGroupCursors = runState.groupSince.size, + ), + ) + } + + /** What the shared store holds for one pubkey. All-empty when it holds nothing. */ + private data class OwnEvents( + val count: Int = 0, + val newestAt: Long? = null, + val profile: UserMetadata? = null, + val hasWallet: Boolean = false, + ) + + /** + * The store-backed facts about an account: how many of its events we + * hold, when the newest one is from, and the replaceables that say who + * it is (kind 0) and whether it has a wallet (kind 17375). A store that + * errors — locked by a concurrent writer, mid-migration — degrades the + * whole group to empty rather than failing the command; the on-disk half + * of the report is still worth printing. + */ + private suspend fun ownEvents( + store: IEventStore, + pubkey: String, + ): OwnEvents = + runCatching { + val authors = listOf(pubkey) + var newestAt: Long? = null + var profile: MetadataEvent? = null + var wallet = false + + // Newest-first with limit 1 gives the last-activity stamp. + store.query(Filter(authors = authors, limit = 1)) { newestAt = it.createdAt } + store.query(Filter(authors = authors, kinds = listOf(MetadataEvent.KIND, CashuWalletEvent.KIND))) { event -> + when (event) { + is MetadataEvent -> { + // The store supersedes replaceables, so this is + // normally a single row — keep the newest anyway. + val known = profile + if (known == null || event.createdAt > known.createdAt) profile = event + } + + is CashuWalletEvent -> wallet = true + else -> Unit + } + } + + OwnEvents( + count = store.count(Filter(authors = authors)), + newestAt = newestAt, + profile = profile?.contactMetaData(), + hasWallet = wallet, + ) + }.getOrElse { OwnEvents() } + + private fun classifySigner( + identity: IdentityFile?, + fileExists: Boolean, + ): SignerInfo { + if (identity == null) return SignerInfo(if (fileExists) "unreadable" else "none", 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 + } + + /** + * Address-book entries, minus the self-alias `init` writes so the user + * can name their own account. Counting that one made every brand-new + * account claim to have saved a contact. + */ + private fun contactCount( + file: File, + ownNpub: String?, + ): Int = readAliases(file).count { (_, npub) -> npub != ownNpub } + + 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/StatusText.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusText.kt new file mode 100644 index 0000000000..38b46d9920 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusText.kt @@ -0,0 +1,222 @@ +/* + * 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.Ansi +import com.vitorpamplona.amethyst.cli.Output +import java.io.File + +/** + * The terminal rendering of [StatusCommand] — the one command whose answer + * the generic key/value renderer buries. + * + * One block per account, four lines at most: + * + * ``` + * alice (current) + * Alice Jones · alice@example.com + * npub1hje47kz5qeneyqrxc9nzgmz06ml6l9lguqv0qtsz4rkwqkmf636qvg4sz3 + * local key, in the login keychain + * saved: 128 events (newest 2h ago) · 3 contacts · 2 Marmot groups + * ``` + * + * The rule that keeps it short: **absent is silent**. A profile the store + * hasn't seen, a footprint an account doesn't have — those lines simply + * don't print, instead of printing as `(none)` / `no` / `0`. Only the npub + * and the signer line are unconditional, because those two ARE the status. + * + * The `--json` shape is the public contract and lives in [StatusReport]; + * this file is free to change with taste. + */ +internal object StatusText { + private const val DOT = " · " + + /** + * Wrap column for the `saved:` list. Fixed rather than probed: JDK 21 + * offers no terminal width, and 80 is the floor every terminal honours. + */ + private const val WIDTH = 78 + + fun render( + rootBase: File, + accounts: List, + color: Ansi, + ): String { + if (accounts.isEmpty()) { + return buildString { + append(color.dim("No accounts under ${rootBase.absolutePath}")) + append("\n\n") + append("Create one with `amy --account init`") + } + } + + val out = StringBuilder() + for (account in accounts) { + if (out.isNotEmpty()) out.append('\n') + appendAccount(out, account, color) + } + out.append('\n').append(footer(rootBase, accounts, color)) + return out.toString() + } + + private fun appendAccount( + out: StringBuilder, + account: StatusReport.AccountReport, + color: Ansi, + ) { + out.append(color.bold(account.name)) + if (account.isCurrent) out.append(' ').append(color.green("(current)")) + out.append('\n') + + // Line 1 — the human, when the local store knows one. A profile amy + // has never fetched simply doesn't get a line. + val who = listOfNotNull(account.profileName, account.nip05) + if (who.isNotEmpty()) out.append(" ").append(who.joinToString(DOT)).append('\n') + + // Line 2 — the identifier, whenever there is one to print. + account.npub?.let { out.append(" ").append(it).append('\n') } + + // Line 3 — can this account act, and with what key. For a directory + // with no usable identity.json this line IS the whole story, which is + // why the npub above and the footprint below both drop out. + out.append(" ").append(signerLine(account.name, account.signer, color)).append('\n') + if (account.npub == null) return + + // Line 4 — the footprint, or one honest word when there isn't one. + // A busy account can list six things, so it wraps under its label. + val label = " " + color.dim("saved:") + " " + if (account.saved.isEmpty) { + out.append(label).append(color.dim("nothing yet")).append('\n') + } else { + appendWrapped(out, label, indent = " ", parts = savedParts(account.saved)) + } + } + + /** + * Join [parts] with [DOT] across as many lines as it takes to stay inside + * [WIDTH], continuing under [indent] (which must be as wide as the visible + * [label]). The separator stays at the end of a line, so a wrapped list + * still reads as one run-on item rather than a new heading. + */ + private fun appendWrapped( + out: StringBuilder, + label: String, + indent: String, + parts: List, + ) { + out.append(label) + var used = indent.length + parts.forEachIndexed { i, part -> + val isLast = i == parts.lastIndex + val piece = if (isLast) part else part + DOT.trimEnd() + if (i > 0) { + if (used + 1 + piece.length > WIDTH) { + out.append('\n').append(indent) + used = indent.length + } else { + out.append(' ') + used += 1 + } + } + out.append(piece) + used += piece.length + } + out.append('\n') + } + + /** + * Plain-English "how do I sign", because `signer: local` + + * `key_storage: keychain:login` + `can_sign: yes` on three lines was + * three facts where users only ever wanted one sentence. + */ + private fun signerLine( + name: String, + signer: StatusReport.SignerInfo, + color: Ansi, + ): String = + when (signer.kind) { + "local" -> + when (val storage = signer.storage) { + "ncryptsec" -> "local key, passphrase-encrypted" + // Worth a warning colour: the key is readable by anything + // running as this user. + "plaintext" -> "local key, " + color.yellow("stored unencrypted") + "legacy-plaintext" -> "local key, " + color.yellow("stored unencrypted") + " (pre-secret-store format)" + null -> "local key" + else -> "local key, in the ${storage.removePrefix("keychain:")} keychain" + } + + "bunker" -> { + val via = signer.bunkerRelays?.firstOrNull() + val more = (signer.bunkerRelays?.size ?: 0) - 1 + val suffix = + when { + via == null -> "" + more > 0 -> " via $via (+$more more)" + else -> " via $via" + } + "remote signer (NIP-46)$suffix" + } + + "read-only" -> color.dim("read-only — public key only, cannot sign") + "unreadable" -> color.red("identity.json is unreadable — check the file before re-running `init`") + // No identity.json at all: the directory exists but `init` never ran. + else -> color.red("no identity — run `amy --account $name init`") + } + + /** + * The footprint, most-interesting first, absent items omitted. Reads as + * a sentence fragment: `128 events (newest 2h ago) · 3 contacts · …`. + */ + private fun savedParts(saved: StatusReport.Saved): List { + val parts = mutableListOf() + if (saved.events > 0) { + val age = saved.newestEventAt?.let { " (newest ${ago(it)})" } ?: "" + parts += "${plural(saved.events, "event")}$age" + } + if (saved.contacts > 0) parts += plural(saved.contacts, "contact") + if (saved.marmotGroups > 0) parts += plural(saved.marmotGroups, "Marmot group") + if (saved.keyPackage) parts += "a published key package" + if (saved.concordCommunities > 0) parts += plural(saved.concordCommunities, "Concord community", "Concord communities") + if (saved.cashuWallet) parts += "a Cashu wallet" + if (saved.dmCursorAt != null) parts += "DMs synced ${ago(saved.dmCursorAt)}" + return parts + } + + private fun footer( + rootBase: File, + accounts: List, + color: Ansi, + ): String { + val head = "${plural(accounts.size, "account")} under ${rootBase.absolutePath}" + // Only nag about switching when there is somewhere to switch to. + val hint = if (accounts.size > 1) DOT + "switch with `amy use `" else "" + return color.dim(head + hint) + } + + private fun ago(epochSeconds: Long): String = Output.relativeTime(System.currentTimeMillis() / 1000 - epochSeconds) + + private fun plural( + n: Int, + singular: String, + plural: String = singular + "s", + ): String = if (n == 1) "$n $singular" else "$n $plural" +} 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 cd6a0c8949..56a7b7367a 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 @@ -24,16 +24,14 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.cli.StoreBackend import com.vitorpamplona.amethyst.cli.StoreFactory +import com.vitorpamplona.amethyst.cli.StoreStats import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import java.io.File -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 @@ -134,71 +132,22 @@ object StoreCommands { return 0 } + /** + * FS `stat`: the full picture — event count, per-kind histogram, disk + * bytes and the mtime range — all of which are filesystem-store concepts + * read straight off the tree by [StoreStats]. A missing store walks to + * all-zero rather than erroring. + */ private fun fsStat(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 @@ -313,23 +262,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() } diff --git a/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/CliResult.kt b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/CliResult.kt index 7991e5372a..630175217a 100644 --- a/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/CliResult.kt +++ b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/CliResult.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.cli import java.io.ByteArrayOutputStream +import java.io.File import java.io.PrintStream import java.nio.file.Files @@ -38,13 +39,39 @@ data class CliResult( val stdoutLines: List get() = stdout.trim().lines().filter { it.isNotBlank() } } +/** + * The `~/.amy` parent [withSharedAmyHome] pins, or null when each [amy] call + * should mint (and delete) its own. Not thread-safe by design — the JVM suite + * runs these serially, and so does the `amy.home` property they both drive. + */ +private var sharedHome: File? = null + +/** + * Run [body] with every [amy] call inside it sharing ONE isolated `~/.amy`, + * so state written by one invocation is visible to the next. Needed by the + * commands whose whole job is to report accumulated on-disk state — `status` + * has nothing to say about accounts a previous, discarded home created. + */ +fun withSharedAmyHome(body: (File) -> T): T { + val previous = sharedHome + val home = Files.createTempDirectory("amy-test-shared").toFile() + sharedHome = home + return try { + body(home) + } finally { + sharedHome = previous + home.deleteRecursively() + } +} + fun amy(vararg argv: String): CliResult { val outBuf = ByteArrayOutputStream() val errBuf = ByteArrayOutputStream() val prevOut = System.out val prevErr = System.err val prevHome = System.getProperty("amy.home") - val tempHome = Files.createTempDirectory("amy-test").toFile() + val shared = sharedHome + val tempHome = shared ?: Files.createTempDirectory("amy-test").toFile() System.setProperty("amy.home", tempHome.absolutePath) Output.mode = Output.Mode.TEXT return try { @@ -57,6 +84,6 @@ fun amy(vararg argv: String): CliResult { System.setErr(prevErr) Output.mode = Output.Mode.TEXT if (prevHome == null) System.clearProperty("amy.home") else System.setProperty("amy.home", prevHome) - tempHome.deleteRecursively() + if (shared == null) tempHome.deleteRecursively() } } diff --git a/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/StatusCommandTest.kt b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/StatusCommandTest.kt new file mode 100644 index 0000000000..4ffe64e2f9 --- /dev/null +++ b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/StatusCommandTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli + +import com.fasterxml.jackson.databind.JsonNode +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * `amy status` answers two questions — who is signed in, and what each of + * them has saved — so these pin both: the `--json` contract that carries the + * answers, and the promises that make the command safe to run blind (works + * with zero or many accounts, never prompts, never writes). + * + * The text rendering is deliberately NOT asserted on beyond "the answer is + * in there": per `cli/DEVELOPMENT.md` only the JSON shape is public API. + */ +class StatusCommandTest { + private fun statusJson(): JsonNode { + val r = amy("--json", "status") + assertEquals(0, r.exit, "status should always exit 0, stderr: ${r.stderr}") + assertEquals(1, r.stdoutLines.size, "expected exactly one stdout line, got: ${r.stdout}") + return Output.mapper.readTree(r.stdoutLines.single()) + } + + private fun initAccount(name: String) { + val r = amy("--secret-backend", "plaintext", "--account", name, "init") + assertEquals(0, r.exit, "init $name failed: ${r.stderr}") + } + + private fun accountNamed( + json: JsonNode, + name: String, + ): JsonNode = assertNotNull(json["accounts"].firstOrNull { it["name"].asText() == name }, "no account '$name' in $json") + + @Test + fun reportsNoAccountsWithoutFailing() = + withSharedAmyHome { + val json = statusJson() + assertTrue(json["accounts"].isEmpty, "a fresh machine has no accounts: $json") + assertTrue(json["current"].isNull, "nothing can be current when nothing exists: $json") + } + + /** + * The read-only promise. `status` is the command a confused user runs + * first, so it must not leave an event store (or anything else) behind on + * a machine that has none. + */ + @Test + fun writesNothingToDisk() = + withSharedAmyHome { home -> + assertEquals(0, amy("--json", "status").exit) + assertFalse(File(home, ".amy").exists(), "status must not create ~/.amy") + } + + @Test + fun namesEveryAccountAndMarksTheCurrentOne() = + withSharedAmyHome { + initAccount("alice") + initAccount("bob") + assertEquals(0, amy("use", "alice").exit) + + val json = statusJson() + assertEquals("alice", json["current"].asText()) + assertEquals(listOf("alice", "bob"), json["accounts"].map { it["name"].asText() }) + assertTrue(accountNamed(json, "alice")["current"].asBoolean()) + assertFalse(accountNamed(json, "bob")["current"].asBoolean()) + } + + @Test + fun reportsHowEachAccountSigns() = + withSharedAmyHome { + initAccount("alice") + + val alice = accountNamed(statusJson(), "alice") + assertEquals("local", alice["signer"].asText()) + assertEquals("plaintext", alice["key_storage"].asText()) + assertTrue(alice["can_sign"].asBoolean()) + assertTrue(alice["npub"].asText().startsWith("npub1")) + assertEquals(64, alice["pubkey"].asText().length) + } + + /** + * A brand-new account has saved nothing — including no contacts. `init` + * writes a self-alias so you can name your own account; counting it made + * every fresh account claim an address book it doesn't have. + */ + @Test + fun freshAccountHasSavedNothing() = + withSharedAmyHome { + initAccount("alice") + + val saved = accountNamed(statusJson(), "alice")["saved"] + assertEquals(0, saved["contacts"].asInt(), "the self-alias is not a saved contact") + assertEquals(0, saved["events"].asInt()) + assertEquals(0, saved["marmot_groups"].asInt()) + assertEquals(0, saved["concord_communities"].asInt()) + assertFalse(saved["key_package"].asBoolean()) + assertFalse(saved["cashu_wallet"].asBoolean()) + assertTrue(saved["dm_cursor_at"].isNull) + + val text = amy("status").stdout + assertTrue(text.contains("nothing yet"), "text should say so plainly: $text") + } + + @Test + fun countsSavedContactsOnceTheAddressBookGrows() = + withSharedAmyHome { home -> + initAccount("alice") + val aliases = File(home, ".amy/alice/aliases.json") + val map = Output.mapper.readValue(aliases, Map::class.java).toMutableMap() + map["bob"] = "npub1ngs0702f9mzph9j8csd22vj8ycg97q3jxzcs4qvdymmmet079nqsr4f60n" + aliases.writeText(Output.mapper.writeValueAsString(map)) + + assertEquals(1, accountNamed(statusJson(), "alice")["saved"]["contacts"].asInt()) + } + + /** Text mode has no shape contract, but it must still carry the answers. */ + @Test + fun textModeCarriesTheSameAnswers() = + withSharedAmyHome { + initAccount("alice") + initAccount("bob") + assertEquals(0, amy("use", "bob").exit) + + val json = statusJson() + val text = amy("status").stdout + assertTrue(text.contains("alice"), "text should name every account: $text") + assertTrue(text.contains(accountNamed(json, "bob")["npub"].asText()), "text should print the npub: $text") + assertTrue(text.contains("current"), "text should mark the active account: $text") + } +} From 340fbee132ae454fd700be91f09c90a493e925a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:53:59 +0000 Subject: [PATCH 2/3] refactor(cli): give each `amy status` saved item its own line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A busy account lists six or seven footprint items. Joined with `·` and wrapped at 78 columns they read as one run-on sentence that has to be parsed; a column of short lines scans in one pass: saved: 128 events (newest 2h ago) 3 contacts 2 Marmot groups a published key package Drops the wrap machinery (`appendWrapped`, the fixed WIDTH) for a plain hanging indent. `saved: nothing yet` is unchanged, and so is `--json`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AtKhnNBSr9CWnZyjTWu7dL --- .../amethyst/cli/commands/StatusText.kt | 60 ++++++------------- 1 file changed, 17 insertions(+), 43 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusText.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusText.kt index 38b46d9920..0ed6fa6e38 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusText.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusText.kt @@ -28,16 +28,23 @@ import java.io.File * The terminal rendering of [StatusCommand] — the one command whose answer * the generic key/value renderer buries. * - * One block per account, four lines at most: + * One block per account — three lines of identity, then one line per + * thing the account has saved: * * ``` * alice (current) * Alice Jones · alice@example.com * npub1hje47kz5qeneyqrxc9nzgmz06ml6l9lguqv0qtsz4rkwqkmf636qvg4sz3 * local key, in the login keychain - * saved: 128 events (newest 2h ago) · 3 contacts · 2 Marmot groups + * saved: 128 events (newest 2h ago) + * 3 contacts + * 2 Marmot groups * ``` * + * The footprint gets a line per item rather than one `·`-joined run: a + * busy account lists six or seven of them, and a column of short lines + * scans in one pass where a wrapped sentence does not. + * * The rule that keeps it short: **absent is silent**. A profile the store * hasn't seen, a footprint an account doesn't have — those lines simply * don't print, instead of printing as `(none)` / `no` / `0`. Only the npub @@ -49,11 +56,8 @@ import java.io.File internal object StatusText { private const val DOT = " · " - /** - * Wrap column for the `saved:` list. Fixed rather than probed: JDK 21 - * offers no terminal width, and 80 is the floor every terminal honours. - */ - private const val WIDTH = 78 + /** Hanging indent for the `saved:` list — the visible width of `" saved: "`. */ + private val SAVED_INDENT = " ".repeat(" saved: ".length) fun render( rootBase: File, @@ -100,46 +104,16 @@ internal object StatusText { out.append(" ").append(signerLine(account.name, account.signer, color)).append('\n') if (account.npub == null) return - // Line 4 — the footprint, or one honest word when there isn't one. - // A busy account can list six things, so it wraps under its label. + // Then the footprint — one line per item, hanging off the label — or + // one honest word when there isn't one. val label = " " + color.dim("saved:") + " " if (account.saved.isEmpty) { out.append(label).append(color.dim("nothing yet")).append('\n') } else { - appendWrapped(out, label, indent = " ", parts = savedParts(account.saved)) - } - } - - /** - * Join [parts] with [DOT] across as many lines as it takes to stay inside - * [WIDTH], continuing under [indent] (which must be as wide as the visible - * [label]). The separator stays at the end of a line, so a wrapped list - * still reads as one run-on item rather than a new heading. - */ - private fun appendWrapped( - out: StringBuilder, - label: String, - indent: String, - parts: List, - ) { - out.append(label) - var used = indent.length - parts.forEachIndexed { i, part -> - val isLast = i == parts.lastIndex - val piece = if (isLast) part else part + DOT.trimEnd() - if (i > 0) { - if (used + 1 + piece.length > WIDTH) { - out.append('\n').append(indent) - used = indent.length - } else { - out.append(' ') - used += 1 - } + savedParts(account.saved).forEachIndexed { i, part -> + out.append(if (i == 0) label else SAVED_INDENT).append(part).append('\n') } - out.append(piece) - used += piece.length } - out.append('\n') } /** @@ -183,8 +157,8 @@ internal object StatusText { } /** - * The footprint, most-interesting first, absent items omitted. Reads as - * a sentence fragment: `128 events (newest 2h ago) · 3 contacts · …`. + * The footprint, most-interesting first, absent items omitted. One + * self-contained phrase per entry, since each gets its own line. */ private fun savedParts(saved: StatusReport.Saved): List { val parts = mutableListOf() From cd5441be442d7d8acc71e67f2de419ae0ab5464f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:26:29 +0000 Subject: [PATCH 3/3] feat(cli): surface relay config, follows, and account selection in `amy status` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of everything amy keeps under `~/.amy/` against what `status` showed. Six gaps, all verified against a real data dir rather than reasoned about. **No account selected.** With a stale `current` pin, or several accounts and no pin, every verb but `use`/`status` dies at account resolution ("pins 'ghost' but … doesn't exist", "multiple accounts … pick one") — and status, the command you run to find out why, showed nothing wrong. It now leads with the cause and the fix. New `current_exists` in JSON. **Relay config was invisible.** kind 10002/10050 are the first thing `amy relay add` writes and every account has them, yet status said nothing about where the account talks. Now `3 relays (2 write, 2 read)` and `DM inbox on 1 relay`. The read/write split follows NIP-65, where a bare `r` counts for both, so the two can exceed the total. **Follows.** kind 3 — the other headline number of a nostr account. All three come from the existing single multi-kind query on the account's pubkey, so they cost no extra store round trips. **"a published key package" was wrong.** It is backed by `marmot/keypackages.bundle`, which is local private MLS material — the old field name `key_package_published` had the same lie in it. Now "a Marmot key package". **Marmot messages.** `FileMarmotMessageStore` writes `.messages` in the `groups/` dir status already lists, so group chat history was sitting there uncounted: `2 Marmot groups, 5 messages`. Counted by streaming newlines, not by reading files in. **The operator key.** `~/.amy/operator/` is a machine-level GrapeRank signing identity that `listAccounts` skips as a reserved name — the one thing under `~/.amy/` nothing reported. Now a footer line when present, via a new read-only `OperatorKeys.peek` that needs no SecretStore and mints nothing (the instance API creates a master on first use). Considered and left out: decrypted DM counts (needs the signer, would break the no-prompt promise); git repos, mute lists, bookmarks, search relays (long tail — each is its own verb, and adding them all rebuilds the wall of zeros this redesign removed); store size (that's `amy store stat`); nutzap info (always published with the wallet). Gathering moves behind `StatusReport.overview()`, which now returns an `Overview` carrying selection state and the operator alongside the accounts, so the command stays parse-call-emit. JSON: adds `current_exists`, `operator`, and `saved.{follows, relays, relays_write, relays_read, dm_relays, marmot_messages}`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AtKhnNBSr9CWnZyjTWu7dL --- cli/README.md | 2 +- cli/ROADMAP.md | 2 +- .../amethyst/cli/OperatorKeys.kt | 18 ++ .../amethyst/cli/commands/StatusCommand.kt | 40 ++-- .../amethyst/cli/commands/StatusReport.kt | 196 ++++++++++++++++-- .../amethyst/cli/commands/StatusText.kt | 89 ++++++-- .../amethyst/cli/StatusCommandTest.kt | 63 ++++++ 7 files changed, 353 insertions(+), 57 deletions(-) diff --git a/cli/README.md b/cli/README.md index 72713a575b..9f7427e2a0 100644 --- a/cli/README.md +++ b/cli/README.md @@ -472,7 +472,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` | Who is signed in and what each account has saved. Per account: the profile name/NIP-05 amy holds locally, the npub, how it signs (local key — keychain/passphrase/plaintext — NIP-46 bunker, or read-only), and its local footprint: own events in the store, contacts, Marmot groups, a published key package, Concord communities, a Cashu wallet, DM sync cursor. Whatever an account doesn't have is left out, so a fresh one is four lines. Built for the returning user: no keychain prompt, no network. (Event-store size and backend: `amy store stat`.) | +| `amy status` | Who is signed in and what each account has saved. Per account: the profile name/NIP-05 amy holds locally, the npub, how it signs (local key — keychain/passphrase/plaintext — NIP-46 bunker, or read-only), and its local footprint: follows, NIP-65 relays (with the read/write split) and NIP-17 DM inbox, own events in the store, contacts, Marmot groups + messages, a Marmot key package, Concord communities, a Cashu wallet, DM sync cursor. Warns up front when **no account is selected** (a stale `current` pin, or several accounts and no pin) — the state that makes every other command fail — and reports the machine-level GrapeRank operator key. Whatever an account doesn't have is left out, so a fresh one is four lines. Built for the returning user: no keychain prompt, no network. (Event-store size and backend: `amy store stat`.) | | `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 46705f1811..ede7f2c648 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -44,7 +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`) — who is signed in (profile name, npub, signer + can-sign) and what each account has saved (own events, contacts, Marmot groups, key package, Concord communities, Cashu wallet, DM cursor) | ✅ | `StatusCommand` + `StatusText`. Cross-account and read-only: no keychain prompt, no network, and it never creates the event store it reads. Store size/backend is `amy store stat`. | +| Status overview (`amy status`) — who is signed in (profile name, npub, signer + can-sign), what each account has saved (follows, relay lists, own events, contacts, Marmot groups + messages, key package, Concord communities, Cashu wallet, DM cursor), whether an account is actually selected, and the machine operator key | ✅ | `StatusCommand` + `StatusReport` + `StatusText`. Cross-account and read-only: no keychain prompt, no network, and it never creates the event store it reads. Store size/backend is `amy store stat`. | | 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/OperatorKeys.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/OperatorKeys.kt index 68c095b234..ae2459ed49 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/OperatorKeys.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/OperatorKeys.kt @@ -169,6 +169,24 @@ class OperatorKeys( companion object { private const val DIR_NAME = "operator" private const val CONFIG_NAME = "operator.json" + + /** + * Read the manifest at `/operator/operator.json` without a + * [SecretStore] and without creating anything — null when this machine + * has no operator yet. + * + * The instance API can't answer "is there one, and what is it?" for a + * read-only caller: it needs a [SecretStore] to construct, and + * [masterPubKey] mints a master on first use. `amy status` must do + * neither, so it peeks. Only public data is returned to the caller — + * [Config.master] is a [SecretStore] descriptor, not the key. + */ + fun peek(amyHome: File): Config? = + File(File(amyHome, DIR_NAME), CONFIG_NAME) + .takeIf { it.isFile } + ?.let { runCatching { Output.mapper.readValue(it.readText()) }.getOrNull() } + ?.takeIf { it.masterPubKey.isNotEmpty() } + private const val DERIVATION_LABEL = "graperank-provider:" private const val MONITOR_LABEL = "relay-monitor:" } 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 index 3508e641a4..bc807fa5a3 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusCommand.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.cli.StoreFactory -import java.io.File /** * `amy status` — who is signed in, and what each of them has saved. @@ -31,11 +30,19 @@ import java.io.File * Two questions, in that order. **Who**: every account under `~/.amy/`, * which one commands run as, the name/NIP-05 on its profile, and how (or * whether) it can sign. **What's saved**: the local footprint that account - * has accumulated — its own events in the shared store, address-book - * aliases, Marmot groups, a published KeyPackage, Concord communities, a - * Cashu wallet, and how far the DM catch-up cursor has run. Anything an - * account does *not* have is left out rather than printed as a zero, so a - * fresh account reads as one short block instead of a wall of `no`. + * has accumulated — follows and relay lists, its own events in the shared + * store, address-book aliases, Marmot groups and their messages, a Marmot + * KeyPackage, Concord communities, a Cashu wallet, and how far the DM + * catch-up cursor has run. Anything an account does *not* have is left out + * rather than printed as a zero, so a fresh account reads as one short + * block instead of a wall of `no`. + * + * It also reports the one thing no other verb can: that **no account is + * selected**. Everything but `use` and `status` resolves an account before + * it runs and dies on a stale `current` pin or an ambiguous `~/.amy/`, so + * the command you reach for to find out why has to name the cause. The + * machine-level GrapeRank operator key gets a line for the same reason — + * `listAccounts` skips it as a reserved name, so nothing else reports it. * * Deliberately out of scope: event-store internals (backend, disk bytes, * kind histogram) — that is `amy store stat`, and duplicating a partial, @@ -67,33 +74,18 @@ object StatusCommand { // 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 } - // One store handle for every account — it's shared. Null when the // machine has no store yet; a store we can't open (locked, corrupt) // degrades to the same thing, so the on-disk half still prints. val store = runCatching { StoreFactory.openExistingShared(rootBase) }.getOrNull() - val accounts = + val overview = try { - DataDir.listAccounts(rootBase).map { name -> - StatusReport.of(File(rootBase, name), name, name == currentPin, store) - } + StatusReport.overview(rootBase, store) } finally { runCatching { store?.close() } } - Output.emit( - mapOf( - "root" to rootBase.absolutePath, - "current" to currentPin, - "accounts" to accounts.map { it.toJson() }, - ), - ) { color -> StatusText.render(rootBase, accounts, color) } + Output.emit(overview.toJson()) { color -> StatusText.render(overview, color) } return 0 } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusReport.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusReport.kt index 07030fadf1..5109d78728 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusReport.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusReport.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.cli.commands import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.OperatorKeys import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.cli.RunState import com.vitorpamplona.amethyst.cli.secrets.IdentityFile @@ -31,7 +33,11 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import java.io.File /** @@ -45,6 +51,54 @@ import java.io.File * cannot pop a keychain dialog or ask for a NIP-49 passphrase. */ internal object StatusReport { + /** + * The whole answer: the machine's `~/.amy/`, which account is selected, + * every account, and the machine-level operator key if one exists. + */ + data class Overview( + val root: File, + val currentPin: String?, + /** + * Whether [currentPin] resolves to a directory that is actually + * there. A pin left behind by a deleted account makes *every* other + * verb fail with "pins 'x' but … doesn't exist" — so status, the + * command you run to work out why, has to be able to say so. + */ + val currentExists: Boolean, + val accounts: List, + val operator: Operator?, + ) { + /** + * True when no account is selected and amy cannot pick one for you — + * a stale pin, or several accounts and no pin. Until it's resolved + * every verb needs an explicit `--account`. + */ + val selectionBroken: Boolean + get() = if (currentPin != null) !currentExists else accounts.size > 1 + + fun toJson(): Map = + linkedMapOf( + "root" to root.absolutePath, + "current" to currentPin, + "current_exists" to currentExists, + "accounts" to accounts.map { it.toJson() }, + "operator" to operator?.toJson(), + ) + } + + /** + * The machine-level GrapeRank operator key at `~/.amy/operator/`, shared + * by every account. `listAccounts` skips it as a reserved name, so + * without this it is the one thing under `~/.amy/` nothing reports. + */ + data class Operator( + val pubkey: String, + val npub: String, + val relays: List, + ) { + fun toJson(): Map = linkedMapOf("pubkey" to pubkey, "npub" to npub, "relays" to relays) + } + /** One account's answer to "who is this" and "what have they got". */ data class AccountReport( val name: String, @@ -81,10 +135,22 @@ internal object StatusReport { * the account's own directory. */ data class Saved( + val follows: Int, + /** + * NIP-65 (kind 10002). A bare `r` tag advertises a relay for both + * directions, so [relaysWrite] + [relaysRead] can exceed [relays] — + * that is the spec, not double counting. + */ + val relays: Int, + val relaysWrite: Int, + val relaysRead: Int, + /** NIP-17 DM inbox (kind 10050) — where others send this account DMs. */ + val dmRelays: Int, val events: Int, val newestEventAt: Long?, val contacts: Int, val marmotGroups: Int, + val marmotMessages: Int, val keyPackage: Boolean, val concordCommunities: Int, val cashuWallet: Boolean, @@ -94,15 +160,22 @@ internal object StatusReport { /** True when this account has nothing but its key — the fresh-`init` state. */ val isEmpty: Boolean get() = - events == 0 && contacts == 0 && marmotGroups == 0 && !keyPackage && - concordCommunities == 0 && !cashuWallet && dmCursorAt == null + follows == 0 && relays == 0 && dmRelays == 0 && events == 0 && contacts == 0 && + marmotGroups == 0 && !keyPackage && concordCommunities == 0 && !cashuWallet && + dmCursorAt == null fun toJson(): Map = linkedMapOf( + "follows" to follows, + "relays" to relays, + "relays_write" to relaysWrite, + "relays_read" to relaysRead, + "dm_relays" to dmRelays, "events" to events, "newest_event_at" to newestEventAt, "contacts" to contacts, "marmot_groups" to marmotGroups, + "marmot_messages" to marmotMessages, "key_package" to keyPackage, "concord_communities" to concordCommunities, "cashu_wallet" to cashuWallet, @@ -130,6 +203,41 @@ internal object StatusReport { val bunkerRelays: List?, ) + /** + * Read every account under [rootBase], plus the selection state and the + * machine-level operator key. [store] is the shared event store, already + * open, or null when this machine has none. + */ + suspend fun overview( + rootBase: File, + store: IEventStore?, + ): Overview { + val pin = + File(rootBase, DataDir.CURRENT_MARKER_NAME) + .takeIf { it.isFile } + ?.readText() + ?.trim() + ?.ifEmpty { null } + + return Overview( + root = rootBase, + currentPin = pin, + currentExists = pin != null && File(rootBase, pin).isDirectory, + accounts = + DataDir.listAccounts(rootBase).map { name -> + of(File(rootBase, name), name, name == pin, store) + }, + operator = + OperatorKeys.peek(rootBase)?.let { cfg -> + Operator( + pubkey = cfg.masterPubKey, + npub = runCatching { NPub.create(cfg.masterPubKey) }.getOrElse { cfg.masterPubKey }, + relays = cfg.relays, + ) + }, + ) + } + /** * Read one account rooted at [accountRoot]. [store] is the shared event * store, already open, or null when this machine has none — in which @@ -159,13 +267,16 @@ internal object StatusReport { signer = classifySigner(identity, identityFile.isFile), saved = Saved( + follows = own.follows, + relays = own.relays, + relaysWrite = own.relaysWrite, + relaysRead = own.relaysRead, + dmRelays = own.dmRelays, events = own.count, newestEventAt = own.newestAt, contacts = contactCount(File(accountRoot, "aliases.json"), identity?.npub), - marmotGroups = - File(accountRoot, "marmot/groups") - .listFiles { f -> f.name.endsWith(".state") } - ?.size ?: 0, + marmotGroups = marmotFiles(accountRoot, ".state").size, + marmotMessages = marmotFiles(accountRoot, ".messages").sumOf { countLines(it) }, keyPackage = File(accountRoot, "marmot/keypackages.bundle").isFile, concordCommunities = ConcordStore(File(accountRoot, "concord.json")).load().size, cashuWallet = own.hasWallet, @@ -181,6 +292,11 @@ internal object StatusReport { val newestAt: Long? = null, val profile: UserMetadata? = null, val hasWallet: Boolean = false, + val follows: Int = 0, + val relays: Int = 0, + val relaysWrite: Int = 0, + val relaysRead: Int = 0, + val dmRelays: Int = 0, ) /** @@ -199,19 +315,32 @@ internal object StatusReport { val authors = listOf(pubkey) var newestAt: Long? = null var profile: MetadataEvent? = null + var contacts: ContactListEvent? = null + var relayList: AdvertisedRelayListEvent? = null + var dmInbox: ChatMessageRelayListEvent? = null var wallet = false // Newest-first with limit 1 gives the last-activity stamp. store.query(Filter(authors = authors, limit = 1)) { newestAt = it.createdAt } - store.query(Filter(authors = authors, kinds = listOf(MetadataEvent.KIND, CashuWalletEvent.KIND))) { event -> - when (event) { - is MetadataEvent -> { - // The store supersedes replaceables, so this is - // normally a single row — keep the newest anyway. - val known = profile - if (known == null || event.createdAt > known.createdAt) profile = event - } + // Every replaceable that describes the account, in ONE pass — + // each is at most one row, so adding a kind here is free. + val describes = + listOf( + MetadataEvent.KIND, + ContactListEvent.KIND, + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + CashuWalletEvent.KIND, + ) + store.query(Filter(authors = authors, kinds = describes)) { event -> + when (event) { + // The store supersedes replaceables, so each of these is + // normally a single row — keep the newest anyway. + is MetadataEvent -> if (isNewer(event, profile)) profile = event + is ContactListEvent -> if (isNewer(event, contacts)) contacts = event + is AdvertisedRelayListEvent -> if (isNewer(event, relayList)) relayList = event + is ChatMessageRelayListEvent -> if (isNewer(event, dmInbox)) dmInbox = event is CashuWalletEvent -> wallet = true else -> Unit } @@ -222,9 +351,48 @@ internal object StatusReport { newestAt = newestAt, profile = profile?.contactMetaData(), hasWallet = wallet, + follows = contacts?.unverifiedFollowKeySet()?.size ?: 0, + relays = relayList?.relays()?.size ?: 0, + relaysWrite = relayList?.writeRelays()?.size ?: 0, + relaysRead = relayList?.readRelays()?.size ?: 0, + dmRelays = dmInbox?.relays()?.size ?: 0, ) }.getOrElse { OwnEvents() } + private fun isNewer( + candidate: Event, + known: Event?, + ): Boolean = known == null || candidate.createdAt > known.createdAt + + /** Marmot per-group files with the given suffix, under `marmot/groups/`. */ + private fun marmotFiles( + accountRoot: File, + suffix: String, + ): List = + File(accountRoot, "marmot/groups") + .listFiles { f -> f.name.endsWith(suffix) } + ?.toList() + .orEmpty() + + /** + * Lines in [file] — one decrypted group message per line, per + * `FileMarmotMessageStore`. Streams bytes rather than reading the file in, + * so a long chat history costs a scan and not its size in heap. + */ + private fun countLines(file: File): Int = + runCatching { + file.inputStream().buffered().use { input -> + var lines = 0 + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + for (i in 0 until read) if (buffer[i] == '\n'.code.toByte()) lines++ + } + lines + } + }.getOrDefault(0) + private fun classifySigner( identity: IdentityFile?, fileExists: Boolean, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusText.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusText.kt index 0ed6fa6e38..c31c34cc87 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusText.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusText.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Ansi import com.vitorpamplona.amethyst.cli.Output -import java.io.File /** * The terminal rendering of [StatusCommand] — the one command whose answer @@ -36,11 +35,17 @@ import java.io.File * Alice Jones · alice@example.com * npub1hje47kz5qeneyqrxc9nzgmz06ml6l9lguqv0qtsz4rkwqkmf636qvg4sz3 * local key, in the login keychain - * saved: 128 events (newest 2h ago) + * saved: 312 follows + * 5 relays (4 write, 3 read) + * DM inbox on 2 relays + * 128 events (newest 2h ago) * 3 contacts - * 2 Marmot groups * ``` * + * A `No account selected` warning leads the whole report when amy cannot + * resolve an account on its own; the footer carries the account count and + * the machine-level operator key. + * * The footprint gets a line per item rather than one `·`-joined run: a * busy account lists six or seven of them, and a column of short lines * scans in one pass where a wrapped sentence does not. @@ -60,27 +65,52 @@ internal object StatusText { private val SAVED_INDENT = " ".repeat(" saved: ".length) fun render( - rootBase: File, - accounts: List, + overview: StatusReport.Overview, color: Ansi, ): String { - if (accounts.isEmpty()) { + if (overview.accounts.isEmpty()) { return buildString { - append(color.dim("No accounts under ${rootBase.absolutePath}")) + append(color.dim("No accounts under ${overview.root.absolutePath}")) append("\n\n") append("Create one with `amy --account init`") } } val out = StringBuilder() - for (account in accounts) { + // Leads, because it's the reason the next command is about to fail. + // No trailing blank line — the account loop below adds the separator. + selectionWarning(overview, color)?.let { out.append(it).append('\n') } + for (account in overview.accounts) { if (out.isNotEmpty()) out.append('\n') appendAccount(out, account, color) } - out.append('\n').append(footer(rootBase, accounts, color)) + out.append('\n').append(footer(overview, color)) return out.toString() } + /** + * The one thing status can say that no other command will: *no account is + * selected*. Every verb but `use` and `status` resolves an account first + * and dies with "pins 'x' but … doesn't exist" or "multiple accounts … + * pick one" — so the command you run to diagnose that has to name it. + * Null when amy can resolve an account on its own (a good pin, or exactly + * one account). + */ + private fun selectionWarning( + overview: StatusReport.Overview, + color: Ansi, + ): String? { + if (!overview.selectionBroken) return null + val detail = + if (overview.currentPin != null) { + "the `current` pin names '${overview.currentPin}', which no longer exists" + } else { + "${overview.accounts.size} accounts and no pin" + } + return color.yellow("No account selected") + + " — $detail. Every command needs `--account ` until you run `amy use `." + } + private fun appendAccount( out: StringBuilder, account: StatusReport.AccountReport, @@ -162,28 +192,53 @@ internal object StatusText { */ private fun savedParts(saved: StatusReport.Saved): List { val parts = mutableListOf() + // The two numbers that define a nostr account come first. + if (saved.follows > 0) parts += plural(saved.follows, "follow") + if (saved.relays > 0) parts += relayLine(saved) + if (saved.dmRelays > 0) parts += "DM inbox on ${plural(saved.dmRelays, "relay")}" if (saved.events > 0) { val age = saved.newestEventAt?.let { " (newest ${ago(it)})" } ?: "" parts += "${plural(saved.events, "event")}$age" } if (saved.contacts > 0) parts += plural(saved.contacts, "contact") - if (saved.marmotGroups > 0) parts += plural(saved.marmotGroups, "Marmot group") - if (saved.keyPackage) parts += "a published key package" + if (saved.marmotGroups > 0) { + val messages = if (saved.marmotMessages > 0) ", ${plural(saved.marmotMessages, "message")}" else "" + parts += plural(saved.marmotGroups, "Marmot group") + messages + } + // NOT "published": keypackages.bundle is the local private MLS + // material. The kind:30443 announcement is a separate thing. + if (saved.keyPackage) parts += "a Marmot key package" if (saved.concordCommunities > 0) parts += plural(saved.concordCommunities, "Concord community", "Concord communities") if (saved.cashuWallet) parts += "a Cashu wallet" if (saved.dmCursorAt != null) parts += "DMs synced ${ago(saved.dmCursorAt)}" return parts } + /** + * `5 relays (4 write, 3 read)`. The breakdown is dropped when every relay + * is advertised for both directions — NIP-65's bare `r` tag — because + * then it just restates the total twice. + */ + private fun relayLine(saved: StatusReport.Saved): String { + val total = plural(saved.relays, "relay") + val bothWays = saved.relaysWrite == saved.relays && saved.relaysRead == saved.relays + return if (bothWays) total else "$total (${saved.relaysWrite} write, ${saved.relaysRead} read)" + } + private fun footer( - rootBase: File, - accounts: List, + overview: StatusReport.Overview, color: Ansi, ): String { - val head = "${plural(accounts.size, "account")} under ${rootBase.absolutePath}" - // Only nag about switching when there is somewhere to switch to. - val hint = if (accounts.size > 1) DOT + "switch with `amy use `" else "" - return color.dim(head + hint) + val head = "${plural(overview.accounts.size, "account")} under ${overview.root.absolutePath}" + // Only nag about switching when there is somewhere to switch to, and + // not when the warning above already said it louder. + val hint = if (overview.accounts.size > 1 && !overview.selectionBroken) DOT + "switch with `amy use `" else "" + val operator = + overview.operator?.let { + val relays = if (it.relays.isEmpty()) "no relays set" else plural(it.relays.size, "relay") + "\nGrapeRank operator key ${it.npub} ($relays)" + } ?: "" + return color.dim(head + hint + operator) } private fun ago(epochSeconds: Long): String = Output.relativeTime(System.currentTimeMillis() / 1000 - epochSeconds) diff --git a/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/StatusCommandTest.kt b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/StatusCommandTest.kt index 4ffe64e2f9..d024548a8d 100644 --- a/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/StatusCommandTest.kt +++ b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/StatusCommandTest.kt @@ -115,7 +115,11 @@ class StatusCommandTest { val saved = accountNamed(statusJson(), "alice")["saved"] assertEquals(0, saved["contacts"].asInt(), "the self-alias is not a saved contact") assertEquals(0, saved["events"].asInt()) + assertEquals(0, saved["follows"].asInt()) + assertEquals(0, saved["relays"].asInt()) + assertEquals(0, saved["dm_relays"].asInt()) assertEquals(0, saved["marmot_groups"].asInt()) + assertEquals(0, saved["marmot_messages"].asInt()) assertEquals(0, saved["concord_communities"].asInt()) assertFalse(saved["key_package"].asBoolean()) assertFalse(saved["cashu_wallet"].asBoolean()) @@ -137,6 +141,65 @@ class StatusCommandTest { assertEquals(1, accountNamed(statusJson(), "alice")["saved"]["contacts"].asInt()) } + /** + * The selection state is the thing only `status` can report. Every other + * verb resolves an account first and dies; a user with several accounts + * and no pin needs to be told that, not left to read a list. + */ + @Test + fun warnsWhenSeveralAccountsAndNoPin() = + withSharedAmyHome { + initAccount("alice") + initAccount("bob") + + val json = statusJson() + assertTrue(json["current"].isNull) + assertFalse(json["current_exists"].asBoolean()) + assertTrue(amy("status").stdout.contains("No account selected"), "status should say nothing is selected") + } + + /** + * A pin left behind by a deleted account makes every other verb fail with + * "pins 'x' but … doesn't exist". Status has to name the same cause. + */ + @Test + fun warnsWhenTheCurrentPinIsStale() = + withSharedAmyHome { home -> + initAccount("alice") + File(home, ".amy/current").writeText("ghost") + + val json = statusJson() + assertEquals("ghost", json["current"].asText()) + assertFalse(json["current_exists"].asBoolean(), "the pinned directory is gone") + + val text = amy("status").stdout + assertTrue(text.contains("No account selected"), "status should flag the broken pin: $text") + assertTrue(text.contains("ghost"), "status should name the dangling pin: $text") + } + + /** A single account, or a good pin, resolves on its own — no nagging. */ + @Test + fun staysQuietWhenAnAccountResolves() = + withSharedAmyHome { + initAccount("alice") + assertTrue(statusJson()["current"].isNull, "one account needs no pin") + assertFalse(amy("status").stdout.contains("No account selected")) + + initAccount("bob") + assertEquals(0, amy("use", "bob").exit) + assertTrue(statusJson()["current_exists"].asBoolean()) + assertFalse(amy("status").stdout.contains("No account selected"), "a good pin is not a warning") + } + + /** The machine-level operator key is reported, and absent by default. */ + @Test + fun reportsNoOperatorKeyUntilOneExists() = + withSharedAmyHome { + initAccount("alice") + assertTrue(statusJson()["operator"].isNull, "graperank was never run") + assertFalse(amy("status").stdout.contains("operator")) + } + /** Text mode has no shape contract, but it must still carry the answers. */ @Test fun textModeCarriesTheSameAnswers() =