mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -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, '/')
|
||||
}
|
||||
}
|
||||
+60
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user