diff --git a/cli/DEVELOPMENT.md b/cli/DEVELOPMENT.md index 6b9eb42376..5f9203cd89 100644 --- a/cli/DEVELOPMENT.md +++ b/cli/DEVELOPMENT.md @@ -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 diff --git a/cli/README.md b/cli/README.md index a198bcaaea..29f233ff9c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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. diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index dd7c0fa984..8a8779b8f7 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -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 init`)") + 1 -> OptionalPick(accounts.single(), null) + else -> + OptionalPick( + null, + "multiple accounts in ${rootBase.absolutePath} (${accounts.joinToString(", ")}); " + + "pick one with --account or `amy use `", + ) + } + } + /** * Auto-select an account when `--name` was not given. Honours * `/current` first (explicit pin from `amy use`), then diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index dd0bac444e..88238e643b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -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 or run `amy use `" + } 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, + ) + } } } 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 446f26ee28..0974d7d2b3 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -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): Int { if (argv.isEmpty() || argv[0] == "--help" || argv[0] == "-h") { printUsage() @@ -197,8 +207,27 @@ private suspend fun dispatch(argv: Array): 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 ...`). diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt index f7ed5a8288..56d1847c30 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt @@ -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") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt index 3d2c4e004e..f912f83253 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt @@ -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`") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt index f89d79c8b0..c6b668d42c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt @@ -60,7 +60,7 @@ object DebitCommands { ) /** Local decode of an `ndebit` pointer — no network, no account needed. */ - private fun info(rest: Array): Int { + internal fun info(rest: Array): Int { val args = Args(rest) val debit = ClinkPointerParser.parse(args.positional(0, "ndebit").trim()) as? NDebit diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt index 25bce1e8b8..cf207405f0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt @@ -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) = diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt index 9126779780..9118f8f925 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt @@ -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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt index 9b14eeac03..6c4d0c8054 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt @@ -113,7 +113,9 @@ object GitCommands { rest: Array, ): 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)) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt index 457d1915c8..97d49b5b4e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt @@ -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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt index e00b767220..4780dec65e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt @@ -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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt index 408f49704a..3fdcfc362d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt @@ -109,7 +109,7 @@ object OfferCommands { } /** Local decode of a `noffer` pointer — no network, no account needed. */ - private fun info(rest: Array): Int { + internal fun info(rest: Array): Int { val args = Args(rest) val offer = ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt index 8545803da8..9d3459d08e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt @@ -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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt index 42fdfc1dc9..4e320e43b6 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt @@ -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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt index 098b83194d..9c91826ec1 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt @@ -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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt index a496cc1184..d70ae89b25 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt @@ -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) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt index 37da518132..c47b725a27 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt @@ -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()) { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt index 4e0bedbcbd..1dae1c9b52 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt @@ -145,7 +145,7 @@ object SearchCommand { timeoutMs: Long, render: (List) -> List>, ): Int { - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val relays = SearchActions.resolveSearchRelays( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt index a00a9ce86a..0d58a3ee6f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt @@ -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`") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt index 9f878186d0..545b35cb50 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt @@ -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(filter) val localById = localEvents.associateBy { it.id }