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 | | 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. | | 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. | | 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/`. | | 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 **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 ```bash
set -euo pipefail set -euo pipefail
TMP=$(mktemp -d) export HOME=$(mktemp -d) # virgin ~/.amy/ for the duration of this script
A=$TMP/alice; B=$TMP/bob
amy --data-dir "$A" create --name Alice amy --name alice create
amy --data-dir "$B" create --name Bob amy --name bob create
# ... the scenario under test ... # ... 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 If an Amethyst scenario cannot be scripted through Amy yet, that's
+79 -49
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 - **`--json` is the machine contract. One line. One object.** Stable
snake_case keys. Pipe it into `jq`, parse it from Python, hand it snake_case keys. Pipe it into `jq`, parse it from Python, hand it
to an agent. Pass `--json` anywhere before the subcommand: 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. - **stderr is for humans.** Progress, warnings, per-relay ACK traces.
Safe to discard. Errors land here too: `error: <code>: <detail>` by Safe to discard. Errors land here too: `error: <code>: <detail>` by
default, or JSON `{"error":"…","detail":"…"}` under `--json`. default, or JSON `{"error":"…","detail":"…"}` under `--json`.
@@ -42,10 +42,13 @@ What every caller — user, script, agent, CI — can rely on:
- `2` — bad arguments - `2` — bad arguments
- `124``await` timed out - `124``await` timed out
- **No interactive prompts, ever.** Passwords, names, keys — all flags. - **No interactive prompts, ever.** Passwords, names, keys — all flags.
- **Data-dir is the whole world.** All state (identity, relays, MLS - **`~/.amy/` is the whole world.** Per-account dirs hold identity,
epochs, message archives, run cursors) lives under `--data-dir PATH`. cursors, MLS state, and aliases at `~/.amy/<account>/`; every
Delete to reset; copy to move; `AMETHYST_CLI_DATA` env var overrides observed Nostr event lands in `~/.amy/shared/events-store/` (one
the default `./amy`. 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 Only the `--json` shape and the exit codes are public API. The default
text format is allowed to change between releases. The rationale lives 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 Every Nostr event Amy observes is verified (NIP-01 id + signature
check) and persisted to a file-backed store at 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 - events received from any relay subscription (`amy feed`, `amy dm
list`, `amy keypackage publish`, group sync, …), list`, `amy keypackage publish`, group sync, …),
@@ -107,11 +111,11 @@ To manage the store directly:
```sh ```sh
# raw inspection # raw inspection
find $AMY_HOME/events-store/events -name '*.json' | head find ~/.amy/shared/events-store/events -name '*.json' | head
jq . $AMY_HOME/events-store/replaceable/0/<pubkey>.json jq . ~/.amy/shared/events-store/replaceable/0/<pubkey>.json
# delete a specific event (tombstone NOT installed — see below) # 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" 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 ## Quick start
```bash ```bash
# 1. Create a data-dir with a full Amethyst-style account. # 1. Create the alice account (~/.amy/alice/) with a full Amethyst-style
# Generates a keypair, seeds default NIP-65 / inbox / key-package # bootstrap: keypair, default NIP-65 / inbox / key-package relays,
# relays, and publishes the nine bootstrap events. # nine bootstrap events.
amy --data-dir ./alice create --name "Alice" amy --name alice create
# 2. Publish a fresh MLS KeyPackage so others can invite you. # 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. # 3. Create a group, invite someone, send a message.
amy --data-dir ./alice marmot group create --name "Test Group" amy --name alice marmot group create --name "Test Group"
amy --data-dir ./alice marmot group add <GID> npub1...bob amy --name alice marmot group add <GID> npub1...bob
amy --data-dir ./alice marmot message send <GID> "hello" amy --name alice marmot message send <GID> "hello"
# 4. On the receiving side — poll until Bob sees the invite. # 4. On the receiving side, in ~/.amy/bob/.
amy --data-dir ./bob marmot await group --name "Test Group" --timeout 60 amy --name bob marmot await group --name "Test Group" --timeout 60
amy --data-dir ./bob marmot message list <GID> amy --name bob marmot message list <GID>
``` ```
Compose with `jq` to chain commands — pass `--json` so stdout switches With one account you can drop the flag (auto-pick); with several, pin
from the default human-readable text to the parseable single-line JSON: 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 ```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 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. | | `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. | | `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. | | `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 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). | | `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`. | | `relay add URL [--type T]` | `T = nip65 \| inbox \| key_package \| all`. |
@@ -228,10 +235,22 @@ itself crashed".
### Global flags ### Global flags
- `--data-dir PATH` — defaults to `./amy` or - `--name ACCOUNT` — pick which account under `~/.amy/<account>/` to
`$AMETHYST_CLI_DATA`. Always an absolute path after resolution. 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. - `--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 ## Relay routing
@@ -261,42 +280,53 @@ events.
--- ---
## Data-dir layout ## On-disk layout
``` ```
<data-dir>/ ~/.amy/ ← root, follows $HOME
├── identity.json # nsec/npub/hex — the account ├── current # marker file written by `amy use NAME`
├── state.json # sync cursors (giftWrapSince, groupSince) ├── shared/
── events-store/ # FsEventStore — every observed Nostr event │ └── events-store/ # FsEventStore — every observed Nostr event
│ ├── events/<aa>/<bb>/… # canonical kind:0 / 3 / 10002 / 10050 / 10051 / 1 / 5 / 1059 / … ├── 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 ├── 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 ├── addressable/… # one slot per (kind, pubkey, d-tag) for kind:30000-39999
│ ├── idx/ # hardlink indexes (kind / author / owner / tag / fts / expires_at) ├── idx/ # hardlink indexes (kind / author / owner / tag / fts / expires_at)
│ └── tombstones/ # NIP-09 / NIP-62 enforcement └── tombstones/ # NIP-09 / NIP-62 enforcement
── marmot/ ── alice/ # one dir per account (e.g. created by `amy --name alice init`)
├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal) ├── identity.json # nsec/npub/hex — the account
── groups/ ── state.json # sync cursors (giftWrapSince, groupSince)
├── <gid>.mls # MLS group state per group ├── aliases.json # local name → npub map (init writes a self-entry)
└── <gid>.log # decrypted inner events (one JSON per line) └── 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 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 The local relay configuration (kind:10002 / 10050 / 10051) is **not** a
separate file — it lives in `events-store/` as signed events. separate file — it lives in the shared `events-store/` as signed events
`amy relay add` builds + signs + ingests a new relay-list event; owned by the account that wrote them. `amy relay add` builds + signs +
`amy relay list` reads URLs straight out of the latest event for each ingests a new relay-list event; `amy relay list` reads URLs straight out
kind; `amy relay publish-lists` broadcasts those events to upstream of the latest event for each kind; `amy relay publish-lists` broadcasts
relays. There is no `relays.json`. those events to upstream relays. There is no `relays.json`.
--- ---
## Troubleshooting ## Troubleshooting
- **`no identity`** — run `init`, `create`, or `login` first, or pass a - **`no identity`** — run `init`, `create`, or `login` first, or pass a
different `--data-dir`. different `--name`.
- **`not_member`** — the group GID is unknown to this data-dir. Run - **`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 `marmot group list` to confirm, or `marmot await group --name …` to
wait for an invite to arrive. wait for an invite to arrive.
- **Hang on a network verb** — Amy connects to the relays advertised - **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. * 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 * Use [resolve] to construct one from CLI flags. [secrets] is the
* state (identity, sync cursors, MLS material, aliases) lives at * [SecretStore] that mediates private-key persistence. Owning it here
* `<root>/<name>/`; the public event store is shared across * keeps the call sites that already thread [DataDir] from having to
* accounts at `<root>/shared/events-store/`. `<root>` defaults to * learn about a second parameter.
* `~/.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.
*/ */
class DataDir( class DataDir(
val root: File, val root: File,
val eventsDir: File, val eventsDir: File,
val accountName: String?, val accountName: String,
val secrets: SecretStore, val secrets: SecretStore,
) { ) {
val identityFile = File(root, "identity.json") val identityFile = File(root, "identity.json")
@@ -213,8 +207,22 @@ class DataDir(
} }
companion object { 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`. */ /** Marker file (one line, just the account name) written by `amy use`. */
const val CURRENT_MARKER_NAME = "current" const val CURRENT_MARKER_NAME = "current"
@@ -242,32 +250,16 @@ class DataDir(
/** /**
* Build a [DataDir] from the parsed CLI flags. * Build a [DataDir] from the parsed CLI flags.
* *
* Resolution order in account mode (when `dataDirFlag` is null): * Resolution order:
* 1. `--name X` if provided. * 1. `--name X` if provided.
* 2. `<root>/current` marker (set by `amy use X`). * 2. `<root>/current` marker (set by `amy use X`).
* 3. Sole subdirectory of `<root>` other than `shared/`. * 3. Sole subdirectory of `<root>` other than `shared/`.
* 4. Error caller must disambiguate. * 4. Error caller must disambiguate via `--name` or `amy use`.
*
* Passing both `dataDirFlag` and `nameFlag` is an
* `IllegalArgumentException` (exit 2 via `main`).
*/ */
fun resolve( fun resolve(
dataDirFlag: String?,
nameFlag: String?, nameFlag: String?,
secrets: SecretStore, secrets: SecretStore,
): DataDir { ): 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 rootBase = DEFAULT_ROOT
val name = if (nameFlag != null) validateName(nameFlag) else pickAccount(rootBase) val name = if (nameFlag != null) validateName(nameFlag) else pickAccount(rootBase)
val accountRoot = File(rootBase, name).absoluteFile val accountRoot = File(rootBase, name).absoluteFile
@@ -281,10 +273,10 @@ class DataDir(
} }
/** /**
* Auto-select an account when neither `--name` nor `--data-dir` * Auto-select an account when `--name` was not given. Honours
* was given. Honours `<root>/current` first (explicit pin from * `<root>/current` first (explicit pin from `amy use`), then
* `amy use`), then falls back to "exactly one account exists". * falls back to "exactly one account exists". Throws
* Throws [IllegalArgumentException] for the ambiguous cases so * [IllegalArgumentException] for the ambiguous cases so
* `main`'s catch-all turns them into a clean exit-2 error. * `main`'s catch-all turns them into a clean exit-2 error.
*/ */
private fun pickAccount(rootBase: File): String { private fun pickAccount(rootBase: File): String {
@@ -304,8 +296,7 @@ class DataDir(
return when (accounts.size) { return when (accounts.size) {
0 -> { 0 -> {
throw IllegalArgumentException( throw IllegalArgumentException(
"no account at ${rootBase.absolutePath}; create one with `amy --name <name> init` " + "no account at ${rootBase.absolutePath}; create one with `amy --name <name> init`",
"(or pass --data-dir <path> for a self-contained dir)",
) )
} }
@@ -82,7 +82,6 @@ private suspend fun dispatch(argv: Array<String>): Int {
// Pull global flags out of argv before subcommand parsing so subcommands see // Pull global flags out of argv before subcommand parsing so subcommands see
// only their own args. // only their own args.
val filteredArgs = mutableListOf<String>() val filteredArgs = mutableListOf<String>()
var dataDirFlag: String? = null
var nameFlag: String? = null var nameFlag: String? = null
var secretBackendFlag: String? = null var secretBackendFlag: String? = null
var passphraseFileFlag: String? = null var passphraseFileFlag: String? = null
@@ -91,7 +90,6 @@ private suspend fun dispatch(argv: Array<String>): Int {
val a = argv[i] val a = argv[i]
val (matched, consumed) = extractGlobalFlag(a, argv, i) val (matched, consumed) = extractGlobalFlag(a, argv, i)
when (matched) { when (matched) {
GlobalFlag.DATA_DIR -> dataDirFlag = consumed.value
GlobalFlag.NAME -> nameFlag = consumed.value GlobalFlag.NAME -> nameFlag = consumed.value
GlobalFlag.SECRET_BACKEND -> secretBackendFlag = consumed.value GlobalFlag.SECRET_BACKEND -> secretBackendFlag = consumed.value
GlobalFlag.PASSPHRASE_FILE -> passphraseFileFlag = 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 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) { return when (head) {
"init" -> { "init" -> {
@@ -212,7 +210,6 @@ private enum class GlobalFlag(
val long: String, val long: String,
val takesValue: Boolean = true, val takesValue: Boolean = true,
) { ) {
DATA_DIR("--data-dir"),
NAME("--name"), NAME("--name"),
SECRET_BACKEND("--secret-backend"), SECRET_BACKEND("--secret-backend"),
PASSPHRASE_FILE("--passphrase-file"), PASSPHRASE_FILE("--passphrase-file"),
@@ -256,25 +253,28 @@ private fun printUsage() {
|amy Amethyst command-line interface |amy Amethyst command-line interface
| |
|Usage: |Usage:
| amy [--name ACCOUNT] (canonical: per-account dir under ~/.amy/) | amy [--name ACCOUNT]
| [--data-dir PATH] (escape hatch: self-contained dir at PATH)
| [--secret-backend auto|keychain|ncryptsec|plaintext] | [--secret-backend auto|keychain|ncryptsec|plaintext]
| [--passphrase-file PATH] | [--passphrase-file PATH]
| [--json] | [--json]
| <cmd> [args...] | <cmd> [args...]
| |
|Account selection: |Account selection:
| Default layout lives at ~/.amy/, with shared/events-store/ holding | All state lives under ~/.amy/. Per-account directories
| every observed Nostr event and ~/.amy/<account>/ holding identity, | ~/.amy/<account>/ hold identity, cursors, MLS state, and
| cursors, MLS state, and aliases. ACCOUNT must match | 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). | [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. | 1. --name X if given.
| 2. ~/.amy/current marker (set by `amy use X`). | 2. ~/.amy/current marker (set by `amy use X`).
| 3. Sole subdirectory of ~/.amy/ other than shared/. | 3. Sole subdirectory of ~/.amy/ other than shared/.
| 4. Error disambiguate with --name or `amy use`. | 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 NAME pin NAME as the active account
| use --clear remove the pin | use --clear remove the pin
| use print current pin + available accounts | use print current pin + available accounts
@@ -35,9 +35,9 @@ object InitCommands {
// would trigger a keychain prompt / passphrase dialog even though the // would trigger a keychain prompt / passphrase dialog even though the
// caller clearly already has the identity set up. // caller clearly already has the identity set up.
dataDir.loadIdentityFileOrNull()?.let { existing -> dataDir.loadIdentityFileOrNull()?.let { existing ->
// Idempotent self-alias upsert when --name was passed and the // Idempotent self-alias upsert when the dir already exists
// dir already exists (e.g. user re-runs `init --name alice`). // (e.g. user re-runs `init --name alice`).
dataDir.accountName?.let { Aliases.set(dataDir, it, existing.npub) } Aliases.set(dataDir, dataDir.accountName, existing.npub)
Output.emit( Output.emit(
mapOf( mapOf(
"name" to dataDir.accountName, "name" to dataDir.accountName,
@@ -53,10 +53,10 @@ object InitCommands {
val nsec = args.flag("nsec") val nsec = args.flag("nsec")
val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create() val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create()
dataDir.saveIdentity(created) dataDir.saveIdentity(created)
// Self-alias: in account mode, record `<name> -> own npub` so the // Self-alias: record `<name> -> own npub` so the user can refer
// user can refer to their own account by name in scripts and (once // to their own account by name in scripts and (once the resolver
// the resolver lands) in recipient slots like `dm send`. // lands) in recipient slots like `dm send`.
dataDir.accountName?.let { Aliases.set(dataDir, it, created.npub) } Aliases.set(dataDir, dataDir.accountName, created.npub)
Output.emit( Output.emit(
mapOf( mapOf(
"name" to dataDir.accountName, "name" to dataDir.accountName,
+48 -45
View File
@@ -28,8 +28,10 @@ REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-cache-headless" STATE_DIR="$SCRIPT_DIR/state-cache-headless"
LOG_DIR="$STATE_DIR/logs" LOG_DIR="$STATE_DIR/logs"
A_DIR="$STATE_DIR/A" # Per-account dirs under the same fake $HOME=$STATE_DIR — accounts A
B_DIR="$STATE_DIR/B" # 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)" RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log" LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
@@ -64,7 +66,7 @@ while [[ $# -gt 0 ]]; do
shift shift
done done
mkdir -p "$STATE_DIR" "$LOG_DIR" "$A_DIR" "$B_DIR" mkdir -p "$STATE_DIR" "$LOG_DIR"
: >"$LOG_FILE" : >"$LOG_FILE"
: >"$RESULTS_FILE" : >"$RESULTS_FILE"
@@ -81,7 +83,7 @@ source "$TESTS_DIR/headless/helpers.sh"
# shellcheck source=../dm/setup.sh # shellcheck source=../dm/setup.sh
source "$TESTS_DIR/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 # 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. # 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 preflight_dm
start_local_relay start_local_relay
# Identity A: full bootstrap so the store has kind:0 / 3 / 10002 / … # Identity A: full bootstrap so the shared store has kind:0 / 3 / 10002 / …
ensure_identity_for A "$A_DIR" ensure_identity_for A
banner "Bootstrapping A's account (publishes kind:0 + bootstrap events)" 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_a relay add "$RELAY_URL" --type all >>"$LOG_FILE" 2>&1
# `amy create` here would mint a *second* identity; A_DIR already has one # `amy create` here would mint a *second* identity; A already exists from
# from `init`. Build the bootstrap events ourselves by publishing a # `init`. Build the bootstrap events ourselves by publishing a minimal
# minimal kind:0 + the relay lists, all of which land in A's local store # kind:0 + the relay lists, all of which land in the shared store via
# via verifyAndStore. # verifyAndStore.
"$AMY_BIN" --data-dir "$A_DIR" relay publish-lists >>"$LOG_FILE" 2>&1 amy_a relay publish-lists >>"$LOG_FILE" 2>&1
amy_a profile edit --name "AAA" --about "cache test subject" \ amy_a profile edit --name "AAA" --about "cache test subject" \
>>"$LOG_FILE" 2>&1 \ >>"$LOG_FILE" 2>&1 \
|| fail_msg "amy_a profile edit failed" || fail_msg "amy_a profile edit failed"
# Identity B: separate data-dir, separate cache, also pointed at the relay. # Identity B: same fake $HOME, separate per-account dir, but A and B
ensure_identity_for B "$B_DIR" # both read/write to $STATE_DIR/.amy/shared/events-store/.
"$AMY_BIN" --data-dir "$B_DIR" relay add "$RELAY_URL" --type all >>"$LOG_FILE" 2>&1 ensure_identity_for B
"$AMY_BIN" --data-dir "$B_DIR" relay publish-lists >>"$LOG_FILE" 2>&1 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. # 1. amy store stat reports a non-empty store after bootstrap.
@@ -191,24 +194,20 @@ else
fi 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" banner "T4 — B's profile lookup of A is a shared-store cache hit"
T4A=$(amy_b_json profile show "$A_NPUB") T4=$(amy_b_json profile show "$A_NPUB")
T4A_SRC=$(printf '%s' "$T4A" | jq -r '.source') T4_SRC=$(printf '%s' "$T4" | jq -r '.source')
T4A_NAME=$(printf '%s' "$T4A" | jq -r '.metadata.name // ""') T4_NAME=$(printf '%s' "$T4" | jq -r '.metadata.name // ""')
assert_eq "$T4A_SRC" "relays" T4a.source "first lookup of a stranger comes from relays" \ assert_eq "$T4_SRC" "cache" T4.source "shared store should hand B a cached A profile immediately" \
&& record_result T4a.source pass "first profile lookup of stranger hit relays" && record_result T4.source pass "B saw A from the shared cache"
assert_eq "$T4A_NAME" "AAA" T4a.name "B should resolve A's name on first fetch" \ assert_eq "$T4_NAME" "AAA" T4.name "A's profile metadata must be readable from B's invocation" \
&& record_result T4a.name pass "B resolved A's metadata" && record_result T4.name pass "shared-cache metadata round-trips across accounts"
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"
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 5. relay list reads URLs back from the local kind:10002 / 10050 / 10051. # 5. relay list reads URLs back from the local kind:10002 / 10050 / 10051.
@@ -245,20 +244,24 @@ else
fi 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" banner "T7 — store maintenance verbs (sweep/scrub/compact) on an empty store"
TMP_NO_ID=$(mktemp -d) TMP_HOME=$(mktemp -d)
T7_STAT=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store stat) T7_AMY=(${AMY_BIN} --secret-backend plaintext --name throwaway --json store)
T7_SWEEP=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store sweep-expired) T7_STAT=$(HOME="$TMP_HOME" "${T7_AMY[@]}" stat)
T7_SCRUB=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store scrub) T7_SWEEP=$(HOME="$TMP_HOME" "${T7_AMY[@]}" sweep-expired)
T7_COMPACT=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store compact) 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 "" \ 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 "" \ 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 "" \ 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 "" \ assert_eq "$(printf '%s' "$T7_COMPACT" | jq -r '.ok')" "true" T7.compact.ok "" \
&& record_result T7.compact pass "compact works without identity" && record_result T7.compact pass "compact works on empty store"
rm -rf "$TMP_NO_ID" rm -rf "$TMP_HOME"
+7 -5
View File
@@ -15,8 +15,10 @@ REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-dm-headless" STATE_DIR="$SCRIPT_DIR/state-dm-headless"
LOG_DIR="$STATE_DIR/logs" LOG_DIR="$STATE_DIR/logs"
A_DIR="$STATE_DIR/A" # Per-account dirs under the same fake $HOME=$STATE_DIR so amy treats
D_DIR="$STATE_DIR/D" # 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)" RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log" LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
@@ -57,7 +59,7 @@ while [[ $# -gt 0 ]]; do
shift shift
done done
mkdir -p "$STATE_DIR" "$LOG_DIR" "$A_DIR" "$D_DIR" mkdir -p "$STATE_DIR" "$LOG_DIR"
: >"$LOG_FILE" : >"$LOG_FILE"
: >"$RESULTS_FILE" : >"$RESULTS_FILE"
@@ -94,8 +96,8 @@ trap 'exit 129' HUP
banner "Amethyst NIP-17 DM headless interop ($RUN_TS)" banner "Amethyst NIP-17 DM headless interop ($RUN_TS)"
preflight_dm preflight_dm
start_local_relay start_local_relay
ensure_identity_for A "$A_DIR" ensure_identity_for A
ensure_identity_for D "$D_DIR" ensure_identity_for D
configure_relays_dm configure_relays_dm
test_01_dm_text_round_trip test_01_dm_text_round_trip
+12 -9
View File
@@ -69,21 +69,24 @@ preflight_dm() {
} }
# --- amy identity wrappers --------------------------------------------------- # --- amy identity wrappers ---------------------------------------------------
# Two identities: A (sender) and D (recipient). We reuse A_DIR for parity # Two identities: A (sender) and D (recipient), both inside the same
# with the existing harness files; D_DIR is new. # $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 — # `--secret-backend=plaintext` keeps these throwaway interop runs headless —
# the default `auto` would try the OS keychain (not available in CI) and then # 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. # 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_a() { HOME="$STATE_DIR" "$AMY_BIN" --name A --secret-backend plaintext --json "$@"; }
amy_d() { "$AMY_BIN" --data-dir "$D_DIR" --secret-backend plaintext --json "$@"; } amy_d() { HOME="$STATE_DIR" "$AMY_BIN" --name D --secret-backend plaintext --json "$@"; }
# --- identity bootstrap ------------------------------------------------------ # --- identity bootstrap ------------------------------------------------------
ensure_identity_for() { ensure_identity_for() {
local who="$1" dir="$2" local who="$1"
step "initialising Identity $who (amy at $dir)" step "initialising Identity $who (amy at \$HOME=$STATE_DIR --name $who)"
local out 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 fail_msg "amy init failed for $who: $out"; exit 1
} }
local npub hex local npub hex
@@ -103,8 +106,8 @@ ensure_identity_for() {
# so the DM strict-relay routing has something to resolve to. # so the DM strict-relay routing has something to resolve to.
configure_relays_dm() { configure_relays_dm() {
banner "Configuring relays → $RELAY_URL" banner "Configuring relays → $RELAY_URL"
"$AMY_BIN" --data-dir "$A_DIR" relay add "$RELAY_URL" --type all >/dev/null amy_a relay add "$RELAY_URL" --type all >/dev/null
"$AMY_BIN" --data-dir "$D_DIR" 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" step "publishing A's NIP-65 + kind:10050 lists"
amy_a relay publish-lists >>"$LOG_FILE" 2>&1 \ 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-05 file message reference mode round-trip (kind:15)
# dm-06 cursor advance (subsequent no-flag `dm list` is empty) # 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. # Wrap amy_json around either account so the per-test code stays tight.
# `--json` opts into amy's machine-readable contract; assertions below # Both share $HOME=$STATE_DIR (set by the harness); --name picks the
# parse with jq. # account inside it. `--json` opts into amy's machine-readable contract;
# assertions below parse with jq.
amy_json_for() { amy_json_for() {
local dir="$1"; shift local account="$1"; shift
local out local out
if ! out=$("$AMY_BIN" --data-dir "$dir" --json "$@" 2>>"$LOG_FILE"); then if ! out=$(HOME="$STATE_DIR" "$AMY_BIN" --name "$account" --secret-backend plaintext --json "$@" 2>>"$LOG_FILE"); then
fail_msg "amy --data-dir $dir $*: exit $? (see $LOG_FILE)" fail_msg "amy --name $account $*: exit $? (see $LOG_FILE)"
printf '%s\n' "$out" >>"$LOG_FILE" printf '%s\n' "$out" >>"$LOG_FILE"
return 1 return 1
fi fi
printf '%s' "$out" printf '%s' "$out"
} }
amy_json_a() { amy_json_for "$A_DIR" "$@"; } amy_json_a() { amy_json_for A "$@"; }
amy_json_d() { amy_json_for "$D_DIR" "$@"; } amy_json_d() { amy_json_for D "$@"; }
test_01_dm_text_round_trip() { test_01_dm_text_round_trip() {
banner "DM-01 — text round-trip A↔D (kind:14)" 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" local id="dm-03 strict no_dm_relays"
# Generate a throwaway identity but do NOT publish its kind:10050. # 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 local ghost_out ghost_npub
ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" --secret-backend plaintext --json init) || { ghost_out=$(HOME="$ghost_home" "$AMY_BIN" --name ghost --secret-backend plaintext --json init) || {
record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return record_result "$id" fail "ghost init failed"; rm -rf "$ghost_home"; return
} }
ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub') ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub')
info "ghost: $ghost_npub (no relays advertised)" info "ghost: $ghost_npub (no relays advertised)"
# A sends without --allow-fallback; amy should refuse. # A sends without --allow-fallback; amy should refuse.
local raw rc 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=$? rc=$?
rm -rf "$tmpdir" rm -rf "$ghost_home"
if [[ "$rc" -ne 0 ]] && printf '%s' "$raw" | grep -q '"error":"no_dm_relays"'; then 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)" info "amy refused with no_dm_relays as expected (exit $rc)"
record_result "$id" pass 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 # fall through kind:10050 → NIP-65 read → bootstrap. Our bootstrap set
# always includes the loopback relay via the shared RelayConfig, so the # always includes the loopback relay via the shared RelayConfig, so the
# publish should succeed even though the ghost has no 10050. # 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 local ghost_out ghost_npub
ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" --secret-backend plaintext --json init) || { ghost_out=$(HOME="$ghost_home" "$AMY_BIN" --name ghost --secret-backend plaintext --json init) || {
record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return record_result "$id" fail "ghost init failed"; rm -rf "$ghost_home"; return
} }
ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub') ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub')
rm -rf "$tmpdir" rm -rf "$ghost_home"
local out source local out source
out=$(amy_json_a dm send "$ghost_npub" "hi via fallback" --allow-fallback) || { 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. # helpers.sh — thin wrappers that keep the per-test code tight.
# --- amy wrapper ------------------------------------------------------------- # --- amy wrapper -------------------------------------------------------------
# `--secret-backend=plaintext` keeps these throwaway interop runs headless — # Tests isolate by overriding $HOME for the amy subprocess; amy reads the
# the default `auto` would try the OS keychain (not available in CI) and then # env var directly (not Java's `user.home`, which JDK 21 pulls from
# ask for a NIP-49 passphrase. Plaintext still writes 0600-owner-only. # /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 # `--json` opts into amy's machine-readable output (the harness parses it
# with jq); the default human-text output is for terminal use. # 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. # Run amy, log stderr, surface JSON on stdout, remember last result.
amy_json() { amy_json() {
+5 -2
View File
@@ -18,7 +18,10 @@ REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-headless" STATE_DIR="$SCRIPT_DIR/state-headless"
LOG_DIR="$STATE_DIR/logs" 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" B_DIR="$STATE_DIR/B"
C_DIR="$STATE_DIR/C" C_DIR="$STATE_DIR/C"
B_SOCKET="$B_DIR/release/wnd.sock" B_SOCKET="$B_DIR/release/wnd.sock"
@@ -62,7 +65,7 @@ while [[ $# -gt 0 ]]; do
shift shift
done 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" : >"$LOG_FILE"
: >"$RESULTS_FILE" : >"$RESULTS_FILE"
+1 -1
View File
@@ -271,7 +271,7 @@ test_14_wn_removes_a() {
local deadline=$(( $(date +%s) + 120 )) removed=0 local deadline=$(( $(date +%s) + 120 )) removed=0
while [[ $(date +%s) -lt $deadline ]]; do while [[ $(date +%s) -lt $deadline ]]; do
local show rc 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) marmot group show "$a_gid" 2>&1)
rc=$? rc=$?
printf '%s\n' "$show" >>"$LOG_FILE" printf '%s\n' "$show" >>"$LOG_FILE"