mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
Merge pull request #4006 from vitorpamplona/claude/amy-status-redesign-xjgo7u
feat(cli): redesign `amy status` around who is signed in and what they saved
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` | 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: 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`) — 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), 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/` |
|
||||
|
||||
@@ -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 `<root>/` that look like accounts (excludes `shared/`). */
|
||||
fun listAccounts(rootBase: File): List<String> =
|
||||
rootBase
|
||||
|
||||
@@ -221,8 +221,8 @@ private suspend fun dispatch(argv: Array<String>): 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.
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,43 +20,52 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.cli.RunState
|
||||
import com.vitorpamplona.amethyst.cli.StoreStats
|
||||
import com.vitorpamplona.amethyst.cli.secrets.IdentityFile
|
||||
import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret
|
||||
import java.io.File
|
||||
import com.vitorpamplona.amethyst.cli.StoreFactory
|
||||
|
||||
/**
|
||||
* `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 — 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,
|
||||
* 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<String>): Int {
|
||||
suspend fun run(tail: Array<String>): Int {
|
||||
if (tail.firstOrNull() == "--help" || tail.firstOrNull() == "-h") {
|
||||
System.err.println(USAGE)
|
||||
return 0
|
||||
@@ -65,114 +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 overview =
|
||||
try {
|
||||
StatusReport.overview(rootBase, store)
|
||||
} finally {
|
||||
runCatching { store?.close() }
|
||||
}
|
||||
|
||||
val accountNames = DataDir.listAccounts(rootBase)
|
||||
val accounts = accountNames.map { accountRow(File(rootBase, it), it, it == currentPin) }
|
||||
|
||||
// The event store is shared across every account.
|
||||
val store = StoreStats.of(File(rootBase, "shared/events-store").toPath())
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"root" to rootBase.absolutePath,
|
||||
"current" to currentPin,
|
||||
"account_count" to accounts.size,
|
||||
"accounts" to accounts,
|
||||
"store" to
|
||||
mapOf(
|
||||
"events" to store.events,
|
||||
"distinct_kinds" to store.distinctKinds,
|
||||
"disk_bytes" to store.diskBytes,
|
||||
"oldest_at" to store.oldestAt,
|
||||
"newest_at" to store.newestAt,
|
||||
"root" to store.root.toString(),
|
||||
),
|
||||
),
|
||||
)
|
||||
Output.emit(overview.toJson()) { color -> StatusText.render(overview, color) }
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun accountRow(
|
||||
accountRoot: File,
|
||||
name: String,
|
||||
isCurrent: Boolean,
|
||||
): Map<String, Any?> {
|
||||
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<String, Any?>()
|
||||
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<String>?,
|
||||
)
|
||||
|
||||
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<IdentityFile>(file.readText()) }.getOrNull() else null
|
||||
|
||||
private fun readAliases(file: File): Map<String, String> = if (file.isFile) runCatching { Output.mapper.readValue<Map<String, String>>(file.readText()) }.getOrElse { emptyMap() } else emptyMap()
|
||||
|
||||
private fun readRunState(file: File): RunState = if (file.isFile) runCatching { Output.mapper.readValue<RunState>(file.readText()) }.getOrElse { RunState() } else RunState()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.OperatorKeys
|
||||
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.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
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
/**
|
||||
* 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,
|
||||
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<String, Any?> {
|
||||
// LinkedHashMap so `--json` key order matches the text block.
|
||||
val row = LinkedHashMap<String, Any?>()
|
||||
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 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,
|
||||
val dmCursorAt: Long?,
|
||||
val marmotGroupCursors: Int,
|
||||
) {
|
||||
/** True when this account has nothing but its key — the fresh-`init` state. */
|
||||
val isEmpty: Boolean
|
||||
get() =
|
||||
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,
|
||||
"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<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
|
||||
* 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(
|
||||
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 = 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,
|
||||
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,
|
||||
val follows: Int = 0,
|
||||
val relays: Int = 0,
|
||||
val relaysWrite: Int = 0,
|
||||
val relaysRead: Int = 0,
|
||||
val dmRelays: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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 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 }
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
OwnEvents(
|
||||
count = store.count(Filter(authors = authors)),
|
||||
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,
|
||||
): 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<IdentityFile>(file.readText()) }.getOrNull() else null
|
||||
|
||||
private fun readAliases(file: File): Map<String, String> = if (file.isFile) runCatching { Output.mapper.readValue<Map<String, String>>(file.readText()) }.getOrElse { emptyMap() } else emptyMap()
|
||||
|
||||
private fun readRunState(file: File): RunState = if (file.isFile) runCatching { Output.mapper.readValue<RunState>(file.readText()) }.getOrElse { RunState() } else RunState()
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* The terminal rendering of [StatusCommand] — the one command whose answer
|
||||
* the generic key/value renderer buries.
|
||||
*
|
||||
* 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: 312 follows
|
||||
* 5 relays (4 write, 3 read)
|
||||
* DM inbox on 2 relays
|
||||
* 128 events (newest 2h ago)
|
||||
* 3 contacts
|
||||
* ```
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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 = " · "
|
||||
|
||||
/** Hanging indent for the `saved:` list — the visible width of `" saved: "`. */
|
||||
private val SAVED_INDENT = " ".repeat(" saved: ".length)
|
||||
|
||||
fun render(
|
||||
overview: StatusReport.Overview,
|
||||
color: Ansi,
|
||||
): String {
|
||||
if (overview.accounts.isEmpty()) {
|
||||
return buildString {
|
||||
append(color.dim("No accounts under ${overview.root.absolutePath}"))
|
||||
append("\n\n")
|
||||
append("Create one with `amy --account <name> init`")
|
||||
}
|
||||
}
|
||||
|
||||
val out = StringBuilder()
|
||||
// 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(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,
|
||||
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
|
||||
|
||||
// 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 {
|
||||
savedParts(account.saved).forEachIndexed { i, part ->
|
||||
out.append(if (i == 0) label else SAVED_INDENT).append(part).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. One
|
||||
* self-contained phrase per entry, since each gets its own line.
|
||||
*/
|
||||
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) {
|
||||
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(
|
||||
overview: StatusReport.Overview,
|
||||
color: Ansi,
|
||||
): String {
|
||||
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)
|
||||
|
||||
private fun plural(
|
||||
n: Int,
|
||||
singular: String,
|
||||
plural: String = singular + "s",
|
||||
): String = if (n == 1) "$n $singular" else "$n $plural"
|
||||
}
|
||||
@@ -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 <stat|sweep-expired|scrub|compact>` — 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<String, Long>(),
|
||||
"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/<k>/ — 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<String, Long>()
|
||||
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() }
|
||||
|
||||
@@ -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<String> 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 <T> 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* 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["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())
|
||||
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())
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() =
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user