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
@@ -31,20 +31,36 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip19Bech32.toNsec
import java.io.File
/** Persisted identity (hex keys + cached bech32 forms for convenience). */
/**
* Persisted identity.
*
* [privKeyHex] may be null for read-only accounts imported from an `npub`,
* `nprofile` or NIP-05 — in that case [nsec] is also null and any CLI verb
* that needs to sign will fail with a clear error. `keyPair()` materialises
* a [KeyPair] with only `pubKey` set when no private key is available.
*/
data class Identity(
val privKeyHex: String,
val privKeyHex: String?,
val pubKeyHex: String,
val nsec: String,
val nsec: String?,
val npub: String,
) {
fun keyPair(): KeyPair = KeyPair(privKey = privKeyHex.hexToByteArray(), pubKey = pubKeyHex.hexToByteArray())
val hasPrivateKey: Boolean get() = privKeyHex != null
fun keyPair(): KeyPair =
if (privKeyHex != null) {
KeyPair(privKey = privKeyHex.hexToByteArray(), pubKey = pubKeyHex.hexToByteArray())
} else {
KeyPair(pubKey = pubKeyHex.hexToByteArray())
}
companion object {
fun create(): Identity {
val kp = KeyPair()
val priv = kp.privKey!!
val pub = kp.pubKey
fun create(): Identity = fromPrivateKey(KeyPair().privKey!!)
fun fromNsec(nsec: String): Identity = fromPrivateKey(nsec.bechToBytes())
fun fromPrivateKey(priv: ByteArray): Identity {
val pub = KeyPair(privKey = priv).pubKey
return Identity(
privKeyHex = priv.toHexKey(),
pubKeyHex = pub.toHexKey(),
@@ -53,16 +69,14 @@ data class Identity(
)
}
fun fromNsec(nsec: String): Identity {
val priv = nsec.bechToBytes()
val kp = KeyPair(privKey = priv)
return Identity(
privKeyHex = priv.toHexKey(),
pubKeyHex = kp.pubKey.toHexKey(),
nsec = priv.toNsec(),
npub = kp.pubKey.toNpub(),
/** Read-only identity (no private key). */
fun fromPublicKeyHex(pubHex: String): Identity =
Identity(
privKeyHex = null,
pubKeyHex = pubHex.lowercase(),
nsec = null,
npub = pubHex.hexToByteArray().toNpub(),
)
}
}
}
@@ -107,6 +107,14 @@ private suspend fun dispatch(argv: Array<String>): Int {
Commands.init(dataDir, Args(tail))
}
"create" -> {
Commands.create(dataDir, tail)
}
"login" -> {
Commands.login(dataDir, tail)
}
"whoami" -> {
Commands.whoami(dataDir)
}
@@ -171,8 +179,10 @@ private fun printUsage() {
| amy [--data-dir PATH] <cmd> [args...]
|
|Identity:
| init [--nsec NSEC] create or import identity
| whoami print current identity
| init [--nsec NSEC] create or import a bare identity (no defaults published)
| create [--name NAME] provision a full Amethyst-style account + publish bootstrap events
| login KEY [--password X] import (nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05)
| whoami print current identity
|
|Relays:
| relay add URL [--type T] T=nip65|inbox|key_package|all (default all)
@@ -34,6 +34,16 @@ object Commands {
args: Args,
): Int = InitCommands.init(dataDir, args)
suspend fun create(
dataDir: DataDir,
tail: Array<String>,
): Int = CreateCommand.run(dataDir, tail)
suspend fun login(
dataDir: DataDir,
tail: Array<String>,
): Int = LoginCommand.run(dataDir, tail)
suspend fun whoami(dataDir: DataDir): Int = InitCommands.whoami(dataDir)
suspend fun relay(
@@ -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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Identity
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.amethyst.cli.RelayConfig
import com.vitorpamplona.amethyst.commons.account.bootstrapAccountEvents
import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65List
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
/**
* `amy create [--name NAME]` — provision a brand-new Nostr account with the
* same defaults Amethyst uses, publish the nine bootstrap events to the
* default NIP-65 relay set, and seed this data-dir's relay config so
* subsequent `amy marmot …` commands immediately target the right relays.
*
* The heavy lifting (which events to sign, with which defaults) lives in
* `commons/.../AccountBootstrap.kt` so this command stays assembly-thin
* and the on-relay shape matches the in-app flow byte-for-byte.
*/
object CreateCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (dataDir.loadIdentityOrNull() != null) {
return Json.error("exists", "identity already exists at ${dataDir.identityFile}")
}
val args = Args(rest)
val name = args.flag("name")
// 1. Mint identity + seed relay config.
val identity = Identity.create()
dataDir.saveIdentity(identity)
dataDir.saveRelays(defaultRelayConfig())
// 2. Build the nine signed bootstrap events via the shared helper.
val signer = NostrSignerSync(identity.keyPair())
val bootstrap = bootstrapAccountEvents(signer, name)
// 3. Open a Context so we reuse publishAndConfirmDetailed + the
// NostrClient plumbing instead of reinventing a second client.
val ctx = Context.open(dataDir)
val accepted = mutableMapOf<String, List<String>>()
try {
ctx.prepare()
for (event in bootstrap.all()) {
val ack = ctx.publish(event, DefaultNIP65RelaySet)
accepted[event.kind.toString()] =
ack.filterValues { it }.keys.map { it.url }
}
} finally {
ctx.close()
}
Json.writeLine(
mapOf(
"npub" to identity.npub,
"hex" to identity.pubKeyHex,
"name" to (name ?: ""),
"data_dir" to dataDir.root.absolutePath,
"published_kinds" to bootstrap.all().map { it.kind },
"accepted_by" to accepted,
"relays" to
mapOf(
"nip65" to DefaultNIP65List.map { it.relayUrl.url },
"inbox" to DefaultDMRelayList.map { it.url },
"key_package" to DefaultNIP65RelaySet.map { it.url },
),
),
)
return 0
}
/**
* Mirror of what Amethyst's in-app defaults would write on disk — NIP-65
* outbox, DM inbox (kind:10050), and KeyPackage host relays (kind:10051).
* Keeping these in sync with the signed events above is load-bearing:
* `amy marmot key-package publish` later reads `relays.json` to decide
* where to publish, and if those diverge from the advertised kind:10051
* nobody will find the KPs.
*/
private fun defaultRelayConfig(): RelayConfig {
val cfg = RelayConfig()
DefaultNIP65List.forEach { cfg.add("nip65", it.relayUrl.url) }
DefaultDMRelayList.forEach { cfg.add("inbox", it.url) }
DefaultNIP65RelaySet.forEach { cfg.add("key_package", it.url) }
return cfg
}
}
@@ -0,0 +1,125 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Identity
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
import okhttp3.OkHttpClient
/**
* `amy login KEY [--password X]` — import any of the identifier forms
* Amethyst's login screen accepts and persist the identity to the
* data-dir. Mirrors [AccountSessionManager.loginSync] but on the JVM.
*
* Accepted forms (tried in this order):
* - nsec1… → full account
* - ncryptsec… + --password X → NIP-49 decrypt → full
* - BIP-39 mnemonic (space-separated) → NIP-06 derive → full
* - 64-hex private key (with --private) → full
* - npub1… / nprofile1… / 64-hex pubkey → read-only
* - NIP-05 identifier (name@domain.tld) → read-only (HTTP lookup)
*/
object LoginCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) {
return Json.error("bad_args", "login <nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05> [--password X]")
}
if (dataDir.loadIdentityOrNull() != null) {
return Json.error("exists", "identity already exists at ${dataDir.identityFile}; use a fresh --data-dir or delete it first")
}
val key = rest[0].trim()
val args = Args(rest.drop(1).toTypedArray())
val identity =
resolveIdentity(key, args)
?: return Json.error(
"bad_key",
"could not parse '$key' as any supported identifier",
)
dataDir.saveIdentity(identity)
Json.writeLine(
mapOf(
"npub" to identity.npub,
"hex" to identity.pubKeyHex,
"read_only" to !identity.hasPrivateKey,
"data_dir" to dataDir.root.absolutePath,
),
)
return 0
}
private suspend fun resolveIdentity(
key: String,
args: Args,
): Identity? {
// 1. ncryptsec — password mandatory.
if (key.startsWith("ncryptsec")) {
val pw =
args.flag("password") ?: args.flag("pw")
?: throw IllegalArgumentException("ncryptsec input requires --password")
val privHex = Nip49().decrypt(key, pw)
return Identity.fromPrivateKey(
com.vitorpamplona.quartz.utils.Hex
.decode(privHex),
)
}
// 2. nsec
if (key.startsWith("nsec1")) return Identity.fromNsec(key)
// 3. mnemonic (space-separated, 12/24 words)
if (key.contains(' ') && Nip06().isValidMnemonic(key)) {
val priv = Nip06().privateKeyFromMnemonic(key)
return Identity.fromPrivateKey(priv)
}
// 4. 64-hex privkey — only when explicitly asked; otherwise a bare
// hex string is ambiguous with a pubkey and we default to public.
if (args.bool("private") && isHex64(key)) {
return Identity.fromPrivateKey(
com.vitorpamplona.quartz.utils.Hex
.decode(key),
)
}
// 5. everything else — defer to the shared resolver (npub / nprofile /
// hex pubkey / NIP-05). Read-only.
val pubHex = resolveUserHexOrNull(key, nip05Client()) ?: return null
return Identity.fromPublicKeyHex(pubHex)
}
private fun isHex64(s: String): Boolean = s.length == 64 && s.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }
private fun nip05Client(): Nip05Client {
// Build a throwaway OkHttp client — we don't hold a Context here and
// login is a one-shot CLI invocation anyway.
val http = OkHttpClient.Builder().build()
return Nip05Client(fetcher = OkHttpNip05Fetcher { _ -> http })
}
}