mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge branch 'main' into claude/improve-desktop-design-iOokB
This commit is contained in:
+26
-85
@@ -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 }}
|
||||
|
||||
@@ -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/<gid>.mls, line endings
|
||||
# in identity.json).
|
||||
#
|
||||
# Asset naming: amy-<version>-<family>-<arch>.<ext>. 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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby zwiększyć liczbę postów</string>
|
||||
<string name="login_with_a_private_key_to_like_posts">Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby polubić posty</string>
|
||||
<string name="no_zap_amount_setup_long_press_to_change">Nie ustawiono kwoty zap-a. Naciśnij i przytrzymaj, aby zmienić</string>
|
||||
<string name="chat_zap_amount_suffix">przesłał %1$s satoszy</string>
|
||||
<string name="chat_zap_anonymous">Anonimowo</string>
|
||||
<string name="chat_raid_is_raiding">prowadzi nalot</string>
|
||||
<string name="chat_clip_created_a_clip">stworzył klip</string>
|
||||
@@ -338,6 +339,7 @@
|
||||
<string name="drawer_accounts">Konta</string>
|
||||
<string name="drawer_section_navigate">Nawigacja</string>
|
||||
<string name="drawer_section_you">Ty</string>
|
||||
<string name="drawer_section_feeds">Kanały</string>
|
||||
<string name="drawer_section_create">Utwórz</string>
|
||||
<string name="drawer_section_system">System</string>
|
||||
<string name="account_switch_select_account">Wybierz Konto</string>
|
||||
@@ -778,6 +780,8 @@
|
||||
<string name="app_notification_replies_summary">Nowe odpowiedzi</string>
|
||||
<string name="app_notification_mentions_channel_name">Wzmianki</string>
|
||||
<string name="app_notification_mentions_channel_description">Powiadamia Cię, gdy ktoś wspomni o Tobie lub zacytuje Cię w poście</string>
|
||||
<string name="app_notification_mentions_channel_message">%1$s wspomniał o Tobie</string>
|
||||
<string name="app_notification_mentions_summary">Nowe wzmianki</string>
|
||||
<!-- Call notifications and UI -->
|
||||
<string name="app_notification_calls_channel_name">Połączenia przychodzące</string>
|
||||
<string name="app_notification_calls_channel_description">Powiadomienia o przychodzących połączeniach głosowych i wideo</string>
|
||||
@@ -1000,6 +1004,12 @@
|
||||
<string name="account_settings">Ustawienia konta</string>
|
||||
<string name="app_settings">Ustawienia aplikacji</string>
|
||||
<string name="danger_zone">Niebezpieczna strefa</string>
|
||||
<string name="reset_marmot_state">Zresetuj stan marmota</string>
|
||||
<string name="reset_marmot_confirm_title">Zresetować stan Marmota?</string>
|
||||
<string name="reset_marmot_confirm_body">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.</string>
|
||||
<string name="reset_marmot_confirm_action">Zresetuj</string>
|
||||
<string name="reset_marmot_success">Reset stanu Marmota.</string>
|
||||
<string name="reset_marmot_failure">Nie można zresetować stanu Marmota: %1$s</string>
|
||||
<string name="connectivity_type_always">Zawsze</string>
|
||||
<string name="connectivity_type_wifi_only">Tylko WiFi</string>
|
||||
<string name="connectivity_type_unmetered_wifi_only">Nielimitowany Wi-Fi</string>
|
||||
@@ -1345,6 +1355,7 @@
|
||||
<string name="new_exclusive_geo_note">Nowy GEO-ekskluzywny Wpis</string>
|
||||
<string name="new_long_form_post">Nowy artykuł</string>
|
||||
<string name="new_poll">Nowa ankieta</string>
|
||||
<string name="new_zap_poll">Nowa ankieta</string>
|
||||
<string name="new_regular_poll">Nowa standardowa ankieta</string>
|
||||
<string name="new_picture">Nowe zdjęcie</string>
|
||||
<string name="new_short_video">Nowy krótki filmik</string>
|
||||
@@ -1608,7 +1619,7 @@
|
||||
<string name="my_lists_and_sets">Moje Listy/Zestawy</string>
|
||||
<string name="my_lists">Moje listy</string>
|
||||
<string name="people_list_label">Użytkownicy</string>
|
||||
<string name="select_list_to_filter">Wybierz listę profili, aby filtrować aktualności</string>
|
||||
<string name="select_list_to_filter">Wybierz opcję, aby filtrować aktualności</string>
|
||||
<string name="feed_group_feeds">Kanały</string>
|
||||
<string name="feed_group_hashtags">Hashtagi</string>
|
||||
<string name="feed_group_interest_sets">Grupy zainteresowań</string>
|
||||
@@ -1812,6 +1823,7 @@
|
||||
<string name="kind_follow_packs">Pakiety subskrybentów</string>
|
||||
<string name="kind_reposts_16">Udostępnienia (16)</string>
|
||||
<string name="kind_geohash_follows">Obserwacja geohashów</string>
|
||||
<string name="kind_gift_wraps">GiftWraps</string>
|
||||
<string name="kind_git_issue">Problem z Git</string>
|
||||
<string name="kind_git_patch">Łatka Git</string>
|
||||
<string name="kind_git_repo">Repozytorium Git</string>
|
||||
@@ -2231,4 +2243,5 @@
|
||||
<string name="emoji_private_badge">Prywatna emotikona</string>
|
||||
<string name="emoji_public_explainer">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.</string>
|
||||
<string name="emoji_private_explainer">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.</string>
|
||||
<string name="gif">Gif</string>
|
||||
</resources>
|
||||
|
||||
@@ -780,6 +780,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="app_notification_chess_challenge_accepted">%1$s je sprejel vaš šahovski izziv</string>
|
||||
<string name="app_notification_chess_your_turn">Poteza uporabnika %1$s — vi ste na vrsti</string>
|
||||
<string name="app_notification_chess_summary">Šahovske novosti</string>
|
||||
<string name="app_notification_mentions_summary">Nove omembe</string>
|
||||
<!-- Call notifications and UI -->
|
||||
<string name="app_notification_calls_channel_name">Dohodni klici</string>
|
||||
<string name="app_notification_calls_channel_description">Obvestila o dohodnih glasovnih in video klicih</string>
|
||||
|
||||
+1
-1
@@ -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/`. |
|
||||
|
||||
|
||||
+10
-9
@@ -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.
|
||||
|
||||
```
|
||||
<data-dir>/
|
||||
├── 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/
|
||||
├── <gid>.mls # MLS group state per group
|
||||
└── <gid>.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/
|
||||
├── <gid>.mls # MLS group state per group
|
||||
└── <gid>.log # decrypted inner events (one JSON per line)
|
||||
```
|
||||
|
||||
All files are plain JSON or framed binary — human-inspectable, easy to
|
||||
|
||||
@@ -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-<ver>-<fam>-<arch>.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<String> =
|
||||
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<Exec>("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 <int> is deprecated; use zip-<level>.
|
||||
"--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<Sync>("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<String> = emptyList(),
|
||||
) = tasks.register<Exec>(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",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,14 @@ private suspend fun dispatch(argv: Array<String>): 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)
|
||||
|
||||
@@ -80,4 +80,14 @@ object Commands {
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = DmCommands.dispatch(dataDir, tail)
|
||||
|
||||
suspend fun profile(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = ProfileCommands.dispatch(dataDir, tail)
|
||||
|
||||
suspend fun notes(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = NotesCommands.dispatch(dataDir, tail)
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
): 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<String>(),
|
||||
"notes" to emptyList<Any>(),
|
||||
),
|
||||
)
|
||||
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<NormalizedRelayUrl> =
|
||||
when (mode) {
|
||||
"author" -> ctx.bootstrapRelays()
|
||||
else -> ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() }
|
||||
}
|
||||
|
||||
private suspend fun resolveFollowing(
|
||||
ctx: Context,
|
||||
timeoutMs: Long,
|
||||
): List<HexKey> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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 <post|feed>` — 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<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "notes <post|feed> …")
|
||||
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]}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <text> [--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<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "post <text> [--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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <show|edit>` — 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<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "profile <show|edit> …")
|
||||
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<String>,
|
||||
): 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<String, Any?>()),
|
||||
"queried_relays" to relays.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun edit(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): 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<NormalizedRelayUrl> =
|
||||
if (isSelf) {
|
||||
ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() }
|
||||
} else {
|
||||
ctx.bootstrapRelays()
|
||||
}
|
||||
|
||||
private suspend fun fetchLatestMetadata(
|
||||
ctx: Context,
|
||||
pubKey: HexKey,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
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 }
|
||||
}
|
||||
}
|
||||
+64
-3
@@ -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-<version>-<family>-<arch>.<ext> (Compose Desktop app)
|
||||
# amy-<version>-<family>-<arch>.<ext> (Amy CLI — cli/ module)
|
||||
#
|
||||
# Contract: amethyst-desktop-<version>-<family>-<arch>.<ext>
|
||||
# <version> = tag stripped of leading 'v' (e.g. "1.08.0")
|
||||
# <family> = macos | windows | linux
|
||||
# <arch> = x64 | arm64
|
||||
@@ -12,7 +15,8 @@
|
||||
#
|
||||
# Usage:
|
||||
# source scripts/asset-name.sh
|
||||
# collect_assets <family> <arch> <version> <dest_dir>
|
||||
# collect_assets <family> <arch> <version> <dest_dir> # desktop
|
||||
# collect_cli_assets <family> <arch> <version> <dest_dir> # 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 <family> <arch> <version> <ext>
|
||||
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 <dest_dir> using the canonical naming scheme.
|
||||
# Usage: collect_assets <family> <arch> <version> <dest_dir>
|
||||
# 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 <dest_dir> using the canonical
|
||||
# amy-<version>-<family>-<arch>.<ext> 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 <family> <arch> <version> <dest_dir>
|
||||
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-<version>-<family>-<arch>.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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user