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:
+7
-8
@@ -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
@@ -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,
|
||||||
|
|||||||
Vendored
+48
-45
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) || {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user