diff --git a/cli/README.md b/cli/README.md index 13da5dd96d..a198bcaaea 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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//` 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 diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 6e5a7cd979..b27e9fcdb5 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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 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/` | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index fc85fa7494..446f26ee28 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -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): 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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LogoffCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LogoffCommand.kt new file mode 100644 index 0000000000..3312c8f61b --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LogoffCommand.kt @@ -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//` (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, + ): 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 withStore( + dataDir: DataDir, + body: (FsEventStore) -> T, + ): T { + val store = + FsEventStore( + root = dataDir.eventsDir.toPath(), + eventToJson = JacksonMapper::toJsonPretty, + ) + try { + return body(store) + } finally { + store.close() + } + } +}