From ece1f43975c0018d9080b3d1ce620f19a39a5c5c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 15:31:28 +0000 Subject: [PATCH] feat(amy): publish napplets and nsites (ship a directory) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `amy nsite publish ` and `amy napplet publish ` so a static-site or napplet directory can be shipped to Nostr in one command, building on the new CLI/Blossom infrastructure. - commons (jvmMain) StaticSitePublisher: the reusable "upload a tree" half — walks a directory (or single file), content-addresses each file, BUD-02 signed-uploads it via BlossomClient, and maps it to an absolute web path (/index.html, /assets/app.js, …). Returns the NIP-5A path→sha256 tags. - cli StaticSitePublish: thin shared flow — uploads via the commons publisher, hands the path tags to a kind-specific builder, signs with the account key, and broadcasts. nsite builds 15128/35128 (+ x aggregate); napplet builds 15129/35129 (aggregate + requires already added by the quartz builder). - nsite/napplet `publish` verbs wired into their routers. Test harness README now recommends `amy napplet publish tools/napplet-test`, keeping publish.sh as a no-amy fallback. Unit test covers the path mapping; cli + commons build green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- .../amethyst/cli/commands/NappletCommands.kt | 25 +++- .../amethyst/cli/commands/NsiteCommands.kt | 29 ++++- .../cli/commands/StaticSitePublish.kt | 116 ++++++++++++++++++ .../service/upload/StaticSitePublisher.kt | 112 +++++++++++++++++ .../service/upload/StaticSitePublisherTest.kt | 60 +++++++++ tools/napplet-test/README.md | 21 +++- 6 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSitePublish.kt create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/StaticSitePublisher.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/StaticSitePublisherTest.kt 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 5c0a182e1a..c9ff43a117 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,12 +57,35 @@ object NappletCommands { route( "napplet", tail, - "napplet …", + "napplet …", mapOf( "fetch" to { rest -> fetch(dataDir, rest) }, + "publish" to { rest -> publish(dataDir, rest) }, ), ) + /** + * `amy napplet publish --server [--requires identity,relay,…] [--d ID] [--relay …]` + * — upload a napplet directory to Blossom and broadcast its NIP-5D manifest (kind 15129 root, or + * 35129 named with `--d`). The builder adds the `x` aggregate hash and the `requires` capability + * tags the shell gates on. + */ + private suspend fun publish( + dataDir: DataDir, + rest: Array, + ): Int = + StaticSitePublish.run( + dataDir, + rest, + "napplet publish --server [--requires identity,relay,…] [--d ID] [--relay R] [--title T]", + ) { m -> + if (m.identifier != null) { + NamedNappletEvent.build(m.identifier, m.paths, m.servers, m.requires, m.title, m.description, m.source) + } else { + RootNappletEvent.build(m.paths, m.servers, m.requires, m.title, m.description, m.source) + } + } + private suspend fun fetch( dataDir: DataDir, rest: Array, 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 5ab090f414..92d5e184db 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 @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag /** @@ -56,12 +57,38 @@ object NsiteCommands { route( "nsite", tail, - "nsite …", + "nsite …", mapOf( "fetch" to { rest -> fetch(dataDir, rest) }, + "publish" to { rest -> publish(dataDir, rest) }, ), ) + /** + * `amy nsite publish --server [--d ID] [--relay …] [--title …]` — upload a static + * site directory to Blossom and broadcast its NIP-5A manifest (kind 15128 root, or 35128 named + * with `--d`). Includes the `x` aggregate hash so the manifest is self-verifying. + */ + private suspend fun publish( + dataDir: DataDir, + rest: Array, + ): Int = + StaticSitePublish.run( + dataDir, + rest, + "nsite publish --server [--d ID] [--relay R] [--title T] [--description D] [--source URL]", + ) { m -> + if (m.identifier != null) { + NamedSiteEvent.build(m.identifier, m.paths, m.servers, m.title, m.description, m.source) { + siteAggregateHash(m.paths) + } + } else { + RootSiteEvent.build(m.paths, m.servers, m.title, m.description, m.source) { + siteAggregateHash(m.paths) + } + } + } + private suspend fun fetch( dataDir: DataDir, rest: Array, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSitePublish.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSitePublish.kt new file mode 100644 index 0000000000..2b1b18f0ef --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSitePublish.kt @@ -0,0 +1,116 @@ +/* + * 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.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.service.upload.StaticSitePublisher +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import java.io.File + +/** + * Shared "ship a directory as a NIP-5A/5D manifest" flow for `amy nsite publish` and + * `amy napplet publish`. Uploads the tree to Blossom (commons [StaticSitePublisher]), then lets the + * caller turn the resulting `path → sha256` tags into the kind-specific manifest event ([buildEvent]), + * which this signs with the account key and broadcasts. Thin assembly: the upload lives in commons, + * the event shape in quartz, and the publish plumbing in [Context]. + */ +object StaticSitePublish { + /** Pieces resolved from the CLI args, handed to the kind-specific event builder. */ + class Manifest( + val identifier: String?, + val paths: List, + val servers: List, + val requires: List, + val title: String?, + val description: String?, + val source: String?, + ) + + suspend fun run( + dataDir: DataDir, + rest: Array, + usage: String, + buildEvent: (Manifest) -> EventTemplate, + ): Int { + val args = Args(rest) + val dirArg = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) + val source = File(dirArg) + if (!source.exists()) return Output.error("bad_args", "no such file or directory: $dirArg") + + val servers = StaticSiteFetch.commaList(args.flag("server")) + if (servers.isEmpty()) return Output.error("bad_args", "publish requires --server (comma-separated for mirrors)") + + val identifier = args.flag("d") + val requires = StaticSiteFetch.commaList(args.flag("requires")) + val title = args.flag("title") + val description = args.flag("description") + val sourceUrl = args.flag("source") + val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + + val result = + try { + StaticSitePublisher().uploadTree(source, servers.first(), ctx.signer) + } catch (e: Exception) { + return Output.error("upload_failed", e.message ?: "upload failed") + } + + val manifest = Manifest(identifier, result.pathTags, servers, requires, title, description, sourceUrl) + val signed = ctx.signer.sign(buildEvent(manifest)) + + val relays = + extraRelays + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + .ifEmpty { ctx.outboxRelays() } + .ifEmpty { ctx.bootstrapRelays() } + + val ack = ctx.publish(signed, relays) + + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "d" to identifier, + "title" to title, + "servers" to servers, + "requires" to requires, + "aggregate_sha256" to SiteAggregateHash.compute(result.pathTags), + "files" to + result.uploaded.map { + mapOf("path" to it.path, "sha256" to it.sha256, "size" to it.size, "url" to it.url) + }, + "accepted_by" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/StaticSitePublisher.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/StaticSitePublisher.kt new file mode 100644 index 0000000000..6a9b5cd000 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/StaticSitePublisher.kt @@ -0,0 +1,112 @@ +/* + * 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.commons.service.upload + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.utils.sha256.sha256 +import java.io.File +import java.nio.file.Files + +/** + * Uploads a local directory (or single file) to a Blossom server and produces the NIP-5A + * `path → sha256` tags a static-site / napplet manifest needs. Each file is content-addressed by its + * own sha256, signed-uploaded via [BlossomClient] + [BlossomAuth], and mapped to an absolute web path + * (`/index.html`, `/assets/app.js`, …) so the same tree resolves the same way the host serves it. + * + * This is the reusable "ship a directory" half of publishing; the caller turns [Result.pathTags] into + * the actual NIP-5A/5D event (kind 15128/35128 or 15129/35129), signs and broadcasts it. Lives next + * to [BlossomClient] in `jvmMain` so the CLI and the desktop app share one upload path. + */ +class StaticSitePublisher( + private val client: BlossomClient = BlossomClient(), +) { + /** One uploaded file: its absolute web [path], content [sha256], byte [size], and Blossom [url]. */ + data class UploadedFile( + val path: String, + val sha256: String, + val size: Long, + val url: String?, + ) + + data class Result( + val uploaded: List, + ) { + val pathTags: List get() = uploaded.map { PathTag(it.path, it.sha256) } + } + + /** + * Uploads every file under [source] (recursively; or [source] itself if it is a single file) to + * [server], signing each BUD-02 upload with [signer]. Returns the per-file results. Throws if + * [source] has no files or any upload fails (so a half-published manifest is never built). + */ + suspend fun uploadTree( + source: File, + server: String, + signer: NostrSigner, + ): Result { + val root = source.canonicalFile + val files = + when { + root.isFile -> listOf(root) + root.isDirectory -> + root + .walkTopDown() + .filter { it.isFile } + .sortedBy { it.invariantPath() } + .toList() + else -> throw IllegalArgumentException("No such file or directory: ${root.path}") + } + require(files.isNotEmpty()) { "No files to upload under ${root.path}" } + + val uploaded = + files.map { file -> + val webPath = webPath(root, file) + val bytes = file.readBytes() + val hash = sha256(bytes).toHexKey() + val mime = runCatching { Files.probeContentType(file.toPath()) }.getOrNull() ?: "application/octet-stream" + val auth = BlossomAuth.createUploadAuth(hash, file.length(), "Upload ${file.name}", signer) + val result = client.upload(file, mime, server, auth) + UploadedFile(webPath, result.sha256 ?: hash, result.size ?: file.length(), result.url) + } + return Result(uploaded) + } + + companion object { + /** The absolute web path a [file] is served under, relative to the published [root]. */ + fun webPath( + root: File, + file: File, + ): String { + if (root.isFile) return "/" + root.name + val rel = + root + .toPath() + .relativize(file.toPath()) + .toString() + .replace(File.separatorChar, '/') + return "/" + rel.removePrefix("/") + } + + private fun File.invariantPath() = path.replace(File.separatorChar, '/') + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/StaticSitePublisherTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/StaticSitePublisherTest.kt new file mode 100644 index 0000000000..3c45b52682 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/StaticSitePublisherTest.kt @@ -0,0 +1,60 @@ +/* + * 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.commons.service.upload + +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The pure path-mapping that decides the absolute web path each uploaded file is served under — it + * must match how the host resolves manifest paths (leading slash, forward slashes, nested dirs). + */ +class StaticSitePublisherTest { + @Test + fun singleFileMapsToItsName() { + val tmp = File.createTempFile("napplet", ".html") + try { + assertEquals("/" + tmp.name, StaticSitePublisher.webPath(tmp, tmp)) + } finally { + tmp.delete() + } + } + + @Test + fun nestedFilesMapToAbsoluteForwardSlashPaths() { + val root = createTempDirectory("nsite").toFile() + try { + val index = File(root, "index.html").apply { writeText("") } + val nested = + File(root, "assets/app.js").apply { + parentFile.mkdirs() + writeText("//") + } + + assertEquals("/index.html", StaticSitePublisher.webPath(root, index)) + assertEquals("/assets/app.js", StaticSitePublisher.webPath(root, nested)) + } finally { + root.deleteRecursively() + } + } +} diff --git a/tools/napplet-test/README.md b/tools/napplet-test/README.md index 2ed5670a6e..d322e5f538 100644 --- a/tools/napplet-test/README.md +++ b/tools/napplet-test/README.md @@ -9,7 +9,24 @@ shim → shell → broker → consent round-trip (and the newer `identity.getLis - `index.html` — the napplet. Read-only checks run on load; publish/upload/pay are behind buttons; live pushes (`identity.changed`, `keys.action`) land in the top banner. -- `publish.sh` — uploads `index.html` to a Blossom server and publishes the napplet event. +- `publish.sh` — a standalone (nak + curl) uploader, for when you don't want to build `amy`. + +## Publish with `amy` (recommended) + +`amy napplet publish` uploads the whole directory to Blossom and broadcasts the NIP-5D event in one +step, using the account `amy` is logged in as: + +```bash +./gradlew :cli:installDist # builds amy +amy napplet publish tools/napplet-test \ + --server https://blossom.primal.net \ + --requires identity,relay,storage,value,resource,upload,keys \ + --d napplet-test --title "Napplet Test Harness" \ + --relay wss://relay.damus.io --relay wss://nos.lol +``` + +(`--d` makes it an addressable kind-35129 napplet; omit it for a root kind-15129. For a plain static +site use `amy nsite publish --server …`.) Use the **same key you're logged in as in Amethyst**. ## Prerequisites @@ -20,7 +37,7 @@ shim → shell → broker → consent round-trip (and the newer `identity.getLis - Your **nsec** — use the **same key you're logged in as in Amethyst**, so the napplet appears under your account and the identity reads (`getProfile`, `getFollows`, …) have data. -## Publish +## Publish without `amy` (standalone) ```bash cd tools/napplet-test