diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e6fa0af --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +build/ +.cache/ +.ci/ +report/ +.git/ +.github/ +**/*.md +!README.md +tests/ +docs/ +plugins/ +scripts/fuzz-* +scripts/lint* +scripts/tidy* +scripts/static-check.sh +scripts/coverage* +scripts/dependency-audit.sh +scripts/sbom.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4733cc0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,234 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + shell: bash + +env: + LLVM_VERSION: "22" + +jobs: + build-release: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - name: linux-x86_64 + runner: ubuntu-24.04 + platform: linux + arch: x86_64 + python_cmd: python3 + binary_path: build/build/Release/bin/beez + - name: linux-aarch64 + runner: ubuntu-24.04-arm + platform: linux + arch: aarch64 + python_cmd: python3 + binary_path: build/build/Release/bin/beez + - name: macos-x86_64 + runner: macos-15-intel + platform: macos + arch: x86_64 + python_cmd: python3 + binary_path: build/build/Release/bin/beez + - name: macos-aarch64 + runner: macos-15 + platform: macos + arch: aarch64 + python_cmd: python3 + binary_path: build/build/Release/bin/beez + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Beez toolchain (Linux) + if: runner.os == 'Linux' + uses: ./.github/actions/setup-beez + with: + install-cppcheck: "false" + install-cmakelang: "false" + + - name: Install Linux packaging prerequisites + if: runner.os == 'Linux' + run: sudo apt-get install -y --no-install-recommends zstd + + - name: Install macOS prerequisites + if: runner.os == 'macOS' + env: + HOMEBREW_NO_AUTO_UPDATE: 1 + HOMEBREW_NO_INSTALL_UPGRADE: 1 + run: | + set -euo pipefail + missing=() + command -v ninja >/dev/null || missing+=(ninja) + command -v zstd >/dev/null || missing+=(zstd) + command -v cmake >/dev/null || missing+=(cmake) + if ((${#missing[@]} > 0)); then + brew install "${missing[@]}" + fi + pipx install conan + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Build release binary + env: + RELEASE_PLATFORM: ${{ matrix.platform }} + RELEASE_ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + chmod +x scripts/ci/release-build.sh scripts/ci/release-conan-profile.sh + ./scripts/ci/release-build.sh + + - name: Package release artifact + run: | + set -euo pipefail + ${{ matrix.python_cmd }} scripts/ci/package_binary.py \ + --version "$GITHUB_REF_NAME" \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --binary "${{ matrix.binary_path }}" \ + --output-dir dist + + - name: Package ReqPack artifact + run: | + set -euo pipefail + ${{ matrix.python_cmd }} scripts/ci/package_reqpack.py \ + --version "$GITHUB_REF_NAME" \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --binary "${{ matrix.binary_path }}" \ + --output-dir dist + + - name: Package Beez plugins + run: | + set -euo pipefail + lua scripts/ci/package_all_plugins.lua \ + --version "$GITHUB_REF_NAME" \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --output-dir dist/plugins + + - name: Upload release artifact + uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.platform }}-${{ matrix.arch }} + path: | + dist/*.tar.gz + dist/*.rqp + dist/plugins/*.rqp + if-no-files-found: error + + release: + needs: build-release + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download packaged artifacts + uses: actions/download-artifact@v4 + with: + pattern: release-* + merge-multiple: true + path: dist + + - name: Build ReqPack repository index + run: | + set -euo pipefail + python3 scripts/ci/build_reqpack_index.py --dist-dir dist --output dist/index.json + lua scripts/ci/build_plugin_index.lua --dist-dir dist/plugins --output dist/plugins/index.json + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + NOTES_FILE="$(mktemp)" + cat > "${NOTES_FILE}" </dev/null 2>&1; then + gh release create "$GITHUB_REF_NAME" --repo "$GH_REPO" --title "Beez ${VERSION}" --notes-file "${NOTES_FILE}" + fi + gh release edit "$GITHUB_REF_NAME" --repo "$GH_REPO" --draft=false --latest --title "Beez ${VERSION}" --notes-file "${NOTES_FILE}" + shopt -s nullglob + assets=(dist/*.tar.gz dist/*.rqp dist/index.json dist/plugins/*.rqp dist/plugins/index.json) + if ((${#assets[@]} == 0)); then + echo "No release assets found in dist/." >&2 + exit 1 + fi + gh release upload "$GITHUB_REF_NAME" "${assets[@]}" --repo "$GH_REPO" --clobber + + docker: + needs: build-release + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Compute image metadata + id: meta + run: | + set -euo pipefail + IMAGE_NAME=$(printf '%s' "ghcr.io/coditary/beez" | tr '[:upper:]' '[:lower:]') + VERSION="${GITHUB_REF_NAME#v}" + CREATED=$(date -u +%Y-%m-%dT%H:%M:%SZ) + echo "image_name=${IMAGE_NAME}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "created=${CREATED}" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + printf '%s' "$GH_TOKEN" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin + + - name: Enable QEMU + run: | + set -euo pipefail + docker run --privileged --rm tonistiigi/binfmt --install amd64,arm64 + + - name: Create buildx builder + run: | + set -euo pipefail + docker buildx create --name beez-builder --use + docker buildx inspect --bootstrap + + - name: Build and push Docker image + run: | + set -euo pipefail + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg BEEZ_VERSION="${{ steps.meta.outputs.version }}" \ + --build-arg BUILD_DATE="${{ steps.meta.outputs.created }}" \ + --label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \ + -t "${{ steps.meta.outputs.image_name }}:${{ steps.meta.outputs.version }}" \ + -t "${{ steps.meta.outputs.image_name }}:${GITHUB_REF_NAME}" \ + -t "${{ steps.meta.outputs.image_name }}:latest" \ + --push . diff --git a/.gitignore b/.gitignore index 80c23fd..0bb55e4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Build build/ out/ +dist/ .cache/ cmake-build-*/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 015bdc1..b0c1ea1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.24) project( Beez - VERSION 0.1.0 + VERSION 1.0.3 DESCRIPTION "Beez - A modern C++ project" HOMEPAGE_URL "https://github.com/Leodoras/Beez" LANGUAGES CXX diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1fd383b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,81 @@ +# syntax=docker/dockerfile:1.7 + +ARG UBUNTU_VERSION=24.04 + +FROM --platform=$BUILDPLATFORM ubuntu:${UBUNTU_VERSION} AS build + +ARG DEBIAN_FRONTEND=noninteractive +ARG BEEZ_VERSION=1.0.2 +ARG LLVM_VERSION=22 +ARG UBUNTU_CODENAME=noble + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + cmake \ + curl \ + git \ + ninja-build \ + pipx \ + pkg-config \ + python3 \ + python3-venv \ + wget \ + && rm -rf /var/lib/apt/lists/* + +RUN wget -qO- "https://apt.llvm.org/llvm-snapshot.gpg.key" \ + | tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc >/dev/null \ + && echo "deb http://apt.llvm.org/${UBUNTU_CODENAME}/ llvm-toolchain-${UBUNTU_CODENAME}-${LLVM_VERSION} main" \ + > "/etc/apt/sources.list.d/llvm-${LLVM_VERSION}.list" \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + "clang-${LLVM_VERSION}" \ + "clang-tools-${LLVM_VERSION}" \ + && update-alternatives --install /usr/bin/clang clang "/usr/bin/clang-${LLVM_VERSION}" 100 \ + && update-alternatives --install /usr/bin/clang++ clang++ "/usr/bin/clang++-${LLVM_VERSION}" 100 \ + && rm -rf /var/lib/apt/lists/* + +ENV CC=clang \ + CXX=clang++ \ + PATH="/root/.local/bin:${PATH}" + +RUN pipx install conan + +WORKDIR /src +COPY . . + +ENV RELEASE_PLATFORM=linux +RUN case "${TARGETARCH}" in \ + amd64) export RELEASE_ARCH=x86_64 ;; \ + arm64) export RELEASE_ARCH=aarch64 ;; \ + *) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && chmod +x scripts/ci/release-build.sh scripts/ci/release-conan-profile.sh \ + && ./scripts/ci/release-build.sh + +FROM ubuntu:${UBUNTU_VERSION} AS runtime + +ARG DEBIAN_FRONTEND=noninteractive +ARG BEEZ_VERSION=1.0.2 +ARG BUILD_DATE + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + libtbb12 \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --uid 10001 beez + +COPY --from=build /src/build/build/Release/bin/beez /usr/local/bin/beez + +LABEL org.opencontainers.image.title="Beez" \ + org.opencontainers.image.description="Build and task orchestrator with Lua DSL" \ + org.opencontainers.image.version="${BEEZ_VERSION}" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.source="https://github.com/Coditary/Beez" + +USER beez +WORKDIR /work + +ENTRYPOINT ["/usr/local/bin/beez"] +CMD ["--help"] diff --git a/build.lua b/build.lua index 623b689..663089f 100644 --- a/build.lua +++ b/build.lua @@ -10,7 +10,7 @@ reqpack { { name = "coditary/ctest", path = "./plugins/coditary/ctest", version = "1.0.0" }, { name = "coditary/coverage", path = "./plugins/coditary/coverage", version = "1.0.0" }, { name = "coditary/fuzzer", path = "./plugins/coditary/fuzzer", version = "1.0.0" }, - { name = "coditary/clang-build", path = "./plugins/coditary/clang-build", version = "1.0.0" }, + { name = "coditary/clang", path = "./plugins/coditary/clang", version = "1.0.0" }, { name = "coditary/pipeline", path = "./plugins/coditary/pipeline", version = "1.0.0" }, }, } @@ -37,21 +37,21 @@ configure({ { "coditary/ctest", { reports_dir = REPORTS_DIR, test_rev = "4" } }, { "coditary/coverage", { report_rev = "1" } }, { "coditary/fuzzer", { fuzz_rev = "4" } }, - { "coditary/clang-build", { compile_rev = "4", link_rev = "10" } }, + { "coditary/clang", { compile_rev = "4", link_rev = "10" } }, }) -- ── Tasks ──────────────────────────────────────────────────────────────────── task("debug", { { plugin = "coditary/conan", step = "configure[debug]" }, - { plugin = "coditary/clang-build", step = "compile[debug]" }, - { plugin = "coditary/clang-build", step = "link[debug]" }, + { plugin = "coditary/clang", step = "compile[debug]" }, + { plugin = "coditary/clang", step = "link[debug]" }, }) task("fuzzer", { { plugin = "coditary/conan", step = "configure[fuzzer]" }, - { plugin = "coditary/clang-build", step = "compile[fuzzer]" }, - { plugin = "coditary/clang-build", step = "link[fuzzer]" }, + { plugin = "coditary/clang", step = "compile[fuzzer]" }, + { plugin = "coditary/clang", step = "link[fuzzer]" }, }) task("clean_reports", "rm -rf " .. REPORTS_DIR) diff --git a/conan.lock b/conan.lock index dbb73a2..22b0280 100644 --- a/conan.lock +++ b/conan.lock @@ -22,9 +22,12 @@ "c4core/0.4.0#fc276addc6ac339965ecb7f5842b2d2b%1782834243.05" ], "build_requires": [ + "strawberryperl/5.32.1.1#8d114504d172cfea8ea1662d09b6333e%1751971032.423", "pkgconf/2.5.1#93c2051284cba1279494a43a4fcfeae2%1757684701.089", "pkgconf/2.1.0#21f96520faf7660b99f872e956d2ac13%1755505630.82", "ninja/1.13.2#c8c5dc2a52ed6e4e42a66d75b4717ceb%1764096931.974", + "nasm/2.16.01#31e26f2ee3c4346ecd347911bd126904%1745483323.489", + "msys2/cci.latest#d22fe7b2808f5fd34d0a7923ace9c54f%1770657326.649", "meson/1.10.2#9d2d10681fe7fe61c788c58626c89b25%1775558003.754", "m4/1.4.19#1727f439cf74e83826ec96d0b4904eee%1784541921.659", "libtool/2.4.7#14e7739cc128bc1623d2ed318008e47e%1755679003.847", diff --git a/conanfile.py b/conanfile.py index 7211dfa..9a86c4d 100644 --- a/conanfile.py +++ b/conanfile.py @@ -6,12 +6,14 @@ class BeezConan(ConanFile): name = "beez" - version = "0.1.0" + version = "1.0.3" package_type = "application" license = "Apache-2.0" url = "https://github.com/Leodoras/Beez" settings = "os", "compiler", "build_type", "arch" + options = {"build_testing": [True, False]} + default_options = {"build_testing": True} exports_sources = "CMakeLists.txt", "src/*", "include/*", "tests/*", "build.lua" @@ -21,7 +23,8 @@ def configure(self): self.options["hwloc"].shared = True def requirements(self): - self.requires("gtest/1.14.0") + if self.options.build_testing: + self.requires("gtest/1.14.0") self.requires("lua/5.4.6") self.requires("cli11/2.6.2") self.requires("spdlog/1.15.3") diff --git a/default-plugin-repositories.json b/default-plugin-repositories.json new file mode 100644 index 0000000..97b9745 --- /dev/null +++ b/default-plugin-repositories.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "repositories": [ + { + "id": "beez-default", + "url": "https://raw.githubusercontent.com/Coditary/beez-registry/main/plugins.json", + "priority": 0, + "enabled": true + } + ] +} diff --git a/docs/plugin-publishing.md b/docs/plugin-publishing.md new file mode 100644 index 0000000..61122fd --- /dev/null +++ b/docs/plugin-publishing.md @@ -0,0 +1,160 @@ +# Beez Plugin Publishing + +Beez workflow plugins are distributed as ReqPack `.rqp` packages and installed through the bundled `beez` ReqPack package manager. + +## Plugin registry + +The plugin catalog lives in a separate repository: + +- Local checkout: `~/Dev/Coditary/beez-registry` +- GitHub: [Coditary/beez-registry](https://github.com/Coditary/beez-registry) +- Catalog file: `plugins.json` + +The `beez` PM fetches `plugins.json` from that repo. Each entry points at the Beez release asset `plugins/index.json`, which lists platform-specific `.rqp` downloads. + +Refresh the catalog after adding or changing plugins under `plugins/coditary/`: + +```bash +lua scripts/ci/sync_plugin_catalog.lua +``` + +Commit and push the changes in `beez-registry` separately from Beez releases. + +## Clang plugin monorepo (`beez-clang`) + +Clang plugins (`clang`, `clang-format`, `clang-tidy`) live in a shared repository: + +- Local checkout: `~/Dev/Coditary/beez-plugins/beez-clang` +- GitHub: [Coditary/beez-clang](https://github.com/Coditary/beez-clang) + +Bootstrap or refresh from the Beez monorepo: + +```bash +lua scripts/ci/bootstrap_beez_clang_monorepo.lua --init-git +``` + +Each plugin is a subdirectory with its own `beez.package.json`. The catalog references them via git source + path: + +```json +"source": { + "type": "git", + "url": "github:Coditary/beez-clang", + "path": "clang-format", + "ref": "1.0.0" +} +``` + +In `build.lua` you can also use: + +```lua +source = "github:Coditary/beez-clang#clang-format" +``` + +## Standalone plugin repositories + +Official plugins can live in separate Git repositories under `~/Dev/Coditary/beez-plugins/`. +Bootstrap or refresh them from the monorepo: + +```bash +lua scripts/ci/bootstrap_standalone_plugins.lua --init-git +``` + +Each directory is a complete Beez plugin (`beez.package.json`, `beez_plugin.lua`, `src/`, `README.md`, `LICENSE`). +Suggested GitHub naming: `Coditary/beez-plugin-`. + +## Monorepo plugins + +The `beez` PM can install from a subdirectory of a git repository: + +- `source = "github:Coditary/beez-clang#clang-format"` in `build.lua` +- catalog field `source.path` with `source.type = "git"` + +For release-based distribution, attach per-plugin indexes under the monorepo release, e.g. +`https://github.com/Coditary/beez-clang/releases/download/v1.0.0/clang-format/index.json`. + +## Package layout + +``` +plugins//// + beez_plugin.lua + src/ + scripts/ +``` + +## Build a plugin package + +```bash +lua scripts/ci/package_beez_plugin.lua \ + --organization coditary \ + --name clang-format \ + --version 1.0.0 \ + --source-dir plugins/coditary/clang-format/1.0.0 \ + --platform linux \ + --arch x86_64 \ + --output-dir dist/plugins +``` + +Build an index for release assets: + +```bash +lua scripts/ci/build_plugin_index.lua \ + --dist-dir dist/plugins \ + --output dist/plugins/index.json +``` + +## Install plugins + +After installing the Beez CLI (`rqp install rqp:beez-cli`), the `beez` package manager is available separately: + +```bash +# PM auto-downloads from 0_registry on first use if missing +rqp install beez coditary/clang-format@1.0.0 +``` + +Registry split (same model as Tempify): + +| Name | Role | Install | +|------|------|---------| +| `beez-cli` | CLI `.rqp` package | `rqp install rqp:beez-cli` | +| `beez` | Plugin package manager | `rqp install beez org/plugin@version` | + +Installing `beez-cli` also bundles the PM for offline use; `rqp install beez …` can still fetch the PM from registry when it is not installed yet. + +## Project declaration (`build.lua`) + +```lua +reqpack { + beez = { + { name = "coditary/clang-format", path = "./plugins/coditary/clang-format", version = "1.0.0" }, + { name = "coditary/clang-tidy", version = "1.0.0" }, + { name = "acme/custom-linter", version = "1.0.0", source = "github:acme/beez-custom-linter" }, + }, +} +``` + +- `path` — local development plugin +- `version` only — install from registry catalog / plugin index +- `source` — git/GitHub fallback when the plugin is not in the catalog + +## Git repository convention + +For plugins distributed outside the catalog: + +``` +my-beez-plugin/ + beez.package.json + beez_plugin.lua + src/ +``` + +`beez.package.json`: + +```json +{ + "schemaVersion": 1, + "organization": "acme", + "name": "custom-linter", + "version": "1.0.0", + "entry": "beez_plugin.lua" +} +``` diff --git a/include/beez/core/plugin/installer.hpp b/include/beez/core/plugin/installer.hpp index af9c9cd..66978c0 100644 --- a/include/beez/core/plugin/installer.hpp +++ b/include/beez/core/plugin/installer.hpp @@ -1,10 +1,12 @@ #pragma once #include "beez/core/util/expected.hpp" +#include "beez/plugin/lua/dsl/plugin_loader.hpp" #include #include #include +#include namespace beez::core { @@ -17,7 +19,8 @@ struct BeezPluginInstallResult [[nodiscard]] std::string formatBeezPluginInstallArg(const std::string& organization, const std::string& name, - const std::string& version); + const std::string& version, + const std::optional& source = std::nullopt); [[nodiscard]] Expected resolveInstalledBeezPluginScript(const std::optional& organization, @@ -30,6 +33,10 @@ resolveInstalledBeezPluginOrganization(const std::string& name, const std::strin [[nodiscard]] BeezPluginInstallResult ensureBeezPluginInstalled(const std::optional& organization, const std::string& name, - const std::string& version); + const std::string& version, + const std::optional& source = std::nullopt); + +[[nodiscard]] BeezPluginInstallResult ensureReqpackBeezPluginsInstalled( + const std::vector& plugins); } // namespace beez::core diff --git a/include/beez/plugin/lua/dsl/plugin_loader.hpp b/include/beez/plugin/lua/dsl/plugin_loader.hpp index 0a6812c..d865585 100644 --- a/include/beez/plugin/lua/dsl/plugin_loader.hpp +++ b/include/beez/plugin/lua/dsl/plugin_loader.hpp @@ -18,9 +18,20 @@ struct BeezPluginRef { std::string organization; std::string name; - std::string path; + std::optional path; std::optional version; + std::optional source; bool fromInstalledCache = false; + + [[nodiscard]] bool isLocal() const + { + return path.has_value() && !path->empty(); + } + + [[nodiscard]] bool isRemote() const + { + return !isLocal(); + } }; [[nodiscard]] std::vector parseBeezPluginTable(const sol::table& table); diff --git a/include/beez/plugin/lua/lua_dsl.hpp b/include/beez/plugin/lua/lua_dsl.hpp index 63cc9c1..3dc32ff 100644 --- a/include/beez/plugin/lua/lua_dsl.hpp +++ b/include/beez/plugin/lua/lua_dsl.hpp @@ -4,6 +4,7 @@ #include "beez/core/reqpack/types.hpp" #include "beez/plugin/contract/dsl_loader.hpp" #include "beez/plugin/contract/plugin.hpp" +#include "beez/plugin/lua/dsl/reqpack_beez_plugin_catalog.hpp" #include #include @@ -30,6 +31,8 @@ class LuaDslLoader : public IDslLoader [[nodiscard]] const core::ReqPackManifest& reqpackManifest() const; + [[nodiscard]] const ReqpackBeezPluginCatalog& reqpackBeezPlugins() const; + private: struct Impl; std::unique_ptr impl_; diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..c1d54cb --- /dev/null +++ b/metadata.json @@ -0,0 +1,8 @@ +{ + "formatVersion": 1, + "name": "beez", + "version": "1.0.3", + "summary": "ReqPack Beez plugin manager", + "description": "ReqPack plugin for Beez workflow plugin catalogs, .rqp installs, and git fallbacks", + "license": "Apache-2.0" +} diff --git a/packaging/reqpack/package/metadata.json.in b/packaging/reqpack/package/metadata.json.in new file mode 100644 index 0000000..68d06e6 --- /dev/null +++ b/packaging/reqpack/package/metadata.json.in @@ -0,0 +1,29 @@ +{ + "formatVersion": 1, + "name": "beez-cli", + "version": @VERSION@, + "release": 1, + "revision": 0, + "summary": "Beez build and task orchestrator", + "description": "Build and task orchestrator with Lua DSL, caching, parallel execution, and bundled Beez plugin manager.", + "license": "Apache-2.0", + "architecture": @ARCHITECTURE@, + "system": [@SYSTEM@], + "vendor": "Coditary", + "maintainerEmail": "Matographo@gmail.com", + "url": @URL@, + "homepage": "https://github.com/Coditary/Beez", + "sourceUrl": "https://github.com/Coditary/Beez", + "buildDate": @BUILD_DATE@, + "tags": ["cli", "build", "orchestrator", "beez"], + "binaries": ["beez"], + "payload": { + "path": "payload/payload.tar.zst", + "archive": "tar", + "compression": "zstd", + "hashAlgorithm": "sha256", + "hashFile": "hashes/payload.sha256", + "sizeCompressed": @SIZE_COMPRESSED@, + "sizeInstalledExpected": @SIZE_INSTALLED_EXPECTED@ + } +} diff --git a/packaging/reqpack/package/reqpack.lua b/packaging/reqpack/package/reqpack.lua new file mode 100644 index 0000000..0ace1b1 --- /dev/null +++ b/packaging/reqpack/package/reqpack.lua @@ -0,0 +1,7 @@ +return { + apiVersion = 1, + hooks = { + install = "scripts/install.lua", + remove = "scripts/remove.lua", + }, +} diff --git a/packaging/reqpack/package/scripts/install.lua b/packaging/reqpack/package/scripts/install.lua new file mode 100644 index 0000000..b0a8bdf --- /dev/null +++ b/packaging/reqpack/package/scripts/install.lua @@ -0,0 +1,106 @@ +local layout = dofile(context.paths.controlDir .. "/scripts/layout.lua") + +local payload, payload_error = layout.load_payload_manifest(context) +if payload == nil then + context.tx.failed(payload_error) + return false +end + +local paths = layout.paths(context) +local ok, dir_error = layout.ensure_directories(context, { + paths.package_root, + paths.version_root, + paths.bin_dir, + paths.share_dir, + paths.doc_dir, + paths.package_doc_dir, +}) +if not ok then + context.tx.failed(dir_error) + return false +end + +local shared_ok, shared_error = layout.ensure_shell_directories(context, { + paths.stable_bin_dir, +}) +if not shared_ok then + context.tx.failed(shared_error) + return false +end + +for _, dir in ipairs(payload.directories or {}) do + local target_dir = layout.join_path(paths.version_root, dir) + local mkdir_ok, mkdir_result = pcall(context.fs.mkdir, target_dir) + if not mkdir_ok or mkdir_result == false then + context.tx.failed("failed to prepare payload directory: " .. tostring(dir)) + return false + end +end + +for _, entry in ipairs(payload.files or {}) do + local source = layout.join_path(context.paths.payloadDir, entry.path) + local target = layout.join_path(paths.version_root, entry.path) + + if not context.fs.exists(source) then + context.tx.failed("missing payload file: " .. tostring(entry.path)) + return false + end + + local copy_ok, copy_result = pcall(context.fs.copy, source, target) + if not copy_ok or copy_result == false then + context.tx.failed("failed to copy payload file: " .. tostring(entry.path)) + return false + end + + if entry.executable then + local chmod_result = context.exec.run("chmod +x " .. layout.shell_quote(target)) + if not chmod_result.success then + local message = chmod_result.stderr ~= "" and chmod_result.stderr or ("failed to mark executable: " .. tostring(entry.path)) + context.tx.failed(message) + return false + end + end +end + +local clean_plugin_result = context.exec.run("rm -rf " .. layout.shell_quote(paths.plugin_root) .. " && mkdir -p " .. layout.shell_quote(paths.plugin_root)) +if not clean_plugin_result.success then + local message = clean_plugin_result.stderr ~= "" and clean_plugin_result.stderr or "failed to prepare bundled plugin root" + context.tx.failed(message) + return false +end + +local runtime_plugin_root = layout.join_path(paths.version_root, "share/reqpack/plugins/beez") +for _, relative_path in ipairs({"metadata.json", "reqpack.lua", "run.lua", "default-plugin-repositories.json", "scripts/install.lua", "scripts/remove.lua"}) do + local source = layout.join_path(runtime_plugin_root, relative_path) + local target = layout.join_path(paths.plugin_root, relative_path) + local parent_dir = layout.parent_dir(target) + local copy_result = context.exec.run( + "mkdir -p " .. layout.shell_quote(parent_dir) .. + " && cp -f " .. layout.shell_quote(source) .. " " .. layout.shell_quote(target) + ) + if not copy_result.success then + context.tx.failed("failed to install bundled plugin file: " .. tostring(relative_path)) + return false + end +end + +local marker_content = layout.owner_marker_contents(context, paths) +local plugin_marker_ok, plugin_marker_error = layout.write_owner_marker(context, paths.plugin_marker_path, marker_content) +if not plugin_marker_ok then + context.tx.failed(plugin_marker_error) + return false +end + +local symlink_marker_ok, symlink_marker_error = layout.write_owner_marker(context, paths.symlink_marker_path, marker_content) +if not symlink_marker_ok then + context.tx.failed(symlink_marker_error) + return false +end + +local link_ok, link_error = layout.update_symlink(context, paths.binary_path, paths.symlink_path) +if not link_ok then + context.tx.failed(link_error) + return false +end + +return true diff --git a/packaging/reqpack/package/scripts/layout.lua b/packaging/reqpack/package/scripts/layout.lua new file mode 100644 index 0000000..6e0ed30 --- /dev/null +++ b/packaging/reqpack/package/scripts/layout.lua @@ -0,0 +1,185 @@ +local M = {} + +local PLUGIN_OWNER_MARKER_NAME = ".beez-package-owner.json" +local SYMLINK_OWNER_MARKER_NAME = ".beez-symlink-owner.json" + +function M.join_path(base, relative) + if base == nil or base == "" or base == "." then + return relative + end + if relative == nil or relative == "" then + return base + end + if base == "/" then + return "/" .. relative + end + if base:sub(-1) == "/" then + return base .. relative + end + return base .. "/" .. relative +end + +function M.parent_dir(path) + local parent = tostring(path):match("^(.*)/[^/]+$") + if parent == nil or parent == "" then + return "." + end + return parent +end + +function M.shell_quote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +function M.trim(value) + return (tostring(value):gsub("^%s+", ""):gsub("%s+$", "")) +end + +function M.home_dir() + local home = os.getenv("HOME") + if home == nil or home == "" then + error("HOME is not set") + end + return home +end + +function M.data_home() + local data_home = os.getenv("XDG_DATA_HOME") + if data_home ~= nil and data_home ~= "" then + return data_home + end + return M.join_path(M.home_dir(), ".local/share") +end + +function M.version_identity(metadata) + return string.format("%s-%s+r%s", tostring(metadata.version), tostring(metadata.release), tostring(metadata.revision)) +end + +function M.package_identity(metadata) + return string.format("%s@%s", tostring(metadata.name), M.version_identity(metadata)) +end + +function M.paths(context) + local home = M.home_dir() + local data_home = M.data_home() + local local_root = M.join_path(home, ".local") + local share_root = M.join_path(local_root, "share") + local package_root = M.join_path(share_root, "beez") + local version_root = M.join_path(package_root, M.version_identity(context.metadata)) + local bin_dir = M.join_path(version_root, "bin") + local share_dir = M.join_path(version_root, "share") + local doc_dir = M.join_path(share_dir, "doc") + local package_doc_dir = M.join_path(doc_dir, "beez") + local stable_bin_dir = M.join_path(local_root, "bin") + local binary_path = M.join_path(bin_dir, "beez") + local symlink_path = M.join_path(stable_bin_dir, "beez") + local symlink_marker_path = M.join_path(stable_bin_dir, SYMLINK_OWNER_MARKER_NAME) + local plugin_root = M.join_path(M.join_path(data_home, "reqpack/plugins"), "beez") + local plugin_marker_path = M.join_path(plugin_root, PLUGIN_OWNER_MARKER_NAME) + + return { + data_home = data_home, + local_root = local_root, + share_root = share_root, + package_root = package_root, + version_root = version_root, + bin_dir = bin_dir, + share_dir = share_dir, + doc_dir = doc_dir, + package_doc_dir = package_doc_dir, + stable_bin_dir = stable_bin_dir, + binary_path = binary_path, + symlink_path = symlink_path, + symlink_marker_path = symlink_marker_path, + plugin_root = plugin_root, + plugin_marker_path = plugin_marker_path, + } +end + +function M.load_payload_manifest(context) + local ok, payload = pcall(dofile, M.join_path(context.paths.controlDir, "scripts/payload_files.lua")) + if not ok then + return nil, "failed to load payload manifest: " .. tostring(payload) + end + return payload +end + +function M.ensure_directories(context, directories) + for _, dir in ipairs(directories) do + if not context.fs.exists(dir) then + local ok, result = pcall(context.fs.mkdir, dir) + if not ok or result == false then + return false, "failed to create directory: " .. tostring(dir) + end + end + end + return true +end + +function M.ensure_shell_directories(context, directories) + for _, dir in ipairs(directories) do + local result = context.exec.run("mkdir -p " .. M.shell_quote(dir)) + if not result.success then + local message = result.stderr ~= "" and result.stderr or ("failed to create directory: " .. tostring(dir)) + return false, message + end + end + return true +end + +function M.update_symlink(context, source, target) + local command = "ln -sfn " .. M.shell_quote(source) .. " " .. M.shell_quote(target) + local result = context.exec.run(command) + if not result.success then + local message = result.stderr ~= "" and result.stderr or ("failed to create symlink: " .. tostring(target)) + return false, message + end + local ok, registered = pcall(context.artifacts.register_symlink, target) + if not ok or registered == false then + return false, "failed to register symlink: " .. tostring(target) + end + return true +end + +function M.owner_marker_contents(context, paths) + return table.concat({ + "{", + ' "package": "' .. tostring(context.metadata.name):gsub('\\', '\\\\'):gsub('"', '\\"') .. '",', + ' "version": "' .. tostring(context.metadata.version):gsub('\\', '\\\\'):gsub('"', '\\"') .. '",', + ' "release": ' .. tostring(context.metadata.release) .. ',', + ' "revision": ' .. tostring(context.metadata.revision) .. ',', + ' "identity": "' .. M.package_identity(context.metadata):gsub('\\', '\\\\'):gsub('"', '\\"') .. '",', + ' "runtimeRoot": "' .. tostring(paths.version_root):gsub('\\', '\\\\'):gsub('"', '\\"') .. '"', + "}", + "", + }, "\n") +end + +function M.write_owner_marker(context, path, content) + local command = "printf %s " .. M.shell_quote(content) .. " > " .. M.shell_quote(path) + local result = context.exec.run(command) + if not result.success then + local message = result.stderr ~= "" and result.stderr or ("failed to write owner marker: " .. tostring(path)) + return false, message + end + return true +end + +function M.read_symlink(context, path) + local result = context.exec.run("readlink " .. M.shell_quote(path)) + if not result.success then + return nil + end + return M.trim(result.stdout) +end + +function M.remove_path(context, path) + local result = context.exec.run("rm -f " .. M.shell_quote(path)) + if not result.success then + local message = result.stderr ~= "" and result.stderr or ("failed to remove path: " .. tostring(path)) + return false, message + end + return true +end + +return M diff --git a/packaging/reqpack/package/scripts/remove.lua b/packaging/reqpack/package/scripts/remove.lua new file mode 100644 index 0000000..9169218 --- /dev/null +++ b/packaging/reqpack/package/scripts/remove.lua @@ -0,0 +1,15 @@ +local layout = dofile(context.paths.controlDir .. "/scripts/layout.lua") +local paths = layout.paths(context) + +if context.fs.exists(paths.symlink_path) then + local current_target = layout.read_symlink(context, paths.symlink_path) + if current_target == paths.binary_path then + local ok, error_message = layout.remove_path(context, paths.symlink_path) + if not ok then + context.tx.failed(error_message) + return false + end + end +end + +return true diff --git a/packaging/reqpack/plugin/lib/archive.lua b/packaging/reqpack/plugin/lib/archive.lua new file mode 100644 index 0000000..ac4eea4 --- /dev/null +++ b/packaging/reqpack/plugin/lib/archive.lua @@ -0,0 +1,40 @@ +local util = dofile((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*)/") .. "/util.lua") + +local M = {} + +function M.create_tar(source_root, tar_path) + local parent = tar_path:match("^(.*)/[^/]+$") + if parent ~= nil then + util.mkdir_p(parent) + end + local ok, output, code = util.run_shell( + "tar -cf " .. util.shell_quote(tar_path) .. " -C " .. util.shell_quote(source_root) .. " ." + ) + if not ok then + error("failed to create tar archive (exit " .. tostring(code) .. "): " .. util.trim(output)) + end +end + +function M.compress_zstd(tar_path, zst_path) + local ok, output, code = util.run_shell( + "zstd -q -f " .. util.shell_quote(tar_path) .. " -o " .. util.shell_quote(zst_path) + ) + if not ok then + error("failed to compress payload (exit " .. tostring(code) .. "): " .. util.trim(output)) + end +end + +function M.create_rqp_archive(control_root, archive_path) + local parent = archive_path:match("^(.*)/[^/]+$") + if parent ~= nil then + util.mkdir_p(parent) + end + local ok, output, code = util.run_shell( + "tar -cf " .. util.shell_quote(archive_path) .. " -C " .. util.shell_quote(control_root) .. " ." + ) + if not ok then + error("failed to create rqp archive (exit " .. tostring(code) .. "): " .. util.trim(output)) + end +end + +return M diff --git a/packaging/reqpack/plugin/lib/hash.lua b/packaging/reqpack/plugin/lib/hash.lua new file mode 100644 index 0000000..875000b --- /dev/null +++ b/packaging/reqpack/plugin/lib/hash.lua @@ -0,0 +1,25 @@ +local util = dofile((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*)/") .. "/util.lua") + +local M = {} + +function M.sha256_file(path) + local ok, output = util.run_shell("sha256sum " .. util.shell_quote(path)) + if ok then + local digest = output:match("^(%x+)") + if digest ~= nil then + return digest + end + end + + ok, output = util.run_shell("openssl dgst -sha256 " .. util.shell_quote(path)) + if ok then + local digest = output:match("= (%x+)") + if digest ~= nil then + return digest + end + end + + error("failed to compute sha256 for " .. tostring(path)) +end + +return M diff --git a/packaging/reqpack/plugin/lib/metadata.lua b/packaging/reqpack/plugin/lib/metadata.lua new file mode 100644 index 0000000..2b0f65a --- /dev/null +++ b/packaging/reqpack/plugin/lib/metadata.lua @@ -0,0 +1,101 @@ +local util = dofile((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*)/") .. "/util.lua") + +local M = {} + +local VENDOR = "Coditary" +local MAINTAINER_EMAIL = "Matographo@gmail.com" +local HOMEPAGE = "https://github.com/Coditary/Beez" + +local SYSTEM_BY_PLATFORM = { + linux = "linux", + macos = "macos", +} + +function M.package_slug(organization, name) + return organization .. "-" .. name +end + +function M.archive_name(organization, name, version, platform, arch) + return string.format( + "%s-%s-%s-%s.rqp", + M.package_slug(organization, name), + version, + platform, + arch + ) +end + +function M.build_metadata(options) + local version = util.normalized_version(options.version) + local platform = options.platform + local arch = options.arch + local archive_name = M.archive_name(options.organization, options.name, version, platform, arch) + local release_tag = "v" .. version + local source_url = options.source_url or HOMEPAGE + local qualified = options.organization .. "/" .. options.name + + return { + formatVersion = 1, + name = qualified, + organization = options.organization, + plugin = options.name, + version = version, + release = 1, + revision = 0, + summary = options.summary or ("Beez plugin " .. qualified), + description = options.description or ("Beez workflow plugin " .. qualified), + license = options.license or "Apache-2.0", + architecture = arch, + system = { SYSTEM_BY_PLATFORM[platform] }, + vendor = VENDOR, + maintainerEmail = MAINTAINER_EMAIL, + url = source_url .. "/releases/download/" .. release_tag .. "/" .. archive_name, + homepage = options.homepage or HOMEPAGE, + sourceUrl = options.homepage or HOMEPAGE, + buildDate = os.date("!%Y-%m-%dT%H:%M:%SZ"), + tags = options.tags or { "beez", "plugin", options.organization, options.name }, + payload = { + path = "payload/payload.tar.zst", + archive = "tar", + compression = "zstd", + hashAlgorithm = "sha256", + hashFile = "hashes/payload.sha256", + sizeCompressed = options.size_compressed, + sizeInstalledExpected = options.size_installed, + }, + } +end + +function M.render_template(template_path, output_path, metadata) + local template = util.read_file(template_path) + if template == nil then + error("metadata template not found: " .. template_path) + end + + local payload = metadata.payload + local replacements = { + ["@QUALIFIED_NAME@"] = string.format("%q", metadata.name), + ["@ORGANIZATION@"] = string.format("%q", metadata.organization), + ["@PLUGIN_NAME@"] = string.format("%q", metadata.plugin), + ["@VERSION@"] = string.format("%q", metadata.version), + ["@ARCHITECTURE@"] = string.format("%q", metadata.architecture), + ["@SYSTEM@"] = string.format("%q", metadata.system[1]), + ["@URL@"] = string.format("%q", metadata.url), + ["@BUILD_DATE@"] = string.format("%q", metadata.buildDate), + ["@SUMMARY@"] = string.format("%q", metadata.summary), + ["@DESCRIPTION@"] = string.format("%q", metadata.description), + ["@SIZE_COMPRESSED@"] = tostring(payload.sizeCompressed), + ["@SIZE_INSTALLED_EXPECTED@"] = tostring(payload.sizeInstalledExpected), + } + + for key, value in pairs(replacements) do + template = template:gsub(key, value, 1) + end + + local ok, err = util.write_file(output_path, template) + if not ok then + error("failed to write metadata: " .. tostring(err)) + end +end + +return M diff --git a/packaging/reqpack/plugin/lib/pack.lua b/packaging/reqpack/plugin/lib/pack.lua new file mode 100644 index 0000000..1d862b0 --- /dev/null +++ b/packaging/reqpack/plugin/lib/pack.lua @@ -0,0 +1,173 @@ +local util = dofile((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*)/") .. "/util.lua") +local hash = dofile((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*)/") .. "/hash.lua") +local archive = dofile((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*)/") .. "/archive.lua") +local metadata_lib = dofile((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*)/") .. "/metadata.lua") + +local M = {} + +local TEMPLATE_FILES = { + "reqpack.lua", + "scripts/layout.lua", + "scripts/install.lua", + "scripts/remove.lua", +} + +local function copy_template_tree(template_root, control_root) + for _, relative_path in ipairs(TEMPLATE_FILES) do + local source_path = util.join_path(template_root, relative_path) + if not util.file_exists(source_path) then + error("template file not found: " .. source_path) + end + local target_path = util.join_path(control_root, relative_path) + util.mkdir_p(target_path:match("^(.*)/[^/]+$") or control_root) + local ok, output = util.run_shell( + "cp -f " .. util.shell_quote(source_path) .. " " .. util.shell_quote(target_path) + ) + if not ok then + error("failed to copy template file: " .. relative_path .. " (" .. util.trim(output) .. ")") + end + end +end + +local function compute_directories(files, payload_root) + local directories = {} + local seen = {} + for _, file_path in ipairs(files) do + local relative = file_path:sub(#payload_root + 2) + local current = relative + while true do + local parent = current:match("^(.*)/[^/]+$") + if parent == nil or parent == "" then + break + end + if not seen[parent] then + seen[parent] = true + directories[#directories + 1] = parent + end + current = parent + end + end + table.sort(directories) + return directories +end + +local function write_payload_manifest(payload_root, script_path) + local files = util.list_files(payload_root) + local directories = compute_directories(files, payload_root) + local lines = { "return {", " directories = {" } + for _, directory in ipairs(directories) do + lines[#lines + 1] = string.format(' "%s",', directory) + end + lines[#lines + 1] = " }," + lines[#lines + 1] = " files = {" + for _, file_path in ipairs(files) do + local relative = file_path:sub(#payload_root + 2) + local executable = util.is_executable(file_path) and "true" or "false" + lines[#lines + 1] = string.format(' { path = "%s", executable = %s },', relative, executable) + end + lines[#lines + 1] = " }," + lines[#lines + 1] = "}" + util.write_file(script_path, table.concat(lines, "\n") .. "\n") +end + +local function installed_payload_size(payload_root) + local total = 0 + for _, file_path in ipairs(util.list_files(payload_root)) do + local handle = io.open(file_path, "rb") + if handle ~= nil then + total = total + handle:seek("end") + handle:close() + end + end + return total +end + +function M.package_plugin(options) + local version = util.normalized_version(options.version) + local source_dir = options.source_dir + if not util.file_exists(util.join_path(source_dir, "beez_plugin.lua")) then + error("source directory must contain beez_plugin.lua: " .. source_dir) + end + + local template_root = options.template_root + local output_dir = options.output_dir + util.mkdir_p(output_dir) + + local archive_name = metadata_lib.archive_name( + options.organization, + options.name, + version, + options.platform, + options.arch + ) + local archive_path = util.join_path(output_dir, archive_name) + local temp_root = util.join_path(output_dir, ".pack-tmp-" .. options.organization .. "-" .. options.name) + util.remove_tree(temp_root) + util.mkdir_p(temp_root) + + local payload_root = util.join_path(temp_root, "payload-tree") + local control_root = util.join_path(temp_root, "control") + local control_scripts = util.join_path(control_root, "scripts") + local control_hashes = util.join_path(control_root, "hashes") + local control_payload = util.join_path(control_root, "payload") + + util.mkdir_p(payload_root) + util.mkdir_p(control_scripts) + util.mkdir_p(control_hashes) + util.mkdir_p(control_payload) + copy_template_tree(template_root, control_root) + + local copied, copy_output = util.copy_tree(source_dir, payload_root) + if not copied then + util.remove_tree(temp_root) + error("failed to copy plugin source: " .. util.trim(copy_output)) + end + + write_payload_manifest(payload_root, util.join_path(control_scripts, "payload_files.lua")) + + local payload_tar = util.join_path(control_payload, "payload.tar") + archive.create_tar(payload_root, payload_tar) + local payload_zst = util.join_path(control_payload, "payload.tar.zst") + archive.compress_zstd(payload_tar, payload_zst) + local zst_handle = io.open(payload_zst, "rb") + local size_compressed = zst_handle:seek("end") + zst_handle:close() + local size_installed = installed_payload_size(payload_root) + local payload_sha256 = hash.sha256_file(payload_zst) + util.write_file( + util.join_path(control_hashes, "payload.sha256"), + payload_sha256 .. " payload/payload.tar.zst\n" + ) + util.run_shell("rm -f " .. util.shell_quote(payload_tar)) + + local metadata = metadata_lib.build_metadata({ + organization = options.organization, + name = options.name, + version = version, + platform = options.platform, + arch = options.arch, + source_url = options.source_url, + homepage = options.homepage, + summary = options.summary, + description = options.description, + license = options.license, + tags = options.tags, + size_compressed = size_compressed, + size_installed = size_installed, + }) + + metadata_lib.render_template( + util.join_path(template_root, "metadata.json.in"), + util.join_path(control_root, "metadata.json"), + metadata + ) + + if util.file_exists(archive_path) then + util.remove_tree(archive_path) + end + archive.create_rqp_archive(control_root, archive_path) + util.remove_tree(temp_root) + return archive_path, metadata +end + +return M diff --git a/packaging/reqpack/plugin/lib/util.lua b/packaging/reqpack/plugin/lib/util.lua new file mode 100644 index 0000000..b2c15bd --- /dev/null +++ b/packaging/reqpack/plugin/lib/util.lua @@ -0,0 +1,142 @@ +local M = {} + +function M.trim(value) + return (tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", "")) +end + +function M.starts_with(value, prefix) + local text = tostring(value or "") + local expected = tostring(prefix or "") + return text:sub(1, #expected) == expected +end + +function M.join_path(...) + local parts = {} + for index = 1, select("#", ...) do + local part = M.trim(select(index, ...)) + if part ~= "" then + if #parts == 0 then + parts[#parts + 1] = part:gsub("[/\\]+$", "") + else + parts[#parts + 1] = part:gsub("^[/\\]+", ""):gsub("[/\\]+$", "") + end + end + end + if #parts == 0 then + return "." + end + return table.concat(parts, "/") +end + +function M.shell_quote(value) + return "'" .. tostring(value or ""):gsub("'", "'\\''") .. "'" +end + +function M.normalized_version(raw) + local text = M.trim(raw) + if M.starts_with(text, "v") then + return text:sub(2) + end + return text +end + +function M.run_shell(command) + local handle = io.popen(command .. " 2>&1") + if handle == nil then + return false, "", 1 + end + local output = handle:read("*a") or "" + local ok, _, code = handle:close() + if ok == true then + return true, output, 0 + end + return false, output, tonumber(code) or 1 +end + +function M.shell_success(command) + local success = M.run_shell(command) + return success +end + +function M.mkdir_p(path) + return M.run_shell("mkdir -p " .. M.shell_quote(path)) +end + +function M.read_file(path) + local file = io.open(path, "rb") + if file == nil then + return nil + end + local content = file:read("*a") or "" + file:close() + return content +end + +function M.write_file(path, content) + local parent = path:match("^(.*)/[^/]+$") + if parent ~= nil and parent ~= "" then + local ok = M.mkdir_p(parent) + if not ok then + return false + end + end + local file, err = io.open(path, "wb") + if file == nil then + return false, err + end + file:write(content or "") + file:close() + return true +end + +function M.file_exists(path) + local file = io.open(path, "rb") + if file == nil then + return false + end + file:close() + return true +end + +function M.is_executable(path) + local ok, output = M.run_shell("test -x " .. M.shell_quote(path) .. " && echo yes") + return ok and M.trim(output) == "yes" +end + +function M.list_files(root) + local files = {} + local ok, output = M.run_shell("find " .. M.shell_quote(root) .. " -type f | sort") + if not ok then + return files + end + for line in (output .. "\n"):gmatch("(.-)\n") do + local trimmed = M.trim(line) + if trimmed ~= "" then + files[#files + 1] = trimmed + end + end + return files +end + +function M.copy_tree(source, destination) + local ok = M.mkdir_p(destination) + if not ok then + return false + end + return M.run_shell("cp -R " .. M.shell_quote(M.join_path(source, ".")) .. " " .. M.shell_quote(destination)) +end + +function M.remove_tree(path) + return M.run_shell("rm -rf " .. M.shell_quote(path)) +end + +function M.repo_root_from_script(level) + local source = debug.getinfo(level or 2, "S").source + if M.starts_with(source, "@") then + source = source:sub(2) + end + local script_dir = source:match("^(.*)/[^/]+$") or "." + return M.join_path(script_dir, "..", "..", "..", "..") +end + +return M diff --git a/packaging/reqpack/plugin/metadata.json.in b/packaging/reqpack/plugin/metadata.json.in new file mode 100644 index 0000000..3757167 --- /dev/null +++ b/packaging/reqpack/plugin/metadata.json.in @@ -0,0 +1,30 @@ +{ + "formatVersion": 1, + "name": @QUALIFIED_NAME@, + "organization": @ORGANIZATION@, + "plugin": @PLUGIN_NAME@, + "version": @VERSION@, + "release": 1, + "revision": 0, + "summary": @SUMMARY@, + "description": @DESCRIPTION@, + "license": "Apache-2.0", + "architecture": @ARCHITECTURE@, + "system": [@SYSTEM@], + "vendor": "Coditary", + "maintainerEmail": "Matographo@gmail.com", + "url": @URL@, + "homepage": "https://github.com/Coditary/Beez", + "sourceUrl": "https://github.com/Coditary/Beez", + "buildDate": @BUILD_DATE@, + "tags": ["beez", "plugin"], + "payload": { + "path": "payload/payload.tar.zst", + "archive": "tar", + "compression": "zstd", + "hashAlgorithm": "sha256", + "hashFile": "hashes/payload.sha256", + "sizeCompressed": @SIZE_COMPRESSED@, + "sizeInstalledExpected": @SIZE_INSTALLED_EXPECTED@ + } +} diff --git a/packaging/reqpack/plugin/reqpack.lua b/packaging/reqpack/plugin/reqpack.lua new file mode 100644 index 0000000..4324955 --- /dev/null +++ b/packaging/reqpack/plugin/reqpack.lua @@ -0,0 +1,5 @@ +return { + apiVersion = 1, + install = "scripts/install.lua", + remove = "scripts/remove.lua", +} diff --git a/packaging/reqpack/plugin/scripts/install.lua b/packaging/reqpack/plugin/scripts/install.lua new file mode 100644 index 0000000..b079be4 --- /dev/null +++ b/packaging/reqpack/plugin/scripts/install.lua @@ -0,0 +1,56 @@ +local layout = dofile(context.paths.controlDir .. "/scripts/layout.lua") + +local payload, payload_error = layout.load_payload_manifest(context) +if payload == nil then + context.tx.failed(payload_error) + return false +end + +local paths = layout.paths(context) +local ok, dir_error = layout.ensure_directories(context, { paths.plugin_root }) +if not ok then + context.tx.failed(dir_error) + return false +end + +for _, entry in ipairs(payload.files or {}) do + local source = layout.join_path(context.paths.payloadDir, entry.path) + local target = layout.join_path(paths.plugin_root, entry.path) + + if not context.fs.exists(source) then + context.tx.failed("missing payload file: " .. tostring(entry.path)) + return false + end + + local parent = target:match("^(.*)/[^/]+$") + if parent ~= nil and parent ~= "" then + local mkdir_ok, mkdir_result = pcall(context.fs.mkdir, parent) + if not mkdir_ok or mkdir_result == false then + context.tx.failed("failed to create plugin directory: " .. tostring(parent)) + return false + end + end + + local copy_ok, copy_result = pcall(context.fs.copy, source, target) + if not copy_ok or copy_result == false then + context.tx.failed("failed to copy plugin file: " .. tostring(entry.path)) + return false + end + + if entry.executable then + local chmod_result = context.exec.run("chmod +x " .. layout.shell_quote(target)) + if not chmod_result.success then + local message = chmod_result.stderr ~= "" and chmod_result.stderr or ("failed to mark executable: " .. tostring(entry.path)) + context.tx.failed(message) + return false + end + end +end + +local entry_path = layout.join_path(paths.plugin_root, "beez_plugin.lua") +if not context.fs.exists(entry_path) then + context.tx.failed("installed plugin is missing beez_plugin.lua") + return false +end + +return true diff --git a/packaging/reqpack/plugin/scripts/layout.lua b/packaging/reqpack/plugin/scripts/layout.lua new file mode 100644 index 0000000..c72346d --- /dev/null +++ b/packaging/reqpack/plugin/scripts/layout.lua @@ -0,0 +1,72 @@ +local M = {} + +function M.join_path(base, relative) + if base == nil or base == "" or base == "." then + return relative + end + if relative == nil or relative == "" then + return base + end + if base == "/" then + return "/" .. relative + end + if base:sub(-1) == "/" then + return base .. relative + end + return base .. "/" .. relative +end + +function M.shell_quote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +function M.trim(value) + return (tostring(value):gsub("^%s+", ""):gsub("%s+$", "")) +end + +function M.cache_home() + local cache_home = os.getenv("XDG_CACHE_HOME") + if cache_home ~= nil and cache_home ~= "" then + return cache_home + end + local home = os.getenv("HOME") + if home == nil or home == "" then + error("HOME is not set") + end + return M.join_path(home, ".cache") +end + +function M.plugin_root(context) + local organization = context.metadata.organization + local plugin_name = context.metadata.plugin + local version = context.metadata.version + return M.join_path(M.cache_home(), "beez", "plugins", organization, plugin_name, version) +end + +function M.paths(context) + return { + plugin_root = M.plugin_root(context), + } +end + +function M.load_payload_manifest(context) + local ok, payload = pcall(dofile, M.join_path(context.paths.controlDir, "scripts/payload_files.lua")) + if not ok then + return nil, "failed to load payload manifest: " .. tostring(payload) + end + return payload +end + +function M.ensure_directories(context, directories) + for _, dir in ipairs(directories) do + if not context.fs.exists(dir) then + local ok, result = pcall(context.fs.mkdir, dir) + if not ok or result == false then + return false, "failed to create directory: " .. tostring(dir) + end + end + end + return true +end + +return M diff --git a/packaging/reqpack/plugin/scripts/remove.lua b/packaging/reqpack/plugin/scripts/remove.lua new file mode 100644 index 0000000..1bb98fe --- /dev/null +++ b/packaging/reqpack/plugin/scripts/remove.lua @@ -0,0 +1,11 @@ +local layout = dofile(context.paths.controlDir .. "/scripts/layout.lua") + +local paths = layout.paths(context) +local remove_result = context.exec.run("rm -rf " .. layout.shell_quote(paths.plugin_root)) +if not remove_result.success then + local message = remove_result.stderr ~= "" and remove_result.stderr or "failed to remove installed plugin" + context.tx.failed(message) + return false +end + +return true diff --git a/plugins/coditary/clang-build/1.0.0/beez_plugin.lua b/plugins/coditary/clang/1.0.0/beez_plugin.lua similarity index 99% rename from plugins/coditary/clang-build/1.0.0/beez_plugin.lua rename to plugins/coditary/clang/1.0.0/beez_plugin.lua index 6cd5899..45d2755 100644 --- a/plugins/coditary/clang-build/1.0.0/beez_plugin.lua +++ b/plugins/coditary/clang/1.0.0/beez_plugin.lua @@ -82,7 +82,7 @@ for step_name, profile_name in pairs(defaults.link_step_profiles) do plugin_steps[step_name] = link_step_def(step_name, profile_name) end -plugin("clang-build", { +plugin("clang", { version = "1.0.0", description = "Clang compile and link steps from compile_commands.json", organization = "coditary", diff --git a/plugins/coditary/clang-build/1.0.0/scripts/compdb_index.py b/plugins/coditary/clang/1.0.0/scripts/compdb_index.py similarity index 100% rename from plugins/coditary/clang-build/1.0.0/scripts/compdb_index.py rename to plugins/coditary/clang/1.0.0/scripts/compdb_index.py diff --git a/plugins/coditary/clang-build/1.0.0/src/command.lua b/plugins/coditary/clang/1.0.0/src/command.lua similarity index 100% rename from plugins/coditary/clang-build/1.0.0/src/command.lua rename to plugins/coditary/clang/1.0.0/src/command.lua diff --git a/plugins/coditary/clang-build/1.0.0/src/compdb.lua b/plugins/coditary/clang/1.0.0/src/compdb.lua similarity index 100% rename from plugins/coditary/clang-build/1.0.0/src/compdb.lua rename to plugins/coditary/clang/1.0.0/src/compdb.lua diff --git a/plugins/coditary/clang-build/1.0.0/src/compile.lua b/plugins/coditary/clang/1.0.0/src/compile.lua similarity index 100% rename from plugins/coditary/clang-build/1.0.0/src/compile.lua rename to plugins/coditary/clang/1.0.0/src/compile.lua diff --git a/plugins/coditary/clang-build/1.0.0/src/config.lua b/plugins/coditary/clang/1.0.0/src/config.lua similarity index 95% rename from plugins/coditary/clang-build/1.0.0/src/config.lua rename to plugins/coditary/clang/1.0.0/src/config.lua index 968726d..0633298 100644 --- a/plugins/coditary/clang-build/1.0.0/src/config.lua +++ b/plugins/coditary/clang/1.0.0/src/config.lua @@ -23,7 +23,7 @@ function M.resolve(step_cfg, profile_name, root) local cfg = step_cfg or {} local profile = defaults.build_profiles[profile_name] if profile == nil then - error("unknown clang-build profile: " .. tostring(profile_name)) + error("unknown clang profile: " .. tostring(profile_name)) end local build_tree = M.resolve_build_tree(profile, cfg) diff --git a/plugins/coditary/clang-build/1.0.0/src/defaults.lua b/plugins/coditary/clang/1.0.0/src/defaults.lua similarity index 98% rename from plugins/coditary/clang-build/1.0.0/src/defaults.lua rename to plugins/coditary/clang/1.0.0/src/defaults.lua index 757e95c..65e2237 100644 --- a/plugins/coditary/clang-build/1.0.0/src/defaults.lua +++ b/plugins/coditary/clang/1.0.0/src/defaults.lua @@ -8,7 +8,7 @@ M.fuzz_build_tree = "build/build/Fuzz" M.fuzzer_bin = M.fuzz_build_tree .. "/fuzz/fuzz_lua_dsl" M.coverage_stamp = M.coverage_build_tree .. "/.beez-coverage-configured" -M.index_script = "plugins/coditary/clang-build/1.0.0/scripts/compdb_index.py" +M.index_script = "plugins/coditary/clang/1.0.0/scripts/compdb_index.py" M.python_binary = "python3" M.log_prefix_compile = "[clang-compile]" diff --git a/plugins/coditary/clang-build/1.0.0/src/link.lua b/plugins/coditary/clang/1.0.0/src/link.lua similarity index 100% rename from plugins/coditary/clang-build/1.0.0/src/link.lua rename to plugins/coditary/clang/1.0.0/src/link.lua diff --git a/plugins/coditary/clang-build/1.0.0/src/runner.lua b/plugins/coditary/clang/1.0.0/src/runner.lua similarity index 92% rename from plugins/coditary/clang-build/1.0.0/src/runner.lua rename to plugins/coditary/clang/1.0.0/src/runner.lua index 66576af..e25e1bb 100644 --- a/plugins/coditary/clang-build/1.0.0/src/runner.lua +++ b/plugins/coditary/clang/1.0.0/src/runner.lua @@ -14,7 +14,7 @@ function M.compile(ctx, step_name) local step_cfg = ctx.get_config() if step_cfg == nil then - error("clang-build compile step config is missing") + error("clang compile step config is missing") end local cfg = config.resolve(step_cfg, profile_name, ctx.project_root) @@ -38,7 +38,7 @@ function M.link(ctx, step_name) local step_cfg = ctx.get_config() if step_cfg == nil then - error("clang-build link step config is missing") + error("clang link step config is missing") end local cfg = config.resolve(step_cfg, profile_name, ctx.project_root) diff --git a/reqpack.lua b/reqpack.lua new file mode 100644 index 0000000..a4228be --- /dev/null +++ b/reqpack.lua @@ -0,0 +1,4 @@ +return { + apiVersion = 1, + depends = {}, +} diff --git a/run.lua b/run.lua new file mode 100644 index 0000000..90c47ef --- /dev/null +++ b/run.lua @@ -0,0 +1,1065 @@ +plugin = {} + +local PLUGIN_NAME = "beez" +local PLUGIN_VERSION = "1.0.3" +local GIT_BINARY = "git" +local STATE_SCHEMA_VERSION = 1 + +local function trim(value) + return (tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", "")) +end + +local function starts_with(value, prefix) + return tostring(value or ""):sub(1, #prefix) == prefix +end + +local function join_path(...) + local parts = {} + for index = 1, select("#", ...) do + local part = trim(select(index, ...)) + if part ~= "" then + if #parts == 0 then + parts[#parts + 1] = part:gsub("[/\\]+$", "") + else + parts[#parts + 1] = part:gsub("^[/\\]+", ""):gsub("[/\\]+$", "") + end + end + end + if #parts == 0 then + return "." + end + return table.concat(parts, "/") +end + +local function shell_quote(value) + return "'" .. tostring(value or ""):gsub("'", "'\\''") .. "'" +end + +local function run_shell(command) + local handle = io.popen(command .. " 2>&1") + if handle == nil then + return false, "", 1 + end + local output = handle:read("*a") or "" + local ok, _, code = handle:close() + if ok == true then + return true, output, 0 + end + return false, output, tonumber(code) or 1 +end + +local function shell_success(command) + local success = run_shell(command) + return success +end + +local function read_file(path) + local file = io.open(path, "rb") + if file == nil then + return nil + end + local content = file:read("*a") or "" + file:close() + return content +end + +local function write_file(path, content) + local parent = path:match("^(.*)/[^/]+$") + if parent ~= nil and parent ~= "" then + run_shell("mkdir -p " .. shell_quote(parent)) + end + local file, err = io.open(path, "wb") + if file == nil then + return false, err + end + file:write(content or "") + file:close() + return true +end + +local function decode_json(text) + local index = 1 + local length = #text + + local function skip_whitespace() + while index <= length and text:sub(index, index):match("%s") do + index = index + 1 + end + end + + local parse_value + + local function parse_string() + index = index + 1 + local start_index = index + while index <= length do + local char = text:sub(index, index) + if char == '"' then + local value = text:sub(start_index, index - 1) + index = index + 1 + return value + end + if char == "\\" then + index = index + 2 + else + index = index + 1 + end + end + error("unterminated json string") + end + + local function parse_array() + index = index + 1 + skip_whitespace() + local items = {} + if text:sub(index, index) == "]" then + index = index + 1 + return items + end + while true do + items[#items + 1] = parse_value() + skip_whitespace() + local char = text:sub(index, index) + if char == "]" then + index = index + 1 + return items + end + if char ~= "," then + error("expected ',' in array") + end + index = index + 1 + skip_whitespace() + end + end + + local function parse_object() + index = index + 1 + skip_whitespace() + local object = {} + if text:sub(index, index) == "}" then + index = index + 1 + return object + end + while true do + skip_whitespace() + if text:sub(index, index) ~= '"' then + error("expected object key") + end + local key = parse_string() + skip_whitespace() + if text:sub(index, index) ~= ":" then + error("expected ':'") + end + index = index + 1 + object[key] = parse_value() + skip_whitespace() + local char = text:sub(index, index) + if char == "}" then + index = index + 1 + return object + end + if char ~= "," then + error("expected ',' in object") + end + index = index + 1 + end + end + + function parse_value() + skip_whitespace() + local char = text:sub(index, index) + if char == '"' then + return parse_string() + end + if char == "{" then + return parse_object() + end + if char == "[" then + return parse_array() + end + if char == "t" and text:sub(index, index + 3) == "true" then + index = index + 4 + return true + end + if char == "f" and text:sub(index, index + 4) == "false" then + index = index + 5 + return false + end + if char == "n" and text:sub(index, index + 3) == "null" then + index = index + 4 + return nil + end + local start_index = index + while index <= length and text:sub(index, index):match("[%d%+%-%.eE]") do + index = index + 1 + end + local number_text = text:sub(start_index, index - 1) + if number_text ~= "" then + return tonumber(number_text) + end + error("invalid json token") + end + + local value = parse_value() + return value +end + +local function read_json_file(path) + local content = read_file(path) + if content == nil then + return nil, "file not found: " .. path + end + local ok, value = pcall(decode_json, content) + if not ok then + return nil, "failed to parse json: " .. tostring(value) + end + return value +end + +local function fetch_json(url) + local target = trim(url) + if starts_with(target, "file://") then + return read_json_file(target:sub(8)) + end + if starts_with(target, "/") then + return read_json_file(target) + end + local ok, output, code = run_shell("curl -fsSL " .. shell_quote(target)) + if not ok then + return nil, "failed to fetch " .. target .. " (exit " .. tostring(code) .. ")" + end + local ok_decode, value = pcall(decode_json, output) + if not ok_decode then + return nil, "failed to parse json from " .. target + end + return value +end + +local function cache_home() + local cache_home_env = os.getenv("XDG_CACHE_HOME") + if cache_home_env ~= nil and cache_home_env ~= "" then + return cache_home_env + end + local home = os.getenv("HOME") + if home == nil or home == "" then + return nil + end + return join_path(home, ".cache") +end + +local function data_home() + local data_home_env = os.getenv("XDG_DATA_HOME") + if data_home_env ~= nil and data_home_env ~= "" then + return data_home_env + end + local home = os.getenv("HOME") + if home == nil or home == "" then + return nil + end + return join_path(home, ".local/share") +end + +local function plugin_dir(context) + if context ~= nil and context.plugin ~= nil and trim(context.plugin.dir) ~= "" then + return trim(context.plugin.dir) + end + local data = data_home() + if data == nil then + return nil + end + return join_path(data, "reqpack/plugins", PLUGIN_NAME) +end + +local function state_paths(cache_root) + return { + cache_root = cache_root, + plugins_root = join_path(cache_root, "beez", "plugins"), + index_root = join_path(cache_root, "beez", "index"), + installed_path = join_path(cache_root, "beez", "index", "installed.json"), + catalog_cache_path = join_path(cache_root, "beez", "index", "catalog.json"), + } +end + +local function default_data_root() + local cache = cache_home() + if cache == nil then + return nil + end + return join_path(cache, "beez") +end + +local function split_plugin_id(plugin_id) + local text = trim(plugin_id) + local slash = text:find("/", 1, true) + if slash == nil or slash == 1 or slash == #text then + return nil, nil + end + return text:sub(1, slash - 1), text:sub(slash + 1) +end + +local function plugin_install_path(paths, organization, name, version) + return join_path(paths.plugins_root, organization, name, version) +end + +local function plugin_script_path(paths, organization, name, version) + return join_path(plugin_install_path(paths, organization, name, version), "beez_plugin.lua") +end + +local function is_installed(paths, organization, name, version) + return shell_success("test -f " .. shell_quote(plugin_script_path(paths, organization, name, version))) +end + +local function load_installed_records(paths) + local records = read_json_file(paths.installed_path) + if records == nil or type(records) ~= "table" then + return { schemaVersion = STATE_SCHEMA_VERSION, plugins = {} } + end + if records.plugins == nil then + records.plugins = {} + end + return records +end + +local function save_installed_records(paths, records) + run_shell("mkdir -p " .. shell_quote(paths.index_root)) + local lines = { + "{", + ' "schemaVersion": ' .. STATE_SCHEMA_VERSION .. ",", + ' "plugins": [', + } + for plugin_index, record in ipairs(records.plugins or {}) do + lines[#lines + 1] = " {" + lines[#lines + 1] = string.format(' "id": %q,', record.id) + lines[#lines + 1] = string.format(' "organization": %q,', record.organization) + lines[#lines + 1] = string.format(' "name": %q,', record.name) + lines[#lines + 1] = string.format(' "version": %q,', record.version) + lines[#lines + 1] = string.format(' "path": %q,', record.path) + lines[#lines + 1] = string.format(' "sourceType": %q', record.sourceType or "unknown") + if plugin_index == #(records.plugins or {}) then + lines[#lines + 1] = " }" + else + lines[#lines + 1] = " }," + end + end + lines[#lines + 1] = " ]" + lines[#lines + 1] = "}" + write_file(paths.installed_path, table.concat(lines, "\n") .. "\n") +end + +local function upsert_installed(records, record) + local updated = { schemaVersion = STATE_SCHEMA_VERSION, plugins = {} } + local replaced = false + for _, existing in ipairs(records.plugins or {}) do + if existing.id == record.id then + updated.plugins[#updated.plugins + 1] = record + replaced = true + else + updated.plugins[#updated.plugins + 1] = existing + end + end + if not replaced then + updated.plugins[#updated.plugins + 1] = record + end + return updated +end + +local function load_repository_config(context) + local dir = plugin_dir(context) + if dir == nil then + return nil, "plugin directory unavailable" + end + local config = read_json_file(join_path(dir, "default-plugin-repositories.json")) + if config == nil then + return nil, "default-plugin-repositories.json not found" + end + return config +end + +local function refresh_catalog(context, paths) + local config, config_error = load_repository_config(context) + if config == nil then + return nil, config_error + end + + local catalog = {} + for _, repository in ipairs(config.repositories or {}) do + if repository.enabled ~= false then + local url = trim(repository.url) + if url ~= "" then + local payload, fetch_error = fetch_json(url) + if payload == nil then + return nil, fetch_error + end + for _, entry in ipairs(payload.plugins or {}) do + catalog[#catalog + 1] = entry + end + end + end + end + + run_shell("mkdir -p " .. shell_quote(paths.index_root)) + return catalog +end + +local function find_catalog_record(catalog, plugin_id, version) + local wanted_version = trim(version) + for _, entry in ipairs(catalog or {}) do + if trim(entry.id) == trim(plugin_id) then + if wanted_version == "" or trim(entry.version) == wanted_version then + return entry + end + end + end + return nil +end + +local function host_architecture() + local ok, output = run_shell("uname -m") + if not ok then + return "x86_64" + end + local arch = trim(output) + if arch == "amd64" then + return "x86_64" + end + if arch == "arm64" then + return "aarch64" + end + return arch +end + +local function host_system() + local ok, output = run_shell("uname -s") + if not ok then + return "linux" + end + local system = trim(output):lower() + if system == "darwin" then + return "macos" + end + return "linux" +end + +local function select_index_package(index, plugin_id) + local arch = host_architecture() + local system = host_system() + for _, entry in ipairs(index.packages or {}) do + if trim(entry.name) == trim(plugin_id) + and trim(entry.architecture) == arch + and type(entry.system) == "table" + and trim(entry.system[1]) == system then + return entry + end + end + return nil +end + +local function download_file(url, destination) + run_shell("mkdir -p " .. shell_quote(destination:match("^(.*)/[^/]+$") or ".")) + local ok, output, code = run_shell("curl -fL " .. shell_quote(url) .. " -o " .. shell_quote(destination)) + if not ok then + return false, "download failed (" .. tostring(code) .. "): " .. trim(output) + end + return true +end + +local function install_rqp_archive(context, archive_path) + local command = "rqp --non-interactive install " .. shell_quote(archive_path) + if context ~= nil and context.exec ~= nil and type(context.exec.run) == "function" then + local result = context.exec.run(command) + if not result.success then + return false, trim(result.stderr ~= "" and result.stderr or result.stdout) + end + return true + end + local ok, output, code = run_shell(command) + if not ok then + return false, "rqp install failed (" .. tostring(code) .. "): " .. trim(output) + end + return true +end + +local function is_repo_shorthand(value) + local text = trim(value) + return text:match("^[%w%-%._]+/[%w%-%._]+$") ~= nil +end + +local function normalize_git_url(value) + local text = trim(value) + if starts_with(text, "github:") then + local shorthand = text:sub(8) + if is_repo_shorthand(shorthand) then + return "https://github.com/" .. shorthand .. ".git" + end + return shorthand + end + if is_repo_shorthand(text) then + return "https://github.com/" .. text .. ".git" + end + return text +end + +local function parse_git_source(value) + local text = trim(value) + local subpath = "" + local hash = text:find("#", 1, true) + if hash ~= nil then + subpath = trim(text:sub(hash + 1)):gsub("^[/\\]+", ""):gsub("[/\\]+$", "") + text = trim(text:sub(1, hash - 1)) + end + return normalize_git_url(text), subpath +end + +local function append_git_subpath(source_url, subpath) + local url, existing_subpath = parse_git_source(source_url) + local resolved_subpath = trim(subpath) + if resolved_subpath == "" then + resolved_subpath = existing_subpath + end + if resolved_subpath == "" then + return url + end + return url .. "#" .. resolved_subpath +end + +local function copy_tree(source, destination) + run_shell("mkdir -p " .. shell_quote(destination)) + local ok, output = run_shell("cp -R " .. shell_quote(join_path(source, ".")) .. " " .. shell_quote(destination)) + if not ok then + return false, trim(output) + end + return true +end + +local function git_clone_repository(git_url, clone_root, source_ref) + local attempts = {} + local wanted_ref = trim(source_ref) + if wanted_ref ~= "" then + attempts[#attempts + 1] = wanted_ref + if not starts_with(wanted_ref, "v") then + attempts[#attempts + 1] = "v" .. wanted_ref + end + end + attempts[#attempts + 1] = "" + + local last_output = "" + local last_code = 1 + for _, ref in ipairs(attempts) do + run_shell("rm -rf " .. shell_quote(clone_root)) + local clone_command = GIT_BINARY .. " clone --depth 1 " + if ref ~= "" then + clone_command = clone_command .. "--branch " .. shell_quote(ref) .. " " + end + clone_command = clone_command .. shell_quote(git_url) .. " " .. shell_quote(clone_root) + local ok, output, code = run_shell(clone_command) + if ok then + return true + end + last_output = output + last_code = code + end + return false, last_output, last_code +end + +local function read_beez_package_manifest(root) + local manifest = read_json_file(join_path(root, "beez.package.json")) + if manifest ~= nil then + return manifest + end + if shell_success("test -f " .. shell_quote(join_path(root, "beez_plugin.lua"))) then + return { + organization = nil, + name = nil, + version = "0.0.0", + entry = "beez_plugin.lua", + } + end + return nil +end + +local function install_from_git(context, paths, plugin_id, version, source_url, source_ref) + local organization, name = split_plugin_id(plugin_id) + if organization == nil then + return false, "plugin id must use organization/name" + end + + local git_url, subpath = parse_git_source(source_url) + local temp_root = join_path(paths.index_root, "git") + local clone_key = git_url:gsub("[^%w]+", "-"):gsub("^-+", ""):gsub("-+$", "") + if clone_key == "" then + clone_key = organization .. "-" .. name + end + local clone_root = join_path(temp_root, clone_key) + run_shell("rm -rf " .. shell_quote(clone_root)) + run_shell("mkdir -p " .. shell_quote(temp_root)) + + local ok, output, code = git_clone_repository(git_url, clone_root, source_ref) + if not ok then + return false, "git clone failed (" .. tostring(code) .. "): " .. trim(output) + end + + local plugin_root = clone_root + if subpath ~= "" then + plugin_root = join_path(clone_root, subpath) + if not shell_success("test -d " .. shell_quote(plugin_root)) then + return false, "plugin path not found in repository: " .. subpath + end + end + + local manifest = read_beez_package_manifest(plugin_root) + if manifest == nil then + return false, "repository does not contain beez.package.json or beez_plugin.lua" + end + + organization = trim(manifest.organization) ~= "" and trim(manifest.organization) or organization + name = trim(manifest.name) ~= "" and trim(manifest.name) or name + version = trim(version) ~= "" and trim(version) or trim(manifest.version) + if version == "" then + version = "0.0.0" + end + + local install_root = plugin_install_path(paths, organization, name, version) + run_shell("rm -rf " .. shell_quote(install_root)) + local copied, copy_error = copy_tree(plugin_root, install_root) + if not copied then + return false, copy_error + end + + if not is_installed(paths, organization, name, version) then + return false, "git install did not produce beez_plugin.lua" + end + + return true, organization, name, version +end + +local function install_from_index(context, paths, plugin_id, catalog_entry) + local source = catalog_entry.source or {} + if trim(source.type) ~= "" and trim(source.type) ~= "index" then + return false, "catalog entry does not provide index source for " .. plugin_id + end + local index, fetch_error = fetch_json(trim(source.url)) + if index == nil then + return false, fetch_error + end + local package_entry = select_index_package(index, plugin_id) + if package_entry == nil then + return false, "no package for host platform in index for " .. plugin_id + end + local temp_dir = join_path(paths.index_root, "downloads") + local archive_name = package_entry.url:match("/([^/]+)$") or "plugin.rqp" + local archive_path = join_path(temp_dir, archive_name) + local downloaded, download_error = download_file(package_entry.url, archive_path) + if not downloaded then + return false, download_error + end + return install_rqp_archive(context, archive_path) +end + +local function install_from_git_catalog(context, paths, plugin_id, catalog_entry) + local source = catalog_entry.source or {} + local url = trim(source.url) + if url == "" then + return false, "catalog git source requires url for " .. plugin_id + end + url = append_git_subpath(url, trim(source.path)) + local source_ref = trim(source.ref) + if source_ref == "" then + source_ref = trim(catalog_entry.version) + end + return install_from_git(context, paths, plugin_id, trim(catalog_entry.version), url, source_ref) +end + +local function install_from_catalog_entry(context, paths, plugin_id, catalog_entry) + local source = catalog_entry.source or {} + local source_type = trim(source.type) + if source_type == "git" then + return install_from_git_catalog(context, paths, plugin_id, catalog_entry) + end + if source_type == "" or source_type == "index" then + return install_from_index(context, paths, plugin_id, catalog_entry) + end + return false, "unsupported catalog source type for " .. plugin_id .. ": " .. source_type +end + +local function parse_package_spec(pkg) + local name = trim(pkg.name) + local version = trim(pkg.version) + local source = nil + local source_path = "" + if type(pkg.extraFields) == "table" then + source = trim(pkg.extraFields.source) + source_path = trim(pkg.extraFields.path or pkg.extraFields.sourcePath) + end + if (source == nil or source == "") and type(pkg.source) == "string" then + source = trim(pkg.source) + end + if (source == nil or source == "") then + local env_source = trim(os.getenv("BEEZ_PLUGIN_SOURCE")) + if env_source ~= "" then + source = env_source + end + end + if source ~= nil and source ~= "" and source_path ~= "" then + source = append_git_subpath(source, source_path) + end + return name, version, source +end + +local function tx_begin(context, label) + if context ~= nil and context.tx ~= nil and type(context.tx.begin_step) == "function" then + context.tx.begin_step(label) + end +end + +local function tx_success(context) + if context ~= nil and context.tx ~= nil and type(context.tx.success) == "function" then + context.tx.success() + end +end + +local function tx_failed(context, message) + if context ~= nil and context.tx ~= nil and type(context.tx.failed) == "function" then + context.tx.failed(message) + end +end + +local function emit_event(context, name, payload) + if context ~= nil and context.events ~= nil and type(context.events[name]) == "function" then + context.events[name](payload) + end +end + +function plugin.getName() + return PLUGIN_NAME +end + +function plugin.getVersion() + return PLUGIN_VERSION +end + +function plugin.getRequirements() + return {} +end + +function plugin.getCategories() + return { "Beez", "Build", "Plugins" } +end + +function plugin.getSecurityMetadata() + return { + role = "package-manager", + capabilities = { "exec", "network" }, + privilegeLevel = "user", + writeScopes = { + { kind = "user-home-subpath", value = ".cache/beez" }, + { kind = "user-home-subpath", value = ".local/share/reqpack/plugins/beez" }, + }, + networkScopes = { "github.com", "raw.githubusercontent.com" }, + } +end + +function plugin.getMissingPackages(packages) + local cache_root = default_data_root() + if cache_root == nil then + return packages or {} + end + local paths = state_paths(cache_root) + local missing = {} + for _, pkg in ipairs(packages or {}) do + local plugin_id, version = parse_package_spec(pkg) + local organization, name = split_plugin_id(plugin_id) + if organization ~= nil and not is_installed(paths, organization, name, version) then + missing[#missing + 1] = pkg + end + end + return missing +end + +function plugin.install(context, packages) + tx_begin(context, "install beez plugins") + + local cache_root = default_data_root() + if cache_root == nil then + tx_failed(context, "cache directory unavailable") + return false + end + local paths = state_paths(cache_root) + local catalog, catalog_error = refresh_catalog(context, paths) + if catalog == nil then + tx_failed(context, catalog_error) + return false + end + + local records = load_installed_records(paths) + local requested = plugin.getMissingPackages(packages) + if #requested == 0 then + tx_success(context) + return true + end + + for _, pkg in ipairs(requested) do + local plugin_id, version, source = parse_package_spec(pkg) + local organization, name = split_plugin_id(plugin_id) + if organization == nil then + tx_failed(context, "plugin id must use organization/name: " .. plugin_id) + return false + end + + local installed = false + if source ~= nil and source ~= "" then + local ok, err_or_org, git_name, git_version = install_from_git(context, paths, plugin_id, version, source, version) + if not ok then + tx_failed(context, err_or_org) + return false + end + organization = err_or_org + name = git_name + version = git_version + installed = true + else + local catalog_entry = find_catalog_record(catalog, plugin_id, version) + if catalog_entry ~= nil then + local source_type = trim((catalog_entry.source or {}).type) + if source_type == "git" then + local ok, err_or_org, git_name, git_version = install_from_git_catalog(context, paths, plugin_id, catalog_entry) + if not ok then + tx_failed(context, err_or_org) + return false + end + organization = err_or_org + name = git_name + version = git_version + else + local ok, install_error = install_from_catalog_entry(context, paths, plugin_id, catalog_entry) + if not ok then + tx_failed(context, install_error) + return false + end + version = trim(catalog_entry.version) + end + installed = true + elseif starts_with(plugin_id, "github:") or is_repo_shorthand(plugin_id) then + local ok, err_or_org, git_name, git_version = install_from_git(context, paths, plugin_id, version, plugin_id, version) + if not ok then + tx_failed(context, err_or_org) + return false + end + organization = err_or_org + name = git_name + version = git_version + plugin_id = organization .. "/" .. name + installed = true + end + end + + if not installed then + tx_failed(context, "plugin not found in catalog and no source provided: " .. plugin_id) + return false + end + + if not is_installed(paths, organization, name, version) then + tx_failed(context, "plugin install did not produce expected files for " .. plugin_id) + return false + end + + records = upsert_installed(records, { + id = organization .. "/" .. name, + organization = organization, + name = name, + version = version, + path = plugin_install_path(paths, organization, name, version), + sourceType = source ~= "" and "git" or "index", + }) + emit_event(context, "installed", { name = plugin_id, version = version }) + end + + save_installed_records(paths, records) + tx_success(context) + return true +end + +function plugin.installLocal(context, path) + tx_begin(context, "install local beez plugin") + + local cache_root = default_data_root() + if cache_root == nil then + tx_failed(context, "cache directory unavailable") + return false + end + local paths = state_paths(cache_root) + + if trim(path):match("%.rqp$") then + local ok, install_error = install_rqp_archive(context, path) + if not ok then + tx_failed(context, install_error) + return false + end + tx_success(context) + return true + end + + local manifest = read_beez_package_manifest(path) + if manifest == nil then + tx_failed(context, "local path is not a beez plugin directory") + return false + end + + local organization = trim(manifest.organization) + local name = trim(manifest.name) + local version = trim(manifest.version) + if organization == "" or name == "" then + tx_failed(context, "beez.package.json requires organization and name") + return false + end + if version == "" then + version = "0.0.0" + end + + local install_root = plugin_install_path(paths, organization, name, version) + run_shell("rm -rf " .. shell_quote(install_root)) + local copied, copy_error = copy_tree(path, install_root) + if not copied then + tx_failed(context, copy_error) + return false + end + + local records = load_installed_records(paths) + records = upsert_installed(records, { + id = organization .. "/" .. name, + organization = organization, + name = name, + version = version, + path = install_root, + sourceType = "local", + }) + save_installed_records(paths, records) + emit_event(context, "installed", { name = organization .. "/" .. name, version = version, localTarget = true }) + tx_success(context) + return true +end + +function plugin.remove(context, packages) + tx_begin(context, "remove beez plugins") + local cache_root = default_data_root() + if cache_root == nil then + tx_failed(context, "cache directory unavailable") + return false + end + local paths = state_paths(cache_root) + local records = load_installed_records(paths) + + for _, pkg in ipairs(packages or {}) do + local plugin_id = trim(pkg.name) + for index, record in ipairs(records.plugins) do + if record.id == plugin_id then + run_shell("rm -rf " .. shell_quote(record.path)) + table.remove(records.plugins, index) + emit_event(context, "deleted", { name = plugin_id, version = record.version }) + break + end + end + end + + save_installed_records(paths, records) + tx_success(context) + return true +end + +function plugin.update(context, packages) + return plugin.install(context, packages) +end + +function plugin.list(context) + local cache_root = default_data_root() + if cache_root == nil then + return {} + end + local records = load_installed_records(state_paths(cache_root)) + local items = {} + for _, record in ipairs(records.plugins or {}) do + items[#items + 1] = { + name = record.id, + version = record.version, + description = "Installed Beez plugin", + extraFields = { path = record.path, sourceType = record.sourceType }, + } + end + return items +end + +function plugin.search(context, prompt) + local cache_root = default_data_root() + if cache_root == nil then + return {} + end + local paths = state_paths(cache_root) + local catalog, catalog_error = refresh_catalog(context, paths) + if catalog == nil then + return {} + end + + local query = trim(prompt) + local items = {} + for _, entry in ipairs(catalog) do + if query == "" + or trim(entry.id):find(query, 1, true) + or trim(entry.description or ""):find(query, 1, true) then + items[#items + 1] = { + name = entry.id, + version = entry.version, + description = entry.description or "", + extraFields = { source = entry.source }, + } + end + end + return items +end + +function plugin.info(context, name) + local plugin_id = trim(name) + local cache_root = default_data_root() + if cache_root == nil then + return {} + end + local paths = state_paths(cache_root) + local records = load_installed_records(paths) + for _, record in ipairs(records.plugins or {}) do + if record.id == plugin_id then + return { + name = record.id, + version = record.version, + description = "Installed Beez plugin", + extraFields = { path = record.path, sourceType = record.sourceType }, + } + end + end + + local catalog, _ = refresh_catalog(context, paths) + if catalog ~= nil then + local entry = find_catalog_record(catalog, plugin_id, "") + if entry ~= nil then + return { + name = entry.id, + version = entry.version, + description = entry.description or "", + extraFields = { source = entry.source, tags = entry.tags }, + } + end + end + return {} +end + +function plugin.outdated(context) + return {} +end + +function plugin.init() + local ok = run_shell("command -v " .. shell_quote(GIT_BINARY) .. " >/dev/null") + return ok +end + +function plugin.shutdown() + return true +end + +return plugin diff --git a/scripts/ci/bootstrap_beez_clang_monorepo.lua b/scripts/ci/bootstrap_beez_clang_monorepo.lua new file mode 100644 index 0000000..b4e013e --- /dev/null +++ b/scripts/ci/bootstrap_beez_clang_monorepo.lua @@ -0,0 +1,251 @@ +#!/usr/bin/env lua + +local script_path = debug.getinfo(1, "S").source:gsub("^@", "") +local script_dir = script_path:match("^(.*)/[^/]+$") +local util_boot = dofile(script_dir .. "/../../packaging/reqpack/plugin/lib/util.lua") +local repo_root = util_boot.join_path(script_dir, "..", "..") +local util = dofile(util_boot.join_path(repo_root, "packaging/reqpack/plugin/lib/util.lua")) + +local DEFAULT_OUTPUT_DIR = util.join_path(repo_root, "..", "beez-plugins", "beez-clang") +local LICENSE_PATH = util.join_path(repo_root, "LICENSE") +local GITHUB_REPO = "Coditary/beez-clang" +local ORGANIZATION = "coditary" +local PLUGIN_VERSION = "1.0.0" + +local CLANG_PLUGINS = { + "clang", + "clang-format", + "clang-tidy", +} + +local function usage() + io.stderr:write("Usage: lua scripts/ci/bootstrap_beez_clang_monorepo.lua [--output-dir ] [--init-git]\n") +end + +local function parse_args(argv) + local options = { + output_dir = DEFAULT_OUTPUT_DIR, + init_git = false, + } + local index = 1 + while index <= #argv do + local arg = argv[index] + if arg == "--output-dir" then + options.output_dir = argv[index + 1] + index = index + 2 + elseif arg == "--init-git" then + options.init_git = true + index = index + 1 + elseif arg == "-h" or arg == "--help" then + usage() + os.exit(0) + end + end + return options +end + +local function read_text(path) + local file = io.open(path, "rb") + if file == nil then + return nil + end + local content = file:read("*a") or "" + file:close() + return content +end + +local function write_text(path, content) + util.mkdir_p(path:match("^(.*)/[^/]+$") or ".") + local file, err = io.open(path, "wb") + if file == nil then + error("failed to write " .. path .. ": " .. tostring(err)) + end + file:write(content) + file:close() +end + +local function copy_tree(source_dir, destination_dir) + util.mkdir_p(destination_dir) + local ok, output = util.run_shell( + "cp -R " + .. util.shell_quote(util.join_path(source_dir, ".")) + .. " " + .. util.shell_quote(destination_dir) + ) + if not ok then + error("copy failed: " .. util.trim(output)) + end +end + +local function read_plugin_description(source_dir) + local content = read_text(util.join_path(source_dir, "beez_plugin.lua")) + if content == nil then + return "" + end + return content:match('description%s*=%s*"([^"]+)"') or "" +end + +local function write_beez_package_json(path, plugin_name) + write_text( + path, + table.concat({ + "{", + ' "schemaVersion": 1,', + string.format(' "organization": %q,', ORGANIZATION), + string.format(' "name": %q,', plugin_name), + string.format(' "version": %q,', PLUGIN_VERSION), + ' "entry": "beez_plugin.lua"', + "}", + "", + }, "\n") + ) +end + +local function write_plugin_readme(path, plugin_name, description) + local plugin_id = ORGANIZATION .. "/" .. plugin_name + write_text( + path, + table.concat({ + "# " .. plugin_name, + "", + description, + "", + "Part of the [" .. GITHUB_REPO .. "](https://github.com/" .. GITHUB_REPO .. ") monorepo.", + "", + "## Install", + "", + "From the Beez plugin catalog:", + "", + "```bash", + "rqp install beez " .. plugin_id .. "@" .. PLUGIN_VERSION, + "```", + "", + "From this subdirectory via `build.lua`:", + "", + "```lua", + "reqpack {", + " beez = {", + " {", + ' name = "' .. plugin_id .. '",', + ' version = "' .. PLUGIN_VERSION .. '",', + ' source = "github:' .. GITHUB_REPO .. "#" .. plugin_name .. '",', + " },", + " },", + "}", + "```", + "", + }, "\n") + ) +end + +local function write_root_readme(path) + write_text( + path, + table.concat({ + "# beez-clang", + "", + "Monorepo of Clang-related Beez workflow plugins.", + "", + "## Plugins", + "", + "| Directory | Plugin ID |", + "|-----------|-----------|", + "| `clang/` | `coditary/clang` |", + "| `clang-format/` | `coditary/clang-format` |", + "| `clang-tidy/` | `coditary/clang-tidy` |", + "", + "## Layout", + "", + "```", + "beez-clang/", + " clang/", + " beez.package.json", + " beez_plugin.lua", + " src/", + " clang-format/", + " clang-tidy/", + " releases/ # optional packaged .rqp + index.json (CI)", + "```", + "", + "## Install", + "", + "Catalog install (after `beez-registry` points clang plugins here):", + "", + "```bash", + "rqp install beez coditary/clang-format@1.0.0", + "```", + "", + "Explicit monorepo path:", + "", + "```lua", + 'source = "github:' .. GITHUB_REPO .. '#clang-format"', + "```", + "", + "## Bootstrap", + "", + "Regenerate from the Beez monorepo:", + "", + "```bash", + "cd ../Beez", + "lua scripts/ci/bootstrap_beez_clang_monorepo.lua --init-git", + "```", + "", + "## Publish", + "", + "1. Push to `https://github.com/" .. GITHUB_REPO .. "`.", + "2. Tag a release and attach per-plugin indexes under `clang-format/index.json`, etc.", + "3. Run `lua scripts/ci/sync_plugin_catalog.lua` in Beez to refresh `beez-registry`.", + "", + }, "\n") + ) +end + +local function init_git_repo(repo_dir) + local exists = util.run_shell("test -d " .. util.shell_quote(util.join_path(repo_dir, ".git"))) + if exists then + return + end + local ok, output = util.run_shell("git -C " .. util.shell_quote(repo_dir) .. " init -b main") + if not ok then + error("git init failed: " .. util.trim(output)) + end +end + +local function main(argv) + local options = parse_args(argv) + local license = read_text(LICENSE_PATH) + if license == nil then + error("LICENSE not found") + end + + util.mkdir_p(options.output_dir) + write_root_readme(util.join_path(options.output_dir, "README.md")) + write_text( + util.join_path(options.output_dir, ".gitignore"), + table.concat({ ".DS_Store", "dist/", "releases/", "*.rqp", "", }, "\n") + ) + write_text(util.join_path(options.output_dir, "LICENSE"), license) + + for _, plugin_name in ipairs(CLANG_PLUGINS) do + local source_dir = util.join_path(repo_root, "plugins", ORGANIZATION, plugin_name, PLUGIN_VERSION) + local plugin_dir = util.join_path(options.output_dir, plugin_name) + util.run_shell("rm -rf " .. util.shell_quote(plugin_dir)) + copy_tree(source_dir, plugin_dir) + write_beez_package_json(util.join_path(plugin_dir, "beez.package.json"), plugin_name) + write_plugin_readme(util.join_path(plugin_dir, "README.md"), plugin_name, read_plugin_description(source_dir)) + io.write("created " .. plugin_dir .. "\n") + end + + if options.init_git then + init_git_repo(options.output_dir) + end + + io.write("wrote " .. options.output_dir .. "\n") + return 0 +end + +local ok, err = pcall(main, { ... }) +if not ok then + io.stderr:write(tostring(err) .. "\n") + os.exit(1) +end diff --git a/scripts/ci/bootstrap_standalone_plugins.lua b/scripts/ci/bootstrap_standalone_plugins.lua new file mode 100644 index 0000000..76c8aec --- /dev/null +++ b/scripts/ci/bootstrap_standalone_plugins.lua @@ -0,0 +1,324 @@ +#!/usr/bin/env lua + +local script_path = debug.getinfo(1, "S").source:gsub("^@", "") +local script_dir = script_path:match("^(.*)/[^/]+$") +local util_boot = dofile(script_dir .. "/../../packaging/reqpack/plugin/lib/util.lua") +local repo_root = util_boot.join_path(script_dir, "..", "..") +local util = dofile(util_boot.join_path(repo_root, "packaging/reqpack/plugin/lib/util.lua")) + +local DEFAULT_OUTPUT_DIR = util.join_path(repo_root, "..", "beez-plugins") +local AUTHOR = "Leonard Ramminger" +local EMAIL = "Matographo@gmail.com" +local ORGANIZATION = "coditary" +local GITHUB_ORG = "Coditary" +local LICENSE_PATH = util.join_path(repo_root, "LICENSE") +local EXCLUDED_PLUGINS = { + ["clang"] = true, + ["clang-format"] = true, + ["clang-tidy"] = true, +} + +local function usage() + io.stderr:write( + "Usage: lua scripts/ci/bootstrap_standalone_plugins.lua [--output-dir ] [--init-git]\n" + ) +end + +local function parse_args(argv) + local options = { + output_dir = DEFAULT_OUTPUT_DIR, + init_git = false, + } + local index = 1 + while index <= #argv do + local arg = argv[index] + if arg == "--output-dir" then + options.output_dir = argv[index + 1] + index = index + 2 + elseif arg == "--init-git" then + options.init_git = true + index = index + 1 + elseif arg == "-h" or arg == "--help" then + usage() + os.exit(0) + else + error("unknown argument: " .. tostring(arg)) + end + end + return options +end + +local function read_text(path) + local file = io.open(path, "rb") + if file == nil then + return nil + end + local content = file:read("*a") or "" + file:close() + return content +end + +local function write_text(path, content) + util.mkdir_p(path:match("^(.*)/[^/]+$") or ".") + local file, err = io.open(path, "wb") + if file == nil then + error("failed to write " .. path .. ": " .. tostring(err)) + end + file:write(content) + file:close() +end + +local function discover_plugins() + local plugins_root = util.join_path(repo_root, "plugins", ORGANIZATION) + local plugins = {} + local ok, output = util.run_shell( + "find " .. util.shell_quote(plugins_root) .. " -mindepth 2 -maxdepth 2 -type d | sort" + ) + if not ok then + return plugins + end + + for line in (output .. "\n"):gmatch("(.-)\n") do + local version_dir = util.trim(line) + if version_dir ~= "" then + local version = version_dir:match("/([^/]+)$") + local plugin_dir = version_dir:match("^(.*)/[^/]+$") + local name = plugin_dir:match("/([^/]+)$") + if name ~= nil and version ~= nil and not EXCLUDED_PLUGINS[name] then + plugins[#plugins + 1] = { + name = name, + version = version, + source_dir = version_dir, + } + end + end + end + return plugins +end + +local function read_plugin_description(source_dir) + local content = read_text(util.join_path(source_dir, "beez_plugin.lua")) + if content == nil then + return "" + end + return content:match('description%s*=%s*"([^"]+)"') or "" +end + +local function copy_tree(source_dir, destination_dir) + util.mkdir_p(destination_dir) + local ok, output = util.run_shell( + "cp -R " + .. util.shell_quote(util.join_path(source_dir, ".")) + .. " " + .. util.shell_quote(destination_dir) + ) + if not ok then + error("copy failed for " .. source_dir .. ": " .. util.trim(output)) + end +end + +local function json_string(value) + return string.format("%q", value) +end + +local function write_beez_package_json(path, plugin) + local payload = { + "{", + ' "schemaVersion": 1,', + string.format(' "organization": %s,', json_string(ORGANIZATION)), + string.format(' "name": %s,', json_string(plugin.name)), + string.format(' "version": %s,', json_string(plugin.version)), + ' "entry": "beez_plugin.lua"', + "}", + "", + } + write_text(path, table.concat(payload, "\n")) +end + +local function write_gitignore(path) + write_text(path, table.concat({ + ".DS_Store", + "Thumbs.db", + "*.swp", + "*.swo", + "*~", + ".vscode/", + ".idea/", + "", + }, "\n")) +end + +local function write_readme(path, plugin) + local repo_name = "beez-" .. plugin.name + local plugin_id = ORGANIZATION .. "/" .. plugin.name + local content = table.concat({ + "# " .. plugin.name, + "", + plugin.description, + "", + "Standalone Beez workflow plugin published by **" .. ORGANIZATION .. "**.", + "", + "## Install", + "", + "From the Beez plugin catalog (after release packaging):", + "", + "```bash", + "rqp install beez " .. plugin_id .. "@" .. plugin.version, + "```", + "", + "From this repository (git source):", + "", + "```bash", + "# build.lua", + 'reqpack { beez = { { name = "' .. plugin_id .. '", version = "' .. plugin.version .. '", source = "github:' .. GITHUB_ORG .. "/" .. repo_name .. '" } } }', + "```", + "", + "```bash", + "beez --install", + "```", + "", + "Local development:", + "", + "```bash", + 'reqpack { beez = { { name = "' .. plugin_id .. '", path = ".", version = "' .. plugin.version .. '" } } }', + "```", + "", + "## Layout", + "", + "```", + "beez.package.json", + "beez_plugin.lua", + "src/", + "scripts/ # optional helper scripts", + "```", + "", + "## Package", + "", + "Build a `.rqp` from the Beez monorepo packaging scripts:", + "", + "```bash", + "lua scripts/ci/package_beez_plugin.lua \\", + " --organization " .. ORGANIZATION .. " \\", + " --name " .. plugin.name .. " \\", + " --version " .. plugin.version .. " \\", + " --source-dir . \\", + " --platform linux \\", + " --arch x86_64 \\", + " --output-dir dist", + "```", + "", + "## Metadata", + "", + "| Field | Value |", + "|-------|-------|", + "| Plugin ID | `" .. plugin_id .. "` |", + "| Version | `" .. plugin.version .. "` |", + "| Suggested repo | `" .. GITHUB_ORG .. "/" .. repo_name .. "` |", + "| Maintainer | " .. AUTHOR .. " <" .. EMAIL .. "> |", + "", + "## License", + "", + "Apache License 2.0. See [LICENSE](LICENSE).", + "", + }, "\n") + write_text(path, content) +end + +local function init_git_repo(plugin_dir) + local exists = util.run_shell("test -d " .. util.shell_quote(util.join_path(plugin_dir, ".git"))) + if exists then + return + end + local ok, output = util.run_shell("git -C " .. util.shell_quote(plugin_dir) .. " init -b main") + if not ok then + error("git init failed for " .. plugin_dir .. ": " .. util.trim(output)) + end +end + +local function main(argv) + local options = parse_args(argv) + local license = read_text(LICENSE_PATH) + if license == nil then + error("LICENSE not found at " .. LICENSE_PATH) + end + + util.mkdir_p(options.output_dir) + local plugins = discover_plugins() + if #plugins == 0 then + error("no plugins discovered") + end + + local manifest_lines = { + "# Beez Plugins", + "", + "Standalone Beez workflow plugins extracted from the Coditary Beez monorepo.", + "", + "## Clang monorepo", + "", + "Clang plugins live together in [`beez-clang/`](beez-clang/) (`Coditary/beez-clang` on GitHub).", + "Refresh with `lua scripts/ci/bootstrap_beez_clang_monorepo.lua --init-git` in the Beez repo.", + "", + "## Standalone plugins", + "", + "Each other subdirectory is intended to become its own Git repository under `" + .. GITHUB_ORG + .. "/beez-`.", + "", + "| Directory | Plugin ID | Suggested GitHub repo |", + "|-----------|-----------|------------------------|", + } + + for _, plugin in ipairs(plugins) do + plugin.description = read_plugin_description(plugin.source_dir) + local plugin_dir = util.join_path(options.output_dir, plugin.name) + util.run_shell("rm -rf " .. util.shell_quote(plugin_dir)) + copy_tree(plugin.source_dir, plugin_dir) + write_beez_package_json(util.join_path(plugin_dir, "beez.package.json"), plugin) + write_readme(util.join_path(plugin_dir, "README.md"), plugin) + write_gitignore(util.join_path(plugin_dir, ".gitignore")) + write_text(util.join_path(plugin_dir, "LICENSE"), license) + + if options.init_git then + init_git_repo(plugin_dir) + end + + local repo_name = GITHUB_ORG .. "/beez-" .. plugin.name + manifest_lines[#manifest_lines + 1] = string.format( + "| `%s/` | `%s/%s` | `%s` |", + plugin.name, + ORGANIZATION, + plugin.name, + repo_name + ) + io.write("created " .. plugin_dir .. "\n") + end + + manifest_lines[#manifest_lines + 1] = "" + manifest_lines[#manifest_lines + 1] = "## Bootstrap" + manifest_lines[#manifest_lines + 1] = "" + manifest_lines[#manifest_lines + 1] = "Regenerate from Beez:" + manifest_lines[#manifest_lines + 1] = "" + manifest_lines[#manifest_lines + 1] = "```bash" + manifest_lines[#manifest_lines + 1] = "cd ../Beez" + manifest_lines[#manifest_lines + 1] = "lua scripts/ci/bootstrap_beez_clang_monorepo.lua --init-git" + manifest_lines[#manifest_lines + 1] = "lua scripts/ci/bootstrap_standalone_plugins.lua --init-git" + manifest_lines[#manifest_lines + 1] = "lua scripts/ci/sync_plugin_catalog.lua" + manifest_lines[#manifest_lines + 1] = "```" + manifest_lines[#manifest_lines + 1] = "" + manifest_lines[#manifest_lines + 1] = "## Publish" + manifest_lines[#manifest_lines + 1] = "" + manifest_lines[#manifest_lines + 1] = "1. Create the GitHub repository (empty)." + manifest_lines[#manifest_lines + 1] = "2. Commit and push each plugin directory." + manifest_lines[#manifest_lines + 1] = "3. Add or update the entry in `beez-registry/plugins.json`." + manifest_lines[#manifest_lines + 1] = "" + + write_text(util.join_path(options.output_dir, "README.md"), table.concat(manifest_lines, "\n")) + io.write("wrote " .. util.join_path(options.output_dir, "README.md") .. " (" .. tostring(#plugins) .. " plugins)\n") + return 0 +end + +local ok, err = pcall(main, { ... }) +if not ok then + io.stderr:write(tostring(err) .. "\n") + os.exit(1) +end diff --git a/scripts/ci/build_plugin_index.lua b/scripts/ci/build_plugin_index.lua new file mode 100644 index 0000000..18f7340 --- /dev/null +++ b/scripts/ci/build_plugin_index.lua @@ -0,0 +1,160 @@ +#!/usr/bin/env lua + +local script_path = debug.getinfo(1, "S").source:gsub("^@", "") +local script_dir = script_path:match("^(.*)/[^/]+$") +local util_boot = dofile(script_dir .. "/../../packaging/reqpack/plugin/lib/util.lua") +local repo_root = util_boot.join_path(script_dir, "..", "..") + +local util = dofile(repo_root .. "/packaging/reqpack/plugin/lib/util.lua") +local hash = dofile(repo_root .. "/packaging/reqpack/plugin/lib/hash.lua") + +local function read_metadata_from_archive(archive_path) + local ok, output = util.run_shell( + "tar -xOf " .. util.shell_quote(archive_path) .. " ./metadata.json 2>/dev/null || tar -xOf " + .. util.shell_quote(archive_path) .. " metadata.json" + ) + if not ok then + error("metadata.json missing in " .. archive_path) + end + + local function capture(pattern) + return output:match(pattern) + end + + local metadata = { + name = capture('"name"%s*:%s*"([^"]+)"'), + organization = capture('"organization"%s*:%s*"([^"]+)"'), + plugin = capture('"plugin"%s*:%s*"([^"]+)"'), + version = capture('"version"%s*:%s*"([^"]+)"'), + release = tonumber(capture('"release"%s*:%s*(%d+)')) or 1, + revision = tonumber(capture('"revision"%s*:%s*(%d+)')) or 0, + architecture = capture('"architecture"%s*:%s*"([^"]+)"'), + system = { capture('"system"%s*:%s*%[%s*"([^"]+)"') or "linux" }, + summary = capture('"summary"%s*:%s*"([^"]+)"') or "", + url = capture('"url"%s*:%s*"([^"]+)"') or "", + tags = {}, + } + + for tag in output:gmatch('"tags"%s*:%s*%b[]') do + for value in tag:gmatch('"([^"]+)"') do + if value ~= "tags" then + metadata.tags[#metadata.tags + 1] = value + end + end + break + end + + if metadata.name == nil then + error("failed to parse metadata.json from " .. archive_path) + end + return metadata +end + +local function usage() + io.stderr:write("Usage: lua scripts/ci/build_plugin_index.lua --dist-dir dist --output dist/index.json\n") +end + +local function parse_args(argv) + local options = { + dist_dir = "dist", + output = "dist/index.json", + } + + local index = 1 + while index <= #argv do + local arg = argv[index] + if arg == "--dist-dir" then + options.dist_dir = argv[index + 1] + index = index + 2 + elseif arg == "--output" then + options.output = argv[index + 1] + index = index + 2 + elseif arg == "-h" or arg == "--help" then + usage() + os.exit(0) + else + error("unknown argument: " .. tostring(arg)) + end + end + + options.dist_dir = util.join_path(repo_root, options.dist_dir) + options.output = util.join_path(repo_root, options.output) + return options +end + +local function main(argv) + local options = parse_args(argv) + local packages = {} + + local ok, output = util.run_shell("find " .. util.shell_quote(options.dist_dir) .. " -maxdepth 1 -name '*.rqp' | sort") + if not ok then + error("failed to scan dist directory") + end + + for line in (output .. "\n"):gmatch("(.-)\n") do + local archive_path = util.trim(line) + if archive_path ~= "" then + local metadata = read_metadata_from_archive(archive_path) + packages[#packages + 1] = { + name = metadata.name, + organization = metadata.organization, + plugin = metadata.plugin, + version = metadata.version, + release = metadata.release, + revision = metadata.revision, + architecture = metadata.architecture, + system = metadata.system, + summary = metadata.summary, + url = metadata.url, + packageSha256 = hash.sha256_file(archive_path), + tags = metadata.tags or {}, + } + end + end + + local index = { + schemaVersion = 1, + packages = packages, + } + + local lines = { "{", ' "schemaVersion": 1,', ' "packages": [' } + for package_index, package_entry in ipairs(packages) do + local system_json = string.format('["%s"]', package_entry.system[1]) + local tags = {} + for _, tag in ipairs(package_entry.tags or {}) do + tags[#tags + 1] = string.format('"%s"', tag) + end + lines[#lines + 1] = " {" + lines[#lines + 1] = string.format(' "name": %q,', package_entry.name) + lines[#lines + 1] = string.format(' "organization": %q,', package_entry.organization) + lines[#lines + 1] = string.format(' "plugin": %q,', package_entry.plugin) + lines[#lines + 1] = string.format(' "version": %q,', package_entry.version) + lines[#lines + 1] = string.format(' "release": %d,', package_entry.release) + lines[#lines + 1] = string.format(' "revision": %d,', package_entry.revision) + lines[#lines + 1] = string.format(' "architecture": %q,', package_entry.architecture) + lines[#lines + 1] = ' "system": ' .. system_json .. "," + lines[#lines + 1] = string.format(' "summary": %q,', package_entry.summary) + lines[#lines + 1] = string.format(' "url": %q,', package_entry.url) + lines[#lines + 1] = string.format(' "packageSha256": %q,', package_entry.packageSha256) + lines[#lines + 1] = ' "tags": [' .. table.concat(tags, ", ") .. "]" + if package_index == #packages then + lines[#lines + 1] = " }" + else + lines[#lines + 1] = " }," + end + end + lines[#lines + 1] = " ]" + lines[#lines + 1] = "}" + lines[#lines + 1] = "" + + util.mkdir_p(options.output:match("^(.*)/[^/]+$") or ".") + util.write_file(options.output, table.concat(lines, "\n")) + print(options.output) + return 0 +end + +local ok, err = pcall(main, { ... }) +if not ok then + io.stderr:write(tostring(err) .. "\n") + os.exit(1) +end diff --git a/scripts/ci/build_reqpack_index.py b/scripts/ci/build_reqpack_index.py new file mode 100755 index 0000000..ae45aa2 --- /dev/null +++ b/scripts/ci/build_reqpack_index.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import hashlib +import json +import tarfile +from pathlib import Path + + +def sha256_hex(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def load_metadata(archive_path: Path) -> dict[str, object]: + with tarfile.open(archive_path, "r") as archive: + metadata_file = archive.extractfile("metadata.json") + if metadata_file is None: + raise RuntimeError(f"metadata.json missing in {archive_path}") + return json.loads(metadata_file.read().decode("utf-8")) + + +def build_index(dist_dir: Path) -> dict[str, object]: + packages: list[dict[str, object]] = [] + for archive_path in sorted(dist_dir.glob("*.rqp")): + metadata = load_metadata(archive_path) + packages.append( + { + "name": metadata["name"], + "version": metadata["version"], + "release": metadata["release"], + "revision": metadata["revision"], + "architecture": metadata["architecture"], + "system": metadata["system"], + "summary": metadata["summary"], + "url": metadata["url"], + "packageSha256": sha256_hex(archive_path), + "tags": metadata.get("tags", []), + } + ) + + return { + "schemaVersion": 1, + "packages": packages, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build ReqPack repository index for Beez release assets") + parser.add_argument("--dist-dir", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args() + + repo_root = Path(__file__).resolve().parents[2] + dist_dir = (repo_root / args.dist_dir).resolve() + output_path = (repo_root / args.output).resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + + index = build_index(dist_dir) + output_path.write_text(json.dumps(index, indent=2) + "\n", encoding="utf-8") + print(output_path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/package_all_plugins.lua b/scripts/ci/package_all_plugins.lua new file mode 100644 index 0000000..bf5887e --- /dev/null +++ b/scripts/ci/package_all_plugins.lua @@ -0,0 +1,107 @@ +#!/usr/bin/env lua + +local script_path = debug.getinfo(1, "S").source:gsub("^@", "") +local script_dir = script_path:match("^(.*)/[^/]+$") +local util_boot = dofile(script_dir .. "/../../packaging/reqpack/plugin/lib/util.lua") +local repo_root = util_boot.join_path(script_dir, "..", "..") +local pack = dofile(util_boot.join_path(repo_root, "packaging/reqpack/plugin/lib/pack.lua")) + +local function usage() + io.stderr:write( + "Usage: lua scripts/ci/package_all_plugins.lua --version --platform --arch [--output-dir dist/plugins]\n" + ) +end + +local function parse_args(argv) + local options = { + output_dir = "dist/plugins", + } + local index = 1 + while index <= #argv do + local arg = argv[index] + if arg == "--version" then + options.version = argv[index + 1] + index = index + 2 + elseif arg == "--platform" then + options.platform = argv[index + 1] + index = index + 2 + elseif arg == "--arch" then + options.arch = argv[index + 1] + index = index + 2 + elseif arg == "--output-dir" then + options.output_dir = argv[index + 1] + index = index + 2 + elseif arg == "-h" or arg == "--help" then + usage() + os.exit(0) + else + error("unknown argument: " .. tostring(arg)) + end + end + + for _, field in ipairs({ "version", "platform", "arch" }) do + if options[field] == nil or util_boot.trim(options[field]) == "" then + usage() + error("missing required argument: --" .. field) + end + end + + options.output_dir = util_boot.join_path(repo_root, options.output_dir) + options.template_root = util_boot.join_path(repo_root, "packaging/reqpack/plugin") + options.source_url = "https://github.com/Coditary/Beez" + return options +end + +local function discover_plugins() + local plugins_root = util_boot.join_path(repo_root, "plugins/coditary") + local plugins = {} + local ok, output = util_boot.run_shell("find " .. util_boot.shell_quote(plugins_root) .. " -mindepth 2 -maxdepth 2 -type d | sort") + if not ok then + return plugins + end + + for line in (output .. "\n"):gmatch("(.-)\n") do + local version_dir = util_boot.trim(line) + if version_dir ~= "" then + local version = version_dir:match("/([^/]+)$") + local plugin_dir = version_dir:match("^(.*)/[^/]+$") + local name = plugin_dir:match("/([^/]+)$") + if name ~= nil and version ~= nil then + plugins[#plugins + 1] = { + organization = "coditary", + name = name, + version = version, + source_dir = version_dir, + } + end + end + end + return plugins +end + +local function main(argv) + local options = parse_args(argv) + util_boot.mkdir_p(options.output_dir) + + for _, plugin in ipairs(discover_plugins()) do + pack.package_plugin({ + organization = plugin.organization, + name = plugin.name, + version = options.version ~= nil and util_boot.normalized_version(options.version) or plugin.version, + source_dir = plugin.source_dir, + platform = options.platform, + arch = options.arch, + output_dir = options.output_dir, + template_root = options.template_root, + source_url = options.source_url, + }) + end + + return 0 +end + +local ok, err = pcall(main, { ... }) +if not ok then + io.stderr:write(tostring(err) .. "\n") + os.exit(1) +end diff --git a/scripts/ci/package_beez_plugin.lua b/scripts/ci/package_beez_plugin.lua new file mode 100644 index 0000000..e4508a2 --- /dev/null +++ b/scripts/ci/package_beez_plugin.lua @@ -0,0 +1,94 @@ +#!/usr/bin/env lua + +local script_path = debug.getinfo(1, "S").source:gsub("^@", "") +local script_dir = script_path:match("^(.*)/[^/]+$") +local util_boot = dofile(script_dir .. "/../../packaging/reqpack/plugin/lib/util.lua") +local repo_root = util_boot.join_path(script_dir, "..", "..") + +local util = dofile(repo_root .. "/packaging/reqpack/plugin/lib/util.lua") +local pack = dofile(repo_root .. "/packaging/reqpack/plugin/lib/pack.lua") + +local function usage() + io.stderr:write([[ +Usage: lua scripts/ci/package_beez_plugin.lua \ + --organization \ + --name \ + --version \ + --source-dir \ + --platform \ + --arch \ + [--output-dir dist] \ + [--source-url ] \ + [--homepage ] + +]]) +end + +local function parse_args(argv) + local options = { + output_dir = "dist", + } + + local index = 1 + while index <= #argv do + local arg = argv[index] + if arg == "--organization" then + options.organization = argv[index + 1] + index = index + 2 + elseif arg == "--name" then + options.name = argv[index + 1] + index = index + 2 + elseif arg == "--version" then + options.version = argv[index + 1] + index = index + 2 + elseif arg == "--source-dir" then + options.source_dir = argv[index + 1] + index = index + 2 + elseif arg == "--platform" then + options.platform = argv[index + 1] + index = index + 2 + elseif arg == "--arch" then + options.arch = argv[index + 1] + index = index + 2 + elseif arg == "--output-dir" then + options.output_dir = argv[index + 1] + index = index + 2 + elseif arg == "--source-url" then + options.source_url = argv[index + 1] + index = index + 2 + elseif arg == "--homepage" then + options.homepage = argv[index + 1] + index = index + 2 + elseif arg == "-h" or arg == "--help" then + usage() + os.exit(0) + else + error("unknown argument: " .. tostring(arg)) + end + end + + for _, field in ipairs({ "organization", "name", "version", "source_dir", "platform", "arch" }) do + if options[field] == nil or util.trim(options[field]) == "" then + usage() + error("missing required argument: --" .. field:gsub("_", "-")) + end + end + + options.source_dir = util.join_path(repo_root, options.source_dir) + options.output_dir = util.join_path(repo_root, options.output_dir) + options.template_root = util.join_path(repo_root, "packaging/reqpack/plugin") + return options +end + +local function main(argv) + local options = parse_args(argv) + local archive_path = pack.package_plugin(options) + print(archive_path) + return 0 +end + +local ok, err = pcall(main, { ... }) +if not ok then + io.stderr:write(tostring(err) .. "\n") + os.exit(1) +end diff --git a/scripts/ci/package_binary.py b/scripts/ci/package_binary.py new file mode 100755 index 0000000..32c4e1a --- /dev/null +++ b/scripts/ci/package_binary.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import re +import shutil +import subprocess +import tarfile +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile + + +PACKAGE_NAME = "beez" +RUNTIME_PATTERNS = { + "windows": ["*.dll"], + "linux": ["*.so", "*.so.*"], + "macos": ["*.dylib"], +} +SYSTEM_LIB_PATTERNS = ( + re.compile(r"^linux-vdso"), + re.compile(r"^ld-linux"), + re.compile(r"^lib(c|m|pthread|dl|rt|resolv|util)\.so"), + re.compile(r"^libgcc_s\.so"), + re.compile(r"^libstdc\+\+\.so"), +) + + +def normalized_version(raw: str) -> str: + return raw[1:] if raw.startswith("v") else raw + + +def copy_runtime_files(binary_path: Path, stage_bin_dir: Path, platform: str) -> None: + for pattern in RUNTIME_PATTERNS.get(platform, []): + for runtime_path in binary_path.parent.glob(pattern): + if runtime_path.is_file(): + shutil.copy2(runtime_path, stage_bin_dir / runtime_path.name) + + +def collect_linux_ldd_libs(binary_path: Path, stage_bin_dir: Path) -> None: + result = subprocess.run( + ["ldd", str(binary_path)], + capture_output=True, + text=True, + check=True, + ) + for line in result.stdout.splitlines(): + match = re.search(r"=> (/[^ ]+)", line) + if match is None: + continue + lib_path = Path(match.group(1)) + if not lib_path.is_file(): + continue + if any(pattern.search(lib_path.name) for pattern in SYSTEM_LIB_PATTERNS): + continue + target = stage_bin_dir / lib_path.name + if not target.exists(): + shutil.copy2(lib_path, target) + + +def collect_macos_otool_libs(binary_path: Path, stage_bin_dir: Path) -> None: + result = subprocess.run( + ["otool", "-L", str(binary_path)], + capture_output=True, + text=True, + check=True, + ) + for line in result.stdout.splitlines()[1:]: + stripped = line.strip() + if not stripped or stripped.startswith("/usr/lib/") or stripped.startswith("/System/"): + continue + lib_path_text = stripped.split(" (")[0] + lib_path = Path(lib_path_text) + if not lib_path.is_file(): + continue + target = stage_bin_dir / lib_path.name + if not target.exists(): + shutil.copy2(lib_path, target) + + +def collect_runtime_libs(binary_path: Path, stage_bin_dir: Path, platform: str) -> None: + if platform == "linux": + collect_linux_ldd_libs(binary_path, stage_bin_dir) + elif platform == "macos": + collect_macos_otool_libs(binary_path, stage_bin_dir) + + +def create_archive(stage_dir: Path, output_dir: Path, archive_base: str, platform: str) -> Path: + output_dir.mkdir(parents=True, exist_ok=True) + if platform == "windows": + archive_path = output_dir / f"{archive_base}.zip" + with ZipFile(archive_path, "w", compression=ZIP_DEFLATED) as archive: + for path in stage_dir.rglob("*"): + if path.is_file(): + archive.write(path, path.relative_to(stage_dir.parent)) + return archive_path + + archive_path = output_dir / f"{archive_base}.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(stage_dir, arcname=stage_dir.name) + return archive_path + + +def main() -> int: + parser = argparse.ArgumentParser(description="Package Beez release binary") + parser.add_argument("--version", required=True) + parser.add_argument("--platform", required=True, choices=["linux", "macos", "windows"]) + parser.add_argument("--arch", required=True) + parser.add_argument("--binary", required=True) + parser.add_argument("--output-dir", required=True) + args = parser.parse_args() + + version = normalized_version(args.version) + binary_path = Path(args.binary).resolve() + if not binary_path.is_file(): + raise FileNotFoundError(f"binary not found: {binary_path}") + + repo_root = Path(__file__).resolve().parents[2] + output_dir = (repo_root / args.output_dir).resolve() + archive_base = f"{PACKAGE_NAME}-{version}-{args.platform}-{args.arch}" + stage_root = output_dir / archive_base + if stage_root.exists(): + shutil.rmtree(stage_root) + + stage_bin_dir = stage_root / "bin" + stage_bin_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(binary_path, stage_bin_dir / binary_path.name) + copy_runtime_files(binary_path, stage_bin_dir, args.platform) + collect_runtime_libs(binary_path, stage_bin_dir, args.platform) + + for doc_name in ("README.md", "LICENSE"): + shutil.copy2(repo_root / doc_name, stage_root / doc_name) + + archive_path = create_archive(stage_root, output_dir, archive_base, args.platform) + print(archive_path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/package_reqpack.py b/scripts/ci/package_reqpack.py new file mode 100755 index 0000000..7420a22 --- /dev/null +++ b/scripts/ci/package_reqpack.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import stat +import subprocess +import tarfile +import tempfile +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath + + +PACKAGE_NAME = "beez-cli" +RUNTIME_PATTERNS = { + "linux": ["*.so", "*.so.*"], + "macos": ["*.dylib"], +} + +SYSTEM_BY_PLATFORM = { + "linux": "linux", + "macos": "macos", +} + +VENDOR = "Coditary" +MAINTAINER_EMAIL = "Matographo@gmail.com" +HOMEPAGE = "https://github.com/Coditary/Beez" +DEFAULT_PLUGIN_REGISTRY_URL = "https://raw.githubusercontent.com/Coditary/beez-registry/main/plugins.json" + +PLUGIN_RUNTIME_FILES = ( + "metadata.json", + "reqpack.lua", + "run.lua", + "default-plugin-repositories.json", + "scripts/install.lua", + "scripts/remove.lua", +) + + +def normalized_version(raw: str) -> str: + return raw[1:] if raw.startswith("v") else raw + + +def is_executable(path: Path) -> bool: + return bool(path.stat().st_mode & stat.S_IXUSR) + + +def copy_runtime_files(binary_path: Path, payload_bin_dir: Path, platform: str) -> list[Path]: + copied: list[Path] = [] + for pattern in RUNTIME_PATTERNS.get(platform, []): + for runtime_path in sorted(binary_path.parent.glob(pattern)): + if runtime_path.is_file(): + target_path = payload_bin_dir / runtime_path.name + shutil.copy2(runtime_path, target_path) + copied.append(target_path) + return copied + + +def compute_payload_directories(files: list[Path], payload_root: Path) -> list[str]: + directories: set[PurePosixPath] = set() + for file_path in files: + relative_path = PurePosixPath(file_path.relative_to(payload_root).as_posix()) + for parent in relative_path.parents: + if str(parent) == ".": + continue + directories.add(parent) + return [directory.as_posix() for directory in sorted(directories)] + + +def write_payload_manifest(payload_root: Path, script_path: Path) -> None: + files = sorted(path for path in payload_root.rglob("*") if path.is_file()) + directories = compute_payload_directories(files, payload_root) + lines = ["return {", " directories = {"] + for directory in directories: + lines.append(f' "{directory}",') + lines.extend([" },", " files = {"]) + + for file_path in files: + relative_path = file_path.relative_to(payload_root).as_posix() + executable = "true" if is_executable(file_path) else "false" + lines.append(f' {{ path = "{relative_path}", executable = {executable} }},') + + lines.extend([" },", "}"]) + script_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def render_template(template_path: Path, output_path: Path, replacements: dict[str, str]) -> None: + content = template_path.read_text(encoding="utf-8") + for key, value in replacements.items(): + content = content.replace(key, value) + output_path.write_text(content, encoding="utf-8") + + +def copy_plugin_runtime(plugin_root: Path, payload_plugin_dir: Path) -> None: + for relative_path in PLUGIN_RUNTIME_FILES: + source_path = plugin_root / relative_path + if not source_path.is_file(): + raise FileNotFoundError(f"plugin runtime file not found: {source_path}") + target_path = payload_plugin_dir / relative_path + target_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_path, target_path) + + +def write_packaged_default_plugin_repositories(target_path: Path) -> None: + payload = { + "schemaVersion": 1, + "repositories": [ + { + "id": "beez-default", + "url": DEFAULT_PLUGIN_REGISTRY_URL, + "priority": 0, + "enabled": True, + } + ], + } + target_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def copy_template_tree(template_root: Path, control_root: Path) -> None: + for source_path in sorted(template_root.rglob("*")): + relative_path = source_path.relative_to(template_root) + if relative_path == Path("metadata.json.in"): + continue + target_path = control_root / relative_path + if source_path.is_dir(): + target_path.mkdir(parents=True, exist_ok=True) + elif source_path.is_file(): + target_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_path, target_path) + + +def add_tree_to_tar(archive: tarfile.TarFile, root: Path) -> None: + for path in sorted(root.rglob("*")): + archive.add(path, arcname=path.relative_to(root).as_posix(), recursive=False) + + +def create_payload_archive(payload_root: Path, payload_archive_path: Path) -> tuple[int, int]: + with tarfile.open(payload_archive_path, "w", format=tarfile.USTAR_FORMAT) as archive: + add_tree_to_tar(archive, payload_root) + + size_installed = sum(path.stat().st_size for path in payload_root.rglob("*") if path.is_file()) + return payload_archive_path.stat().st_size, size_installed + + +def compress_payload(payload_tar_path: Path, payload_zst_path: Path) -> None: + subprocess.run( + ["zstd", "-q", "-f", str(payload_tar_path), "-o", str(payload_zst_path)], + check=True, + ) + + +def write_sha256(path: Path, hash_path: Path) -> str: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + hash_path.write_text(f"{digest} payload/payload.tar.zst\n", encoding="utf-8") + return digest + + +def build_metadata(version: str, platform: str, arch: str, archive_name: str, size_compressed: int, size_installed: int) -> dict[str, object]: + release_tag = f"v{version}" + build_date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + return { + "formatVersion": 1, + "name": PACKAGE_NAME, + "version": version, + "release": 1, + "revision": 0, + "summary": "Beez build and task orchestrator", + "description": "Build and task orchestrator with Lua DSL, caching, parallel execution, and bundled Beez plugin manager.", + "license": "Apache-2.0", + "architecture": arch, + "system": [SYSTEM_BY_PLATFORM[platform]], + "vendor": VENDOR, + "maintainerEmail": MAINTAINER_EMAIL, + "url": f"{HOMEPAGE}/releases/download/{release_tag}/{archive_name}", + "homepage": HOMEPAGE, + "sourceUrl": HOMEPAGE, + "buildDate": build_date, + "tags": ["cli", "build", "orchestrator", "beez"], + "binaries": [PACKAGE_NAME], + "payload": { + "path": "payload/payload.tar.zst", + "archive": "tar", + "compression": "zstd", + "hashAlgorithm": "sha256", + "hashFile": "hashes/payload.sha256", + "sizeCompressed": size_compressed, + "sizeInstalledExpected": size_installed, + }, + } + + +def metadata_replacements(metadata: dict[str, object]) -> dict[str, str]: + payload = metadata["payload"] + return { + "@VERSION@": json.dumps(metadata["version"]), + "@ARCHITECTURE@": json.dumps(metadata["architecture"]), + "@SYSTEM@": json.dumps(metadata["system"][0]), + "@URL@": json.dumps(metadata["url"]), + "@BUILD_DATE@": json.dumps(metadata["buildDate"]), + "@SIZE_COMPRESSED@": str(payload["sizeCompressed"]), + "@SIZE_INSTALLED_EXPECTED@": str(payload["sizeInstalledExpected"]), + } + + +def create_rqp_archive(control_root: Path, archive_path: Path) -> None: + with tarfile.open(archive_path, "w", format=tarfile.USTAR_FORMAT) as archive: + add_tree_to_tar(archive, control_root) + + +def validate_rqp_archive(archive_path: Path, payload_sha256: str) -> None: + required_entries = { + "metadata.json", + "reqpack.lua", + "scripts/layout.lua", + "scripts/install.lua", + "scripts/remove.lua", + "scripts/payload_files.lua", + "hashes/payload.sha256", + "payload/payload.tar.zst", + } + allowed_top_levels = {"metadata.json", "reqpack.lua", "scripts", "hashes", "payload"} + + with tarfile.open(archive_path, "r") as archive: + names = {member.name.rstrip("/") for member in archive.getmembers() if member.name.rstrip("/")} + missing_entries = required_entries - names + if missing_entries: + missing_display = ", ".join(sorted(missing_entries)) + raise RuntimeError(f"rqp archive missing entries: {missing_display}") + + for name in names: + top_level = PurePosixPath(name).parts[0] + if top_level not in allowed_top_levels: + raise RuntimeError(f"unexpected top-level archive entry: {name}") + + metadata = json.loads(archive.extractfile("metadata.json").read().decode("utf-8")) + if metadata.get("payload", {}).get("path") != "payload/payload.tar.zst": + raise RuntimeError("metadata payload path mismatch") + + hash_content = archive.extractfile("hashes/payload.sha256").read().decode("utf-8") + expected_hash_line = f"{payload_sha256} payload/payload.tar.zst\n" + if hash_content != expected_hash_line: + raise RuntimeError("payload hash file content mismatch") + + +def import_runtime_libs_from_binary_package(binary_path: Path, payload_bin_dir: Path, platform: str) -> None: + import sys + + ci_dir = Path(__file__).resolve().parent + if str(ci_dir) not in sys.path: + sys.path.insert(0, str(ci_dir)) + from package_binary import collect_runtime_libs, copy_runtime_files + + copy_runtime_files(binary_path, payload_bin_dir, platform) + collect_runtime_libs(binary_path, payload_bin_dir, platform) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Package Beez as ReqPack .rqp archive") + parser.add_argument("--version", required=True) + parser.add_argument("--platform", required=True, choices=["linux", "macos"]) + parser.add_argument("--arch", required=True, choices=["x86_64", "aarch64"]) + parser.add_argument("--binary", required=True) + parser.add_argument("--output-dir", required=True) + args = parser.parse_args() + + version = normalized_version(args.version) + binary_path = Path(args.binary).resolve() + if not binary_path.is_file(): + raise FileNotFoundError(f"binary not found: {binary_path}") + + repo_root = Path(__file__).resolve().parents[2] + template_root = repo_root / "packaging" / "reqpack" / "package" + plugin_root = repo_root + output_dir = (repo_root / args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + for required_path in ( + template_root / "metadata.json.in", + template_root / "reqpack.lua", + template_root / "scripts" / "layout.lua", + template_root / "scripts" / "install.lua", + template_root / "scripts" / "remove.lua", + ): + if not required_path.is_file(): + raise FileNotFoundError(f"reqpack template file not found: {required_path}") + + archive_name = f"{PACKAGE_NAME}-{version}-{args.platform}-{args.arch}.rqp" + archive_path = output_dir / archive_name + + with tempfile.TemporaryDirectory(prefix=f"beez-rqp-{args.platform}-{args.arch}-", dir=output_dir) as temp_dir: + temp_root = Path(temp_dir) + payload_root = temp_root / "payload-tree" + payload_bin_dir = payload_root / "bin" + payload_doc_dir = payload_root / "share" / "doc" / "beez" + payload_plugin_dir = payload_root / "share" / "reqpack" / "plugins" / "beez" + control_root = temp_root / "control" + control_scripts_dir = control_root / "scripts" + control_hashes_dir = control_root / "hashes" + control_payload_dir = control_root / "payload" + + payload_bin_dir.mkdir(parents=True, exist_ok=True) + payload_doc_dir.mkdir(parents=True, exist_ok=True) + payload_plugin_dir.mkdir(parents=True, exist_ok=True) + control_hashes_dir.mkdir(parents=True, exist_ok=True) + control_payload_dir.mkdir(parents=True, exist_ok=True) + copy_template_tree(template_root, control_root) + + binary_target = payload_bin_dir / "beez" + shutil.copy2(binary_path, binary_target) + import_runtime_libs_from_binary_package(binary_path, payload_bin_dir, args.platform) + for doc_name in ("README.md", "LICENSE"): + shutil.copy2(repo_root / doc_name, payload_doc_dir / doc_name) + copy_plugin_runtime(plugin_root, payload_plugin_dir) + write_packaged_default_plugin_repositories(payload_plugin_dir / "default-plugin-repositories.json") + + write_payload_manifest(payload_root, control_scripts_dir / "payload_files.lua") + + payload_tar_path = control_payload_dir / "payload.tar" + _, size_installed = create_payload_archive(payload_root, payload_tar_path) + payload_zst_path = control_payload_dir / "payload.tar.zst" + compress_payload(payload_tar_path, payload_zst_path) + size_compressed = payload_zst_path.stat().st_size + payload_sha256 = write_sha256(payload_zst_path, control_hashes_dir / "payload.sha256") + payload_tar_path.unlink() + + metadata = build_metadata(version, args.platform, args.arch, archive_name, size_compressed, size_installed) + render_template( + template_root / "metadata.json.in", + control_root / "metadata.json", + metadata_replacements(metadata), + ) + + if archive_path.exists(): + archive_path.unlink() + create_rqp_archive(control_root, archive_path) + validate_rqp_archive(archive_path, payload_sha256) + + print(archive_path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/release-build.ps1 b/scripts/ci/release-build.ps1 new file mode 100644 index 0000000..e904636 --- /dev/null +++ b/scripts/ci/release-build.ps1 @@ -0,0 +1,67 @@ +$ErrorActionPreference = "Stop" + +$RootDir = Resolve-Path (Join-Path $PSScriptRoot "..\..") +Set-Location $RootDir + +$BuildDir = if ($env:BUILD_DIR) { $env:BUILD_DIR } else { "build" } +$BuildType = if ($env:BUILD_TYPE) { $env:BUILD_TYPE } else { "Release" } +$BuildTesting = if ($env:BUILD_TESTING) { $env:BUILD_TESTING } else { "OFF" } + +if (-not $env:RELEASE_PLATFORM) { $env:RELEASE_PLATFORM = "windows" } +if (-not $env:RELEASE_ARCH) { $env:RELEASE_ARCH = "x86_64" } + +function Invoke-Checked { + param( + [Parameter(Mandatory = $true, Position = 0)] + [scriptblock]$Command, + [string]$ErrorMessage = "Command failed" + ) + + & $Command + if ($LASTEXITCODE -ne 0) { + throw "$ErrorMessage (exit code $LASTEXITCODE)" + } +} + +$ProfilePath = & (Join-Path $PSScriptRoot "release-conan-profile.ps1") +$env:CONAN_PROFILE = $ProfilePath.Trim() + +Write-Host "Using Conan profile at $env:CONAN_PROFILE" +Get-Content $env:CONAN_PROFILE + +Invoke-Checked { + conan install . ` + --output-folder=$BuildDir ` + --build=missing ` + --lockfile-partial ` + -o "&:build_testing=False" ` + -s "build_type=$BuildType" ` + -pr $env:CONAN_PROFILE ` + -pr:b $env:CONAN_PROFILE +} -ErrorMessage "conan install failed" + +Invoke-Checked { + cmake --preset conan-release ` + -DBUILD_TESTING=$BuildTesting ` + -DBUILD_FUZZER=OFF ` + -DBUILD_COVERAGE=OFF ` + -DBUILD_CACHE=ON +} -ErrorMessage "cmake configure failed" + +Invoke-Checked { + cmake --build --preset conan-release --target beez --parallel +} -ErrorMessage "cmake build failed" + +$BinaryPath = Join-Path $BuildDir "build/$BuildType/bin/beez.exe" +if (-not (Test-Path $BinaryPath)) { + $discovered = Get-ChildItem -Path $BuildDir -Filter "beez.exe" -Recurse -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if ($discovered) { + $BinaryPath = $discovered + } + else { + throw "release binary not found at $BinaryPath" + } +} + +Write-Host "Built $BinaryPath" diff --git a/scripts/ci/release-build.sh b/scripts/ci/release-build.sh new file mode 100755 index 0000000..0d049f0 --- /dev/null +++ b/scripts/ci/release-build.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "${ROOT_DIR}" + +BUILD_DIR="${BUILD_DIR:-build}" +BUILD_TYPE="${BUILD_TYPE:-Release}" +BUILD_TESTING="${BUILD_TESTING:-OFF}" + +export CC="${CC:-clang}" +export CXX="${CXX:-clang++}" + +PROFILE_PATH="$(./scripts/ci/release-conan-profile.sh)" +export CONAN_PROFILE="${PROFILE_PATH}" + +echo "Using Conan profile at ${CONAN_PROFILE}" +cat "${CONAN_PROFILE}" + +conan install . \ + --output-folder="${BUILD_DIR}" \ + --build=missing \ + --lockfile-partial \ + -o "&:build_testing=False" \ + -s "build_type=${BUILD_TYPE}" \ + -pr "${CONAN_PROFILE}" \ + -pr:b "${CONAN_PROFILE}" + +cmake --preset "conan-release" \ + -DBUILD_TESTING="${BUILD_TESTING}" \ + -DBUILD_FUZZER=OFF \ + -DBUILD_COVERAGE=OFF \ + -DBUILD_CACHE=ON + +cmake --build --preset conan-release --target beez --parallel + +BINARY_PATH="${BUILD_DIR}/build/${BUILD_TYPE}/bin/beez" +if [ ! -x "${BINARY_PATH}" ]; then + echo "ERROR: release binary not found at ${BINARY_PATH}" >&2 + exit 1 +fi + +echo "Built ${BINARY_PATH}" diff --git a/scripts/ci/release-conan-profile.ps1 b/scripts/ci/release-conan-profile.ps1 new file mode 100644 index 0000000..6d40d16 --- /dev/null +++ b/scripts/ci/release-conan-profile.ps1 @@ -0,0 +1,75 @@ +$ErrorActionPreference = "Stop" + +$RootDir = Resolve-Path (Join-Path $PSScriptRoot "..\..") +$CiDir = Join-Path $RootDir ".ci" +$ProfilePath = Join-Path $CiDir "release-conan-profile" + +$Platform = if ($env:RELEASE_PLATFORM) { $env:RELEASE_PLATFORM } else { "windows" } +$Arch = if ($env:RELEASE_ARCH) { $env:RELEASE_ARCH } else { "x86_64" } +$BuildType = if ($env:RELEASE_BUILD_TYPE) { $env:RELEASE_BUILD_TYPE } else { "Release" } + +$ConanArch = switch ($Arch) { + "x86_64" { "x86_64" } + "aarch64" { "armv8" } + default { throw "unsupported RELEASE_ARCH=$Arch" } +} + +function Resolve-ConanVsVersion { + if ($env:VS_VERSION) { + return $env:VS_VERSION + } + + $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" + if (-not (Test-Path $vswhere)) { + throw "vswhere not found; set VS_VERSION explicitly for the Conan profile" + } + + $productLine = (& $vswhere -latest -property catalog_productLineVersion).Trim() + switch ($productLine) { + "2022" { return "17" } + "18" { return "18" } + "2026" { return "18" } + default { + $installationVersion = (& $vswhere -latest -property installationVersion).Trim() + $major = $installationVersion.Split(".")[0] + if ($major -eq "17") { return "17" } + if ([int]$major -ge 18) { return "18" } + throw "unsupported Visual Studio product line '$productLine' (installationVersion=$installationVersion)" + } + } +} + +New-Item -ItemType Directory -Force -Path $CiDir | Out-Null + +if ($Platform -ne "windows") { + throw "release-conan-profile.ps1 supports RELEASE_PLATFORM=windows only" +} + +$VsVersion = Resolve-ConanVsVersion +$MsvcVersion = if ($env:MSVC_VERSION) { + $env:MSVC_VERSION +} elseif ($VsVersion -eq "17") { + "193" +} else { + "194" +} + +Write-Host "Using Conan VS version $VsVersion (MSVC $MsvcVersion, arch $ConanArch)" + +@" +[settings] +arch=$ConanArch +build_type=$BuildType +compiler=msvc +compiler.version=$MsvcVersion +compiler.cppstd=20 +compiler.runtime=dynamic +os=Windows + +[conf] +tools.cmake.cmaketoolchain:generator=Ninja +tools.microsoft.msbuild:vs_version=$VsVersion +"@ | Set-Content -Path $ProfilePath -Encoding ascii -NoNewline +Add-Content -Path $ProfilePath -Value "" + +Write-Output $ProfilePath diff --git a/scripts/ci/release-conan-profile.sh b/scripts/ci/release-conan-profile.sh new file mode 100755 index 0000000..2351fbd --- /dev/null +++ b/scripts/ci/release-conan-profile.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PROFILE_PATH="${ROOT_DIR}/.ci/release-conan-profile" + +PLATFORM="${RELEASE_PLATFORM:-linux}" +ARCH="${RELEASE_ARCH:-x86_64}" +BUILD_TYPE="${RELEASE_BUILD_TYPE:-Release}" + +case "${ARCH}" in + x86_64) CONAN_ARCH="x86_64" ;; + aarch64) CONAN_ARCH="armv8" ;; + *) + echo "ERROR: unsupported RELEASE_ARCH=${ARCH}" >&2 + exit 1 + ;; +esac + +mkdir -p "${ROOT_DIR}/.ci" + +case "${PLATFORM}" in + linux) + CXX="${CXX:-clang++}" + CLANG_MAJOR="$("$CXX" --version | sed -n 's/.*clang version \([0-9]*\).*/\1/p')" + if [ -z "${CLANG_MAJOR}" ]; then + echo "ERROR: Could not detect Clang version from ${CXX}" >&2 + exit 1 + fi + cat > "${PROFILE_PATH}" <&2 + exit 1 + fi + cat > "${PROFILE_PATH}" < "${PROFILE_PATH}" <&2 + exit 1 + ;; +esac + +echo "${PROFILE_PATH}" diff --git a/scripts/ci/sync_plugin_catalog.lua b/scripts/ci/sync_plugin_catalog.lua new file mode 100644 index 0000000..d44551d --- /dev/null +++ b/scripts/ci/sync_plugin_catalog.lua @@ -0,0 +1,204 @@ +#!/usr/bin/env lua + +local script_path = debug.getinfo(1, "S").source:gsub("^@", "") +local script_dir = script_path:match("^(.*)/[^/]+$") +local util_boot = dofile(script_dir .. "/../../packaging/reqpack/plugin/lib/util.lua") +local repo_root = util_boot.join_path(script_dir, "..", "..") +local util = dofile(util_boot.join_path(repo_root, "packaging/reqpack/plugin/lib/util.lua")) + +local DEFAULT_REGISTRY_DIR = util.join_path(repo_root, "..", "beez-registry") +local DEFAULT_INDEX_URL = "https://github.com/Coditary/Beez/releases/latest/download/plugins/index.json" +local CLANG_MONOREPO_URL = "github:Coditary/beez-clang" +local CLANG_MONOREPO_PLUGINS = { + ["clang"] = true, + ["clang-format"] = true, + ["clang-tidy"] = true, +} + +local GIT_STANDALONE_PLUGINS = { + ["conan"] = true, + ["coverage"] = true, + ["cppcheck"] = true, + ["ctest"] = true, + ["cyclonedx"] = true, + ["fuzzer"] = true, + ["osv-audit"] = true, + ["pipeline"] = true, +} + +local function standalone_git_repo_url(plugin_name) + return "github:Coditary/beez-" .. plugin_name +end + +local TAGS = { + ["clang"] = { "build", "clang", "coditary" }, + ["clang-format"] = { "format", "clang", "coditary" }, + ["clang-tidy"] = { "lint", "clang", "coditary" }, + ["conan"] = { "conan", "dependencies", "coditary" }, + ["cppcheck"] = { "lint", "static-analysis", "coditary" }, + ["coverage"] = { "coverage", "test", "coditary" }, + ["ctest"] = { "test", "ctest", "coditary" }, + ["cyclonedx"] = { "sbom", "security", "coditary" }, + ["fuzzer"] = { "fuzz", "security", "coditary" }, + ["osv-audit"] = { "security", "audit", "coditary" }, + ["pipeline"] = { "workflow", "pipeline", "coditary" }, +} + +local function usage() + io.stderr:write( + "Usage: lua scripts/ci/sync_plugin_catalog.lua [--registry-dir ] [--index-url ]\n" + ) +end + +local function parse_args(argv) + local options = { + registry_dir = DEFAULT_REGISTRY_DIR, + index_url = DEFAULT_INDEX_URL, + } + local index = 1 + while index <= #argv do + local arg = argv[index] + if arg == "--registry-dir" then + options.registry_dir = argv[index + 1] + index = index + 2 + elseif arg == "--index-url" then + options.index_url = argv[index + 1] + index = index + 2 + elseif arg == "-h" or arg == "--help" then + usage() + os.exit(0) + else + error("unknown argument: " .. tostring(arg)) + end + end + return options +end + +local function read_text(path) + local file = io.open(path, "rb") + if file == nil then + return nil + end + local content = file:read("*a") or "" + file:close() + return content +end + +local function discover_plugins(organization) + local plugins_root = util.join_path(repo_root, "plugins", organization) + local plugins = {} + local ok, output = util.run_shell( + "find " .. util.shell_quote(plugins_root) .. " -mindepth 2 -maxdepth 2 -type d | sort" + ) + if not ok then + return plugins + end + + for line in (output .. "\n"):gmatch("(.-)\n") do + local version_dir = util.trim(line) + if version_dir ~= "" then + local version = version_dir:match("/([^/]+)$") + local plugin_dir = version_dir:match("^(.*)/[^/]+$") + local name = plugin_dir:match("/([^/]+)$") + if name ~= nil and version ~= nil then + plugins[#plugins + 1] = { + organization = organization, + name = name, + version = version, + source_dir = version_dir, + } + end + end + end + return plugins +end + +local function read_plugin_description(source_dir) + local content = read_text(util.join_path(source_dir, "beez_plugin.lua")) + if content == nil then + return "" + end + return content:match('description%s*=%s*"([^"]+)"') or "" +end + +local function json_string(value) + return string.format("%q", value):gsub("\\\n", "\\n") +end + +local function write_catalog(path, plugins, index_url) + local lines = { + "{", + ' "schemaVersion": 1,', + ' "plugins": [', + } + + for plugin_index, plugin in ipairs(plugins) do + local tags = TAGS[plugin.name] or { plugin.name, plugin.organization } + local description = read_plugin_description(plugin.source_dir) + lines[#lines + 1] = " {" + lines[#lines + 1] = string.format(' "id": %s,', json_string(plugin.organization .. "/" .. plugin.name)) + lines[#lines + 1] = string.format(' "version": %s,', json_string(plugin.version)) + lines[#lines + 1] = string.format(' "description": %s,', json_string(description)) + lines[#lines + 1] = ' "tags": [' + for tag_index, tag in ipairs(tags) do + local suffix = tag_index == #tags and "" or "," + lines[#lines + 1] = string.format(' %s%s', json_string(tag), suffix) + end + lines[#lines + 1] = " ]," + lines[#lines + 1] = ' "source": {' + if CLANG_MONOREPO_PLUGINS[plugin.name] then + lines[#lines + 1] = ' "type": "git",' + lines[#lines + 1] = string.format(' "url": %s,', json_string(CLANG_MONOREPO_URL)) + lines[#lines + 1] = string.format(' "path": %s,', json_string(plugin.name)) + lines[#lines + 1] = string.format(' "ref": %s', json_string(plugin.version)) + elseif GIT_STANDALONE_PLUGINS[plugin.name] then + lines[#lines + 1] = ' "type": "git",' + lines[#lines + 1] = string.format(' "url": %s,', json_string(standalone_git_repo_url(plugin.name))) + lines[#lines + 1] = string.format(' "ref": %s', json_string(plugin.version)) + else + lines[#lines + 1] = ' "type": "index",' + lines[#lines + 1] = string.format(' "url": %s', json_string(index_url)) + end + lines[#lines + 1] = " }" + if plugin_index == #plugins then + lines[#lines + 1] = " }" + else + lines[#lines + 1] = " }," + end + end + + lines[#lines + 1] = " ]" + lines[#lines + 1] = "}" + lines[#lines + 1] = "" + + local file, err = io.open(path, "wb") + if file == nil then + error("failed to write " .. path .. ": " .. tostring(err)) + end + file:write(table.concat(lines, "\n")) + file:close() +end + +local function main(argv) + local options = parse_args(argv) + local plugins = discover_plugins("coditary") + if #plugins == 0 then + error("no plugins discovered under plugins/coditary") + end + + table.sort(plugins, function(left, right) + return left.name < right.name + end) + + util.mkdir_p(options.registry_dir) + local output_path = util.join_path(options.registry_dir, "plugins.json") + write_catalog(output_path, plugins, options.index_url) + io.write("wrote " .. output_path .. " (" .. tostring(#plugins) .. " plugins)\n") + return 0 +end + +local ok, err = pcall(main, { ... }) +if not ok then + io.stderr:write(tostring(err) .. "\n") + os.exit(1) +end diff --git a/scripts/ci/validate-release-profiles.sh b/scripts/ci/validate-release-profiles.sh new file mode 100755 index 0000000..a01d7c3 --- /dev/null +++ b/scripts/ci/validate-release-profiles.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "${ROOT_DIR}" + +BUILD_DIR="${BUILD_DIR:-build-release-check}" +BUILD_TYPE="${BUILD_TYPE:-Release}" + +validate_profile() { + local platform="$1" + local arch="$2" + + echo "=== Conan graph: ${platform}-${arch} ===" + RELEASE_PLATFORM="${platform}" RELEASE_ARCH="${arch}" ./scripts/ci/release-conan-profile.sh >/dev/null + conan graph info . \ + --lockfile-partial \ + -o "&:build_testing=False" \ + -pr "${ROOT_DIR}/.ci/release-conan-profile" \ + -pr:b "${ROOT_DIR}/.ci/release-conan-profile" \ + -s "build_type=${BUILD_TYPE}" +} + +validate_profile linux x86_64 +validate_profile linux aarch64 +validate_profile macos x86_64 +validate_profile macos aarch64 + +if [[ "${1:-}" == "--build" ]]; then + echo "=== Local release build (host platform) ===" + BUILD_DIR="${BUILD_DIR}" ./scripts/ci/release-build.sh +fi + +echo "Release profile validation passed." diff --git a/scripts/install.lua b/scripts/install.lua new file mode 100644 index 0000000..b30e187 --- /dev/null +++ b/scripts/install.lua @@ -0,0 +1 @@ +return true diff --git a/scripts/remove.lua b/scripts/remove.lua new file mode 100644 index 0000000..b30e187 --- /dev/null +++ b/scripts/remove.lua @@ -0,0 +1 @@ +return true diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt index 555fba1..e62fe45 100644 --- a/src/cli/CMakeLists.txt +++ b/src/cli/CMakeLists.txt @@ -9,11 +9,18 @@ find_package( set(COMPLETION_EMBEDDED_CPP "${CMAKE_CURRENT_BINARY_DIR}/completion_embedded.cpp") set(COMPLETION_SOURCE_DIR "${CMAKE_SOURCE_DIR}/scripts/completions") +find_program(BEEZ_CLANG_FORMAT NAMES clang-format clang-format.exe) +if(BEEZ_CLANG_FORMAT) + set(BEEZ_COMPLETION_POST_GEN COMMAND ${BEEZ_CLANG_FORMAT} -i "${COMPLETION_EMBEDDED_CPP}") +else() + set(BEEZ_COMPLETION_POST_GEN COMMAND ${CMAKE_COMMAND} -E true) +endif() + add_custom_command( OUTPUT "${COMPLETION_EMBEDDED_CPP}" COMMAND ${Python3_EXECUTABLE} "${CMAKE_SOURCE_DIR}/scripts/generate-completion-embedded.py" "${COMPLETION_SOURCE_DIR}" "${COMPLETION_EMBEDDED_CPP}" - COMMAND clang-format -i "${COMPLETION_EMBEDDED_CPP}" + ${BEEZ_COMPLETION_POST_GEN} DEPENDS "${CMAKE_SOURCE_DIR}/scripts/generate-completion-embedded.py" "${COMPLETION_SOURCE_DIR}/beez.bash" "${COMPLETION_SOURCE_DIR}/beez.zsh" "${COMPLETION_SOURCE_DIR}/activate.bash" "${COMPLETION_SOURCE_DIR}/activate.zsh" "${COMPLETION_SOURCE_DIR}/install-lib.sh" diff --git a/src/cli/commands/reqpack.cpp b/src/cli/commands/reqpack.cpp index bc2e6db..58d1710 100644 --- a/src/cli/commands/reqpack.cpp +++ b/src/cli/commands/reqpack.cpp @@ -2,6 +2,7 @@ #include "beez/cli/parsing/parsed_options.hpp" #include "beez/cli/session.hpp" +#include "beez/core/plugin/installer.hpp" #include "beez/core/reqpack/installer.hpp" #include "beez/plugin/lua/lua_dsl.hpp" @@ -47,6 +48,21 @@ namespace std::optional runReqPackInstallCommand(const LoadedProject& project, const ParsedOptions& options) { + const auto BeezPluginResult = core::ensureReqpackBeezPluginsInstalled( + project.luaLoader->reqpackBeezPlugins().plugins()); + if (!BeezPluginResult.message.empty() && + (BeezPluginResult.message.find("failed to install") != std::string::npos || + BeezPluginResult.message.find("is not available after install") != std::string::npos || + BeezPluginResult.message.find("requires a version pin") != std::string::npos || + BeezPluginResult.message.find("ReqPack (rqp) is required") != std::string::npos)) + { + if (!options.silent) + { + std::cerr << BeezPluginResult.message << '\n'; + } + return 1; + } + const auto Result = core::installReqPackDependencies( project.luaLoader->reqpackManifest(), project.context, makeInstallOptions(options)); if (const auto ExitCode = handleInstallResult(Result, options.silent)) diff --git a/src/core/cache/step/index.cpp b/src/core/cache/step/index.cpp index 298fc85..4b332da 100644 --- a/src/core/cache/step/index.cpp +++ b/src/core/cache/step/index.cpp @@ -221,7 +221,7 @@ void writeCacheIndex(const std::filesystem::path& indexPath, for (const auto& stamp : entry.inputs) { stream << "input=" << stamp.path << '\t' << stamp.size << '\t' - << stamp.modified.time_since_epoch().count() << '\n'; + << static_cast(stamp.modified.time_since_epoch().count()) << '\n'; } for (const auto& output : entry.outputs) { diff --git a/src/core/plugin/installer.cpp b/src/core/plugin/installer.cpp index 9a5fbbc..36f7dec 100644 --- a/src/core/plugin/installer.cpp +++ b/src/core/plugin/installer.cpp @@ -3,6 +3,7 @@ #include "beez/core/plugin/paths.hpp" #include "beez/core/reqpack/installer.hpp" #include "beez/core/util/expected.hpp" +#include "beez/plugin/lua/dsl/plugin_loader.hpp" #include #include @@ -13,10 +14,35 @@ namespace beez::core { +namespace +{ + +[[nodiscard]] std::string shellQuote(const std::string& value) +{ + std::string quoted = "'"; + for (const char character : value) + { + if (character == '\'') + { + quoted += "'\\''"; + } + else + { + quoted += character; + } + } + quoted += '\''; + return quoted; +} + +} // namespace + std::string formatBeezPluginInstallArg(const std::string& organization, const std::string& name, - const std::string& version) + const std::string& version, + const std::optional& source) { + static_cast(source); return "beez:" + organization + '/' + name + '@' + version; } @@ -78,7 +104,8 @@ std::optional resolveInstalledBeezPluginOrganization(const std::str BeezPluginInstallResult ensureBeezPluginInstalled(const std::optional& organization, const std::string& name, - const std::string& version) + const std::string& version, + const std::optional& source) { if (resolveInstalledBeezPluginScript(organization, name, version).hasValue()) { @@ -91,6 +118,11 @@ BeezPluginInstallResult ensureBeezPluginInstalled(const std::optionalempty()) + { + command << "BEEZ_PLUGIN_SOURCE=" << shellQuote(*source) << ' '; + } + command << "rqp --json install " << formatBeezPluginInstallArg(*organization, name, version); + + const auto CommandResult = executeRqpCommand(command.str()); if (CommandResult.exitCode != 0) { std::ostringstream stream; @@ -123,4 +161,38 @@ BeezPluginInstallResult ensureBeezPluginInstalled(const std::optional& plugins) +{ + for (const auto& pluginRef : plugins) + { + if (pluginRef.isLocal()) + { + continue; + } + + if (!pluginRef.version.has_value() || pluginRef.version->empty()) + { + return {.installed = false, + .message = "remote reqpack.beez plugin '" + pluginRef.organization + '/' + + pluginRef.name + "' requires a version pin"}; + } + + const auto Result = ensureBeezPluginInstalled(pluginRef.organization, + pluginRef.name, + *pluginRef.version, + pluginRef.source); + if (!Result.message.empty() && + (Result.message.find("failed to install") != std::string::npos || + Result.message.find("is not available after install") != std::string::npos || + Result.message.find("requires a version pin") != std::string::npos || + Result.message.find("ReqPack (rqp) is required") != std::string::npos)) + { + return Result; + } + } + + return {.installed = true, .message = {}}; +} + } // namespace beez::core diff --git a/src/plugins/lua/api/data/detail/yaml_convert.cpp b/src/plugins/lua/api/data/detail/yaml_convert.cpp index df429ed..49465c6 100644 --- a/src/plugins/lua/api/data/detail/yaml_convert.cpp +++ b/src/plugins/lua/api/data/detail/yaml_convert.cpp @@ -3,8 +3,10 @@ #include "beez/plugin/lua/api/data/detail/lua_convert.hpp" #include +#include #include #include +#include #include #include #include @@ -41,6 +43,25 @@ namespace return false; } +[[nodiscard]] bool parseDouble(const std::string& text, double& output) +{ +#if defined(__APPLE__) + errno = 0; + char* end = nullptr; + const double value = std::strtod(text.c_str(), &end); + if (errno == ERANGE || end == text.c_str() || + static_cast(end - text.c_str()) != text.size()) + { + return false; + } + output = value; + return true; +#else + const auto result = std::from_chars(text.data(), text.data() + text.size(), output); + return result.ec == std::errc(); +#endif +} + [[nodiscard]] bool parseNumber(const ryml::csubstr Value, sol::state_view luaState, sol::object& output) { @@ -49,13 +70,12 @@ parseNumber(const ryml::csubstr Value, sol::state_view luaState, sol::object& ou Text.find('E') != std::string::npos) { double number = 0.0; - const auto Result = std::from_chars(Text.data(), Text.data() + Text.size(), number); - if (Result.ec == std::errc()) + if (!parseDouble(Text, number)) { - output = sol::make_object(luaState, number); - return true; + return false; } - return false; + output = sol::make_object(luaState, number); + return true; } std::int64_t integer = 0; diff --git a/src/plugins/lua/dsl/plugin_loader.cpp b/src/plugins/lua/dsl/plugin_loader.cpp index fc1f978..83e6196 100644 --- a/src/plugins/lua/dsl/plugin_loader.cpp +++ b/src/plugins/lua/dsl/plugin_loader.cpp @@ -47,7 +47,7 @@ namespace if (!entry.is()) { throw std::runtime_error( - "reqpack.beez entries must be tables with name, path, and optional version"); + "reqpack.beez entries must be tables with name and either path or version"); } const sol::table PluginTable = entry.as(); @@ -57,17 +57,20 @@ namespace throw std::runtime_error("reqpack.beez plugin entry requires string name"); } - const sol::object PathObject = PluginTable["path"]; - if (!PathObject.is() || PathObject.as().empty()) - { - throw std::runtime_error("reqpack.beez plugin entry requires string path"); - } - BeezPluginRef pluginRef; const auto [Organization, PluginName] = splitPluginName(NameObject.as()); pluginRef.organization = Organization; pluginRef.name = PluginName; - pluginRef.path = PathObject.as(); + + const sol::object PathObject = PluginTable["path"]; + if (PathObject.valid() && PathObject.get_type() != sol::type::lua_nil) + { + if (!PathObject.is()) + { + throw std::runtime_error("reqpack.beez plugin path must be a string"); + } + pluginRef.path = PathObject.as(); + } const sol::object VersionObject = PluginTable["version"]; if (VersionObject.valid() && VersionObject.get_type() != sol::type::lua_nil) @@ -79,6 +82,22 @@ namespace pluginRef.version = VersionObject.as(); } + const sol::object SourceObject = PluginTable["source"]; + if (SourceObject.valid() && SourceObject.get_type() != sol::type::lua_nil) + { + if (!SourceObject.is()) + { + throw std::runtime_error("reqpack.beez plugin source must be a string"); + } + pluginRef.source = SourceObject.as(); + } + + if (!pluginRef.isLocal() && !pluginRef.version.has_value()) + { + throw std::runtime_error("remote reqpack.beez plugin '" + pluginRef.organization + '/' + + pluginRef.name + "' requires a version pin"); + } + return pluginRef; } @@ -265,7 +284,13 @@ void loadPluginScript(const std::filesystem::path& scriptPath, [[nodiscard]] std::filesystem::path resolvePluginScript(const BeezPluginRef& pluginRef, const core::Context& context) { - std::filesystem::path PluginDirectory = context.projectRoot() / pluginRef.path; + if (!pluginRef.path.has_value()) + { + throw std::runtime_error("beez plugin '" + pluginRef.organization + '/' + pluginRef.name + + "' does not have a local path"); + } + + std::filesystem::path PluginDirectory = context.projectRoot() / *pluginRef.path; if (pluginRef.version.has_value()) { PluginDirectory /= pluginRef.version.value(); @@ -304,6 +329,11 @@ void loadBeezPlugins(const std::vector& plugins, { for (const auto& pluginRef : plugins) { + if (!pluginRef.isLocal()) + { + continue; + } + const auto ScriptPath = resolvePluginScript(pluginRef, context); loadPluginScript(ScriptPath, pluginRef, registry, context); } diff --git a/src/plugins/lua/lua_dsl.cpp b/src/plugins/lua/lua_dsl.cpp index 3dce2dc..3825ae7 100644 --- a/src/plugins/lua/lua_dsl.cpp +++ b/src/plugins/lua/lua_dsl.cpp @@ -82,6 +82,11 @@ const core::ReqPackManifest& LuaDslLoader::reqpackManifest() const return impl_->reqpackManifest; } +const ReqpackBeezPluginCatalog& LuaDslLoader::reqpackBeezPlugins() const +{ + return impl_->reqpackBeezPlugins; +} + void LuaDslLoader::setGcThroughputMode(bool enable) { if (impl_->luaState == nullptr) diff --git a/tests/unit/core/registry/test_registry.cpp b/tests/unit/core/registry/test_registry.cpp index 1c6a9f7..9b8959a 100644 --- a/tests/unit/core/registry/test_registry.cpp +++ b/tests/unit/core/registry/test_registry.cpp @@ -426,17 +426,17 @@ TEST(RegistryTest, ResolvesVersionedPluginStepReference) compileStep.scope = "code"; compileStep.shellRun = "echo versioned"; registry.registerPluginStep( - std::move(compileStep), "coditary", "clang-build", std::string {"1.0.0"}); + std::move(compileStep), "coditary", "clang", std::string {"1.0.0"}); - const auto Resolved = registry.resolveStep("clang-build:compile@1.0.0"); + const auto Resolved = registry.resolveStep("clang:compile@1.0.0"); ASSERT_TRUE(Resolved.hasValue()); EXPECT_EQ(Resolved.value().shellRun.value_or(""), "echo versioned"); - const auto Qualified = registry.resolveStep("coditary/clang-build:compile@1.0.0"); + const auto Qualified = registry.resolveStep("coditary/clang:compile@1.0.0"); ASSERT_TRUE(Qualified.hasValue()); EXPECT_EQ(Qualified.value().shellRun.value_or(""), "echo versioned"); - const auto Unversioned = registry.resolveStep("clang-build:compile"); + const auto Unversioned = registry.resolveStep("clang:compile"); ASSERT_TRUE(Unversioned.hasValue()); EXPECT_EQ(Unversioned.value().shellRun.value_or(""), "echo versioned"); } @@ -451,12 +451,12 @@ TEST(RegistryTest, InstalledPluginVersionKeepsVersionedAliasesOnly) compileStep.scope = "code"; compileStep.shellRun = "echo versioned"; registry.registerPluginStep( - std::move(compileStep), "coditary", "clang-build", std::string {"1.0.0"}, false); + std::move(compileStep), "coditary", "clang", std::string {"1.0.0"}, false); - const auto Versioned = registry.resolveStep("clang-build:compile@1.0.0"); + const auto Versioned = registry.resolveStep("clang:compile@1.0.0"); ASSERT_TRUE(Versioned.hasValue()); - const auto Unversioned = registry.resolveStep("clang-build:compile"); + const auto Unversioned = registry.resolveStep("clang:compile"); EXPECT_FALSE(Unversioned.hasValue()); } @@ -469,18 +469,18 @@ TEST(RegistryTest, UniquePluginStepKeepsShortNameAlias) compileStep.phase = "build"; compileStep.scope = "code"; compileStep.shellRun = "echo compile"; - registry.registerPluginStep(std::move(compileStep), "coditary", "clang-build"); + registry.registerPluginStep(std::move(compileStep), "coditary", "clang"); const auto Resolved = registry.resolveStep("compile:code"); ASSERT_TRUE(Resolved.hasValue()); ASSERT_TRUE(Resolved.value().hasShellRun()); EXPECT_EQ(Resolved.value().shellRun.value_or(""), "echo compile"); - const auto PluginAction = registry.resolveStep("clang-build:compile"); + const auto PluginAction = registry.resolveStep("clang:compile"); ASSERT_TRUE(PluginAction.hasValue()); EXPECT_EQ(PluginAction.value().shellRun.value_or(""), "echo compile"); - const auto QualifiedAction = registry.resolveStep("coditary/clang-build:compile"); + const auto QualifiedAction = registry.resolveStep("coditary/clang:compile"); ASSERT_TRUE(QualifiedAction.hasValue()); EXPECT_EQ(QualifiedAction.value().shellRun.value_or(""), "echo compile"); } diff --git a/tests/unit/core/registry/test_step_reference.cpp b/tests/unit/core/registry/test_step_reference.cpp index b5c469e..9c2bf51 100644 --- a/tests/unit/core/registry/test_step_reference.cpp +++ b/tests/unit/core/registry/test_step_reference.cpp @@ -8,8 +8,8 @@ TEST(StepReferenceTest, FormatsQualifiedReference) { - EXPECT_EQ(beez::core::formatQualifiedStepRef("coditary", "clang-build", "compile:code"), - "coditary/clang-build:compile:code"); + EXPECT_EQ(beez::core::formatQualifiedStepRef("coditary", "clang", "compile:code"), + "coditary/clang:compile:code"); } TEST(StepReferenceTest, FormatsShortPluginReference) @@ -19,10 +19,10 @@ TEST(StepReferenceTest, FormatsShortPluginReference) TEST(StepReferenceTest, ParsesQualifiedReferenceWithColonInStepName) { - const auto Parsed = beez::core::parseQualifiedStepRef("coditary/clang-build:compile:code"); + const auto Parsed = beez::core::parseQualifiedStepRef("coditary/clang:compile:code"); ASSERT_TRUE(Parsed.has_value()); EXPECT_EQ(Parsed->organization, "coditary"); - EXPECT_EQ(Parsed->plugin, "clang-build"); + EXPECT_EQ(Parsed->plugin, "clang"); EXPECT_EQ(Parsed->stepName, "compile:code"); } @@ -51,18 +51,18 @@ TEST(StepReferenceTest, ParsesShortPluginReference) TEST(StepReferenceTest, SplitsVersionSuffix) { - const auto [Base, Version] = beez::core::splitStepReferenceVersion("clang-build:compile@1.0.0"); - EXPECT_EQ(Base, "clang-build:compile"); + const auto [Base, Version] = beez::core::splitStepReferenceVersion("clang:compile@1.0.0"); + EXPECT_EQ(Base, "clang:compile"); ASSERT_TRUE(Version.has_value()); EXPECT_EQ(*Version, "1.0.0"); } TEST(StepReferenceTest, ParsesQualifiedReferenceWithVersionSuffix) { - const auto Parsed = beez::core::parseQualifiedStepRef("coditary/clang-build:compile@1.0.0"); + const auto Parsed = beez::core::parseQualifiedStepRef("coditary/clang:compile@1.0.0"); ASSERT_TRUE(Parsed.has_value()); EXPECT_EQ(Parsed->organization, "coditary"); - EXPECT_EQ(Parsed->plugin, "clang-build"); + EXPECT_EQ(Parsed->plugin, "clang"); EXPECT_EQ(Parsed->stepName, "compile"); ASSERT_TRUE(Parsed->version.has_value()); EXPECT_EQ(*Parsed->version, "1.0.0"); diff --git a/tests/unit/plugin/lua/test_lua_dsl_plugins.cpp b/tests/unit/plugin/lua/test_lua_dsl_plugins.cpp index 67042c3..d6717a0 100644 --- a/tests/unit/plugin/lua/test_lua_dsl_plugins.cpp +++ b/tests/unit/plugin/lua/test_lua_dsl_plugins.cpp @@ -220,7 +220,7 @@ reqpack { EXPECT_FALSE(loadScript(Project, registry)); } -TEST(LuaDslPluginTest, RejectsCacheOnlyPluginEntry) +TEST(LuaDslPluginTest, AcceptsRemoteOnlyPluginEntry) { const beez::test::TempProject Project; Project.writeBuildLua(R"( @@ -232,7 +232,7 @@ reqpack { )"); beez::core::Registry registry; - EXPECT_FALSE(loadScript(Project, registry)); + EXPECT_TRUE(loadScript(Project, registry)); } TEST(LuaDslPluginTest, RejectsUnqualifiedPluginName)