mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge remote-tracking branch 'origin/main' into claude/graperank-wot-cli-qreg2a
# Conflicts: # cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt
This commit is contained in:
@@ -38,6 +38,15 @@ What every caller — user, script, agent, CI — can rely on:
|
||||
copy to move. Tests isolate by overriding `$HOME` for the amy
|
||||
subprocess (`HOME=/tmp/run.123 amy --account alice …`) — same
|
||||
convention `git`, `gpg`, and `npm` use.
|
||||
- **An account is only required to _sign_.** Read-only verbs (relay
|
||||
queries, the shared `store`, `offer`/`debit info`, and the stateless
|
||||
primitives) run against an empty `~/.amy/` — `DataDir.resolveOptional`
|
||||
hands them an accountless dir (its `hasAccount = false`) pointing only at
|
||||
the shared event store, and `Context.openOrAnonymous` gives them an
|
||||
ephemeral key-less identity (they read fine, they just can't
|
||||
authenticate). Signing verbs go through `Context.open`, which re-asserts
|
||||
the account requirement — `init`/`create`/`login`/`logoff`/`whoami`
|
||||
resolve strictly, since they operate on the account dir itself.
|
||||
|
||||
Only the `--json` shape and the exit codes are public API. The default
|
||||
text format is allowed to change between releases. The five design
|
||||
|
||||
+19
-5
@@ -374,6 +374,8 @@ 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 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
|
||||
|
||||
@@ -588,7 +590,18 @@ matches that:
|
||||
|
||||
1. If `~/.amy/current` is set, use it.
|
||||
2. Else if exactly one account exists, use it (silent auto-pick).
|
||||
3. Else error and list the candidates so you can disambiguate.
|
||||
3. Else — for a **read-only** verb, run **anonymously**; for a **signing**
|
||||
verb, error and list the candidates so you can disambiguate.
|
||||
|
||||
**No account? Reads still work.** Verbs that only query relays or the shared
|
||||
event store — `fetch`, `subscribe`, `count`, `publish` (broadcasts a
|
||||
pre-signed event), `outbox`, `search`, `sync`, `store …`, the read halves of
|
||||
`profile`/`notes`/`git`/`podcast`/`podcast20`, `nsite`/`napplet` fetch/serve/
|
||||
list, `blossom download`/`check`, `offer`/`debit info`, and every stateless
|
||||
primitive — run against an empty `~/.amy/` with a throwaway key. They read
|
||||
fine; they just can't authenticate. Only verbs that **sign or encrypt with
|
||||
your key** (post, edit, follow, dm, marmot, zap, relay-list edits, blossom
|
||||
upload/list/delete, cashu, …) require an account — and say so.
|
||||
|
||||
`amy use NAME` writes `~/.amy/current`; `amy use --clear` removes it.
|
||||
For one-off override, prepend `--account NAME` to any command.
|
||||
@@ -654,11 +667,12 @@ Inside the amy process there's no test mode — it just sees a fresh
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`no account at ~/.amy`** — you haven't created one yet. Run
|
||||
- **`no account configured` / `multiple accounts in ~/.amy (alice, bob)`** —
|
||||
only **signing** verbs raise these; reads run anonymously instead (see
|
||||
"No account? Reads still work" above). Create one with
|
||||
`amy --account NAME init` (bare keypair) or `amy --account NAME create`
|
||||
(full Amethyst-style bootstrap).
|
||||
- **`multiple accounts in ~/.amy (alice, bob)`** — pin one with
|
||||
`amy use NAME` or pass `--account NAME` per command.
|
||||
(full Amethyst-style bootstrap), or pin/select one with `amy use NAME` /
|
||||
`--account NAME`.
|
||||
- **`current pins 'X' but ~/.amy/X doesn't exist`** — the active-account
|
||||
marker is stale. Rewrite with `amy use OTHER` or `amy use --clear`.
|
||||
- **`no_dm_relays`** — recipient hasn't published a kind:10050 inbox.
|
||||
|
||||
@@ -43,6 +43,8 @@ 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`. |
|
||||
| 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/` |
|
||||
|
||||
@@ -143,6 +143,16 @@ data class Identity(
|
||||
npub = pubHex.hexToByteArray().toNpub(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Ephemeral, key-less identity for anonymous read-only runs (no
|
||||
* account on disk). It mints a throwaway public key so the
|
||||
* relay-list fallbacks (`outboxRelays()` etc.) resolve to the
|
||||
* built-in defaults, and it carries no private key, so any attempt
|
||||
* to sign/encrypt fails loudly — "you can read, you just can't
|
||||
* auth". Used by [com.vitorpamplona.amethyst.cli.Context.openOrAnonymous].
|
||||
*/
|
||||
fun anonymous(): Identity = fromPublicKeyHex(KeyPair().pubKey.toHexKey())
|
||||
|
||||
/**
|
||||
* Rebuild an in-memory identity after a load. Accepts the public
|
||||
* parts that live on disk and a private key resolved from the
|
||||
@@ -204,6 +214,17 @@ class DataDir(
|
||||
val eventsDir: File,
|
||||
val accountName: String,
|
||||
val secrets: SecretStore,
|
||||
/**
|
||||
* Whether this points at a concrete account. `false` for the
|
||||
* accountless directory [resolveOptional] hands back when `~/.amy/`
|
||||
* has no unambiguous account — [root] then points at the shared
|
||||
* sibling and only [eventsDir] (the cross-account event store) is
|
||||
* meaningful. Read-only verbs run anonymously against it; signing
|
||||
* verbs get [noAccountDetail] via `Context.open`.
|
||||
*/
|
||||
val hasAccount: Boolean = true,
|
||||
/** Human-readable reason there is no account, for the signing-verb error. */
|
||||
val noAccountDetail: String? = null,
|
||||
) {
|
||||
val identityFile = File(root, "identity.json")
|
||||
val stateFile = File(root, "state.json")
|
||||
@@ -231,12 +252,16 @@ class DataDir(
|
||||
|
||||
init {
|
||||
SecureFileIO.secureMkdirs(root)
|
||||
SecureFileIO.secureMkdirs(groupsDir)
|
||||
// Tighten perms on any data already on disk from an older, unhardened CLI.
|
||||
SecureFileIO.tighten(identityFile)
|
||||
SecureFileIO.tighten(stateFile)
|
||||
SecureFileIO.tighten(marmotDir)
|
||||
SecureFileIO.tighten(keyPackageBundleFile)
|
||||
// The accountless dir only ever exposes the shared event store; don't
|
||||
// seed per-account marmot dirs / tighten identity files under it.
|
||||
if (hasAccount) {
|
||||
SecureFileIO.secureMkdirs(groupsDir)
|
||||
// Tighten perms on any data already on disk from an older, unhardened CLI.
|
||||
SecureFileIO.tighten(identityFile)
|
||||
SecureFileIO.tighten(stateFile)
|
||||
SecureFileIO.tighten(marmotDir)
|
||||
SecureFileIO.tighten(keyPackageBundleFile)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -391,6 +416,69 @@ class DataDir(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [resolve], but never throws when there is no account: read-only
|
||||
* verbs can run without one. When `--account` is given it is honoured;
|
||||
* otherwise the pin / sole-account are used if unambiguous. Failing
|
||||
* that, returns an *accountless* [DataDir] (`hasAccount = false`) whose
|
||||
* [root] is the shared sibling and whose [eventsDir] is still the
|
||||
* cross-account event store — enough for anonymous relay queries and
|
||||
* `store` maintenance. The reason no account was chosen is carried in
|
||||
* [DataDir.noAccountDetail] so a signing verb can surface it.
|
||||
*/
|
||||
fun resolveOptional(
|
||||
accountFlag: String?,
|
||||
secrets: SecretStore,
|
||||
): DataDir {
|
||||
val rootBase = DEFAULT_ROOT
|
||||
val sharedEvents = File(rootBase, "$SHARED_DIR_NAME/events-store").absoluteFile
|
||||
if (accountFlag != null) {
|
||||
val name = validateName(accountFlag)
|
||||
return DataDir(File(rootBase, name).absoluteFile, sharedEvents, name, secrets)
|
||||
}
|
||||
val picked = pickAccountOptional(rootBase)
|
||||
return if (picked.name != null) {
|
||||
DataDir(File(rootBase, picked.name).absoluteFile, sharedEvents, picked.name, secrets)
|
||||
} else {
|
||||
DataDir(
|
||||
root = File(rootBase, SHARED_DIR_NAME).absoluteFile,
|
||||
eventsDir = sharedEvents,
|
||||
accountName = SHARED_DIR_NAME,
|
||||
secrets = secrets,
|
||||
hasAccount = false,
|
||||
noAccountDetail = picked.detail,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of [pickAccountOptional]: an account [name], or null plus a [detail] reason. */
|
||||
private data class OptionalPick(
|
||||
val name: String?,
|
||||
val detail: String?,
|
||||
)
|
||||
|
||||
/** Non-throwing sibling of [pickAccount]: null [name] with a [detail] when 0 / ambiguous. */
|
||||
private fun pickAccountOptional(rootBase: File): OptionalPick {
|
||||
val current = File(rootBase, CURRENT_MARKER_NAME)
|
||||
if (current.isFile) {
|
||||
val pinned = current.readText().trim()
|
||||
if (pinned.isNotEmpty() && File(rootBase, pinned).isDirectory) {
|
||||
return OptionalPick(pinned, null)
|
||||
}
|
||||
}
|
||||
val accounts = listAccounts(rootBase)
|
||||
return when (accounts.size) {
|
||||
0 -> OptionalPick(null, "no account configured (create one with `amy --account <name> init`)")
|
||||
1 -> OptionalPick(accounts.single(), null)
|
||||
else ->
|
||||
OptionalPick(
|
||||
null,
|
||||
"multiple accounts in ${rootBase.absolutePath} (${accounts.joinToString(", ")}); " +
|
||||
"pick one with --account <name> or `amy use <name>`",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-select an account when `--name` was not given. Honours
|
||||
* `<root>/current` first (explicit pin from `amy use`), then
|
||||
|
||||
@@ -120,6 +120,14 @@ class Context(
|
||||
val dataDir: DataDir,
|
||||
val identity: Identity,
|
||||
val state: RunState,
|
||||
/**
|
||||
* Anonymous read-only run: no account on disk, [identity] is an ephemeral
|
||||
* key-less identity (see [Identity.anonymous]). Marmot state is not
|
||||
* restored and run-state is not persisted — the run only reads relays and
|
||||
* the shared event store. Signing verbs never take this path; they go
|
||||
* through [Companion.open], which requires a real account.
|
||||
*/
|
||||
val anonymous: Boolean = false,
|
||||
) : AutoCloseable {
|
||||
private val okhttp =
|
||||
OkHttpClient
|
||||
@@ -225,9 +233,12 @@ class Context(
|
||||
.OkHttpNip05Fetcher { _ -> okhttp },
|
||||
)
|
||||
|
||||
private val mlsStore = FileMlsGroupStateStore(dataDir.groupsDir)
|
||||
private val keyPackageStore = FileKeyPackageBundleStore(dataDir.keyPackageBundleFile)
|
||||
private val messageStore = FileMarmotMessageStore(dataDir.groupsDir)
|
||||
// Lazy so an anonymous read (no account dir) never materialises the
|
||||
// per-account marmot stores — constructing them would `mkdir` group dirs
|
||||
// under the shared root. Real accounts build them on first marmot use.
|
||||
private val mlsStore by lazy { FileMlsGroupStateStore(dataDir.groupsDir) }
|
||||
private val keyPackageStore by lazy { FileKeyPackageBundleStore(dataDir.keyPackageBundleFile) }
|
||||
private val messageStore by lazy { FileMarmotMessageStore(dataDir.groupsDir) }
|
||||
|
||||
/**
|
||||
* Shared Nostr event store for this run, opened via [StoreFactory]
|
||||
@@ -240,7 +251,7 @@ class Context(
|
||||
val store: IEventStore by storeDelegate
|
||||
|
||||
/** Fully-wired manager. Call [prepare] once before use to load persisted state. */
|
||||
val marmot: MarmotManager = MarmotManager(signer, mlsStore, messageStore, keyPackageStore)
|
||||
val marmot: MarmotManager by lazy { MarmotManager(signer, mlsStore, messageStore, keyPackageStore) }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Cashu (NIP-60 / NIP-61) — shared wallet code from commons
|
||||
@@ -359,7 +370,9 @@ class Context(
|
||||
*/
|
||||
suspend fun prepare() {
|
||||
if (prepared) return
|
||||
marmot.restoreAll()
|
||||
// Anonymous runs have no account and therefore no marmot state to
|
||||
// restore (and touching `marmot` would allocate the per-account stores).
|
||||
if (!anonymous) marmot.restoreAll()
|
||||
client.connect()
|
||||
// A bunker account must open its NIP-46 response subscription and run
|
||||
// the connect handshake before any signing/encryption call.
|
||||
@@ -949,7 +962,8 @@ class Context(
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
dataDir.saveRunState(state)
|
||||
// Nothing to persist for an anonymous run (no account dir to write into).
|
||||
if (!anonymous) dataDir.saveRunState(state)
|
||||
(signer as? NostrSignerRemote)?.let {
|
||||
try {
|
||||
it.closeSubscription()
|
||||
@@ -978,12 +992,21 @@ class Context(
|
||||
*/
|
||||
private const val GIFT_WRAP_LOOKBACK_SECS: Long = 2L * 24 * 60 * 60
|
||||
|
||||
/** Build a Context but require an identity to already exist — most commands can't run without one. */
|
||||
/**
|
||||
* Build a Context but require an account with a usable identity —
|
||||
* signing verbs can't run without one. Throws [IllegalArgumentException]
|
||||
* (→ exit 2) when no account was resolvable, carrying the "which
|
||||
* account?" hint from [DataDir.resolveOptional]; throws
|
||||
* [IllegalStateException] when the account exists but has no identity.
|
||||
*/
|
||||
fun open(dataDir: DataDir): Context {
|
||||
require(dataDir.hasAccount) {
|
||||
dataDir.noAccountDetail ?: "no account selected; pass --account <name> or run `amy use <name>`"
|
||||
}
|
||||
val identity =
|
||||
dataDir.loadIdentityOrNull()
|
||||
?: run {
|
||||
System.err.println("No identity found at ${dataDir.identityFile}. Run `amethyst-cli init` first.")
|
||||
System.err.println("No identity found at ${dataDir.identityFile}. Run `amy --account ${dataDir.accountName} init` first.")
|
||||
throw IllegalStateException("no identity")
|
||||
}
|
||||
return Context(
|
||||
@@ -992,5 +1015,24 @@ class Context(
|
||||
state = dataDir.loadRunState(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for read-only verbs: use the resolved account when one is
|
||||
* present, otherwise run anonymously (ephemeral key-less identity, no
|
||||
* persisted state). Lets `fetch`/`subscribe`/`count`/`publish`/`outbox`/
|
||||
* … query relays and the shared store with no account on disk — they
|
||||
* read fine, they just can't sign.
|
||||
*/
|
||||
fun openOrAnonymous(dataDir: DataDir): Context =
|
||||
if (dataDir.hasAccount && dataDir.identityExists()) {
|
||||
open(dataDir)
|
||||
} else {
|
||||
Context(
|
||||
dataDir = dataDir,
|
||||
identity = Identity.anonymous(),
|
||||
state = RunState(),
|
||||
anonymous = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.cli.commands.KeyCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.KeyPackageCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.KindCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.LoginCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.LogoffCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.MarmotResetCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.MessageCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.NamecoinCommand
|
||||
@@ -61,6 +62,7 @@ import com.vitorpamplona.amethyst.cli.commands.PublishCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.RelayCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.SearchCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.ServeCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.StatusCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.StoreCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.SubscribeCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.SyncCommand
|
||||
@@ -137,6 +139,16 @@ class AwaitTimeout(
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
|
||||
/**
|
||||
* Verbs that create, select, or delete the account/identity on disk. They
|
||||
* write to (or read) the per-account directory directly rather than through
|
||||
* `Context.open`, so they need a concrete account and must resolve strictly —
|
||||
* an accountless run has nowhere to put a new identity. Every other verb
|
||||
* resolves via [DataDir.resolveOptional] and either runs anonymously (reads)
|
||||
* or re-asserts the requirement inside `Context.open` (signing).
|
||||
*/
|
||||
private val STRICT_ACCOUNT_VERBS = setOf("init", "create", "login", "logoff", "whoami")
|
||||
|
||||
private suspend fun dispatch(argv: Array<String>): Int {
|
||||
if (argv.isEmpty() || argv[0] == "--help" || argv[0] == "-h") {
|
||||
printUsage()
|
||||
@@ -179,6 +191,14 @@ 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
|
||||
// accounts exist (zero, one, or many), so it dispatches before account
|
||||
// resolution rather than through the single-account DataDir path.
|
||||
if (head == "status") {
|
||||
return StatusCommand.run(tail)
|
||||
}
|
||||
|
||||
// Stateless local primitives (nak-style army-knife verbs). They operate
|
||||
// purely on their arguments — no identity, no relays, no `~/.amy/` — so
|
||||
// they dispatch before account resolution and work with zero state.
|
||||
@@ -206,13 +226,33 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
return CashuMintCommands.dispatch(tail.drop(1).toTypedArray())
|
||||
}
|
||||
|
||||
// `offer info NOFFER` / `debit info NDEBIT` decode a CLINK pointer locally —
|
||||
// no network, no account. The rest of `offer`/`debit` operates on the account.
|
||||
if (head == "offer" && tail.firstOrNull() == "info") {
|
||||
return OfferCommands.info(tail.drop(1).toTypedArray())
|
||||
}
|
||||
if (head == "debit" && tail.firstOrNull() == "info") {
|
||||
return DebitCommands.info(tail.drop(1).toTypedArray())
|
||||
}
|
||||
|
||||
val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag)
|
||||
val dataDir = DataDir.resolve(accountFlag = accountFlag, secrets = secrets)
|
||||
// Identity-lifecycle verbs create / select / delete the account itself, so
|
||||
// they need a concrete account and resolve strictly (helpful ambiguity
|
||||
// errors). Everything else resolves optionally: read-only verbs then run
|
||||
// anonymously when there is no account, while signing verbs re-assert the
|
||||
// requirement through `Context.open`.
|
||||
val dataDir =
|
||||
if (head in STRICT_ACCOUNT_VERBS) {
|
||||
DataDir.resolve(accountFlag = accountFlag, secrets = secrets)
|
||||
} else {
|
||||
DataDir.resolveOptional(accountFlag = accountFlag, secrets = secrets)
|
||||
}
|
||||
|
||||
return when (head) {
|
||||
"init" -> InitCommands.init(dataDir, Args(tail))
|
||||
"create" -> CreateCommand.run(dataDir, tail)
|
||||
"login" -> LoginCommand.run(dataDir, tail)
|
||||
"logoff" -> LogoffCommand.run(dataDir, tail)
|
||||
"whoami" -> InitCommands.whoami(dataDir)
|
||||
"relay" -> RelayCommands.dispatch(dataDir, tail)
|
||||
"marmot" -> marmotDispatch(dataDir, tail)
|
||||
@@ -341,7 +381,12 @@ private fun printUsage() {
|
||||
| 1. --account X if given.
|
||||
| 2. ~/.amy/current marker (set by `amy use X`).
|
||||
| 3. Sole subdirectory of ~/.amy/ other than shared/.
|
||||
| 4. Error — disambiguate with --account or `amy use`.
|
||||
| 4. Read-only verbs (fetch, subscribe, count, publish, outbox,
|
||||
| search, sync, store, profile/git/podcast reads, nsite/napplet
|
||||
| fetch, decode/encode/… primitives, offer/debit info) run
|
||||
| ANONYMOUSLY — they query relays and the shared store with no
|
||||
| account, they just can't sign. Signing verbs error here:
|
||||
| disambiguate with --account or `amy use`.
|
||||
|
|
||||
| Test harnesses isolate by overriding ${'$'}HOME for the amy
|
||||
| subprocess (`HOME=/tmp/run.123 amy --account alice ...`).
|
||||
@@ -349,6 +394,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)
|
||||
|
|
||||
|Output:
|
||||
| Default: human-readable text on stdout.
|
||||
@@ -395,6 +443,9 @@ private fun printUsage() {
|
||||
| create [--name NAME] provision a full Amethyst-style account + publish bootstrap events
|
||||
| login KEY [--password X] import (nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05|bunker://)
|
||||
| whoami print current identity
|
||||
| logoff [--yes] [--keep-events] log off: delete this account's key, per-account state,
|
||||
| and its events in the shared store (--keep-events skips the
|
||||
| cache purge). Requires --yes; without it, prints a dry run.
|
||||
|
|
||||
|Remote signing (NIP-46):
|
||||
| bunker [--relay URL[,URL…]] run a remote signer for this (local-key) account; prints a
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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 java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.TimeUnit
|
||||
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).
|
||||
*/
|
||||
data class StoreStats(
|
||||
val events: Long,
|
||||
/** Per-kind event counts derived from `idx/kind/<k>/`, sorted by kind string. */
|
||||
val byKind: Map<String, Long>,
|
||||
val diskBytes: Long,
|
||||
/** Oldest / newest event file mtime, in unix seconds. Null on an empty store. */
|
||||
val oldestAt: Long?,
|
||||
val newestAt: Long?,
|
||||
val root: Path,
|
||||
) {
|
||||
val distinctKinds: Int get() = byKind.size
|
||||
|
||||
companion object {
|
||||
/** Compute stats for the store rooted at [storeRoot]. Missing dir → all-zero. */
|
||||
fun of(storeRoot: Path): StoreStats {
|
||||
if (!storeRoot.exists()) {
|
||||
return StoreStats(0, emptyMap(), 0L, null, null, storeRoot.toAbsolutePath())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return StoreStats(
|
||||
events = count,
|
||||
byKind = byKind,
|
||||
diskBytes = walkSize(storeRoot),
|
||||
oldestAt = oldest,
|
||||
newestAt = newest,
|
||||
root = storeRoot.toAbsolutePath(),
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,8 @@ object BlossomCommands {
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
Context.open(dataDir).use { _ ->
|
||||
// Read-only HEAD probe — no auth, so it runs anonymously without an account.
|
||||
Context.openOrAnonymous(dataDir).use { _ ->
|
||||
val http = OkHttpClient()
|
||||
val results =
|
||||
hashes.map { hash ->
|
||||
@@ -188,7 +189,8 @@ object BlossomCommands {
|
||||
val server = args.flag("server")
|
||||
val url = if (server != null && !target.startsWith("http")) BlossomServerUrl.blob(server, target) else target
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
// Public download — no auth, so it runs anonymously without an account.
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
val bytes =
|
||||
BlossomClient().download(url)
|
||||
?: return Output.error("not_found", "server returned no blob for $url")
|
||||
|
||||
@@ -47,7 +47,7 @@ object CountCommand {
|
||||
val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 15L) * 1000
|
||||
val filter = RawEventSupport.buildFilter(args)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val relays = RawEventSupport.queryTargets(ctx, args)
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`")
|
||||
|
||||
@@ -60,7 +60,7 @@ object DebitCommands {
|
||||
)
|
||||
|
||||
/** Local decode of an `ndebit` pointer — no network, no account needed. */
|
||||
private fun info(rest: Array<String>): Int {
|
||||
internal fun info(rest: Array<String>): Int {
|
||||
val args = Args(rest)
|
||||
val debit =
|
||||
ClinkPointerParser.parse(args.positional(0, "ndebit").trim()) as? NDebit
|
||||
|
||||
@@ -61,7 +61,10 @@ object FeedCommand {
|
||||
val until = args.flag("until")?.toLongOrNull()
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
// Read-only: runs anonymously when there is no account. `--author` /
|
||||
// `--following` still work; the bare "self" feed just has no self to
|
||||
// resolve without an account.
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
|
||||
val (authors, mode) =
|
||||
|
||||
@@ -94,7 +94,7 @@ object FetchCommand {
|
||||
val filter = RawEventSupport.buildFilter(args).copy(limit = effectiveLimit)
|
||||
val paginate = args.bool("paginate") || args.bool("all")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val relays = RawEventSupport.queryTargets(ctx, args)
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`")
|
||||
@@ -148,7 +148,7 @@ object FetchCommand {
|
||||
timeoutMs: Long,
|
||||
): Int {
|
||||
val code = codeArg.removePrefix("nostr:")
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
|
||||
var filter: Filter
|
||||
|
||||
@@ -113,7 +113,9 @@ object GitCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
Context.open(dataDir).use { ctx ->
|
||||
// Read-only: runs anonymously when there is no account (defaults to
|
||||
// the anonymous key, so pass a USER to list someone's repos).
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
|
||||
val relays = RawEventSupport.queryTargets(ctx, args)
|
||||
@@ -143,7 +145,7 @@ object GitCommands {
|
||||
return Output.error("bad_args", "not a git repository address (expected kind ${GitRepositoryEvent.KIND}, got ${addr.kind})")
|
||||
}
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord")
|
||||
Output.emit(repoSummary(repo) + mapOf("event_id" to repo.id, "content" to repo.content))
|
||||
|
||||
@@ -77,7 +77,7 @@ object KeyCommands {
|
||||
Output.emit(mapOf("valid" to false))
|
||||
return 0
|
||||
}
|
||||
val npub = hex!!.hexToByteArray().toNpub()
|
||||
val npub = hex.hexToByteArray().toNpub()
|
||||
Output.emit(mapOf("valid" to true, "pubkey" to hex, "npub" to npub))
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* `amy logoff [--yes] [--keep-events]` — log off an account and clear its
|
||||
* local data.
|
||||
*
|
||||
* "Logging off" a CLI with no server session means removing everything the
|
||||
* account left on this machine:
|
||||
* - the identity file and any backend-held secret (keychain / ncryptsec /
|
||||
* plaintext) — via [DataDir.deleteIdentity],
|
||||
* - the rest of the per-account directory `~/.amy/<account>/` (run-state
|
||||
* cursors, aliases, cashu counters, all MLS/Marmot state),
|
||||
* - the active-account pin at `~/.amy/current`, if it points here,
|
||||
* - and the account's events in the SHARED store at
|
||||
* `~/.amy/shared/events-store/`.
|
||||
*
|
||||
* The event store is shared across every account on the machine, so this
|
||||
* does NOT wipe it wholesale — it deletes only the events that involve this
|
||||
* account: those it authored (`authors`) plus those addressed to it via a
|
||||
* `#p` tag (inbound gift wraps, nutzaps, reactions, mentions…). Other
|
||||
* accounts' cached events are untouched. Pass `--keep-events` to leave the
|
||||
* shared cache alone and only remove the identity + per-account state.
|
||||
*
|
||||
* The account is selected the normal way (the `--account` flag, the
|
||||
* `current` pin, or the sole account) — when more than one account exists
|
||||
* and none is pinned, [DataDir.resolve] already errors out asking the caller
|
||||
* to disambiguate, so logoff never guesses which account to destroy.
|
||||
*
|
||||
* Reads the public key straight from `identity.json` (never unlocking the
|
||||
* private key), so it needs no passphrase and pops no keychain prompt.
|
||||
*
|
||||
* Requires `--yes` to execute, because it is destructive and cannot be
|
||||
* undone — the private key is gone with the identity file. Without `--yes`
|
||||
* the command reports what it would delete and exits with code 2.
|
||||
*/
|
||||
object LogoffCommand {
|
||||
suspend fun run(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
val confirmed = tail.any { it == "--yes" || it == "-y" }
|
||||
val keepEvents = tail.any { it == "--keep-events" }
|
||||
|
||||
// Read the on-disk identity metadata only — no SecretStore round-trip,
|
||||
// so we never prompt for a passphrase or trip a keychain dialog just
|
||||
// to log off.
|
||||
val idFile =
|
||||
dataDir.loadIdentityFileOrNull()
|
||||
?: return Output.error(
|
||||
"no_account",
|
||||
"no identity at ${dataDir.identityFile.absolutePath}; nothing to log off",
|
||||
)
|
||||
val pubkey = idFile.pubKeyHex
|
||||
|
||||
val marker = File(DataDir.DEFAULT_ROOT, DataDir.CURRENT_MARKER_NAME)
|
||||
val isPinned = marker.isFile && marker.readText().trim() == dataDir.accountName
|
||||
|
||||
// Everything the account touched in the shared store: authored by it,
|
||||
// or addressed to it via a #p tag (gift wraps, nutzaps, reactions…).
|
||||
val involvedFilters =
|
||||
listOf(
|
||||
Filter(authors = listOf(pubkey)),
|
||||
Filter(tags = mapOf("p" to listOf(pubkey))),
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
val eventCount = if (keepEvents) 0 else withStore(dataDir) { it.count(involvedFilters) }
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"dry_run" to true,
|
||||
"account" to dataDir.accountName,
|
||||
"npub" to idFile.npub,
|
||||
"pubkey" to pubkey,
|
||||
"account_dir" to dataDir.root.absolutePath,
|
||||
"pinned" to isPinned,
|
||||
"events_to_purge" to eventCount,
|
||||
"keep_events" to keepEvents,
|
||||
"detail" to "pass --yes to permanently delete this account's key, local state" +
|
||||
(if (keepEvents) "" else ", and cached events"),
|
||||
),
|
||||
)
|
||||
return 2
|
||||
}
|
||||
|
||||
// 1. Purge the account's events from the shared store.
|
||||
var purged = 0
|
||||
if (!keepEvents) {
|
||||
withStore(dataDir) { store ->
|
||||
val before = store.count(involvedFilters)
|
||||
store.delete(involvedFilters)
|
||||
purged = (before - store.count(involvedFilters)).coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove the identity file and any backend-held secret.
|
||||
dataDir.deleteIdentity()
|
||||
|
||||
// 3. Wipe the rest of the per-account directory (run-state, aliases,
|
||||
// cashu counters, Marmot/MLS state). The shared events-store lives
|
||||
// outside this directory, so it is not affected.
|
||||
val dirFullyRemoved = dataDir.root.deleteRecursively()
|
||||
|
||||
// 4. Drop the active-account pin if it pointed at this account.
|
||||
val clearedPin = isPinned && marker.delete()
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"logoff" to true,
|
||||
"account" to dataDir.accountName,
|
||||
"npub" to idFile.npub,
|
||||
"events_purged" to purged,
|
||||
"removed_dir" to dataDir.root.absolutePath,
|
||||
"dir_fully_removed" to dirFullyRemoved,
|
||||
"cleared_pin" to clearedPin,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the shared [FsEventStore] directly — logoff needs the store but no
|
||||
* identity, signer, or relays, so it skips [com.vitorpamplona.amethyst.cli.Context.open]
|
||||
* (which requires a bootstrapped identity). Mirrors `StoreCommands.withStore`.
|
||||
*/
|
||||
private inline fun <T> withStore(
|
||||
dataDir: DataDir,
|
||||
body: (FsEventStore) -> T,
|
||||
): T {
|
||||
val store =
|
||||
FsEventStore(
|
||||
root = dataDir.eventsDir.toPath(),
|
||||
eventToJson = JacksonMapper::toJsonPretty,
|
||||
)
|
||||
try {
|
||||
return body(store)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ object NappletCommands {
|
||||
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val authorHex = ctx.requireUserHex(author)
|
||||
val relays =
|
||||
@@ -134,7 +134,7 @@ object NappletCommands {
|
||||
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val authorHex = ctx.requireUserHex(author)
|
||||
val relays =
|
||||
@@ -193,7 +193,7 @@ object NappletCommands {
|
||||
val extraServers = StaticSiteFetch.commaList(args.flag("server"))
|
||||
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val relays =
|
||||
extraRelays
|
||||
|
||||
@@ -81,7 +81,7 @@ object NostrConnect {
|
||||
}
|
||||
}
|
||||
if (secret == null) return null
|
||||
return Offer(clientPubkey, relays, secret!!, name)
|
||||
return Offer(clientPubkey, relays, secret, name)
|
||||
}
|
||||
|
||||
private fun buildOffer(
|
||||
|
||||
@@ -79,7 +79,7 @@ object NsiteCommands {
|
||||
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val authorHex = ctx.requireUserHex(author)
|
||||
val relays =
|
||||
@@ -145,7 +145,7 @@ object NsiteCommands {
|
||||
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val authorHex = ctx.requireUserHex(author)
|
||||
val relays =
|
||||
@@ -203,7 +203,7 @@ object NsiteCommands {
|
||||
val extraServers = StaticSiteFetch.commaList(args.flag("server"))
|
||||
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val authorHex = ctx.requireUserHex(author)
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ object OfferCommands {
|
||||
}
|
||||
|
||||
/** Local decode of a `noffer` pointer — no network, no account needed. */
|
||||
private fun info(rest: Array<String>): Int {
|
||||
internal fun info(rest: Array<String>): Int {
|
||||
val args = Args(rest)
|
||||
val offer =
|
||||
ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer
|
||||
|
||||
@@ -44,7 +44,7 @@ object OutboxCommand {
|
||||
val refresh = args.bool("refresh")
|
||||
val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 8L) * 1000
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val pubkey = ctx.requireUserHex(user)
|
||||
|
||||
|
||||
@@ -219,7 +219,9 @@ object Podcast20Commands {
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val limit = args.intFlag("limit", 50)
|
||||
Context.open(dataDir).use { ctx ->
|
||||
// Read-only: runs anonymously when there is no account (pass a USER to
|
||||
// list someone else's episodes).
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
|
||||
val relays = RawEventSupport.queryTargets(ctx, args)
|
||||
|
||||
@@ -136,7 +136,9 @@ object PodcastCommands {
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val limit = args.intFlag("limit", 50)
|
||||
Context.open(dataDir).use { ctx ->
|
||||
// Read-only: runs anonymously when there is no account (pass a USER to
|
||||
// list someone else's podcasts).
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
|
||||
val relays = RawEventSupport.queryTargets(ctx, args)
|
||||
|
||||
@@ -63,7 +63,9 @@ object ProfileCommands {
|
||||
val args = Args(rest)
|
||||
val refresh = args.bool("refresh")
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
Context.open(dataDir).use { ctx ->
|
||||
// Read-only: runs anonymously when there is no account (an explicit
|
||||
// USER is then required, since there is no "own" profile to default to).
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val pubKey =
|
||||
args.positionalOrNull(0)?.let { ctx.requireUserHex(it) }
|
||||
|
||||
@@ -55,7 +55,7 @@ object PublishCommand {
|
||||
return Output.error("invalid_event", "event id/signature does not verify — refusing to publish")
|
||||
}
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val targets = RawEventSupport.publishTargets(ctx, args)
|
||||
if (targets.isEmpty()) {
|
||||
|
||||
@@ -145,7 +145,7 @@ object SearchCommand {
|
||||
timeoutMs: Long,
|
||||
render: (List<Event>) -> List<Map<String, Any?>>,
|
||||
): Int {
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val relays =
|
||||
SearchActions.resolveSearchRelays(
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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.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
|
||||
|
||||
/**
|
||||
* `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?"
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
object StatusCommand {
|
||||
fun run(tail: Array<String>): Int {
|
||||
// `status` takes no positional args; tolerate an accidental one
|
||||
// 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 }
|
||||
|
||||
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(),
|
||||
),
|
||||
),
|
||||
)
|
||||
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()
|
||||
}
|
||||
@@ -53,7 +53,7 @@ object SubscribeCommand {
|
||||
val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 }
|
||||
val filter = RawEventSupport.buildFilter(args)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val relays = RawEventSupport.queryTargets(ctx, args)
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`")
|
||||
|
||||
@@ -95,7 +95,7 @@ object SyncCommand {
|
||||
val down = args.bool("down") || !up
|
||||
val filter = RawEventSupport.buildFilter(args)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val localEvents = ctx.store.query<Event>(filter)
|
||||
val localById = localEvents.associateBy { it.id }
|
||||
|
||||
+1
@@ -185,6 +185,7 @@ class NostrSignerPermissionLedger(
|
||||
* Deliberately conservative: when a kind's blast radius is unclear, it is left out so the user
|
||||
* is asked rather than surprised.
|
||||
*/
|
||||
@Suppress("DEPRECATION") // TorrentCommentEvent is deprecated (NIP-22) but still a reasonable sign kind
|
||||
val REASONABLE_SIGN_KINDS: Set<Int> =
|
||||
setOf(
|
||||
TextNoteEvent.KIND, // 1 — short text notes & replies
|
||||
|
||||
+1
-1
@@ -324,7 +324,7 @@ actual class SecureKeyStorage private actual constructor() {
|
||||
} else {
|
||||
// Fallback for non-interactive environments (testing, etc.)
|
||||
print("Enter master password: ")
|
||||
readLine() ?: throw SecureStorageException("Password required for fallback storage")
|
||||
readlnOrNull() ?: throw SecureStorageException("Password required for fallback storage")
|
||||
}
|
||||
}
|
||||
return fallbackPassword!!
|
||||
|
||||
@@ -326,6 +326,7 @@ data class KindName(
|
||||
* platform concern layered on top, never a fork of this data.
|
||||
*/
|
||||
object KindNames {
|
||||
@Suppress("DEPRECATION") // registry intentionally names deprecated kinds (GitReply, TorrentComment) for display
|
||||
val names: Map<Int, KindName> =
|
||||
mapOf(
|
||||
AcceptedBadgeSetEvent.KIND to KindName("Accepted Badge Set", "58"),
|
||||
|
||||
+1
-3
@@ -83,9 +83,7 @@ class CommandSerializer : StdSerializer<Command>(Command::class.java) {
|
||||
gen.writeString(cmd.subId)
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
gen.writeEndArray()
|
||||
|
||||
@@ -2523,9 +2523,7 @@ class QuicConnection(
|
||||
PathValidator.RecordResult.Stored,
|
||||
PathValidator.RecordResult.Duplicate,
|
||||
PathValidator.RecordResult.AlreadyRetired,
|
||||
-> {
|
||||
Unit
|
||||
}
|
||||
-> {}
|
||||
|
||||
PathValidator.RecordResult.PoolFull -> {
|
||||
// Peer over-issued past its own advertised
|
||||
@@ -2573,11 +2571,9 @@ class QuicConnection(
|
||||
// same path before the next outbound packet (which would
|
||||
// otherwise stamp a now-retired CID).
|
||||
when (val rotation = pathValidator.forceRotateToHigherSequence()) {
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
|
||||
// active CID is still valid; nothing to do.
|
||||
null -> {}
|
||||
|
||||
PathValidator.ForcedRotationResult.NoSpareCid -> {
|
||||
// Watermark forced retirement of the active CID but
|
||||
// the pool is empty — we have nothing valid to use.
|
||||
@@ -2613,9 +2609,7 @@ class QuicConnection(
|
||||
when (val outcome = pathValidator.applyPathResponse(payload)) {
|
||||
PathValidator.ValidationOutcome.NotValidating,
|
||||
PathValidator.ValidationOutcome.PayloadMismatch,
|
||||
-> {
|
||||
Unit
|
||||
}
|
||||
-> {}
|
||||
|
||||
is PathValidator.ValidationOutcome.Validated -> {
|
||||
// Bug-7 fix: a valid PATH_RESPONSE proves the peer
|
||||
|
||||
+1
-3
@@ -852,9 +852,7 @@ private fun dispatchFrames(
|
||||
// peer knows it just violated the spec instead of
|
||||
// having its bytes silently dropped.
|
||||
when (stream.receive.insert(frame.offset, frame.data, frame.fin)) {
|
||||
com.vitorpamplona.quic.stream.ReceiveBuffer.InsertResult.OK -> {
|
||||
Unit
|
||||
}
|
||||
com.vitorpamplona.quic.stream.ReceiveBuffer.InsertResult.OK -> {}
|
||||
|
||||
com.vitorpamplona.quic.stream.ReceiveBuffer.InsertResult.OFFSET_PAST_FIN -> {
|
||||
conn.markClosedExternally(
|
||||
|
||||
@@ -165,9 +165,7 @@ class Http3FrameReader(
|
||||
)
|
||||
}
|
||||
when (context) {
|
||||
StreamContext.UNCHECKED -> {
|
||||
Unit
|
||||
}
|
||||
StreamContext.UNCHECKED -> {}
|
||||
|
||||
StreamContext.CONTROL -> {
|
||||
// §7.2.4: SETTINGS MUST be the first frame on the
|
||||
|
||||
@@ -491,9 +491,7 @@ class WtPeerStreamDemux(
|
||||
}
|
||||
|
||||
// no new requests; we don't enforce yet
|
||||
else -> {
|
||||
Unit
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user