feat(cli,commons): amy login + amy create, share defaults with Amethyst

Move the "defaults a new Amethyst account gets seeded with" into commons
so the UI and amy agree byte-for-byte on what a fresh account looks like.

commons additions:
- commons/defaults/Constants.kt         — relay URL constants (moved from amethyst/)
- commons/defaults/AmethystDefaults.kt  — DefaultChannels, DefaultNIP65RelaySet,
                                          DefaultNIP65List, DefaultGlobalRelays,
                                          DefaultDMRelayList, DefaultSearchRelayList,
                                          DefaultIndexerRelayList (moved from
                                          amethyst/model/AccountSettings.kt)
- commons/account/AccountBootstrapEvents.kt — data class + bootstrapAccountEvents(signer, name)
  that builds the nine events a new Amethyst account publishes: kind:0, 3,
  10002, 10050, 10051, 10099, 50, 51, + channel list. One source of truth.

Retrofits:
- AccountSessionManager.createNewAccount now delegates event construction to
  the commons helper; it still packages them into AccountSettings for the
  Android storage path.
- DefaultSignerPermissions stays in AccountSettings.kt — it uses Android
  NIP-55 Permission/CommandType types.
- 19 import updates across amethyst/ to point at the new commons paths.

New CLI verbs:
- amy create [--name NAME]: mint a keypair, write identity.json, seed
  relays.json with the Amethyst defaults, publish all nine bootstrap events
  to DefaultNIP65RelaySet via NostrClient.publishAndConfirmDetailed.
- amy login KEY [--password X]: accept any identifier form Amethyst's login
  screen accepts — nsec1, ncryptsec (+ --password, NIP-49), BIP-39 mnemonic
  (NIP-06), npub1, nprofile1, 64-hex pubkey (default) or 64-hex privkey
  (with --private), NIP-05 (name@domain.tld, HTTP lookup). Read-only keys
  land with privKeyHex=null; Identity now carries that cleanly.

