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:
Claude
2026-07-18 20:54:14 +00:00
parent 7e3c01ab08
commit 6bcae39b83
10 changed files with 621 additions and 42 deletions
+9
View File
@@ -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")
}
}