feat(cli): drop --data-dir; tests isolate via $HOME override

There is no installed base to protect, so the self-contained
`--data-dir P` escape hatch goes away entirely. amy now has exactly
one layout — the production multi-account one — and tests exercise
that same code path. Drops one global flag, one DataDir construction
mode, and the "tests use a different code path than users" footgun.

Layout (unchanged from the previous commits — just the only mode now):

    ~/.amy/
    ├── current                      # `amy use NAME` marker
    ├── shared/
    │   └── events-store/            # FsEventStore, one per machine
    ├── alice/
    │   ├── identity.json
    │   ├── state.json
    │   ├── aliases.json             # {"alice": "<own npub>"} after init
    │   └── marmot/
    └── bob/ ...

`DataDir.DEFAULT_ROOT` reads `$HOME` directly (falling back to
`user.home`) because JDK 21 resolves `user.home` via getpwuid and
ignores `$HOME` — which would have broken the standard CLI test
isolation pattern of `HOME=/tmp/foo amy …` (the same convention
git, gpg, npm follow).

Test sweep:

- `cli/tests/headless/helpers.sh`, `cli/tests/dm/setup.sh`,
  `cli/tests/cache/cache-headless.sh` wrappers all switch to
  `HOME=$STATE_DIR amy --name X …`.
- `ensure_identity_for` drops its `dir` parameter; the function and
  every harness call site go through `--name` only.
- `A_DIR`/`B_DIR`/`D_DIR` get repointed at `$STATE_DIR/.amy/X`. The
  one consumer (`cache-headless.sh`'s T6 `relays.json` check) still
  works since it's just a path probe.
- `cli/tests/dm/tests-dm.sh` ghost identities get their own
  short-lived `$HOME` so they don't pollute the main test root.

Cache-test T4 inverts: pre-shared-store, "B has not seen A → first
profile show is a relay miss, second is a cache hit" tested
per-account caching. With one shared events-store, B's first lookup
of A is already a cache hit because A wrote kind:0 there during
bootstrap. T4 now asserts that — drops the stale "second lookup hits
cache" half. T7 (no-identity maintenance verbs) gets a fresh fake
`$HOME` plus a throwaway `--name` so the empty store has no inherited
events.

Docs (README + DEVELOPMENT) rewritten to match — quick-start now uses
`amy --name alice create`, the on-disk-layout section shows the new
tree, and the global-flags table replaces `--data-dir` with `--name`
plus the new `amy use` verb.
This commit is contained in:
Claude
2026-04-25 15:07:31 +00:00
parent 46bdd59ead
commit 99586fbe7e
12 changed files with 240 additions and 200 deletions
+7 -8
View File
@@ -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
+74 -44
View File
@@ -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: <code>: <detail>` 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/<account>/`; 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
`<data-dir>/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/<pubkey>.json
find ~/.amy/shared/events-store/events -name '*.json' | head
jq . ~/.amy/shared/events-store/replaceable/0/<pubkey>.json
# delete a specific event (tombstone NOT installed — see below)
rm $AMY_HOME/events-store/events/<aa>/<bb>/<id>.json
rm ~/.amy/shared/events-store/events/<aa>/<bb>/<id>.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 <GID> npub1...bob
amy --data-dir ./alice marmot message send <GID> "hello"
amy --name alice marmot group create --name "Test Group"
amy --name alice marmot group add <GID> npub1...bob
amy --name alice marmot message send <GID> "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 <GID>
# 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 <GID>
```
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/<account>/` 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
```
<data-dir>/
├── identity.json # nsec/npub/hex — the account
├── state.json # sync cursors (giftWrapSince, groupSince)
── events-store/ # FsEventStore — every observed Nostr event
~/.amy/ ← root, follows $HOME
├── current # marker file written by `amy use NAME`
├── shared/
│ └── events-store/ # FsEventStore — every observed Nostr event
│ ├── events/<aa>/<bb>/… # canonical kind:0 / 3 / 10002 / 10050 / 10051 / 1 / 5 / 1059 / …
│ ├── replaceable/<k>/… # 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/
├── <gid>.mls # MLS group state per group
└── <gid>.log # decrypted inner events (one JSON per line)
── 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/
│ ├── <gid>.mls # MLS group state per group
│ └── <gid>.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 <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
@@ -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 `<root>/<name>/`; the event store is shared across accounts
* at `<root>/shared/events-store/`. `<root>` 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
* `<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
* 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 `<account>/` live. */
val DEFAULT_ROOT: File get() = File(System.getProperty("user.home"), ".amy")
/**
* Per-user root under which `shared/` and `<account>/` 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. `<root>/current` marker (set by `amy use X`).
* 3. Sole subdirectory of `<root>` 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 `<root>/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
* `<root>/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 <name> init` " +
"(or pass --data-dir <path> for a self-contained dir)",
"no account at ${rootBase.absolutePath}; create one with `amy --name <name> init`",
)
}
@@ -82,7 +82,6 @@ private suspend fun dispatch(argv: Array<String>): Int {
// Pull global flags out of argv before subcommand parsing so subcommands see
// only their own args.
val filteredArgs = mutableListOf<String>()
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<String>): 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<String>): 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]
| <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. ACCOUNT must match
| All state lives under ~/.amy/. Per-account directories
| ~/.amy/<account>/ 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
@@ -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 `<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) }
// Self-alias: 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`.
Aliases.set(dataDir, dataDir.accountName, created.npub)
Output.emit(
mapOf(
"name" to dataDir.accountName,
+48 -45
View File
@@ -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"
+7 -5
View File
@@ -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
+12 -9
View File
@@ -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 \
+20 -17
View File
@@ -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) || {
+10 -4
View File
@@ -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() {
+5 -2
View File
@@ -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"
+1 -1
View File
@@ -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"