diff --git a/cli/DEVELOPMENT.md b/cli/DEVELOPMENT.md index 469c8e93d..ce275e433 100644 --- a/cli/DEVELOPMENT.md +++ b/cli/DEVELOPMENT.md @@ -201,11 +201,11 @@ Amy-specific layer still needs its own coverage: | Layer | Test approach | |---|---| -| Argument parsing (`Args`, flag forms, `--data-dir=…` vs `--data-dir …`) | Plain JVM unit tests in `cli/src/test/kotlin/`. | +| Argument parsing (`Args`, flag forms, `--name=…` vs `--name …`) | Plain JVM unit tests in `cli/src/test/kotlin/`. | | Error / exit-code contract (bad args → 2, await timeout → 124, runtime → 1) | Table-driven tests invoking `main(argv)` with captured stdout/stderr. | -| JSON output shape (each command's keys and types under `--json`) | Snapshot tests: run a command with `--json` against a throwaway data-dir, assert the JSON matches a golden file. The default text render has no shape contract and shouldn't be snapshotted. | +| JSON output shape (each command's keys and types under `--json`) | Snapshot tests: run a command with `--json` against a throwaway `$HOME` (`HOME=$(mktemp -d) amy --name X …`), assert the JSON matches a golden file. The default text render has no shape contract and shouldn't be snapshotted. | | File layout on disk (`identity.json`, `events-store/…`, `marmot/groups/*.mls`, `marmot/keypackages.bundle`) | Structural assertions after a command sequence. | -| Round-trip between two data-dirs on a local relay | End-to-end shell harnesses under `cli/tests/`. Each harness spins up a local `nostr-rs-relay`, bootstraps two or more fresh identities in their own `--data-dir`s, and drives a scenario via `amy` (+ `wn` for Marmot interop against whitenoise-rs). Today there are two suites: `cli/tests/marmot/` (13 MLS scenarios vs whitenoise-rs) and `cli/tests/dm/` (NIP-17 DM round-trips between two `amy` clients). | +| Round-trip between two accounts on a local relay | End-to-end shell harnesses under `cli/tests/`. Each harness spins up a local `nostr-rs-relay` and a fresh `$HOME=$STATE_DIR` so amy sees a virgin `~/.amy/`, then bootstraps multiple identities (`--name A`, `--name D`, etc.) sharing the same `~/.amy/shared/events-store/` and drives a scenario through them (+ `wn` for Marmot interop against whitenoise-rs). Today there are two suites: `cli/tests/marmot/` (MLS scenarios vs whitenoise-rs) and `cli/tests/dm/` (NIP-17 DM round-trips between two `amy` accounts). | | Interop with other clients | Covered by `cli/tests/marmot/marmot-interop-headless.sh` (drives Amy against whitenoise-rs `wn`/`wnd`). Add new scenarios there or start a new sibling under `cli/tests/`. | **What not to test here:** event signing, filter assembly, MLS @@ -222,15 +222,14 @@ At the byte-banging level, a minimal round-trip looks like: ```bash set -euo pipefail -TMP=$(mktemp -d) -A=$TMP/alice; B=$TMP/bob +export HOME=$(mktemp -d) # virgin ~/.amy/ for the duration of this script -amy --data-dir "$A" create --name Alice -amy --data-dir "$B" create --name Bob +amy --name alice create +amy --name bob create # ... the scenario under test ... -amy --data-dir "$B" marmot await message "$GID" --match "hello" --timeout 60 +amy --name bob marmot await message "$GID" --match "hello" --timeout 60 ``` If an Amethyst scenario cannot be scripted through Amy yet, that's diff --git a/cli/README.md b/cli/README.md index e9a39d592..d5df6f96e 100644 --- a/cli/README.md +++ b/cli/README.md @@ -32,7 +32,7 @@ What every caller — user, script, agent, CI — can rely on: - **`--json` is the machine contract. One line. One object.** Stable snake_case keys. Pipe it into `jq`, parse it from Python, hand it to an agent. Pass `--json` anywhere before the subcommand: - `amy --json whoami`, `amy --data-dir ./alice --json marmot group list`. + `amy --json whoami`, `amy --name alice --json marmot group list`. - **stderr is for humans.** Progress, warnings, per-relay ACK traces. Safe to discard. Errors land here too: `error: : ` by default, or JSON `{"error":"…","detail":"…"}` under `--json`. @@ -42,10 +42,13 @@ What every caller — user, script, agent, CI — can rely on: - `2` — bad arguments - `124` — `await` timed out - **No interactive prompts, ever.** Passwords, names, keys — all flags. -- **Data-dir is the whole world.** All state (identity, relays, MLS - epochs, message archives, run cursors) lives under `--data-dir PATH`. - Delete to reset; copy to move; `AMETHYST_CLI_DATA` env var overrides - the default `./amy`. +- **`~/.amy/` is the whole world.** Per-account dirs hold identity, + cursors, MLS state, and aliases at `~/.amy//`; every + observed Nostr event lands in `~/.amy/shared/events-store/` (one + cache for every account on the machine). Delete to reset; copy to + move. Tests isolate by overriding `$HOME` for the amy subprocess + (`HOME=/tmp/run.123 amy --name alice …`) — same convention `git`, + `gpg`, and `npm` use. Only the `--json` shape and the exit codes are public API. The default text format is allowed to change between releases. The rationale lives @@ -57,7 +60,8 @@ in [DEVELOPMENT.md](./DEVELOPMENT.md). Every Nostr event Amy observes is verified (NIP-01 id + signature check) and persisted to a file-backed store at -`/events-store/`. That includes: +`~/.amy/shared/events-store/` (one store per machine, shared across +every account in `~/.amy/`). That includes: - events received from any relay subscription (`amy feed`, `amy dm list`, `amy keypackage publish`, group sync, …), @@ -107,11 +111,11 @@ To manage the store directly: ```sh # raw inspection -find $AMY_HOME/events-store/events -name '*.json' | head -jq . $AMY_HOME/events-store/replaceable/0/.json +find ~/.amy/shared/events-store/events -name '*.json' | head +jq . ~/.amy/shared/events-store/replaceable/0/.json # delete a specific event (tombstone NOT installed — see below) -rm $AMY_HOME/events-store/events///.json +rm ~/.amy/shared/events-store/events///.json ``` Deleting an event file is treated as a deliberate "I never saw this" @@ -146,29 +150,31 @@ The `installDist` tree under `cli/build/install/amy/` is self-contained ## Quick start ```bash -# 1. Create a data-dir with a full Amethyst-style account. -# Generates a keypair, seeds default NIP-65 / inbox / key-package -# relays, and publishes the nine bootstrap events. -amy --data-dir ./alice create --name "Alice" +# 1. Create the alice account (~/.amy/alice/) with a full Amethyst-style +# bootstrap: keypair, default NIP-65 / inbox / key-package relays, +# nine bootstrap events. +amy --name alice create # 2. Publish a fresh MLS KeyPackage so others can invite you. -amy --data-dir ./alice marmot key-package publish +amy --name alice marmot key-package publish # 3. Create a group, invite someone, send a message. -amy --data-dir ./alice marmot group create --name "Test Group" -amy --data-dir ./alice marmot group add npub1...bob -amy --data-dir ./alice marmot message send "hello" +amy --name alice marmot group create --name "Test Group" +amy --name alice marmot group add npub1...bob +amy --name alice marmot message send "hello" -# 4. On the receiving side — poll until Bob sees the invite. -amy --data-dir ./bob marmot await group --name "Test Group" --timeout 60 -amy --data-dir ./bob marmot message list +# 4. On the receiving side, in ~/.amy/bob/. +amy --name bob marmot await group --name "Test Group" --timeout 60 +amy --name bob marmot message list ``` -Compose with `jq` to chain commands — pass `--json` so stdout switches -from the default human-readable text to the parseable single-line JSON: +With one account you can drop the flag (auto-pick); with several, pin +one with `amy use alice` and the same commands work flagless. Compose +with `jq` by adding `--json` to switch stdout from human text to a +parseable single-line JSON object: ```bash -GID=$(amy --data-dir ./alice --json marmot group create --name "Test" | jq -r .group_id) +GID=$(amy --name alice --json marmot group create --name "Test" | jq -r .group_id) ``` For an interop-test script template, see @@ -188,7 +194,8 @@ Run `amy --help` for the canonical list. As of today: | `init [--nsec NSEC]` | Create or import a bare identity. Does not publish anything. | | `create [--name NAME]` | Provision a full account + publish the nine Amethyst bootstrap events. | | `login KEY [--password X] [--private]` | Import `nsec` / `ncryptsec` / BIP-39 mnemonic / `npub` / `nprofile` / hex / NIP-05. Read-only when no secret material is supplied. | -| `whoami` | Print the identity stored in `--data-dir`. | +| `whoami` | Print the active account's name + npub. | +| `use NAME` | Pin NAME as the active account (`~/.amy/current`). `use --clear` removes the pin; `use` prints the pin and the available accounts. | | `profile show [PUBKEY] [--refresh] [--timeout SECS]` | Print kind:0 metadata. Default reads from the local store (cache-first); `--refresh` forces a relay drain. PUBKEY accepts `npub` / `nprofile` / hex / `name@domain.tld`; omit for self. | | `profile edit --name X [--display-name X] …` | Build + publish a new kind:0 starting from the current cached metadata (or fetched if missing). | | `relay add URL [--type T]` | `T = nip65 \| inbox \| key_package \| all`. | @@ -228,10 +235,22 @@ itself crashed". ### Global flags -- `--data-dir PATH` — defaults to `./amy` or - `$AMETHYST_CLI_DATA`. Always an absolute path after resolution. +- `--name ACCOUNT` — pick which account under `~/.amy//` to + operate on. Required only when more than one account exists; with a + single account amy auto-picks. Override with `amy use NAME` to + pin one as the default. ACCOUNT must match `[a-zA-Z0-9_-]{1,64}`. +- `--json` — switch stdout to the machine-readable contract. +- `--secret-backend auto|keychain|ncryptsec|plaintext` — where to + persist the private key. +- `--passphrase-file PATH` — passphrase for the `ncryptsec` backend. - `--help` / `-h` — usage summary. +### Account-management verbs + +- `amy use NAME` — pin NAME as the active account (`~/.amy/current`). +- `amy use --clear` — remove the pin. +- `amy use` — print the current pin and the list of available accounts. + --- ## Relay routing @@ -261,42 +280,53 @@ events. --- -## Data-dir layout +## On-disk layout ``` -/ -├── identity.json # nsec/npub/hex — the account -├── state.json # sync cursors (giftWrapSince, groupSince) -├── events-store/ # FsEventStore — every observed Nostr event -│ ├── events///… # canonical kind:0 / 3 / 10002 / 10050 / 10051 / 1 / 5 / 1059 / … -│ ├── replaceable//… # one slot per (kind, pubkey) for kind:0/3/10000-19999 -│ ├── addressable/… # one slot per (kind, pubkey, d-tag) for kind:30000-39999 -│ ├── idx/ # hardlink indexes (kind / author / owner / tag / fts / expires_at) -│ └── tombstones/ # NIP-09 / NIP-62 enforcement -└── marmot/ - ├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal) - └── groups/ - ├── .mls # MLS group state per group - └── .log # decrypted inner events (one JSON per line) +~/.amy/ ← root, follows $HOME +├── current # marker file written by `amy use NAME` +├── shared/ +│ └── events-store/ # FsEventStore — every observed Nostr event +│ ├── events///… # canonical kind:0 / 3 / 10002 / 10050 / 10051 / 1 / 5 / 1059 / … +│ ├── replaceable//… # one slot per (kind, pubkey) for kind:0/3/10000-19999 +│ ├── addressable/… # one slot per (kind, pubkey, d-tag) for kind:30000-39999 +│ ├── idx/ # hardlink indexes (kind / author / owner / tag / fts / expires_at) +│ └── tombstones/ # NIP-09 / NIP-62 enforcement +├── alice/ # one dir per account (e.g. created by `amy --name alice init`) +│ ├── identity.json # nsec/npub/hex — the account +│ ├── state.json # sync cursors (giftWrapSince, groupSince) +│ ├── aliases.json # local name → npub map (init writes a self-entry) +│ └── marmot/ +│ ├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal) +│ └── groups/ +│ ├── .mls # MLS group state per group +│ └── .log # decrypted inner events (one JSON per line) +└── bob/ ... # additional accounts sit alongside ``` All files are plain JSON or framed binary — human-inspectable, easy to -diff across two data-dirs in a test run. +diff across two accounts. Two accounts on the same machine share +`~/.amy/shared/events-store/`, so a public event observed once doesn't +get re-stored per account. The local relay configuration (kind:10002 / 10050 / 10051) is **not** a -separate file — it lives in `events-store/` as signed events. -`amy relay add` builds + signs + ingests a new relay-list event; -`amy relay list` reads URLs straight out of the latest event for each -kind; `amy relay publish-lists` broadcasts those events to upstream -relays. There is no `relays.json`. +separate file — it lives in the shared `events-store/` as signed events +owned by the account that wrote them. `amy relay add` builds + signs + +ingests a new relay-list event; `amy relay list` reads URLs straight out +of the latest event for each kind; `amy relay publish-lists` broadcasts +those events to upstream relays. There is no `relays.json`. --- ## Troubleshooting - **`no identity`** — run `init`, `create`, or `login` first, or pass a - different `--data-dir`. -- **`not_member`** — the group GID is unknown to this data-dir. Run + different `--name`. +- **`no account at ~/.amy`** — there's no account dir yet; create one + with `amy --name init`. +- **`multiple accounts in ~/.amy (...)`** — disambiguate with `--name` + on the next call, or pin a default via `amy use NAME`. +- **`not_member`** — the group GID is unknown to this account. Run `marmot group list` to confirm, or `marmot await group --name …` to wait for an invite to arrive. - **Hang on a network verb** — Amy connects to the relays advertised diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index 94fff502c..b5f912556 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -112,28 +112,22 @@ data class RunState( /** * Root of the on-disk layout for one account. * - * Two construction modes: + * Per-account state (identity, sync cursors, MLS material, aliases) + * lives at `//`; the event store is shared across accounts + * at `/shared/events-store/`. `` is always `~/.amy/` + * (Java's `user.home` + `/.amy`); test harnesses isolate by overriding + * `$HOME` for the amy subprocess, exactly the pattern `git`, `gpg`, + * `npm` etc. use. * - * 1. **Account mode** (`--name X`): the canonical layout. Per-account - * state (identity, sync cursors, MLS material, aliases) lives at - * `//`; the public event store is shared across - * accounts at `/shared/events-store/`. `` 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 - * having to learn about a second parameter. + * Use [resolve] to construct one from CLI flags. [secrets] is the + * [SecretStore] that mediates private-key persistence. Owning it here + * keeps the call sites that already thread [DataDir] from having to + * learn about a second parameter. */ class DataDir( val root: File, val eventsDir: File, - val accountName: String?, + val accountName: String, val secrets: SecretStore, ) { val identityFile = File(root, "identity.json") @@ -213,8 +207,22 @@ class DataDir( } companion object { - /** Per-user root under which `shared/` and `/` live. */ - val DEFAULT_ROOT: File get() = File(System.getProperty("user.home"), ".amy") + /** + * Per-user root under which `shared/` and `/` live. + * + * Reads `$HOME` directly rather than `user.home` because JDK 21 + * resolves the latter from `getpwuid` and ignores `$HOME`, + * which would break the standard `HOME=/tmp/foo amy …` test + * isolation pattern (the same convention `git`, `gpg`, `npm` + * follow). Falls back to `user.home` only when `$HOME` is unset + * (Windows, weird containers). + */ + val DEFAULT_ROOT: File get() { + val home = + System.getenv("HOME").takeUnless { it.isNullOrBlank() } + ?: System.getProperty("user.home") + return File(home, ".amy") + } /** Marker file (one line, just the account name) written by `amy use`. */ const val CURRENT_MARKER_NAME = "current" @@ -242,32 +250,16 @@ class DataDir( /** * Build a [DataDir] from the parsed CLI flags. * - * Resolution order in account mode (when `dataDirFlag` is null): + * Resolution order: * 1. `--name X` if provided. * 2. `/current` marker (set by `amy use X`). * 3. Sole subdirectory of `` other than `shared/`. - * 4. Error — caller must disambiguate. - * - * Passing both `dataDirFlag` and `nameFlag` is an - * `IllegalArgumentException` (exit 2 via `main`). + * 4. Error — caller must disambiguate via `--name` or `amy use`. */ fun resolve( - dataDirFlag: String?, nameFlag: String?, secrets: SecretStore, ): DataDir { - require(!(dataDirFlag != null && nameFlag != null)) { - "pass either --data-dir or --name, not both" - } - if (dataDirFlag != null) { - val root = File(dataDirFlag).absoluteFile - return DataDir( - root = root, - eventsDir = File(root, "events-store"), - accountName = null, - secrets = secrets, - ) - } val rootBase = DEFAULT_ROOT val name = if (nameFlag != null) validateName(nameFlag) else pickAccount(rootBase) val accountRoot = File(rootBase, name).absoluteFile @@ -281,10 +273,10 @@ class DataDir( } /** - * Auto-select an account when neither `--name` nor `--data-dir` - * was given. Honours `/current` first (explicit pin from - * `amy use`), then falls back to "exactly one account exists". - * Throws [IllegalArgumentException] for the ambiguous cases so + * Auto-select an account when `--name` was not given. Honours + * `/current` first (explicit pin from `amy use`), then + * falls back to "exactly one account exists". Throws + * [IllegalArgumentException] for the ambiguous cases so * `main`'s catch-all turns them into a clean exit-2 error. */ private fun pickAccount(rootBase: File): String { @@ -304,8 +296,7 @@ class DataDir( return when (accounts.size) { 0 -> { throw IllegalArgumentException( - "no account at ${rootBase.absolutePath}; create one with `amy --name init` " + - "(or pass --data-dir for a self-contained dir)", + "no account at ${rootBase.absolutePath}; create one with `amy --name init`", ) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 453440c44..991b37113 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -82,7 +82,6 @@ private suspend fun dispatch(argv: Array): Int { // Pull global flags out of argv before subcommand parsing so subcommands see // only their own args. val filteredArgs = mutableListOf() - var dataDirFlag: String? = null var nameFlag: String? = null var secretBackendFlag: String? = null var passphraseFileFlag: String? = null @@ -91,7 +90,6 @@ private suspend fun dispatch(argv: Array): Int { val a = argv[i] 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 @@ -118,7 +116,7 @@ private suspend fun dispatch(argv: Array): Int { } val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag) - val dataDir = DataDir.resolve(dataDirFlag = dataDirFlag, nameFlag = nameFlag, secrets = secrets) + val dataDir = DataDir.resolve(nameFlag = nameFlag, secrets = secrets) return when (head) { "init" -> { @@ -212,7 +210,6 @@ private enum class GlobalFlag( val long: String, val takesValue: Boolean = true, ) { - DATA_DIR("--data-dir"), NAME("--name"), SECRET_BACKEND("--secret-backend"), PASSPHRASE_FILE("--passphrase-file"), @@ -256,25 +253,28 @@ private fun printUsage() { |amy — Amethyst command-line interface | |Usage: - | amy [--name ACCOUNT] (canonical: per-account dir under ~/.amy/) - | [--data-dir PATH] (escape hatch: self-contained dir at PATH) + | amy [--name ACCOUNT] | [--secret-backend auto|keychain|ncryptsec|plaintext] | [--passphrase-file PATH] | [--json] | [args...] | |Account selection: - | Default layout lives at ~/.amy/, with shared/events-store/ holding - | every observed Nostr event and ~/.amy// holding identity, - | cursors, MLS state, and aliases. ACCOUNT must match + | All state lives under ~/.amy/. Per-account directories + | ~/.amy// hold identity, cursors, MLS state, and + | aliases; every observed Nostr event lands in the shared + | ~/.amy/shared/events-store/. ACCOUNT must match | [a-zA-Z0-9_-]{1,64} (no spaces, no slashes). | - | Resolution order when --data-dir is not set: + | Resolution order: | 1. --name X if given. | 2. ~/.amy/current marker (set by `amy use X`). | 3. Sole subdirectory of ~/.amy/ other than shared/. | 4. Error — disambiguate with --name or `amy use`. | + | Test harnesses isolate by overriding ${'$'}HOME for the amy + | subprocess (`HOME=/tmp/run.123 amy --name alice ...`). + | | use NAME pin NAME as the active account | use --clear remove the pin | use print current pin + available accounts diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt index 1c0178937..1a152f374 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt @@ -35,9 +35,9 @@ 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) } + // Idempotent self-alias upsert when the dir already exists + // (e.g. user re-runs `init --name alice`). + Aliases.set(dataDir, dataDir.accountName, existing.npub) Output.emit( mapOf( "name" to dataDir.accountName, @@ -53,10 +53,10 @@ 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 ` -> 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) } + // Self-alias: record ` -> 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`. + Aliases.set(dataDir, dataDir.accountName, created.npub) Output.emit( mapOf( "name" to dataDir.accountName, diff --git a/cli/tests/cache/cache-headless.sh b/cli/tests/cache/cache-headless.sh index 4212c77d6..18d7fc731 100755 --- a/cli/tests/cache/cache-headless.sh +++ b/cli/tests/cache/cache-headless.sh @@ -28,8 +28,10 @@ REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" STATE_DIR="$SCRIPT_DIR/state-cache-headless" LOG_DIR="$STATE_DIR/logs" -A_DIR="$STATE_DIR/A" -B_DIR="$STATE_DIR/B" +# Per-account dirs under the same fake $HOME=$STATE_DIR — accounts A +# and B share $STATE_DIR/.amy/shared/events-store/ (production layout). +A_DIR="$STATE_DIR/.amy/A" +B_DIR="$STATE_DIR/.amy/B" RUN_TS="$(date +%Y%m%d-%H%M%S)" LOG_FILE="$LOG_DIR/run-$RUN_TS.log" @@ -64,7 +66,7 @@ while [[ $# -gt 0 ]]; do shift done -mkdir -p "$STATE_DIR" "$LOG_DIR" "$A_DIR" "$B_DIR" +mkdir -p "$STATE_DIR" "$LOG_DIR" : >"$LOG_FILE" : >"$RESULTS_FILE" @@ -81,7 +83,7 @@ source "$TESTS_DIR/headless/helpers.sh" # shellcheck source=../dm/setup.sh source "$TESTS_DIR/dm/setup.sh" -amy_b() { "$AMY_BIN" --data-dir "$B_DIR" --json "$@"; } +amy_b() { HOME="$STATE_DIR" "$AMY_BIN" --name B --secret-backend plaintext --json "$@"; } # Helper: B-side amy_json. The dm headless's helpers.sh hardcodes A_DIR # in amy_a; we need a parallel for B without re-sourcing. @@ -111,23 +113,24 @@ banner "Amethyst event-store cache headless ($RUN_TS)" preflight_dm start_local_relay -# Identity A: full bootstrap so the store has kind:0 / 3 / 10002 / … -ensure_identity_for A "$A_DIR" +# Identity A: full bootstrap so the shared store has kind:0 / 3 / 10002 / … +ensure_identity_for A banner "Bootstrapping A's account (publishes kind:0 + bootstrap events)" -"$AMY_BIN" --data-dir "$A_DIR" relay add "$RELAY_URL" --type all >>"$LOG_FILE" 2>&1 -# `amy create` here would mint a *second* identity; A_DIR already has one -# from `init`. Build the bootstrap events ourselves by publishing a -# minimal kind:0 + the relay lists, all of which land in A's local store -# via verifyAndStore. -"$AMY_BIN" --data-dir "$A_DIR" relay publish-lists >>"$LOG_FILE" 2>&1 +amy_a relay add "$RELAY_URL" --type all >>"$LOG_FILE" 2>&1 +# `amy create` here would mint a *second* identity; A already exists from +# `init`. Build the bootstrap events ourselves by publishing a minimal +# kind:0 + the relay lists, all of which land in the shared store via +# verifyAndStore. +amy_a relay publish-lists >>"$LOG_FILE" 2>&1 amy_a profile edit --name "AAA" --about "cache test subject" \ >>"$LOG_FILE" 2>&1 \ || fail_msg "amy_a profile edit failed" -# Identity B: separate data-dir, separate cache, also pointed at the relay. -ensure_identity_for B "$B_DIR" -"$AMY_BIN" --data-dir "$B_DIR" relay add "$RELAY_URL" --type all >>"$LOG_FILE" 2>&1 -"$AMY_BIN" --data-dir "$B_DIR" relay publish-lists >>"$LOG_FILE" 2>&1 +# Identity B: same fake $HOME, separate per-account dir, but A and B +# both read/write to $STATE_DIR/.amy/shared/events-store/. +ensure_identity_for B +amy_b relay add "$RELAY_URL" --type all >>"$LOG_FILE" 2>&1 +amy_b relay publish-lists >>"$LOG_FILE" 2>&1 # ---------------------------------------------------------------------- # 1. amy store stat reports a non-empty store after bootstrap. @@ -191,24 +194,20 @@ else fi # ---------------------------------------------------------------------- -# 4. B has not seen A → first profile show is a relay miss; second is a hit. +# 4. Shared events-store: B looks up A's profile and gets a cache hit +# on the first try, because A's kind:0 already landed in the shared +# store during A's bootstrap. (Pre-shared-store behaviour was "first +# lookup is a relay miss, second is a cache hit"; that test only +# made sense when each account had its own private cache.) # ---------------------------------------------------------------------- -banner "T4 — B sees A first via relay, then via cache" -T4A=$(amy_b_json profile show "$A_NPUB") -T4A_SRC=$(printf '%s' "$T4A" | jq -r '.source') -T4A_NAME=$(printf '%s' "$T4A" | jq -r '.metadata.name // ""') -assert_eq "$T4A_SRC" "relays" T4a.source "first lookup of a stranger comes from relays" \ - && record_result T4a.source pass "first profile lookup of stranger hit relays" -assert_eq "$T4A_NAME" "AAA" T4a.name "B should resolve A's name on first fetch" \ - && record_result T4a.name pass "B resolved A's metadata" - -T4B=$(amy_b_json profile show "$A_NPUB") -T4B_SRC=$(printf '%s' "$T4B" | jq -r '.source') -T4B_NAME=$(printf '%s' "$T4B" | jq -r '.metadata.name // ""') -assert_eq "$T4B_SRC" "cache" T4b.source "second lookup of the same stranger must serve from cache" \ - && record_result T4b.source pass "second lookup served from B's cache" -assert_eq "$T4B_NAME" "AAA" T4b.name "cached metadata must match" \ - && record_result T4b.name pass "cached metadata identical to fresh" +banner "T4 — B's profile lookup of A is a shared-store cache hit" +T4=$(amy_b_json profile show "$A_NPUB") +T4_SRC=$(printf '%s' "$T4" | jq -r '.source') +T4_NAME=$(printf '%s' "$T4" | jq -r '.metadata.name // ""') +assert_eq "$T4_SRC" "cache" T4.source "shared store should hand B a cached A profile immediately" \ + && record_result T4.source pass "B saw A from the shared cache" +assert_eq "$T4_NAME" "AAA" T4.name "A's profile metadata must be readable from B's invocation" \ + && record_result T4.name pass "shared-cache metadata round-trips across accounts" # ---------------------------------------------------------------------- # 5. relay list reads URLs back from the local kind:10002 / 10050 / 10051. @@ -245,20 +244,24 @@ else fi # ---------------------------------------------------------------------- -# 7. Maintenance verbs work without an identity (they only need the store). +# 7. Maintenance verbs work even when the selected account has no +# identity yet — they only construct the FsEventStore, never +# `Context.open`. We use a fresh fake $HOME so the empty store has +# no inherited events. # ---------------------------------------------------------------------- -banner "T7 — store maintenance verbs (sweep/scrub/compact) without identity" -TMP_NO_ID=$(mktemp -d) -T7_STAT=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store stat) -T7_SWEEP=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store sweep-expired) -T7_SCRUB=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store scrub) -T7_COMPACT=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store compact) +banner "T7 — store maintenance verbs (sweep/scrub/compact) on an empty store" +TMP_HOME=$(mktemp -d) +T7_AMY=(${AMY_BIN} --secret-backend plaintext --name throwaway --json store) +T7_STAT=$(HOME="$TMP_HOME" "${T7_AMY[@]}" stat) +T7_SWEEP=$(HOME="$TMP_HOME" "${T7_AMY[@]}" sweep-expired) +T7_SCRUB=$(HOME="$TMP_HOME" "${T7_AMY[@]}" scrub) +T7_COMPACT=$(HOME="$TMP_HOME" "${T7_AMY[@]}" compact) assert_eq "$(printf '%s' "$T7_STAT" | jq -r '.events')" "0" T7.stat.events "" \ - && record_result T7.stat pass "stat works without identity" + && record_result T7.stat pass "stat works on empty store" assert_eq "$(printf '%s' "$T7_SWEEP" | jq -r '.swept')" "0" T7.sweep.swept "" \ - && record_result T7.sweep pass "sweep-expired works without identity" + && record_result T7.sweep pass "sweep-expired works on empty store" assert_eq "$(printf '%s' "$T7_SCRUB" | jq -r '.ok')" "true" T7.scrub.ok "" \ - && record_result T7.scrub pass "scrub works without identity" + && record_result T7.scrub pass "scrub works on empty store" assert_eq "$(printf '%s' "$T7_COMPACT" | jq -r '.ok')" "true" T7.compact.ok "" \ - && record_result T7.compact pass "compact works without identity" -rm -rf "$TMP_NO_ID" + && record_result T7.compact pass "compact works on empty store" +rm -rf "$TMP_HOME" diff --git a/cli/tests/dm/dm-interop-headless.sh b/cli/tests/dm/dm-interop-headless.sh index e916bc888..687b9349c 100755 --- a/cli/tests/dm/dm-interop-headless.sh +++ b/cli/tests/dm/dm-interop-headless.sh @@ -15,8 +15,10 @@ REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" STATE_DIR="$SCRIPT_DIR/state-dm-headless" LOG_DIR="$STATE_DIR/logs" -A_DIR="$STATE_DIR/A" -D_DIR="$STATE_DIR/D" +# Per-account dirs under the same fake $HOME=$STATE_DIR so amy treats +# this as one user with two accounts (production layout). +A_DIR="$STATE_DIR/.amy/A" +D_DIR="$STATE_DIR/.amy/D" RUN_TS="$(date +%Y%m%d-%H%M%S)" LOG_FILE="$LOG_DIR/run-$RUN_TS.log" @@ -57,7 +59,7 @@ while [[ $# -gt 0 ]]; do shift done -mkdir -p "$STATE_DIR" "$LOG_DIR" "$A_DIR" "$D_DIR" +mkdir -p "$STATE_DIR" "$LOG_DIR" : >"$LOG_FILE" : >"$RESULTS_FILE" @@ -94,8 +96,8 @@ trap 'exit 129' HUP banner "Amethyst NIP-17 DM headless interop ($RUN_TS)" preflight_dm start_local_relay -ensure_identity_for A "$A_DIR" -ensure_identity_for D "$D_DIR" +ensure_identity_for A +ensure_identity_for D configure_relays_dm test_01_dm_text_round_trip diff --git a/cli/tests/dm/setup.sh b/cli/tests/dm/setup.sh index 1b20dc60f..677d57cc0 100644 --- a/cli/tests/dm/setup.sh +++ b/cli/tests/dm/setup.sh @@ -69,21 +69,24 @@ preflight_dm() { } # --- amy identity wrappers --------------------------------------------------- -# Two identities: A (sender) and D (recipient). We reuse A_DIR for parity -# with the existing harness files; D_DIR is new. +# Two identities: A (sender) and D (recipient), both inside the same +# $STATE_DIR/.amy tree. They share $STATE_DIR/.amy/shared/events-store/ +# — the same code path real users hit with multiple accounts. The +# enclosing harness sets STATE_DIR to a fresh tempdir per run, so amy +# sees a virgin home each time. # # `--secret-backend=plaintext` keeps these throwaway interop runs headless — # the default `auto` would try the OS keychain (not available in CI) and then # ask for a NIP-49 passphrase. Plaintext still writes 0600-owner-only. -amy_a() { "$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext --json "$@"; } -amy_d() { "$AMY_BIN" --data-dir "$D_DIR" --secret-backend plaintext --json "$@"; } +amy_a() { HOME="$STATE_DIR" "$AMY_BIN" --name A --secret-backend plaintext --json "$@"; } +amy_d() { HOME="$STATE_DIR" "$AMY_BIN" --name D --secret-backend plaintext --json "$@"; } # --- identity bootstrap ------------------------------------------------------ ensure_identity_for() { - local who="$1" dir="$2" - step "initialising Identity $who (amy at $dir)" + local who="$1" + step "initialising Identity $who (amy at \$HOME=$STATE_DIR --name $who)" local out - out=$("$AMY_BIN" --data-dir "$dir" --secret-backend plaintext --json init) || { + out=$(HOME="$STATE_DIR" "$AMY_BIN" --name "$who" --secret-backend plaintext --json init) || { fail_msg "amy init failed for $who: $out"; exit 1 } local npub hex @@ -103,8 +106,8 @@ ensure_identity_for() { # so the DM strict-relay routing has something to resolve to. configure_relays_dm() { banner "Configuring relays → $RELAY_URL" - "$AMY_BIN" --data-dir "$A_DIR" relay add "$RELAY_URL" --type all >/dev/null - "$AMY_BIN" --data-dir "$D_DIR" relay add "$RELAY_URL" --type all >/dev/null + amy_a relay add "$RELAY_URL" --type all >/dev/null + amy_d relay add "$RELAY_URL" --type all >/dev/null step "publishing A's NIP-65 + kind:10050 lists" amy_a relay publish-lists >>"$LOG_FILE" 2>&1 \ diff --git a/cli/tests/dm/tests-dm.sh b/cli/tests/dm/tests-dm.sh index e7bab9f37..0b32513aa 100644 --- a/cli/tests/dm/tests-dm.sh +++ b/cli/tests/dm/tests-dm.sh @@ -12,22 +12,23 @@ # dm-05 file message reference mode round-trip (kind:15) # dm-06 cursor advance (subsequent no-flag `dm list` is empty) -# Wrap amy_json around either data-dir so the per-test code stays tight. -# `--json` opts into amy's machine-readable contract; assertions below -# parse with jq. +# Wrap amy_json around either account so the per-test code stays tight. +# Both share $HOME=$STATE_DIR (set by the harness); --name picks the +# account inside it. `--json` opts into amy's machine-readable contract; +# assertions below parse with jq. amy_json_for() { - local dir="$1"; shift + local account="$1"; shift local out - if ! out=$("$AMY_BIN" --data-dir "$dir" --json "$@" 2>>"$LOG_FILE"); then - fail_msg "amy --data-dir $dir $*: exit $? (see $LOG_FILE)" + if ! out=$(HOME="$STATE_DIR" "$AMY_BIN" --name "$account" --secret-backend plaintext --json "$@" 2>>"$LOG_FILE"); then + fail_msg "amy --name $account $*: exit $? (see $LOG_FILE)" printf '%s\n' "$out" >>"$LOG_FILE" return 1 fi printf '%s' "$out" } -amy_json_a() { amy_json_for "$A_DIR" "$@"; } -amy_json_d() { amy_json_for "$D_DIR" "$@"; } +amy_json_a() { amy_json_for A "$@"; } +amy_json_d() { amy_json_for D "$@"; } test_01_dm_text_round_trip() { banner "DM-01 — text round-trip A↔D (kind:14)" @@ -91,19 +92,21 @@ test_03_dm_send_rejects_no_inbox() { local id="dm-03 strict no_dm_relays" # Generate a throwaway identity but do NOT publish its kind:10050. - local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX") + # The ghost lives in its own fake $HOME so it doesn't pollute the + # test's main STATE_DIR with a third account. + local ghost_home; ghost_home=$(mktemp -d "${STATE_DIR}/ghost-home.XXXXXX") local ghost_out ghost_npub - ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" --secret-backend plaintext --json init) || { - record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return + ghost_out=$(HOME="$ghost_home" "$AMY_BIN" --name ghost --secret-backend plaintext --json init) || { + record_result "$id" fail "ghost init failed"; rm -rf "$ghost_home"; return } ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub') info "ghost: $ghost_npub (no relays advertised)" # A sends without --allow-fallback; amy should refuse. local raw rc - raw=$("$AMY_BIN" --data-dir "$A_DIR" --json dm send "$ghost_npub" "should be rejected" 2>&1) + raw=$(HOME="$STATE_DIR" "$AMY_BIN" --name A --secret-backend plaintext --json dm send "$ghost_npub" "should be rejected" 2>&1) rc=$? - rm -rf "$tmpdir" + rm -rf "$ghost_home" if [[ "$rc" -ne 0 ]] && printf '%s' "$raw" | grep -q '"error":"no_dm_relays"'; then info "amy refused with no_dm_relays as expected (exit $rc)" record_result "$id" pass @@ -121,13 +124,13 @@ test_04_dm_send_allow_fallback() { # fall through kind:10050 → NIP-65 read → bootstrap. Our bootstrap set # always includes the loopback relay via the shared RelayConfig, so the # publish should succeed even though the ghost has no 10050. - local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX") + local ghost_home; ghost_home=$(mktemp -d "${STATE_DIR}/ghost-home.XXXXXX") local ghost_out ghost_npub - ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" --secret-backend plaintext --json init) || { - record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return + ghost_out=$(HOME="$ghost_home" "$AMY_BIN" --name ghost --secret-backend plaintext --json init) || { + record_result "$id" fail "ghost init failed"; rm -rf "$ghost_home"; return } ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub') - rm -rf "$tmpdir" + rm -rf "$ghost_home" local out source out=$(amy_json_a dm send "$ghost_npub" "hi via fallback" --allow-fallback) || { diff --git a/cli/tests/headless/helpers.sh b/cli/tests/headless/helpers.sh index 5dabad2de..8521c219c 100644 --- a/cli/tests/headless/helpers.sh +++ b/cli/tests/headless/helpers.sh @@ -3,12 +3,18 @@ # helpers.sh — thin wrappers that keep the per-test code tight. # --- amy wrapper ------------------------------------------------------------- -# `--secret-backend=plaintext` keeps these throwaway interop runs headless — -# the default `auto` would try the OS keychain (not available in CI) and then -# ask for a NIP-49 passphrase. Plaintext still writes 0600-owner-only. +# Tests isolate by overriding $HOME for the amy subprocess; amy reads the +# env var directly (not Java's `user.home`, which JDK 21 pulls from +# /etc/passwd) and treats $HOME/.amy/ as its tree. No --data-dir flag, +# no test-only escape hatch — production code path, fresh every run. +# +# `--secret-backend=plaintext` keeps these throwaway runs headless — +# the default `auto` would try the OS keychain (not available in CI) and +# then ask for a NIP-49 passphrase. Plaintext still writes 0600-owner-only. +# # `--json` opts into amy's machine-readable output (the harness parses it # with jq); the default human-text output is for terminal use. -amy_a() { "$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext --json "$@"; } +amy_a() { HOME="$STATE_DIR" "$AMY_BIN" --name alice --secret-backend plaintext --json "$@"; } # Run amy, log stderr, surface JSON on stdout, remember last result. amy_json() { diff --git a/cli/tests/marmot/marmot-interop-headless.sh b/cli/tests/marmot/marmot-interop-headless.sh index c89ce806c..32794ea67 100755 --- a/cli/tests/marmot/marmot-interop-headless.sh +++ b/cli/tests/marmot/marmot-interop-headless.sh @@ -18,7 +18,10 @@ REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" STATE_DIR="$SCRIPT_DIR/state-headless" LOG_DIR="$STATE_DIR/logs" -A_DIR="$STATE_DIR/A" +# A is the amy account inside the fake $HOME=$STATE_DIR layout. B and +# C are wnd (whitenoise) state dirs — different binary, separate +# convention, so they stay as plain $STATE_DIR siblings. +A_DIR="$STATE_DIR/.amy/A" B_DIR="$STATE_DIR/B" C_DIR="$STATE_DIR/C" B_SOCKET="$B_DIR/release/wnd.sock" @@ -62,7 +65,7 @@ while [[ $# -gt 0 ]]; do shift done -mkdir -p "$STATE_DIR" "$LOG_DIR" "$A_DIR" "$B_DIR/logs" "$C_DIR/logs" +mkdir -p "$STATE_DIR" "$LOG_DIR" "$B_DIR/logs" "$C_DIR/logs" : >"$LOG_FILE" : >"$RESULTS_FILE" diff --git a/cli/tests/marmot/tests-extras.sh b/cli/tests/marmot/tests-extras.sh index ec819d311..4730a7630 100644 --- a/cli/tests/marmot/tests-extras.sh +++ b/cli/tests/marmot/tests-extras.sh @@ -271,7 +271,7 @@ test_14_wn_removes_a() { local deadline=$(( $(date +%s) + 120 )) removed=0 while [[ $(date +%s) -lt $deadline ]]; do local show rc - show=$("$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext --json \ + show=$(HOME="$STATE_DIR" "$AMY_BIN" --name A --secret-backend plaintext --json \ marmot group show "$a_gid" 2>&1) rc=$? printf '%s\n' "$show" >>"$LOG_FILE"