mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
feat(cli): introduce ~/.amy account-mode layout + --name flag
Default on-disk layout becomes:
~/.amy/
├── shared/
│ └── events-store/ (lazy: created on first event)
└── <account>/ (created by `amy --name X init`)
├── identity.json
├── state.json
├── aliases.json
└── marmot/
Per-account state moves under `~/.amy/<account>/`; the events-store is
shared across accounts under `~/.amy/shared/`. The shared store is
safe to share for now because amy doesn't currently persist any
decrypted inner events to it (NIP-17 DMs unwrap-and-display in
DmCommands; MLS inner events go to the per-group .log under
<account>/marmot/groups/, not the event store). When that changes,
a follow-up will introduce a per-account private-events-store and a
composite reader.
A new `--name X` global flag selects (or creates) `~/.amy/X/`.
`--data-dir P` is preserved as a self-contained escape hatch — the
test harness and ad-hoc throwaway dirs use it, events-store stays
inside P. Pass exactly one of `--name` or `--data-dir`; passing both
or neither is bad_args (exit 2). Names must match
[a-zA-Z0-9_-]{1,64}; `shared` is reserved.
`amy init --name alice` writes a self-entry into
`<account>/aliases.json` ({"alice":"npub1…"}) so future commands and
the planned `amy alias add` / dm-recipient resolver can refer to the
account by name. The init result map gains a `name` key (null in
legacy mode).
Open question for a follow-up: when only one account exists in
~/.amy/, should `amy whoami` work without --name? Today it doesn't —
strict mode. Also pending: README rewrite, plus a sweep through
commands that print `data_dir` to also surface `name` where useful.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
|
||||
/**
|
||||
* Per-account `aliases.json` — short human-friendly names that map to
|
||||
* npubs. Today it's only populated by `init --name X` (a self-entry so
|
||||
* the user can refer to their own account by name); future verbs like
|
||||
* `amy alias add bob npub1…` and recipient resolution in `dm send` will
|
||||
* read from the same file.
|
||||
*
|
||||
* Shape on disk: a JSON object of `{name: npub}` pairs. We persist the
|
||||
* npub form (not hex) so `cat aliases.json` is human-inspectable.
|
||||
*/
|
||||
object Aliases {
|
||||
/** Read the alias map; returns empty when the file doesn't exist. */
|
||||
fun load(dataDir: DataDir): MutableMap<String, String> {
|
||||
val f = dataDir.aliasesFile
|
||||
if (!f.exists()) return linkedMapOf()
|
||||
return Output.mapper.readValue(f.readText())
|
||||
}
|
||||
|
||||
/** Upsert one entry. Idempotent. */
|
||||
fun set(
|
||||
dataDir: DataDir,
|
||||
name: String,
|
||||
npub: String,
|
||||
) {
|
||||
val map = load(dataDir)
|
||||
map[name] = npub
|
||||
SecureFileIO.writeTextAtomic(dataDir.aliasesFile, Output.mapper.writeValueAsString(map))
|
||||
}
|
||||
}
|
||||
@@ -110,8 +110,21 @@ data class RunState(
|
||||
)
|
||||
|
||||
/**
|
||||
* Root of the on-disk layout. Any absolute path chosen by `--data-dir` (or
|
||||
* `$AMETHYST_CLI_DATA`) — defaults to `./amy`.
|
||||
* Root of the on-disk layout for one account.
|
||||
*
|
||||
* Two construction modes:
|
||||
*
|
||||
* 1. **Account mode** (`--name X`): the canonical layout. Per-account
|
||||
* state (identity, sync cursors, MLS material, aliases) lives at
|
||||
* `<root>/<name>/`; the public event store is shared across
|
||||
* accounts at `<root>/shared/events-store/`. `<root>` defaults to
|
||||
* `~/.amy/`.
|
||||
* 2. **Self-contained mode** (`--data-dir P`): everything — including
|
||||
* the event store — lives under `P`. Used by the test harness and
|
||||
* ad-hoc throw-away dirs that don't want to touch a shared root.
|
||||
*
|
||||
* Use [resolve] to construct one from CLI flags; pass exactly one of
|
||||
* `--name` or `--data-dir`.
|
||||
*
|
||||
* [secrets] is the [SecretStore] that mediates private-key persistence.
|
||||
* Owning it here keeps the call sites that already thread [DataDir] from
|
||||
@@ -119,17 +132,17 @@ data class RunState(
|
||||
*/
|
||||
class DataDir(
|
||||
val root: File,
|
||||
val eventsDir: File,
|
||||
val accountName: String?,
|
||||
val secrets: SecretStore,
|
||||
) {
|
||||
val identityFile = File(root, "identity.json")
|
||||
val stateFile = File(root, "state.json")
|
||||
val aliasesFile = File(root, "aliases.json")
|
||||
val marmotDir = File(root, "marmot")
|
||||
val groupsDir = File(marmotDir, "groups")
|
||||
val keyPackageBundleFile = File(marmotDir, "keypackages.bundle")
|
||||
|
||||
/** Root of the file-backed Nostr event store (`FsEventStore`). */
|
||||
val eventsDir = File(root, "events-store")
|
||||
|
||||
init {
|
||||
SecureFileIO.secureMkdirs(root)
|
||||
SecureFileIO.secureMkdirs(groupsDir)
|
||||
@@ -200,13 +213,71 @@ class DataDir(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Per-user root under which `shared/` and `<account>/` live. */
|
||||
val DEFAULT_ROOT: File get() = File(System.getProperty("user.home"), ".amy")
|
||||
|
||||
/**
|
||||
* Account names become directory names AND alias keys, so we
|
||||
* keep them to a portable, shell-friendly subset. `shared` is
|
||||
* reserved for the cross-account events-store sibling.
|
||||
*/
|
||||
private val NAME_REGEX = Regex("^[a-zA-Z0-9_-]{1,64}$")
|
||||
private const val SHARED_DIR_NAME = "shared"
|
||||
|
||||
fun validateName(name: String): String {
|
||||
require(NAME_REGEX.matches(name)) {
|
||||
"--name must match [a-zA-Z0-9_-]{1,64} (got '$name')"
|
||||
}
|
||||
require(name != SHARED_DIR_NAME) {
|
||||
"--name must not be '$SHARED_DIR_NAME' (reserved for the shared events-store)"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a [DataDir] from the parsed CLI flags. Exactly one of
|
||||
* `dataDirFlag` or `nameFlag` must be set; passing both is an
|
||||
* `IllegalArgumentException` (caught by `main` as exit 2).
|
||||
*/
|
||||
fun resolve(
|
||||
flag: String?,
|
||||
dataDirFlag: String?,
|
||||
nameFlag: String?,
|
||||
secrets: SecretStore,
|
||||
): DataDir {
|
||||
val envPath = System.getenv("AMETHYST_CLI_DATA")
|
||||
val path = flag ?: envPath ?: "./amy"
|
||||
return DataDir(File(path).absoluteFile, secrets)
|
||||
require(!(dataDirFlag != null && nameFlag != null)) {
|
||||
"pass either --data-dir or --name, not both"
|
||||
}
|
||||
return when {
|
||||
dataDirFlag != null -> {
|
||||
val root = File(dataDirFlag).absoluteFile
|
||||
DataDir(
|
||||
root = root,
|
||||
eventsDir = File(root, "events-store"),
|
||||
accountName = null,
|
||||
secrets = secrets,
|
||||
)
|
||||
}
|
||||
|
||||
nameFlag != null -> {
|
||||
val name = validateName(nameFlag)
|
||||
val rootBase = DEFAULT_ROOT
|
||||
val accountRoot = File(rootBase, name).absoluteFile
|
||||
val sharedEvents = File(rootBase, "$SHARED_DIR_NAME/events-store").absoluteFile
|
||||
DataDir(
|
||||
root = accountRoot,
|
||||
eventsDir = sharedEvents,
|
||||
accountName = name,
|
||||
secrets = secrets,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw IllegalArgumentException(
|
||||
"missing account selector: pass --name <account> (creates ${DEFAULT_ROOT.absolutePath}/<account>/) " +
|
||||
"or --data-dir <path> for a self-contained dir",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
// only their own args.
|
||||
val filteredArgs = mutableListOf<String>()
|
||||
var dataDirFlag: String? = null
|
||||
var nameFlag: String? = null
|
||||
var secretBackendFlag: String? = null
|
||||
var passphraseFileFlag: String? = null
|
||||
var i = 0
|
||||
@@ -91,6 +92,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
val (matched, consumed) = extractGlobalFlag(a, argv, i)
|
||||
when (matched) {
|
||||
GlobalFlag.DATA_DIR -> dataDirFlag = consumed.value
|
||||
GlobalFlag.NAME -> nameFlag = consumed.value
|
||||
GlobalFlag.SECRET_BACKEND -> secretBackendFlag = consumed.value
|
||||
GlobalFlag.PASSPHRASE_FILE -> passphraseFileFlag = consumed.value
|
||||
GlobalFlag.JSON -> Output.mode = Output.Mode.JSON
|
||||
@@ -104,7 +106,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
}
|
||||
|
||||
val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag)
|
||||
val dataDir = DataDir.resolve(dataDirFlag, secrets)
|
||||
val dataDir = DataDir.resolve(dataDirFlag = dataDirFlag, nameFlag = nameFlag, secrets = secrets)
|
||||
val head = filteredArgs[0]
|
||||
val tail = filteredArgs.drop(1).toTypedArray()
|
||||
|
||||
@@ -201,6 +203,7 @@ private enum class GlobalFlag(
|
||||
val takesValue: Boolean = true,
|
||||
) {
|
||||
DATA_DIR("--data-dir"),
|
||||
NAME("--name"),
|
||||
SECRET_BACKEND("--secret-backend"),
|
||||
PASSPHRASE_FILE("--passphrase-file"),
|
||||
JSON("--json", takesValue = false),
|
||||
@@ -243,12 +246,20 @@ private fun printUsage() {
|
||||
|amy — Amethyst command-line interface
|
||||
|
|
||||
|Usage:
|
||||
| amy [--data-dir PATH]
|
||||
| amy [--name ACCOUNT] (canonical: per-account dir under ~/.amy/)
|
||||
| [--data-dir PATH] (escape hatch: self-contained dir at PATH)
|
||||
| [--secret-backend auto|keychain|ncryptsec|plaintext]
|
||||
| [--passphrase-file PATH]
|
||||
| [--json]
|
||||
| <cmd> [args...]
|
||||
|
|
||||
|Account selection:
|
||||
| Default layout lives at ~/.amy/, with shared/events-store/ holding
|
||||
| every observed Nostr event and ~/.amy/<account>/ holding identity,
|
||||
| cursors, MLS state, and aliases. Pass exactly one of --name or
|
||||
| --data-dir; passing both or neither is a bad-args error. ACCOUNT
|
||||
| must match [a-zA-Z0-9_-]{1,64} (no spaces, no slashes).
|
||||
|
|
||||
|Output:
|
||||
| Default: human-readable text on stdout.
|
||||
| --json: one JSON object per success on stdout, JSON
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Aliases
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Identity
|
||||
@@ -34,8 +35,12 @@ object InitCommands {
|
||||
// would trigger a keychain prompt / passphrase dialog even though the
|
||||
// caller clearly already has the identity set up.
|
||||
dataDir.loadIdentityFileOrNull()?.let { existing ->
|
||||
// Idempotent self-alias upsert when --name was passed and the
|
||||
// dir already exists (e.g. user re-runs `init --name alice`).
|
||||
dataDir.accountName?.let { Aliases.set(dataDir, it, existing.npub) }
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"name" to dataDir.accountName,
|
||||
"npub" to existing.npub,
|
||||
"hex" to existing.pubKeyHex,
|
||||
"nsec" to null,
|
||||
@@ -48,8 +53,13 @@ object InitCommands {
|
||||
val nsec = args.flag("nsec")
|
||||
val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create()
|
||||
dataDir.saveIdentity(created)
|
||||
// Self-alias: in account mode, record `<name> -> own npub` so the
|
||||
// user can refer to their own account by name in scripts and (once
|
||||
// the resolver lands) in recipient slots like `dm send`.
|
||||
dataDir.accountName?.let { Aliases.set(dataDir, it, created.npub) }
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"name" to dataDir.accountName,
|
||||
"npub" to created.npub,
|
||||
"hex" to created.pubKeyHex,
|
||||
"nsec" to created.nsec,
|
||||
|
||||
Reference in New Issue
Block a user