feat(cli): default amy stdout to human-readable text; --json opts in

amy's default stdout is now a YAML-ish render of the underlying result
map; the previous single-line JSON contract moves behind a global
`--json` flag. Errors mirror the same rule (`error: <code>: <detail>`
on stderr by default, JSON `{"error":...,"detail":...}` under --json).
Exit codes (0/1/2/124) and the --json shape itself are unchanged —
only the default presentation flips.

- Replaces Json.writeLine / Json.error with mode-aware
  Output.emit / Output.error. The same Jackson mapper is reused for
  on-disk JSON via Output.mapper.
- Adds `--json` to the global flag set in Main.kt; honoured even when
  argument parsing fails so error JSON keeps shape under --json.
- Updates the test harness wrappers (amy_a / amy_b / amy_d in
  cli/tests/{headless,dm,cache}/) and the few direct $AMY_BIN call
  sites whose stdout is consumed via $() / 2>&1 — they now pass
  --json so the existing jq pipelines keep working.
- Rewrites the README "Output contract" and DEVELOPMENT design
  principles to describe the new default, and clarifies that only
  --json is the public API; the text shape is allowed to drift.
This commit is contained in:
Claude
2026-04-25 12:23:37 +00:00
parent fb478d6019
commit 03eb7be509
31 changed files with 442 additions and 242 deletions
+25 -17
View File
@@ -16,13 +16,13 @@ or encryption in here, stop — that code belongs in `quartz/` or
## 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 +30,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,7 +45,7 @@ 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
├── Output.kt # text/json mode emitter (--json flag)
├── Config.kt # Identity, RunState, DataDir
├── Context.kt # per-run wiring: signer + NostrClient +
│ # MarmotManager + publish/drain/sync helpers
@@ -77,7 +80,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 +139,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 +159,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 },
@@ -173,6 +176,11 @@ command table and [ROADMAP.md](./ROADMAP.md)'s parity matrix 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 +188,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.
---
@@ -195,7 +203,7 @@ Amy-specific layer still needs its own coverage:
|---|---|
| Argument parsing (`Args`, flag forms, `--data-dir=…` vs `--data-dir …`) | 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 data-dir, 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/`. |
+15 -9
View File
@@ -27,13 +27,18 @@ Amy exists for three audiences at once:
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.
- **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:
`amy --json whoami`, `amy --data-dir ./alice --json marmot group list`.
- **stderr is for humans.** Progress, warnings, per-relay ACK traces.
Safe to discard.
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 (JSON `{"error":"…","detail":"…"}` on stderr)
- `1` — runtime error
- `2` — bad arguments
- `124``await` timed out
- **No interactive prompts, ever.** Passwords, names, keys — all flags.
@@ -42,9 +47,9 @@ What every caller — user, script, agent, CI — can rely on:
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.
Only the `--json` shape and the exit codes are public API. The default
text format is allowed to change between releases. The rationale lives
in [DEVELOPMENT.md](./DEVELOPMENT.md).
---
@@ -159,10 +164,11 @@ amy --data-dir ./bob marmot await group --name "Test Group" --timeout 60
amy --data-dir ./bob marmot message list <GID>
```
Compose with `jq` to chain commands:
Compose with `jq` to chain commands — pass `--json` so stdout switches
from the default human-readable text to the parseable single-line JSON:
```bash
GID=$(amy --data-dir ./alice marmot group create --name "Test" | jq -r .group_id)
GID=$(amy --data-dir ./alice --json marmot group create --name "Test" | jq -r .group_id)
```
For an interop-test script template, see
@@ -145,7 +145,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 +177,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,10 +193,10 @@ 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 {
@@ -1,42 +0,0 @@
/*
* 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.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
object Json {
val mapper: ObjectMapper = jacksonObjectMapper()
fun writeLine(obj: Any) {
println(mapper.writeValueAsString(obj))
}
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
}
}
@@ -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)
@@ -85,6 +93,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
GlobalFlag.DATA_DIR -> dataDirFlag = 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
@@ -189,10 +198,12 @@ private suspend fun marmotDispatch(
private enum class GlobalFlag(
val long: String,
val takesValue: Boolean = true,
) {
DATA_DIR("--data-dir"),
SECRET_BACKEND("--secret-backend"),
PASSPHRASE_FILE("--passphrase-file"),
JSON("--json", takesValue = false),
}
private data class ConsumedFlag(
@@ -212,7 +223,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)) {
@@ -231,8 +246,16 @@ private fun printUsage() {
| amy [--data-dir PATH]
| [--secret-backend auto|keychain|ncryptsec|plaintext]
| [--passphrase-file PATH]
| [--json]
| <cmd> [args...]
|
|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)
@@ -0,0 +1,201 @@
/*
* 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.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
/**
* 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.
*/
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 -> {
System.err.println(if (detail != null) "error: $code: $detail" else "error: $code")
}
}
return 1
}
private fun renderText(value: Any?): String {
val out = StringBuilder()
when (value) {
null -> {}
is Map<*, *> -> {
renderMapBody(out, value, "")
}
is List<*> -> {
renderListBody(out, value, "")
}
else -> {
out.append(value.toString()).append('\n')
}
}
return out.toString().trimEnd('\n')
}
private fun renderMapBody(
out: StringBuilder,
map: Map<*, *>,
prefix: String,
) {
for ((k, v) in map) {
if (v == null) continue
val key = k.toString()
when (v) {
is Map<*, *> -> {
if (v.isEmpty()) {
out.append(prefix).append(key).append(": {}\n")
} else {
out.append(prefix).append(key).append(":\n")
renderMapBody(out, v, "$prefix ")
}
}
is List<*> -> {
if (v.isEmpty()) {
out.append(prefix).append(key).append(": []\n")
} else {
out.append(prefix).append(key).append(":\n")
renderListBody(out, v, "$prefix ")
}
}
else -> {
out
.append(prefix)
.append(key)
.append(": ")
.append(v.toString())
.append('\n')
}
}
}
}
private fun renderListBody(
out: StringBuilder,
list: List<*>,
prefix: String,
) {
for (item in list) {
when (item) {
null -> {}
is Map<*, *> -> {
val entries = item.entries.filter { it.value != null }
if (entries.isEmpty()) {
out.append(prefix).append("- {}\n")
continue
}
val first = entries.first()
val firstV = first.value
if (firstV is Map<*, *> || firstV is List<*>) {
out.append(prefix).append("-\n")
renderMapBody(out, item, "$prefix ")
} else {
out
.append(prefix)
.append("- ")
.append(first.key)
.append(": ")
.append(firstV.toString())
.append('\n')
for ((rk, rv) in entries.drop(1)) {
when (rv) {
is Map<*, *> -> {
if (rv.isEmpty()) {
out.append("$prefix ").append(rk).append(": {}\n")
} else {
out.append("$prefix ").append(rk).append(":\n")
renderMapBody(out, rv, "$prefix ")
}
}
is List<*> -> {
if (rv.isEmpty()) {
out.append("$prefix ").append(rk).append(": []\n")
} else {
out.append("$prefix ").append(rk).append(":\n")
renderListBody(out, rv, "$prefix ")
}
}
else -> {
out
.append("$prefix ")
.append(rk)
.append(": ")
.append(rv.toString())
.append('\n')
}
}
}
}
}
is List<*> -> {
out.append(prefix).append("-\n")
renderListBody(out, item, "$prefix ")
}
else -> {
out
.append(prefix)
.append("- ")
.append(item.toString())
.append('\n')
}
}
}
}
}
@@ -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,
@@ -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),
@@ -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,
@@ -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()
@@ -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
object InitCommands {
suspend fun init(
@@ -34,7 +34,7 @@ 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(
Output.emit(
mapOf(
"npub" to existing.npub,
"hex" to existing.pubKeyHex,
@@ -48,7 +48,7 @@ object InitCommands {
val nsec = args.flag("nsec")
val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create()
dataDir.saveIdentity(created)
Json.writeLine(
Output.emit(
mapOf(
"npub" to created.npub,
"hex" to created.pubKeyHex,
@@ -65,9 +65,9 @@ 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(
"npub" to file.npub,
"hex" to file.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
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
}
+5 -5
View File
@@ -81,7 +81,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() { "$AMY_BIN" --data-dir "$B_DIR" --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.
@@ -249,10 +249,10 @@ fi
# ----------------------------------------------------------------------
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)
T7_STAT=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store stat)
T7_SWEEP=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store sweep-expired)
T7_SCRUB=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store scrub)
T7_COMPACT=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store compact)
assert_eq "$(printf '%s' "$T7_STAT" | jq -r '.events')" "0" T7.stat.events "" \
&& record_result T7.stat pass "stat works without identity"
assert_eq "$(printf '%s' "$T7_SWEEP" | jq -r '.swept')" "0" T7.sweep.swept "" \
+3 -3
View File
@@ -75,15 +75,15 @@ preflight_dm() {
# `--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() { "$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext --json "$@"; }
amy_d() { "$AMY_BIN" --data-dir "$D_DIR" --secret-backend plaintext --json "$@"; }
# --- identity bootstrap ------------------------------------------------------
ensure_identity_for() {
local who="$1" dir="$2"
step "initialising Identity $who (amy at $dir)"
local out
out=$("$AMY_BIN" --data-dir "$dir" --secret-backend plaintext init) || {
out=$("$AMY_BIN" --data-dir "$dir" --secret-backend plaintext --json init) || {
fail_msg "amy init failed for $who: $out"; exit 1
}
local npub hex
+6 -4
View File
@@ -13,10 +13,12 @@
# dm-06 cursor advance (subsequent no-flag `dm list` is empty)
# Wrap amy_json around either data-dir so the per-test code stays tight.
# `--json` opts into amy's machine-readable contract; assertions below
# parse with jq.
amy_json_for() {
local dir="$1"; shift
local out
if ! out=$("$AMY_BIN" --data-dir "$dir" "$@" 2>>"$LOG_FILE"); then
if ! out=$("$AMY_BIN" --data-dir "$dir" --json "$@" 2>>"$LOG_FILE"); then
fail_msg "amy --data-dir $dir $*: exit $? (see $LOG_FILE)"
printf '%s\n' "$out" >>"$LOG_FILE"
return 1
@@ -91,7 +93,7 @@ test_03_dm_send_rejects_no_inbox() {
# Generate a throwaway identity but do NOT publish its kind:10050.
local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX")
local ghost_out ghost_npub
ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" --secret-backend plaintext init) || {
ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" --secret-backend plaintext --json init) || {
record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return
}
ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub')
@@ -99,7 +101,7 @@ test_03_dm_send_rejects_no_inbox() {
# 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=$("$AMY_BIN" --data-dir "$A_DIR" --json dm send "$ghost_npub" "should be rejected" 2>&1)
rc=$?
rm -rf "$tmpdir"
if [[ "$rc" -ne 0 ]] && printf '%s' "$raw" | grep -q '"error":"no_dm_relays"'; then
@@ -121,7 +123,7 @@ test_04_dm_send_allow_fallback() {
# publish should succeed even though the ghost has no 10050.
local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX")
local ghost_out ghost_npub
ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" --secret-backend plaintext init) || {
ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" --secret-backend plaintext --json init) || {
record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return
}
ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub')
+3 -1
View File
@@ -6,7 +6,9 @@
# `--secret-backend=plaintext` keeps these throwaway interop runs headless —
# the default `auto` would try the OS keychain (not available in CI) and then
# ask for a NIP-49 passphrase. Plaintext still writes 0600-owner-only.
amy_a() { "$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext "$@"; }
# `--json` opts into amy's machine-readable output (the harness parses it
# with jq); the default human-text output is for terminal use.
amy_a() { "$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext --json "$@"; }
# Run amy, log stderr, surface JSON on stdout, remember last result.
amy_json() {
+1 -1
View File
@@ -271,7 +271,7 @@ test_14_wn_removes_a() {
local deadline=$(( $(date +%s) + 120 )) removed=0
while [[ $(date +%s) -lt $deadline ]]; do
local show rc
show=$("$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext \
show=$("$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext --json \
marmot group show "$a_gid" 2>&1)
rc=$?
printf '%s\n' "$show" >>"$LOG_FILE"