mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
@@ -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
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user