feat(amy): publish napplets and nsites (ship a directory)

Add `amy nsite publish <dir>` and `amy napplet publish <dir>` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-22 15:31:28 +00:00
parent 08947510d9
commit ece1f43975
6 changed files with 359 additions and 4 deletions
@@ -57,12 +57,35 @@ object NappletCommands {
route(
"napplet",
tail,
"napplet <fetch> …",
"napplet <fetch|publish> …",
mapOf(
"fetch" to { rest -> fetch(dataDir, rest) },
"publish" to { rest -> publish(dataDir, rest) },
),
)
/**
* `amy napplet publish <dir> --server <blossom> [--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<String>,
): Int =
StaticSitePublish.run(
dataDir,
rest,
"napplet publish <dir> --server <blossom-url> [--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<String>,
@@ -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 <fetch> …",
"nsite <fetch|publish> …",
mapOf(
"fetch" to { rest -> fetch(dataDir, rest) },
"publish" to { rest -> publish(dataDir, rest) },
),
)
/**
* `amy nsite publish <dir> --server <blossom> [--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<String>,
): Int =
StaticSitePublish.run(
dataDir,
rest,
"nsite publish <dir> --server <blossom-url> [--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<String>,
@@ -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<PathTag>,
val servers: List<String>,
val requires: List<String>,
val title: String?,
val description: String?,
val source: String?,
)
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
usage: String,
buildEvent: (Manifest) -> EventTemplate<out Event>,
): 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 <blossom-url> (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
}
}
}
@@ -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<UploadedFile>,
) {
val pathTags: List<PathTag> 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, '/')
}
}
@@ -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("<html>") }
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()
}
}
}
+19 -2
View File
@@ -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 <dir> --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