mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
fix(cli): repair the exit-code and flag-parsing contract
- Output.error now derives the exit code from the error code (bad_args -> 2, timeout -> 124, else 1), so every 'return Output.error(...)' site honours the documented contract. Previously ~225 bad_args sites exited 1 while the docs promised 2, and two timeout paths (nostrconnect wait, namecoin lookup) exited 1 instead of 124. - Args: literal '--' ends flag parsing (escape hatch for values that start with '--'); intFlag/longFlag reject non-numeric values instead of silently using the default; requireFlag/positional no longer double-print to stderr; new rejectUnknown() turns typo'd flags into bad_args failures; new 'help' detection. - route() understands --help/-h/help (prints group usage, exit 0) and names the expected verbs on an unknown sub-verb. - Unknown or missing top-level subcommand now emits a proper bad_args error (JSON-aware under --json) plus a one-screen verb list instead of dumping the full 400-line usage. - RawEventSupport: --relay/--kind/--author/--id/--since/--until/--limit entries that do not parse are now bad_args errors; previously an unresolvable --author was silently DROPPED and the query ran with a weaker filter than requested. New shared publishGuard() reports 'rejected' (exit 1) when every relay refuses an event. - runCli() seam extracted from main() plus an 'amy.home' system-property override of DEFAULT_ROOT so the new JVM test suite can drive the CLI in-process; first contract tests: ArgsTest, ExitCodeContractTest, JsonContractTest (NIP-19 vector goldens). BREAKING (--json): error code pow_timeout is now timeout; exit codes for bad-argument failures move from 1 to 2 as documented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
This commit is contained in:
@@ -17,6 +17,10 @@ sourceSets {
|
||||
kotlin.srcDir("src/main/kotlin")
|
||||
resources.srcDir("src/main/resources")
|
||||
}
|
||||
test {
|
||||
kotlin.srcDir("src/test/kotlin")
|
||||
resources.srcDir("src/test/resources")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -32,6 +36,11 @@ dependencies {
|
||||
implementation(libs.okhttpCoroutines)
|
||||
implementation(libs.jackson.module.kotlin)
|
||||
implementation(libs.slf4j.nop)
|
||||
|
||||
testImplementation(libs.kotlin.test)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
// The JVM secp256k1 JNI binding so tests can exercise real signing/verification.
|
||||
testImplementation(libs.secp256k1.kmp.jni.jvm)
|
||||
}
|
||||
|
||||
// amy is headless. It compiles against zero Compose UI (the Compose deps are
|
||||
|
||||
@@ -24,8 +24,12 @@ package com.vitorpamplona.amethyst.cli
|
||||
* Minimal argv parser. Splits flags (--key value or --key=value) from positional args.
|
||||
* Boolean flags are those whose next token starts with "--" or is absent.
|
||||
*
|
||||
* The parser is intentionally tiny — this CLI is driven by shell scripts, not humans,
|
||||
* so we don't need subcommand groups, short flags, or help text generation.
|
||||
* A literal `--` token ends flag parsing: every later token is positional even if
|
||||
* it starts with `--` (the same escape hatch getopt-style tools offer, so
|
||||
* `amy notes post -- "--good morning"` posts the literal text).
|
||||
*
|
||||
* Every accessor records the flag names a command supports; [rejectUnknown] then
|
||||
* turns any leftover `--typo` into a `bad_args` failure instead of a silent no-op.
|
||||
*/
|
||||
class Args(
|
||||
argv: Array<String>,
|
||||
@@ -34,14 +38,21 @@ class Args(
|
||||
val booleans: Set<String>
|
||||
val positional: List<String>
|
||||
|
||||
/** Flag names a command declared support for by reading them. */
|
||||
private val consumed = mutableSetOf<String>()
|
||||
|
||||
init {
|
||||
val f = mutableMapOf<String, String>()
|
||||
val b = mutableSetOf<String>()
|
||||
val p = mutableListOf<String>()
|
||||
var i = 0
|
||||
var flagsEnded = false
|
||||
while (i < argv.size) {
|
||||
val a = argv[i]
|
||||
if (a.startsWith("--")) {
|
||||
if (!flagsEnded && a == "--") {
|
||||
flagsEnded = true
|
||||
i++
|
||||
} else if (!flagsEnded && a.startsWith("--")) {
|
||||
val eq = a.indexOf('=')
|
||||
if (eq >= 0) {
|
||||
f[a.substring(2, eq)] = a.substring(eq + 1)
|
||||
@@ -67,37 +78,72 @@ class Args(
|
||||
positional = p
|
||||
}
|
||||
|
||||
/** True when the caller asked for help (`--help`, or `-h` slipping in as a positional). */
|
||||
val help: Boolean get() = "help" in booleans || positional.firstOrNull() == "-h"
|
||||
|
||||
fun flag(
|
||||
name: String,
|
||||
default: String? = null,
|
||||
): String? = flags[name] ?: default
|
||||
): String? {
|
||||
consumed.add(name)
|
||||
return flags[name] ?: default
|
||||
}
|
||||
|
||||
fun intFlag(
|
||||
name: String,
|
||||
default: Int,
|
||||
): Int = flags[name]?.toIntOrNull() ?: default
|
||||
): Int {
|
||||
consumed.add(name)
|
||||
val raw = flags[name] ?: return default
|
||||
return raw.toIntOrNull()
|
||||
?: throw IllegalArgumentException("--$name expects a number, got '$raw'")
|
||||
}
|
||||
|
||||
fun longFlag(
|
||||
name: String,
|
||||
default: Long,
|
||||
): Long = flags[name]?.toLongOrNull() ?: default
|
||||
): Long {
|
||||
consumed.add(name)
|
||||
val raw = flags[name] ?: return default
|
||||
return raw.toLongOrNull()
|
||||
?: throw IllegalArgumentException("--$name expects a number, got '$raw'")
|
||||
}
|
||||
|
||||
fun requireFlag(name: String): String =
|
||||
flags[name] ?: run {
|
||||
System.err.println("missing required flag: --$name")
|
||||
throw IllegalArgumentException("missing flag $name")
|
||||
fun requireFlag(name: String): String {
|
||||
consumed.add(name)
|
||||
return flags[name]
|
||||
?: throw IllegalArgumentException("missing required flag: --$name")
|
||||
}
|
||||
|
||||
fun bool(name: String): Boolean {
|
||||
consumed.add(name)
|
||||
return name in booleans
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail with `bad_args` (exit 2) when argv carried a flag no accessor asked
|
||||
* about — the difference between `--limt 5` silently no-oping and the user
|
||||
* learning about the typo. Call after every supported flag has been read
|
||||
* (a conditional read still counts: `flag()`/`bool()` record the name even
|
||||
* when the flag is absent). Commands that forward arbitrary flags simply
|
||||
* don't call this.
|
||||
*/
|
||||
fun rejectUnknown(vararg alsoAllowed: String) {
|
||||
val known = consumed + alsoAllowed + setOf("help")
|
||||
val unknown = (flags.keys + booleans).filterNot { it in known }
|
||||
if (unknown.isNotEmpty()) {
|
||||
throw IllegalArgumentException(
|
||||
"unknown flag${if (unknown.size > 1) "s" else ""}: ${unknown.joinToString(", ") { "--$it" }}",
|
||||
)
|
||||
}
|
||||
|
||||
fun bool(name: String): Boolean = name in booleans
|
||||
}
|
||||
|
||||
fun positional(
|
||||
index: Int,
|
||||
name: String,
|
||||
): String =
|
||||
positional.getOrNull(index) ?: run {
|
||||
System.err.println("missing positional arg: $name (index $index)")
|
||||
throw IllegalArgumentException("missing positional $name")
|
||||
}
|
||||
positional.getOrNull(index)
|
||||
?: throw IllegalArgumentException("missing positional arg: $name")
|
||||
|
||||
fun positionalOrNull(index: Int): String? = positional.getOrNull(index)
|
||||
}
|
||||
|
||||
@@ -106,6 +106,16 @@ import kotlin.system.exitProcess
|
||||
* shape is not. Diagnostic logs always go to stderr.
|
||||
*/
|
||||
fun main(argv: Array<String>) {
|
||||
exitProcess(runCli(argv))
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything [main] does except the process exit — the seam the JVM test
|
||||
* suite drives (`exitProcess` would kill the test JVM). Note the shared
|
||||
* mutable bits ([Output.mode], [Log.minLevel]) persist across calls; tests
|
||||
* reset them between invocations.
|
||||
*/
|
||||
fun runCli(argv: Array<String>): Int {
|
||||
// Force AWT headless before any class load that might touch ImageIO,
|
||||
// Toolkit, or Graphics2D (image upload pulls in BufferedImage via
|
||||
// commons MediaMetadataReader / ImageReencoder). The Gradle launcher
|
||||
@@ -137,7 +147,7 @@ fun main(argv: Array<String>) {
|
||||
Output.error("runtime", "${e::class.simpleName}: ${e.message}")
|
||||
1
|
||||
}
|
||||
exitProcess(code)
|
||||
return code
|
||||
}
|
||||
|
||||
class AwaitTimeout(
|
||||
@@ -181,7 +191,8 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
i += consumed.tokensConsumed
|
||||
}
|
||||
if (filteredArgs.isEmpty()) {
|
||||
printUsage()
|
||||
Output.error("bad_args", "no subcommand given")
|
||||
printVerbList()
|
||||
return 2
|
||||
}
|
||||
|
||||
@@ -305,13 +316,37 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
}
|
||||
"concord" -> ConcordCommands.dispatch(dataDir, tail)
|
||||
else -> {
|
||||
System.err.println("unknown subcommand: $head")
|
||||
printUsage()
|
||||
Output.error("bad_args", "unknown subcommand: $head")
|
||||
printVerbList()
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-screen alternative to the full usage dump: shown on an unknown or
|
||||
* missing subcommand so the error stays readable. `amy --help` still prints
|
||||
* the full reference.
|
||||
*/
|
||||
private fun printVerbList() {
|
||||
System.err.println(
|
||||
"""
|
||||
|
|
||||
|Commands (see `amy --help` for the full reference, `amy <cmd> --help` for one group):
|
||||
| identity: init create login logoff whoami use status
|
||||
| primitives: decode encode verify key filter nip kind pow namecoin
|
||||
| events: event publish fetch subscribe count sync encrypt decrypt gift
|
||||
| social: notes profile follow unfollow search zap dm outbox
|
||||
| groups: marmot relaygroup concord geochat
|
||||
| relays: relay admin serve store
|
||||
| trust: graperank fof
|
||||
| media/sites: blossom nsite napplet podcast podcast20 git
|
||||
| payments: cashu offer debit
|
||||
| signing: bunker
|
||||
""".trimMargin(),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun marmotDispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
@@ -680,15 +715,15 @@ private fun printUsage() {
|
||||
|CLINK Offers:
|
||||
| offer info NOFFER decode a noffer1… pointer (local, no network)
|
||||
| offer request NOFFER [--amount SATS] kind:21001 round-trip: ask the service for a
|
||||
| [--timeout MS] fresh BOLT11 (amount required for spontaneous
|
||||
| [--timeout SECS] fresh BOLT11 (amount required for spontaneous
|
||||
| offers; defaults to the pointer's fixed price)
|
||||
|
|
||||
|CLINK Debits:
|
||||
| debit info NDEBIT decode an ndebit1… pointer (local, no network)
|
||||
| debit pay NDEBIT BOLT11 [--amount SATS] kind:21002 round-trip: ask the wallet to pay the
|
||||
| [--timeout MS] invoice; prints the preimage or a GFY error
|
||||
| [--timeout SECS] invoice; prints the preimage or a GFY error
|
||||
| debit budget NDEBIT --amount SATS authorize a spending budget; omit --frequency
|
||||
| [--frequency day|week|month] [--timeout MS] for a one-time budget
|
||||
| [--frequency day|week|month] [--timeout SECS] for a one-time budget
|
||||
|
|
||||
|Search (NIP-50):
|
||||
| search user QUERY [--limit N] search kind:0 profiles
|
||||
|
||||
@@ -60,6 +60,12 @@ object Output {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a failure and return the exit code the process should end with.
|
||||
* The code string picks the exit code — `bad_args` → 2, `timeout` → 124,
|
||||
* everything else → 1 — so `return Output.error(…)` always honours the
|
||||
* documented exit-code contract without per-site bookkeeping.
|
||||
*/
|
||||
fun error(
|
||||
code: String,
|
||||
detail: String? = null,
|
||||
@@ -83,7 +89,11 @@ object Output {
|
||||
System.err.println(base + suffix)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
return when (code) {
|
||||
"bad_args" -> 2
|
||||
"timeout" -> 124
|
||||
else -> 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
@@ -50,15 +51,36 @@ object RawEventSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a `--relay a,b,c` flag into normalized relay URLs (silently drops un-normalizable entries). */
|
||||
/** Parse a `--relay a,b,c` flag into normalized relay URLs; an un-normalizable entry is a `bad_args` failure. */
|
||||
fun relayFlag(args: Args): Set<NormalizedRelayUrl> =
|
||||
args
|
||||
.flag("relay")
|
||||
?.split(',')
|
||||
?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) }
|
||||
?.toSet()
|
||||
?.map { raw ->
|
||||
RelayUrlNormalizer.normalizeOrNull(raw.trim())
|
||||
?: throw IllegalArgumentException("invalid relay url: ${raw.trim()}")
|
||||
}?.toSet()
|
||||
.orEmpty()
|
||||
|
||||
/**
|
||||
* The exit decision after a publish: `null` when at least one relay (or
|
||||
* no relay at all — a deliberately local-only build) accepted the event,
|
||||
* or a non-zero exit code after reporting `rejected` when every targeted
|
||||
* relay refused it. Callers use `publishGuard(ack, event.id)?.let { return it }`
|
||||
* so a total rejection stops a `set -e` script instead of exiting 0.
|
||||
*/
|
||||
fun publishGuard(
|
||||
ack: Map<NormalizedRelayUrl, Boolean>,
|
||||
eventId: String,
|
||||
): Int? {
|
||||
if (ack.isEmpty() || ack.any { it.value }) return null
|
||||
return Output.error(
|
||||
"rejected",
|
||||
"no relay accepted event $eventId",
|
||||
extra = mapOf("event_id" to eventId, "rejected_by" to ack.keys.map { it.url }),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve where to publish: the explicit `--relay` set when given,
|
||||
* otherwise the account's NIP-65 outbox. Empty only when neither is
|
||||
@@ -91,27 +113,38 @@ object RawEventSupport {
|
||||
* --search TEXT NIP-50
|
||||
*
|
||||
* Author/id decoding is local (no NIP-05 round-trip) — pass hex or a
|
||||
* bech32 entity. Unparseable entries are dropped.
|
||||
* bech32 entity. An unparseable entry is a `bad_args` failure: silently
|
||||
* dropping it would run the query with a *weaker* filter than the user
|
||||
* asked for and return silently-wrong results.
|
||||
*/
|
||||
fun buildFilter(args: Args): Filter {
|
||||
val kinds =
|
||||
args
|
||||
.flag("kind")
|
||||
?.split(',')
|
||||
?.mapNotNull { it.trim().toIntOrNull() }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.map { raw ->
|
||||
raw.trim().toIntOrNull()
|
||||
?: throw IllegalArgumentException("--kind expects a number, got '${raw.trim()}'")
|
||||
}?.takeIf { it.isNotEmpty() }
|
||||
val authors =
|
||||
args
|
||||
.flag("author")
|
||||
?.split(',')
|
||||
?.mapNotNull { decodePublicKeyAsHexOrNull(it.trim()) }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.map { raw ->
|
||||
decodePublicKeyAsHexOrNull(raw.trim())
|
||||
?: throw IllegalArgumentException(
|
||||
"--author expects npub/nprofile/64-hex, got '${raw.trim()}' " +
|
||||
"(NIP-05 names need a network round-trip — resolve first with `amy profile show`)",
|
||||
)
|
||||
}?.takeIf { it.isNotEmpty() }
|
||||
val ids =
|
||||
args
|
||||
.flag("id")
|
||||
?.split(',')
|
||||
?.mapNotNull { decodeEventIdAsHexOrNull(it.trim()) }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.map { raw ->
|
||||
decodeEventIdAsHexOrNull(raw.trim())
|
||||
?: throw IllegalArgumentException("--id expects note/nevent/naddr/64-hex, got '${raw.trim()}'")
|
||||
}?.takeIf { it.isNotEmpty() }
|
||||
val tags =
|
||||
args
|
||||
.flag("tag")
|
||||
@@ -127,9 +160,9 @@ object RawEventSupport {
|
||||
authors = authors,
|
||||
kinds = kinds,
|
||||
tags = tags,
|
||||
since = args.flag("since")?.toLongOrNull(),
|
||||
until = args.flag("until")?.toLongOrNull(),
|
||||
limit = args.flag("limit")?.toIntOrNull(),
|
||||
since = args.flag("since")?.let { it.toLongOrNull() ?: throw IllegalArgumentException("--since expects unix seconds, got '$it'") },
|
||||
until = args.flag("until")?.let { it.toLongOrNull() ?: throw IllegalArgumentException("--until expects unix seconds, got '$it'") },
|
||||
limit = args.flag("limit")?.let { it.toIntOrNull() ?: throw IllegalArgumentException("--limit expects a number, got '$it'") },
|
||||
search = args.flag("search"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,17 +26,28 @@ import com.vitorpamplona.amethyst.cli.Output
|
||||
* Shared sub-verb router used by every `*Commands.dispatch`.
|
||||
*
|
||||
* Maps the first token of [tail] to a handler over the remaining args.
|
||||
* Empty input emits `bad_args: <usage>`; an unrecognised verb emits
|
||||
* `bad_args: <name> <verb>`. Handlers receive the args *after* the verb,
|
||||
* mirroring the old hand-rolled `when (tail[0]) { … }` blocks.
|
||||
* `--help` / `-h` / `help` prints the group's [help] text (falling back to
|
||||
* the one-line [usage]) to stderr and exits 0. Empty input emits
|
||||
* `bad_args: <usage>`; an unrecognised verb names the verbs that do exist.
|
||||
* Handlers receive the args *after* the verb, mirroring the old
|
||||
* hand-rolled `when (tail[0]) { … }` blocks.
|
||||
*/
|
||||
suspend fun route(
|
||||
name: String,
|
||||
tail: Array<String>,
|
||||
usage: String,
|
||||
routes: Map<String, suspend (Array<String>) -> Int>,
|
||||
help: String? = null,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Output.error("bad_args", usage)
|
||||
val handler = routes[tail[0]] ?: return Output.error("bad_args", "$name ${tail[0]}")
|
||||
when (tail.firstOrNull()) {
|
||||
null -> return Output.error("bad_args", usage)
|
||||
"--help", "-h", "help" -> {
|
||||
System.err.println(help ?: usage)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
val handler =
|
||||
routes[tail[0]]
|
||||
?: return Output.error("bad_args", "unknown verb: $name ${tail[0]} (expected ${routes.keys.joinToString("|")})")
|
||||
return handler(tail.drop(1).toTypedArray())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* 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 kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ArgsTest {
|
||||
@Test
|
||||
fun flagForms() {
|
||||
val args = Args(arrayOf("--name", "alice", "--about=hi there", "pos1"))
|
||||
assertEquals("alice", args.flag("name"))
|
||||
assertEquals("hi there", args.flag("about"))
|
||||
assertEquals("pos1", args.positional(0, "first"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun booleanFlags() {
|
||||
val args = Args(arrayOf("--json-ish", "--limit", "5"))
|
||||
assertTrue(args.bool("json-ish"))
|
||||
assertEquals(5, args.intFlag("limit", 1))
|
||||
assertFalse(args.bool("absent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doubleDashEndsFlagParsing() {
|
||||
val args = Args(arrayOf("--relay", "wss://a", "--", "--not-a-flag", "-x"))
|
||||
assertEquals("wss://a", args.flag("relay"))
|
||||
assertEquals(listOf("--not-a-flag", "-x"), args.positional)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonNumericIntFlagThrows() {
|
||||
val args = Args(arrayOf("--limit", "ten"))
|
||||
assertFailsWith<IllegalArgumentException> { args.intFlag("limit", 1) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonNumericLongFlagThrows() {
|
||||
val args = Args(arrayOf("--timeout", "soon"))
|
||||
assertFailsWith<IllegalArgumentException> { args.longFlag("timeout", 1) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun absentNumericFlagsFallBackToDefault() {
|
||||
val args = Args(arrayOf())
|
||||
assertEquals(7, args.intFlag("limit", 7))
|
||||
assertEquals(9L, args.longFlag("timeout", 9))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requireFlagThrowsWithFlagNameInMessage() {
|
||||
val e = assertFailsWith<IllegalArgumentException> { Args(arrayOf()).requireFlag("server") }
|
||||
assertTrue(e.message!!.contains("--server"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingPositionalThrows() {
|
||||
assertFailsWith<IllegalArgumentException> { Args(arrayOf()).positional(0, "text") }
|
||||
assertNull(Args(arrayOf()).positionalOrNull(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectUnknownFlagsTypo() {
|
||||
val args = Args(arrayOf("--limt", "5"))
|
||||
args.intFlag("limit", 1)
|
||||
val e = assertFailsWith<IllegalArgumentException> { args.rejectUnknown() }
|
||||
assertTrue(e.message!!.contains("--limt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectUnknownPassesWhenAllFlagsRead() {
|
||||
val args = Args(arrayOf("--limit", "5", "--force"))
|
||||
args.intFlag("limit", 1)
|
||||
args.bool("force")
|
||||
args.rejectUnknown() // must not throw
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectUnknownHonoursAlsoAllowed() {
|
||||
val args = Args(arrayOf("--relay", "wss://a"))
|
||||
args.rejectUnknown("relay") // read by a helper later — declared instead
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectUnknownAlwaysAllowsHelp() {
|
||||
val args = Args(arrayOf("--help"))
|
||||
args.rejectUnknown()
|
||||
assertTrue(args.help)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conditionalReadStillRegisters() {
|
||||
// flag() records the name even when the flag is absent from argv.
|
||||
val args = Args(arrayOf("--mint", "https://m"))
|
||||
assertNull(args.flag("mints"))
|
||||
assertEquals("https://m", args.flag("mint"))
|
||||
args.rejectUnknown()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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 java.io.ByteArrayOutputStream
|
||||
import java.io.PrintStream
|
||||
import java.nio.file.Files
|
||||
|
||||
/**
|
||||
* Drives [runCli] in-process with captured stdout/stderr and an isolated
|
||||
* `~/.amy` (via the `amy.home` system-property seam in [DataDir.DEFAULT_ROOT]).
|
||||
* The global [Output.mode] is reset around every run so `--json` in one
|
||||
* invocation cannot leak into the next.
|
||||
*/
|
||||
data class CliResult(
|
||||
val exit: Int,
|
||||
val stdout: String,
|
||||
val stderr: String,
|
||||
) {
|
||||
val stdoutLines: List<String> get() = stdout.trim().lines().filter { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun amy(vararg argv: String): CliResult {
|
||||
val outBuf = ByteArrayOutputStream()
|
||||
val errBuf = ByteArrayOutputStream()
|
||||
val prevOut = System.out
|
||||
val prevErr = System.err
|
||||
val prevHome = System.getProperty("amy.home")
|
||||
val tempHome = Files.createTempDirectory("amy-test").toFile()
|
||||
System.setProperty("amy.home", tempHome.absolutePath)
|
||||
Output.mode = Output.Mode.TEXT
|
||||
return try {
|
||||
System.setOut(PrintStream(outBuf, true, Charsets.UTF_8))
|
||||
System.setErr(PrintStream(errBuf, true, Charsets.UTF_8))
|
||||
val exit = runCli(argv.toList().toTypedArray())
|
||||
CliResult(exit, outBuf.toString(Charsets.UTF_8), errBuf.toString(Charsets.UTF_8))
|
||||
} finally {
|
||||
System.setOut(prevOut)
|
||||
System.setErr(prevErr)
|
||||
Output.mode = Output.Mode.TEXT
|
||||
if (prevHome == null) System.clearProperty("amy.home") else System.setProperty("amy.home", prevHome)
|
||||
tempHome.deleteRecursively()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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 kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The documented exit-code contract (README/DEVELOPMENT): 0 success,
|
||||
* 1 runtime error, 2 bad arguments, 124 timeout. These tests pin the
|
||||
* codes an interop script keys on.
|
||||
*/
|
||||
class ExitCodeContractTest {
|
||||
@Test
|
||||
fun noSubcommandExits2() {
|
||||
assertEquals(2, amy().exit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownSubcommandExits2() {
|
||||
val r = amy("frobnicate")
|
||||
assertEquals(2, r.exit)
|
||||
assertTrue(r.stderr.contains("unknown subcommand"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownSubcommandPrintsShortVerbListNotFullUsage() {
|
||||
val r = amy("frobnicate")
|
||||
// The full reference is ~400 lines; the error path must stay one screen.
|
||||
assertTrue(r.stderr.lines().size < 30, "expected a short verb list, got ${r.stderr.lines().size} lines")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingPositionalExits2() {
|
||||
assertEquals(2, amy("decode").exit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownSubVerbExits2() {
|
||||
val r = amy("notes", "bogusverb")
|
||||
assertEquals(2, r.exit)
|
||||
assertTrue(r.stderr.contains("bad_args"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonNumericFilterFlagExits2() {
|
||||
assertEquals(2, amy("filter", "--limit", "ten").exit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unresolvableAuthorErrorsInsteadOfSilentlyDropping() {
|
||||
// Historically `--author bob@example.com` was silently dropped and the
|
||||
// query ran unfiltered — the worst possible failure mode for a script.
|
||||
val r = amy("filter", "--author", "bob@example.com")
|
||||
assertEquals(2, r.exit)
|
||||
assertTrue(r.stderr.contains("--author"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownFlagTypoExits2() {
|
||||
val r = amy("key", "generate", "--limt", "5")
|
||||
assertEquals(2, r.exit)
|
||||
assertTrue(r.stderr.contains("--limt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun topLevelHelpExits0() {
|
||||
assertEquals(0, amy("--help").exit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupHelpExits0AndDoesNotRun() {
|
||||
val r = amy("notes", "--help")
|
||||
assertEquals(0, r.exit)
|
||||
assertTrue(r.stderr.contains("post"), "group help should list its sub-verbs")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flatCommandHelpExits0AndDoesNotRun() {
|
||||
// `fetch --help` used to RUN A REAL NETWORK FETCH. It must print usage
|
||||
// and exit 0 without touching the network (an isolated empty ~/.amy +
|
||||
// instant return is the observable proof).
|
||||
val r = amy("fetch", "--help")
|
||||
assertEquals(0, r.exit)
|
||||
assertTrue(r.stderr.contains("fetch"))
|
||||
assertTrue(r.stdout.isBlank(), "help must not emit a result object")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun powMineTimeoutExits124() {
|
||||
val template = """{"created_at":1,"kind":1,"tags":[],"content":"x"}"""
|
||||
val pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
|
||||
val r = amy("pow", "mine", "--target", "60", "--timeout", "1", "--pubkey", pubkey, template)
|
||||
assertEquals(124, r.exit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jsonErrorsAreSingleJsonObjectOnStderr() {
|
||||
val r = amy("--json", "decode")
|
||||
assertEquals(2, r.exit)
|
||||
assertTrue(r.stdout.isBlank(), "errors must not write stdout")
|
||||
val errLines =
|
||||
r.stderr
|
||||
.trim()
|
||||
.lines()
|
||||
.filter { it.isNotBlank() && it.startsWith("{") }
|
||||
assertEquals(1, errLines.size, "expected exactly one JSON error object on stderr, got: ${r.stderr}")
|
||||
val parsed = Output.mapper.readTree(errLines.single())
|
||||
assertEquals("bad_args", parsed["error"].asText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun errorsAreNotDoubleReported() {
|
||||
// Args used to print a plain-text line AND throw (reported again by
|
||||
// main) — two error lines per failure.
|
||||
val r = amy("decode")
|
||||
val errorLines =
|
||||
r.stderr
|
||||
.trim()
|
||||
.lines()
|
||||
.filter { it.contains("missing", ignoreCase = true) }
|
||||
assertEquals(1, errorLines.size, "expected one error line, got: ${r.stderr}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Pins the `--json` machine contract for the deterministic stateless
|
||||
* primitives: one JSON object, one line, stable snake_case keys. Uses the
|
||||
* NIP-19 spec test vectors so the goldens are protocol-anchored, not
|
||||
* implementation-anchored.
|
||||
*/
|
||||
class JsonContractTest {
|
||||
private val vectorHex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
|
||||
private val vectorNpub = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"
|
||||
|
||||
private fun jsonOf(r: CliResult): JsonNode {
|
||||
assertEquals(0, r.exit, "expected success, stderr: ${r.stderr}")
|
||||
assertEquals(1, r.stdoutLines.size, "expected exactly one stdout line, got: ${r.stdout}")
|
||||
return Output.mapper.readTree(r.stdoutLines.single())
|
||||
}
|
||||
|
||||
private fun assertSnakeCaseKeys(node: JsonNode) {
|
||||
node.fieldNames().forEach { key ->
|
||||
assertTrue(key.matches(Regex("[a-z0-9_]+")), "non-snake_case key: $key")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeNpubMatchesNip19Vector() {
|
||||
val json = jsonOf(amy("--json", "encode", "npub", vectorHex))
|
||||
assertEquals(vectorNpub, json["npub"].asText())
|
||||
assertSnakeCaseKeys(json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeNpubMatchesNip19Vector() {
|
||||
val json = jsonOf(amy("--json", "decode", vectorNpub))
|
||||
assertTrue(json.toString().contains(vectorHex), "decode result should carry the hex pubkey: $json")
|
||||
assertSnakeCaseKeys(json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keyGenerateThenPublicRoundTrips() {
|
||||
val generated = jsonOf(amy("--json", "key", "generate"))
|
||||
val nsec = generated["nsec"].asText()
|
||||
val pubkey = generated["pubkey"].asText()
|
||||
assertTrue(nsec.startsWith("nsec1"))
|
||||
assertEquals(64, pubkey.length)
|
||||
assertSnakeCaseKeys(generated)
|
||||
|
||||
val derived = jsonOf(amy("--json", "key", "public", nsec))
|
||||
assertEquals(pubkey, derived["pubkey"].asText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filterAssemblesTheFlagsItWasGiven() {
|
||||
val json = jsonOf(amy("--json", "filter", "--kind", "1,7", "--limit", "5"))
|
||||
val kinds = json["kinds"].map { it.asInt() }
|
||||
assertEquals(listOf(1, 7), kinds)
|
||||
assertEquals(5, json["limit"].asInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifyReportsIdAndSignatureSeparately() {
|
||||
// A structurally valid event with a wrong id/signature must still parse
|
||||
// and report the two checks as fields, not crash.
|
||||
val bogus =
|
||||
"""{"id":"${"0".repeat(64)}","pubkey":"$vectorHex","created_at":1,"kind":1,"tags":[],"content":"x","sig":"${"0".repeat(128)}"}"""
|
||||
val r = amy("--json", "verify", bogus)
|
||||
val line =
|
||||
r.stdoutLines.singleOrNull() ?: r.stderr
|
||||
.trim()
|
||||
.lines()
|
||||
.last()
|
||||
val json = Output.mapper.readTree(line)
|
||||
assertTrue(json.has("id_ok") || json.has("error"), "verify should report id_ok/signature_ok or a structured error: $json")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun textModeAndJsonModeCarrySameData() {
|
||||
val json = jsonOf(amy("--json", "encode", "npub", vectorHex))
|
||||
val text = amy("encode", "npub", vectorHex)
|
||||
assertEquals(0, text.exit)
|
||||
assertTrue(text.stdout.contains(json["npub"].asText()), "text render should carry the same npub")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user