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:
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user