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 ## Design principles
1. **Non-interactive.** One verb = one JSON object on stdout = one 1. **Non-interactive.** One verb = one result on stdout = one exit
exit code. No REPL, no daemon, no prompts. Any network wait is an code. No REPL, no daemon, no prompts. Any network wait is an
explicit `await` verb with a `--timeout`. explicit `await` verb with a `--timeout`.
2. **Thin command layer.** Each file in `commands/` parses args, 2. **Thin command layer.** Each file in `commands/` parses args,
calls into `commons/` or `quartz/`, and prints JSON. A file longer calls into `commons/` or `quartz/`, and emits a result map via
than ~200 lines is a code smell — the logic is living in the wrong `Output.emit(...)`. A file longer than ~200 lines is a code smell
module. — the logic is living in the wrong module.
3. **Everything persistent is on-disk.** No in-memory caches that 3. **Everything persistent is on-disk.** No in-memory caches that
survive between invocations. Every run reloads cursors, MLS state, survive between invocations. Every run reloads cursors, MLS state,
identity, and relay config. This is what makes Amy safe to run 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 4. **Shared defaults.** When Amethyst picks a default relay, kind, or
tag — Amy calls the same helper. No hand-rolled duplicates. If the tag — Amy calls the same helper. No hand-rolled duplicates. If the
helper doesn't exist yet, extract it to `commons/` first. helper doesn't exist yet, extract it to `commons/` first.
5. **JSON is the public API.** Output-shape changes are breaking 5. **The `--json` output shape is the public API.** Default stdout is
changes. Version them explicitly in commit messages; update interop human-readable text (a YAML-ish render of the result map by way of
fixtures. `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/ cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/
├── Main.kt # argv → subcommand dispatch ├── Main.kt # argv → subcommand dispatch
├── Args.kt # tiny flag parser (no framework) ├── 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 ├── Config.kt # Identity, RunState, DataDir
├── Context.kt # per-run wiring: signer + NostrClient + ├── Context.kt # per-run wiring: signer + NostrClient +
│ # MarmotManager + publish/drain/sync helpers │ # MarmotManager + publish/drain/sync helpers
@@ -77,7 +80,7 @@ try {
ctx.syncIncoming() // pull new gift-wraps + group events ctx.syncIncoming() // pull new gift-wraps + group events
// ...call into commons/ or quartz/ to build an event... // ...call into commons/ or quartz/ to build an event...
val ack = ctx.publish(event, targets) val ack = ctx.publish(event, targets)
Json.writeLine(mapOf(...)) Output.emit(mapOf(...))
} finally { } finally {
ctx.close() // flush RunState, disconnect 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.Args
import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json import com.vitorpamplona.amethyst.cli.Output
object NoteCommands { object NoteCommands {
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int { 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() val rest = tail.drop(1).toTypedArray()
return when (tail[0]) { return when (tail[0]) {
"publish" -> publish(dataDir, rest) "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() ctx.prepare()
val event = com.vitorpamplona.amethyst.commons.note.buildTextNote(ctx.signer, text) val event = com.vitorpamplona.amethyst.commons.note.buildTextNote(ctx.signer, text)
val ack = ctx.publish(event, ctx.outboxRelays()) val ack = ctx.publish(event, ctx.outboxRelays())
Json.writeLine(mapOf( Output.emit(mapOf(
"event_id" to event.id, "event_id" to event.id,
"kind" to event.kind, "kind" to event.kind,
"published_to" to ack.filterValues { it }.keys.map { it.url }, "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 ### 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. - Top-level object always.
- Stable snake_case keys. - Stable snake_case keys.
- Event IDs as hex strings (not npub-style). - 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":…`). primary subject (`"npub":…`).
- Relay URLs as strings, normalized, never objects. - Relay URLs as strings, normalized, never objects.
- Lists of events under a plural key (`"messages"`, `"members"`). - Lists of events under a plural key (`"messages"`, `"members"`).
- Errors via `Json.error("code","detail")` — single lower_snake code, - Errors via `Output.error("code","detail")` — single lower_snake
free-form detail. 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/`. | | 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. | | 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. | | File layout on disk (`identity.json`, `events-store/…`, `marmot/groups/*.mls`, `marmot/keypackages.bundle`) | Structural assertions after a command sequence. |
| Round-trip between two data-dirs on a local relay | End-to-end shell harnesses under `cli/tests/`. Each harness spins up a local `nostr-rs-relay`, bootstraps two or more fresh identities in their own `--data-dir`s, and drives a scenario via `amy` (+ `wn` for Marmot interop against whitenoise-rs). Today there are two suites: `cli/tests/marmot/` (13 MLS scenarios vs whitenoise-rs) and `cli/tests/dm/` (NIP-17 DM round-trips between two `amy` clients). | | Round-trip between two 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/`. | | 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: What every caller — user, script, agent, CI — can rely on:
- **stdout is JSON. One line. One object.** Stable snake_case keys. - **Default stdout is human-readable text.** A YAML-ish render of the
Pipe it into `jq`, parse it from Python, hand it to an agent. 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. - **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.** - **Exit codes are the real signal.**
- `0` — success - `0` — success
- `1` — runtime error (JSON `{"error":"…","detail":"…"}` on stderr) - `1` — runtime error
- `2` — bad arguments - `2` — bad arguments
- `124``await` timed out - `124``await` timed out
- **No interactive prompts, ever.** Passwords, names, keys — all flags. - **No interactive prompts, ever.** Passwords, names, keys — all flags.
@@ -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 Delete to reset; copy to move; `AMETHYST_CLI_DATA` env var overrides
the default `./amy`. the default `./amy`.
The rationale behind each of these lives in Only the `--json` shape and the exit codes are public API. The default
[DEVELOPMENT.md](./DEVELOPMENT.md). Breaking any of them is a breaking text format is allowed to change between releases. The rationale lives
change to Amy's public API. 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> 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 ```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 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 * for "does an identity exist?" / "what's the npub?" checks that must
* not pop a keychain prompt or ask for a passphrase. * 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() fun identityExists(): Boolean = identityFile.exists()
@@ -177,14 +177,14 @@ class DataDir(
fun saveIdentity(id: Identity) { fun saveIdentity(id: Identity) {
val secret: IdentitySecret? = id.privKeyHex?.let { secrets.store(id.pubKeyHex, it) } val secret: IdentitySecret? = id.privKeyHex?.let { secrets.store(id.pubKeyHex, it) }
val file = IdentityFile(pubKeyHex = id.pubKeyHex, npub = id.npub, secret = secret) 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. */ /** Remove the identity file and any backend-held secret. */
fun deleteIdentity() { fun deleteIdentity() {
if (identityFile.exists()) { if (identityFile.exists()) {
runCatching { runCatching {
val file = Json.mapper.readValue<IdentityFile>(identityFile.readText()) val file = Output.mapper.readValue<IdentityFile>(identityFile.readText())
file.secret?.let { secrets.delete(it) } file.secret?.let { secrets.delete(it) }
} }
if (!identityFile.delete() && identityFile.exists()) { 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) { fun saveRunState(s: RunState) {
SecureFileIO.writeTextAtomic(stateFile, Json.mapper.writeValueAsString(s)) SecureFileIO.writeTextAtomic(stateFile, Output.mapper.writeValueAsString(s))
} }
companion object { 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: * Exit codes:
* 0 — success * 0 — success
* 1 — runtime error (printed as JSON on stderr: {"error": "...", "detail": "..."}) * 1 — runtime error
* 2 — invalid arguments * 2 — invalid arguments
* 124 — await timeout * 124 — await timeout
* *
* Every command that succeeds prints exactly one JSON object to stdout. * Default output is human-readable text on stdout. Pass `--json` to
* Diagnostic logs go to stderr and are safe to discard. * 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>) { 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 = val code =
try { try {
runBlocking { dispatch(argv) } runBlocking { dispatch(argv) }
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
Json.error("bad_args", e.message) Output.error("bad_args", e.message)
2 2
} catch (e: AwaitTimeout) { } catch (e: AwaitTimeout) {
Json.error("timeout", e.message) Output.error("timeout", e.message)
124 124
} catch (e: Exception) { } catch (e: Exception) {
Json.error("runtime", "${e::class.simpleName}: ${e.message}") Output.error("runtime", "${e::class.simpleName}: ${e.message}")
1 1
} }
exitProcess(code) exitProcess(code)
@@ -85,6 +93,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
GlobalFlag.DATA_DIR -> dataDirFlag = consumed.value GlobalFlag.DATA_DIR -> dataDirFlag = consumed.value
GlobalFlag.SECRET_BACKEND -> secretBackendFlag = consumed.value GlobalFlag.SECRET_BACKEND -> secretBackendFlag = consumed.value
GlobalFlag.PASSPHRASE_FILE -> passphraseFileFlag = consumed.value GlobalFlag.PASSPHRASE_FILE -> passphraseFileFlag = consumed.value
GlobalFlag.JSON -> Output.mode = Output.Mode.JSON
null -> filteredArgs.add(a) null -> filteredArgs.add(a)
} }
i += consumed.tokensConsumed i += consumed.tokensConsumed
@@ -189,10 +198,12 @@ private suspend fun marmotDispatch(
private enum class GlobalFlag( private enum class GlobalFlag(
val long: String, val long: String,
val takesValue: Boolean = true,
) { ) {
DATA_DIR("--data-dir"), DATA_DIR("--data-dir"),
SECRET_BACKEND("--secret-backend"), SECRET_BACKEND("--secret-backend"),
PASSPHRASE_FILE("--passphrase-file"), PASSPHRASE_FILE("--passphrase-file"),
JSON("--json", takesValue = false),
} }
private data class ConsumedFlag( private data class ConsumedFlag(
@@ -212,7 +223,11 @@ private fun extractGlobalFlag(
): Pair<GlobalFlag?, ConsumedFlag> { ): Pair<GlobalFlag?, ConsumedFlag> {
for (flag in GlobalFlag.values()) { for (flag in GlobalFlag.values()) {
if (token == flag.long) { 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}=" val prefix = "${flag.long}="
if (token.startsWith(prefix)) { if (token.startsWith(prefix)) {
@@ -231,8 +246,16 @@ private fun printUsage() {
| amy [--data-dir PATH] | amy [--data-dir PATH]
| [--secret-backend auto|keychain|ncryptsec|plaintext] | [--secret-backend auto|keychain|ncryptsec|plaintext]
| [--passphrase-file PATH] | [--passphrase-file PATH]
| [--json]
| <cmd> [args...] | <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: |Private-key storage:
| Default (`auto`) uses the OS keychain when one is available | Default (`auto`) uses the OS keychain when one is available
| (macOS `security`, or Linux `secret-tool` on a session D-Bus) | (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.AwaitTimeout
import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir 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.awaitAdmin
import com.vitorpamplona.amethyst.cli.commands.AwaitCommands.awaitMember import com.vitorpamplona.amethyst.cli.commands.AwaitCommands.awaitMember
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
@@ -43,7 +43,7 @@ object AwaitCommands {
dataDir: DataDir, dataDir: DataDir,
tail: Array<String>, tail: Array<String>,
): Int { ): 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() val rest = tail.drop(1).toTypedArray()
return when (tail[0]) { return when (tail[0]) {
"key-package" -> awaitKeyPackage(dataDir, rest) "key-package" -> awaitKeyPackage(dataDir, rest)
@@ -53,7 +53,7 @@ object AwaitCommands {
"message" -> awaitMessage(dataDir, rest) "message" -> awaitMessage(dataDir, rest)
"rename" -> awaitRename(dataDir, rest) "rename" -> awaitRename(dataDir, rest)
"epoch" -> awaitEpoch(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, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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 args = Args(rest.drop(1).toTypedArray())
val timeoutSecs = args.longFlag("timeout", 30) val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
@@ -100,7 +100,7 @@ object AwaitCommands {
timeoutMs = 3_000, timeoutMs = 3_000,
) )
if (event is KeyPackageEvent) { if (event is KeyPackageEvent) {
Json.writeLine( Output.emit(
mapOf( mapOf(
"event_id" to event.id, "event_id" to event.id,
"author" to event.pubKey, "author" to event.pubKey,
@@ -135,7 +135,7 @@ object AwaitCommands {
wantedName == null || ctx.marmot.groupMetadata(gid)?.name == wantedName wantedName == null || ctx.marmot.groupMetadata(gid)?.name == wantedName
} }
if (match != null) { if (match != null) {
Json.writeLine( Output.emit(
mapOf( mapOf(
"group_id" to match, "group_id" to match,
"mls_group_id" to ctx.marmot.mlsGroupIdHex(match), "mls_group_id" to ctx.marmot.mlsGroupIdHex(match),
@@ -193,7 +193,7 @@ object AwaitCommands {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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 args = Args(rest.drop(1).toTypedArray())
val wantedName = args.requireFlag("name") val wantedName = args.requireFlag("name")
val timeoutSecs = args.longFlag("timeout", 30) val timeoutSecs = args.longFlag("timeout", 30)
@@ -206,7 +206,7 @@ object AwaitCommands {
ctx.syncIncoming(timeoutMs = 3_000) ctx.syncIncoming(timeoutMs = 3_000)
val name = ctx.marmot.groupMetadata(gid)?.name val name = ctx.marmot.groupMetadata(gid)?.name
if (name == wantedName) { 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 return 0
} }
delay(1_500) delay(1_500)
@@ -221,7 +221,7 @@ object AwaitCommands {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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 args = Args(rest.drop(1).toTypedArray())
val min = args.longFlag("min", 1) val min = args.longFlag("min", 1)
val timeoutSecs = args.longFlag("timeout", 30) val timeoutSecs = args.longFlag("timeout", 30)
@@ -234,7 +234,7 @@ object AwaitCommands {
ctx.syncIncoming(timeoutMs = 3_000) ctx.syncIncoming(timeoutMs = 3_000)
val epoch = ctx.marmot.groupEpoch(gid) val epoch = ctx.marmot.groupEpoch(gid)
if (epoch != null && epoch >= min) { 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 return 0
} }
delay(1_500) delay(1_500)
@@ -249,7 +249,7 @@ object AwaitCommands {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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 args = Args(rest.drop(1).toTypedArray())
val needle = args.requireFlag("match") val needle = args.requireFlag("match")
val timeoutSecs = args.longFlag("timeout", 30) val timeoutSecs = args.longFlag("timeout", 30)
@@ -264,13 +264,13 @@ object AwaitCommands {
for (line in msgs.asReversed()) { for (line in msgs.asReversed()) {
val obj = val obj =
try { try {
Json.mapper.readValue<Map<String, Any?>>(line) Output.mapper.readValue<Map<String, Any?>>(line)
} catch (_: Exception) { } catch (_: Exception) {
null null
} ?: continue } ?: continue
val content = obj["content"]?.toString() ?: continue val content = obj["content"]?.toString() ?: continue
if (content.contains(needle)) { if (content.contains(needle)) {
Json.writeLine( Output.emit(
mapOf( mapOf(
"group_id" to gid, "group_id" to gid,
"id" to obj["id"], "id" to obj["id"],
@@ -301,7 +301,7 @@ object AwaitCommands {
targetIdx: Int, targetIdx: Int,
check: suspend (Context, Array<String>) -> Map<String, Any?>?, check: suspend (Context, Array<String>) -> Map<String, Any?>?,
): Int { ): 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 args = Args(rest.drop(targetIdx + 1).toTypedArray())
val timeoutSecs = args.longFlag("timeout", 30) val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
@@ -312,7 +312,7 @@ object AwaitCommands {
ctx.syncIncoming(timeoutMs = 3_000) ctx.syncIncoming(timeoutMs = 3_000)
val hit = check(ctx, rest) val hit = check(ctx, rest)
if (hit != null) { if (hit != null) {
Json.writeLine(hit) Output.emit(hit)
return 0 return 0
} }
delay(1_500) delay(1_500)
@@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Identity 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.account.bootstrapAccountEvents
import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65List import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65List
@@ -52,7 +52,7 @@ object CreateCommand {
rest: Array<String>, rest: Array<String>,
): Int { ): Int {
if (dataDir.identityExists()) { 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 args = Args(rest)
val name = args.flag("name") val name = args.flag("name")
@@ -84,7 +84,7 @@ object CreateCommand {
ctx.close() ctx.close()
} }
Json.writeLine( Output.emit(
mapOf( mapOf(
"npub" to identity.npub, "npub" to identity.npub,
"hex" to identity.pubKeyHex, "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.AwaitTimeout
import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir 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.filterGiftWrapsToPubkey
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
@@ -62,14 +62,14 @@ object DmCommands {
dataDir: DataDir, dataDir: DataDir,
tail: Array<String>, tail: Array<String>,
): Int { ): 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() val rest = tail.drop(1).toTypedArray()
return when (tail[0]) { return when (tail[0]) {
"send" -> send(dataDir, rest) "send" -> send(dataDir, rest)
"send-file" -> sendFile(dataDir, rest) "send-file" -> sendFile(dataDir, rest)
"list" -> list(dataDir, rest) "list" -> list(dataDir, rest)
"await" -> await(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, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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 text = rest[1]
val args = Args(rest.drop(2).toTypedArray()) val args = Args(rest.drop(2).toTypedArray())
val allowFallback = args.bool("allow-fallback") val allowFallback = args.bool("allow-fallback")
@@ -114,7 +114,7 @@ object DmCommands {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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 args = Args(rest.drop(1).toTypedArray())
val allowFallback = args.bool("allow-fallback") val allowFallback = args.bool("allow-fallback")
val recipientInput = rest[0] val recipientInput = rest[0]
@@ -147,7 +147,7 @@ object DmCommands {
): Pair<com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<ChatMessageEncryptedFileHeaderEvent>, Map<String, Any?>>? { ): Pair<com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<ChatMessageEncryptedFileHeaderEvent>, Map<String, Any?>>? {
val file = java.io.File(args.requireFlag("file")) val file = java.io.File(args.requireFlag("file"))
if (!file.exists()) { 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 return null
} }
val server = args.requireFlag("server") val server = args.requireFlag("server")
@@ -158,7 +158,7 @@ object DmCommands {
val uploaded = orchestrator.uploadEncrypted(file, cipher, server, ctx.signer) val uploaded = orchestrator.uploadEncrypted(file, cipher, server, ctx.signer)
val uploadedUrl = val uploadedUrl =
uploaded.blossom.url ?: run { 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 return null
} }
val mimeType = args.flag("mime-type") ?: uploaded.metadata.mimeType 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?>>? { ): Pair<com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<ChatMessageEncryptedFileHeaderEvent>, Map<String, Any?>>? {
val url = val url =
args.positionalOrNull(0) ?: run { args.positionalOrNull(0) ?: run {
Json.error("bad_args", USAGE_SEND_FILE) Output.error("bad_args", USAGE_SEND_FILE)
return null return null
} }
val keyHex = args.requireFlag("key") val keyHex = args.requireFlag("key")
val nonceHex = args.requireFlag("nonce") val nonceHex = args.requireFlag("nonce")
val keyBytes = val keyBytes =
runCatching { keyHex.hexToByteArray() }.getOrElse { 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 return null
} }
val nonceBytes = val nonceBytes =
runCatching { nonceHex.hexToByteArray() }.getOrElse { 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 return null
} }
val mimeType = args.flag("mime-type") val mimeType = args.flag("mime-type")
@@ -227,7 +227,7 @@ object DmCommands {
val match = val match =
Regex("^(\\d+)x(\\d+)$").matchEntire(raw) Regex("^(\\d+)x(\\d+)$").matchEntire(raw)
?: run { ?: run {
Json.error("bad_args", "--dim must be WxH (got '$raw')") Output.error("bad_args", "--dim must be WxH (got '$raw')")
return null return null
} }
com.vitorpamplona.quartz.nip94FileMetadata.tags com.vitorpamplona.quartz.nip94FileMetadata.tags
@@ -266,7 +266,7 @@ object DmCommands {
val target = wrap.recipientPubKey() ?: continue val target = wrap.recipientPubKey() ?: continue
val resolution = resolveDmRelays(ctx, target, allowFallback) val resolution = resolveDmRelays(ctx, target, allowFallback)
if (resolution.relays.isEmpty()) { if (resolution.relays.isEmpty()) {
return Json.error( return Output.error(
"no_dm_relays", "no_dm_relays",
"$target has no kind:10050; pass --allow-fallback to use NIP-65 read or bootstrap", "$target has no kind:10050; pass --allow-fallback to use NIP-65 read or bootstrap",
) )
@@ -289,7 +289,7 @@ object DmCommands {
putAll(extra) putAll(extra)
put("recipients", recipientsOut) put("recipients", recipientsOut)
} }
Json.writeLine(out) Output.emit(out)
return 0 return 0
} }
@@ -313,7 +313,7 @@ object DmCommands {
.inboxRelays() .inboxRelays()
.ifEmpty { ctx.outboxRelays() } .ifEmpty { ctx.outboxRelays() }
.ifEmpty { ctx.bootstrapRelays() } .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 advanceCursor = peerInput == null && sinceFlag == null
val since = sinceFlag ?: ctx.state.giftWrapSince val since = sinceFlag ?: ctx.state.giftWrapSince
@@ -338,7 +338,7 @@ object DmCommands {
if (maxSeen != null) ctx.state.giftWrapSince = maxSeen if (maxSeen != null) ctx.state.giftWrapSince = maxSeen
} }
Json.writeLine( Output.emit(
mapOf( mapOf(
"peer" to peerHex, "peer" to peerHex,
"messages" to out, "messages" to out,
@@ -369,7 +369,7 @@ object DmCommands {
.inboxRelays() .inboxRelays()
.ifEmpty { ctx.outboxRelays() } .ifEmpty { ctx.outboxRelays() }
.ifEmpty { ctx.bootstrapRelays() } .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 val deadline = System.currentTimeMillis() + timeoutSecs * 1000
var since = ctx.state.giftWrapSince var since = ctx.state.giftWrapSince
@@ -388,7 +388,7 @@ object DmCommands {
// can grep for either with one --match flag. // can grep for either with one --match flag.
val hit = messages.firstOrNull { match in it.searchText } val hit = messages.firstOrNull { match in it.searchText }
if (hit != null) { if (hit != null) {
Json.writeLine(hit.toJson()) Output.emit(hit.toJson())
return 0 return 0
} }
val maxSeen = messages.maxOfOrNull { it.createdAt } 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.Args
import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir 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.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -53,10 +53,10 @@ object FeedCommand {
val author = args.flag("author") val author = args.flag("author")
val following = args.bool("following") val following = args.bool("following")
if (author != null && 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) 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 since = args.flag("since")?.toLongOrNull()
val until = args.flag("until")?.toLongOrNull() val until = args.flag("until")?.toLongOrNull()
val timeoutSecs = args.longFlag("timeout", 8L) val timeoutSecs = args.longFlag("timeout", 8L)
@@ -73,7 +73,7 @@ object FeedCommand {
} }
if (authors.isEmpty()) { if (authors.isEmpty()) {
Json.writeLine( Output.emit(
mapOf( mapOf(
"mode" to mode, "mode" to mode,
"authors" to emptyList<String>(), "authors" to emptyList<String>(),
@@ -85,7 +85,7 @@ object FeedCommand {
val relays = relaysForReadingFeed(ctx, mode) val relays = relaysForReadingFeed(ctx, mode)
if (relays.isEmpty()) { 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 = val filter =
@@ -121,7 +121,7 @@ object FeedCommand {
) )
}.toList() }.toList()
Json.writeLine( Output.emit(
mapOf( mapOf(
"mode" to mode, "mode" to mode,
"authors" to authors, "authors" to authors,
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir 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.amethyst.commons.defaults.DefaultDMRelayList
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
@@ -45,13 +45,13 @@ object GroupAddMemberCommand {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0]) val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming() 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, // Accept any identifier the UI would: npub1…, nprofile1…, 64-hex,
// NIP-05 (name@domain). Resolution fires NIP-05 HTTP fetches in parallel // NIP-05 (name@domain). Resolution fires NIP-05 HTTP fetches in parallel
@@ -160,7 +160,7 @@ object GroupAddMemberCommand {
) )
} }
Json.writeLine( Output.emit(
mapOf( mapOf(
"group_id" to gid, "group_id" to gid,
"epoch" to ctx.marmot.groupEpoch(gid), "epoch" to ctx.marmot.groupEpoch(gid),
@@ -21,14 +21,14 @@
package com.vitorpamplona.amethyst.cli.commands package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json import com.vitorpamplona.amethyst.cli.Output
object GroupCommands { object GroupCommands {
suspend fun dispatch( suspend fun dispatch(
dataDir: DataDir, dataDir: DataDir,
tail: Array<String>, tail: Array<String>,
): Int { ): 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() val rest = tail.drop(1).toTypedArray()
return when (tail[0]) { return when (tail[0]) {
"create" -> GroupCreateCommand.run(dataDir, rest) "create" -> GroupCreateCommand.run(dataDir, rest)
@@ -42,7 +42,7 @@ object GroupCommands {
"demote" -> GroupMetadataCommands.demote(dataDir, rest) "demote" -> GroupMetadataCommands.demote(dataDir, rest)
"remove" -> GroupMembershipCommands.remove(dataDir, rest) "remove" -> GroupMembershipCommands.remove(dataDir, rest)
"leave" -> GroupMembershipCommands.leave(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.Args
import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir 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.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.RandomInstance
@@ -56,7 +56,7 @@ object GroupCreateCommand {
) )
ctx.marmot.createGroup(gid, initialMetadata = metadata) ctx.marmot.createGroup(gid, initialMetadata = metadata)
Json.writeLine( Output.emit(
mapOf( mapOf(
"group_id" to gid, "group_id" to gid,
"mls_group_id" to ctx.marmot.mlsGroupIdHex(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.Context
import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json import com.vitorpamplona.amethyst.cli.Output
object GroupMembershipCommands { object GroupMembershipCommands {
suspend fun remove( suspend fun remove(
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0]) val gid = ctx.resolveGroupId(rest[0])
val target = ctx.requireUserHex(rest[1]) val target = ctx.requireUserHex(rest[1])
ctx.syncIncoming() 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 = val leafIndex =
ctx.marmot.leafIndexOf(gid, target) 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 outbound = ctx.marmot.removeMember(nostrGroupId = gid, targetLeafIndex = leafIndex)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(outbound.signedEvent, targets) val ack = ctx.publish(outbound.signedEvent, targets)
Json.writeLine( Output.emit(
mapOf( mapOf(
"group_id" to gid, "group_id" to gid,
"removed" to target, "removed" to target,
@@ -65,12 +65,12 @@ object GroupMembershipCommands {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0]) 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() } val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
@@ -102,7 +102,7 @@ object GroupMembershipCommands {
val outbound = ctx.marmot.leaveGroup(gid) val outbound = ctx.marmot.leaveGroup(gid)
val ack = ctx.publish(outbound.signedEvent, targets) val ack = ctx.publish(outbound.signedEvent, targets)
Json.writeLine( Output.emit(
mapOf( mapOf(
"group_id" to gid, "group_id" to gid,
"self_demote_event_id" to demoteEventId, "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.Context
import com.vitorpamplona.amethyst.cli.DataDir 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.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -35,7 +35,7 @@ object GroupMetadataCommands {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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]) } return edit(dataDir, rest[0]) { _, cur -> cur.copy(name = rest[1]) }
} }
@@ -43,7 +43,7 @@ object GroupMetadataCommands {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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 -> return edit(dataDir, rest[0]) { ctx, cur ->
val newAdmin = ctx.requireUserHex(rest[1]) val newAdmin = ctx.requireUserHex(rest[1])
val admins = cur.adminPubkeys.toMutableList() val admins = cur.adminPubkeys.toMutableList()
@@ -56,7 +56,7 @@ object GroupMetadataCommands {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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 -> return edit(dataDir, rest[0]) { ctx, cur ->
val target = ctx.requireUserHex(rest[1]) val target = ctx.requireUserHex(rest[1])
val admins = cur.adminPubkeys.filter { it != target } val admins = cur.adminPubkeys.filter { it != target }
@@ -74,7 +74,7 @@ object GroupMetadataCommands {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rawGid) val gid = ctx.resolveGroupId(rawGid)
ctx.syncIncoming() 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 outboxUrls = ctx.outboxRelays().map { it.url }
val cur = val cur =
ctx.marmot.groupMetadata(gid) ctx.marmot.groupMetadata(gid)
@@ -89,7 +89,7 @@ object GroupMetadataCommands {
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(commit.signedEvent, targets) val ack = ctx.publish(commit.signedEvent, targets)
Json.writeLine( Output.emit(
mapOf( mapOf(
"group_id" to gid, "group_id" to gid,
"name" to updated.name, "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.Context
import com.vitorpamplona.amethyst.cli.DataDir 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. * Read-only queries. None of these publish; they all sync-then-report.
@@ -44,7 +44,7 @@ object GroupReadCommands {
"epoch" to ctx.marmot.groupEpoch(id), "epoch" to ctx.marmot.groupEpoch(id),
) )
} }
Json.writeLine(mapOf("groups" to items)) Output.emit(mapOf("groups" to items))
return 0 return 0
} finally { } finally {
ctx.close() ctx.close()
@@ -55,19 +55,19 @@ object GroupReadCommands {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0]) val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming() 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 meta = ctx.marmot.groupMetadata(gid)
val members = val members =
ctx.marmot.memberPubkeys(gid).map { ctx.marmot.memberPubkeys(gid).map {
mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex) mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex)
} }
Json.writeLine( Output.emit(
mapOf( mapOf(
"group_id" to gid, "group_id" to gid,
"mls_group_id" to ctx.marmot.mlsGroupIdHex(gid), "mls_group_id" to ctx.marmot.mlsGroupIdHex(gid),
@@ -90,18 +90,18 @@ object GroupReadCommands {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0]) val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming() 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 = val members =
ctx.marmot.memberPubkeys(gid).map { ctx.marmot.memberPubkeys(gid).map {
mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex) 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 return 0
} finally { } finally {
ctx.close() ctx.close()
@@ -112,15 +112,15 @@ object GroupReadCommands {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0]) val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming() 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) 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 return 0
} finally { } finally {
ctx.close() ctx.close()
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Identity import com.vitorpamplona.amethyst.cli.Identity
import com.vitorpamplona.amethyst.cli.Json import com.vitorpamplona.amethyst.cli.Output
object InitCommands { object InitCommands {
suspend fun init( suspend fun init(
@@ -34,7 +34,7 @@ object InitCommands {
// would trigger a keychain prompt / passphrase dialog even though the // would trigger a keychain prompt / passphrase dialog even though the
// caller clearly already has the identity set up. // caller clearly already has the identity set up.
dataDir.loadIdentityFileOrNull()?.let { existing -> dataDir.loadIdentityFileOrNull()?.let { existing ->
Json.writeLine( Output.emit(
mapOf( mapOf(
"npub" to existing.npub, "npub" to existing.npub,
"hex" to existing.pubKeyHex, "hex" to existing.pubKeyHex,
@@ -48,7 +48,7 @@ object InitCommands {
val nsec = args.flag("nsec") val nsec = args.flag("nsec")
val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create() val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create()
dataDir.saveIdentity(created) dataDir.saveIdentity(created)
Json.writeLine( Output.emit(
mapOf( mapOf(
"npub" to created.npub, "npub" to created.npub,
"hex" to created.pubKeyHex, "hex" to created.pubKeyHex,
@@ -65,9 +65,9 @@ object InitCommands {
// or ask for a NIP-49 passphrase just to echo the npub. // or ask for a NIP-49 passphrase just to echo the npub.
val file = dataDir.loadIdentityFileOrNull() val file = dataDir.loadIdentityFileOrNull()
if (file == null) { 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( mapOf(
"npub" to file.npub, "npub" to file.npub,
"hex" to file.pubKeyHex, "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.Context
import com.vitorpamplona.amethyst.cli.DataDir 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.RecipientRelayFetcher
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
@@ -31,11 +31,11 @@ object KeyPackageCommands {
dataDir: DataDir, dataDir: DataDir,
tail: Array<String>, tail: Array<String>,
): Int { ): 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]) { return when (tail[0]) {
"publish" -> publish(dataDir) "publish" -> publish(dataDir)
"check" -> check(dataDir, tail.drop(1).toTypedArray()) "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 { try {
ctx.prepare() ctx.prepare()
val relays = ctx.keyPackageRelays().ifEmpty { ctx.outboxRelays() }.ifEmpty { ctx.anyRelays() } 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 event = ctx.marmot.generateKeyPackageEvent(relays.toList())
val ack = ctx.publish(event, relays) val ack = ctx.publish(event, relays)
Json.writeLine( Output.emit(
mapOf( mapOf(
"event_id" to event.id, "event_id" to event.id,
"kind" to event.kind, "kind" to event.kind,
@@ -66,7 +66,7 @@ object KeyPackageCommands {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
@@ -76,7 +76,7 @@ object KeyPackageCommands {
// Look those up first from bootstrap seeds so `check` works even // Look those up first from bootstrap seeds so `check` works even
// when the target and inviter share no relays. // when the target and inviter share no relays.
val seed = ctx.bootstrapRelays() 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 // Cache-first via Context.cachedRelayListsOf — replaceable
// events live in the local store after the first sync. // events live in the local store after the first sync.
val recipient = val recipient =
@@ -96,9 +96,9 @@ object KeyPackageCommands {
timeoutMs = 10_000, timeoutMs = 10_000,
) )
if (event == null) { 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( mapOf(
"event_id" to event.id, "event_id" to event.id,
"author" to event.pubKey, "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.Args
import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Identity 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.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull
@@ -50,10 +50,10 @@ object LoginCommand {
rest: Array<String>, rest: Array<String>,
): Int { ): Int {
if (rest.isEmpty()) { 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()) { 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() val key = rest[0].trim()
@@ -61,13 +61,13 @@ object LoginCommand {
val identity = val identity =
resolveIdentity(key, args) resolveIdentity(key, args)
?: return Json.error( ?: return Output.error(
"bad_key", "bad_key",
"could not parse '$key' as any supported identifier", "could not parse '$key' as any supported identifier",
) )
dataDir.saveIdentity(identity) dataDir.saveIdentity(identity)
Json.writeLine( Output.emit(
mapOf( mapOf(
"npub" to identity.npub, "npub" to identity.npub,
"hex" to identity.pubKeyHex, "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.Context
import com.vitorpamplona.amethyst.cli.DataDir 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. * `amy marmot reset [--yes]` — wipe all local Marmot state.
@@ -56,7 +56,7 @@ object MarmotResetCommand {
.sorted() .sorted()
if (!confirmed) { if (!confirmed) {
Json.writeLine( Output.emit(
mapOf( mapOf(
"dry_run" to true, "dry_run" to true,
"would_wipe_groups" to groupIds, "would_wipe_groups" to groupIds,
@@ -70,7 +70,7 @@ object MarmotResetCommand {
ctx.state.giftWrapSince = null ctx.state.giftWrapSince = null
ctx.state.groupSince.clear() ctx.state.groupSince.clear()
Json.writeLine( Output.emit(
mapOf( mapOf(
"reset" to true, "reset" to true,
"wiped_groups" to groupIds, "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.Args
import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir 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 import com.vitorpamplona.quartz.nip01Core.core.Event
object MessageCommands { object MessageCommands {
@@ -32,14 +32,14 @@ object MessageCommands {
dataDir: DataDir, dataDir: DataDir,
tail: Array<String>, tail: Array<String>,
): Int { ): 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() val rest = tail.drop(1).toTypedArray()
return when (tail[0]) { return when (tail[0]) {
"send" -> send(dataDir, rest) "send" -> send(dataDir, rest)
"list" -> list(dataDir, rest) "list" -> list(dataDir, rest)
"react" -> react(dataDir, rest) "react" -> react(dataDir, rest)
"delete" -> delete(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, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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 text = rest[1]
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0]) val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming() 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 bundle = ctx.marmot.buildTextMessage(gid, text)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(bundle.outbound.signedEvent, targets) val ack = ctx.publish(bundle.outbound.signedEvent, targets)
Json.writeLine( Output.emit(
mapOf( mapOf(
"group_id" to gid, "group_id" to gid,
"inner_event_id" to bundle.innerEvent.id, "inner_event_id" to bundle.innerEvent.id,
@@ -79,7 +79,7 @@ object MessageCommands {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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 args = Args(rest.drop(1).toTypedArray())
val limit = args.intFlag("limit", Int.MAX_VALUE) val limit = args.intFlag("limit", Int.MAX_VALUE)
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
@@ -87,7 +87,7 @@ object MessageCommands {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0]) val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming() 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 raw = ctx.marmot.loadStoredMessages(gid)
val items = val items =
@@ -95,7 +95,7 @@ object MessageCommands {
.map { line -> .map { line ->
try { try {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val obj = Json.mapper.readValue<Map<String, Any?>>(line) val obj = Output.mapper.readValue<Map<String, Any?>>(line)
mapOf( mapOf(
"id" to obj["id"], "id" to obj["id"],
"author" to obj["pubkey"], "author" to obj["pubkey"],
@@ -108,7 +108,7 @@ object MessageCommands {
} }
}.takeLast(limit) }.takeLast(limit)
Json.writeLine(mapOf("group_id" to gid, "messages" to items)) Output.emit(mapOf("group_id" to gid, "messages" to items))
return 0 return 0
} finally { } finally {
ctx.close() ctx.close()
@@ -119,7 +119,7 @@ object MessageCommands {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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 targetId = rest[1]
val emoji = rest[2] val emoji = rest[2]
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
@@ -127,14 +127,14 @@ object MessageCommands {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0]) val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming() 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 bundle = ctx.marmot.buildReactionMessage(gid, target, emoji)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(bundle.outbound.signedEvent, targets) val ack = ctx.publish(bundle.outbound.signedEvent, targets)
Json.writeLine( Output.emit(
mapOf( mapOf(
"group_id" to gid, "group_id" to gid,
"inner_event_id" to bundle.innerEvent.id, "inner_event_id" to bundle.innerEvent.id,
@@ -155,25 +155,25 @@ object MessageCommands {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0]) val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming() 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 targetIds = rest.drop(1)
val targets = val targets =
targetIds.map { id -> 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 bundle = ctx.marmot.buildDeletionMessage(gid, targets)
val relays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val relays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(bundle.outbound.signedEvent, relays) val ack = ctx.publish(bundle.outbound.signedEvent, relays)
Json.writeLine( Output.emit(
mapOf( mapOf(
"group_id" to gid, "group_id" to gid,
"inner_event_id" to bundle.innerEvent.id, "inner_event_id" to bundle.innerEvent.id,
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.cli.commands package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.DataDir 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 * `amy notes <post|feed>` — NIP-10 kind:1 short text notes. Sits alongside
@@ -33,12 +33,12 @@ object NotesCommands {
dataDir: DataDir, dataDir: DataDir,
tail: Array<String>, tail: Array<String>,
): Int { ): 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() val rest = tail.drop(1).toTypedArray()
return when (tail[0]) { return when (tail[0]) {
"post" -> PostCommand.run(dataDir, rest) "post" -> PostCommand.run(dataDir, rest)
"feed" -> FeedCommand.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.Args
import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
/** /**
@@ -39,9 +39,9 @@ object PostCommand {
dataDir: DataDir, dataDir: DataDir,
rest: Array<String>, rest: Array<String>,
): Int { ): 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] 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 args = Args(rest.drop(1).toTypedArray())
val extraRelays = val extraRelays =
@@ -61,13 +61,13 @@ object PostCommand {
} }
val targets = (outbox + extraNormalized).toSet() val targets = (outbox + extraNormalized).toSet()
if (targets.isEmpty()) { 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 signed = ctx.signer.sign(TextNoteEvent.build(text))
val ack = ctx.publish(signed, targets) val ack = ctx.publish(signed, targets)
Json.writeLine( Output.emit(
mapOf( mapOf(
"event_id" to signed.id, "event_id" to signed.id,
"kind" to signed.kind, "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.Args
import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir 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.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -44,12 +44,12 @@ object ProfileCommands {
dataDir: DataDir, dataDir: DataDir,
tail: Array<String>, tail: Array<String>,
): Int { ): 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() val rest = tail.drop(1).toTypedArray()
return when (tail[0]) { return when (tail[0]) {
"show" -> show(dataDir, rest) "show" -> show(dataDir, rest)
"edit" -> edit(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) { if (event == null) {
Json.writeLine( Output.emit(
mapOf( mapOf(
"pubkey" to pubKey, "pubkey" to pubKey,
"found" to false, "found" to false,
@@ -100,11 +100,11 @@ object ProfileCommands {
} }
val metadata = val metadata =
try { try {
Json.mapper.readTree(event.content) Output.mapper.readTree(event.content)
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
Json.writeLine( Output.emit(
mapOf( mapOf(
"pubkey" to pubKey, "pubkey" to pubKey,
"found" to true, "found" to true,
@@ -145,7 +145,7 @@ object ProfileCommands {
listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github) listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github)
.any { it != null } .any { it != null }
if (!touched) { if (!touched) {
return Json.error( return Output.error(
"bad_args", "bad_args",
"profile edit needs at least one of " + "profile edit needs at least one of " +
"--name --display-name --about --picture --banner --website " + "--name --display-name --about --picture --banner --website " +
@@ -204,13 +204,13 @@ object ProfileCommands {
val signed = ctx.signer.sign(template) val signed = ctx.signer.sign(template)
val ack = ctx.publish(signed, targets) val ack = ctx.publish(signed, targets)
Json.writeLine( Output.emit(
mapOf( mapOf(
"event_id" to signed.id, "event_id" to signed.id,
"kind" to signed.kind, "kind" to signed.kind,
"created_at" to signed.createdAt, "created_at" to signed.createdAt,
"based_on" to latest?.id, "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 }, "published_to" to ack.filterValues { it }.keys.map { it.url },
"rejected_by" 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.Args
import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir 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.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
@@ -52,14 +52,14 @@ object RelayCommands {
dataDir: DataDir, dataDir: DataDir,
tail: Array<String>, tail: Array<String>,
): Int { ): 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 sub = tail[0]
val rest = tail.drop(1).toTypedArray() val rest = tail.drop(1).toTypedArray()
return when (sub) { return when (sub) {
"add" -> add(dataDir, Args(rest)) "add" -> add(dataDir, Args(rest))
"list" -> list(dataDir) "list" -> list(dataDir)
"publish-lists" -> publishLists(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 type = args.flag("type", "all") ?: "all"
val normalized = val normalized =
rawUrl.normalizeRelayUrlOrNull() 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 targets = if (type == "all") listOf("nip65", "inbox", "key_package") else listOf(type)
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
@@ -81,7 +81,7 @@ object RelayCommands {
for (t in targets) { for (t in targets) {
if (addToBucket(ctx, t, normalized)) addedTo.add(t) else alreadyPresent.add(t) if (addToBucket(ctx, t, normalized)) addedTo.add(t) else alreadyPresent.add(t)
} }
Json.writeLine( Output.emit(
mapOf( mapOf(
"url" to rawUrl, "url" to rawUrl,
"added_to" to addedTo, "added_to" to addedTo,
@@ -141,7 +141,7 @@ object RelayCommands {
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
val self = ctx.identity.pubKeyHex val self = ctx.identity.pubKeyHex
Json.writeLine( Output.emit(
mapOf( mapOf(
"nip65" to (ctx.relaysOf(self)?.relaysNorm()?.map { it.url } ?: emptyList()), "nip65" to (ctx.relaysOf(self)?.relaysNorm()?.map { it.url } ?: emptyList()),
"inbox" to (ctx.dmInboxOf(self)?.relays()?.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) val keyPackageEvent = ctx.keyPackageRelaysOf(self)
if (nip65Event == null && inboxEvent == null && keyPackageEvent == null) { if (nip65Event == null && inboxEvent == null && keyPackageEvent == null) {
return Json.error( return Output.error(
"no_relays", "no_relays",
"no relay lists in the local store; run `amy relay add` first or `amy create` to bootstrap defaults", "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 inboxResult = inboxEvent?.let { ctx.publish(it, targets) }.orEmpty()
val keyPackageResult = keyPackageEvent?.let { ctx.publish(it, targets) }.orEmpty() val keyPackageResult = keyPackageEvent?.let { ctx.publish(it, targets) }.orEmpty()
Json.writeLine( Output.emit(
mapOf( mapOf(
"nip65_event_id" to nip65Event?.id, "nip65_event_id" to nip65Event?.id,
"inbox_event_id" to inboxEvent?.id, "inbox_event_id" to inboxEvent?.id,
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.cli.commands package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.DataDir 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.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore
import java.io.IOException import java.io.IOException
@@ -51,21 +51,21 @@ object StoreCommands {
dataDir: DataDir, dataDir: DataDir,
tail: Array<String>, tail: Array<String>,
): Int { ): 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() val rest = tail.drop(1).toTypedArray()
return when (tail[0]) { return when (tail[0]) {
"stat" -> stat(dataDir) "stat" -> stat(dataDir)
"sweep-expired" -> sweepExpired(dataDir) "sweep-expired" -> sweepExpired(dataDir)
"scrub" -> scrub(dataDir) "scrub" -> scrub(dataDir)
"compact" -> compact(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 { private fun stat(dataDir: DataDir): Int {
val storeRoot = dataDir.eventsDir.toPath() val storeRoot = dataDir.eventsDir.toPath()
if (!storeRoot.exists()) { if (!storeRoot.exists()) {
Json.writeLine( Output.emit(
mapOf( mapOf(
"events" to 0, "events" to 0,
"by_kind" to emptyMap<String, Long>(), "by_kind" to emptyMap<String, Long>(),
@@ -119,7 +119,7 @@ object StoreCommands {
val diskBytes = walkSize(storeRoot) val diskBytes = walkSize(storeRoot)
Json.writeLine( Output.emit(
mapOf( mapOf(
"events" to count, "events" to count,
"by_kind" to byKind, "by_kind" to byKind,
@@ -138,7 +138,7 @@ object StoreCommands {
val before = countEntries(expiresAtDir) val before = countEntries(expiresAtDir)
store.deleteExpiredEvents() store.deleteExpiredEvents()
val after = countEntries(expiresAtDir) val after = countEntries(expiresAtDir)
Json.writeLine( Output.emit(
mapOf( mapOf(
"swept" to (before - after).coerceAtLeast(0L), "swept" to (before - after).coerceAtLeast(0L),
"remaining" to after, "remaining" to after,
@@ -150,14 +150,14 @@ object StoreCommands {
private fun scrub(dataDir: DataDir): Int = private fun scrub(dataDir: DataDir): Int =
withStore(dataDir) { store -> withStore(dataDir) { store ->
store.scrub() store.scrub()
Json.writeLine(mapOf("ok" to true)) Output.emit(mapOf("ok" to true))
0 0
} }
private fun compact(dataDir: DataDir): Int = private fun compact(dataDir: DataDir): Int =
withStore(dataDir) { store -> withStore(dataDir) { store ->
store.compact() store.compact()
Json.writeLine(mapOf("ok" to true)) Output.emit(mapOf("ok" to true))
0 0
} }
+5 -5
View File
@@ -81,7 +81,7 @@ source "$TESTS_DIR/headless/helpers.sh"
# shellcheck source=../dm/setup.sh # shellcheck source=../dm/setup.sh
source "$TESTS_DIR/dm/setup.sh" source "$TESTS_DIR/dm/setup.sh"
amy_b() { "$AMY_BIN" --data-dir "$B_DIR" "$@"; } amy_b() { "$AMY_BIN" --data-dir "$B_DIR" --json "$@"; }
# Helper: B-side amy_json. The dm headless's helpers.sh hardcodes A_DIR # Helper: B-side amy_json. The dm headless's helpers.sh hardcodes A_DIR
# in amy_a; we need a parallel for B without re-sourcing. # in amy_a; we need a parallel for B without re-sourcing.
@@ -249,10 +249,10 @@ fi
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
banner "T7 — store maintenance verbs (sweep/scrub/compact) without identity" banner "T7 — store maintenance verbs (sweep/scrub/compact) without identity"
TMP_NO_ID=$(mktemp -d) TMP_NO_ID=$(mktemp -d)
T7_STAT=$(${AMY_BIN} --data-dir "$TMP_NO_ID" store stat) T7_STAT=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store stat)
T7_SWEEP=$(${AMY_BIN} --data-dir "$TMP_NO_ID" store sweep-expired) T7_SWEEP=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store sweep-expired)
T7_SCRUB=$(${AMY_BIN} --data-dir "$TMP_NO_ID" store scrub) T7_SCRUB=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store scrub)
T7_COMPACT=$(${AMY_BIN} --data-dir "$TMP_NO_ID" store compact) 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 "" \ 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 without identity"
assert_eq "$(printf '%s' "$T7_SWEEP" | jq -r '.swept')" "0" T7.sweep.swept "" \ assert_eq "$(printf '%s' "$T7_SWEEP" | jq -r '.swept')" "0" T7.sweep.swept "" \
+3 -3
View File
@@ -75,15 +75,15 @@ preflight_dm() {
# `--secret-backend=plaintext` keeps these throwaway interop runs headless — # `--secret-backend=plaintext` keeps these throwaway interop runs headless —
# the default `auto` would try the OS keychain (not available in CI) and then # the default `auto` would try the OS keychain (not available in CI) and then
# ask for a NIP-49 passphrase. Plaintext still writes 0600-owner-only. # ask for a NIP-49 passphrase. Plaintext still writes 0600-owner-only.
amy_a() { "$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext "$@"; } amy_a() { "$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext --json "$@"; }
amy_d() { "$AMY_BIN" --data-dir "$D_DIR" --secret-backend plaintext "$@"; } amy_d() { "$AMY_BIN" --data-dir "$D_DIR" --secret-backend plaintext --json "$@"; }
# --- identity bootstrap ------------------------------------------------------ # --- identity bootstrap ------------------------------------------------------
ensure_identity_for() { ensure_identity_for() {
local who="$1" dir="$2" local who="$1" dir="$2"
step "initialising Identity $who (amy at $dir)" step "initialising Identity $who (amy at $dir)"
local out 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 fail_msg "amy init failed for $who: $out"; exit 1
} }
local npub hex local npub hex
+6 -4
View File
@@ -13,10 +13,12 @@
# dm-06 cursor advance (subsequent no-flag `dm list` is empty) # dm-06 cursor advance (subsequent no-flag `dm list` is empty)
# Wrap amy_json around either data-dir so the per-test code stays tight. # Wrap amy_json around either 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() { amy_json_for() {
local dir="$1"; shift local dir="$1"; shift
local out 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)" fail_msg "amy --data-dir $dir $*: exit $? (see $LOG_FILE)"
printf '%s\n' "$out" >>"$LOG_FILE" printf '%s\n' "$out" >>"$LOG_FILE"
return 1 return 1
@@ -91,7 +93,7 @@ test_03_dm_send_rejects_no_inbox() {
# Generate a throwaway identity but do NOT publish its kind:10050. # Generate a throwaway identity but do NOT publish its kind:10050.
local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX") local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX")
local ghost_out ghost_npub 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 record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return
} }
ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub') 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. # A sends without --allow-fallback; amy should refuse.
local raw rc 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=$? rc=$?
rm -rf "$tmpdir" rm -rf "$tmpdir"
if [[ "$rc" -ne 0 ]] && printf '%s' "$raw" | grep -q '"error":"no_dm_relays"'; then if [[ "$rc" -ne 0 ]] && printf '%s' "$raw" | grep -q '"error":"no_dm_relays"'; then
@@ -121,7 +123,7 @@ test_04_dm_send_allow_fallback() {
# publish should succeed even though the ghost has no 10050. # publish should succeed even though the ghost has no 10050.
local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX") local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX")
local ghost_out ghost_npub 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 record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return
} }
ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub') 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 — # `--secret-backend=plaintext` keeps these throwaway interop runs headless —
# the default `auto` would try the OS keychain (not available in CI) and then # the default `auto` would try the OS keychain (not available in CI) and then
# ask for a NIP-49 passphrase. Plaintext still writes 0600-owner-only. # ask for a NIP-49 passphrase. Plaintext still writes 0600-owner-only.
amy_a() { "$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext "$@"; } # `--json` 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. # Run amy, log stderr, surface JSON on stdout, remember last result.
amy_json() { amy_json() {
+1 -1
View File
@@ -271,7 +271,7 @@ test_14_wn_removes_a() {
local deadline=$(( $(date +%s) + 120 )) removed=0 local deadline=$(( $(date +%s) + 120 )) removed=0
while [[ $(date +%s) -lt $deadline ]]; do while [[ $(date +%s) -lt $deadline ]]; do
local show rc local show rc
show=$("$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext \ show=$("$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext --json \
marmot group show "$a_gid" 2>&1) marmot group show "$a_gid" 2>&1)
rc=$? rc=$?
printf '%s\n' "$show" >>"$LOG_FILE" printf '%s\n' "$show" >>"$LOG_FILE"