Merge pull request #3493 from vitorpamplona/claude/cli-account-requirement-31pdbn

CLI: enable read-only verbs to run without an account
This commit is contained in:
Vitor Pamplona
2026-07-07 19:02:15 -04:00
committed by GitHub
22 changed files with 243 additions and 45 deletions
+9
View File
@@ -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
+17 -5
View File
@@ -554,7 +554,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.
@@ -620,11 +631,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.
@@ -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")
@@ -215,12 +236,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)
}
}
/**
@@ -375,6 +400,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
@@ -115,6 +115,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.Builder().socketFactory(TcpNoDelaySocketFactory).build()
@@ -158,9 +166,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) }
/**
* Filesystem-backed Nostr event store, rooted at [DataDir.eventsDir].
@@ -183,7 +194,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
@@ -302,7 +313,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.
@@ -852,7 +865,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()
@@ -881,12 +895,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(
@@ -895,5 +918,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,
)
}
}
}
@@ -129,6 +129,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()
@@ -197,8 +207,27 @@ 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))
@@ -328,7 +357,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 ...`).
@@ -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))
@@ -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
@@ -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(
@@ -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 }