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