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() =