feat(cli): add amy logoff to clear an account's local data

Adds `amy logoff [--yes] [--keep-events]`, the CLI counterpart to logging
out: it removes everything an account left on the 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 Marmot/MLS state)
  - the ~/.amy/current pin, when it points at this account
  - the account's events in the SHARED ~/.amy/shared/events-store/

The event store is shared across accounts, so logoff does not wipe it
wholesale — it deletes only the events that involve this account: those it
authored plus those addressed to it via a #p tag (gift wraps, nutzaps,
reactions, mentions). Other accounts' cached events are left untouched.
`--keep-events` skips the shared-cache purge entirely.

The public key is read straight from identity.json (never unlocking the
private key), so logoff needs no passphrase and pops no keychain prompt.
Destructive and irreversible, so it follows the `marmot reset` precedent:
`--yes` is required to execute; without it the command prints a dry run of
what would be deleted and exits 2.

Thin-assembly only — event deletion is quartz's FsEventStore.delete; this
just resolves the account, counts, and wires the filesystem teardown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PH3rqz5KaA7CYFPAtxgoz1
This commit is contained in:
Claude
2026-07-07 22:11:52 +00:00
parent b5d5fc2d2a
commit c8c4111e0c
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()
}
}
}