diff --git a/.github/workflows/marketplace-build.yml b/.github/workflows/marketplace-build.yml new file mode 100644 index 00000000..27a28142 --- /dev/null +++ b/.github/workflows/marketplace-build.yml @@ -0,0 +1,184 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +name: Marketplace Build (Reusable) + +on: + workflow_call: + inputs: + version: + description: "Marketplace version (e.g., 1.2.3)" + required: true + type: string + release_tag: + description: "Release tag for bundle URLs and registry PR" + required: true + type: string + registry_base_url: + description: "Registry base URL override" + required: false + type: string + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + SHERPA_ONNX_VERSION: "1.12.17" + MARKETPLACE_VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ inputs.release_tag }} + REGISTRY_BASE_URL: ${{ inputs.registry_base_url || format('https://{0}.github.io/streamkit/registry', github.repository_owner) }} + +jobs: + build-marketplace: + name: Build Marketplace Bundles + runs-on: ubuntu-22.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y cmake pkg-config libclang-dev wget libopenblas-dev zstd minisign patchelf python3-yaml + + - name: Install sherpa-onnx + run: | + cd /tmp + wget https://github.com/k2-fsa/sherpa-onnx/releases/download/v${SHERPA_ONNX_VERSION}/sherpa-onnx-v${SHERPA_ONNX_VERSION}-linux-x64-shared.tar.bz2 + tar xf sherpa-onnx-v${SHERPA_ONNX_VERSION}-linux-x64-shared.tar.bz2 + sudo cp -r sherpa-onnx-v${SHERPA_ONNX_VERSION}-linux-x64-shared/lib/* /usr/local/lib/ + sudo cp -r sherpa-onnx-v${SHERPA_ONNX_VERSION}-linux-x64-shared/include/* /usr/local/include/ + sudo ldconfig + + - name: Build CTranslate2 + run: | + git clone --depth 1 --recurse-submodules --branch v4.5.0 https://github.com/OpenNMT/CTranslate2.git + cd CTranslate2 + mkdir build && cd build + cmake -DCMAKE_BUILD_TYPE=Release \ + -DWITH_CUDA=OFF \ + -DWITH_MKL=OFF \ + -DWITH_OPENBLAS=ON \ + -DOPENMP_RUNTIME=COMP \ + -DBUILD_CLI=OFF \ + .. + make -j$(nproc) + sudo make install + sudo ldconfig + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.92.0" + + - name: Verify official plugins metadata is current + run: | + python3 scripts/marketplace/generate_official_plugins.py + git diff --exit-code -- marketplace/official-plugins.json + + - name: Collect marketplace workspaces + id: marketplace-workspaces + run: | + python3 - <<'PY' + import json + import os + import pathlib + import sys + + plugins_path = pathlib.Path("marketplace/official-plugins.json") + metadata = json.loads(plugins_path.read_text()) + plugin_ids = [plugin["id"] for plugin in metadata.get("plugins", [])] + if not plugin_ids: + print("No plugins found in marketplace/official-plugins.json", file=sys.stderr) + sys.exit(1) + + out_path = os.environ["GITHUB_OUTPUT"] + with open(out_path, "a", encoding="utf-8") as handle: + handle.write("workspaces< /tmp/streamkit.key + chmod 600 /tmp/streamkit.key + + - name: Build registry artifacts + run: | + VERSION="${MARKETPLACE_VERSION#v}" + python3 scripts/marketplace/build_registry.py \ + --plugins marketplace/official-plugins.json \ + --version "${VERSION}" \ + --bundle-base-url "https://github.com/${{ github.repository }}/releases/download/${RELEASE_TAG}" \ + --registry-base-url "${REGISTRY_BASE_URL}" \ + --bundles-out dist/bundles \ + --registry-out dist/registry \ + --signing-key /tmp/streamkit.key + + - name: Verify marketplace bundle portability + run: | + python3 scripts/marketplace/verify_bundles.py \ + --plugins marketplace/official-plugins.json \ + --bundles dist/bundles + + - name: Upload marketplace bundles + uses: actions/upload-artifact@v4 + with: + name: marketplace-bundles + path: dist/bundles/*.tar.zst + + - name: Upload registry metadata + uses: actions/upload-artifact@v4 + with: + name: marketplace-registry + path: dist/registry/** + + publish-registry: + name: Publish Registry (PR) + needs: [build-marketplace] + runs-on: ubuntu-22.04 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v5 + + - name: Download registry artifact + uses: actions/download-artifact@v4 + with: + name: marketplace-registry + path: dist/registry + + - name: Update docs registry folder + run: | + rm -rf docs/public/registry + mkdir -p docs/public/registry + cp -R dist/registry/* docs/public/registry/ + + - name: Create pull request + uses: peter-evans/create-pull-request@v6 + with: + branch: "registry/${{ env.RELEASE_TAG }}" + title: "chore(registry): publish marketplace registry for ${{ env.RELEASE_TAG }}" + commit-message: "chore(registry): publish marketplace registry for ${{ env.RELEASE_TAG }}" + body: | + Automated registry metadata update for `${{ env.RELEASE_TAG }}`. + base: main diff --git a/.github/workflows/marketplace-release.yml b/.github/workflows/marketplace-release.yml new file mode 100644 index 00000000..d5a809f1 --- /dev/null +++ b/.github/workflows/marketplace-release.yml @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +name: Marketplace Release + +on: + workflow_dispatch: + inputs: + version: + description: "Marketplace version (e.g., 1.2.3)" + required: true + release_tag: + description: "Release tag (defaults to marketplace-v)" + required: false + prerelease: + description: "Mark release as prerelease" + required: false + type: boolean + default: false + +env: + RELEASE_TAG: ${{ inputs.release_tag || format('marketplace-v{0}', inputs.version) }} + +jobs: + marketplace: + uses: ./.github/workflows/marketplace-build.yml + with: + version: ${{ inputs.version }} + release_tag: ${{ inputs.release_tag || format('marketplace-v{0}', inputs.version) }} + + create-release: + name: Create Marketplace Release + needs: [marketplace] + runs-on: ubuntu-22.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.RELEASE_TAG }} + target_commitish: ${{ github.sha }} + name: "Marketplace ${{ inputs.version }}" + files: | + artifacts/**/marketplace-bundles/*.tar.zst + body: | + Marketplace bundles for version `${{ inputs.version }}`. + draft: false + prerelease: ${{ inputs.prerelease }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3216507..3e311c9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,9 +80,15 @@ jobs: streamkit-${{ github.ref_name }}-linux-x64.tar.gz streamkit-${{ github.ref_name }}-linux-x64.tar.gz.sha256 + marketplace: + uses: ./.github/workflows/marketplace-build.yml + with: + version: ${{ github.ref_name }} + release_tag: ${{ github.ref_name }} + create-release: name: Create GitHub Release - needs: [build-linux-x64] + needs: [build-linux-x64, marketplace] runs-on: ubuntu-22.04 permissions: contents: write @@ -120,7 +126,10 @@ jobs: - name: Create Release uses: softprops/action-gh-release@v2 with: - files: artifacts/**/* + files: | + artifacts/**/streamkit-*.tar.gz + artifacts/**/streamkit-*.tar.gz.sha256 + artifacts/**/marketplace-bundles/*.tar.zst body_path: changelog.md draft: false prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }} diff --git a/AGENTS.md b/AGENTS.md index fcc41930..64794e33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,3 +32,29 @@ Agent-assisted contributions are welcome, but should be **supervised** and **rev - Official images are built from `Dockerfile` (CPU) and `Dockerfile.gpu` (GPU-tagged) via `.github/workflows/docker.yml`. - `/healthz` is the lightweight health endpoint (also `/health`). - Official images do not bundle ML models or plugins; they are expected to be mounted at runtime. + +## Adding an official plugin + +When making a plugin official and downloadable from the registry, update all of +the following: + +- Plugin source under `plugins/native//` (crate metadata + README). +- Plugin metadata in `plugins/native//marketplace.yml` (id, entrypoint, + artifact path, models, licenses, homepage/repo). +- Generate `marketplace/official-plugins.json` with + `scripts/marketplace/generate_official_plugins.py` and commit the result. +- Build list in `scripts/marketplace/build_official_plugins.sh`. +- Build prerequisites in `.github/workflows/release.yml` if new system deps are + required to compile or package the plugin. +- Bundle/registry smoke check: run `scripts/marketplace/build_registry.py` and + `scripts/marketplace/verify_bundles.py` locally. +- Portability table in `marketplace/PORTABILITY_REVIEW.md` (NEEDED deps, + RUNPATH/RPATH, recommendation). +- Docs: add/update the plugin page under + `docs/src/content/docs/reference/plugins/` and list it in + `docs/src/content/docs/reference/plugins/index.md` if applicable. +- Runtime shared libs: if the plugin needs bundled `.so` files, ensure the + bundle includes them and the entrypoint RUNPATH uses `$ORIGIN`, and update the + portability gate in `scripts/marketplace/verify_bundles.py` as needed. +- **Human review required** before bundling any new third-party shared libraries + (licensing, security, size, and distro compatibility). diff --git a/RELEASING.md b/RELEASING.md index cdfbb0b2..4ab4999f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -12,6 +12,67 @@ This repo ships: - Client CLI: `skit-cli` (crate: `streamkit-client`) - Optional crates for other developers (crates.io) +## Marketplace registry + bundles + +This release flow publishes signed registry metadata to GitHub Pages and bundle +artifacts to GitHub Releases. + +### Setup (one-time) + +```bash +# Generate an unencrypted minisign keypair for registry signing. +# Keep the secret key safe and commit the public key. +minisign -G -W -s /tmp/streamkit.key -p docs/public/registry/streamkit.pub +``` + +Set the GitHub Actions secret `MINISIGN_SECRET_KEY` to the contents of the +secret key file: + +```bash +cat /tmp/streamkit.key +``` + +If `docs/public/registry/streamkit.pub` contains a placeholder, overwrite it +with the generated public key before tagging. + +### System dependencies (v1) + +- When present, `pocket-tts` requires OpenSSL 3 (`libssl.so.3`, `libcrypto.so.3`). + - Ubuntu: `libssl3` +- Native plugins expect system `libstdc++` and `libgcc_s`. + +### Trigger a release + +```bash +git tag vX.Y.Z +git push origin vX.Y.Z +``` + +### Marketplace-only release (decoupled) + +Use the GitHub Actions workflow `Marketplace Release` with: + +- `version`: marketplace version (e.g., `1.2.3`) +- `release_tag` (optional): defaults to `marketplace-v` + +This workflow publishes bundle assets to the GitHub Release for `release_tag` +and opens the registry PR without rebuilding the server/UI. Both tag releases +and marketplace-only releases share the same reusable marketplace workflow +(`.github/workflows/marketplace-build.yml`). + +### Verify outputs + +- GitHub Release includes `*-bundle.tar.zst` assets. +- Registry metadata is published after merging the registry PR: + `https://.github.io/streamkit/registry/index.json`. +- Verify a manifest signature: + +```bash +minisign -V -P "$(tail -n 1 docs/public/registry/streamkit.pub)" \ + -m manifest.json \ + -x manifest.minisig +``` + ## crates.io publishing ### Intended publish set diff --git a/docs/public/registry/streamkit.pub b/docs/public/registry/streamkit.pub new file mode 100644 index 00000000..31929f72 --- /dev/null +++ b/docs/public/registry/streamkit.pub @@ -0,0 +1,2 @@ +untrusted comment: minisign public key 81C485A94492F33F +RWQ/85JEqYXEgX+2kl7Rwd8AcpVjYciSLzvLggzivbGyIrDPjfmcqjYP diff --git a/marketplace/PORTABILITY_REVIEW.md b/marketplace/PORTABILITY_REVIEW.md new file mode 100644 index 00000000..36f40f8f --- /dev/null +++ b/marketplace/PORTABILITY_REVIEW.md @@ -0,0 +1,29 @@ + + +# Marketplace Portability Review + +Collected from local artifacts in `plugins/native/*/target/release/*.so` on 2026-01-26. +ldd checks were run on the local dev environment (not a clean container). + +| Plugin | Non-glibc NEEDED deps | RUNPATH/RPATH | ldd (local) | Recommendation | +| --- | --- | --- | --- | --- | +| `helsinki` | `libgcc_s.so.1` | — | ok | system dependency (accepted) | +| `kokoro` | `libsherpa-onnx-c-api.so`, `libgcc_s.so.1` | `RUNPATH=/usr/local/lib` | ok | must bundle + `$ORIGIN` | +| `matcha` | `libsherpa-onnx-c-api.so`, `libgcc_s.so.1` | `RUNPATH=/usr/local/lib` | ok | must bundle + `$ORIGIN` | +| `nllb` | `libstdc++.so.6`, `libgcc_s.so.1` | — | ok | system dependency (accepted) | +| `piper` | `libsherpa-onnx-c-api.so`, `libgcc_s.so.1` | `RUNPATH=/usr/local/lib` | ok | must bundle + `$ORIGIN` | +| `sensevoice` | `libsherpa-onnx-c-api.so`, `libstdc++.so.6`, `libgcc_s.so.1` | `RUNPATH=/usr/local/lib` | ok | must bundle + `$ORIGIN` | +| `vad` | `libsherpa-onnx-c-api.so`, `libgcc_s.so.1` | `RUNPATH=/usr/local/lib` | ok | must bundle + `$ORIGIN` | +| `whisper` | `libstdc++.so.6`, `libgcc_s.so.1` | — | ok | system dependency (accepted) | + +## Proposed v1 stance (decision checkpoint) + +Decision: option 3 (mix). + +- Bundle sherpa-onnx shared libs with official bundles and set `RUNPATH=$ORIGIN` (or equivalent). +- Rely on system OpenSSL (libssl/libcrypto) and GCC runtime (libstdc++/libgcc_s). +- When present, `pocket-tts` may rely on system OpenSSL 3. diff --git a/marketplace/official-plugins.json b/marketplace/official-plugins.json new file mode 100644 index 00000000..49272d4f --- /dev/null +++ b/marketplace/official-plugins.json @@ -0,0 +1,293 @@ +{ + "plugins": [ + { + "id": "helsinki", + "name": "Helsinki", + "node_kind": "helsinki", + "kind": "native", + "entrypoint": "libhelsinki.so", + "artifact": "plugins/native/helsinki/target/release/libhelsinki.so", + "description": "Neural machine translation using OPUS-MT", + "license": "MPL-2.0", + "models": [ + { + "id": "opus-mt-en-es", + "name": "OPUS-MT en-es", + "default": true, + "source": "huggingface", + "repo_id": "streamkit/helsinki-models", + "revision": "main", + "files": [ + "opus-mt-en-es.tar.bz2" + ], + "license": "Apache-2.0", + "license_url": "https://huggingface.co/Helsinki-NLP/opus-mt-en-es" + }, + { + "id": "opus-mt-es-en", + "name": "OPUS-MT es-en", + "default": false, + "source": "huggingface", + "repo_id": "streamkit/helsinki-models", + "revision": "main", + "files": [ + "opus-mt-es-en.tar.bz2" + ], + "license": "Apache-2.0", + "license_url": "https://huggingface.co/Helsinki-NLP/opus-mt-es-en" + } + ] + }, + { + "id": "kokoro", + "name": "Kokoro", + "node_kind": "kokoro", + "kind": "native", + "entrypoint": "libkokoro.so", + "artifact": "plugins/native/kokoro/target/release/libkokoro.so", + "description": "Text-to-speech using Sherpa-ONNX Kokoro models", + "license": "MPL-2.0", + "models": [ + { + "id": "kokoro-multi-lang-v1_1", + "name": "Kokoro multi-lang v1.1", + "default": true, + "source": "huggingface", + "repo_id": "streamkit/kokoro-models", + "revision": "main", + "files": [ + "kokoro-multi-lang-v1_1.tar.bz2" + ], + "license": "Apache-2.0", + "license_url": "https://github.com/k2-fsa/sherpa-onnx/blob/master/LICENSE", + "sha256": "a3f4c73d043860e3fd2e5b06f36795eb81de0fc8e8de6df703245edddd87dbad" + } + ] + }, + { + "id": "matcha", + "name": "Matcha", + "node_kind": "matcha", + "kind": "native", + "entrypoint": "libmatcha.so", + "artifact": "plugins/native/matcha/target/release/libmatcha.so", + "description": "Text-to-speech using Matcha models", + "license": "MPL-2.0", + "models": [ + { + "id": "matcha-icefall-en_US-ljspeech", + "name": "Matcha LJSpeech", + "default": true, + "source": "huggingface", + "repo_id": "streamkit/matcha-models", + "revision": "main", + "files": [ + "matcha-icefall-en_US-ljspeech.tar.bz2", + "matcha-icefall-en_US-ljspeech/vocos-22khz-univ.onnx" + ], + "license": "CC-BY-4.0", + "license_url": "https://keithito.com/LJ-Speech-Dataset/" + } + ] + }, + { + "id": "nllb", + "name": "NLLB", + "node_kind": "nllb", + "kind": "native", + "entrypoint": "libnllb.so", + "artifact": "plugins/native/nllb/target/release/libnllb.so", + "description": "Neural machine translation using NLLB", + "license": "MPL-2.0", + "models": [ + { + "id": "nllb-200-distilled-600M-ct2-int8", + "name": "NLLB-200 distilled 600M (CTranslate2 int8)", + "default": false, + "source": "huggingface", + "repo_id": "streamkit/nllb-models", + "revision": "main", + "files": [ + "nllb-200-distilled-600M-ct2-int8.tar.bz2" + ], + "license": "CC-BY-NC-4.0", + "license_url": "https://huggingface.co/facebook/nllb-200-distilled-600M" + } + ] + }, + { + "id": "piper", + "name": "Piper", + "node_kind": "piper", + "kind": "native", + "entrypoint": "libpiper.so", + "artifact": "plugins/native/piper/target/release/libpiper.so", + "description": "Text-to-speech using Piper VITS models", + "license": "MPL-2.0", + "models": [ + { + "id": "piper-en_US-libritts_r-medium", + "name": "Piper en_US libritts_r (medium)", + "default": true, + "source": "huggingface", + "repo_id": "streamkit/piper-models", + "revision": "main", + "files": [ + "vits-piper-en_US-libritts_r-medium.tar.bz2" + ], + "license": "CC-BY-4.0 + GPL-3.0", + "license_url": "http://www.openslr.org/141/", + "sha256": "78c137daa7eddaf57190cf05c020efd6e593015f62c82ee999ef570fc2dff496" + }, + { + "id": "piper-es_MX-claude-high", + "name": "Piper es_MX claude (high)", + "default": false, + "source": "huggingface", + "repo_id": "streamkit/piper-models", + "revision": "main", + "files": [ + "vits-piper-es_MX-claude-high.tar.bz2" + ], + "license": "Apache-2.0 + GPL-3.0", + "license_url": "https://huggingface.co/spaces/HirCoir/Piper-TTS-Spanish", + "sha256": "ec33fb689c248fe64810aab564cba97babf0f506672cfd404928d46e751a4721" + } + ] + }, + { + "id": "sensevoice", + "name": "SenseVoice", + "node_kind": "sensevoice", + "kind": "native", + "entrypoint": "libsensevoice.so", + "artifact": "plugins/native/sensevoice/target/release/libsensevoice.so", + "description": "Streaming speech-to-text using SenseVoice", + "license": "MPL-2.0", + "models": [ + { + "id": "sensevoice-small-yue", + "name": "SenseVoice small (yue)", + "default": true, + "source": "huggingface", + "repo_id": "streamkit/sensevoice-models", + "revision": "main", + "files": [ + "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09.tar.bz2" + ], + "license": "Apache-2.0", + "license_url": "https://huggingface.co/ASLP-lab/WSYue-ASR", + "sha256": "7305f7905bfcf77fa0b39388a313f3da35c68d971661a65475b56fb2162c8e63" + }, + { + "id": "silero-vad", + "name": "Silero VAD (v6.2)", + "default": true, + "source": "huggingface", + "repo_id": "streamkit/sensevoice-models", + "revision": "main", + "files": [ + "silero_vad.onnx" + ], + "license": "MIT", + "license_url": "https://github.com/snakers4/silero-vad/blob/master/LICENSE", + "sha256": "1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3" + } + ] + }, + { + "id": "vad", + "name": "VAD", + "node_kind": "vad", + "kind": "native", + "entrypoint": "libvad.so", + "artifact": "plugins/native/vad/target/release/libvad.so", + "description": "Voice activity detection", + "license": "MPL-2.0", + "models": [ + { + "id": "ten-vad", + "name": "ten-vad", + "default": true, + "source": "huggingface", + "repo_id": "streamkit/vad-models", + "revision": "main", + "files": [ + "ten-vad.onnx" + ], + "license": "LicenseRef-ten-vad", + "license_url": "https://github.com/TEN-framework/ten-vad", + "sha256": "718cb7eef47e3cf5ddbe7e967a7503f46b8b469c0706872f494dfa921b486206" + } + ] + }, + { + "id": "whisper", + "name": "Whisper", + "node_kind": "whisper", + "kind": "native", + "entrypoint": "libwhisper.so", + "artifact": "plugins/native/whisper/target/release/libwhisper.so", + "description": "Streaming speech-to-text using whisper.cpp", + "license": "MPL-2.0", + "models": [ + { + "id": "whisper-tiny-en-q5_1", + "name": "Whisper tiny.en (q5_1)", + "default": true, + "source": "huggingface", + "repo_id": "streamkit/whisper-models", + "revision": "main", + "files": [ + "ggml-tiny.en-q5_1.bin" + ], + "license": "MIT", + "license_url": "https://github.com/openai/whisper/blob/main/LICENSE", + "sha256": "c77c5766f1cef09b6b7d47f21b546cbddd4157886b3b5d6d4f709e91e66c7c2b" + }, + { + "id": "whisper-base-en-q5_1", + "name": "Whisper base.en (q5_1)", + "default": false, + "source": "huggingface", + "repo_id": "streamkit/whisper-models", + "revision": "main", + "files": [ + "ggml-base.en-q5_1.bin" + ], + "license": "MIT", + "license_url": "https://github.com/openai/whisper/blob/main/LICENSE", + "sha256": "4baf70dd0d7c4247ba2b81fafd9c01005ac77c2f9ef064e00dcf195d0e2fdd2f" + }, + { + "id": "whisper-base-q5_1", + "name": "Whisper base (q5_1)", + "default": false, + "source": "huggingface", + "repo_id": "streamkit/whisper-models", + "revision": "main", + "files": [ + "ggml-base-q5_1.bin" + ], + "license": "MIT", + "license_url": "https://github.com/openai/whisper/blob/main/LICENSE", + "sha256": "422f1ae452ade6f30a004d7e5c6a43195e4433bc370bf23fac9cc591f01a8898" + }, + { + "id": "silero-vad", + "name": "Silero VAD (v6.2)", + "default": true, + "source": "huggingface", + "repo_id": "streamkit/whisper-models", + "revision": "main", + "files": [ + "silero_vad.onnx" + ], + "license": "MIT", + "license_url": "https://github.com/snakers4/silero-vad/blob/master/LICENSE", + "sha256": "1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3" + } + ] + } + ] +} diff --git a/plugins/native/helsinki/marketplace.yml b/plugins/native/helsinki/marketplace.yml new file mode 100644 index 00000000..89d0ca9c --- /dev/null +++ b/plugins/native/helsinki/marketplace.yml @@ -0,0 +1,29 @@ +id: helsinki +name: Helsinki +node_kind: helsinki +kind: native +entrypoint: libhelsinki.so +artifact: plugins/native/helsinki/target/release/libhelsinki.so +description: Neural machine translation using OPUS-MT +license: MPL-2.0 +models: +- id: opus-mt-en-es + name: OPUS-MT en-es + default: true + source: huggingface + repo_id: streamkit/helsinki-models + revision: main + files: + - opus-mt-en-es.tar.bz2 + license: Apache-2.0 + license_url: https://huggingface.co/Helsinki-NLP/opus-mt-en-es +- id: opus-mt-es-en + name: OPUS-MT es-en + default: false + source: huggingface + repo_id: streamkit/helsinki-models + revision: main + files: + - opus-mt-es-en.tar.bz2 + license: Apache-2.0 + license_url: https://huggingface.co/Helsinki-NLP/opus-mt-es-en diff --git a/plugins/native/kokoro/marketplace.yml b/plugins/native/kokoro/marketplace.yml new file mode 100644 index 00000000..413427ac --- /dev/null +++ b/plugins/native/kokoro/marketplace.yml @@ -0,0 +1,20 @@ +id: kokoro +name: Kokoro +node_kind: kokoro +kind: native +entrypoint: libkokoro.so +artifact: plugins/native/kokoro/target/release/libkokoro.so +description: Text-to-speech using Sherpa-ONNX Kokoro models +license: MPL-2.0 +models: +- id: kokoro-multi-lang-v1_1 + name: Kokoro multi-lang v1.1 + default: true + source: huggingface + repo_id: streamkit/kokoro-models + revision: main + files: + - kokoro-multi-lang-v1_1.tar.bz2 + license: Apache-2.0 + license_url: https://github.com/k2-fsa/sherpa-onnx/blob/master/LICENSE + sha256: a3f4c73d043860e3fd2e5b06f36795eb81de0fc8e8de6df703245edddd87dbad diff --git a/plugins/native/matcha/marketplace.yml b/plugins/native/matcha/marketplace.yml new file mode 100644 index 00000000..fe8089f3 --- /dev/null +++ b/plugins/native/matcha/marketplace.yml @@ -0,0 +1,20 @@ +id: matcha +name: Matcha +node_kind: matcha +kind: native +entrypoint: libmatcha.so +artifact: plugins/native/matcha/target/release/libmatcha.so +description: Text-to-speech using Matcha models +license: MPL-2.0 +models: +- id: matcha-icefall-en_US-ljspeech + name: Matcha LJSpeech + default: true + source: huggingface + repo_id: streamkit/matcha-models + revision: main + files: + - matcha-icefall-en_US-ljspeech.tar.bz2 + - matcha-icefall-en_US-ljspeech/vocos-22khz-univ.onnx + license: CC-BY-4.0 + license_url: https://keithito.com/LJ-Speech-Dataset/ diff --git a/plugins/native/nllb/marketplace.yml b/plugins/native/nllb/marketplace.yml new file mode 100644 index 00000000..b5e6aa3c --- /dev/null +++ b/plugins/native/nllb/marketplace.yml @@ -0,0 +1,19 @@ +id: nllb +name: NLLB +node_kind: nllb +kind: native +entrypoint: libnllb.so +artifact: plugins/native/nllb/target/release/libnllb.so +description: Neural machine translation using NLLB +license: MPL-2.0 +models: +- id: nllb-200-distilled-600M-ct2-int8 + name: NLLB-200 distilled 600M (CTranslate2 int8) + default: false + source: huggingface + repo_id: streamkit/nllb-models + revision: main + files: + - nllb-200-distilled-600M-ct2-int8.tar.bz2 + license: CC-BY-NC-4.0 + license_url: https://huggingface.co/facebook/nllb-200-distilled-600M diff --git a/plugins/native/piper/marketplace.yml b/plugins/native/piper/marketplace.yml new file mode 100644 index 00000000..1541565a --- /dev/null +++ b/plugins/native/piper/marketplace.yml @@ -0,0 +1,31 @@ +id: piper +name: Piper +node_kind: piper +kind: native +entrypoint: libpiper.so +artifact: plugins/native/piper/target/release/libpiper.so +description: Text-to-speech using Piper VITS models +license: MPL-2.0 +models: +- id: piper-en_US-libritts_r-medium + name: Piper en_US libritts_r (medium) + default: true + source: huggingface + repo_id: streamkit/piper-models + revision: main + files: + - vits-piper-en_US-libritts_r-medium.tar.bz2 + license: CC-BY-4.0 + GPL-3.0 + license_url: http://www.openslr.org/141/ + sha256: 78c137daa7eddaf57190cf05c020efd6e593015f62c82ee999ef570fc2dff496 +- id: piper-es_MX-claude-high + name: Piper es_MX claude (high) + default: false + source: huggingface + repo_id: streamkit/piper-models + revision: main + files: + - vits-piper-es_MX-claude-high.tar.bz2 + license: Apache-2.0 + GPL-3.0 + license_url: https://huggingface.co/spaces/HirCoir/Piper-TTS-Spanish + sha256: ec33fb689c248fe64810aab564cba97babf0f506672cfd404928d46e751a4721 diff --git a/plugins/native/sensevoice/marketplace.yml b/plugins/native/sensevoice/marketplace.yml new file mode 100644 index 00000000..c5d3bcbc --- /dev/null +++ b/plugins/native/sensevoice/marketplace.yml @@ -0,0 +1,31 @@ +id: sensevoice +name: SenseVoice +node_kind: sensevoice +kind: native +entrypoint: libsensevoice.so +artifact: plugins/native/sensevoice/target/release/libsensevoice.so +description: Streaming speech-to-text using SenseVoice +license: MPL-2.0 +models: +- id: sensevoice-small-yue + name: SenseVoice small (yue) + default: true + source: huggingface + repo_id: streamkit/sensevoice-models + revision: main + files: + - sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09.tar.bz2 + license: Apache-2.0 + license_url: https://huggingface.co/ASLP-lab/WSYue-ASR + sha256: 7305f7905bfcf77fa0b39388a313f3da35c68d971661a65475b56fb2162c8e63 +- id: silero-vad + name: Silero VAD (v6.2) + default: true + source: huggingface + repo_id: streamkit/sensevoice-models + revision: main + files: + - silero_vad.onnx + license: MIT + license_url: https://github.com/snakers4/silero-vad/blob/master/LICENSE + sha256: 1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3 diff --git a/plugins/native/vad/marketplace.yml b/plugins/native/vad/marketplace.yml new file mode 100644 index 00000000..6b1b9f5b --- /dev/null +++ b/plugins/native/vad/marketplace.yml @@ -0,0 +1,20 @@ +id: vad +name: VAD +node_kind: vad +kind: native +entrypoint: libvad.so +artifact: plugins/native/vad/target/release/libvad.so +description: Voice activity detection +license: MPL-2.0 +models: +- id: ten-vad + name: ten-vad + default: true + source: huggingface + repo_id: streamkit/vad-models + revision: main + files: + - ten-vad.onnx + license: LicenseRef-ten-vad + license_url: https://github.com/TEN-framework/ten-vad + sha256: 718cb7eef47e3cf5ddbe7e967a7503f46b8b469c0706872f494dfa921b486206 diff --git a/plugins/native/whisper/marketplace.yml b/plugins/native/whisper/marketplace.yml new file mode 100644 index 00000000..33bd388a --- /dev/null +++ b/plugins/native/whisper/marketplace.yml @@ -0,0 +1,53 @@ +id: whisper +name: Whisper +node_kind: whisper +kind: native +entrypoint: libwhisper.so +artifact: plugins/native/whisper/target/release/libwhisper.so +description: Streaming speech-to-text using whisper.cpp +license: MPL-2.0 +models: +- id: whisper-tiny-en-q5_1 + name: Whisper tiny.en (q5_1) + default: true + source: huggingface + repo_id: streamkit/whisper-models + revision: main + files: + - ggml-tiny.en-q5_1.bin + license: MIT + license_url: https://github.com/openai/whisper/blob/main/LICENSE + sha256: c77c5766f1cef09b6b7d47f21b546cbddd4157886b3b5d6d4f709e91e66c7c2b +- id: whisper-base-en-q5_1 + name: Whisper base.en (q5_1) + default: false + source: huggingface + repo_id: streamkit/whisper-models + revision: main + files: + - ggml-base.en-q5_1.bin + license: MIT + license_url: https://github.com/openai/whisper/blob/main/LICENSE + sha256: 4baf70dd0d7c4247ba2b81fafd9c01005ac77c2f9ef064e00dcf195d0e2fdd2f +- id: whisper-base-q5_1 + name: Whisper base (q5_1) + default: false + source: huggingface + repo_id: streamkit/whisper-models + revision: main + files: + - ggml-base-q5_1.bin + license: MIT + license_url: https://github.com/openai/whisper/blob/main/LICENSE + sha256: 422f1ae452ade6f30a004d7e5c6a43195e4433bc370bf23fac9cc591f01a8898 +- id: silero-vad + name: Silero VAD (v6.2) + default: true + source: huggingface + repo_id: streamkit/whisper-models + revision: main + files: + - silero_vad.onnx + license: MIT + license_url: https://github.com/snakers4/silero-vad/blob/master/LICENSE + sha256: 1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3 diff --git a/scripts/marketplace/build_official_plugins.sh b/scripts/marketplace/build_official_plugins.sh new file mode 100755 index 00000000..2cd63595 --- /dev/null +++ b/scripts/marketplace/build_official_plugins.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# SPDX-License-Identifier: MPL-2.0 + +set -euo pipefail + +python3 scripts/marketplace/generate_official_plugins.py + +plugins=$(python3 - <<'PY' +import json +import pathlib +import sys + +plugins_path = pathlib.Path("marketplace/official-plugins.json") +metadata = json.loads(plugins_path.read_text()) +plugin_ids = [plugin["id"] for plugin in metadata.get("plugins", [])] + +native_root = pathlib.Path("plugins/native") +native_dirs = [path.name for path in native_root.iterdir() if path.is_dir()] + +missing = sorted(set(native_dirs) - set(plugin_ids)) +if missing: + print( + "Missing entries in marketplace/official-plugins.json for: " + + ", ".join(missing), + file=sys.stderr, + ) + sys.exit(1) + +print("\n".join(plugin_ids)) +PY +) + +while IFS= read -r plugin; do + if [ -z "${plugin}" ]; then + continue + fi + if [ ! -d "plugins/native/${plugin}" ]; then + echo "Missing plugin directory: plugins/native/${plugin}" >&2 + exit 1 + fi + echo "Building native plugin: ${plugin}" + ( + cd "plugins/native/${plugin}" + cargo build --release + ) +done <<< "${plugins}" diff --git a/scripts/marketplace/build_registry.py b/scripts/marketplace/build_registry.py new file mode 100644 index 00000000..0fb48140 --- /dev/null +++ b/scripts/marketplace/build_registry.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# SPDX-License-Identifier: MPL-2.0 + +import argparse +import datetime +import hashlib +import json +import os +import pathlib +import shutil +import subprocess +import sys + + +def sha256_file(path: pathlib.Path) -> str: + hasher = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + hasher.update(chunk) + return hasher.hexdigest() + + +def readelf_dynamic(path: pathlib.Path) -> tuple[list[str], list[str]]: + result = subprocess.run( + ["readelf", "-d", str(path)], + check=True, + text=True, + capture_output=True, + ) + needed = [] + rpaths = [] + for line in result.stdout.splitlines(): + line = line.strip() + if "(NEEDED)" in line and "Shared library:" in line: + lib = line.split("[", 1)[-1].split("]", 1)[0] + needed.append(lib) + elif "(RUNPATH)" in line or "(RPATH)" in line: + value = line.split("[", 1)[-1].split("]", 1)[0] + rpaths.append(value) + return needed, rpaths + + +def normalize_base_url(url: str) -> str: + return url.rstrip("/") + + +def ensure_dir(path: pathlib.Path) -> None: + path.mkdir(parents=True, exist_ok=True) + + +def copy_file(src: pathlib.Path, dest: pathlib.Path) -> None: + ensure_dir(dest.parent) + shutil.copy2(src, dest) + + +def require_tool(name: str) -> None: + if shutil.which(name) is None: + raise RuntimeError(f"Missing required tool: {name}") + + +def ensure_sherpa_runtime(work_dir: pathlib.Path) -> None: + lib_dir = pathlib.Path(os.environ.get("SHERPA_ONNX_LIB_DIR", "/usr/local/lib")) + sherpa_libs = ["libsherpa-onnx-c-api.so", "libonnxruntime.so"] + for lib in sherpa_libs: + src = lib_dir / lib + if not src.exists(): + raise FileNotFoundError(f"Missing sherpa runtime library: {src}") + copy_file(src, work_dir / lib) + + +def set_runpath_origin(target: pathlib.Path) -> None: + require_tool("patchelf") + subprocess.run(["patchelf", "--set-rpath", "$ORIGIN", str(target)], check=True) + + +def build_bundle( + plugin: dict, version: str, bundles_out: pathlib.Path, work_root: pathlib.Path +) -> dict: + plugin_id = plugin["id"] + artifact = pathlib.Path(plugin["artifact"]) + entrypoint = pathlib.Path(plugin["entrypoint"]) + + if not artifact.exists(): + raise FileNotFoundError(f"Missing artifact: {artifact}") + + work_dir = work_root / f"{plugin_id}-{version}" + if work_dir.exists(): + shutil.rmtree(work_dir) + ensure_dir(work_dir) + + entrypoint_path = work_dir / entrypoint + copy_file(artifact, entrypoint_path) + + needed, _ = readelf_dynamic(entrypoint_path) + if "libsherpa-onnx-c-api.so" in needed: + ensure_sherpa_runtime(work_dir) + set_runpath_origin(entrypoint_path) + + for extra in plugin.get("extra_files", []): + if isinstance(extra, str): + src = pathlib.Path(extra) + dest = pathlib.Path(src.name) + else: + src = pathlib.Path(extra["source"]) + dest = pathlib.Path(extra.get("dest", src.name)) + copy_file(src, work_dir / dest) + + bundle_name = f"{plugin_id}-{version}-bundle.tar.zst" + bundle_path = bundles_out / bundle_name + ensure_dir(bundles_out) + + subprocess.run( + [ + "tar", + "--zstd", + "-cf", + str(bundle_path), + "-C", + str(work_dir), + ".", + ], + check=True, + ) + + return { + "bundle_name": bundle_name, + "bundle_path": bundle_path, + "sha256": sha256_file(bundle_path), + "size_bytes": bundle_path.stat().st_size, + } + + +def write_json(path: pathlib.Path, payload: dict) -> None: + ensure_dir(path.parent) + path.write_text(json.dumps(payload, indent=2, sort_keys=False)) + + +def strip_none(payload: dict) -> dict: + return {key: value for key, value in payload.items() if value is not None} + + +def sign_manifest(manifest_path: pathlib.Path, signing_key: pathlib.Path) -> pathlib.Path: + signature_path = manifest_path.with_name("manifest.minisig") + subprocess.run( + [ + "minisign", + "-S", + "-s", + str(signing_key), + "-m", + str(manifest_path), + "-x", + str(signature_path), + ], + check=True, + ) + return signature_path + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--plugins", required=True, help="Path to plugin metadata JSON") + parser.add_argument("--version", required=True, help="Release version (e.g., 1.2.3)") + parser.add_argument( + "--bundle-base-url", required=True, help="Base URL for bundle downloads" + ) + parser.add_argument( + "--registry-base-url", required=True, help="Base URL for registry metadata" + ) + parser.add_argument("--bundles-out", required=True, help="Output directory for bundles") + parser.add_argument("--registry-out", required=True, help="Output directory for registry JSON") + parser.add_argument("--signing-key", required=True, help="Path to minisign secret key") + args = parser.parse_args() + + if not args.version.strip(): + print("Release version must be non-empty", file=sys.stderr) + return 1 + + plugins_path = pathlib.Path(args.plugins) + bundles_out = pathlib.Path(args.bundles_out) + registry_out = pathlib.Path(args.registry_out) + signing_key = pathlib.Path(args.signing_key) + + if not plugins_path.exists(): + print(f"Missing metadata file: {plugins_path}", file=sys.stderr) + return 1 + if not signing_key.exists(): + print(f"Missing minisign key: {signing_key}", file=sys.stderr) + return 1 + + metadata = json.loads(plugins_path.read_text()) + plugins = metadata.get("plugins", []) + if not plugins: + print("No plugins found in metadata", file=sys.stderr) + return 1 + + bundle_base_url = normalize_base_url(args.bundle_base_url) + registry_base_url = normalize_base_url(args.registry_base_url) + published_at = datetime.date.today().isoformat() + + registry_plugins = [] + work_root = registry_out / ".work" + if work_root.exists(): + shutil.rmtree(work_root) + + for plugin in plugins: + bundle_info = build_bundle(plugin, args.version, bundles_out, work_root) + manifest = { + "schema_version": 1, + "id": plugin["id"], + "name": plugin.get("name"), + "version": args.version, + "node_kind": plugin["node_kind"], + "kind": plugin["kind"], + "description": plugin.get("description"), + "license": plugin.get("license"), + "license_url": plugin.get("license_url"), + "homepage": plugin.get("homepage"), + "repository": plugin.get("repository"), + "entrypoint": plugin["entrypoint"], + "bundle": { + "url": f"{bundle_base_url}/{bundle_info['bundle_name']}", + "sha256": bundle_info["sha256"], + "size_bytes": bundle_info["size_bytes"], + }, + "compatibility": plugin.get("compatibility"), + "models": plugin.get("models", []), + } + manifest = strip_none(manifest) + + manifest_dir = registry_out / "plugins" / plugin["id"] / args.version + manifest_path = manifest_dir / "manifest.json" + write_json(manifest_path, manifest) + signature_path = sign_manifest(manifest_path, signing_key) + + registry_plugins.append( + { + "id": plugin["id"], + "name": plugin.get("name"), + "description": plugin.get("description"), + "latest": args.version, + "versions": [ + { + "version": args.version, + "manifest_url": f"{registry_base_url}/plugins/{plugin['id']}/{args.version}/manifest.json", + "signature_url": f"{registry_base_url}/plugins/{plugin['id']}/{args.version}/manifest.minisig", + "published_at": published_at, + } + ], + } + ) + + print( + f"Built bundle for {plugin['id']} -> {bundle_info['bundle_name']} ({bundle_info['sha256']})" + ) + + index = {"schema_version": 1, "plugins": registry_plugins} + write_json(registry_out / "index.json", index) + + if work_root.exists(): + shutil.rmtree(work_root) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/marketplace/generate_official_plugins.py b/scripts/marketplace/generate_official_plugins.py new file mode 100644 index 00000000..8d05da46 --- /dev/null +++ b/scripts/marketplace/generate_official_plugins.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# SPDX-License-Identifier: MPL-2.0 + +import json +import pathlib +import sys + + +def load_yaml(path: pathlib.Path) -> dict: + try: + import yaml + except ImportError as exc: + raise RuntimeError( + "PyYAML is required. Install python3-yaml or pip install pyyaml." + ) from exc + return yaml.safe_load(path.read_text()) + + +def validate_plugin(plugin: dict, plugin_dir: pathlib.Path) -> dict: + required = ["id", "name", "node_kind", "kind", "entrypoint", "artifact", "description", "license"] + missing = [key for key in required if not plugin.get(key)] + if missing: + raise ValueError(f"{plugin_dir}: missing required fields: {', '.join(missing)}") + if plugin["id"] != plugin_dir.name: + raise ValueError( + f"{plugin_dir}: id '{plugin['id']}' must match directory name" + ) + ordered_keys = [ + "id", + "name", + "node_kind", + "kind", + "entrypoint", + "artifact", + "description", + "license", + "license_url", + "homepage", + "repository", + "compatibility", + "models", + ] + ordered = {key: plugin[key] for key in ordered_keys if key in plugin} + for key, value in plugin.items(): + if key not in ordered: + ordered[key] = value + return ordered + + +def main() -> int: + repo_root = pathlib.Path(__file__).resolve().parents[2] + plugins_root = repo_root / "plugins" / "native" + output_path = repo_root / "marketplace" / "official-plugins.json" + + if not plugins_root.exists(): + print(f"Missing plugins root: {plugins_root}", file=sys.stderr) + return 1 + + plugins = [] + errors = [] + for plugin_dir in sorted(plugins_root.iterdir()): + if not plugin_dir.is_dir(): + continue + metadata_path = plugin_dir / "marketplace.yml" + if not metadata_path.exists(): + metadata_path = plugin_dir / "marketplace.yaml" + if not metadata_path.exists(): + errors.append(f"Missing {plugin_dir}/marketplace.yml") + continue + try: + data = load_yaml(metadata_path) or {} + plugins.append(validate_plugin(data, plugin_dir)) + except Exception as exc: + errors.append(f"{metadata_path}: {exc}") + + if errors: + print("Failed to load marketplace metadata:", file=sys.stderr) + for err in errors: + print(f"- {err}", file=sys.stderr) + return 1 + + plugins = sorted(plugins, key=lambda item: item["id"]) + payload = {"plugins": plugins} + output_path.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n") + print(f"Wrote {output_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/marketplace/verify_bundles.py b/scripts/marketplace/verify_bundles.py new file mode 100644 index 00000000..06d5a69c --- /dev/null +++ b/scripts/marketplace/verify_bundles.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# SPDX-License-Identifier: MPL-2.0 + +import argparse +import json +import pathlib +import subprocess +import sys +import tempfile + + +def readelf_dynamic(path: pathlib.Path) -> tuple[list[str], list[str]]: + result = subprocess.run( + ["readelf", "-d", str(path)], + check=True, + text=True, + capture_output=True, + ) + needed = [] + rpaths = [] + for line in result.stdout.splitlines(): + line = line.strip() + if "(NEEDED)" in line and "Shared library:" in line: + lib = line.split("[", 1)[-1].split("]", 1)[0] + needed.append(lib) + elif "(RUNPATH)" in line or "(RPATH)" in line: + value = line.split("[", 1)[-1].split("]", 1)[0] + rpaths.append(value) + return needed, rpaths + + +def extract_bundle(bundle_path: pathlib.Path, dest: pathlib.Path) -> None: + subprocess.run( + ["tar", "--zstd", "-xf", str(bundle_path), "-C", str(dest)], + check=True, + ) + + +def find_bundle(bundles_dir: pathlib.Path, plugin_id: str) -> pathlib.Path | None: + suffix = "-bundle.tar.zst" + matches = [ + path + for path in bundles_dir.glob("*.tar.zst") + if path.name.startswith(f"{plugin_id}-") and path.name.endswith(suffix) + ] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise RuntimeError(f"Multiple bundles found for {plugin_id}: {matches}") + return None + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--plugins", required=True, help="Path to plugin metadata JSON") + parser.add_argument("--bundles", required=True, help="Directory with bundle archives") + args = parser.parse_args() + + plugins_path = pathlib.Path(args.plugins) + bundles_dir = pathlib.Path(args.bundles) + + if not plugins_path.exists(): + print(f"Missing metadata file: {plugins_path}", file=sys.stderr) + return 1 + if not bundles_dir.exists(): + print(f"Missing bundles directory: {bundles_dir}", file=sys.stderr) + return 1 + + metadata = json.loads(plugins_path.read_text()) + plugins = metadata.get("plugins", []) + if not plugins: + print("No plugins found in metadata", file=sys.stderr) + return 1 + + errors = [] + + for plugin in plugins: + plugin_id = plugin["id"] + entrypoint = plugin["entrypoint"] + bundle_path = find_bundle(bundles_dir, plugin_id) + if bundle_path is None: + errors.append(f"Missing bundle for {plugin_id} in {bundles_dir}") + continue + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = pathlib.Path(tmp_dir) + extract_bundle(bundle_path, tmp_path) + entrypoint_path = tmp_path / entrypoint + if not entrypoint_path.exists(): + errors.append( + f"{plugin_id}: missing entrypoint {entrypoint} in {bundle_path.name}" + ) + continue + + needed, rpaths = readelf_dynamic(entrypoint_path) + if any("/usr/local/lib" in value for value in rpaths): + errors.append( + f"{plugin_id}: entrypoint has RPATH/RUNPATH referencing /usr/local/lib" + ) + + if "libsherpa-onnx-c-api.so" in needed: + sherpa_lib = tmp_path / "libsherpa-onnx-c-api.so" + onnx_lib = tmp_path / "libonnxruntime.so" + if not sherpa_lib.exists(): + errors.append( + f"{plugin_id}: missing libsherpa-onnx-c-api.so in bundle" + ) + if not onnx_lib.exists(): + errors.append( + f"{plugin_id}: missing libonnxruntime.so in bundle" + ) + + if errors: + print("Portability verification failed:") + for err in errors: + print(f"- {err}") + return 1 + + print("Portability verification passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())