From d60a618653c5af8d5fb07d70a1432f571d2b3876 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 15:38:21 +0000 Subject: [PATCH] feat(amy): list an author's napplets / nsites Add `amy napplet list ` and `amy nsite list `: fetch the author's root + named manifests (15129/35129 for napplets, 15128/35128 for nsites), keep the latest per identifier, and emit a summary of each (kind, d, title, description, path count, servers, requires/aggregate, event id, created_at). Thin assembly over ctx.drain + the quartz manifest accessors. Harness README notes `amy napplet list` for enumerating what you've published. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- .../amethyst/cli/commands/NappletCommands.kt | 54 ++++++++++++++- .../amethyst/cli/commands/NsiteCommands.kt | 66 ++++++++++++++++++- tools/napplet-test/README.md | 1 + 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt index 489a42b147..45f12f106e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt @@ -57,14 +57,66 @@ object NappletCommands { route( "napplet", tail, - "napplet …", + "napplet …", mapOf( "fetch" to { rest -> fetch(dataDir, rest) }, "publish" to { rest -> publish(dataDir, rest) }, "serve" to { rest -> serve(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, ), ) + /** + * `amy napplet list [--relay R] [--timeout S]` — enumerate an author's published + * napplets: the root (kind 15129) and every named one (kind 35129), latest per identifier. + */ + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val author = args.positionalOrNull(0) ?: return Output.error("bad_args", "napplet list [--relay R] [--timeout S]") + val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) + val timeoutSecs = args.longFlag("timeout", 8L) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val authorHex = ctx.requireUserHex(author) + val relays = + extraRelays + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + .ifEmpty { ctx.bootstrapRelays() } + val filter = Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), authors = listOf(authorHex)) + val items = + ctx + .drain(relays.associateWith { listOf(filter) }, timeoutSecs * 1000) + .map { (_, ev) -> ev } + .filter { it.pubKey == authorHex && it is NappletManifest } + .groupBy { it.kind to (it as? NamedNappletEvent)?.identifier() } + .mapNotNull { (_, dupes) -> dupes.maxByOrNull { it.createdAt } } + .sortedByDescending { it.createdAt } + .map { ev -> + val m = ev as NappletManifest + mapOf( + "kind" to ev.kind, + "d" to (ev as? NamedNappletEvent)?.identifier(), + "title" to m.title(), + "description" to m.description(), + "paths" to m.paths().size, + "servers" to m.servers(), + "requires" to m.requires(), + "aggregate_sha256" to m.declaredAggregateHash(), + "aggregate_verified" to m.verifyAggregate(), + "event_id" to ev.id, + "created_at" to ev.createdAt, + ) + } + Output.emit(mapOf("pubkey" to authorHex, "count" to items.size, "napplets" to items)) + return 0 + } + } + /** * `amy napplet serve [--d ID] [--port N]` — fetch + aggregate-verify the manifest and * serve its static content over a local HTTP server. NOTE: the napplet's window.napplet.* runtime diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt index b0b8dc51e7..2a3c2b35f8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt @@ -57,14 +57,78 @@ object NsiteCommands { route( "nsite", tail, - "nsite …", + "nsite …", mapOf( "fetch" to { rest -> fetch(dataDir, rest) }, "publish" to { rest -> publish(dataDir, rest) }, "serve" to { rest -> serve(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, ), ) + /** + * `amy nsite list [--relay R] [--timeout S]` — enumerate an author's static websites: + * the root (kind 15128) and every named one (kind 35128), latest per identifier. + */ + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val author = args.positionalOrNull(0) ?: return Output.error("bad_args", "nsite list [--relay R] [--timeout S]") + val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) + val timeoutSecs = args.longFlag("timeout", 8L) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val authorHex = ctx.requireUserHex(author) + val relays = + extraRelays + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + .ifEmpty { ctx.bootstrapRelays() } + val filter = Filter(kinds = listOf(RootSiteEvent.KIND, NamedSiteEvent.KIND), authors = listOf(authorHex)) + val items = + ctx + .drain(relays.associateWith { listOf(filter) }, timeoutSecs * 1000) + .map { (_, ev) -> ev } + .filter { it.pubKey == authorHex && (it is RootSiteEvent || it is NamedSiteEvent) } + .groupBy { it.kind to (it as? NamedSiteEvent)?.identifier() } + .mapNotNull { (_, dupes) -> dupes.maxByOrNull { it.createdAt } } + .sortedByDescending { it.createdAt } + .map { ev -> describe(ev) } + Output.emit(mapOf("pubkey" to authorHex, "count" to items.size, "nsites" to items)) + return 0 + } + } + + private fun describe(ev: Event): Map = + when (ev) { + is NamedSiteEvent -> + mapOf( + "kind" to ev.kind, + "d" to ev.identifier(), + "title" to ev.title(), + "description" to ev.description(), + "paths" to ev.paths().size, + "servers" to ev.servers(), + "event_id" to ev.id, + "created_at" to ev.createdAt, + ) + is RootSiteEvent -> + mapOf( + "kind" to ev.kind, + "d" to null, + "title" to ev.title(), + "description" to ev.description(), + "paths" to ev.paths().size, + "servers" to ev.servers(), + "event_id" to ev.id, + "created_at" to ev.createdAt, + ) + else -> mapOf("kind" to ev.kind, "event_id" to ev.id) + } + /** * `amy nsite serve [--d ID] [--port N]` — fetch the manifest and serve it over a local * HTTP server (sha256-verified per request) so you can open it in a browser. diff --git a/tools/napplet-test/README.md b/tools/napplet-test/README.md index 48cf9ff7df..380bc4bcd5 100644 --- a/tools/napplet-test/README.md +++ b/tools/napplet-test/README.md @@ -28,6 +28,7 @@ amy napplet publish tools/napplet-test \ the identity reads (`getProfile`, `getFollows`, …) have data. Verify it resolves (optional): `amy napplet fetch --d napplet-test`. +List everything you've published: `amy napplet list ` (or `amy nsite list `). ## Preview in a browser (optional)