Merge pull request #3491 from vitorpamplona/claude/account-logout-uc4ych

Add logoff command to CLI for account deletion
This commit is contained in:
Vitor Pamplona
2026-07-07 18:14:14 -04:00
committed by GitHub
4 changed files with 174 additions and 0 deletions
+1
View File
@@ -374,6 +374,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 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
View File
@@ -43,6 +43,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. |
| 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/` |
@@ -44,6 +44,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
@@ -203,6 +204,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
"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)
@@ -380,6 +382,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,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()
}
}
}