mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
Merge pull request #2572 from vitorpamplona/claude/compare-cli-interface-SEx9K
Dual-output contract: text by default, --json for machines
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: amy-expert
|
||||
description: Patterns for extending `amy`, the Amethyst CLI in `cli/`. Use when adding an `amy <verb>` command, touching files under `cli/src/main/kotlin/…/cli/`, wiring a new subcommand into `Main.kt`, writing an interop test script that drives Amy, or extracting logic out of `amethyst/` into `commons/` so a CLI command can call it. Enforces the thin-assembly-layer rule (no Nostr protocol or business logic inside `cli/`), the JSON-output contract (single-line object on stdout, exit codes 0/1/2/124), and the extract-from-Android recipe. Complements `nostr-expert` (protocol in Quartz), `kotlin-multiplatform` (expect/actual for extraction), and `feed-patterns` / `account-state` / `relay-client` (where the business logic should end up). NOT for general Nostr or Kotlin work — those have their own skills.
|
||||
description: Patterns for extending `amy`, the Amethyst CLI in `cli/`. Use when adding an `amy <verb>` command, touching files under `cli/src/main/kotlin/…/cli/`, wiring a new subcommand into `Main.kt`, writing an interop test script that drives Amy, or extracting logic out of `amethyst/` into `commons/` so a CLI command can call it. Enforces the thin-assembly-layer rule (no Nostr protocol or business logic inside `cli/`), the dual-output contract (text by default, single-line JSON object on stdout under `--json`, exit codes 0/1/2/124), and the extract-from-Android recipe. Complements `nostr-expert` (protocol in Quartz), `kotlin-multiplatform` (expect/actual for extraction), and `feed-patterns` / `account-state` / `relay-client` (where the business logic should end up). NOT for general Nostr or Kotlin work — those have their own skills.
|
||||
---
|
||||
|
||||
# Amy CLI Expert
|
||||
@@ -42,17 +42,28 @@ A `commands/*.kt` file longer than ~200 lines is a code smell.
|
||||
Either the command is doing too many things, or the logic has
|
||||
leaked in from where it should have lived.
|
||||
|
||||
### Rule 2 — stdout JSON, stderr humans
|
||||
### Rule 2 — text by default, `--json` is the machine contract
|
||||
|
||||
- Every success: one line, one JSON object on stdout, via
|
||||
`Json.writeLine(mapOf(...))`.
|
||||
- Every failure: `Json.error("code", "detail")` → single-line JSON
|
||||
on stderr, non-zero exit.
|
||||
- Exit codes: `0` success · `1` runtime · `2` bad args · `124` await
|
||||
timeout.
|
||||
- Keys are stable, snake_case. Adding a key is safe; renaming or
|
||||
removing one is a breaking change and needs the commit message to
|
||||
say so.
|
||||
amy ships a dual-output contract:
|
||||
|
||||
- **Default stdout is human-readable text.** A YAML-ish render of the
|
||||
result map. No shape promise — the renderer can change between
|
||||
releases.
|
||||
- **`--json` switches stdout to one JSON object, one line.** Stable
|
||||
snake_case keys; this shape is the public API.
|
||||
- **stderr is for humans.** Progress logs, warnings, per-relay ACK
|
||||
traces. Errors go here too — `error: <code>: <detail>` by default,
|
||||
JSON `{"error":"…","detail":"…"}` under `--json`.
|
||||
- **Exit codes:** `0` success · `1` runtime · `2` bad args · `124`
|
||||
await timeout.
|
||||
- Adding a `--json` key is safe; renaming or removing one is a
|
||||
breaking change and needs the commit message to say so.
|
||||
|
||||
Commands emit results via `Output.emit(mapOf(...))` and errors via
|
||||
`Output.error("code", "detail")`. The `Output` object (in
|
||||
`cli/src/main/kotlin/…/cli/Output.kt`) handles the text-vs-JSON
|
||||
branching automatically. Never `println(...)` user-facing output
|
||||
directly — `System.err.println(...)` is fine for progress logs only.
|
||||
|
||||
See `references/output-conventions.md`.
|
||||
|
||||
@@ -62,16 +73,37 @@ No `readLine()`, no TTY prompts, no hidden interactive behaviour.
|
||||
Passwords, names, keys, anything — all flags. Any network wait is
|
||||
an explicit `await` verb with `--timeout`.
|
||||
|
||||
### Rule 4 — Data-dir is the whole world
|
||||
### Rule 4 — `~/.amy/` is the whole world
|
||||
|
||||
State is reloaded from `--data-dir PATH` on every invocation. No
|
||||
singletons, no in-process caches that survive across runs. This is
|
||||
what lets 100 parallel interop scenarios share a harness safely.
|
||||
State is reloaded from `~/.amy/` on every invocation. No singletons,
|
||||
no in-process caches that survive across runs. This is what lets 100
|
||||
parallel interop scenarios share a harness safely.
|
||||
|
||||
Files live in well-known locations — see
|
||||
`cli/README.md § Data-dir layout`. Don't add unstructured state; if
|
||||
you need new persisted state, add it to `Config.kt` or
|
||||
`stores/FileStores.kt` with a named JSON schema.
|
||||
The layout:
|
||||
|
||||
- `~/.amy/shared/events-store/` — one file-backed Nostr event store
|
||||
per machine, shared across every account.
|
||||
- `~/.amy/<account>/` — per-account dir: `identity.json`,
|
||||
`state.json`, `aliases.json`, `marmot/`.
|
||||
- `~/.amy/current` — marker file written by `amy use NAME` to pin
|
||||
the active account.
|
||||
|
||||
Account selection is via the global `--account NAME` flag (required
|
||||
when more than one account exists; auto-picked when exactly one
|
||||
does). `--account` cannot collide with subcommand flags, so commands
|
||||
like `marmot group create --name "Group"` or `profile edit --name "Alice"`
|
||||
keep their own `--name` parameter.
|
||||
|
||||
Tests isolate by overriding `$HOME` for the amy subprocess
|
||||
(`HOME=$(mktemp -d) amy --account alice init`). amy reads `$HOME`
|
||||
directly (not `user.home`, which JDK 21 derives from `getpwuid` and
|
||||
ignores `$HOME`), so the same convention `git`/`gpg`/`npm`/`ssh`
|
||||
follow Just Works.
|
||||
|
||||
If you need new persisted state, add it to `Config.kt`,
|
||||
`stores/FileStores.kt`, or a new helper (e.g. `Aliases.kt`) with a
|
||||
named JSON schema. Don't smuggle state into `~/.amy/` outside the
|
||||
documented files.
|
||||
|
||||
### Rule 5 — Extract before adding
|
||||
|
||||
@@ -92,9 +124,9 @@ Full checklist: `references/extraction-recipe.md`.
|
||||
## Standard command shape
|
||||
|
||||
Every new command follows the same shape — parse args, open Context,
|
||||
prepare, call into commons/quartz, publish or drain, emit one JSON
|
||||
line. The template is in `references/command-template.md`; copy it
|
||||
rather than re-deriving it.
|
||||
prepare, call into commons/quartz, publish or drain, emit one result
|
||||
via `Output.emit`. The template is in `references/command-template.md`;
|
||||
copy it rather than re-deriving it.
|
||||
|
||||
Wire-up checklist:
|
||||
1. New file in `cli/commands/` with the `object` pattern.
|
||||
@@ -107,34 +139,54 @@ Wire-up checklist:
|
||||
7. If the verb changes observable wire behaviour (a new event kind,
|
||||
a new relay-routing rule, a new JSON discriminator), add a case
|
||||
in the appropriate harness under `cli/tests/` — `cli/tests/marmot/`
|
||||
for MLS flows, `cli/tests/dm/` for NIP-17, or a new sibling suite
|
||||
if it's neither.
|
||||
for MLS flows, `cli/tests/dm/` for NIP-17, `cli/tests/cache/` for
|
||||
event-store behaviour, or a new sibling suite if it's none.
|
||||
|
||||
If you change output shape: note it in the commit message, bump the
|
||||
example in `README.md`, update any interop fixtures under
|
||||
`cli/tests/`.
|
||||
If you change `--json` output shape: note it in the commit message,
|
||||
bump the example in `cli/README.md`, update any interop fixtures
|
||||
under `cli/tests/`.
|
||||
|
||||
## Where things live
|
||||
|
||||
```
|
||||
cli/
|
||||
├── README.md # user-facing: commands, JSON contract, quick start
|
||||
├── DEVELOPMENT.md # touch-the-code: architecture, conventions, testing
|
||||
├── README.md # user-facing tour: install, examples, command tables
|
||||
├── DEVELOPMENT.md # public contract, architecture, design rules,
|
||||
│ # event-store, relay-routing, full on-disk layout
|
||||
├── ROADMAP.md # parity matrix + ordered milestones
|
||||
├── plans/ # dated design docs (use for new subsystems)
|
||||
├── tests/ # end-to-end shell harnesses against a local relay
|
||||
│ ├── lib.sh # shared logging + result tracking
|
||||
│ ├── headless/ # shared amy wrappers + assertions
|
||||
│ ├── marmot/ # MLS group-messaging interop (vs whitenoise-rs)
|
||||
│ └── dm/ # NIP-17 DM interop (two amy clients)
|
||||
│ ├── dm/ # NIP-17 DM interop (two amy clients)
|
||||
│ └── cache/ # FsEventStore behaviour vs the cache helpers
|
||||
└── src/main/kotlin/…/cli/
|
||||
├── Main.kt # argv dispatch
|
||||
├── Main.kt # argv dispatch, global flags
|
||||
├── Args.kt # flag parser
|
||||
├── Json.kt # stdout/stderr JSON
|
||||
├── Config.kt # Identity, RelayConfig, RunState, DataDir
|
||||
├── Output.kt # text/json mode emitter + colour
|
||||
├── Aliases.kt # per-account aliases.json read/write
|
||||
├── Config.kt # Identity, RunState, DataDir (~/.amy layout)
|
||||
├── Context.kt # per-run wiring — the backbone
|
||||
├── stores/ # file-backed persistence
|
||||
└── commands/ # one file per top-level verb group
|
||||
├── SecureFileIO.kt # 0600/0700 atomic writes, perm tighten
|
||||
├── stores/ # file-backed MLS / KP / message stores
|
||||
├── secrets/ # SecretStore backends (keychain / ncryptsec / plaintext)
|
||||
└── commands/ # one file (or group) per top-level verb
|
||||
├── UseCommand.kt # `amy use NAME`
|
||||
├── InitCommands.kt # init, whoami
|
||||
├── CreateCommand.kt + LoginCommand.kt
|
||||
├── RelayCommands.kt
|
||||
├── ProfileCommands.kt
|
||||
├── NotesCommands.kt + PostCommand.kt + FeedCommand.kt
|
||||
├── DmCommands.kt
|
||||
├── KeyPackageCommands.kt
|
||||
├── GroupCommands.kt + GroupCreateCommand.kt + GroupReadCommands.kt
|
||||
│ GroupAddMemberCommand.kt + GroupMembershipCommands.kt
|
||||
│ GroupMetadataCommands.kt
|
||||
├── MessageCommands.kt
|
||||
├── MarmotResetCommand.kt
|
||||
├── AwaitCommands.kt
|
||||
└── StoreCommands.kt
|
||||
```
|
||||
|
||||
Shared logic consumed by Amy lives in `commons/`:
|
||||
@@ -146,9 +198,10 @@ Shared logic consumed by Amy lives in `commons/`:
|
||||
## Common mistakes to refuse
|
||||
|
||||
- **Adding protocol logic to `cli/`.** Push back, offer to extract.
|
||||
- **Silently changing a JSON key.** Flag as breaking.
|
||||
- **Using `println` or `print`.** Use `Json.writeLine` / `Json.error`.
|
||||
Plain `System.err.println` is fine for progress logs but never for
|
||||
- **Silently changing a `--json` key.** Flag as breaking.
|
||||
- **Using `println` or `print` for command output.** Use
|
||||
`Output.emit(...)` / `Output.error(...)`. Plain
|
||||
`System.err.println` is fine for progress logs but never for
|
||||
user-consumable output.
|
||||
- **`runBlocking` inside a command** — the top-level `main` already
|
||||
does that. Commands are `suspend fun`.
|
||||
@@ -158,6 +211,13 @@ Shared logic consumed by Amy lives in `commons/`:
|
||||
or `resolveUserHexOrNull` in `quartz/nip05DnsIdentifiers/`.
|
||||
- **Re-inventing publish-and-confirm.** Use `Context.publish`.
|
||||
- **Re-inventing one-shot subscription.** Use `Context.drain`.
|
||||
- **Reading `user.home` directly.** Use `DataDir.DEFAULT_ROOT`, which
|
||||
reads `$HOME` (the convention `git`/`gpg`/`npm` follow); JDK 21's
|
||||
`user.home` is derived from `getpwuid` and ignores `$HOME`, which
|
||||
silently breaks the test-isolation pattern.
|
||||
- **Adding a global flag that collides with subcommand flags.**
|
||||
`--name` is reserved for subcommand use (group/profile names).
|
||||
Account selection is `--account`.
|
||||
|
||||
## Plans & design docs
|
||||
|
||||
@@ -171,9 +231,10 @@ frozen.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [`cli/README.md`](../../../cli/README.md)
|
||||
- [`cli/DEVELOPMENT.md`](../../../cli/DEVELOPMENT.md)
|
||||
- [`cli/ROADMAP.md`](../../../cli/ROADMAP.md)
|
||||
- [`cli/README.md`](../../../cli/README.md) — user-facing tour
|
||||
- [`cli/DEVELOPMENT.md`](../../../cli/DEVELOPMENT.md) — public
|
||||
contract, architecture, on-disk layout
|
||||
- [`cli/ROADMAP.md`](../../../cli/ROADMAP.md) — parity matrix
|
||||
- `references/command-template.md`
|
||||
- `references/extraction-recipe.md`
|
||||
- `references/output-conventions.md`
|
||||
|
||||
@@ -11,7 +11,7 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
|
||||
object NotePublishCommand {
|
||||
suspend fun run(dataDir: DataDir, rest: Array<String>): Int {
|
||||
@@ -26,7 +26,7 @@ object NotePublishCommand {
|
||||
.buildTextNote(ctx.signer, text)
|
||||
val ack = ctx.publish(event, ctx.outboxRelays())
|
||||
|
||||
Json.writeLine(mapOf(
|
||||
Output.emit(mapOf(
|
||||
"event_id" to event.id,
|
||||
"kind" to event.kind,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
@@ -40,6 +40,10 @@ object NotePublishCommand {
|
||||
}
|
||||
```
|
||||
|
||||
`Output.emit(...)` handles the text-vs-JSON mode automatically. The
|
||||
result map IS the `--json` shape; the human-readable text default is
|
||||
derived from the same map by `Output.kt`'s renderer.
|
||||
|
||||
## Multi-verb group
|
||||
|
||||
When a feature has several verbs (`note publish`, `note show`,
|
||||
@@ -48,13 +52,13 @@ When a feature has several verbs (`note publish`, `note show`,
|
||||
```kotlin
|
||||
object NoteCommands {
|
||||
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "note <publish|show|react>")
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "note <publish|show|react>")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"publish" -> NotePublishCommand.run(dataDir, rest)
|
||||
"show" -> NoteShowCommand.run(dataDir, rest)
|
||||
"react" -> NoteReactCommand.run(dataDir, rest)
|
||||
else -> Json.error("bad_args", "note ${tail[0]}")
|
||||
else -> Output.error("bad_args", "note ${tail[0]}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,13 +89,17 @@ For every new command:
|
||||
|
||||
- No `runBlocking` in a command body — `main()` already does it.
|
||||
- No `println` / `print` for command output — use
|
||||
`Json.writeLine(...)`. `System.err.println(...)` is fine for
|
||||
progress logs (they're already disposable).
|
||||
`Output.emit(...)` / `Output.error(...)`. `System.err.println(...)`
|
||||
is fine for progress logs (they're already disposable).
|
||||
- No swallowing errors — let exceptions bubble; `main()` translates
|
||||
them to `{"error":...}` + exit code.
|
||||
them to `error: …` (text mode) / `{"error":…}` (JSON mode) plus the
|
||||
right exit code.
|
||||
- No holding a connection open across invocations — every run opens
|
||||
a fresh `Context` and closes it in `finally`.
|
||||
- No blocking reads for user input — take a flag.
|
||||
- No global flags that collide with subcommand flags. `--name` is
|
||||
reserved for subcommand use (group/profile name); the global
|
||||
account selector is `--account`.
|
||||
|
||||
## Output-shape rules
|
||||
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
# Output conventions
|
||||
|
||||
Amy's JSON contract is its public API. Follow these rules.
|
||||
amy ships a dual-output contract. Default stdout is human-readable
|
||||
text (a YAML-ish render of the underlying result map); `--json` flips
|
||||
stdout to a single JSON object per success. The text shape can drift;
|
||||
the `--json` shape is the public API.
|
||||
|
||||
Commands always emit via `Output.emit(mapOf(...))`. The map IS the
|
||||
JSON shape — the renderer in `Output.kt` derives the text from the
|
||||
same map. Don't write two render paths; write one map and let
|
||||
`Output` pick.
|
||||
|
||||
## Channels
|
||||
|
||||
| Stream | What goes here |
|
||||
|---|---|
|
||||
| **stdout** | Exactly one JSON object per successful invocation. Nothing else. |
|
||||
| **stderr** | Human progress logs, warnings, per-relay ACK traces, stack traces, `printUsage()` output. Safe to discard. Not machine-consumed. |
|
||||
| Stream | Default mode | `--json` mode |
|
||||
|---|---|---|
|
||||
| **stdout** | YAML-ish text from `Output.emit(...)` | Exactly one JSON object per successful invocation |
|
||||
| **stderr** | Human progress logs, warnings, per-relay ACK traces, stack traces, `printUsage()` output, errors as `error: <code>: <detail>` | Same logs, plus errors as `{"error":...,"detail":...}` |
|
||||
|
||||
If a command needs to emit structured data for machines, it goes on
|
||||
stdout. If it needs to explain what it's doing to a human watching,
|
||||
stderr.
|
||||
stdout and is automatically JSON under `--json`. If it needs to
|
||||
explain what it's doing to a human watching, stderr.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `0` | Success. Stdout has a JSON object. |
|
||||
| `1` | Runtime error. Stderr has `{"error":"...","detail":"..."}`. |
|
||||
| `2` | Bad arguments. Stderr has a JSON error object and/or usage. |
|
||||
| `0` | Success. |
|
||||
| `1` | Runtime error. |
|
||||
| `2` | Bad arguments. |
|
||||
| `124` | `await` timed out. |
|
||||
|
||||
Throw the right exception type in commands:
|
||||
@@ -32,7 +40,7 @@ The top-level `main()` in `Main.kt` handles the translation. Don't
|
||||
try-catch at the command level unless you're converting a third-party
|
||||
exception into one of the above.
|
||||
|
||||
## Object shape
|
||||
## `--json` object shape
|
||||
|
||||
### Top-level
|
||||
|
||||
@@ -58,8 +66,9 @@ newline-delimited stream.
|
||||
| Pubkey (primary subject) | hex **and** bech32. Keys: `pubkey` + `npub`. |
|
||||
| Pubkey (secondary reference) | hex only. Key: `pubkey`. |
|
||||
| Relay URL | Normalized string (`wss://…`). Never an object. |
|
||||
| Timestamps | Unix seconds, integer. Key names end in `_at`. |
|
||||
| Timestamps | Unix seconds, integer. Key names end in `_at`. The text renderer auto-formats these as `2026-04-25 13:42:11Z (8m ago)`. |
|
||||
| Group ID (Marmot) | Hex string. Key: `group_id`. |
|
||||
| Byte counts | Integer. Key names end in `_bytes`. The text renderer auto-formats these as `8.7 KiB`. |
|
||||
|
||||
### Collections
|
||||
|
||||
@@ -70,7 +79,8 @@ newline-delimited stream.
|
||||
|
||||
### Booleans
|
||||
|
||||
- Use `true`/`false`, not `0`/`1`, not `"yes"`.
|
||||
- Use `true`/`false` in the result map. The text renderer prints them
|
||||
as `yes`/`no` (green/red); `--json` keeps the literal booleans.
|
||||
- Name keys so `true` is the expected/successful state:
|
||||
`is_member`, `published`, `accepted`.
|
||||
|
||||
@@ -93,22 +103,40 @@ appear in neither — add `timed_out_on` if you need to surface them.
|
||||
|
||||
### Error shape
|
||||
|
||||
Default mode (text):
|
||||
|
||||
```text
|
||||
error: not_member: <gid>
|
||||
```
|
||||
|
||||
Under `--json`:
|
||||
|
||||
```json
|
||||
{ "error": "code", "detail": "free-form explanation" }
|
||||
{ "error": "not_member", "detail": "<gid>" }
|
||||
```
|
||||
|
||||
- `error` is a short, stable, lower_snake code. Agents can branch on
|
||||
it.
|
||||
- `detail` is free text — OK to change between versions.
|
||||
- Common codes today: `bad_args`, `no_identity`, `exists`, `bad_key`,
|
||||
`not_member`, `timeout`, `runtime`. Reuse before inventing.
|
||||
- Common codes today: `bad_args`, `no_identity`, `no_account`,
|
||||
`exists`, `bad_key`, `not_member`, `no_dm_relays`, `timeout`,
|
||||
`runtime`. Reuse before inventing.
|
||||
|
||||
Use `Output.error("code", "detail")` from commands; it picks the
|
||||
right channel and format based on the active mode.
|
||||
|
||||
## Never
|
||||
|
||||
- `println(...)` of anything except `Json.writeLine(...)`.
|
||||
- Multi-line JSON (pretty-printed). One line, always.
|
||||
- Mixing stdout lines — one command invocation emits one stdout line.
|
||||
If you need progress updates, they go on stderr.
|
||||
- `println(...)` of anything except `Output.emit(...)`.
|
||||
- `Json.writeLine` / `Json.error` — that helper is gone; use the
|
||||
`Output` object instead.
|
||||
- Multi-line JSON (pretty-printed) under `--json`. One line, always.
|
||||
- Mixing stdout lines — one command invocation emits one stdout line
|
||||
in `--json` mode. If you need progress updates, they go on stderr.
|
||||
- Machine output to stderr. The whole point is clean separation.
|
||||
- Silent fallbacks — if a relay rejects your publish, say so in the
|
||||
JSON.
|
||||
result map.
|
||||
- Building text rendering by hand. Trust the `Output.kt` renderer:
|
||||
it handles alignment, colour, byte/timestamp formatting, nested
|
||||
maps and lists. If you need a bespoke render for one command,
|
||||
pass a custom render lambda — don't go around `Output`.
|
||||
|
||||
+193
-39
@@ -2,7 +2,7 @@
|
||||
|
||||
How to touch the `cli/` module without breaking its public contract.
|
||||
|
||||
- What Amy is and what's already shipped: [README.md](./README.md).
|
||||
- What Amy is + how to use it: [README.md](./README.md).
|
||||
- What to build next and in what order: [ROADMAP.md](./ROADMAP.md).
|
||||
- Plans for cross-cutting work: see this module's `plans/` folder
|
||||
and `commons/plans/` for shared-code work consumed by Amy.
|
||||
@@ -14,15 +14,46 @@ or encryption in here, stop — that code belongs in `quartz/` or
|
||||
|
||||
---
|
||||
|
||||
## Public contract
|
||||
|
||||
What every caller — user, script, agent, CI — can rely on:
|
||||
|
||||
- **Default stdout is human-readable text.** A YAML-ish render of the
|
||||
underlying result map. Friendly at a terminal; no shape promises.
|
||||
- **`--json` is the machine contract. One line. One object.** Stable
|
||||
snake_case keys. Pipe it into `jq`, parse it from Python, hand it to
|
||||
an agent. Pass `--json` anywhere before the subcommand.
|
||||
- **stderr is for humans.** Progress, warnings, per-relay ACK traces.
|
||||
Safe to discard. Errors land here too: `error: <code>: <detail>` by
|
||||
default, or JSON `{"error":"…","detail":"…"}` under `--json`.
|
||||
- **Exit codes are the real signal.**
|
||||
- `0` — success
|
||||
- `1` — runtime error
|
||||
- `2` — bad arguments
|
||||
- `124` — `await` timed out
|
||||
- **No interactive prompts, ever.** Passwords, names, keys — all flags.
|
||||
- **`~/.amy/` is the whole world.** Per-account dirs hold identity,
|
||||
cursors, MLS state, and aliases at `~/.amy/<account>/`; every observed
|
||||
Nostr event lands in `~/.amy/shared/events-store/`. Delete to reset;
|
||||
copy to move. Tests isolate by overriding `$HOME` for the amy
|
||||
subprocess (`HOME=/tmp/run.123 amy --account alice …`) — same
|
||||
convention `git`, `gpg`, and `npm` use.
|
||||
|
||||
Only the `--json` shape and the exit codes are public API. The default
|
||||
text format is allowed to change between releases. The five design
|
||||
principles below are how we keep that promise.
|
||||
|
||||
---
|
||||
|
||||
## Design principles
|
||||
|
||||
1. **Non-interactive.** One verb = one JSON object on stdout = one
|
||||
exit code. No REPL, no daemon, no prompts. Any network wait is an
|
||||
1. **Non-interactive.** One verb = one result on stdout = one exit
|
||||
code. No REPL, no daemon, no prompts. Any network wait is an
|
||||
explicit `await` verb with a `--timeout`.
|
||||
2. **Thin command layer.** Each file in `commands/` parses args,
|
||||
calls into `commons/` or `quartz/`, and prints JSON. A file longer
|
||||
than ~200 lines is a code smell — the logic is living in the wrong
|
||||
module.
|
||||
calls into `commons/` or `quartz/`, and emits a result map via
|
||||
`Output.emit(...)`. A file longer than ~200 lines is a code smell
|
||||
— the logic is living in the wrong module.
|
||||
3. **Everything persistent is on-disk.** No in-memory caches that
|
||||
survive between invocations. Every run reloads cursors, MLS state,
|
||||
identity, and relay config. This is what makes Amy safe to run
|
||||
@@ -30,9 +61,12 @@ or encryption in here, stop — that code belongs in `quartz/` or
|
||||
4. **Shared defaults.** When Amethyst picks a default relay, kind, or
|
||||
tag — Amy calls the same helper. No hand-rolled duplicates. If the
|
||||
helper doesn't exist yet, extract it to `commons/` first.
|
||||
5. **JSON is the public API.** Output-shape changes are breaking
|
||||
changes. Version them explicitly in commit messages; update interop
|
||||
fixtures.
|
||||
5. **The `--json` output shape is the public API.** Default stdout is
|
||||
human-readable text (a YAML-ish render of the result map by way of
|
||||
`Output.kt`'s default formatter) — that text shape can change
|
||||
freely without warning. The `--json` shape cannot: changes to
|
||||
keys, types, or nesting are breaking changes. Version them
|
||||
explicitly in commit messages; update interop fixtures.
|
||||
|
||||
---
|
||||
|
||||
@@ -42,26 +76,32 @@ or encryption in here, stop — that code belongs in `quartz/` or
|
||||
cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/
|
||||
├── Main.kt # argv → subcommand dispatch
|
||||
├── Args.kt # tiny flag parser (no framework)
|
||||
├── Json.kt # single-line stdout + error printer
|
||||
├── Config.kt # Identity, RunState, DataDir
|
||||
├── Output.kt # text/json mode emitter (--json flag)
|
||||
├── Aliases.kt # per-account aliases.json read/write
|
||||
├── Config.kt # Identity, RunState, DataDir (~/.amy layout)
|
||||
├── Context.kt # per-run wiring: signer + NostrClient +
|
||||
│ # MarmotManager + publish/drain/sync helpers
|
||||
├── SecureFileIO.kt # 0600/0700 atomic writes, perm tighten
|
||||
├── stores/FileStores.kt # File-backed MLS / KP / message stores
|
||||
├── secrets/ # SecretStore backends (keychain / ncryptsec / plaintext)
|
||||
└── commands/
|
||||
├── Commands.kt # dispatcher
|
||||
├── Commands.kt # dispatcher tables
|
||||
├── UseCommand.kt # `amy use NAME` — pin active account
|
||||
├── InitCommands.kt # init, whoami
|
||||
├── CreateCommand.kt # full bootstrap (→ commons/account/)
|
||||
├── LoginCommand.kt # nsec/ncryptsec/mnemonic/npub/nprofile/hex/nip05
|
||||
├── RelayCommands.kt # add/list/publish-lists
|
||||
├── ProfileCommands.kt # profile show / edit (kind:0)
|
||||
├── NotesCommands.kt + PostCommand.kt + FeedCommand.kt # kind:1
|
||||
├── DmCommands.kt # NIP-17 dm send / send-file / list / await
|
||||
├── KeyPackageCommands.kt # marmot key-package publish / check
|
||||
├── GroupCommands.kt # marmot group create/list/show/…
|
||||
├── GroupCreateCommand.kt
|
||||
├── GroupReadCommands.kt
|
||||
├── GroupAddMemberCommand.kt
|
||||
├── GroupMembershipCommands.kt
|
||||
├── GroupMetadataCommands.kt
|
||||
├── MessageCommands.kt # marmot message send / list
|
||||
└── AwaitCommands.kt # poll-until-condition helpers
|
||||
├── GroupCommands.kt + GroupCreateCommand.kt
|
||||
│ GroupReadCommands.kt + GroupAddMemberCommand.kt
|
||||
│ GroupMembershipCommands.kt + GroupMetadataCommands.kt
|
||||
├── MessageCommands.kt # marmot message send / list / react / delete
|
||||
├── MarmotResetCommand.kt # destructive wipe of MLS state
|
||||
├── AwaitCommands.kt # poll-until-condition helpers
|
||||
└── StoreCommands.kt # store stat / sweep-expired / scrub / compact
|
||||
```
|
||||
|
||||
**Dependencies:** `:quartz` + `:commons` + kotlinx-coroutines + OkHttp
|
||||
@@ -77,7 +117,7 @@ try {
|
||||
ctx.syncIncoming() // pull new gift-wraps + group events
|
||||
// ...call into commons/ or quartz/ to build an event...
|
||||
val ack = ctx.publish(event, targets)
|
||||
Json.writeLine(mapOf(...))
|
||||
Output.emit(mapOf(...))
|
||||
} finally {
|
||||
ctx.close() // flush RunState, disconnect
|
||||
}
|
||||
@@ -136,15 +176,15 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
|
||||
object NoteCommands {
|
||||
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "note <publish|read|…>")
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "note <publish|read|…>")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"publish" -> publish(dataDir, rest)
|
||||
else -> Json.error("bad_args", "note ${tail[0]}")
|
||||
else -> Output.error("bad_args", "note ${tail[0]}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +196,7 @@ object NoteCommands {
|
||||
ctx.prepare()
|
||||
val event = com.vitorpamplona.amethyst.commons.note.buildTextNote(ctx.signer, text)
|
||||
val ack = ctx.publish(event, ctx.outboxRelays())
|
||||
Json.writeLine(mapOf(
|
||||
Output.emit(mapOf(
|
||||
"event_id" to event.id,
|
||||
"kind" to event.kind,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
@@ -168,11 +208,17 @@ object NoteCommands {
|
||||
```
|
||||
|
||||
Wire it into `Commands.kt`, add a top-level branch in `Main.kt`'s
|
||||
`dispatch`, and extend `printUsage()`. Keep [README.md](./README.md)'s
|
||||
command table and [ROADMAP.md](./ROADMAP.md)'s parity matrix in sync.
|
||||
`dispatch`, and extend `printUsage()`. Keep the command tour in
|
||||
[README.md](./README.md) and the parity matrix in
|
||||
[ROADMAP.md](./ROADMAP.md) in sync.
|
||||
|
||||
### 4. Output-shape conventions
|
||||
|
||||
The result map you pass to `Output.emit(...)` IS the `--json` shape —
|
||||
treat its keys and types as the public API. The default text render
|
||||
is derived from the same map by `Output.kt` and intentionally has no
|
||||
contract.
|
||||
|
||||
- Top-level object always.
|
||||
- Stable snake_case keys.
|
||||
- Event IDs as hex strings (not npub-style).
|
||||
@@ -180,8 +226,8 @@ command table and [ROADMAP.md](./ROADMAP.md)'s parity matrix in sync.
|
||||
primary subject (`"npub":…`).
|
||||
- Relay URLs as strings, normalized, never objects.
|
||||
- Lists of events under a plural key (`"messages"`, `"members"`).
|
||||
- Errors via `Json.error("code","detail")` — single lower_snake code,
|
||||
free-form detail.
|
||||
- Errors via `Output.error("code","detail")` — single lower_snake
|
||||
code, free-form detail.
|
||||
|
||||
---
|
||||
|
||||
@@ -193,12 +239,11 @@ Amy-specific layer still needs its own coverage:
|
||||
|
||||
| Layer | Test approach |
|
||||
|---|---|
|
||||
| Argument parsing (`Args`, flag forms, `--data-dir=…` vs `--data-dir …`) | Plain JVM unit tests in `cli/src/test/kotlin/`. |
|
||||
| Argument parsing (`Args`, flag forms, `--account=…` vs `--account …`) | Plain JVM unit tests in `cli/src/test/kotlin/`. |
|
||||
| Error / exit-code contract (bad args → 2, await timeout → 124, runtime → 1) | Table-driven tests invoking `main(argv)` with captured stdout/stderr. |
|
||||
| JSON output shape (each command's keys and types) | Snapshot tests: run a command against a throwaway data-dir, assert the JSON matches a golden file. |
|
||||
| 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 --account X …`), assert the JSON matches a golden file. The default text render has no shape contract and shouldn't be snapshotted. |
|
||||
| File layout on disk (`identity.json`, `events-store/…`, `marmot/groups/*.mls`, `marmot/keypackages.bundle`) | Structural assertions after a command sequence. |
|
||||
| Round-trip between two data-dirs on a local relay | End-to-end shell harnesses under `cli/tests/`. Each harness spins up a local `nostr-rs-relay`, bootstraps two or more fresh identities in their own `--data-dir`s, and drives a scenario via `amy` (+ `wn` for Marmot interop against whitenoise-rs). Today there are two suites: `cli/tests/marmot/` (13 MLS scenarios vs whitenoise-rs) and `cli/tests/dm/` (NIP-17 DM round-trips between two `amy` clients). |
|
||||
| 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/`. |
|
||||
| Round-trip between two accounts on a local relay | End-to-end shell harnesses under `cli/tests/`: each spins up a local `nostr-rs-relay` and a fresh `$HOME=$STATE_DIR` so amy sees a virgin `~/.amy/`, then bootstraps multiple accounts (`--account A`, `--account D`, …) sharing one `~/.amy/shared/events-store/` and drives a scenario through them. Today: `cli/tests/dm/` (NIP-17 DMs between two amy accounts) and `cli/tests/marmot/` (MLS scenarios vs whitenoise-rs `wn`/`wnd`). |
|
||||
|
||||
**What not to test here:** event signing, filter assembly, MLS
|
||||
correctness, NIP-44 encryption. Those belong in `quartz`/`commons`.
|
||||
@@ -214,15 +259,14 @@ At the byte-banging level, a minimal round-trip looks like:
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
TMP=$(mktemp -d)
|
||||
A=$TMP/alice; B=$TMP/bob
|
||||
export HOME=$(mktemp -d) # virgin ~/.amy/ for the duration of this script
|
||||
|
||||
amy --data-dir "$A" create --name Alice
|
||||
amy --data-dir "$B" create --name Bob
|
||||
amy --account alice create
|
||||
amy --account bob create
|
||||
|
||||
# ... the scenario under test ...
|
||||
|
||||
amy --data-dir "$B" marmot await message "$GID" --match "hello" --timeout 60
|
||||
amy --account bob marmot await message "$GID" --match "hello" --timeout 60
|
||||
```
|
||||
|
||||
If an Amethyst scenario cannot be scripted through Amy yet, that's
|
||||
@@ -230,11 +274,121 @@ a gap — add it to [ROADMAP.md](./ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
## Local event store — the source of truth
|
||||
|
||||
Every Nostr event amy observes is verified (NIP-01 id + signature
|
||||
check) and persisted to a file-backed store at
|
||||
`~/.amy/shared/events-store/` (one store per machine, shared across
|
||||
every account in `~/.amy/`). That includes:
|
||||
|
||||
- events received from any relay subscription (`amy notes feed`,
|
||||
`amy dm list`, `amy marmot key-package publish`, group sync, …),
|
||||
- events amy generates and publishes itself,
|
||||
- inner events unwrapped from NIP-59 gift wraps.
|
||||
|
||||
Malformed events are dropped before reaching command code. Persistence
|
||||
is best-effort — if the store fails (full disk, permissions), the relay
|
||||
subscription still works, but the event is not cached.
|
||||
|
||||
The store is the authoritative cache of everything amy has seen:
|
||||
profile metadata, relay lists (NIP-65 and NIP-02), gift wraps, group
|
||||
events, follow lists, etc. Commands that need any of these read from
|
||||
the store first and only fall back to a relay fetch on miss. Three
|
||||
convenience helpers exist on `Context`:
|
||||
|
||||
```kotlin
|
||||
ctx.profileOf(pubKey) // latest kind:0 (NIP-01)
|
||||
ctx.relaysOf(pubKey) // latest kind:10002 (NIP-65)
|
||||
ctx.contactsOf(pubKey) // latest kind:3 (NIP-02)
|
||||
ctx.dmInboxOf(pubKey) // latest kind:10050 (NIP-17 DM inbox)
|
||||
ctx.keyPackageRelaysOf(pubKey) // latest kind:10051 (MIP-00 KP relays)
|
||||
ctx.cachedRelayListsOf(pubKey) // RecipientRelayFetcher.Lists from cache
|
||||
```
|
||||
|
||||
The store implements every feature of the Quartz SQLite store —
|
||||
NIP-01 replaceable / addressable uniqueness, NIP-09 deletion
|
||||
tombstones, NIP-40 expiration, NIP-50 search, NIP-62 right-to-vanish,
|
||||
NIP-91 multi-tag AND. See
|
||||
[`cli/plans/2026-04-24-file-event-store-*.md`](./plans/) for the design
|
||||
and `quartz/.../store/fs/FsEventStore.kt` for the implementation. The
|
||||
on-disk layout is plain JSON files under shard directories,
|
||||
intentionally inspectable with `ls`, `cat`, `jq`, `grep`, `find`,
|
||||
`rsync`, and `git`. Deleting an event file is treated as a deliberate
|
||||
"I never saw this" by amy; dangling indexes are skipped at query time
|
||||
and can be cleaned up with `amy store scrub` / `amy store compact`.
|
||||
|
||||
---
|
||||
|
||||
## Relay routing
|
||||
|
||||
amy follows the Marmot protocol's per-event routing rules so two users
|
||||
with completely disjoint relay configurations can still marmot each
|
||||
other. No event ever ships blindly to "our configured relays" — amy
|
||||
looks up the right relay set per event per recipient.
|
||||
|
||||
| Event | Publish to | Fetch from |
|
||||
|---|---|---|
|
||||
| kind:30443 (our own KeyPackage) | `key_package` bucket → NIP-65 outbox → any configured | — |
|
||||
| kind:30443 (someone else's KeyPackage) | — | Their kind:10051 → their kind:10002 write → our bootstrap pool |
|
||||
| kind:10051 / 10050 / 10002 (our own lists) | All configured relays (broadcast) | — |
|
||||
| kind:10051 / 10050 / 10002 (someone else's) | — | Our bootstrap pool = configured relays ∪ Amethyst defaults |
|
||||
| kind:1059 Welcome gift wrap (kind:444 inside) | Recipient's kind:10050 → their kind:10002 read → `DefaultDMRelayList` → our outbox | — |
|
||||
| kind:1059 gift wraps addressed to us | — | Our kind:10050 |
|
||||
| kind:445 Group Event (Commit / Proposal / chat) | Group's MIP-01 `relays` field | Same |
|
||||
|
||||
**Bootstrap pool**: when amy needs to discover a user it's never talked
|
||||
to, it queries `configured relays ∪ Amethyst's default NIP-65 set ∪
|
||||
Amethyst's default DM-inbox set`. These defaults come from
|
||||
`commons.defaults.AmethystDefaults` and match what the Android/Desktop
|
||||
UI publishes to on first run, so any fresh Amethyst account is
|
||||
reachable via the bootstrap pool even before amy has seen any of their
|
||||
events.
|
||||
|
||||
---
|
||||
|
||||
## Full on-disk layout
|
||||
|
||||
```
|
||||
~/.amy/ ← root, follows $HOME
|
||||
├── current # marker file written by `amy use NAME`
|
||||
├── shared/
|
||||
│ └── events-store/ # FsEventStore — every observed Nostr event
|
||||
│ ├── events/<aa>/<bb>/… # canonical kind:0 / 3 / 10002 / 10050 / 10051 / 1 / 5 / 1059 / …
|
||||
│ ├── replaceable/<k>/… # one slot per (kind, pubkey) for kind:0/3/10000-19999
|
||||
│ ├── addressable/… # one slot per (kind, pubkey, d-tag) for kind:30000-39999
|
||||
│ ├── idx/ # hardlink indexes (kind / author / owner / tag / fts / expires_at)
|
||||
│ └── tombstones/ # NIP-09 / NIP-62 enforcement
|
||||
├── alice/ # one dir per account (`amy --account alice init`)
|
||||
│ ├── identity.json # nsec/npub/hex — the account
|
||||
│ ├── state.json # sync cursors (giftWrapSince, groupSince)
|
||||
│ ├── aliases.json # local name → npub map (init writes a self-entry)
|
||||
│ └── marmot/
|
||||
│ ├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal)
|
||||
│ └── groups/
|
||||
│ ├── <gid>.mls # MLS group state per group
|
||||
│ └── <gid>.log # decrypted inner events (one JSON per line)
|
||||
└── bob/ ... # additional accounts sit alongside
|
||||
```
|
||||
|
||||
All files are plain JSON or framed binary — human-inspectable, easy to
|
||||
diff across two accounts. Two accounts on the same machine share
|
||||
`~/.amy/shared/events-store/`, so a public event observed once doesn't
|
||||
get re-stored per account.
|
||||
|
||||
The local relay configuration (kind:10002 / 10050 / 10051) is **not** a
|
||||
separate file — it lives in the shared `events-store/` as signed events
|
||||
owned by the account that wrote them. `amy relay add` builds + signs +
|
||||
ingests a new relay-list event; `amy relay list` reads URLs straight
|
||||
out of the latest event for each kind; `amy relay publish-lists`
|
||||
broadcasts those events to upstream relays. There is no `relays.json`.
|
||||
|
||||
---
|
||||
|
||||
## Housekeeping
|
||||
|
||||
- Run `./gradlew spotlessApply` before every commit.
|
||||
- Keep three things in sync: `printUsage()` in `Main.kt`, the command
|
||||
table in [README.md](./README.md), and the parity matrix in
|
||||
tour in [README.md](./README.md), and the parity matrix in
|
||||
[ROADMAP.md](./ROADMAP.md). They drift fast.
|
||||
- Never add a Gradle dependency on `:amethyst` or `:desktopApp`. If
|
||||
you need something from there, move it to `commons/` first.
|
||||
|
||||
+384
-245
@@ -1,302 +1,441 @@
|
||||
# Amy — Amethyst CLI
|
||||
|
||||
`amy` is the non-interactive command-line face of Amethyst. It speaks the
|
||||
same Nostr protocol as the Android and Desktop apps, shares the same
|
||||
`quartz` and `commons` code, and aims to eventually expose every feature
|
||||
the GUI offers as a command you can script.
|
||||
`amy` is the command-line face of [Amethyst](https://github.com/vitorpamplona/amethyst).
|
||||
It speaks the same Nostr protocol as the Android and Desktop apps and shares
|
||||
their codebase. From a terminal you can post notes, send NIP-17 DMs,
|
||||
manage MLS group chats, switch identities, and pipe machine-readable JSON
|
||||
into the rest of your toolbox.
|
||||
|
||||
Amy exists for three audiences at once:
|
||||
`amy` is built for three audiences at once: humans at a terminal,
|
||||
agents/LLMs driving an account through a deterministic JSON interface,
|
||||
and interop test harnesses pinning Amethyst against the rest of the
|
||||
Nostr-client ecosystem.
|
||||
|
||||
1. **Humans** using Amethyst from a terminal or remote shell.
|
||||
2. **Agents / LLMs** driving a Nostr account through a deterministic,
|
||||
JSON-typed interface — no interactive prompts, no screen scraping.
|
||||
3. **Interop test harnesses** that put Amethyst side-by-side with the
|
||||
other ~100 Nostr clients publishing and consuming the same events.
|
||||
Any flow that is tested in the Amethyst app should be reproducible
|
||||
through `amy` — that's the bar.
|
||||
|
||||
> Today Amy covers identity, relay config, account bootstrap, and
|
||||
> Marmot / MLS group chat (MIP-00 / NIP-445). Everything else from the
|
||||
> Android app is on the roadmap — see [ROADMAP.md](./ROADMAP.md).
|
||||
>
|
||||
> To extend Amy, see [DEVELOPMENT.md](./DEVELOPMENT.md).
|
||||
|
||||
---
|
||||
|
||||
## Output contract
|
||||
|
||||
What every caller — user, script, agent, CI — can rely on:
|
||||
|
||||
- **stdout is JSON. One line. One object.** Stable snake_case keys.
|
||||
Pipe it into `jq`, parse it from Python, hand it to an agent.
|
||||
- **stderr is for humans.** Progress, warnings, per-relay ACK traces.
|
||||
Safe to discard.
|
||||
- **Exit codes are the real signal.**
|
||||
- `0` — success
|
||||
- `1` — runtime error (JSON `{"error":"…","detail":"…"}` on stderr)
|
||||
- `2` — bad arguments
|
||||
- `124` — `await` timed out
|
||||
- **No interactive prompts, ever.** Passwords, names, keys — all flags.
|
||||
- **Data-dir is the whole world.** All state (identity, relays, MLS
|
||||
epochs, message archives, run cursors) lives under `--data-dir PATH`.
|
||||
Delete to reset; copy to move; `AMETHYST_CLI_DATA` env var overrides
|
||||
the default `./amy`.
|
||||
|
||||
The rationale behind each of these lives in
|
||||
[DEVELOPMENT.md](./DEVELOPMENT.md). Breaking any of them is a breaking
|
||||
change to Amy's public API.
|
||||
|
||||
---
|
||||
|
||||
## Local event store — the source of truth
|
||||
|
||||
Every Nostr event Amy observes is verified (NIP-01 id + signature
|
||||
check) and persisted to a file-backed store at
|
||||
`<data-dir>/events-store/`. That includes:
|
||||
|
||||
- events received from any relay subscription (`amy feed`, `amy dm
|
||||
list`, `amy keypackage publish`, group sync, …),
|
||||
- events Amy generates and publishes itself,
|
||||
- inner events unwrapped from NIP-59 gift wraps.
|
||||
|
||||
Malformed events are dropped before reaching command code. Persistence
|
||||
is best-effort — if the store fails (full disk, permissions), the
|
||||
relay subscription still works, but the event is not cached.
|
||||
|
||||
The store is the authoritative cache of everything Amy has seen:
|
||||
profile metadata, relay lists (NIP-65 and NIP-02), gift wraps, group
|
||||
events, follow lists, etc. Commands that need any of these should read
|
||||
from the store first and only fall back to a relay fetch on miss.
|
||||
Three convenience helpers exist on `Context`:
|
||||
|
||||
```kotlin
|
||||
ctx.profileOf(pubKey) // latest kind:0 (NIP-01)
|
||||
ctx.relaysOf(pubKey) // latest kind:10002 (NIP-65)
|
||||
ctx.contactsOf(pubKey) // latest kind:3 (NIP-02)
|
||||
ctx.dmInboxOf(pubKey) // latest kind:10050 (NIP-17 DM inbox)
|
||||
ctx.keyPackageRelaysOf(pubKey) // latest kind:10051 (MIP-00 KP relays)
|
||||
ctx.cachedRelayListsOf(pubKey) // RecipientRelayFetcher.Lists from cache
|
||||
```
|
||||
|
||||
Commands that already read these cache-first:
|
||||
|
||||
- `amy profile show` — `--refresh` to bypass.
|
||||
- `amy feed --following` — local kind:3 served via slot lookup; falls
|
||||
back to a relay drain on first run.
|
||||
- `amy dm send` — recipient's kind:10050 / 10051 / 10002 served from
|
||||
cache before falling back to `RecipientRelayFetcher`.
|
||||
- `amy marmot key-package check` and `amy marmot await key-package`
|
||||
— same recipient-relay lookup, cache-first.
|
||||
- `amy marmot group add` — invitee relay lists served from cache.
|
||||
|
||||
The store implements every feature of the Quartz SQLite store —
|
||||
NIP-01 replaceable / addressable uniqueness, NIP-09 deletion
|
||||
tombstones, NIP-40 expiration, NIP-50 search, NIP-62 right-to-vanish,
|
||||
NIP-91 multi-tag AND. See `cli/plans/2026-04-24-file-event-store-*.md`
|
||||
for the design and `quartz/.../store/fs/FsEventStore.kt` for the
|
||||
implementation. The on-disk layout is plain JSON files under shard
|
||||
directories, intentionally inspectable with `ls`, `cat`, `jq`,
|
||||
`grep`, `find`, `rsync`, and `git`.
|
||||
|
||||
To manage the store directly:
|
||||
|
||||
```sh
|
||||
# raw inspection
|
||||
find $AMY_HOME/events-store/events -name '*.json' | head
|
||||
jq . $AMY_HOME/events-store/replaceable/0/<pubkey>.json
|
||||
|
||||
# delete a specific event (tombstone NOT installed — see below)
|
||||
rm $AMY_HOME/events-store/events/<aa>/<bb>/<id>.json
|
||||
```
|
||||
|
||||
Deleting an event file is treated as a deliberate "I never saw this"
|
||||
by Amy. The store tolerates external edits: dangling index entries are
|
||||
skipped at query time and can be cleaned up with `compact()` /
|
||||
`scrub()` from the API.
|
||||
> **Looking for the architecture and the public-API contract?** See
|
||||
> [DEVELOPMENT.md](./DEVELOPMENT.md). For what's coming, see
|
||||
> [ROADMAP.md](./ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
Until Amy ships as a signed native binary (see
|
||||
[cli/plans/2026-04-21-cli-distribution.md](./plans/2026-04-21-cli-distribution.md)),
|
||||
run it from source:
|
||||
`amy` builds from this repository — no package manager yet.
|
||||
|
||||
```bash
|
||||
# One-shot run — positional args go after `--args`, quoted as one string
|
||||
./gradlew :cli:run --quiet --args="whoami"
|
||||
|
||||
# Or build a runnable distribution and use the generated launch script
|
||||
# build the runnable distribution
|
||||
./gradlew :cli:installDist
|
||||
./cli/build/install/amy/bin/amy whoami
|
||||
|
||||
# the launch script
|
||||
./cli/build/install/amy/bin/amy --help
|
||||
|
||||
# put it on your PATH if you want
|
||||
ln -s "$PWD/cli/build/install/amy/bin/amy" ~/.local/bin/amy
|
||||
```
|
||||
|
||||
The `installDist` tree under `cli/build/install/amy/` is self-contained
|
||||
(JVM launcher + jars) and is what downstream packaging will wrap.
|
||||
|
||||
**Requirements:** JDK 21.
|
||||
Requires **JDK 21**. All state lives under `~/.amy/` — delete to reset.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# 1. Create a data-dir with a full Amethyst-style account.
|
||||
# Generates a keypair, seeds default NIP-65 / inbox / key-package
|
||||
# relays, and publishes the nine bootstrap events.
|
||||
amy --data-dir ./alice create --name "Alice"
|
||||
# 1. Create an account named alice — keypair, default relays, kind:0 metadata,
|
||||
# everything Amethyst stamps on first run.
|
||||
amy --account alice create --name "Alice"
|
||||
|
||||
# 2. Publish a fresh MLS KeyPackage so others can invite you.
|
||||
amy --data-dir ./alice marmot key-package publish
|
||||
# 2. With one account you can drop the flag from now on (auto-pick).
|
||||
amy whoami
|
||||
|
||||
# 3. Create a group, invite someone, send a message.
|
||||
amy --data-dir ./alice marmot group create --name "Test Group"
|
||||
amy --data-dir ./alice marmot group add <GID> npub1...bob
|
||||
amy --data-dir ./alice marmot message send <GID> "hello"
|
||||
# 3. Post a short note.
|
||||
amy notes post "hello from amy"
|
||||
|
||||
# 4. On the receiving side — poll until Bob sees the invite.
|
||||
amy --data-dir ./bob marmot await group --name "Test Group" --timeout 60
|
||||
amy --data-dir ./bob marmot message list <GID>
|
||||
# 4. Send a NIP-17 DM.
|
||||
amy dm send bob@example.com "hey"
|
||||
|
||||
# 5. Read your inbox.
|
||||
amy dm list
|
||||
```
|
||||
|
||||
Compose with `jq` to chain commands:
|
||||
That's the full loop. Add `--json` to any command if you want a single-line
|
||||
JSON object instead of human-readable text — same data, machine shape.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### 1. Post a note
|
||||
|
||||
```text
|
||||
$ amy notes post "good morning nostr"
|
||||
|
||||
event_id: a3c1f9c2…(64 hex)
|
||||
kind: 1
|
||||
accepted_by:
|
||||
- wss://relay.damus.io/
|
||||
- wss://nos.lol/
|
||||
rejected_by: (none)
|
||||
```
|
||||
|
||||
`amy notes feed` reads recent kind:1 notes from your follows; `--limit N`
|
||||
caps the count, `--author npub1…` narrows to one user.
|
||||
|
||||
### 2. Send a direct message
|
||||
|
||||
```text
|
||||
$ amy dm send npub1uu8m… "lunch friday?"
|
||||
|
||||
event_id: 18bd0a7e…
|
||||
kind: 14
|
||||
recipients:
|
||||
- pubkey: e70fb804…
|
||||
relay_source: kind_10050
|
||||
relays:
|
||||
- wss://nostr.wine/
|
||||
```
|
||||
|
||||
`recipients[*].relay_source` tells you how amy resolved the recipient's
|
||||
inbox — `kind_10050` is the strict NIP-17 inbox; `nip65_read` /
|
||||
`bootstrap` only fire when you pass `--allow-fallback`.
|
||||
|
||||
### 3. Read a DM thread
|
||||
|
||||
```text
|
||||
$ amy dm list --peer npub1uu8m… --limit 5
|
||||
|
||||
messages:
|
||||
- event_id: a82f04e1…
|
||||
author: 71cf3ab2…
|
||||
type: text
|
||||
created_at: 2026-04-25 13:42:11Z (8m ago)
|
||||
content: sounds good
|
||||
- event_id: 18bd0a7e…
|
||||
author: e70fb804…
|
||||
type: text
|
||||
created_at: 2026-04-25 13:30:02Z (20m ago)
|
||||
content: lunch friday?
|
||||
```
|
||||
|
||||
`amy dm await --peer NPUB --match TEXT --timeout 60` blocks until a matching
|
||||
DM arrives — useful in scripts.
|
||||
|
||||
### 4. View a profile
|
||||
|
||||
```text
|
||||
$ amy profile show npub1th9z…
|
||||
|
||||
pubkey: 5dca27ae…
|
||||
found: yes
|
||||
source: cache
|
||||
event_id: a041df5a…
|
||||
created_at: 2026-04-25 13:36:23Z (1h ago)
|
||||
metadata:
|
||||
name: Alice
|
||||
picture: https://example.test/a.png
|
||||
about: demo identity
|
||||
nip05: alice@example.test
|
||||
queried_relays: (none)
|
||||
```
|
||||
|
||||
`source: cache` means the local store served the lookup; pass `--refresh` to
|
||||
force a relay round-trip. Profiles for `name@domain.tld` (NIP-05) are
|
||||
resolved transparently.
|
||||
|
||||
### 5. Create a group, invite someone, send a message
|
||||
|
||||
```bash
|
||||
GID=$(amy --data-dir ./alice marmot group create --name "Test" | jq -r .group_id)
|
||||
# Mint a group and invite Bob.
|
||||
GID=$(amy --json marmot group create --name "Lunch Plans" | jq -r .group_id)
|
||||
amy marmot group add "$GID" npub1...bob
|
||||
|
||||
# Send an MLS-encrypted message.
|
||||
amy marmot message send "$GID" "hello group"
|
||||
```
|
||||
|
||||
For an interop-test script template, see
|
||||
[DEVELOPMENT.md § Testing](./DEVELOPMENT.md#testing). The runnable
|
||||
harnesses live under [`cli/tests/`](./tests/README.md) —
|
||||
`cli/tests/marmot/` for MLS group messaging vs whitenoise-rs,
|
||||
`cli/tests/dm/` for NIP-17 DMs between two `amy` clients.
|
||||
On the other side:
|
||||
|
||||
```bash
|
||||
# Bob waits for the invite to land, then sees the message.
|
||||
amy --account bob marmot await group --name "Lunch Plans" --timeout 60
|
||||
amy --account bob marmot message list "$GID"
|
||||
```
|
||||
|
||||
### 6. Switch between accounts
|
||||
|
||||
```text
|
||||
$ amy whoami
|
||||
error: bad_args: multiple accounts in /home/me/.amy (alice, bob); pick one with --account <name> or `amy use <name>`
|
||||
|
||||
$ amy use bob
|
||||
current: bob
|
||||
root: /home/me/.amy
|
||||
|
||||
$ amy whoami
|
||||
name: bob
|
||||
npub: npub1uu8m…
|
||||
data_dir: /home/me/.amy/bob
|
||||
```
|
||||
|
||||
`amy use --clear` removes the pin; `amy --account alice <cmd>` overrides
|
||||
it for one command.
|
||||
|
||||
### 7. Add a relay
|
||||
|
||||
```text
|
||||
$ amy relay add wss://nostr.wine
|
||||
|
||||
url: wss://nostr.wine
|
||||
added_to:
|
||||
- nip65
|
||||
- inbox
|
||||
- key_package
|
||||
already_present: (none)
|
||||
|
||||
$ amy relay publish-lists # broadcast updated kind:10002/10050/10051
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Command reference
|
||||
## Commands
|
||||
|
||||
Run `amy --help` for the canonical list. As of today:
|
||||
### Identity
|
||||
|
||||
| Verb | Summary |
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `init [--nsec NSEC]` | Create or import a bare identity. Does not publish anything. |
|
||||
| `create [--name NAME]` | Provision a full account + publish the nine Amethyst bootstrap events. |
|
||||
| `login KEY [--password X] [--private]` | Import `nsec` / `ncryptsec` / BIP-39 mnemonic / `npub` / `nprofile` / hex / NIP-05. Read-only when no secret material is supplied. |
|
||||
| `whoami` | Print the identity stored in `--data-dir`. |
|
||||
| `profile show [PUBKEY] [--refresh] [--timeout SECS]` | Print kind:0 metadata. Default reads from the local store (cache-first); `--refresh` forces a relay drain. PUBKEY accepts `npub` / `nprofile` / hex / `name@domain.tld`; omit for self. |
|
||||
| `profile edit --name X [--display-name X] …` | Build + publish a new kind:0 starting from the current cached metadata (or fetched if missing). |
|
||||
| `relay add URL [--type T]` | `T = nip65 \| inbox \| key_package \| all`. |
|
||||
| `relay list` | Dump configured relays by bucket. |
|
||||
| `relay publish-lists` | Publish kind:10002 (NIP-65) + kind:10050 (DM inbox) + kind:10051 (KeyPackage relay list). |
|
||||
| `marmot key-package publish` | Publish a fresh MLS KeyPackage (kind:30443) to the configured `key_package` bucket (fallback: NIP-65 outbox). |
|
||||
| `marmot key-package check NPUB` | Look up NPUB's kind:10051 / kind:10002 on bootstrap relays, then fetch their KeyPackage from those relays. |
|
||||
| `marmot group create [--name NAME]` | New empty group with you as sole admin. |
|
||||
| `marmot group list` | All groups you're a member of. |
|
||||
| `marmot group show GID` | Full group state (members, admins, epoch, metadata). |
|
||||
| `marmot group members GID` | Members only. |
|
||||
| `marmot group admins GID` | Admins only. |
|
||||
| `marmot group add GID NPUB [NPUB…]` | Fetch KeyPackages for the npubs and commit an add. |
|
||||
| `marmot group rename GID NAME` | Commit a metadata change. |
|
||||
| `marmot group promote GID NPUB` | Make an existing member an admin. |
|
||||
| `marmot group demote GID NPUB` | Revoke admin. |
|
||||
| `marmot group remove GID NPUB` | Remove a member. |
|
||||
| `marmot group leave GID` | Self-remove. |
|
||||
| `marmot message send GID TEXT` | Publish a kind:9 inner event into the group. |
|
||||
| `marmot message list GID [--limit N]` | Decrypted inner events, oldest first. |
|
||||
| `marmot await key-package NPUB` | Block until a KeyPackage is seen on NPUB's advertised relays (kind:10051 / kind:10002). |
|
||||
| `marmot await group --name NAME` | Block until we're added to a group with that name. |
|
||||
| `marmot await member GID NPUB` | Block until NPUB is in GID's member set. |
|
||||
| `marmot await admin GID NPUB` | Block until NPUB is an admin of GID. |
|
||||
| `marmot await message GID --match TEXT` | Block until a message containing `TEXT` lands. |
|
||||
| `marmot await rename GID --name NAME` | Block until GID's name matches. |
|
||||
| `marmot await epoch GID --min N` | Block until GID's MLS epoch is ≥ N. |
|
||||
| `dm send RECIPIENT TEXT [--allow-fallback]` | Send a NIP-17 gift-wrapped text DM (kind:14 inside kind:1059). Default delivers only to the recipient's kind:10050 (per NIP-17); pass `--allow-fallback` to fall back to kind:10002 read marker → bootstrap pool. |
|
||||
| `dm send-file RECIPIENT --file PATH --server URL [--mime-type M] [--allow-fallback]` | Encrypt the local file with a fresh AES-GCM cipher, upload the ciphertext to the Blossom server, then publish a kind:15 NIP-17 file message referencing the returned URL. The auto-detected hash, size, dimensions, and blurhash from the upload are folded into the event. The response also surfaces the encryption key + nonce so the same blob can be re-shared without re-uploading. |
|
||||
| `dm send-file RECIPIENT URL --key HEX --nonce HEX [--mime-type M] [--hash H] [--original-hash H] [--size N] [--dim WxH] [--blurhash S] [--allow-fallback]` | Reference-mode variant: the file is already uploaded; `--key`/`--nonce` carry the AES-GCM material that recipients use to decrypt the bytes at `URL`. Useful when the upload happened elsewhere or to re-publish a previously-uploaded blob. |
|
||||
| `dm list [--peer NPUB] [--since TS] [--limit N] [--timeout SECS]` | Drain and decrypt gift wraps on our inbox relays. Returns kind:14 (text) and kind:15 (file) messages with a `type` discriminator. With neither `--peer` nor `--since` the gift-wrap cursor in `state.json` is advanced to the newest message seen. |
|
||||
| `dm await --peer NPUB --match TEXT [--timeout SECS]` | Block until a DM from NPUB containing TEXT arrives (matches text content for kind:14, URL for kind:15). Timeout exits 124. |
|
||||
| `amy --account NAME init [--nsec NSEC]` | Create or import a bare keypair. No relay traffic. |
|
||||
| `amy --account NAME create [--name X]` | Full Amethyst-style bootstrap: keypair, default relays, kind:0, kind:3, the works. |
|
||||
| `amy login KEY [--password X]` | Import an existing identity (`nsec`/`ncryptsec`/mnemonic/`npub`/`nprofile`/hex/NIP-05). |
|
||||
| `amy whoami` | Print the active account's name + npub. |
|
||||
| `amy use NAME` / `--clear` / no-arg | Pin / clear / inspect the active account. |
|
||||
|
||||
All `await` verbs accept `--timeout SECS` (default 30). Timeout exits 124
|
||||
so scripts can distinguish "condition never happened" from "the command
|
||||
itself crashed".
|
||||
### Social
|
||||
|
||||
### Global flags
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy notes post TEXT [--relay URL]` | Publish a kind:1 short text note. |
|
||||
| `amy notes feed [--author USER \| --following] [--limit N]` | Read recent kind:1 notes (yours, one user's, or your follow set). |
|
||||
| `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. |
|
||||
| `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. |
|
||||
|
||||
- `--data-dir PATH` — defaults to `./amy` or
|
||||
`$AMETHYST_CLI_DATA`. Always an absolute path after resolution.
|
||||
- `--help` / `-h` — usage summary.
|
||||
### Direct messages (NIP-17)
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy dm send RECIPIENT TEXT [--allow-fallback]` | Gift-wrap a kind:14 to RECIPIENT. Strict kind:10050 routing by default. |
|
||||
| `amy dm send-file RECIPIENT --file PATH --server URL` | Encrypt a local file, upload to a Blossom server, publish a kind:15 referencing it. |
|
||||
| `amy dm send-file RECIPIENT URL --key HEX --nonce HEX` | Reference-mode: file already uploaded; just publish the kind:15. |
|
||||
| `amy dm list [--peer NPUB] [--since TS] [--limit N]` | Drain and decrypt gift wraps. |
|
||||
| `amy dm await --peer NPUB --match TEXT [--timeout SECS]` | Block until a matching DM arrives. |
|
||||
|
||||
### Groups (Marmot / MLS)
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy marmot key-package publish` | Publish a fresh KeyPackage so others can invite you. |
|
||||
| `amy marmot key-package check NPUB` | Look up someone else's KeyPackage on relays. |
|
||||
| `amy marmot group create [--name X]` | New empty group with you as sole admin. |
|
||||
| `amy marmot group list` | All groups you're a member of. |
|
||||
| `amy marmot group show GID` | Members, admins, epoch, metadata. |
|
||||
| `amy marmot group add GID NPUB [NPUB…]` | Fetch KeyPackages and invite. |
|
||||
| `amy marmot group rename GID NAME` | Commit a metadata change. |
|
||||
| `amy marmot group promote / demote / remove GID NPUB` | Admin verbs. |
|
||||
| `amy marmot group leave GID` | Self-remove. |
|
||||
| `amy marmot message send GID TEXT` | Publish a kind:9 inner event into the group. |
|
||||
| `amy marmot message list GID [--limit N]` | Decrypted inner events, oldest first. |
|
||||
| `amy marmot message react GID EVENT_ID EMOJI` | Publish a kind:7 reaction. |
|
||||
| `amy marmot message delete GID EVENT_ID …` | Publish a kind:5 deletion. |
|
||||
|
||||
### Wait-for-condition (`await`)
|
||||
|
||||
Every `await` verb blocks until the condition holds, then prints the
|
||||
matching event/state. All accept `--timeout SECS` (default 30); on
|
||||
timeout the exit code is **124** so scripts can tell "didn't happen"
|
||||
from "command crashed".
|
||||
|
||||
| Command | Blocks until… |
|
||||
|---|---|
|
||||
| `amy marmot await key-package NPUB` | NPUB has a KeyPackage discoverable on their advertised relays. |
|
||||
| `amy marmot await group --name X` | You've been added to a group with that name. |
|
||||
| `amy marmot await member GID NPUB` | NPUB is in GID's member set. |
|
||||
| `amy marmot await admin GID NPUB` | NPUB is an admin of GID. |
|
||||
| `amy marmot await message GID --match TEXT` | A message containing TEXT lands in GID. |
|
||||
| `amy marmot await rename GID --name X` | GID's name matches X. |
|
||||
| `amy marmot await epoch GID --min N` | GID's MLS epoch reaches N. |
|
||||
| `amy dm await --peer NPUB --match TEXT` | A matching DM from NPUB arrives. |
|
||||
|
||||
### Relays
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy relay add URL [--type T]` | Add URL to a bucket: `nip65`, `inbox`, `key_package`, or `all`. |
|
||||
| `amy relay list` | Print the configured relays per bucket. |
|
||||
| `amy relay publish-lists` | Broadcast your kind:10002 / 10050 / 10051. |
|
||||
|
||||
### Local store maintenance
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy store stat` | Event count, kind histogram, disk usage, oldest/newest timestamps. |
|
||||
| `amy store sweep-expired` | Delete events past their NIP-40 expiration. |
|
||||
| `amy store scrub` | Rebuild the index after external edits or a crash. |
|
||||
| `amy store compact` | Drop dangling index entries (canonical event already gone). |
|
||||
|
||||
---
|
||||
|
||||
## Relay routing
|
||||
## Output: text by default, JSON on demand
|
||||
|
||||
Amy follows the Marmot protocol's per-event routing rules so two users
|
||||
with completely disjoint relay configurations can still marmot each
|
||||
other. No event ever ships blindly to "our configured relays" — Amy
|
||||
looks up the right relay set per event per recipient.
|
||||
By default amy writes a YAML-ish, colored, human-readable result to
|
||||
stdout. Pass `--json` and stdout becomes a single-line JSON object —
|
||||
same data, stable snake_case keys, ready for `jq`:
|
||||
|
||||
| Event | Publish to | Fetch from |
|
||||
|---|---|---|
|
||||
| kind:30443 (our own KeyPackage) | `key_package` bucket → NIP-65 outbox → any configured | — |
|
||||
| kind:30443 (someone else's KeyPackage) | — | Their kind:10051 → their kind:10002 write → our bootstrap pool |
|
||||
| kind:10051 / 10050 / 10002 (our own lists) | All configured relays (broadcast) | — |
|
||||
| kind:10051 / 10050 / 10002 (someone else's) | — | Our bootstrap pool = configured relays ∪ Amethyst defaults |
|
||||
| kind:1059 Welcome gift wrap (kind:444 inside) | Recipient's kind:10050 → their kind:10002 read → `DefaultDMRelayList` → our outbox | — |
|
||||
| kind:1059 gift wraps addressed to us | — | Our kind:10050 |
|
||||
| kind:445 Group Event (Commit / Proposal / chat) | Group's MIP-01 `relays` field | Same |
|
||||
```bash
|
||||
$ amy --json whoami
|
||||
{"name":"alice","npub":"npub1th9z…","hex":"5dca27ae…","data_dir":"/home/me/.amy/alice"}
|
||||
|
||||
**Bootstrap pool**: when Amy needs to discover a user it's never talked
|
||||
to, it queries `configured relays ∪ Amethyst's default NIP-65 set ∪
|
||||
Amethyst's default DM-inbox set`. These defaults come from
|
||||
`commons.defaults.AmethystDefaults` and match what the Android/Desktop
|
||||
UI publishes to on first run, so any fresh Amethyst account is
|
||||
reachable via the bootstrap pool even before Amy has seen any of their
|
||||
events.
|
||||
$ amy --json marmot group create --name "Lunch" | jq -r .group_id
|
||||
ab12cd34…
|
||||
```
|
||||
|
||||
Errors mirror the same rule. Default:
|
||||
|
||||
```text
|
||||
$ amy marmot group show abc123
|
||||
error: not_member: abc123 # exit 1
|
||||
```
|
||||
|
||||
Under `--json` the error goes to stderr as `{"error":"not_member","detail":"abc123"}`.
|
||||
|
||||
Color auto-disables when stdout is a pipe; force it with `CLICOLOR_FORCE=1`,
|
||||
turn it off entirely with `NO_COLOR=1`.
|
||||
|
||||
**Exit codes** — the real signal for scripts:
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| 0 | success |
|
||||
| 1 | runtime error (network, permission, NIP rejection, …) |
|
||||
| 2 | bad arguments |
|
||||
| 124 | `await` timed out |
|
||||
|
||||
---
|
||||
|
||||
## Data-dir layout
|
||||
## Multi-account workflows
|
||||
|
||||
`amy` is built to host more than one identity per machine. The layout
|
||||
matches that:
|
||||
|
||||
```
|
||||
<data-dir>/
|
||||
├── identity.json # nsec/npub/hex — the account
|
||||
├── state.json # sync cursors (giftWrapSince, groupSince)
|
||||
├── events-store/ # FsEventStore — every observed Nostr event
|
||||
│ ├── events/<aa>/<bb>/… # canonical kind:0 / 3 / 10002 / 10050 / 10051 / 1 / 5 / 1059 / …
|
||||
│ ├── replaceable/<k>/… # one slot per (kind, pubkey) for kind:0/3/10000-19999
|
||||
│ ├── addressable/… # one slot per (kind, pubkey, d-tag) for kind:30000-39999
|
||||
│ ├── idx/ # hardlink indexes (kind / author / owner / tag / fts / expires_at)
|
||||
│ └── tombstones/ # NIP-09 / NIP-62 enforcement
|
||||
└── marmot/
|
||||
├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal)
|
||||
└── groups/
|
||||
├── <gid>.mls # MLS group state per group
|
||||
└── <gid>.log # decrypted inner events (one JSON per line)
|
||||
~/.amy/
|
||||
├── current # marker: which account `amy use NAME` pinned
|
||||
├── shared/
|
||||
│ └── events-store/ # one Nostr event store, shared by every account
|
||||
├── alice/
|
||||
│ ├── identity.json # keypair (or reference to keychain entry)
|
||||
│ ├── state.json # sync cursors
|
||||
│ ├── aliases.json # local name → npub map
|
||||
│ └── marmot/ # MLS state per group
|
||||
└── bob/
|
||||
└── …
|
||||
```
|
||||
|
||||
All files are plain JSON or framed binary — human-inspectable, easy to
|
||||
diff across two data-dirs in a test run.
|
||||
**Account selection** when you don't pass `--account`:
|
||||
|
||||
The local relay configuration (kind:10002 / 10050 / 10051) is **not** a
|
||||
separate file — it lives in `events-store/` as signed events.
|
||||
`amy relay add` builds + signs + ingests a new relay-list event;
|
||||
`amy relay list` reads URLs straight out of the latest event for each
|
||||
kind; `amy relay publish-lists` broadcasts those events to upstream
|
||||
relays. There is no `relays.json`.
|
||||
1. If `~/.amy/current` is set, use it.
|
||||
2. Else if exactly one account exists, use it (silent auto-pick).
|
||||
3. Else error and list the candidates so you can disambiguate.
|
||||
|
||||
`amy use NAME` writes `~/.amy/current`; `amy use --clear` removes it.
|
||||
For one-off override, prepend `--account NAME` to any command.
|
||||
|
||||
`init` and `create` write a self-entry into `aliases.json` so you can
|
||||
refer to your own account by name in future commands. The alias resolver
|
||||
in recipient slots (`amy dm send alice "hi"`) is on the roadmap.
|
||||
|
||||
For the deeper layout (events-store internals, relay-routing rules, the
|
||||
public-contract guarantees) see [DEVELOPMENT.md](./DEVELOPMENT.md).
|
||||
|
||||
---
|
||||
|
||||
## For agents and scripts
|
||||
|
||||
Three contracts keep amy machine-safe:
|
||||
|
||||
1. **One JSON object per success on stdout** under `--json`. Stable
|
||||
snake_case keys; keys never disappear silently.
|
||||
2. **Errors as JSON on stderr** under `--json`: `{"error":"...","detail":"..."}`.
|
||||
3. **Exit codes mean specific things** (table above) — `124` for
|
||||
`await` timeout in particular lets you distinguish "condition never
|
||||
happened" from "the command itself crashed".
|
||||
|
||||
### Recipes
|
||||
|
||||
```bash
|
||||
# Capture a fresh group's id.
|
||||
GID=$(amy --json marmot group create --name "ops" | jq -r .group_id)
|
||||
|
||||
# Add several members at once and report which KeyPackages were missing.
|
||||
amy --json marmot group add "$GID" npub1aaa npub1bbb npub1ccc \
|
||||
| jq -r '.added[] | select(.status != "ok") | "missing: \(.pubkey)"'
|
||||
|
||||
# Wait up to 5 minutes for a particular message and capture its event id.
|
||||
EVT=$(amy --json marmot await message "$GID" --match "deploy starting" --timeout 300 \
|
||||
| jq -r .event_id)
|
||||
|
||||
# Run a command per follow.
|
||||
amy --json notes feed --following --limit 50 \
|
||||
| jq -r '.notes[].author' \
|
||||
| sort -u \
|
||||
| while read -r author; do
|
||||
amy --json profile show "$author" | jq -r '.metadata.name // "?"'
|
||||
done
|
||||
```
|
||||
|
||||
### Test isolation
|
||||
|
||||
amy reads `$HOME` directly to find `~/.amy/`, so harnesses isolate the
|
||||
exact same way `git`, `gpg`, `npm`, and `ssh` do — by overriding `$HOME`
|
||||
for the subprocess:
|
||||
|
||||
```bash
|
||||
HOME=$(mktemp -d) amy --account alice init
|
||||
HOME=$(mktemp -d) amy --account alice marmot group create --name "scratch"
|
||||
```
|
||||
|
||||
Inside the amy process there's no test mode — it just sees a fresh
|
||||
`~/.amy/` and behaves like a brand-new install.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`no identity`** — run `init`, `create`, or `login` first, or pass a
|
||||
different `--data-dir`.
|
||||
- **`not_member`** — the group GID is unknown to this data-dir. Run
|
||||
`marmot group list` to confirm, or `marmot await group --name …` to
|
||||
wait for an invite to arrive.
|
||||
- **Hang on a network verb** — Amy connects to the relays advertised
|
||||
in your local kind:10002 / 10050 / 10051 events; inspect with
|
||||
`amy relay list`. Every network-bound operation has a timeout — use
|
||||
`--timeout` for `await`, or wrap the whole command in `timeout(1)`
|
||||
if you're scripting.
|
||||
- **Nothing seems to publish** — inspect stderr; each publish prints
|
||||
per-relay `OK` / `REJECT` via the `[cli] …` traces.
|
||||
- **`no account at ~/.amy`** — you haven't created one yet. Run
|
||||
`amy --account NAME init` (bare keypair) or `amy --account NAME create`
|
||||
(full Amethyst-style bootstrap).
|
||||
- **`multiple accounts in ~/.amy (alice, bob)`** — pin one with
|
||||
`amy use NAME` or pass `--account NAME` per command.
|
||||
- **`current pins 'X' but ~/.amy/X doesn't exist`** — the active-account
|
||||
marker is stale. Rewrite with `amy use OTHER` or `amy use --clear`.
|
||||
- **`no_dm_relays`** — recipient hasn't published a kind:10050 inbox.
|
||||
Pass `--allow-fallback` to fall back to their kind:10002 read marker
|
||||
→ bootstrap pool. Or wait for them to publish one.
|
||||
- **`not_member`** — the group GID is unknown to this account. Run
|
||||
`amy marmot group list` to see what you're in, or `await group --name X`
|
||||
to wait for an invite.
|
||||
- **A network verb hangs** — every network verb has a relay timeout.
|
||||
Inspect what amy is connecting to with `amy relay list`. Wrap any
|
||||
command in `timeout(1)` if you're scripting and want a hard ceiling.
|
||||
- **Nothing seems to publish** — stderr carries `[cli] …` traces with
|
||||
per-relay `OK` / `REJECT`. Capture with `2> /tmp/amy.log` and grep.
|
||||
|
||||
---
|
||||
|
||||
## Where to go next
|
||||
|
||||
- **[DEVELOPMENT.md](./DEVELOPMENT.md)** — design principles,
|
||||
architecture, the public contract, the local event store, relay
|
||||
routing, full on-disk layout, how to extend amy without breaking it.
|
||||
- **[ROADMAP.md](./ROADMAP.md)** — north-star goal and the parity matrix
|
||||
tracking what's left to extract from the Android app.
|
||||
- **[`plans/`](./plans/)** — design docs for cross-cutting work
|
||||
(CLI distribution, file-backed event store, NIP-17 DMs, …).
|
||||
- **[Nostr NIPs](https://github.com/nostr-protocol/nips)** — the
|
||||
protocol amy speaks.
|
||||
|
||||
+14
-12
@@ -7,8 +7,8 @@ full command-line mirror of Amethyst.
|
||||
feature. Move rows between tables, adjust ordering, add non-goals.
|
||||
This is the single source of truth for "what's left".
|
||||
|
||||
- How the CLI is used today: [README.md](./README.md)
|
||||
- How to implement an item: [DEVELOPMENT.md](./DEVELOPMENT.md)
|
||||
- What amy is + how to use it: [README.md](./README.md)
|
||||
- The public contract + how to extend amy: [DEVELOPMENT.md](./DEVELOPMENT.md)
|
||||
- Ongoing design plans: [plans/](./plans/)
|
||||
- Shared work consumed here: [../commons/plans/](../commons/plans/)
|
||||
|
||||
@@ -48,13 +48,13 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|
||||
| Marmot group create / add / rename / promote / demote / remove / leave | ✅ | `commons/marmot/` |
|
||||
| Marmot message send / list | ✅ | `commons/marmot/` |
|
||||
| `await` polling (KP / group / member / admin / message / rename / epoch) | ✅ | `AwaitCommands` |
|
||||
| NIP-01 note publish (`amy note publish TEXT`) | 🆕 | Needs a `commons/` builder wrapper. |
|
||||
| NIP-01 feed read (`amy feed home`, `amy feed hashtag #X`, `amy feed profile NPUB`) | 🆕 | Extract `FeedFilter` usage from `amethyst/ui/dal/` into `commons/` entry points. |
|
||||
| NIP-01 note publish (`amy notes post TEXT`) | ✅ | `PostCommand` — outbox via `RelayCommands` configured set. |
|
||||
| NIP-01 feed read (`amy notes feed [--following \| --author NPUB]`) | ✅ | `FeedCommand`. Hashtag / community feeds still pending. |
|
||||
| NIP-02 follow list add / remove / list | 🆕 | Logic in `amethyst/model/nip02FollowLists/`. |
|
||||
| NIP-09 event deletion | 🆕 | Builder exists in quartz. |
|
||||
| NIP-17 DMs send / list / await | ✅ | `DmCommands` — reuses Quartz `NIP17Factory` + `RecipientRelayFetcher`; filter extracted to `commons/relayClient/nip17Dm/`. Plan: [`cli/plans/2026-04-23-nip17-dm.md`](./plans/2026-04-23-nip17-dm.md). |
|
||||
| NIP-18 reposts / quotes | 🆕 | |
|
||||
| NIP-25 reactions | 🆕 | |
|
||||
| NIP-25 reactions | ✅ in groups · 🆕 elsewhere | `marmot message react` covers MLS group reactions; outer-event reactions still pending. |
|
||||
| NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` |
|
||||
| NIP-57 zaps (send + verify) | 🆕 | Needs LN-URL plumbing; `amethyst/service/lnurl/`. |
|
||||
| NIP-65 outbox model queries | 🆕 | |
|
||||
@@ -65,7 +65,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|
||||
| Blossom uploads (NIP-B7) | 🆕 | |
|
||||
| NIP-47 Wallet Connect | 🆕 | |
|
||||
| NIP-46 bunker signer | 🆕 | Needs a signers abstraction in Amy. |
|
||||
| Profile view (`amy profile show NPUB`) | ⚠️ | Blocked on event-renderer plan in `commons/plans/`. |
|
||||
| Profile view (`amy profile show NPUB`) + edit | ✅ | `ProfileCommands`. Cache-first; `--refresh` forces a relay drain. |
|
||||
| Thread view (`amy thread show EVENT_ID`) | ⚠️ | Same. |
|
||||
| Notifications feed | 🆕 | |
|
||||
| Search (NIP-50) | 🆕 | |
|
||||
@@ -82,14 +82,16 @@ move anything, re-audit — you're probably duplicating logic.
|
||||
with renderers for kinds 0 / 1 / 3 / 6 / 7 / 10002 / 10050.
|
||||
Unblocks all the 🆕 and ⚠️ read-path rows below.
|
||||
Design: `commons/plans/2026-04-21-event-renderer.md`.
|
||||
2. **`amy note publish` / `amy note show` / `amy note react`** —
|
||||
smallest end-to-end write+read loop outside Marmot.
|
||||
3. **`amy feed home|profile|hashtag|thread`** reading through the
|
||||
renderer.
|
||||
2. **`amy notes post` / `amy notes show` / `amy notes react`** —
|
||||
smallest end-to-end write+read loop outside Marmot. Post + feed
|
||||
✅ shipped; `notes show` and outer-event `react` still pending.
|
||||
3. **`amy notes feed home|profile|hashtag|thread`** reading through the
|
||||
renderer. `--following` and `--author NPUB` ✅; hashtag/thread
|
||||
variants still pending.
|
||||
4. **`amy follow add|remove|list`** (NIP-02) — proves extraction of
|
||||
list-building logic from `amethyst/model/`.
|
||||
5. **`amy dm send|list`** (NIP-17) — reuses the gift-wrap path already
|
||||
exercised by Marmot.
|
||||
5. **`amy dm send|list`** (NIP-17) — ✅ shipped. Reuses the gift-wrap
|
||||
path also exercised by Marmot.
|
||||
6. **`amy list bookmarks|mute|pin …`** (NIP-51).
|
||||
7. **`amy zap send|verify`** (NIP-57).
|
||||
8. **Distribution** — Homebrew + Scoop + `.deb` in the same release
|
||||
|
||||
+26
-15
@@ -20,23 +20,34 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
|
||||
object Json {
|
||||
val mapper: ObjectMapper = jacksonObjectMapper()
|
||||
|
||||
fun writeLine(obj: Any) {
|
||||
println(mapper.writeValueAsString(obj))
|
||||
/**
|
||||
* Per-account `aliases.json` — short human-friendly names that map to
|
||||
* npubs. Today it's only populated by `init --name X` (a self-entry so
|
||||
* the user can refer to their own account by name); future verbs like
|
||||
* `amy alias add bob npub1…` and recipient resolution in `dm send` will
|
||||
* read from the same file.
|
||||
*
|
||||
* Shape on disk: a JSON object of `{name: npub}` pairs. We persist the
|
||||
* npub form (not hex) so `cat aliases.json` is human-inspectable.
|
||||
*/
|
||||
object Aliases {
|
||||
/** Read the alias map; returns empty when the file doesn't exist. */
|
||||
fun load(dataDir: DataDir): MutableMap<String, String> {
|
||||
val f = dataDir.aliasesFile
|
||||
if (!f.exists()) return linkedMapOf()
|
||||
return Output.mapper.readValue(f.readText())
|
||||
}
|
||||
|
||||
fun error(
|
||||
code: String,
|
||||
detail: String? = null,
|
||||
): Int {
|
||||
val payload = mutableMapOf<String, Any>("error" to code)
|
||||
if (detail != null) payload["detail"] = detail
|
||||
System.err.println(mapper.writeValueAsString(payload))
|
||||
return 1
|
||||
/** Upsert one entry. Idempotent. */
|
||||
fun set(
|
||||
dataDir: DataDir,
|
||||
name: String,
|
||||
npub: String,
|
||||
) {
|
||||
val map = load(dataDir)
|
||||
map[name] = npub
|
||||
SecureFileIO.writeTextAtomic(dataDir.aliasesFile, Output.mapper.writeValueAsString(map))
|
||||
}
|
||||
}
|
||||
@@ -110,26 +110,33 @@ data class RunState(
|
||||
)
|
||||
|
||||
/**
|
||||
* Root of the on-disk layout. Any absolute path chosen by `--data-dir` (or
|
||||
* `$AMETHYST_CLI_DATA`) — defaults to `./amy`.
|
||||
* Root of the on-disk layout for one account.
|
||||
*
|
||||
* [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.
|
||||
* 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.
|
||||
*
|
||||
* Use [resolve] to construct one from CLI flags. [secrets] is the
|
||||
* [SecretStore] that mediates private-key persistence. Owning it here
|
||||
* keeps the call sites that already thread [DataDir] from having to
|
||||
* learn about a second parameter.
|
||||
*/
|
||||
class DataDir(
|
||||
val root: File,
|
||||
val eventsDir: File,
|
||||
val accountName: String,
|
||||
val secrets: SecretStore,
|
||||
) {
|
||||
val identityFile = File(root, "identity.json")
|
||||
val stateFile = File(root, "state.json")
|
||||
val aliasesFile = File(root, "aliases.json")
|
||||
val marmotDir = File(root, "marmot")
|
||||
val groupsDir = File(marmotDir, "groups")
|
||||
val keyPackageBundleFile = File(marmotDir, "keypackages.bundle")
|
||||
|
||||
/** Root of the file-backed Nostr event store (`FsEventStore`). */
|
||||
val eventsDir = File(root, "events-store")
|
||||
|
||||
init {
|
||||
SecureFileIO.secureMkdirs(root)
|
||||
SecureFileIO.secureMkdirs(groupsDir)
|
||||
@@ -145,7 +152,7 @@ class DataDir(
|
||||
* for "does an identity exist?" / "what's the npub?" checks that must
|
||||
* not pop a keychain prompt or ask for a passphrase.
|
||||
*/
|
||||
fun loadIdentityFileOrNull(): IdentityFile? = if (identityFile.exists()) Json.mapper.readValue(identityFile.readText()) else null
|
||||
fun loadIdentityFileOrNull(): IdentityFile? = if (identityFile.exists()) Output.mapper.readValue(identityFile.readText()) else null
|
||||
|
||||
fun identityExists(): Boolean = identityFile.exists()
|
||||
|
||||
@@ -177,14 +184,14 @@ class DataDir(
|
||||
fun saveIdentity(id: Identity) {
|
||||
val secret: IdentitySecret? = id.privKeyHex?.let { secrets.store(id.pubKeyHex, it) }
|
||||
val file = IdentityFile(pubKeyHex = id.pubKeyHex, npub = id.npub, secret = secret)
|
||||
SecureFileIO.writeTextAtomic(identityFile, Json.mapper.writeValueAsString(file))
|
||||
SecureFileIO.writeTextAtomic(identityFile, Output.mapper.writeValueAsString(file))
|
||||
}
|
||||
|
||||
/** Remove the identity file and any backend-held secret. */
|
||||
fun deleteIdentity() {
|
||||
if (identityFile.exists()) {
|
||||
runCatching {
|
||||
val file = Json.mapper.readValue<IdentityFile>(identityFile.readText())
|
||||
val file = Output.mapper.readValue<IdentityFile>(identityFile.readText())
|
||||
file.secret?.let { secrets.delete(it) }
|
||||
}
|
||||
if (!identityFile.delete() && identityFile.exists()) {
|
||||
@@ -193,20 +200,125 @@ class DataDir(
|
||||
}
|
||||
}
|
||||
|
||||
fun loadRunState(): RunState = if (stateFile.exists()) Json.mapper.readValue(stateFile.readText()) else RunState()
|
||||
fun loadRunState(): RunState = if (stateFile.exists()) Output.mapper.readValue(stateFile.readText()) else RunState()
|
||||
|
||||
fun saveRunState(s: RunState) {
|
||||
SecureFileIO.writeTextAtomic(stateFile, Json.mapper.writeValueAsString(s))
|
||||
SecureFileIO.writeTextAtomic(stateFile, Output.mapper.writeValueAsString(s))
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Per-user root under which `shared/` and `<account>/` live.
|
||||
*
|
||||
* Reads `$HOME` directly rather than `user.home` because JDK 21
|
||||
* resolves the latter from `getpwuid` and ignores `$HOME`,
|
||||
* which would break the standard `HOME=/tmp/foo amy …` test
|
||||
* isolation pattern (the same convention `git`, `gpg`, `npm`
|
||||
* follow). Falls back to `user.home` only when `$HOME` is unset
|
||||
* (Windows, weird containers).
|
||||
*/
|
||||
val DEFAULT_ROOT: File get() {
|
||||
val home =
|
||||
System.getenv("HOME").takeUnless { it.isNullOrBlank() }
|
||||
?: System.getProperty("user.home")
|
||||
return File(home, ".amy")
|
||||
}
|
||||
|
||||
/** Marker file (one line, just the account name) written by `amy use`. */
|
||||
const val CURRENT_MARKER_NAME = "current"
|
||||
|
||||
/**
|
||||
* Account names become directory names AND alias keys, so we
|
||||
* keep them to a portable, shell-friendly subset. `shared` is
|
||||
* reserved for the cross-account events-store sibling, and
|
||||
* `current` collides with the active-account marker file.
|
||||
*/
|
||||
private val NAME_REGEX = Regex("^[a-zA-Z0-9_-]{1,64}$")
|
||||
private const val SHARED_DIR_NAME = "shared"
|
||||
private val RESERVED_NAMES = setOf(SHARED_DIR_NAME, CURRENT_MARKER_NAME)
|
||||
|
||||
fun validateName(name: String): String {
|
||||
require(NAME_REGEX.matches(name)) {
|
||||
"--account must match [a-zA-Z0-9_-]{1,64} (got '$name')"
|
||||
}
|
||||
require(name !in RESERVED_NAMES) {
|
||||
"'$name' is reserved (cannot be used as an account name)"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a [DataDir] from the parsed CLI flags.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. `--account X` if provided.
|
||||
* 2. `<root>/current` marker (set by `amy use X`).
|
||||
* 3. Sole subdirectory of `<root>` other than `shared/`.
|
||||
* 4. Error — caller must disambiguate via `--account` or `amy use`.
|
||||
*/
|
||||
fun resolve(
|
||||
flag: String?,
|
||||
accountFlag: String?,
|
||||
secrets: SecretStore,
|
||||
): DataDir {
|
||||
val envPath = System.getenv("AMETHYST_CLI_DATA")
|
||||
val path = flag ?: envPath ?: "./amy"
|
||||
return DataDir(File(path).absoluteFile, secrets)
|
||||
val rootBase = DEFAULT_ROOT
|
||||
val name = if (accountFlag != null) validateName(accountFlag) else pickAccount(rootBase)
|
||||
val accountRoot = File(rootBase, name).absoluteFile
|
||||
val sharedEvents = File(rootBase, "$SHARED_DIR_NAME/events-store").absoluteFile
|
||||
return DataDir(
|
||||
root = accountRoot,
|
||||
eventsDir = sharedEvents,
|
||||
accountName = name,
|
||||
secrets = secrets,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-select an account when `--name` was not given. Honours
|
||||
* `<root>/current` first (explicit pin from `amy use`), then
|
||||
* falls back to "exactly one account exists". Throws
|
||||
* [IllegalArgumentException] for the ambiguous cases so
|
||||
* `main`'s catch-all turns them into a clean exit-2 error.
|
||||
*/
|
||||
private fun pickAccount(rootBase: File): String {
|
||||
val current = File(rootBase, CURRENT_MARKER_NAME)
|
||||
if (current.isFile) {
|
||||
val pinned = current.readText().trim()
|
||||
require(pinned.isNotEmpty()) {
|
||||
"${current.absolutePath} is empty; rewrite with `amy use <name>` or pass --account"
|
||||
}
|
||||
require(File(rootBase, pinned).isDirectory) {
|
||||
"${current.absolutePath} pins '$pinned' but ${File(rootBase, pinned).absolutePath} doesn't exist; " +
|
||||
"rewrite with `amy use <name>` or pass --account"
|
||||
}
|
||||
return pinned
|
||||
}
|
||||
val accounts = listAccounts(rootBase)
|
||||
return when (accounts.size) {
|
||||
0 -> {
|
||||
throw IllegalArgumentException(
|
||||
"no account at ${rootBase.absolutePath}; create one with `amy --account <name> init`",
|
||||
)
|
||||
}
|
||||
|
||||
1 -> {
|
||||
accounts.single()
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw IllegalArgumentException(
|
||||
"multiple accounts in ${rootBase.absolutePath} (${accounts.joinToString(", ")}); " +
|
||||
"pick one with --account <name> or `amy use <name>`",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Subdirectories of `<root>/` that look like accounts (excludes `shared/`). */
|
||||
fun listAccounts(rootBase: File): List<String> =
|
||||
rootBase
|
||||
.listFiles { f -> f.isDirectory && f.name !in RESERVED_NAMES }
|
||||
?.map { it.name }
|
||||
?.sorted()
|
||||
.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,25 +37,33 @@ import kotlin.system.exitProcess
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — success
|
||||
* 1 — runtime error (printed as JSON on stderr: {"error": "...", "detail": "..."})
|
||||
* 1 — runtime error
|
||||
* 2 — invalid arguments
|
||||
* 124 — await timeout
|
||||
*
|
||||
* Every command that succeeds prints exactly one JSON object to stdout.
|
||||
* Diagnostic logs go to stderr and are safe to discard.
|
||||
* Default output is human-readable text on stdout. Pass `--json` to
|
||||
* switch to the machine contract: a single JSON object on stdout per
|
||||
* successful command, JSON `{"error": "...", "detail": "..."}` on stderr
|
||||
* for failures. The JSON shape is amy's stable public API; the text
|
||||
* shape is not. Diagnostic logs always go to stderr.
|
||||
*/
|
||||
fun main(argv: Array<String>) {
|
||||
// Set output mode before dispatch so even argument-parsing errors
|
||||
// honour --json.
|
||||
if (argv.any { it == "--json" || it == "--json=true" }) {
|
||||
Output.mode = Output.Mode.JSON
|
||||
}
|
||||
val code =
|
||||
try {
|
||||
runBlocking { dispatch(argv) }
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Json.error("bad_args", e.message)
|
||||
Output.error("bad_args", e.message)
|
||||
2
|
||||
} catch (e: AwaitTimeout) {
|
||||
Json.error("timeout", e.message)
|
||||
Output.error("timeout", e.message)
|
||||
124
|
||||
} catch (e: Exception) {
|
||||
Json.error("runtime", "${e::class.simpleName}: ${e.message}")
|
||||
Output.error("runtime", "${e::class.simpleName}: ${e.message}")
|
||||
1
|
||||
}
|
||||
exitProcess(code)
|
||||
@@ -74,7 +82,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
// Pull global flags out of argv before subcommand parsing so subcommands see
|
||||
// only their own args.
|
||||
val filteredArgs = mutableListOf<String>()
|
||||
var dataDirFlag: String? = null
|
||||
var accountFlag: String? = null
|
||||
var secretBackendFlag: String? = null
|
||||
var passphraseFileFlag: String? = null
|
||||
var i = 0
|
||||
@@ -82,9 +90,10 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
val a = argv[i]
|
||||
val (matched, consumed) = extractGlobalFlag(a, argv, i)
|
||||
when (matched) {
|
||||
GlobalFlag.DATA_DIR -> dataDirFlag = consumed.value
|
||||
GlobalFlag.ACCOUNT -> accountFlag = consumed.value
|
||||
GlobalFlag.SECRET_BACKEND -> secretBackendFlag = consumed.value
|
||||
GlobalFlag.PASSPHRASE_FILE -> passphraseFileFlag = consumed.value
|
||||
GlobalFlag.JSON -> Output.mode = Output.Mode.JSON
|
||||
null -> filteredArgs.add(a)
|
||||
}
|
||||
i += consumed.tokensConsumed
|
||||
@@ -94,11 +103,21 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
return 2
|
||||
}
|
||||
|
||||
val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag)
|
||||
val dataDir = DataDir.resolve(dataDirFlag, secrets)
|
||||
val head = filteredArgs[0]
|
||||
val tail = filteredArgs.drop(1).toTypedArray()
|
||||
|
||||
// `use` operates on `<root>/current` directly and must work even
|
||||
// when account auto-pick would fail (the whole point of `use` is to
|
||||
// resolve "multiple accounts, ambiguous" cases) — so it skips
|
||||
// DataDir.resolve. Other commands fall through to the normal path.
|
||||
if (head == "use") {
|
||||
return com.vitorpamplona.amethyst.cli.commands.UseCommand
|
||||
.run(tail)
|
||||
}
|
||||
|
||||
val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag)
|
||||
val dataDir = DataDir.resolve(accountFlag = accountFlag, secrets = secrets)
|
||||
|
||||
return when (head) {
|
||||
"init" -> {
|
||||
Commands.init(dataDir, Args(tail))
|
||||
@@ -189,10 +208,12 @@ private suspend fun marmotDispatch(
|
||||
|
||||
private enum class GlobalFlag(
|
||||
val long: String,
|
||||
val takesValue: Boolean = true,
|
||||
) {
|
||||
DATA_DIR("--data-dir"),
|
||||
ACCOUNT("--account"),
|
||||
SECRET_BACKEND("--secret-backend"),
|
||||
PASSPHRASE_FILE("--passphrase-file"),
|
||||
JSON("--json", takesValue = false),
|
||||
}
|
||||
|
||||
private data class ConsumedFlag(
|
||||
@@ -212,7 +233,11 @@ private fun extractGlobalFlag(
|
||||
): Pair<GlobalFlag?, ConsumedFlag> {
|
||||
for (flag in GlobalFlag.values()) {
|
||||
if (token == flag.long) {
|
||||
return flag to ConsumedFlag(argv.getOrNull(idx + 1), 2)
|
||||
return if (flag.takesValue) {
|
||||
flag to ConsumedFlag(argv.getOrNull(idx + 1), 2)
|
||||
} else {
|
||||
flag to ConsumedFlag(null, 1)
|
||||
}
|
||||
}
|
||||
val prefix = "${flag.long}="
|
||||
if (token.startsWith(prefix)) {
|
||||
@@ -228,11 +253,39 @@ private fun printUsage() {
|
||||
|amy — Amethyst command-line interface
|
||||
|
|
||||
|Usage:
|
||||
| amy [--data-dir PATH]
|
||||
| amy [--account ACCOUNT]
|
||||
| [--secret-backend auto|keychain|ncryptsec|plaintext]
|
||||
| [--passphrase-file PATH]
|
||||
| [--json]
|
||||
| <cmd> [args...]
|
||||
|
|
||||
|Account selection:
|
||||
| All state lives under ~/.amy/. Per-account directories
|
||||
| ~/.amy/<account>/ hold identity, cursors, MLS state, and
|
||||
| aliases; every observed Nostr event lands in the shared
|
||||
| ~/.amy/shared/events-store/. ACCOUNT must match
|
||||
| [a-zA-Z0-9_-]{1,64} (no spaces, no slashes).
|
||||
|
|
||||
| Resolution order:
|
||||
| 1. --account X if given.
|
||||
| 2. ~/.amy/current marker (set by `amy use X`).
|
||||
| 3. Sole subdirectory of ~/.amy/ other than shared/.
|
||||
| 4. Error — disambiguate with --account or `amy use`.
|
||||
|
|
||||
| Test harnesses isolate by overriding ${'$'}HOME for the amy
|
||||
| subprocess (`HOME=/tmp/run.123 amy --account alice ...`).
|
||||
|
|
||||
| use NAME pin NAME as the active account
|
||||
| use --clear remove the pin
|
||||
| use print current pin + available accounts
|
||||
|
|
||||
|Output:
|
||||
| Default: human-readable text on stdout.
|
||||
| --json: one JSON object per success on stdout, JSON
|
||||
| {"error":...,"detail":...} on stderr for failures
|
||||
| (the stable machine-readable contract — exit codes
|
||||
| 0 success / 1 error / 2 bad args / 124 timeout).
|
||||
|
|
||||
|Private-key storage:
|
||||
| Default (`auto`) uses the OS keychain when one is available
|
||||
| (macOS `security`, or Linux `secret-tool` on a session D-Bus)
|
||||
@@ -243,7 +296,7 @@ private fun printUsage() {
|
||||
|
|
||||
|Identity:
|
||||
| init [--nsec NSEC] create or import a bare identity (no defaults published)
|
||||
| create [--name NAME] provision a full Amethyst-style account + publish bootstrap events
|
||||
| create [--name NAME] provision a full Amethyst-style account + publish bootstrap events
|
||||
| login KEY [--password X] import (nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05)
|
||||
| whoami print current identity
|
||||
|
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Stdout/stderr emitter for amy.
|
||||
*
|
||||
* Default mode is human-readable text. Pass `--json` on the command line
|
||||
* to switch to the machine contract: a single JSON object on stdout per
|
||||
* successful command, JSON `{"error": ..., "detail": ...}` on stderr for
|
||||
* failures. The JSON shape is the public API — the text shape is not.
|
||||
*
|
||||
* The text renderer:
|
||||
* - aligns sibling keys at each indentation level (YAML-ish columns),
|
||||
* - rewrites unix-second timestamps under `*_at` keys to a readable
|
||||
* ISO + relative form,
|
||||
* - rewrites byte counts under `*_bytes` keys to KiB / MiB / GiB,
|
||||
* - rewrites booleans to yes / no,
|
||||
* - paints the result with ANSI colour when stdout is a TTY (disabled
|
||||
* by `NO_COLOR`, forced on by `CLICOLOR_FORCE`).
|
||||
*/
|
||||
object Output {
|
||||
enum class Mode { TEXT, JSON }
|
||||
|
||||
@Volatile var mode: Mode = Mode.TEXT
|
||||
|
||||
val mapper: ObjectMapper = jacksonObjectMapper()
|
||||
|
||||
fun emit(result: Any?) {
|
||||
when (mode) {
|
||||
Mode.JSON -> println(mapper.writeValueAsString(result))
|
||||
Mode.TEXT -> println(renderText(result))
|
||||
}
|
||||
}
|
||||
|
||||
fun error(
|
||||
code: String,
|
||||
detail: String? = null,
|
||||
): Int {
|
||||
when (mode) {
|
||||
Mode.JSON -> {
|
||||
val payload = mutableMapOf<String, Any>("error" to code)
|
||||
if (detail != null) payload["detail"] = detail
|
||||
System.err.println(mapper.writeValueAsString(payload))
|
||||
}
|
||||
|
||||
Mode.TEXT -> {
|
||||
val color = Ansi.forStream(isStderr = true)
|
||||
val prefix = color.bold(color.red("error"))
|
||||
val codePart = color.yellow(code)
|
||||
System.err.println(if (detail != null) "$prefix: $codePart: $detail" else "$prefix: $codePart")
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
private fun renderText(value: Any?): String {
|
||||
val color = Ansi.forStream(isStderr = false)
|
||||
val out = StringBuilder()
|
||||
when (val v = unwrap(value)) {
|
||||
null -> {}
|
||||
|
||||
is Map<*, *> -> {
|
||||
renderMapBody(out, v, "", color)
|
||||
}
|
||||
|
||||
is List<*> -> {
|
||||
renderListBody(out, v, "", color)
|
||||
}
|
||||
|
||||
else -> {
|
||||
out.append(v.toString()).append('\n')
|
||||
}
|
||||
}
|
||||
return out.toString().trimEnd('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any embedded Jackson [JsonNode] into plain Java types
|
||||
* (`LinkedHashMap` / `ArrayList` / boxed primitives) so the generic
|
||||
* renderer can descend into it. Plain Maps / Lists / scalars are
|
||||
* returned unchanged. Walks recursively because callers commonly
|
||||
* mix a JsonNode subtree into a hand-built Map (e.g. profile show
|
||||
* stuffing the parsed kind:0 content under a `metadata` key).
|
||||
*/
|
||||
private fun unwrap(value: Any?): Any? =
|
||||
when (value) {
|
||||
is JsonNode -> mapper.convertValue(value, Any::class.java)
|
||||
is Map<*, *> -> value.mapValues { (_, v) -> unwrap(v) }
|
||||
is List<*> -> value.map { unwrap(it) }
|
||||
else -> value
|
||||
}
|
||||
|
||||
private fun renderMapBody(
|
||||
out: StringBuilder,
|
||||
map: Map<*, *>,
|
||||
prefix: String,
|
||||
color: Ansi,
|
||||
) {
|
||||
val entries = map.entries.filter { it.value != null }
|
||||
if (entries.isEmpty()) return
|
||||
val keyWidth = entries.maxOf { it.key.toString().length }
|
||||
for ((k, v) in entries) {
|
||||
val key = k.toString()
|
||||
val coloredKey = color.bold(key)
|
||||
val padding = " ".repeat(keyWidth - key.length)
|
||||
when (v) {
|
||||
is Map<*, *> -> {
|
||||
if (v.isEmpty()) {
|
||||
out
|
||||
.append(prefix)
|
||||
.append(coloredKey)
|
||||
.append(":")
|
||||
.append(padding)
|
||||
out.append(' ').append(color.dim("(empty)")).append('\n')
|
||||
} else {
|
||||
out.append(prefix).append(coloredKey).append(":\n")
|
||||
renderMapBody(out, v, "$prefix ", color)
|
||||
}
|
||||
}
|
||||
|
||||
is List<*> -> {
|
||||
if (v.isEmpty()) {
|
||||
out
|
||||
.append(prefix)
|
||||
.append(coloredKey)
|
||||
.append(":")
|
||||
.append(padding)
|
||||
out.append(' ').append(color.dim("(none)")).append('\n')
|
||||
} else {
|
||||
out.append(prefix).append(coloredKey).append(":\n")
|
||||
renderListBody(out, v, "$prefix ", color)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
out
|
||||
.append(prefix)
|
||||
.append(coloredKey)
|
||||
.append(":")
|
||||
.append(padding)
|
||||
.append(' ')
|
||||
.append(formatScalar(key, v, color))
|
||||
.append('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderListBody(
|
||||
out: StringBuilder,
|
||||
list: List<*>,
|
||||
prefix: String,
|
||||
color: Ansi,
|
||||
) {
|
||||
val dash = color.dim("-")
|
||||
for (item in list) {
|
||||
when (item) {
|
||||
null -> {}
|
||||
|
||||
is Map<*, *> -> {
|
||||
val entries = item.entries.filter { it.value != null }
|
||||
if (entries.isEmpty()) {
|
||||
out
|
||||
.append(prefix)
|
||||
.append(dash)
|
||||
.append(' ')
|
||||
.append(color.dim("(empty)"))
|
||||
.append('\n')
|
||||
continue
|
||||
}
|
||||
val keyWidth = entries.maxOf { it.key.toString().length }
|
||||
val first = entries.first()
|
||||
val firstV = first.value
|
||||
if (firstV is Map<*, *> || firstV is List<*>) {
|
||||
out.append(prefix).append(dash).append('\n')
|
||||
renderMapBody(out, item, "$prefix ", color)
|
||||
} else {
|
||||
val firstKey = first.key.toString()
|
||||
out
|
||||
.append(prefix)
|
||||
.append(dash)
|
||||
.append(' ')
|
||||
.append(color.bold(firstKey))
|
||||
.append(':')
|
||||
.append(" ".repeat(keyWidth - firstKey.length))
|
||||
.append(' ')
|
||||
.append(formatScalar(firstKey, firstV, color))
|
||||
.append('\n')
|
||||
for ((rk, rv) in entries.drop(1)) {
|
||||
val rKey = rk.toString()
|
||||
val rPad = " ".repeat(keyWidth - rKey.length)
|
||||
when (rv) {
|
||||
is Map<*, *> -> {
|
||||
if (rv.isEmpty()) {
|
||||
out
|
||||
.append("$prefix ")
|
||||
.append(color.bold(rKey))
|
||||
.append(':')
|
||||
.append(rPad)
|
||||
.append(' ')
|
||||
.append(color.dim("(empty)"))
|
||||
.append('\n')
|
||||
} else {
|
||||
out.append("$prefix ").append(color.bold(rKey)).append(":\n")
|
||||
renderMapBody(out, rv, "$prefix ", color)
|
||||
}
|
||||
}
|
||||
|
||||
is List<*> -> {
|
||||
if (rv.isEmpty()) {
|
||||
out
|
||||
.append("$prefix ")
|
||||
.append(color.bold(rKey))
|
||||
.append(':')
|
||||
.append(rPad)
|
||||
.append(' ')
|
||||
.append(color.dim("(none)"))
|
||||
.append('\n')
|
||||
} else {
|
||||
out.append("$prefix ").append(color.bold(rKey)).append(":\n")
|
||||
renderListBody(out, rv, "$prefix ", color)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
out
|
||||
.append("$prefix ")
|
||||
.append(color.bold(rKey))
|
||||
.append(':')
|
||||
.append(rPad)
|
||||
.append(' ')
|
||||
.append(formatScalar(rKey, rv, color))
|
||||
.append('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is List<*> -> {
|
||||
out.append(prefix).append(dash).append('\n')
|
||||
renderListBody(out, item, "$prefix ", color)
|
||||
}
|
||||
|
||||
else -> {
|
||||
out
|
||||
.append(prefix)
|
||||
.append(dash)
|
||||
.append(' ')
|
||||
.append(formatScalar("", item, color))
|
||||
.append('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic scalar formatting that's safe for any command:
|
||||
* - `*_at` ints → `2026-04-25 12:30:45Z (2m ago)`
|
||||
* - `*_bytes` ints → `7.0 KiB`
|
||||
* - bools → `yes` / `no`, coloured.
|
||||
* The key is the snake_case name from the result map; an empty key
|
||||
* means the scalar is an array element.
|
||||
*/
|
||||
private fun formatScalar(
|
||||
key: String,
|
||||
value: Any?,
|
||||
color: Ansi,
|
||||
): String {
|
||||
if (value is Boolean) {
|
||||
return if (value) color.green("yes") else color.red("no")
|
||||
}
|
||||
val asLong = (value as? Number)?.toLong()
|
||||
if (asLong != null) {
|
||||
if (key.endsWith("_at") && asLong > 1_000_000_000L) {
|
||||
return formatTimestamp(asLong, color)
|
||||
}
|
||||
if (key.endsWith("_bytes") || key == "size") {
|
||||
return formatBytes(asLong)
|
||||
}
|
||||
}
|
||||
return value.toString()
|
||||
}
|
||||
|
||||
private fun formatTimestamp(
|
||||
epochSeconds: Long,
|
||||
color: Ansi,
|
||||
): String {
|
||||
val instant = Instant.ofEpochSecond(epochSeconds)
|
||||
val iso = ISO_FORMAT.format(instant)
|
||||
val rel = relativeTime(Instant.now().epochSecond - epochSeconds)
|
||||
return "$iso ${color.dim("($rel)")}"
|
||||
}
|
||||
|
||||
private fun relativeTime(secondsAgo: Long): String {
|
||||
val s = if (secondsAgo < 0) -secondsAgo else secondsAgo
|
||||
val suffix = if (secondsAgo < 0) "from now" else "ago"
|
||||
val unit =
|
||||
when {
|
||||
s < 60 -> "${s}s"
|
||||
s < 3600 -> "${s / 60}m"
|
||||
s < 86_400 -> "${s / 3600}h"
|
||||
s < 30 * 86_400 -> "${s / 86_400}d"
|
||||
s < 365 * 86_400 -> "${s / (30 * 86_400)}mo"
|
||||
else -> "${s / (365 * 86_400)}y"
|
||||
}
|
||||
return "$unit $suffix"
|
||||
}
|
||||
|
||||
private fun formatBytes(n: Long): String {
|
||||
if (n < 1024) return "$n B"
|
||||
val units = arrayOf("KiB", "MiB", "GiB", "TiB", "PiB")
|
||||
var v = n.toDouble() / 1024.0
|
||||
var i = 0
|
||||
while (v >= 1024.0 && i < units.size - 1) {
|
||||
v /= 1024.0
|
||||
i++
|
||||
}
|
||||
return "%.1f %s".format(v, units[i])
|
||||
}
|
||||
|
||||
private val ISO_FORMAT: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss'Z'").withZone(ZoneOffset.UTC)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny ANSI helper. The standard contract:
|
||||
* - colour on when stdout (or stderr) is a TTY,
|
||||
* - off when piped or redirected,
|
||||
* - `NO_COLOR=…` (any non-empty value) hard-disables,
|
||||
* - `CLICOLOR_FORCE=1` re-enables even when piped.
|
||||
*/
|
||||
internal class Ansi(
|
||||
private val enabled: Boolean,
|
||||
) {
|
||||
fun bold(s: String) = wrap(s, "[1m")
|
||||
|
||||
fun dim(s: String) = wrap(s, "[2m")
|
||||
|
||||
fun red(s: String) = wrap(s, "[31m")
|
||||
|
||||
fun green(s: String) = wrap(s, "[32m")
|
||||
|
||||
fun yellow(s: String) = wrap(s, "[33m")
|
||||
|
||||
private fun wrap(
|
||||
s: String,
|
||||
code: String,
|
||||
): String = if (enabled && s.isNotEmpty()) "$code$s[0m" else s
|
||||
|
||||
companion object {
|
||||
private val noColor: Boolean = !System.getenv("NO_COLOR").isNullOrEmpty()
|
||||
private val forceColor: Boolean = System.getenv("CLICOLOR_FORCE") == "1"
|
||||
|
||||
fun forStream(isStderr: Boolean): Ansi {
|
||||
if (noColor) return Ansi(false)
|
||||
if (forceColor) return Ansi(true)
|
||||
// System.console() is non-null only when both stdin and stdout are
|
||||
// connected to a terminal. Good enough for the common case (interactive
|
||||
// shell vs `amy ... | jq`); honor CLICOLOR_FORCE for the rest.
|
||||
val tty = System.console() != null
|
||||
return Ansi(tty)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.AwaitTimeout
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.cli.commands.AwaitCommands.awaitAdmin
|
||||
import com.vitorpamplona.amethyst.cli.commands.AwaitCommands.awaitMember
|
||||
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
|
||||
@@ -43,7 +43,7 @@ object AwaitCommands {
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "await <key-package|group|member|admin|message|rename|epoch>")
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "await <key-package|group|member|admin|message|rename|epoch>")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"key-package" -> awaitKeyPackage(dataDir, rest)
|
||||
@@ -53,7 +53,7 @@ object AwaitCommands {
|
||||
"message" -> awaitMessage(dataDir, rest)
|
||||
"rename" -> awaitRename(dataDir, rest)
|
||||
"epoch" -> awaitEpoch(dataDir, rest)
|
||||
else -> Json.error("bad_args", "await ${tail[0]}")
|
||||
else -> Output.error("bad_args", "await ${tail[0]}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ object AwaitCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "await key-package <npub>")
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "await key-package <npub>")
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val timeoutSecs = args.longFlag("timeout", 30)
|
||||
val ctx = Context.open(dataDir)
|
||||
@@ -100,7 +100,7 @@ object AwaitCommands {
|
||||
timeoutMs = 3_000,
|
||||
)
|
||||
if (event is KeyPackageEvent) {
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to event.id,
|
||||
"author" to event.pubKey,
|
||||
@@ -135,7 +135,7 @@ object AwaitCommands {
|
||||
wantedName == null || ctx.marmot.groupMetadata(gid)?.name == wantedName
|
||||
}
|
||||
if (match != null) {
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"group_id" to match,
|
||||
"mls_group_id" to ctx.marmot.mlsGroupIdHex(match),
|
||||
@@ -193,7 +193,7 @@ object AwaitCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "await rename <gid> --name <name>")
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "await rename <gid> --name <name>")
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val wantedName = args.requireFlag("name")
|
||||
val timeoutSecs = args.longFlag("timeout", 30)
|
||||
@@ -206,7 +206,7 @@ object AwaitCommands {
|
||||
ctx.syncIncoming(timeoutMs = 3_000)
|
||||
val name = ctx.marmot.groupMetadata(gid)?.name
|
||||
if (name == wantedName) {
|
||||
Json.writeLine(mapOf("group_id" to gid, "name" to name, "epoch" to ctx.marmot.groupEpoch(gid)))
|
||||
Output.emit(mapOf("group_id" to gid, "name" to name, "epoch" to ctx.marmot.groupEpoch(gid)))
|
||||
return 0
|
||||
}
|
||||
delay(1_500)
|
||||
@@ -221,7 +221,7 @@ object AwaitCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "await epoch <gid> --min N")
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "await epoch <gid> --min N")
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val min = args.longFlag("min", 1)
|
||||
val timeoutSecs = args.longFlag("timeout", 30)
|
||||
@@ -234,7 +234,7 @@ object AwaitCommands {
|
||||
ctx.syncIncoming(timeoutMs = 3_000)
|
||||
val epoch = ctx.marmot.groupEpoch(gid)
|
||||
if (epoch != null && epoch >= min) {
|
||||
Json.writeLine(mapOf("group_id" to gid, "epoch" to epoch))
|
||||
Output.emit(mapOf("group_id" to gid, "epoch" to epoch))
|
||||
return 0
|
||||
}
|
||||
delay(1_500)
|
||||
@@ -249,7 +249,7 @@ object AwaitCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "await message <gid> --match STRING")
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "await message <gid> --match STRING")
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val needle = args.requireFlag("match")
|
||||
val timeoutSecs = args.longFlag("timeout", 30)
|
||||
@@ -264,13 +264,13 @@ object AwaitCommands {
|
||||
for (line in msgs.asReversed()) {
|
||||
val obj =
|
||||
try {
|
||||
Json.mapper.readValue<Map<String, Any?>>(line)
|
||||
Output.mapper.readValue<Map<String, Any?>>(line)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: continue
|
||||
val content = obj["content"]?.toString() ?: continue
|
||||
if (content.contains(needle)) {
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"id" to obj["id"],
|
||||
@@ -301,7 +301,7 @@ object AwaitCommands {
|
||||
targetIdx: Int,
|
||||
check: suspend (Context, Array<String>) -> Map<String, Any?>?,
|
||||
): Int {
|
||||
if (rest.size <= targetIdx) return Json.error("bad_args", usage)
|
||||
if (rest.size <= targetIdx) return Output.error("bad_args", usage)
|
||||
val args = Args(rest.drop(targetIdx + 1).toTypedArray())
|
||||
val timeoutSecs = args.longFlag("timeout", 30)
|
||||
val ctx = Context.open(dataDir)
|
||||
@@ -312,7 +312,7 @@ object AwaitCommands {
|
||||
ctx.syncIncoming(timeoutMs = 3_000)
|
||||
val hit = check(ctx, rest)
|
||||
if (hit != null) {
|
||||
Json.writeLine(hit)
|
||||
Output.emit(hit)
|
||||
return 0
|
||||
}
|
||||
delay(1_500)
|
||||
|
||||
@@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Identity
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.commons.account.bootstrapAccountEvents
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65List
|
||||
@@ -52,7 +52,7 @@ object CreateCommand {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (dataDir.identityExists()) {
|
||||
return Json.error("exists", "identity already exists at ${dataDir.identityFile}")
|
||||
return Output.error("exists", "identity already exists at ${dataDir.identityFile}")
|
||||
}
|
||||
val args = Args(rest)
|
||||
val name = args.flag("name")
|
||||
@@ -84,7 +84,7 @@ object CreateCommand {
|
||||
ctx.close()
|
||||
}
|
||||
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"npub" to identity.npub,
|
||||
"hex" to identity.pubKeyHex,
|
||||
|
||||
@@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.AwaitTimeout
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
|
||||
@@ -62,14 +62,14 @@ object DmCommands {
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "dm <send|send-file|list|await> …")
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "dm <send|send-file|list|await> …")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"send" -> send(dataDir, rest)
|
||||
"send-file" -> sendFile(dataDir, rest)
|
||||
"list" -> list(dataDir, rest)
|
||||
"await" -> await(dataDir, rest)
|
||||
else -> Json.error("bad_args", "dm ${tail[0]}")
|
||||
else -> Output.error("bad_args", "dm ${tail[0]}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ object DmCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "dm send <recipient> <text> [--allow-fallback]")
|
||||
if (rest.size < 2) return Output.error("bad_args", "dm send <recipient> <text> [--allow-fallback]")
|
||||
val text = rest[1]
|
||||
val args = Args(rest.drop(2).toTypedArray())
|
||||
val allowFallback = args.bool("allow-fallback")
|
||||
@@ -114,7 +114,7 @@ object DmCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", USAGE_SEND_FILE)
|
||||
if (rest.isEmpty()) return Output.error("bad_args", USAGE_SEND_FILE)
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val allowFallback = args.bool("allow-fallback")
|
||||
val recipientInput = rest[0]
|
||||
@@ -147,7 +147,7 @@ object DmCommands {
|
||||
): Pair<com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<ChatMessageEncryptedFileHeaderEvent>, Map<String, Any?>>? {
|
||||
val file = java.io.File(args.requireFlag("file"))
|
||||
if (!file.exists()) {
|
||||
Json.error("bad_args", "file does not exist: ${file.absolutePath}")
|
||||
Output.error("bad_args", "file does not exist: ${file.absolutePath}")
|
||||
return null
|
||||
}
|
||||
val server = args.requireFlag("server")
|
||||
@@ -158,7 +158,7 @@ object DmCommands {
|
||||
val uploaded = orchestrator.uploadEncrypted(file, cipher, server, ctx.signer)
|
||||
val uploadedUrl =
|
||||
uploaded.blossom.url ?: run {
|
||||
Json.error("upload_failed", "Blossom server $server returned no URL")
|
||||
Output.error("upload_failed", "Blossom server $server returned no URL")
|
||||
return null
|
||||
}
|
||||
val mimeType = args.flag("mime-type") ?: uploaded.metadata.mimeType
|
||||
@@ -202,19 +202,19 @@ object DmCommands {
|
||||
): Pair<com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<ChatMessageEncryptedFileHeaderEvent>, Map<String, Any?>>? {
|
||||
val url =
|
||||
args.positionalOrNull(0) ?: run {
|
||||
Json.error("bad_args", USAGE_SEND_FILE)
|
||||
Output.error("bad_args", USAGE_SEND_FILE)
|
||||
return null
|
||||
}
|
||||
val keyHex = args.requireFlag("key")
|
||||
val nonceHex = args.requireFlag("nonce")
|
||||
val keyBytes =
|
||||
runCatching { keyHex.hexToByteArray() }.getOrElse {
|
||||
Json.error("bad_args", "--key must be hex (got ${keyHex.length} chars)")
|
||||
Output.error("bad_args", "--key must be hex (got ${keyHex.length} chars)")
|
||||
return null
|
||||
}
|
||||
val nonceBytes =
|
||||
runCatching { nonceHex.hexToByteArray() }.getOrElse {
|
||||
Json.error("bad_args", "--nonce must be hex (got ${nonceHex.length} chars)")
|
||||
Output.error("bad_args", "--nonce must be hex (got ${nonceHex.length} chars)")
|
||||
return null
|
||||
}
|
||||
val mimeType = args.flag("mime-type")
|
||||
@@ -227,7 +227,7 @@ object DmCommands {
|
||||
val match =
|
||||
Regex("^(\\d+)x(\\d+)$").matchEntire(raw)
|
||||
?: run {
|
||||
Json.error("bad_args", "--dim must be WxH (got '$raw')")
|
||||
Output.error("bad_args", "--dim must be WxH (got '$raw')")
|
||||
return null
|
||||
}
|
||||
com.vitorpamplona.quartz.nip94FileMetadata.tags
|
||||
@@ -266,7 +266,7 @@ object DmCommands {
|
||||
val target = wrap.recipientPubKey() ?: continue
|
||||
val resolution = resolveDmRelays(ctx, target, allowFallback)
|
||||
if (resolution.relays.isEmpty()) {
|
||||
return Json.error(
|
||||
return Output.error(
|
||||
"no_dm_relays",
|
||||
"$target has no kind:10050; pass --allow-fallback to use NIP-65 read or bootstrap",
|
||||
)
|
||||
@@ -289,7 +289,7 @@ object DmCommands {
|
||||
putAll(extra)
|
||||
put("recipients", recipientsOut)
|
||||
}
|
||||
Json.writeLine(out)
|
||||
Output.emit(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ object DmCommands {
|
||||
.inboxRelays()
|
||||
.ifEmpty { ctx.outboxRelays() }
|
||||
.ifEmpty { ctx.bootstrapRelays() }
|
||||
if (inbox.isEmpty()) return Json.error("no_inbox_relays", "configure relays or bootstrap defaults first")
|
||||
if (inbox.isEmpty()) return Output.error("no_inbox_relays", "configure relays or bootstrap defaults first")
|
||||
|
||||
val advanceCursor = peerInput == null && sinceFlag == null
|
||||
val since = sinceFlag ?: ctx.state.giftWrapSince
|
||||
@@ -338,7 +338,7 @@ object DmCommands {
|
||||
if (maxSeen != null) ctx.state.giftWrapSince = maxSeen
|
||||
}
|
||||
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"peer" to peerHex,
|
||||
"messages" to out,
|
||||
@@ -369,7 +369,7 @@ object DmCommands {
|
||||
.inboxRelays()
|
||||
.ifEmpty { ctx.outboxRelays() }
|
||||
.ifEmpty { ctx.bootstrapRelays() }
|
||||
if (inbox.isEmpty()) return Json.error("no_inbox_relays", "configure relays or bootstrap defaults first")
|
||||
if (inbox.isEmpty()) return Output.error("no_inbox_relays", "configure relays or bootstrap defaults first")
|
||||
|
||||
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
|
||||
var since = ctx.state.giftWrapSince
|
||||
@@ -388,7 +388,7 @@ object DmCommands {
|
||||
// can grep for either with one --match flag.
|
||||
val hit = messages.firstOrNull { match in it.searchText }
|
||||
if (hit != null) {
|
||||
Json.writeLine(hit.toJson())
|
||||
Output.emit(hit.toJson())
|
||||
return 0
|
||||
}
|
||||
val maxSeen = messages.maxOfOrNull { it.createdAt }
|
||||
|
||||
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -53,10 +53,10 @@ object FeedCommand {
|
||||
val author = args.flag("author")
|
||||
val following = args.bool("following")
|
||||
if (author != null && following) {
|
||||
return Json.error("bad_args", "feed: pass either --author or --following, not both")
|
||||
return Output.error("bad_args", "feed: pass either --author or --following, not both")
|
||||
}
|
||||
val limit = args.intFlag("limit", 50)
|
||||
if (limit <= 0) return Json.error("bad_args", "feed: --limit must be > 0")
|
||||
if (limit <= 0) return Output.error("bad_args", "feed: --limit must be > 0")
|
||||
val since = args.flag("since")?.toLongOrNull()
|
||||
val until = args.flag("until")?.toLongOrNull()
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
@@ -73,7 +73,7 @@ object FeedCommand {
|
||||
}
|
||||
|
||||
if (authors.isEmpty()) {
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"mode" to mode,
|
||||
"authors" to emptyList<String>(),
|
||||
@@ -85,7 +85,7 @@ object FeedCommand {
|
||||
|
||||
val relays = relaysForReadingFeed(ctx, mode)
|
||||
if (relays.isEmpty()) {
|
||||
return Json.error("no_relays", "no relays available; run `amy relay add` first")
|
||||
return Output.error("no_relays", "no relays available; run `amy relay add` first")
|
||||
}
|
||||
|
||||
val filter =
|
||||
@@ -121,7 +121,7 @@ object FeedCommand {
|
||||
)
|
||||
}.toList()
|
||||
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"mode" to mode,
|
||||
"authors" to authors,
|
||||
|
||||
+4
-4
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
|
||||
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
@@ -45,13 +45,13 @@ object GroupAddMemberCommand {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "group add <group_id> <npub> [<npub> ...]")
|
||||
if (rest.size < 2) return Output.error("bad_args", "group add <group_id> <npub> [<npub> ...]")
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rest[0])
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid)
|
||||
|
||||
// Accept any identifier the UI would: npub1…, nprofile1…, 64-hex,
|
||||
// NIP-05 (name@domain). Resolution fires NIP-05 HTTP fetches in parallel
|
||||
@@ -160,7 +160,7 @@ object GroupAddMemberCommand {
|
||||
)
|
||||
}
|
||||
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"epoch" to ctx.marmot.groupEpoch(gid),
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
|
||||
object GroupCommands {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "group <create|list|show|…>")
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "group <create|list|show|…>")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"create" -> GroupCreateCommand.run(dataDir, rest)
|
||||
@@ -42,7 +42,7 @@ object GroupCommands {
|
||||
"demote" -> GroupMetadataCommands.demote(dataDir, rest)
|
||||
"remove" -> GroupMembershipCommands.remove(dataDir, rest)
|
||||
"leave" -> GroupMembershipCommands.leave(dataDir, rest)
|
||||
else -> Json.error("bad_args", "group ${tail[0]}")
|
||||
else -> Output.error("bad_args", "group ${tail[0]}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
@@ -56,7 +56,7 @@ object GroupCreateCommand {
|
||||
)
|
||||
ctx.marmot.createGroup(gid, initialMetadata = metadata)
|
||||
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"mls_group_id" to ctx.marmot.mlsGroupIdHex(gid),
|
||||
|
||||
+8
-8
@@ -22,30 +22,30 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
|
||||
object GroupMembershipCommands {
|
||||
suspend fun remove(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "group remove <gid> <npub>")
|
||||
if (rest.size < 2) return Output.error("bad_args", "group remove <gid> <npub>")
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rest[0])
|
||||
val target = ctx.requireUserHex(rest[1])
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid)
|
||||
|
||||
val leafIndex =
|
||||
ctx.marmot.leafIndexOf(gid, target)
|
||||
?: return Json.error("not_in_group", target)
|
||||
?: return Output.error("not_in_group", target)
|
||||
|
||||
val outbound = ctx.marmot.removeMember(nostrGroupId = gid, targetLeafIndex = leafIndex)
|
||||
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||
val ack = ctx.publish(outbound.signedEvent, targets)
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"removed" to target,
|
||||
@@ -65,12 +65,12 @@ object GroupMembershipCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "group leave <gid>")
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "group leave <gid>")
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rest[0])
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid)
|
||||
|
||||
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||
|
||||
@@ -102,7 +102,7 @@ object GroupMembershipCommands {
|
||||
|
||||
val outbound = ctx.marmot.leaveGroup(gid)
|
||||
val ack = ctx.publish(outbound.signedEvent, targets)
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"self_demote_event_id" to demoteEventId,
|
||||
|
||||
+6
-6
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
@@ -35,7 +35,7 @@ object GroupMetadataCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "group rename <gid> <name>")
|
||||
if (rest.size < 2) return Output.error("bad_args", "group rename <gid> <name>")
|
||||
return edit(dataDir, rest[0]) { _, cur -> cur.copy(name = rest[1]) }
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ object GroupMetadataCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "group promote <gid> <npub>")
|
||||
if (rest.size < 2) return Output.error("bad_args", "group promote <gid> <npub>")
|
||||
return edit(dataDir, rest[0]) { ctx, cur ->
|
||||
val newAdmin = ctx.requireUserHex(rest[1])
|
||||
val admins = cur.adminPubkeys.toMutableList()
|
||||
@@ -56,7 +56,7 @@ object GroupMetadataCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "group demote <gid> <npub>")
|
||||
if (rest.size < 2) return Output.error("bad_args", "group demote <gid> <npub>")
|
||||
return edit(dataDir, rest[0]) { ctx, cur ->
|
||||
val target = ctx.requireUserHex(rest[1])
|
||||
val admins = cur.adminPubkeys.filter { it != target }
|
||||
@@ -74,7 +74,7 @@ object GroupMetadataCommands {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rawGid)
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid)
|
||||
val outboxUrls = ctx.outboxRelays().map { it.url }
|
||||
val cur =
|
||||
ctx.marmot.groupMetadata(gid)
|
||||
@@ -89,7 +89,7 @@ object GroupMetadataCommands {
|
||||
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||
val ack = ctx.publish(commit.signedEvent, targets)
|
||||
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"name" to updated.name,
|
||||
|
||||
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
|
||||
/**
|
||||
* Read-only queries. None of these publish; they all sync-then-report.
|
||||
@@ -44,7 +44,7 @@ object GroupReadCommands {
|
||||
"epoch" to ctx.marmot.groupEpoch(id),
|
||||
)
|
||||
}
|
||||
Json.writeLine(mapOf("groups" to items))
|
||||
Output.emit(mapOf("groups" to items))
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
@@ -55,19 +55,19 @@ object GroupReadCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "group show <group_id>")
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "group show <group_id>")
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rest[0])
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid)
|
||||
val meta = ctx.marmot.groupMetadata(gid)
|
||||
val members =
|
||||
ctx.marmot.memberPubkeys(gid).map {
|
||||
mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex)
|
||||
}
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"mls_group_id" to ctx.marmot.mlsGroupIdHex(gid),
|
||||
@@ -90,18 +90,18 @@ object GroupReadCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "group members <group_id>")
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "group members <group_id>")
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rest[0])
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid)
|
||||
val members =
|
||||
ctx.marmot.memberPubkeys(gid).map {
|
||||
mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex)
|
||||
}
|
||||
Json.writeLine(mapOf("group_id" to gid, "members" to members))
|
||||
Output.emit(mapOf("group_id" to gid, "members" to members))
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
@@ -112,15 +112,15 @@ object GroupReadCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "group admins <group_id>")
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "group admins <group_id>")
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rest[0])
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid)
|
||||
val m = ctx.marmot.groupMetadata(gid)
|
||||
Json.writeLine(mapOf("group_id" to gid, "admins" to (m?.adminPubkeys ?: emptyList())))
|
||||
Output.emit(mapOf("group_id" to gid, "admins" to (m?.adminPubkeys ?: emptyList())))
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
|
||||
@@ -20,10 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Aliases
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Identity
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
|
||||
object InitCommands {
|
||||
suspend fun init(
|
||||
@@ -34,8 +35,12 @@ object InitCommands {
|
||||
// would trigger a keychain prompt / passphrase dialog even though the
|
||||
// caller clearly already has the identity set up.
|
||||
dataDir.loadIdentityFileOrNull()?.let { existing ->
|
||||
Json.writeLine(
|
||||
// Idempotent self-alias upsert when the dir already exists
|
||||
// (e.g. user re-runs `init --name alice`).
|
||||
Aliases.set(dataDir, dataDir.accountName, existing.npub)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"name" to dataDir.accountName,
|
||||
"npub" to existing.npub,
|
||||
"hex" to existing.pubKeyHex,
|
||||
"nsec" to null,
|
||||
@@ -48,8 +53,13 @@ object InitCommands {
|
||||
val nsec = args.flag("nsec")
|
||||
val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create()
|
||||
dataDir.saveIdentity(created)
|
||||
Json.writeLine(
|
||||
// Self-alias: record `<name> -> own npub` so the user can refer
|
||||
// to their own account by name in scripts and (once the resolver
|
||||
// lands) in recipient slots like `dm send`.
|
||||
Aliases.set(dataDir, dataDir.accountName, created.npub)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"name" to dataDir.accountName,
|
||||
"npub" to created.npub,
|
||||
"hex" to created.pubKeyHex,
|
||||
"nsec" to created.nsec,
|
||||
@@ -65,10 +75,11 @@ object InitCommands {
|
||||
// or ask for a NIP-49 passphrase just to echo the npub.
|
||||
val file = dataDir.loadIdentityFileOrNull()
|
||||
if (file == null) {
|
||||
return Json.error("no_identity", "No identity at ${dataDir.identityFile}. Run `init` first.")
|
||||
return Output.error("no_identity", "No identity at ${dataDir.identityFile}. Run `init` first.")
|
||||
}
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"name" to dataDir.accountName,
|
||||
"npub" to file.npub,
|
||||
"hex" to file.pubKeyHex,
|
||||
"data_dir" to dataDir.root.absolutePath,
|
||||
|
||||
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
|
||||
@@ -31,11 +31,11 @@ object KeyPackageCommands {
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "key-package <publish|check> …")
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "key-package <publish|check> …")
|
||||
return when (tail[0]) {
|
||||
"publish" -> publish(dataDir)
|
||||
"check" -> check(dataDir, tail.drop(1).toTypedArray())
|
||||
else -> Json.error("bad_args", "key-package ${tail[0]}")
|
||||
else -> Output.error("bad_args", "key-package ${tail[0]}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +44,11 @@ object KeyPackageCommands {
|
||||
try {
|
||||
ctx.prepare()
|
||||
val relays = ctx.keyPackageRelays().ifEmpty { ctx.outboxRelays() }.ifEmpty { ctx.anyRelays() }
|
||||
if (relays.isEmpty()) return Json.error("no_relays", "configure relays first")
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "configure relays first")
|
||||
|
||||
val event = ctx.marmot.generateKeyPackageEvent(relays.toList())
|
||||
val ack = ctx.publish(event, relays)
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to event.id,
|
||||
"kind" to event.kind,
|
||||
@@ -66,7 +66,7 @@ object KeyPackageCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "key-package check <npub>")
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "key-package check <npub>")
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
@@ -76,7 +76,7 @@ object KeyPackageCommands {
|
||||
// Look those up first from bootstrap seeds so `check` works even
|
||||
// when the target and inviter share no relays.
|
||||
val seed = ctx.bootstrapRelays()
|
||||
if (seed.isEmpty()) return Json.error("no_relays", "configure relays first")
|
||||
if (seed.isEmpty()) return Output.error("no_relays", "configure relays first")
|
||||
// Cache-first via Context.cachedRelayListsOf — replaceable
|
||||
// events live in the local store after the first sync.
|
||||
val recipient =
|
||||
@@ -96,9 +96,9 @@ object KeyPackageCommands {
|
||||
timeoutMs = 10_000,
|
||||
)
|
||||
if (event == null) {
|
||||
return Json.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)")
|
||||
return Output.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)")
|
||||
}
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to event.id,
|
||||
"author" to event.pubKey,
|
||||
|
||||
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Identity
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull
|
||||
@@ -50,10 +50,10 @@ object LoginCommand {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) {
|
||||
return Json.error("bad_args", "login <nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05> [--password X]")
|
||||
return Output.error("bad_args", "login <nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05> [--password X]")
|
||||
}
|
||||
if (dataDir.identityExists()) {
|
||||
return Json.error("exists", "identity already exists at ${dataDir.identityFile}; use a fresh --data-dir or delete it first")
|
||||
return Output.error("exists", "identity already exists at ${dataDir.identityFile}; use a fresh --data-dir or delete it first")
|
||||
}
|
||||
|
||||
val key = rest[0].trim()
|
||||
@@ -61,13 +61,13 @@ object LoginCommand {
|
||||
|
||||
val identity =
|
||||
resolveIdentity(key, args)
|
||||
?: return Json.error(
|
||||
?: return Output.error(
|
||||
"bad_key",
|
||||
"could not parse '$key' as any supported identifier",
|
||||
)
|
||||
|
||||
dataDir.saveIdentity(identity)
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"npub" to identity.npub,
|
||||
"hex" to identity.pubKeyHex,
|
||||
|
||||
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
|
||||
/**
|
||||
* `amy marmot reset [--yes]` — wipe all local Marmot state.
|
||||
@@ -56,7 +56,7 @@ object MarmotResetCommand {
|
||||
.sorted()
|
||||
|
||||
if (!confirmed) {
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"dry_run" to true,
|
||||
"would_wipe_groups" to groupIds,
|
||||
@@ -70,7 +70,7 @@ object MarmotResetCommand {
|
||||
ctx.state.giftWrapSince = null
|
||||
ctx.state.groupSince.clear()
|
||||
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"reset" to true,
|
||||
"wiped_groups" to groupIds,
|
||||
|
||||
@@ -24,7 +24,7 @@ import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
object MessageCommands {
|
||||
@@ -32,14 +32,14 @@ object MessageCommands {
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "message <send|list|react|delete> …")
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "message <send|list|react|delete> …")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"send" -> send(dataDir, rest)
|
||||
"list" -> list(dataDir, rest)
|
||||
"react" -> react(dataDir, rest)
|
||||
"delete" -> delete(dataDir, rest)
|
||||
else -> Json.error("bad_args", "message ${tail[0]}")
|
||||
else -> Output.error("bad_args", "message ${tail[0]}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,20 +47,20 @@ object MessageCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "message send <gid> <text>")
|
||||
if (rest.size < 2) return Output.error("bad_args", "message send <gid> <text>")
|
||||
val text = rest[1]
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rest[0])
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid)
|
||||
|
||||
val bundle = ctx.marmot.buildTextMessage(gid, text)
|
||||
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||
val ack = ctx.publish(bundle.outbound.signedEvent, targets)
|
||||
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"inner_event_id" to bundle.innerEvent.id,
|
||||
@@ -79,7 +79,7 @@ object MessageCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "message list <gid>")
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "message list <gid>")
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val limit = args.intFlag("limit", Int.MAX_VALUE)
|
||||
val ctx = Context.open(dataDir)
|
||||
@@ -87,7 +87,7 @@ object MessageCommands {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rest[0])
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid)
|
||||
|
||||
val raw = ctx.marmot.loadStoredMessages(gid)
|
||||
val items =
|
||||
@@ -95,7 +95,7 @@ object MessageCommands {
|
||||
.map { line ->
|
||||
try {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val obj = Json.mapper.readValue<Map<String, Any?>>(line)
|
||||
val obj = Output.mapper.readValue<Map<String, Any?>>(line)
|
||||
mapOf(
|
||||
"id" to obj["id"],
|
||||
"author" to obj["pubkey"],
|
||||
@@ -108,7 +108,7 @@ object MessageCommands {
|
||||
}
|
||||
}.takeLast(limit)
|
||||
|
||||
Json.writeLine(mapOf("group_id" to gid, "messages" to items))
|
||||
Output.emit(mapOf("group_id" to gid, "messages" to items))
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
@@ -119,7 +119,7 @@ object MessageCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 3) return Json.error("bad_args", "message react <gid> <target_event_id> <emoji>")
|
||||
if (rest.size < 3) return Output.error("bad_args", "message react <gid> <target_event_id> <emoji>")
|
||||
val targetId = rest[1]
|
||||
val emoji = rest[2]
|
||||
val ctx = Context.open(dataDir)
|
||||
@@ -127,14 +127,14 @@ object MessageCommands {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rest[0])
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid)
|
||||
|
||||
val target = findStoredInnerEvent(ctx, gid, targetId) ?: return Json.error("not_found", targetId)
|
||||
val target = findStoredInnerEvent(ctx, gid, targetId) ?: return Output.error("not_found", targetId)
|
||||
val bundle = ctx.marmot.buildReactionMessage(gid, target, emoji)
|
||||
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||
val ack = ctx.publish(bundle.outbound.signedEvent, targets)
|
||||
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"inner_event_id" to bundle.innerEvent.id,
|
||||
@@ -155,25 +155,25 @@ object MessageCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "message delete <gid> <target_event_id> [target_event_id ...]")
|
||||
if (rest.size < 2) return Output.error("bad_args", "message delete <gid> <target_event_id> [target_event_id ...]")
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rest[0])
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid)
|
||||
|
||||
val targetIds = rest.drop(1)
|
||||
val targets =
|
||||
targetIds.map { id ->
|
||||
findStoredInnerEvent(ctx, gid, id) ?: return Json.error("not_found", id)
|
||||
findStoredInnerEvent(ctx, gid, id) ?: return Output.error("not_found", id)
|
||||
}
|
||||
|
||||
val bundle = ctx.marmot.buildDeletionMessage(gid, targets)
|
||||
val relays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||
val ack = ctx.publish(bundle.outbound.signedEvent, relays)
|
||||
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"inner_event_id" to bundle.innerEvent.id,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
|
||||
/**
|
||||
* `amy notes <post|feed>` — NIP-10 kind:1 short text notes. Sits alongside
|
||||
@@ -33,12 +33,12 @@ object NotesCommands {
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "notes <post|feed> …")
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "notes <post|feed> …")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"post" -> PostCommand.run(dataDir, rest)
|
||||
"feed" -> FeedCommand.run(dataDir, rest)
|
||||
else -> Json.error("bad_args", "notes ${tail[0]}")
|
||||
else -> Output.error("bad_args", "notes ${tail[0]}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
|
||||
/**
|
||||
@@ -39,9 +39,9 @@ object PostCommand {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "post <text> [--relay URL …]")
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "post <text> [--relay URL …]")
|
||||
val text = rest[0]
|
||||
if (text.isBlank()) return Json.error("bad_args", "post text must not be blank")
|
||||
if (text.isBlank()) return Output.error("bad_args", "post text must not be blank")
|
||||
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val extraRelays =
|
||||
@@ -61,13 +61,13 @@ object PostCommand {
|
||||
}
|
||||
val targets = (outbox + extraNormalized).toSet()
|
||||
if (targets.isEmpty()) {
|
||||
return Json.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`")
|
||||
return Output.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`")
|
||||
}
|
||||
|
||||
val signed = ctx.signer.sign(TextNoteEvent.build(text))
|
||||
val ack = ctx.publish(signed, targets)
|
||||
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"kind" to signed.kind,
|
||||
|
||||
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
@@ -44,12 +44,12 @@ object ProfileCommands {
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "profile <show|edit> …")
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "profile <show|edit> …")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"show" -> show(dataDir, rest)
|
||||
"edit" -> edit(dataDir, rest)
|
||||
else -> Json.error("bad_args", "profile ${tail[0]}")
|
||||
else -> Output.error("bad_args", "profile ${tail[0]}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ object ProfileCommands {
|
||||
}
|
||||
|
||||
if (event == null) {
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"pubkey" to pubKey,
|
||||
"found" to false,
|
||||
@@ -100,11 +100,11 @@ object ProfileCommands {
|
||||
}
|
||||
val metadata =
|
||||
try {
|
||||
Json.mapper.readTree(event.content)
|
||||
Output.mapper.readTree(event.content)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"pubkey" to pubKey,
|
||||
"found" to true,
|
||||
@@ -145,7 +145,7 @@ object ProfileCommands {
|
||||
listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github)
|
||||
.any { it != null }
|
||||
if (!touched) {
|
||||
return Json.error(
|
||||
return Output.error(
|
||||
"bad_args",
|
||||
"profile edit needs at least one of " +
|
||||
"--name --display-name --about --picture --banner --website " +
|
||||
@@ -204,13 +204,13 @@ object ProfileCommands {
|
||||
val signed = ctx.signer.sign(template)
|
||||
val ack = ctx.publish(signed, targets)
|
||||
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"kind" to signed.kind,
|
||||
"created_at" to signed.createdAt,
|
||||
"based_on" to latest?.id,
|
||||
"metadata" to Json.mapper.readTree(signed.content),
|
||||
"metadata" to Output.mapper.readTree(signed.content),
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
|
||||
),
|
||||
|
||||
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
|
||||
@@ -52,14 +52,14 @@ object RelayCommands {
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "relay <add|list|publish-lists> …")
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "relay <add|list|publish-lists> …")
|
||||
val sub = tail[0]
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (sub) {
|
||||
"add" -> add(dataDir, Args(rest))
|
||||
"list" -> list(dataDir)
|
||||
"publish-lists" -> publishLists(dataDir)
|
||||
else -> Json.error("bad_args", "relay $sub")
|
||||
else -> Output.error("bad_args", "relay $sub")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ object RelayCommands {
|
||||
val type = args.flag("type", "all") ?: "all"
|
||||
val normalized =
|
||||
rawUrl.normalizeRelayUrlOrNull()
|
||||
?: return Json.error("bad_args", "invalid relay url: $rawUrl")
|
||||
?: return Output.error("bad_args", "invalid relay url: $rawUrl")
|
||||
|
||||
val targets = if (type == "all") listOf("nip65", "inbox", "key_package") else listOf(type)
|
||||
val ctx = Context.open(dataDir)
|
||||
@@ -81,7 +81,7 @@ object RelayCommands {
|
||||
for (t in targets) {
|
||||
if (addToBucket(ctx, t, normalized)) addedTo.add(t) else alreadyPresent.add(t)
|
||||
}
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"url" to rawUrl,
|
||||
"added_to" to addedTo,
|
||||
@@ -141,7 +141,7 @@ object RelayCommands {
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
val self = ctx.identity.pubKeyHex
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"nip65" to (ctx.relaysOf(self)?.relaysNorm()?.map { it.url } ?: emptyList()),
|
||||
"inbox" to (ctx.dmInboxOf(self)?.relays()?.map { it.url } ?: emptyList()),
|
||||
@@ -164,7 +164,7 @@ object RelayCommands {
|
||||
val keyPackageEvent = ctx.keyPackageRelaysOf(self)
|
||||
|
||||
if (nip65Event == null && inboxEvent == null && keyPackageEvent == null) {
|
||||
return Json.error(
|
||||
return Output.error(
|
||||
"no_relays",
|
||||
"no relay lists in the local store; run `amy relay add` first or `amy create` to bootstrap defaults",
|
||||
)
|
||||
@@ -175,7 +175,7 @@ object RelayCommands {
|
||||
val inboxResult = inboxEvent?.let { ctx.publish(it, targets) }.orEmpty()
|
||||
val keyPackageResult = keyPackageEvent?.let { ctx.publish(it, targets) }.orEmpty()
|
||||
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"nip65_event_id" to nip65Event?.id,
|
||||
"inbox_event_id" to inboxEvent?.id,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore
|
||||
import java.io.IOException
|
||||
@@ -51,21 +51,21 @@ object StoreCommands {
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "store <stat|sweep-expired|scrub|compact>")
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "store <stat|sweep-expired|scrub|compact>")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"stat" -> stat(dataDir)
|
||||
"sweep-expired" -> sweepExpired(dataDir)
|
||||
"scrub" -> scrub(dataDir)
|
||||
"compact" -> compact(dataDir)
|
||||
else -> Json.error("bad_args", "store ${tail[0]}")
|
||||
else -> Output.error("bad_args", "store ${tail[0]}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stat(dataDir: DataDir): Int {
|
||||
val storeRoot = dataDir.eventsDir.toPath()
|
||||
if (!storeRoot.exists()) {
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"events" to 0,
|
||||
"by_kind" to emptyMap<String, Long>(),
|
||||
@@ -119,7 +119,7 @@ object StoreCommands {
|
||||
|
||||
val diskBytes = walkSize(storeRoot)
|
||||
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"events" to count,
|
||||
"by_kind" to byKind,
|
||||
@@ -138,7 +138,7 @@ object StoreCommands {
|
||||
val before = countEntries(expiresAtDir)
|
||||
store.deleteExpiredEvents()
|
||||
val after = countEntries(expiresAtDir)
|
||||
Json.writeLine(
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"swept" to (before - after).coerceAtLeast(0L),
|
||||
"remaining" to after,
|
||||
@@ -150,14 +150,14 @@ object StoreCommands {
|
||||
private fun scrub(dataDir: DataDir): Int =
|
||||
withStore(dataDir) { store ->
|
||||
store.scrub()
|
||||
Json.writeLine(mapOf("ok" to true))
|
||||
Output.emit(mapOf("ok" to true))
|
||||
0
|
||||
}
|
||||
|
||||
private fun compact(dataDir: DataDir): Int =
|
||||
withStore(dataDir) { store ->
|
||||
store.compact()
|
||||
Json.writeLine(mapOf("ok" to true))
|
||||
Output.emit(mapOf("ok" to true))
|
||||
0
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.cli.SecureFileIO
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* `amy use <name>` — pin the active account in `~/.amy/current`.
|
||||
*
|
||||
* Sits outside the `DataDir`-needing dispatch path: its whole purpose
|
||||
* is to disambiguate the auto-pick when more than one account exists,
|
||||
* so it must work even when `DataDir.resolve` would otherwise fail.
|
||||
*
|
||||
* `amy use` with no argument prints the current pin (or `(none)` when
|
||||
* unset). `amy use --clear` removes the marker entirely.
|
||||
*/
|
||||
object UseCommand {
|
||||
fun run(tail: Array<String>): Int {
|
||||
val rootBase = DataDir.DEFAULT_ROOT
|
||||
val markerFile = File(rootBase, DataDir.CURRENT_MARKER_NAME)
|
||||
|
||||
if (tail.isEmpty()) {
|
||||
val pinned = if (markerFile.isFile) markerFile.readText().trim().ifEmpty { null } else null
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"current" to pinned,
|
||||
"available" to DataDir.listAccounts(rootBase),
|
||||
"root" to rootBase.absolutePath,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
if (tail[0] == "--clear") {
|
||||
val existed = markerFile.isFile && markerFile.delete()
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"current" to null,
|
||||
"cleared" to existed,
|
||||
"root" to rootBase.absolutePath,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
val name =
|
||||
try {
|
||||
DataDir.validateName(tail[0])
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return Output.error("bad_args", e.message)
|
||||
}
|
||||
val accountDir = File(rootBase, name)
|
||||
if (!accountDir.isDirectory) {
|
||||
return Output.error(
|
||||
"no_account",
|
||||
"${accountDir.absolutePath} doesn't exist; create it with `amy --account $name init`",
|
||||
)
|
||||
}
|
||||
SecureFileIO.secureMkdirs(rootBase)
|
||||
SecureFileIO.writeTextAtomic(markerFile, name)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"current" to name,
|
||||
"root" to rootBase.absolutePath,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
Vendored
+48
-45
@@ -28,8 +28,10 @@ REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
|
||||
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
STATE_DIR="$SCRIPT_DIR/state-cache-headless"
|
||||
LOG_DIR="$STATE_DIR/logs"
|
||||
A_DIR="$STATE_DIR/A"
|
||||
B_DIR="$STATE_DIR/B"
|
||||
# Per-account dirs under the same fake $HOME=$STATE_DIR — accounts A
|
||||
# and B share $STATE_DIR/.amy/shared/events-store/ (production layout).
|
||||
A_DIR="$STATE_DIR/.amy/A"
|
||||
B_DIR="$STATE_DIR/.amy/B"
|
||||
|
||||
RUN_TS="$(date +%Y%m%d-%H%M%S)"
|
||||
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
|
||||
@@ -64,7 +66,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
done
|
||||
|
||||
mkdir -p "$STATE_DIR" "$LOG_DIR" "$A_DIR" "$B_DIR"
|
||||
mkdir -p "$STATE_DIR" "$LOG_DIR"
|
||||
: >"$LOG_FILE"
|
||||
: >"$RESULTS_FILE"
|
||||
|
||||
@@ -81,7 +83,7 @@ source "$TESTS_DIR/headless/helpers.sh"
|
||||
# shellcheck source=../dm/setup.sh
|
||||
source "$TESTS_DIR/dm/setup.sh"
|
||||
|
||||
amy_b() { "$AMY_BIN" --data-dir "$B_DIR" "$@"; }
|
||||
amy_b() { HOME="$STATE_DIR" "$AMY_BIN" --account B --secret-backend plaintext --json "$@"; }
|
||||
|
||||
# Helper: B-side amy_json. The dm headless's helpers.sh hardcodes A_DIR
|
||||
# in amy_a; we need a parallel for B without re-sourcing.
|
||||
@@ -111,23 +113,24 @@ banner "Amethyst event-store cache headless ($RUN_TS)"
|
||||
preflight_dm
|
||||
start_local_relay
|
||||
|
||||
# Identity A: full bootstrap so the store has kind:0 / 3 / 10002 / …
|
||||
ensure_identity_for A "$A_DIR"
|
||||
# Identity A: full bootstrap so the shared store has kind:0 / 3 / 10002 / …
|
||||
ensure_identity_for A
|
||||
banner "Bootstrapping A's account (publishes kind:0 + bootstrap events)"
|
||||
"$AMY_BIN" --data-dir "$A_DIR" relay add "$RELAY_URL" --type all >>"$LOG_FILE" 2>&1
|
||||
# `amy create` here would mint a *second* identity; A_DIR already has one
|
||||
# from `init`. Build the bootstrap events ourselves by publishing a
|
||||
# minimal kind:0 + the relay lists, all of which land in A's local store
|
||||
# via verifyAndStore.
|
||||
"$AMY_BIN" --data-dir "$A_DIR" relay publish-lists >>"$LOG_FILE" 2>&1
|
||||
amy_a relay add "$RELAY_URL" --type all >>"$LOG_FILE" 2>&1
|
||||
# `amy create` here would mint a *second* identity; A already exists from
|
||||
# `init`. Build the bootstrap events ourselves by publishing a minimal
|
||||
# kind:0 + the relay lists, all of which land in the shared store via
|
||||
# verifyAndStore.
|
||||
amy_a relay publish-lists >>"$LOG_FILE" 2>&1
|
||||
amy_a profile edit --name "AAA" --about "cache test subject" \
|
||||
>>"$LOG_FILE" 2>&1 \
|
||||
|| fail_msg "amy_a profile edit failed"
|
||||
|
||||
# Identity B: separate data-dir, separate cache, also pointed at the relay.
|
||||
ensure_identity_for B "$B_DIR"
|
||||
"$AMY_BIN" --data-dir "$B_DIR" relay add "$RELAY_URL" --type all >>"$LOG_FILE" 2>&1
|
||||
"$AMY_BIN" --data-dir "$B_DIR" relay publish-lists >>"$LOG_FILE" 2>&1
|
||||
# Identity B: same fake $HOME, separate per-account dir, but A and B
|
||||
# both read/write to $STATE_DIR/.amy/shared/events-store/.
|
||||
ensure_identity_for B
|
||||
amy_b relay add "$RELAY_URL" --type all >>"$LOG_FILE" 2>&1
|
||||
amy_b relay publish-lists >>"$LOG_FILE" 2>&1
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 1. amy store stat reports a non-empty store after bootstrap.
|
||||
@@ -191,24 +194,20 @@ else
|
||||
fi
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 4. B has not seen A → first profile show is a relay miss; second is a hit.
|
||||
# 4. Shared events-store: B looks up A's profile and gets a cache hit
|
||||
# on the first try, because A's kind:0 already landed in the shared
|
||||
# store during A's bootstrap. (Pre-shared-store behaviour was "first
|
||||
# lookup is a relay miss, second is a cache hit"; that test only
|
||||
# made sense when each account had its own private cache.)
|
||||
# ----------------------------------------------------------------------
|
||||
banner "T4 — B sees A first via relay, then via cache"
|
||||
T4A=$(amy_b_json profile show "$A_NPUB")
|
||||
T4A_SRC=$(printf '%s' "$T4A" | jq -r '.source')
|
||||
T4A_NAME=$(printf '%s' "$T4A" | jq -r '.metadata.name // ""')
|
||||
assert_eq "$T4A_SRC" "relays" T4a.source "first lookup of a stranger comes from relays" \
|
||||
&& record_result T4a.source pass "first profile lookup of stranger hit relays"
|
||||
assert_eq "$T4A_NAME" "AAA" T4a.name "B should resolve A's name on first fetch" \
|
||||
&& record_result T4a.name pass "B resolved A's metadata"
|
||||
|
||||
T4B=$(amy_b_json profile show "$A_NPUB")
|
||||
T4B_SRC=$(printf '%s' "$T4B" | jq -r '.source')
|
||||
T4B_NAME=$(printf '%s' "$T4B" | jq -r '.metadata.name // ""')
|
||||
assert_eq "$T4B_SRC" "cache" T4b.source "second lookup of the same stranger must serve from cache" \
|
||||
&& record_result T4b.source pass "second lookup served from B's cache"
|
||||
assert_eq "$T4B_NAME" "AAA" T4b.name "cached metadata must match" \
|
||||
&& record_result T4b.name pass "cached metadata identical to fresh"
|
||||
banner "T4 — B's profile lookup of A is a shared-store cache hit"
|
||||
T4=$(amy_b_json profile show "$A_NPUB")
|
||||
T4_SRC=$(printf '%s' "$T4" | jq -r '.source')
|
||||
T4_NAME=$(printf '%s' "$T4" | jq -r '.metadata.name // ""')
|
||||
assert_eq "$T4_SRC" "cache" T4.source "shared store should hand B a cached A profile immediately" \
|
||||
&& record_result T4.source pass "B saw A from the shared cache"
|
||||
assert_eq "$T4_NAME" "AAA" T4.name "A's profile metadata must be readable from B's invocation" \
|
||||
&& record_result T4.name pass "shared-cache metadata round-trips across accounts"
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 5. relay list reads URLs back from the local kind:10002 / 10050 / 10051.
|
||||
@@ -245,20 +244,24 @@ else
|
||||
fi
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 7. Maintenance verbs work without an identity (they only need the store).
|
||||
# 7. Maintenance verbs work even when the selected account has no
|
||||
# identity yet — they only construct the FsEventStore, never
|
||||
# `Context.open`. We use a fresh fake $HOME so the empty store has
|
||||
# no inherited events.
|
||||
# ----------------------------------------------------------------------
|
||||
banner "T7 — store maintenance verbs (sweep/scrub/compact) without identity"
|
||||
TMP_NO_ID=$(mktemp -d)
|
||||
T7_STAT=$(${AMY_BIN} --data-dir "$TMP_NO_ID" store stat)
|
||||
T7_SWEEP=$(${AMY_BIN} --data-dir "$TMP_NO_ID" store sweep-expired)
|
||||
T7_SCRUB=$(${AMY_BIN} --data-dir "$TMP_NO_ID" store scrub)
|
||||
T7_COMPACT=$(${AMY_BIN} --data-dir "$TMP_NO_ID" store compact)
|
||||
banner "T7 — store maintenance verbs (sweep/scrub/compact) on an empty store"
|
||||
TMP_HOME=$(mktemp -d)
|
||||
T7_AMY=(${AMY_BIN} --secret-backend plaintext --account throwaway --json store)
|
||||
T7_STAT=$(HOME="$TMP_HOME" "${T7_AMY[@]}" stat)
|
||||
T7_SWEEP=$(HOME="$TMP_HOME" "${T7_AMY[@]}" sweep-expired)
|
||||
T7_SCRUB=$(HOME="$TMP_HOME" "${T7_AMY[@]}" scrub)
|
||||
T7_COMPACT=$(HOME="$TMP_HOME" "${T7_AMY[@]}" compact)
|
||||
assert_eq "$(printf '%s' "$T7_STAT" | jq -r '.events')" "0" T7.stat.events "" \
|
||||
&& record_result T7.stat pass "stat works without identity"
|
||||
&& record_result T7.stat pass "stat works on empty store"
|
||||
assert_eq "$(printf '%s' "$T7_SWEEP" | jq -r '.swept')" "0" T7.sweep.swept "" \
|
||||
&& record_result T7.sweep pass "sweep-expired works without identity"
|
||||
&& record_result T7.sweep pass "sweep-expired works on empty store"
|
||||
assert_eq "$(printf '%s' "$T7_SCRUB" | jq -r '.ok')" "true" T7.scrub.ok "" \
|
||||
&& record_result T7.scrub pass "scrub works without identity"
|
||||
&& record_result T7.scrub pass "scrub works on empty store"
|
||||
assert_eq "$(printf '%s' "$T7_COMPACT" | jq -r '.ok')" "true" T7.compact.ok "" \
|
||||
&& record_result T7.compact pass "compact works without identity"
|
||||
rm -rf "$TMP_NO_ID"
|
||||
&& record_result T7.compact pass "compact works on empty store"
|
||||
rm -rf "$TMP_HOME"
|
||||
|
||||
@@ -15,8 +15,10 @@ REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
|
||||
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
STATE_DIR="$SCRIPT_DIR/state-dm-headless"
|
||||
LOG_DIR="$STATE_DIR/logs"
|
||||
A_DIR="$STATE_DIR/A"
|
||||
D_DIR="$STATE_DIR/D"
|
||||
# Per-account dirs under the same fake $HOME=$STATE_DIR so amy treats
|
||||
# this as one user with two accounts (production layout).
|
||||
A_DIR="$STATE_DIR/.amy/A"
|
||||
D_DIR="$STATE_DIR/.amy/D"
|
||||
|
||||
RUN_TS="$(date +%Y%m%d-%H%M%S)"
|
||||
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
|
||||
@@ -57,7 +59,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
done
|
||||
|
||||
mkdir -p "$STATE_DIR" "$LOG_DIR" "$A_DIR" "$D_DIR"
|
||||
mkdir -p "$STATE_DIR" "$LOG_DIR"
|
||||
: >"$LOG_FILE"
|
||||
: >"$RESULTS_FILE"
|
||||
|
||||
@@ -94,8 +96,8 @@ trap 'exit 129' HUP
|
||||
banner "Amethyst NIP-17 DM headless interop ($RUN_TS)"
|
||||
preflight_dm
|
||||
start_local_relay
|
||||
ensure_identity_for A "$A_DIR"
|
||||
ensure_identity_for D "$D_DIR"
|
||||
ensure_identity_for A
|
||||
ensure_identity_for D
|
||||
configure_relays_dm
|
||||
|
||||
test_01_dm_text_round_trip
|
||||
|
||||
+12
-9
@@ -69,21 +69,24 @@ preflight_dm() {
|
||||
}
|
||||
|
||||
# --- amy identity wrappers ---------------------------------------------------
|
||||
# Two identities: A (sender) and D (recipient). We reuse A_DIR for parity
|
||||
# with the existing harness files; D_DIR is new.
|
||||
# Two identities: A (sender) and D (recipient), both inside the same
|
||||
# $STATE_DIR/.amy tree. They share $STATE_DIR/.amy/shared/events-store/
|
||||
# — the same code path real users hit with multiple accounts. The
|
||||
# enclosing harness sets STATE_DIR to a fresh tempdir per run, so amy
|
||||
# sees a virgin home each time.
|
||||
#
|
||||
# `--secret-backend=plaintext` keeps these throwaway interop runs headless —
|
||||
# the default `auto` would try the OS keychain (not available in CI) and then
|
||||
# ask for a NIP-49 passphrase. Plaintext still writes 0600-owner-only.
|
||||
amy_a() { "$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext "$@"; }
|
||||
amy_d() { "$AMY_BIN" --data-dir "$D_DIR" --secret-backend plaintext "$@"; }
|
||||
amy_a() { HOME="$STATE_DIR" "$AMY_BIN" --account A --secret-backend plaintext --json "$@"; }
|
||||
amy_d() { HOME="$STATE_DIR" "$AMY_BIN" --account D --secret-backend plaintext --json "$@"; }
|
||||
|
||||
# --- identity bootstrap ------------------------------------------------------
|
||||
ensure_identity_for() {
|
||||
local who="$1" dir="$2"
|
||||
step "initialising Identity $who (amy at $dir)"
|
||||
local who="$1"
|
||||
step "initialising Identity $who (amy at \$HOME=$STATE_DIR --account $who)"
|
||||
local out
|
||||
out=$("$AMY_BIN" --data-dir "$dir" --secret-backend plaintext init) || {
|
||||
out=$(HOME="$STATE_DIR" "$AMY_BIN" --account "$who" --secret-backend plaintext --json init) || {
|
||||
fail_msg "amy init failed for $who: $out"; exit 1
|
||||
}
|
||||
local npub hex
|
||||
@@ -103,8 +106,8 @@ ensure_identity_for() {
|
||||
# so the DM strict-relay routing has something to resolve to.
|
||||
configure_relays_dm() {
|
||||
banner "Configuring relays → $RELAY_URL"
|
||||
"$AMY_BIN" --data-dir "$A_DIR" relay add "$RELAY_URL" --type all >/dev/null
|
||||
"$AMY_BIN" --data-dir "$D_DIR" relay add "$RELAY_URL" --type all >/dev/null
|
||||
amy_a relay add "$RELAY_URL" --type all >/dev/null
|
||||
amy_d relay add "$RELAY_URL" --type all >/dev/null
|
||||
|
||||
step "publishing A's NIP-65 + kind:10050 lists"
|
||||
amy_a relay publish-lists >>"$LOG_FILE" 2>&1 \
|
||||
|
||||
+20
-15
@@ -12,20 +12,23 @@
|
||||
# dm-05 file message reference mode round-trip (kind:15)
|
||||
# dm-06 cursor advance (subsequent no-flag `dm list` is empty)
|
||||
|
||||
# Wrap amy_json around either data-dir so the per-test code stays tight.
|
||||
# Wrap amy_json around either account so the per-test code stays tight.
|
||||
# Both share $HOME=$STATE_DIR (set by the harness); --account picks the
|
||||
# account inside it. `--json` opts into amy's machine-readable contract;
|
||||
# assertions below parse with jq.
|
||||
amy_json_for() {
|
||||
local dir="$1"; shift
|
||||
local account="$1"; shift
|
||||
local out
|
||||
if ! out=$("$AMY_BIN" --data-dir "$dir" "$@" 2>>"$LOG_FILE"); then
|
||||
fail_msg "amy --data-dir $dir $*: exit $? (see $LOG_FILE)"
|
||||
if ! out=$(HOME="$STATE_DIR" "$AMY_BIN" --account "$account" --secret-backend plaintext --json "$@" 2>>"$LOG_FILE"); then
|
||||
fail_msg "amy --account $account $*: exit $? (see $LOG_FILE)"
|
||||
printf '%s\n' "$out" >>"$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
amy_json_a() { amy_json_for "$A_DIR" "$@"; }
|
||||
amy_json_d() { amy_json_for "$D_DIR" "$@"; }
|
||||
amy_json_a() { amy_json_for A "$@"; }
|
||||
amy_json_d() { amy_json_for D "$@"; }
|
||||
|
||||
test_01_dm_text_round_trip() {
|
||||
banner "DM-01 — text round-trip A↔D (kind:14)"
|
||||
@@ -89,19 +92,21 @@ test_03_dm_send_rejects_no_inbox() {
|
||||
local id="dm-03 strict no_dm_relays"
|
||||
|
||||
# Generate a throwaway identity but do NOT publish its kind:10050.
|
||||
local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX")
|
||||
# The ghost lives in its own fake $HOME so it doesn't pollute the
|
||||
# test's main STATE_DIR with a third account.
|
||||
local ghost_home; ghost_home=$(mktemp -d "${STATE_DIR}/ghost-home.XXXXXX")
|
||||
local ghost_out ghost_npub
|
||||
ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" --secret-backend plaintext init) || {
|
||||
record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return
|
||||
ghost_out=$(HOME="$ghost_home" "$AMY_BIN" --account ghost --secret-backend plaintext --json init) || {
|
||||
record_result "$id" fail "ghost init failed"; rm -rf "$ghost_home"; return
|
||||
}
|
||||
ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub')
|
||||
info "ghost: $ghost_npub (no relays advertised)"
|
||||
|
||||
# A sends without --allow-fallback; amy should refuse.
|
||||
local raw rc
|
||||
raw=$("$AMY_BIN" --data-dir "$A_DIR" dm send "$ghost_npub" "should be rejected" 2>&1)
|
||||
raw=$(HOME="$STATE_DIR" "$AMY_BIN" --account A --secret-backend plaintext --json dm send "$ghost_npub" "should be rejected" 2>&1)
|
||||
rc=$?
|
||||
rm -rf "$tmpdir"
|
||||
rm -rf "$ghost_home"
|
||||
if [[ "$rc" -ne 0 ]] && printf '%s' "$raw" | grep -q '"error":"no_dm_relays"'; then
|
||||
info "amy refused with no_dm_relays as expected (exit $rc)"
|
||||
record_result "$id" pass
|
||||
@@ -119,13 +124,13 @@ test_04_dm_send_allow_fallback() {
|
||||
# fall through kind:10050 → NIP-65 read → bootstrap. Our bootstrap set
|
||||
# always includes the loopback relay via the shared RelayConfig, so the
|
||||
# publish should succeed even though the ghost has no 10050.
|
||||
local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX")
|
||||
local ghost_home; ghost_home=$(mktemp -d "${STATE_DIR}/ghost-home.XXXXXX")
|
||||
local ghost_out ghost_npub
|
||||
ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" --secret-backend plaintext init) || {
|
||||
record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return
|
||||
ghost_out=$(HOME="$ghost_home" "$AMY_BIN" --account ghost --secret-backend plaintext --json init) || {
|
||||
record_result "$id" fail "ghost init failed"; rm -rf "$ghost_home"; return
|
||||
}
|
||||
ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub')
|
||||
rm -rf "$tmpdir"
|
||||
rm -rf "$ghost_home"
|
||||
|
||||
local out source
|
||||
out=$(amy_json_a dm send "$ghost_npub" "hi via fallback" --allow-fallback) || {
|
||||
|
||||
@@ -3,10 +3,18 @@
|
||||
# helpers.sh — thin wrappers that keep the per-test code tight.
|
||||
|
||||
# --- amy wrapper -------------------------------------------------------------
|
||||
# `--secret-backend=plaintext` keeps these throwaway interop runs headless —
|
||||
# the default `auto` would try the OS keychain (not available in CI) and then
|
||||
# ask for a NIP-49 passphrase. Plaintext still writes 0600-owner-only.
|
||||
amy_a() { "$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext "$@"; }
|
||||
# Tests isolate by overriding $HOME for the amy subprocess; amy reads the
|
||||
# env var directly (not Java's `user.home`, which JDK 21 pulls from
|
||||
# /etc/passwd) and treats $HOME/.amy/ as its tree. No --data-dir flag,
|
||||
# no test-only escape hatch — production code path, fresh every run.
|
||||
#
|
||||
# `--secret-backend=plaintext` keeps these throwaway runs headless —
|
||||
# the default `auto` would try the OS keychain (not available in CI) and
|
||||
# then ask for a NIP-49 passphrase. Plaintext still writes 0600-owner-only.
|
||||
#
|
||||
# `--json` opts into amy's machine-readable output (the harness parses it
|
||||
# with jq); the default human-text output is for terminal use.
|
||||
amy_a() { HOME="$STATE_DIR" "$AMY_BIN" --account A --secret-backend plaintext --json "$@"; }
|
||||
|
||||
# Run amy, log stderr, surface JSON on stdout, remember last result.
|
||||
amy_json() {
|
||||
|
||||
@@ -18,7 +18,10 @@ REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
|
||||
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
STATE_DIR="$SCRIPT_DIR/state-headless"
|
||||
LOG_DIR="$STATE_DIR/logs"
|
||||
A_DIR="$STATE_DIR/A"
|
||||
# A is the amy account inside the fake $HOME=$STATE_DIR layout. B and
|
||||
# C are wnd (whitenoise) state dirs — different binary, separate
|
||||
# convention, so they stay as plain $STATE_DIR siblings.
|
||||
A_DIR="$STATE_DIR/.amy/A"
|
||||
B_DIR="$STATE_DIR/B"
|
||||
C_DIR="$STATE_DIR/C"
|
||||
B_SOCKET="$B_DIR/release/wnd.sock"
|
||||
@@ -36,11 +39,19 @@ AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
|
||||
# Local relay wiring — cloned + built during preflight, started on
|
||||
# $RELAY_PORT. The harness never touches the public internet for test
|
||||
# traffic; wn/wnd/amy all point at this one loopback endpoint.
|
||||
#
|
||||
# Bind to 127.0.0.2 rather than 127.0.0.1: Quartz's RelayUrlNormalizer
|
||||
# strips literal 127.0.0.1 / localhost / 192.168.* out of NIP-17 inbox
|
||||
# (kind:10050) and KeyPackage (kind:10051) relay-list events as a
|
||||
# privacy guard, which would silently leave the harness publishing to
|
||||
# Amethyst's public defaults instead of the loopback. 127.0.0.2 is
|
||||
# still pure loopback (no network traffic) but isn't on the strip list.
|
||||
RELAY_HOST="${RELAY_HOST:-127.0.0.2}"
|
||||
RELAY_REPO="${RELAY_REPO:-$STATE_DIR/nostr-rs-relay}"
|
||||
RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
|
||||
RELAY_DATA="$STATE_DIR/relay"
|
||||
RELAY_PORT="${RELAY_PORT:-8080}"
|
||||
RELAY_URL="ws://127.0.0.1:$RELAY_PORT"
|
||||
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
|
||||
NO_BUILD=0
|
||||
|
||||
A_NPUB=""
|
||||
@@ -52,7 +63,8 @@ C_HEX=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port) RELAY_PORT="$2"; RELAY_URL="ws://127.0.0.1:$RELAY_PORT"; shift ;;
|
||||
--port) RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;;
|
||||
--host) RELAY_HOST="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;;
|
||||
--no-build) NO_BUILD=1 ;;
|
||||
-h|--help)
|
||||
sed -n '3,14p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'
|
||||
@@ -62,7 +74,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
done
|
||||
|
||||
mkdir -p "$STATE_DIR" "$LOG_DIR" "$A_DIR" "$B_DIR/logs" "$C_DIR/logs"
|
||||
mkdir -p "$STATE_DIR" "$LOG_DIR" "$B_DIR/logs" "$C_DIR/logs"
|
||||
: >"$LOG_FILE"
|
||||
: >"$RESULTS_FILE"
|
||||
|
||||
|
||||
@@ -271,7 +271,7 @@ test_14_wn_removes_a() {
|
||||
local deadline=$(( $(date +%s) + 120 )) removed=0
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
local show rc
|
||||
show=$("$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext \
|
||||
show=$(HOME="$STATE_DIR" "$AMY_BIN" --account A --secret-backend plaintext --json \
|
||||
marmot group show "$a_gid" 2>&1)
|
||||
rc=$?
|
||||
printf '%s\n' "$show" >>"$LOG_FILE"
|
||||
|
||||
Reference in New Issue
Block a user