mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
feat(cli): redesign amy status around who is signed in and what they saved
The old output was a generic key/value dump: 12 fields per account, most
of them `no` or `0`, plus a `store` block that was wrong on the default
backend — it always walked the FS store path (`shared/events-store`),
so a SQLite install (the default since AMY_STORE landed) reported
`events: 0` no matter how full the database was.
`status` now answers two questions and drops everything else:
alice (current)
Alice Jones · alice@example.com
npub1hje47kz5qeneyqrxc9nzgmz06ml6l9lguqv0qtsz4rkwqkmf636qvg4sz3
local key, in the login keychain
saved: 128 events (newest 2h ago) · 3 contacts · 2 Marmot groups
WHO: the profile name/NIP-05 amy holds locally (read from the account's
own kind:0 in the store — new), the npub, and one plain-English sentence
for the signer instead of three fields (`signer` + `key_storage` +
`can_sign`). Plaintext key storage is called out in yellow.
WHAT'S SAVED: the account's own events in the store and when the newest
one landed (new), contacts, Marmot groups, key package, Concord
communities (new — never reported before), Cashu wallet, DM cursor.
The rule that keeps it short is "absent is silent": anything an account
doesn't have is omitted rather than printed as `no`/`0`, so a fresh
account is four lines and says `saved: nothing yet`. Two accuracy fixes
fall out of that: the self-alias `init` writes is no longer counted as a
saved contact, and the Cashu wallet is detected from a real kind:17375
in the store rather than from `cashu.json`, which only ever held NUT-13
counters. A directory whose `identity.json` won't parse now says so
instead of suggesting `init`, which would mint a new key over it.
Event-store size, backend and kind histogram move out entirely — that is
`amy store stat`, which had its own (correct, backend-aware) version all
along.
Mechanics:
- `Output.emit(result) { color -> … }`, an internal overload for a command
with a purpose-built human rendering. JSON mode is untouched.
- `StoreFactory.openExistingShared(root)` opens the cross-account store
only if it already exists, so this read-only command never leaves an
empty database behind — covered by a test.
- `StoreCommands.fsStat` now calls `StoreStats.of`, which it had
duplicated line for line; `status` was `StoreStats`' only caller and no
longer needs it. Same output, ~50 fewer lines.
- Split into StatusCommand (dispatch) / StatusReport (data + JSON
contract) / StatusText (rendering) to stay under the module's file-size
convention.
JSON contract change (per DEVELOPMENT.md principle 5): `store` and
`account_count` are gone; `hex` is now `pubkey` per the documented
convention; per-account footprint fields move under `saved`; adds
`profile_name`, `nip05`, `saved.events`, `saved.newest_event_at`,
`saved.concord_communities`. No in-tree consumer read the old shape.
Co-Authored-By: Claude Opus 5 <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` | Read-only overview of everything under `~/.amy/`: every account, which one is current, each signer type (local keychain/ncryptsec/plaintext, NIP-46 bunker, or read-only) and whether it can sign, the local Marmot / Cashu / alias / sync-cursor footprint per account, and the shared event store's size. Built for the returning user. No keychain prompt, no network. |
|
||||
| `amy status` | Who is signed in and what each account has saved. Per account: the profile name/NIP-05 amy holds locally, the npub, how it signs (local key — keychain/passphrase/plaintext — NIP-46 bunker, or read-only), and its local footprint: own events in the store, contacts, Marmot groups, a published key package, Concord communities, a Cashu wallet, DM sync cursor. Whatever an account doesn't have is left out, so a fresh one is four lines. Built for the returning user: no keychain prompt, no network. (Event-store size and backend: `amy store stat`.) |
|
||||
| `amy logoff [--yes] [--keep-events]` | Log off an account: delete its key + backend secret, the whole `~/.amy/<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) and what each account has saved (own events, contacts, Marmot groups, key package, Concord communities, Cashu wallet, DM cursor) | ✅ | `StatusCommand` + `StatusText`. Cross-account and read-only: no keychain prompt, no network, and it never creates the event store it reads. Store size/backend is `amy store stat`. |
|
||||
| Relay config — every relay-list bucket (nip65 10002 via `outbox`/`inbox`/`nip65` nouns with spec read/write merge, dm 10050, key-package 10051, search 10007, private-outbox 10013, blocked 10006, trusted 10089, proxy 10087, indexer 10086, broadcast 10088, favorite 10012) — noun-first `relay <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.
|
||||
|
||||
@@ -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,45 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.cli.RunState
|
||||
import com.vitorpamplona.amethyst.cli.StoreStats
|
||||
import com.vitorpamplona.amethyst.cli.secrets.IdentityFile
|
||||
import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret
|
||||
import com.vitorpamplona.amethyst.cli.StoreFactory
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* `amy status` — a single at-a-glance overview of everything amy is
|
||||
* holding on disk under `~/.amy/`. Built for the returning user: "I
|
||||
* haven't run this in months — what accounts do I have, which one is
|
||||
* active, can they still sign, and how big is the local database?"
|
||||
* `amy status` — who is signed in, and what each of them has saved.
|
||||
*
|
||||
* Two questions, in that order. **Who**: every account under `~/.amy/`,
|
||||
* which one commands run as, the name/NIP-05 on its profile, and how (or
|
||||
* whether) it can sign. **What's saved**: the local footprint that account
|
||||
* has accumulated — its own events in the shared store, address-book
|
||||
* aliases, Marmot groups, a published KeyPackage, Concord communities, a
|
||||
* Cashu wallet, and how far the DM catch-up cursor has run. Anything an
|
||||
* account does *not* have is left out rather than printed as a zero, so a
|
||||
* fresh account reads as one short block instead of a wall of `no`.
|
||||
*
|
||||
* Deliberately out of scope: event-store internals (backend, disk bytes,
|
||||
* kind histogram) — that is `amy store stat`, and duplicating a partial,
|
||||
* FS-only view of it here is what made the old output so noisy.
|
||||
*
|
||||
* Cross-account by design, so it dispatches *before* account resolution
|
||||
* (like `use`) and never fails on "zero accounts" or "ambiguous account".
|
||||
* It is strictly read-only and metadata-only: it parses the on-disk
|
||||
* `identity.json` / `state.json` / `aliases.json` and walks the shared
|
||||
* event store, but it never unlocks a private key (no keychain prompt,
|
||||
* no NIP-49 passphrase) and never touches the network.
|
||||
* Strictly read-only: [StatusReport] parses the on-disk JSON and reads —
|
||||
* never creates — the shared event store. It never unlocks a private key
|
||||
* (no keychain prompt, no NIP-49 passphrase) and never touches the network.
|
||||
*
|
||||
* Per account it reports the npub, how the key is stored (local keychain
|
||||
* / ncryptsec / plaintext, a NIP-46 bunker, or read-only), whether it can
|
||||
* sign, and the local footprint that account has accumulated: aliases,
|
||||
* Marmot groups, a published KeyPackage bundle, a Cashu wallet, and the
|
||||
* sync cursors that tell catch-up commands where they left off.
|
||||
* [StatusReport] holds the data model and the `--json` contract;
|
||||
* [StatusText] holds the terminal rendering.
|
||||
*/
|
||||
object StatusCommand {
|
||||
val USAGE: String =
|
||||
"""
|
||||
|amy status — read-only overview of every account, signer type, local
|
||||
|Marmot/Cashu state, and the shared event store (no keychain prompt,
|
||||
|no network). Takes no arguments.
|
||||
|amy status — who is signed in and what each account has saved
|
||||
|locally. Read-only: no keychain prompt, no network. Takes no
|
||||
|arguments. For event-store size and backend, see `amy store stat`.
|
||||
""".trimMargin()
|
||||
|
||||
fun run(tail: Array<String>): Int {
|
||||
suspend fun run(tail: Array<String>): Int {
|
||||
if (tail.firstOrNull() == "--help" || tail.firstOrNull() == "-h") {
|
||||
System.err.println(USAGE)
|
||||
return 0
|
||||
@@ -72,107 +74,26 @@ object StatusCommand {
|
||||
?.trim()
|
||||
?.ifEmpty { null }
|
||||
|
||||
val accountNames = DataDir.listAccounts(rootBase)
|
||||
val accounts = accountNames.map { accountRow(File(rootBase, it), it, it == currentPin) }
|
||||
|
||||
// The event store is shared across every account.
|
||||
val store = StoreStats.of(File(rootBase, "shared/events-store").toPath())
|
||||
// One store handle for every account — it's shared. Null when the
|
||||
// machine has no store yet; a store we can't open (locked, corrupt)
|
||||
// degrades to the same thing, so the on-disk half still prints.
|
||||
val store = runCatching { StoreFactory.openExistingShared(rootBase) }.getOrNull()
|
||||
val accounts =
|
||||
try {
|
||||
DataDir.listAccounts(rootBase).map { name ->
|
||||
StatusReport.of(File(rootBase, name), name, name == currentPin, store)
|
||||
}
|
||||
} finally {
|
||||
runCatching { store?.close() }
|
||||
}
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"root" to rootBase.absolutePath,
|
||||
"current" to currentPin,
|
||||
"account_count" to accounts.size,
|
||||
"accounts" to accounts,
|
||||
"store" to
|
||||
mapOf(
|
||||
"events" to store.events,
|
||||
"distinct_kinds" to store.distinctKinds,
|
||||
"disk_bytes" to store.diskBytes,
|
||||
"oldest_at" to store.oldestAt,
|
||||
"newest_at" to store.newestAt,
|
||||
"root" to store.root.toString(),
|
||||
),
|
||||
"accounts" to accounts.map { it.toJson() },
|
||||
),
|
||||
)
|
||||
) { color -> StatusText.render(rootBase, accounts, color) }
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun accountRow(
|
||||
accountRoot: File,
|
||||
name: String,
|
||||
isCurrent: Boolean,
|
||||
): Map<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,268 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.cli.RunState
|
||||
import com.vitorpamplona.amethyst.cli.secrets.IdentityFile
|
||||
import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret
|
||||
import com.vitorpamplona.amethyst.cli.stores.ConcordStore
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What [StatusCommand] gathers about one account, and how it reads it off
|
||||
* disk. Kept apart from the command so the command stays what every other
|
||||
* one in `commands/` is: parse, call, emit.
|
||||
*
|
||||
* Everything here is read-only and prompt-free by construction — it parses
|
||||
* the account's own JSON files and asks the (already-open) shared event
|
||||
* store about the account's pubkey. It never resolves a private key, so it
|
||||
* cannot pop a keychain dialog or ask for a NIP-49 passphrase.
|
||||
*/
|
||||
internal object StatusReport {
|
||||
/** One account's answer to "who is this" and "what have they got". */
|
||||
data class AccountReport(
|
||||
val name: String,
|
||||
val isCurrent: Boolean,
|
||||
val npub: String?,
|
||||
val pubkey: String?,
|
||||
val profileName: String?,
|
||||
val nip05: String?,
|
||||
val signer: SignerInfo,
|
||||
val saved: Saved,
|
||||
) {
|
||||
/** The `--json` projection — this shape is the public contract. */
|
||||
fun toJson(): Map<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 events: Int,
|
||||
val newestEventAt: Long?,
|
||||
val contacts: Int,
|
||||
val marmotGroups: Int,
|
||||
val keyPackage: Boolean,
|
||||
val concordCommunities: Int,
|
||||
val cashuWallet: Boolean,
|
||||
val dmCursorAt: Long?,
|
||||
val marmotGroupCursors: Int,
|
||||
) {
|
||||
/** True when this account has nothing but its key — the fresh-`init` state. */
|
||||
val isEmpty: Boolean
|
||||
get() =
|
||||
events == 0 && contacts == 0 && marmotGroups == 0 && !keyPackage &&
|
||||
concordCommunities == 0 && !cashuWallet && dmCursorAt == null
|
||||
|
||||
fun toJson(): Map<String, Any?> =
|
||||
linkedMapOf(
|
||||
"events" to events,
|
||||
"newest_event_at" to newestEventAt,
|
||||
"contacts" to contacts,
|
||||
"marmot_groups" to marmotGroups,
|
||||
"key_package" to keyPackage,
|
||||
"concord_communities" to concordCommunities,
|
||||
"cashu_wallet" to cashuWallet,
|
||||
"dm_cursor_at" to dmCursorAt,
|
||||
"marmot_group_cursors" to marmotGroupCursors,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How this account can sign, derived purely from the on-disk
|
||||
* [IdentityFile] — never resolves the secret itself.
|
||||
* - `local` — an on-device private key ([storage] says where).
|
||||
* - `bunker` — a NIP-46 remote signer ([bunkerRelays] lists it).
|
||||
* - `read-only` — imported from an npub/nprofile/NIP-05; cannot sign.
|
||||
* - `none` — no `identity.json` at all; the directory is a shell.
|
||||
* - `unreadable` — the file is there but won't parse. Kept distinct from
|
||||
* `none` because the fixes are opposite: `none` wants `init`, and
|
||||
* running `init` over a file amy merely failed to READ would mint a
|
||||
* new key and strand the old one.
|
||||
*/
|
||||
data class SignerInfo(
|
||||
val kind: String,
|
||||
val storage: String?,
|
||||
val canSign: Boolean,
|
||||
val bunkerRelays: List<String>?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Read one account rooted at [accountRoot]. [store] is the shared event
|
||||
* store, already open, or null when this machine has none — in which
|
||||
* case the store-backed fields simply come back empty.
|
||||
*/
|
||||
suspend fun of(
|
||||
accountRoot: File,
|
||||
name: String,
|
||||
isCurrent: Boolean,
|
||||
store: IEventStore?,
|
||||
): AccountReport {
|
||||
val identityFile = File(accountRoot, "identity.json")
|
||||
val identity = readIdentity(identityFile)
|
||||
val pubkey = identity?.pubKeyHex
|
||||
val runState = readRunState(File(accountRoot, "state.json"))
|
||||
|
||||
// Everything the store can answer about this pubkey, in one hop.
|
||||
val own = if (pubkey != null && store != null) ownEvents(store, pubkey) else OwnEvents()
|
||||
|
||||
return AccountReport(
|
||||
name = name,
|
||||
isCurrent = isCurrent,
|
||||
npub = identity?.npub,
|
||||
pubkey = pubkey,
|
||||
profileName = own.profile?.bestName()?.takeIf { it.isNotBlank() },
|
||||
nip05 = own.profile?.nip05?.takeIf { it.isNotBlank() },
|
||||
signer = classifySigner(identity, identityFile.isFile),
|
||||
saved =
|
||||
Saved(
|
||||
events = own.count,
|
||||
newestEventAt = own.newestAt,
|
||||
contacts = contactCount(File(accountRoot, "aliases.json"), identity?.npub),
|
||||
marmotGroups =
|
||||
File(accountRoot, "marmot/groups")
|
||||
.listFiles { f -> f.name.endsWith(".state") }
|
||||
?.size ?: 0,
|
||||
keyPackage = File(accountRoot, "marmot/keypackages.bundle").isFile,
|
||||
concordCommunities = ConcordStore(File(accountRoot, "concord.json")).load().size,
|
||||
cashuWallet = own.hasWallet,
|
||||
dmCursorAt = runState.giftWrapSince,
|
||||
marmotGroupCursors = runState.groupSince.size,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** What the shared store holds for one pubkey. All-empty when it holds nothing. */
|
||||
private data class OwnEvents(
|
||||
val count: Int = 0,
|
||||
val newestAt: Long? = null,
|
||||
val profile: UserMetadata? = null,
|
||||
val hasWallet: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* The store-backed facts about an account: how many of its events we
|
||||
* hold, when the newest one is from, and the replaceables that say who
|
||||
* it is (kind 0) and whether it has a wallet (kind 17375). A store that
|
||||
* errors — locked by a concurrent writer, mid-migration — degrades the
|
||||
* whole group to empty rather than failing the command; the on-disk half
|
||||
* of the report is still worth printing.
|
||||
*/
|
||||
private suspend fun ownEvents(
|
||||
store: IEventStore,
|
||||
pubkey: String,
|
||||
): OwnEvents =
|
||||
runCatching {
|
||||
val authors = listOf(pubkey)
|
||||
var newestAt: Long? = null
|
||||
var profile: MetadataEvent? = null
|
||||
var wallet = false
|
||||
|
||||
// Newest-first with limit 1 gives the last-activity stamp.
|
||||
store.query<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
|
||||
}
|
||||
|
||||
is CashuWalletEvent -> wallet = true
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
OwnEvents(
|
||||
count = store.count(Filter(authors = authors)),
|
||||
newestAt = newestAt,
|
||||
profile = profile?.contactMetaData(),
|
||||
hasWallet = wallet,
|
||||
)
|
||||
}.getOrElse { OwnEvents() }
|
||||
|
||||
private fun classifySigner(
|
||||
identity: IdentityFile?,
|
||||
fileExists: Boolean,
|
||||
): SignerInfo {
|
||||
if (identity == null) return SignerInfo(if (fileExists) "unreadable" else "none", null, false, null)
|
||||
identity.bunker?.let { bunker ->
|
||||
return SignerInfo("bunker", secretStorageLabel(identity.secret), true, bunker.relays)
|
||||
}
|
||||
val storage = secretStorageLabel(identity.secret)
|
||||
return when {
|
||||
identity.secret != null -> SignerInfo("local", storage, true, null)
|
||||
// Pre-secret-store data-dirs kept the key inline; still signable.
|
||||
identity.privKeyHex != null || identity.nsec != null -> SignerInfo("local", "legacy-plaintext", true, null)
|
||||
else -> SignerInfo("read-only", null, false, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun secretStorageLabel(secret: IdentitySecret?): String? =
|
||||
when (secret) {
|
||||
is IdentitySecret.Keychain -> "keychain:${secret.backend}"
|
||||
is IdentitySecret.Ncryptsec -> "ncryptsec"
|
||||
is IdentitySecret.Plaintext -> "plaintext"
|
||||
null -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Address-book entries, minus the self-alias `init` writes so the user
|
||||
* can name their own account. Counting that one made every brand-new
|
||||
* account claim to have saved a contact.
|
||||
*/
|
||||
private fun contactCount(
|
||||
file: File,
|
||||
ownNpub: String?,
|
||||
): Int = readAliases(file).count { (_, npub) -> npub != ownNpub }
|
||||
|
||||
private fun readIdentity(file: File): IdentityFile? = if (file.isFile) runCatching { Output.mapper.readValue<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,222 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Ansi
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The terminal rendering of [StatusCommand] — the one command whose answer
|
||||
* the generic key/value renderer buries.
|
||||
*
|
||||
* One block per account, four lines at most:
|
||||
*
|
||||
* ```
|
||||
* alice (current)
|
||||
* Alice Jones · alice@example.com
|
||||
* npub1hje47kz5qeneyqrxc9nzgmz06ml6l9lguqv0qtsz4rkwqkmf636qvg4sz3
|
||||
* local key, in the login keychain
|
||||
* saved: 128 events (newest 2h ago) · 3 contacts · 2 Marmot groups
|
||||
* ```
|
||||
*
|
||||
* The rule that keeps it short: **absent is silent**. A profile the store
|
||||
* hasn't seen, a footprint an account doesn't have — those lines simply
|
||||
* don't print, instead of printing as `(none)` / `no` / `0`. Only the npub
|
||||
* and the signer line are unconditional, because those two ARE the status.
|
||||
*
|
||||
* The `--json` shape is the public contract and lives in [StatusReport];
|
||||
* this file is free to change with taste.
|
||||
*/
|
||||
internal object StatusText {
|
||||
private const val DOT = " · "
|
||||
|
||||
/**
|
||||
* Wrap column for the `saved:` list. Fixed rather than probed: JDK 21
|
||||
* offers no terminal width, and 80 is the floor every terminal honours.
|
||||
*/
|
||||
private const val WIDTH = 78
|
||||
|
||||
fun render(
|
||||
rootBase: File,
|
||||
accounts: List<StatusReport.AccountReport>,
|
||||
color: Ansi,
|
||||
): String {
|
||||
if (accounts.isEmpty()) {
|
||||
return buildString {
|
||||
append(color.dim("No accounts under ${rootBase.absolutePath}"))
|
||||
append("\n\n")
|
||||
append("Create one with `amy --account <name> init`")
|
||||
}
|
||||
}
|
||||
|
||||
val out = StringBuilder()
|
||||
for (account in accounts) {
|
||||
if (out.isNotEmpty()) out.append('\n')
|
||||
appendAccount(out, account, color)
|
||||
}
|
||||
out.append('\n').append(footer(rootBase, accounts, color))
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun appendAccount(
|
||||
out: StringBuilder,
|
||||
account: StatusReport.AccountReport,
|
||||
color: Ansi,
|
||||
) {
|
||||
out.append(color.bold(account.name))
|
||||
if (account.isCurrent) out.append(' ').append(color.green("(current)"))
|
||||
out.append('\n')
|
||||
|
||||
// Line 1 — the human, when the local store knows one. A profile amy
|
||||
// has never fetched simply doesn't get a line.
|
||||
val who = listOfNotNull(account.profileName, account.nip05)
|
||||
if (who.isNotEmpty()) out.append(" ").append(who.joinToString(DOT)).append('\n')
|
||||
|
||||
// Line 2 — the identifier, whenever there is one to print.
|
||||
account.npub?.let { out.append(" ").append(it).append('\n') }
|
||||
|
||||
// Line 3 — can this account act, and with what key. For a directory
|
||||
// with no usable identity.json this line IS the whole story, which is
|
||||
// why the npub above and the footprint below both drop out.
|
||||
out.append(" ").append(signerLine(account.name, account.signer, color)).append('\n')
|
||||
if (account.npub == null) return
|
||||
|
||||
// Line 4 — the footprint, or one honest word when there isn't one.
|
||||
// A busy account can list six things, so it wraps under its label.
|
||||
val label = " " + color.dim("saved:") + " "
|
||||
if (account.saved.isEmpty) {
|
||||
out.append(label).append(color.dim("nothing yet")).append('\n')
|
||||
} else {
|
||||
appendWrapped(out, label, indent = " ", parts = savedParts(account.saved))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join [parts] with [DOT] across as many lines as it takes to stay inside
|
||||
* [WIDTH], continuing under [indent] (which must be as wide as the visible
|
||||
* [label]). The separator stays at the end of a line, so a wrapped list
|
||||
* still reads as one run-on item rather than a new heading.
|
||||
*/
|
||||
private fun appendWrapped(
|
||||
out: StringBuilder,
|
||||
label: String,
|
||||
indent: String,
|
||||
parts: List<String>,
|
||||
) {
|
||||
out.append(label)
|
||||
var used = indent.length
|
||||
parts.forEachIndexed { i, part ->
|
||||
val isLast = i == parts.lastIndex
|
||||
val piece = if (isLast) part else part + DOT.trimEnd()
|
||||
if (i > 0) {
|
||||
if (used + 1 + piece.length > WIDTH) {
|
||||
out.append('\n').append(indent)
|
||||
used = indent.length
|
||||
} else {
|
||||
out.append(' ')
|
||||
used += 1
|
||||
}
|
||||
}
|
||||
out.append(piece)
|
||||
used += piece.length
|
||||
}
|
||||
out.append('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-English "how do I sign", because `signer: local` +
|
||||
* `key_storage: keychain:login` + `can_sign: yes` on three lines was
|
||||
* three facts where users only ever wanted one sentence.
|
||||
*/
|
||||
private fun signerLine(
|
||||
name: String,
|
||||
signer: StatusReport.SignerInfo,
|
||||
color: Ansi,
|
||||
): String =
|
||||
when (signer.kind) {
|
||||
"local" ->
|
||||
when (val storage = signer.storage) {
|
||||
"ncryptsec" -> "local key, passphrase-encrypted"
|
||||
// Worth a warning colour: the key is readable by anything
|
||||
// running as this user.
|
||||
"plaintext" -> "local key, " + color.yellow("stored unencrypted")
|
||||
"legacy-plaintext" -> "local key, " + color.yellow("stored unencrypted") + " (pre-secret-store format)"
|
||||
null -> "local key"
|
||||
else -> "local key, in the ${storage.removePrefix("keychain:")} keychain"
|
||||
}
|
||||
|
||||
"bunker" -> {
|
||||
val via = signer.bunkerRelays?.firstOrNull()
|
||||
val more = (signer.bunkerRelays?.size ?: 0) - 1
|
||||
val suffix =
|
||||
when {
|
||||
via == null -> ""
|
||||
more > 0 -> " via $via (+$more more)"
|
||||
else -> " via $via"
|
||||
}
|
||||
"remote signer (NIP-46)$suffix"
|
||||
}
|
||||
|
||||
"read-only" -> color.dim("read-only — public key only, cannot sign")
|
||||
"unreadable" -> color.red("identity.json is unreadable — check the file before re-running `init`")
|
||||
// No identity.json at all: the directory exists but `init` never ran.
|
||||
else -> color.red("no identity — run `amy --account $name init`")
|
||||
}
|
||||
|
||||
/**
|
||||
* The footprint, most-interesting first, absent items omitted. Reads as
|
||||
* a sentence fragment: `128 events (newest 2h ago) · 3 contacts · …`.
|
||||
*/
|
||||
private fun savedParts(saved: StatusReport.Saved): List<String> {
|
||||
val parts = mutableListOf<String>()
|
||||
if (saved.events > 0) {
|
||||
val age = saved.newestEventAt?.let { " (newest ${ago(it)})" } ?: ""
|
||||
parts += "${plural(saved.events, "event")}$age"
|
||||
}
|
||||
if (saved.contacts > 0) parts += plural(saved.contacts, "contact")
|
||||
if (saved.marmotGroups > 0) parts += plural(saved.marmotGroups, "Marmot group")
|
||||
if (saved.keyPackage) parts += "a published key package"
|
||||
if (saved.concordCommunities > 0) parts += plural(saved.concordCommunities, "Concord community", "Concord communities")
|
||||
if (saved.cashuWallet) parts += "a Cashu wallet"
|
||||
if (saved.dmCursorAt != null) parts += "DMs synced ${ago(saved.dmCursorAt)}"
|
||||
return parts
|
||||
}
|
||||
|
||||
private fun footer(
|
||||
rootBase: File,
|
||||
accounts: List<StatusReport.AccountReport>,
|
||||
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)
|
||||
}
|
||||
|
||||
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,154 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* `amy status` answers two questions — who is signed in, and what each of
|
||||
* them has saved — so these pin both: the `--json` contract that carries the
|
||||
* answers, and the promises that make the command safe to run blind (works
|
||||
* with zero or many accounts, never prompts, never writes).
|
||||
*
|
||||
* The text rendering is deliberately NOT asserted on beyond "the answer is
|
||||
* in there": per `cli/DEVELOPMENT.md` only the JSON shape is public API.
|
||||
*/
|
||||
class StatusCommandTest {
|
||||
private fun statusJson(): JsonNode {
|
||||
val r = amy("--json", "status")
|
||||
assertEquals(0, r.exit, "status should always exit 0, stderr: ${r.stderr}")
|
||||
assertEquals(1, r.stdoutLines.size, "expected exactly one stdout line, got: ${r.stdout}")
|
||||
return Output.mapper.readTree(r.stdoutLines.single())
|
||||
}
|
||||
|
||||
private fun initAccount(name: String) {
|
||||
val r = amy("--secret-backend", "plaintext", "--account", name, "init")
|
||||
assertEquals(0, r.exit, "init $name failed: ${r.stderr}")
|
||||
}
|
||||
|
||||
private fun accountNamed(
|
||||
json: JsonNode,
|
||||
name: String,
|
||||
): JsonNode = assertNotNull(json["accounts"].firstOrNull { it["name"].asText() == name }, "no account '$name' in $json")
|
||||
|
||||
@Test
|
||||
fun reportsNoAccountsWithoutFailing() =
|
||||
withSharedAmyHome {
|
||||
val json = statusJson()
|
||||
assertTrue(json["accounts"].isEmpty, "a fresh machine has no accounts: $json")
|
||||
assertTrue(json["current"].isNull, "nothing can be current when nothing exists: $json")
|
||||
}
|
||||
|
||||
/**
|
||||
* The read-only promise. `status` is the command a confused user runs
|
||||
* first, so it must not leave an event store (or anything else) behind on
|
||||
* a machine that has none.
|
||||
*/
|
||||
@Test
|
||||
fun writesNothingToDisk() =
|
||||
withSharedAmyHome { home ->
|
||||
assertEquals(0, amy("--json", "status").exit)
|
||||
assertFalse(File(home, ".amy").exists(), "status must not create ~/.amy")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun namesEveryAccountAndMarksTheCurrentOne() =
|
||||
withSharedAmyHome {
|
||||
initAccount("alice")
|
||||
initAccount("bob")
|
||||
assertEquals(0, amy("use", "alice").exit)
|
||||
|
||||
val json = statusJson()
|
||||
assertEquals("alice", json["current"].asText())
|
||||
assertEquals(listOf("alice", "bob"), json["accounts"].map { it["name"].asText() })
|
||||
assertTrue(accountNamed(json, "alice")["current"].asBoolean())
|
||||
assertFalse(accountNamed(json, "bob")["current"].asBoolean())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reportsHowEachAccountSigns() =
|
||||
withSharedAmyHome {
|
||||
initAccount("alice")
|
||||
|
||||
val alice = accountNamed(statusJson(), "alice")
|
||||
assertEquals("local", alice["signer"].asText())
|
||||
assertEquals("plaintext", alice["key_storage"].asText())
|
||||
assertTrue(alice["can_sign"].asBoolean())
|
||||
assertTrue(alice["npub"].asText().startsWith("npub1"))
|
||||
assertEquals(64, alice["pubkey"].asText().length)
|
||||
}
|
||||
|
||||
/**
|
||||
* A brand-new account has saved nothing — including no contacts. `init`
|
||||
* writes a self-alias so you can name your own account; counting it made
|
||||
* every fresh account claim an address book it doesn't have.
|
||||
*/
|
||||
@Test
|
||||
fun freshAccountHasSavedNothing() =
|
||||
withSharedAmyHome {
|
||||
initAccount("alice")
|
||||
|
||||
val saved = accountNamed(statusJson(), "alice")["saved"]
|
||||
assertEquals(0, saved["contacts"].asInt(), "the self-alias is not a saved contact")
|
||||
assertEquals(0, saved["events"].asInt())
|
||||
assertEquals(0, saved["marmot_groups"].asInt())
|
||||
assertEquals(0, saved["concord_communities"].asInt())
|
||||
assertFalse(saved["key_package"].asBoolean())
|
||||
assertFalse(saved["cashu_wallet"].asBoolean())
|
||||
assertTrue(saved["dm_cursor_at"].isNull)
|
||||
|
||||
val text = amy("status").stdout
|
||||
assertTrue(text.contains("nothing yet"), "text should say so plainly: $text")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun countsSavedContactsOnceTheAddressBookGrows() =
|
||||
withSharedAmyHome { home ->
|
||||
initAccount("alice")
|
||||
val aliases = File(home, ".amy/alice/aliases.json")
|
||||
val map = Output.mapper.readValue(aliases, Map::class.java).toMutableMap()
|
||||
map["bob"] = "npub1ngs0702f9mzph9j8csd22vj8ycg97q3jxzcs4qvdymmmet079nqsr4f60n"
|
||||
aliases.writeText(Output.mapper.writeValueAsString(map))
|
||||
|
||||
assertEquals(1, accountNamed(statusJson(), "alice")["saved"]["contacts"].asInt())
|
||||
}
|
||||
|
||||
/** Text mode has no shape contract, but it must still carry the answers. */
|
||||
@Test
|
||||
fun textModeCarriesTheSameAnswers() =
|
||||
withSharedAmyHome {
|
||||
initAccount("alice")
|
||||
initAccount("bob")
|
||||
assertEquals(0, amy("use", "bob").exit)
|
||||
|
||||
val json = statusJson()
|
||||
val text = amy("status").stdout
|
||||
assertTrue(text.contains("alice"), "text should name every account: $text")
|
||||
assertTrue(text.contains(accountNamed(json, "bob")["npub"].asText()), "text should print the npub: $text")
|
||||
assertTrue(text.contains("current"), "text should mark the active account: $text")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user