diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f03e5fbfb9..905fe4aa0e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,14 +31,26 @@ jobs: - name: Linter (gradle) run: ./gradlew spotlessCheck - test: + test-and-build: needs: lint strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + include: + - os: ubuntu-latest + desktop-task: packageDeb + desktop-artifact-name: Desktop Linux DEB + desktop-artifact-path: desktopApp/build/compose/binaries/main/deb/*.deb + - os: macos-latest + desktop-task: packageDmg + desktop-artifact-name: Desktop macOS DMG + desktop-artifact-path: desktopApp/build/compose/binaries/main/dmg/*.dmg + - os: windows-latest + desktop-task: packageMsi + desktop-artifact-name: Desktop Windows MSI + desktop-artifact-path: desktopApp/build/compose/binaries/main/msi/*.msi runs-on: ${{ matrix.os }} - timeout-minutes: 30 + timeout-minutes: 60 defaults: run: shell: bash @@ -53,12 +65,12 @@ jobs: java-version: 21 cache: gradle - - name: Test (gradle) - run: ./gradlew test --no-daemon + - name: Test + Build Desktop (gradle) + run: ./gradlew test :desktopApp:${{ matrix.desktop-task }} --no-daemon - - name: Stop Gradle Daemon - if: always() - run: ./gradlew --stop + - name: Build Android Benchmark APKs (gradle) + if: matrix.os == 'ubuntu-latest' + run: ./gradlew assembleBenchmark --no-daemon - name: Android Test Report uses: asadmansr/android-test-report-action@v1.2.0 @@ -71,100 +83,29 @@ jobs: name: Test Reports path: amethyst/build/reports - build-android: - needs: test - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - distribution: 'zulu' - java-version: 21 - cache: gradle - - - name: Build APK (gradle) - run: ./gradlew assembleDebug - - - name: Upload Play APK + - name: Upload Desktop Distribution uses: actions/upload-artifact@v7 with: - name: Play Debug APK - path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk - - - name: Upload FDroid APK - uses: actions/upload-artifact@v7 - with: - name: FDroid Debug APK - path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk - - - name: Build Benchmark APK (gradle) - run: ./gradlew assembleBenchmark + name: ${{ matrix.desktop-artifact-name }} + path: ${{ matrix.desktop-artifact-path }} - name: Upload Play APK Benchmark uses: actions/upload-artifact@v7 + if: matrix.os == 'ubuntu-latest' with: name: Play Benchmark APK path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk - name: Upload FDroid APK Benchmark uses: actions/upload-artifact@v7 + if: matrix.os == 'ubuntu-latest' with: name: FDroid Benchmark APK path: amethyst/build/outputs/apk/fdroid/benchmark/amethyst-fdroid-universal-benchmark.apk - name: Upload Compose Reports uses: actions/upload-artifact@v7 + if: matrix.os == 'ubuntu-latest' with: name: Compose Reports path: amethyst/build/compose_compiler - - build-desktop: - needs: test - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - task: packageDeb - artifact-name: Desktop Linux DEB - artifact-path: desktopApp/build/compose/binaries/main/deb/*.deb - - os: macos-latest - task: packageDmg - artifact-name: Desktop macOS DMG - artifact-path: desktopApp/build/compose/binaries/main/dmg/*.dmg - - os: windows-latest - task: packageMsi - artifact-name: Desktop Windows MSI - artifact-path: desktopApp/build/compose/binaries/main/msi/*.msi - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - defaults: - run: - shell: bash - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - distribution: 'zulu' - java-version: 21 - cache: gradle - - - name: Build Desktop Distribution - run: ./gradlew :desktopApp:${{ matrix.task }} - - - name: Stop Gradle Daemon - if: always() - run: ./gradlew --stop - - - name: Upload Desktop Distribution - uses: actions/upload-artifact@v7 - with: - name: ${{ matrix.artifact-name }} - path: ${{ matrix.artifact-path }} diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index d4abc1b4cb..0ac241bfb7 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -173,6 +173,142 @@ jobs: ls -la dist >> "$GITHUB_STEP_SUMMARY" echo '```' >> "$GITHUB_STEP_SUMMARY" + # --------------------------------------------------------------------------- + # Amy CLI build matrix. Each leg produces a self-contained amy bundle with a + # minimal jlink'd JRE — no system Java required on the user's machine. + # + # amyImage task (all legs): cli/build/amy-image/amy/ → amy-*.tar.gz + # jpackageDeb / jpackageRpm: cli/build/jpackage/amy_*.deb + amy-*.rpm + # + # macOS legs ship only the tarball. We deliberately avoid jpackage --type + # app-image on macOS because it produces an .app bundle (burying the binary + # at Contents/MacOS/amy) — wrong UX for a CLI. + # + # Windows is intentionally deferred — cli/ has not been validated on Windows + # yet (data-dir path handling, file locking on groups/.mls, line endings + # in identity.json). + # + # Asset naming: amy---.. See scripts/asset-name.sh. + # --------------------------------------------------------------------------- + build-cli: + strategy: + fail-fast: false + matrix: + include: + - { os: macos-13, arch: x64, family: macos, tasks: "amyImage" } + - { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" } + - { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - name: Resolve tag + version + id: ver + env: + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + TEST_TAG: ${{ github.event.inputs.test_tag || '' }} + run: | + set -euo pipefail + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + TAG="${TEST_TAG:-v0.0.0-dryrun}" + else + TAG="${GITHUB_REF_NAME}" + fi + VER="${TAG#v}" + TOML_VER=$(grep -E '^app\s*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2) + # On dry-run we only require that TOML has a version; on real tag push we require exact match. + if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then + if [[ "$TOML_VER" != "$VER" ]]; then + echo "::error::gradle/libs.versions.toml app=$TOML_VER but tag is $TAG" + exit 1 + fi + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VER" >> "$GITHUB_OUTPUT" + + - name: Install RPM tooling (linux only) + if: matrix.family == 'linux' + run: sudo apt-get update && sudo apt-get install -y rpm fakeroot + + - name: Build amy artifacts + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + max_attempts: 2 + timeout_minutes: 15 + command: ./gradlew --no-daemon :cli:${{ matrix.tasks }} + + - name: Collect + rename assets + run: | + set -euo pipefail + # shellcheck source=scripts/asset-name.sh + source scripts/asset-name.sh + collect_cli_assets "${{ matrix.family }}" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist + + - name: Enforce CLI size budget (200 MB per asset) + run: | + set -euo pipefail + # The plan at cli/plans/2026-04-21-cli-distribution.md §size-budget + # targets < 80 MB, but :commons currently leaks Compose + Skiko as + # transitive deps (~40 MB of unused UI jars). Budget is set to + # 200 MB until commons is split into core + ui modules — track that + # as a follow-up. Until then, this gate just catches pathological + # regressions (e.g. accidental :amethyst dep pulling Android libs). + fail=0 + for f in dist/*; do + if [[ -f "$f" ]]; then + size=$(wc -c < "$f") + mb=$(( size / 1048576 )) + if (( size > 209715200 )); then + echo "::error file=$f::asset is ${mb} MB — exceeds 200 MB amy budget" + fail=1 + else + echo "OK: $f — ${mb} MB" + fi + fi + done + [[ "$fail" == 0 ]] + + - name: Classify release + id: classify + run: | + TAG="${{ steps.ver.outputs.tag }}" + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "prerelease=false" >> "$GITHUB_OUTPUT" + else + echo "prerelease=true" >> "$GITHUB_OUTPUT" + fi + + - name: Upload to GH Release (skip on dry-run) + if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + files: dist/* + tag_name: ${{ steps.ver.outputs.tag }} + prerelease: ${{ steps.classify.outputs.prerelease }} + draft: false + fail_on_unmatched_files: true + generate_release_notes: false # Android job writes release notes (last-writer-wins race) + + - name: Dry-run summary + if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' + run: | + echo "### Dry-run: amy ${{ matrix.family }}/${{ matrix.arch }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + ls -la dist >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + # --------------------------------------------------------------------------- # Android build + sign + direct-upload. Logic preserved from previous workflow; # uses softprops/action-gh-release@v2 instead of deprecated upload-release-asset. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt index f4535feef4..8e8dc20d35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt @@ -34,7 +34,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBoxState -import androidx.compose.material3.SwipeToDismissBoxValue.EndToStart import androidx.compose.material3.SwipeToDismissBoxValue.Settled import androidx.compose.material3.SwipeToDismissBoxValue.StartToEnd import androidx.compose.material3.Text @@ -64,22 +63,6 @@ fun SwipeToDeleteContainer( ) { val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { - when (it) { - StartToEnd -> { - onStartToEnd() - } - - EndToStart -> { - return@rememberSwipeToDismissBoxState false - } - - Settled -> { - return@rememberSwipeToDismissBoxState false - } - } - return@rememberSwipeToDismissBoxState true - }, positionalThreshold = { it * .40f }, ) @@ -88,6 +71,7 @@ fun SwipeToDeleteContainer( modifier = modifier, backgroundContent = { DismissBackground(dismissState) }, enableDismissFromEndToStart = false, + onDismiss = { if (it == StartToEnd) onStartToEnd() }, content = content, ) } diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index abfb482e5c..188d8a4ef6 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -49,6 +49,7 @@ Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby zwiększyć liczbę postów Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby polubić posty Nie ustawiono kwoty zap-a. Naciśnij i przytrzymaj, aby zmienić + przesłał %1$s satoszy Anonimowo prowadzi nalot stworzył klip @@ -338,6 +339,7 @@ Konta Nawigacja Ty + Kanały Utwórz System Wybierz Konto @@ -778,6 +780,8 @@ Nowe odpowiedzi Wzmianki Powiadamia Cię, gdy ktoś wspomni o Tobie lub zacytuje Cię w poście + %1$s wspomniał o Tobie + Nowe wzmianki Połączenia przychodzące Powiadomienia o przychodzących połączeniach głosowych i wideo @@ -1000,6 +1004,12 @@ Ustawienia konta Ustawienia aplikacji Niebezpieczna strefa + Zresetuj stan marmota + Zresetować stan Marmota? + Spowoduje to trwałe usunięcie każdego czatu grupowego Marmot, historii wiadomości i klucza MLS na tym urządzeniu dla bieżącego konta. Uczestnicy nie będą powiadamiani i mogą widzieć Cię w grupach do czasu następnej aktualizacji. Tej operacji nie można cofnąć. Nowy pakiet kluczowy zostanie opublikowany przy następnej synchronizacji aplikacji. + Zresetuj + Reset stanu Marmota. + Nie można zresetować stanu Marmota: %1$s Zawsze Tylko WiFi Nielimitowany Wi-Fi @@ -1345,6 +1355,7 @@ Nowy GEO-ekskluzywny Wpis Nowy artykuł Nowa ankieta + Nowa ankieta Nowa standardowa ankieta Nowe zdjęcie Nowy krótki filmik @@ -1608,7 +1619,7 @@ Moje Listy/Zestawy Moje listy Użytkownicy - Wybierz listę profili, aby filtrować aktualności + Wybierz opcję, aby filtrować aktualności Kanały Hashtagi Grupy zainteresowań @@ -1812,6 +1823,7 @@ Pakiety subskrybentów Udostępnienia (16) Obserwacja geohashów + GiftWraps Problem z Git Łatka Git Repozytorium Git @@ -2231,4 +2243,5 @@ Prywatna emotikona Publiczne emotikony są widoczne dla wszystkich i pojawiają się w menu odezw oraz w oknie podpowiedzi \":\", jeśli ten pakiet znajduje się na liście emotikonów. Prywatne emotikony są przechowywane zaszyfrowane na transmiterach i widoczne tylko dla Ciebie. Znajdują się w menu odzewów oraz w autouzupełnianiu po wpisaniu znaku „:” – tak samo jak emotikony publiczne. + Gif diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index f68c185db3..ef81acd617 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -780,6 +780,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem %1$s je sprejel vaš šahovski izziv Poteza uporabnika %1$s — vi ste na vrsti Šahovske novosti + Nove omembe Dohodni klici Obvestila o dohodnih glasovnih in video klicih diff --git a/cli/DEVELOPMENT.md b/cli/DEVELOPMENT.md index 6b855761aa..63f04477b8 100644 --- a/cli/DEVELOPMENT.md +++ b/cli/DEVELOPMENT.md @@ -196,7 +196,7 @@ Amy-specific layer still needs its own coverage: | Argument parsing (`Args`, flag forms, `--data-dir=…` vs `--data-dir …`) | Plain JVM unit tests in `cli/src/test/kotlin/`. | | Error / exit-code contract (bad args → 2, await timeout → 124, runtime → 1) | Table-driven tests invoking `main(argv)` with captured stdout/stderr. | | JSON output shape (each command's keys and types) | Snapshot tests: run a command against a throwaway data-dir, assert the JSON matches a golden file. | -| File layout on disk (`identity.json`, `relays.json`, `groups/*.mls`, `keypackages.bundle`) | Structural assertions after a command sequence. | +| File layout on disk (`identity.json`, `relays.json`, `marmot/groups/*.mls`, `marmot/keypackages.bundle`) | Structural assertions after a command sequence. | | Round-trip between two data-dirs on a local relay | End-to-end shell harnesses under `cli/tests/`. Each harness spins up a local `nostr-rs-relay`, bootstraps two or more fresh identities in their own `--data-dir`s, and drives a scenario via `amy` (+ `wn` for Marmot interop against whitenoise-rs). Today there are two suites: `cli/tests/marmot/` (13 MLS scenarios vs whitenoise-rs) and `cli/tests/dm/` (NIP-17 DM round-trips between two `amy` clients). | | Interop with other clients | Covered by `cli/tests/marmot/marmot-interop-headless.sh` (drives Amy against whitenoise-rs `wn`/`wnd`). Add new scenarios there or start a new sibling under `cli/tests/`. | diff --git a/cli/README.md b/cli/README.md index abc2141470..22879c3255 100644 --- a/cli/README.md +++ b/cli/README.md @@ -40,7 +40,7 @@ What every caller — user, script, agent, CI — can rely on: - **Data-dir is the whole world.** All state (identity, relays, MLS epochs, message archives, run cursors) lives under `--data-dir PATH`. Delete to reset; copy to move; `AMETHYST_CLI_DATA` env var overrides - the default `./amethyst-cli-data`. + the default `./amy`. The rationale behind each of these lives in [DEVELOPMENT.md](./DEVELOPMENT.md). Breaking any of them is a breaking @@ -152,7 +152,7 @@ itself crashed". ### Global flags -- `--data-dir PATH` — defaults to `./amethyst-cli-data` or +- `--data-dir PATH` — defaults to `./amy` or `$AMETHYST_CLI_DATA`. Always an absolute path after resolution. - `--help` / `-h` — usage summary. @@ -189,13 +189,14 @@ events. ``` / -├── identity.json # nsec/npub/hex — the account -├── relays.json # nip65 / inbox / key_package buckets -├── state.json # sync cursors (giftWrapSince, groupSince) -├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal) -└── groups/ - ├── .mls # MLS group state per group - └── .log # decrypted inner events (one JSON per line) +├── identity.json # nsec/npub/hex — the account +├── relays.json # nip65 / inbox / key_package buckets +├── state.json # sync cursors (giftWrapSince, groupSince) +└── marmot/ + ├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal) + └── groups/ + ├── .mls # MLS group state per group + └── .log # decrypted inner events (one JSON per line) ``` All files are plain JSON or framed binary — human-inspectable, easy to diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 30b9a910f7..55871b14b3 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -34,3 +34,187 @@ application { mainClass.set("com.vitorpamplona.amethyst.cli.MainKt") applicationName = "amy" } + +// --------------------------------------------------------------------------- +// Native distribution (jlink + jpackage) +// +// Produces a self-contained `amy` bundle with a minimal jlink'd JRE embedded — +// no JDK required on the user machine. Outputs land under cli/build/: +// - amy-image/amy/ portable, flat directory (bin/ + lib/ + runtime/) +// tar this up on every OS → amy---.tar.gz +// - jpackage/amy_*.deb Debian/Ubuntu package (Linux runners only) +// - jpackage/amy-*.rpm Fedora/RHEL package (Linux runners only) +// +// We deliberately build our own app-image instead of using `jpackage --type +// app-image`, because on macOS jpackage produces an `.app` bundle (with the +// binary buried at Contents/MacOS/amy) — awful UX for a CLI. The flat tree we +// build matches the Linux jpackage layout on every OS. +// +// Packaging for release is wired in .github/workflows/create-release.yml. +// See cli/plans/2026-04-21-cli-distribution.md for the overall plan. +// --------------------------------------------------------------------------- + +val appVersion: String = project.version.toString() + +// RPM rejects dashes in version strings — replace with tilde (~), which RPM +// treats as prerelease-lower-than: 1.08.0~rc1 < 1.08.0. +val rpmVersion: String = appVersion.replace("-", "~") + +val mainJarName: String = "cli-$appVersion.jar" +val mainClassName: String = "com.vitorpamplona.amethyst.cli.MainKt" + +// Minimal JDK 21 module set for amy. Keep this tight — every module adds +// megabytes to the bundle. If a transitive dep needs more, `jlink` fails loudly +// at build time with "module X not found". +val jlinkModules: String = listOf( + "java.base", + "java.logging", + "java.naming", + "java.net.http", + "java.sql", + "java.xml", + "jdk.crypto.ec", + "jdk.unsupported", +).joinToString(",") + +fun javaToolBin(name: String): Provider = + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(21)) + }.map { + val exe = if (org.gradle.internal.os.OperatingSystem.current().isWindows) "$name.exe" else name + it.metadata.installationPath.file("bin/$exe").asFile.absolutePath + } + +val jlinkRuntimeDir = layout.buildDirectory.dir("jlink-runtime") +val amyImageRoot = layout.buildDirectory.dir("amy-image") +val amyImageDir = layout.buildDirectory.dir("amy-image/amy") +val installLibDir = layout.buildDirectory.dir("install/amy/lib") +val jpackageOutDir = layout.buildDirectory.dir("jpackage") + +val jlinkRuntime = + tasks.register("jlinkRuntime") { + group = "distribution" + description = "Build a minimal JRE for amy via jlink." + + outputs.dir(jlinkRuntimeDir) + + val jlinkBin = javaToolBin("jlink") + val outDir = jlinkRuntimeDir + val modules = jlinkModules + + doFirst { + // jlink refuses to write into an existing directory. + outDir.get().asFile.deleteRecursively() + executable = jlinkBin.get() + args( + "--add-modules", modules, + "--no-header-files", + "--no-man-pages", + "--strip-debug", + // JDK 21+: --compress is deprecated; use zip-. + "--compress", "zip-6", + "--output", outDir.get().asFile.absolutePath, + ) + } + } + +// Flat app-image: bin/amy launcher + lib/*.jar + runtime/ (the jlink'd JRE). +// Cross-platform — the release workflow tars this up on every OS. +val amyImage = + tasks.register("amyImage") { + group = "distribution" + description = "Assemble a portable amy app-image (bin/ + lib/ + runtime/)." + + dependsOn(tasks.named("installDist"), jlinkRuntime) + + into(amyImageDir) + + // jars from installDist + from(installLibDir) { + into("lib") + } + // jlink'd JRE + from(jlinkRuntimeDir) { + into("runtime") + } + + val mainJar = mainJarName + val mainClass = mainClassName + val unixLauncher = + """ + #!/bin/sh + # amy launcher — uses the bundled jlink'd JRE so no system Java is required. + DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)" + exec "${'$'}DIR/runtime/bin/java" -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@" + """.trimIndent() + "\n" + + doLast { + val binDir = amyImageDir.get().asFile.resolve("bin") + binDir.mkdirs() + val launcher = binDir.resolve("amy") + launcher.writeText(unixLauncher) + launcher.setExecutable(true, false) + } + } + +fun registerJpackage( + taskName: String, + type: String, + extraArgs: List = emptyList(), +) = tasks.register(taskName) { + group = "distribution" + description = "Run jpackage --type $type for amy." + + dependsOn(tasks.named("installDist"), jlinkRuntime) + + inputs.dir(installLibDir) + inputs.dir(jlinkRuntimeDir) + outputs.dir(jpackageOutDir) + + val jpackageBin = javaToolBin("jpackage") + val inDir = installLibDir + val runtimeDir = jlinkRuntimeDir + val outDir = jpackageOutDir + val versionArg = if (type == "rpm") rpmVersion else appVersion + val extra = extraArgs + + doFirst { + outDir.get().asFile.mkdirs() + executable = jpackageBin.get() + args( + "--type", type, + "--name", "amy", + "--app-version", versionArg, + "--vendor", "Amethyst Contributors", + "--description", "Amethyst CLI — a non-interactive Nostr client.", + "--input", inDir.get().asFile.absolutePath, + "--runtime-image", runtimeDir.get().asFile.absolutePath, + "--main-jar", mainJarName, + "--main-class", mainClassName, + "--dest", outDir.get().asFile.absolutePath, + ) + args(extra) + } +} + +// .deb for Debian/Ubuntu. Installs under /opt/amy/ with /opt/amy/bin/amy as +// the launcher. We intentionally do NOT request --linux-shortcut (no .desktop +// entry for a CLI). +registerJpackage( + "jpackageDeb", + "deb", + extraArgs = listOf( + "--linux-package-name", "amy", + "--linux-deb-maintainer", "vitor@vitorpamplona.com", + ), +) + +// .rpm for Fedora/RHEL/openSUSE. +registerJpackage( + "jpackageRpm", + "rpm", + extraArgs = listOf( + "--linux-package-name", "amy", + "--linux-rpm-license-type", "MIT", + ), +) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index 989338e54f..85a077591c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -132,7 +132,7 @@ data class RunState( /** * Root of the on-disk layout. Any absolute path chosen by `--data-dir` (or - * `$AMETHYST_CLI_DATA`) — defaults to `./amethyst-cli-data`. + * `$AMETHYST_CLI_DATA`) — defaults to `./amy`. */ class DataDir( val root: File, @@ -140,8 +140,9 @@ class DataDir( val identityFile = File(root, "identity.json") val relaysFile = File(root, "relays.json") val stateFile = File(root, "state.json") - val groupsDir = File(root, "groups") - val keyPackageBundleFile = File(root, "keypackages.bundle") + val marmotDir = File(root, "marmot") + val groupsDir = File(marmotDir, "groups") + val keyPackageBundleFile = File(marmotDir, "keypackages.bundle") init { root.mkdirs() @@ -169,7 +170,7 @@ class DataDir( companion object { fun resolve(flag: String?): DataDir { val envPath = System.getenv("AMETHYST_CLI_DATA") - val path = flag ?: envPath ?: "./amethyst-cli-data" + val path = flag ?: envPath ?: "./amy" return DataDir(File(path).absoluteFile) } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index b6fbb76463..5eafa600ab 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -131,6 +131,14 @@ private suspend fun dispatch(argv: Array): Int { Commands.dm(dataDir, tail) } + "profile" -> { + Commands.profile(dataDir, tail) + } + + "notes" -> { + Commands.notes(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -197,6 +205,28 @@ private fun printUsage() { | relay list print configured relays | relay publish-lists publish kind:10002 + kind:10050 | + |Profile (NIP-01 kind:0): + | profile show [USER] [--timeout SECS] fetch latest kind:0 metadata + | (USER: npub|nprofile|hex|name@domain) + | profile edit [--name NAME] patch kind:0; unset flags keep prior values, + | [--display-name N] blank values delete the field + | [--about TEXT] + | [--picture URL] [--banner URL] + | [--website URL] [--nip05 ID] + | [--lud16 X] [--lud06 X] + | [--pronouns P] + | [--twitter H] [--mastodon H] [--github H] + | [--timeout SECS] + | + |Notes (NIP-10 kind:1): + | notes post TEXT [--relay URL] publish a kind:1 short text note + | (--relay accepts comma-separated extras) + | notes feed [--author USER] fetch kind:1 notes + | [--following] (default: own; --author: one user; + | [--limit N] --following: every contact-list pubkey) + | [--since TS] [--until TS] + | [--timeout SECS] + | |Direct messages (NIP-17): | dm send RECIPIENT TEXT send a gift-wrapped DM | [--allow-fallback] (default: only deliver to recipient's kind:10050) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 135cb186d5..af17793778 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -80,4 +80,14 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = DmCommands.dispatch(dataDir, tail) + + suspend fun profile( + dataDir: DataDir, + tail: Array, + ): Int = ProfileCommands.dispatch(dataDir, tail) + + suspend fun notes( + dataDir: DataDir, + tail: Array, + ): Int = NotesCommands.dispatch(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt new file mode 100644 index 0000000000..06ab61b3cd --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt @@ -0,0 +1,175 @@ +/* + * 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.Json +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent + +/** + * `amy feed [--author USER] [--following] [--limit N] [--since TS] [--until TS]` + * + * Read-side counterpart to `amy post`. Three modes selected by flags: + * + * - default: kind:1 by the current account. + * - `--author USER`: kind:1 by the given user (npub/nprofile/64-hex/NIP-05). + * - `--following`: fetch the current account's NIP-02 contact list and pull + * kind:1 from every author in it. + * + * Events are deduplicated by id, sorted newest-first, and capped at `--limit` + * (default 50). The output is a JSON object with a `notes` array of + * `{id, pubkey, created_at, content, tags}`. + */ +object FeedCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val author = args.flag("author") + val following = args.bool("following") + if (author != null && following) { + return Json.error("bad_args", "feed: pass either --author or --following, not both") + } + val limit = args.intFlag("limit", 50) + if (limit <= 0) return Json.error("bad_args", "feed: --limit must be > 0") + val since = args.flag("since")?.toLongOrNull() + val until = args.flag("until")?.toLongOrNull() + val timeoutSecs = args.longFlag("timeout", 8L) + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + + val (authors, mode) = + when { + following -> resolveFollowing(ctx, timeoutSecs * 1000) to "following" + author != null -> listOf(ctx.requireUserHex(author)) to "author" + else -> listOf(ctx.identity.pubKeyHex) to "self" + } + + if (authors.isEmpty()) { + Json.writeLine( + mapOf( + "mode" to mode, + "authors" to emptyList(), + "notes" to emptyList(), + ), + ) + return 0 + } + + val relays = relaysForReadingFeed(ctx, mode) + if (relays.isEmpty()) { + return Json.error("no_relays", "no relays available; run `amy relay add` first") + } + + val filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + authors = authors, + since = since, + until = until, + // Ask for some headroom over the requested limit because + // each relay applies the cap independently and we then + // merge + dedup across the union. + limit = (limit * 2).coerceAtMost(500), + ) + val filterMap = relays.associateWith { listOf(filter) } + val received = ctx.drain(filterMap, timeoutSecs * 1000) + + val notes = + received + .asSequence() + .map { it.second } + .filter { it.kind == TextNoteEvent.KIND } + .filter { it.pubKey in authors.toSet() } + .distinctBy { it.id } + .sortedByDescending { it.createdAt } + .take(limit) + .map { ev -> + mapOf( + "id" to ev.id, + "pubkey" to ev.pubKey, + "created_at" to ev.createdAt, + "content" to ev.content, + "tags" to ev.tags.map { it.toList() }, + ) + }.toList() + + Json.writeLine( + mapOf( + "mode" to mode, + "authors" to authors, + "queried_relays" to relays.map { it.url }, + "count" to notes.size, + "notes" to notes, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + /** + * Read-side relay strategy: own / following feeds use our outbox set + * (where we expect our follows to also publish, in NIP-65 spirit). For + * an arbitrary `--author` we have no idea where they publish, so we + * widen to the bootstrap union. + */ + private fun relaysForReadingFeed( + ctx: Context, + mode: String, + ): Set = + when (mode) { + "author" -> ctx.bootstrapRelays() + else -> ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() } + } + + private suspend fun resolveFollowing( + ctx: Context, + timeoutMs: Long, + ): List { + val relays = ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() } + if (relays.isEmpty()) return emptyList() + val filter = + Filter( + kinds = listOf(ContactListEvent.KIND), + authors = listOf(ctx.identity.pubKeyHex), + limit = 1, + ) + val filterMap = relays.associateWith { listOf(filter) } + val received = ctx.drain(filterMap, timeoutMs) + val latest = + received + .mapNotNull { (_, ev) -> ev as? ContactListEvent } + .filter { it.pubKey == ctx.identity.pubKeyHex } + .maxByOrNull { it.createdAt } + return latest?.verifiedFollowKeySet()?.toList() ?: emptyList() + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NotesCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NotesCommands.kt new file mode 100644 index 0000000000..98fd115b18 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NotesCommands.kt @@ -0,0 +1,44 @@ +/* + * 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.DataDir +import com.vitorpamplona.amethyst.cli.Json + +/** + * `amy notes ` — NIP-10 kind:1 short text notes. Sits alongside + * `profile` (kind:0) and `dm` (NIP-17) as a top-level verb group so the CLI + * shape mirrors the Amethyst UI: one subcommand group per event family. + */ +object NotesCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Json.error("bad_args", "notes …") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "post" -> PostCommand.run(dataDir, rest) + "feed" -> FeedCommand.run(dataDir, rest) + else -> Json.error("bad_args", "notes ${tail[0]}") + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt new file mode 100644 index 0000000000..a3a3be77c3 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt @@ -0,0 +1,85 @@ +/* + * 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.Json +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent + +/** + * `amy post [--relay URL …]` — publish a NIP-10 kind:1 short text note + * to the user's outbox relays. + * + * Threading is intentionally out of scope here — `amy post` only handles new + * top-level notes. Replies/quotes need richer event-hint plumbing and will get + * their own verb when needed. + */ +object PostCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "post [--relay URL …]") + val text = rest[0] + if (text.isBlank()) return Json.error("bad_args", "post text must not be blank") + + val args = Args(rest.drop(1).toTypedArray()) + val extraRelays = + args.flags["relay"] + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } ?: emptyList() + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val outbox = ctx.outboxRelays() + val extraNormalized = + extraRelays.mapNotNull { + com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + .normalizeOrNull(it) + } + val targets = (outbox + extraNormalized).toSet() + if (targets.isEmpty()) { + return Json.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`") + } + + val signed = ctx.signer.sign(TextNoteEvent.build(text)) + val ack = ctx.publish(signed, targets) + + Json.writeLine( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "created_at" to signed.createdAt, + "content" to signed.content, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt new file mode 100644 index 0000000000..742696c9b0 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt @@ -0,0 +1,233 @@ +/* + * 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.Json +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * `amy profile ` — read and update the current user's + * NIP-01 kind:0 metadata event. + * + * `show` accepts an optional user identifier (npub/nprofile/64-hex/NIP-05); when + * omitted, it reports the current account's profile. `edit` only mutates the + * caller's own profile and patches whichever fields are supplied — unset flags + * leave the existing values untouched, blank values delete the field (mirroring + * [MetadataEvent.updateFromPast]). + */ +object ProfileCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Json.error("bad_args", "profile …") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "show" -> show(dataDir, rest) + "edit" -> edit(dataDir, rest) + else -> Json.error("bad_args", "profile ${tail[0]}") + } + } + + private suspend fun show( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val timeoutSecs = args.longFlag("timeout", 8L) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val pubKey = + args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } + ?: ctx.identity.pubKeyHex + val isSelf = pubKey == ctx.identity.pubKeyHex + val relays = relaysForReadingProfile(ctx, isSelf) + val event = fetchLatestMetadata(ctx, pubKey, relays, timeoutSecs * 1000) + if (event == null) { + Json.writeLine( + mapOf( + "pubkey" to pubKey, + "found" to false, + "queried_relays" to relays.map { it.url }, + ), + ) + return 0 + } + val metadata = + try { + Json.mapper.readTree(event.content) + } catch (_: Exception) { + null + } + Json.writeLine( + mapOf( + "pubkey" to pubKey, + "found" to true, + "event_id" to event.id, + "created_at" to event.createdAt, + "metadata" to (metadata ?: emptyMap()), + "queried_relays" to relays.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + private suspend fun edit( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val name = args.flag("name") + val displayName = args.flag("display-name") + val about = args.flag("about") + val picture = args.flag("picture") + val banner = args.flag("banner") + val website = args.flag("website") + val nip05 = args.flag("nip05") + val lud16 = args.flag("lud16") + val lud06 = args.flag("lud06") + val pronouns = args.flag("pronouns") + val twitter = args.flag("twitter") + val mastodon = args.flag("mastodon") + val github = args.flag("github") + val timeoutSecs = args.longFlag("timeout", 8L) + + val touched = + listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github) + .any { it != null } + if (!touched) { + return Json.error( + "bad_args", + "profile edit needs at least one of " + + "--name --display-name --about --picture --banner --website " + + "--nip05 --lud16 --lud06 --pronouns --twitter --mastodon --github", + ) + } + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val targets = ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() } + val latest = + fetchLatestMetadata( + ctx, + ctx.identity.pubKeyHex, + targets, + timeoutSecs * 1000, + ) + + val template = + if (latest != null) { + MetadataEvent.updateFromPast( + latest = latest, + name = name, + displayName = displayName, + picture = picture, + banner = banner, + website = website, + about = about, + nip05 = nip05, + lnAddress = lud16, + lnURL = lud06, + pronouns = pronouns, + twitter = twitter, + mastodon = mastodon, + github = github, + ) + } else { + MetadataEvent.createNew( + name = name, + displayName = displayName, + picture = picture, + banner = banner, + website = website, + about = about, + nip05 = nip05, + lnAddress = lud16, + lnURL = lud06, + pronouns = pronouns, + twitter = twitter, + mastodon = mastodon, + github = github, + ) + } + + val signed = ctx.signer.sign(template) + val ack = ctx.publish(signed, targets) + + Json.writeLine( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "created_at" to signed.createdAt, + "based_on" to latest?.id, + "metadata" to Json.mapper.readTree(signed.content), + "published_to" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + /** + * For our own profile, our outbox relays are authoritative; for someone + * else's profile, fall back to the bootstrap union so we still find a + * kind:0 even when our relay set and theirs are disjoint. + */ + private fun relaysForReadingProfile( + ctx: Context, + isSelf: Boolean, + ): Set = + if (isSelf) { + ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() } + } else { + ctx.bootstrapRelays() + } + + private suspend fun fetchLatestMetadata( + ctx: Context, + pubKey: HexKey, + relays: Set, + timeoutMs: Long, + ): MetadataEvent? { + if (relays.isEmpty()) return null + val filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubKey), limit = 1) + val filterMap = relays.associateWith { listOf(filter) } + val received = ctx.drain(filterMap, timeoutMs) + return received + .mapNotNull { (_, ev) -> ev as? MetadataEvent } + .filter { it.pubKey == pubKey } + .maxByOrNull { it.createdAt } + } +} diff --git a/scripts/asset-name.sh b/scripts/asset-name.sh index eab8f6bed0..aac7b3147f 100755 --- a/scripts/asset-name.sh +++ b/scripts/asset-name.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash -# Single source of truth for Amethyst Desktop release asset naming. +# Single source of truth for Amethyst release asset naming. +# +# Two product lines share this contract: +# amethyst-desktop---. (Compose Desktop app) +# amy---. (Amy CLI — cli/ module) # -# Contract: amethyst-desktop---. # = tag stripped of leading 'v' (e.g. "1.08.0") # = macos | windows | linux # = x64 | arm64 @@ -12,7 +15,8 @@ # # Usage: # source scripts/asset-name.sh -# collect_assets +# collect_assets # desktop +# collect_cli_assets # amy CLI # # Expected examples: # amethyst-desktop-1.08.0-macos-x64.dmg @@ -23,6 +27,11 @@ # amethyst-desktop-1.08.0-linux-x64.rpm # amethyst-desktop-1.08.0-linux-x64.AppImage # amethyst-desktop-1.08.0-linux-x64.tar.gz +# amy-1.08.0-macos-arm64.tar.gz +# amy-1.08.0-macos-x64.tar.gz +# amy-1.08.0-linux-x64.tar.gz +# amy-1.08.0-linux-x64.deb +# amy-1.08.0-linux-x64.rpm set -euo pipefail @@ -33,6 +42,13 @@ asset_name() { printf 'amethyst-desktop-%s-%s-%s.%s' "$version" "$family" "$arch" "$ext" } +# CLI variant of asset_name — same shape, different product prefix. +# Usage: cli_asset_name +cli_asset_name() { + local family="$1" arch="$2" version="$3" ext="$4" + printf 'amy-%s-%s-%s.%s' "$version" "$family" "$arch" "$ext" +} + # Copy + rename build outputs into using the canonical naming scheme. # Usage: collect_assets # Expects build outputs under desktopApp/build/... (Compose binaries + custom tasks + portable archives). @@ -66,3 +82,48 @@ collect_assets() { done shopt -u nullglob } + +# Copy + rename amy CLI build outputs into using the canonical +# amy---. scheme. +# +# Expected inputs: +# cli/build/amy-image/amy/ flat app-image built by :cli:amyImage +# (bin/amy + lib/*.jar + runtime/) +# cli/build/jpackage/*.deb from :cli:jpackageDeb (Linux only) +# cli/build/jpackage/*.rpm from :cli:jpackageRpm (Linux only) +# +# Usage: collect_cli_assets +collect_cli_assets() { + local family="$1" arch="$2" version="$3" dest="$4" + mkdir -p "$dest" + local abs_dest + abs_dest="$(cd "$dest" && pwd)" + shopt -s nullglob + + # 1. Tar the flat app-image into amy---.tar.gz. + # This is the portable-across-OS asset — macOS runners produce only + # this one. + local app_image="cli/build/amy-image/amy" + if [ -d "$app_image" ]; then + local tarball="$abs_dest/$(cli_asset_name "$family" "$arch" "$version" tar.gz)" + ( cd "$(dirname "$app_image")" && tar czf "$tarball" "$(basename "$app_image")" ) + echo "Collected: $tarball" + fi + + # 2. Linux native installers (.deb, .rpm). jpackage writes them directly + # to cli/build/jpackage/ with its own naming, so we re-copy under the + # canonical scheme. + local src base ext dst + for src in \ + cli/build/jpackage/*.deb \ + cli/build/jpackage/*.rpm \ + ; do + [ -f "$src" ] || continue + base="$(basename "$src")" + ext="${base##*.}" + dst="$abs_dest/$(cli_asset_name "$family" "$arch" "$version" "$ext")" + cp "$src" "$dst" + echo "Collected: $dst" + done + shopt -u nullglob +}