https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
This commit is contained in:
Claude
2026-04-21 21:41:47 +00:00
parent 39e11d29c1
commit 559c69660f
27 changed files with 511 additions and 105 deletions
@@ -0,0 +1,114 @@
/*
* 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.commons.account
import com.vitorpamplona.amethyst.commons.defaults.DefaultChannels
import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultGlobalRelays
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65List
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
/**
* Full set of "new Amethyst account" bootstrap events: the nine signed
* events that [com.vitorpamplona.amethyst.ui.screen.AccountSessionManager.createNewAccount]
* produces for a brand-new user, in the exact same order and with the same
* relay defaults. Used by:
*
* - Amethyst's in-app "Create Account" flow (builds an `AccountSettings`
* from these).
* - `amy create` (writes the keypair to disk, publishes these to the
* default NIP-65 relay set).
*
* Each event is optional in the sense that the builder lets you null out
* individual ones via the args — but the sensible default is "all of them".
*/
data class AccountBootstrapEvents(
val userMetadata: MetadataEvent,
val contactList: ContactListEvent,
val nip65RelayList: AdvertisedRelayListEvent,
val dmRelayList: ChatMessageRelayListEvent,
val keyPackageRelayList: KeyPackageRelayListEvent,
val searchRelayList: SearchRelayListEvent,
val indexerRelayList: IndexerRelayListEvent,
val channelList: ChannelListEvent,
val relayFeedsList: RelayFeedsListEvent,
) {
/** All nine signed events in publication order. */
fun all(): List<com.vitorpamplona.quartz.nip01Core.core.Event> =
listOf(
userMetadata,
contactList,
nip65RelayList,
dmRelayList,
keyPackageRelayList,
searchRelayList,
indexerRelayList,
channelList,
relayFeedsList,
)
}
/**
* Build and sign the nine events that comprise a freshly-created Amethyst
* account. The caller is responsible for publishing them — typically to
* [DefaultNIP65RelaySet] — after at least a short delay so relay
* connections are established.
*
* [name] is the optional display name; null or blank produces a kind:0
* event with no `name` field.
*/
fun bootstrapAccountEvents(
signer: NostrSignerSync,
name: String? = null,
): AccountBootstrapEvents =
AccountBootstrapEvents(
userMetadata = signer.sign(MetadataEvent.newUser(name)),
contactList =
ContactListEvent.createFromScratch(
followUsers = listOf(ContactTag(signer.keyPair.pubKey.toHexKey(), null, null)),
relayUse = emptyMap(),
signer = signer,
),
nip65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, signer),
dmRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, signer),
// MIP-00: advertise the default outbox relays as KeyPackage hosts
// so other users can discover and fetch this account's KeyPackage
// events without having to guess where they were published.
keyPackageRelayList = KeyPackageRelayListEvent.create(DefaultNIP65RelaySet.toList(), signer),
searchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), signer),
indexerRelayList = IndexerRelayListEvent.create(DefaultIndexerRelayList.toList(), signer),
channelList = ChannelListEvent.create(emptyList(), DefaultChannels, signer),
relayFeedsList = RelayFeedsListEvent.create(DefaultGlobalRelays, signer),
)
@@ -0,0 +1,63 @@
/*
* 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.commons.defaults
import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
/**
* Default relay/channel bundles applied to every freshly-created Amethyst
* account. Both the Android UI (`AccountSessionManager.createNewAccount`)
* and the `amy` CLI (`amy create`) seed new accounts from these so users
* land in the same connected state regardless of which client they start
* with.
*
* Pure data — no platform deps, no runtime config, no i18n. Change here,
* both clients follow.
*/
val DefaultChannels =
listOf(
// Anigma's Nostr
ChannelTag("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", Constants.nos),
// Amethyst's Group
ChannelTag("42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", Constants.nos),
)
val DefaultNIP65RelaySet = setOf(Constants.mom, Constants.nos, Constants.bitcoiner)
val DefaultNIP65List =
listOf(
AdvertisedRelayInfo(Constants.mom, AdvertisedRelayType.BOTH),
AdvertisedRelayInfo(Constants.nos, AdvertisedRelayType.BOTH),
AdvertisedRelayInfo(Constants.bitcoiner, AdvertisedRelayType.BOTH),
)
val DefaultGlobalRelays = listOf(Constants.wine, Constants.news)
val DefaultDMRelayList = listOf(Constants.auth, Constants.oxchat, Constants.nos)
val DefaultSearchRelayList =
setOf(Constants.wine, Constants.where, Constants.nostoday, Constants.antiprimal, Constants.ditto)
val DefaultIndexerRelayList =
setOf(Constants.purplepages, Constants.coracle, Constants.userkinds, Constants.yabu, Constants.nostr1)
@@ -0,0 +1,60 @@
/*
* 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.commons.defaults
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
/**
* Relay URLs that Amethyst (and `amy`) seed new accounts with and use as
* sensible defaults across the app. Pure data — lives in commons so JVM /
* iOS / desktop builds get the same URLs without duplicating the list.
*/
object Constants {
val nos = RelayUrlNormalizer.normalize("wss://nos.lol")
val mom = RelayUrlNormalizer.normalize("wss://nostr.mom")
val primal = RelayUrlNormalizer.normalize("wss://relay.primal.net")
val damus = RelayUrlNormalizer.normalize("wss://relay.damus.io")
val wine = RelayUrlNormalizer.normalize("wss://nostr.wine")
val where = RelayUrlNormalizer.normalize("wss://relay.noswhere.com")
val elites = RelayUrlNormalizer.normalize("wss://nostrelites.org")
val bitcoiner = RelayUrlNormalizer.normalize("wss://nostr.bitcoiner.social")
val oxtr = RelayUrlNormalizer.normalize("wss://nostr.oxtr.dev")
val nostoday = RelayUrlNormalizer.normalize("wss://search.nos.today")
val antiprimal = RelayUrlNormalizer.normalize("wss://antiprimal.net")
val ditto = RelayUrlNormalizer.normalize("wss://relay.ditto.pub")
val auth = RelayUrlNormalizer.normalize("wss://auth.nostr1.com")
val oxchat = RelayUrlNormalizer.normalize("wss://relay.0xchat.com")
val news = RelayUrlNormalizer.normalize("wss://news.utxo.one")
val purplepages = RelayUrlNormalizer.normalize("wss://purplepag.es")
val coracle = RelayUrlNormalizer.normalize("wss://indexer.coracle.social")
val userkinds = RelayUrlNormalizer.normalize("wss://user.kindpag.es")
val yabu = RelayUrlNormalizer.normalize("wss://directory.yabu.me")
val nostr1 = RelayUrlNormalizer.normalize("wss://profiles.nostr1.com")
val bootstrapInbox = setOf(damus, primal, mom, nos, bitcoiner, oxtr, yabu)
val eventFinderRelays = setOf(wine, damus, primal, mom, nos, bitcoiner, oxtr)
}