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
|
## 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
@@ -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,
|
||||||
|
|||||||
+4
-4
@@ -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),
|
||||||
|
|||||||
+8
-8
@@ -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,
|
||||||
|
|||||||
+6
-6
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+5
-5
@@ -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 "" \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user