mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(cli): surface relay config, follows, and account selection in amy status
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 `<group>.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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtKhnNBSr9CWnZyjTWu7dL
This commit is contained in:
+1
-1
@@ -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/<account>/` 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
|
||||
|
||||
+1
-1
@@ -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 <noun> 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/` |
|
||||
|
||||
@@ -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 `<amyHome>/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<Config>(it.readText()) }.getOrNull() }
|
||||
?.takeIf { it.masterPubKey.isNotEmpty() }
|
||||
|
||||
private const val DERIVATION_LABEL = "graperank-provider:"
|
||||
private const val MONITOR_LABEL = "relay-monitor:"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AccountReport>,
|
||||
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<String, Any?> =
|
||||
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<String>,
|
||||
) {
|
||||
fun toJson(): Map<String, Any?> = 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<String, Any?> =
|
||||
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<String>?,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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<Event>(Filter(authors = authors, limit = 1)) { newestAt = it.createdAt }
|
||||
store.query<Event>(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<Event>(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> =
|
||||
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,
|
||||
|
||||
@@ -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<StatusReport.AccountReport>,
|
||||
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 <name> 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 <name>` until you run `amy use <name>`."
|
||||
}
|
||||
|
||||
private fun appendAccount(
|
||||
out: StringBuilder,
|
||||
account: StatusReport.AccountReport,
|
||||
@@ -162,28 +192,53 @@ internal object StatusText {
|
||||
*/
|
||||
private fun savedParts(saved: StatusReport.Saved): List<String> {
|
||||
val parts = mutableListOf<String>()
|
||||
// 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<StatusReport.AccountReport>,
|
||||
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 <name>`" 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 <name>`" 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)
|
||||
|
||||
@@ -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() =
|
||||
|
||||
Reference in New Issue
Block a user